From c997b2e58809cc22b81941c373628d58da8f554b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 22:45:25 -0400 Subject: [PATCH] feat(engine): live sheet preview while Irregular packs The frontier packer reports its placements after each choice; the engine turns them into preview parts at most four times a second and sends them as NestProgress.BestParts with the stock work area, which desktop Auto Nest already draws on the plate view. Reporting only reads finished placements, so layouts are unchanged with or without a progress sink. Co-Authored-By: Claude Opus 5.5 --- .../IrregularNestingEngineTests.cs | 42 +++++++++++++- .../Irregular/FrontierPacker.cs | 8 ++- .../Irregular/IrregularNestingEngine.cs | 5 +- .../NestingEngines/Irregular/SheetPreview.cs | 57 +++++++++++++++++++ docs/nesting-engines.md | 7 +++ 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 OpenNest.Engine/NestingEngines/Irregular/SheetPreview.cs 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