fix(engine): reject unrepresentable nesting output before geometry checks
This commit is contained in:
@@ -7,8 +7,8 @@ namespace OpenNest.Engine.Jobs.Adapters;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Placements whose requirement ID is not in the map are skipped here; the pipeline reports
|
||||
/// them as violations instead of dropping them silently.
|
||||
/// Every pose must be finite and refer to a mapped drawing. Malformed sheets are rejected
|
||||
/// before binding any parts; the pipeline reports these violations without invoking binding.
|
||||
/// </summary>
|
||||
public static class NestResultBinder
|
||||
{
|
||||
@@ -19,12 +19,17 @@ public static class NestResultBinder
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sheet);
|
||||
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);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
var part = new Part(drawingsByPartId[pose.PartId]);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
|
||||
@@ -21,7 +21,8 @@ namespace OpenNest.Engine.Jobs;
|
||||
/// </summary>
|
||||
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
|
||||
/// not checked, matching the benchmark contract.</summary>
|
||||
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
|
||||
@@ -36,21 +37,61 @@ public static class NestLayoutCheck
|
||||
NestJob job,
|
||||
NestJobResult result,
|
||||
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(result);
|
||||
ArgumentNullException.ThrowIfNull(displayNames);
|
||||
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();
|
||||
var violations = Validate(runs, requirements);
|
||||
var violations = new List<string>();
|
||||
ValidateStructure(job, result, displayNames, violations);
|
||||
canKeep = violations.Count == 0;
|
||||
if (canKeep)
|
||||
{
|
||||
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,
|
||||
p => displayNames.GetValueOrDefault(p.Id, p.Id)), 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.
|
||||
/// The leftmost raw outline is inflated, matching the full-layout sweep; ties retain
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static void ValidateAgainstJob(
|
||||
@@ -135,6 +176,9 @@ public static class NestLayoutCheck
|
||||
var partsById = job.Parts.ToDictionary(p => p.Id);
|
||||
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)
|
||||
{
|
||||
if (
|
||||
@@ -167,8 +211,10 @@ public static class NestLayoutCheck
|
||||
{
|
||||
foreach (var placement in sheet.Placements)
|
||||
{
|
||||
if (!partsById.TryGetValue(placement.PartId, out var part))
|
||||
continue; // reported by ValidateQuantities
|
||||
if (placement.PartId == null
|
||||
|| !partsById.TryGetValue(placement.PartId, out var part)
|
||||
|| !double.IsFinite(placement.Rotation))
|
||||
continue; // reported by ValidateStructure
|
||||
|
||||
if (!part.Rotation.Allows(placement.Rotation))
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class NestPipelineResult
|
||||
NestJobResult raw,
|
||||
IReadOnlyList<ProposedPlate> plates,
|
||||
IReadOnlyList<string> violations,
|
||||
bool canKeep,
|
||||
TimeSpan solveTime,
|
||||
TimeSpan validationTime
|
||||
)
|
||||
@@ -43,6 +44,7 @@ public sealed class NestPipelineResult
|
||||
Raw = raw;
|
||||
Plates = plates;
|
||||
Violations = violations;
|
||||
CanKeep = canKeep;
|
||||
SolveTime = solveTime;
|
||||
ValidationTime = validationTime;
|
||||
}
|
||||
@@ -53,6 +55,9 @@ public sealed class NestPipelineResult
|
||||
public IReadOnlyList<ProposedPlate> Plates { get; }
|
||||
public IReadOnlyList<string> Violations { get; }
|
||||
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 NestJobStopReason StopReason => Raw.StopReason;
|
||||
public TimeSpan SolveTime { get; }
|
||||
@@ -109,6 +114,7 @@ public static class NestPipeline
|
||||
}
|
||||
|
||||
var job = new NestJob(parts, request.Stock, request.Options);
|
||||
NestJobValidator.Validate(job);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var raw =
|
||||
@@ -118,16 +124,22 @@ public static class NestPipeline
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
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 plates = raw
|
||||
.Plates.Select(sheet => new ProposedPlate(
|
||||
var plates = canKeep
|
||||
? raw.Plates.Select(sheet => new ProposedPlate(
|
||||
sheet.PlateIndex,
|
||||
sheet.Stock,
|
||||
NestResultBinder.Bind(sheet, drawingsByPartId)
|
||||
))
|
||||
.ToList();
|
||||
.ToList()
|
||||
: new List<ProposedPlate>();
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new NestPipelineResult(
|
||||
@@ -136,55 +148,9 @@ public static class NestPipeline
|
||||
raw,
|
||||
plates,
|
||||
violations,
|
||||
canKeep,
|
||||
solveTime,
|
||||
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