From 0b29c683b9fbf4d1f64e411a679f6dfbbc5d5bec Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 18:57:31 -0400 Subject: [PATCH] feat(cutouts): trial physical composite reconciliation in pipeline --- .../Cutouts/CutoutPipelinePreviewTests.cs | 304 ++++++++++++++++++ .../Jobs/Cutouts/CutoutPipelinePrepass.cs | 169 ++++++++++ OpenNest.Engine/Jobs/NestPipeline.cs | 76 ++++- docs/nesting-engines.md | 19 +- 4 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs create mode 100644 OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs new file mode 100644 index 0000000..7bd7e27 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs @@ -0,0 +1,304 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Cutouts; +using OpenNest.Geometry; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +public class CutoutPipelinePreviewTests +{ + private static NestPipelineRequest Request(string engine = "Irregular") + { + var frame = new Drawing("frame", TestShapes.Ring(20, 10)); + var insert = new Drawing("insert", TestShapes.Rectangle(3, 3)); + return new NestPipelineRequest(engine, + new[] { new NestItem { Drawing = frame, Quantity = 1 }, + new NestItem { Drawing = insert, Quantity = 1 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); + } + + [Theory] + [InlineData("Irregular")] + [InlineData("Default")] + public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine) + { + var request = Request(engine); + var result = NestPipeline.RunCutoutPreview(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.True(result.CanKeep); + var plate = Assert.Single(result.Plates); + Assert.Equal(2, plate.Parts.Count); + Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing); + Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing); + Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId)); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced))); + Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested); + Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); + } + + private sealed class Stub(Func solve) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) => solve(job); + } + + private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses) + { + var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count()); + var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity); + return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete, + complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, + poses.Length == 0 ? Array.Empty() : + new[] { new NestJobPlateResult(0, job.Plates[0], poses) }, + job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, + placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))), + new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1, + job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) }); + } + + [Fact] + public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand() + { + var called = 0; + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + called++; + var proxy = Assert.Single(job.Parts); + Assert.StartsWith("__cutout-proxy-", proxy.Id); + Assert.True(proxy.Rotation.Allows(0)); + Assert.False(proxy.Rotation.Allows(System.Math.PI / 2)); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + }), "stub", request); + Assert.Equal(1, called); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, result.Plates.Single().Parts.Count); + } + + [Fact] + public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("did not complete")); + Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); + AssertCannotCommit(result, request); + } + + [Fact] + public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id; + var pose = new NestJobPlacement(id, 1, 11, 11, 0); + return Result(job, pose); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("accounting")); + } + + [Fact] + public void PartialBundleKeepsIndependentResidualByOriginalRequirement() + { + var request = Request("stub"); + request.Items[1].Quantity = 2; + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + var residual = job.Parts.Single(p => p.Id == "part-1"); + Assert.Equal(1, residual.Quantity); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), + new NestJobPlacement(residual.Id, 0, 22, 1, 0)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-1", "part-1" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements + .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); + Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing), + p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing)); + } + + [Fact] + public void UntrustedStockOverdrawOrRotationCannotBindAComposite() + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, + System.Math.PI / 2)); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("rotation constraint")); + + var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult( + NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, job.Plates[0], new[] { + new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }), + new NestJobPlateResult(1, job.Plates[0], new[] { + new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) }, + Array.Empty(), Array.Empty())), "stub", request); + Assert.False(overdraw.CanKeep); + Assert.Empty(overdraw.Plates); + Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available")); + } + + [Fact] + public void CancellationBeforePreparationDoesNotCallEngine() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var engine = new Stub(_ => throw new Exception("Must not call Solve")); + Assert.Throws(() => + NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token)); + } + + [Fact] + public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion() + { + var request = Request("stub"); + request.Items[1].Quantity = 2; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), + new NestJobPlacement("part-1", 0, 11, 11, 0)); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("required spacing")); + AssertCannotCommit(result, request); + } + + private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request) + { + var nest = new Nest(); + foreach (var item in request.Items) + nest.Drawings.Add(item.Drawing); + var empty = nest.CreatePlate(); + using var manager = new PlateManager(nest); + Assert.Throws(() => + NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true)); + Assert.Same(empty, Assert.Single(nest.Plates)); + Assert.Empty(empty.Parts); + Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested)); + } + + private sealed class CollectProgress : IProgress + { + public List Events { get; } = new(); + public void Report(NestJobProgress value) => Events.Add(value); + } + + [Fact] + public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape() + { + var request = Request("stub"); + var progress = new CollectProgress(); + var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress); + Assert.False(result.CanKeep); + Assert.Single(progress.Events); + Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage); + Assert.Equal(0, progress.Events[0].CommittedPlates); + Assert.Equal(0, progress.Events[0].CommittedParts); + } + + private sealed class ProgressStub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, + job.Plates[0].Id, 0, 1, 1)); + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + job.Plates[0].Id, 0, 1, 1)); + return Result(job); + } + } + + [Fact] + public void ReorderedButContiguousEngineIndicesAreAccepted() + { + var request = Request("stub"); + request.Items[1].Quantity = 3; + request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity); + return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), + new NestJobPlacement("part-1", 1, 22, 1, 0), + new NestJobPlacement("part-1", 0, 22, 9, 0)); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements + .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment) + { + var request = Request("stub"); + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); + return new NestJobResult(good.Status, good.StopReason, good.Plates, + fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment, + fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) }); + }), "stub", request); + Assert.False(result.CanKeep); + Assert.Empty(result.Plates); + Assert.Contains(result.Violations, v => v.Contains("accounting")); + } + + [Fact] + public void SameDrawingInTwoRequirementRowsRetainsSeparateIds() + { + var initial = Request("stub"); + var request = initial with + { + Items = new[] { initial.Items[0], initial.Items[1], + new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } }, + Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } + }; + var result = NestPipeline.RunCutoutPreview(new Stub(job => + { + var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); + var residual = job.Parts.Where(p => p.Id != proxy.Id) + .Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0)); + return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) } + .Concat(residual).ToArray()); + }), "stub", request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(new[] { "part-0", "part-1", "part-2" }, + result.Raw.Plates.Single().Placements.Select(p => p.PartId)); + Assert.Same(result.Plates.Single().Parts[1].BaseDrawing, + result.Plates.Single().Parts[2].BaseDrawing); + } + + [Fact] + public void OrdinaryPipelineIsStillUnchanged() + { + var request = Request("stub"); + var result = NestPipeline.Run(new Stub(job => + { + Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-")); + return Result(job); + }), "stub", request); + Assert.True(result.CanKeep); + Assert.Empty(result.Plates); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs new file mode 100644 index 0000000..8d8f998 --- /dev/null +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -0,0 +1,169 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest.Engine.Jobs.Cutouts; + +/// Internal-only single-frame composite trial. No normal pipeline caller enables this. +internal sealed class CutoutPipelinePrepass +{ + private readonly string proxyId; + private readonly string frameId; + private readonly IReadOnlyList localInserts; + + private CutoutPipelinePrepass(NestJob engineJob, string proxyId, string frameId, + IReadOnlyList localInserts) + { + EngineJob = engineJob; + this.proxyId = proxyId; + this.frameId = frameId; + this.localInserts = localInserts; + } + + internal NestJob EngineJob { get; } + + internal static CutoutPipelinePrepass? Prepare(NestJob original, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (original.Plates.Count == 0) + return null; + // A single fixed-zero proxy is safe even when a symmetric outer perimeter causes + // an engine to deduplicate rotations. General orientation intersections are deferred. + var spacing = original.Plates.Max(p => p.PartSpacing); + foreach (var frame in original.Parts.Where(p => p.Quantity == 1 && p.Rotation.Allows(0))) + { + var geometry = JobPartGeometry.TryRead(frame.Geometry); + if (geometry == null || geometry.Cutouts.Count == 0) + continue; + var candidates = original.Parts.Where(p => p.Id != frame.Id).ToArray(); + if (candidates.Length == 0) + continue; + for (var hole = 0; hole < geometry.Cutouts.Count; hole++) + { + token.ThrowIfCancellationRequested(); + var poses = CutoutRouter.Fill(frame, hole, candidates, spacing, token); + if (poses.Count == 0) + continue; + var reserved = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count()); + var proxyId = "__cutout-proxy-" + frame.Id; + if (original.Parts.Any(p => p.Id == proxyId)) + throw new InvalidOperationException("Cutout proxy ID collides with a requirement."); + var transformed = new List(); + foreach (var part in original.Parts) + { + if (part.Id == frame.Id) + transformed.Add(new NestJobPart(proxyId, part.Geometry, 1, part.Priority, + RotationPolicy.Fixed(0))); + else + { + var remaining = part.Quantity - reserved.GetValueOrDefault(part.Id); + if (remaining > 0) + transformed.Add(new NestJobPart(part.Id, part.Geometry, remaining, + part.Priority, part.Rotation)); + } + } + return new CutoutPipelinePrepass(new NestJob(transformed, original.Plates, + original.Options), proxyId, frame.Id, poses); + } + } + return null; + } + + /// Reject untrusted accounting before expansion. A failed trial is never bindable, + /// including with allowInvalid. Only actually placed proxies consume reserved inserts. + internal bool TryExpand(NestJob original, NestJobResult result, out NestJobResult? expanded, + out string failure) + { + expanded = null; + failure = "Invalid cutout engine accounting"; + var parts = EngineJob.Parts.ToDictionary(p => p.Id); + var counts = parts.ToDictionary(p => p.Key, _ => 0); + var indices = parts.ToDictionary(p => p.Key, _ => new HashSet()); + var used = EngineJob.Plates.ToDictionary(s => s.Id, _ => 0); + var expectedIndex = 0; + foreach (var sheet in result.Plates) + { + if (sheet.PlateIndex != expectedIndex++ || sheet.Placements.Count == 0 + || !used.ContainsKey(sheet.StockId)) + return false; + used[sheet.StockId]++; + foreach (var pose in sheet.Placements) + { + if (pose.PartId == null || !parts.ContainsKey(pose.PartId) + || pose.InstanceIndex < 0 || !indices[pose.PartId].Add(pose.InstanceIndex) + || ++counts[pose.PartId] > parts[pose.PartId].Quantity) + return false; + } + } + if (indices.Any(row => row.Value.Any(index => index >= counts[row.Key]))) + return false; + if (result.Fulfillment.Count != parts.Count || result.StockUsage.Count != used.Count) + return false; + foreach (var row in result.Fulfillment) + if (row.PartId == null || !parts.TryGetValue(row.PartId, out var part) + || row.Requested != part.Quantity || row.Placed != counts[row.PartId] + || row.Unplaced != part.Quantity - counts[row.PartId] + || result.Fulfillment.Count(r => r.PartId == row.PartId) != 1) + return false; + foreach (var row in result.StockUsage) + { + var stock = EngineJob.Plates.FirstOrDefault(s => s.Id == row.StockId); + if (stock == null || row.Used != used[row.StockId] + || row.Remaining != stock.Quantity - row.Used + || result.StockUsage.Count(r => r.StockId == row.StockId) != 1) + return false; + } + var complete = parts.Values.All(p => counts[p.Id] == p.Quantity); + if (result.Status != (complete ? NestJobStatus.Complete : NestJobStatus.Incomplete) + || (complete && result.StopReason != NestJobStopReason.Completed) + || (!complete && result.StopReason == NestJobStopReason.Completed)) + return false; + + // An incomplete solve cannot silently reserve inserts or reach the commit boundary. + if (!complete) + { + failure = "Cutout engine did not complete the transformed job"; + return false; + } + var builder = new NestJobResultBuilder(original); + foreach (var sheet in result.Plates) + { + var poses = new List<(string PartId, double X, double Y, double Rotation)>(); + foreach (var pose in sheet.Placements) + { + if (pose.PartId != proxyId) + { + poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); + continue; + } + poses.Add((frameId, pose.X, pose.Y, pose.Rotation)); + var sine = System.Math.Sin(pose.Rotation); + var cosine = System.Math.Cos(pose.Rotation); + foreach (var local in localInserts) + poses.Add((local.PartId, + pose.X + local.X * cosine - local.Y * sine, + pose.Y + local.X * sine + local.Y * cosine, + pose.Rotation + local.Rotation)); + } + // Canonical stock reference, not an engine-supplied object with altered settings. + var stock = original.Plates.Single(s => s.Id == sheet.StockId); + try + { + builder.AddSheet(stock, poses); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return false; + } + } + expanded = builder.Build(result.StopReason); + if (expanded.Status != NestJobStatus.Complete) + { + expanded = null; + return false; + } + return true; + } +} diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 858a39b..45507ff 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Cutouts; namespace OpenNest.Engine.Jobs; @@ -72,6 +73,15 @@ public sealed class NestPipelineResult /// public static class NestPipeline { + private sealed class PreviewProgress(IProgress inner) : IProgress + { + public void Report(NestJobProgress value) + { + if (value != null && value.Stage == NestJobStage.EvaluatingCandidate) + inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 }); + } + } + /// Resolves through /// ; unknown names throw . /// Engine cancellation propagates unchanged. @@ -93,6 +103,32 @@ public static class NestPipeline NestPipelineRequest request, IProgress progress = null, CancellationToken token = default + ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false); + + /// Internal test-only integration trial. Normal Run never prepares cutouts; + /// this entry point must not be called by production front ends before cutting/post gates. + internal static NestPipelineResult RunCutoutPreview( + INestingEngine engine, + string engineName, + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true); + + internal static NestPipelineResult RunCutoutPreview( + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName), + request.EngineName, request, progress, token); + + private static NestPipelineResult RunCore( + INestingEngine engine, + string engineName, + NestPipelineRequest request, + IProgress progress, + CancellationToken token, + bool cutoutPreview ) { ArgumentNullException.ThrowIfNull(engine); @@ -115,10 +151,15 @@ public static class NestPipeline var job = new NestJob(parts, request.Stock, request.Options); NestJobValidator.Validate(job); + var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null; + var engineJob = prepass?.EngineJob ?? job; + if (prepass != null) + NestJobValidator.Validate(engineJob); var clock = Stopwatch.StartNew(); var raw = - engine.Solve(job, progress, token) + engine.Solve(engineJob, prepass == null || progress == null + ? progress : new PreviewProgress(progress), token) ?? throw new InvalidOperationException($"Engine '{engineName}' returned no result."); var solveTime = clock.Elapsed; token.ThrowIfCancellationRequested(); @@ -129,7 +170,27 @@ public static class NestPipeline kv => kv.Value.Name ?? kv.Key, StringComparer.Ordinal ); - var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep); + var checkedNames = prepass == null ? names : engineJob.Parts.ToDictionary(p => p.Id, + p => names.GetValueOrDefault(p.Id, p.Id), StringComparer.Ordinal); + var violations = NestLayoutCheck.Violations(engineJob, raw, checkedNames, out var canKeep).ToList(); + if (prepass != null) + { + // A compound may be physically invalid even when its envelope is legal. + // Refuse every unverified trial, including with explicit invalid-result consent. + var failure = "Cutout engine output failed reconciliation"; + if (violations.Count == 0 && prepass.TryExpand(job, raw, out var expanded, out failure)) + { + raw = expanded; + violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep)); + canKeep = physicalKeep && violations.Count == 0; + } + else + { + if (violations.Count == 0) + violations.Add(failure); + canKeep = false; + } + } var validationTime = clock.Elapsed; var plates = canKeep @@ -141,6 +202,17 @@ public static class NestPipeline .ToList() : new List(); + token.ThrowIfCancellationRequested(); + if (prepass != null && canKeep) + { + var count = 0; + for (var i = 0; i < raw.Plates.Count; i++) + { + count += raw.Plates[i].Placements.Count; + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + raw.Plates[i].StockId, i, i + 1, count)); + } + } token.ThrowIfCancellationRequested(); return new NestPipelineResult( engineName, diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index a81268a..049f068 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -70,8 +70,9 @@ remain separate sequencer/verification work. ## Filling cutouts (not yet in production) Placing parts inside another part's enclosed cutout is being built as a step that runs before any -engine, so every engine benefits. Nothing calls it yet: a part inside a cutout must be cut before -the cutout's contour, and the sequencer does not enforce that order. +engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part +inside a cutout must be cut before the cutout's contour, and the cutting/post safety gates are +not complete. Ordinary whole-job calls do not enable this path. `CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts @@ -96,8 +97,18 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal. An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. Six geometry-only, anonymized real-job probes and neutral ring fixtures informed the search-order hints; these limited cases are not a global density guarantee. -The router has no pipeline caller and does not change -stock, live demand, cutting order or posting safety; in-hole production use remains held. +The internal `NestPipeline.RunCutoutPreview` prepares at most one quantity-one frame with one +cutout as a fixed-zero proxy. Reserved inserts are removed from the engine's independent demand; +remaining demand is offered once to the selected engine. Only after a complete, accounted-for +engine result does it expand the proxy back to original requirement IDs, validate every physical +pose, stock and quantity, then bind caller drawings. Incomplete, forged or geometrically invalid +composites return no bindable plates even with invalid-result consent. Candidate evaluation +progress is forwarded with unverified proxy commitment counts zeroed; transformed proxy commits +are hidden. Physical commits are reported only after physical validation. Frames needing other +orientations or repeated frame copies still run through the ordinary unbundled path. This is a +narrow internal integration trial, not a production setting or a cut-ready nest; multi-frame +routing, rotation compatibility, save/reload and cutting/post safety are still pending before +enablement. ## Renamed engines