14 changed files with 1501 additions and 100 deletions
@@ -32,6 +32,20 @@ public sealed class OverlapGeometryStamp
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 Part part;
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Diagnostics;
@@ -9,12 +11,16 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail
public sealed class OverlapReportState
{
private OverlapGeometryStamp stamp;
private OverlapGeometryStamp displayStamp;
private OverlapGeometryStamp observedDisplayStamp;
private int uncheckedPartCount;
public long Generation { get; private set; }
public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
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 string Message => Status switch
@@ -36,7 +42,8 @@ public sealed class OverlapReportState
/// </summary>
public long Begin(Plate plate, bool automatic = false)
{
Clear(OverlapCheckStatus.Checking);
RefreshDisplayPairs(plate);
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
stamp = OverlapGeometryStamp.Capture(plate);
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas;
@@ -48,6 +55,9 @@ public sealed class OverlapReportState
if (!CanComplete(generation, plate))
return false;
Report = report;
DisplayPairs = report.Pairs;
displayStamp = stamp;
observedDisplayStamp = stamp;
uncheckedPartCount = CountUncheckedParts(report.Issues);
Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
return true;
@@ -66,20 +76,52 @@ public sealed class OverlapReportState
public bool EnsureFresh(Plate plate)
{
RefreshDisplayPairs(plate);
if (stamp == null)
return false;
if (stamp.Matches(plate))
return true;
Invalidate();
Invalidate(plate);
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()
{
ClearDisplayPairs();
if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
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()
{
if (IsRunning)
@@ -88,8 +130,10 @@ public sealed class OverlapReportState
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
private void Clear(OverlapCheckStatus status)
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
{
if (!preserveDisplay)
ClearDisplayPairs();
Generation++;
Report = null;
uncheckedPartCount = 0;
@@ -0,0 +1,165 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Geometry;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularBlockTests
{
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
new Dictionary<int, IReadOnlyList<PairPose>>();
[Theory]
[InlineData(1)]
[InlineData(2)]
public void SmallDemandNeverPreparesAFill(int quantity)
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), quantity, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(catalog.Get(types[0], quantity, new Box(0, 0, 60, 40), CancellationToken.None));
Assert.Equal(0, catalog.PreparationCount);
}
[Fact]
public void FillProposalIsTrimmedAndCertifiedWithoutChangingSingleRotations()
{
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 7, RotationPolicy.Automatic) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var types = PartCatalog.Build(job);
var original = types[0].Orientations.ToArray();
using var catalog = new BlockCatalog(0.25, types, NoPairs);
var block = catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None);
Assert.Equal(7, block.Count);
Assert.Equal(original, types[0].Orientations);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
for (var i = 0; i < block.Count; i++)
for (var j = i + 1; j < block.Count; j++)
Assert.True(NestLayoutCheck.Clears(geometry,
new NestJobPlacement("ell", i, block[i].X, block[i].Y, block[i].Orientation.Rotation), geometry,
new NestJobPlacement("ell", j, block[j].X, block[j].Y, block[j].Orientation.Rotation), 0.25));
Assert.Same(block, catalog.Get(types[0], 7, new Box(0, 0, 60, 40), CancellationToken.None));
}
[Theory]
[InlineData(0)]
[InlineData(2.1)]
public void InvalidInternalSpacingRejectsTheWholeProposal(double offset)
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
var members = Enumerable.Range(0, 3).Select(i => new OpenNest.Part(drawing)
{ Location = new Vector(i * offset, 0) }).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void PhysicalFreeRectanglesExcludeOccupiedMaterialAndClearance()
{
var job = Job(new[] { Rectangle("box", 4, 10, 1) }, new[] { Stock("sheet", 10, 20) });
var type = PartCatalog.Build(job)[0];
var part = new Placed(type.Orientations[0], 8, 0);
var rectangles = BlockCatalog.Rectangles(new Box(0, 0, 20, 10), new[] { part }, 0.25);
Assert.NotEmpty(rectangles);
Assert.All(rectangles, box => Assert.True(box.Right <= 7.75 || box.Left >= 12.25));
}
[Fact]
public void RepeatedEllBlockCompetesWithPairsOnAnOpenSheet()
{
var stock = Stock("sheet", 30, 40, spacing: 0.25);
var job = Job(new[] { Part("ell", LShape(9, 7, 3), 44, RotationPolicy.Automatic) }, new[] { stock });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 40, 30, CancellationToken.None);
using var blocks = new BlockCatalog(0.25, types, pairs);
var before = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter()).Fill(new[] { 44 }, CancellationToken.None);
var after = new FrontierPacker(types, new NoFitCache(0.25), pairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 44 }, CancellationToken.None);
Assert.True(after.Parts.Count > before.Parts.Count,
$"Before {before.Parts.Count}; after {after.Parts.Count}");
var result = new NestJobResultBuilder(job);
result.AddSheet(stock, after.Parts.Select(p => ("ell", p.X, p.Y, p.Orientation.Rotation)));
LayoutAssert.Valid(job, result.Build(NestJobStopReason.StockExhausted));
}
[Fact]
public void UndersizedRectangleKeepsSinglesFallback()
{
var stock = Stock("sheet", 3, 3, spacing: 0.25);
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { stock });
var types = PartCatalog.Build(job);
using var blocks = new BlockCatalog(0.25, types, NoPairs);
Assert.Empty(blocks.Get(types[0], 3, new Box(0, 0, 3, 3), CancellationToken.None));
Assert.Equal(0, blocks.PreparationCount);
var fill = new FrontierPacker(types, new NoFitCache(0.25), NoPairs, stock,
PackAxis.X, 1, new WorkCounter(), blocks).Fill(new[] { 3 }, CancellationToken.None);
Assert.Single(fill.Parts);
}
[Fact]
public void ForbiddenRotationsAreRejectedBeforeAddingGroupOrientations()
{
var program = LShape(9, 7, 3);
var job = Job(new[] { Part("ell", program, 3, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 40, 60, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("ell", program);
var members = Enumerable.Range(0, 3).Select(i =>
{
var part = OpenNest.Part.CreateAtOrigin(drawing, System.Math.PI / 2);
part.Offset(new Vector(i * 12, 0));
return part;
}).ToArray();
var orientations = type.Orientations.ToList();
Assert.Empty(BlockCatalog.Resolve(type, members, orientations, 0.25));
Assert.Equal(type.Orientations, orientations);
}
[Fact]
public void CancellationDuringCertificationPropagates()
{
var program = Shapes.Rectangle(2, 2);
var job = Job(new[] { Part("box", program, 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 20, 20, spacing: 0.25) });
var type = PartCatalog.Build(job)[0];
var drawing = new Drawing("box", program);
using var cancellation = new CancellationTokenSource();
var members = new CancellingMembers(Enumerable.Range(0, 3)
.Select(i => new OpenNest.Part(drawing) { Location = new Vector(i * 3, 0) }).ToArray(), cancellation);
Assert.Throws<OperationCanceledException>(() => BlockCatalog.Resolve(type, members,
type.Orientations.ToList(), 0.25, cancellation.Token));
}
private sealed class CancellingMembers(OpenNest.Part[] parts, CancellationTokenSource cancellation)
: IReadOnlyList<OpenNest.Part>
{
public int Count => parts.Length;
public OpenNest.Part this[int index] => parts[index];
public IEnumerator<OpenNest.Part> GetEnumerator()
{
foreach (var part in parts)
yield return part;
cancellation.Cancel();
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public void CancellationIsNotConvertedIntoAnEmptyProposal()
{
var job = Job(new[] { Rectangle("box", 2, 2, 3) }, new[] { Stock("sheet", 10, 20) });
var types = PartCatalog.Build(job);
using var catalog = new BlockCatalog(0.25, types, NoPairs);
Assert.Throws<OperationCanceledException>(() =>
catalog.Get(types[0], 3, new Box(0, 0, 20, 10), new CancellationToken(true)));
}
}
@@ -0,0 +1,229 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.NestingEngines;
public class IrregularPairTests
{
// A 20-long wedge, 8 wide at one end and 6 at the other. Lying down, two copies need
// 8 + 0.25 + 8 = 16.25 across, but nested slant-to-slant they need only about 14.25.
// The 30 x 15 sheet holds the pair only when the two copies interlock.
private static OpenNest.CNC.Program Wedge() => Polyline((0, 0), (8, 0), (6, 20), (0, 20));
private static OpenNest.CNC.Program MirroredWedge() => Polyline((0, 0), (8, 0), (8, 20), (2, 20));
public static TheoryData<string> Wedges => new() { "wedge", "mirrored" };
private static OpenNest.CNC.Program NativeU()
{
// Same native semicircular U as the shared curve-contact regression; no DXF dependency.
var p = new OpenNest.CNC.Program();
p.MoveTo(2.5, 0.625);
p.LineTo(2.5, 0);
p.LineTo(1.5, 0);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 3, 1.5, 1.5, OpenNest.RotationType.CW));
p.LineTo(2.5, 3);
p.LineTo(2.5, 2.375);
p.LineTo(1.5, 2.375);
p.Codes.Add(new OpenNest.CNC.ArcMove(1.5, 0.625, 1.5, 1.5, OpenNest.RotationType.CCW));
p.LineTo(2.5, 0.625);
return p;
}
[Fact]
public void NativeUQuantityTwoOffersAndPlacesAnInterlockedClearPair()
{
var job = Job(new[] { Part("native-u", NativeU(), 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 6, 6, spacing: 0.25) });
var types = PartCatalog.Build(job);
var pairs = PairCatalog.Build(types, 0.25, 6, 6, CancellationToken.None);
Assert.NotEmpty(pairs[0]);
var pair = pairs[0][0];
// Bounding boxes overlap on both axes: this is an interlock, not adjacent boxes.
Assert.True(System.Math.Min(pair.A.MaxX, pair.Dx + pair.B.MaxX)
> System.Math.Max(pair.A.MinX, pair.Dx + pair.B.MinX));
Assert.True(System.Math.Min(pair.A.MaxY, pair.Dy + pair.B.MaxY)
> System.Math.Max(pair.A.MinY, pair.Dy + pair.B.MinY));
var tightJob = Job(job.Parts.ToArray(),
new[] { Stock("tight", pair.Height + 0.01, pair.Width + 0.01, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(tightJob);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
LayoutAssert.Valid(tightJob, result);
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
Assert.Empty(geometry.Cutouts);
Assert.Equal(2.5, geometry.Bounds.Length, 9);
Assert.Equal(3, geometry.Bounds.Width, 9);
var outlines = result.Plates[0].Placements.Select(placement =>
{
var shape = (OpenNest.Geometry.Shape)geometry.Perimeter.Clone();
shape.Rotate(placement.Rotation);
shape.Offset(placement.X, placement.Y);
return shape.ToPolygonWithTolerance(1e-6);
}).ToArray();
// Raw fine boundaries, independent of BestFit's offsets and the NFP free regions.
Assert.True(OpenNest.Geometry.Clearance.Between(outlines[0], outlines[1]).Distance >= 0.25 - 2e-6);
Assert.True(outlines[0].BoundingBox.Right > outlines[1].BoundingBox.Left
&& outlines[1].BoundingBox.Right > outlines[0].BoundingBox.Left);
Assert.True(outlines[0].BoundingBox.Top > outlines[1].BoundingBox.Bottom
&& outlines[1].BoundingBox.Top > outlines[0].BoundingBox.Bottom);
}
[Theory]
[InlineData(0.0)] // Coincident material.
[InlineData(3.1)] // Disjoint outlines, but only 0.1 clearance rather than 0.25.
public void PairResolutionRejectsOverlapAndInsufficientSpacing(double offset)
{
var program = NativeU();
var job = Job(new[] { Part("native-u", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 10, 10, spacing: 0.25) });
var type = Assert.Single(PartCatalog.Build(job));
var geometry = JobPartGeometry.Read(job.Parts[0].Geometry);
var drawing = new Drawing("native-u", program);
var members = new List<OpenNest.Part>
{
new(drawing),
new(drawing) { Location = new OpenNest.Geometry.Vector(0, offset) },
};
var extra = new List<Orientation>();
Assert.Null(PairCatalog.Resolve(type, extra, geometry, members, 0, 0.25));
Assert.Empty(extra);
}
[Fact]
public void PairOnlyOrientationRemainsEligibleWhenSampledSinglesDoNotFit()
{
var wedge = MirroredWedge();
wedge.Rotate(System.Math.PI / 4);
// A many-type job samples only two single orientations. The pair adds its own
// legal rotations; neither sampled single fits this narrow stock.
var parts = Enumerable.Range(0, 24).Select(i => Rectangle($"oversized-{i}", 100, 100, 1)).ToList();
parts.Insert(0, Part("wedge", wedge, 2, RotationPolicy.Automatic));
var stock = Stock("sheet", 15, 30, spacing: 0.25);
var job = Job(parts.ToArray(), new[] { stock });
var types = PartCatalog.Build(job);
Assert.DoesNotContain(types[0].Orientations, o => stock.Fits(o.Width, o.Height));
var pairs = PairCatalog.Build(types, 0.25, 30, 15, CancellationToken.None);
Assert.Contains(pairs[0], p => stock.Fits(p.Width, p.Height));
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void QuantityTwoInterlocksWhenOnlyAPairFits(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Fixed(System.Math.PI / 2, allow180Equivalent: true)) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
}
[Theory]
[MemberData(nameof(Wedges))]
public void AutomaticRotationAlsoFindsThePair(string shape)
{
var program = shape == "wedge" ? Wedge() : MirroredWedge();
var job = Job(
new[] { Part("wedge", program, 2, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Single(result.Plates);
}
[Fact]
public void PairsNeverUseARotationThePolicyForbids()
{
// Fixed at 0 with no 180-degree twin: every best-fit pair turns its second copy,
// so no pair is allowed and the parts must be placed as singles.
var job = Job(
new[] { Part("wedge", Wedge(), 2, RotationPolicy.Fixed(0)) },
new[] { Stock("sheet", 25, 30, spacing: 0.25) }
);
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.All(result.Plates.SelectMany(p => p.Placements), p => Assert.Equal(0, p.Rotation, 9));
}
[Fact]
public void OddDemandPlacesTheLastCopyAsASingleWithoutOverdrawing()
{
var job = Job(new[] { Part("wedge", MirroredWedge(), 3, RotationPolicy.Automatic) },
new[] { Stock("sheet", 15, 30, spacing: 0.25) });
var result = new IrregularNestingEngine().Solve(job);
LayoutAssert.Valid(job, result);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(new[] { 1, 2 }, result.Plates.Select(p => p.Placements.Count).OrderBy(n => n));
}
[Fact]
public void PairMembersLeaveTheirEnclosedGapAvailableForLaterParts()
{
var stock = Stock("sheet", 6.1, 6.1, spacing: 0.1);
var job = Job(new[] { Rectangle("bar", 2, 6, 2), Rectangle("insert", 1, 1, 1) }, new[] { stock });
var types = PartCatalog.Build(job);
var bar = Assert.Single(types[0].Orientations);
var pair = new PairPose { A = bar, B = bar, Dx = 4, Dy = 0 };
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
// Along Y the pair buys twice the area for the same advance, so it wins first.
var fill = new FrontierPacker(types, new NoFitCache(0.1), pairs, stock,
PackAxis.Y, 1, new WorkCounter()).Fill(new[] { 2, 1 }, CancellationToken.None);
Assert.Equal(3, fill.Parts.Count);
Assert.Equal(0, fill.Parts[0].Orientation.TypeIndex);
Assert.Equal(0, fill.Parts[1].Orientation.TypeIndex);
var insert = fill.Parts[2];
Assert.Equal(1, insert.Orientation.TypeIndex);
Assert.True(insert.Left > fill.Parts[0].Right);
Assert.True(insert.Right < fill.Parts[1].Left);
var geometries = job.Parts.Select(p => JobPartGeometry.Read(p.Geometry)).ToArray();
for (var i = 0; i < fill.Parts.Count; i++)
for (var j = i + 1; j < fill.Parts.Count; j++)
{
var a = fill.Parts[i];
var b = fill.Parts[j];
Assert.True(NestLayoutCheck.Clears(geometries[a.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[a.Orientation.TypeIndex].Id, i, a.X, a.Y, a.Orientation.Rotation),
geometries[b.Orientation.TypeIndex],
new NestJobPlacement(job.Parts[b.Orientation.TypeIndex].Id, j, b.X, b.Y, b.Orientation.Rotation), 0.1));
}
}
[Fact]
public void PairedJobsAreDeterministic()
{
// Best-fit evaluates in parallel; equal-area pairs must still be chosen the same way.
NestJob Build() =>
Job(
new[]
{
Part("wedge", Wedge(), 6, RotationPolicy.Automatic),
Part("ell", LShape(9, 7, 3), 4, RotationPolicy.Automatic),
},
new[] { Stock("sheet", 30, 40, spacing: 0.25) }
);
var first = new IrregularNestingEngine().Solve(Build());
var second = new IrregularNestingEngine().Solve(Build());
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
}
}
@@ -0,0 +1,156 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Clipper2Lib;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>Per-solve, per-spacing Fill proposals. Only the members occupy material.</summary>
internal sealed class BlockCatalog : IDisposable
{
private readonly double spacing;
private readonly Dictionary<int, Drawing> drawings = new();
private readonly Dictionary<int, List<Orientation>> orientations = new();
private readonly Dictionary<int, int> attempts = new();
internal int PreparationCount => attempts.Values.Sum();
private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList<Placed>> cache = new();
public BlockCatalog(double spacing, IReadOnlyList<PartType> types,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs)
{
this.spacing = spacing;
foreach (var type in types)
{
var poses = type.Orientations.ToList();
if (pairs.TryGetValue(type.Index, out var found))
poses.AddRange(found.SelectMany(p => new[] { p.A, p.B }));
orientations[type.Index] = poses.Distinct().ToList();
}
}
internal static IReadOnlyList<Box> Rectangles(Box work, IReadOnlyList<Placed> placed, double spacing)
{
var free = new PathsD { new PathD
{
new(work.Left, work.Bottom), new(work.Right, work.Bottom),
new(work.Right, work.Top), new(work.Left, work.Top),
} };
foreach (var part in placed)
{
// Physical occupied material, not a reference-point region for any moving part.
// The catalog currently prepares solid outlines; future profile preparation owns holes.
var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline },
spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon,
2, NoFitCache.Precision);
free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y),
FillRule.NonZero, NoFitCache.Precision);
}
return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area())
.ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray();
}
public IReadOnlyList<Placed> Get(PartType type, int quantity, Box rectangle, CancellationToken token)
{
token.ThrowIfCancellationRequested();
if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0)
return Array.Empty<Placed>();
var key = (type.Index, quantity, rectangle.Length, rectangle.Width);
if (cache.TryGetValue(key, out var cached))
return cached;
if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8)
return Array.Empty<Placed>();
attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1;
var result = Build(type, quantity, rectangle, token);
cache[key] = result;
return result;
}
private IReadOnlyList<Placed> Build(PartType type, int quantity, Box rectangle, CancellationToken token)
{
if (!drawings.TryGetValue(type.Index, out var drawing))
drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part);
var plate = new Plate(new Size(rectangle.Width, rectangle.Length)) { PartSpacing = spacing };
try
{
// The drawing is private: stabilize this cache entry before Fill's candidate pruning.
var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing);
var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray();
fits.Clear();
fits.AddRange(sorted);
var members = PlateFillService.FillItem("Default", plate, new NestItem
{
Drawing = drawing,
Quantity = quantity,
RotationStart = type.Part.Rotation.Start,
RotationEnd = type.Part.Rotation.End,
StepAngle = DrawingJobMapper.LegacyStep(type.Part.Rotation),
}, new Box(0, 0, rectangle.Length, rectangle.Width), null!, token);
token.ThrowIfCancellationRequested();
return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return Array.Empty<Placed>();
}
}
public void Dispose()
{
foreach (var drawing in drawings.Values)
BestFitCache.Invalidate(drawing);
drawings.Clear();
}
internal static IReadOnlyList<Placed> Resolve(PartType type, IReadOnlyList<Part> members,
List<Orientation> orientations, double spacing, CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (members.Count <= 2)
return Array.Empty<Placed>();
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
return Array.Empty<Placed>();
// Canonical rebinding is already performed by FillItem. Quantization removes sub-grid
// arithmetic differences from equivalent Fill proposals; certify the resulting poses.
var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0,
System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8),
System.Math.Round(Angle.NormalizeRad(p.Rotation), 10)))
.OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray();
if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation)
|| !type.Part.Rotation.Allows(p.Rotation)))
return Array.Empty<Placed>();
for (var i = 0; i < poses.Length; i++)
for (var j = i + 1; j < poses.Length; j++)
{
token.ThrowIfCancellationRequested();
if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing))
return Array.Empty<Placed>();
}
var result = new List<Placed>();
foreach (var pose in poses)
{
token.ThrowIfCancellationRequested();
var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation);
if (orientation == null)
{
orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation);
if (orientation == null)
return Array.Empty<Placed>();
orientations.Add(orientation);
}
result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y));
}
return result;
}
}
@@ -55,17 +55,30 @@ internal sealed class FrontierPacker
private readonly IReadOnlyList<PartType> types;
private readonly NoFitCache nfps;
private readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs;
private readonly NestPlateStock stock;
private readonly PackAxis axis;
private readonly double beta;
private readonly Box work;
private readonly WorkCounter counter;
private readonly BlockCatalog? blocks;
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
public FrontierPacker(
IReadOnlyList<PartType> types,
NoFitCache nfps,
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs,
NestPlateStock stock,
PackAxis axis,
double beta,
WorkCounter counter,
BlockCatalog? blocks = null
)
{
this.blocks = blocks;
this.counter = counter;
this.types = types;
this.nfps = nfps;
this.pairs = pairs;
this.stock = stock;
this.axis = axis;
this.beta = beta;
@@ -76,74 +89,145 @@ internal sealed class FrontierPacker
{
var left = remaining.ToArray();
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)
{
if (left[type.Index] <= 0)
continue;
foreach (var o in type.Orientations)
if (stock.Fits(o.Width, o.Height))
states.Add(new Region(o, work));
Track(o, single: true);
if (left[type.Index] < 2 || !pairs.TryGetValue(type.Index, out var typePairs))
continue;
foreach (var pair in typePairs)
{
// Members missing from the catalog get regions too, but only for pair placement.
if (stock.Fits(pair.Width, pair.Height) && Track(pair.A, single: false) && Track(pair.B, single: false))
offered.Add(new PairState(pair, byOrientation[pair.A], byOrientation[pair.B]));
}
}
var placed = new List<Placed>();
var 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 front = axis == PackAxis.X ? work.Left : work.Bottom;
while (states.Count > 0)
{
token.ThrowIfCancellationRequested();
var choice = Choose(states, front);
var choice = Choose(states, offered, blockStates, front);
if (choice == null)
break;
var (region, point) = choice.Value;
var part = new Placed(region.Orientation, point.x, point.y);
placed.Add(part);
var typeIndex = region.Orientation.TypeIndex;
partArea += types[typeIndex].Area;
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
var typeIndex = choice[0].Orientation.TypeIndex;
foreach (var part in choice)
{
placed.Add(part);
partArea += types[typeIndex].Area;
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
}
if (--left[typeIndex] == 0)
left[typeIndex] -= choice.Count;
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
if (left[typeIndex] == 0)
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
if (left[typeIndex] < 2)
{
offered.RemoveAll(p => p.Pose.TypeIndex == typeIndex);
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex && !s.Single);
}
// Each surviving region loses the positions the new part now blocks. Regions are
// Each surviving region loses the positions the new parts now block. Regions are
// independent, so they update in parallel without affecting determinism.
var snapshot = states.ToArray();
counter.Add(snapshot.Length);
Parallel.For(
0,
snapshot.Length,
new ParallelOptions { CancellationToken = token },
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
);
foreach (var part in choice)
{
counter.Add(snapshot.Length);
Parallel.For(
0,
snapshot.Length,
new ParallelOptions { CancellationToken = token },
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
);
}
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);
}
private (Region, PointD)? Choose(List<Region> states, double front)
/// <summary>
/// The next placement: one part, or both members of a pair. Singles and pairs compete
/// under the same rule; a pair counts as one piece of twice the part area, and on a tie
/// the single (considered first) is kept.
/// </summary>
private IReadOnlyList<Placed>? Choose(List<Region> states, List<PairState> offered, List<BlockState> blocks, double front)
{
Region? bestRegion = null;
var bestPoint = default(PointD);
IReadOnlyList<Placed>? bestParts = null;
var bestFills = false;
var bestValue = double.PositiveInfinity;
var bestSide = double.PositiveInfinity;
var bestLead = double.PositiveInfinity;
var bestPriority = int.MaxValue;
foreach (var region in states)
void Consider(Func<IReadOnlyList<Placed>> build, int typeIndex, double area, double advance, double side, double lead)
{
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var area = types[region.Orientation.TypeIndex].Area;
var priority = types[region.Orientation.TypeIndex].Part.Priority;
if (priority > bestPriority) continue;
var priority = types[typeIndex].Part.Priority;
if (priority > bestPriority)
return;
var fills = advance <= Tie;
// 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 better = bestRegion == null
var better = bestParts == null
|| priority < bestPriority
|| (fills && !bestFills)
|| (
@@ -157,17 +241,83 @@ internal sealed class FrontierPacker
)
);
if (!better)
continue;
bestRegion = region;
return;
bestParts = build();
bestPriority = priority;
bestPoint = point;
bestFills = fills;
bestValue = value;
bestSide = side;
bestLead = lead;
}
return bestRegion == null ? null : (bestRegion, bestPoint);
foreach (var region in states)
{
if (!region.Single)
continue;
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
continue;
var o = region.Orientation;
Consider(() => new[] { new Placed(o, point.x, point.y) }, o.TypeIndex, types[o.TypeIndex].Area, advance, side, lead);
}
foreach (var state in offered)
{
var pair = state.Pose;
if (!state.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
Consider(
() => new[] { new Placed(pair.A, point.x, point.y), new Placed(pair.B, point.x + pair.Dx, point.y + pair.Dy) },
pair.TypeIndex,
2 * types[pair.TypeIndex].Area,
advance,
side,
lead
);
}
foreach (var block in blocks)
{
if (!block.TryLowest(axis, front, counter, out var point, out var advance, out var side, out var lead))
continue;
var typeIndex = block.Members[0].Orientation.TypeIndex;
Consider(() => block.Members.Select(p => new Placed(p.Orientation, p.X + point.x, p.Y + point.y)).ToArray(),
typeIndex, block.Members.Count * types[typeIndex].Area, advance, side, lead);
}
return bestParts;
}
private sealed class BlockState(IReadOnlyList<Placed> members, Region[] regions)
{
public IReadOnlyList<Placed> Members { get; } = members;
public Region[] Regions { get; } = regions;
public bool TryLowest(PackAxis axis, double front, WorkCounter counter,
out PointD point, out double advance, out double side, out double lead)
{
var free = Clipper.TranslatePaths(Regions[0].Free, -Members[0].X, -Members[0].Y);
var minX = double.NegativeInfinity;
var minY = double.NegativeInfinity;
var maxX = double.PositiveInfinity;
var maxY = double.PositiveInfinity;
for (var i = 0; i < Members.Count; i++)
{
var member = Members[i];
var region = Regions[i];
minX = System.Math.Max(minX, region.MinX - member.X);
minY = System.Math.Max(minY, region.MinY - member.Y);
maxX = System.Math.Min(maxX, region.MaxX - member.X);
maxY = System.Math.Min(maxY, region.MaxY - member.Y);
if (i > 0)
{
counter.Add(1);
free = Clipper.Intersect(free, Clipper.TranslatePaths(region.Free, -member.X, -member.Y),
FillRule.NonZero, NoFitCache.Precision);
}
}
return BestVertex(free, minX, minY, System.Math.Max(minX, maxX), System.Math.Max(minY, maxY),
(Members.Min(p => p.Left), Members.Min(p => p.Bottom), Members.Max(p => p.Right), Members.Max(p => p.Top)),
axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>Legal reference points for one orientation on this sheet.</summary>
@@ -177,9 +327,10 @@ internal sealed class FrontierPacker
private PathsD free;
private RectD bounds;
public Region(Orientation orientation, Box work)
public Region(Orientation orientation, Box work, bool single)
{
Orientation = orientation;
Single = single;
minX = work.Left - orientation.MinX;
maxX = work.Right - orientation.MaxX;
minY = work.Bottom - orientation.MinY;
@@ -203,6 +354,10 @@ internal sealed class FrontierPacker
}
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 void Subtract(Nfp nfp, double dx, double dy)
@@ -219,49 +374,132 @@ internal sealed class FrontierPacker
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
free.RemoveAll(p => p.Count < 3);
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>
/// 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.
/// </summary>
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;
foreach (var path in free)
foreach (var raw in path)
return BestVertex(free, minX, minY, maxX, maxY, (o.MinX, o.MinY, o.MaxX, o.MaxY),
axis, front, out point, out advance, out side, out lead);
}
}
/// <summary>
/// Scores the vertices of <paramref name="free"/>, each clamped to the inner-fit box
/// [minX, maxX] x [minY, maxY], for a piece occupying <paramref name="box"/> around its
/// reference point.
/// </summary>
private static bool BestVertex(
PathsD free,
double minX,
double minY,
double maxX,
double maxY,
(double MinX, double MinY, double MaxX, double MaxY) box,
PackAxis axis,
double front,
out PointD point,
out double advance,
out double side,
out double lead
)
{
point = default;
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)
{
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 + 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;
reach = x + box.MaxX;
across = y + box.MinY;
start = x + box.MinX;
}
return found;
else
{
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
using System;
using System.Collections.Generic;
using System.Linq;
using System;
using System.Threading;
using OpenNest.Engine.Jobs;
@@ -48,9 +48,10 @@ public sealed class IrregularNestingEngine : INestingEngine
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
var types = PartCatalog.Build(job);
var solver = new Solver(job, types, progress, token);
using var solver = new Solver(job, types, progress, token);
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
// Pair-only orientations may fit stock even when the sampled single poses do not.
// Demand that neither a single nor a pair can fit is reported unplaced.
var demand = new int[types.Count];
foreach (var type in types)
{
@@ -58,7 +59,8 @@ public sealed class IrregularNestingEngine : INestingEngine
stock.Quantity != 0
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
);
demand[type.Index] = placeable ? type.Part.Quantity : 0;
demand[type.Index] = placeable || solver.PairFits(type)
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
}
Plan? best = null;
@@ -85,10 +87,26 @@ public sealed class IrregularNestingEngine : INestingEngine
IReadOnlyList<PartType> types,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
) : IDisposable
{
public void Dispose()
{
foreach (var catalog in blocks.Values)
catalog.Dispose();
}
private const int MaxTail = 3;
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();
@@ -157,6 +175,30 @@ public sealed class IrregularNestingEngine : INestingEngine
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>
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
@@ -199,7 +241,7 @@ public sealed class IrregularNestingEngine : INestingEngine
if (stock.Quantity is int available && used[stock.Id] >= available)
continue;
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
var fill = packer.Fill(remaining, token);
if (fill.Parts.Count > 0)
trials.Add((fill, NetArea(job.Options, fill)));
@@ -0,0 +1,207 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Math;
namespace OpenNest.Engine.NestingEngines.Irregular;
/// <summary>
/// Two copies of one part type placed together: member A at the pair's reference point and
/// member B at <see cref="Dx"/>, <see cref="Dy"/> from it.
/// </summary>
internal sealed class PairPose
{
public required Orientation A { get; init; }
public required Orientation B { get; init; }
public required double Dx { get; init; }
public required double Dy { get; init; }
public int TypeIndex => A.TypeIndex;
/// <summary>Box of both members around the pair's reference point (catalog-padded).</summary>
public double MinX => System.Math.Min(A.MinX, Dx + B.MinX);
public double MinY => System.Math.Min(A.MinY, Dy + B.MinY);
public double MaxX => System.Math.Max(A.MaxX, Dx + B.MaxX);
public double MaxY => System.Math.Max(A.MaxY, Dy + B.MaxY);
public double Width => MaxX - MinX;
public double Height => MaxY - MinY;
}
/// <summary>
/// Best-fit pairs offered to the packer for part types with two or more copies.
///
/// A pair is placed through the existing free regions: its reference point must be legal for
/// member A and, shifted by the pair offset, for member B (region A intersected with region B
/// moved back by the offset). No new no-fit polygons are built. The members' clearance from
/// each other comes from the best-fit search and is re-checked here once per pair with the
/// layout check at the job spacing; a pair that fails is never offered.
/// </summary>
internal static class PairCatalog
{
/// <summary>Best-fit results tried per type, in rank order.</summary>
private const int CandidatesPerType = 12;
/// <summary>Pairs kept per type; each one costs a region intersection per placement.</summary>
private const int PairsPerType = 2;
private const double AngleTolerance = 1e-6;
/// <summary>
/// Pairs for every type with quantity two or more, for one part spacing. A member rotation
/// missing from the catalog gets a pair-only orientation (indexed after the catalog's), so
/// both members always own cached footprints; such orientations are never offered as singles.
/// </summary>
/// <param name="sheetLength">Largest sheet X extent offered at this spacing.</param>
/// <param name="sheetWidth">Largest sheet Y extent offered at this spacing.</param>
public static IReadOnlyDictionary<int, IReadOnlyList<PairPose>> Build(
IReadOnlyList<PartType> types,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var result = new Dictionary<int, IReadOnlyList<PairPose>>();
foreach (var type in types)
{
token.ThrowIfCancellationRequested();
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
continue;
var geometry = JobPartGeometry.TryRead(type.Part.Geometry);
if (geometry == null)
continue;
var pairs = BuildForType(type, geometry, spacing, sheetLength, sheetWidth, token);
if (pairs.Count > 0)
result[type.Index] = pairs;
}
return result;
}
private static List<PairPose> BuildForType(
PartType type,
JobPartGeometry geometry,
double spacing,
double sheetLength,
double sheetWidth,
CancellationToken token
)
{
var drawing = new Drawing(type.Part.Id, DrawingJobMapper.ToProgram(type.Part.Geometry));
List<BestFitResult> fits;
try
{
fits = BestFitCache.GetOrCompute(drawing, sheetLength, sheetWidth, spacing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException
or NotSupportedException or ArithmeticException)
{
return new List<PairPose>();
}
finally
{
// The drawing exists only for this call; release its cache entry now.
BestFitCache.Invalidate(drawing);
}
// Best-fit evaluates in parallel, so equal-area results arrive in any order. Sort on
// the candidate itself as well, so the same job always picks the same pairs.
var ranked = fits
.Where(f => f.Keep)
.OrderBy(f => f.RotatedArea)
.ThenBy(f => f.Candidate.StrategyIndex)
.ThenBy(f => f.Candidate.Part2Rotation)
.ThenBy(f => f.Candidate.Part2Offset.X)
.ThenBy(f => f.Candidate.Part2Offset.Y)
.ThenBy(f => f.OptimalRotation)
.Take(CandidatesPerType);
var pairs = new List<PairPose>();
var extra = new List<Orientation>();
foreach (var fit in ranked)
{
token.ThrowIfCancellationRequested();
List<Part> members;
try
{
members = fit.BuildSourceParts(drawing);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
continue;
}
if (members.Count != 2)
continue;
// The whole pair may also turn a quarter: offer it along each sheet axis.
foreach (var turn in new[] { 0.0, System.Math.PI / 2 })
{
var pair = Resolve(type, extra, geometry, members, turn, spacing);
if (pair != null && !pairs.Any(p => SamePair(p, pair)))
pairs.Add(pair);
if (pairs.Count >= PairsPerType)
return pairs;
}
}
return pairs;
}
internal static PairPose? Resolve(
PartType type,
List<Orientation> extra,
JobPartGeometry geometry,
List<Part> members,
double turn,
double spacing
)
{
// Turning a part about the origin turns its program and its location together,
// so the members keep their relative placement.
var poses = members
.Select(member =>
{
var location = member.Location.Rotate(turn);
return (Rotation: Angle.NormalizeRad(member.Rotation + turn), location.X, location.Y);
})
.ToArray();
if (!type.Part.Rotation.Allows(poses[0].Rotation) || !type.Part.Rotation.Allows(poses[1].Rotation))
return null;
var a = new NestJobPlacement(type.Part.Id, 0, poses[0].X, poses[0].Y, poses[0].Rotation);
var b = new NestJobPlacement(type.Part.Id, 1, poses[1].X, poses[1].Y, poses[1].Rotation);
if (!NestLayoutCheck.Clears(geometry, a, geometry, b, spacing))
return null;
var oa = Find(type, extra, poses[0].Rotation);
var ob = Find(type, extra, poses[1].Rotation);
if (oa == null || ob == null)
return null;
return new PairPose { A = oa, B = ob, Dx = poses[1].X - poses[0].X, Dy = poses[1].Y - poses[0].Y };
}
private static Orientation? Find(PartType type, List<Orientation> extra, double rotation)
{
foreach (var orientation in type.Orientations.Concat(extra))
if (SameAngle(orientation.Rotation, rotation))
return orientation;
var added = PartCatalog.CreateOrientation(type, type.Orientations.Count + extra.Count, rotation);
if (added != null)
extra.Add(added);
return added;
}
private static bool SamePair(PairPose first, PairPose second) =>
ReferenceEquals(first.A, second.A)
&& ReferenceEquals(first.B, second.B)
&& System.Math.Abs(first.Dx - second.Dx) < 1e-6
&& System.Math.Abs(first.Dy - second.Dy) < 1e-6;
private static bool SameAngle(double first, double second)
{
var difference = Angle.NormalizeRad(first - second);
return difference < AngleTolerance || Angle.TwoPI - difference < AngleTolerance;
}
}
@@ -41,6 +41,9 @@ internal sealed class PartType
public required NestJobPart Part { get; init; }
public required double Area { 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>
@@ -97,11 +100,32 @@ internal static class PartCatalog
}
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
types.Add(new PartType
{
Index = index,
Part = part,
Area = area,
Orientations = orientations,
Perimeter = perimeter,
});
}
return types;
}
/// <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) =>
JobPartGeometry.TryRead(geometry)?.Perimeter;
@@ -215,6 +215,145 @@ public class OverlapReportStateTests
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 Plate PlateWithParts()
@@ -97,6 +97,80 @@ public class PlateOverlapOverlayTests
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]
public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
{
@@ -233,11 +307,10 @@ public class PlateOverlapOverlayTests
});
[Theory]
[InlineData("add")]
[InlineData("remove")]
[InlineData("replace")]
[InlineData("clear")]
public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
{
using var run = new OverlayRun();
run.Finish();
@@ -247,7 +320,6 @@ public class PlateOverlapOverlayTests
Assert.NotNull(run.View.OverlapOverlay.CachedPath);
switch (edit)
{
case "add": run.View.Plate.Parts.Add(Rectangle()); break;
case "remove": run.View.Plate.Parts.RemoveAt(0); break;
case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
case "clear": run.View.Plate.Parts.Clear(); break;
@@ -257,6 +329,23 @@ public class PlateOverlapOverlayTests
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]
public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
{
+26 -12
View File
@@ -33,7 +33,7 @@ internal sealed class OverlapOverlayController : IDisposable
private ObservableList<Part> observedParts;
private CancellationTokenSource cancellation;
private GraphicsPath path;
private PlateOverlapReport pathReport;
private IReadOnlyList<PlateOverlapPair> pathPairs;
private float pathScale;
private Units capturedUnits;
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
@@ -139,7 +139,6 @@ internal sealed class OverlapOverlayController : IDisposable
var source = new CancellationTokenSource();
cancellation = source;
var token = source.Token;
ReleasePath();
NotifyChanged(toStatusBar: !automatic);
try
{
@@ -204,13 +203,22 @@ internal sealed class OverlapOverlayController : IDisposable
{
materialCache.Clear();
baseline = null;
Invalidate();
state.Invalidate();
CancelWorker();
ReleasePath();
NotifyChanged();
}
public void Invalidate()
{
var generation = state.Generation;
state.Invalidate();
var display = state.DisplayPairs;
state.Invalidate(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation == state.Generation)
{
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
@@ -223,7 +231,6 @@ internal sealed class OverlapOverlayController : IDisposable
return;
}
CancelWorker();
ReleasePath();
NotifyChanged();
}
@@ -250,6 +257,8 @@ internal sealed class OverlapOverlayController : IDisposable
private void NotifyChanged(bool toStatusBar = false)
{
if (!ReferenceEquals(pathPairs, state.DisplayPairs))
ReleasePath();
ClearHover();
if (disposed || view.IsDisposed || view.Disposing)
return;
@@ -343,7 +352,7 @@ internal sealed class OverlapOverlayController : IDisposable
UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
ValidateHoverTransform();
if (state.Report != null)
if (state.DisplayPairs.Count > 0)
{
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
{
@@ -366,11 +375,16 @@ internal sealed class OverlapOverlayController : IDisposable
if (disposed)
return false;
var generation = state.Generation;
var display = state.DisplayPairs;
var fresh = state.EnsureFresh(view.Plate);
if (!ReferenceEquals(display, state.DisplayPairs))
{
ReleasePath();
ClearHover();
}
if (generation != state.Generation)
{
CancelWorker();
ReleasePath();
NotifyChanged();
}
return fresh;
@@ -457,7 +471,7 @@ internal sealed class OverlapOverlayController : IDisposable
using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale);
// Stack coincident pair labels instead of replacing them with a fragment count.
var labelRows = new Dictionary<PointF, int>();
foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId))
{
var center = view.PointWorldToGraph(pair.Centroid);
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
@@ -532,13 +546,13 @@ internal sealed class OverlapOverlayController : IDisposable
private void EnsurePath()
{
if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null)
if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null)
return;
ReleasePath();
var next = new GraphicsPath(FillMode.Winding);
try
{
foreach (var pair in state.Report.Pairs)
foreach (var pair in state.DisplayPairs)
{
foreach (var region in pair.Regions)
{
@@ -560,7 +574,7 @@ internal sealed class OverlapOverlayController : IDisposable
}
}
path = next;
pathReport = state.Report;
pathPairs = state.DisplayPairs;
pathScale = view.ViewScale;
}
catch
@@ -592,7 +606,7 @@ internal sealed class OverlapOverlayController : IDisposable
{
path?.Dispose();
path = null;
pathReport = null;
pathPairs = null;
}
public void Dispose()
+24 -7
View File
@@ -34,8 +34,10 @@ fragment counts. This diagnostic checks shared material, not minimum spacing,
plate edges, or cutting-path crossings.
In a nest window the active plate is checked automatically. Any layout edit
(add, remove, reorder, move, rotate, fill) clears the overlay and shows
**Overlaps: check pending…**; once the layout has been unchanged for 0.5 s and no
(add, remove, reorder, move, rotate, fill) invalidates the full report and shows
**Overlaps: check pending…**, but highlights for unchanged pairs remain visible.
Only highlights involving changed ordered input slots are removed; once the
layout has been unchanged for 0.5 s and no
mouse button, modal dialog, or fill progress window is active, the check reruns.
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
@@ -211,8 +213,21 @@ changed code count or rotation is detected). Preparing material dominates
first-check time for drawings with many holes. Incremental analysis reuses a pair
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.
`InvalidateOverlapCheck()` clears the cache and baseline, so in-place program
editors must keep calling it before loading.
`InvalidateOverlapCheck()` clears the cache, baseline and every highlight, so
in-place program editors must keep calling it before loading.
Moving one part hides only highlights involving changed parts. Unchanged pairs
remain visible throughout the quiet period and background recheck, sharing their
existing immutable regions rather than recalculating them. `OverlapReportState`
keeps these `DisplayPairs` separate from its full `Report`: the full report is
unavailable and the label stays pending/out-of-date/checking until a fresh result
is published. Retained highlights are known overlaps, never an all-clear for the
edited layout. Each paint checks exact poses and references again, including
further edits while an earlier check is pending. Hover details remain disabled
until the full report is current. Collection edits conservatively discard pairs
whose ordered input slots changed; this display-only path does not renumber them.
Explicit geometry invalidation, plate switch, handle loss, cancellation and
failure clear all retained highlights.
Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check
takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental
@@ -227,7 +242,7 @@ PlateView draws the controller overlay after work-area/debug-remnant drawing and
before action paint subscribers and hover tooltips. One consistently wound path
is filled once, avoiding fragment outlines, internal triangulation seams, and
darker triple coverage. World-to-graph conversion excludes pan, because PlateView
already applies origin translation. Paths are rebuilt for report/scale changes,
already applies origin translation. Paths are rebuilt for displayed-pair/scale changes,
not ordinary repaints or panning. The state label saves/restores graphics state.
Centroid hit tests use only cached report coordinates and DPI-scaled screen
radii. Hover clears on edits, mode/request/view changes, leave, and teardown.
@@ -258,7 +273,8 @@ coincident duplicates, and covers pair reuse, issue renumbering, cache clearing
cancellation. `OverlapAutoCheckSchedulerTests` covers the quiet period, interaction
waits, and the no-retry rule for canceled or failed layouts.
`OverlapReportStateTests` verifies request supersession, exact pose/reference
freshness, stale clearing, cancellation, and incomplete-versus-clear messaging.
freshness, per-pair display retention through repeated edits and pending checks,
hard invalidation, cancellation, and incomplete-versus-clear messaging.
`PolygonAreaMomentsTests` covers analytic
centers, unequal/disconnected fragments, winding, closure, large translations,
and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit
@@ -277,7 +293,8 @@ dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not
execute Windows tests or verify appearance, DPI, or interaction. On Windows,
check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
alignment, stale clearing during edits/plate switches, converter cancellation,
alignment, changed-pair clearing and unchanged-pair retention during edits and
pending rechecks, full clearing on plate switches and converter cancellation,
the pending label and automatic recheck after dragging a part onto another,
crowded-marker PageUp/PageDown access to the last pair, and repeated
check/toggle/close cycles without GDI/disposed-control errors.
+25 -2
View File
@@ -12,7 +12,7 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
| 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 |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling, six whole-job strategy variants and a tail re-plan |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan |
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
| Default, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill |
@@ -26,10 +26,33 @@ coincides with the plate work-area boundary. Internal leftover edges keep the st
tolerance, and actual part dimensions still determine spacing away from the plate boundary.
Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
enclosed cutouts. Concave no-fit polygons are prepared with a single boundary/containment union.
enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair
where both members' free regions allow it. Each pair's internal spacing is re-checked with the
layout check before it is offered, and only rotations the part's policy allows are used. A pair
may introduce legal rotations beyond the sampled single poses; these remain eligible even when
none of the sampled singles fits the stock. Both members block space separately, leaving their
notches and intervening gaps available for later parts. Concave no-fit polygons are prepared
with a single boundary/containment union.
Any remaining numerical hole is filled only when its entire ring is certified to lie in forbidden
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
Earlier releases shipped these as plug-ins under other names. The registry maps the old names so