mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-01 16:38:49 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4954bdf515 |
@@ -32,20 +32,6 @@ 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,6 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Diagnostics;
|
||||
|
||||
@@ -11,16 +9,12 @@ 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
|
||||
@@ -42,8 +36,7 @@ public sealed class OverlapReportState
|
||||
/// </summary>
|
||||
public long Begin(Plate plate, bool automatic = false)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
Clear(OverlapCheckStatus.Checking, preserveDisplay: true);
|
||||
Clear(OverlapCheckStatus.Checking);
|
||||
stamp = OverlapGeometryStamp.Capture(plate);
|
||||
if (!automatic && DisplayMode == OverlapDisplayMode.Off)
|
||||
DisplayMode = OverlapDisplayMode.Areas;
|
||||
@@ -55,9 +48,6 @@ 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;
|
||||
@@ -76,52 +66,20 @@ public sealed class OverlapReportState
|
||||
|
||||
public bool EnsureFresh(Plate plate)
|
||||
{
|
||||
RefreshDisplayPairs(plate);
|
||||
if (stamp == null)
|
||||
return false;
|
||||
if (stamp.Matches(plate))
|
||||
return true;
|
||||
Invalidate(plate);
|
||||
Invalidate();
|
||||
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)
|
||||
@@ -130,10 +88,8 @@ public sealed class OverlapReportState
|
||||
|
||||
public void Reset() => Clear(OverlapCheckStatus.NotChecked);
|
||||
|
||||
private void Clear(OverlapCheckStatus status, bool preserveDisplay = false)
|
||||
private void Clear(OverlapCheckStatus status)
|
||||
{
|
||||
if (!preserveDisplay)
|
||||
ClearDisplayPairs();
|
||||
Generation++;
|
||||
Report = null;
|
||||
uncheckedPartCount = 0;
|
||||
|
||||
@@ -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 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,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs,
|
||||
NestPlateStock stock,
|
||||
PackAxis axis,
|
||||
double beta,
|
||||
WorkCounter counter,
|
||||
BlockCatalog? blocks = null
|
||||
)
|
||||
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
|
||||
{
|
||||
this.blocks = blocks;
|
||||
this.counter = counter;
|
||||
this.types = types;
|
||||
this.nfps = nfps;
|
||||
this.pairs = pairs;
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
@@ -89,145 +76,74 @@ 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)
|
||||
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]));
|
||||
}
|
||||
if (stock.Fits(o.Width, o.Height))
|
||||
states.Add(new Region(o, work));
|
||||
}
|
||||
|
||||
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, offered, blockStates, front);
|
||||
var choice = Choose(states, front);
|
||||
if (choice == null)
|
||||
break;
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
left[typeIndex] -= choice.Count;
|
||||
blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]);
|
||||
if (left[typeIndex] == 0)
|
||||
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 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.
|
||||
var snapshot = states.ToArray();
|
||||
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)
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
private (Region, PointD)? Choose(List<Region> states, double front)
|
||||
{
|
||||
IReadOnlyList<Placed>? bestParts = null;
|
||||
Region? bestRegion = null;
|
||||
var bestPoint = default(PointD);
|
||||
var bestFills = false;
|
||||
var bestValue = double.PositiveInfinity;
|
||||
var bestSide = double.PositiveInfinity;
|
||||
var bestLead = double.PositiveInfinity;
|
||||
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 (priority > bestPriority)
|
||||
return;
|
||||
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 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 = bestParts == null
|
||||
var better = bestRegion == null
|
||||
|| priority < bestPriority
|
||||
|| (fills && !bestFills)
|
||||
|| (
|
||||
@@ -241,83 +157,17 @@ internal sealed class FrontierPacker
|
||||
)
|
||||
);
|
||||
if (!better)
|
||||
return;
|
||||
bestParts = build();
|
||||
continue;
|
||||
bestRegion = region;
|
||||
bestPriority = priority;
|
||||
bestPoint = point;
|
||||
bestFills = fills;
|
||||
bestValue = value;
|
||||
bestSide = side;
|
||||
bestLead = lead;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return bestRegion == null ? null : (bestRegion, bestPoint);
|
||||
}
|
||||
|
||||
/// <summary>Legal reference points for one orientation on this sheet.</summary>
|
||||
@@ -327,10 +177,9 @@ internal sealed class FrontierPacker
|
||||
private PathsD free;
|
||||
private RectD bounds;
|
||||
|
||||
public Region(Orientation orientation, Box work, bool single)
|
||||
public Region(Orientation orientation, Box work)
|
||||
{
|
||||
Orientation = orientation;
|
||||
Single = single;
|
||||
minX = work.Left - orientation.MinX;
|
||||
maxX = work.Right - orientation.MaxX;
|
||||
minY = work.Bottom - orientation.MinY;
|
||||
@@ -354,10 +203,6 @@ 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)
|
||||
@@ -374,132 +219,49 @@ 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;
|
||||
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)
|
||||
foreach (var path in free)
|
||||
foreach (var raw in path)
|
||||
{
|
||||
reach = x + box.MaxX;
|
||||
across = y + box.MinY;
|
||||
start = x + box.MinX;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 +48,9 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
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 neither a single nor a pair can fit is reported unplaced.
|
||||
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
|
||||
var demand = new int[types.Count];
|
||||
foreach (var type in types)
|
||||
{
|
||||
@@ -59,8 +58,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
stock.Quantity != 0
|
||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
||||
);
|
||||
demand[type.Index] = placeable || solver.PairFits(type)
|
||||
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
|
||||
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||
}
|
||||
|
||||
Plan? best = null;
|
||||
@@ -87,26 +85,10 @@ 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();
|
||||
|
||||
@@ -175,30 +157,6 @@ 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
|
||||
@@ -241,7 +199,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), 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);
|
||||
if (fill.Parts.Count > 0)
|
||||
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 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>
|
||||
@@ -100,32 +97,11 @@ 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,
|
||||
Perimeter = perimeter,
|
||||
});
|
||||
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
|
||||
}
|
||||
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,145 +215,6 @@ 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,80 +97,6 @@ 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(() =>
|
||||
{
|
||||
@@ -307,10 +233,11 @@ public class PlateOverlapOverlayTests
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("add")]
|
||||
[InlineData("remove")]
|
||||
[InlineData("replace")]
|
||||
[InlineData("clear")]
|
||||
public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
|
||||
public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
|
||||
{
|
||||
using var run = new OverlayRun();
|
||||
run.Finish();
|
||||
@@ -320,6 +247,7 @@ 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;
|
||||
@@ -329,23 +257,6 @@ 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(() =>
|
||||
{
|
||||
|
||||
@@ -33,7 +33,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
private ObservableList<Part> observedParts;
|
||||
private CancellationTokenSource cancellation;
|
||||
private GraphicsPath path;
|
||||
private IReadOnlyList<PlateOverlapPair> pathPairs;
|
||||
private PlateOverlapReport pathReport;
|
||||
private float pathScale;
|
||||
private Units capturedUnits;
|
||||
private IReadOnlyList<PlateOverlapPair> hoveredPairs = Array.Empty<PlateOverlapPair>();
|
||||
@@ -139,6 +139,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
var source = new CancellationTokenSource();
|
||||
cancellation = source;
|
||||
var token = source.Token;
|
||||
ReleasePath();
|
||||
NotifyChanged(toStatusBar: !automatic);
|
||||
try
|
||||
{
|
||||
@@ -203,22 +204,13 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
{
|
||||
materialCache.Clear();
|
||||
baseline = null;
|
||||
state.Invalidate();
|
||||
CancelWorker();
|
||||
ReleasePath();
|
||||
NotifyChanged();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Invalidate()
|
||||
{
|
||||
var generation = state.Generation;
|
||||
var display = state.DisplayPairs;
|
||||
state.Invalidate(view.Plate);
|
||||
if (!ReferenceEquals(display, state.DisplayPairs))
|
||||
{
|
||||
ReleasePath();
|
||||
ClearHover();
|
||||
}
|
||||
state.Invalidate();
|
||||
if (generation == state.Generation)
|
||||
{
|
||||
// Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
|
||||
@@ -231,6 +223,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
return;
|
||||
}
|
||||
CancelWorker();
|
||||
ReleasePath();
|
||||
NotifyChanged();
|
||||
}
|
||||
|
||||
@@ -257,8 +250,6 @@ 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;
|
||||
@@ -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.
|
||||
ValidateHoverTransform();
|
||||
|
||||
if (state.DisplayPairs.Count > 0)
|
||||
if (state.Report != null)
|
||||
{
|
||||
if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both)
|
||||
{
|
||||
@@ -375,16 +366,11 @@ 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;
|
||||
@@ -471,7 +457,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.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);
|
||||
if (!float.IsFinite(center.X) || !float.IsFinite(center.Y))
|
||||
@@ -546,13 +532,13 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
|
||||
private void EnsurePath()
|
||||
{
|
||||
if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null)
|
||||
if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null)
|
||||
return;
|
||||
ReleasePath();
|
||||
var next = new GraphicsPath(FillMode.Winding);
|
||||
try
|
||||
{
|
||||
foreach (var pair in state.DisplayPairs)
|
||||
foreach (var pair in state.Report.Pairs)
|
||||
{
|
||||
foreach (var region in pair.Regions)
|
||||
{
|
||||
@@ -574,7 +560,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
}
|
||||
}
|
||||
path = next;
|
||||
pathPairs = state.DisplayPairs;
|
||||
pathReport = state.Report;
|
||||
pathScale = view.ViewScale;
|
||||
}
|
||||
catch
|
||||
@@ -606,7 +592,7 @@ internal sealed class OverlapOverlayController : IDisposable
|
||||
{
|
||||
path?.Dispose();
|
||||
path = null;
|
||||
pathPairs = null;
|
||||
pathReport = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -34,10 +34,8 @@ 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) 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
|
||||
(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
|
||||
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
|
||||
@@ -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
|
||||
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, 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.
|
||||
`InvalidateOverlapCheck()` clears the cache and baseline, so in-place program
|
||||
editors must keep calling it before loading.
|
||||
|
||||
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
|
||||
@@ -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
|
||||
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 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.
|
||||
Centroid hit tests use only cached report coordinates and DPI-scaled screen
|
||||
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
|
||||
waits, and the no-retry rule for canceled or failed layouts.
|
||||
`OverlapReportStateTests` verifies request supersession, exact pose/reference
|
||||
freshness, per-pair display retention through repeated edits and pending checks,
|
||||
hard invalidation, cancellation, and incomplete-versus-clear messaging.
|
||||
freshness, stale clearing, 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
|
||||
@@ -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
|
||||
execute Windows tests or verify appearance, DPI, or interaction. On Windows,
|
||||
check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
|
||||
alignment, changed-pair clearing and unchanged-pair retention during edits and
|
||||
pending rechecks, full clearing on plate switches and converter cancellation,
|
||||
alignment, stale clearing during edits/plate switches, 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.
|
||||
|
||||
+2
-25
@@ -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 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 |
|
||||
| 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.
|
||||
|
||||
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
|
||||
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.
|
||||
enclosed cutouts. 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
|
||||
|
||||
Reference in New Issue
Block a user