mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 07:34:14 -04:00
feat(cutouts): trial physical composite reconciliation in pipeline
This commit is contained in:
1 parent
71bd964344
commit
0b29c683b9
4 files changed
+562
-6
No files matched your search
@@ -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<NestJob, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? 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<NestJobPlateResult>() :
|
||||
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<PartFulfillment>(), Array.Empty<StockUsage>())), "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<OperationCanceledException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<NestJobProgress>
|
||||
{
|
||||
public List<NestJobProgress> 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<NestJobProgress>? 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Internal-only single-frame composite trial. No normal pipeline caller enables this.</summary>
|
||||
internal sealed class CutoutPipelinePrepass
|
||||
{
|
||||
private readonly string proxyId;
|
||||
private readonly string frameId;
|
||||
private readonly IReadOnlyList<NestJobPlacement> localInserts;
|
||||
|
||||
private CutoutPipelinePrepass(NestJob engineJob, string proxyId, string frameId,
|
||||
IReadOnlyList<NestJobPlacement> 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<NestJobPart>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
|
||||
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
|
||||
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<int>());
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public static class NestPipeline
|
||||
{
|
||||
private sealed class PreviewProgress(IProgress<NestJobProgress> inner) : IProgress<NestJobProgress>
|
||||
{
|
||||
public void Report(NestJobProgress value)
|
||||
{
|
||||
if (value != null && value.Stage == NestJobStage.EvaluatingCandidate)
|
||||
inner.Report(value with { CommittedPlates = 0, CommittedParts = 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
||||
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
||||
/// Engine cancellation propagates unchanged.</summary>
|
||||
@@ -93,6 +103,32 @@ public static class NestPipeline
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
internal static NestPipelineResult RunCutoutPreview(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true);
|
||||
|
||||
internal static NestPipelineResult RunCutoutPreview(
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> 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<NestJobProgress> 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<ProposedPlate>();
|
||||
|
||||
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,
|
||||
|
||||
+15
-4
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user