diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs index 4555c0a2..664aa6f2 100644 --- a/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs @@ -1,13 +1,13 @@ -using OpenNest.Engine.NestingEngines.Irregular; -using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; -using static OpenNest.Engine.Tests.NestingEngines.Shapes; using System; using System.Collections.Generic; using System.Linq; using OpenNest.CNC; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; +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; @@ -211,6 +211,42 @@ public class IrregularNestingEngineTests Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); } + [Fact] + public void ReportsLiveSheetPreviewWithoutChangingTheLayout() + { + var job = Job(new[] { Part("disc", Disc(3), 10), Part("ell", LShape(12, 8, 4), 10) }, + new[] { Stock("sheet", 40, 60, spacing: 0.5) }); + var reports = new List(); + + var withPreview = new IrregularNestingEngine().Solve(job, new SyncProgress(reports.Add)); + var without = new IrregularNestingEngine().Solve(job); + + var previews = reports.Where(r => r.LegacyProgress?.BestParts is { Count: > 0 }).ToList(); + Assert.NotEmpty(previews); + var work = job.Plates[0].WorkArea; + foreach (var preview in previews.Select(r => r.LegacyProgress)) + { + Assert.True(preview.IsOverallBest); + Assert.Equal((work.Left, work.Bottom, work.Right, work.Top), (preview.ActiveWorkArea.Left, + preview.ActiveWorkArea.Bottom, preview.ActiveWorkArea.Right, preview.ActiveWorkArea.Top)); + foreach (var part in preview.BestParts) + { + Assert.InRange(part.BoundingBox.Left, work.Left - 1e-6, work.Right); + Assert.InRange(part.BoundingBox.Right, work.Left, work.Right + 1e-6); + Assert.InRange(part.BoundingBox.Bottom, work.Bottom - 1e-6, work.Top); + Assert.InRange(part.BoundingBox.Top, work.Bottom, work.Top + 1e-6); + } + } + Assert.Equal( + without.Plates.SelectMany(p => p.Placements), + withPreview.Plates.SelectMany(p => p.Placements)); + } + + private sealed class SyncProgress(Action report) : IProgress + { + public void Report(NestJobProgress value) => report(value); + } + [Fact] public void HasPublicParameterlessConstructorForPluginDiscovery() { diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs index deb3b394..e8c109a1 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -64,6 +64,9 @@ internal sealed class FrontierPacker private readonly BlockCatalog? blocks; private readonly ClampObstacles? clamps; + /// Observer of the placements so far (live preview); read-only, it never steers packing. + private readonly Action>? placedChanged; + public FrontierPacker( IReadOnlyList types, NoFitCache nfps, @@ -73,9 +76,11 @@ internal sealed class FrontierPacker double beta, WorkCounter counter, BlockCatalog? blocks = null, - ClampObstacles? clamps = null + ClampObstacles? clamps = null, + Action>? placedChanged = null ) { + this.placedChanged = placedChanged; this.blocks = blocks; this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock); this.counter = counter; @@ -182,6 +187,7 @@ internal sealed class FrontierPacker partArea += types[typeIndex].Area; front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top); } + placedChanged?.Invoke(placed); left[typeIndex] -= choice.Count; blockStates.RemoveAll(b => b.Members.Count > left[b.Members[0].Orientation.TypeIndex]); diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index fd1470ff..8dcaf3bf 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -131,6 +131,7 @@ public sealed class IrregularNestingEngine : INestingEngine } private const int MaxTail = 3; + private readonly SheetPreview? preview = progress == null ? null : new SheetPreview(progress, types, drawingFor); private readonly Dictionary caches = new(); private readonly Dictionary clamps = new(); private readonly Dictionary>> pairs = new(); @@ -289,8 +290,10 @@ 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 plateIndex = sheets.Count; var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, - BlocksFor(stock), ClampsFor(stock)); + BlocksFor(stock), ClampsFor(stock), + preview == null ? null : placed => preview.Report(stock, plateIndex, placed)); var fill = packer.Fill(remaining, token); sheetPacked?.Invoke(); if (fill.Parts.Count > 0) diff --git a/OpenNest.Engine/NestingEngines/Irregular/SheetPreview.cs b/OpenNest.Engine/NestingEngines/Irregular/SheetPreview.cs new file mode 100644 index 00000000..80f5fce6 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/SheetPreview.cs @@ -0,0 +1,57 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Live preview of the sheet a packer is filling, sent as job progress detail +/// () for front ends that draw it. Reports are +/// throttled to one per ; the clock only gates reporting and never feeds +/// back into packing, so the solve stays deterministic. +/// +internal sealed class SheetPreview( + IProgress progress, + IReadOnlyList types, + Func drawingFor +) +{ + internal static readonly TimeSpan Interval = TimeSpan.FromMilliseconds(250); + + private readonly Stopwatch clock = Stopwatch.StartNew(); + private TimeSpan? last; + + /// Called after each placement on a sheet trial; reports when the interval has passed. + public void Report(NestPlateStock stock, int plateIndex, IReadOnlyList placed) + { + var now = clock.Elapsed; + if (last is TimeSpan previous && now - previous < Interval) + return; + last = now; + + var parts = new List(placed.Count); + foreach (var pose in placed) + { + // Same pose mapping as the materialized result: rotate about the snapshot origin, then move. + var part = new Part(drawingFor(types[pose.Orientation.TypeIndex].Part)); + part.Rotate(pose.Orientation.Rotation); + part.Location = new Vector(pose.X, pose.Y); + part.UpdateBounds(); + parts.Add(part); + } + + progress.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, plateIndex, 0, 0, + new NestProgress + { + Phase = NestPhase.Custom, + PlateNumber = plateIndex + 1, + BestParts = parts, + ActiveWorkArea = stock.WorkArea, + IsOverallBest = true, + Description = $"Irregular: packing plate {plateIndex + 1} on stock {stock.Id} ({parts.Count} parts)", + })); + } +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 5fb40528..b1ad7b93 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -63,6 +63,13 @@ Curved outlines retain conservative flattening-error padding. This does not rela footprints, no-fit polygons or layout validation tolerances; a part extending beyond the accepted work-area bounds remains invalid. +While it runs with a progress sink, Irregular previews the sheet it is packing: at most four +times a second it reports the placements so far as `NestProgress.BestParts` with the stock's +work area, and desktop Auto Nest draws them on the plate view. The preview follows every trial +(each stock, sheet, strategy variant and tail re-plan), so it shows the search at work, not the +layout that will be kept. Reporting only reads finished placements, so layouts are identical +with or without a sink. + 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