fix(engine): reject unrepresentable nesting output before geometry checks
This commit is contained in:
@@ -6,32 +6,78 @@ namespace OpenNest.Engine.Tests.Jobs;
|
|||||||
|
|
||||||
public class NestLayoutCheckTests
|
public class NestLayoutCheckTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
[InlineData(1, true)]
|
||||||
|
[InlineData(2, false)]
|
||||||
|
[InlineData(3, false)]
|
||||||
|
public void MaxPlatesCountsPhysicalSheetsIncludingEmptyOnes(int? maxPlates, bool exceedsLimit)
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock("sheet", new Size(48, 96));
|
||||||
|
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock }, new NestJobOptions(maxPlates: maxPlates));
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new NestJobPlateResult(7, stock, Array.Empty<NestJobPlacement>()),
|
||||||
|
new NestJobPlateResult(42, stock, Array.Empty<NestJobPlacement>()),
|
||||||
|
}, Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
var violations = NestLayoutCheck.Violations(job, result);
|
||||||
|
|
||||||
|
if (exceedsLimit)
|
||||||
|
Assert.Contains(violations, v => v.Contains("MaxPlates") && v.Contains("2") && v.Contains("1"));
|
||||||
|
else
|
||||||
|
Assert.Empty(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ghost", 1, 1, 0)]
|
||||||
|
[InlineData(null, 1, 1, 0)]
|
||||||
|
[InlineData("p", double.NaN, 1, 0)]
|
||||||
|
[InlineData("p", 1, double.PositiveInfinity, 0)]
|
||||||
|
[InlineData("p", 1, 1, double.NaN)]
|
||||||
|
public void MalformedPlacementsProduceDiagnosticsRatherThanThrowing(string? partId, double x, double y, double rotation)
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock("sheet", new Size(48, 96));
|
||||||
|
var job = new NestJob(new[]
|
||||||
|
{
|
||||||
|
new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 1),
|
||||||
|
}, new[] { stock });
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement(partId!, 0, x, y, rotation) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
var violations = NestLayoutCheck.Violations(job, result);
|
||||||
|
|
||||||
|
Assert.NotEmpty(violations);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances()
|
public void TangentDiscsClearAtSafeMarginAcrossRadiiAnglesAndTolerances()
|
||||||
{
|
{
|
||||||
var count = 0;
|
var count = 0;
|
||||||
foreach (var radius in new[] { 0.1, 1.0, 10.0 })
|
foreach (var radius in new[] { 0.1, 1.0, 10.0 })
|
||||||
foreach (var tolerance in new[] { 0.0, 0.0005, 0.01 })
|
foreach (var tolerance in new[] { 0.0, 0.0005, 0.01 })
|
||||||
foreach (var spacing in new[] { 0.0, 0.25 })
|
foreach (var spacing in new[] { 0.0, 0.25 })
|
||||||
{
|
{
|
||||||
var program = new Program();
|
var program = new Program();
|
||||||
program.MoveTo(radius, 0);
|
program.MoveTo(radius, 0);
|
||||||
program.Codes.Add(new ArcMove(radius, 0, 0, 0, RotationType.CW));
|
program.Codes.Add(new ArcMove(radius, 0, 0, 0, RotationType.CW));
|
||||||
var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program));
|
var geometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(program));
|
||||||
// Two inscribed engine outlines can underestimate true extent by t each.
|
// Two inscribed engine outlines can underestimate true extent by t each.
|
||||||
var distance = 2 * radius + spacing
|
var distance = 2 * radius + spacing
|
||||||
+ NestTolerances.SafeClearanceMargin(tolerance) - 2 * tolerance;
|
+ NestTolerances.SafeClearanceMargin(tolerance) - 2 * tolerance;
|
||||||
for (var degrees = 0; degrees < 360; degrees += 15)
|
for (var degrees = 0; degrees < 360; degrees += 15)
|
||||||
{
|
{
|
||||||
var angle = degrees * System.Math.PI / 180;
|
var angle = degrees * System.Math.PI / 180;
|
||||||
var a = new NestJobPlacement("disc", 0, 0.12345, -0.54321, angle / 3);
|
var a = new NestJobPlacement("disc", 0, 0.12345, -0.54321, angle / 3);
|
||||||
var b = new NestJobPlacement("disc", 1, a.X + distance * System.Math.Cos(angle),
|
var b = new NestJobPlacement("disc", 1, a.X + distance * System.Math.Cos(angle),
|
||||||
a.Y + distance * System.Math.Sin(angle), -angle / 7);
|
a.Y + distance * System.Math.Sin(angle), -angle / 7);
|
||||||
Assert.True(NestLayoutCheck.Clears(geometry, a, geometry, b, spacing));
|
Assert.True(NestLayoutCheck.Clears(geometry, a, geometry, b, spacing));
|
||||||
Assert.True(NestLayoutCheck.Clears(geometry, b, geometry, a, spacing));
|
Assert.True(NestLayoutCheck.Clears(geometry, b, geometry, a, spacing));
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Assert.Equal(432, count);
|
Assert.Equal(432, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
using OpenNest.Engine.Jobs;
|
using OpenNest.Engine.Jobs;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ public class NestPipelineTests
|
|||||||
var result = NestPipeline.Run(Request("Default", item));
|
var result = NestPipeline.Run(Request("Default", item));
|
||||||
|
|
||||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
|
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
|
||||||
Assert.Equal(10, parts.Count);
|
Assert.Equal(10, parts.Count);
|
||||||
@@ -58,6 +60,8 @@ public class NestPipelineTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void OverlappingEngineOutputIsReportedByDrawingNameWithoutThrowing()
|
public void OverlappingEngineOutputIsReportedByDrawingNameWithoutThrowing()
|
||||||
{
|
{
|
||||||
|
var item = Item("bracket", 2);
|
||||||
|
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
|
||||||
var engine = new StubEngine(job =>
|
var engine = new StubEngine(job =>
|
||||||
OnePlate(
|
OnePlate(
|
||||||
job,
|
job,
|
||||||
@@ -66,29 +70,167 @@ public class NestPipelineTests
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = NestPipeline.Run(engine, "Overlapper", Request("Overlapper", Item("bracket", 2)));
|
var result = NestPipeline.Run(engine, "Overlapper", Request("Overlapper", item));
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
Assert.False(result.IsValid);
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
Assert.Contains(result.Violations, v => v.Contains("bracket") && v.Contains("spacing"));
|
Assert.Contains(result.Violations, v => v.Contains("bracket") && v.Contains("spacing"));
|
||||||
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||||
|
Assert.All(result.Plates.Single().Parts, part => Assert.Same(item.Drawing, part.BaseDrawing));
|
||||||
|
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
|
||||||
|
Assert.Equal(2, item.Quantity);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ghost")]
|
||||||
|
[InlineData(null)]
|
||||||
|
public void UnknownOrNullRequirementMakesTheEntireProposalNonKeepable(string? partId)
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
|
||||||
|
var engine = new StubEngine(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete,
|
||||||
|
NestJobStopReason.Completed,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) }),
|
||||||
|
new NestJobPlateResult(1, job.Plates[0], new[] { new NestJobPlacement(partId!, 0, 30, 1, 0) }),
|
||||||
|
},
|
||||||
|
Array.Empty<PartFulfillment>(),
|
||||||
|
Array.Empty<StockUsage>()
|
||||||
|
));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(engine, "Ghost", Request("Ghost", item));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains(partId ?? "null") && v.Contains("Plate 1"));
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
|
||||||
|
Assert.Equal(1, item.Quantity);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(double.NaN, 1, 0, "X")]
|
||||||
|
[InlineData(double.PositiveInfinity, 1, 0, "X")]
|
||||||
|
[InlineData(double.NegativeInfinity, 1, 0, "X")]
|
||||||
|
[InlineData(1, double.NaN, 0, "Y")]
|
||||||
|
[InlineData(1, double.PositiveInfinity, 0, "Y")]
|
||||||
|
[InlineData(1, double.NegativeInfinity, 0, "Y")]
|
||||||
|
[InlineData(1, 1, double.NaN, "Rotation")]
|
||||||
|
[InlineData(1, 1, double.PositiveInfinity, "Rotation")]
|
||||||
|
[InlineData(1, 1, double.NegativeInfinity, "Rotation")]
|
||||||
|
public void NonfinitePoseIsReportedAndNeverProposed(double x, double y, double rotation, string field)
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 2);
|
||||||
|
var before = PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions;
|
||||||
|
var engine = new StubEngine(job => OnePlate(job,
|
||||||
|
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
|
||||||
|
new NestJobPlacement(job.Parts[0].Id, 1, x, y, rotation)));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(engine, "Nonfinite", Request("Nonfinite", item));
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains(field) && v.Contains("finite") && v.Contains("bracket"));
|
||||||
|
Assert.Equal(before, PartGeometrySnapshot.FromProgram(item.Drawing.Program).Motions);
|
||||||
|
Assert.Equal(2, item.Quantity);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void PlacementForUnknownRequirementIsAViolationNotSilentlyDropped()
|
public void ReportsEveryStructuralViolationWithoutMaterializingAnyGeometry()
|
||||||
{
|
{
|
||||||
var engine = new StubEngine(job =>
|
var engine = new StubEngine(job => OnePlate(job,
|
||||||
OnePlate(
|
new NestJobPlacement("ghost", 0, double.NaN, double.PositiveInfinity, double.NegativeInfinity),
|
||||||
job,
|
new NestJobPlacement(null!, 0, 1, 1, 0),
|
||||||
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
|
new NestJobPlacement(job.Parts[0].Id, 0, double.NegativeInfinity, 1, 0)));
|
||||||
new NestJobPlacement("ghost", 0, 30, 1, 0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = NestPipeline.Run(engine, "Ghost", Request("Ghost", Item("bracket", 1)));
|
var result = NestPipeline.Run(engine, "Malformed", Request("Malformed", Item("bracket", 1)));
|
||||||
|
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(6, result.Violations.Count);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("ghost") && v.Contains("not part of this job"));
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("null") && v.Contains("not part of this job"));
|
||||||
|
Assert.Equal(2, result.Violations.Count(v => v.Contains("nonfinite X")));
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("nonfinite Y"));
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("nonfinite Rotation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExceedingMaxPlatesIsInvalidButRemainsFullyKeepable()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 2);
|
||||||
|
var request = Request("TooManySheets", item) with { Options = new NestJobOptions(maxPlates: 1) };
|
||||||
|
var engine = new StubEngine(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete,
|
||||||
|
NestJobStopReason.Completed,
|
||||||
|
Enumerable.Range(0, 2).Select(i => new NestJobPlateResult(i, job.Plates[0],
|
||||||
|
new[] { new NestJobPlacement(job.Parts[0].Id, i, 1, 1, 0) })),
|
||||||
|
new[] { new PartFulfillment(job.Parts[0].Id, 2, 2, 0) },
|
||||||
|
new[] { new StockUsage(job.Plates[0].Id, 2, null) }
|
||||||
|
));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(engine, "TooManySheets", request);
|
||||||
|
|
||||||
Assert.False(result.IsValid);
|
Assert.False(result.IsValid);
|
||||||
Assert.Contains(result.Violations, v => v.Contains("ghost"));
|
Assert.True(result.CanKeep);
|
||||||
Assert.Single(result.Plates.Single().Parts);
|
Assert.Contains(result.Violations, v => v.Contains("MaxPlates") && v.Contains("2") && v.Contains("1"));
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
Assert.All(result.Plates, plate => Assert.Same(item.Drawing, Assert.Single(plate.Parts).BaseDrawing));
|
||||||
|
Assert.Equal(2, item.Quantity);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidStockIsRejectedBeforeCallingAnArbitraryEngine()
|
||||||
|
{
|
||||||
|
var called = false;
|
||||||
|
var engine = new StubEngine(job =>
|
||||||
|
{
|
||||||
|
called = true;
|
||||||
|
return OnePlate(job);
|
||||||
|
});
|
||||||
|
var request = Request("Unchecked", Item("bracket", 1)) with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("bad", new Size(48, 96), partSpacing: double.NaN) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var error = Assert.Throws<ArgumentException>(() => NestPipeline.Run(engine, "Unchecked", request));
|
||||||
|
|
||||||
|
Assert.Contains("Invalid stock", error.Message);
|
||||||
|
Assert.False(called);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void InvalidGeometryIsRejectedBeforeCallingAnArbitraryEngine(bool nonfinite)
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
if (nonfinite)
|
||||||
|
((Motion)item.Drawing.Program.Codes[1]).EndPoint = new Vector(double.NaN, 0);
|
||||||
|
else
|
||||||
|
item.Drawing.Program.Codes.Clear();
|
||||||
|
var called = false;
|
||||||
|
var engine = new StubEngine(job =>
|
||||||
|
{
|
||||||
|
called = true;
|
||||||
|
return OnePlate(job);
|
||||||
|
});
|
||||||
|
|
||||||
|
var error = Assert.Throws<ArgumentException>(() =>
|
||||||
|
NestPipeline.Run(engine, "Unchecked", Request("Unchecked", item)));
|
||||||
|
|
||||||
|
Assert.Contains("Geometry must contain finite motions", error.Message);
|
||||||
|
Assert.False(called);
|
||||||
|
Assert.Equal(1, item.Quantity);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
public class NestResultBinderTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ghost", 1, 1, 0)]
|
||||||
|
[InlineData(null, 1, 1, 0)]
|
||||||
|
[InlineData("p", double.NaN, 1, 0)]
|
||||||
|
[InlineData("p", 1, double.PositiveInfinity, 0)]
|
||||||
|
[InlineData("p", 1, 1, double.NaN)]
|
||||||
|
public void MalformedSheetCannotBePartiallyBound(string? partId, double x, double y, double rotation)
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("bracket", TestDrawingFactory.Rectangle());
|
||||||
|
var before = PartGeometrySnapshot.FromProgram(drawing.Program).Motions;
|
||||||
|
var sheet = new NestJobPlateResult(0, new NestPlateStock("sheet", new Size(48, 96)),
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new NestJobPlacement("p", 0, 1, 1, 0),
|
||||||
|
new NestJobPlacement(partId!, 1, x, y, rotation),
|
||||||
|
});
|
||||||
|
var drawings = new Dictionary<string, Drawing> { ["p"] = drawing };
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentException>(() => NestResultBinder.Bind(sheet, drawings));
|
||||||
|
|
||||||
|
Assert.Equal(before, PartGeometrySnapshot.FromProgram(drawing.Program).Motions);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ namespace OpenNest.Engine.Jobs.Adapters;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Binds one result sheet's poses to the caller's own drawings. The pose semantics match
|
/// Binds one result sheet's poses to the caller's own drawings. The pose semantics match
|
||||||
/// <see cref="NestResultMaterializer"/>: rotate about the snapshot origin, then translate.
|
/// <see cref="NestResultMaterializer"/>: rotate about the snapshot origin, then translate.
|
||||||
/// Placements whose requirement ID is not in the map are skipped here; the pipeline reports
|
/// Every pose must be finite and refer to a mapped drawing. Malformed sheets are rejected
|
||||||
/// them as violations instead of dropping them silently.
|
/// before binding any parts; the pipeline reports these violations without invoking binding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NestResultBinder
|
public static class NestResultBinder
|
||||||
{
|
{
|
||||||
@@ -19,12 +19,17 @@ public static class NestResultBinder
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sheet);
|
ArgumentNullException.ThrowIfNull(sheet);
|
||||||
ArgumentNullException.ThrowIfNull(drawingsByPartId);
|
ArgumentNullException.ThrowIfNull(drawingsByPartId);
|
||||||
|
foreach (var pose in sheet.Placements)
|
||||||
|
{
|
||||||
|
if (pose.PartId == null || !drawingsByPartId.TryGetValue(pose.PartId, out var drawing) || drawing == null)
|
||||||
|
throw new ArgumentException("Result contains a requirement not present in the drawing map.", nameof(sheet));
|
||||||
|
if (!double.IsFinite(pose.X) || !double.IsFinite(pose.Y) || !double.IsFinite(pose.Rotation))
|
||||||
|
throw new ArgumentException("Result contains a nonfinite placement pose.", nameof(sheet));
|
||||||
|
}
|
||||||
var parts = new List<Part>(sheet.Placements.Count);
|
var parts = new List<Part>(sheet.Placements.Count);
|
||||||
foreach (var pose in sheet.Placements)
|
foreach (var pose in sheet.Placements)
|
||||||
{
|
{
|
||||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
var part = new Part(drawingsByPartId[pose.PartId]);
|
||||||
continue;
|
|
||||||
var part = new Part(drawing);
|
|
||||||
part.Rotate(pose.Rotation);
|
part.Rotate(pose.Rotation);
|
||||||
part.Location = new Vector(pose.X, pose.Y);
|
part.Location = new Vector(pose.X, pose.Y);
|
||||||
part.UpdateBounds();
|
part.UpdateBounds();
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ namespace OpenNest.Engine.Jobs;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class NestLayoutCheck
|
public static class NestLayoutCheck
|
||||||
{
|
{
|
||||||
/// <summary>Checks bounds, spacing, quantities, offered stock and rotation policies.
|
/// <summary>Checks placement structure, bounds, spacing, quantities, offered stock,
|
||||||
|
/// MaxPlates and rotation policies. Unrepresentable placements skip geometry checks.
|
||||||
/// Requirement IDs are used in messages. Instance indices and fulfillment metadata are
|
/// Requirement IDs are used in messages. Instance indices and fulfillment metadata are
|
||||||
/// not checked, matching the benchmark contract.</summary>
|
/// not checked, matching the benchmark contract.</summary>
|
||||||
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
|
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
|
||||||
@@ -36,21 +37,61 @@ public static class NestLayoutCheck
|
|||||||
NestJob job,
|
NestJob job,
|
||||||
NestJobResult result,
|
NestJobResult result,
|
||||||
IReadOnlyDictionary<string, string> displayNames
|
IReadOnlyDictionary<string, string> displayNames
|
||||||
|
) => Violations(job, result, displayNames, out _);
|
||||||
|
|
||||||
|
internal static IReadOnlyList<string> Violations(
|
||||||
|
NestJob job,
|
||||||
|
NestJobResult result,
|
||||||
|
IReadOnlyDictionary<string, string> displayNames,
|
||||||
|
out bool canKeep
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(job);
|
ArgumentNullException.ThrowIfNull(job);
|
||||||
ArgumentNullException.ThrowIfNull(result);
|
ArgumentNullException.ThrowIfNull(result);
|
||||||
ArgumentNullException.ThrowIfNull(displayNames);
|
ArgumentNullException.ThrowIfNull(displayNames);
|
||||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
var violations = new List<string>();
|
||||||
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
ValidateStructure(job, result, displayNames, violations);
|
||||||
p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
|
canKeep = violations.Count == 0;
|
||||||
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
|
if (canKeep)
|
||||||
var violations = Validate(runs, requirements);
|
{
|
||||||
|
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||||
|
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
||||||
|
p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
|
||||||
|
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
|
||||||
|
violations.AddRange(Validate(runs, requirements));
|
||||||
|
}
|
||||||
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
|
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
|
||||||
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
|
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
|
||||||
return violations;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateStructure(
|
||||||
|
NestJob job,
|
||||||
|
NestJobResult result,
|
||||||
|
IReadOnlyDictionary<string, string> displayNames,
|
||||||
|
List<string> violations
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var partIds = job.Parts.Select(p => p.Id).ToHashSet(StringComparer.Ordinal);
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < sheet.Placements.Count; i++)
|
||||||
|
{
|
||||||
|
var pose = sheet.Placements[i];
|
||||||
|
var name = pose.PartId == null ? "<null>" : displayNames.GetValueOrDefault(pose.PartId, pose.PartId);
|
||||||
|
var description = $"Plate {sheet.PlateIndex} placement {i} for '{name}'";
|
||||||
|
if (pose.PartId == null || !partIds.Contains(pose.PartId))
|
||||||
|
violations.Add($"{description}, which is not part of this job");
|
||||||
|
if (!double.IsFinite(pose.X))
|
||||||
|
violations.Add($"{description} has nonfinite X ({pose.X})");
|
||||||
|
if (!double.IsFinite(pose.Y))
|
||||||
|
violations.Add($"{description} has nonfinite Y ({pose.Y})");
|
||||||
|
if (!double.IsFinite(pose.Rotation))
|
||||||
|
violations.Add($"{description} has nonfinite Rotation ({pose.Rotation})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Tests material clearance using the benchmark's conservative outlines.
|
/// <summary>Tests material clearance using the benchmark's conservative outlines.
|
||||||
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
|
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
|
||||||
/// argument order. Geometry is cloned before transformation.</summary>
|
/// argument order. Geometry is cloned before transformation.</summary>
|
||||||
@@ -121,7 +162,7 @@ public static class NestLayoutCheck
|
|||||||
/// Checks what the materialized layout cannot show: every sheet must be
|
/// Checks what the materialized layout cannot show: every sheet must be
|
||||||
/// one of the job's own stock entries (an engine may not invent a sheet
|
/// one of the job's own stock entries (an engine may not invent a sheet
|
||||||
/// size or loosen its spacing/edge settings, which the layout checks
|
/// size or loosen its spacing/edge settings, which the layout checks
|
||||||
/// would otherwise trust), finite stock may not be overdrawn, and every
|
/// would otherwise trust), finite stock and MaxPlates may not be overdrawn, and every
|
||||||
/// placement's rotation must satisfy its part's RotationPolicy.
|
/// placement's rotation must satisfy its part's RotationPolicy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static void ValidateAgainstJob(
|
internal static void ValidateAgainstJob(
|
||||||
@@ -135,6 +176,9 @@ public static class NestLayoutCheck
|
|||||||
var partsById = job.Parts.ToDictionary(p => p.Id);
|
var partsById = job.Parts.ToDictionary(p => p.Id);
|
||||||
var sheetsUsed = new Dictionary<string, int>();
|
var sheetsUsed = new Dictionary<string, int>();
|
||||||
|
|
||||||
|
if (job.Options.MaxPlates is int maxPlates && jobResult.Plates.Count > maxPlates)
|
||||||
|
result.Add($"Used {jobResult.Plates.Count} sheet(s) but the job MaxPlates limit is {maxPlates}");
|
||||||
|
|
||||||
foreach (var sheet in jobResult.Plates)
|
foreach (var sheet in jobResult.Plates)
|
||||||
{
|
{
|
||||||
if (
|
if (
|
||||||
@@ -167,8 +211,10 @@ public static class NestLayoutCheck
|
|||||||
{
|
{
|
||||||
foreach (var placement in sheet.Placements)
|
foreach (var placement in sheet.Placements)
|
||||||
{
|
{
|
||||||
if (!partsById.TryGetValue(placement.PartId, out var part))
|
if (placement.PartId == null
|
||||||
continue; // reported by ValidateQuantities
|
|| !partsById.TryGetValue(placement.PartId, out var part)
|
||||||
|
|| !double.IsFinite(placement.Rotation))
|
||||||
|
continue; // reported by ValidateStructure
|
||||||
|
|
||||||
if (!part.Rotation.Allows(placement.Rotation))
|
if (!part.Rotation.Allows(placement.Rotation))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public sealed class NestPipelineResult
|
|||||||
NestJobResult raw,
|
NestJobResult raw,
|
||||||
IReadOnlyList<ProposedPlate> plates,
|
IReadOnlyList<ProposedPlate> plates,
|
||||||
IReadOnlyList<string> violations,
|
IReadOnlyList<string> violations,
|
||||||
|
bool canKeep,
|
||||||
TimeSpan solveTime,
|
TimeSpan solveTime,
|
||||||
TimeSpan validationTime
|
TimeSpan validationTime
|
||||||
)
|
)
|
||||||
@@ -43,6 +44,7 @@ public sealed class NestPipelineResult
|
|||||||
Raw = raw;
|
Raw = raw;
|
||||||
Plates = plates;
|
Plates = plates;
|
||||||
Violations = violations;
|
Violations = violations;
|
||||||
|
CanKeep = canKeep;
|
||||||
SolveTime = solveTime;
|
SolveTime = solveTime;
|
||||||
ValidationTime = validationTime;
|
ValidationTime = validationTime;
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,9 @@ public sealed class NestPipelineResult
|
|||||||
public IReadOnlyList<ProposedPlate> Plates { get; }
|
public IReadOnlyList<ProposedPlate> Plates { get; }
|
||||||
public IReadOnlyList<string> Violations { get; }
|
public IReadOnlyList<string> Violations { get; }
|
||||||
public bool IsValid => Violations.Count == 0;
|
public bool IsValid => Violations.Count == 0;
|
||||||
|
/// <summary>True when every placement can be represented, even if layout rules fail.
|
||||||
|
/// False for unknown/null requirement IDs or nonfinite poses; Plates is then empty.</summary>
|
||||||
|
public bool CanKeep { get; }
|
||||||
public NestJobStatus Status => Raw.Status;
|
public NestJobStatus Status => Raw.Status;
|
||||||
public NestJobStopReason StopReason => Raw.StopReason;
|
public NestJobStopReason StopReason => Raw.StopReason;
|
||||||
public TimeSpan SolveTime { get; }
|
public TimeSpan SolveTime { get; }
|
||||||
@@ -109,6 +114,7 @@ public static class NestPipeline
|
|||||||
}
|
}
|
||||||
|
|
||||||
var job = new NestJob(parts, request.Stock, request.Options);
|
var job = new NestJob(parts, request.Stock, request.Options);
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
|
||||||
var clock = Stopwatch.StartNew();
|
var clock = Stopwatch.StartNew();
|
||||||
var raw =
|
var raw =
|
||||||
@@ -118,16 +124,22 @@ public static class NestPipeline
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
clock.Restart();
|
clock.Restart();
|
||||||
var violations = Validate(job, raw, drawingsByPartId);
|
var names = drawingsByPartId.ToDictionary(
|
||||||
|
kv => kv.Key,
|
||||||
|
kv => kv.Value.Name ?? kv.Key,
|
||||||
|
StringComparer.Ordinal
|
||||||
|
);
|
||||||
|
var violations = NestLayoutCheck.Violations(job, raw, names, out var canKeep);
|
||||||
var validationTime = clock.Elapsed;
|
var validationTime = clock.Elapsed;
|
||||||
|
|
||||||
var plates = raw
|
var plates = canKeep
|
||||||
.Plates.Select(sheet => new ProposedPlate(
|
? raw.Plates.Select(sheet => new ProposedPlate(
|
||||||
sheet.PlateIndex,
|
sheet.PlateIndex,
|
||||||
sheet.Stock,
|
sheet.Stock,
|
||||||
NestResultBinder.Bind(sheet, drawingsByPartId)
|
NestResultBinder.Bind(sheet, drawingsByPartId)
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList()
|
||||||
|
: new List<ProposedPlate>();
|
||||||
|
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
return new NestPipelineResult(
|
return new NestPipelineResult(
|
||||||
@@ -136,55 +148,9 @@ public static class NestPipeline
|
|||||||
raw,
|
raw,
|
||||||
plates,
|
plates,
|
||||||
violations,
|
violations,
|
||||||
|
canKeep,
|
||||||
solveTime,
|
solveTime,
|
||||||
validationTime
|
validationTime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Benchmark validation, with messages naming the caller's drawings rather than
|
|
||||||
/// internal requirement IDs. Placements for unknown requirements are reported and then
|
|
||||||
/// excluded so the remaining layout is still checked.</summary>
|
|
||||||
private static IReadOnlyList<string> Validate(
|
|
||||||
NestJob job,
|
|
||||||
NestJobResult raw,
|
|
||||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var violations = new List<string>();
|
|
||||||
var known = raw;
|
|
||||||
var unknown = raw
|
|
||||||
.Plates.SelectMany(sheet =>
|
|
||||||
sheet.Placements.Where(p => !drawingsByPartId.ContainsKey(p.PartId))
|
|
||||||
.Select(p => (sheet.PlateIndex, p.PartId))
|
|
||||||
)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (unknown.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (var group in unknown.GroupBy(u => (u.PlateIndex, u.PartId)))
|
|
||||||
violations.Add(
|
|
||||||
$"Plate {group.Key.PlateIndex} has {group.Count()} placement(s) for '{group.Key.PartId}', which is not part of this job"
|
|
||||||
);
|
|
||||||
|
|
||||||
known = new NestJobResult(
|
|
||||||
raw.Status,
|
|
||||||
raw.StopReason,
|
|
||||||
raw.Plates.Select(sheet => new NestJobPlateResult(
|
|
||||||
sheet.PlateIndex,
|
|
||||||
sheet.Stock,
|
|
||||||
sheet.Placements.Where(p => drawingsByPartId.ContainsKey(p.PartId))
|
|
||||||
)),
|
|
||||||
raw.Fulfillment,
|
|
||||||
raw.StockUsage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var names = drawingsByPartId.ToDictionary(
|
|
||||||
kv => kv.Key,
|
|
||||||
kv => kv.Value.Name ?? kv.Key,
|
|
||||||
StringComparer.Ordinal
|
|
||||||
);
|
|
||||||
violations.AddRange(NestLayoutCheck.Violations(job, known, names));
|
|
||||||
return violations;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user