mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 12:31:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d745d20ba | ||
|
|
74f32f0516 | ||
|
|
6424512e8c | ||
|
|
27f8c9799b | ||
|
|
775ece5d21 | ||
|
|
cad1083287 | ||
|
|
4183af4142 | ||
|
|
73be301ebf | ||
|
|
fdc6f074ee | ||
|
|
dc4b07ddf5 | ||
|
|
6c76617905 | ||
|
|
43e08965b9 | ||
|
|
ddd1e422be | ||
|
|
b9a387b517 | ||
|
|
9f2ece37ed | ||
|
|
81cccfc610 | ||
|
|
c107ab13f7 | ||
|
|
57450ecea8 | ||
|
|
b245290664 | ||
|
|
6d5d963654 | ||
|
|
178954e2df | ||
|
|
f63fee7392 | ||
|
|
5857e7fc69 | ||
|
|
15055aef4d | ||
|
|
c57e0ded83 | ||
|
|
f2cc471a33 | ||
|
|
07cba5719f | ||
|
|
9df9a802cd | ||
|
|
1bcead4b3a | ||
|
|
d9273c889f | ||
|
|
4226c0f902 | ||
|
|
6908e00969 | ||
|
|
e38980e54b | ||
|
|
a1914e147a | ||
|
|
64ebda2f6f | ||
|
|
360f58c101 | ||
|
|
6fc1a0ebdc | ||
|
|
6b995ac40a | ||
|
|
7ed40d4bb8 | ||
|
|
04d538b0a4 | ||
|
|
65754957cc | ||
|
|
53db543d6b | ||
|
|
2b208c9b6a | ||
|
|
b46310e01b | ||
|
|
f19a825c4e | ||
|
|
af50da7609 | ||
|
|
3bf63b74f9 | ||
|
|
d24e660286 | ||
|
|
9b5885e5a2 | ||
|
|
8f0c0692fd | ||
|
|
3f0a2703fd | ||
|
|
83ae1a48f0 | ||
|
|
92d8964339 | ||
|
|
df2f1b796d | ||
|
|
3e9e336072 | ||
|
|
c18db3f896 | ||
|
|
9a293bf221 | ||
|
|
bde332fb10 | ||
|
|
d7187f65c7 | ||
|
|
3014ab3480 | ||
|
|
104b0aef99 | ||
|
|
74544c689a | ||
|
|
3e58a9e93f | ||
|
|
87849e5326 | ||
|
|
83c77269a1 | ||
|
|
ec058d270c | ||
|
|
bafb311735 | ||
|
|
37700b3ef9 | ||
|
|
5125a5f2f3 | ||
|
|
fe7083f4c4 | ||
|
|
1e37c661ff | ||
|
|
76b49677c4 | ||
|
|
aea51b5e1d | ||
|
|
5c9c7df038 | ||
|
|
7069f155f5 | ||
|
|
99f64840b2 | ||
|
|
5674764447 | ||
|
|
81e3e58f13 | ||
|
|
cc85a9e068 | ||
|
|
040776807a | ||
|
|
fbca54637b | ||
|
|
c374e02614 | ||
|
|
de9a087027 | ||
|
|
dc87911b2f | ||
|
|
d001abcf69 | ||
|
|
c16092c293 | ||
|
|
c04562f56d | ||
|
|
b66af8658b | ||
|
|
311178c15a | ||
|
|
b36db41769 | ||
|
|
e5c82e2c92 | ||
|
|
522de2761c | ||
|
|
49017c4a91 | ||
|
|
c1d5a827bc | ||
|
|
937448242f | ||
|
|
f74da248b3 | ||
|
|
c67577d463 | ||
|
|
565dc90762 | ||
|
|
18d63ffd61 | ||
|
|
e3bcc77145 | ||
|
|
df94824054 | ||
|
|
9bd5d0dfa7 | ||
|
|
d6ae706098 | ||
|
|
f9b2e1282a | ||
|
|
943b63937d | ||
|
|
6ed7ab7392 | ||
|
|
4738a0390e | ||
|
|
322dc3eda6 | ||
|
|
ddc4710dfd | ||
|
|
97a468ee2c | ||
|
|
09dd19ca66 | ||
|
|
fae37dd1d0 | ||
|
|
94ed8a0f8f | ||
|
|
028832debc | ||
|
|
51ad700bec | ||
|
|
0216a9e9e2 | ||
|
|
a7c8e8541e | ||
|
|
5f1eeb00eb | ||
|
|
914c197181 | ||
|
|
5598804942 | ||
|
|
d216bcd406 | ||
|
|
11471e3c71 | ||
|
|
ed7600dc90 | ||
|
|
a47b414392 | ||
|
|
3d512c794c | ||
|
|
08acb092a7 | ||
|
|
dc142cc88d | ||
|
|
6799e08356 | ||
|
|
ca6966906f | ||
|
|
c28ccd7568 | ||
|
|
90ae3b6a84 | ||
|
|
72776886cb | ||
|
|
d59c03f5ab | ||
|
|
bb108e9341 | ||
|
|
80c647bf07 | ||
|
|
24be90ace9 | ||
|
|
4feada094c | ||
|
|
4e846007a2 | ||
|
|
8ebb48694f | ||
|
|
afa50c5a12 | ||
|
|
ef6ca80e6d | ||
|
|
0c99455489 | ||
|
|
c34ac2bc0e | ||
|
|
076b794dda | ||
|
|
5dbf887b68 | ||
|
|
17e504fb70 | ||
|
|
69304d3851 | ||
|
|
1c6d02c480 | ||
|
|
d328c5f691 | ||
|
|
e897258a85 | ||
|
|
1b9d8a36d8 | ||
|
|
b750475228 | ||
|
|
138c86526a | ||
|
|
07e682a654 | ||
|
|
0b29c683b9 | ||
|
|
71bd964344 | ||
|
|
1d50c6ce60 | ||
|
|
962ae8c825 | ||
|
|
d3e325f36c | ||
|
|
575412a464 | ||
|
|
60fb009329 | ||
|
|
41737a4f4c | ||
|
|
f1d1df3f17 | ||
|
|
81404fc673 | ||
|
|
a5b6c8a00e | ||
|
|
2a6e5504cd | ||
|
|
3d5c73a353 | ||
|
|
8b94e49711 | ||
|
|
2623517219 |
No files matched your search
@@ -62,3 +62,4 @@ Read the relevant contract before changing its behavior:
|
|||||||
- [Pre-post verification](docs/post-verification.md)
|
- [Pre-post verification](docs/post-verification.md)
|
||||||
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
|
||||||
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
|
||||||
|
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
|
||||||
@@ -5,6 +5,9 @@ namespace OpenNest.Api;
|
|||||||
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
/// <summary>One explicit physical-stock type for a whole nesting job.</summary>
|
||||||
public class NestRequestPlate
|
public class NestRequestPlate
|
||||||
{
|
{
|
||||||
|
/// <summary>Positive per-sheet cost in common units; omit on every row for area scoring.</summary>
|
||||||
|
public double? Cost { get; init; }
|
||||||
|
|
||||||
public string Id { get; init; }
|
public string Id { get; init; }
|
||||||
public Size Size { get; init; }
|
public Size Size { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public sealed record NestPartFulfillment(string PartId, int Requested, int Place
|
|||||||
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
/// <summary>Physical-sheet usage for one stock identity.</summary>
|
||||||
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
||||||
|
|
||||||
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
/// <summary>Maps each materialized layout (Nest.Plates index) to its stock identity.
|
||||||
|
/// Its Plate.Quantity gives the physical copies; schema 3 and older used one sheet per entry.</summary>
|
||||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||||
|
|
||||||
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||||
@@ -24,10 +25,11 @@ public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
|||||||
|
|
||||||
public class NestResponse
|
public class NestResponse
|
||||||
{
|
{
|
||||||
public const int CurrentSchemaVersion = 3;
|
public const int CurrentSchemaVersion = 4;
|
||||||
|
|
||||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||||
|
/// <summary>Physical sheets: sum of Plate.Quantity, not materialized layout entries.</summary>
|
||||||
public int SheetCount { get; init; }
|
public int SheetCount { get; init; }
|
||||||
|
|
||||||
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
/// <summary>Placed-part area divided by total materialized physical-sheet area, as a 0.0–1.0 ratio.</summary>
|
||||||
@@ -44,6 +46,7 @@ public class NestResponse
|
|||||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||||
|
public NestCostSummary Costs { get; init; }
|
||||||
public Nest Nest { get; init; }
|
public Nest Nest { get; init; }
|
||||||
public NestRequest Request { get; init; }
|
public NestRequest Request { get; init; }
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ public class NestResponse
|
|||||||
Fulfillment = Fulfillment is null
|
Fulfillment = Fulfillment is null
|
||||||
? []
|
? []
|
||||||
: new List<NestPartFulfillment>(Fulfillment),
|
: new List<NestPartFulfillment>(Fulfillment),
|
||||||
|
Costs = Costs,
|
||||||
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
StockUsage = StockUsage is null ? [] : new List<NestStockUsage>(StockUsage),
|
||||||
PlateStockMappings = PlateStockMappings is null
|
PlateStockMappings = PlateStockMappings is null
|
||||||
? []
|
? []
|
||||||
@@ -171,6 +175,7 @@ public class NestResponse
|
|||||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||||
|
Costs = archive.Costs,
|
||||||
Nest = nest,
|
Nest = nest,
|
||||||
Request = request,
|
Request = request,
|
||||||
};
|
};
|
||||||
@@ -178,6 +183,7 @@ public class NestResponse
|
|||||||
|
|
||||||
private sealed class NestResponseArchiveDto
|
private sealed class NestResponseArchiveDto
|
||||||
{
|
{
|
||||||
|
public NestCostSummary Costs { get; init; }
|
||||||
public int SchemaVersion { get; init; }
|
public int SchemaVersion { get; init; }
|
||||||
public int SheetCount { get; init; }
|
public int SheetCount { get; init; }
|
||||||
public double Utilization { get; init; }
|
public double Utilization { get; init; }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public static class NestRunner
|
|||||||
if (requestParts.Count == 0)
|
if (requestParts.Count == 0)
|
||||||
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
||||||
|
|
||||||
|
var stock = CreateStock(request);
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
var parts = IdentifyParts(requestParts);
|
var parts = IdentifyParts(requestParts);
|
||||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||||
@@ -82,7 +83,6 @@ public static class NestRunner
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var stock = CreateStock(request);
|
|
||||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||||
@@ -91,6 +91,9 @@ public static class NestRunner
|
|||||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||||
|
if (request.Plates != null)
|
||||||
|
nest.PlateOptions = stock.Where(s => s.Quantity != 0).Select(s => new PlateOption
|
||||||
|
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
nest.Drawings.Add(item.Drawing);
|
nest.Drawings.Add(item.Drawing);
|
||||||
foreach (var proposed in result.Plates)
|
foreach (var proposed in result.Plates)
|
||||||
@@ -100,7 +103,7 @@ public static class NestRunner
|
|||||||
PartSpacing = proposed.Stock.PartSpacing,
|
PartSpacing = proposed.Stock.PartSpacing,
|
||||||
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||||
Quadrant = proposed.Stock.Quadrant,
|
Quadrant = proposed.Stock.Quadrant,
|
||||||
Quantity = 1,
|
Quantity = proposed.Quantity,
|
||||||
};
|
};
|
||||||
plate.Parts.AddRange(proposed.Parts);
|
plate.Parts.AddRange(proposed.Parts);
|
||||||
nest.Plates.Add(plate);
|
nest.Plates.Add(plate);
|
||||||
@@ -108,8 +111,8 @@ public static class NestRunner
|
|||||||
|
|
||||||
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
||||||
// returned, bound placements and the original request, never plug-in summaries.
|
// returned, bound placements and the original request, never plug-in summaries.
|
||||||
var counts = result.Plates.SelectMany(p => p.Parts)
|
var counts = result.Plates.SelectMany(p => p.Parts.Select(part => (part.BaseDrawing, p.Quantity)))
|
||||||
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
|
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity));
|
||||||
var fulfillment = parts.Select((part, i) =>
|
var fulfillment = parts.Select((part, i) =>
|
||||||
{
|
{
|
||||||
var placed = counts.GetValueOrDefault(items[i].Drawing);
|
var placed = counts.GetValueOrDefault(items[i].Drawing);
|
||||||
@@ -118,7 +121,7 @@ public static class NestRunner
|
|||||||
}).ToArray();
|
}).ToArray();
|
||||||
var usage = stock.Select(s =>
|
var usage = stock.Select(s =>
|
||||||
{
|
{
|
||||||
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
var used = result.Plates.Where(p => p.Stock.Id == s.Id).Sum(p => p.Quantity);
|
||||||
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
|
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
var complete = fulfillment.All(f => f.Placed == f.Requested);
|
var complete = fulfillment.All(f => f.Placed == f.Requested);
|
||||||
@@ -133,7 +136,7 @@ public static class NestRunner
|
|||||||
return Task.FromResult(
|
return Task.FromResult(
|
||||||
new NestResponse
|
new NestResponse
|
||||||
{
|
{
|
||||||
SheetCount = nest.Plates.Count,
|
SheetCount = nest.Plates.Sum(p => p.Quantity),
|
||||||
Utilization = CalculateUtilization(nest),
|
Utilization = CalculateUtilization(nest),
|
||||||
CutTime = cutTime,
|
CutTime = cutTime,
|
||||||
Elapsed = sw.Elapsed,
|
Elapsed = sw.Elapsed,
|
||||||
@@ -144,6 +147,7 @@ public static class NestRunner
|
|||||||
Violations = result.Violations,
|
Violations = result.Violations,
|
||||||
Fulfillment = fulfillment,
|
Fulfillment = fulfillment,
|
||||||
StockUsage = usage,
|
StockUsage = usage,
|
||||||
|
Costs = result.CanKeep && result.IsValid ? NestCostSummary.FromAccepted(result) : null,
|
||||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||||
Nest = nest,
|
Nest = nest,
|
||||||
@@ -209,11 +213,13 @@ public static class NestRunner
|
|||||||
plate.Quantity,
|
plate.Quantity,
|
||||||
plate.PartSpacing,
|
plate.PartSpacing,
|
||||||
plate.EdgeSpacing,
|
plate.EdgeSpacing,
|
||||||
plate.Quadrant
|
plate.Quadrant,
|
||||||
|
plate.Cost
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NestJobValidator.ValidateStockCosts(stock);
|
||||||
return stock;
|
return stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,11 +248,11 @@ public static class NestRunner
|
|||||||
|
|
||||||
private static double CalculateUtilization(Nest nest)
|
private static double CalculateUtilization(Nest nest)
|
||||||
{
|
{
|
||||||
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
var sheetArea = nest.Plates.Sum(plate => plate.Area() * plate.Quantity);
|
||||||
if (sheetArea == 0)
|
if (sheetArea == 0)
|
||||||
return 0;
|
return 0;
|
||||||
var placedArea = nest.Plates.Sum(plate =>
|
var placedArea = nest.Plates.Sum(plate =>
|
||||||
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) * plate.Quantity
|
||||||
);
|
);
|
||||||
return placedArea / sheetArea;
|
return placedArea / sheetArea;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,9 +514,9 @@ static class NestConsole
|
|||||||
foreach (var violation in result.Violations)
|
foreach (var violation in result.Violations)
|
||||||
Console.Error.WriteLine($"Violation: {violation}");
|
Console.Error.WriteLine($"Violation: {violation}");
|
||||||
|
|
||||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
if (!result.CanKeep || result.Raw.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(result.Plates.Count > 1
|
Console.Error.WriteLine(result.Raw.Plates.Count > 1
|
||||||
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
||||||
: !result.CanKeep
|
: !result.CanKeep
|
||||||
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
|
||||||
|
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
|
||||||
|
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
|
||||||
|
/// <param name="CutOffs">Cut-off definitions.</param>
|
||||||
|
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
|
||||||
|
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
|
||||||
|
IReadOnlyList<int> PlateNumbers)
|
||||||
|
{
|
||||||
|
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
|
||||||
|
|
||||||
|
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
|
||||||
|
public string Describe()
|
||||||
|
{
|
||||||
|
if (!HasExistingWork)
|
||||||
|
return string.Empty;
|
||||||
|
var found = new List<string>();
|
||||||
|
if (LeadInParts > 0)
|
||||||
|
found.Add($"lead-ins on {Count(LeadInParts, "part")}"
|
||||||
|
+ (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty));
|
||||||
|
if (CutOffs > 0)
|
||||||
|
found.Add(Count(CutOffs, "cut-off"));
|
||||||
|
var plates = PlateNumbers.Count == 1 ? "plate " : "plates ";
|
||||||
|
var kept = new List<string>();
|
||||||
|
if (LockedLeadInParts > 0)
|
||||||
|
kept.Add("locked lead-ins");
|
||||||
|
if (CutOffs > 0)
|
||||||
|
kept.Add("existing cut-offs");
|
||||||
|
return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}."
|
||||||
|
+ (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty)
|
||||||
|
+ (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty)
|
||||||
|
+ " Clear them to recalculate everything from scratch.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
|
||||||
|
public static class AppliedCuttingWork
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
|
||||||
|
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
|
||||||
|
/// </summary>
|
||||||
|
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
|
||||||
|
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> ignoredCutOffs = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plates);
|
||||||
|
if (plateNumbers != null && plateNumbers.Count != plates.Count)
|
||||||
|
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
|
||||||
|
var ignored = new HashSet<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
|
||||||
|
int leads = 0, locked = 0, cutOffs = 0;
|
||||||
|
var numbers = new SortedSet<int>();
|
||||||
|
for (var index = 0; index < plates.Count; index++)
|
||||||
|
{
|
||||||
|
var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates));
|
||||||
|
var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray();
|
||||||
|
var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c));
|
||||||
|
leads += plateLeads.Length;
|
||||||
|
locked += plateLeads.Count(p => p.LeadInsLocked);
|
||||||
|
cutOffs += plateCutOffs;
|
||||||
|
if (plateLeads.Length > 0 || plateCutOffs > 0)
|
||||||
|
numbers.Add(plateNumbers?[index] ?? index + 1);
|
||||||
|
}
|
||||||
|
return new(leads, locked, cutOffs, numbers.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes every applied lead-in (locked ones too) from real parts and every cut-off with
|
||||||
|
/// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses,
|
||||||
|
/// order and quantities are unchanged. Returns what was cleared.
|
||||||
|
/// </summary>
|
||||||
|
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> plateNumbers = null)
|
||||||
|
{
|
||||||
|
var cleared = Summarize(plates, plateNumbers);
|
||||||
|
foreach (var plate in plates)
|
||||||
|
{
|
||||||
|
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray())
|
||||||
|
part.RemoveLeadIns();
|
||||||
|
plate.CutOffs.Clear();
|
||||||
|
for (var index = plate.Parts.Count - 1; index >= 0; index--)
|
||||||
|
if (plate.Parts[index].BaseDrawing.IsCutOff)
|
||||||
|
plate.Parts.RemoveAt(index);
|
||||||
|
plate.CuttingParameters = null;
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
|
|||||||
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
|
||||||
|
AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies like <see cref="Apply"/>, but keeps the recovery state so the caller can take the
|
||||||
|
/// new cut-offs back off again (<see cref="AutomaticCutOffStaging.Revert"/>) when the work they
|
||||||
|
/// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
|
||||||
|
/// </summary>
|
||||||
|
public static AutomaticCutOffStaging Stage(IReadOnlyList<Plate> plates,
|
||||||
AutomaticCutOffOptions options, CutOffSettings settings)
|
AutomaticCutOffOptions options, CutOffSettings settings)
|
||||||
{
|
{
|
||||||
var plans = Create(plates, options, settings);
|
var plans = Create(plates, options, settings);
|
||||||
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
if (plans.Any(plan => plan.HasBlockingDiagnostics))
|
||||||
return plans;
|
return new AutomaticCutOffStaging(plans, []);
|
||||||
|
|
||||||
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
var restoreActions = new List<(int PlateNumber, Action Restore)>();
|
||||||
try
|
try
|
||||||
@@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch
|
|||||||
}
|
}
|
||||||
catch (Exception applyError)
|
catch (Exception applyError)
|
||||||
{
|
{
|
||||||
var rollbackErrors = new List<Exception>();
|
var rollbackErrors = RestoreAll(restoreActions);
|
||||||
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
|
||||||
{
|
|
||||||
var saved = restoreActions[index];
|
|
||||||
try
|
|
||||||
{
|
|
||||||
saved.Restore();
|
|
||||||
}
|
|
||||||
catch (Exception rollbackError)
|
|
||||||
{
|
|
||||||
// A broken observer on one plate must not prevent recovery of the others.
|
|
||||||
rollbackErrors.Add(new InvalidOperationException(
|
|
||||||
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (rollbackErrors.Count > 0)
|
if (rollbackErrors.Count > 0)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
$"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: "
|
||||||
@@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
$"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError);
|
||||||
}
|
}
|
||||||
return plans;
|
return new AutomaticCutOffStaging(plans, restoreActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<Exception> RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
|
||||||
|
{
|
||||||
|
var errors = new List<Exception>();
|
||||||
|
for (var index = restoreActions.Count - 1; index >= 0; index--)
|
||||||
|
{
|
||||||
|
var saved = restoreActions[index];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
saved.Restore();
|
||||||
|
}
|
||||||
|
catch (Exception rollbackError)
|
||||||
|
{
|
||||||
|
// A broken observer on one plate must not prevent recovery of the others.
|
||||||
|
errors.Add(new InvalidOperationException(
|
||||||
|
$"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan)
|
||||||
@@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cut-offs added by <see cref="AutomaticCutOffBatch.Stage"/> that are still provisional: either
|
||||||
|
/// <see cref="Keep"/> them once the work they belong to is committed, or <see cref="Revert"/> them.
|
||||||
|
/// Owner-thread only, like the plates it changed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AutomaticCutOffStaging
|
||||||
|
{
|
||||||
|
private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
|
||||||
|
|
||||||
|
internal AutomaticCutOffStaging(IReadOnlyList<AutomaticCutOffPlan> plans,
|
||||||
|
IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
|
||||||
|
{
|
||||||
|
Plans = plans;
|
||||||
|
this.restoreActions = restoreActions;
|
||||||
|
Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The fresh plans, one per plate in the order given.</summary>
|
||||||
|
public IReadOnlyList<AutomaticCutOffPlan> Plans { get; }
|
||||||
|
|
||||||
|
/// <summary>True when any plate blocked the batch; then nothing was added.</summary>
|
||||||
|
public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
|
||||||
|
|
||||||
|
/// <summary>The definitions this staging added (empty when blocked or nothing was needed).</summary>
|
||||||
|
public IReadOnlyList<CutOff> Definitions { get; }
|
||||||
|
|
||||||
|
/// <summary>True while the added cut-offs can still be reverted.</summary>
|
||||||
|
public bool CanRevert => restoreActions.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Makes the added cut-offs permanent; a later <see cref="Revert"/> does nothing.</summary>
|
||||||
|
public void Keep() => restoreActions = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the added cut-offs and restores each touched plate's previous cut-off programs and
|
||||||
|
/// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored.
|
||||||
|
/// </summary>
|
||||||
|
public void Revert()
|
||||||
|
{
|
||||||
|
var actions = restoreActions;
|
||||||
|
restoreActions = [];
|
||||||
|
var errors = AutomaticCutOffBatch.RestoreAll(actions);
|
||||||
|
if (errors.Count > 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message))
|
||||||
|
+ "\nThe nest may be incomplete; review it before saving or cutting.",
|
||||||
|
new AggregateException(errors));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
|
|||||||
/// The desktop default is 12 inches (304.8 mm).
|
/// The desktop default is 12 inches (304.8 mm).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double MinimumTailLength { get; set; }
|
public double MinimumTailLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
|
||||||
|
/// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
|
||||||
|
/// </summary>
|
||||||
|
public double? MinimumStripLength { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AutomaticCutOffDiagnosticCode
|
public enum AutomaticCutOffDiagnosticCode
|
||||||
@@ -29,8 +35,10 @@ public enum AutomaticCutOffDiagnosticCode
|
|||||||
LimitedCutOffConflict,
|
LimitedCutOffConflict,
|
||||||
EmptyCut,
|
EmptyCut,
|
||||||
SegmentedCut,
|
SegmentedCut,
|
||||||
|
ClampBlockedCut,
|
||||||
NoSafeTailSeparator,
|
NoSafeTailSeparator,
|
||||||
TailBelowMinimum,
|
TailBelowMinimum,
|
||||||
|
ShiftedCut,
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AutomaticCutOffDiagnostic(
|
public sealed record AutomaticCutOffDiagnostic(
|
||||||
@@ -69,6 +77,12 @@ public sealed class AutomaticCutOffPlan
|
|||||||
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
|
||||||
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
public bool HasSeparatedTail => TailSeparatorX.HasValue;
|
||||||
|
|
||||||
|
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
|
||||||
|
public double StripCutLength { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Length of the nominal new strip lines in the same gaps. Equal to StripCutLength on the fixed-grid path.</summary>
|
||||||
|
public double FixedGridCutLength { get; internal set; }
|
||||||
|
|
||||||
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +92,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
public const double GeometryTolerance = Tolerance.Epsilon;
|
public const double GeometryTolerance = Tolerance.Epsilon;
|
||||||
public const double MinimumSpacing = 2 * GeometryTolerance;
|
public const double MinimumSpacing = 2 * GeometryTolerance;
|
||||||
public const int MaximumCandidateCount = 10000;
|
public const int MaximumCandidateCount = 10000;
|
||||||
|
public const int MaximumShiftCandidateCount = 20000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
|
||||||
@@ -93,6 +108,8 @@ public static class AutomaticCutOffPlanner
|
|||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
ArgumentNullException.ThrowIfNull(settings);
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
ValidateInputs(plate, options, settings);
|
ValidateInputs(plate, options, settings);
|
||||||
|
// Stored zones use the sheet's lower-left frame; cut positions are signed.
|
||||||
|
var clamps = ClampZoneFrame.ToPlacement(plate);
|
||||||
var bounds = plate.BoundingBox(false);
|
var bounds = plate.BoundingBox(false);
|
||||||
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
Require(ValidBox(bounds) && double.IsFinite(bounds.Top + settings.Overtravel),
|
||||||
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
"Physical sheet bounds and overtravel must be finite.", nameof(plate));
|
||||||
@@ -130,14 +147,57 @@ public static class AutomaticCutOffPlanner
|
|||||||
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
var diagnostics = new List<AutomaticCutOffDiagnostic>();
|
||||||
var separated = false;
|
var separated = false;
|
||||||
var separatorX = (double?)null;
|
var separatorX = (double?)null;
|
||||||
|
var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing;
|
||||||
|
var stripCutLength = 0.0;
|
||||||
|
var fixedGridCutLength = 0.0;
|
||||||
|
var measurements = new Dictionary<double, (double Length, int SegmentCount)>();
|
||||||
|
var candidates = new HashSet<double>();
|
||||||
|
|
||||||
// Multiplication by an integer avoids drift from repeated floating-point addition.
|
if (shifted)
|
||||||
for (var index = 1; index <= (int)candidateCount; index++)
|
|
||||||
{
|
{
|
||||||
var distance = index * options.Spacing;
|
// Full-span definitions fix the strip boundaries, even when their emitted
|
||||||
if (distance >= usedSpan - GeometryTolerance)
|
// programs are segmented. Partial cuts remain obstacles, not boundaries.
|
||||||
break;
|
var boundaries = plate.CutOffs
|
||||||
AddCandidate(sign * distance, false);
|
.Where(c => c.Axis == CutOffAxis.Vertical && FullSpanLimits(c, bounds, settings))
|
||||||
|
.Select(c => sign * c.Position.X)
|
||||||
|
.Where(distance => distance > GeometryTolerance && distance < usedSpan - GeometryTolerance)
|
||||||
|
.Distinct().OrderBy(distance => distance).Append(usedSpan);
|
||||||
|
var gaps = new List<(double Start, double End, int Count)>();
|
||||||
|
var start = 0.0;
|
||||||
|
var nominalCount = 0;
|
||||||
|
foreach (var end in boundaries)
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
for (var index = 1; index <= (int)candidateCount; index++)
|
||||||
|
{
|
||||||
|
if (start + index * options.Spacing >= end - GeometryTolerance)
|
||||||
|
break;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
gaps.Add((start, end, count));
|
||||||
|
nominalCount += count;
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
// The origin-based lattice alone fits within 20,000 positions, but its
|
||||||
|
// off-lattice nominal lines need reserved slots too. Budget both before
|
||||||
|
// constructing any row so a valid, dense grid is never refused halfway.
|
||||||
|
Require(nominalCount < MaximumShiftCandidateCount,
|
||||||
|
"Too many nominal strip lines for the shift search.", nameof(options));
|
||||||
|
var step = System.Math.Max(options.Spacing / 700,
|
||||||
|
usedSpan / (MaximumShiftCandidateCount - nominalCount));
|
||||||
|
foreach (var gap in gaps)
|
||||||
|
AddShiftedGap(gap.Start, gap.End, gap.Count, step);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Keep the legacy loop and certification unchanged when shifting is disabled.
|
||||||
|
for (var index = 1; index <= (int)candidateCount; index++)
|
||||||
|
{
|
||||||
|
var distance = index * options.Spacing;
|
||||||
|
if (distance >= usedSpan - GeometryTolerance)
|
||||||
|
break;
|
||||||
|
AddCandidate(sign * distance, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (hasTailCandidate)
|
if (hasTailCandidate)
|
||||||
{
|
{
|
||||||
@@ -155,9 +215,14 @@ public static class AutomaticCutOffPlanner
|
|||||||
{
|
{
|
||||||
// A partial proposal must not accidentally be accepted around a manual conflict.
|
// A partial proposal must not accidentally be accepted around a manual conflict.
|
||||||
definitions.Clear();
|
definitions.Clear();
|
||||||
|
stripCutLength = 0;
|
||||||
separated = false;
|
separated = false;
|
||||||
separatorX = null;
|
separatorX = null;
|
||||||
}
|
}
|
||||||
|
// The legacy placement loop stays untouched. Its detached programs are already
|
||||||
|
// regenerated, so reporting their actual new strip cutting needs no new search.
|
||||||
|
if (!shifted)
|
||||||
|
fixedGridCutLength = stripCutLength;
|
||||||
|
|
||||||
return new AutomaticCutOffPlan
|
return new AutomaticCutOffPlan
|
||||||
{
|
{
|
||||||
@@ -168,12 +233,74 @@ public static class AutomaticCutOffPlanner
|
|||||||
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
|
||||||
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
|
||||||
TailSeparatorX = separatorX,
|
TailSeparatorX = separatorX,
|
||||||
|
StripCutLength = stripCutLength,
|
||||||
|
FixedGridCutLength = fixedGridCutLength,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void AddShiftedGap(double start, double end, int count, double step)
|
||||||
|
{
|
||||||
|
var nominal = new List<double>(count);
|
||||||
|
for (var index = 1; index <= count; index++)
|
||||||
|
{
|
||||||
|
var distance = start + index * options.Spacing;
|
||||||
|
nominal.Add(distance);
|
||||||
|
fixedGridCutLength += Measure(distance).Length;
|
||||||
|
}
|
||||||
|
if (count == 0)
|
||||||
|
return;
|
||||||
|
var positions = AutomaticCutOffStripSearch.FindPositions(start, end, count,
|
||||||
|
options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
|
||||||
|
for (var index = 0; index < positions.Length; index++)
|
||||||
|
{
|
||||||
|
var distance = positions[index];
|
||||||
|
AddCandidate(sign * distance, false);
|
||||||
|
if (!Near(distance, nominal[index]))
|
||||||
|
{
|
||||||
|
var difference = Measure(nominal[index]).Length - Measure(distance).Length;
|
||||||
|
var comparison = difference > 0 ? $"{difference:G8} model units less" :
|
||||||
|
difference < 0 ? $"{-difference:G8} model units more" : "the same length";
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
|
||||||
|
$"Moved from {nominal[index]:G8} to {distance:G8} model units; "
|
||||||
|
+ $"this line cuts {comparison}.", X: sign * distance));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Allowed(double distance)
|
||||||
|
{
|
||||||
|
RegisterCandidate(distance);
|
||||||
|
// Use the certification path's exact Near predicate, including its ULP
|
||||||
|
// allowance, so a selected line cannot rediscover a limited-cut conflict.
|
||||||
|
var x = sign * distance;
|
||||||
|
return !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical && Near(c.Position.X, x)) &&
|
||||||
|
!clamps.Any(zone => CrossesClamp(zone, x));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RegisterCandidate(double distance)
|
||||||
|
{
|
||||||
|
if (candidates.Add(distance))
|
||||||
|
Require(candidates.Count <= MaximumShiftCandidateCount,
|
||||||
|
$"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
|
||||||
|
+ "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
(double Length, int SegmentCount) Measure(double distance)
|
||||||
|
{
|
||||||
|
RegisterCandidate(distance);
|
||||||
|
if (!measurements.TryGetValue(distance, out var measured))
|
||||||
|
{
|
||||||
|
measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
|
||||||
|
.MeasureSegments(plate, settings, cache);
|
||||||
|
measurements.Add(distance, measured);
|
||||||
|
}
|
||||||
|
return measured;
|
||||||
|
}
|
||||||
|
|
||||||
void AddCandidate(double x, bool isSeparator)
|
void AddCandidate(double x, bool isSeparator)
|
||||||
{
|
{
|
||||||
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
|
||||||
Near(c.Position.X, x)).ToList();
|
Near(c.Position.X, x)).ToList();
|
||||||
|
|
||||||
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
if (matches.Any(c => !FullSpanLimits(c, bounds, settings)))
|
||||||
{
|
{
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
@@ -185,6 +312,24 @@ public static class AutomaticCutOffPlanner
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A full-width cut crosses every active front-edge clamp at its X. Test the
|
||||||
|
// proposed X and every equivalent existing definition, because a tolerance-close
|
||||||
|
// existing line is regenerated and certified at its own X. Keep the whole
|
||||||
|
// footprint when it hangs off the sheet. A same-line manual limit conflict above
|
||||||
|
// still blocks the whole plan, clamp or not.
|
||||||
|
var blocked = clamps.FirstOrDefault(zone =>
|
||||||
|
matches.Select(c => c.Position.X).Prepend(x).Any(position => CrossesClamp(zone, position)));
|
||||||
|
if (blocked != null)
|
||||||
|
{
|
||||||
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
|
AutomaticCutOffDiagnosticCode.ClampBlockedCut,
|
||||||
|
$"The proposed cut crosses clamp '{blocked.Id}' (including bottom-edge clearance); it was skipped.",
|
||||||
|
X: x));
|
||||||
|
if (isSeparator)
|
||||||
|
WarnNoSeparator(x);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
|
// Even a duplicate must be regenerated detached with CURRENT settings. Its live
|
||||||
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
// drawing can be stale, and a tolerance-close line can intersect a part at the tail.
|
||||||
var existing = matches.FirstOrDefault();
|
var existing = matches.FirstOrDefault();
|
||||||
@@ -202,7 +347,7 @@ public static class AutomaticCutOffPlanner
|
|||||||
if (!usable)
|
if (!usable)
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.EmptyCut,
|
AutomaticCutOffDiagnosticCode.EmptyCut,
|
||||||
"The line has no usable cut segments after part clearance and minimum-length filtering; it is not a partition.",
|
"The line has no usable cut segments after part clearance and minimum-length filtering.",
|
||||||
X: x));
|
X: x));
|
||||||
|
|
||||||
if (isSeparator)
|
if (isSeparator)
|
||||||
@@ -218,16 +363,33 @@ public static class AutomaticCutOffPlanner
|
|||||||
else if (usable && !IsFullSpanProgram(program, bounds))
|
else if (usable && !IsFullSpanProgram(program, bounds))
|
||||||
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
AutomaticCutOffDiagnosticCode.SegmentedCut,
|
||||||
"Part clearance or segment filtering interrupts this line; nominal spacing does not guarantee disconnected scrap.",
|
"Part clearance or segment filtering interrupts this line; review scrap separation before relying on it.",
|
||||||
X: x));
|
X: x));
|
||||||
|
|
||||||
if (usable && existing == null)
|
if (usable && existing == null)
|
||||||
|
{
|
||||||
definitions.Add(candidate);
|
definitions.Add(candidate);
|
||||||
|
if (!isSeparator)
|
||||||
|
{
|
||||||
|
if (shifted)
|
||||||
|
stripCutLength += Measure(System.Math.Abs(x)).Length;
|
||||||
|
else
|
||||||
|
for (var index = 0; index < program.Codes.Count; index += 2)
|
||||||
|
stripCutLength += System.Math.Abs(
|
||||||
|
((LinearMove)program.Codes[index + 1]).EndPoint.Y -
|
||||||
|
((RapidMove)program.Codes[index]).EndPoint.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Share the exact placement-frame predicate with candidate exclusion and final
|
||||||
|
// certification. Infeasible gaps still use nominal lines through AddCandidate.
|
||||||
|
bool CrossesClamp(ClampZone zone, double x) =>
|
||||||
|
x >= zone.Bounds.Left - plate.EdgeSpacing.Bottom && x <= zone.Bounds.Right + plate.EdgeSpacing.Bottom;
|
||||||
|
|
||||||
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
|
||||||
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
AutomaticCutOffDiagnosticCode.NoSafeTailSeparator,
|
||||||
"No safe full-width tail separator survives the current settings. No separated tail is claimed; review manually.",
|
"No safe full-width tail separator survives the current settings. Review the tail manually.",
|
||||||
X: x));
|
X: x));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +397,10 @@ public static class AutomaticCutOffPlanner
|
|||||||
{
|
{
|
||||||
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
|
||||||
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
|
||||||
|
Require(!options.MinimumStripLength.HasValue ||
|
||||||
|
(double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing &&
|
||||||
|
options.MinimumStripLength.Value <= options.Spacing),
|
||||||
|
$"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", nameof(options));
|
||||||
Require(Nonnegative(options.MinimumTailLength),
|
Require(Nonnegative(options.MinimumTailLength),
|
||||||
"Minimum tail length must be finite and nonnegative.", nameof(options));
|
"Minimum tail length must be finite and nonnegative.", nameof(options));
|
||||||
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
|
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
|
||||||
@@ -242,6 +408,9 @@ public static class AutomaticCutOffPlanner
|
|||||||
"Sheet length and width must be positive and finite.", nameof(plate));
|
"Sheet length and width must be positive and finite.", nameof(plate));
|
||||||
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
Require(plate.Quadrant is >= 1 and <= 4, "Quadrant must be 1 through 4.", nameof(plate));
|
||||||
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
Require(Nonnegative(plate.PartSpacing), "Part spacing must be finite and nonnegative.", nameof(plate));
|
||||||
|
Require(plate.ClampZones != null && (plate.ClampZones.Count == 0 ||
|
||||||
|
Nonnegative(plate.EdgeSpacing.Bottom)),
|
||||||
|
"Clamp zones and bottom-edge clearance must be finite and valid.", nameof(plate));
|
||||||
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
Require(Nonnegative(settings.PartClearance) && Nonnegative(settings.MinSegmentLength) &&
|
||||||
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
Nonnegative(settings.Overtravel), "Cut-off settings must be finite and nonnegative.", nameof(settings));
|
||||||
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
Require(Enum.IsDefined(settings.CutDirection), "Unknown cut direction.", nameof(settings));
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
|
||||||
|
internal static class AutomaticCutOffStripSearch
|
||||||
|
{
|
||||||
|
internal static double[] FindPositions(double start, double end, int count, double spacing,
|
||||||
|
double minimum, double step, Func<double, bool> allowed,
|
||||||
|
Func<double, (double Length, int SegmentCount)> measure)
|
||||||
|
{
|
||||||
|
var rows = new List<double[]>(count);
|
||||||
|
var nominal = new double[count];
|
||||||
|
for (var line = 1; line <= count; line++)
|
||||||
|
{
|
||||||
|
var today = start + line * spacing;
|
||||||
|
nominal[line - 1] = today;
|
||||||
|
var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing);
|
||||||
|
var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance);
|
||||||
|
var positions = new List<double>();
|
||||||
|
// Integer indices keep candidates on the origin-based lattice without cumulative drift.
|
||||||
|
var first = (long)System.Math.Ceiling(low / step);
|
||||||
|
var last = (long)System.Math.Floor(high / step);
|
||||||
|
for (var index = first; index <= last; index++)
|
||||||
|
{
|
||||||
|
var position = index * step;
|
||||||
|
if (position >= low && position <= high && allowed(position))
|
||||||
|
positions.Add(position);
|
||||||
|
}
|
||||||
|
if (allowed(today))
|
||||||
|
positions.Add(today);
|
||||||
|
positions.Sort();
|
||||||
|
for (var index = positions.Count - 1; index > 0; index--)
|
||||||
|
if (positions[index] == positions[index - 1])
|
||||||
|
positions.RemoveAt(index);
|
||||||
|
if (positions.Count == 0)
|
||||||
|
return null;
|
||||||
|
rows.Add(positions.ToArray());
|
||||||
|
}
|
||||||
|
return Solve(start, rows, nominal, spacing, minimum, measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles.
|
||||||
|
internal static double[] Solve(double start, IReadOnlyList<double[]> rows,
|
||||||
|
IReadOnlyList<double> nominal, double spacing, double minimum,
|
||||||
|
Func<double, (double Length, int SegmentCount)> measure)
|
||||||
|
{
|
||||||
|
var previous = new List<State> { new() { Position = start } };
|
||||||
|
for (var line = 0; line < rows.Count; line++)
|
||||||
|
{
|
||||||
|
var current = new List<State>(rows[line].Length);
|
||||||
|
var deque = new int[previous.Count];
|
||||||
|
var head = 0;
|
||||||
|
var tail = 0;
|
||||||
|
var added = 0;
|
||||||
|
foreach (var position in rows[line])
|
||||||
|
{
|
||||||
|
var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance;
|
||||||
|
var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance;
|
||||||
|
while (added < previous.Count && previous[added].Position <= upper)
|
||||||
|
{
|
||||||
|
while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0)
|
||||||
|
tail--;
|
||||||
|
deque[tail++] = added++;
|
||||||
|
}
|
||||||
|
while (head < tail && previous[deque[head]].Position < lower)
|
||||||
|
head++;
|
||||||
|
if (head == tail)
|
||||||
|
continue;
|
||||||
|
var parent = previous[deque[head]];
|
||||||
|
var cost = measure(position);
|
||||||
|
current.Add(new State
|
||||||
|
{
|
||||||
|
Position = position,
|
||||||
|
Length = parent.Length + RoundedMicroUnits(cost.Length),
|
||||||
|
Pierces = parent.Pierces + cost.SegmentCount,
|
||||||
|
Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position),
|
||||||
|
Parent = parent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (current.Count == 0)
|
||||||
|
return null;
|
||||||
|
AssignLexicographicRanks(current, previous.Count);
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
State best = null;
|
||||||
|
foreach (var state in previous)
|
||||||
|
if (best == null || Compare(state, best) < 0)
|
||||||
|
best = state;
|
||||||
|
var result = new double[rows.Count];
|
||||||
|
for (var line = result.Length - 1; line >= 0; line--)
|
||||||
|
{
|
||||||
|
result[line] = best.Position;
|
||||||
|
best = best.Parent;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigInteger RoundedMicroUnits(double length)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(length) || length < 0)
|
||||||
|
throw new ArgumentException("Cut-off segment length must be finite and nonnegative.", nameof(length));
|
||||||
|
var rounded = System.Math.Round(length, 6);
|
||||||
|
// A shortest round-trip decimal may spell a different integer near 1e16
|
||||||
|
// (for example 26833294515438512d prints as 26833294515438510).
|
||||||
|
// An already-integral double is exact; never round it through text.
|
||||||
|
if (rounded == System.Math.Truncate(rounded))
|
||||||
|
return new BigInteger(rounded) * 1_000_000;
|
||||||
|
// Compare sums of the specified decimal-rounded model units, not binary
|
||||||
|
// approximations such as 0.1 + 0.2 versus 0.3. The round-trip string
|
||||||
|
// retains micro-unit digits for fractional scores.
|
||||||
|
var value = decimal.Parse(rounded.ToString("R", CultureInfo.InvariantCulture),
|
||||||
|
NumberStyles.Float, CultureInfo.InvariantCulture);
|
||||||
|
return new BigInteger(decimal.Round(value * 1_000_000m, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int Compare(State left, State right)
|
||||||
|
{
|
||||||
|
var comparison = left.Length.CompareTo(right.Length);
|
||||||
|
if (comparison == 0)
|
||||||
|
comparison = left.Pierces.CompareTo(right.Pierces);
|
||||||
|
if (comparison == 0)
|
||||||
|
comparison = left.Deviation.CompareTo(right.Deviation);
|
||||||
|
return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssignLexicographicRanks(List<State> states, int parentCount)
|
||||||
|
{
|
||||||
|
// Stable counting sort by prefix rank. Positions are already sorted, so within a
|
||||||
|
// prefix their order is lexicographic. Ranking and transitions are linear per row.
|
||||||
|
var offsets = new int[parentCount];
|
||||||
|
foreach (var state in states)
|
||||||
|
offsets[state.Parent.LexicographicRank]++;
|
||||||
|
var total = 0;
|
||||||
|
for (var index = 0; index < offsets.Length; index++)
|
||||||
|
{
|
||||||
|
var count = offsets[index];
|
||||||
|
offsets[index] = total;
|
||||||
|
total += count;
|
||||||
|
}
|
||||||
|
foreach (var state in states)
|
||||||
|
state.LexicographicRank = offsets[state.Parent.LexicographicRank]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class State
|
||||||
|
{
|
||||||
|
public double Position;
|
||||||
|
public BigInteger Length;
|
||||||
|
public int Pierces;
|
||||||
|
public double Deviation;
|
||||||
|
public State Parent;
|
||||||
|
public int LexicographicRank;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,11 +55,13 @@ internal static class ContourProgramVerifier
|
|||||||
var ringIndex = -1;
|
var ringIndex = -1;
|
||||||
var entityIndex = -1;
|
var entityIndex = -1;
|
||||||
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
for (var r = 0; r < material.Rings.Count && ringIndex < 0; r++)
|
||||||
|
{
|
||||||
|
var admitted = AdmittedClosureSlack(material.Rings[r]);
|
||||||
for (var e = 0; e < material.Rings[r].Length; e++)
|
for (var e = 0; e < material.Rings[r].Length; e++)
|
||||||
{
|
{
|
||||||
Query(token, ref budget);
|
Query(token, ref budget);
|
||||||
var curve = material.Rings[r][e];
|
var curve = material.Rings[r][e];
|
||||||
if (curve.SameDirection(first) && curve.Contains(first.Start)
|
if (curve.SameDirection(first, admitted) && curve.Contains(first.Start, admitted)
|
||||||
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
&& curve.DistanceAlong(first.Start) < curve.Length - Epsilon)
|
||||||
{
|
{
|
||||||
ringIndex = r;
|
ringIndex = r;
|
||||||
@@ -67,28 +69,39 @@ internal static class ContourProgramVerifier
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
if (ringIndex < 0 || !visited.Add(ringIndex)) return false;
|
||||||
var ring = material.Rings[ringIndex];
|
var ring = material.Rings[ringIndex];
|
||||||
|
// Capture admits a nominal ring that closes within NominalContourClosure.Tolerance, and
|
||||||
|
// preparation agrees at that inclusive boundary. A rotated tool path bridges that one
|
||||||
|
// admitted joint, so this ring's positional and length agreement carries exactly that
|
||||||
|
// admitted slack. Direction, coverage order and retrace accounting stay at the epsilon.
|
||||||
|
var tolerance = AdmittedClosureSlack(ring);
|
||||||
var nominalLength = ring.Sum(c => c.Length);
|
var nominalLength = ring.Sum(c => c.Length);
|
||||||
var cutLength = run.Curves.Sum(c => c.Length);
|
var cutLength = run.Curves.Sum(c => c.Length);
|
||||||
var missing = nominalLength - cutLength;
|
var missing = nominalLength - cutLength;
|
||||||
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
if (missing < -Epsilon) return false; // Never a second circuit/retrace.
|
||||||
if (missing > Epsilon)
|
if (missing > tolerance)
|
||||||
{
|
{
|
||||||
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
// Fixed legacy payloads have no certified selected tab/entry metadata.
|
||||||
// A merely open contour or a caller's current tab switch proves nothing.
|
// A merely open contour or a caller's current tab switch proves nothing.
|
||||||
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
if (selected == null || ringIndex != 0 || selected.TabSize <= 0
|
||||||
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > Epsilon)
|
|| System.Math.Abs(first.Start.DistanceTo(run.Curves[^1].End) - selected.TabSize) > tolerance)
|
||||||
return false;
|
return false;
|
||||||
// SameEmission has already bound this exact terminal gap to the owned
|
// SameEmission has already bound this exact terminal gap to the owned
|
||||||
// settings' independently emitted, rounded/clamped selected geometry.
|
// settings' independently emitted, rounded/clamped selected geometry.
|
||||||
}
|
}
|
||||||
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
if (!Follows(run.Curves, ring, entityIndex, ring[entityIndex].DistanceAlong(first.Start),
|
||||||
true, token, ref budget)) return false;
|
true, tolerance, token, ref budget)) return false;
|
||||||
}
|
}
|
||||||
return visited.Count == material.Rings.Count;
|
return visited.Count == material.Rings.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero for an exactly closed ring: a ring may lawfully close within the nominal closure
|
||||||
|
// tolerance, and only that admitted endpoint gap relaxes replay's positional agreement.
|
||||||
|
private static double AdmittedClosureSlack(IReadOnlyList<Curve> ring) => ring.Count == 0 ? Epsilon
|
||||||
|
: Epsilon + System.Math.Max(0.0, ring[^1].End.DistanceTo(ring[0].Start));
|
||||||
|
|
||||||
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
private static bool SameEmission(OwnedExecution actual, OwnedExecution expected,
|
||||||
CancellationToken token, ref int budget)
|
CancellationToken token, ref int budget)
|
||||||
{
|
{
|
||||||
@@ -107,15 +120,17 @@ internal static class ContourProgramVerifier
|
|||||||
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
if (a.End.DistanceTo(b.End) > Epsilon) return false;
|
||||||
}
|
}
|
||||||
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
else if (System.Math.Abs(a.Curves.Sum(c => c.Length) - b.Curves.Sum(c => c.Length)) > Epsilon
|
||||||
|| !Follows(a.Curves, b.Curves, 0, 0, false, token, ref budget)) return false;
|
|| !Follows(a.Curves, b.Curves, 0, 0, false, Epsilon, token, ref budget)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consume directed native arc-length from both streams. Subdivision, merged
|
// Consume directed native arc-length from both streams. Subdivision, merged
|
||||||
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
// collinear moves and a cyclic ring's reindexing do not change coverage.
|
||||||
|
// `tolerance` is the admitted positional slack for this pair of streams (the exact epsilon
|
||||||
|
// unless the nominal ring closes only within the nominal closure tolerance).
|
||||||
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
private static bool Follows(IReadOnlyList<Curve> actual, IReadOnlyList<Curve> expected,
|
||||||
int index, double offset, bool cyclic, CancellationToken token, ref int budget)
|
int index, double offset, bool cyclic, double tolerance, CancellationToken token, ref int budget)
|
||||||
{
|
{
|
||||||
foreach (var curve in actual)
|
foreach (var curve in actual)
|
||||||
{
|
{
|
||||||
@@ -137,13 +152,13 @@ internal static class ContourProgramVerifier
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var length = System.Math.Min(remaining, curve.Length - consumed);
|
var length = System.Math.Min(remaining, curve.Length - consumed);
|
||||||
if (!nominal.SameDirection(curve)
|
if (!nominal.SameDirection(curve, tolerance)
|
||||||
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > Epsilon
|
|| nominal.PointAtLength(offset).DistanceTo(curve.PointAtLength(consumed)) > tolerance
|
||||||
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > Epsilon)
|
|| nominal.PointAtLength(offset + length).DistanceTo(curve.PointAtLength(consumed + length)) > tolerance)
|
||||||
return false;
|
return false;
|
||||||
consumed += length;
|
consumed += length;
|
||||||
offset += length;
|
offset += length;
|
||||||
if (offset >= nominal.Length - Epsilon)
|
if (offset >= nominal.Length - tolerance)
|
||||||
{
|
{
|
||||||
index++;
|
index++;
|
||||||
offset = 0;
|
offset = 0;
|
||||||
@@ -151,7 +166,7 @@ internal static class ContourProgramVerifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cyclic || index == expected.Count || index == expected.Count - 1
|
return cyclic || index == expected.Count || index == expected.Count - 1
|
||||||
&& expected[index].Length - offset <= Epsilon;
|
&& expected[index].Length - offset <= tolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Run> Runs(OwnedExecution execution)
|
private static List<Run> Runs(OwnedExecution execution)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.Diagnostics;
|
using OpenNest.Diagnostics;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingPlanning;
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ public sealed class LeadMaterialSnapshot
|
|||||||
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
if (chain.Count > 0 && chain[^1].End.DistanceTo(curve.Start) > PostVerificationGeometry.Epsilon)
|
||||||
throw new ArgumentException("Material contour is discontinuous.");
|
throw new ArgumentException("Material contour is discontinuous.");
|
||||||
chain.Add(curve);
|
chain.Add(curve);
|
||||||
if (PostVerificationGeometry.Closed(chain))
|
if (NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||||
Finish();
|
Finish();
|
||||||
}
|
}
|
||||||
Finish();
|
Finish();
|
||||||
@@ -66,11 +67,23 @@ public sealed class LeadMaterialSnapshot
|
|||||||
var b = rings[s][j];
|
var b = rings[s][j];
|
||||||
var contacts = a.Contacts(b, out var overlap);
|
var contacts = a.Contacts(b, out var overlap);
|
||||||
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
var adjacent = r == s && (j == i + 1 || (i == 0 && j == rings[r].Length - 1));
|
||||||
|
var arcJoint = adjacent && a.IsFiniteArc && b.IsFiniteArc
|
||||||
|
&& (a.End.DistanceTo(b.Start) <= Tolerance.Epsilon
|
||||||
|
|| a.Start.DistanceTo(b.End) <= Tolerance.Epsilon);
|
||||||
|
// Native arc queries pad angles by 1e-5 radians, admitting
|
||||||
|
// intersections on an extension (distance grows with radius).
|
||||||
|
// At a shared arc joint require both actual finite spans;
|
||||||
|
// overlap and genuine second intersections still refuse.
|
||||||
|
if (arcJoint)
|
||||||
|
contacts = contacts.Where(p => !a.IsOutsideArcSpan(p) && !b.IsOutsideArcSpan(p)).ToArray();
|
||||||
|
// Native contacts at rounded arc/line joints can drift from the
|
||||||
|
// authored endpoint. Use the shared geometry tolerance only to
|
||||||
|
// recognize that joint; overlaps and other contacts still refuse.
|
||||||
if (overlap || contacts.Any(p => !adjacent
|
if (overlap || contacts.Any(p => !adjacent
|
||||||
|| !(p.DistanceTo(a.End) <= PostVerificationGeometry.Epsilon
|
|| !(p.DistanceTo(a.End) <= Tolerance.Epsilon
|
||||||
&& p.DistanceTo(b.Start) <= PostVerificationGeometry.Epsilon)
|
&& p.DistanceTo(b.Start) <= Tolerance.Epsilon)
|
||||||
&& !(p.DistanceTo(a.Start) <= PostVerificationGeometry.Epsilon
|
&& !(p.DistanceTo(a.Start) <= Tolerance.Epsilon
|
||||||
&& p.DistanceTo(b.End) <= PostVerificationGeometry.Epsilon)))
|
&& p.DistanceTo(b.End) <= Tolerance.Epsilon)))
|
||||||
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
throw new ArgumentException($"Material boundaries overlap, touch or self-intersect ({r}:{i}, {s}:{j}, overlap={overlap}).");
|
||||||
}
|
}
|
||||||
var outer = -1;
|
var outer = -1;
|
||||||
@@ -97,8 +110,13 @@ public sealed class LeadMaterialSnapshot
|
|||||||
{
|
{
|
||||||
if (chain.Count == 0)
|
if (chain.Count == 0)
|
||||||
return;
|
return;
|
||||||
if (!PostVerificationGeometry.Closed(chain))
|
if (!NominalContourClosure.IsClosed(chain[0].Start, chain[^1].End))
|
||||||
throw new ArgumentException("Nominal material contour is open; tab gaps cannot be filled implicitly.");
|
{
|
||||||
|
var gap = chain[0].Start.DistanceTo(chain[^1].End);
|
||||||
|
throw new ArgumentException(FormattableString.Invariant(
|
||||||
|
$"Nominal material contour {rings.Count + 1} is open: endpoint gap {gap:G6} model units exceeds closure tolerance {NominalContourClosure.Tolerance:G6}.")
|
||||||
|
+ " Review the source contour in the drawing editor; gaps cannot be filled implicitly.");
|
||||||
|
}
|
||||||
rings.Add(chain.ToArray());
|
rings.Add(chain.ToArray());
|
||||||
chain.Clear();
|
chain.Clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ public static class LeadPathValidator
|
|||||||
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
return material.ContainsMaterial(move.Curve.Midpoint, token) ? "interior" : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Missing leads are the ReleasedContourState check's responsibility.
|
// No lead motion is valid for direct contour piercing. The caller still
|
||||||
|
// checks rapid travel and contour accounting for the complete program.
|
||||||
return new(true, true, null);
|
return new(true, true, null);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>Closure of source contours during planner material capture and preparation.</summary>
|
||||||
|
internal static class NominalContourClosure
|
||||||
|
{
|
||||||
|
internal const double Tolerance = 0.000001;
|
||||||
|
|
||||||
|
internal static bool IsClosed(Vector start, Vector end)
|
||||||
|
{
|
||||||
|
// Subtracting translated/rotated coordinates can put an exact boundary gap
|
||||||
|
// a few floating-point ulps above the limit. Bound that allowance to 1e-12.
|
||||||
|
var scale = System.Math.Max(1, System.Math.Max(
|
||||||
|
System.Math.Max(System.Math.Abs(start.X), System.Math.Abs(start.Y)),
|
||||||
|
System.Math.Max(System.Math.Abs(end.X), System.Math.Abs(end.Y))));
|
||||||
|
var roundoff = System.Math.Min(1e-12, 4 * 2.2204460492503131e-16 * scale);
|
||||||
|
return start.DistanceTo(end) <= Tolerance + roundoff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ public sealed class PreparedContours
|
|||||||
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon)
|
||||||
throw new ArgumentException("Discontinuous native contour.");
|
throw new ArgumentException("Discontinuous native contour.");
|
||||||
current.Entities.Add(entity);
|
current.Entities.Add(entity);
|
||||||
if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon)
|
if (NominalContourClosure.IsClosed(Start(current.Entities[0]), End(entity)))
|
||||||
FinishContour();
|
FinishContour();
|
||||||
}
|
}
|
||||||
if (current.Entities.Count != 0 || contours.Count == 0)
|
if (current.Entities.Count != 0 || contours.Count == 0)
|
||||||
@@ -254,12 +254,11 @@ public sealed class PreparedContours
|
|||||||
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
.Where(p => p.Kind == AutomaticEntryKind.ConvexCorner))
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
var cornerPoint = End(shape.Entities[corner.EntityOrdinal]);
|
|
||||||
// Back INTO each incident edge from the corner: the edge ending at the
|
// Back INTO each incident edge from the corner: the edge ending at the
|
||||||
// corner retreats against its own travel, the edge starting at the corner
|
// corner retreats against its own travel, the edge starting at the corner
|
||||||
// advances along its own travel.
|
// advances along its own travel.
|
||||||
AddInset(corner.EntityOrdinal, cornerPoint, inward: false);
|
AddInset(corner.EntityOrdinal, inward: false);
|
||||||
AddInset((corner.EntityOrdinal + 1) % count, cornerPoint, inward: true);
|
AddInset((corner.EntityOrdinal + 1) % count, inward: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +275,7 @@ public sealed class PreparedContours
|
|||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
return fallbacks;
|
return fallbacks;
|
||||||
|
|
||||||
void AddInset(int entityOrdinal, Vector corner, bool inward)
|
void AddInset(int entityOrdinal, bool inward)
|
||||||
{
|
{
|
||||||
// `inward` selects along the edge's own travel from its start; without it the
|
// `inward` selects along the edge's own travel from its start; without it the
|
||||||
// point retreats against travel. Both ways move BACK INTO the edge from the
|
// point retreats against travel. Both ways move BACK INTO the edge from the
|
||||||
@@ -285,7 +284,9 @@ public sealed class PreparedContours
|
|||||||
return;
|
return;
|
||||||
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
var direction = (line.EndPoint - line.StartPoint).Normalize();
|
||||||
var offset = direction * (2 * lead);
|
var offset = direction * (2 * lead);
|
||||||
var point = inward ? corner + offset : corner - offset;
|
// Adjacent native endpoints may differ within contour-continuity tolerance.
|
||||||
|
// Anchor to the selected edge itself, never the preceding edge's vertex.
|
||||||
|
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
|
||||||
// Strictly inside the edge by projection parameter (distance alone loses the
|
// Strictly inside the edge by projection parameter (distance alone loses the
|
||||||
// sign when a short edge is overshoot): never the corner, never the far
|
// sign when a short edge is overshoot): never the corner, never the far
|
||||||
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
// endpoint (where a reflex vertex might sit). Short edges omit the point.
|
||||||
@@ -510,6 +511,17 @@ public sealed class PreparedContours
|
|||||||
return centres;
|
return centres;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether <paramref name="point"/> lies inside the internal contour <paramref name="contourOrdinal"/>.
|
||||||
|
/// A routing hint only (chorded at the overlap check's tolerance), never a clearance verdict.
|
||||||
|
/// </summary>
|
||||||
|
internal bool HoleContains(int contourOrdinal, Vector point)
|
||||||
|
{
|
||||||
|
var shape = GetShape(contourOrdinal);
|
||||||
|
return contourOrdinal != PerimeterOrdinal && shape.BoundingBox.Contains(point)
|
||||||
|
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
|
||||||
|
}
|
||||||
|
|
||||||
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
|
||||||
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,20 @@ namespace OpenNest.CNC.CuttingPlanning;
|
|||||||
public sealed class ReleasedContourState
|
public sealed class ReleasedContourState
|
||||||
{
|
{
|
||||||
private readonly List<Obstacle> obstacles = new();
|
private readonly List<Obstacle> obstacles = new();
|
||||||
|
private readonly bool reportMissingLeadIns;
|
||||||
|
|
||||||
|
public ReleasedContourState() : this(true) { }
|
||||||
|
|
||||||
|
/// <param name="reportMissingLeadIns">Keep pre-post warnings by default. Planning
|
||||||
|
/// may pierce directly on a contour and checks travel without requiring a lead.</param>
|
||||||
|
public ReleasedContourState(bool reportMissingLeadIns)
|
||||||
|
{
|
||||||
|
this.reportMissingLeadIns = reportMissingLeadIns;
|
||||||
|
}
|
||||||
|
|
||||||
public ReleasedContourState Copy()
|
public ReleasedContourState Copy()
|
||||||
{
|
{
|
||||||
var copy = new ReleasedContourState();
|
var copy = new ReleasedContourState(reportMissingLeadIns);
|
||||||
copy.obstacles.AddRange(obstacles);
|
copy.obstacles.AddRange(obstacles);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
@@ -68,7 +78,13 @@ public sealed class ReleasedContourState
|
|||||||
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
|
||||||
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
|
||||||
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
|
||||||
$"of part {obstacle.Part}."));
|
$"of part {obstacle.Part}.")
|
||||||
|
{
|
||||||
|
Location = (start + move.End) * 0.5,
|
||||||
|
RapidStart = start,
|
||||||
|
RapidEnd = move.End,
|
||||||
|
ContactPoints = PostVerificationGeometry.ContactPoints(start, move.End, obstacle.Curves, token)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (move.Layer == LayerType.Leadin)
|
else if (move.Layer == LayerType.Leadin)
|
||||||
@@ -92,9 +108,10 @@ public sealed class ReleasedContourState
|
|||||||
if (contour.Count == 0)
|
if (contour.Count == 0)
|
||||||
{
|
{
|
||||||
contourNumber++;
|
contourNumber++;
|
||||||
if (!cutoff && !hasLead)
|
if (reportMissingLeadIns && !cutoff && !hasLead)
|
||||||
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
|
||||||
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
|
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
|
||||||
|
{ Location = move.Curve.Start });
|
||||||
hasLead = false;
|
hasLead = false;
|
||||||
// A rapid can pause/reposition without leaving any material gap.
|
// A rapid can pause/reposition without leaving any material gap.
|
||||||
// Retain already-cut fragments, but do not turn them into obstacles
|
// Retain already-cut fragments, but do not turn them into obstacles
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.CNC.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A seam overcut baked into a clean contour: the last cut runs on past the contour's own start
|
||||||
|
/// along its first cut, then a straight jog returns to the start (converted nests carry a source
|
||||||
|
/// CAM's 0.03 overcut this way). The retraced tail encloses no area, so trimming it leaves the
|
||||||
|
/// material unchanged; left in, the contour touches itself at its seam and nothing can certify it.
|
||||||
|
/// Only that exact shape is recognized. Any other self-contact is still refused by its consumer.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SeamOvercut
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The same program when it has no seam overcut, otherwise an owned copy without them.
|
||||||
|
/// The source program is never edited.
|
||||||
|
/// </summary>
|
||||||
|
internal static Program Trimmed(Program program)
|
||||||
|
{
|
||||||
|
var edits = Find(program);
|
||||||
|
if (edits.Count == 0)
|
||||||
|
return program;
|
||||||
|
var copy = (Program)program.Clone();
|
||||||
|
Apply(copy, edits);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Trims seam overcuts from an owned program in place; returns how many were removed.</summary>
|
||||||
|
internal static int Trim(Program program)
|
||||||
|
{
|
||||||
|
var edits = Find(program);
|
||||||
|
Apply(program, edits);
|
||||||
|
return edits.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(Program program, List<Edit> edits)
|
||||||
|
{
|
||||||
|
// Last first, so removing a return jog does not shift an earlier edit's indices.
|
||||||
|
for (var i = edits.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var edit = edits[i];
|
||||||
|
var last = (Motion)program.Codes[edit.LastCut];
|
||||||
|
last.EndPoint = edit.EndPoint;
|
||||||
|
if (last is ArcMove arc)
|
||||||
|
arc.CenterPoint = edit.CenterPoint;
|
||||||
|
program.Codes.RemoveAt(edit.ReturnJog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Edit> Find(Program program)
|
||||||
|
{
|
||||||
|
var edits = new List<Edit>();
|
||||||
|
if (program?.Codes == null)
|
||||||
|
return edits;
|
||||||
|
var incremental = program.Mode == Mode.Incremental;
|
||||||
|
var run = new List<Segment>();
|
||||||
|
// Null while the position is unknown: after a subprogram call in an absolute program.
|
||||||
|
Vector? position = new Vector();
|
||||||
|
for (var index = 0; index < program.Codes.Count; index++)
|
||||||
|
{
|
||||||
|
var code = program.Codes[index];
|
||||||
|
if (code is not Motion motion)
|
||||||
|
{
|
||||||
|
Finish();
|
||||||
|
if (code is SubProgramCall && !incremental)
|
||||||
|
position = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Incremental coordinates only need a consistent origin within one contour.
|
||||||
|
var reference = incremental ? position ?? new Vector() : new Vector();
|
||||||
|
var end = reference + motion.EndPoint;
|
||||||
|
var cut = !motion.Suppressed && motion is LinearMove { Layer: LayerType.Cut or LayerType.Display }
|
||||||
|
or ArcMove { Layer: LayerType.Cut or LayerType.Display };
|
||||||
|
if (!cut || position == null)
|
||||||
|
{
|
||||||
|
Finish();
|
||||||
|
position = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
run.Add(new(index, motion, position.Value, end,
|
||||||
|
motion is ArcMove arc ? reference + arc.CenterPoint : null, reference));
|
||||||
|
position = end;
|
||||||
|
}
|
||||||
|
Finish();
|
||||||
|
return edits;
|
||||||
|
|
||||||
|
void Finish()
|
||||||
|
{
|
||||||
|
if (TryTrim(run, out var edit))
|
||||||
|
edits.Add(edit);
|
||||||
|
run.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryTrim(List<Segment> run, out Edit edit)
|
||||||
|
{
|
||||||
|
edit = default;
|
||||||
|
if (run.Count < 3)
|
||||||
|
return false;
|
||||||
|
var first = run[0];
|
||||||
|
var last = run[^2];
|
||||||
|
var jog = run[^1];
|
||||||
|
var seam = first.Start;
|
||||||
|
var overcut = last.End;
|
||||||
|
if (jog.Motion is not LinearMove || !NominalContourClosure.IsClosed(seam, jog.End)
|
||||||
|
|| HasVariables(last.Motion) || HasVariables(jog.Motion)
|
||||||
|
|| !IsFinite(seam) || !IsFinite(overcut) || !IsFinite(last.Start)
|
||||||
|
|| overcut.DistanceTo(seam) <= Tolerance.Epsilon || last.Start.DistanceTo(seam) <= Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (first.Center == null && last.Center == null)
|
||||||
|
{
|
||||||
|
var length = first.Start.DistanceTo(first.End);
|
||||||
|
if (length <= Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
var direction = (first.End - first.Start) * (1 / length);
|
||||||
|
// The last cut lies on the first cut's line, passes the seam and stops inside the first cut.
|
||||||
|
var along = Dot(overcut - seam, direction);
|
||||||
|
var before = Dot(seam - last.Start, direction);
|
||||||
|
if (System.Math.Abs(Cross(overcut - seam, direction)) > Tolerance.Epsilon
|
||||||
|
|| System.Math.Abs(Cross(last.Start - seam, direction)) > Tolerance.Epsilon
|
||||||
|
|| along <= Tolerance.Epsilon || along >= length - Tolerance.Epsilon || before <= Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
edit = new(last.Index, jog.Index, seam - last.Reference, new Vector());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first.Center is not { } firstCenter || last.Center is not { } lastCenter
|
||||||
|
|| !IsFinite(firstCenter) || !IsFinite(lastCenter)
|
||||||
|
|| ((ArcMove)first.Motion).Rotation != ((ArcMove)last.Motion).Rotation)
|
||||||
|
return false;
|
||||||
|
var radius = seam.DistanceTo(firstCenter);
|
||||||
|
if (radius <= Tolerance.Epsilon || firstCenter.DistanceTo(lastCenter) > Tolerance.Epsilon
|
||||||
|
|| System.Math.Abs(overcut.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon
|
||||||
|
|| System.Math.Abs(overcut.DistanceTo(firstCenter) - radius) > Tolerance.Epsilon
|
||||||
|
|| System.Math.Abs(seam.DistanceTo(lastCenter) - radius) > Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
var clockwise = ((ArcMove)first.Motion).Rotation == RotationType.CW;
|
||||||
|
var margin = Tolerance.Epsilon / radius;
|
||||||
|
// The overcut end lies inside the first arc's span, and the seam inside the last arc's.
|
||||||
|
var firstSweep = first.Start.DistanceTo(first.End) <= Tolerance.Epsilon
|
||||||
|
? 2 * System.Math.PI : Travel(firstCenter, seam, first.End, clockwise);
|
||||||
|
var passed = Travel(firstCenter, seam, overcut, clockwise);
|
||||||
|
var lastSweep = Travel(lastCenter, last.Start, overcut, clockwise);
|
||||||
|
var toSeam = Travel(lastCenter, last.Start, seam, clockwise);
|
||||||
|
if (passed <= margin || passed >= System.Math.PI || passed >= firstSweep - margin
|
||||||
|
|| toSeam <= margin || toSeam >= lastSweep - margin
|
||||||
|
|| System.Math.Abs(lastSweep - toSeam - passed) > margin)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Keep the shortened arc's center equidistant from both of its ends.
|
||||||
|
var chord = seam - last.Start;
|
||||||
|
var normal = new Vector(-chord.Y, chord.X) * (1 / last.Start.DistanceTo(seam));
|
||||||
|
var middle = (last.Start + seam) * 0.5;
|
||||||
|
var center = middle + normal * Dot(lastCenter - middle, normal);
|
||||||
|
edit = new(last.Index, jog.Index, seam - last.Reference, center - last.Reference);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The angle travelled around <paramref name="center"/> from one point to another, in [0, 2pi).</summary>
|
||||||
|
private static double Travel(Vector center, Vector from, Vector to, bool clockwise)
|
||||||
|
{
|
||||||
|
var start = System.Math.Atan2(from.Y - center.Y, from.X - center.X);
|
||||||
|
var end = System.Math.Atan2(to.Y - center.Y, to.X - center.X);
|
||||||
|
var travel = (clockwise ? start - end : end - start) % (2 * System.Math.PI);
|
||||||
|
return travel < 0 ? travel + 2 * System.Math.PI : travel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasVariables(Motion motion) => motion.VariableRefs is { Count: > 0 };
|
||||||
|
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||||
|
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||||
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
|
|
||||||
|
/// <summary>One cut of a contour in the walk's coordinates; <see cref="Reference"/> maps them back to its code.</summary>
|
||||||
|
private readonly record struct Segment(int Index, Motion Motion, Vector Start, Vector End, Vector? Center, Vector Reference);
|
||||||
|
|
||||||
|
/// <summary>The last cut's new code-relative end and center, and the return jog to remove.</summary>
|
||||||
|
private readonly record struct Edit(int LastCut, int ReturnJog, Vector EndPoint, Vector CenterPoint);
|
||||||
|
}
|
||||||
@@ -31,16 +31,16 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List<Segment> results)
|
||||||
{
|
{
|
||||||
// Draw the rapid from the previous tool position to the program's first
|
// Draw the approach to the first rapid destination, or to the program
|
||||||
// pierce point. The walk then starts at the program origin (basePos), not
|
// origin when it starts cutting without a rapid. Only an initial rapid
|
||||||
// the pierce: the skipped first rapid still advances pos, so starting at
|
// (or call arrival) is already represented by this approach; a later
|
||||||
// the pierce would apply a nonzero Incremental first delta twice (as in
|
// first rapid must remain visible. Walk starts at the program origin,
|
||||||
// lead-in programs) and shift every later rapid by it.
|
// so an incremental initial rapid is not applied twice.
|
||||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||||
results.Add(new Segment(startPos, firstPierce));
|
results.Add(new Segment(startPos, firstPierce));
|
||||||
|
|
||||||
var pos = basePos;
|
var pos = basePos;
|
||||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
Walk(pgm, basePos, ref pos, skipFirst: StartsWithRapidOrCall(pgm), results);
|
||||||
// The last rapid ends at a pierce, not necessarily the final tool position.
|
// The last rapid ends at a pierce, not necessarily the final tool position.
|
||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
@@ -53,11 +53,23 @@ namespace OpenNest.CNC
|
|||||||
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||||
|
|
||||||
if (pgm[i] is Motion motion)
|
if (pgm[i] is Motion motion)
|
||||||
return motion.EndPoint + basePos;
|
return motion is RapidMove ? motion.EndPoint + basePos : basePos;
|
||||||
}
|
}
|
||||||
return basePos;
|
return basePos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool StartsWithRapidOrCall(Program pgm)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < pgm.Length; i++)
|
||||||
|
{
|
||||||
|
if (pgm[i] is SubProgramCall { Program: not null })
|
||||||
|
return true;
|
||||||
|
if (pgm[i] is Motion motion)
|
||||||
|
return motion is RapidMove;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static void Walk(
|
private static void Walk(
|
||||||
Program pgm,
|
Program pgm,
|
||||||
Vector basePos,
|
Vector basePos,
|
||||||
@@ -83,7 +95,7 @@ namespace OpenNest.CNC
|
|||||||
results.Add(new Segment(pos, firstPierce));
|
results.Add(new Segment(pos, firstPierce));
|
||||||
|
|
||||||
var subPos = holeBase;
|
var subPos = holeBase;
|
||||||
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
Walk(program, holeBase, ref subPos, skipFirst: StartsWithRapidOrCall(program), results);
|
||||||
pos = subPos;
|
pos = subPos;
|
||||||
}
|
}
|
||||||
else if (code is Motion motion)
|
else if (code is Motion motion)
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The machine's fixed clamp rail: identical rectangular clamps whose X centers are
|
||||||
|
/// measured from the plate origin along the origin-side (operator) edge. Plates always
|
||||||
|
/// start at the origin, so the same rail resolves onto every sheet size without
|
||||||
|
/// re-scaling. A clamp whose footprint lies wholly beyond a sheet is skipped; one that
|
||||||
|
/// still reaches onto it is kept whole: the zone is an avoidance area, so its full
|
||||||
|
/// footprint applies even where it hangs past the sheet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClampRail : IEquatable<ClampRail>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Planner default minutes to remove one clamp and put it back after cutting (D9),
|
||||||
|
/// used when a caller does not supply its own value.
|
||||||
|
/// </summary>
|
||||||
|
public const double DefaultRemovalMinutes = 5.0;
|
||||||
|
|
||||||
|
public ClampRail(
|
||||||
|
double width,
|
||||||
|
double depth,
|
||||||
|
IEnumerable<double> centers,
|
||||||
|
double removalMinutes = DefaultRemovalMinutes
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(centers);
|
||||||
|
if (!double.IsFinite(width) || width <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(width),
|
||||||
|
"Clamp width must be finite and positive."
|
||||||
|
);
|
||||||
|
if (!double.IsFinite(depth) || depth <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(depth),
|
||||||
|
"Clamp depth must be finite and positive."
|
||||||
|
);
|
||||||
|
if (!double.IsFinite(removalMinutes) || removalMinutes < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(removalMinutes),
|
||||||
|
"Clamp removal time must be finite and zero or more."
|
||||||
|
);
|
||||||
|
|
||||||
|
var sorted = new SortedSet<double>();
|
||||||
|
foreach (var center in centers)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(center) || center < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(centers),
|
||||||
|
"Clamp centers must be finite and measured from the origin (zero or more)."
|
||||||
|
);
|
||||||
|
sorted.Add(center);
|
||||||
|
}
|
||||||
|
|
||||||
|
Width = width;
|
||||||
|
Depth = depth;
|
||||||
|
RemovalMinutes = removalMinutes;
|
||||||
|
Centers = sorted.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clamp extent along the edge (X).</summary>
|
||||||
|
public double Width { get; }
|
||||||
|
|
||||||
|
/// <summary>Clamp intrusion into the sheet from the origin-side edge (Y).</summary>
|
||||||
|
public double Depth { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minutes to remove one clamp and put it back after cutting (D9). Added to a sheet's
|
||||||
|
/// cut-time estimate once per clamp <see cref="Plate.RemovedClamps"/> lists there.
|
||||||
|
/// </summary>
|
||||||
|
public double RemovalMinutes { get; }
|
||||||
|
|
||||||
|
/// <summary>Distinct clamp centers along X from the origin, ascending.</summary>
|
||||||
|
public IReadOnlyList<double> Centers { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The zones this rail places on a sheet, in plate coordinates (the frame of
|
||||||
|
/// <see cref="Plate.ClampZones"/>: origin at the sheet's lower-left corner). The
|
||||||
|
/// quadrant locates the plate origin, and with it the origin-side edge, on the sheet.
|
||||||
|
/// Zones are whole clamp footprints, never clipped, so one reaching past a sheet end
|
||||||
|
/// (or deeper than a narrow sheet) extends beyond the sheet. Ids are <c>clamp-N</c> by
|
||||||
|
/// rail position, so one physical clamp keeps its id on every sheet even when others
|
||||||
|
/// are skipped.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<ClampZone> ResolveFor(Size size, int quadrant)
|
||||||
|
{
|
||||||
|
var length = size.Length;
|
||||||
|
var sheetWidth = size.Width;
|
||||||
|
if (!(length > 0) || !(sheetWidth > 0) || !double.IsFinite(length) || !double.IsFinite(sheetWidth))
|
||||||
|
return Array.Empty<ClampZone>();
|
||||||
|
|
||||||
|
var extendsLeft = quadrant is 2 or 3;
|
||||||
|
var extendsDown = quadrant is 3 or 4;
|
||||||
|
// The origin-side edge is y = 0 of the sheet, or its top when it extends to -Y.
|
||||||
|
var y = extendsDown ? sheetWidth - Depth : 0;
|
||||||
|
var half = Width / 2;
|
||||||
|
|
||||||
|
var zones = new List<ClampZone>(Centers.Count);
|
||||||
|
for (var i = 0; i < Centers.Count; i++)
|
||||||
|
{
|
||||||
|
// Distances from the origin along the edge.
|
||||||
|
var near = Centers[i] - half;
|
||||||
|
var far = Centers[i] + half;
|
||||||
|
if (near >= length)
|
||||||
|
continue; // wholly beyond this sheet
|
||||||
|
|
||||||
|
// Plate coordinates are measured from the lower-left corner; a sheet extending
|
||||||
|
// to -X from the origin has its origin edge at x = length.
|
||||||
|
var x = extendsLeft ? length - far : near;
|
||||||
|
zones.Add(new ClampZone($"clamp-{i + 1}", new Box(x, y, Width, Depth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return zones;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the plate's zones are exactly (ids and bits) this rail's for its sheet.</summary>
|
||||||
|
public bool Matches(Plate plate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
return SameZones(plate.ClampZones, ResolveFor(plate.Size, plate.Quadrant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the plate's zones are exactly this rail's resolution for its sheet minus
|
||||||
|
/// the clamps named in <paramref name="removedClamps"/> (D8 last-resort removal, C13).
|
||||||
|
/// A null or empty set is the same check as <see cref="Matches(Plate)"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Matches(Plate plate, IReadOnlyCollection<string> removedClamps)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
if (removedClamps == null || removedClamps.Count == 0)
|
||||||
|
return Matches(plate);
|
||||||
|
var expected = ResolveFor(plate.Size, plate.Quadrant)
|
||||||
|
.Where(z => !removedClamps.Contains(z.Id))
|
||||||
|
.ToList();
|
||||||
|
return SameZones(plate.ClampZones, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool SameZones(IList<ClampZone> current, IReadOnlyList<ClampZone> expected)
|
||||||
|
{
|
||||||
|
if (current.Count != expected.Count)
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < current.Count; i++)
|
||||||
|
{
|
||||||
|
var a = current[i];
|
||||||
|
var b = expected[i];
|
||||||
|
if (
|
||||||
|
!string.Equals(a.Id, b.Id, StringComparison.Ordinal)
|
||||||
|
|| a.Bounds.X != b.Bounds.X
|
||||||
|
|| a.Bounds.Y != b.Bounds.Y
|
||||||
|
|| a.Bounds.Length != b.Bounds.Length
|
||||||
|
|| a.Bounds.Width != b.Bounds.Width
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(ClampRail other) =>
|
||||||
|
other is not null
|
||||||
|
&& Width.Equals(other.Width)
|
||||||
|
&& Depth.Equals(other.Depth)
|
||||||
|
&& RemovalMinutes.Equals(other.RemovalMinutes)
|
||||||
|
&& Centers.SequenceEqual(other.Centers);
|
||||||
|
|
||||||
|
public override bool Equals(object obj) => Equals(obj as ClampRail);
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
var hash = HashCode.Combine(Width, Depth, RemovalMinutes, Centers.Count);
|
||||||
|
foreach (var center in Centers)
|
||||||
|
hash = HashCode.Combine(hash, center);
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"ClampRail {Width:R} x {Depth:R} at [{string.Join(", ", Centers.Select(c => c.ToString("R")))}]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A fixed rectangular keep-out region in plate coordinates (the sheet's own frame:
|
||||||
|
/// origin at the plate corner, X along the edge, Y depth from it). Machine edge clamps
|
||||||
|
/// are the first users; a future "don't cut here" tag is another entry of this shape.
|
||||||
|
/// Zones are exclusion and display only - never material, quantity, utilization,
|
||||||
|
/// cut sequence or CNC output. Rectangles only: axis-aligned by construction.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClampZone
|
||||||
|
{
|
||||||
|
public ClampZone(string id, Box bounds)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
|
ArgumentNullException.ThrowIfNull(bounds);
|
||||||
|
if (
|
||||||
|
!double.IsFinite(bounds.X)
|
||||||
|
|| !double.IsFinite(bounds.Y)
|
||||||
|
|| !double.IsFinite(bounds.Length)
|
||||||
|
|| !double.IsFinite(bounds.Width)
|
||||||
|
)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Clamp zone '{id}' bounds must have finite coordinates and size.",
|
||||||
|
nameof(bounds)
|
||||||
|
);
|
||||||
|
if (bounds.Length <= 0 || bounds.Width <= 0)
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Clamp zone '{id}' must have a positive length (X) and width (Y).",
|
||||||
|
nameof(bounds)
|
||||||
|
);
|
||||||
|
|
||||||
|
Id = id;
|
||||||
|
// Detached copy: the zone owns its rectangle, callers may keep mutating theirs.
|
||||||
|
Bounds = new Box(bounds.X, bounds.Y, bounds.Length, bounds.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stable identifier used by persistence and diagnostics.</summary>
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The rectangle in plate coordinates: Length is the extent along X, Width the
|
||||||
|
/// extent along Y (depth from the y = 0 operator-side edge for machine clamps).
|
||||||
|
/// </summary>
|
||||||
|
public Box Bounds { get; }
|
||||||
|
|
||||||
|
public override string ToString() =>
|
||||||
|
$"ClampZone '{Id}' [{Bounds.Left:R}, {Bounds.Bottom:R}] x {Bounds.Length:R} x {Bounds.Width:R}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a plate's <see cref="Plate.ClampZones"/>, stored in the sheet's lower-left frame
|
||||||
|
/// (<see cref="ClampRail.ResolveFor"/>), into the signed placement frame of its parts,
|
||||||
|
/// cut-offs and motions. The translation matches the sheet itself and the engine stock:
|
||||||
|
/// -Length on X in quadrants 2 and 3, -Width on Y in quadrants 3 and 4. Whole footprints are
|
||||||
|
/// kept, never clipped. Every plate uses the sheet frame, including plates the engine builds
|
||||||
|
/// from a <c>NestPlateStock</c>, so this is the only translation any consumer applies.
|
||||||
|
/// </summary>
|
||||||
|
public static class ClampZoneFrame
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Detached placement-frame copies in the plate's zone order; empty when there are none.
|
||||||
|
/// Throws <see cref="ArgumentException"/> for a missing or malformed zone: zone bounds stay
|
||||||
|
/// publicly mutable, so construction-time validation is not proof of current geometry.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ClampZone> ToPlacement(Plate plate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
var zones = plate.ClampZones;
|
||||||
|
if (zones == null || zones.Count == 0)
|
||||||
|
return Array.Empty<ClampZone>();
|
||||||
|
if (plate.Quadrant is < 1 or > 4)
|
||||||
|
throw new ArgumentException("Quadrant must be 1 through 4.", nameof(plate));
|
||||||
|
var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0;
|
||||||
|
var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0;
|
||||||
|
if (!double.IsFinite(left) || !double.IsFinite(bottom))
|
||||||
|
throw new ArgumentException("Sheet size must be finite.", nameof(plate));
|
||||||
|
|
||||||
|
var mapped = new ClampZone[zones.Count];
|
||||||
|
for (var i = 0; i < mapped.Length; i++)
|
||||||
|
{
|
||||||
|
var zone = zones[i];
|
||||||
|
if (zone?.Bounds == null)
|
||||||
|
throw new ArgumentException($"Clamp zone {i + 1} is missing.", nameof(plate));
|
||||||
|
var box = zone.Bounds;
|
||||||
|
var x = box.X + left;
|
||||||
|
var y = box.Y + bottom;
|
||||||
|
if (!(box.Length > 0) || !(box.Width > 0) || !double.IsFinite(box.Length)
|
||||||
|
|| !double.IsFinite(box.Width) || !double.IsFinite(box.X) || !double.IsFinite(box.Y)
|
||||||
|
|| !double.IsFinite(x) || !double.IsFinite(y)
|
||||||
|
|| !double.IsFinite(x + box.Length) || !double.IsFinite(y + box.Width))
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Clamp zone '{zone.Id}' must have finite bounds and a positive size.", nameof(plate));
|
||||||
|
mapped[i] = new ClampZone(zone.Id, new Box(x, y, box.Length, box.Width));
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
|
namespace OpenNest.Converters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
|
||||||
|
/// only in each placed part's saved cutting program; the drawing itself had none, so removing
|
||||||
|
/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
|
||||||
|
/// </summary>
|
||||||
|
public static class DrawingMarkRecovery
|
||||||
|
{
|
||||||
|
private const double MatchTolerance = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// For each drawing without etch marks whose saved placed programs all carry the same marks,
|
||||||
|
/// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
|
||||||
|
/// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
|
||||||
|
/// already have marks, are left alone. Returns the number of drawings that were given marks.
|
||||||
|
/// </summary>
|
||||||
|
public static int Apply(Nest nest)
|
||||||
|
{
|
||||||
|
if (nest == null)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
|
||||||
|
var recovered = 0;
|
||||||
|
foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
|
||||||
|
{
|
||||||
|
var drawing = (Drawing)group.Key;
|
||||||
|
if (Apply(drawing, group.ToList()))
|
||||||
|
recovered++;
|
||||||
|
}
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recovers marks for one drawing from its placed parts; true when marks were added.</summary>
|
||||||
|
public static bool Apply(Drawing drawing, IReadOnlyList<Part> placedParts)
|
||||||
|
{
|
||||||
|
if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
|
||||||
|
return false;
|
||||||
|
// An incremental call's end position is not tracked by Program.EndPoint, so only
|
||||||
|
// flat drawings (which is what conversion produces) can be extended safely.
|
||||||
|
if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
|
||||||
|
.ToList();
|
||||||
|
if (saved.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var marks = MarksInDrawingFrame(saved[0]);
|
||||||
|
if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
AppendMarks(drawing.Program, marks);
|
||||||
|
drawing.UpdateArea();
|
||||||
|
drawing.RecomputeCanonicalAngle();
|
||||||
|
|
||||||
|
foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
|
||||||
|
&& !part.HasManualLeadIns))
|
||||||
|
part.Update();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasMarks(Program program) =>
|
||||||
|
program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
|
||||||
|
|| code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The part's marks as they would sit in its drawing: <see cref="Part.RemoveLeadIns"/>
|
||||||
|
/// rebuilds the part as the drawing turned by <see cref="Part.Rotation"/>, so undo that turn.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> MarksInDrawingFrame(Part part)
|
||||||
|
{
|
||||||
|
var marks = ConvertProgram.ToGeometry(part.Program)
|
||||||
|
.Where(entity => entity.Layer == SpecialLayers.Scribe)
|
||||||
|
.ToList();
|
||||||
|
if (!Tolerance.IsEqualTo(part.Rotation, 0))
|
||||||
|
foreach (var mark in marks)
|
||||||
|
mark.Rotate(-part.Rotation);
|
||||||
|
return marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool SameMarks(List<Entity> expected, List<Entity> actual)
|
||||||
|
{
|
||||||
|
if (expected.Count != actual.Count)
|
||||||
|
return false;
|
||||||
|
var unmatched = actual.ToList();
|
||||||
|
foreach (var mark in expected)
|
||||||
|
{
|
||||||
|
var index = unmatched.FindIndex(other => SameMark(mark, other));
|
||||||
|
if (index < 0)
|
||||||
|
return false;
|
||||||
|
unmatched.RemoveAt(index);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction does not matter for an etch: both marks must lie on each other.
|
||||||
|
private static bool SameMark(Entity a, Entity b) =>
|
||||||
|
a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
|
||||||
|
|
||||||
|
private static bool LiesOn(List<Vector> points, Entity entity) =>
|
||||||
|
points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
|
||||||
|
|
||||||
|
private static List<Vector> Samples(Entity entity) => entity switch
|
||||||
|
{
|
||||||
|
Line line => new List<Vector>
|
||||||
|
{
|
||||||
|
line.StartPoint,
|
||||||
|
line.EndPoint,
|
||||||
|
new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
|
||||||
|
},
|
||||||
|
Arc arc => new List<Vector> { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
|
||||||
|
Circle circle => new List<Vector>
|
||||||
|
{
|
||||||
|
new(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||||
|
new(circle.Center.X - circle.Radius, circle.Center.Y),
|
||||||
|
new(circle.Center.X, circle.Center.Y + circle.Radius),
|
||||||
|
new(circle.Center.X, circle.Center.Y - circle.Radius),
|
||||||
|
},
|
||||||
|
_ => new List<Vector>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the marks after the drawing's existing motion, so the existing codes and the
|
||||||
|
/// drawing's first-rapid origin stay exactly as they were.
|
||||||
|
/// </summary>
|
||||||
|
private static void AppendMarks(Program program, List<Entity> marks)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.AddRange(marks);
|
||||||
|
var markProgram = ConvertGeometry.ToProgram(shape);
|
||||||
|
|
||||||
|
// Start from the drawing's end point, with an explicit rapid onto the first mark.
|
||||||
|
var start = EndOf(program);
|
||||||
|
var first = markProgram.Codes.OfType<Motion>().First();
|
||||||
|
var positioned = new Program(Mode.Absolute);
|
||||||
|
positioned.Codes.Add(new RapidMove(start));
|
||||||
|
if (first is not RapidMove)
|
||||||
|
positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
|
||||||
|
positioned.Codes.AddRange(markProgram.Codes);
|
||||||
|
|
||||||
|
if (program.Mode == Mode.Incremental)
|
||||||
|
positioned.Mode = Mode.Incremental;
|
||||||
|
// Drop the move onto the drawing's end point; it is where the drawing already stops.
|
||||||
|
program.Codes.AddRange(positioned.Codes.Skip(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a flat program leaves the head (callers exclude sub-program calls).
|
||||||
|
private static Vector EndOf(Program program)
|
||||||
|
{
|
||||||
|
var position = Vector.Zero;
|
||||||
|
foreach (var motion in program.Codes.OfType<Motion>())
|
||||||
|
position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector StartOf(Entity entity) => entity switch
|
||||||
|
{
|
||||||
|
Line line => line.StartPoint,
|
||||||
|
Arc arc => arc.StartPoint(),
|
||||||
|
Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
|
||||||
|
_ => Vector.Zero,
|
||||||
|
};
|
||||||
|
}
|
||||||
+91
-2
@@ -39,6 +39,72 @@ namespace OpenNest
|
|||||||
Drawing.Program = program;
|
Drawing.Program = program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Measures the same trimmed, filtered segments as regeneration without building a program.</summary>
|
||||||
|
internal (double Length, int SegmentCount) MeasureSegments(
|
||||||
|
Plate plate,
|
||||||
|
CutOffSettings settings,
|
||||||
|
Dictionary<Part, Entity> cache = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var segments = ComputeSegments(plate, settings, cache);
|
||||||
|
return (segments.Sum(s => s.End - s.Start), segments.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when <paramref name="part"/>, the placed part of <paramref name="definition"/>, cuts
|
||||||
|
/// one continuous straight line on the definition's axis across the whole sheet, so it
|
||||||
|
/// splits the sheet in two. Anything else (gaps around parts, limits short of an edge, a
|
||||||
|
/// line on the sheet edge, a stale or unrecognised program) does not separate the sheet.
|
||||||
|
/// </summary>
|
||||||
|
internal static bool SeparatesSheet(Part part, CutOff definition, Box sheet)
|
||||||
|
{
|
||||||
|
if (part?.Program == null || definition == null || sheet == null
|
||||||
|
|| part.Program.Mode != Mode.Absolute)
|
||||||
|
return false;
|
||||||
|
var vertical = definition.Axis == CutOffAxis.Vertical;
|
||||||
|
var line = vertical ? definition.Position.X : definition.Position.Y;
|
||||||
|
var acrossMin = vertical ? sheet.Left : sheet.Bottom;
|
||||||
|
var acrossMax = vertical ? sheet.Right : sheet.Top;
|
||||||
|
var alongMin = vertical ? sheet.Bottom : sheet.Left;
|
||||||
|
var alongMax = vertical ? sheet.Top : sheet.Right;
|
||||||
|
if (!(line > acrossMin + Math.Tolerance.Epsilon && line < acrossMax - Math.Tolerance.Epsilon))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Vector? position = null;
|
||||||
|
double? low = null, high = null;
|
||||||
|
var cutting = false;
|
||||||
|
foreach (var code in part.Program.Codes)
|
||||||
|
{
|
||||||
|
switch (code)
|
||||||
|
{
|
||||||
|
case RapidMove rapid:
|
||||||
|
if (cutting)
|
||||||
|
return false; // A second pierce leaves an uncut gap.
|
||||||
|
position = rapid.EndPoint + part.Location;
|
||||||
|
break;
|
||||||
|
case LinearMove move when position != null:
|
||||||
|
var end = move.EndPoint + part.Location;
|
||||||
|
if (!OnLine(position.Value) || !OnLine(end))
|
||||||
|
return false;
|
||||||
|
foreach (var value in new[] { Along(position.Value), Along(end) })
|
||||||
|
{
|
||||||
|
low = low == null ? value : System.Math.Min(low.Value, value);
|
||||||
|
high = high == null ? value : System.Math.Max(high.Value, value);
|
||||||
|
}
|
||||||
|
position = end;
|
||||||
|
cutting = true;
|
||||||
|
break;
|
||||||
|
case Motion:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cutting && low <= alongMin + Math.Tolerance.Epsilon && high >= alongMax - Math.Tolerance.Epsilon;
|
||||||
|
|
||||||
|
bool OnLine(Vector point) =>
|
||||||
|
System.Math.Abs((vertical ? point.X : point.Y) - line) <= Math.Tolerance.Epsilon;
|
||||||
|
double Along(Vector point) => vertical ? point.Y : point.X;
|
||||||
|
}
|
||||||
|
|
||||||
private string GetName()
|
private string GetName()
|
||||||
{
|
{
|
||||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||||
@@ -134,8 +200,22 @@ namespace OpenNest
|
|||||||
double clearance
|
double clearance
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var bb = part.BoundingBox;
|
var cached = part.BoundingBox;
|
||||||
var roundoff = GetBoundsRoundoff(part);
|
var roundoff = GetBoundsRoundoff(part);
|
||||||
|
// Keep the farther of the cached and converted perimeter extents. The
|
||||||
|
// perimeter's fine intersection/offset can miss a near-tangent sliver;
|
||||||
|
// only a line in that narrow outer band needs the conservative box.
|
||||||
|
var bb = cached;
|
||||||
|
if (perimeter != null)
|
||||||
|
{
|
||||||
|
var converted = perimeter.BoundingBox;
|
||||||
|
var left = System.Math.Min(cached.Left, converted.Left);
|
||||||
|
var right = System.Math.Max(cached.Right, converted.Right);
|
||||||
|
var bottom = System.Math.Min(cached.Bottom, converted.Bottom);
|
||||||
|
var top = System.Math.Max(cached.Top, converted.Top);
|
||||||
|
bb = new Box(left, bottom, right - left, top - bottom);
|
||||||
|
}
|
||||||
|
var (cachedMin, cachedMax) = AxisBounds(cached, clearance);
|
||||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||||
// Match the planner's representational allowance in BOTH pruning and
|
// Match the planner's representational allowance in BOTH pruning and
|
||||||
@@ -150,6 +230,8 @@ namespace OpenNest
|
|||||||
|
|
||||||
if (perimeter != null)
|
if (perimeter != null)
|
||||||
{
|
{
|
||||||
|
if (cutPosition <= cachedMin + roundoff || cutPosition >= cachedMax - roundoff)
|
||||||
|
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||||
var perimeterExclusions = IntersectPerimeter(
|
var perimeterExclusions = IntersectPerimeter(
|
||||||
perimeter,
|
perimeter,
|
||||||
cutPosition,
|
cutPosition,
|
||||||
@@ -180,7 +262,14 @@ namespace OpenNest
|
|||||||
magnitude = System.Math.Max(magnitude,
|
magnitude = System.Math.Max(magnitude,
|
||||||
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
System.Math.Max(System.Math.Abs(part.Location.X), System.Math.Abs(part.Location.Y)));
|
||||||
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
var step = System.Math.BitIncrement(magnitude) - magnitude;
|
||||||
return double.IsFinite(step) ? System.Math.Min(8 * step, Math.Tolerance.Epsilon / 4) : 0;
|
// Imported, nearly tangent arcs can amplify endpoint rounding when
|
||||||
|
// ConvertProgram fits their centers to a chord. Keep that allowance
|
||||||
|
// tiny and apply it to both pruning and fallback exclusions above;
|
||||||
|
// larger refits still require an explicit geometry repair.
|
||||||
|
var arcFitRoundoff = System.Math.Min(4096 * step, Math.Tolerance.Epsilon / 100000);
|
||||||
|
return double.IsFinite(step)
|
||||||
|
? System.Math.Min(System.Math.Max(8 * step, arcFitRoundoff), Math.Tolerance.Epsilon / 4)
|
||||||
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<(double Start, double End)> IntersectPerimeter(
|
private List<(double Start, double End)> IntersectPerimeter(
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>A nominal manual cut-off line meeting an active, physical clamp footprint.</summary>
|
||||||
|
public sealed record CutOffClampFinding(int CutOffIndex, string ClampId, Vector Location);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only axis-aligned clamp contact checks. Nominal cut-off definitions are checked
|
||||||
|
/// even if their current regenerated program is stale or trimmed by nearby parts.
|
||||||
|
/// A removed clamp is absent from Plate.ClampZones and therefore cannot be reported.
|
||||||
|
/// Zones are compared in the plate's signed placement frame (<see cref="ClampZoneFrame"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static class ClampCutDiagnostics
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<CutOffClampFinding> Find(Plate plate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
var findings = new List<CutOffClampFinding>();
|
||||||
|
if (plate.ClampZones.Count == 0 || plate.CutOffs.Count == 0)
|
||||||
|
return findings.AsReadOnly();
|
||||||
|
var zones = ClampZoneFrame.ToPlacement(plate);
|
||||||
|
var sheet = plate.BoundingBox(false);
|
||||||
|
for (var index = 0; index < plate.CutOffs.Count; index++)
|
||||||
|
{
|
||||||
|
var cut = plate.CutOffs[index];
|
||||||
|
if (cut == null || !Enum.IsDefined(cut.Axis))
|
||||||
|
throw new ArgumentException("Cut-off has an invalid axis.", nameof(plate));
|
||||||
|
var start = cut.Axis == CutOffAxis.Vertical
|
||||||
|
? new Vector(cut.Position.X, cut.StartLimit ?? sheet.Bottom)
|
||||||
|
: new Vector(cut.StartLimit ?? sheet.Left, cut.Position.Y);
|
||||||
|
var end = cut.Axis == CutOffAxis.Vertical
|
||||||
|
? new Vector(cut.Position.X, cut.EndLimit ?? sheet.Top)
|
||||||
|
: new Vector(cut.EndLimit ?? sheet.Right, cut.Position.Y);
|
||||||
|
foreach (var zone in zones)
|
||||||
|
if (Intersects(start, end, zone.Bounds, out var location))
|
||||||
|
findings.Add(new CutOffClampFinding(index, zone.Id, location));
|
||||||
|
}
|
||||||
|
return findings.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True for contact with the whole rectangle, including a boundary touch.</summary>
|
||||||
|
public static bool Intersects(Vector from, Vector to, Box box, out Vector location)
|
||||||
|
{
|
||||||
|
location = default;
|
||||||
|
if (box == null || !Finite(from) || !Finite(to) || !Finite(box.Location)
|
||||||
|
|| !double.IsFinite(box.Right) || !double.IsFinite(box.Top)
|
||||||
|
|| box.Length <= 0 || box.Width <= 0)
|
||||||
|
throw new ArgumentException("Clamp contact geometry must be finite and nonempty.");
|
||||||
|
var dx = to.X - from.X;
|
||||||
|
var dy = to.Y - from.Y;
|
||||||
|
if (!double.IsFinite(dx) || !double.IsFinite(dy)
|
||||||
|
|| !double.IsFinite(from.X - box.Left) || !double.IsFinite(box.Right - from.X)
|
||||||
|
|| !double.IsFinite(from.Y - box.Bottom) || !double.IsFinite(box.Top - from.Y))
|
||||||
|
throw new ArgumentException("Clamp contact motion is too large.");
|
||||||
|
var enter = 0.0;
|
||||||
|
var exit = 1.0;
|
||||||
|
if (!Clip(-dx, from.X - box.Left, ref enter, ref exit)
|
||||||
|
|| !Clip(dx, box.Right - from.X, ref enter, ref exit)
|
||||||
|
|| !Clip(-dy, from.Y - box.Bottom, ref enter, ref exit)
|
||||||
|
|| !Clip(dy, box.Top - from.Y, ref enter, ref exit))
|
||||||
|
return false;
|
||||||
|
var midpoint = (enter + exit) * 0.5;
|
||||||
|
location = new Vector(from.X + dx * midpoint, from.Y + dy * midpoint);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Clip(double p, double q, ref double enter, ref double exit)
|
||||||
|
{
|
||||||
|
if (p == 0)
|
||||||
|
return q >= 0;
|
||||||
|
var ratio = q / p;
|
||||||
|
if (p < 0)
|
||||||
|
enter = System.Math.Max(enter, ratio);
|
||||||
|
else
|
||||||
|
exit = System.Math.Min(exit, ratio);
|
||||||
|
return enter <= exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool Finite(Vector value) => double.IsFinite(value.X) && double.IsFinite(value.Y);
|
||||||
|
}
|
||||||
@@ -11,20 +11,71 @@ public sealed class OverlapGeometryStamp
|
|||||||
{
|
{
|
||||||
private readonly Plate plate;
|
private readonly Plate plate;
|
||||||
private readonly Entry[] entries;
|
private readonly Entry[] entries;
|
||||||
|
private readonly (string Id, long X, long Y, long Length, long Width)[] zones;
|
||||||
|
private readonly (CutOff Definition, CutOffAxis Axis, long X, long Y, long? Start, long? End)[] cutoffs;
|
||||||
|
private readonly (long Width, long Length, int Quadrant) sheet;
|
||||||
|
|
||||||
private OverlapGeometryStamp(Plate plate)
|
private OverlapGeometryStamp(Plate plate)
|
||||||
{
|
{
|
||||||
this.plate = plate;
|
this.plate = plate;
|
||||||
|
sheet = SheetState(plate);
|
||||||
entries = new Entry[plate.Parts.Count];
|
entries = new Entry[plate.Parts.Count];
|
||||||
for (var i = 0; i < entries.Length; i++)
|
for (var i = 0; i < entries.Length; i++)
|
||||||
entries[i] = new Entry(plate.Parts[i]);
|
entries[i] = new Entry(plate.Parts[i]);
|
||||||
|
zones = new (string, long, long, long, long)[plate.ClampZones.Count];
|
||||||
|
for (var i = 0; i < zones.Length; i++)
|
||||||
|
zones[i] = ZoneState(plate.ClampZones[i]);
|
||||||
|
cutoffs = new (CutOff, CutOffAxis, long, long, long?, long?)[plate.CutOffs.Count];
|
||||||
|
for (var i = 0; i < cutoffs.Length; i++)
|
||||||
|
cutoffs[i] = CutOffState(plate.CutOffs[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Missing entries are malformed input the analysis itself reports; freshness must not
|
||||||
|
// throw first (the desktop captures it before its error boundary).
|
||||||
|
private static (string, long, long, long, long) ZoneState(ClampZone zone) =>
|
||||||
|
zone?.Bounds is not { } box ? default
|
||||||
|
: (zone.Id, Bits(box.X), Bits(box.Y), Bits(box.Length), Bits(box.Width));
|
||||||
|
|
||||||
|
private static (CutOff, CutOffAxis, long, long, long?, long?) CutOffState(CutOff cut) =>
|
||||||
|
cut == null ? default
|
||||||
|
: (cut, cut.Axis, Bits(cut.Position.X), Bits(cut.Position.Y),
|
||||||
|
cut.StartLimit.HasValue ? Bits(cut.StartLimit.Value) : null,
|
||||||
|
cut.EndLimit.HasValue ? Bits(cut.EndLimit.Value) : null);
|
||||||
|
|
||||||
|
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
|
||||||
|
|
||||||
|
private static (long, long, int) SheetState(Plate plate) =>
|
||||||
|
(Bits(plate.Size.Width), Bits(plate.Size.Length), plate.Quadrant);
|
||||||
|
|
||||||
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
|
||||||
|
|
||||||
|
internal bool SameZones(Plate current)
|
||||||
|
{
|
||||||
|
// Stored zones are sheet-relative: a size or quadrant change moves them in the
|
||||||
|
// parts' frame without changing their bits.
|
||||||
|
if (!ReferenceEquals(plate, current) || current.ClampZones.Count != zones.Length
|
||||||
|
|| sheet != SheetState(current))
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < zones.Length; i++)
|
||||||
|
if (zones[i] != ZoneState(current.ClampZones[i]))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool SameCutOffs(Plate current)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(plate, current) || current.CutOffs.Count != cutoffs.Length
|
||||||
|
|| sheet != SheetState(current))
|
||||||
|
return false;
|
||||||
|
for (var i = 0; i < cutoffs.Length; i++)
|
||||||
|
if (cutoffs[i] != CutOffState(current.CutOffs[i]))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public bool Matches(Plate current)
|
public bool Matches(Plate current)
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
|
if (current.Parts.Count != entries.Length || !SameZones(current) || !SameCutOffs(current))
|
||||||
return false;
|
return false;
|
||||||
for (var i = 0; i < entries.Length; i++)
|
for (var i = 0; i < entries.Length; i++)
|
||||||
if (!entries[i].Matches(current.Parts[i]))
|
if (!entries[i].Matches(current.Parts[i]))
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ namespace OpenNest.Diagnostics;
|
|||||||
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
/// <summary>Read-only pair presentation; no geometry preparation or collision queries.</summary>
|
||||||
public static class OverlapPairPresentation
|
public static class OverlapPairPresentation
|
||||||
{
|
{
|
||||||
public static string Label(PlateOverlapPair pair) => $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
/// <summary>True for part-vs-clamp findings carried on the report (negative clamp slot).</summary>
|
||||||
|
public static bool IsClampFinding(PlateOverlapPair pair) => pair.PartBId < 0;
|
||||||
|
|
||||||
|
public static string Label(PlateOverlapPair pair) =>
|
||||||
|
IsClampFinding(pair) ? $"{pair.PartAId + 1}/clamp" : $"{pair.PartAId + 1}/{pair.PartBId + 1}";
|
||||||
|
|
||||||
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
public static string Details(PlateOverlapPair pair, Units capturedUnits, IFormatProvider provider = null)
|
||||||
{
|
{
|
||||||
var units = UnitsHelper.GetShortString(capturedUnits);
|
var units = UnitsHelper.GetShortString(capturedUnits);
|
||||||
return $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}\n"
|
var head = IsClampFinding(pair)
|
||||||
|
? $"Clamp overlap: {pair.PartAName} / {pair.PartBName}"
|
||||||
|
: $"Pair {Label(pair)}: {pair.PartAName} / {pair.PartBName}";
|
||||||
|
return head + "\n"
|
||||||
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
+ $"Shared area ≈ {Number(pair.Area, provider)} {units}²\n"
|
||||||
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
+ $"Centroid: ({Number(pair.Centroid.X, provider)}, {Number(pair.Centroid.Y, provider)}) {units}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,22 @@ public sealed class OverlapReportState
|
|||||||
public PlateOverlapReport Report { get; private set; }
|
public PlateOverlapReport Report { get; private set; }
|
||||||
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
|
/// <summary>Known overlap pairs safe to draw, even while the full layout needs a recheck.</summary>
|
||||||
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
public IReadOnlyList<PlateOverlapPair> DisplayPairs { get; private set; } = Array.Empty<PlateOverlapPair>();
|
||||||
|
public IReadOnlyList<CutOffClampFinding> DisplayCutOffClampFindings { get; private set; } =
|
||||||
|
Array.Empty<CutOffClampFinding>();
|
||||||
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
public bool IsRunning => Status == OverlapCheckStatus.Checking;
|
||||||
|
|
||||||
public string Message => Status switch
|
public string Message => Status switch
|
||||||
{
|
{
|
||||||
OverlapCheckStatus.Checking => "Checking overlaps…",
|
OverlapCheckStatus.Checking => "Checking overlaps…",
|
||||||
OverlapCheckStatus.Current => Report.Pairs.Count == 0
|
OverlapCheckStatus.Current => Report.ClampFindings.Count == 0 && Report.CutOffClampFindings.Count == 0
|
||||||
? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
|
? (Report.Pairs.Count == 0 ? "No material overlaps detected"
|
||||||
|
: $"Overlaps: {Report.Pairs.Count} pairs")
|
||||||
|
: $"Overlaps: {Report.Pairs.Count} pairs, {Report.ClampFindings.Count} clamps, "
|
||||||
|
+ $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts",
|
||||||
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
|
||||||
|
+ (Report.ClampFindings.Count == 0 ? "" : $"{Report.ClampFindings.Count} clamp findings; ")
|
||||||
|
+ (Report.CutOffClampFindings.Count == 0 ? ""
|
||||||
|
: $"{Report.CutOffClampFindings.Count} cut-off/clamp contacts; ")
|
||||||
+ $"{uncheckedPartCount} parts could not be checked",
|
+ $"{uncheckedPartCount} parts could not be checked",
|
||||||
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
|
||||||
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
OverlapCheckStatus.Canceled => "Overlap check canceled",
|
||||||
@@ -55,7 +63,9 @@ public sealed class OverlapReportState
|
|||||||
if (!CanComplete(generation, plate))
|
if (!CanComplete(generation, plate))
|
||||||
return false;
|
return false;
|
||||||
Report = report;
|
Report = report;
|
||||||
DisplayPairs = report.Pairs;
|
DisplayPairs = report.ClampFindings.Count == 0 ? report.Pairs
|
||||||
|
: report.Pairs.Concat(report.ClampFindings).ToList().AsReadOnly();
|
||||||
|
DisplayCutOffClampFindings = report.CutOffClampFindings;
|
||||||
displayStamp = stamp;
|
displayStamp = stamp;
|
||||||
observedDisplayStamp = stamp;
|
observedDisplayStamp = stamp;
|
||||||
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
uncheckedPartCount = CountUncheckedParts(report.Issues);
|
||||||
@@ -106,10 +116,14 @@ public sealed class OverlapReportState
|
|||||||
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
|
if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true)
|
||||||
return;
|
return;
|
||||||
var unchanged = displayStamp.UnchangedSlots(plate);
|
var unchanged = displayStamp.UnchangedSlots(plate);
|
||||||
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList();
|
var sameZones = displayStamp.SameZones(plate);
|
||||||
|
if (!sameZones || !displayStamp.SameCutOffs(plate))
|
||||||
|
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||||
|
var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId]
|
||||||
|
&& (pair.PartBId < 0 ? sameZones : unchanged[pair.PartBId])).ToList();
|
||||||
if (retained.Count != DisplayPairs.Count)
|
if (retained.Count != DisplayPairs.Count)
|
||||||
DisplayPairs = retained.AsReadOnly();
|
DisplayPairs = retained.AsReadOnly();
|
||||||
if (DisplayPairs.Count == 0)
|
if (DisplayPairs.Count == 0 && DisplayCutOffClampFindings.Count == 0)
|
||||||
ClearDisplayPairs();
|
ClearDisplayPairs();
|
||||||
else
|
else
|
||||||
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
observedDisplayStamp = OverlapGeometryStamp.Capture(plate);
|
||||||
@@ -118,6 +132,7 @@ public sealed class OverlapReportState
|
|||||||
private void ClearDisplayPairs()
|
private void ClearDisplayPairs()
|
||||||
{
|
{
|
||||||
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
DisplayPairs = Array.Empty<PlateOverlapPair>();
|
||||||
|
DisplayCutOffClampFindings = Array.Empty<CutOffClampFinding>();
|
||||||
displayStamp = null;
|
displayStamp = null;
|
||||||
observedDisplayStamp = null;
|
observedDisplayStamp = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingPlanning;
|
||||||
using OpenNest.Converters;
|
using OpenNest.Converters;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -16,13 +17,41 @@ public static class PlateOverlapAnalyzer
|
|||||||
{
|
{
|
||||||
public const double ChordTolerance = 0.001;
|
public const double ChordTolerance = 0.001;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Capture a whole plate including active clamps and nominal cut-off definitions.
|
||||||
|
/// Use this entry for the desktop overlap check so manual cut-off contacts
|
||||||
|
/// cannot be mistaken for a clear plate merely because cut-offs are not material.
|
||||||
|
/// Clamp zones are mapped from the sheet's lower-left frame into the parts' signed
|
||||||
|
/// placement frame; a malformed zone throws <see cref="ArgumentException"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateOverlapSnapshot CapturePlate(Plate plate, OverlapMaterialCache cache = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
var material = Capture(plate.Parts.ToArray(), ClampZoneFrame.ToPlacement(plate).ToArray(),
|
||||||
|
cache ?? new OverlapMaterialCache(), cancellationToken);
|
||||||
|
var cutoffs = ClampCutDiagnostics.Find(plate);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return new PlateOverlapSnapshot(material.Parts.ToList(), material.Issues.ToList(),
|
||||||
|
material.Clamps.ToList(), cutoffs.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Captures poses and converts each distinct clean source program to owned entities once.
|
/// Captures poses and converts each distinct clean source program to owned entities once.
|
||||||
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
/// Inputs must not change during capture. Later analysis never reads live domain objects.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Capture(parts, new OverlapMaterialCache(), cancellationToken);
|
Capture(parts, null, new OverlapMaterialCache(), cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, additionally capturing
|
||||||
|
/// the plate's clamp keep-out rectangles: Analyze then reports part-vs-clamp findings
|
||||||
|
/// (C1) alongside part pairs. Null means no zones.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||||
|
IReadOnlyList<ClampZone> clampZones, CancellationToken cancellationToken = default) =>
|
||||||
|
Capture(parts, clampZones, new OverlapMaterialCache(), cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
|
||||||
@@ -30,6 +59,12 @@ public static class PlateOverlapAnalyzer
|
|||||||
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Capture(parts, null, cache, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="Capture(IReadOnlyList{Part}, IReadOnlyList{ClampZone}, CancellationToken)"/>
|
||||||
|
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
|
||||||
|
IReadOnlyList<ClampZone> clampZones, OverlapMaterialCache cache,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(parts);
|
ArgumentNullException.ThrowIfNull(parts);
|
||||||
@@ -63,8 +98,21 @@ public static class PlateOverlapAnalyzer
|
|||||||
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
issues.Add(new PlateOverlapIssue(id, null, exception.Message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var clamps = new List<CapturedClamp>();
|
||||||
|
if (clampZones != null)
|
||||||
|
{
|
||||||
|
for (var ordinal = 0; ordinal < clampZones.Count; ordinal++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var zone = clampZones[ordinal];
|
||||||
|
// ClampZone validates finiteness and positive size at construction; the
|
||||||
|
// snapshot still owns its own copy so later plate edits never move it.
|
||||||
|
clamps.Add(new CapturedClamp(ordinal, zone.Id,
|
||||||
|
new Box(zone.Bounds.X, zone.Bounds.Y, zone.Bounds.Length, zone.Bounds.Width)));
|
||||||
|
}
|
||||||
|
}
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return new PlateOverlapSnapshot(captured, issues);
|
return new PlateOverlapSnapshot(captured, issues, clamps);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OverlapSource CaptureSource(Program program)
|
private static OverlapSource CaptureSource(Program program)
|
||||||
@@ -73,8 +121,9 @@ public static class PlateOverlapAnalyzer
|
|||||||
{
|
{
|
||||||
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
|
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance), false);
|
||||||
// Conversion creates fresh geometry, including expanded shared hole calls;
|
// Conversion creates fresh geometry, including expanded shared hole calls;
|
||||||
// no cloning/rotation of a live program or subprogram is necessary.
|
// no cloning/rotation of a live program or subprogram is necessary. A baked-in
|
||||||
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
|
// seam overcut is trimmed on an owned copy: it encloses no area.
|
||||||
|
return new OverlapSource(program, ConvertProgram.ToGeometry(SeamOvercut.Trimmed(program))
|
||||||
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
|
||||||
&& entity.Layer != SpecialLayers.Leadin
|
&& entity.Layer != SpecialLayers.Leadin
|
||||||
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
|
||||||
@@ -192,9 +241,120 @@ public static class PlateOverlapAnalyzer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var clampFindings = FindClampOverlaps(prepared, snapshot, issues, cancellationToken);
|
||||||
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
|
||||||
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
|
||||||
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
|
.ThenBy(issue => issue.PartBId).ToList(), snapshot, clampFindings
|
||||||
|
.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId).ToList(),
|
||||||
|
snapshot.CutOffClampFindings.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C1 part-vs-clamp findings: the prepared hole-aware material against each captured
|
||||||
|
/// zone rectangle with the same exact Collision oracle as part pairs. Deterministic in
|
||||||
|
/// (part input id, clamp ordinal) order; failures become part issues like any other
|
||||||
|
/// pair geometry failure. Empty zone lists return without touching the material.
|
||||||
|
/// </summary>
|
||||||
|
private static List<PlateOverlapPair> FindClampOverlaps(List<PreparedPart> prepared,
|
||||||
|
PlateOverlapSnapshot snapshot, List<PlateOverlapIssue> issues,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var findings = new List<PlateOverlapPair>();
|
||||||
|
if (snapshot.Clamps.Count == 0)
|
||||||
|
return findings;
|
||||||
|
|
||||||
|
foreach (var part in prepared)
|
||||||
|
{
|
||||||
|
var partBounds = part.Material.Outer.BoundingBox;
|
||||||
|
foreach (var clamp in snapshot.Clamps)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var zone = clamp.Bounds;
|
||||||
|
// The prepared material is inscribed at ChordTolerance, so real material can
|
||||||
|
// reach that far past its bounds; a tiny clamp there must still be checked.
|
||||||
|
var reach = KeepOutOverlap.NeedsToleranceFreeTest(zone) ? ChordTolerance : 0;
|
||||||
|
if (partBounds.Right + reach <= zone.Left || zone.Right <= partBounds.Left - reach
|
||||||
|
|| partBounds.Top + reach <= zone.Bottom || zone.Top <= partBounds.Bottom - reach)
|
||||||
|
continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A clamp below the polygon tolerance collapses in Collision.Check and would
|
||||||
|
// read as clear (PM aa995123): judge it tolerance-free and report the whole
|
||||||
|
// (negligibly small) clamp rectangle as the overlap fragment.
|
||||||
|
if (KeepOutOverlap.NeedsToleranceFreeTest(zone))
|
||||||
|
{
|
||||||
|
// Re-read the native contours conservatively and finely enough for
|
||||||
|
// this clamp: the shared 0.001 chords can hide material between an
|
||||||
|
// arc and its chord.
|
||||||
|
var profile = new ShapeProfile(part.Input.Source.Entities.Select(e => e.Clone()).ToList());
|
||||||
|
if (profile.Perimeter == null)
|
||||||
|
throw new ArgumentException("Drawing has no closed material contour.");
|
||||||
|
if (KeepOutOverlap.ConservativePolygons(profile, zone)
|
||||||
|
.Select(polygons => new OverlapMaterial(polygons.Perimeter, polygons.Holes)
|
||||||
|
.Transform(part.Input.Rotation, part.Input.Location))
|
||||||
|
.Any(fine => KeepOutOverlap.MaterialEnters(fine.Outer, fine.Holes, zone)))
|
||||||
|
findings.Add(TinyClampFinding(part, clamp));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var origin = part.Material.Outer.Vertices[0];
|
||||||
|
var local = part.Material.Transform(0, origin * -1);
|
||||||
|
var rect = RectanglePolygon(zone, origin * -1);
|
||||||
|
var result = Collision.Check(local.Outer, rect, local.Holes, null);
|
||||||
|
if (!result.Overlaps)
|
||||||
|
continue;
|
||||||
|
var moments = new List<PolygonAreaMoments>();
|
||||||
|
var regions = new List<PlateOverlapRegion>();
|
||||||
|
foreach (var region in result.OverlapRegions)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!PolygonAreaMoments.TryCompute(region.Vertices, out var fragment))
|
||||||
|
throw new ArithmeticException("Clamp overlap fragment area moments are invalid.");
|
||||||
|
moments.Add(fragment);
|
||||||
|
regions.Add(new PlateOverlapRegion(
|
||||||
|
region.Vertices.Select(point => point + origin), fragment.Area));
|
||||||
|
}
|
||||||
|
if (!PolygonAreaMoments.TryCombine(moments, out var combined))
|
||||||
|
throw new ArithmeticException("Combined clamp overlap area moments are invalid.");
|
||||||
|
var centroid = combined.Centroid + origin;
|
||||||
|
if (!OverlapMaterial.IsFinite(centroid))
|
||||||
|
throw new ArithmeticException("Clamp overlap centroid is not finite in world coordinates.");
|
||||||
|
findings.Add(new PlateOverlapPair(part.Input.Id,
|
||||||
|
ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||||
|
$"Clamp '{clamp.Id}'", regions, centroid));
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsGeometryFailure(exception))
|
||||||
|
{
|
||||||
|
issues.Add(new PlateOverlapIssue(part.Input.Id, null, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateOverlapPair TinyClampFinding(PreparedPart part, CapturedClamp clamp)
|
||||||
|
{
|
||||||
|
var zone = clamp.Bounds;
|
||||||
|
var corners = new[]
|
||||||
|
{
|
||||||
|
new Vector(zone.Left, zone.Bottom), new Vector(zone.Right, zone.Bottom),
|
||||||
|
new Vector(zone.Right, zone.Top), new Vector(zone.Left, zone.Top),
|
||||||
|
};
|
||||||
|
var centre = new Vector((zone.Left + zone.Right) / 2, (zone.Bottom + zone.Top) / 2);
|
||||||
|
return new PlateOverlapPair(part.Input.Id, ClampFindingSlot.IdFor(clamp.Ordinal), part.Input.Name,
|
||||||
|
$"Clamp '{clamp.Id}'", new List<PlateOverlapRegion> { new(corners, zone.Length * zone.Width) }, centre);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Polygon RectanglePolygon(Box box, Vector offset)
|
||||||
|
{
|
||||||
|
var polygon = new Polygon();
|
||||||
|
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Bottom + offset.Y));
|
||||||
|
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Bottom + offset.Y));
|
||||||
|
polygon.Vertices.Add(new Vector(box.Right + offset.X, box.Top + offset.Y));
|
||||||
|
polygon.Vertices.Add(new Vector(box.Left + offset.X, box.Top + offset.Y));
|
||||||
|
polygon.Close();
|
||||||
|
polygon.UpdateBounds();
|
||||||
|
return polygon;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ namespace OpenNest.Diagnostics;
|
|||||||
public sealed class PlateOverlapReport
|
public sealed class PlateOverlapReport
|
||||||
{
|
{
|
||||||
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
|
||||||
PlateOverlapSnapshot snapshot)
|
PlateOverlapSnapshot snapshot, List<PlateOverlapPair> clampFindings = null,
|
||||||
|
List<CutOffClampFinding> cutOffClampFindings = null)
|
||||||
{
|
{
|
||||||
Pairs = pairs.AsReadOnly();
|
Pairs = pairs.AsReadOnly();
|
||||||
Issues = issues.AsReadOnly();
|
Issues = issues.AsReadOnly();
|
||||||
Snapshot = snapshot;
|
Snapshot = snapshot;
|
||||||
|
ClampFindings = (clampFindings ?? new List<PlateOverlapPair>()).AsReadOnly();
|
||||||
|
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
|
||||||
@@ -20,6 +23,16 @@ public sealed class PlateOverlapReport
|
|||||||
|
|
||||||
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
|
||||||
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Part-vs-clamp-zone findings (C1): the same owned-fragment shape as part pairs, with a
|
||||||
|
/// negative <see cref="PlateOverlapPair.PartBId"/> slot encoding the zone ordinal. Findings
|
||||||
|
/// are reported like other overlap findings; they do not change <see cref="IsComplete"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<PlateOverlapPair> ClampFindings { get; }
|
||||||
|
/// <summary>Nominal cut-off/clamp contacts. Lines have no material area or part pair.</summary>
|
||||||
|
public IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||||
|
|
||||||
public bool IsComplete => Issues.Count == 0;
|
public bool IsComplete => Issues.Count == 0;
|
||||||
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
public double ChordTolerance => PlateOverlapAnalyzer.ChordTolerance;
|
||||||
}
|
}
|
||||||
@@ -102,15 +115,32 @@ public sealed record PlateOverlapIssue(int PartAId, int? PartBId, string Message
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PlateOverlapSnapshot
|
public sealed class PlateOverlapSnapshot
|
||||||
{
|
{
|
||||||
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues)
|
internal PlateOverlapSnapshot(List<CapturedOverlapPart> parts, List<PlateOverlapIssue> issues,
|
||||||
|
List<CapturedClamp> clamps = null, List<CutOffClampFinding> cutOffClampFindings = null)
|
||||||
{
|
{
|
||||||
Parts = parts.AsReadOnly();
|
Parts = parts.AsReadOnly();
|
||||||
Issues = issues.AsReadOnly();
|
Issues = issues.AsReadOnly();
|
||||||
|
Clamps = (clamps ?? new List<CapturedClamp>()).AsReadOnly();
|
||||||
|
CutOffClampFindings = (cutOffClampFindings ?? new List<CutOffClampFinding>()).AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
internal IReadOnlyList<CapturedOverlapPart> Parts { get; }
|
||||||
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
|
||||||
|
|
||||||
|
/// <summary>Owned clamp keep-out rectangles in plate coordinates, capture order.</summary>
|
||||||
|
internal IReadOnlyList<CapturedClamp> Clamps { get; }
|
||||||
|
internal IReadOnlyList<CutOffClampFinding> CutOffClampFindings { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
|
||||||
double Rotation, Vector Location);
|
double Rotation, Vector Location);
|
||||||
|
|
||||||
|
/// <summary>A clamp keep-out rectangle owned by the snapshot; Ordinal is its capture index.</summary>
|
||||||
|
internal sealed record CapturedClamp(int Ordinal, string Id, Box Bounds);
|
||||||
|
|
||||||
|
/// <summary>Deterministic negative PartBId for a part-vs-clamp finding's clamp slot.</summary>
|
||||||
|
internal static class ClampFindingSlot
|
||||||
|
{
|
||||||
|
public static int IdFor(int ordinal) => -2 - ordinal;
|
||||||
|
public static int OrdinalOf(int pairPartBId) => -2 - pairPartBId;
|
||||||
|
}
|
||||||
@@ -56,6 +56,51 @@ public static class PostVerificationAnalyzer
|
|||||||
}
|
}
|
||||||
var materialParts = new List<Part>();
|
var materialParts = new List<Part>();
|
||||||
var indices = new List<int>();
|
var indices = new List<int>();
|
||||||
|
// Stored zones use the sheet's lower-left frame; motions and parts are signed.
|
||||||
|
// Validate them before any early exit: zone bounds are publicly mutable.
|
||||||
|
IReadOnlyList<ClampZone> clamps;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
clamps = ClampZoneFrame.ToPlacement(plate);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
clamps = null;
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||||
|
"Clamp check: " + exception.Message));
|
||||||
|
}
|
||||||
|
// One clamp warning per cut-off part and clamp: the placed-program check below
|
||||||
|
// reports only what the nominal definition check has not already reported.
|
||||||
|
var cutOffClampContacts = new HashSet<(int PartNumber, string ClampId)>();
|
||||||
|
if (clamps != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var contact in ClampCutDiagnostics.Find(plate))
|
||||||
|
{
|
||||||
|
var cut = plate.CutOffs[contact.CutOffIndex];
|
||||||
|
var partIndex = -1;
|
||||||
|
for (var partSlot = 0; partSlot < plate.Parts.Count; partSlot++)
|
||||||
|
if (ReferenceEquals(plate.Parts[partSlot]?.BaseDrawing, cut.Drawing))
|
||||||
|
{
|
||||||
|
partIndex = partSlot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (partIndex >= 0)
|
||||||
|
cutOffClampContacts.Add((partIndex + 1, contact.ClampId));
|
||||||
|
findings.Add(new(PostVerificationKind.Overlap, plateNumber,
|
||||||
|
partIndex < 0 ? null : partIndex + 1, null,
|
||||||
|
$"Manual cut-off {contact.CutOffIndex + 1} crosses active clamp '{contact.ClampId}'. "
|
||||||
|
+ "Review the clamp and cut before posting; this warning is not a repair.")
|
||||||
|
{ Location = contact.Location, ClampId = contact.ClampId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
|
{
|
||||||
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, null, null,
|
||||||
|
"Clamp/cut-off check: " + exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
var obstacles = new ReleasedContourState();
|
var obstacles = new ReleasedContourState();
|
||||||
Vector? position = startPoint;
|
Vector? position = startPoint;
|
||||||
for (var index = 0; index < plate.Parts.Count; index++)
|
for (var index = 0; index < plate.Parts.Count; index++)
|
||||||
@@ -95,6 +140,46 @@ public static class PostVerificationAnalyzer
|
|||||||
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
&& move.Curve.Length > PostVerificationGeometry.Epsilon))
|
||||||
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
Incomplete("Placed program has no cutting contour motions for this drawing.");
|
||||||
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
obstacles.AnalyzeMoves(moves, cutoff, findings, plateNumber, partNumber, cancellationToken);
|
||||||
|
if (clamps is { Count: > 0 })
|
||||||
|
foreach (var move in moves)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!move.Rapid || move.Start is not { } from
|
||||||
|
|| from.X == move.End.X && from.Y == move.End.Y)
|
||||||
|
continue;
|
||||||
|
foreach (var zone in clamps)
|
||||||
|
if (ClampCutDiagnostics.Intersects(from, move.End, zone.Bounds, out var contact))
|
||||||
|
findings.Add(new(PostVerificationKind.RapidCrossing, plateNumber,
|
||||||
|
partNumber, null,
|
||||||
|
$"Direct XY rapid crosses active clamp '{zone.Id}'. "
|
||||||
|
+ "Head height is not modeled; review the posted route and clamp setup.")
|
||||||
|
{
|
||||||
|
Location = (from + move.End) * 0.5,
|
||||||
|
RapidStart = from,
|
||||||
|
RapidEnd = move.End,
|
||||||
|
ContactPoints = new[] { contact },
|
||||||
|
ClampId = zone.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The placed program is what posts: a definition edited without
|
||||||
|
// regenerating it can still cut through a clamp its nominal line misses.
|
||||||
|
if (cutoff && clamps is { Count: > 0 })
|
||||||
|
foreach (var move in moves)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (move.Rapid || move.Start is not { } from)
|
||||||
|
continue;
|
||||||
|
foreach (var zone in clamps)
|
||||||
|
if (!cutOffClampContacts.Contains((partNumber, zone.Id))
|
||||||
|
&& CutTouches(move, from, zone.Bounds, out var contact))
|
||||||
|
{
|
||||||
|
cutOffClampContacts.Add((partNumber, zone.Id));
|
||||||
|
findings.Add(new(PostVerificationKind.Overlap, plateNumber, partNumber, null,
|
||||||
|
$"The placed cut-off program crosses active clamp '{zone.Id}'. "
|
||||||
|
+ "Regenerate or move the cut-off before posting; this warning is not a repair.")
|
||||||
|
{ Location = contact, ClampId = zone.Id });
|
||||||
|
}
|
||||||
|
}
|
||||||
position = moves[^1].End;
|
position = moves[^1].End;
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (IsInvalid(exception))
|
catch (Exception exception) when (IsInvalid(exception))
|
||||||
@@ -110,10 +195,17 @@ public static class PostVerificationAnalyzer
|
|||||||
}
|
}
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var overlap = PlateOverlapAnalyzer.Analyze(
|
var overlap = PlateOverlapAnalyzer.Analyze(
|
||||||
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
|
PlateOverlapAnalyzer.Capture(materialParts, (clamps ?? Array.Empty<ClampZone>()).ToArray(),
|
||||||
|
cancellationToken),
|
||||||
|
cancellationToken);
|
||||||
foreach (var pair in overlap.Pairs)
|
foreach (var pair in overlap.Pairs)
|
||||||
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||||
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
|
indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
|
||||||
|
{ Location = pair.Centroid, Overlap = pair });
|
||||||
|
foreach (var pair in overlap.ClampFindings)
|
||||||
|
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
|
||||||
|
null, $"Clean drawing material overlaps {pair.PartBName} (holes subtracted).")
|
||||||
|
{ Location = pair.Centroid, Overlap = pair });
|
||||||
foreach (var issue in overlap.Issues)
|
foreach (var issue in overlap.Issues)
|
||||||
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
|
||||||
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
|
||||||
@@ -122,6 +214,19 @@ public static class PostVerificationAnalyzer
|
|||||||
return new PostVerificationReport(findings);
|
return new PostVerificationReport(findings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lines are tested exactly; an arc conservatively by its whole supporting extent.
|
||||||
|
private static bool CutTouches(ExecutionMotion move, Vector from, Box zone, out Vector contact)
|
||||||
|
{
|
||||||
|
if (move.Curve == null || move.Curve.IsLine)
|
||||||
|
return ClampCutDiagnostics.Intersects(from, move.End, zone, out contact);
|
||||||
|
var extent = move.Curve.Extent;
|
||||||
|
contact = new Vector(
|
||||||
|
System.Math.Clamp(move.Curve.Midpoint.X, zone.Left, zone.Right),
|
||||||
|
System.Math.Clamp(move.Curve.Midpoint.Y, zone.Bottom, zone.Top));
|
||||||
|
return !(extent.MinX > zone.Right || extent.MaxX < zone.Left
|
||||||
|
|| extent.MinY > zone.Top || extent.MaxY < zone.Bottom);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsInvalid(Exception exception) => exception is
|
private static bool IsInvalid(Exception exception) => exception is
|
||||||
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ namespace OpenNest.Diagnostics;
|
|||||||
internal static class PostVerificationGeometry
|
internal static class PostVerificationGeometry
|
||||||
{
|
{
|
||||||
internal const double Epsilon = 1e-8;
|
internal const double Epsilon = 1e-8;
|
||||||
|
// Matches NominalContourClosure.Tolerance: the largest gap capture admits as a closed ring.
|
||||||
|
// Ray parity snaps vertex Y comparisons within this band so an admitted seam cannot flip an
|
||||||
|
// exterior point to interior (see Curve.CrossesRay).
|
||||||
|
internal const double RaySeamTolerance = 1e-6;
|
||||||
private const double TwoPi = 2 * System.Math.PI;
|
private const double TwoPi = 2 * System.Math.PI;
|
||||||
|
|
||||||
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
|
/// <summary>An axis-aligned extent used only to skip queries that cannot meet.</summary>
|
||||||
@@ -89,6 +93,30 @@ internal static class PostVerificationGeometry
|
|||||||
return inside;
|
return inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<Vector> ContactPoints(Vector start, Vector end,
|
||||||
|
IReadOnlyList<Curve> curves, CancellationToken token)
|
||||||
|
{
|
||||||
|
var length = start.DistanceTo(end);
|
||||||
|
if (length <= Epsilon)
|
||||||
|
return Array.Empty<Vector>();
|
||||||
|
var direction = (end - start) * (1 / length);
|
||||||
|
var distances = new List<double>();
|
||||||
|
foreach (var curve in curves)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
curve.ContactAfterStart(start, direction, length, distances);
|
||||||
|
}
|
||||||
|
distances.Sort();
|
||||||
|
var points = new List<Vector>();
|
||||||
|
foreach (var distance in distances)
|
||||||
|
{
|
||||||
|
var point = start + direction * distance;
|
||||||
|
if (points.Count == 0 || points[^1].DistanceTo(point) > Epsilon)
|
||||||
|
points.Add(point);
|
||||||
|
}
|
||||||
|
return points.AsReadOnly();
|
||||||
|
}
|
||||||
|
|
||||||
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y;
|
||||||
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
private static double Normalize(double angle)
|
private static double Normalize(double angle)
|
||||||
@@ -106,14 +134,20 @@ internal static class PostVerificationGeometry
|
|||||||
Center = center;
|
Center = center;
|
||||||
Radius = radius;
|
Radius = radius;
|
||||||
Sweep = sweep;
|
Sweep = sweep;
|
||||||
|
nativeEntity = new Lazy<Entity>(ToEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never expose this mutable native wrapper; its geometry is read-only after creation.
|
||||||
|
// Lazy preserves the old conversion point for unsupported curves and is safe for concurrent checks.
|
||||||
|
private readonly Lazy<Entity> nativeEntity;
|
||||||
|
|
||||||
internal Vector Start { get; }
|
internal Vector Start { get; }
|
||||||
internal Vector End { get; }
|
internal Vector End { get; }
|
||||||
private Vector? Center { get; }
|
private Vector? Center { get; }
|
||||||
private double Radius { get; }
|
private double Radius { get; }
|
||||||
private double Sweep { get; }
|
private double Sweep { get; }
|
||||||
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
internal double Length => Center.HasValue ? Radius * System.Math.Abs(Sweep) : Start.DistanceTo(End);
|
||||||
|
internal bool IsLine => !Center.HasValue;
|
||||||
|
|
||||||
// Native entities are freshly allocated; the immutable curve never exposes state.
|
// Native entities are freshly allocated; the immutable curve never exposes state.
|
||||||
internal Entity ToEntity() => Center is { } center
|
internal Entity ToEntity() => Center is { } center
|
||||||
@@ -138,20 +172,31 @@ internal static class PostVerificationGeometry
|
|||||||
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
center.Y + Radius * System.Math.Sin(StartAngle + Sweep / 2))
|
||||||
: (Start + End) * 0.5;
|
: (Start + End) * 0.5;
|
||||||
|
|
||||||
internal bool SameSupport(Curve other)
|
internal bool SameSupport(Curve other) => SameSupport(other, Epsilon);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Support agreement with an explicit positional allowance. Used only where an upstream
|
||||||
|
/// stage has already admitted a larger nominal discrepancy — currently the inclusive
|
||||||
|
/// <see cref="NominalContourClosure.Tolerance"/> joint that a replayed ring bridges. It is
|
||||||
|
/// never a collision tolerance: safety queries keep the exact epsilon.
|
||||||
|
/// </summary>
|
||||||
|
internal bool SameSupport(Curve other, double positionTolerance)
|
||||||
{
|
{
|
||||||
if (Center is { } center)
|
if (Center is { } center)
|
||||||
return other.Center is { } c && center.DistanceTo(c) <= Epsilon
|
return other.Center is { } c && center.DistanceTo(c) <= positionTolerance
|
||||||
&& System.Math.Abs(Radius - other.Radius) <= Epsilon;
|
&& System.Math.Abs(Radius - other.Radius) <= positionTolerance;
|
||||||
if (other.Center.HasValue || Length <= Epsilon || other.Length <= Epsilon)
|
if (other.Center.HasValue || Length <= positionTolerance || other.Length <= positionTolerance)
|
||||||
return false;
|
return false;
|
||||||
var direction = (End - Start) * (1 / Length);
|
var direction = (End - Start) * (1 / Length);
|
||||||
return System.Math.Abs(Cross(other.Start - Start, direction)) <= Epsilon
|
return System.Math.Abs(Cross(other.Start - Start, direction)) <= positionTolerance
|
||||||
&& System.Math.Abs(Cross(other.End - Start, direction)) <= Epsilon;
|
&& System.Math.Abs(Cross(other.End - Start, direction)) <= positionTolerance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directed native arc-length coordinates for contour replay, not collision queries.
|
// Directed native arc-length coordinates for contour replay, not collision queries.
|
||||||
internal bool SameDirection(Curve other) => SameSupport(other)
|
internal bool SameDirection(Curve other) => SameDirection(other, Epsilon);
|
||||||
|
|
||||||
|
/// <summary>Directed coverage check with an admitted positional allowance; direction sign stays exact.</summary>
|
||||||
|
internal bool SameDirection(Curve other, double positionTolerance) => SameSupport(other, positionTolerance)
|
||||||
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
&& (Center.HasValue ? System.Math.Sign(Sweep) == System.Math.Sign(other.Sweep)
|
||||||
: Dot(End - Start, other.End - other.Start) > 0);
|
: Dot(End - Start, other.End - other.Start) > 0);
|
||||||
|
|
||||||
@@ -170,12 +215,25 @@ internal static class PostVerificationGeometry
|
|||||||
: Start + (End - Start) * (distance / Length);
|
: Start + (End - Start) * (distance / Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool Contains(Vector point) => ToEntity().ClosestPointTo(point).DistanceTo(point) <= Epsilon;
|
internal bool Contains(Vector point) => Contains(point, Epsilon);
|
||||||
|
|
||||||
|
/// <summary>On-support test with an admitted positional allowance (see <see cref="SameSupport(Curve, double)"/>).</summary>
|
||||||
|
internal bool Contains(Vector point, double positionTolerance)
|
||||||
|
=> nativeEntity.Value.ClosestPointTo(point).DistanceTo(point) <= positionTolerance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filters supporting-circle contacts against the actual finite arc span. The
|
||||||
|
/// native angular padding is larger and must not turn an arc extension into a
|
||||||
|
/// self-intersection at an adjacent nominal joint. This is not a radial test.
|
||||||
|
/// </summary>
|
||||||
|
internal bool IsOutsideArcSpan(Vector point) => Center.HasValue && !OnArc(point);
|
||||||
|
|
||||||
|
internal bool IsFiniteArc => Center.HasValue && System.Math.Abs(Sweep) < TwoPi;
|
||||||
|
|
||||||
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
internal IReadOnlyList<Vector> Contacts(Curve other, out bool overlap)
|
||||||
{
|
{
|
||||||
var entity = ToEntity();
|
var entity = nativeEntity.Value;
|
||||||
var candidate = other.ToEntity();
|
var candidate = other.nativeEntity.Value;
|
||||||
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
// Native arc filters discard NaN supporting-circle intersections. Inspect
|
||||||
// both unfiltered queries first: roundoff can differ by operand direction.
|
// both unfiltered queries first: roundoff can differ by operand direction.
|
||||||
// Coincident supports have separate overlap handling below.
|
// Coincident supports have separate overlap handling below.
|
||||||
@@ -203,18 +261,21 @@ internal static class PostVerificationGeometry
|
|||||||
points.Clear();
|
points.Clear();
|
||||||
// Coincident circles produce NaNs in the native discrete-contact query.
|
// Coincident circles produce NaNs in the native discrete-contact query.
|
||||||
// Their support overlap is handled separately, without inventing crossings.
|
// Their support overlap is handled separately, without inventing crossings.
|
||||||
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other)
|
// Supports that agree only within Epsilon (arcs of one circle whose centers were
|
||||||
|| InteriorWitness(other.Midpoint, this)
|
// each derived from their own endpoints) put a witness up to that far off the other
|
||||||
|| InteriorWitness(Start, other) || InteriorWitness(End, other)
|
// support. Test those spans at the admitted seam tolerance instead: shared spans
|
||||||
|| InteriorWitness(other.Start, this) || InteriorWitness(other.End, this));
|
// still refuse, while arcs that only meet at a joint or lie apart are not overlap.
|
||||||
|
var witness = Center is { } c && other.Center is { } oc
|
||||||
|
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius) ? RaySeamTolerance : Epsilon;
|
||||||
|
overlap = SameSupport(other) && (InteriorWitness(Midpoint, other, witness)
|
||||||
|
|| InteriorWitness(other.Midpoint, this, witness)
|
||||||
|
|| InteriorWitness(Start, other, witness) || InteriorWitness(End, other, witness)
|
||||||
|
|| InteriorWitness(other.Start, this, witness) || InteriorWitness(other.End, this, witness));
|
||||||
if (SameSupport(other))
|
if (SameSupport(other))
|
||||||
points.Clear();
|
points.Clear();
|
||||||
foreach (var point in points)
|
foreach (var point in points)
|
||||||
Validate(point);
|
Validate(point);
|
||||||
var nativeContactCount = points.Count;
|
var nativeContactCount = points.Count;
|
||||||
if (Center is { } c && other.Center is { } oc && SameSupport(other)
|
|
||||||
&& (c.X != oc.X || c.Y != oc.Y || Radius != other.Radius))
|
|
||||||
overlap = true; // Nearly coincident supports are uncertain, never clear.
|
|
||||||
foreach (var point in new[] { Start, End, other.Start, other.End })
|
foreach (var point in new[] { Start, End, other.Start, other.End })
|
||||||
if (Contains(point) && other.Contains(point)
|
if (Contains(point) && other.Contains(point)
|
||||||
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
&& !points.Exists(p => p.DistanceTo(point) <= Epsilon))
|
||||||
@@ -227,9 +288,9 @@ internal static class PostVerificationGeometry
|
|||||||
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
throw new NotSupportedException("Native contact query is numerically uncertain.");
|
||||||
return points;
|
return points;
|
||||||
|
|
||||||
static bool InteriorWitness(Vector point, Curve curve) => curve.Contains(point)
|
static bool InteriorWitness(Vector point, Curve curve, double tolerance) => curve.Contains(point, tolerance)
|
||||||
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|
&& (curve.Start.DistanceTo(curve.End) <= Epsilon
|
||||||
|| (point.DistanceTo(curve.Start) > Epsilon && point.DistanceTo(curve.End) > Epsilon));
|
|| (point.DistanceTo(curve.Start) > tolerance && point.DistanceTo(curve.End) > tolerance));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -281,7 +342,8 @@ internal static class PostVerificationGeometry
|
|||||||
// The largest distance from the centre that ContactAfterStart can count as contact.
|
// The largest distance from the centre that ContactAfterStart can count as contact.
|
||||||
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
private double ContactReach => System.Math.Sqrt(Radius * Radius + ContactSlack);
|
||||||
|
|
||||||
internal bool ContactAfterStart(Vector origin, Vector direction, double length)
|
internal bool ContactAfterStart(Vector origin, Vector direction, double length,
|
||||||
|
List<double> contacts = null)
|
||||||
{
|
{
|
||||||
if (Center is { } center)
|
if (Center is { } center)
|
||||||
{
|
{
|
||||||
@@ -294,10 +356,13 @@ internal static class PostVerificationGeometry
|
|||||||
if (square < -ContactSlack)
|
if (square < -ContactSlack)
|
||||||
return false;
|
return false;
|
||||||
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
var offset = System.Math.Sqrt(System.Math.Max(0, square));
|
||||||
return Hit(projection - offset) || Hit(projection + offset);
|
return contacts == null
|
||||||
|
? Hit(projection - offset) || Hit(projection + offset)
|
||||||
|
: Hit(projection - offset) | Hit(projection + offset);
|
||||||
|
|
||||||
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
bool Hit(double distance) => distance > Epsilon && distance <= length + Epsilon
|
||||||
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length));
|
&& OnArc(origin + direction * System.Math.Clamp(distance, 0, length))
|
||||||
|
&& Record(distance);
|
||||||
}
|
}
|
||||||
var edge = End - Start;
|
var edge = End - Start;
|
||||||
var relativeStart = Start - origin;
|
var relativeStart = Start - origin;
|
||||||
@@ -310,19 +375,36 @@ internal static class PostVerificationGeometry
|
|||||||
var b = Dot(End - origin, direction);
|
var b = Dot(End - origin, direction);
|
||||||
var low = System.Math.Max(0, System.Math.Min(a, b));
|
var low = System.Math.Max(0, System.Math.Min(a, b));
|
||||||
var high = System.Math.Min(length, System.Math.Max(a, b));
|
var high = System.Math.Min(length, System.Math.Max(a, b));
|
||||||
return high > Epsilon && low <= high + Epsilon;
|
return high > Epsilon && low <= high + Epsilon && Record(low) && Record(high);
|
||||||
}
|
}
|
||||||
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
var distanceAlongRapid = Cross(relativeStart, edge) / denominator;
|
||||||
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
var fractionAlongEdge = Cross(relativeStart, direction) / denominator;
|
||||||
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
return distanceAlongRapid > Epsilon && distanceAlongRapid <= length + Epsilon
|
||||||
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
&& fractionAlongEdge >= -Epsilon / System.Math.Max(Length, Epsilon)
|
||||||
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon);
|
&& fractionAlongEdge <= 1 + Epsilon / System.Math.Max(Length, Epsilon)
|
||||||
|
&& Record(distanceAlongRapid);
|
||||||
|
|
||||||
|
bool Record(double distance)
|
||||||
|
{
|
||||||
|
contacts?.Add(System.Math.Clamp(distance, 0, length));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal bool CrossesRay(Vector point)
|
internal bool CrossesRay(Vector point)
|
||||||
{
|
{
|
||||||
|
// Ray parity needs both edges meeting at a shared vertex to agree which side of the ray
|
||||||
|
// that vertex is on. Rings are only nominally closed: capture admits closure seams and
|
||||||
|
// PEP-converted or rotated geometry carries sub-micro-inch vertex jogs, so an exact
|
||||||
|
// comparison can split one vertex across the ray and report far-away exterior points as
|
||||||
|
// interior — which made LeadPathValidator refuse leads that could touch nothing.
|
||||||
|
// Snapping within the admitted seam tolerance keeps the decision consistent on both
|
||||||
|
// edges; real features (notches, tabs, arc spans) are far larger than the tolerance and
|
||||||
|
// still count. Containment of a point that itself lies within the tolerance of a
|
||||||
|
// boundary edge stays ambiguous by design, exactly as it was before.
|
||||||
|
var rayY = point.Y + RaySeamTolerance;
|
||||||
if (Center is not { } center)
|
if (Center is not { } center)
|
||||||
return (Start.Y > point.Y) != (End.Y > point.Y)
|
return (Start.Y > rayY) != (End.Y > rayY)
|
||||||
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
&& Start.X + (point.Y - Start.Y) * (End.X - Start.X) / (End.Y - Start.Y) > point.X;
|
||||||
|
|
||||||
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
// Split arcs at vertical extrema, giving monotone-Y pieces. Apply the same
|
||||||
@@ -343,7 +425,7 @@ internal static class PostVerificationGeometry
|
|||||||
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
var b = StartAngle + System.Math.Sign(Sweep) * breaks[i];
|
||||||
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
var ya = i == 1 ? Start.Y : center.Y + Radius * System.Math.Sin(a);
|
||||||
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
var yb = i == breaks.Count - 1 ? End.Y : center.Y + Radius * System.Math.Sin(b);
|
||||||
if ((ya > point.Y) == (yb > point.Y))
|
if ((ya > rayY) == (yb > rayY))
|
||||||
continue;
|
continue;
|
||||||
var dy = point.Y - center.Y;
|
var dy = point.Y - center.Y;
|
||||||
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
var dx = System.Math.Sqrt(System.Math.Max(0, Radius * Radius - dy * dy));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Diagnostics;
|
namespace OpenNest.Diagnostics;
|
||||||
|
|
||||||
@@ -15,7 +16,17 @@ public enum PostVerificationKind
|
|||||||
|
|
||||||
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
|
||||||
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
|
||||||
int? PartNumber, int? OtherPartNumber, string Message);
|
int? PartNumber, int? OtherPartNumber, string Message)
|
||||||
|
{
|
||||||
|
public Vector? Location { get; init; }
|
||||||
|
public Vector? RapidStart { get; init; }
|
||||||
|
public Vector? RapidEnd { get; init; }
|
||||||
|
/// <summary>Rapid boundary contacts in travel order; collinear contact spans use their endpoints.</summary>
|
||||||
|
public IReadOnlyList<Vector> ContactPoints { get; init; } = Array.Empty<Vector>();
|
||||||
|
public PlateOverlapPair Overlap { get; init; }
|
||||||
|
/// <summary>Active clamp contacted by a cut-off or direct XY rapid, if applicable.</summary>
|
||||||
|
public string ClampId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
|
||||||
public sealed class PostVerificationReport
|
public sealed class PostVerificationReport
|
||||||
@@ -29,10 +40,12 @@ public sealed class PostVerificationReport
|
|||||||
public bool HasWarnings => Findings.Count != 0;
|
public bool HasWarnings => Findings.Count != 0;
|
||||||
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
|
||||||
|
|
||||||
public string ToDisplayText()
|
public string ToDisplayText() => ToDisplayText("Pre-post verification");
|
||||||
|
|
||||||
|
public string ToDisplayText(string title)
|
||||||
{
|
{
|
||||||
var text = new StringBuilder();
|
var text = new StringBuilder();
|
||||||
text.AppendLine("Pre-post verification");
|
text.AppendLine(title);
|
||||||
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
|
||||||
Summary(PostVerificationKind.Overlap, "Overlap");
|
Summary(PostVerificationKind.Overlap, "Overlap");
|
||||||
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
|
||||||
@@ -46,16 +59,16 @@ public sealed class PostVerificationReport
|
|||||||
text.Append($", other part {other}");
|
text.Append($", other part {other}");
|
||||||
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
text.AppendLine($": {finding.Kind}: {finding.Message}");
|
||||||
}
|
}
|
||||||
text.AppendLine("This is not a physical safety certification. The check uses direct XY rapids " +
|
text.AppendLine("These checks cover direct XY rapids in plate/program order; the post may " +
|
||||||
"in plate/program order; the post may change order, routing or retracts. Inspect the posted " +
|
"change order, routing or retracts. Inspect the posted machine program and machine setup " +
|
||||||
"machine program and machine setup. Actual contour gaps are not proof of adequate retention.");
|
"before cutting, and confirm actual contour gaps give adequate retention.");
|
||||||
return text.ToString();
|
return text.ToString();
|
||||||
|
|
||||||
void Summary(PostVerificationKind kind, string label)
|
void Summary(PostVerificationKind kind, string label)
|
||||||
{
|
{
|
||||||
var count = Findings.Count(finding => finding.Kind == kind);
|
var count = Findings.Count(finding => finding.Kind == kind);
|
||||||
text.AppendLine($"{label}: {count} warning(s)" +
|
text.AppendLine($"{label}: {count} warning(s)" +
|
||||||
(incomplete ? "; verification incomplete — do not treat as clear." : "."));
|
(incomplete ? "; verification incomplete — review before posting." : "."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,16 @@ namespace OpenNest.Geometry
|
|||||||
return Separation(linesA, linesB);
|
return Separation(linesA, linesB);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Distance from a closed boundary's segments to every segment
|
||||||
|
/// of an intentional open cutting path. Unlike polygonizing that path,
|
||||||
|
/// this retains segment interiors and disjoint subpaths.</summary>
|
||||||
|
internal static ClearanceResult BoundaryDistance(List<Line> closed, List<Line> open)
|
||||||
|
{
|
||||||
|
if (closed.Count == 0 || open.Count == 0)
|
||||||
|
return new ClearanceResult(0, new Vector(1, 0));
|
||||||
|
return Separation(closed, open);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Minimum boundary distance between two non-overlapping rings and the
|
/// Minimum boundary distance between two non-overlapping rings and the
|
||||||
/// direction that translates <paramref name="linesA"/> away from
|
/// direction that translates <paramref name="linesA"/> away from
|
||||||
|
|||||||
@@ -953,7 +953,7 @@ namespace OpenNest.Geometry
|
|||||||
OldSampleCount = old.TotalSamples,
|
OldSampleCount = old.TotalSamples,
|
||||||
DiagnosticIoU = iou,
|
DiagnosticIoU = iou,
|
||||||
FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0
|
FailureMessage = (reasons & (AlignmentReasons.FailedConvergence | AlignmentReasons.InsufficientSupport)) != 0
|
||||||
? "Automatic alignment is not trustworthy for this pair; use the manual overlay."
|
? "Use the manual overlay to align this pair."
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tolerance-free test of material against an axis-aligned keep-out rectangle, for
|
||||||
|
/// rectangles too small for the tolerance-based overlap machinery (validation offsets,
|
||||||
|
/// Clipper grids, triangulation and vector equality work at 1e-3 to 1e-5 scale and collapse
|
||||||
|
/// such a rectangle, PM 6859db53). Positive-area overlap only: flush contact is clear, as
|
||||||
|
/// everywhere else.
|
||||||
|
/// </summary>
|
||||||
|
public static class KeepOutOverlap
|
||||||
|
{
|
||||||
|
/// <summary>The scale below which tolerance-based overlap tests are not trusted for a
|
||||||
|
/// keep-out rectangle (the validation outline chord tolerance).</summary>
|
||||||
|
public const double ToleranceFreeBelow = 0.001;
|
||||||
|
|
||||||
|
/// <summary>A keep-out narrower than <see cref="ToleranceFreeBelow"/> on either axis.</summary>
|
||||||
|
public static bool NeedsToleranceFreeTest(Box keepOut) =>
|
||||||
|
keepOut.Length < ToleranceFreeBelow || keepOut.Width < ToleranceFreeBelow;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chord tolerance for flattening real part material against <paramref name="keepOut"/>:
|
||||||
|
/// a quarter of its smaller side, at most <see cref="ToleranceFreeBelow"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static double FlatteningTolerance(Box keepOut) =>
|
||||||
|
System.Math.Min(ToleranceFreeBelow, System.Math.Min(keepOut.Length, keepOut.Width) / 4);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A part's native contours flattened two ways for a tiny keep-out, never through a
|
||||||
|
/// grid-rounding offset: lines exact, every arc (perimeter and cutouts) inscribed in the
|
||||||
|
/// first and circumscribed in the second, at <see cref="FlatteningTolerance"/>. Along a
|
||||||
|
/// convex arc the circumscribed version holds all real material, along a concave arc
|
||||||
|
/// (a notch, a round hole) the inscribed one does, so together they hold all of it and
|
||||||
|
/// testing both finds any real overlap. The price is a possible report within that
|
||||||
|
/// tolerance of an arc, never a false clear.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<(Polygon Perimeter, List<Polygon> Holes)> ConservativePolygons(
|
||||||
|
ShapeProfile profile, Box keepOut)
|
||||||
|
{
|
||||||
|
var tolerance = FlatteningTolerance(keepOut);
|
||||||
|
foreach (var circumscribe in new[] { false, true })
|
||||||
|
{
|
||||||
|
var perimeter = profile.Perimeter.ToPolygonWithTolerance(tolerance, circumscribe);
|
||||||
|
var holes = new List<Polygon>(profile.Cutouts.Count);
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
holes.Add(cutout.ToPolygonWithTolerance(tolerance, circumscribe));
|
||||||
|
yield return (perimeter, holes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when either of the <see cref="ConservativePolygons"/>, translated by
|
||||||
|
/// <paramref name="offset"/>, enters <paramref name="keepOut"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ProfileEnters(ShapeProfile profile, Vector offset, Box keepOut)
|
||||||
|
{
|
||||||
|
foreach (var (perimeter, holes) in ConservativePolygons(profile, keepOut))
|
||||||
|
{
|
||||||
|
perimeter.Offset(offset);
|
||||||
|
foreach (var hole in holes)
|
||||||
|
hole.Offset(offset);
|
||||||
|
if (MaterialEnters(perimeter, holes, keepOut))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the material (perimeter minus holes) shares positive area with the open
|
||||||
|
/// rectangle: some boundary edge runs through its interior, or its interior lies wholly
|
||||||
|
/// inside the material (tested at its centre).
|
||||||
|
/// </summary>
|
||||||
|
public static bool MaterialEnters(Polygon perimeter, IReadOnlyList<Polygon> holes, Box box)
|
||||||
|
{
|
||||||
|
if (RingEnters(perimeter, box))
|
||||||
|
return true;
|
||||||
|
if (holes != null)
|
||||||
|
foreach (var hole in holes)
|
||||||
|
if (RingEnters(hole, box))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var centre = new Vector((box.Left + box.Right) / 2, (box.Bottom + box.Top) / 2);
|
||||||
|
if (!Contains(perimeter, centre))
|
||||||
|
return false;
|
||||||
|
if (holes != null)
|
||||||
|
foreach (var hole in holes)
|
||||||
|
if (Contains(hole, centre))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RingEnters(Polygon ring, Box box)
|
||||||
|
{
|
||||||
|
var vertices = ring.Vertices;
|
||||||
|
var count = vertices.Count;
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var next = vertices[(i + 1) % count];
|
||||||
|
if (SegmentEntersOpenBox(vertices[i], next, box))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liang-Barsky clip against the open rectangle; a positive-length part of the
|
||||||
|
/// segment must lie strictly inside it.</summary>
|
||||||
|
private static bool SegmentEntersOpenBox(Vector p, Vector q, Box box)
|
||||||
|
{
|
||||||
|
var dx = q.X - p.X;
|
||||||
|
var dy = q.Y - p.Y;
|
||||||
|
var t0 = 0.0;
|
||||||
|
var t1 = 1.0;
|
||||||
|
return Clip(-dx, p.X - box.Left, ref t0, ref t1)
|
||||||
|
&& Clip(dx, box.Right - p.X, ref t0, ref t1)
|
||||||
|
&& Clip(-dy, p.Y - box.Bottom, ref t0, ref t1)
|
||||||
|
&& Clip(dy, box.Top - p.Y, ref t0, ref t1)
|
||||||
|
&& t0 < t1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Restricts [t0, t1] to the parameters where <c>denominator * t < numerator</c>.</summary>
|
||||||
|
private static bool Clip(double denominator, double numerator, ref double t0, ref double t1)
|
||||||
|
{
|
||||||
|
if (denominator == 0)
|
||||||
|
return numerator > 0;
|
||||||
|
var r = numerator / denominator;
|
||||||
|
if (denominator < 0)
|
||||||
|
{
|
||||||
|
if (r >= t1)
|
||||||
|
return false;
|
||||||
|
if (r > t0)
|
||||||
|
t0 = r;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (r <= t0)
|
||||||
|
return false;
|
||||||
|
if (r < t1)
|
||||||
|
t1 = r;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Even-odd ray cast; exact for a point that is not on the ring (callers only
|
||||||
|
/// ask after ruling out any edge through the rectangle containing the point).</summary>
|
||||||
|
private static bool Contains(Polygon ring, Vector point)
|
||||||
|
{
|
||||||
|
var inside = false;
|
||||||
|
var vertices = ring.Vertices;
|
||||||
|
var count = vertices.Count;
|
||||||
|
for (int i = 0, j = count - 1; i < count; j = i++)
|
||||||
|
{
|
||||||
|
var a = vertices[i];
|
||||||
|
var b = vertices[j];
|
||||||
|
if ((a.Y > point.Y) != (b.Y > point.Y)
|
||||||
|
&& point.X < (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y) + a.X)
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Geometry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict positive-length entry of an open native line path into polygon material.
|
||||||
|
/// Boundary-only contact is not entry; holes subtract material. This deliberately
|
||||||
|
/// does not infer material from a path's bounding box or close the open path.
|
||||||
|
/// Curved contours are flattened by the caller, which must handle chord uncertainty.
|
||||||
|
/// </summary>
|
||||||
|
internal static class OpenPathMaterial
|
||||||
|
{
|
||||||
|
public static bool Enters(Polygon outer, IReadOnlyList<Polygon> holes, IReadOnlyList<Line> path)
|
||||||
|
{
|
||||||
|
if (outer?.Vertices.Count < 4 || path == null)
|
||||||
|
return true; // Unresolved geometry is not a certificate of clear material.
|
||||||
|
|
||||||
|
foreach (var line in path)
|
||||||
|
{
|
||||||
|
var start = line.StartPoint;
|
||||||
|
var end = line.EndPoint;
|
||||||
|
var travel = end - start;
|
||||||
|
var lengthSquared = travel.DotProduct(travel);
|
||||||
|
if (!double.IsFinite(lengthSquared) || lengthSquared <= 0)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var cuts = new List<double> { 0, 1 };
|
||||||
|
CollectCuts(outer, start, travel, lengthSquared, cuts);
|
||||||
|
if (holes != null)
|
||||||
|
foreach (var hole in holes)
|
||||||
|
CollectCuts(hole, start, travel, lengthSquared, cuts);
|
||||||
|
cuts.Sort();
|
||||||
|
for (var i = 1; i < cuts.Count; i++)
|
||||||
|
{
|
||||||
|
if (cuts[i] <= cuts[i - 1])
|
||||||
|
continue;
|
||||||
|
var midpoint = start + travel * ((cuts[i - 1] + cuts[i]) / 2);
|
||||||
|
if (!StrictlyInside(outer, midpoint))
|
||||||
|
continue;
|
||||||
|
var inHole = false;
|
||||||
|
if (holes != null)
|
||||||
|
foreach (var hole in holes)
|
||||||
|
if (InsideOrOnBoundary(hole, midpoint))
|
||||||
|
{
|
||||||
|
inHole = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!inHole)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectCuts(Polygon ring, Vector start, Vector travel,
|
||||||
|
double lengthSquared, List<double> cuts)
|
||||||
|
{
|
||||||
|
if (ring?.Vertices.Count < 4)
|
||||||
|
{
|
||||||
|
// A degenerate hole cannot be subtracted reliably. The caller's
|
||||||
|
// unresolved-topology guard handles this case before this helper.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var vertices = ring.Vertices;
|
||||||
|
for (var i = 1; i < vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var a = vertices[i - 1];
|
||||||
|
var edge = vertices[i] - a;
|
||||||
|
var difference = a - start;
|
||||||
|
var denominator = Cross(travel, edge);
|
||||||
|
if (denominator == 0)
|
||||||
|
{
|
||||||
|
if (Cross(difference, travel) != 0)
|
||||||
|
continue;
|
||||||
|
AddParameter(difference.DotProduct(travel) / lengthSquared, cuts);
|
||||||
|
AddParameter((vertices[i] - start).DotProduct(travel) / lengthSquared, cuts);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var t = Cross(difference, edge) / denominator;
|
||||||
|
var u = Cross(difference, travel) / denominator;
|
||||||
|
if (u >= -1e-12 && u <= 1 + 1e-12)
|
||||||
|
AddParameter(t, cuts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddParameter(double t, List<double> cuts)
|
||||||
|
{
|
||||||
|
if (double.IsFinite(t) && t >= -1e-12 && t <= 1 + 1e-12)
|
||||||
|
cuts.Add(System.Math.Clamp(t, 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool StrictlyInside(Polygon ring, Vector point) =>
|
||||||
|
Contains(ring, point, boundaryIsInside: false);
|
||||||
|
|
||||||
|
private static bool InsideOrOnBoundary(Polygon ring, Vector point) =>
|
||||||
|
Contains(ring, point, boundaryIsInside: true);
|
||||||
|
|
||||||
|
private static bool Contains(Polygon ring, Vector point, bool boundaryIsInside)
|
||||||
|
{
|
||||||
|
var vertices = ring.Vertices;
|
||||||
|
var inside = false;
|
||||||
|
for (var i = 1; i < vertices.Count; i++)
|
||||||
|
{
|
||||||
|
var a = vertices[i - 1];
|
||||||
|
var b = vertices[i];
|
||||||
|
var edge = b - a;
|
||||||
|
var relative = point - a;
|
||||||
|
if (Cross(edge, relative) == 0 &&
|
||||||
|
point.X >= System.Math.Min(a.X, b.X) && point.X <= System.Math.Max(a.X, b.X) &&
|
||||||
|
point.Y >= System.Math.Min(a.Y, b.Y) && point.Y <= System.Math.Max(a.Y, b.Y))
|
||||||
|
return boundaryIsInside;
|
||||||
|
if ((a.Y > point.Y) != (b.Y > point.Y) &&
|
||||||
|
point.X < a.X + (b.X - a.X) * (point.Y - a.Y) / (b.Y - a.Y))
|
||||||
|
inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Cross(Vector a, Vector b) => a.X * b.Y - a.Y * b.X;
|
||||||
|
}
|
||||||
+25
-1
@@ -65,6 +65,21 @@ namespace OpenNest
|
|||||||
return plate;
|
return plate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes <paramref name="rail"/> this nest's machine clamp rail: new plates follow it
|
||||||
|
/// (<see cref="PlateSettings.ClampRail"/>) and every existing plate re-resolves its
|
||||||
|
/// zones from it. Null, or a rail without clamps, removes the rail and every plate's
|
||||||
|
/// zones; the rail is nest-wide, so per-plate zones are replaced either way.
|
||||||
|
/// </summary>
|
||||||
|
public void SetClampRail(ClampRail rail)
|
||||||
|
{
|
||||||
|
if (rail != null && rail.Centers.Count == 0)
|
||||||
|
rail = null;
|
||||||
|
PlateDefaults.ClampRail = rail;
|
||||||
|
foreach (var plate in Plates)
|
||||||
|
plate.SetClampRail(rail);
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateDrawingQuantities()
|
public void UpdateDrawingQuantities()
|
||||||
{
|
{
|
||||||
foreach (var drawing in Drawings)
|
foreach (var drawing in Drawings)
|
||||||
@@ -114,6 +129,12 @@ namespace OpenNest
|
|||||||
set { plate.PartSpacing = value; }
|
set { plate.PartSpacing = value; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The machine clamp rail new plates follow, or null for none. Set through
|
||||||
|
/// <see cref="Nest.SetClampRail"/> to also update existing plates.
|
||||||
|
/// </summary>
|
||||||
|
public ClampRail ClampRail { get; set; }
|
||||||
|
|
||||||
public void SetFromExisting(Plate plate)
|
public void SetFromExisting(Plate plate)
|
||||||
{
|
{
|
||||||
Quadrant = plate.Quadrant;
|
Quadrant = plate.Quadrant;
|
||||||
@@ -124,7 +145,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
public Plate CreateNew()
|
public Plate CreateNew()
|
||||||
{
|
{
|
||||||
return new Plate()
|
var created = new Plate()
|
||||||
{
|
{
|
||||||
Size = Size,
|
Size = Size,
|
||||||
EdgeSpacing = EdgeSpacing,
|
EdgeSpacing = EdgeSpacing,
|
||||||
@@ -132,6 +153,9 @@ namespace OpenNest
|
|||||||
Quadrant = Quadrant,
|
Quadrant = Quadrant,
|
||||||
Quantity = 1,
|
Quantity = 1,
|
||||||
};
|
};
|
||||||
|
if (ClampRail != null)
|
||||||
|
created.SetClampRail(ClampRail);
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-3
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
@@ -55,6 +56,10 @@ namespace OpenNest
|
|||||||
Parts.ItemAdded += Parts_PartAdded;
|
Parts.ItemAdded += Parts_PartAdded;
|
||||||
Parts.ItemRemoved += Parts_PartRemoved;
|
Parts.ItemRemoved += Parts_PartRemoved;
|
||||||
CutOffs = new ObservableList<CutOff>();
|
CutOffs = new ObservableList<CutOff>();
|
||||||
|
ClampZones = new ObservableList<ClampZone>();
|
||||||
|
ClampZones.ItemAdded += (_, _) => OnClampZonesChanged();
|
||||||
|
ClampZones.ItemRemoved += (_, _) => OnClampZonesChanged();
|
||||||
|
ClampZones.ItemChanged += (_, _) => OnClampZonesChanged();
|
||||||
Quadrant = 1;
|
Quadrant = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,9 +86,20 @@ namespace OpenNest
|
|||||||
public Spacing EdgeSpacing;
|
public Spacing EdgeSpacing;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The size of the plate.
|
/// The size of the plate. A plate following a <see cref="ClampRail"/> re-resolves
|
||||||
|
/// its clamp zones for the new size.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Size Size { get; set; }
|
public Size Size
|
||||||
|
{
|
||||||
|
get { return size; }
|
||||||
|
set
|
||||||
|
{
|
||||||
|
size = value;
|
||||||
|
ResolveClampRail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Size size;
|
||||||
|
|
||||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||||
|
|
||||||
@@ -102,6 +118,106 @@ namespace OpenNest
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public ObservableList<CutOff> CutOffs { get; set; }
|
public ObservableList<CutOff> CutOffs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixed rectangular machine-clamp keep-outs in plate coordinates
|
||||||
|
/// (<see cref="ClampZone"/>). Never parts: excluded from quantity, utilization,
|
||||||
|
/// cut sequence and CNC output; placement rules and display consume them. Mutate
|
||||||
|
/// through this list; <see cref="ClampZonesChanged"/> fires for every change.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableList<ClampZone> ClampZones { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised after <see cref="ClampZones"/> content changes so views can refresh
|
||||||
|
/// the zone overlay. Carries no geometry arguments.
|
||||||
|
/// </summary>
|
||||||
|
public event EventHandler? ClampZonesChanged;
|
||||||
|
|
||||||
|
private void OnClampZonesChanged() => ClampZonesChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The machine clamp rail this plate follows, or null when its
|
||||||
|
/// <see cref="ClampZones"/> are explicit (engine sheets, files without a rail). While
|
||||||
|
/// set, the zones are derived: re-resolved whenever <see cref="Size"/> or
|
||||||
|
/// <see cref="Quadrant"/> changes, because the clamps are fixed to the machine.
|
||||||
|
/// </summary>
|
||||||
|
public ClampRail ClampRail { get; private set; }
|
||||||
|
|
||||||
|
private static readonly IReadOnlySet<string> NoRemovedClamps = ImmutableHashSet<string>.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rail clamp ids taken off this sheet as a last resort (D8) so a part that could not
|
||||||
|
/// be nested anywhere with every clamp in place still fits. Empty unless
|
||||||
|
/// <see cref="ClampRail"/> is set; <see cref="ClampZones"/> already excludes them, so
|
||||||
|
/// nothing else needs to consult this list to avoid them. Ids stay listed across a
|
||||||
|
/// resize/rotate even when the matching clamp is temporarily beyond the sheet, so it
|
||||||
|
/// re-appears removed if a later resize brings it back onto the sheet (C13).
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlySet<string> RemovedClamps { get; private set; } = NoRemovedClamps;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minutes this sheet's cut-time estimate gains from <see cref="RemovedClamps"/> (D9):
|
||||||
|
/// each listed clamp the rail places on this sheet adds
|
||||||
|
/// <see cref="OpenNest.ClampRail.RemovalMinutes"/> once; a listed clamp that is beyond
|
||||||
|
/// the sheet (or unknown to the rail) is not pulled and costs nothing. Zero when there
|
||||||
|
/// is no rail (removal is meaningless without one to remove clamps from).
|
||||||
|
/// </summary>
|
||||||
|
public double ClampRemovalMinutes =>
|
||||||
|
ClampRail == null || RemovedClamps.Count == 0
|
||||||
|
? 0
|
||||||
|
: ClampRail.ResolveFor(Size, Quadrant).Count(zone => RemovedClamps.Contains(zone.Id))
|
||||||
|
* ClampRail.RemovalMinutes;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Follows <paramref name="rail"/> and replaces the zones with its resolution for
|
||||||
|
/// this sheet; null detaches and removes every zone. Unchanged zones raise no events.
|
||||||
|
/// </summary>
|
||||||
|
public void SetClampRail(ClampRail rail) => SetClampRail(rail, null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Follows <paramref name="rail"/> with the clamps named in <paramref name="removedClamps"/>
|
||||||
|
/// taken off this sheet (D8): zones become the rail's resolution for this sheet minus
|
||||||
|
/// those ids. Null <paramref name="rail"/> detaches, clears every zone and forgets the
|
||||||
|
/// removed set (removal only means something while following a rail).
|
||||||
|
/// </summary>
|
||||||
|
public void SetClampRail(ClampRail rail, IEnumerable<string> removedClamps)
|
||||||
|
{
|
||||||
|
ClampRail = rail;
|
||||||
|
RemovedClamps = rail == null || removedClamps == null
|
||||||
|
? NoRemovedClamps
|
||||||
|
: removedClamps.ToImmutableHashSet(StringComparer.Ordinal);
|
||||||
|
ReplaceClampZones(ResolveRailZones());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolveClampRail()
|
||||||
|
{
|
||||||
|
// Called from the constructor's Size assignment before the zone list exists.
|
||||||
|
if (ClampRail != null)
|
||||||
|
ReplaceClampZones(ResolveRailZones());
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<ClampZone> ResolveRailZones()
|
||||||
|
{
|
||||||
|
if (ClampRail == null)
|
||||||
|
return Array.Empty<ClampZone>();
|
||||||
|
var resolved = ClampRail.ResolveFor(Size, Quadrant);
|
||||||
|
if (RemovedClamps.Count == 0)
|
||||||
|
return resolved;
|
||||||
|
var kept = new List<ClampZone>(resolved.Count);
|
||||||
|
foreach (var zone in resolved)
|
||||||
|
if (!RemovedClamps.Contains(zone.Id))
|
||||||
|
kept.Add(zone);
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReplaceClampZones(IReadOnlyList<ClampZone> zones)
|
||||||
|
{
|
||||||
|
if (ClampRail.SameZones(ClampZones, zones))
|
||||||
|
return;
|
||||||
|
ClampZones.Clear();
|
||||||
|
foreach (var zone in zones)
|
||||||
|
ClampZones.Add(zone);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Regenerates all cut-off drawings and materializes them as parts.
|
/// Regenerates all cut-off drawings and materializes them as parts.
|
||||||
/// Existing cut-off parts are removed first, then each cut-off is
|
/// Existing cut-off parts are removed first, then each cut-off is
|
||||||
@@ -236,7 +352,11 @@ namespace OpenNest
|
|||||||
public int Quadrant
|
public int Quadrant
|
||||||
{
|
{
|
||||||
get { return quadrant; }
|
get { return quadrant; }
|
||||||
set { quadrant = value <= 4 && value > 0 ? value : 1; }
|
set
|
||||||
|
{
|
||||||
|
quadrant = value <= 4 && value > 0 ? value : 1;
|
||||||
|
ResolveClampRail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -119,6 +119,50 @@ namespace OpenNest
|
|||||||
SubscribeToTailPlates();
|
SubscribeToTailPlates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a nest-defaults edit, moving only an untouched trailing empty sheet
|
||||||
|
/// that matched the old defaults to the new defaults.
|
||||||
|
/// </summary>
|
||||||
|
public void ChangePlateDefaults(Action changeDefaults)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(changeDefaults);
|
||||||
|
var defaults = nest.PlateDefaults;
|
||||||
|
var trailing = Count > 0 ? nest.Plates[^1] : null;
|
||||||
|
// Exact configuration equality, not geometric tolerance: even a small edit
|
||||||
|
// makes this configured stock rather than the untouched workspace sentinel.
|
||||||
|
var followsDefaults = trailing != null
|
||||||
|
&& trailing.Parts.Count == 0
|
||||||
|
&& trailing.CutOffs.Count == 0
|
||||||
|
&& trailing.Size.Width == defaults.Size.Width
|
||||||
|
&& trailing.Size.Length == defaults.Size.Length
|
||||||
|
&& trailing.Quadrant == defaults.Quadrant
|
||||||
|
&& trailing.EdgeSpacing.Left == defaults.EdgeSpacing.Left
|
||||||
|
&& trailing.EdgeSpacing.Bottom == defaults.EdgeSpacing.Bottom
|
||||||
|
&& trailing.EdgeSpacing.Right == defaults.EdgeSpacing.Right
|
||||||
|
&& trailing.EdgeSpacing.Top == defaults.EdgeSpacing.Top
|
||||||
|
&& trailing.PartSpacing == defaults.PartSpacing
|
||||||
|
&& trailing.RemovedClamps.Count == 0
|
||||||
|
&& Equals(trailing.ClampRail, defaults.ClampRail)
|
||||||
|
&& (defaults.ClampRail == null
|
||||||
|
? trailing.ClampZones.Count == 0
|
||||||
|
: defaults.ClampRail.Matches(trailing));
|
||||||
|
|
||||||
|
changeDefaults();
|
||||||
|
|
||||||
|
if (!followsDefaults)
|
||||||
|
return;
|
||||||
|
|
||||||
|
defaults = nest.PlateDefaults;
|
||||||
|
trailing.Size = defaults.Size;
|
||||||
|
trailing.Quadrant = defaults.Quadrant;
|
||||||
|
trailing.EdgeSpacing = defaults.EdgeSpacing;
|
||||||
|
trailing.PartSpacing = defaults.PartSpacing;
|
||||||
|
trailing.SetClampRail(defaults.ClampRail);
|
||||||
|
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
if (ReferenceEquals(CurrentPlate, trailing))
|
||||||
|
FireCurrentPlateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
public void BeginBatch()
|
public void BeginBatch()
|
||||||
{
|
{
|
||||||
batching = true;
|
batching = true;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Sequencing
|
||||||
|
{
|
||||||
|
/// <summary>Moves one existing part to a one-based cutting sequence position.</summary>
|
||||||
|
public static class ManualPartSequencing
|
||||||
|
{
|
||||||
|
public static bool Move(Plate plate, Part part, int sequenceNumber)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
ArgumentNullException.ThrowIfNull(part);
|
||||||
|
var order = plate.Parts.ToList();
|
||||||
|
if (sequenceNumber < 1 || sequenceNumber > order.Count)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var oldIndex = order.FindIndex(p => ReferenceEquals(p, part));
|
||||||
|
if (oldIndex < 0)
|
||||||
|
return false;
|
||||||
|
if (oldIndex == sequenceNumber - 1)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
order.RemoveAt(oldIndex);
|
||||||
|
order.Insert(sequenceNumber - 1, part);
|
||||||
|
plate.Parts.Reorder(order);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using OpenNest.Api;
|
using OpenNest.Api;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
@@ -43,6 +43,10 @@ namespace OpenNest
|
|||||||
pos = endpt;
|
pos = endpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// D9: once per physical sheet, before the quantity multiply below scales it with
|
||||||
|
// everything else cut on that sheet.
|
||||||
|
info.ClampRemovalMinutes = plate.ClampRemovalMinutes;
|
||||||
|
|
||||||
info *= plate.Quantity;
|
info *= plate.Quantity;
|
||||||
|
|
||||||
return info;
|
return info;
|
||||||
@@ -91,6 +95,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
|
time += TimeSpan.FromTicks(info.PierceCount * cutParams.PierceTime.Ticks);
|
||||||
|
|
||||||
|
// D9: a flat per-removed-clamp time, already in minutes regardless of cutParams.Units.
|
||||||
|
time += TimeSpan.FromMinutes(info.ClampRemovalMinutes);
|
||||||
|
|
||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ namespace OpenNest
|
|||||||
|
|
||||||
public double CutDistance;
|
public double CutDistance;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minutes added by last-resort clamp removal (D9): once per physical sheet, for
|
||||||
|
/// every clamp <see cref="Plate.RemovedClamps"/> lists. Zero for a plate with no
|
||||||
|
/// removal, so existing timing is unaffected.
|
||||||
|
/// </summary>
|
||||||
|
public double ClampRemovalMinutes;
|
||||||
|
|
||||||
public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
|
public static TimingInfo operator +(TimingInfo info1, TimingInfo info2)
|
||||||
{
|
{
|
||||||
return new TimingInfo
|
return new TimingInfo
|
||||||
@@ -18,6 +25,7 @@ namespace OpenNest
|
|||||||
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
|
IntersectionCount = info1.IntersectionCount + info2.IntersectionCount,
|
||||||
TravelDistance = info1.TravelDistance + info2.TravelDistance,
|
TravelDistance = info1.TravelDistance + info2.TravelDistance,
|
||||||
PierceCount = info1.PierceCount + info2.PierceCount,
|
PierceCount = info1.PierceCount + info2.PierceCount,
|
||||||
|
ClampRemovalMinutes = info1.ClampRemovalMinutes + info2.ClampRemovalMinutes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +37,7 @@ namespace OpenNest
|
|||||||
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
|
IntersectionCount = info1.IntersectionCount - info2.IntersectionCount,
|
||||||
TravelDistance = info1.TravelDistance - info2.TravelDistance,
|
TravelDistance = info1.TravelDistance - info2.TravelDistance,
|
||||||
PierceCount = info1.PierceCount - info2.PierceCount,
|
PierceCount = info1.PierceCount - info2.PierceCount,
|
||||||
|
ClampRemovalMinutes = info1.ClampRemovalMinutes - info2.ClampRemovalMinutes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +49,7 @@ namespace OpenNest
|
|||||||
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
|
IntersectionCount = info1.IntersectionCount * info2.IntersectionCount,
|
||||||
TravelDistance = info1.TravelDistance * info2.TravelDistance,
|
TravelDistance = info1.TravelDistance * info2.TravelDistance,
|
||||||
PierceCount = info1.PierceCount * info2.PierceCount,
|
PierceCount = info1.PierceCount * info2.PierceCount,
|
||||||
|
ClampRemovalMinutes = info1.ClampRemovalMinutes * info2.ClampRemovalMinutes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +61,7 @@ namespace OpenNest
|
|||||||
IntersectionCount = info1.IntersectionCount * factor,
|
IntersectionCount = info1.IntersectionCount * factor,
|
||||||
TravelDistance = info1.TravelDistance * factor,
|
TravelDistance = info1.TravelDistance * factor,
|
||||||
PierceCount = info1.PierceCount * factor,
|
PierceCount = info1.PierceCount * factor,
|
||||||
|
ClampRemovalMinutes = info1.ClampRemovalMinutes * factor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
namespace OpenNest.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decision logic behind the desktop Clamps dialog: edits one nest's clamp rail in that
|
||||||
|
/// nest's units. Three explicit boundaries: <see cref="LoadMachineDefaults"/> only fills
|
||||||
|
/// the editor, <see cref="ApplyTo"/> changes the nest (the dialog's OK), and
|
||||||
|
/// <see cref="ToMachineDefaults"/> is what "Save as Machine Defaults" writes immediately.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClampRailEditor
|
||||||
|
{
|
||||||
|
private readonly List<double> _centers = new();
|
||||||
|
|
||||||
|
/// <summary>Starts from the nest's rail, or the default clamp size with no clamps.</summary>
|
||||||
|
public ClampRailEditor(ClampRail? rail, Units units, bool applyToNewNests)
|
||||||
|
{
|
||||||
|
Units = units;
|
||||||
|
ApplyToNewNests = applyToNewNests;
|
||||||
|
var initial = ClampSettings.FromRail(rail, units, applyToNewNests);
|
||||||
|
Width = initial.Width;
|
||||||
|
Depth = initial.Depth;
|
||||||
|
RemovalMinutes = initial.RemovalMinutes;
|
||||||
|
_centers.AddRange(initial.Centers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The nest's units; every value is in these units.</summary>
|
||||||
|
public Units Units { get; }
|
||||||
|
|
||||||
|
/// <summary>Clamp extent along the edge (X).</summary>
|
||||||
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Clamp intrusion into the sheet (Y).</summary>
|
||||||
|
public double Depth { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Minutes to remove one clamp and put it back after cutting (D9); not a
|
||||||
|
/// length, so it is never converted between units.</summary>
|
||||||
|
public double RemovalMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether saved machine defaults give new nests these clamps.</summary>
|
||||||
|
public bool ApplyToNewNests { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Clamp centers along X from the plate origin, ascending and distinct.</summary>
|
||||||
|
public IReadOnlyList<double> Centers => _centers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a clamp center, keeping the list sorted. Refuses (with an operator-facing
|
||||||
|
/// reason) values that are not finite, negative, or already listed.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryAddCenter(double center, out string? error)
|
||||||
|
{
|
||||||
|
if (!double.IsFinite(center) || center < 0)
|
||||||
|
{
|
||||||
|
error = "Enter a clamp center measured from the plate origin (zero or more).";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = _centers.BinarySearch(center);
|
||||||
|
if (index >= 0)
|
||||||
|
{
|
||||||
|
error = $"A clamp is already centered at {center:0.####}.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_centers.Insert(~index, center);
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveCenterAt(int index)
|
||||||
|
{
|
||||||
|
if (index >= 0 && index < _centers.Count)
|
||||||
|
_centers.RemoveAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replaces the editor's values with the machine defaults converted to this nest's
|
||||||
|
/// units. Nothing is applied or saved.
|
||||||
|
/// </summary>
|
||||||
|
public void LoadMachineDefaults(ClampSettings settings)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(settings);
|
||||||
|
var converted = settings.ConvertedTo(Units);
|
||||||
|
Width = converted.Width;
|
||||||
|
Depth = converted.Depth;
|
||||||
|
RemovalMinutes = converted.RemovalMinutes;
|
||||||
|
ApplyToNewNests = settings.Enabled;
|
||||||
|
_centers.Clear();
|
||||||
|
_centers.AddRange(converted.Centers.Distinct().OrderBy(c => c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The edited rail, or null when no clamp is listed. Throws
|
||||||
|
/// <see cref="ArgumentOutOfRangeException"/> for a non-positive width or depth, or a
|
||||||
|
/// negative/non-finite removal time.
|
||||||
|
/// </summary>
|
||||||
|
public ClampRail? BuildRail() =>
|
||||||
|
_centers.Count == 0 ? null : new ClampRail(Width, Depth, _centers, RemovalMinutes);
|
||||||
|
|
||||||
|
/// <summary>The dialog's OK: makes the edited rail the nest's, re-resolving every plate.</summary>
|
||||||
|
public void ApplyTo(Nest nest)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
nest.SetClampRail(BuildRail());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The machine settings "Save as Machine Defaults" writes: these values in this nest's
|
||||||
|
/// units, enabled for new nests only when requested and at least one clamp is listed.
|
||||||
|
/// </summary>
|
||||||
|
public ClampSettings ToMachineDefaults()
|
||||||
|
{
|
||||||
|
// Validate before anything is persisted.
|
||||||
|
if (!double.IsFinite(Width) || Width <= 0 || !double.IsFinite(Depth) || Depth <= 0)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(Width),
|
||||||
|
"Clamp width and depth must be positive."
|
||||||
|
);
|
||||||
|
if (!double.IsFinite(RemovalMinutes) || RemovalMinutes < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(RemovalMinutes),
|
||||||
|
"Clamp removal time must be finite and zero or more."
|
||||||
|
);
|
||||||
|
return new ClampSettings
|
||||||
|
{
|
||||||
|
Enabled = ApplyToNewNests && _centers.Count > 0,
|
||||||
|
Units = Units,
|
||||||
|
Width = Width,
|
||||||
|
Depth = Depth,
|
||||||
|
RemovalMinutes = RemovalMinutes,
|
||||||
|
Centers = _centers.ToArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace OpenNest.Data;
|
||||||
|
|
||||||
|
/// <summary>Outcome of <see cref="ClampSettings.Load(string, out ClampSettingsStatus)"/>.</summary>
|
||||||
|
public enum ClampSettingsStatus
|
||||||
|
{
|
||||||
|
/// <summary>Every value present in the file was valid.</summary>
|
||||||
|
Ok,
|
||||||
|
|
||||||
|
/// <summary>No file exists at the path; clamps stay off.</summary>
|
||||||
|
Missing,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The file loaded, but some values were invalid and were ignored (an invalid clamp
|
||||||
|
/// center is dropped, other fields fall back individually). Callers should warn: the
|
||||||
|
/// resulting rail may lack a physical clamp.
|
||||||
|
/// </summary>
|
||||||
|
Partial,
|
||||||
|
|
||||||
|
/// <summary>The file exists but could not be read or parsed; clamps stay off.</summary>
|
||||||
|
Invalid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The machine's fixed edge-clamp rail, persisted to a single JSON file (by default
|
||||||
|
/// %APPDATA%\OpenNest\clamps.json). Off by default: until <see cref="Enabled"/> is set
|
||||||
|
/// with at least one center, new nests get no clamps and behave exactly as before.
|
||||||
|
/// Loading never throws; values are validated field by field.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClampSettings
|
||||||
|
{
|
||||||
|
public const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Default clamp extent along the edge, in <see cref="Units"/> (inches).</summary>
|
||||||
|
public const double DefaultWidth = 1.5;
|
||||||
|
|
||||||
|
/// <summary>Default clamp intrusion into the sheet, in <see cref="Units"/> (inches).</summary>
|
||||||
|
public const double DefaultDepth = 0.625;
|
||||||
|
|
||||||
|
private const double MillimetersPerInch = 25.4;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Whether new nests receive this rail. Default off.</summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Units of <see cref="Width"/>, <see cref="Depth"/> and <see cref="Centers"/>.</summary>
|
||||||
|
public Units Units { get; set; } = Units.Inches;
|
||||||
|
|
||||||
|
public double Width { get; set; } = DefaultWidth;
|
||||||
|
|
||||||
|
public double Depth { get; set; } = DefaultDepth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minutes to remove one clamp and put it back after cutting (D9), stored and edited in
|
||||||
|
/// the machine clamp settings alongside the rail's physical size.
|
||||||
|
/// </summary>
|
||||||
|
public double RemovalMinutes { get; set; } = ClampRail.DefaultRemovalMinutes;
|
||||||
|
|
||||||
|
/// <summary>Clamp centers along X, measured from the plate origin.</summary>
|
||||||
|
public IReadOnlyList<double> Centers { get; set; } = Array.Empty<double>();
|
||||||
|
|
||||||
|
/// <summary>The built-in settings used for a missing or unreadable file: clamps off.</summary>
|
||||||
|
public static ClampSettings Fallback => new();
|
||||||
|
|
||||||
|
/// <summary>%APPDATA%\OpenNest\clamps.json.</summary>
|
||||||
|
public static string DefaultPath =>
|
||||||
|
Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"OpenNest",
|
||||||
|
"clamps.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures a nest's rail (or the default clamp size and no centers when it has none)
|
||||||
|
/// in that nest's units, e.g. for "save as machine defaults".
|
||||||
|
/// </summary>
|
||||||
|
public static ClampSettings FromRail(ClampRail? rail, Units units, bool enabled) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Enabled = enabled,
|
||||||
|
Units = units,
|
||||||
|
Width = rail?.Width ?? ConvertDefault(DefaultWidth, units),
|
||||||
|
Depth = rail?.Depth ?? ConvertDefault(DefaultDepth, units),
|
||||||
|
RemovalMinutes = rail?.RemovalMinutes ?? ClampRail.DefaultRemovalMinutes,
|
||||||
|
Centers = rail?.Centers.ToArray() ?? Array.Empty<double>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The rail a new nest in <paramref name="units"/> receives, or null when clamps are
|
||||||
|
/// disabled or no center is configured. Values are converted between inches and
|
||||||
|
/// millimeters as needed; <see cref="RemovalMinutes"/> is a time, so it is never scaled.
|
||||||
|
/// </summary>
|
||||||
|
public ClampRail? RailFor(Units units)
|
||||||
|
{
|
||||||
|
if (!Enabled || Centers.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var converted = ConvertedTo(units);
|
||||||
|
return new ClampRail(converted.Width, converted.Depth, converted.Centers, converted.RemovalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy with width, depth and centers expressed in <paramref name="units"/>
|
||||||
|
/// (inches and millimeters convert at 25.4); <see cref="Enabled"/> and
|
||||||
|
/// <see cref="RemovalMinutes"/> (a time, not a length) are kept unscaled.
|
||||||
|
/// </summary>
|
||||||
|
public ClampSettings ConvertedTo(Units units)
|
||||||
|
{
|
||||||
|
var scale = Scale(Units, units);
|
||||||
|
return new ClampSettings
|
||||||
|
{
|
||||||
|
Enabled = Enabled,
|
||||||
|
Units = units,
|
||||||
|
Width = Width * scale,
|
||||||
|
Depth = Depth * scale,
|
||||||
|
RemovalMinutes = RemovalMinutes,
|
||||||
|
Centers = Centers.Select(c => c * scale).ToArray(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gives a newly created nest this machine rail when enabled (after the nest's units are
|
||||||
|
/// set); otherwise leaves it untouched.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyToNewNest(Nest nest)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(nest);
|
||||||
|
var rail = RailFor(nest.Units);
|
||||||
|
if (rail != null)
|
||||||
|
nest.SetClampRail(rail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ClampSettings Load(string path) => Load(path, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the settings, never throwing. A missing or unreadable file yields
|
||||||
|
/// <see cref="Fallback"/> (clamps off); a readable file merges valid values over it and
|
||||||
|
/// reports <see cref="ClampSettingsStatus.Partial"/> if anything was ignored.
|
||||||
|
/// </summary>
|
||||||
|
public static ClampSettings Load(string path, out ClampSettingsStatus status)
|
||||||
|
{
|
||||||
|
var settings = Fallback;
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||||
|
{
|
||||||
|
status = ClampSettingsStatus.Missing;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read as a raw document so a missing field (fallback, Ok) is distinguishable from a
|
||||||
|
// present but unusable one, including an explicit null (fallback, Partial).
|
||||||
|
Dictionary<string, JsonElement> values;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
||||||
|
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
status = ClampSettingsStatus.Invalid;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
values = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var property in document.RootElement.EnumerateObject())
|
||||||
|
values[property.Name] = property.Value.Clone();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (IsUnreadableFile(ex))
|
||||||
|
{
|
||||||
|
status = ClampSettingsStatus.Invalid;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ignored = false;
|
||||||
|
|
||||||
|
if (values.TryGetValue("enabled", out var enabled))
|
||||||
|
{
|
||||||
|
if (enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||||
|
settings.Enabled = enabled.GetBoolean();
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.TryGetValue("units", out var units))
|
||||||
|
{
|
||||||
|
if (TryParseUnits(units, out var parsed))
|
||||||
|
settings.Units = parsed;
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback sizes are the physical default clamp, expressed in the file's units.
|
||||||
|
settings.Width = ConvertDefault(DefaultWidth, settings.Units);
|
||||||
|
settings.Depth = ConvertDefault(DefaultDepth, settings.Units);
|
||||||
|
|
||||||
|
if (values.TryGetValue("width", out var width))
|
||||||
|
{
|
||||||
|
if (TryLength(width, out var value) && value > 0)
|
||||||
|
settings.Width = value;
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.TryGetValue("depth", out var depth))
|
||||||
|
{
|
||||||
|
if (TryLength(depth, out var value) && value > 0)
|
||||||
|
settings.Depth = value;
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.TryGetValue("removalMinutes", out var removalMinutes))
|
||||||
|
{
|
||||||
|
if (TryLength(removalMinutes, out var value) && value >= 0)
|
||||||
|
settings.RemovalMinutes = value;
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.TryGetValue("centers", out var centers))
|
||||||
|
{
|
||||||
|
if (centers.ValueKind == JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
var valid = new List<double>();
|
||||||
|
foreach (var center in centers.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (TryLength(center, out var x) && x >= 0)
|
||||||
|
valid.Add(x);
|
||||||
|
else
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
settings.Centers = valid.Distinct().OrderBy(x => x).ToArray();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ignored = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status = ignored ? ClampSettingsStatus.Partial : ClampSettingsStatus.Ok;
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the file (creating the parent directory), retrying briefly on IO collisions
|
||||||
|
/// the same way <see cref="LocalJsonProvider"/> does.
|
||||||
|
/// </summary>
|
||||||
|
public void Save(string path, int maxRetries = 3)
|
||||||
|
{
|
||||||
|
var dto = new
|
||||||
|
{
|
||||||
|
version = CurrentVersion,
|
||||||
|
enabled = Enabled,
|
||||||
|
units = Units.ToString().ToLowerInvariant(),
|
||||||
|
width = Width,
|
||||||
|
depth = Depth,
|
||||||
|
removalMinutes = RemovalMinutes,
|
||||||
|
centers = Centers,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(dto, JsonOptions);
|
||||||
|
|
||||||
|
var directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, json);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (IOException) when (attempt < maxRetries - 1)
|
||||||
|
{
|
||||||
|
Thread.Sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Scale(Units from, Units to) =>
|
||||||
|
from == to ? 1
|
||||||
|
: from == Units.Inches ? MillimetersPerInch
|
||||||
|
: 1 / MillimetersPerInch;
|
||||||
|
|
||||||
|
private static double ConvertDefault(double inches, Units units) =>
|
||||||
|
inches * Scale(Units.Inches, units);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A finite JSON number that stays finite in either unit, so applying it to an inch or a
|
||||||
|
/// millimeter nest can never overflow.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryLength(JsonElement element, out double value)
|
||||||
|
{
|
||||||
|
value = 0;
|
||||||
|
return element.ValueKind == JsonValueKind.Number
|
||||||
|
&& element.TryGetDouble(out value)
|
||||||
|
&& double.IsFinite(value)
|
||||||
|
&& double.IsFinite(value * MillimetersPerInch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accepts only a defined unit name (case-insensitive); Enum.TryParse would also accept
|
||||||
|
/// numeric strings and comma-joined names.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryParseUnits(JsonElement element, out Units units)
|
||||||
|
{
|
||||||
|
units = default;
|
||||||
|
if (element.ValueKind != JsonValueKind.String)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var text = element.GetString();
|
||||||
|
foreach (var candidate in Enum.GetValues<Units>())
|
||||||
|
{
|
||||||
|
if (string.Equals(candidate.ToString(), text, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
units = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnreadableFile(Exception ex) =>
|
||||||
|
ex
|
||||||
|
is JsonException
|
||||||
|
or IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Security.SecurityException;
|
||||||
|
}
|
||||||
@@ -34,4 +34,50 @@ public interface INestRepository
|
|||||||
Guid id, NestRecord record, CancellationToken cancellationToken = default);
|
Guid id, NestRecord record, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Reads bounded immutable history; unsupported backends fail closed.</summary>
|
||||||
|
Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support nest version history.");
|
||||||
|
|
||||||
|
Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support nest version history.");
|
||||||
|
|
||||||
|
Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support guarded metadata edits.");
|
||||||
|
|
||||||
|
Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support deleted nests.");
|
||||||
|
|
||||||
|
Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support deleted nests.");
|
||||||
|
|
||||||
|
Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support deleted nests.");
|
||||||
|
|
||||||
|
Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support deleted nest history.");
|
||||||
|
|
||||||
|
Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support deleted nest history.");
|
||||||
|
|
||||||
|
Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support guarded deletion.");
|
||||||
|
|
||||||
|
Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support guarded restoration.");
|
||||||
|
|
||||||
|
Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support guarded recovery.");
|
||||||
|
|
||||||
|
/// <summary>Atomically replaces the current archive only at the expected revision.</summary>
|
||||||
|
Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
throw new NotSupportedException("This repository does not support guarded saves. Upgrade the nest server and reload.");
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -11,18 +11,32 @@ public class MachineConfig
|
|||||||
public UnitSystem Units { get; set; } = UnitSystem.Inches;
|
public UnitSystem Units { get; set; } = UnitSystem.Inches;
|
||||||
public List<MaterialConfig> Materials { get; set; } = new();
|
public List<MaterialConfig> Materials { get; set; } = new();
|
||||||
|
|
||||||
public ThicknessConfig? GetParameters(string material, double thickness)
|
public ThicknessConfig? GetParameters(string material, double thickness) =>
|
||||||
|
GetParameters(material, null, thickness);
|
||||||
|
|
||||||
|
public ThicknessConfig? GetParameters(string material, string? grade, double thickness)
|
||||||
{
|
{
|
||||||
var mat = GetMaterial(material);
|
var mat = grade is null ? GetMaterial(material) : GetMaterial(material, grade);
|
||||||
if (mat is null)
|
return mat?.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
|
||||||
return null;
|
|
||||||
return mat.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public MaterialConfig? GetMaterial(string name)
|
/// <summary>
|
||||||
{
|
/// Returns the first material matching <paramref name="name"/>, regardless of
|
||||||
return Materials.FirstOrDefault(m =>
|
/// grade. Use <see cref="GetMaterial(string, string)"/> when the material may
|
||||||
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)
|
/// have more than one grade on file.
|
||||||
|
/// </summary>
|
||||||
|
public MaterialConfig? GetMaterial(string name) =>
|
||||||
|
Materials.FirstOrDefault(m => SharedListNames.Key(m.Name) == SharedListNames.Key(name));
|
||||||
|
|
||||||
|
public MaterialConfig? GetMaterial(string name, string grade) =>
|
||||||
|
Materials.FirstOrDefault(m =>
|
||||||
|
SharedListNames.Key(m.Name) == SharedListNames.Key(name)
|
||||||
|
&& SharedListNames.Key(m.Grade) == SharedListNames.Key(grade)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns every grade on file for the given material name.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<MaterialConfig> GetGrades(string name) =>
|
||||||
|
Materials.Where(m => SharedListNames.Key(m.Name) == SharedListNames.Key(name));
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,8 @@ public sealed class NestBrowseSession : IDisposable
|
|||||||
private int _generation;
|
private int _generation;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit)
|
public NestBrowseSession(INestRepository repository, int pageSize = NestQuery.DefaultLimit,
|
||||||
|
NestSortField sort = NestSortField.SavedAt, bool descending = true)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(repository);
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
|
if (pageSize < 1 || pageSize > NestQuery.MaxLimit)
|
||||||
@@ -23,6 +24,8 @@ public sealed class NestBrowseSession : IDisposable
|
|||||||
|
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
PageSize = pageSize;
|
PageSize = pageSize;
|
||||||
|
Sort = Enum.IsDefined(sort) ? sort : NestSortField.SavedAt;
|
||||||
|
Descending = descending;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int PageSize { get; }
|
public int PageSize { get; }
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace OpenNest.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URL-bound, bounded history browsing. Historical reads never bind an editable document.
|
||||||
|
/// A selection change cancels and invalidates previous reads even if a backend ignores cancellation.
|
||||||
|
/// Mutations use the displayed current revision; an uncertain failure requires an explicit refresh.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NestHistorySession : IDisposable
|
||||||
|
{
|
||||||
|
private readonly INestRepository repository;
|
||||||
|
private CancellationTokenSource? pending;
|
||||||
|
private int generation;
|
||||||
|
private bool disposed;
|
||||||
|
public string ServerUrl { get; }
|
||||||
|
public Guid NestId { get; private set; }
|
||||||
|
public bool Deleted { get; private set; }
|
||||||
|
public NestRecord? Current { get; private set; }
|
||||||
|
public NestRevisionPage? Page { get; private set; }
|
||||||
|
public bool IsMutating { get; private set; }
|
||||||
|
public const int PageSize = 50;
|
||||||
|
|
||||||
|
public NestHistorySession(INestRepository repository, string serverUrl)
|
||||||
|
{
|
||||||
|
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||||
|
ServerUrl = Normalize(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> LoadAsync(Guid id, bool deleted = false, int offset = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
|
||||||
|
if (id == Guid.Empty || offset < 0) throw new ArgumentOutOfRangeException(nameof(id));
|
||||||
|
pending?.Cancel();
|
||||||
|
var run = ++generation;
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
pending = cts;
|
||||||
|
NestId = id;
|
||||||
|
Deleted = deleted;
|
||||||
|
Current = null;
|
||||||
|
Page = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var record = deleted ? await repository.GetDeletedMetadataAsync(id, cts.Token)
|
||||||
|
: await repository.GetMetadataAsync(id, cts.Token);
|
||||||
|
if (record == null) throw new FileNotFoundException("This nest is no longer in this view. Refresh the list.");
|
||||||
|
ValidateRecord(record, id, deleted);
|
||||||
|
if (record.Revision < 1) throw new NotSupportedException("Upgrade the nest server to browse or change version history. Current nests can still be opened read-only.");
|
||||||
|
var page = deleted ? await repository.GetDeletedRevisionsAsync(id, offset, PageSize, cts.Token)
|
||||||
|
: await repository.GetRevisionsAsync(id, offset, PageSize, cts.Token);
|
||||||
|
if (page.Offset != offset || page.Items.Count > PageSize || page.Total < offset + page.Items.Count
|
||||||
|
|| page.Items.Any(r => r.Id != id || r.Revision < 1))
|
||||||
|
throw new InvalidDataException("The server returned invalid nest history.");
|
||||||
|
if (page.Items.Any(r => r.Revision > record.Revision))
|
||||||
|
throw new IOException("The current version changed while loading history. Refresh to review the new current version.");
|
||||||
|
if (run != generation || cts.IsCancellationRequested || disposed) return false;
|
||||||
|
Current = record;
|
||||||
|
Page = page;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception) when (run != generation || cts.IsCancellationRequested || disposed) { return false; }
|
||||||
|
finally { if (ReferenceEquals(pending, cts)) pending = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]?> DownloadAsync(int revision, string serverUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureReady(serverUrl);
|
||||||
|
var id = NestId;
|
||||||
|
var run = generation;
|
||||||
|
var snapshot = Page!.Items.SingleOrDefault(r => r.Revision == revision)
|
||||||
|
?? throw new InvalidOperationException("Select a revision on the displayed page.");
|
||||||
|
var file = Deleted ? await repository.GetDeletedRevisionFileAsync(id, revision, ct)
|
||||||
|
: await repository.GetRevisionFileAsync(id, revision, ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (disposed || run != generation) return null;
|
||||||
|
if (file == null || file.LongLength != snapshot.FileSize)
|
||||||
|
throw new InvalidDataException("The revision archive is missing or does not match its metadata. Refresh before trying again.");
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NestRecord> RestoreAsync(int sourceRevision, string serverUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureReady(serverUrl);
|
||||||
|
if (Deleted) throw new InvalidOperationException("Recover the deleted nest before restoring an earlier version.");
|
||||||
|
if (!Page!.Items.Any(r => r.Revision == sourceRevision)) throw new InvalidOperationException("Select a displayed revision.");
|
||||||
|
return await MutateAsync(() => repository.RestoreAsync(NestId, sourceRevision, Current!.Revision, ct), false, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NestRecord> RecoverAsync(string serverUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureReady(serverUrl);
|
||||||
|
if (!Deleted) throw new InvalidOperationException("This nest is not deleted.");
|
||||||
|
return await MutateAsync(() => repository.RecoverAsync(NestId, Current!.Revision, ct), false, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NestRecord> DeleteAsync(string serverUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
EnsureReady(serverUrl);
|
||||||
|
if (Deleted) throw new InvalidOperationException("This nest is already deleted.");
|
||||||
|
return await MutateAsync(() => repository.DeleteAsync(NestId, Current!.Revision, ct), true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NestRecord> MutateAsync(Func<Task<NestRecord>> write, bool deleted, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IsMutating = true;
|
||||||
|
var expected = Current!.Revision;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await write();
|
||||||
|
ValidateRecord(result, NestId, deleted);
|
||||||
|
if (result.Revision != expected + 1) throw new InvalidDataException("Invalid mutation revision. Refresh to reconcile.");
|
||||||
|
// Verify the exact target after the write; do not declare success from the response alone.
|
||||||
|
var verified = deleted ? await repository.GetDeletedMetadataAsync(NestId, ct)
|
||||||
|
: await repository.GetMetadataAsync(NestId, ct);
|
||||||
|
if (verified == null || verified.Id != NestId || verified.Revision < result.Revision || verified.Deleted != deleted)
|
||||||
|
throw new InvalidDataException("Could not verify the new current revision. Refresh to reconcile.");
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (disposed) throw new OperationCanceledException("The history browser was closed. Reopen to reconcile.");
|
||||||
|
Current = verified;
|
||||||
|
Deleted = deleted;
|
||||||
|
Page = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch { Current = null; throw; }
|
||||||
|
finally { IsMutating = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureReady(string url)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(disposed, this);
|
||||||
|
if (Normalize(url) != ServerUrl) throw new InvalidOperationException("The nest server changed. Close this browser and reopen it on the selected server.");
|
||||||
|
if (Current == null || IsMutating) throw new InvalidOperationException("Refresh the current version before continuing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateRecord(NestRecord record, Guid id, bool deleted)
|
||||||
|
{
|
||||||
|
if (record.Id != id || record.Deleted != deleted) throw new InvalidDataException("The server returned a different nest identity or visibility.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||||
|
|
||||||
|
/// <summary>Read an immutable current snapshot and ensure metadata did not change during download.</summary>
|
||||||
|
public static async Task<NestCurrentSnapshot> ReadCurrentAsync(INestRepository repository, Guid id,
|
||||||
|
string serverUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var before = await repository.GetMetadataAsync(id, ct)
|
||||||
|
?? throw new FileNotFoundException("This nest no longer exists on the server.");
|
||||||
|
ValidateRecord(before, id, false);
|
||||||
|
var beforeMetadata = JsonSerializer.Serialize(before);
|
||||||
|
var file = before.Revision > 0 ? await repository.GetRevisionFileAsync(id, before.Revision, ct)
|
||||||
|
: await repository.GetFileAsync(id, ct);
|
||||||
|
var after = await repository.GetMetadataAsync(id, ct);
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (after == null || JsonSerializer.Serialize(after) != beforeMetadata
|
||||||
|
|| file == null || file.LongLength != before.FileSize)
|
||||||
|
throw new InvalidDataException("The current nest changed or its archive does not match its metadata. Refresh and open it again.");
|
||||||
|
return new NestCurrentSnapshot(before, file, Normalize(serverUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
if (IsMutating) throw new InvalidOperationException("Wait for the current operation to finish.");
|
||||||
|
++generation;
|
||||||
|
pending?.Cancel();
|
||||||
|
NestId = Guid.Empty;
|
||||||
|
Current = null;
|
||||||
|
Page = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
disposed = true;
|
||||||
|
++generation;
|
||||||
|
pending?.Cancel();
|
||||||
|
Current = null;
|
||||||
|
Page = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record NestCurrentSnapshot(NestRecord Metadata, byte[] Archive, string ServerUrl);
|
||||||
@@ -11,6 +11,14 @@ public sealed class NestRecord
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Server revision; zero means the server does not support history.</summary>
|
||||||
|
public int Revision { get; set; }
|
||||||
|
|
||||||
|
public bool Deleted { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Selected historical revision promoted by an explicit Restore.</summary>
|
||||||
|
public int? RestoredFromRevision { get; set; }
|
||||||
|
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
|
||||||
public string Customer { get; set; } = "";
|
public string Customer { get; set; } = "";
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace OpenNest.Data;
|
||||||
|
|
||||||
|
/// <summary>A bounded page of immutable metadata snapshots, newest first.</summary>
|
||||||
|
public sealed class NestRevisionPage
|
||||||
|
{
|
||||||
|
public const int DefaultLimit = 100;
|
||||||
|
public const int MaxLimit = 200;
|
||||||
|
public IReadOnlyList<NestRecord> Items { get; init; } = Array.Empty<NestRecord>();
|
||||||
|
public int Total { get; init; }
|
||||||
|
public int Offset { get; init; }
|
||||||
|
public int Limit { get; init; }
|
||||||
|
}
|
||||||
@@ -9,6 +9,21 @@ public sealed class NestSaveSession
|
|||||||
{
|
{
|
||||||
public Guid RemoteId { get; private set; }
|
public Guid RemoteId { get; private set; }
|
||||||
public string ServerUrl { get; private set; } = "";
|
public string ServerUrl { get; private set; } = "";
|
||||||
|
public int Revision { get; private set; }
|
||||||
|
public bool RequiresReload { get; private set; }
|
||||||
|
public Guid PendingCreateId { get; private set; }
|
||||||
|
public bool PendingCreateWasCopy => PendingCreateId != Guid.Empty && pendingCreateWasCopy;
|
||||||
|
private string pendingCreateUrl = "";
|
||||||
|
private byte[]? pendingCreateArchive;
|
||||||
|
private NestRecord? pendingCreateMetadata;
|
||||||
|
private bool pendingCreateWasCopy;
|
||||||
|
|
||||||
|
public void Bind(Guid id, string serverUrl, int revision)
|
||||||
|
{
|
||||||
|
if (revision < 0) throw new ArgumentOutOfRangeException(nameof(revision));
|
||||||
|
Bind(id, serverUrl);
|
||||||
|
Revision = revision;
|
||||||
|
}
|
||||||
|
|
||||||
public void Bind(Guid id, string serverUrl)
|
public void Bind(Guid id, string serverUrl)
|
||||||
{
|
{
|
||||||
@@ -16,6 +31,36 @@ public sealed class NestSaveSession
|
|||||||
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
|
throw new ArgumentException("A saved nest needs a non-empty id.", nameof(id));
|
||||||
RemoteId = id;
|
RemoteId = id;
|
||||||
ServerUrl = Normalize(serverUrl);
|
ServerUrl = Normalize(serverUrl);
|
||||||
|
Revision = 0; // Legacy reads remain viewable, but must not become guarded saves.
|
||||||
|
RequiresReload = false;
|
||||||
|
PendingCreateId = Guid.Empty;
|
||||||
|
pendingCreateArchive = null;
|
||||||
|
pendingCreateMetadata = null;
|
||||||
|
pendingCreateWasCopy = false;
|
||||||
|
pendingCreateUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Explicitly reconcile an upload whose reply was lost before any retry.</summary>
|
||||||
|
public async Task<NestRecord?> ReconcilePendingCreateAsync(INestRepository repository,
|
||||||
|
string serverUrl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(repository);
|
||||||
|
if (PendingCreateId == Guid.Empty || Normalize(serverUrl) != pendingCreateUrl)
|
||||||
|
throw new InvalidOperationException("No pending upload on this server to reconcile.");
|
||||||
|
var record = await repository.GetMetadataAsync(PendingCreateId, cancellationToken);
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
// A deliberate subsequent Save may retry this SAME ID, never create a second ID.
|
||||||
|
RequiresReload = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var bytes = await repository.GetFileAsync(PendingCreateId, cancellationToken);
|
||||||
|
if (record.Id != PendingCreateId || record.Revision < 1 || bytes is null
|
||||||
|
|| !bytes.AsSpan().SequenceEqual(pendingCreateArchive)
|
||||||
|
|| !SameEditableMetadata(record, pendingCreateMetadata!))
|
||||||
|
throw new IOException("The pending upload differs from the server record. Reopen the server nest before editing.");
|
||||||
|
Bind(record.Id, pendingCreateUrl, record.Revision);
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<NestRecord> SaveAsync(
|
public async Task<NestRecord> SaveAsync(
|
||||||
@@ -30,17 +75,52 @@ public sealed class NestSaveSession
|
|||||||
if (normalizedUrl.Length == 0)
|
if (normalizedUrl.Length == 0)
|
||||||
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
|
throw new ArgumentException("A database server URL is required.", nameof(serverUrl));
|
||||||
|
|
||||||
|
if (PendingCreateId != Guid.Empty && (RequiresReload || normalizedUrl != pendingCreateUrl
|
||||||
|
|| saveCopy != pendingCreateWasCopy))
|
||||||
|
throw new IOException("Reconcile the pending upload on its original server, then retry with the same Save or Save As action.");
|
||||||
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
|
var id = !saveCopy && ServerUrl == normalizedUrl ? RemoteId : Guid.Empty;
|
||||||
var record = NestRecordFactory.FromNest(nest, id, archive.LongLength);
|
var creating = PendingCreateId != Guid.Empty || id == Guid.Empty;
|
||||||
var saved = id == Guid.Empty
|
var uploadId = creating ? (PendingCreateId == Guid.Empty ? Guid.NewGuid() : PendingCreateId) : id;
|
||||||
? await repository.UploadAsync(archive, record, cancellationToken)
|
var record = NestRecordFactory.FromNest(nest, uploadId, archive.LongLength);
|
||||||
: await repository.UpdateFileAsync(id, archive, record, cancellationToken);
|
if (!creating && (Revision < 1 || RequiresReload))
|
||||||
if (saved.Id == Guid.Empty || (id != Guid.Empty && saved.Id != id))
|
throw new IOException("Reload the current nest revision before saving, or Save As a new nest. Upgrade the nest server if version history is unavailable.");
|
||||||
throw new InvalidDataException("Server returned an invalid nest id.");
|
if (creating)
|
||||||
|
{
|
||||||
|
PendingCreateId = uploadId;
|
||||||
|
pendingCreateUrl = normalizedUrl;
|
||||||
|
pendingCreateArchive = archive.ToArray();
|
||||||
|
pendingCreateMetadata = record;
|
||||||
|
pendingCreateWasCopy = saveCopy;
|
||||||
|
}
|
||||||
|
NestRecord saved;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
saved = creating
|
||||||
|
? await repository.UploadAsync(archive, record, cancellationToken)
|
||||||
|
: await repository.UpdateFileAsync(id, archive, record, Revision, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or System.Net.Http.HttpRequestException or OperationCanceledException or System.Text.Json.JsonException)
|
||||||
|
{
|
||||||
|
// The request may have committed before its reply was lost. Do not replay
|
||||||
|
// pending bytes until the operator explicitly reloads/reconciles the record.
|
||||||
|
RequiresReload = true;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
if (saved.Id != uploadId || (!creating && saved.Revision < Revision))
|
||||||
|
{
|
||||||
|
RequiresReload = true;
|
||||||
|
throw new InvalidDataException("Server returned an invalid nest id or revision. Reload to reconcile before saving again.");
|
||||||
|
}
|
||||||
|
|
||||||
Bind(saved.Id, normalizedUrl);
|
Bind(saved.Id, normalizedUrl, saved.Revision);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
|
private static string Normalize(string? url) => (url ?? "").Trim().TrimEnd('/');
|
||||||
|
|
||||||
|
private static bool SameEditableMetadata(NestRecord a, NestRecord b) =>
|
||||||
|
a.Name == b.Name && a.Customer == b.Customer && a.DateCreated == b.DateCreated
|
||||||
|
&& a.DateModified == b.DateModified && a.Material == b.Material && a.Thickness == b.Thickness
|
||||||
|
&& a.Status == b.Status && a.PlateCount == b.PlateCount && a.PartCount == b.PartCount
|
||||||
|
&& a.Comments == b.Comments && a.MadeBy == b.MadeBy;
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,18 @@ public sealed class NestStorageSettings
|
|||||||
public bool IsDatabaseMode =>
|
public bool IsDatabaseMode =>
|
||||||
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
Mode == NestStorageMode.Database && !string.IsNullOrWhiteSpace(ServerUrl);
|
||||||
|
|
||||||
|
/// <summary>Validate and normalize a nest server base URL without changing storage mode.</summary>
|
||||||
|
public static bool TryNormalizeServerUrl(string? url, out string normalized)
|
||||||
|
{
|
||||||
|
normalized = NormalizeUrl(url);
|
||||||
|
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)
|
||||||
|
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
normalized = "";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
/// <summary>%APPDATA%\OpenNest\storage.json.</summary>
|
||||||
public static string DefaultPath => Path.Combine(
|
public static string DefaultPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
|||||||
return items ?? new List<NestRecord>();
|
return items ?? new List<NestRecord>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default)
|
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
|
||||||
|
QueryCoreAsync(query, false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<NestPage> QueryDeletedAsync(NestQuery query, CancellationToken cancellationToken = default) =>
|
||||||
|
QueryCoreAsync(query, true, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<NestPage> QueryCoreAsync(NestQuery query, bool deleted, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(query);
|
ArgumentNullException.ThrowIfNull(query);
|
||||||
var error = query.GetValidationError();
|
var error = query.GetValidationError();
|
||||||
@@ -67,7 +73,7 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
|||||||
throw new ArgumentException(error, nameof(query));
|
throw new ArgumentException(error, nameof(query));
|
||||||
|
|
||||||
using var response = await _httpClient.GetAsync(
|
using var response = await _httpClient.GetAsync(
|
||||||
Url("api/nests/query?" + BuildQueryString(query)), cancellationToken);
|
Url((deleted ? "api/nests/deleted/query?" : "api/nests/query?") + BuildQueryString(query)), cancellationToken);
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
throw new IOException(
|
throw new IOException(
|
||||||
@@ -93,18 +99,30 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
|||||||
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
|
+ "&offset=" + query.Offset.ToString(CultureInfo.InvariantCulture)
|
||||||
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
|
+ "&limit=" + query.Limit.ToString(CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
public async Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default)
|
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
|
GetMetadataCoreAsync(id, false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<NestRecord?> GetDeletedMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
|
GetMetadataCoreAsync(id, true, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<NestRecord?> GetMetadataCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}"), cancellationToken);
|
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}"), cancellationToken);
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
return null;
|
return null;
|
||||||
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
|
await EnsureSuccess(response, $"get nest {id}", cancellationToken);
|
||||||
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
return await response.Content.ReadFromJsonAsync<NestRecord>(JsonOptions, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default)
|
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
|
GetFileCoreAsync(id, false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<byte[]?> GetDeletedFileAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||||
|
GetFileCoreAsync(id, true, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<byte[]?> GetFileCoreAsync(Guid id, bool deleted, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var response = await _httpClient.GetAsync(Url($"api/nests/{id}/file"), cancellationToken);
|
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/file"), cancellationToken);
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
return null;
|
return null;
|
||||||
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
|
await EnsureSuccess(response, $"download nest {id}", cancellationToken);
|
||||||
@@ -123,26 +141,145 @@ public sealed class RemoteNestRepository : INestRepository, IDisposable
|
|||||||
public async Task<NestRecord> UpdateFileAsync(
|
public async Task<NestRecord> UpdateFileAsync(
|
||||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var content = BuildMultipart(nestFile, record);
|
return await UpdateFileAsync(id, nestFile, record, record.Revision, cancellationToken);
|
||||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/file"), content, cancellationToken);
|
|
||||||
await EnsureSuccess(response, $"update nest {id}", cancellationToken);
|
|
||||||
return await ReadRecord(response, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<NestRecord> UpdateMetadataAsync(
|
public async Task<NestRecord> UpdateMetadataAsync(
|
||||||
Guid id, NestRecord record, CancellationToken cancellationToken = default)
|
Guid id, NestRecord record, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var content = new StringContent(
|
return await UpdateMetadataAsync(id, record, record.Revision, cancellationToken);
|
||||||
JsonSerializer.Serialize(record, JsonOptions), Encoding.UTF8, "application/json");
|
|
||||||
using var response = await _httpClient.PutAsync(Url($"api/nests/{id}/metadata"), content, cancellationToken);
|
|
||||||
await EnsureSuccess(response, $"update nest metadata {id}", cancellationToken);
|
|
||||||
return await ReadRecord(response, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var response = await _httpClient.DeleteAsync(Url($"api/nests/{id}"), cancellationToken);
|
await Task.CompletedTask;
|
||||||
await EnsureSuccess(response, $"delete nest {id}", cancellationToken);
|
throw new IOException("Deletion requires guarded soft-delete support and is not available yet. Upgrade the client before deleting a server nest.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<NestRevisionPage> GetRevisionsAsync(Guid id, int offset = 0, int limit = 100,
|
||||||
|
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<NestRevisionPage> GetDeletedRevisionsAsync(Guid id, int offset = 0, int limit = 100,
|
||||||
|
CancellationToken cancellationToken = default) => GetHistoryAsync(id, offset, limit, true, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<NestRevisionPage> GetHistoryAsync(Guid id, int offset, int limit, bool deleted,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidateId(id);
|
||||||
|
if (offset < 0) throw new ArgumentOutOfRangeException(nameof(offset));
|
||||||
|
if (limit < 1 || limit > NestRevisionPage.MaxLimit) throw new ArgumentOutOfRangeException(nameof(limit));
|
||||||
|
using var response = await _httpClient.GetAsync(
|
||||||
|
Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions?offset={offset.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
throw new IOException("Nest history is unavailable or this nest no longer exists. Upgrade the nest server if it does not support version history, then reload.");
|
||||||
|
await EnsureSuccess(response, $"read nest history {id}", cancellationToken);
|
||||||
|
var page = await response.Content.ReadFromJsonAsync<NestRevisionPage>(JsonOptions, cancellationToken)
|
||||||
|
?? throw new IOException("Nest server returned an empty history page.");
|
||||||
|
if (page.Items is null || page.Items.Count > limit || page.Offset != offset || page.Limit != limit
|
||||||
|
|| page.Total < 0 || page.Items.Count > System.Math.Max(0L, (long)page.Total - offset)
|
||||||
|
|| page.Items.Any(item => item is null || item.Revision < 1 || item.Id != id)
|
||||||
|
|| page.Items.Zip(page.Items.Skip(1)).Any(pair => pair.First.Revision <= pair.Second.Revision))
|
||||||
|
throw new IOException("Nest server returned an invalid or unbounded history page.");
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]?> GetRevisionFileAsync(Guid id, int revisionNumber,
|
||||||
|
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, false, cancellationToken);
|
||||||
|
|
||||||
|
public Task<byte[]?> GetDeletedRevisionFileAsync(Guid id, int revisionNumber,
|
||||||
|
CancellationToken cancellationToken = default) => GetHistoricalFileAsync(id, revisionNumber, true, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<byte[]?> GetHistoricalFileAsync(Guid id, int revisionNumber, bool deleted,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidateId(id);
|
||||||
|
ValidateRevision(revisionNumber);
|
||||||
|
using var response = await _httpClient.GetAsync(Url($"api/nests/{(deleted ? "deleted/" : "")}{id}/revisions/{revisionNumber.ToString(CultureInfo.InvariantCulture)}/file"), cancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound) return null;
|
||||||
|
await EnsureSuccess(response, $"download nest {id} revision {revisionNumber}", cancellationToken);
|
||||||
|
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NestRecord> UpdateFileAsync(Guid id, byte[] nestFile, NestRecord record, int expectedRevision,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ValidateId(id);
|
||||||
|
if (expectedRevision < 1)
|
||||||
|
throw new IOException("This nest has no known revision. Upgrade the nest server and reload before saving, or Save As a new nest.");
|
||||||
|
// A legacy server can ignore If-Match and overwrite data. Prove history support
|
||||||
|
// before sending any mutation; never infer revision 1 from a legacy read.
|
||||||
|
var history = await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
|
||||||
|
if (history.Items.Count == 0)
|
||||||
|
throw new IOException("Nest server returned no revision history. Upgrade the nest server and reload before saving.");
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/file"));
|
||||||
|
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||||
|
request.Content = BuildMultipart(nestFile, record);
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
await EnsureSuccess(response, $"update nest {id}; reload the current version or Save As after a conflict", cancellationToken);
|
||||||
|
var saved = await ReadRecord(response, cancellationToken);
|
||||||
|
if (saved.Id != id || saved.Revision < expectedRevision)
|
||||||
|
throw new IOException("Nest server returned an invalid saved revision. Reload to reconcile before saving again.");
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, int expectedRevision,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ValidateId(id);
|
||||||
|
ValidateRevision(expectedRevision);
|
||||||
|
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, Url($"api/nests/{id}/metadata"));
|
||||||
|
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||||
|
request.Content = JsonContent.Create(record, options: JsonOptions);
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
await EnsureSuccess(response, $"edit nest {id}; reload after a conflict", cancellationToken);
|
||||||
|
return await ReadRecord(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NestRecord> RestoreAsync(Guid id, int sourceRevision, int expectedRevision,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ValidateId(id);
|
||||||
|
ValidateRevision(sourceRevision);
|
||||||
|
ValidateRevision(expectedRevision);
|
||||||
|
await GetRevisionsAsync(id, limit: 1, cancellationToken: cancellationToken);
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post,
|
||||||
|
Url($"api/nests/{id}/revisions/{sourceRevision.ToString(CultureInfo.InvariantCulture)}/restore"));
|
||||||
|
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
await EnsureSuccess(response, $"restore nest {id}; reload after a conflict", cancellationToken);
|
||||||
|
return await ReadRecord(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<NestRecord> DeleteAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||||
|
ChangeVisibilityAsync(id, expectedRevision, true, cancellationToken);
|
||||||
|
|
||||||
|
public Task<NestRecord> RecoverAsync(Guid id, int expectedRevision, CancellationToken cancellationToken = default) =>
|
||||||
|
ChangeVisibilityAsync(id, expectedRevision, false, cancellationToken);
|
||||||
|
|
||||||
|
private async Task<NestRecord> ChangeVisibilityAsync(Guid id, int expectedRevision, bool deleted,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidateId(id);
|
||||||
|
ValidateRevision(expectedRevision);
|
||||||
|
// A legacy server cannot be trusted to preserve history on DELETE.
|
||||||
|
await GetHistoryAsync(id, 0, 1, !deleted, cancellationToken);
|
||||||
|
using var request = new HttpRequestMessage(deleted ? HttpMethod.Delete : HttpMethod.Post,
|
||||||
|
Url(deleted ? $"api/nests/{id}" : $"api/nests/deleted/{id}/recover"));
|
||||||
|
request.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{expectedRevision.ToString(CultureInfo.InvariantCulture)}\""));
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
await EnsureSuccess(response, $"change nest visibility {id}; reload after a conflict", cancellationToken);
|
||||||
|
return await ReadRecord(response, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateId(Guid id)
|
||||||
|
{
|
||||||
|
if (id == Guid.Empty) throw new ArgumentException("A nest id is required.", nameof(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateRevision(int revisionNumber)
|
||||||
|
{
|
||||||
|
if (revisionNumber < 1) throw new ArgumentOutOfRangeException(nameof(revisionNumber));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice 4: interactive pushes and spacing expansion never target poses inside a
|
||||||
|
/// clamp keep-out. The plate's zones act as static obstacles for Compactor.Push,
|
||||||
|
/// and as static walls for Expander — including the Expander edge inputs (clusters
|
||||||
|
/// that already overlap or are pinned to an edge), which must degrade to a
|
||||||
|
/// violation report instead of a crash.
|
||||||
|
/// </summary>
|
||||||
|
public class ClampZonePushTests
|
||||||
|
{
|
||||||
|
// Plate: 24 x 24, 0.5 uniform edge spacing, part spacing 1.0. Zones on the
|
||||||
|
// front edge; validation clearance (bottom edge spacing) is 0.5.
|
||||||
|
private const double Edge = 0.5;
|
||||||
|
|
||||||
|
private static readonly Box ZoneA = new(3, 0, 1.5, 0.625);
|
||||||
|
private static readonly Box ZoneB = new(8, 0, 1.5, 0.625);
|
||||||
|
|
||||||
|
private static Plate MakePlate(params ClampZone[] zones)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(24, 24));
|
||||||
|
plate.EdgeSpacing = new Spacing(Edge, Edge);
|
||||||
|
plate.PartSpacing = 1.0;
|
||||||
|
foreach (var zone in zones)
|
||||||
|
plate.ClampZones.Add(zone);
|
||||||
|
return plate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program SquareProgram(double size)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(size, 0);
|
||||||
|
program.LineTo(size, size);
|
||||||
|
program.LineTo(0, size);
|
||||||
|
program.LineTo(0, size);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part AddSquare(Plate plate, double x, double y, double size = 3)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing($"sq{plate.Parts.Count}", SquareProgram(size)), new Vector(x, y));
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Box Inflated(Box zone, double clearance) =>
|
||||||
|
new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
|
||||||
|
|
||||||
|
private static bool Overlaps(Box a, Box b) =>
|
||||||
|
a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6;
|
||||||
|
|
||||||
|
private static void AssertClear(Plate plate, params Part[] parts)
|
||||||
|
{
|
||||||
|
foreach (var part in parts)
|
||||||
|
foreach (var zone in plate.ClampZones)
|
||||||
|
Assert.False(
|
||||||
|
Overlaps(part.BoundingBox, Inflated(zone.Bounds, Edge)),
|
||||||
|
$"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) enters clamp '{zone.Id}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PushDownTowardTheClampEdgeStopsClearOfTheKeepOut()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
var mover = AddSquare(plate, 3, 8); // column crosses zone front-1
|
||||||
|
|
||||||
|
var moved = Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.True(moved > 0.1, $"expected a downward move, got {moved}");
|
||||||
|
AssertClear(plate, mover);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PushDownBetweenTheClampsStillReachesTheEdge()
|
||||||
|
{
|
||||||
|
// Material between clamps stays usable (D6): the free column pushes to the edge.
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
var mover = AddSquare(plate, 14, 8);
|
||||||
|
|
||||||
|
var moved = Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.True(moved > 7, $"free column should reach near the edge, moved {moved}");
|
||||||
|
AssertClear(plate, mover);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettleDoesNotMoveGroupThroughAnOccupiedPart()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(20, 20))
|
||||||
|
{
|
||||||
|
EdgeSpacing = new Spacing(0.25, 0.25),
|
||||||
|
PartSpacing = 0.1,
|
||||||
|
};
|
||||||
|
plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625)));
|
||||||
|
var occupant = AddSquare(plate, 0.25, 0.25, 4);
|
||||||
|
var first = AddSquare(plate, 6, 7);
|
||||||
|
var second = AddSquare(plate, 11, 7);
|
||||||
|
Compactor.Settle(new List<Part> { first, second }, plate, plate.WorkArea());
|
||||||
|
Assert.False(Overlaps(first.BoundingBox, occupant.BoundingBox));
|
||||||
|
Assert.False(Overlaps(second.BoundingBox, occupant.BoundingBox));
|
||||||
|
AssertClear(plate, first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SettleKeepsGroupOutsideClampsInsideItsFillArea()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA));
|
||||||
|
var first = AddSquare(plate, 3, 8);
|
||||||
|
var second = AddSquare(plate, 12, 8);
|
||||||
|
Compactor.Settle(new List<Part> { first, second }, plate, plate.WorkArea());
|
||||||
|
AssertClear(plate, first, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PushWithAlreadyOverlappingMemberDoesNotMoveClearMemberIntoClamp()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA));
|
||||||
|
var straddler = AddSquare(plate, 3, 0.5);
|
||||||
|
var clear = AddSquare(plate, 3, 8);
|
||||||
|
Compactor.Push(new List<Part> { straddler, clear }, plate, PushDirection.Down);
|
||||||
|
AssertClear(plate, clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpanderPreservesBottomEdgeClearanceWhenItsAchievedSpacingIsSmaller()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(12, 12))
|
||||||
|
{
|
||||||
|
EdgeSpacing = new Spacing(2, 2),
|
||||||
|
PartSpacing = 0.1,
|
||||||
|
};
|
||||||
|
plate.ClampZones.Add(new ClampZone("front", new Box(4, 0, 2, 1)));
|
||||||
|
var upper = AddSquare(plate, 4, 5, 2);
|
||||||
|
var lower = AddSquare(plate, 4, 3.1, 2);
|
||||||
|
var result = Expander.Expand(new List<Part> { upper, lower }, plate);
|
||||||
|
Assert.False(result.Cancelled);
|
||||||
|
Assert.True(lower.BoundingBox.Bottom >= 3 - 1e-6,
|
||||||
|
$"Achieved={result.AchievedSpacing}, lower at {lower.Location.Y}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpanderNeverTargetsAPoseInsideAClampWhenGrowthWouldEnterOne()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
// Overlapping pair directly above zone front-2 (validation keep-out top y = 1.125):
|
||||||
|
// separating them drives the lower square down through the keep-out boundary.
|
||||||
|
var upper = AddSquare(plate, 8, 4.0, 3);
|
||||||
|
var lower = AddSquare(plate, 8, 1.2, 3);
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { upper, lower }, plate);
|
||||||
|
|
||||||
|
Assert.False(result.Cancelled);
|
||||||
|
AssertClear(plate, upper, lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpanderDegradesToAReportWhenAMemberAlreadyOverlapsAClamp()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
var straddler = AddSquare(plate, 3, 0.3, 3); // starts inside the clamp keep-out
|
||||||
|
var clear = AddSquare(plate, 15, 0.3, 3);
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { straddler, clear }, plate);
|
||||||
|
|
||||||
|
Assert.False(result.Cancelled);
|
||||||
|
// The clear member must not be driven into any clamp while its partner is stuck.
|
||||||
|
AssertClear(plate, clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpanderOnEdgePinnedClusterWithClampsReportsInsteadOfCrashing()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
// Three tight squares away from the clamp edge (the Expander edge-input class:
|
||||||
|
// an overlapping, mutually blocked cluster). All start clear of the zones.
|
||||||
|
var a = AddSquare(plate, 15, 0.5, 3);
|
||||||
|
var b = AddSquare(plate, 17.75, 0.5, 3);
|
||||||
|
var c = AddSquare(plate, 15, 3.25, 3);
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { a, b, c }, plate);
|
||||||
|
|
||||||
|
Assert.False(result.Cancelled);
|
||||||
|
// Whatever the achieved spacing or reported violations, no clear member may
|
||||||
|
// end inside a clamp keep-out.
|
||||||
|
AssertClear(plate, a, b, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderCutoffDeltaTests
|
||||||
|
{
|
||||||
|
private static Plate Sheet() => new(new Size(30, 30)) { EdgeSpacing = new Spacing(0, 0) };
|
||||||
|
|
||||||
|
private static Program Rectangle(double width = 2, double height = 2)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(width, 0);
|
||||||
|
program.LineTo(width, height);
|
||||||
|
program.LineTo(0, height);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Add(Plate plate, Program program, double x, double y)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing("neutral", program), new Vector(x, y));
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Disk(double radius) =>
|
||||||
|
ConvertGeometry.ToProgram(new List<Entity> { new Circle(0, 0, radius) });
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CutOffAxis.Horizontal)]
|
||||||
|
[InlineData(CutOffAxis.Vertical)]
|
||||||
|
public void Expand_RegeneratedCutoff_MustPreserveNativeEntryGap(CutOffAxis axis)
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var anchor = Add(plate, Rectangle(), 1, 1);
|
||||||
|
var mover = Add(plate, Rectangle(), 4, 4);
|
||||||
|
plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
|
||||||
|
var settings = new CutOffSettings();
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
var before = axis == CutOffAxis.Horizontal
|
||||||
|
? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 });
|
||||||
|
var after = axis == CutOffAxis.Horizontal
|
||||||
|
? 10 - mover.BoundingBox.Top : 10 - mover.BoundingBox.Right;
|
||||||
|
Assert.True(after >= before - 1.1e-6,
|
||||||
|
$"Cutoff entry gap shrank {before:R}->{after:R}, mover=({mover.Location.X:R},{mover.Location.Y:R}), achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
Assert.Single(plate.CutOffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expand_RemoteOpenU_MustNotMoveTowardNativeCutoff()
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var anchor = Add(plate, Rectangle(), 1, 4);
|
||||||
|
var mover = Add(plate, Rectangle(), 4, 4);
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 10);
|
||||||
|
program.LineTo(30, 10);
|
||||||
|
program.LineTo(30, 0);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
plate.Parts.Add(new Part(new Drawing("neutral-cutoff", program) { IsCutOff = true }, Vector.Zero));
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 });
|
||||||
|
// The three real segments are at least four units from the selected
|
||||||
|
// square at entry; an invented closing chord is not a real obstacle.
|
||||||
|
var gap = System.Math.Min(mover.BoundingBox.Bottom,
|
||||||
|
System.Math.Min(10 - mover.BoundingBox.Top, 30 - mover.BoundingBox.Right));
|
||||||
|
Assert.True(gap >= 4 - 1.1e-6,
|
||||||
|
$"Open path gap shrank to {gap:R}; mover=({mover.Location.X:R},{mover.Location.Y:R}); spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void Separate_ShallowDiskOrRingVsRectangle_MustNotClaimClear(bool annulus)
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var entities = new List<Entity> { new Circle(0, 0, 2) };
|
||||||
|
if (annulus)
|
||||||
|
entities.Add(new Circle(0, 0, 1));
|
||||||
|
Add(plate, ConvertGeometry.ToProgram(entities), 10, 10);
|
||||||
|
const double angle = 0.017;
|
||||||
|
const double depth = 0.0005;
|
||||||
|
var mover = Add(plate, Rectangle(0.01, 0.01),
|
||||||
|
10 + (2 - depth) * System.Math.Cos(angle),
|
||||||
|
10 + (2 - depth) * System.Math.Sin(angle));
|
||||||
|
|
||||||
|
var result = Expander.Separate(new List<Part> { mover }, plate, 0);
|
||||||
|
var nearest = result.Positions[0].DistanceTo(new Vector(10, 10));
|
||||||
|
Assert.True(nearest >= 2 - 1e-8 || result.Violations.Any(v => v.Achieved < 0),
|
||||||
|
$"Native material overlap remains: nearest={nearest:R}, converged={result.Converged}, violations={result.Violations.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(CutOffAxis.Horizontal)]
|
||||||
|
[InlineData(CutOffAxis.Vertical)]
|
||||||
|
public void Separate_RegeneratedCutoff_MustNotCrossEvenAtFarClearEndpoint(CutOffAxis axis)
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var horizontal = axis == CutOffAxis.Horizontal;
|
||||||
|
var anchor = Add(plate, Rectangle(), 1, 1);
|
||||||
|
var mover = Add(plate, Rectangle(), horizontal ? 1 : 6, horizontal ? 6 : 1);
|
||||||
|
plate.CutOffs.Add(new CutOff(new Vector(10, 10), axis));
|
||||||
|
plate.RegenerateCutOffs(new CutOffSettings());
|
||||||
|
|
||||||
|
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 12);
|
||||||
|
var trailingEdge = (horizontal ? result.Positions[1].Y : result.Positions[1].X) + 2;
|
||||||
|
Assert.True(trailingEdge <= 10 + 1e-6,
|
||||||
|
$"Crossed native cutoff, trailing edge={trailingEdge:R}");
|
||||||
|
Assert.False(result.Converged);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderCutoffQuadrantTests
|
||||||
|
{
|
||||||
|
private static Program Square()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(2, 0);
|
||||||
|
program.LineTo(2, 2);
|
||||||
|
program.LineTo(0, 2);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> Cases()
|
||||||
|
{
|
||||||
|
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
||||||
|
foreach (var verticalMotion in new[] { false, true })
|
||||||
|
foreach (var cutoff in new[] { false, true })
|
||||||
|
foreach (var expand in new[] { false, true })
|
||||||
|
yield return new object[] { quadrant, verticalMotion, cutoff, expand };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Cases))]
|
||||||
|
public void RemoteCutoffMustNotPinNegativeParallelMotion(
|
||||||
|
int quadrant, bool verticalMotion, bool cutoff, bool expand)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(80, 80))
|
||||||
|
{
|
||||||
|
Quadrant = quadrant,
|
||||||
|
EdgeSpacing = new Spacing(0, 0),
|
||||||
|
};
|
||||||
|
var origin = new Vector(
|
||||||
|
quadrant == 2 || quadrant == 3 ? -80 : 0,
|
||||||
|
quadrant == 3 || quadrant == 4 ? -80 : 0);
|
||||||
|
var anchorPose = origin + new Vector(30, 30);
|
||||||
|
var moverPose = anchorPose + (verticalMotion ? new Vector(0, -3) : new Vector(-3, 0));
|
||||||
|
var anchor = new Part(new Drawing("anchor", Square()), anchorPose);
|
||||||
|
var mover = new Part(new Drawing("mover", Square()), moverPose);
|
||||||
|
plate.Parts.Add(anchor);
|
||||||
|
plate.Parts.Add(mover);
|
||||||
|
|
||||||
|
if (cutoff)
|
||||||
|
{
|
||||||
|
plate.CutOffs.Add(new CutOff(
|
||||||
|
origin + (verticalMotion ? new Vector(40, 0) : new Vector(0, 40)),
|
||||||
|
verticalMotion ? CutOffAxis.Vertical : CutOffAxis.Horizontal));
|
||||||
|
plate.RegenerateCutOffs(new CutOffSettings());
|
||||||
|
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector end;
|
||||||
|
int violations;
|
||||||
|
if (expand)
|
||||||
|
{
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 });
|
||||||
|
end = mover.Location;
|
||||||
|
violations = result.Violations.Count;
|
||||||
|
Assert.True(result.AchievedSpacing >= 2.99,
|
||||||
|
$"spacing={result.AchievedSpacing:R}; end=({end.X:R},{end.Y:R})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 3);
|
||||||
|
end = result.Positions[1];
|
||||||
|
violations = result.Violations.Count;
|
||||||
|
Assert.True(result.Converged,
|
||||||
|
$"unchanged=({end.X:R},{end.Y:R}), violations={violations}");
|
||||||
|
Assert.Equal(moverPose.X, mover.Location.X);
|
||||||
|
Assert.Equal(moverPose.Y, mover.Location.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two units of negative-axis motion opens the selected gap 1 -> 3.
|
||||||
|
// The parallel open cutoff remains eight units away throughout.
|
||||||
|
Assert.True(verticalMotion ? end.Y <= moverPose.Y - 1.999 : end.X <= moverPose.X - 1.999);
|
||||||
|
Assert.Equal(0, violations);
|
||||||
|
Assert.Equal(anchorPose.X, anchor.Location.X);
|
||||||
|
Assert.Equal(anchorPose.Y, anchor.Location.Y);
|
||||||
|
if (cutoff)
|
||||||
|
{
|
||||||
|
plate.RegenerateCutOffs(new CutOffSettings());
|
||||||
|
Assert.Single(plate.CutOffs);
|
||||||
|
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderFinalQualityTests
|
||||||
|
{
|
||||||
|
private static Plate Sheet() => new(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
|
||||||
|
|
||||||
|
private static Program Disk(double radius) =>
|
||||||
|
ConvertGeometry.ToProgram(new List<Entity> { new Circle(0, 0, radius) });
|
||||||
|
|
||||||
|
private static Program Rectangle(double width, double height)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(width, 0);
|
||||||
|
program.LineTo(width, height);
|
||||||
|
program.LineTo(0, height);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part Add(Plate plate, Program program, double x, double y)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing("neutral", program), new Vector(x, y));
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.73, 0.001)]
|
||||||
|
[InlineData(0.017, 0.001)]
|
||||||
|
[InlineData(0.73, 0.0001)]
|
||||||
|
public void Expand_NativeDisks_MustReportOrRemoveRemainingOverlap(double angle, double depth)
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var anchor = Add(plate, Disk(2), 7, 6);
|
||||||
|
var mover = Add(plate, Disk(2),
|
||||||
|
7 + (4 - depth) * System.Math.Cos(angle),
|
||||||
|
6 + (4 - depth) * System.Math.Sin(angle));
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 });
|
||||||
|
var nativeGap = anchor.Location.DistanceTo(mover.Location) - 4;
|
||||||
|
Assert.True(nativeGap >= -1e-8 || result.Violations.Any(v => v.Achieved < 0),
|
||||||
|
$"Native disks still overlap by {-nativeGap:R} but spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}; mover=({mover.Location.X:R},{mover.Location.Y:R})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_CurvedExactContact_CanStillGrowARealGap()
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
Add(plate, Disk(2), 10, 6);
|
||||||
|
var mover = Add(plate, Rectangle(1, 1), 12, 5.5);
|
||||||
|
var result = Expander.Separate(new List<Part> { mover }, plate, spacing: 1);
|
||||||
|
Assert.True(result.Converged, $"curved contact pinned: {result.Violations.Count} violations");
|
||||||
|
Assert.True(result.Positions[0].X >= 13 - 1e-3);
|
||||||
|
Assert.Equal(12, mover.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_SmallConcaveOverlap_MustNotClaimConvergedAtZero()
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var program = new Program();
|
||||||
|
const double k = 0.0001;
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(6 * k, 0);
|
||||||
|
program.LineTo(6 * k, 6 * k);
|
||||||
|
program.LineTo(4 * k, 6 * k);
|
||||||
|
program.LineTo(4 * k, 2 * k);
|
||||||
|
program.LineTo(2 * k, 2 * k);
|
||||||
|
program.LineTo(2 * k, 6 * k);
|
||||||
|
program.LineTo(0, 6 * k);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
Add(plate, program, 10, 5);
|
||||||
|
var mover = Add(plate, Rectangle(2 * k, 2 * k), 10 + k, 5 + 3 * k);
|
||||||
|
// Left finger [10,10.0002] × [5.0002,5.0006] intersects the
|
||||||
|
// mover [10.0001,10.0003] × [5.0003,5.0005] in positive area.
|
||||||
|
var result = Expander.Separate(new List<Part> { mover }, plate, 0);
|
||||||
|
Assert.False(result.Converged,
|
||||||
|
$"Known material overlap; pose={result.Positions[0]}, violations={result.Violations.Count}");
|
||||||
|
Assert.Contains(result.Violations, v => v.Achieved < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expand_RemoteRegeneratedCutoff_MustNotPinUnrelatedGrowth()
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
|
||||||
|
var mover = Add(plate, Rectangle(2, 2), 4, 4);
|
||||||
|
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
|
||||||
|
var settings = new CutOffSettings();
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
var cutPart = Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 });
|
||||||
|
Assert.True(mover.Location.X >= 6 - 1e-4 && result.AchievedSpacing >= 2.99,
|
||||||
|
$"Remote cutoff pinned horizontal move: x={mover.Location.X:R}, achieved={result.AchievedSpacing:R}, violations={result.Violations.Count}");
|
||||||
|
Assert.Equal(0, cutPart.Location.X);
|
||||||
|
Assert.Equal(0, cutPart.Location.Y);
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
Assert.Single(plate.CutOffs);
|
||||||
|
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expand_RegeneratedCutoffBlocksCrossingEvenWithClearFarEndpoint()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(20, 30)) { EdgeSpacing = new Spacing(0, 0) };
|
||||||
|
var anchor = Add(plate, Rectangle(2, 2), 1, 1);
|
||||||
|
var mover = Add(plate, Rectangle(2, 2), 1, 6);
|
||||||
|
plate.CutOffs.Add(new CutOff(new Vector(0, 10), CutOffAxis.Horizontal));
|
||||||
|
var settings = new CutOffSettings();
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
Assert.Single(plate.Parts.Where(p => p.BaseDrawing.IsCutOff));
|
||||||
|
|
||||||
|
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, spacing: 12);
|
||||||
|
Assert.False(result.Converged);
|
||||||
|
Assert.True(result.Positions[1].Y + 2 <= 10 + 1e-6,
|
||||||
|
$"Mover crossed regenerated cut path: y={result.Positions[1].Y:R}");
|
||||||
|
Assert.Equal(6, mover.Location.Y);
|
||||||
|
plate.RegenerateCutOffs(settings);
|
||||||
|
Assert.Single(plate.CutOffs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expand_OutsideUnchangedMover_MustNotReturnUnqualifiedSuccess()
|
||||||
|
{
|
||||||
|
var plate = Sheet();
|
||||||
|
var anchor = Add(plate, Rectangle(2, 2), 1, 4);
|
||||||
|
var mover = Add(plate, Rectangle(2, 2), 35, 4);
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 });
|
||||||
|
|
||||||
|
Assert.True(mover.BoundingBox.Right > 30); // The invalid entry stays unchanged.
|
||||||
|
Assert.Equal(35, mover.Location.X);
|
||||||
|
Assert.Equal(0, result.AchievedSpacing);
|
||||||
|
var edge = Assert.Single(result.Violations.Where(v => ReferenceEquals(v.A, mover)));
|
||||||
|
Assert.Null(edge.B);
|
||||||
|
Assert.True(edge.BlockedByEdge);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderIntersectingOpenPathTests
|
||||||
|
{
|
||||||
|
private static Program Square()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(2, 0);
|
||||||
|
program.LineTo(2, 2);
|
||||||
|
program.LineTo(0, 2);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> Cases()
|
||||||
|
{
|
||||||
|
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
||||||
|
foreach (var transpose in new[] { false, true })
|
||||||
|
foreach (var expand in new[] { false, true })
|
||||||
|
yield return new object[] { quadrant, transpose, expand };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Cases))]
|
||||||
|
public void IntersectingOpenPathMustRemainAViolation(int quadrant, bool transpose, bool expand)
|
||||||
|
{
|
||||||
|
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
|
||||||
|
quadrant is 3 or 4 ? -100 : 0);
|
||||||
|
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
|
||||||
|
var plate = new Plate(new Size(100, 100))
|
||||||
|
{
|
||||||
|
Quadrant = quadrant,
|
||||||
|
EdgeSpacing = new Spacing(0, 0),
|
||||||
|
};
|
||||||
|
var expectedA = origin + Swap(40, 40);
|
||||||
|
var expectedB = origin + Swap(43, 40);
|
||||||
|
var anchor = new Part(new Drawing("anchor", Square()), expectedA);
|
||||||
|
var mover = new Part(new Drawing("mover", Square()), expectedB);
|
||||||
|
plate.Parts.Add(anchor);
|
||||||
|
plate.Parts.Add(mover);
|
||||||
|
|
||||||
|
var path = new Program();
|
||||||
|
var start = Swap(20, 40);
|
||||||
|
var across = Swap(70, 42);
|
||||||
|
var high = Swap(70, 60);
|
||||||
|
var tail = Swap(75, 60);
|
||||||
|
path.MoveTo(start.X, start.Y);
|
||||||
|
path.LineTo(across.X, across.Y);
|
||||||
|
path.LineTo(high.X, high.Y);
|
||||||
|
path.LineTo(tail.X, tail.Y);
|
||||||
|
var cutoff = new Part(new Drawing("neutral-open-cut", path) { IsCutOff = true }, origin);
|
||||||
|
plate.Parts.Add(cutoff);
|
||||||
|
|
||||||
|
// First segment is y=40+0.04*(x-20). At x=41 and x=44,
|
||||||
|
// y=40.84 and 40.96 are strictly INSIDE both squares. Signed origins
|
||||||
|
// and transposition preserve these independent interior witnesses.
|
||||||
|
List<Expander.Violation> violations;
|
||||||
|
if (expand)
|
||||||
|
{
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 0.1 });
|
||||||
|
violations = result.Violations;
|
||||||
|
Assert.Equal(0, result.AchievedSpacing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 0);
|
||||||
|
violations = result.Violations;
|
||||||
|
Assert.False(result.Converged,
|
||||||
|
$"Native path intersects both squares but reports converged with {violations.Count} violations");
|
||||||
|
}
|
||||||
|
Assert.Contains(violations, v => ReferenceEquals(v.B, cutoff) && v.Achieved < 0);
|
||||||
|
Assert.Equal(expectedA.X, anchor.Location.X);
|
||||||
|
Assert.Equal(expectedA.Y, anchor.Location.Y);
|
||||||
|
Assert.Equal(expectedB.X, mover.Location.X);
|
||||||
|
Assert.Equal(expectedB.Y, mover.Location.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderQualityReviewTests
|
||||||
|
{
|
||||||
|
private static Program Rectangle(double width, double height)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(width, 0);
|
||||||
|
program.LineTo(width, height);
|
||||||
|
program.LineTo(0, height);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Part AddSquare(Plate plate, double x, double y, double size)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing("neutral", Rectangle(size, size)), new Vector(x, y));
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Plate MakePlate(double length) =>
|
||||||
|
new(new Size(12, length)) { EdgeSpacing = new Spacing(0, 0) };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TinyZoneContainedInMover_MustNotReturnClearPositiveSpacing()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30);
|
||||||
|
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
|
||||||
|
var anchor = AddSquare(plate, 1, 4, 2);
|
||||||
|
var mover = AddSquare(plate, 4, 4, 2);
|
||||||
|
// Independent oracle: [5,5.00001]^2 lies strictly inside [4,6]^2.
|
||||||
|
var result = Expander.Expand(
|
||||||
|
new List<Part> { anchor, mover },
|
||||||
|
plate,
|
||||||
|
new Expander.Options { InitialStep = 3, MaxSpacing = 3 }
|
||||||
|
);
|
||||||
|
Assert.True(
|
||||||
|
result.Violations.Count > 0,
|
||||||
|
$"Physical clamp is still inside mover but report is clear: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}"
|
||||||
|
);
|
||||||
|
Assert.True(result.Violations[0].Achieved < 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TinyZoneInsideCutoutIsNotReportedAsMaterialOverlap()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30);
|
||||||
|
plate.ClampZones.Add(new ClampZone("tiny", new Box(5, 5, 1e-5, 1e-5)));
|
||||||
|
var frameProgram = Rectangle(10, 10);
|
||||||
|
frameProgram.MoveTo(4, 4);
|
||||||
|
frameProgram.LineTo(6, 4);
|
||||||
|
frameProgram.LineTo(6, 6);
|
||||||
|
frameProgram.LineTo(4, 6);
|
||||||
|
frameProgram.LineTo(4, 4);
|
||||||
|
var frame = new Part(new Drawing("neutral-frame", frameProgram), Vector.Zero);
|
||||||
|
plate.Parts.Add(frame);
|
||||||
|
|
||||||
|
var result = Expander.Separate(new List<Part> { frame }, plate, spacing: 0.5);
|
||||||
|
Assert.True(result.Converged, $"zone inside cutout reported as material: {result.Violations.Count}");
|
||||||
|
Assert.Empty(result.Violations);
|
||||||
|
Assert.Equal(0, frame.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EdgeLimitedConvexOverlap_ShouldRetainCertifiedPartialImprovement()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(11.5);
|
||||||
|
var anchor = AddSquare(plate, 5, 4, 4);
|
||||||
|
var mover = AddSquare(plate, 7, 4, 4);
|
||||||
|
var result = Expander.Expand(
|
||||||
|
new List<Part> { anchor, mover }, plate, new Expander.Options { MaxSpacing = 1 }
|
||||||
|
);
|
||||||
|
// Along x in [7,7.5], penetration is 9-x and strictly decreases.
|
||||||
|
Assert.True(
|
||||||
|
mover.Location.X >= 7.49,
|
||||||
|
$"Lost safe partial-overlap improvement: x={mover.Location.X:R}; expected edge at 7.5"
|
||||||
|
);
|
||||||
|
Assert.NotEmpty(result.Violations);
|
||||||
|
Assert.True(mover.Location.X + 4 <= 11.5 + 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NegativeTarget_ConvexOverlapCanReachLessNegativeClearance()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30);
|
||||||
|
var anchor = AddSquare(plate, 5, 4, 4);
|
||||||
|
var mover = AddSquare(plate, 7, 4, 4);
|
||||||
|
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, -1);
|
||||||
|
Assert.True(result.Converged, $"Signed target -1 was not reached; x={result.Positions[1].X:R}");
|
||||||
|
Assert.True(result.Positions[1].X >= 8);
|
||||||
|
Assert.Equal(7, mover.Location.X);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderRemoteObliquePathTests
|
||||||
|
{
|
||||||
|
private static Program Square()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(2, 0);
|
||||||
|
program.LineTo(2, 2);
|
||||||
|
program.LineTo(0, 2);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> Cases()
|
||||||
|
{
|
||||||
|
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
||||||
|
foreach (var transpose in new[] { false, true })
|
||||||
|
foreach (var cutoff in new[] { false, true })
|
||||||
|
foreach (var expand in new[] { false, true })
|
||||||
|
yield return new object[] { quadrant, transpose, cutoff, expand };
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Cases))]
|
||||||
|
public void RemoteObliqueOpenPathMustNotPinSafeParallelGrowth(
|
||||||
|
int quadrant, bool transpose, bool cutoff, bool expand)
|
||||||
|
{
|
||||||
|
var origin = new Vector(quadrant is 2 or 3 ? -100 : 0,
|
||||||
|
quadrant is 3 or 4 ? -100 : 0);
|
||||||
|
Vector Swap(double x, double y) => transpose ? new Vector(y, x) : new Vector(x, y);
|
||||||
|
var plate = new Plate(new Size(100, 100))
|
||||||
|
{
|
||||||
|
Quadrant = quadrant,
|
||||||
|
EdgeSpacing = new Spacing(0, 0),
|
||||||
|
};
|
||||||
|
var anchorPose = origin + Swap(40, 40);
|
||||||
|
var moverPose = origin + Swap(43, 40);
|
||||||
|
var anchor = new Part(new Drawing("anchor", Square()), anchorPose);
|
||||||
|
var mover = new Part(new Drawing("mover", Square()), moverPose);
|
||||||
|
plate.Parts.Add(anchor);
|
||||||
|
plate.Parts.Add(mover);
|
||||||
|
if (cutoff)
|
||||||
|
{
|
||||||
|
var path = new Program();
|
||||||
|
var start = Swap(18, 32);
|
||||||
|
var middle = Swap(68, 64);
|
||||||
|
var high = Swap(68, 80);
|
||||||
|
var tail = Swap(75, 80);
|
||||||
|
path.MoveTo(start.X, start.Y);
|
||||||
|
path.LineTo(middle.X, middle.Y);
|
||||||
|
path.LineTo(high.X, high.Y);
|
||||||
|
path.LineTo(tail.X, tail.Y);
|
||||||
|
plate.Parts.Add(new Part(new Drawing("neutral-path", path) { IsCutOff = true }, origin));
|
||||||
|
}
|
||||||
|
// First line is y=.64*x+20.48. The anchor's nearest corner (40,42)
|
||||||
|
// has exact gap 4.08/sqrt(1+.64^2)>3; mover (43,42) >5.
|
||||||
|
Vector end;
|
||||||
|
if (expand)
|
||||||
|
{
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, mover }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 2 });
|
||||||
|
end = mover.Location;
|
||||||
|
Assert.True(result.AchievedSpacing >= 1.99,
|
||||||
|
$"Remote cutoff pinned mover: spacing={result.AchievedSpacing:R}, violations={result.Violations.Count}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var result = Expander.Separate(new List<Part> { anchor, mover }, plate, 2);
|
||||||
|
end = result.Positions[1];
|
||||||
|
Assert.True(result.Converged,
|
||||||
|
$"Remote cutoff pinned mover: end=({end.X:R},{end.Y:R}), violations={result.Violations.Count}");
|
||||||
|
Assert.Equal(moverPose.X, mover.Location.X);
|
||||||
|
Assert.Equal(moverPose.Y, mover.Location.Y);
|
||||||
|
}
|
||||||
|
Assert.True(transpose ? end.Y >= moverPose.Y + 0.999 : end.X >= moverPose.X + 0.999);
|
||||||
|
Assert.Equal(anchorPose.X, anchor.Location.X);
|
||||||
|
Assert.Equal(anchorPose.Y, anchor.Location.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Fill;
|
||||||
|
|
||||||
|
public class ExpanderSpecRegressionTests
|
||||||
|
{
|
||||||
|
private static Program Disk(double radius)
|
||||||
|
{
|
||||||
|
var p = new Program();
|
||||||
|
p.MoveTo(radius, 0);
|
||||||
|
p.ArcTo(-radius, 0, 0, 0, RotationType.CCW);
|
||||||
|
p.ArcTo(radius, 0, 0, 0, RotationType.CCW);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (Plate Plate, Part A, Part B) Fixture()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(12, 30)) { EdgeSpacing = new Spacing(0, 0) };
|
||||||
|
var a = new Part(new Drawing("disk-a", Disk(2)), new Vector(7, 6));
|
||||||
|
var b = new Part(new Drawing("disk-b", Disk(2)), new Vector(9, 6));
|
||||||
|
plate.Parts.Add(a);
|
||||||
|
plate.Parts.Add(b);
|
||||||
|
return (plate, a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_OverlappingNativeDisks_RetainsCertifiedConvexExit()
|
||||||
|
{
|
||||||
|
var (plate, a, b) = Fixture();
|
||||||
|
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
|
||||||
|
Assert.True(result.Converged,
|
||||||
|
$"remaining violations={result.Violations.Count}; mover=({result.Positions[1].X:R},{result.Positions[1].Y:R})");
|
||||||
|
// Independent analytic disk material clearance, not production polygons.
|
||||||
|
Assert.True(result.Positions[1].DistanceTo(result.Positions[0]) - 4 >= 2.99);
|
||||||
|
Assert.Equal(7, a.Location.X);
|
||||||
|
Assert.Equal(9, b.Location.X); // scratch-only
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_OverlappingNativeDisks_CannotPassThroughThirdWall()
|
||||||
|
{
|
||||||
|
var (plate, a, b) = Fixture();
|
||||||
|
var wallProgram = new Program();
|
||||||
|
wallProgram.MoveTo(0, 0);
|
||||||
|
wallProgram.LineTo(0.2, 0);
|
||||||
|
wallProgram.LineTo(0.2, 12);
|
||||||
|
wallProgram.LineTo(0, 12);
|
||||||
|
wallProgram.LineTo(0, 0);
|
||||||
|
var wall = new Part(new Drawing("fixed-wall", wallProgram), new Vector(12, 0));
|
||||||
|
plate.Parts.Add(wall);
|
||||||
|
|
||||||
|
var result = Expander.Separate(new List<Part> { a, b }, plate, spacing: 3);
|
||||||
|
Assert.False(result.Converged);
|
||||||
|
Assert.True(result.Positions[1].X + 2 <= 12 + 1e-6,
|
||||||
|
$"native disk crossed third wall: centre={result.Positions[1].X:R}");
|
||||||
|
Assert.Equal(9, b.Location.X);
|
||||||
|
Assert.Equal(12, wall.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expand_OverlappingNativeDisks_RetainsExistingSeparation()
|
||||||
|
{
|
||||||
|
var (plate, a, b) = Fixture();
|
||||||
|
var result = Expander.Expand(new List<Part> { a, b }, plate,
|
||||||
|
new Expander.Options { InitialStep = 0.5, MaxSpacing = 3 });
|
||||||
|
var nativeGap = a.Location.DistanceTo(b.Location) - 4;
|
||||||
|
Assert.True(nativeGap >= 2.99,
|
||||||
|
$"native gap={nativeGap:R}; achieved={result.AchievedSpacing:R}; violations={result.Violations.Count}");
|
||||||
|
Assert.True(result.AchievedSpacing >= 2.9);
|
||||||
|
Assert.Empty(result.Violations);
|
||||||
|
Assert.Equal(7, a.Location.X);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,13 @@ public class ExpanderTests
|
|||||||
return part;
|
return part;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Part AddRectangle(Plate plate, double x, double y, double length, double width)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing($"rect{plate.Parts.Count}", Rectangle(length, width)), new Vector(x, y));
|
||||||
|
plate.Parts.Add(part);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
|
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
|
||||||
{
|
{
|
||||||
var plate = new Plate(new Size(widthY, lengthX));
|
var plate = new Plate(new Size(widthY, lengthX));
|
||||||
@@ -327,6 +334,323 @@ public class ExpanderTests
|
|||||||
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
|
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { a, a }, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_PreservesEachEntryGap_EvenWhenTargetIsSmaller()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30, 12, edge: 0);
|
||||||
|
var anchor = AddSquare(plate, 1, 4, 2);
|
||||||
|
var mover = AddSquare(plate, 4, 4, 2);
|
||||||
|
var wall = AddSquare(plate, 12, 4, 2);
|
||||||
|
var initialWallGap = wall.Location.X - (mover.Location.X + 2);
|
||||||
|
|
||||||
|
var (converged, positions, _) = Expander.Separate(
|
||||||
|
new List<Part> { anchor, mover }, plate, spacing: 3
|
||||||
|
);
|
||||||
|
|
||||||
|
// The wall is six units away at entry. Merely clearing the requested
|
||||||
|
// three-unit spacing is not permission to consume that original gap.
|
||||||
|
Assert.True(positions[1].X + 2 <= wall.Location.X - initialWallGap + 1e-6,
|
||||||
|
$"entry gap {initialWallGap}, proposed gap {wall.Location.X - positions[1].X - 2}");
|
||||||
|
Assert.Equal(new Vector(4, 4), mover.Location);
|
||||||
|
Assert.Equal(new Vector(12, 4), wall.Location);
|
||||||
|
Assert.False(converged); // This direct translation cannot reach 3 safely.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_RetriesMiddleAfterLaterBlockerMoves()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(60, 14, edge: 0);
|
||||||
|
var first = AddSquare(plate, 5, 5);
|
||||||
|
var middle = AddSquare(plate, 10, 5);
|
||||||
|
var last = AddSquare(plate, 14, 5);
|
||||||
|
var selected = new List<Part> { first, middle, last };
|
||||||
|
|
||||||
|
var (converged, positions, violations) = Expander.Separate(selected, plate, spacing: 2);
|
||||||
|
Assert.True(converged, $"remaining: {violations.Count}");
|
||||||
|
Assert.Empty(violations);
|
||||||
|
Assert.Equal(5, positions[0].X);
|
||||||
|
Assert.True(positions[1].X >= 11 - 1e-4);
|
||||||
|
Assert.True(positions[2].X >= positions[1].X + 6 - 1e-4);
|
||||||
|
Assert.Equal(10, middle.Location.X); // Separate is scratch-only.
|
||||||
|
Assert.Equal(14, last.Location.X);
|
||||||
|
|
||||||
|
var result = Expander.Expand(selected, plate, new Expander.Options { MaxSpacing = 2 });
|
||||||
|
Assert.True(result.AchievedSpacing >= 1.99, $"achieved {result.AchievedSpacing}");
|
||||||
|
Assert.True(middle.Location.X >= 11 - 1e-4);
|
||||||
|
Assert.True(last.Location.X >= middle.Location.X + 6 - 1e-4);
|
||||||
|
Assert.Equal(5, first.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_CannotPassThroughFullHeightWall_ToClearEndpoint()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30, 12, edge: 0);
|
||||||
|
var anchor = AddSquare(plate, 1, 4, 2);
|
||||||
|
var moving = AddSquare(plate, 4, 4, 2);
|
||||||
|
var wall = AddRectangle(plate, 6.5, 0, 0.2, 12);
|
||||||
|
|
||||||
|
// Target 5 puts the otherwise clear endpoint beyond the wall AND
|
||||||
|
// preserves the entry wall gap of 0.5. An endpoint-only check passes it.
|
||||||
|
var (converged, poses, violations) = Expander.Separate(
|
||||||
|
new List<Part> { anchor, moving }, plate, spacing: 5
|
||||||
|
);
|
||||||
|
Assert.False(converged);
|
||||||
|
Assert.NotEmpty(violations);
|
||||||
|
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6,
|
||||||
|
$"move crossed wall: right={poses[1].X + 2}, wall={wall.Location.X}");
|
||||||
|
Assert.Equal(4, moving.Location.X); // public Separate never commits.
|
||||||
|
Assert.Equal(6.5, wall.Location.X);
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { anchor, moving }, plate,
|
||||||
|
new Expander.Options { InitialStep = 5, MaxSpacing = 5 });
|
||||||
|
Assert.True(moving.Location.X + 2 <= wall.Location.X + 1e-6);
|
||||||
|
Assert.True(result.AchievedSpacing < 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_OverlappingPair_CannotExitAcrossThirdWall()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30, 12, edge: 0);
|
||||||
|
var anchor = AddSquare(plate, 5, 4);
|
||||||
|
var moving = AddSquare(plate, 7, 4); // Overlaps anchor by two.
|
||||||
|
var wall = AddRectangle(plate, 12, 0, 0.2, 12);
|
||||||
|
|
||||||
|
var (converged, poses, violations) = Expander.Separate(
|
||||||
|
new List<Part> { anchor, moving }, plate, spacing: 5
|
||||||
|
);
|
||||||
|
Assert.False(converged);
|
||||||
|
Assert.NotEmpty(violations);
|
||||||
|
Assert.True(poses[1].X + 4 <= wall.Location.X + 1e-6,
|
||||||
|
$"overlap exit crossed third wall: {poses[1].X}");
|
||||||
|
Assert.Equal(7, moving.Location.X);
|
||||||
|
Assert.Equal(12, wall.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_CannotCrossOppositeCutoutWall()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(40, 20, edge: 0);
|
||||||
|
var host = new Part(new Drawing("host", RectangleWithHole(10, 10, 0.2, 0.2, 9.6, 9.6)), new Vector(5, 1));
|
||||||
|
plate.Parts.Add(host);
|
||||||
|
var anchor = AddSquare(plate, 1, 5, 1);
|
||||||
|
var moving = AddSquare(plate, 5.2, 5, 1); // Touches inner left wall.
|
||||||
|
|
||||||
|
var (converged, poses, _) = Expander.Separate(
|
||||||
|
new List<Part> { anchor, moving }, plate, spacing: 14
|
||||||
|
);
|
||||||
|
Assert.False(converged);
|
||||||
|
Assert.True(poses[1].X + 1 <= 14.8 + 1e-6,
|
||||||
|
$"insert crossed opposite hole wall: {poses[1].X}");
|
||||||
|
Assert.Equal(5.2, moving.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_TinyClampZone_IsNotAnInvisibleWall()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30, 12, edge: 0);
|
||||||
|
plate.ClampZones.Add(new ClampZone("tiny", new Box(6.5, 0, 1e-5, 1e-5)));
|
||||||
|
var anchor = AddSquare(plate, 1, 0, 2);
|
||||||
|
var moving = AddSquare(plate, 4, 0, 2);
|
||||||
|
|
||||||
|
var (converged, poses, _) = Expander.Separate(
|
||||||
|
new List<Part> { anchor, moving }, plate, spacing: 6
|
||||||
|
);
|
||||||
|
Assert.False(converged);
|
||||||
|
Assert.True(poses[1].X + 2 <= 6.5 + 1e-6,
|
||||||
|
$"crossed tiny clamp at x=6.5: right={poses[1].X + 2}");
|
||||||
|
Assert.Equal(4, moving.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_RemoteTinyClampDoesNotPinUnrelatedGrowth()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(30, 12, edge: 0);
|
||||||
|
plate.ClampZones.Add(new ClampZone("tiny", new Box(0, 0, 1e-5, 1e-5)));
|
||||||
|
var anchor = AddSquare(plate, 1, 4, 2);
|
||||||
|
var moving = AddSquare(plate, 4, 4, 2);
|
||||||
|
|
||||||
|
var (converged, poses, violations) = Expander.Separate(
|
||||||
|
new List<Part> { anchor, moving }, plate, spacing: 3
|
||||||
|
);
|
||||||
|
Assert.True(converged, $"remote zone pinned growth: {violations.Count} violations, " +
|
||||||
|
$"poses {poses[0].X:R},{poses[1].X:R}; " +
|
||||||
|
string.Join("; ", violations.Select(v => $"{v.A.BaseDrawing.Name}/{v.B.BaseDrawing.Name}={v.Achieved:R}")));
|
||||||
|
Assert.True(poses[1].X >= 6 - 1e-4);
|
||||||
|
Assert.Equal(4, moving.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_TouchingHoleWall_UsesOppositeDirectionToOpenGap()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(40, 24, edge: 0);
|
||||||
|
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), new Vector(0, 0));
|
||||||
|
plate.Parts.Add(host);
|
||||||
|
var insert = AddSquare(plate, 13, 9, 2);
|
||||||
|
|
||||||
|
var (converged, poses, violations) = Expander.Separate(
|
||||||
|
new List<Part> { insert }, plate, spacing: 1
|
||||||
|
);
|
||||||
|
Assert.True(converged, $"contact still blocked: {violations.Count}");
|
||||||
|
Assert.True(poses[0].X <= 12 + 1e-4, $"insert at x={poses[0].X}");
|
||||||
|
Assert.True(poses[0].X >= 5 + 1 - 1e-4);
|
||||||
|
Assert.Equal(13, insert.Location.X);
|
||||||
|
Assert.Equal(0, host.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(13, 9)]
|
||||||
|
[InlineData(5, 9)]
|
||||||
|
[InlineData(9, 13)]
|
||||||
|
[InlineData(9, 5)]
|
||||||
|
[InlineData(12.9, 9)] // Positive gap uses its ordinary nearest-boundary direction.
|
||||||
|
public void Separate_FourHoleSides_ReachARealUnitGap(double x, double y)
|
||||||
|
{
|
||||||
|
var plate = MakePlate(40, 24, edge: 0);
|
||||||
|
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
|
||||||
|
plate.Parts.Add(host);
|
||||||
|
var insert = AddSquare(plate, x, y, 2);
|
||||||
|
|
||||||
|
var (converged, poses, violations) = Expander.Separate(new List<Part> { insert }, plate, 1);
|
||||||
|
Assert.True(converged, $"{x},{y}: {violations.Count} violations");
|
||||||
|
Assert.InRange(poses[0].X, 6 - 1e-4, 12 + 1e-4);
|
||||||
|
Assert.InRange(poses[0].Y, 6 - 1e-4, 12 + 1e-4);
|
||||||
|
Assert.Equal(x, insert.Location.X);
|
||||||
|
Assert.Equal(y, insert.Location.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Expand_TouchingHoleInsertWithSecondSelectedPart_StaysInsideCutout()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(40, 24, edge: 0);
|
||||||
|
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
|
||||||
|
plate.Parts.Add(host);
|
||||||
|
var insert = AddSquare(plate, 13, 9, 2);
|
||||||
|
var other = AddSquare(plate, 30, 9, 2);
|
||||||
|
|
||||||
|
var result = Expander.Expand(new List<Part> { insert, other }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 1 });
|
||||||
|
Assert.False(result.Cancelled);
|
||||||
|
Assert.True(result.AchievedSpacing >= 0.99);
|
||||||
|
Assert.InRange(insert.Location.X, 6 - 1e-4, 12 + 1e-4);
|
||||||
|
Assert.False(MateriallyOverlaps(insert, host));
|
||||||
|
Assert.Equal(0, host.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_ReportsPartBlockerRatherThanPlateEdge()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(20, 20, edge: 0);
|
||||||
|
var pinned = AddSquare(plate, 8, 8);
|
||||||
|
AddSquare(plate, 3.8, 8);
|
||||||
|
AddSquare(plate, 12.2, 8);
|
||||||
|
AddSquare(plate, 8, 3.8);
|
||||||
|
AddSquare(plate, 8, 12.2);
|
||||||
|
|
||||||
|
var (_, poses, violations) = Expander.Separate(new List<Part> { pinned }, plate, 1);
|
||||||
|
Assert.NotEmpty(violations);
|
||||||
|
Assert.All(violations, v => Assert.False(v.BlockedByEdge,
|
||||||
|
$"{v.B.BaseDrawing.Name}: blocker before the work-area edge, pose={poses[0]}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_ReportsActualPlateEdgeWhenItLimitsMovement()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(12, 12, edge: 0);
|
||||||
|
var anchor = AddSquare(plate, 1, 4, 2);
|
||||||
|
var moving = AddSquare(plate, 7, 4, 2);
|
||||||
|
|
||||||
|
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 9);
|
||||||
|
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
|
||||||
|
Assert.True(pair.BlockedByEdge);
|
||||||
|
Assert.True(poses[1].X + 2 <= 12 + 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_ReportsPartBeforeFartherEdge_OnNarrowPlate()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(9, 2, edge: 0);
|
||||||
|
var anchor = AddSquare(plate, 0, 0, 2);
|
||||||
|
var moving = AddSquare(plate, 3, 0, 2);
|
||||||
|
var wall = AddRectangle(plate, 5.5, 0, 0.2, 2);
|
||||||
|
|
||||||
|
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 4);
|
||||||
|
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
|
||||||
|
Assert.False(pair.BlockedByEdge);
|
||||||
|
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData((int)Expander.Checkpoint.AfterScratchMove)]
|
||||||
|
[InlineData((int)Expander.Checkpoint.AfterSuccessfulProbe)]
|
||||||
|
[InlineData((int)Expander.Checkpoint.BeforeApply)]
|
||||||
|
public void Expand_CancellationAtDeterministicCheckpoint_LeavesLiveStateBitIdentical(int stage)
|
||||||
|
{
|
||||||
|
var cancelAt = (Expander.Checkpoint)stage;
|
||||||
|
var plate = MakePlate(30, 12, edge: 0);
|
||||||
|
var anchor = AddSquare(plate, 5, 4);
|
||||||
|
var moving = AddSquare(plate, 10, 4);
|
||||||
|
var wall = AddSquare(plate, 0, 4);
|
||||||
|
var before = plate.Parts.Select(p => (
|
||||||
|
Part: p, Program: p.Program,
|
||||||
|
X: BitConverter.DoubleToInt64Bits(p.Location.X),
|
||||||
|
Y: BitConverter.DoubleToInt64Bits(p.Location.Y),
|
||||||
|
Left: BitConverter.DoubleToInt64Bits(p.BoundingBox.Left))).ToArray();
|
||||||
|
var partSpacing = plate.PartSpacing;
|
||||||
|
var edgeSpacing = plate.EdgeSpacing;
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var seen = false;
|
||||||
|
|
||||||
|
var result = Expander.ExpandObserved(new List<Part> { anchor, moving }, plate,
|
||||||
|
new Expander.Options { MaxSpacing = 3 }, checkpoint =>
|
||||||
|
{
|
||||||
|
if (checkpoint != cancelAt)
|
||||||
|
return;
|
||||||
|
seen = true;
|
||||||
|
cts.Cancel();
|
||||||
|
}, cts.Token);
|
||||||
|
|
||||||
|
Assert.True(seen, $"checkpoint {cancelAt} was not reached");
|
||||||
|
Assert.True(result.Cancelled);
|
||||||
|
foreach (var state in before)
|
||||||
|
{
|
||||||
|
Assert.Same(state.Program, state.Part.Program);
|
||||||
|
Assert.Equal(state.X, BitConverter.DoubleToInt64Bits(state.Part.Location.X));
|
||||||
|
Assert.Equal(state.Y, BitConverter.DoubleToInt64Bits(state.Part.Location.Y));
|
||||||
|
Assert.Equal(state.Left, BitConverter.DoubleToInt64Bits(state.Part.BoundingBox.Left));
|
||||||
|
}
|
||||||
|
Assert.Equal(partSpacing, plate.PartSpacing);
|
||||||
|
Assert.Equal(edgeSpacing, plate.EdgeSpacing);
|
||||||
|
Assert.Equal(0, wall.Location.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Separate_PinnedBetweenFixedParts_StopsAfterOneNoProgressPass()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(20, 20, edge: 0);
|
||||||
|
var pinned = AddSquare(plate, 8, 8);
|
||||||
|
AddSquare(plate, 3.8, 8);
|
||||||
|
AddSquare(plate, 12.2, 8);
|
||||||
|
AddSquare(plate, 8, 3.8);
|
||||||
|
AddSquare(plate, 8, 12.2);
|
||||||
|
var passes = 0;
|
||||||
|
var initial = pinned.Location;
|
||||||
|
|
||||||
|
var (converged, poses, violations) = Expander.SeparateObserved(
|
||||||
|
new List<Part> { pinned }, plate, spacing: 1,
|
||||||
|
checkpoint =>
|
||||||
|
{
|
||||||
|
if (checkpoint == Expander.Checkpoint.AfterPairPass)
|
||||||
|
passes++;
|
||||||
|
}, maxIterations: 10000
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.False(converged);
|
||||||
|
Assert.NotEmpty(violations);
|
||||||
|
Assert.Equal(1, passes); // Not a MaxIterations spin or wall-clock assertion.
|
||||||
|
Assert.Equal(initial, poses[0]);
|
||||||
|
Assert.Equal(initial, pinned.Location);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
|
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using OpenNest.Engine;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A template plate following the machine clamp rail offers every stock size with the rail
|
||||||
|
/// resolved for that size (D2): no clamp beyond a shorter sheet, every clamp on a longer
|
||||||
|
/// one, measured from the origin in every quadrant.
|
||||||
|
/// </summary>
|
||||||
|
public class ClampRailStockTests
|
||||||
|
{
|
||||||
|
private static readonly double[] MachineCenters = { 6, 21, 36, 51, 66, 81, 96, 111 };
|
||||||
|
|
||||||
|
private static Plate RailTemplate(int quadrant = 1)
|
||||||
|
{
|
||||||
|
var template = new Plate(48, 120) { Quadrant = quadrant, EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25) };
|
||||||
|
template.SetClampRail(new ClampRail(1.5, 0.625, MachineCenters));
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateOption[] Options() =>
|
||||||
|
[
|
||||||
|
new PlateOption { Width = 48, Length = 96 },
|
||||||
|
new PlateOption { Width = 60, Length = 144 },
|
||||||
|
new PlateOption { Width = 48, Length = 60 },
|
||||||
|
];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EachOptionGetsTheRailResolvedForItsOwnSize()
|
||||||
|
{
|
||||||
|
var stock = NestStockBuilder.FromTemplate(RailTemplate(), Options());
|
||||||
|
|
||||||
|
Assert.Equal(new[] { 7, 8, 4 }, stock.Select(s => s.ClampZones.Count));
|
||||||
|
Assert.Equal(new[] { 5.25, 20.25, 35.25, 50.25 }, stock[2].ClampZones.Select(z => z.Bounds.X));
|
||||||
|
// Every offered sheet passes job validation: no clamp lies outside its sheet.
|
||||||
|
NestJobValidator.Validate(new NestJob([], stock));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
public void OptionsResolveFromTheOriginWhenSheetsExtendToNegativeX(int quadrant)
|
||||||
|
{
|
||||||
|
var stock = NestStockBuilder.FromTemplate(RailTemplate(quadrant), Options());
|
||||||
|
|
||||||
|
foreach (var sheet in stock)
|
||||||
|
{
|
||||||
|
// Placement frame: the first clamp is 6" from the origin, toward -X.
|
||||||
|
var first = sheet.ClampZones.Single(z => z.Id == "clamp-1").Bounds;
|
||||||
|
Assert.Equal(-6.0, first.Center.X, 9);
|
||||||
|
}
|
||||||
|
NestJobValidator.Validate(new NestJob([], stock));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Rectangles")]
|
||||||
|
[InlineData("Irregular")]
|
||||||
|
[InlineData("Default")]
|
||||||
|
public void LanesAvoidTheWholeFootprintOfAClampHangingPastTheSheetEnd(string engine)
|
||||||
|
{
|
||||||
|
// 12" sheet, clamp centered 12.25" from the origin: 1" of its 1.5" lies past the end.
|
||||||
|
var template = new Plate(10, 12) { EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), PartSpacing = 0.25 };
|
||||||
|
template.SetClampRail(new ClampRail(1.5, 0.625, new[] { 12.25 }));
|
||||||
|
var zone = Assert.Single(template.ClampZones);
|
||||||
|
Assert.Equal(11.5, zone.Bounds.X);
|
||||||
|
Assert.Equal(1.5, zone.Bounds.Length);
|
||||||
|
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(1, 1));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(new NestPipelineRequest(engine,
|
||||||
|
[new NestItem { Drawing = drawing, Quantity = 80 }], NestStockBuilder.SinglePlate(template)));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
// Independent oracle: the whole clamp grown by the bottom edge spacing.
|
||||||
|
var keepOut = zone.Bounds.Offset(0.25);
|
||||||
|
Assert.All(parts, part =>
|
||||||
|
{
|
||||||
|
var box = part.BoundingBox;
|
||||||
|
var overlapX = System.Math.Min(box.Right, keepOut.Right) - System.Math.Max(box.Left, keepOut.Left);
|
||||||
|
var overlapY = System.Math.Min(box.Top, keepOut.Top) - System.Math.Max(box.Bottom, keepOut.Bottom);
|
||||||
|
Assert.False(overlapX > 1e-6 && overlapY > 1e-6, $"{box} enters {keepOut}");
|
||||||
|
});
|
||||||
|
// Rectangles still uses the front edge beside the clamp. (Irregular may lift its whole
|
||||||
|
// block above the clamp band at the same count; that is a layout choice, not a leak.)
|
||||||
|
if (engine == "Rectangles")
|
||||||
|
Assert.Contains(parts, p => p.BoundingBox.Bottom < keepOut.Top && p.BoundingBox.Right <= keepOut.Left + 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SingleSheetStockCarriesTheTemplatesRailZones()
|
||||||
|
{
|
||||||
|
var template = RailTemplate();
|
||||||
|
|
||||||
|
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
|
||||||
|
|
||||||
|
Assert.Equal(8, single.ClampZones.Count);
|
||||||
|
Assert.Equal(template.ClampZones.Select(z => z.Bounds.X), single.ClampZones.Select(z => z.Bounds.X));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D8/C13: a plate Auto Nest commits with a last-resort clamp removal keeps following its
|
||||||
|
/// rail - minus the removed clamp - through later resizes, exactly like an ordinary
|
||||||
|
/// rail-following sheet (Slice 3), and the removal itself is real, inspectable plate state.
|
||||||
|
/// </summary>
|
||||||
|
public class ClampRemovalCommitTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CommittedPlateKeepsFollowingTheRailMinusTheRemovedClamp()
|
||||||
|
{
|
||||||
|
// Same geometry as ClampRemovalRecoveryTests.BlockedPartRemovesOnlyTheNearestOriginCoveredClamp:
|
||||||
|
// clamps 15" apart on a 23"-long, 3"-wide sheet; a 14"-wide part can only clear both by
|
||||||
|
// removing one, and "clamp-1" (nearest the origin) wins the tie.
|
||||||
|
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
nest.PlateDefaults.Size = new Size(3, 23);
|
||||||
|
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
|
||||||
|
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 }));
|
||||||
|
nest.CreatePlate(); // empty template the commit fills
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var request = new NestPipelineRequest("Rectangles",
|
||||||
|
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
NestStockBuilder.SinglePlate(nest.Plates[0]));
|
||||||
|
var result = NestPipeline.Run(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
|
||||||
|
var plate = Assert.Single(applied);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
|
||||||
|
Assert.NotNull(plate.ClampRail);
|
||||||
|
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
|
||||||
|
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
|
||||||
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 1 clamp removed
|
||||||
|
|
||||||
|
// C13: still follows the rail (minus the removal) through a resize/rotate.
|
||||||
|
plate.Size = new Size(3, 60);
|
||||||
|
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
|
||||||
|
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// D8/D9 last-resort clamp removal in the whole-job pipeline: a part blocked by clamps on
|
||||||
|
/// every offered stock gets the fewest clamps removed that let it fit (C10/C11), and the
|
||||||
|
/// residual solve is merged back into one result; everything else is untouched (C10's
|
||||||
|
/// bit-identical guarantee for an unblocked job).
|
||||||
|
/// </summary>
|
||||||
|
public class ClampRemovalRecoveryTests
|
||||||
|
{
|
||||||
|
// Two clamps 15" apart on a 23"-long, 3"-wide sheet (no edge/part spacing, so the math
|
||||||
|
// below is exact): clamp-1 spans X [5.25, 6.75], clamp-2 spans X [20.25, 21.75]. A 3"-tall
|
||||||
|
// part exactly fills the sheet's Y extent, so it always sits at Y = 0 - through the clamp
|
||||||
|
// band - for whatever X it lands at.
|
||||||
|
private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625));
|
||||||
|
private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625));
|
||||||
|
|
||||||
|
// The machine rail those zones resolve from: only rail-backed clamps can be removed.
|
||||||
|
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
|
||||||
|
|
||||||
|
private static NestPlateStock ClampedSheet(params ClampZone[] zones) =>
|
||||||
|
RailSheet(null, zones);
|
||||||
|
|
||||||
|
private static NestPlateStock RailSheet(int? quantity, params ClampZone[] zones) =>
|
||||||
|
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, zones) { ClampRail = Rail };
|
||||||
|
|
||||||
|
private static readonly RectanglesNestingEngine Engine = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlockedPartRemovesOnlyTheNearestOriginCoveredClamp()
|
||||||
|
{
|
||||||
|
// A 14"-wide part: with both clamps up, no X position in [0, 9] clears both (the gap
|
||||||
|
// between them is 13.5" < 14"), so it is genuinely blocked (C10), not merely unlucky.
|
||||||
|
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
|
||||||
|
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||||
|
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||||
|
|
||||||
|
var raw = Engine.Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, raw.Status); // sanity: today's solve fails
|
||||||
|
|
||||||
|
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
|
||||||
|
|
||||||
|
Assert.NotNull(outcome);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
|
||||||
|
var sheet = Assert.Single(outcome.Result.Plates);
|
||||||
|
Assert.Single(sheet.Placements);
|
||||||
|
// Removing either clamp alone would free enough room; "clamp-1" is nearest the origin.
|
||||||
|
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
|
||||||
|
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
|
||||||
|
var fulfillment = Assert.Single(outcome.Result.Fulfillment);
|
||||||
|
Assert.Equal(0, fulfillment.Unplaced);
|
||||||
|
// The expanded job the result validates against offers the original stock plus
|
||||||
|
// exactly the reduced-clamp variant actually used.
|
||||||
|
Assert.Equal(2, outcome.Job.Plates.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemovalFreesSpaceForOtherResidualDemandToo()
|
||||||
|
{
|
||||||
|
// C12: a sheet nested with a clamp removed may also take other residual parts.
|
||||||
|
var blocked = JobBuilder.Rectangle("wide", w: 14, h: 3, count: 1);
|
||||||
|
var filler = JobBuilder.Rectangle("filler", w: 2, h: 1, count: 1);
|
||||||
|
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||||
|
var job = JobBuilder.Job(new[] { blocked, filler }, new[] { stock });
|
||||||
|
|
||||||
|
var raw = Engine.Solve(job);
|
||||||
|
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
|
||||||
|
|
||||||
|
Assert.NotNull(outcome);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
|
||||||
|
Assert.All(outcome.Result.Fulfillment, f => Assert.Equal(0, f.Unplaced));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AlreadyCompleteResultIsUntouched()
|
||||||
|
{
|
||||||
|
// C10: no blocked part exists, so recovery is a strict no-op (bit-identical to Slice 3).
|
||||||
|
var part = JobBuilder.Rectangle("p", w: 1, h: 1, count: 1);
|
||||||
|
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||||
|
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||||
|
var raw = Engine.Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, raw.Status);
|
||||||
|
|
||||||
|
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NoClampsOfferedNeverRecovers()
|
||||||
|
{
|
||||||
|
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
|
||||||
|
var stock = JobBuilder.Stock("sheet", width: 3, length: 10); // too short, no clamps
|
||||||
|
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||||
|
var raw = Engine.Solve(job);
|
||||||
|
Assert.NotEqual(NestJobStatus.Complete, raw.Status);
|
||||||
|
|
||||||
|
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartBlockedOnlyByStockExhaustionIsNeverTreatedAsClampBlocked()
|
||||||
|
{
|
||||||
|
// The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the
|
||||||
|
// second copy. That is a capacity limit, not a clamp block, so nothing is removed.
|
||||||
|
var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2);
|
||||||
|
var stock = RailSheet(1, Clamp1(), Clamp2());
|
||||||
|
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||||
|
|
||||||
|
var raw = Engine.Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
|
||||||
|
Assert.Equal(NestJobStopReason.StockExhausted, raw.StopReason);
|
||||||
|
|
||||||
|
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnfitEvenWithoutAnyClampStaysUnplaced()
|
||||||
|
{
|
||||||
|
// Wider than the whole sheet: removing every clamp still cannot make it fit.
|
||||||
|
var part = JobBuilder.Rectangle("p", w: 30, h: 3, count: 1);
|
||||||
|
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||||
|
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||||
|
var raw = Engine.Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
|
||||||
|
|
||||||
|
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice 6 task 6.4: regressions for the independent Slice 3b reviews of last-resort clamp
|
||||||
|
/// removal (D8/D9, C10-C13). Geometry: a 23"-long, 3"-wide sheet with 1.5 x 0.625 clamps
|
||||||
|
/// centred at X = 6 and X = 21 (clamp-1 spans X [5.25, 6.75], clamp-2 [20.25, 21.75]); a
|
||||||
|
/// 3"-tall part always crosses the clamp band, a 14"-wide part fits only with one clamp
|
||||||
|
/// removed, a 22"-wide part only with both removed.
|
||||||
|
/// </summary>
|
||||||
|
public class ClampRemovalRepairTests
|
||||||
|
{
|
||||||
|
private static ClampZone[] Zones() => new[]
|
||||||
|
{
|
||||||
|
new ClampZone("clamp-1", new Box(5.25, 0, 1.5, 0.625)),
|
||||||
|
new ClampZone("clamp-2", new Box(20.25, 0, 1.5, 0.625)),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly ClampRail Rail = new(1.5, 0.625, new[] { 6.0, 21.0 });
|
||||||
|
|
||||||
|
/// <summary>Stock resolved from the machine rail (as NestStockBuilder builds it).</summary>
|
||||||
|
private static NestPlateStock Stock(int? quantity = null) =>
|
||||||
|
new("sheet", new Size(3, 23), quantity, 0, default, 1, null, Zones()) { ClampRail = Rail };
|
||||||
|
|
||||||
|
/// <summary>The same zones given explicitly, with no rail behind them.</summary>
|
||||||
|
private static NestPlateStock ExplicitStock() =>
|
||||||
|
JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: Zones());
|
||||||
|
|
||||||
|
private static NestItem Item(string name, double w, double h, int quantity) =>
|
||||||
|
new() { Drawing = new Drawing(name, TestDrawingFactory.Rectangle(w, h)), Quantity = quantity };
|
||||||
|
|
||||||
|
private static NestPipelineRequest Request(NestPlateStock stock, params NestItem[] items) =>
|
||||||
|
new("Rectangles", items, new[] { stock });
|
||||||
|
|
||||||
|
/// <summary>Delegates the ordinary solve to Rectangles, then fails every later call.</summary>
|
||||||
|
private sealed class ProbeThrowEngine : INestingEngine
|
||||||
|
{
|
||||||
|
public int Calls;
|
||||||
|
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
if (++Calls > 1)
|
||||||
|
throw new InvalidOperationException("feasibility probe failed");
|
||||||
|
return new RectanglesNestingEngine().Solve(job, progress, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Solves with Rectangles and cancels the run on its second call.</summary>
|
||||||
|
private sealed class CancelOnSecondCallEngine(CancellationTokenSource source) : INestingEngine
|
||||||
|
{
|
||||||
|
private int calls;
|
||||||
|
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var result = new RectanglesNestingEngine().Solve(job, progress, token);
|
||||||
|
if (++calls == 2)
|
||||||
|
source.Cancel();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PM 1db4333f: an optional recovery probe failure must not discard the ordinary result.
|
||||||
|
[Fact]
|
||||||
|
public void FailingRecoveryProbeKeepsTheOrdinaryResult()
|
||||||
|
{
|
||||||
|
var engine = new ProbeThrowEngine();
|
||||||
|
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(engine, "probe-throw", request);
|
||||||
|
|
||||||
|
Assert.True(engine.Calls > 1, "recovery never probed");
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
// Items map to part ids in request order: part-0 = wide (blocked), part-1 = small.
|
||||||
|
Assert.Equal(1, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-0").Unplaced);
|
||||||
|
Assert.Equal(0, Assert.Single(result.Raw.Fulfillment, f => f.PartId == "part-1").Unplaced);
|
||||||
|
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationDuringRecoveryStillCancelsTheRun()
|
||||||
|
{
|
||||||
|
using var source = new CancellationTokenSource();
|
||||||
|
var request = Request(Stock(), Item("wide", 14, 3, 1), Item("small", 2, 1, 1));
|
||||||
|
|
||||||
|
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||||
|
NestPipeline.Run(new CancelOnSecondCallEngine(source), "cancel", request, token: source.Token));
|
||||||
|
}
|
||||||
|
// PM 346d46ed: explicit zones have no rail to name a removed clamp or its time.
|
||||||
|
[Fact]
|
||||||
|
public void ExplicitZonesWithoutARailAreNeverRemoved()
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(Request(ExplicitStock(), Item("wide", 14, 3, 1)));
|
||||||
|
|
||||||
|
Assert.NotEqual(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RailStockRemovalIsNamedOnACommittedPlateWithoutARail()
|
||||||
|
{
|
||||||
|
// The stock comes from a rail-following template; the commit target is an empty
|
||||||
|
// sheet holding the same zones explicitly. The removal must still be recorded.
|
||||||
|
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
|
||||||
|
var template = new Plate(new Size(3, 23)) { EdgeSpacing = new Spacing(0, 0, 0, 0) };
|
||||||
|
template.SetClampRail(Rail);
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
nest.PlateDefaults.Size = new Size(3, 23);
|
||||||
|
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
|
||||||
|
var target = nest.CreatePlate();
|
||||||
|
foreach (var zone in Zones())
|
||||||
|
target.ClampZones.Add(zone);
|
||||||
|
Assert.Null(target.ClampRail);
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
|
||||||
|
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, NestStockBuilder.SinglePlate(template)));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var plate = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
|
||||||
|
Assert.NotNull(plate.ClampRail);
|
||||||
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes);
|
||||||
|
}
|
||||||
|
// PM 6ded8ac4 (d): the nearest-origin tie-break follows distance along the edge, not
|
||||||
|
// the order the zones happen to be listed in.
|
||||||
|
[Fact]
|
||||||
|
public void TieBreakPrefersTheClampNearestTheOriginWhateverTheZoneOrder()
|
||||||
|
{
|
||||||
|
var reversed = new NestPlateStock("sheet", new Size(3, 23), null, 0, default, 1, null,
|
||||||
|
Zones().Reverse().ToArray())
|
||||||
|
{ ClampRail = Rail };
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(Request(reversed, Item("wide", 14, 3, 1)));
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var sheet = Assert.Single(result.Raw.Plates);
|
||||||
|
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
|
||||||
|
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
|
||||||
|
}
|
||||||
|
// PM c5984c6e (C10): one lane's failure to place is not proof the part cannot fit. The
|
||||||
|
// fixed-rotation L below fits at the origin with its notch over the clamp (an explicit
|
||||||
|
// witness passes the layout check), but Rectangles packs its 10 x 10 bounding box and
|
||||||
|
// cannot place it; no clamp may be removed for it.
|
||||||
|
[Fact]
|
||||||
|
public void PartThatFitsClampedInSomeLaneNeverRemovesAClamp()
|
||||||
|
{
|
||||||
|
var program = new OpenNest.CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(2, 0);
|
||||||
|
program.LineTo(2, 8);
|
||||||
|
program.LineTo(10, 8);
|
||||||
|
program.LineTo(10, 10);
|
||||||
|
program.LineTo(0, 10);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var rail = new ClampRail(1.5, 0.625, new[] { 5.75 }); // clamp-1 spans X [5, 6.5]
|
||||||
|
var size = new Size(10, 10);
|
||||||
|
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
|
||||||
|
{ ClampRail = rail };
|
||||||
|
var drawing = new Drawing("notched", program);
|
||||||
|
|
||||||
|
var witnessJob = new NestJob(new[] { DrawingJobMapper.FromItem("p", new NestItem { Drawing = drawing, Quantity = 1 }) },
|
||||||
|
new[] { stock });
|
||||||
|
var witness = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
Assert.Empty(NestLayoutCheck.Violations(witnessJob, witness)); // fits with the clamp in place
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
|
||||||
|
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { stock }));
|
||||||
|
|
||||||
|
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
|
||||||
|
Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones));
|
||||||
|
}
|
||||||
|
private static int RemovedCount(NestJobPlateResult sheet) => 2 - sheet.Stock.ClampZones.Count;
|
||||||
|
|
||||||
|
// PM f91e054a (C11): minimality holds per sheet. part-0 (22 wide) needs both clamps off,
|
||||||
|
// part-1 (14 wide) only clamp-1; the sheet holding only part-1 must keep clamp-2.
|
||||||
|
[Fact]
|
||||||
|
public void EachSheetRemovesOnlyTheClampsItsOwnPartsNeed()
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(Request(Stock(), Item("widest", 22, 3, 1), Item("wide", 14, 3, 1)));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
foreach (var sheet in result.Raw.Plates)
|
||||||
|
{
|
||||||
|
var needsBoth = sheet.Placements.Any(p => p.PartId == "part-0");
|
||||||
|
Assert.Equal(needsBoth ? 2 : 1, RemovedCount(sheet));
|
||||||
|
if (!needsBoth)
|
||||||
|
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PM 245a555f (C12): reduced-clamp sheets come out of the same finite pool.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(3, false)] // three blocked parts, one physical sheet
|
||||||
|
[InlineData(1, true)] // the ordinary part already used the only sheet
|
||||||
|
public void RemovalNeverInventsSheetsBeyondTheFiniteStock(int wide, bool small)
|
||||||
|
{
|
||||||
|
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
|
||||||
|
if (small)
|
||||||
|
items.Add(Item("small", 2, 1, 1));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(Request(Stock(quantity: 1), items.ToArray()));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Single(result.Raw.Plates);
|
||||||
|
Assert.Equal(1, result.Raw.StockUsage.Sum(u => u.Used));
|
||||||
|
Assert.Equal(1, result.Job.Plates.Sum(s => s.Quantity ?? int.MaxValue));
|
||||||
|
Assert.All(result.Raw.StockUsage, u => Assert.True(u.Remaining is null or >= 0));
|
||||||
|
}
|
||||||
|
/// <summary>Never places anything; counts how often recovery asks it to.</summary>
|
||||||
|
private sealed class CountingNoFitEngine : INestingEngine
|
||||||
|
{
|
||||||
|
public int Calls;
|
||||||
|
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
Calls++;
|
||||||
|
return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.NoPlacementFound,
|
||||||
|
Array.Empty<NestJobPlateResult>(),
|
||||||
|
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)).ToList(),
|
||||||
|
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)).ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestPlateStock WideRailStock(string id, ClampRail rail)
|
||||||
|
{
|
||||||
|
var size = new Size(3, 23);
|
||||||
|
return new NestPlateStock(id, size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PM 0e655fdb: parts that cannot fit even with every clamp removed are dismissed by one
|
||||||
|
// all-clamps-removed probe per stock instead of a search over every clamp run.
|
||||||
|
[Fact]
|
||||||
|
public void PartsThatNeverFitAreDismissedWithoutSearchingClampRuns()
|
||||||
|
{
|
||||||
|
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
|
||||||
|
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
|
||||||
|
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 30, h: 3, count: 1)).ToArray();
|
||||||
|
var job = new NestJob(parts, stocks);
|
||||||
|
var engine = new CountingNoFitEngine();
|
||||||
|
var raw = engine.Solve(job);
|
||||||
|
engine.Calls = 0;
|
||||||
|
|
||||||
|
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
|
||||||
|
|
||||||
|
Assert.Null(outcome);
|
||||||
|
// One feasibility probe per part plus one all-clamps-removed probe per part and stock.
|
||||||
|
Assert.True(engine.Calls <= 25 + 25 * 4, $"recovery made {engine.Calls} solves");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PM 0e655fdb: the search has a fixed budget; running out keeps the ordinary result.
|
||||||
|
[Fact]
|
||||||
|
public void RecoveryStopsAtItsProbeBudget()
|
||||||
|
{
|
||||||
|
// 23-wide parts fit only with all 20 clamps off: every smaller run is probed first.
|
||||||
|
var rail = new ClampRail(0.5, 0.625, Enumerable.Range(0, 20).Select(i => 0.5 + i * 1.1));
|
||||||
|
var stocks = Enumerable.Range(0, 4).Select(i => WideRailStock($"option-{i}", rail)).ToArray();
|
||||||
|
var parts = Enumerable.Range(0, 25).Select(i => JobBuilder.Rectangle($"p{i}", w: 23, h: 3, count: 1)).ToArray();
|
||||||
|
var job = new NestJob(parts, stocks);
|
||||||
|
var engine = new CountingNoFitEngine();
|
||||||
|
var raw = engine.Solve(job);
|
||||||
|
engine.Calls = 0;
|
||||||
|
|
||||||
|
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
|
||||||
|
|
||||||
|
Assert.Null(outcome);
|
||||||
|
Assert.True(engine.Calls <= ClampRemovalRecovery.MaxProbeSolves, $"recovery made {engine.Calls} solves");
|
||||||
|
}
|
||||||
|
// PM 60f7a673: the removal is recorded whatever the empty target sheet already holds.
|
||||||
|
[Theory]
|
||||||
|
[InlineData("reduced-explicit")] // no rail, already exactly the solved reduced zones
|
||||||
|
[InlineData("other-rail")] // follows a different rail that cannot recreate them
|
||||||
|
public void CommittedRemovalIsRecordedOnAnyEmptyTarget(string target)
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
nest.PlateDefaults.Size = new Size(3, 23);
|
||||||
|
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
|
||||||
|
var plate = nest.CreatePlate();
|
||||||
|
if (target == "reduced-explicit")
|
||||||
|
plate.ClampZones.Add(Zones()[1]);
|
||||||
|
else
|
||||||
|
plate.SetClampRail(new ClampRail(2, 0.625, new[] { 6.0, 21.0 }));
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(Request(Stock(), Item("wide", 14, 3, 1)));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var applied = Assert.Single(NestPipelineCommit.ApplyToEmptyPlates(result, manager));
|
||||||
|
|
||||||
|
Assert.Same(plate, applied);
|
||||||
|
Assert.Same(Rail, applied.ClampRail);
|
||||||
|
Assert.Equal(new[] { "clamp-1" }, applied.RemovedClamps);
|
||||||
|
Assert.Equal(new[] { "clamp-2" }, applied.ClampZones.Select(z => z.Id));
|
||||||
|
Assert.Equal(ClampRail.DefaultRemovalMinutes, applied.ClampRemovalMinutes);
|
||||||
|
}
|
||||||
|
// PM bf4438ac: when one offered stock runs out, a blocked part may still use another
|
||||||
|
// offered stock that has sheets left (each with its own minimal removal).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2, false)] // two blocked copies: one on A, one on B
|
||||||
|
[InlineData(1, true)] // the ordinary part took A; the blocked part goes to B
|
||||||
|
public void RecoveryMovesOnToAnotherStockWithSheetsLeft(int wide, bool small)
|
||||||
|
{
|
||||||
|
NestPlateStock Sheet(string id, double length)
|
||||||
|
{
|
||||||
|
var size = new Size(3, length);
|
||||||
|
return new NestPlateStock(id, size, 1, 0, default, 1, null, Rail.ResolveFor(size, 1)) { ClampRail = Rail };
|
||||||
|
}
|
||||||
|
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
|
||||||
|
if (small)
|
||||||
|
items.Add(Item("small", 2, 1, 1));
|
||||||
|
var request = new NestPipelineRequest("Rectangles", items, new[] { Sheet("A", 23), Sheet("B", 24) },
|
||||||
|
new NestJobOptions(maxPlates: 2));
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(request);
|
||||||
|
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.All(result.Raw.Plates.Where(p => p.StockId.Contains("removed")), p => Assert.Single(p.Stock.ClampZones));
|
||||||
|
Assert.Equal(2, result.Raw.StockUsage.Sum(u => u.Used));
|
||||||
|
}
|
||||||
|
// Delta review policy gate 3: identical offered stock (four 48x120 sheets with the same
|
||||||
|
// eight-clamp rail) must not multiply the probe count, or a moderate job of full-sheet
|
||||||
|
// parts runs out of budget although every part fits with its clamps removed.
|
||||||
|
[Fact]
|
||||||
|
public void IdenticalStockOptionsShareTheirProbes()
|
||||||
|
{
|
||||||
|
var rail = new ClampRail(1.5, 0.625, new[] { 6.0, 21.0, 36.0, 51.0, 66.0, 81.0, 96.0, 111.0 });
|
||||||
|
var size = new Size(48, 120);
|
||||||
|
var stocks = Enumerable.Range(0, 4)
|
||||||
|
.Select(i => new NestPlateStock($"s{i}", size, null, 0, default, 1, null, rail.ResolveFor(size, 1)) { ClampRail = rail })
|
||||||
|
.ToArray();
|
||||||
|
var parts = Enumerable.Range(0, 8)
|
||||||
|
.Select(i => JobBuilder.Rectangle($"full-{i}", w: 120, h: 48, count: 1, x: i * 1e-3))
|
||||||
|
.ToArray();
|
||||||
|
var job = new NestJob(parts, stocks);
|
||||||
|
var engine = new OpenNest.Engine.NestingEngines.Default.DefaultNestingEngine();
|
||||||
|
var raw = engine.Solve(job);
|
||||||
|
Assert.Empty(raw.Plates);
|
||||||
|
|
||||||
|
var outcome = ClampRemovalRecovery.TryRecover(job, raw, engine, default);
|
||||||
|
|
||||||
|
Assert.NotNull(outcome);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
|
||||||
|
Assert.Equal(8, outcome.Result.Plates.Count);
|
||||||
|
Assert.All(outcome.Result.Plates, sheet => Assert.Empty(sheet.Stock.ClampZones));
|
||||||
|
}
|
||||||
|
// PM 3df03af2 (C11/D8 "the fewest clamps"): the minimal set need not be neighbours. The
|
||||||
|
// notch over the middle clamp lets it stay while the two outer clamps come off.
|
||||||
|
[Fact]
|
||||||
|
public void FewestClampsNeedNotBeNeighbours()
|
||||||
|
{
|
||||||
|
var program = new OpenNest.CNC.Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(8, 0);
|
||||||
|
program.LineTo(8, 1);
|
||||||
|
program.LineTo(15, 1);
|
||||||
|
program.LineTo(15, 0);
|
||||||
|
program.LineTo(23, 0);
|
||||||
|
program.LineTo(23, 3);
|
||||||
|
program.LineTo(0, 3);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var rail = new ClampRail(1.5, 0.625, new[] { 3.0, 11.5, 20.0 });
|
||||||
|
var size = new Size(3, 23);
|
||||||
|
var stock = new NestPlateStock("sheet", size, null, 0, default, 1, null, rail.ResolveFor(size, 1))
|
||||||
|
{ ClampRail = rail };
|
||||||
|
|
||||||
|
var result = NestPipeline.Run(new NestPipelineRequest("Default",
|
||||||
|
new[] { new NestItem { Drawing = new Drawing("middle-notch", program), Quantity = 1 } },
|
||||||
|
new[] { stock }));
|
||||||
|
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var sheet = Assert.Single(result.Raw.Plates);
|
||||||
|
Assert.Equal(new[] { "clamp-2" }, sheet.Stock.ClampZones.Select(z => z.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A committed live plate must carry exactly the clamp keep-outs its layout was solved and
|
||||||
|
/// validated against, in plate coordinates, including sheets the commit had to create.
|
||||||
|
/// </summary>
|
||||||
|
public class ClampZoneCommitTests
|
||||||
|
{
|
||||||
|
// A 1.5 x 0.625 clamp on the front edge of a 48 x 96 sheet (Length 96 runs along X).
|
||||||
|
private const double ZoneX = 5.25;
|
||||||
|
|
||||||
|
private static ClampZone Zone() => new("clamp-1", new Box(ZoneX, 0, 1.5, 0.625));
|
||||||
|
|
||||||
|
/// <summary>Two different one-part sheets so repeat grouping cannot merge them.</summary>
|
||||||
|
private sealed class TwoSheetStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var stock = job.Plates[0];
|
||||||
|
var work = stock.WorkArea;
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 20, work.Bottom + 10, 0.0) });
|
||||||
|
builder.AddSheet(stock, new[] { (job.Parts[0].Id, work.Left + 40, work.Bottom + 20, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestPipelineResult Result(Drawing drawing, int quadrant, params ClampZone[] zones)
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new TwoSheetStub(), "stub", new NestPipelineRequest("stub",
|
||||||
|
new[] { new NestItem { Drawing = drawing, Quantity = 2 } },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new NestPlateStock("sheet", new Size(48, 96), null, 0.25, new Spacing(1, 1, 1, 1),
|
||||||
|
quadrant, cost: null, clampZones: zones),
|
||||||
|
}));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void EveryCommittedPlateCarriesTheSolvedZonesInPlateCoordinates(int quadrant)
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
nest.CreatePlate(); // empty template without zones; the second sheet is created by commit
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, quadrant, Zone()), manager);
|
||||||
|
|
||||||
|
Assert.Equal(2, applied.Count);
|
||||||
|
Assert.All(applied, plate =>
|
||||||
|
{
|
||||||
|
var zone = Assert.Single(plate.ClampZones);
|
||||||
|
Assert.Equal("clamp-1", zone.Id);
|
||||||
|
// Exact bits: the inverse of the stock's placement-frame translation.
|
||||||
|
Assert.Equal(ZoneX, zone.Bounds.X);
|
||||||
|
Assert.Equal(0, zone.Bounds.Y);
|
||||||
|
Assert.Equal(1.5, zone.Bounds.Length);
|
||||||
|
Assert.Equal(0.625, zone.Bounds.Width);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MatchingPlateZonesAreKeptWithoutChangeEvents()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var template = nest.CreatePlate();
|
||||||
|
template.Quadrant = 3;
|
||||||
|
template.Size = new Size(48, 96);
|
||||||
|
var original = Zone();
|
||||||
|
template.ClampZones.Add(original);
|
||||||
|
var changes = 0;
|
||||||
|
template.ClampZonesChanged += (_, _) => changes++;
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 3, Zone()), manager);
|
||||||
|
|
||||||
|
Assert.Same(template, applied[0]);
|
||||||
|
Assert.Same(original, Assert.Single(template.ClampZones));
|
||||||
|
Assert.Equal(0, changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void SolvedZonesARailCannotRecreateStopFollowingThatRail(int quadrant)
|
||||||
|
{
|
||||||
|
// The nest rail puts a clamp at 6"; the template carries a different explicit zone,
|
||||||
|
// so the extra sheet the commit creates must not keep following the nest rail.
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
nest.PlateDefaults.Size = new Size(48, 96);
|
||||||
|
nest.PlateDefaults.Quadrant = quadrant;
|
||||||
|
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0 }));
|
||||||
|
var template = nest.CreatePlate();
|
||||||
|
template.SetClampRail(null!);
|
||||||
|
var manual = new ClampZone("manual", new Box(10, 0, 2, 0.5));
|
||||||
|
template.ClampZones.Add(manual);
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(
|
||||||
|
Result(drawing, quadrant, template.ClampZones.ToArray()), manager);
|
||||||
|
|
||||||
|
Assert.Equal(2, applied.Count);
|
||||||
|
Assert.All(applied, plate =>
|
||||||
|
{
|
||||||
|
Assert.Null(plate.ClampRail);
|
||||||
|
plate.Size = plate.Size; // the Edit Plate dialog assigns Size even when unchanged
|
||||||
|
plate.Quadrant = plate.Quadrant;
|
||||||
|
Assert.Equal("manual", Assert.Single(plate.ClampZones).Id);
|
||||||
|
Assert.Equal("manual", Assert.Single(Assert.Single(
|
||||||
|
NestStockBuilder.FromTemplate(plate, [new PlateOption { Width = 48, Length = 96 }])).ClampZones).Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SolvedZonesTheRailRecreatesKeepFollowingIt()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
nest.PlateDefaults.Size = new Size(48, 96);
|
||||||
|
var rail = new ClampRail(1.5, 0.625, new[] { 6.0 });
|
||||||
|
nest.SetClampRail(rail);
|
||||||
|
var template = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(
|
||||||
|
Result(drawing, 1, template.ClampZones.ToArray()), manager);
|
||||||
|
|
||||||
|
Assert.Equal(2, applied.Count);
|
||||||
|
Assert.All(applied, plate =>
|
||||||
|
{
|
||||||
|
Assert.Same(rail, plate.ClampRail);
|
||||||
|
plate.Size = new Size(48, 4);
|
||||||
|
Assert.Empty(plate.ClampZones); // still re-resolves: no clamp reaches a 4" sheet
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZonesTheLayoutWasNotSolvedWithAreRemoved()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var template = nest.CreatePlate();
|
||||||
|
template.ClampZones.Add(Zone());
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(Result(drawing, 1), manager);
|
||||||
|
|
||||||
|
Assert.All(applied, plate => Assert.Empty(plate.ClampZones));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clamp zones as engine input: immutable stock carry with the WorkArea quadrant
|
||||||
|
/// transform, validation of zone geometry, repeated-layout identity (C8) and the
|
||||||
|
/// layout-check violation against the D6-inflated rectangle (C1/C5).
|
||||||
|
/// </summary>
|
||||||
|
public class ClampZoneEngineTests
|
||||||
|
{
|
||||||
|
private const double ZoneX = 5.25;
|
||||||
|
private const double ZoneLength = 1.5;
|
||||||
|
private const double ZoneWidth = 0.625;
|
||||||
|
|
||||||
|
private static ClampZone Zone(string id = "clamp-0", double x = ZoneX) =>
|
||||||
|
new(id, new Box(x, 0, ZoneLength, ZoneWidth));
|
||||||
|
|
||||||
|
private static NestPlateStock Stock(
|
||||||
|
int quadrant = 1,
|
||||||
|
double bottomSpacing = 0.25,
|
||||||
|
params ClampZone[] zones
|
||||||
|
) =>
|
||||||
|
new("sheet", new Size(48, 96), null, 0.25,
|
||||||
|
new Spacing(0.25, bottomSpacing, 0.25, 0.25), quadrant, null, zones);
|
||||||
|
|
||||||
|
// ---- stock carry and quadrant transform ----------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StockWithoutZonesExposesAnEmptyList()
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock("s", new Size(48, 96));
|
||||||
|
Assert.Empty(stock.ClampZones);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 0.0, 0.0)]
|
||||||
|
[InlineData(2, -96.0, 0.0)]
|
||||||
|
[InlineData(3, -96.0, -48.0)]
|
||||||
|
[InlineData(4, 0.0, -48.0)]
|
||||||
|
public void ZonesTransformWithTheWorkAreaIntoEveryQuadrant(int quadrant, double dx, double dy)
|
||||||
|
{
|
||||||
|
var stock = Stock(quadrant: quadrant, zones: Zone());
|
||||||
|
|
||||||
|
var zone = Assert.Single(stock.ClampZones);
|
||||||
|
Assert.Equal("clamp-0", zone.Id);
|
||||||
|
Assert.Equal(ZoneX + dx, zone.Bounds.X); // exact bits: same translation WorkArea uses
|
||||||
|
Assert.Equal(dy, zone.Bounds.Y);
|
||||||
|
Assert.Equal(ZoneLength, zone.Bounds.Length);
|
||||||
|
Assert.Equal(ZoneWidth, zone.Bounds.Width);
|
||||||
|
|
||||||
|
// The keep-out stays inside the sheet box of this quadrant.
|
||||||
|
var work = stock.WorkArea;
|
||||||
|
Assert.True(zone.Bounds.Right <= work.Right + 0.25);
|
||||||
|
Assert.True(zone.Bounds.Bottom >= work.Bottom - 0.25 - 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StockDetachesZonesFromCallerBoxes()
|
||||||
|
{
|
||||||
|
var bounds = new Box(ZoneX, 0, ZoneLength, ZoneWidth);
|
||||||
|
var zone = new ClampZone("clamp", bounds);
|
||||||
|
var stock = Stock(zones: zone);
|
||||||
|
bounds.Offset(500, 500);
|
||||||
|
Assert.Equal(ZoneX, Assert.Single(stock.ClampZones).Bounds.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MapperCarriesPlateZonesInBothDirections()
|
||||||
|
{
|
||||||
|
var plate = new Plate(48, 96) { Quadrant = 2 };
|
||||||
|
plate.ClampZones.Add(Zone());
|
||||||
|
|
||||||
|
var stock = DrawingJobMapper.FromPlate("s", plate, quantity: null);
|
||||||
|
var zone = Assert.Single(stock.ClampZones);
|
||||||
|
Assert.Equal(ZoneX - 96, zone.Bounds.X);
|
||||||
|
Assert.Equal(0, zone.Bounds.Y);
|
||||||
|
|
||||||
|
// A plate materialized from the stock stores plate coordinates like every Plate;
|
||||||
|
// mapping it into the placement frame reproduces the stock zone exactly.
|
||||||
|
var materialized = DrawingJobMapper.CreatePlate(stock);
|
||||||
|
var copy = Assert.Single(materialized.ClampZones);
|
||||||
|
Assert.Equal(ZoneX, copy.Bounds.X);
|
||||||
|
Assert.Equal(0, copy.Bounds.Y); // quadrant 2 keeps Y at zero
|
||||||
|
Assert.Equal("clamp-0", copy.Id);
|
||||||
|
var placed = Assert.Single(ClampZoneFrame.ToPlacement(materialized));
|
||||||
|
Assert.Equal(zone.Bounds.X, placed.Bounds.X);
|
||||||
|
Assert.Equal(zone.Bounds.Y, placed.Bounds.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StockBuilderCarriesTemplateZonesForEveryOption()
|
||||||
|
{
|
||||||
|
var template = new Plate(48, 96);
|
||||||
|
template.ClampZones.Add(Zone("a", 3));
|
||||||
|
template.ClampZones.Add(Zone("b", 20));
|
||||||
|
|
||||||
|
var single = Assert.Single(NestStockBuilder.SinglePlate(template));
|
||||||
|
Assert.Equal(2, single.ClampZones.Count);
|
||||||
|
|
||||||
|
var options = NestStockBuilder.FromTemplate(template,
|
||||||
|
[new PlateOption { Width = 48, Length = 96 }, new PlateOption { Width = 40, Length = 80 }]);
|
||||||
|
Assert.All(options, stock => Assert.Equal(2, stock.ClampZones.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- validator ------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorAcceptsEdgeFlushZones()
|
||||||
|
{
|
||||||
|
var job = new NestJob([], [Stock(zones: new[] { Zone(), Zone("flush", 94.5) })]);
|
||||||
|
NestJobValidator.Validate(job); // y = 0 front edge and right-edge flush both legal
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorAcceptsWholeClampsHangingPastTheSheet()
|
||||||
|
{
|
||||||
|
// A clamp is an avoidance area: a footprint reaching onto the sheet is kept whole.
|
||||||
|
NestJobValidator.Validate(new NestJob([], [Stock(zones: Zone(x: 95.5))]));
|
||||||
|
NestJobValidator.Validate(new NestJob([], [new NestPlateStock("s", new Size(48, 96),
|
||||||
|
clampZones: [new ClampZone("under", new Box(5, -0.5, 1.5, 0.625))])]));
|
||||||
|
NestJobValidator.Validate(new NestJob([], [Stock(quadrant: 3, zones: Zone(x: -0.5))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(96, 0)] // touches the far end only
|
||||||
|
[InlineData(100, 0)]
|
||||||
|
[InlineData(-1.5, 0)] // touches the origin end only
|
||||||
|
[InlineData(5, -0.625)] // below the front edge, touching it
|
||||||
|
[InlineData(5, 48)] // beyond the back edge
|
||||||
|
public void ValidatorRejectsZonesWhollyOffTheSheet(double x, double y)
|
||||||
|
{
|
||||||
|
var job = new NestJob([], [new NestPlateStock("s", new Size(48, 96),
|
||||||
|
clampZones: [new ClampZone("off", new Box(x, y, 1.5, 0.625))])]);
|
||||||
|
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidatorRejectsDuplicateZoneIds()
|
||||||
|
{
|
||||||
|
var job = new NestJob([], [Stock(zones: new[] { Zone("dup", 3), Zone("dup", 20) })]);
|
||||||
|
Assert.Throws<ArgumentException>(() => NestJobValidator.Validate(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- repeated-layout identity (C8) ----------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentClampSetsAreDifferentStock()
|
||||||
|
{
|
||||||
|
var plain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25);
|
||||||
|
var clamped = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
|
||||||
|
clampZones: [Zone()]);
|
||||||
|
var sameAsPlain = new NestPlateStock("sheet", new Size(20, 30), 10, 0.25,
|
||||||
|
clampZones: [Zone()]);
|
||||||
|
|
||||||
|
Assert.False(RepeatedPlateGrouping.SameStock(plain, clamped));
|
||||||
|
Assert.True(RepeatedPlateGrouping.SameStock(clamped, sameAsPlain));
|
||||||
|
|
||||||
|
var pose = new NestJobPlacement("p", 0, 1, 1, 0);
|
||||||
|
var groups = RepeatedPlateGrouping.Create(
|
||||||
|
[
|
||||||
|
new NestJobPlateResult(0, clamped, [pose]),
|
||||||
|
new NestJobPlateResult(1, plain, [pose]),
|
||||||
|
new NestJobPlateResult(2, sameAsPlain, [pose]),
|
||||||
|
], consolidate: true);
|
||||||
|
// sheet2 repeats sheet0's exact layout and an identical clamp set, so only the
|
||||||
|
// plain-clamp sheet stays a separate group; the clamped layout is never reused
|
||||||
|
// for the plain sheet (C8).
|
||||||
|
Assert.Equal(new[] { 2, 1 }, groups.Select(g => g.Quantity).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- layout check (C1/C5) ---------------------------------------------------------
|
||||||
|
|
||||||
|
private static NestJobPart RectPart(string id = "p", double w = 2, double h = 2) =>
|
||||||
|
new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(w, h)), 1);
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> Check(NestPlateStock stock, double x, double y)
|
||||||
|
{
|
||||||
|
var job = new NestJob([RectPart()], [stock]);
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
[new NestJobPlateResult(0, stock, [new NestJobPlacement("p", 0, x, y, 0)])],
|
||||||
|
[], []);
|
||||||
|
return NestLayoutCheck.Violations(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckerFlagsPartsInsideTheInflatedClampRectangle()
|
||||||
|
{
|
||||||
|
// keep-out = zone inflated by bottom EdgeSpacing (0.25): x [~5.0, ~7.0], y up to ~0.875
|
||||||
|
var violations = Check(Stock(zones: Zone()), x: 5.3, y: 0.4);
|
||||||
|
Assert.Contains(violations, v => v.Contains("clamp-0") && v.Contains("clamp zone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckerAcceptsPartsClearOfTheInflatedClampRectangle()
|
||||||
|
{
|
||||||
|
// Above the inflated depth (0.625 + 0.25) and clear in X beyond the inflated width.
|
||||||
|
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 5.3, y: 1.0),
|
||||||
|
v => v.Contains("clamp zone"));
|
||||||
|
Assert.DoesNotContain(Check(Stock(zones: Zone()), x: 7.2, y: 0.4),
|
||||||
|
v => v.Contains("clamp zone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CheckerEnforcesTransformedZonesInOtherQuadrants()
|
||||||
|
{
|
||||||
|
// Quadrant 3 moves the front-edge zone to (5.25-96, -48); a part inside it must red.
|
||||||
|
var stock = Stock(quadrant: 3, zones: Zone());
|
||||||
|
var violations = Check(stock, x: 5.25 - 96 + 0.1, y: -48 + 0.4);
|
||||||
|
Assert.Contains(violations, v => v.Contains("clamp zone"));
|
||||||
|
|
||||||
|
// The same sheet a clear distance away from the transformed zone passes.
|
||||||
|
Assert.DoesNotContain(Check(stock, x: -50, y: -40), v => v.Contains("clamp zone"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyClampListAddsNoViolations()
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(Check(Stock(), x: 10, y: 10), v => v.Contains("clamp"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice 4 (C1): the interactive single-plate surface - PlateFillService strategies
|
||||||
|
/// (Fill/Strip/Vertical+Horizontal Remnant), PackArea and whole-plate Nest - never
|
||||||
|
/// proposes a part inside a clamp keep-out inflated by the plate's bottom-edge
|
||||||
|
/// spacing, while material outside the zones stays usable. Oracles are analytic:
|
||||||
|
/// strict positive-area overlap against independently inflated zone rectangles.
|
||||||
|
/// </summary>
|
||||||
|
public class ClampZoneInteractiveFillTests
|
||||||
|
{
|
||||||
|
// Plate: 48 (X) by 24 (Y), uniform 0.25 edge spacing -> bottom clearance 0.25.
|
||||||
|
// Two 1.5 x 0.625 clamps on the front edge at X 10 and X 20.
|
||||||
|
private const double Edge = 0.25;
|
||||||
|
private const double PartSpacing = 0.5;
|
||||||
|
|
||||||
|
private static readonly Box ZoneA = new(10, 0, 1.5, 0.625);
|
||||||
|
private static readonly Box ZoneB = new(20, 0, 1.5, 0.625);
|
||||||
|
|
||||||
|
private static Plate MakePlate(params ClampZone[] zones)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(24, 48));
|
||||||
|
plate.EdgeSpacing = new Spacing(Edge, Edge);
|
||||||
|
plate.PartSpacing = PartSpacing;
|
||||||
|
foreach (var zone in zones)
|
||||||
|
plate.ClampZones.Add(zone);
|
||||||
|
return plate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program RectProgram(double lengthX, double widthY)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(lengthX, 0);
|
||||||
|
program.LineTo(lengthX, widthY);
|
||||||
|
program.LineTo(0, widthY);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Drawing RectDrawing(double lengthX, double widthY) =>
|
||||||
|
new($"rect-{lengthX}-{widthY}", RectProgram(lengthX, widthY));
|
||||||
|
|
||||||
|
private static Box Inflated(Box zone, double clearance) =>
|
||||||
|
new(zone.X - clearance, zone.Y - clearance, zone.Length + 2 * clearance, zone.Width + 2 * clearance);
|
||||||
|
|
||||||
|
/// <summary>Strict positive-area overlap: touching edges (the admitted closure) are clear.</summary>
|
||||||
|
private static bool Overlaps(Box a, Box b) =>
|
||||||
|
a.Left < b.Right - 1e-6 && b.Left < a.Right - 1e-6 && a.Bottom < b.Top - 1e-6 && b.Bottom < a.Top - 1e-6;
|
||||||
|
|
||||||
|
private static void AssertClearOfClamps(IEnumerable<Part> parts, params Box[] zones)
|
||||||
|
{
|
||||||
|
foreach (var part in parts)
|
||||||
|
foreach (var zone in zones)
|
||||||
|
{
|
||||||
|
var keepOut = Inflated(zone, Edge);
|
||||||
|
Assert.False(
|
||||||
|
Overlaps(part.BoundingBox, keepOut),
|
||||||
|
$"'{part.BaseDrawing.Name}' at ({part.Location.X:R},{part.Location.Y:R}) "
|
||||||
|
+ $"box [{part.BoundingBox.Left:R}..{part.BoundingBox.Right:R} x "
|
||||||
|
+ $"{part.BoundingBox.Bottom:R}..{part.BoundingBox.Top:R}] enters the clamp keep-out"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<string> Strategies => new() { "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Strategies))]
|
||||||
|
public void FillItemNeverProposesPartInsideClampKeepOut(string strategy)
|
||||||
|
{
|
||||||
|
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14)); // occupied-plate fixture
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
plate.Parts.Add(occupant);
|
||||||
|
|
||||||
|
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 3 };
|
||||||
|
var parts = PlateFillService.FillItem(
|
||||||
|
strategy, plate, item, plate.WorkArea(), null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(3, parts.Count);
|
||||||
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Strategies))]
|
||||||
|
public void FillUsesMaterialBetweenAndBehindTheClamps(string strategy)
|
||||||
|
{
|
||||||
|
// The band covers both clamps only where y < 3: parts must land in the strip
|
||||||
|
// above the clamps and in the slot between them - not merely fewer parts.
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
var band = new Box(9, 0.25, 13, 2.75);
|
||||||
|
|
||||||
|
var item = new NestItem { Drawing = RectDrawing(3, 1.8), Quantity = 2 };
|
||||||
|
var parts = PlateFillService.FillItem(strategy, plate, item, band, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, parts.Count);
|
||||||
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DesktopGroupFillThenSettleRemainsClearOfPlateOccupant()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(20, 20))
|
||||||
|
{
|
||||||
|
EdgeSpacing = new Spacing(0.25, 0.25),
|
||||||
|
PartSpacing = 0.1,
|
||||||
|
};
|
||||||
|
plate.ClampZones.Add(new ClampZone("remote", new Box(15, 0, 1.5, 0.625)));
|
||||||
|
var occupant = new Part(RectDrawing(4, 19.5), new Vector(0.25, 0.25));
|
||||||
|
plate.Parts.Add(occupant);
|
||||||
|
var group = new List<Part> { new(RectDrawing(3, 3)) };
|
||||||
|
var result = PlateFillService.FillGroup("Fill", plate, group,
|
||||||
|
plate.WorkArea(), null, CancellationToken.None);
|
||||||
|
Assert.True(result.Count > 1);
|
||||||
|
Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox));
|
||||||
|
Compactor.Settle(result, plate, plate.WorkArea());
|
||||||
|
Assert.DoesNotContain(result, part => Overlaps(part.BoundingBox, occupant.BoundingBox));
|
||||||
|
AssertClearOfClamps(result, new Box(15, 0, 1.5, 0.625));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillGroupRetainsCompleteCopiesAheadOfAPartialTrailingCopy()
|
||||||
|
{
|
||||||
|
var drawing = RectDrawing(2, 2);
|
||||||
|
var group = new List<Part>
|
||||||
|
{
|
||||||
|
new(drawing, new Vector(0, 0)), new(drawing, new Vector(3, 0)),
|
||||||
|
};
|
||||||
|
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
|
||||||
|
var area = new Box(0, 0, 8, 2);
|
||||||
|
var original = PlateFillService.FillGroup("Fill", plate, group, area,
|
||||||
|
null, CancellationToken.None);
|
||||||
|
Assert.True(original.Count > group.Count && original.Count % group.Count != 0,
|
||||||
|
$"expected a full copy and partial tail, got {original.Count}");
|
||||||
|
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
|
||||||
|
var clamped = PlateFillService.FillGroup("Fill", plate, group, area,
|
||||||
|
null, CancellationToken.None);
|
||||||
|
Assert.Equal(group.Count, clamped.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillGroupRejectsTheWholeCopyWhenOnlyOneMemberTouchesAClamp()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0 };
|
||||||
|
plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5)));
|
||||||
|
var drawing = RectDrawing(2, 2);
|
||||||
|
var group = new List<Part>
|
||||||
|
{
|
||||||
|
new(drawing, new Vector(2, 0)), new(drawing, new Vector(5, 0)),
|
||||||
|
};
|
||||||
|
var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(2, 0, 5, 2),
|
||||||
|
null, CancellationToken.None);
|
||||||
|
Assert.Equal(0, parts.Count % group.Count);
|
||||||
|
Assert.Empty(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillGroupKeepsAnInsertInItsFrameHoleWhenAClampIsElsewhere()
|
||||||
|
{
|
||||||
|
var program = RectProgram(4, 4);
|
||||||
|
program.MoveTo(1, 1);
|
||||||
|
program.LineTo(1, 3);
|
||||||
|
program.LineTo(3, 3);
|
||||||
|
program.LineTo(3, 1);
|
||||||
|
program.LineTo(1, 1);
|
||||||
|
var frame = new Drawing("frame", program);
|
||||||
|
var insert = RectDrawing(1, 1);
|
||||||
|
var plate = new Plate(new Size(20, 20)) { PartSpacing = 0.25 };
|
||||||
|
plate.ClampZones.Add(new ClampZone("remote", new Box(18, 0, 1, 0.5)));
|
||||||
|
var group = new List<Part>
|
||||||
|
{
|
||||||
|
new(frame, new Vector(1, 3)), new(insert, new Vector(2.5, 4.5)),
|
||||||
|
};
|
||||||
|
var parts = PlateFillService.FillGroup("Fill", plate, group, new Box(1, 3, 4, 4),
|
||||||
|
null, CancellationToken.None);
|
||||||
|
Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, frame));
|
||||||
|
Assert.Contains(parts, part => ReferenceEquals(part.BaseDrawing, insert));
|
||||||
|
Assert.Equal(0, parts.Count % 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackAreaKeepsAllowedOverhangOnlyAtThePlateBoundary()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(12, 12))
|
||||||
|
{
|
||||||
|
EdgeSpacing = new Spacing(1, 1),
|
||||||
|
PartSpacing = 0,
|
||||||
|
};
|
||||||
|
plate.ClampZones.Add(new ClampZone("front", new Box(5, 0, 1, 0.5)));
|
||||||
|
var drawing = RectDrawing(10 + OpenNest.Engine.NestingEngines.Rectangles.SheetPacker.OverhangAllowance * 0.8, 4);
|
||||||
|
var items = new List<NestItem> { new() { Drawing = drawing, Quantity = 2 } };
|
||||||
|
var parts = PlateFillService.PackArea("Fill", plate, plate.WorkArea(), items,
|
||||||
|
null, CancellationToken.None);
|
||||||
|
Assert.Equal(2, parts.Count);
|
||||||
|
Assert.All(parts, part => Assert.True(part.BoundingBox.Right > plate.WorkArea().Right));
|
||||||
|
AssertClearOfClamps(parts, new Box(5, 0, 1, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PackAreaNeverPacksInsideClampKeepOut()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 8 } };
|
||||||
|
|
||||||
|
var parts = PlateFillService.PackArea(
|
||||||
|
"Fill", plate, plate.WorkArea(), items, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(parts.Count >= 5, $"expected the unclamped material to pack parts, got {parts.Count}");
|
||||||
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProgressNeverPublishesUnverifiedClampPlacements()
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA));
|
||||||
|
var updates = new List<NestProgress>();
|
||||||
|
var progress = new InlineProgress(update => updates.Add(update));
|
||||||
|
var item = new NestItem { Drawing = RectDrawing(4, 4), Quantity = 4 };
|
||||||
|
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(),
|
||||||
|
progress, CancellationToken.None);
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
Assert.NotEmpty(updates);
|
||||||
|
Assert.All(updates, update => AssertClearOfClamps(update.BestParts, ZoneA));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class InlineProgress(System.Action<NestProgress> report) : System.IProgress<NestProgress>
|
||||||
|
{
|
||||||
|
public void Report(NestProgress value) => report(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Strategies))]
|
||||||
|
public void WholePlateNestWithNoRequestedPartsDoesNotFillBecauseOfClamps(string strategy)
|
||||||
|
{
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA));
|
||||||
|
var item = new NestItem { Drawing = RectDrawing(3, 3), Quantity = 0 };
|
||||||
|
var parts = PlateFillService.Nest(strategy, plate, new List<NestItem> { item },
|
||||||
|
null, CancellationToken.None);
|
||||||
|
if (strategy != "Strip")
|
||||||
|
Assert.Empty(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Strategies))]
|
||||||
|
public void WholePlateNestAvoidsClampsOnAnOccupiedPlate(string strategy)
|
||||||
|
{
|
||||||
|
var occupant = new Part(RectDrawing(6, 6), new Vector(38, 14));
|
||||||
|
var plate = MakePlate(new ClampZone("front-1", ZoneA), new ClampZone("front-2", ZoneB));
|
||||||
|
plate.Parts.Add(occupant);
|
||||||
|
|
||||||
|
var items = new List<NestItem> { new() { Drawing = RectDrawing(4, 4), Quantity = 10 } };
|
||||||
|
var parts = PlateFillService.Nest(strategy, plate, items, null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(10, parts.Count);
|
||||||
|
AssertClearOfClamps(parts, ZoneA, ZoneB);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Diagnostics;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every <see cref="Plate"/> stores clamp zones in the sheet's lower-left frame, including
|
||||||
|
/// plates the engine builds from stock; consumers map them into the signed placement frame
|
||||||
|
/// through <see cref="ClampZoneFrame"/>. Fixtures use a real rail in all quadrants, with
|
||||||
|
/// expected footprints from the physical rule (center +/- width/2 from the origin along the
|
||||||
|
/// edge, depth into the sheet).
|
||||||
|
/// </summary>
|
||||||
|
public class ClampZoneSheetFrameTests
|
||||||
|
{
|
||||||
|
private static (string, long, long, long, long) Bits(ClampZone zone) =>
|
||||||
|
(zone.Id, BitConverter.DoubleToInt64Bits(zone.Bounds.X), BitConverter.DoubleToInt64Bits(zone.Bounds.Y),
|
||||||
|
BitConverter.DoubleToInt64Bits(zone.Bounds.Length), BitConverter.DoubleToInt64Bits(zone.Bounds.Width));
|
||||||
|
|
||||||
|
private static Box World(int quadrant, double center, double width = 1.5, double depth = 0.625)
|
||||||
|
{
|
||||||
|
var x = quadrant is 2 or 3 ? -(center + width / 2) : center - width / 2;
|
||||||
|
var y = quadrant is 3 or 4 ? -depth : 0;
|
||||||
|
return new Box(x, y, width, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program Rectangle(double length, double width)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(length, 0);
|
||||||
|
program.LineTo(length, width);
|
||||||
|
program.LineTo(0, width);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void MaterializedPlatesStoreSheetFrameZonesAndWarnForCutsThroughThem(int quadrant)
|
||||||
|
{
|
||||||
|
var live = new Plate(81, 120) { Quadrant = quadrant };
|
||||||
|
live.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 120.5 }));
|
||||||
|
var stock = DrawingJobMapper.FromPlate("s", live, 1);
|
||||||
|
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock });
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>()) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
var nest = NestResultMaterializer.Materialize(job, result).Nest;
|
||||||
|
var plate = Assert.Single(nest.Plates);
|
||||||
|
|
||||||
|
Assert.Equal(live.ClampZones.Select(Bits), plate.ClampZones.Select(Bits));
|
||||||
|
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
|
||||||
|
var footprint = World(quadrant, 35);
|
||||||
|
Assert.Equal(footprint.Left, stock.ClampZones[0].Bounds.Left, 9);
|
||||||
|
Assert.Equal(footprint.Bottom, stock.ClampZones[0].Bounds.Bottom, 9);
|
||||||
|
|
||||||
|
// A short cut-off through the physical clamp must be found and require consent.
|
||||||
|
plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical)
|
||||||
|
{
|
||||||
|
StartLimit = quadrant is 3 or 4 ? -2 : 0,
|
||||||
|
EndLimit = quadrant is 3 or 4 ? 0 : 2,
|
||||||
|
});
|
||||||
|
Assert.Equal("clamp-1", Assert.Single(ClampCutDiagnostics.Find(plate)).ClampId);
|
||||||
|
plate.RegenerateCutOffs(new CutOffSettings());
|
||||||
|
var report = PostVerificationAnalyzer.Analyze(nest);
|
||||||
|
Assert.Contains(report.Findings, f => f.ClampId == "clamp-1");
|
||||||
|
Assert.False(report.CanPost(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StockClampZonesCannotBeEditedThroughTheReturnedZones()
|
||||||
|
{
|
||||||
|
// Stock is immutable input: editing a returned zone must not move the stock's clamp
|
||||||
|
// away from the zones its materialized plates (validation, fill, posting) use.
|
||||||
|
var live = new Plate(24, 48);
|
||||||
|
live.ClampZones.Add(new ClampZone("z", new Box(10, 0, 1.5, 0.625)));
|
||||||
|
var stock = DrawingJobMapper.FromPlate("s", live, 1);
|
||||||
|
|
||||||
|
stock.ClampZones[0].Bounds.X += 10;
|
||||||
|
foreach (var zone in stock.ClampZones)
|
||||||
|
zone.Bounds.Y = 5;
|
||||||
|
|
||||||
|
var current = Assert.Single(stock.ClampZones);
|
||||||
|
Assert.Equal(10, current.Bounds.X);
|
||||||
|
Assert.Equal(0, current.Bounds.Y);
|
||||||
|
var plate = DrawingJobMapper.CreatePlate(stock);
|
||||||
|
Assert.Equal(stock.ClampZones.Select(Bits), ClampZoneFrame.ToPlacement(plate).Select(Bits));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void InteractiveFillOnLiveRailPlateAvoidsClampsInEveryQuadrant(int quadrant)
|
||||||
|
{
|
||||||
|
// Five rows of 4.29 squares at 0.5 spacing span 23.45 of the 23.5 work-area height, so an
|
||||||
|
// unobstructed fill reaches the front edge in every quadrant.
|
||||||
|
var plate = new Plate(new Size(24, 48))
|
||||||
|
{
|
||||||
|
Quadrant = quadrant,
|
||||||
|
EdgeSpacing = new Spacing(0.25, 0.25),
|
||||||
|
PartSpacing = 0.5,
|
||||||
|
};
|
||||||
|
var centers = new[] { 10.75, 20.75, 30.75, 40.75 };
|
||||||
|
plate.SetClampRail(new ClampRail(1.5, 0.625, centers));
|
||||||
|
var keepOuts = centers.Select(c => World(quadrant, c).Offset(0.25)).ToArray();
|
||||||
|
var item = new NestItem { Drawing = new Drawing("square", Rectangle(4.29, 4.29)), Quantity = 500 };
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(parts.Count >= 20, $"only {parts.Count} parts placed");
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
var box = part.BoundingBox;
|
||||||
|
Assert.DoesNotContain(keepOuts, k => box.Left < k.Right - 1e-6 && k.Left < box.Right - 1e-6
|
||||||
|
&& box.Bottom < k.Top - 1e-6 && k.Bottom < box.Top - 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
public class CompletedLayoutRepeatTests
|
||||||
|
{
|
||||||
|
private static Dictionary<string, int> Counts(int a, int b) => new(StringComparer.Ordinal) { ["a"] = a, ["b"] = b };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null, 5)]
|
||||||
|
[InlineData(3, null, 3)]
|
||||||
|
[InlineData(null, 2, 2)]
|
||||||
|
[InlineData(4, 3, 3)]
|
||||||
|
[InlineData(0, null, 0)]
|
||||||
|
[InlineData(null, 0, 0)]
|
||||||
|
public void ScarcestRequirementAndEachPhysicalBoundClampTotalCopies(int? stock, int? cap, int expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, 1)]
|
||||||
|
[InlineData(true, 2)]
|
||||||
|
public void UnknownSearchAndCompetingEntriesUseOne(bool completed, int entries)
|
||||||
|
{
|
||||||
|
Assert.Equal(1, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), completed, entries, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptyLayoutAndNoStockCannotProduceASheet()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int>(), true, 1, null, null));
|
||||||
|
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 0, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExactCaseSensitiveIdsAndOnlyPresentRequirementsMatter()
|
||||||
|
{
|
||||||
|
var remaining = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 20, ["A"] = 3, ["absent"] = 0 };
|
||||||
|
var layout = new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 4, ["A"] = 1 };
|
||||||
|
Assert.Equal(3, CompletedLayoutRepeat.Limit(remaining, layout, true, 1, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IntegerEdgeHasNoMultiplicationOverflowOrFirstCopyOffByOne()
|
||||||
|
{
|
||||||
|
var remaining = new Dictionary<string, int> { ["a"] = int.MaxValue };
|
||||||
|
Assert.Equal(int.MaxValue, CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 1 }, true, 1, null, null));
|
||||||
|
var q = CompletedLayoutRepeat.Limit(remaining, new Dictionary<string, int> { ["a"] = 2 }, true, 1, null, null);
|
||||||
|
Assert.Equal(1, checked(int.MaxValue - q * 2));
|
||||||
|
Assert.Equal(0, CompletedLayoutRepeat.Limit(Counts(3, 1), Counts(4, 2), true, 1, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
public void InvalidCountsAndUnknownIdsAreRefused(int count)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(count, 2), true, 1, null, null));
|
||||||
|
Assert.Throws<ArgumentException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), new Dictionary<string, int> { ["unknown"] = 1 }, true, 1, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(-1, null)]
|
||||||
|
[InlineData(null, -1)]
|
||||||
|
public void NegativeBoundsAreRefused(int? stock, int? cap)
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => CompletedLayoutRepeat.Limit(Counts(20, 12), Counts(4, 2), true, 1, stock, cap));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
public class CutoutNfpProposalTests
|
||||||
|
{
|
||||||
|
private const double Spacing = 0.25;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.Equal(insert.Id, pose.PartId);
|
||||||
|
Assert.Equal(0, pose.InstanceIndex);
|
||||||
|
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||||
|
AssertSquareInRoundHole(pose, 6, Spacing);
|
||||||
|
|
||||||
|
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
|
||||||
|
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
|
||||||
|
var movedInsert = pose with
|
||||||
|
{
|
||||||
|
X = framePose.X - pose.Y,
|
||||||
|
Y = framePose.Y + pose.X,
|
||||||
|
Rotation = pose.Rotation + framePose.Rotation,
|
||||||
|
};
|
||||||
|
// This insert is fixed at zero for the local proposal; the rotated variant needs
|
||||||
|
// a correspondingly legal global policy before it can be handed to a job.
|
||||||
|
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
|
||||||
|
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
|
||||||
|
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
|
||||||
|
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
|
||||||
|
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
|
||||||
|
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
|
||||||
|
new[] { new StockUsage(stock.Id, 1, null) });
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
var frame = JobBuilder.Part("square-frame", program, 1);
|
||||||
|
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
|
||||||
|
{
|
||||||
|
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
|
||||||
|
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
|
||||||
|
});
|
||||||
|
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
|
||||||
|
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
|
||||||
|
Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
var frame = JobBuilder.Part("frame", program, 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
|
||||||
|
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
|
||||||
|
Assert.NotNull(pose);
|
||||||
|
Assert.True(insert.Rotation.Allows(pose.Rotation));
|
||||||
|
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
|
||||||
|
{
|
||||||
|
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||||
|
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||||
|
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
|
||||||
|
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(9.51)]
|
||||||
|
[InlineData(10.0)]
|
||||||
|
[InlineData(9.6)]
|
||||||
|
public void OversizedInsertIsNeverProposed(double side)
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", side, side, 1);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedCoordinateScaleFailsClosed()
|
||||||
|
{
|
||||||
|
const double scale = 1e15;
|
||||||
|
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
|
||||||
|
(scale, scale), (-scale, scale));
|
||||||
|
program.MoveTo(-scale / 2, -scale / 2);
|
||||||
|
program.LineTo(-scale / 2, scale / 2);
|
||||||
|
program.LineTo(scale / 2, scale / 2);
|
||||||
|
program.LineTo(scale / 2, -scale / 2);
|
||||||
|
program.LineTo(-scale / 2, -scale / 2);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
|
||||||
|
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||||
|
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
|
||||||
|
double centerX = 0, double centerY = 0)
|
||||||
|
{
|
||||||
|
var sin = System.Math.Sin(pose.Rotation);
|
||||||
|
var cos = System.Math.Cos(pose.Rotation);
|
||||||
|
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
|
||||||
|
{
|
||||||
|
var dx = pose.X + x * cos - y * sin - centerX;
|
||||||
|
var dy = pose.Y + x * sin + y * cos - centerY;
|
||||||
|
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
|
||||||
|
$"corner ({dx}, {dy}) does not clear the circular wall");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationIsNotReportedAsNoFit()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
|
||||||
|
using var cancelled = new CancellationTokenSource();
|
||||||
|
cancelled.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
|
||||||
|
cancelled.Token));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
|
||||||
|
public sealed class CutoutPipelineRegistryCollection { }
|
||||||
|
|
||||||
|
[Collection("Cutout pipeline registry")]
|
||||||
|
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.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
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 RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
Options = new NestJobOptions(maxPlates: 2)
|
||||||
|
};
|
||||||
|
var calls = 0;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
|
||||||
|
Assert.Equal(2, proxies.Length);
|
||||||
|
Assert.Equal(2, job.Parts.Count);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in proxies)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
plate.Placements.Select(p => p.PartId)));
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
|
||||||
|
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } };
|
||||||
|
var calls = 0;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.NoPlacementFound);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status);
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
|
||||||
|
{
|
||||||
|
var initial = Request("stub");
|
||||||
|
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
|
||||||
|
initial.Items[1].Quantity = 2;
|
||||||
|
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
var request = initial with
|
||||||
|
{
|
||||||
|
Items = new[] { initial.Items[0],
|
||||||
|
new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] },
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
|
||||||
|
Assert.Equal(2, proxies.Length);
|
||||||
|
Assert.Equal(2, job.Parts.Count);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in proxies)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" },
|
||||||
|
result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId));
|
||||||
|
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
|
||||||
|
.Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
|
||||||
|
{
|
||||||
|
var frame = TestShapes.Rectangle(20, 20);
|
||||||
|
frame.MoveTo(5, 5);
|
||||||
|
frame.LineTo(5, 15);
|
||||||
|
frame.LineTo(15, 15);
|
||||||
|
frame.LineTo(15, 5);
|
||||||
|
frame.LineTo(5, 5);
|
||||||
|
var quarter = System.Math.PI / 2;
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
|
||||||
|
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1,
|
||||||
|
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
Assert.True(proxy.Rotation.Allows(quarter));
|
||||||
|
Assert.False(proxy.Rotation.Allows(0));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var poses = result.Raw.Plates.Single().Placements;
|
||||||
|
Assert.Equal(quarter, poses[0].Rotation, 8);
|
||||||
|
Assert.Equal(quarter, poses[1].Rotation, 8);
|
||||||
|
// Independent square-hole oracle, not the production clearance predicate.
|
||||||
|
var localX = poses[1].Y - poses[0].Y;
|
||||||
|
var localY = -(poses[1].X - poses[0].X);
|
||||||
|
foreach (var dx in new[] { 0.0, 3.0 })
|
||||||
|
foreach (var dy in new[] { 0.0, 3.0 })
|
||||||
|
{
|
||||||
|
Assert.InRange(localX + dx, 5.25, 14.75);
|
||||||
|
Assert.InRange(localY + dy, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
|
||||||
|
{
|
||||||
|
var request = Request("stub") with
|
||||||
|
{
|
||||||
|
Stock = new[] {
|
||||||
|
new NestPlateStock("tight", new Size(22, 22), 1, 0.25),
|
||||||
|
new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) }
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
|
||||||
|
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
|
||||||
|
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
|
||||||
|
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
|
||||||
|
{
|
||||||
|
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
|
||||||
|
var calls = 0;
|
||||||
|
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = NestPipeline.RunCutoutPreview(Request(name));
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
|
||||||
|
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
foreach (var origin in new[] { 2.0, 12.0 })
|
||||||
|
{
|
||||||
|
program.MoveTo(origin, origin);
|
||||||
|
program.LineTo(origin, origin + 6);
|
||||||
|
program.LineTo(origin + 6, origin + 6);
|
||||||
|
program.LineTo(origin + 6, origin);
|
||||||
|
program.LineTo(origin, origin);
|
||||||
|
}
|
||||||
|
var frame = new Drawing("two-hole-frame", program);
|
||||||
|
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = frame, Quantity = 1 },
|
||||||
|
new NestItem { Drawing = insert, Quantity = 2 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray();
|
||||||
|
Assert.Equal(2, inserts.Length);
|
||||||
|
Assert.All(inserts, p =>
|
||||||
|
{
|
||||||
|
var localX = p.X - 1;
|
||||||
|
var localY = p.Y - 1;
|
||||||
|
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
|
||||||
|
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
|
||||||
|
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[1].Priority = 3;
|
||||||
|
var result = NestPipeline.RunCutoutPreview(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 11.0, 11.0)]
|
||||||
|
[InlineData(2, -11.0, 11.0)]
|
||||||
|
[InlineData(3, -11.0, -11.0)]
|
||||||
|
[InlineData(4, 11.0, -11.0)]
|
||||||
|
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
|
||||||
|
{
|
||||||
|
var request = Request("stub") with
|
||||||
|
{
|
||||||
|
Stock = new[] {
|
||||||
|
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
|
||||||
|
new Spacing(0.5, 0.5), quadrant) },
|
||||||
|
Options = new NestJobOptions(maxPlates: 1)
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))),
|
||||||
|
"stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant);
|
||||||
|
Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X,
|
||||||
|
result.Raw.Plates.Single().Placements[0].Y));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
|
||||||
|
new Stub(job =>
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
cts.Cancel();
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request, token: cts.Token));
|
||||||
|
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult(
|
||||||
|
NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
Array.Empty<NestJobPlateResult>(),
|
||||||
|
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)),
|
||||||
|
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("accounting"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var progress = new CollectProgress();
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted },
|
||||||
|
progress.Events.Select(e => e.Stage));
|
||||||
|
Assert.Equal(2, progress.Events.Last().CommittedParts);
|
||||||
|
Assert.Equal(1, progress.Events.Last().CommittedPlates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ReportingStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
|
||||||
|
job.Plates[0].Id, 0, 0, 0));
|
||||||
|
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
|
||||||
|
job.Plates[0].Id, 0, 1, 1));
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 10);
|
||||||
|
program.MoveTo(2, 2);
|
||||||
|
program.LineTo(2, 8);
|
||||||
|
program.LineTo(18, 8);
|
||||||
|
program.LineTo(18, 2);
|
||||||
|
program.LineTo(2, 2);
|
||||||
|
var request = new NestPipelineRequest("Irregular", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var frame = result.Raw.Plates.Single().Placements[0];
|
||||||
|
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
|
||||||
|
Assert.Equal(new[] { "part-0", "part-1" },
|
||||||
|
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
|
||||||
|
var proxy = Assert.Single(job.Parts);
|
||||||
|
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
|
||||||
|
{
|
||||||
|
var request = Request("stub");
|
||||||
|
request.Items[0].Quantity = 2;
|
||||||
|
request.Items[1].Quantity = 2;
|
||||||
|
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
|
||||||
|
var options = new NestJobOptions("Strip", maxPlates: 1,
|
||||||
|
salvageRate: 0.4, minimumSalvageDimension: 2);
|
||||||
|
request = request with
|
||||||
|
{
|
||||||
|
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
|
||||||
|
Options = options
|
||||||
|
};
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.Same(options, job.Options);
|
||||||
|
Assert.Equal("Strip", job.Options.PlacementStrategy);
|
||||||
|
Assert.Equal(0.4, job.Options.SalvageRate);
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
foreach (var proxy in job.Parts)
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
|
||||||
|
AssertCannotCommit(result, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
const double angle = 0.3;
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
|
||||||
|
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)),
|
||||||
|
Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))),
|
||||||
|
"stub", request);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
var poses = result.Raw.Plates.Single().Placements;
|
||||||
|
Assert.Equal(angle, poses[1].Rotation, 8);
|
||||||
|
var dx = poses[1].X - poses[0].X;
|
||||||
|
var dy = poses[1].Y - poses[0].Y;
|
||||||
|
var cosine = System.Math.Cos(angle);
|
||||||
|
var sine = System.Math.Sin(angle);
|
||||||
|
var localX = dx * cosine + dy * sine;
|
||||||
|
var localY = -dx * sine + dy * cosine;
|
||||||
|
foreach (var x in new[] { 0.0, 3.0 })
|
||||||
|
foreach (var y in new[] { 0.0, 3.0 })
|
||||||
|
{
|
||||||
|
Assert.InRange(localX + x, 5.25, 14.75);
|
||||||
|
Assert.InRange(localY + y, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
|
||||||
|
{
|
||||||
|
var frame = TestShapes.Rectangle(20, 20);
|
||||||
|
frame.MoveTo(5, 5);
|
||||||
|
frame.LineTo(5, 15);
|
||||||
|
frame.LineTo(15, 15);
|
||||||
|
frame.LineTo(15, 5);
|
||||||
|
frame.LineTo(5, 5);
|
||||||
|
var request = new NestPipelineRequest("stub", new[] {
|
||||||
|
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
|
||||||
|
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
|
||||||
|
new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)),
|
||||||
|
Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) });
|
||||||
|
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
|
||||||
|
{
|
||||||
|
Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-")));
|
||||||
|
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity);
|
||||||
|
Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity);
|
||||||
|
return Result(job);
|
||||||
|
}), "stub", request);
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40),
|
||||||
|
(f.Requested, f.Placed, f.Unplaced)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[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,228 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Cutouts;
|
||||||
|
using OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||||
|
|
||||||
|
public class CutoutRouterTests
|
||||||
|
{
|
||||||
|
private static NestJobPart Frame()
|
||||||
|
{
|
||||||
|
var program = TestShapes.Rectangle(20, 20);
|
||||||
|
program.MoveTo(5, 5);
|
||||||
|
program.LineTo(5, 15);
|
||||||
|
program.LineTo(15, 15);
|
||||||
|
program.LineTo(15, 5);
|
||||||
|
program.LineTo(5, 5);
|
||||||
|
return JobBuilder.Part("frame", program, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
|
||||||
|
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
|
||||||
|
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
|
||||||
|
(pose, lattice) => origins.Add((pose, lattice)));
|
||||||
|
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
|
||||||
|
Assert.Single(poses, p => p.PartId == large.Id);
|
||||||
|
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
|
||||||
|
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
|
||||||
|
Assert.Equal(poses, origins.Select(row => row.Pose));
|
||||||
|
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { small, large }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
|
||||||
|
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
|
||||||
|
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
|
||||||
|
Assert.Equal(3, together.Count);
|
||||||
|
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { small, impossible }, together);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
|
||||||
|
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
|
||||||
|
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
|
||||||
|
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
|
||||||
|
AssertPhysical(frame, new[] { small, large }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Single(poses);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
|
||||||
|
{
|
||||||
|
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
|
||||||
|
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||||
|
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
|
||||||
|
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
|
||||||
|
(pose, lattice) => origins.Add((pose, lattice)));
|
||||||
|
Assert.Equal(12, poses.Count);
|
||||||
|
Assert.Equal(11, origins.Count(row => row.Lattice));
|
||||||
|
Assert.Single(origins, row => !row.Lattice);
|
||||||
|
Assert.Equal(poses, origins.Select(row => row.Pose));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
|
||||||
|
{
|
||||||
|
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
|
||||||
|
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Equal(2, poses.Count);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
|
||||||
|
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
|
||||||
|
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
|
||||||
|
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
|
||||||
|
Assert.Single(poses);
|
||||||
|
AssertPhysical(frame, new[] { insert }, poses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
|
||||||
|
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
|
||||||
|
new[] { insert, insert }, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
|
||||||
|
{
|
||||||
|
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
|
||||||
|
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
|
||||||
|
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
|
||||||
|
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
|
||||||
|
Assert.Equal(3, early.Count);
|
||||||
|
var blocked = early[0] with { PartId = first.Id };
|
||||||
|
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
|
||||||
|
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
|
||||||
|
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
|
||||||
|
Assert.Equal(3, later.Count);
|
||||||
|
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
|
||||||
|
JobPartGeometry.Read(more.Geometry), p, 0.25)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
|
||||||
|
{
|
||||||
|
var frame = Frame();
|
||||||
|
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
|
||||||
|
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
|
||||||
|
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
|
||||||
|
Assert.Equal(3, poses.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationIsNotNoFit()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
|
||||||
|
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
|
||||||
|
IReadOnlyList<NestJobPlacement> poses)
|
||||||
|
{
|
||||||
|
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
|
||||||
|
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
|
||||||
|
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
|
||||||
|
var offsetX = 1 - frameBounds.Left;
|
||||||
|
var offsetY = 1 - frameBounds.Bottom;
|
||||||
|
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
|
||||||
|
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, placements) },
|
||||||
|
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
|
||||||
|
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
|
||||||
|
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
|
||||||
|
.ToArray(),
|
||||||
|
new[] { new StockUsage(stock.Id, 1, null) });
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
|
||||||
|
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
|
||||||
|
foreach (var pose in poses)
|
||||||
|
{
|
||||||
|
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
|
||||||
|
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
|
||||||
|
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
|
||||||
|
var bounds = geometry.Bounds;
|
||||||
|
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
|
||||||
|
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
|
||||||
|
{
|
||||||
|
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
|
||||||
|
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
|
||||||
|
if (frameBounds.Left >= 0)
|
||||||
|
{
|
||||||
|
Assert.InRange(rx, 5.25, 14.75);
|
||||||
|
Assert.InRange(ry, 5.25, 14.75);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
|
||||||
|
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
|
||||||
|
for (var i = 0; i < poses.Count; i++)
|
||||||
|
for (var j = i + 1; j < poses.Count; j++)
|
||||||
|
{
|
||||||
|
var a = Bounds(poses[i]);
|
||||||
|
var b = Bounds(poses[j]);
|
||||||
|
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
|
||||||
|
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
|
||||||
|
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
|
||||||
|
$"independent material gap too small: {poses[i]} vs {poses[j]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
|
||||||
|
{
|
||||||
|
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
|
||||||
|
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
|
||||||
|
(box.Right, box.Top), (box.Left, box.Top) };
|
||||||
|
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
|
||||||
|
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
|
||||||
|
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
|
||||||
|
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
|
||||||
|
return (x.Min(), x.Max(), y.Min(), y.Max());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,9 @@ public class NestJobCostTests
|
|||||||
public static IEnumerable<object[]> EdgeCases()
|
public static IEnumerable<object[]> EdgeCases()
|
||||||
{
|
{
|
||||||
foreach (var rate in new[] { 0.0, 0.5, 1.0 })
|
foreach (var rate in new[] { 0.0, 0.5, 1.0 })
|
||||||
foreach (var edge in new[] { "bottom", "top", "left", "right" })
|
foreach (var edge in new[] { "bottom", "top", "left", "right" })
|
||||||
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
||||||
yield return new object[] { rate, edge, quadrant };
|
yield return new object[] { rate, edge, quadrant };
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -84,7 +84,14 @@ public class NestJobCostTests
|
|||||||
TestDrawingFactory.Rectangle(4, 3)), 5);
|
TestDrawingFactory.Rectangle(4, 3)), 5);
|
||||||
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
|
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
|
||||||
foreach (var sheet in result.Plates)
|
foreach (var sheet in result.Plates)
|
||||||
Assert.Equal(LegacyNestJobCost.EstimateNetArea(cleanJob, sheet), NestJobCost.NetSheetArea(job, sheet));
|
{
|
||||||
|
var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet);
|
||||||
|
Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet));
|
||||||
|
var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity,
|
||||||
|
sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37);
|
||||||
|
var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements);
|
||||||
|
Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet));
|
||||||
|
}
|
||||||
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
|
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
|
||||||
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
|
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
|
||||||
NestJobCost.Evaluate(job, result));
|
NestJobCost.Evaluate(job, result));
|
||||||
|
|||||||
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
|
|||||||
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
Assert.Equal(2, Assert.Single(applied).Parts.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var result = NestPipeline.Run(new PreMutationStub(), "stub",
|
||||||
|
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PreMutationStub : INestingEngine
|
||||||
|
{
|
||||||
|
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||||
|
CancellationToken token = default)
|
||||||
|
{
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
|
||||||
|
return builder.Build(NestJobStopReason.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
|
||||||
|
: OpenNest.CNC.LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override OpenNest.CNC.CodeType Type
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
source.Cancel();
|
||||||
|
return base.Type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationDuringFreshnessScanDoesNotMutateNest()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
var empty = nest.CreatePlate();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var result = NestPipeline.Run(new PreMutationStub(), "stub",
|
||||||
|
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||||
|
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
|
||||||
|
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
|
||||||
|
Assert.Throws<OperationCanceledException>(() =>
|
||||||
|
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
|
||||||
|
Assert.True(cts.IsCancellationRequested);
|
||||||
|
Assert.Same(empty, Assert.Single(nest.Plates));
|
||||||
|
Assert.Empty(empty.Parts);
|
||||||
|
Assert.Equal(0, drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancelledCommitDoesNotCreateAnyPlate()
|
public void CancelledCommitDoesNotCreateAnyPlate()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -258,6 +258,44 @@ public class NestPipelineTests
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
var result = NestPipeline.Run(new StubEngine(job =>
|
||||||
|
{
|
||||||
|
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
|
||||||
|
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
|
||||||
|
}), "Mutator", Request("Mutator", item));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override ICode Clone()
|
||||||
|
{
|
||||||
|
var before = EndPoint;
|
||||||
|
EndPoint = new Vector(100, 100);
|
||||||
|
try { return base.Clone(); }
|
||||||
|
finally { EndPoint = before; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
|
||||||
|
{
|
||||||
|
var item = Item("bracket", 1);
|
||||||
|
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
|
||||||
|
var result = NestPipeline.Run(new StubEngine(job =>
|
||||||
|
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
|
||||||
|
"Transient", Request("Transient", item));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CancellationPropagatesWithoutAResult()
|
public void CancellationPropagatesWithoutAResult()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
public class RepeatedPlateGroupingTests
|
||||||
|
{
|
||||||
|
private static NestItem Item(string name = "same-name", int quantity = 10) => new()
|
||||||
|
{
|
||||||
|
Drawing = new Drawing(name, TestDrawingFactory.Rectangle(2, 2)),
|
||||||
|
Quantity = quantity,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static NestPlateStock Stock(string id = "sheet") => new(id, new Size(20, 30), 10, 0.25);
|
||||||
|
|
||||||
|
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 Raw(IEnumerable<NestJobPlateResult> sheets) =>
|
||||||
|
new(NestJobStatus.Complete, NestJobStopReason.Completed, sheets, [], []);
|
||||||
|
|
||||||
|
private static NestJobPlateResult Sheet(NestPlateStock stock, int index, params NestJobPlacement[] poses) =>
|
||||||
|
new(index, stock, poses);
|
||||||
|
|
||||||
|
private static NestJobPlacement Pose(string id = "part-0", int instance = 0,
|
||||||
|
double x = 1, double y = 1, double rotation = 0) => new(id, instance, x, y, rotation);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NonAdjacentOrderedDuplicatesKeepFirstOccurrenceAndIgnoreInstanceIndices()
|
||||||
|
{
|
||||||
|
var item = Item();
|
||||||
|
var input = new NestPipelineRequest("stub", [item], [Stock()]);
|
||||||
|
NestJobResult? raw = null;
|
||||||
|
var result = NestPipeline.Run(new Stub(job => raw = Raw([
|
||||||
|
Sheet(job.Plates[0], 7, Pose(instance: 0), Pose(instance: 1, x: 6)),
|
||||||
|
Sheet(job.Plates[0], 8, Pose(instance: 2, x: 3)),
|
||||||
|
Sheet(job.Plates[0], 9, Pose(instance: 3), Pose(instance: 4, x: 6))])), "stub", input);
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Same(raw, result.Raw);
|
||||||
|
Assert.Equal(new[] { 7, 8 }, result.Plates.Select(p => p.PlateIndex));
|
||||||
|
Assert.Equal(new[] { 2, 1 }, result.Plates.Select(p => p.Quantity));
|
||||||
|
Assert.Equal(new[] { 1d, 6d }, result.Plates[0].Parts.Select(p => p.Location.X));
|
||||||
|
Assert.All(result.Plates.SelectMany(p => p.Parts), p => Assert.Same(item.Drawing, p.BaseDrawing));
|
||||||
|
Assert.Equal(5, result.Plates.Sum(p => p.Parts.Count * p.Quantity));
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
|
Assert.Empty(NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
|
||||||
|
result.Plates, result.DrawingsByPartId, consolidate: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("x")]
|
||||||
|
[InlineData("y")]
|
||||||
|
[InlineData("rotation")]
|
||||||
|
[InlineData("negative-zero")]
|
||||||
|
public void ScalarBitDifferenceDoesNotGroup(string field)
|
||||||
|
{
|
||||||
|
var second = field switch
|
||||||
|
{
|
||||||
|
"x" => Pose(instance: 1, x: double.BitIncrement(1)),
|
||||||
|
"y" => Pose(instance: 1, y: double.BitIncrement(1)),
|
||||||
|
"rotation" => Pose(instance: 1, rotation: double.BitIncrement(0)),
|
||||||
|
_ => Pose(instance: 1, rotation: -0.0),
|
||||||
|
};
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, second)])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReorderedPlacementsAndDistinctSameNameRequirementsStaySeparate()
|
||||||
|
{
|
||||||
|
var a = Item();
|
||||||
|
var b = Item();
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose(), Pose("part-1", x: 6)),
|
||||||
|
Sheet(job.Plates[0], 1, Pose("part-1", 1, x: 6), Pose(instance: 1)),
|
||||||
|
Sheet(job.Plates[0], 2, Pose(instance: 2)),
|
||||||
|
Sheet(job.Plates[0], 3, Pose("part-1", 2))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [a, b], [Stock()]));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(4, result.Plates.Count);
|
||||||
|
Assert.Same(a.Drawing, result.Plates[2].Parts[0].BaseDrawing);
|
||||||
|
Assert.Same(b.Drawing, result.Plates[3].Parts[0].BaseDrawing);
|
||||||
|
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameSizeDifferentStockIdentityDoesNotGroup()
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[1], 1, Pose(instance: 1))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [Item()], [Stock("a"), Stock("b")]));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("width")]
|
||||||
|
[InlineData("length")]
|
||||||
|
[InlineData("spacing")]
|
||||||
|
[InlineData("left")]
|
||||||
|
[InlineData("right")]
|
||||||
|
[InlineData("top")]
|
||||||
|
[InlineData("bottom")]
|
||||||
|
[InlineData("quadrant")]
|
||||||
|
[InlineData("cost")]
|
||||||
|
[InlineData("quantity")]
|
||||||
|
public void EveryStockSettingIsPartOfExactIdentity(string field)
|
||||||
|
{
|
||||||
|
var size = new Size(field == "width" ? double.BitIncrement(20) : 20,
|
||||||
|
field == "length" ? double.BitIncrement(30) : 30);
|
||||||
|
var spacing = new Spacing
|
||||||
|
{
|
||||||
|
Left = field == "left" ? double.Epsilon : 0,
|
||||||
|
Right = field == "right" ? double.Epsilon : 0,
|
||||||
|
Top = field == "top" ? double.Epsilon : 0,
|
||||||
|
Bottom = field == "bottom" ? double.Epsilon : 0,
|
||||||
|
};
|
||||||
|
var second = new NestPlateStock("sheet", size, field == "quantity" ? 11 : 10,
|
||||||
|
field == "spacing" ? double.BitIncrement(0.25) : 0.25,
|
||||||
|
spacing, field == "quadrant" ? 2 : 1, field == "cost" ? 1 : null);
|
||||||
|
var groups = RepeatedPlateGrouping.Create([
|
||||||
|
Sheet(Stock(), 0, Pose()), Sheet(second, 1, Pose(instance: 1))], true);
|
||||||
|
Assert.Equal(2, groups.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToleranceEquivalentValidStockSettingsStillDoNotGroup()
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()),
|
||||||
|
Sheet(new NestPlateStock("sheet", job.Plates[0].Size, 10,
|
||||||
|
double.BitIncrement(0.25)), 1, Pose(instance: 1))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EmptySheetsNeverGroup()
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0), Sheet(job.Plates[0], 1)])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void QuantityWeightedCostAndSalvageMatchExpandedPhysicalSheets(bool priced)
|
||||||
|
{
|
||||||
|
var item = Item(quantity: 3);
|
||||||
|
var stock = new NestPlateStock("sheet", new Size(20, 30), 4, 0.25,
|
||||||
|
new Spacing(), 1, cost: priced ? 7 : null);
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1)),
|
||||||
|
Sheet(job.Plates[0], 2, Pose(instance: 2, x: 15))])), "stub",
|
||||||
|
new NestPipelineRequest("stub", [item], [stock],
|
||||||
|
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1)));
|
||||||
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||||
|
Assert.Equal(new[] { 2, 1 }, result.Plates.Select(p => p.Quantity));
|
||||||
|
var summary = NestCostSummary.FromAccepted(result);
|
||||||
|
var gross = result.Raw.Plates.Sum(p => NestJobCost.GrossSheetCost(p.Stock));
|
||||||
|
var net = NestJobCost.Evaluate(result.Job, result.Raw);
|
||||||
|
Assert.Equal(gross, summary.GrossTotal, 10);
|
||||||
|
Assert.Equal(net, summary.NetScore, 10);
|
||||||
|
Assert.Equal(gross - net, summary.SalvageCredit, 10);
|
||||||
|
Assert.True(summary.SalvageCredit > 0);
|
||||||
|
Assert.Equal(3, Assert.Single(summary.Stock).Used);
|
||||||
|
Assert.Equal(gross, Assert.Single(summary.Stock).GrossSubtotal, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidDuplicatesRemainPhysicalEvenWithExplicitCommitConsent()
|
||||||
|
{
|
||||||
|
var item = Item();
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose(), Pose(instance: 1)),
|
||||||
|
Sheet(job.Plates[0], 1, Pose(instance: 2), Pose(instance: 3))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.True(result.CanKeep);
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
Assert.All(result.Plates, p => Assert.Equal(1, p.Quantity));
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(item.Drawing);
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true);
|
||||||
|
Assert.Equal(2, applied.Count);
|
||||||
|
Assert.Equal(4, item.Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("ghost", 1)]
|
||||||
|
[InlineData(null, 1)]
|
||||||
|
[InlineData("part-0", double.NaN)]
|
||||||
|
public void MalformedDuplicatesRemainUnkeepable(string? id, double x)
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose(id!, x: x)),
|
||||||
|
Sheet(job.Plates[0], 1, Pose(id!, 1, x: x))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(2, result.Raw.Plates.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProposalKeepsThreeFieldConstructorAndDeconstructionAndRequiresPositiveQuantity()
|
||||||
|
{
|
||||||
|
var stock = Stock();
|
||||||
|
var proposed = new ProposedPlate(4, stock, []);
|
||||||
|
var (index, actualStock, parts) = proposed;
|
||||||
|
Assert.Equal(4, index);
|
||||||
|
Assert.Same(stock, actualStock);
|
||||||
|
Assert.Empty(parts);
|
||||||
|
Assert.Equal(1, proposed.Quantity);
|
||||||
|
Assert.Equal(2, (proposed with { Quantity = 2 }).Quantity);
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => proposed with { Quantity = 0 });
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => proposed with { Quantity = -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("program")]
|
||||||
|
[InlineData("pose")]
|
||||||
|
[InlineData("drawing")]
|
||||||
|
public void GroupedProposalFreshnessStillRefusesMutationBeforeAttachment(string field)
|
||||||
|
{
|
||||||
|
var item = Item(quantity: 2);
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
|
||||||
|
var proposed = Assert.Single(result.Plates);
|
||||||
|
Assert.Equal(2, proposed.Quantity);
|
||||||
|
if (field == "program") ((Motion)proposed.Parts[0].Program.Codes[1]).EndPoint = new Vector(10, 10);
|
||||||
|
else if (field == "pose") proposed.Parts[0].Location = new Vector(double.BitIncrement(1), 1);
|
||||||
|
else item.Drawing.Program = TestDrawingFactory.Rectangle(10, 10);
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(item.Drawing);
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager, true));
|
||||||
|
Assert.Empty(nest.Plates);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("quantity")]
|
||||||
|
[InlineData("stock")]
|
||||||
|
[InlineData("index")]
|
||||||
|
public void RawToGroupedMatchChecksQuantityStockAndFirstPhysicalIndex(string field)
|
||||||
|
{
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [Item()], [Stock()]));
|
||||||
|
var proposed = Assert.Single(result.Plates);
|
||||||
|
var changed = field switch
|
||||||
|
{
|
||||||
|
"quantity" => proposed with { Quantity = 1 },
|
||||||
|
"stock" => proposed with { Stock = Stock("other") },
|
||||||
|
_ => proposed with { PlateIndex = 1 },
|
||||||
|
};
|
||||||
|
Assert.NotEmpty(NestPipelineDrawingFreshness.BoundChanges(result.Job, result.Raw,
|
||||||
|
[changed], result.DrawingsByPartId, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TransientMove(double x, double y) : LinearMove(x, y)
|
||||||
|
{
|
||||||
|
public override ICode Clone()
|
||||||
|
{
|
||||||
|
var before = EndPoint;
|
||||||
|
EndPoint = new Vector(100, 100);
|
||||||
|
try { return base.Clone(); }
|
||||||
|
finally { EndPoint = before; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GroupedBindingTransientCloneEditRemainsUnkeepable()
|
||||||
|
{
|
||||||
|
var item = Item();
|
||||||
|
item.Drawing.Program.Codes[^1] = new TransientMove(0, 0);
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
|
||||||
|
Assert.False(result.CanKeep);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Contains(result.Violations, v => v.Contains("Bound part"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelledGroupingAndCommitLeaveInputsUntouched()
|
||||||
|
{
|
||||||
|
using var source = new CancellationTokenSource();
|
||||||
|
source.Cancel();
|
||||||
|
Assert.Throws<OperationCanceledException>(() => RepeatedPlateGrouping.Create([
|
||||||
|
Sheet(Stock(), 0, Pose()), Sheet(Stock(), 1, Pose(instance: 1))], true, source.Token));
|
||||||
|
var item = Item();
|
||||||
|
var result = NestPipeline.Run(new Stub(job => Raw([
|
||||||
|
Sheet(job.Plates[0], 0, Pose()), Sheet(job.Plates[0], 1, Pose(instance: 1))])),
|
||||||
|
"stub", new NestPipelineRequest("stub", [item], [Stock()]));
|
||||||
|
var nest = new Nest();
|
||||||
|
using var manager = new PlateManager(nest);
|
||||||
|
Assert.Throws<OperationCanceledException>(() => NestPipelineCommit.ApplyToEmptyPlates(result, manager,
|
||||||
|
token: source.Token));
|
||||||
|
Assert.Empty(nest.Plates);
|
||||||
|
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
public class StockCostTests
|
||||||
|
{
|
||||||
|
public static IEnumerable<object[]> Engines()
|
||||||
|
{
|
||||||
|
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
|
||||||
|
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
|
||||||
|
yield return new object[] { engine, factor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestPlateStock Stock(string id, double cost, int quantity = 2, double size = 10) =>
|
||||||
|
new(id, new Size(size, size), quantity, 0.1, default, 1, cost);
|
||||||
|
|
||||||
|
private static NestJob Job(params NestPlateStock[] stocks) => new(
|
||||||
|
new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1) }, stocks);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidationRejectsForgedStockPrice()
|
||||||
|
{
|
||||||
|
var job = Job(Stock("offered", 10));
|
||||||
|
var forged = Stock("offered", 1);
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, forged, new[] { new NestJobPlacement("part", 0, 0, 0, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
Assert.NotEmpty(NestLayoutCheck.Violations(job, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnavailablePriceDoesNotChangeAreaModeOrLegacyPenalty()
|
||||||
|
{
|
||||||
|
var job = Job(new NestPlateStock("available", new Size(10, 10)), Stock("unavailable", 10000, 0));
|
||||||
|
Assert.False(NestJobCost.UsesExplicitCosts(job));
|
||||||
|
Assert.Equal(100, NestJobCost.UnplacedPartPenalty(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FixedStrategyUsesSalvageOnlyForPricedStock()
|
||||||
|
{
|
||||||
|
var poses = new[] { new NestJobPlacement("part", 0, 0, 0, 0) };
|
||||||
|
var candidate = new PlateCandidate(poses);
|
||||||
|
var priced = Job(Stock("small", 10, size: 5), Stock("large", 12, size: 10));
|
||||||
|
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1);
|
||||||
|
var pricedJob = new NestJob(priced.Parts, priced.Plates, options);
|
||||||
|
Assert.True(new NestJobCandidateComparer(pricedJob).Compare(candidate, priced.Plates[1], 1,
|
||||||
|
candidate, priced.Plates[0], 0) > 0);
|
||||||
|
var unpriced = new NestJob(priced.Parts, new[] { new NestPlateStock("small", new Size(5, 5)),
|
||||||
|
new NestPlateStock("large", new Size(10, 10)) }, options);
|
||||||
|
Assert.True(new NestJobCandidateComparer(unpriced).Compare(candidate, unpriced.Plates[0], 0,
|
||||||
|
candidate, unpriced.Plates[1], 1) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 20, 0)]
|
||||||
|
[InlineData(0.5, 14, 6)]
|
||||||
|
public void SummarySeparatesGrossCreditAndNet(double rate, double net, double credit)
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("square", TestDrawingFactory.Rectangle(4, 4));
|
||||||
|
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
|
||||||
|
var result = NestPipeline.Run(new NestPipelineRequest("Rectangles",
|
||||||
|
new[] { new NestItem { Drawing = drawing, Quantity = 1 } }, new[] { stock },
|
||||||
|
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 6)));
|
||||||
|
Assert.Empty(result.Violations);
|
||||||
|
var summary = NestCostSummary.FromAccepted(result);
|
||||||
|
Assert.Equal("supplied-cost", summary.Basis);
|
||||||
|
Assert.Equal(20, summary.GrossTotal);
|
||||||
|
Assert.Equal(net, summary.NetScore, 10);
|
||||||
|
Assert.Equal(credit, summary.SalvageCredit, 10);
|
||||||
|
Assert.Equal(1, Assert.Single(summary.Stock).Used);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(Engines))]
|
||||||
|
public void PricesReverseSelectionWithoutChangingFulfillment(string engine, double factor)
|
||||||
|
{
|
||||||
|
foreach (var cheap in new[] { "small", "large" })
|
||||||
|
{
|
||||||
|
var job = Job(Stock("small", (cheap == "small" ? 1 : 10) * factor),
|
||||||
|
Stock("large", (cheap == "large" ? 1 : 10) * factor, size: 12));
|
||||||
|
var result = NestingEngineRegistry.Create(engine).Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||||
|
Assert.Equal(cheap, Assert.Single(result.Plates).StockId);
|
||||||
|
Assert.Equal(factor, NestJobCost.Evaluate(job, result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Rectangles")]
|
||||||
|
[InlineData("Irregular")]
|
||||||
|
[InlineData("StockLadder")]
|
||||||
|
[InlineData("Default")]
|
||||||
|
[InlineData("Fill")]
|
||||||
|
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
|
||||||
|
{
|
||||||
|
var stocks = new[] { Stock("expensive", 10, 2, 5), Stock("cheap", 1, 1, 5) };
|
||||||
|
var job = new NestJob(new[] { new NestJobPart("part",
|
||||||
|
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 3) }, stocks);
|
||||||
|
var result = NestingEngineRegistry.Create(engine).Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||||
|
Assert.Equal("cheap", result.Plates[0].StockId);
|
||||||
|
Assert.Single(result.Plates.Where(p => p.StockId == "cheap"));
|
||||||
|
Assert.Equal(21, NestJobCost.Evaluate(job, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-1)]
|
||||||
|
[InlineData(double.NaN)]
|
||||||
|
[InlineData(double.PositiveInfinity)]
|
||||||
|
public void ExplicitCostsMustBeFiniteAndPositive(double cost) =>
|
||||||
|
Assert.Throws<ArgumentOutOfRangeException>(() => Stock("bad", cost));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MixedModeRejectsOnlyAvailableStock()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => Job(Stock("priced", 2), new NestPlateStock("missing", new Size(10, 10))));
|
||||||
|
var job = Job(Stock("priced", 2), new NestPlateStock("unavailable", new Size(10, 10), 0));
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
Assert.Equal(2, NestJobCost.UnplacedPartPenalty(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 5, 20)]
|
||||||
|
[InlineData(0.5, 0, 20)]
|
||||||
|
[InlineData(0.5, 7, 20)]
|
||||||
|
[InlineData(0.5, 6, 14)]
|
||||||
|
public void PricesCreditSalvageProportionally(double rate, double minimum, double expected)
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock("priced", new Size(10, 10), 1, 0, default, 1, 20);
|
||||||
|
var job = new NestJob(Job(stock).Parts, new[] { stock }, new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
|
||||||
|
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
|
||||||
|
Assert.Equal(expected, NestJobCost.NetSheetCost(job, sheet), 10);
|
||||||
|
Assert.Equal(expected, NestJobCost.NetSheetCost(job.Options, stock, new Box(0, 0, 4, 4)), 10);
|
||||||
|
Assert.Equal(expected * 5, NestJobCost.NetSheetArea(job, sheet), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AggregateOverflowFailsClearly()
|
||||||
|
{
|
||||||
|
var stock = Stock("huge", double.MaxValue);
|
||||||
|
var job = new NestJob(new[] { new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 2) }, new[] { stock });
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
|
||||||
|
builder.AddSheet(stock, new[] { ("part", 0d, 0d, 0d) });
|
||||||
|
Assert.Throws<OverflowException>(() => NestJobCost.Evaluate(job, builder.Build(NestJobStopReason.Completed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuilderMapsLegacyZeroAndSnapshotsPositiveCosts()
|
||||||
|
{
|
||||||
|
var option = new PlateOption { Width = 10, Length = 10 };
|
||||||
|
Assert.Null(Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option })).Cost);
|
||||||
|
option.Cost = 12;
|
||||||
|
var stock = Assert.Single(NestStockBuilder.FromTemplate(new Plate(10, 10), new[] { option }));
|
||||||
|
option.Cost = 50;
|
||||||
|
Assert.Equal(12, stock.Cost);
|
||||||
|
Assert.Throws<ArgumentException>(() => NestStockBuilder.FromTemplate(new Plate(10, 10),
|
||||||
|
new[] { option, new PlateOption { Width = 12, Length = 12 } }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Engine.Fill;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Engine.NestingEngines.Default;
|
||||||
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// C16 (PM 6859db53): a clamp zone the job validator accepts may be far smaller than the
|
||||||
|
/// validation outline tolerance (here 1e-5 square). Every keep-out consumer must check it
|
||||||
|
/// without throwing, flag a placement whose material overlaps it (plus the D6 clearance),
|
||||||
|
/// and keep a placement that only touches or clears it clear. Tolerances are unchanged (C5):
|
||||||
|
/// the expected verdicts below are exact rectangle arithmetic, not the checker's output.
|
||||||
|
/// </summary>
|
||||||
|
public class TinyClampZoneTests
|
||||||
|
{
|
||||||
|
private const double Tiny = 1e-5;
|
||||||
|
|
||||||
|
private static readonly ClampZone TinyZone = new("tiny", new Box(8, 0, Tiny, Tiny));
|
||||||
|
|
||||||
|
private static Program SquareProgram()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(2, 0));
|
||||||
|
program.Codes.Add(new LinearMove(2, 2));
|
||||||
|
program.Codes.Add(new LinearMove(0, 2));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJobPart Square() =>
|
||||||
|
new("square", PartGeometrySnapshot.FromProgram(SquareProgram()), 1, 0, RotationPolicy.Fixed(0));
|
||||||
|
|
||||||
|
private static NestPlateStock Stock(double bottom) =>
|
||||||
|
new("sheet", new Size(10, 12), null, 0, new Spacing(0, bottom, 0, 0), 1,
|
||||||
|
new[] { TinyZone });
|
||||||
|
|
||||||
|
private static NestJob Job(double bottom) => new(new[] { Square() }, new[] { Stock(bottom) });
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ClampViolations(NestJob job, NestJobResult result) =>
|
||||||
|
NestLayoutCheck.Violations(job, result).Where(v => v.Contains("clamp zone")).ToList();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RectanglesResultOnATinyClampIsCheckedWithoutThrowing()
|
||||||
|
{
|
||||||
|
// The PM probe: Rectangles places the square flush against the tiny clamp.
|
||||||
|
var job = Job(0);
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
var result = new RectanglesNestingEngine().Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
|
||||||
|
var violations = ClampViolations(job, result);
|
||||||
|
|
||||||
|
// Independent oracle: the square's footprint shares no positive area with the zone.
|
||||||
|
var pose = Assert.Single(result.Plates.SelectMany(p => p.Placements));
|
||||||
|
var overlapX = System.Math.Min(pose.X + 2, 8 + Tiny) - System.Math.Max(pose.X, 8);
|
||||||
|
var overlapY = System.Math.Min(pose.Y + 2, Tiny) - System.Math.Max(pose.Y, 0);
|
||||||
|
var expectOverlap = overlapX > 0 && overlapY > 0;
|
||||||
|
Assert.Equal(expectOverlap, violations.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultSolvesATinyClampJobToAValidResult()
|
||||||
|
{
|
||||||
|
var job = Job(0);
|
||||||
|
var result = new DefaultNestingEngine().Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Empty(NestLayoutCheck.Violations(job, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// (bottom edge spacing, x, y, material overlaps the zone inflated by the clearance).
|
||||||
|
// The keep-out is [8 - c, 8 + 1e-5 + c] x [-c, 1e-5 + c] with c = bottom - 0.0005
|
||||||
|
// (the shared spacing slack); the 2x2 square spans [x, x+2] x [y, y+2].
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0, 7.5, 0.0, true)] // covers the zone
|
||||||
|
[InlineData(0.0, 6.0, 0.0, false)] // right edge flush on the zone's left side
|
||||||
|
[InlineData(0.0, 8.00001, 0.0, false)] // left edge flush on the zone's right side
|
||||||
|
[InlineData(0.0, 8.0, Tiny, false)] // bottom edge flush on the zone's top
|
||||||
|
[InlineData(0.0, 3.0, 0.0, false)] // well clear
|
||||||
|
[InlineData(0.25, 7.5, 0.1, true)] // over the zone, inside the clearance band
|
||||||
|
[InlineData(0.25, 8.0, 0.24, true)] // above the zone but inside the clearance
|
||||||
|
[InlineData(0.25, 8.25001, 0.1, false)] // exactly at the clearance to the right
|
||||||
|
[InlineData(0.25, 7.0, 0.25001, false)] // exactly at the clearance above
|
||||||
|
[InlineData(0.25, 3.0, 0.25, false)] // well clear
|
||||||
|
public void LayoutCheckJudgesPlacementsAgainstATinyClamp(double bottom, double x, double y,
|
||||||
|
bool overlaps)
|
||||||
|
{
|
||||||
|
var job = Job(bottom);
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
var stock = job.Plates[0];
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("square", 0, x, y, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
var violations = ClampViolations(job, result);
|
||||||
|
|
||||||
|
Assert.Equal(overlaps, violations.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program FrameProgram()
|
||||||
|
{
|
||||||
|
// 4x4 square with a 2x2 hole in its middle.
|
||||||
|
var program = new Program();
|
||||||
|
program.Codes.Add(new RapidMove(0, 0));
|
||||||
|
program.Codes.Add(new LinearMove(4, 0));
|
||||||
|
program.Codes.Add(new LinearMove(4, 4));
|
||||||
|
program.Codes.Add(new LinearMove(0, 4));
|
||||||
|
program.Codes.Add(new LinearMove(0, 0));
|
||||||
|
program.Codes.Add(new RapidMove(1, 1));
|
||||||
|
program.Codes.Add(new LinearMove(1, 3));
|
||||||
|
program.Codes.Add(new LinearMove(3, 3));
|
||||||
|
program.Codes.Add(new LinearMove(3, 1));
|
||||||
|
program.Codes.Add(new LinearMove(1, 1));
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tiny zone inside the frame's hole is clear; inside its material it is not.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(6.0, -2.0, false)] // hole spans [7, 9] x [-1, 1] around the zone
|
||||||
|
[InlineData(6.0, 0.0, true)] // the frame's bottom bar covers the zone
|
||||||
|
public void LayoutCheckSeesHolesAroundATinyClamp(double x, double y, bool overlaps)
|
||||||
|
{
|
||||||
|
var frame = new NestJobPart("frame", PartGeometrySnapshot.FromProgram(FrameProgram()), 1, 0,
|
||||||
|
RotationPolicy.Fixed(0));
|
||||||
|
var job = new NestJob(new[] { frame }, new[] { Stock(0) });
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement("frame", 0, x, y, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(7.5, 0.0, true)]
|
||||||
|
[InlineData(6.0, 0.0, false)]
|
||||||
|
[InlineData(8.00001, 0.0, false)]
|
||||||
|
[InlineData(3.0, 0.0, false)]
|
||||||
|
public void InteractiveKeepOutTestJudgesATinyClamp(double x, double y, bool overlaps)
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("square", SquareProgram());
|
||||||
|
var part = new Part(drawing, new Vector(x, y));
|
||||||
|
Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, new Box(8, 0, Tiny, Tiny)));
|
||||||
|
}
|
||||||
|
private static Plate TinyZonePlate()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(10, 12)); // zero edge and part spacing
|
||||||
|
plate.ClampZones.Add(TinyZone);
|
||||||
|
return plate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive-area overlap with the zone, admitting 1e-9 of floating closure (a ten
|
||||||
|
// thousandth of the zone): a push that stops flush on the zone is clear.
|
||||||
|
private static bool EntersTinyZone(Part part) =>
|
||||||
|
System.Math.Min(part.BoundingBox.Right, 8 + Tiny) - System.Math.Max(part.BoundingBox.Left, 8) > 1e-9
|
||||||
|
&& System.Math.Min(part.BoundingBox.Top, Tiny) - System.Math.Max(part.BoundingBox.Bottom, 0) > 1e-9;
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Fill")]
|
||||||
|
[InlineData("Strip")]
|
||||||
|
[InlineData("Vertical Remnant")]
|
||||||
|
[InlineData("Horizontal Remnant")]
|
||||||
|
public void InteractiveFillNeverCoversATinyClamp(string strategy)
|
||||||
|
{
|
||||||
|
var plate = TinyZonePlate();
|
||||||
|
var item = new NestItem { Drawing = new Drawing("square", SquareProgram()), Quantity = 30 };
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillItem(strategy, plate, item, plate.WorkArea(), null,
|
||||||
|
System.Threading.CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
Assert.DoesNotContain(parts, EntersTinyZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PushDownStopsBeforeCoveringATinyClamp()
|
||||||
|
{
|
||||||
|
var plate = TinyZonePlate();
|
||||||
|
var mover = new Part(new Drawing("square", SquareProgram()), new Vector(7.5, 5));
|
||||||
|
plate.Parts.Add(mover);
|
||||||
|
|
||||||
|
Compactor.Push(new List<Part> { mover }, plate, PushDirection.Down);
|
||||||
|
|
||||||
|
Assert.False(EntersTinyZone(mover), $"pushed to ({mover.Location.X:R},{mover.Location.Y:R})");
|
||||||
|
}
|
||||||
|
// PM 407f5eb7: the tolerance-free test must read the part's real material, not the
|
||||||
|
// 1e-4-grid validation outline or a 0.001 arc flattening.
|
||||||
|
private static Program RectProgram(double w, double h)
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(w, 0);
|
||||||
|
program.LineTo(w, h);
|
||||||
|
program.LineTo(0, h);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program DiskProgram()
|
||||||
|
{
|
||||||
|
// Radius 2 centred at (10, 10), two absolute CCW half circles.
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(12, 10);
|
||||||
|
program.ArcTo(8, 10, 10, 10, RotationType.CCW);
|
||||||
|
program.ArcTo(12, 10, 10, 10, RotationType.CCW);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Box DiskBox(double radius)
|
||||||
|
{
|
||||||
|
var cx = 10 + radius * System.Math.Cos(0.02);
|
||||||
|
var cy = 10 + radius * System.Math.Sin(0.02);
|
||||||
|
return new Box(cx - 0.000005, cy - 0.000005, Tiny, Tiny);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<string, Box, bool> RealMaterialRows => new()
|
||||||
|
{
|
||||||
|
{ "rect-8.00004", new Box(8.00002, 0.5, Tiny, Tiny), true }, // wholly inside the part
|
||||||
|
{ "rect-8.00004", new Box(8.00005, 0.5, Tiny, Tiny), false }, // just beyond its edge
|
||||||
|
{ "rect-2.00004", new Box(2.000032, 1, 5e-6, Tiny), true },
|
||||||
|
{ "disk", DiskBox(1.9998), true }, // inside the true arc, outside its 0.001 chords
|
||||||
|
{ "disk", DiskBox(2.0002), false }, // just outside the true arc
|
||||||
|
// The arc cuts a corner off the zone, 6.2e-7 deep: less than a fine chord's sag.
|
||||||
|
{ "disk", new Box(11.935615871667656, 10.503377293956166, Tiny, Tiny), true },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Program MaterialProgram(string name) => name switch
|
||||||
|
{
|
||||||
|
"rect-8.00004" => RectProgram(8.00004, 2),
|
||||||
|
"rect-2.00004" => RectProgram(2.00004, 2),
|
||||||
|
_ => DiskProgram(),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(RealMaterialRows))]
|
||||||
|
public void LayoutCheckReadsRealMaterialAroundATinyClamp(string material, Box zone, bool overlaps)
|
||||||
|
{
|
||||||
|
var program = MaterialProgram(material);
|
||||||
|
var stock = new NestPlateStock("sheet", new Size(20, 20), null, 0, default, 1,
|
||||||
|
new[] { new ClampZone("tiny", zone) });
|
||||||
|
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0,
|
||||||
|
RotationPolicy.Fixed(0)) }, new[] { stock });
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
Assert.Equal(overlaps, ClampViolations(job, result).Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(RealMaterialRows))]
|
||||||
|
public void InteractiveKeepOutTestReadsRealMaterial(string material, Box zone, bool overlaps)
|
||||||
|
{
|
||||||
|
var part = new Part(new Drawing(material, MaterialProgram(material)));
|
||||||
|
Assert.Equal(overlaps, InteractiveClamps.MaterialInKeepOut(part, zone));
|
||||||
|
}
|
||||||
|
// Delta review 2: a concave (notch) arc must not lose material either. The zone's corner
|
||||||
|
// sits 6e-7 into the material beside a semicircular notch, at a flattening vertex.
|
||||||
|
internal static (Program Program, Box Zone) NotchCornerFixture()
|
||||||
|
{
|
||||||
|
var program = new Program();
|
||||||
|
program.MoveTo(0, 0);
|
||||||
|
program.LineTo(20, 0);
|
||||||
|
program.LineTo(20, 20);
|
||||||
|
program.LineTo(12, 20);
|
||||||
|
program.ArcTo(8, 20, 10, 20, RotationType.CW);
|
||||||
|
program.LineTo(0, 20);
|
||||||
|
program.LineTo(0, 0);
|
||||||
|
var arc = new ShapeProfile(OpenNest.Converters.ConvertProgram.ToGeometry(program))
|
||||||
|
.Perimeter.Entities.OfType<Arc>().Single();
|
||||||
|
var native = arc.ToPoints(arc.SegmentsForTolerance(Tiny / 4)).First(v => v.X > 11.9 && v.Y < 19.5);
|
||||||
|
var centre = new Vector(10, 20);
|
||||||
|
var outward = centre + (native - centre) / 2 * (2 + 6e-7);
|
||||||
|
return (program, new Box(outward.X - Tiny, outward.Y, Tiny, Tiny));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcaveArcMaterialIsNotLostBesideATinyClamp()
|
||||||
|
{
|
||||||
|
var (program, zone) = NotchCornerFixture();
|
||||||
|
var stock = new NestPlateStock("sheet", new Size(25, 25), null, 0, default, 1,
|
||||||
|
new[] { new ClampZone("tiny", zone) });
|
||||||
|
var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1, 0,
|
||||||
|
RotationPolicy.Fixed(0)) }, new[] { stock });
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
|
||||||
|
new[] { new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("p", 0, 0, 0, 0) }) },
|
||||||
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>());
|
||||||
|
|
||||||
|
Assert.NotEmpty(ClampViolations(job, result));
|
||||||
|
Assert.True(InteractiveClamps.MaterialInKeepOut(new Part(new Drawing("notch", program)), zone));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.NestingEngines.Default;
|
||||||
|
using OpenNest.Engine.NestingEngines.Irregular;
|
||||||
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
|
||||||
|
public class IrregularClampZoneTests
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
|
||||||
|
new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void CurvedPartsClearEveryInflatedClampInEveryQuadrant(int quadrant)
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 10, 20, spacing: 0.1, quadrant: quadrant,
|
||||||
|
edge: new Spacing(0.1, 0.25, 0.1, 0.1), clampZones:
|
||||||
|
[new("left", new Box(0, 0, 3, 0.625)), new("middle", new Box(7, 0, 1.5, 0.625))]);
|
||||||
|
var job = Job([Part("disc", Disc(1), 9, RotationPolicy.Fixed(0))], [stock]);
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(9, Assert.Single(result.Plates).Placements.Count);
|
||||||
|
AssertDiscsClearClamps(result, 1);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Contains(result.Plates[0].Placements, p => p.Y - 1 < stock.WorkArea.Bottom + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Irregular")]
|
||||||
|
[InlineData("Rectangles")]
|
||||||
|
[InlineData("Default")]
|
||||||
|
public void BothLanesAndDefaultAvoidClamps(string engine)
|
||||||
|
{
|
||||||
|
var job = Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))],
|
||||||
|
[Stock("sheet", 5, 12, edge: new Spacing(0.25, 0.25, 0.25, 0.25),
|
||||||
|
clampZones: [new("front", new Box(0, 0, 3, 0.625))])]);
|
||||||
|
INestingEngine solver = engine switch
|
||||||
|
{
|
||||||
|
"Irregular" => new IrregularNestingEngine(),
|
||||||
|
"Rectangles" => new RectanglesNestingEngine(),
|
||||||
|
_ => new DefaultNestingEngine(),
|
||||||
|
};
|
||||||
|
var result = solver.Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||||
|
AssertDiscsClearClamps(result, 1);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void ClampClearanceUsesBottomEdgeSpacingNotPartSpacing(double partSpacing)
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 4, 5, spacing: partSpacing,
|
||||||
|
edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new("rail", new Box(0, 0, 5, 0.625))]);
|
||||||
|
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
|
||||||
|
Assert.InRange(pose.Y - 1, 0.875, 0.90);
|
||||||
|
AssertDiscsClearClamps(result, 1);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullRailBlocksTheOnlyRowInsteadOfReturningAnUnsafePlacement()
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 2.5, 6, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new("rail", new Box(0, 0, 6, 0.625))]);
|
||||||
|
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(1)]
|
||||||
|
public void PairOnlyOrientationsAlsoLoseClampBlockedSpace(int axis)
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 10, 10, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new("rail", new Box(0, 0, 10, 1))]);
|
||||||
|
var job = Job([Rectangle("bar", 2, 3, 2, RotationPolicy.Automatic)], [stock]);
|
||||||
|
var types = PartCatalog.Build(job);
|
||||||
|
var extra = PartCatalog.CreateOrientation(types[0], 100, System.Math.PI / 2)!;
|
||||||
|
var pair = new PairPose { A = extra, B = extra, Dx = 0, Dy = 3 };
|
||||||
|
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
|
||||||
|
var fill = new FrontierPacker(types, new NoFitCache(0), pairs, stock,
|
||||||
|
(PackAxis)axis, 1, new WorkCounter()).Fill([2], default);
|
||||||
|
|
||||||
|
Assert.Equal(2, fill.Parts.Count);
|
||||||
|
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
|
||||||
|
if (axis == 0)
|
||||||
|
Assert.All(fill.Parts, p => Assert.Same(extra, p.Orientation));
|
||||||
|
AssertValidFill(job, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlockOnlyOrientationsAlsoLoseClampBlockedSpace()
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 20, 12, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new("rail", new Box(0, 0, 12, 1))]);
|
||||||
|
var job = Job([Rectangle("bar", 2, 3, 6, RotationPolicy.Automatic)], [stock]);
|
||||||
|
var types = PartCatalog.Build(job);
|
||||||
|
using var blocks = new BlockCatalog(0.1, types, NoPairs, new WorkCounter(), long.MaxValue);
|
||||||
|
var fill = new FrontierPacker(types, new NoFitCache(0.1), NoPairs, stock,
|
||||||
|
PackAxis.Y, 1, new WorkCounter(), blocks).Fill([6], default);
|
||||||
|
|
||||||
|
Assert.Equal(6, fill.Parts.Count);
|
||||||
|
Assert.True(blocks.PreparationCount > 0);
|
||||||
|
Assert.Contains(fill.Parts, p => !types[0].Orientations.Contains(p.Orientation));
|
||||||
|
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
|
||||||
|
AssertValidFill(job, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameSpacingStocksDoNotShareClampGeometry()
|
||||||
|
{
|
||||||
|
var wall = new ClampZone("rail", new Box(0, 0, 6, 1));
|
||||||
|
var job = Job([Rectangle("part", 2, 2, 1)],
|
||||||
|
[Stock("blocked", 2.5, 6, clampZones: [wall]), Stock("open", 2.5, 6)]);
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal("open", Assert.Single(result.Plates).StockId);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConcavePartCanUseItsOpenNotchAroundAClamp()
|
||||||
|
{
|
||||||
|
// The only fitting pose surrounds the rail in an open notch. Blocking the
|
||||||
|
// part's bounding box instead of its NFP would incorrectly reject this job.
|
||||||
|
var program = Polyline((0, 0), (1, 0), (1, 3), (3, 3),
|
||||||
|
(3, 0), (4, 0), (4, 4), (0, 4));
|
||||||
|
var stock = Stock("sheet", 4.2, 4, edge: new Spacing(0, 0.1, 0, 0),
|
||||||
|
clampZones: [new("notch", new Box(1.5, 0, 1, 2))]);
|
||||||
|
var job = Job([Part("notched", program, 1, RotationPolicy.Fixed(0))], [stock]);
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
|
||||||
|
Assert.Equal(0, pose.X, 8);
|
||||||
|
Assert.Equal(0.1, pose.Y, 8);
|
||||||
|
// Analytic notch walls versus the rectangle inflated by 0.1.
|
||||||
|
Assert.True(pose.X + 1 <= 1.4 && pose.X + 3 >= 2.6 && pose.Y + 3 >= 2.1);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DifferentClampedStocksAtTheSameSpacingKeepTheirOwnObstacles()
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("part", 2, 2, 2)],
|
||||||
|
[Stock("left", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(0, 0, 3, 1))]),
|
||||||
|
Stock("right", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(3, 0, 3, 1))])]);
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
{
|
||||||
|
var pose = Assert.Single(sheet.Placements);
|
||||||
|
Assert.True(sheet.StockId == "left" ? pose.X >= 3 : pose.X + 2 <= 3);
|
||||||
|
}
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancelledClampPackingDoesNotReturnAnEmptySuccess()
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 4, 5, clampZones: [new("rail", new Box(0, 0, 5, 1))]);
|
||||||
|
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
|
||||||
|
var packer = new FrontierPacker(PartCatalog.Build(job), new NoFitCache(0), NoPairs,
|
||||||
|
stock, PackAxis.X, 1, new WorkCounter());
|
||||||
|
Assert.Throws<OperationCanceledException>(() => packer.Fill([1], new CancellationToken(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoteSubGridClampDoesNotCrashWholeSolve()
|
||||||
|
{
|
||||||
|
// Exact reported reproducer: Stock takes Y width, then X length.
|
||||||
|
var stock = Stock("sheet", 10, 12,
|
||||||
|
clampZones: [new("remote-small-clamp", new Box(8, 0, 1e-5, 1e-5))]);
|
||||||
|
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertCompleteRectangleClearsClamps(result, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> SubGridClampCases()
|
||||||
|
{
|
||||||
|
foreach (var quadrant in Enumerable.Range(1, 4))
|
||||||
|
foreach (var fixture in Enumerable.Range(0, 6))
|
||||||
|
yield return [quadrant, fixture];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(SubGridClampCases))]
|
||||||
|
public void SubGridClampsBlockTheOriginalRectanglePlacement(int quadrant, int fixture)
|
||||||
|
{
|
||||||
|
var (bounds, originX, originY, clearance) = fixture switch
|
||||||
|
{
|
||||||
|
0 => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 0.0),
|
||||||
|
1 => (new Box(0.75, 0.5, 1e-5, 1e-5), 7.25, -3.125, 0.0),
|
||||||
|
2 => (new Box(0.75, 0.5, 1e-5, 0.75), 7.25, -3.125, 0.0),
|
||||||
|
3 => (new Box(0.5, 0.75, 0.75, 1e-5), 7.25, -3.125, 0.0),
|
||||||
|
// Positive dimensions can disappear even before grid conversion.
|
||||||
|
4 => (new Box(1, 1, 1e-20, 1e-20), 7.25, -3.125, 0.0),
|
||||||
|
_ => (new Box(0, 0, 1e-5, 1e-5), 0.0, 0.0, 1e-6),
|
||||||
|
};
|
||||||
|
var edge = new Spacing(0, clearance, 0, 0);
|
||||||
|
var stock = Stock("sheet", 10, 12, quadrant: quadrant, edge: edge,
|
||||||
|
clampZones: [new("tiny", bounds)]);
|
||||||
|
var part = Rectangle("part", 2, 2, 1, x: originX, y: originY);
|
||||||
|
var job = Job([part], [stock]);
|
||||||
|
NestJobValidator.Validate(job);
|
||||||
|
var zone = Assert.Single(stock.ClampZones);
|
||||||
|
if (fixture == 4)
|
||||||
|
{
|
||||||
|
Assert.Equal(zone.Bounds.Left, zone.Bounds.Right);
|
||||||
|
Assert.Equal(zone.Bounds.Bottom, zone.Bounds.Top);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A drop-clamp mutation must fail: the same job without a clamp enters it.
|
||||||
|
var unclamped = Job([part], [Stock("sheet", 10, 12, quadrant: quadrant, edge: edge)]);
|
||||||
|
var original = new IrregularNestingEngine().Solve(unclamped);
|
||||||
|
AssertCompleteRectangleClearsClamps(original, originX, originY);
|
||||||
|
var originalPose = Assert.Single(Assert.Single(original.Plates).Placements);
|
||||||
|
Assert.True(RectangleEntersInflatedClamp(originalPose, originX, originY, zone.Bounds, clearance),
|
||||||
|
"The fixture must block the unclamped placement.");
|
||||||
|
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
|
||||||
|
AssertCompleteRectangleClearsClamps(result, originX, originY);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "0F90BA3EF6C6392CF1C1E01A6209D55CA88B4F968D6DD0D01850AE15A349FE88")]
|
||||||
|
[InlineData(1, "A970D56C5768763CE339999F91ADD7FFB9EC2AF6C1585CC51C4B0869D45D0C53")]
|
||||||
|
[InlineData(2, "65ECA7F7163DB0AB7BCA331FBF2A240604B25C44D62CE97CB2015FC78A9631AA")]
|
||||||
|
// Full result hashes captured on d71f086 before static-obstacle preparation.
|
||||||
|
public void EmptyClampLayoutsKeepBaselineBits(int fixture, string expected)
|
||||||
|
{
|
||||||
|
var job = fixture switch
|
||||||
|
{
|
||||||
|
0 => Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, spacing: 0.25)]),
|
||||||
|
1 => Job([Part("ell", LShape(4, 3, 1), 1), Part("tri", Triangle(3, 2), 1)],
|
||||||
|
[Stock("sheet", 10, 15, spacing: 0.25)]),
|
||||||
|
_ => Job([Rectangle("square", 4, 4, 3)], [Stock("sheet", 5, 5, spacing: 0.25)]),
|
||||||
|
};
|
||||||
|
var result = new IrregularNestingEngine().Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result))));
|
||||||
|
Assert.Equal(expected, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertCompleteRectangleClearsClamps(NestJobResult result, double originX, double originY)
|
||||||
|
{
|
||||||
|
// Do not call NestLayoutCheck here: its separate S1 tiny-clamp null-outline
|
||||||
|
// defect can throw when broad-phase bounds touch. This is an analytic oracle.
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(NestJobStopReason.Completed, result.StopReason);
|
||||||
|
Assert.Equal(new PartFulfillment("part", 1, 1, 0), Assert.Single(result.Fulfillment));
|
||||||
|
Assert.Equal(1, Assert.Single(result.StockUsage).Used);
|
||||||
|
var sheet = Assert.Single(result.Plates);
|
||||||
|
var pose = Assert.Single(sheet.Placements);
|
||||||
|
Assert.Equal("part", pose.PartId);
|
||||||
|
Assert.Equal(0, pose.InstanceIndex);
|
||||||
|
Assert.Equal(0, pose.Rotation);
|
||||||
|
var work = sheet.Stock.WorkArea;
|
||||||
|
var slack = NestTolerances.WorkAreaSlack;
|
||||||
|
var left = pose.X + originX;
|
||||||
|
var bottom = pose.Y + originY;
|
||||||
|
Assert.True(left >= work.Left - slack && bottom >= work.Bottom - slack
|
||||||
|
&& left + 2 <= work.Right + slack && bottom + 2 <= work.Top + slack);
|
||||||
|
foreach (var zone in sheet.Stock.ClampZones)
|
||||||
|
Assert.False(RectangleEntersInflatedClamp(pose, originX, originY, zone.Bounds,
|
||||||
|
sheet.Stock.EdgeSpacing.Bottom),
|
||||||
|
$"Rectangle at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RectangleEntersInflatedClamp(NestJobPlacement pose, double originX, double originY,
|
||||||
|
Box bounds, double clearance)
|
||||||
|
{
|
||||||
|
// Decimal arithmetic retains the positive 1e-20 fixture dimensions even
|
||||||
|
// when Box.Right == Box.Left in double arithmetic. No Clipper/checker reuse.
|
||||||
|
var left = (decimal)pose.X + (decimal)originX;
|
||||||
|
var bottom = (decimal)pose.Y + (decimal)originY;
|
||||||
|
var c = (decimal)clearance;
|
||||||
|
return left < (decimal)bounds.Left + (decimal)bounds.Length + c
|
||||||
|
&& left + 2 > (decimal)bounds.Left - c
|
||||||
|
&& bottom < (decimal)bounds.Bottom + (decimal)bounds.Width + c
|
||||||
|
&& bottom + 2 > (decimal)bounds.Bottom - c;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertDiscsClearClamps(NestJobResult result, double radius)
|
||||||
|
{
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
foreach (var pose in sheet.Placements)
|
||||||
|
foreach (var zone in sheet.Stock.ClampZones)
|
||||||
|
{
|
||||||
|
var b = zone.Bounds;
|
||||||
|
var c = sheet.Stock.EdgeSpacing.Bottom;
|
||||||
|
var dx = pose.X - System.Math.Clamp(pose.X, b.Left - c, b.Right + c);
|
||||||
|
var dy = pose.Y - System.Math.Clamp(pose.Y, b.Bottom - c, b.Top + c);
|
||||||
|
Assert.True(dx * dx + dy * dy >= radius * radius,
|
||||||
|
$"Disc at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertValidFill(NestJob job, SheetFill fill)
|
||||||
|
{
|
||||||
|
var builder = new NestJobResultBuilder(job);
|
||||||
|
builder.AddSheet(fill.Stock, fill.Parts.Select(p =>
|
||||||
|
(job.Parts[p.Orientation.TypeIndex].Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||||
|
LayoutAssert.Valid(job, builder.Build(NestJobStopReason.Completed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.NestingEngines.Irregular;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
|
||||||
|
public class IrregularRepeatTests : RepeatEngineSafetyTests
|
||||||
|
{
|
||||||
|
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
|
||||||
|
new IrregularNestingEngine { WorkBudget = 0, SheetPacked = packed, BatchSelected = batches };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompletedFullLayoutRemovesActualFrontierFillCallsIncludingTailTrials()
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
|
||||||
|
var calls = 0;
|
||||||
|
var result = new IrregularNestingEngine { SheetPacked = () => calls++ }.Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(10, result.Plates.Count);
|
||||||
|
Assert.Equal(24, calls); // Six main variants and six trials for each physical tail of 1..3 sheets.
|
||||||
|
var first = result.Plates[0];
|
||||||
|
foreach (var plate in result.Plates.Skip(1))
|
||||||
|
{
|
||||||
|
Assert.NotSame(first.Placements, plate.Placements);
|
||||||
|
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
|
||||||
|
Assert.NotSame(first.Placements[0], plate.Placements[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SeededPhysicalTailReturnsFiniteStockAndReconsumesItWithoutDroppingRepeatedPrefix()
|
||||||
|
{
|
||||||
|
// A lifecycle test, not a claim that public Solve chooses this deliberately wasteful
|
||||||
|
// incumbent. Four identical full copies precede two half-full physical tail copies.
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 20)], [Stock("s", 20, 20, spacing: 0.2, quantity: 6)]);
|
||||||
|
var orientation = Assert.Single(Assert.Single(PartCatalog.Build(job)).Orientations);
|
||||||
|
var full = new[] { new Placed(orientation, 0, 0), new Placed(orientation, 9.2, 0),
|
||||||
|
new Placed(orientation, 0, 9.2), new Placed(orientation, 9.2, 9.2) };
|
||||||
|
var seed = Enumerable.Range(0, 4).Select(_ => new SheetFill(job.Plates[0], full.ToArray(), 324))
|
||||||
|
.Concat(Enumerable.Range(0, 2).Select(_ => new SheetFill(job.Plates[0], full.Take(2).ToArray(), 162))).ToArray();
|
||||||
|
var incumbent = new NestJobResultBuilder(job);
|
||||||
|
foreach (var sheet in seed)
|
||||||
|
incumbent.AddSheet(sheet.Stock, sheet.Parts.Select(p => ("a", p.X, p.Y, p.Orientation.Rotation)));
|
||||||
|
var before = incumbent.Build(NestJobStopReason.Completed);
|
||||||
|
LayoutAssert.Valid(job, before);
|
||||||
|
var replaced = new List<int>();
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = new IrregularNestingEngine { TailReplaced = replaced.Add, BatchSelected = batches.Add }
|
||||||
|
.ImproveTailForTesting(job, seed);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(new[] { 2 }, replaced);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(5, result.Plates.Count);
|
||||||
|
Assert.Equal(20, Assert.Single(result.Fulfillment).Placed);
|
||||||
|
Assert.Equal((5, 1), (Assert.Single(result.StockUsage).Used, Assert.Single(result.StockUsage).Remaining));
|
||||||
|
Assert.Equal(2000, NestJobCost.Evaluate(job, result), precision: 8);
|
||||||
|
Assert.True(NestJobCost.Evaluate(job, result) < NestJobCost.Evaluate(job, before));
|
||||||
|
for (var i = 0; i < 4; i++)
|
||||||
|
Assert.Equal(before.Plates[i].Placements.Select(Pose), result.Plates[i].Placements.Select(Pose));
|
||||||
|
Assert.All(result.Plates, p => Assert.Equal(4, p.Placements.Count));
|
||||||
|
Assert.Equal(Enumerable.Range(0, 20), result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex));
|
||||||
|
Assert.All(seed.Take(4), s => Assert.Equal(full, s.Parts)); // The incumbent was not mutated.
|
||||||
|
Assert.Contains(batches, b => b.ForcedFirst && b.Copies > 1); // The retained physical tail can still repeat.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForcedFirstStockCountsAllAvailableEntriesAndResumesAlternatives()
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 16)],
|
||||||
|
[Stock("one", 20, 20, spacing: 0.2, quantity: 10), Stock("two", 20, 20, spacing: 0.2, quantity: 10)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = new IrregularNestingEngine { BatchSelected = batches.Add }.Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var forced = batches.Where(b => b.ForcedFirst).ToArray();
|
||||||
|
Assert.NotEmpty(forced);
|
||||||
|
Assert.All(forced, b =>
|
||||||
|
{
|
||||||
|
Assert.Equal(2, b.AvailableStockEntries);
|
||||||
|
Assert.Equal(1, b.Copies);
|
||||||
|
});
|
||||||
|
// Within a multi-sheet Decode forced to start on "two", the next decision can
|
||||||
|
// choose "one". A trial-subset count of one would incorrectly batch that first sheet.
|
||||||
|
Assert.Contains(Enumerable.Range(0, batches.Count - 1), i =>
|
||||||
|
batches[i] is { ForcedFirst: true, StockId: "two" }
|
||||||
|
&& batches[i + 1] is { ForcedFirst: false, StockId: "one", SheetsBefore: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
|
||||||
|
}
|
||||||
@@ -16,8 +16,9 @@ public static class JobBuilder
|
|||||||
/// <param name="width">Y extent.</param>
|
/// <param name="width">Y extent.</param>
|
||||||
/// <param name="length">X extent.</param>
|
/// <param name="length">X extent.</param>
|
||||||
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
|
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
|
||||||
Spacing edge = default, int quadrant = 1, int? quantity = null) =>
|
Spacing edge = default, int quadrant = 1, int? quantity = null,
|
||||||
new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
ClampZone[]? clampZones = null) =>
|
||||||
|
new(id, new Size(width, length), quantity, spacing, edge, quadrant, null, clampZones);
|
||||||
|
|
||||||
public static NestJobPart Rectangle(string id, double w, double h, int count,
|
public static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||||
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
|
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Rectangles lane treats clamp zones as hard obstacles: placements never enter
|
||||||
|
/// the keep-out, demand uses the material between clamps, and layouts with zero
|
||||||
|
/// clamps stay bit-identical to the pre-clamp baseline (C2). Irregular and Default
|
||||||
|
/// parity is covered separately by IrregularClampZoneTests.
|
||||||
|
/// </summary>
|
||||||
|
public class RectanglesClampZoneTests
|
||||||
|
{
|
||||||
|
private static OpenNest.Engine.NestingEngines.Rectangles.RectanglesNestingEngine Engine() => new();
|
||||||
|
|
||||||
|
// ---- C2 characterization: zero-clamp layouts must not shift by a single bit -------
|
||||||
|
|
||||||
|
private static string LayoutHash(NestJobResult result)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var sheet in result.Plates)
|
||||||
|
foreach (var p in sheet.Placements)
|
||||||
|
sb.Append(sheet.PlateIndex).Append('|').Append(sheet.StockId).Append('|')
|
||||||
|
.Append(p.PartId).Append('|')
|
||||||
|
.Append(p.X.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
||||||
|
.Append(p.Y.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
||||||
|
.Append(p.Rotation.ToString("R", CultureInfo.InvariantCulture))
|
||||||
|
.Append('\n');
|
||||||
|
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124.
|
||||||
|
[InlineData("1FA61E933D2D0D6F1E3336FCEBBB21CEB7E7D1533FDB253F9D48E947217575CE")]
|
||||||
|
public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) =>
|
||||||
|
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
||||||
|
new[] { Part("rect", Rectangle(10, 5), 12) },
|
||||||
|
new[] { Stock("sheet", 48, 96, spacing: 0.25) }))));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("9B343B6C5CF2CE8753F2E04ACB0A9C4D5921F4ADC15AA2586AF466769C0F019B")]
|
||||||
|
public void ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline(string hash) =>
|
||||||
|
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Part("disc", Disc(3), 10),
|
||||||
|
Part("ell", LShape(12, 8, 4), 10),
|
||||||
|
Part("tri", Triangle(9, 6), 10),
|
||||||
|
},
|
||||||
|
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5)) }))));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("DBF93C1684D85B5EE7EB24C96A84904D2BA0F9A8183631EFC61638CB8B9B935A")]
|
||||||
|
public void ZeroClampMultiSheetLayoutIsBitIdenticalToBaseline(string hash) =>
|
||||||
|
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
||||||
|
new[] { Part("square", Rectangle(10, 10), 30) },
|
||||||
|
new[] { Stock("sheet", 25, 45, spacing: 0.25) }))));
|
||||||
|
|
||||||
|
// ---- clamp avoidance ---------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartsNeverTouchAClampZone()
|
||||||
|
{
|
||||||
|
var clamp = new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625));
|
||||||
|
var job = Job(
|
||||||
|
new[] { Rectangle("blk", 6, 6, 6) },
|
||||||
|
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [clamp]) });
|
||||||
|
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||||
|
{
|
||||||
|
// Fixed 0-degree rectangles: analytic independent oracle against the raw zone.
|
||||||
|
Assert.False(
|
||||||
|
placement.X < 20.75 && placement.X + 6 > 19.25 &&
|
||||||
|
placement.Y < 0.625 && placement.Y + 6 > 0,
|
||||||
|
$"placement at ({placement.X},{placement.Y}) intrudes into the clamp zone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DemandLandsBetweenClampsWhenItFits()
|
||||||
|
{
|
||||||
|
// One row of three 2x2 parts; only [0,3], [5,7] and [9,12] are open along X.
|
||||||
|
var clamps = new[]
|
||||||
|
{
|
||||||
|
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
||||||
|
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
||||||
|
};
|
||||||
|
var job = Job(
|
||||||
|
new[] { Rectangle("row", 2, 2, 3) },
|
||||||
|
new[] { Stock("strip", 2, 12, clampZones: clamps) });
|
||||||
|
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
|
||||||
|
Assert.Equal(3, placements.Count);
|
||||||
|
Assert.Contains(placements, p => System.Math.Abs(p.X - 5) < 1e-9); // the middle slot
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClampWallAcrossTheFrontEdgeBlocksTheOnlyViableRow()
|
||||||
|
{
|
||||||
|
// Without clamps the single viable row sits at y = bottom spacing (1.0). The full-width
|
||||||
|
// front clamp with its edge clearance raises that floor above the work area.
|
||||||
|
var wall = new ClampZone("rail-wall", new Box(0, 0, 10, 1));
|
||||||
|
var job = Job(
|
||||||
|
new[] { Rectangle("tall", 4, 4, 2) },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Stock("sheet", 6, 10, spacing: 0,
|
||||||
|
edge: new Spacing(left: 0.25, bottom: 1, right: 0.25, top: 0.25),
|
||||||
|
clampZones: [wall]),
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||||
|
Assert.Empty(result.Plates.SelectMany(p => p.Placements));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ZonesClearOfTheWorkAreaNeedNotShiftAnything()
|
||||||
|
{
|
||||||
|
// Top-edge zone the demand never reaches: layout stays identical to no clamps.
|
||||||
|
var far = new ClampZone("back", new Box(0, 47.5, 1.5, 0.5));
|
||||||
|
var jobClamped = Job(
|
||||||
|
new[] { Part("rect", Rectangle(10, 5), 12) },
|
||||||
|
new[] { Stock("sheet", 48, 96, spacing: 0.25, clampZones: [far]) });
|
||||||
|
var jobPlain = Job(
|
||||||
|
new[] { Part("rect", Rectangle(10, 5), 12) },
|
||||||
|
new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||||
|
|
||||||
|
var clamped = Engine().Solve(jobClamped);
|
||||||
|
var plain = Engine().Solve(jobPlain);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(jobClamped, clamped);
|
||||||
|
Assert.Equal(LayoutHash(plain), LayoutHash(clamped));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) --------
|
||||||
|
//
|
||||||
|
// The packer reserves a trailing part-spacing tail behind every item, so the block it
|
||||||
|
// carves per axis is [zone - clearance + s, zone + clearance]. When s exceeds the
|
||||||
|
// inflated span, that interval inverts. D11: block a zero-extent line at the clamp
|
||||||
|
// edge facing the sheet interior — at (zone max + clearance) when the zone center lies
|
||||||
|
// in the lower half of the bin on that axis, else at (zone min - clearance + s). The
|
||||||
|
// old collapse to the zone centerline let a part start inside the clamp.
|
||||||
|
//
|
||||||
|
// The oracle below is independent of the packer and of NestLayoutCheck: material
|
||||||
|
// [x, x+w] x [y, y+h] must not share interior with the clamp footprint predicted
|
||||||
|
// directly from the rail rule (center +/- width/2 from the origin edge, depth into the
|
||||||
|
// sheet, translated exactly like the work area), inflated by the bottom-edge spacing.
|
||||||
|
// Boundary contact is clear (C16).
|
||||||
|
|
||||||
|
private static Box InflatedRail(int quadrant, double length, double width,
|
||||||
|
double center, double clampWidth, double depth, double clearance)
|
||||||
|
{
|
||||||
|
var left = quadrant is 1 or 4 ? 0.0 : -length;
|
||||||
|
var bottom = quadrant is 1 or 2 ? 0.0 : -width;
|
||||||
|
var near = center - clampWidth / 2 + left;
|
||||||
|
var far = center + clampWidth / 2 + left;
|
||||||
|
return new Box(
|
||||||
|
System.Math.Min(near, far) - clearance,
|
||||||
|
bottom - clearance,
|
||||||
|
clampWidth + 2 * clearance,
|
||||||
|
depth + 2 * clearance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertMaterialClearOfRail(NestJobResult result, Box inflated,
|
||||||
|
double partW, double partH)
|
||||||
|
{
|
||||||
|
Assert.NotEmpty(result.Plates.SelectMany(p => p.Placements));
|
||||||
|
foreach (var p in result.Plates.SelectMany(pl => pl.Placements))
|
||||||
|
{
|
||||||
|
var overlaps = p.X < inflated.Right - 1e-9 && p.X + partW > inflated.Left + 1e-9
|
||||||
|
&& p.Y < inflated.Top - 1e-9 && p.Y + partH > inflated.Bottom + 1e-9;
|
||||||
|
Assert.False(overlaps,
|
||||||
|
$"material [{p.X},{p.X + partW}] x [{p.Y},{p.Y + partH}] intrudes into the " +
|
||||||
|
$"inflated clamp footprint [{inflated.Left},{inflated.Right}] x " +
|
||||||
|
$"[{inflated.Bottom},{inflated.Top}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// PM d86d22c9 probe fixture: Y degenerate (s=4 > 0.625 + 2x0.25), full-width front
|
||||||
|
// rail. Red on ab50900: Rectangles completed at y=0.3125, material on the clamp.
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(2)]
|
||||||
|
[InlineData(3)]
|
||||||
|
[InlineData(4)]
|
||||||
|
public void DegenerateYSpanKeepsExactClearanceInEveryQuadrant(int quadrant)
|
||||||
|
{
|
||||||
|
var stock = Stock("sheet", 4, 5, spacing: 4, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
quadrant: quadrant, clampZones: [new ClampZone("rail", new Box(0, 0, 5, 0.625))]);
|
||||||
|
var job = Job(new[] { Rectangle("square", 2, 2, 1) }, new[] { stock });
|
||||||
|
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
AssertMaterialClearOfRail(result,
|
||||||
|
InflatedRail(quadrant, 5, 4, 2.5, 5, 0.625, 0.25), 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DegenerateXSpanMidRailKeepsExactClearance()
|
||||||
|
{
|
||||||
|
// X degenerate (s=3 > 1.5 + 2x0.25); the 10-wide part is forced across the clamp's
|
||||||
|
// X span because neither side can hold it with the tail. Red on ab50900: completed
|
||||||
|
// at x=11.5 (the centerline), material inside the clamp.
|
||||||
|
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]);
|
||||||
|
var job = Job(new[] { Rectangle("wide", 10, 2, 1) }, new[] { stock });
|
||||||
|
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 11.5, 1.5, 0.625, 0.25), 10, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DegenerateXSpanFarRailClearsTheInteriorFacingEdge()
|
||||||
|
{
|
||||||
|
// The clamp center sits in the upper half of the bin, so D11 blocks the
|
||||||
|
// origin-facing edge (zone min - clearance + s = 20). Red on ab50900: the
|
||||||
|
// centerline split let the 2x2 land at x=21, material inside the clamp;
|
||||||
|
// after D11 it lands at x=20 — boundary contact, which is clear.
|
||||||
|
var stock = Stock("sheet", 4, 23, spacing: 3, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new ClampZone("far-rail", new Box(20.25, 0, 1.5, 0.625))]);
|
||||||
|
var job = Job(new[] { Rectangle("small", 2, 2, 1) }, new[] { stock });
|
||||||
|
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
AssertMaterialClearOfRail(result, InflatedRail(1, 23, 4, 21, 1.5, 0.625, 0.25), 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- C15 keep rows: non-inverted spans and between-clamp packing stay bit-exact ----
|
||||||
|
|
||||||
|
private static NestJobResult KeepFixtureLargeSpacingNonInverted() => Engine().Solve(Job(
|
||||||
|
new[] { Rectangle("blk", 6, 6, 6) },
|
||||||
|
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625))]) }));
|
||||||
|
|
||||||
|
private static NestJobResult KeepFixtureBetweenClamps() => Engine().Solve(Job(
|
||||||
|
new[] { Rectangle("row", 2, 2, 3) },
|
||||||
|
new[] { Stock("strip", 2, 12, clampZones: new[]
|
||||||
|
{
|
||||||
|
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
||||||
|
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
||||||
|
}) }));
|
||||||
|
|
||||||
|
private static NestJobResult KeepFixtureBetweenClampsWithSpacing() => Engine().Solve(Job(
|
||||||
|
new[] { Rectangle("row", 2, 2, 3) },
|
||||||
|
new[] { Stock("strip", 2, 12, spacing: 0.5, clampZones: new[]
|
||||||
|
{
|
||||||
|
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
||||||
|
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
||||||
|
}) }));
|
||||||
|
|
||||||
|
private static NestJobResult KeepFixtureMidRailSmallSpacing() => Engine().Solve(Job(
|
||||||
|
new[] { Rectangle("wide", 10, 2, 2) },
|
||||||
|
new[] { Stock("sheet", 4, 23, spacing: 0.5, edge: new Spacing(0, 0.25, 0, 0),
|
||||||
|
clampZones: [new ClampZone("mid-rail", new Box(10.75, 0, 1.5, 0.625))]) }));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
// Captured from the unchanged packer at ab50900 (see progress task-6.2 evidence):
|
||||||
|
// non-inverted clamp spans must keep today's blocks and layouts bit-for-bit.
|
||||||
|
[InlineData("KEEP_LARGE_SPACING")]
|
||||||
|
[InlineData("KEEP_BETWEEN_CLAMPS")]
|
||||||
|
[InlineData("KEEP_BETWEEN_SPACED")]
|
||||||
|
[InlineData("KEEP_MID_RAIL_SMALL_SPACING")]
|
||||||
|
public void NonInvertedClampLayoutsMatchAb50900Digests(string fixture)
|
||||||
|
{
|
||||||
|
var hash = fixture switch
|
||||||
|
{
|
||||||
|
"KEEP_LARGE_SPACING" => "F7E4691C085FFB2164CEFFFB9B082B5F271E56E8B8B3D2AE23ECF5646FD40450",
|
||||||
|
"KEEP_BETWEEN_CLAMPS" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
|
||||||
|
"KEEP_BETWEEN_SPACED" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61",
|
||||||
|
"KEEP_MID_RAIL_SMALL_SPACING" => "97868486E834CA0B1DB110FF717635E38537DB96DF5CDFBB2AFE37E4B1366B71",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
|
||||||
|
};
|
||||||
|
var actual = fixture switch
|
||||||
|
{
|
||||||
|
"KEEP_LARGE_SPACING" => LayoutHash(KeepFixtureLargeSpacingNonInverted()),
|
||||||
|
"KEEP_BETWEEN_CLAMPS" => LayoutHash(KeepFixtureBetweenClamps()),
|
||||||
|
"KEEP_BETWEEN_SPACED" => LayoutHash(KeepFixtureBetweenClampsWithSpacing()),
|
||||||
|
"KEEP_MID_RAIL_SMALL_SPACING" => LayoutHash(KeepFixtureMidRailSmallSpacing()),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(fixture)),
|
||||||
|
};
|
||||||
|
Assert.Equal(hash, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||||
|
using OpenNest.Engine.RectanglePacking;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
|
||||||
|
public class RectanglesRepeatTests : RepeatEngineSafetyTests
|
||||||
|
{
|
||||||
|
private protected override INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null) =>
|
||||||
|
new RectanglesNestingEngine { SheetPacked = _ => packed?.Invoke(), BatchSelected = batches };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompletedFullLayoutRemovesActualPackCallsInBothModes()
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
|
||||||
|
var calls = new List<PickMode>();
|
||||||
|
var result = new RectanglesNestingEngine { SheetPacked = calls.Add }.Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(10, result.Plates.Count);
|
||||||
|
Assert.Equal(6, calls.Count(m => m == PickMode.Global));
|
||||||
|
Assert.Equal(6, calls.Count(m => m == PickMode.Ordered));
|
||||||
|
var first = result.Plates[0];
|
||||||
|
foreach (var plate in result.Plates.Skip(1))
|
||||||
|
{
|
||||||
|
Assert.NotSame(first.Placements, plate.Placements);
|
||||||
|
Assert.Equal(first.Placements.Select(Pose), plate.Placements.Select(Pose));
|
||||||
|
Assert.NotSame(first.Placements[0], plate.Placements[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CandidateOutsideTheStricterCertificationBoundsRetainsOrdinaryAllocation()
|
||||||
|
{
|
||||||
|
// MaxRects/layout-check slack accepts this overhang; the candidate validator does not.
|
||||||
|
// This real Solve must pack five times rather than certifying a repeat from that layout.
|
||||||
|
var job = Job([Rectangle("a", 10.000006, 9, 5)], [Stock("s", 10, 10)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var calls = 0;
|
||||||
|
var result = new RectanglesNestingEngine { SheetPacked = _ => calls++, BatchSelected = batches.Add }.Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(5, result.Plates.Count);
|
||||||
|
Assert.Equal(60, calls);
|
||||||
|
Assert.Equal(5, batches.Count);
|
||||||
|
Assert.All(batches, b => Assert.Equal(1, b.Copies));
|
||||||
|
var candidate = new PlateCandidate(result.Plates[0].Placements);
|
||||||
|
Assert.Throws<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(candidate, job.Plates[0],
|
||||||
|
new Dictionary<string, int> { ["a"] = 5 }, job.Parts.ToDictionary(p => p.Id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string, double, double, double) Pose(NestJobPlacement p) => (p.PartId, p.X, p.Y, p.Rotation);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.Jobs;
|
||||||
|
using OpenNest.Engine.Jobs.Adapters;
|
||||||
|
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||||
|
|
||||||
|
/// <summary>Physical accounting/continuation contract, run independently for each repeating engine.</summary>
|
||||||
|
public abstract class RepeatEngineSafetyTests
|
||||||
|
{
|
||||||
|
private protected abstract INestingEngine Engine(Action? packed = null, Action<RepeatBatch>? batches = null);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(5)]
|
||||||
|
[InlineData(1)]
|
||||||
|
public void MixedScarcityContinuesToDifferentResidualLayoutInOneSolve(int urgentCount)
|
||||||
|
{
|
||||||
|
var job = Mixed(urgentCount, 8);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(urgentCount, batches[0].Copies);
|
||||||
|
Assert.True(batches.Count > 1);
|
||||||
|
Assert.Equal(urgentCount, result.Fulfillment.Single(f => f.PartId == "a").Placed);
|
||||||
|
Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "b").Placed);
|
||||||
|
Assert.All(result.Plates.Take(urgentCount), p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId)));
|
||||||
|
Assert.All(result.Plates.Skip(urgentCount), p => Assert.All(p.Placements, v => Assert.Equal("b", v.PartId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SmallDemandThatFitsTheRemnantIsPlacedBeforeRepeating()
|
||||||
|
{
|
||||||
|
var job = Mixed(10, 10);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(10, Assert.Single(batches).Copies);
|
||||||
|
Assert.All(result.Plates, p => Assert.Equal(new[] { "a", "b" }, p.Placements.Select(v => v.PartId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(2, null, NestJobStopReason.StockExhausted)]
|
||||||
|
[InlineData(null, 2, NestJobStopReason.PlateLimitReached)]
|
||||||
|
[InlineData(3, 2, NestJobStopReason.PlateLimitReached)]
|
||||||
|
public void FiniteStockAndMaxPlatesCountPhysicalCopies(int? stockCount, int? cap, NestJobStopReason reason)
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2, quantity: stockCount)], new NestJobOptions(maxPlates: cap));
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(reason, result.StopReason);
|
||||||
|
Assert.Equal(2, result.Plates.Count);
|
||||||
|
Assert.Equal(8, Assert.Single(result.Fulfillment).Placed);
|
||||||
|
Assert.Equal(2, Assert.Single(batches).Copies);
|
||||||
|
Assert.Equal(stockCount - 2, Assert.Single(result.StockUsage).Remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompetingSameSizeStockEntriesCommitSinglyUntilOneRemains()
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 16)], [Stock("one", 20, 20, spacing: 0.2, quantity: 2), Stock("two", 20, 20, spacing: 0.2, quantity: 2)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var calls = 0;
|
||||||
|
var result = Engine(() => calls++, batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(new[] { 1, 1, 2 }, batches.Select(b => b.Copies));
|
||||||
|
Assert.Equal(new[] { 2, 2, 1 }, batches.Select(b => b.AvailableStockEntries));
|
||||||
|
Assert.All(result.StockUsage, s => Assert.Equal(2, s.Used));
|
||||||
|
Assert.True(calls >= 5); // Two competing trials twice, then the genuinely sole remaining stock.
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SameNamedDrawingsRetainExactDistinctRequirementIds()
|
||||||
|
{
|
||||||
|
var a = new Drawing("same", Shapes.Rectangle(6, 9));
|
||||||
|
var b = new Drawing("same", Shapes.Rectangle(3, 9)) { Priority = 1 };
|
||||||
|
var job = Job([DrawingJobMapper.FromDrawing("a", a, 5), DrawingJobMapper.FromDrawing("A", b, 8)], [Stock("s", 10, 10, spacing: 0.2)]);
|
||||||
|
var result = Engine().Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||||
|
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "a").Placed);
|
||||||
|
Assert.Equal(8, result.Fulfillment.Single(f => f.PartId == "A").Placed);
|
||||||
|
Assert.Equal(0, a.Quantity.Nested);
|
||||||
|
Assert.Equal(0, b.Quantity.Nested);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SalvageOffcutDoesNotPreventRepeatingCompletedJobSearch()
|
||||||
|
{
|
||||||
|
var stock = new NestPlateStock("s", new OpenNest.Geometry.Size(10, 10), null, 0.2, default, 1, cost: 20);
|
||||||
|
var job = Job([Rectangle("a", 6, 9, 10)], [stock], new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1));
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(10, Assert.Single(batches).Copies);
|
||||||
|
Assert.Equal(10, result.Plates.Count);
|
||||||
|
Assert.Equal(162, NestJobCost.Evaluate(job, result), precision: 8);
|
||||||
|
Assert.True(NestJobCost.NetSheetCost(job, result.Plates[0]) < stock.Cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OversizedRequirementRemainsUnplacedWhileUsefulDemandRepeats()
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("huge", 100, 100, 4), Rectangle("small", 6, 9, 5)], [Stock("s", 10, 10, spacing: 0.2)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||||
|
Assert.Equal(4, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||||
|
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "small").Placed);
|
||||||
|
Assert.Contains(batches, b => b.Copies == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false)]
|
||||||
|
[InlineData(true)]
|
||||||
|
public void NoFitOrUnavailableStockProducesNoEmptyRepeat(bool unavailable)
|
||||||
|
{
|
||||||
|
var job = Job([Rectangle("a", 100, 100, 2)], [Stock("s", 10, 10, quantity: unavailable ? 0 : null)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Empty(batches);
|
||||||
|
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationAtRepeatDecisionCannotReturnAProposal()
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
|
||||||
|
var observed = 0;
|
||||||
|
var engine = Engine(batches: b => { Assert.Equal(10, b.Copies); observed++; cancellation.Cancel(); });
|
||||||
|
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cancellation.Token));
|
||||||
|
Assert.Equal(1, observed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1)]
|
||||||
|
[InlineData(10)]
|
||||||
|
public void CancellationDuringPhysicalExpansionIncludingLastCopyIsObserved(int cancelAfter)
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var committed = new List<NestJobProgress>();
|
||||||
|
var progress = new InlineProgress(p =>
|
||||||
|
{
|
||||||
|
if (p.Stage != NestJobStage.PlateCommitted) return;
|
||||||
|
committed.Add(p);
|
||||||
|
if (p.CommittedPlates == cancelAfter) cancellation.Cancel();
|
||||||
|
});
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
|
||||||
|
Assert.Throws<OperationCanceledException>(() => Engine().Solve(job, progress, cancellation.Token));
|
||||||
|
Assert.Equal(cancelAfter, committed.Count);
|
||||||
|
Assert.Equal(cancelAfter * 4, committed.Last().CommittedParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SuccessfulProgressRemainsPhysicalAndOrdered()
|
||||||
|
{
|
||||||
|
var committed = new List<NestJobProgress>();
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
|
||||||
|
var result = Engine().Solve(job, new InlineProgress(p => { if (p.Stage == NestJobStage.PlateCommitted) committed.Add(p); }));
|
||||||
|
LayoutAssert.Valid(job, result);
|
||||||
|
Assert.Equal(Enumerable.Range(1, 10), committed.Select(p => p.CommittedPlates));
|
||||||
|
Assert.Equal(Enumerable.Range(1, 10).Select(i => i * 4), committed.Select(p => p.CommittedParts));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnreadableGeometryIsNotTurnedIntoAnEmptyRepeat()
|
||||||
|
{
|
||||||
|
var job = Job([Part("unsupported", new OpenNest.CNC.Program(), 12)], [Stock("s", 10, 10)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var result = Engine(batches: batches.Add).Solve(job);
|
||||||
|
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||||
|
Assert.Equal(12, Assert.Single(result.Fulfillment).Unplaced);
|
||||||
|
Assert.Empty(result.Plates);
|
||||||
|
Assert.Empty(batches);
|
||||||
|
Assert.Equal(0, Assert.Single(result.StockUsage).Used);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CancellationInsideTheRealSheetSearchNeverCertifiesARepeat()
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]);
|
||||||
|
var batches = new List<RepeatBatch>();
|
||||||
|
var packed = 0;
|
||||||
|
var progress = new InlineProgress(p =>
|
||||||
|
{
|
||||||
|
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
|
||||||
|
});
|
||||||
|
Assert.ThrowsAny<OperationCanceledException>(() => Engine(() => packed++, batches.Add).Solve(job, progress, cancellation.Token));
|
||||||
|
Assert.Equal(0, packed);
|
||||||
|
Assert.Empty(batches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NestJob Mixed(int a, int b) => Job(
|
||||||
|
[Part("a", Shapes.Rectangle(6, 9), a, RotationPolicy.Fixed(0)), Part("b", Shapes.Rectangle(3, 9), b, RotationPolicy.Fixed(0), priority: 1)],
|
||||||
|
[Stock("s", 10, 10, spacing: 0.2)]);
|
||||||
|
|
||||||
|
private sealed class InlineProgress(Action<NestJobProgress> report) : IProgress<NestJobProgress>
|
||||||
|
{
|
||||||
|
public void Report(NestJobProgress value) => report(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,13 +22,13 @@ internal static class BestEffortCuttingPlan
|
|||||||
return new(CuttingPlanStatus.InvalidInput);
|
return new(CuttingPlanStatus.InvalidInput);
|
||||||
var findings = snapshot.Findings.ToList();
|
var findings = snapshot.Findings.ToList();
|
||||||
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete,
|
findings.Add(new(null, null, null, null, PostVerificationKind.Incomplete,
|
||||||
"Best-effort plan: lead clearance, rapid travel and material containment are not certified. "
|
"Best-effort plan: review lead clearance, rapid travel, material containment and source "
|
||||||
+ "Review the complete plan before cutting. Source contours have not been repaired."));
|
+ "contours as part of a complete review before cutting."));
|
||||||
var order = new List<FixedProgramPlacement>();
|
var order = new List<FixedProgramPlacement>();
|
||||||
var done = new HashSet<int>();
|
var done = new HashSet<int>();
|
||||||
var position = snapshot.StartPoint;
|
var position = snapshot.StartPoint;
|
||||||
var distance = 0.0;
|
var distance = 0.0;
|
||||||
var checker = new ReleasedContourState();
|
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (order.Count < snapshot.Placements.Count)
|
while (order.Count < snapshot.Placements.Count)
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public sealed record ContourFeasibilityVerdict(ContourFeasibilityStatus Status,
|
|||||||
/// candidate contour through the S06 diagnostic seam, reads it at the placement position,
|
/// candidate contour through the S06 diagnostic seam, reads it at the placement position,
|
||||||
/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a
|
/// and certifies the emitted lead-in and lead-out against ALL placed material. It is not a
|
||||||
/// new collision implementation and not a plan approval: a Clear verdict certifies this
|
/// new collision implementation and not a plan approval: a Clear verdict certifies this
|
||||||
/// contour's emitted leads only — the missing-lead (NoLeadIn) check still runs later on the
|
/// contour's emitted leads only — NoLeadIn is supported, and the complete plan must
|
||||||
/// complete plan, and rapids/pierce clearance belong to their existing checkers. A
|
/// still pass rapid travel and contour-accounting checks. A
|
||||||
/// candidate rejected here is not proven infeasible by anything else: this adapter only
|
/// candidate rejected here is not proven infeasible by anything else: this adapter only
|
||||||
/// reports what the validator reported.
|
/// reports what the validator reported.
|
||||||
/// One instance is one captured planning attempt: verdicts cache per exact choice and node
|
/// One instance is one captured planning attempt: verdicts cache per exact choice and node
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ internal static class ContourEntrySelection
|
|||||||
if (sawIncomplete)
|
if (sawIncomplete)
|
||||||
{
|
{
|
||||||
shortfall = ContourSelectionShortfall.Incomplete;
|
shortfall = ContourSelectionShortfall.Incomplete;
|
||||||
reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible."
|
reason = "At least one lead check could not complete; treat the result as undetermined rather than a geometric verdict."
|
||||||
+ (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
|
+ (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
|
||||||
}
|
}
|
||||||
else if (selected.Count >= maxEntries)
|
else if (selected.Count >= maxEntries)
|
||||||
|
|||||||
@@ -83,6 +83,16 @@ internal sealed class CuttingDependencyGraph
|
|||||||
{
|
{
|
||||||
if (!nodes[cut].IsCutOff)
|
if (!nodes[cut].IsCutOff)
|
||||||
continue;
|
continue;
|
||||||
|
if (nodes[cut].SeparatesSheet)
|
||||||
|
{
|
||||||
|
// Cutting a line that splits the sheet comes after every part and every other
|
||||||
|
// cutoff, so the head never travels back across the separated edge. Such a line
|
||||||
|
// is continuous, so it passes through no part and needs no crossing edge.
|
||||||
|
for (var other = 0; other < nodes.Count; other++)
|
||||||
|
if (!nodes[other].SeparatesSheet)
|
||||||
|
edges[cut].Add(other);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var definition = nodes[cut].CutOff;
|
var definition = nodes[cut].CutOff;
|
||||||
for (var part = 0; part < nodes.Count; part++)
|
for (var part = 0; part < nodes.Count; part++)
|
||||||
{
|
{
|
||||||
@@ -190,9 +200,10 @@ internal sealed class DependencyNode
|
|||||||
private LeadMaterialSnapshot material;
|
private LeadMaterialSnapshot material;
|
||||||
|
|
||||||
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
|
internal DependencyNode(bool isCutOff, CutOffDefinition cutOff, Box placedBounds, Box cleanBounds,
|
||||||
Func<LeadMaterialSnapshot> materialFactory)
|
Func<LeadMaterialSnapshot> materialFactory, bool separatesSheet = false)
|
||||||
{
|
{
|
||||||
IsCutOff = isCutOff;
|
IsCutOff = isCutOff;
|
||||||
|
SeparatesSheet = isCutOff && separatesSheet;
|
||||||
CutOff = cutOff;
|
CutOff = cutOff;
|
||||||
PlacedBounds = Copy(placedBounds);
|
PlacedBounds = Copy(placedBounds);
|
||||||
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
|
CleanBounds = cleanBounds == null ? null : Copy(cleanBounds);
|
||||||
@@ -201,6 +212,8 @@ internal sealed class DependencyNode
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal bool IsCutOff { get; }
|
internal bool IsCutOff { get; }
|
||||||
|
/// <summary>A cutoff whose placed program is one continuous cut across the whole sheet.</summary>
|
||||||
|
internal bool SeparatesSheet { get; }
|
||||||
internal CutOffDefinition CutOff { get; }
|
internal CutOffDefinition CutOff { get; }
|
||||||
internal Box PlacedBounds { get; }
|
internal Box PlacedBounds { get; }
|
||||||
internal Box CleanBounds { get; }
|
internal Box CleanBounds { get; }
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proposes a whole-part order for plates with parts nested inside other parts' cutouts. A host
|
||||||
|
/// is cut after everything inside it, so the tool reaches it from whichever inner part was cut
|
||||||
|
/// last; from the middle of a crowded cutout every straight approach crosses a finished
|
||||||
|
/// neighbour. Each host's inner parts are therefore toured as one run that ends beside the
|
||||||
|
/// host's own contours, immediately before the host. Plates without nested parts get exactly
|
||||||
|
/// <see cref="CuttingPartOrder"/>'s order. Like that order, this is only a proposal.
|
||||||
|
/// </summary>
|
||||||
|
internal static class CuttingNestedOrder
|
||||||
|
{
|
||||||
|
/// <param name="Centres">One representative point per part ordinal.</param>
|
||||||
|
/// <param name="Prerequisites">Ordinals that must come before each part, learned rules included.</param>
|
||||||
|
/// <param name="Inside">Per part ordinal, the parts nested inside its cutouts at any depth.</param>
|
||||||
|
/// <param name="HostEntry">The point on a host's contours nearest a tool position.</param>
|
||||||
|
internal sealed record Context(IReadOnlyList<Vector> Centres,
|
||||||
|
IReadOnlyList<IReadOnlyCollection<int>> Prerequisites, IReadOnlyList<IReadOnlyCollection<int>> Inside,
|
||||||
|
Func<int, Vector, Vector> HostEntry, CancellationToken Token);
|
||||||
|
|
||||||
|
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
|
||||||
|
/// <param name="start">The modeled tool position before the first of these parts.</param>
|
||||||
|
/// <param name="endpoint">Optional position the run should end beside.</param>
|
||||||
|
internal static int[] Plan(IReadOnlyList<int> parts, Vector start, Context context, Vector? endpoint = null)
|
||||||
|
{
|
||||||
|
context.Token.ThrowIfCancellationRequested();
|
||||||
|
var set = parts.ToHashSet();
|
||||||
|
var rootOf = new Dictionary<int, int>();
|
||||||
|
foreach (var host in parts)
|
||||||
|
foreach (var inner in context.Inside[host])
|
||||||
|
if (set.Contains(inner) && !context.Inside[inner].Contains(host))
|
||||||
|
rootOf[inner] = host;
|
||||||
|
if (rootOf.Count == 0)
|
||||||
|
return Flat();
|
||||||
|
// Follow nested hosts up to the outermost one present.
|
||||||
|
foreach (var inner in rootOf.Keys.ToArray())
|
||||||
|
while (rootOf.TryGetValue(rootOf[inner], out var outer))
|
||||||
|
rootOf[inner] = outer;
|
||||||
|
var roots = parts.Where(p => !rootOf.ContainsKey(p)).ToArray();
|
||||||
|
// A rule between parts of two groups orders the groups.
|
||||||
|
var groupRules = context.Centres.Select(_ => new HashSet<int>()).ToArray();
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
var group = rootOf.GetValueOrDefault(part, part);
|
||||||
|
foreach (var prerequisite in context.Prerequisites[part])
|
||||||
|
if (set.Contains(prerequisite) && rootOf.GetValueOrDefault(prerequisite, prerequisite) != group)
|
||||||
|
groupRules[group].Add(rootOf.GetValueOrDefault(prerequisite, prerequisite));
|
||||||
|
}
|
||||||
|
// A finished host is a large obstacle and can only be left across its own outside
|
||||||
|
// edge, so the parts beside it go first. Rules that say otherwise win.
|
||||||
|
var hostsLast = groupRules.Select(rules => new HashSet<int>(rules)).ToArray();
|
||||||
|
var hosts = rootOf.Values.ToHashSet();
|
||||||
|
foreach (var host in hosts)
|
||||||
|
foreach (var root in roots)
|
||||||
|
if (!hosts.Contains(root))
|
||||||
|
hostsLast[host].Add(root);
|
||||||
|
int[] rootOrder;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rootOrder = CuttingPartOrder.Plan(roots, context.Centres, start, hostsLast, context.Token, endpoint);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
rootOrder = CuttingPartOrder.Plan(roots, context.Centres, start, groupRules, context.Token, endpoint);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return Flat(); // Rules that cross groups both ways cannot keep the groups whole.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var order = new List<int>(parts.Count);
|
||||||
|
var position = start;
|
||||||
|
foreach (var root in rootOrder)
|
||||||
|
{
|
||||||
|
var inner = parts.Where(p => rootOf.TryGetValue(p, out var host) && host == root).ToArray();
|
||||||
|
if (inner.Length != 0)
|
||||||
|
order.AddRange(Plan(inner, position, context, context.HostEntry(root, position)));
|
||||||
|
order.Add(root);
|
||||||
|
position = context.Centres[root];
|
||||||
|
}
|
||||||
|
var placed = new HashSet<int>();
|
||||||
|
foreach (var part in order)
|
||||||
|
{
|
||||||
|
if (context.Prerequisites[part].Any(p => set.Contains(p) && !placed.Contains(p)))
|
||||||
|
return Flat();
|
||||||
|
placed.Add(part);
|
||||||
|
}
|
||||||
|
return order.ToArray();
|
||||||
|
|
||||||
|
int[] Flat() => CuttingPartOrder.Plan(parts, context.Centres, start, context.Prerequisites, context.Token, endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,9 @@ internal static class CuttingPartOrder
|
|||||||
/// <param name="centres">One representative point per part ordinal.</param>
|
/// <param name="centres">One representative point per part ordinal.</param>
|
||||||
/// <param name="start">The modeled tool position before the first of these parts.</param>
|
/// <param name="start">The modeled tool position before the first of these parts.</param>
|
||||||
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
|
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
|
||||||
|
/// <param name="endpoint">Optional fixed final position after the last of these parts.</param>
|
||||||
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
|
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
|
||||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
|
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null)
|
||||||
{
|
{
|
||||||
var local = new Dictionary<int, int>();
|
var local = new Dictionary<int, int>();
|
||||||
for (var i = 0; i < parts.Count; i++)
|
for (var i = 0; i < parts.Count; i++)
|
||||||
@@ -33,7 +34,7 @@ internal static class CuttingPartOrder
|
|||||||
var localPrerequisites = parts
|
var localPrerequisites = parts
|
||||||
.Select(p => (IReadOnlyCollection<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
|
.Select(p => (IReadOnlyCollection<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray();
|
return Plan(localCentres, start, localPrerequisites, token, endpoint).Select(i => parts[i]).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <param name="centres">One representative point per part ordinal.</param>
|
/// <param name="centres">One representative point per part ordinal.</param>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public const int ExpansionsPerPart = 400;
|
public const int ExpansionsPerPart = 400;
|
||||||
|
|
||||||
|
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
|
||||||
|
internal const int FirstPassBudget = 1000;
|
||||||
|
internal const int FirstPassEntries = 8;
|
||||||
|
|
||||||
private readonly Entry[] entries;
|
private readonly Entry[] entries;
|
||||||
private readonly CuttingParameters ownedParameters;
|
private readonly CuttingParameters ownedParameters;
|
||||||
|
|
||||||
@@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch
|
|||||||
if (entry.Reorder != null)
|
if (entry.Reorder != null)
|
||||||
{
|
{
|
||||||
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
||||||
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
reorder = PlanReorder(entry.Reorder, token);
|
||||||
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
||||||
{
|
{
|
||||||
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
|
||||||
@@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch
|
|||||||
return new(plans, ownedParameters);
|
return new(plans, ownedParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
|
||||||
|
int firstPassBudget = FirstPassBudget)
|
||||||
|
{
|
||||||
|
// A failed first pass is not a proof of impossibility: retain all original
|
||||||
|
// full-cap alternatives and the complete original budget for the retry.
|
||||||
|
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
|
||||||
|
&& snapshot.ExpansionBudget > firstPassBudget)
|
||||||
|
{
|
||||||
|
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
|
||||||
|
if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed)
|
||||||
|
|| first.Status == CuttingPlanStatus.Cancelled)
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
return CuttingPlanService.Plan(snapshot, token);
|
||||||
|
}
|
||||||
|
|
||||||
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
|
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
|
||||||
@@ -216,6 +236,10 @@ public sealed class CuttingPlanPlateResult
|
|||||||
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
|
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
|
||||||
|
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
|
||||||
|
int? OtherPartNumber, string Message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
|
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -243,6 +267,35 @@ public sealed class CuttingPlanProposal
|
|||||||
|
|
||||||
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
|
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
|
||||||
|
|
||||||
|
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
|
||||||
|
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
|
||||||
|
{
|
||||||
|
var rows = new List<CuttingPlanDiagnosticRow>();
|
||||||
|
for (var index = 0; index < Plates.Count; index++)
|
||||||
|
{
|
||||||
|
var plate = Plates[index];
|
||||||
|
if (plate.Overlap != null)
|
||||||
|
{
|
||||||
|
foreach (var pair in plate.Overlap.Pairs)
|
||||||
|
rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1,
|
||||||
|
$"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
|
||||||
|
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}."));
|
||||||
|
foreach (var issue in plate.Overlap.Issues)
|
||||||
|
rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
|
||||||
|
issue.PartBId is int other ? other + 1 : null,
|
||||||
|
$"Overlap check incomplete for part {issue.PartAId + 1}"
|
||||||
|
+ (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
|
||||||
|
+ $": {issue.Message}"));
|
||||||
|
}
|
||||||
|
foreach (var finding in plate.Result.Findings)
|
||||||
|
rows.Add(new(index, plate.PlateNumber,
|
||||||
|
finding.SourceOrdinal is int part ? part + 1 : null,
|
||||||
|
finding.OtherSourceOrdinal is int other ? other + 1 : null,
|
||||||
|
DescribeFinding(finding)));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
|
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
|
||||||
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
|
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
|
||||||
@@ -283,6 +336,9 @@ public sealed class CuttingPlanProposal
|
|||||||
PartSpacing = source.PartSpacing,
|
PartSpacing = source.PartSpacing,
|
||||||
EdgeSpacing = source.EdgeSpacing,
|
EdgeSpacing = source.EdgeSpacing,
|
||||||
};
|
};
|
||||||
|
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
|
||||||
|
foreach (var zone in source.ClampZones)
|
||||||
|
preview.ClampZones.Add(zone);
|
||||||
foreach (var proposal in planned.Result.ProposedOrder)
|
foreach (var proposal in planned.Result.ProposedOrder)
|
||||||
{
|
{
|
||||||
var part = (Part)proposal.SourcePart.Clone();
|
var part = (Part)proposal.SourcePart.Clone();
|
||||||
@@ -296,7 +352,7 @@ public sealed class CuttingPlanProposal
|
|||||||
public IReadOnlyList<string> Describe(string unit)
|
public IReadOnlyList<string> Describe(string unit)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
var ready = Plates.Count(p => p.IsReady);
|
var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
|
||||||
if (IsCancelled)
|
if (IsCancelled)
|
||||||
lines.Add("Planning was cancelled. Nothing has changed.");
|
lines.Add("Planning was cancelled. Nothing has changed.");
|
||||||
else if (CanApply)
|
else if (CanApply)
|
||||||
@@ -305,9 +361,9 @@ public sealed class CuttingPlanProposal
|
|||||||
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
|
||||||
else if (RequiresWarningAcceptance)
|
else if (RequiresWarningAcceptance)
|
||||||
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
|
lines.Add("Best-effort plan available. Review the warnings and accept the unverified plan to apply. "
|
||||||
+ "This is not approval to cut or post CNC output.");
|
+ "Posting CNC output still requires its own review and approval.");
|
||||||
else
|
else
|
||||||
lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
|
lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
|
||||||
+ "be planned. No plate changes until every plate is ready.");
|
+ "be planned. No plate changes until every plate is ready.");
|
||||||
|
|
||||||
foreach (var plate in Plates)
|
foreach (var plate in Plates)
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot
|
|||||||
public int ExpansionBudget { get; }
|
public int ExpansionBudget { get; }
|
||||||
internal CuttingPlanStatus? Failure { get; }
|
internal CuttingPlanStatus? Failure { get; }
|
||||||
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
internal IReadOnlyList<CuttingPlanFinding> Findings { get; }
|
||||||
|
|
||||||
|
// A worker-only search variant over the same owned capture. Never recapture the live plate:
|
||||||
|
// both proposals retain the identical freshness record and confirmed settings.
|
||||||
|
internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) =>
|
||||||
|
new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration,
|
||||||
|
PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters,
|
||||||
|
Dependencies, BestEffort);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FixedProgramPlacement
|
public sealed class FixedProgramPlacement
|
||||||
@@ -140,12 +147,15 @@ public sealed class FixedProgramPlacement
|
|||||||
}
|
}
|
||||||
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
|
return new(SourcePart, SourceOrdinal, Location, Rotation, LeadInsLocked, execution,
|
||||||
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
|
OwnedProgramCopy.Copy(proposed, token), Prepared, Material, choices)
|
||||||
{ SelectedProgram = selected, IsCutOff = IsCutOff };
|
{ SelectedProgram = selected, IsCutOff = IsCutOff, SeparatesSheet = SeparatesSheet };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
|
/// <summary>A cutoff: always a fixed open-cut program, never material or regenerated.</summary>
|
||||||
public bool IsCutOff { get; internal init; }
|
public bool IsCutOff { get; internal init; }
|
||||||
|
|
||||||
|
/// <summary>A cutoff that splits the whole sheet; it is cut after every other placement.</summary>
|
||||||
|
public bool SeparatesSheet { get; internal init; }
|
||||||
|
|
||||||
public Part SourcePart { get; }
|
public Part SourcePart { get; }
|
||||||
public int SourceOrdinal { get; }
|
public int SourceOrdinal { get; }
|
||||||
public Vector Location { get; }
|
public Vector Location { get; }
|
||||||
|
|||||||
@@ -78,12 +78,15 @@ public static class CuttingPlanService
|
|||||||
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
var cut = ExecutionMotionReader.ReadSupported(source.Program, source.Location, request.StartPoint, token);
|
||||||
if (!cut.HasCuttingContour)
|
if (!cut.HasCuttingContour)
|
||||||
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
|
throw new ArgumentException("Placed cutoff program has no nonzero cutting motions.");
|
||||||
nodes.Add(new(true, definitions.TryGetValue(source.BaseDrawing, out var definition)
|
var defined = definitions.TryGetValue(source.BaseDrawing, out var definition);
|
||||||
|
var separates = defined && CutOff.SeparatesSheet(source, definition,
|
||||||
|
request.Plate.BoundingBox(includeParts: false));
|
||||||
|
nodes.Add(new(true, defined
|
||||||
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
|
? new(definition.Axis, definition.Position, definition.StartLimit, definition.EndLimit) : null,
|
||||||
source.BoundingBox, null, null));
|
source.BoundingBox, null, null, separates));
|
||||||
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
placements.Add(new(source, index, source.Location, source.Rotation, source.LeadInsLocked,
|
||||||
cut, OwnedProgramCopy.Copy(source.Program, token))
|
cut, OwnedProgramCopy.Copy(source.Program, token))
|
||||||
{ IsCutOff = true });
|
{ IsCutOff = true, SeparatesSheet = separates });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
|
var clean = ExecutionMotionReader.ReadSupported(source.BaseDrawing.Program, Vector.Zero, null, token);
|
||||||
@@ -101,6 +104,8 @@ public static class CuttingPlanService
|
|||||||
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
|
// Exact placed/proposed payloads still use lossless OwnedProgramCopy.
|
||||||
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
|
var ownedClean = (Program)source.BaseDrawing.Program.Clone();
|
||||||
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
|
ownedClean.Rotate(source.Rotation - source.BaseDrawing.Program.Rotation);
|
||||||
|
// A baked-in seam overcut encloses no area; left in, the ring touches itself.
|
||||||
|
SeamOvercut.Trim(ownedClean);
|
||||||
// Material extent only: remote rapids and scribe marks must not widen it, or a
|
// Material extent only: remote rapids and scribe marks must not widen it, or a
|
||||||
// genuine insert could fail the containment prefilter.
|
// genuine insert could fail the containment prefilter.
|
||||||
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
|
var cleanBounds = ExecutionMotionReader.ReadSupported(ownedClean, source.Location, null, token).Motions
|
||||||
@@ -241,7 +246,7 @@ public static class CuttingPlanService
|
|||||||
if (placement.Prepared != null)
|
if (placement.Prepared != null)
|
||||||
continue; // An eligible old crossing is precisely what regeneration may repair.
|
continue; // An eligible old crossing is precisely what regeneration may repair.
|
||||||
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
// Ignore only the unknown incoming rapid. Every fixed internal motion is checked.
|
||||||
var findings = new ReleasedContourState().Check(placement.Execution, null,
|
var findings = new ReleasedContourState(reportMissingLeadIns: false).Check(placement.Execution, null,
|
||||||
placement.SourceOrdinal + 1, placement.IsCutOff, token);
|
placement.SourceOrdinal + 1, placement.IsCutOff, token);
|
||||||
fixedFindings.AddRange(Map(snapshot, findings));
|
fixedFindings.AddRange(Map(snapshot, findings));
|
||||||
}
|
}
|
||||||
@@ -279,7 +284,7 @@ public static class CuttingPlanService
|
|||||||
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
|
if (snapshot.Dependencies.FirstViolation(order) is { } violation)
|
||||||
return new(CuttingPlanStatus.InvalidInput,
|
return new(CuttingPlanStatus.InvalidInput,
|
||||||
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
||||||
var checker = new ReleasedContourState();
|
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||||
var position = snapshot.StartPoint;
|
var position = snapshot.StartPoint;
|
||||||
var distance = 0.0;
|
var distance = 0.0;
|
||||||
var findings = new List<PostVerificationFinding>();
|
var findings = new List<PostVerificationFinding>();
|
||||||
@@ -313,7 +318,7 @@ public static class CuttingPlanService
|
|||||||
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
|
if (snapshot.Dependencies.FirstViolation(order.Select(p => p.SourceOrdinal).ToArray()) is { } violation)
|
||||||
return new(CuttingPlanStatus.InvalidInput,
|
return new(CuttingPlanStatus.InvalidInput,
|
||||||
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
findings: [DependencyFinding(snapshot, violation, "The proposed order")], expansions: expansions);
|
||||||
var checker = new ReleasedContourState();
|
var checker = new ReleasedContourState(reportMissingLeadIns: false);
|
||||||
var position = snapshot.StartPoint;
|
var position = snapshot.StartPoint;
|
||||||
var distance = 0.0;
|
var distance = 0.0;
|
||||||
var findings = new List<CuttingPlanFinding>();
|
var findings = new List<CuttingPlanFinding>();
|
||||||
@@ -380,6 +385,10 @@ public static class CuttingPlanService
|
|||||||
{
|
{
|
||||||
var part = snapshot.Placements[violation.Part];
|
var part = snapshot.Placements[violation.Part];
|
||||||
var prerequisite = snapshot.Placements[violation.Prerequisite];
|
var prerequisite = snapshot.Placements[violation.Prerequisite];
|
||||||
|
if (part.SeparatesSheet)
|
||||||
|
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
|
||||||
|
$"{subject} cuts the sheet-separating cutoff (part {part.SourceOrdinal + 1}) before part "
|
||||||
|
+ $"{prerequisite.SourceOrdinal + 1}; every part and other cutoff must be cut before the sheet is separated.");
|
||||||
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
|
var reason = prerequisite.IsCutOff ? "the cutoff crossing it" : "the part nested in its cutout";
|
||||||
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
|
return new(part.SourceOrdinal, part.SourcePart, prerequisite.SourceOrdinal, prerequisite.SourcePart, null,
|
||||||
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
|
$"{subject} cuts part {part.SourceOrdinal + 1} before {reason} (part {prerequisite.SourceOrdinal + 1}).");
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.CuttingPlanning;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owner-thread lifetime of a read-only cutting proposal. Capture before starting the worker;
|
||||||
|
/// complete on the owner thread. A close request never releases ownership while work is running.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CuttingProposalInspection
|
||||||
|
{
|
||||||
|
private CancellationTokenSource cancellation;
|
||||||
|
private Task<CuttingPlanProposal> worker;
|
||||||
|
public bool IsRunning => cancellation != null;
|
||||||
|
public bool CloseRequested { get; private set; }
|
||||||
|
public CuttingPlanProposal Proposal { get; private set; }
|
||||||
|
public Plate Preview { get; private set; }
|
||||||
|
|
||||||
|
public Task<CuttingPlanProposal> BeginPlanning(CuttingPlanBatch captured,
|
||||||
|
Action<CancellationToken> beforePlan = null, IProgress<CuttingPlanProgress> progress = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(captured);
|
||||||
|
if (IsRunning || CloseRequested)
|
||||||
|
throw new InvalidOperationException("This inspection cannot start another worker.");
|
||||||
|
Proposal = null;
|
||||||
|
Preview = null;
|
||||||
|
cancellation = new CancellationTokenSource();
|
||||||
|
var token = cancellation.Token;
|
||||||
|
worker = Task.Run(() =>
|
||||||
|
{
|
||||||
|
beforePlan?.Invoke(token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return captured.Plan(progress, token);
|
||||||
|
});
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True only when the panel can release its input lock and be removed.</summary>
|
||||||
|
public bool RequestClose()
|
||||||
|
{
|
||||||
|
CloseRequested = true;
|
||||||
|
Preview = null;
|
||||||
|
Proposal = null;
|
||||||
|
cancellation?.Cancel();
|
||||||
|
return !IsRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompletePlanning(Task<CuttingPlanProposal> completed)
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(worker, completed) || !completed.IsCompleted)
|
||||||
|
throw new InvalidOperationException("Only the completed current worker can release ownership.");
|
||||||
|
cancellation.Dispose();
|
||||||
|
cancellation = null;
|
||||||
|
worker = null;
|
||||||
|
if (CloseRequested || !completed.IsCompletedSuccessfully)
|
||||||
|
return;
|
||||||
|
Proposal = completed.Result;
|
||||||
|
RefreshPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Recheck exact freshness before displaying, including every subsequent paint.</summary>
|
||||||
|
private int previewIndex = -1;
|
||||||
|
|
||||||
|
public Plate RefreshPreview(int plateIndex = 0)
|
||||||
|
{
|
||||||
|
if (plateIndex != previewIndex)
|
||||||
|
{
|
||||||
|
Preview = null;
|
||||||
|
previewIndex = plateIndex;
|
||||||
|
}
|
||||||
|
if (Proposal == null || CloseRequested || plateIndex < 0 || plateIndex >= Proposal.Plates.Count)
|
||||||
|
return Preview = null;
|
||||||
|
// BuildPreview is the authority; never clone a rejected program for presentation.
|
||||||
|
if (Proposal.Plates[plateIndex].Result.Snapshot?.PlateState?.IsCurrent() != true)
|
||||||
|
return Preview = null;
|
||||||
|
return Preview ??= Proposal.BuildPreview(plateIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zero-based proposed position for a one-based source diagnostic number.</summary>
|
||||||
|
public static int? ProposedPosition(CuttingPlanProposal proposal, int plateIndex, int sourceNumber)
|
||||||
|
{
|
||||||
|
if (proposal == null || plateIndex < 0 || plateIndex >= proposal.Plates.Count || sourceNumber < 1)
|
||||||
|
return null;
|
||||||
|
var order = proposal.Plates[plateIndex].Result.ProposedOrder;
|
||||||
|
for (var i = 0; i < order.Count; ++i)
|
||||||
|
if (order[i].SourceOrdinal == sourceNumber - 1)
|
||||||
|
return i;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ internal static class FixedProgramSearch
|
|||||||
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||||
{
|
{
|
||||||
var stack = new Stack<Frame>();
|
var stack = new Stack<Frame>();
|
||||||
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState()));
|
stack.Push(Create([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false)));
|
||||||
var rejected = new HashSet<PostVerificationFinding>();
|
var rejected = new HashSet<PostVerificationFinding>();
|
||||||
var expansions = 0;
|
var expansions = 0;
|
||||||
while (stack.Count != 0)
|
while (stack.Count != 0)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// along its order before it gives up and learns from the part that blocked it.
|
/// along its order before it gives up and learns from the part that blocked it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal const int StallExpansionsPerEntry = 8;
|
internal const int StallExpansionsPerEntry = 8;
|
||||||
|
private const int MaximumRecoveryHoles = 16;
|
||||||
|
|
||||||
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
|
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
|
||||||
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
|
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
|
||||||
@@ -46,6 +47,9 @@ internal static class JointCuttingPlanSearch
|
|||||||
var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
|
var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
|
||||||
if (preferred.Order != null)
|
if (preferred.Order != null)
|
||||||
return walk.Ready(preferred.Order);
|
return walk.Ready(preferred.Order);
|
||||||
|
var recovered = walk.Follow(keptSequence, stall, null, contourRecovery: true);
|
||||||
|
if (recovered.Order != null)
|
||||||
|
return walk.Ready(recovered.Order);
|
||||||
}
|
}
|
||||||
var kept = walk.Follow(keptSequence, null, null);
|
var kept = walk.Follow(keptSequence, null, null);
|
||||||
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
|
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
|
||||||
@@ -55,24 +59,36 @@ internal static class JointCuttingPlanSearch
|
|||||||
var prerequisites = Enumerable.Range(0, count)
|
var prerequisites = Enumerable.Range(0, count)
|
||||||
.Select(i => new HashSet<int>(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
|
.Select(i => new HashSet<int>(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
|
||||||
|
|
||||||
var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
|
var nesting = NestedOrder(snapshot, centres, prerequisites, token);
|
||||||
snapshot.StartPoint, prerequisites, token);
|
var sequence = CuttingNestedOrder.Plan(Enumerable.Range(0, count).ToArray(), snapshot.StartPoint, nesting);
|
||||||
Node resume = null;
|
Node resume = null;
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
if (preferEndpoints)
|
// A pass that teaches an order rule ends the round: the heavier passes would
|
||||||
|
// only repeat the same blocked approach along the same order.
|
||||||
|
Attempt attempt = null;
|
||||||
|
foreach (var (preferredOnly, contourRecovery) in preferEndpoints
|
||||||
|
? new[] { (true, false), (false, true), (false, false) } : [(false, false)])
|
||||||
{
|
{
|
||||||
var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true);
|
var pass = walk.Follow(sequence, stall, resume, preferredOnly, contourRecovery);
|
||||||
if (preferred.Order != null)
|
if (pass.Order != null)
|
||||||
return walk.Ready(preferred.Order);
|
return walk.Ready(pass.Order);
|
||||||
|
if (Learn(pass, prerequisites))
|
||||||
|
{
|
||||||
|
attempt = pass;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var attempt = walk.Follow(sequence, stall, resume);
|
if (attempt == null)
|
||||||
if (attempt.Order != null)
|
|
||||||
return walk.Ready(attempt.Order);
|
|
||||||
if (!Learn(attempt, prerequisites))
|
|
||||||
{
|
{
|
||||||
// "Cut before" rules are a heuristic: a part blocked straight after another can
|
// "Cut before" rules are a heuristic: a part blocked straight after another can
|
||||||
// still be reachable via a third. Search every order with what budget remains.
|
// still be reachable via a third. Search every order with what budget remains.
|
||||||
|
if (preferEndpoints)
|
||||||
|
{
|
||||||
|
var recovery = walk.Follow(null, stall, null, contourRecovery: true);
|
||||||
|
if (recovery.Order != null)
|
||||||
|
return walk.Ready(recovery.Order);
|
||||||
|
}
|
||||||
var full = walk.Follow(null, null, null);
|
var full = walk.Follow(null, null, null);
|
||||||
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
|
return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
|
||||||
}
|
}
|
||||||
@@ -80,8 +96,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
// parts cut before it, and re-plan the rest from where the tool is at that point.
|
// parts cut before it, and re-plan the rest from where the tool is at that point.
|
||||||
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
|
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
|
||||||
resume = attempt.BoundaryAt(back);
|
resume = attempt.BoundaryAt(back);
|
||||||
sequence = [.. sequence.Take(back), .. CuttingPartOrder.Plan(sequence[back..], centres,
|
sequence = [.. sequence.Take(back), .. CuttingNestedOrder.Plan(sequence[back..], resume.Position, nesting)];
|
||||||
resume.Position, prerequisites, token)];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (BudgetExceededException)
|
catch (BudgetExceededException)
|
||||||
@@ -94,6 +109,24 @@ internal static class JointCuttingPlanSearch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Captured containment (never learned rules) and each host's placed cut contours.
|
||||||
|
private static CuttingNestedOrder.Context NestedOrder(CuttingPlanSnapshot snapshot, Vector[] centres,
|
||||||
|
HashSet<int>[] prerequisites, CancellationToken token)
|
||||||
|
{
|
||||||
|
var inside = snapshot.Placements.Select((placement, host) => placement.IsCutOff ? []
|
||||||
|
: snapshot.Dependencies.PrerequisitesOf(host).Where(p => !snapshot.Placements[p].IsCutOff).ToArray()).ToArray();
|
||||||
|
var contours = new Dictionary<int, Entity[]>();
|
||||||
|
return new(centres, prerequisites, inside, (host, position) =>
|
||||||
|
{
|
||||||
|
if (!contours.TryGetValue(host, out var entities))
|
||||||
|
contours[host] = entities = snapshot.Placements[host].Execution.Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||||
|
.Select(m => m.Curve.ToEntity()).ToArray();
|
||||||
|
return entities.Length == 0 ? centres[host]
|
||||||
|
: entities.Select(e => e.ClosestPointTo(position)).MinBy(point => point.DistanceTo(position));
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
|
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
|
||||||
// would contradict an order already required. False when nothing new was learned.
|
// would contradict an order already required. False when nothing new was learned.
|
||||||
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
|
private static bool Learn(Attempt attempt, HashSet<int>[] prerequisites)
|
||||||
@@ -162,6 +195,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
// memoize per owned choice for the attempt, never statically or across attempts.
|
// memoize per owned choice for the attempt, never statically or across attempts.
|
||||||
private readonly Dictionary<int, ContourEntryFeasibility> feasibility = [];
|
private readonly Dictionary<int, ContourEntryFeasibility> feasibility = [];
|
||||||
private readonly HashSet<int> reportedNoFit = [];
|
private readonly HashSet<int> reportedNoFit = [];
|
||||||
|
private readonly Dictionary<int, Entity[]> hostContours = [];
|
||||||
|
|
||||||
internal int Expansions { get; private set; }
|
internal int Expansions { get; private set; }
|
||||||
|
|
||||||
@@ -189,36 +223,56 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// With a stall limit the attempt ends once that many expansions pass without getting
|
/// With a stall limit the attempt ends once that many expansions pass without getting
|
||||||
/// further along the order; it never backtracks behind its starting node.
|
/// further along the order; it never backtracks behind its starting node.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false)
|
internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false)
|
||||||
{
|
{
|
||||||
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
|
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
|
||||||
var attempt = new Attempt(sequence);
|
var attempt = new Attempt(sequence);
|
||||||
var progressExpansions = Expansions;
|
var progressExpansions = Expansions;
|
||||||
var stack = new Stack<Frame>();
|
var stack = new Stack<Frame>();
|
||||||
stack.Push(new(root));
|
stack.Push(new(root));
|
||||||
while (stack.Count != 0)
|
try
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
while (stack.Count != 0)
|
||||||
var frame = stack.Peek();
|
|
||||||
var node = frame.Node;
|
|
||||||
if (node.Order.Length == snapshot.Placements.Count)
|
|
||||||
{
|
{
|
||||||
attempt.Order = node.Order;
|
token.ThrowIfCancellationRequested();
|
||||||
return attempt;
|
var frame = stack.Peek();
|
||||||
|
var node = frame.Node;
|
||||||
|
if (node.Order.Length == snapshot.Placements.Count)
|
||||||
|
{
|
||||||
|
attempt.Order = node.Order;
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
if (attempt.Advance(node))
|
||||||
|
progressExpansions = Expansions;
|
||||||
|
else if (stall is int limit && Expansions - progressExpansions > limit)
|
||||||
|
{
|
||||||
|
if (sequence != null || !contourRecovery)
|
||||||
|
return attempt;
|
||||||
|
// Diversify the first whole part rather than spending the remainder
|
||||||
|
// on endpoints of a stalled root's already completed prefix.
|
||||||
|
while (stack.Count > 1)
|
||||||
|
stack.Pop().Children?.Dispose();
|
||||||
|
attempt.Restart();
|
||||||
|
progressExpansions = Expansions;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly, contourRecovery).GetEnumerator();
|
||||||
|
if (!frame.Children.MoveNext())
|
||||||
|
{
|
||||||
|
stack.Pop().Children.Dispose();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack.Push(new(frame.Children.Current.Node));
|
||||||
}
|
}
|
||||||
if (attempt.Advance(node))
|
return attempt;
|
||||||
progressExpansions = Expansions;
|
}
|
||||||
else if (stall is int limit && Expansions - progressExpansions > limit)
|
finally
|
||||||
return attempt;
|
{
|
||||||
frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
|
// Ready, stalled, cancelled and budget-exhausted searches can all leave
|
||||||
if (frame.Next == frame.Children.Length)
|
// suspended siblings. Release their captured prefixes on every exit.
|
||||||
{
|
foreach (var frame in stack)
|
||||||
stack.Pop();
|
frame.Children?.Dispose();
|
||||||
continue;
|
|
||||||
}
|
|
||||||
stack.Push(new(frame.Children[frame.Next++].Node));
|
|
||||||
}
|
}
|
||||||
return attempt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -228,16 +282,19 @@ internal static class JointCuttingPlanSearch
|
|||||||
/// dependency-ready remaining part once the current part counts as finished, stable
|
/// dependency-ready remaining part once the current part counts as finished, stable
|
||||||
/// ordinal ties. Never the current or a finished part. Global coordinates.
|
/// ordinal ties. Never the current or a finished part. Global coordinates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source)
|
private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source) =>
|
||||||
|
NextCut(node, sequence, source) is { } next ? Facing(source, next) : null;
|
||||||
|
|
||||||
|
private FixedProgramPlacement NextCut(Node node, int[] sequence, FixedProgramPlacement source)
|
||||||
{
|
{
|
||||||
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
|
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
|
||||||
finished.Add(source.SourceOrdinal);
|
finished.Add(source.SourceOrdinal);
|
||||||
if (sequence != null)
|
if (sequence != null)
|
||||||
for (var i = node.Order.Length + 1; i < sequence.Length; i++)
|
for (var i = node.Order.Length + 1; i < sequence.Length; i++)
|
||||||
if (!finished.Contains(sequence[i]))
|
if (!finished.Contains(sequence[i]))
|
||||||
return Centre(snapshot.Placements[sequence[i]]);
|
return snapshot.Placements[sequence[i]];
|
||||||
var from = Centre(source);
|
var from = Centre(source);
|
||||||
Vector? best = null;
|
FixedProgramPlacement best = null;
|
||||||
var bestDistance = double.PositiveInfinity;
|
var bestDistance = double.PositiveInfinity;
|
||||||
foreach (var candidate in snapshot.Placements
|
foreach (var candidate in snapshot.Placements
|
||||||
.Where(p => !finished.Contains(p.SourceOrdinal)
|
.Where(p => !finished.Contains(p.SourceOrdinal)
|
||||||
@@ -248,20 +305,61 @@ internal static class JointCuttingPlanSearch
|
|||||||
if (distance < bestDistance - 1e-9)
|
if (distance < bestDistance - 1e-9)
|
||||||
{
|
{
|
||||||
bestDistance = distance;
|
bestDistance = distance;
|
||||||
best = Centre(candidate);
|
best = candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where the tool may have to go next, in global coordinates: the look-ahead point and,
|
||||||
|
// for an ordinary next part, the corners of its cut extent. Its pierce can be anywhere
|
||||||
|
// on it, so an outside entry that sees all of them leaves the most ways open.
|
||||||
|
private Vector[] SightTargets(Node node, int[] sequence, FixedProgramPlacement source)
|
||||||
|
{
|
||||||
|
if (NextCut(node, sequence, source) is not { } next)
|
||||||
|
return [];
|
||||||
|
var facing = Facing(source, next);
|
||||||
|
var cuts = next.Execution.Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||||
|
.Select(m => m.Curve.ToEntity().BoundingBox).ToList();
|
||||||
|
if (cuts.Count == 0 || facing != Centre(next))
|
||||||
|
return [facing];
|
||||||
|
var box = cuts.GetBoundingBox();
|
||||||
|
return [facing, new(box.Left, box.Bottom), new(box.Right, box.Bottom), new(box.Left, box.Top), new(box.Right, box.Top)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next cut as seen from the current part. A host's centre lies in the middle of
|
||||||
|
// its cutout, among the parts nested there; a part inside it leaves toward the
|
||||||
|
// nearest of the host's own contours instead.
|
||||||
|
private Vector Facing(FixedProgramPlacement source, FixedProgramPlacement next)
|
||||||
|
{
|
||||||
|
if (source.IsCutOff || next.IsCutOff
|
||||||
|
|| !snapshot.Dependencies.PrerequisitesOf(next.SourceOrdinal).Contains(source.SourceOrdinal))
|
||||||
|
return Centre(next);
|
||||||
|
if (!hostContours.TryGetValue(next.SourceOrdinal, out var contours))
|
||||||
|
hostContours[next.SourceOrdinal] = contours = next.Execution.Motions
|
||||||
|
.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
|
||||||
|
.Select(m => m.Curve.ToEntity()).ToArray();
|
||||||
|
var from = Centre(source);
|
||||||
|
return contours.Length == 0 ? Centre(next)
|
||||||
|
: contours.Select(c => c.ClosestPointTo(from)).MinBy(point => point.DistanceTo(from));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
|
/// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
|
||||||
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
|
||||||
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
private IEnumerable<Edge> OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
|
||||||
{
|
{
|
||||||
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
|
// A prescribed next part (or an active part's next contour) has a single
|
||||||
|
// source. Expand already yields its contour/entry rank order, so emit and
|
||||||
|
// validate a sibling only when DFS reaches it. Free whole-part selection
|
||||||
|
// still needs every source's distances before it can rank them. Recovery
|
||||||
|
// instead visits dependency-ready sources lazily by their material centres.
|
||||||
|
if (sequence != null || node.Active != null || contourRecovery)
|
||||||
|
return Expand(node, sequence, attempt, preferredOnly, contourRecovery);
|
||||||
|
var edges = Expand(node, sequence, attempt, preferredOnly, contourRecovery).ToList();
|
||||||
if (edges.Count <= 1)
|
if (edges.Count <= 1)
|
||||||
return edges.ToArray();
|
return edges.ToArray();
|
||||||
// Stable source order: the minimum incremental rapid per source, ties ordinal.
|
// Stable source order: the minimum incremental rapid per source, ties ordinal.
|
||||||
@@ -279,7 +377,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
|
private IEnumerable<Edge> Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery)
|
||||||
{
|
{
|
||||||
IEnumerable<FixedProgramPlacement> sources;
|
IEnumerable<FixedProgramPlacement> sources;
|
||||||
if (node.Active is { } active)
|
if (node.Active is { } active)
|
||||||
@@ -293,6 +391,8 @@ internal static class JointCuttingPlanSearch
|
|||||||
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
|
: snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
|
||||||
? [snapshot.Placements[sequence[node.Order.Length]]] : [];
|
? [snapshot.Placements[sequence[node.Order.Length]]] : [];
|
||||||
}
|
}
|
||||||
|
if (contourRecovery)
|
||||||
|
sources = sources.OrderBy(p => Centre(p).DistanceTo(node.Position)).ThenBy(p => p.SourceOrdinal);
|
||||||
foreach (var source in sources)
|
foreach (var source in sources)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -306,6 +406,12 @@ internal static class JointCuttingPlanSearch
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
var prepared = source.Prepared;
|
var prepared = source.Prepared;
|
||||||
|
if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null)
|
||||||
|
{
|
||||||
|
foreach (var edge in RecoverPart(node, sequence, source, attempt))
|
||||||
|
yield return edge;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (prepared.Count > 1 && node.Active == null)
|
if (prepared.Count > 1 && node.Active == null)
|
||||||
{
|
{
|
||||||
// Selecting an endpoint does not cut it. Each endpoint owns a separate
|
// Selecting an endpoint does not cut it. Each endpoint owns a separate
|
||||||
@@ -334,8 +440,10 @@ internal static class JointCuttingPlanSearch
|
|||||||
// First try one ranked hole chain per endpoint. A later-part failure then
|
// First try one ranked hole chain per endpoint. A later-part failure then
|
||||||
// changes the endpoint before replaying all earlier hole combinations.
|
// changes the endpoint before replaying all earlier hole combinations.
|
||||||
// The retained pass below still searches every entry and hole order.
|
// The retained pass below still searches every entry and hole order.
|
||||||
|
if (preference != null)
|
||||||
|
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
|
||||||
if (preferredOnly && preference != null)
|
if (preferredOnly && preference != null)
|
||||||
contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
|
contours = contours.Take(1);
|
||||||
foreach (var contour in contours)
|
foreach (var contour in contours)
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
token.ThrowIfCancellationRequested();
|
||||||
@@ -352,12 +460,18 @@ internal static class JointCuttingPlanSearch
|
|||||||
.FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
|
.FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1);
|
||||||
var target = downstream < 0 ? preference.PerimeterPierce
|
var target = downstream < 0 ? preference.PerimeterPierce
|
||||||
: preference.Pierces[downstream];
|
: preference.Pierces[downstream];
|
||||||
entries = SelectEntries(source, contour, target, node.Position - source.Location);
|
// The coarse pass already checked one owned, lead-feasible entry
|
||||||
// Preference only reorders retained candidates. Neither a failed
|
// for this hole. Do not prepare every alternate before trying it.
|
||||||
// preferred entry nor an alternate hole order prunes this branch.
|
// The subsequent full pass still enumerates and checks them all.
|
||||||
if (preference.Entries.TryGetValue(contour, out var preferred))
|
if (preferredOnly && preference.Entries.TryGetValue(contour, out var first))
|
||||||
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point)
|
entries = [first];
|
||||||
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray();
|
else
|
||||||
|
{
|
||||||
|
entries = SelectEntries(source, contour, target, node.Position - source.Location);
|
||||||
|
if (preference.Entries.TryGetValue(contour, out var preferred))
|
||||||
|
entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point)
|
||||||
|
<= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -402,6 +516,92 @@ internal static class JointCuttingPlanSearch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recovery only: solve directed contour transitions rather than assuming
|
||||||
|
// that a centre-to-centre hole tour can cross the already released material.
|
||||||
|
// One complete checked chain per outside endpoint; original DFS remains fallback.
|
||||||
|
private IEnumerable<Edge> RecoverPart(Node node, int[] sequence,
|
||||||
|
FixedProgramPlacement source, Attempt attempt)
|
||||||
|
{
|
||||||
|
var prepared = source.Prepared;
|
||||||
|
var target = LookAheadCentre(node, sequence, source) - source.Location;
|
||||||
|
// Placement origins are authored program coordinates, not a downstream cut.
|
||||||
|
// Use the same material-centre look-ahead as the retained entry search.
|
||||||
|
var perimeters = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token)
|
||||||
|
.OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.Position - source.Location))
|
||||||
|
.Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear).Select(c => c.Choice).ToArray();
|
||||||
|
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal)
|
||||||
|
.Select(c => prepared.AutomaticEntryCandidatesWithFallbacks(c, token: token)
|
||||||
|
.Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).Select(e => e.Choice).ToArray()).ToArray();
|
||||||
|
var motions = new Dictionary<ContourChoice, OwnedExecution>();
|
||||||
|
var stop = Expansions + (long)StallExpansionsPerEntry * snapshot.MaxEntries * prepared.Count;
|
||||||
|
OwnedExecution Motion(ContourChoice choice)
|
||||||
|
{
|
||||||
|
if (motions.TryGetValue(choice, out var known)) return known;
|
||||||
|
CountExpansion(source);
|
||||||
|
return motions[choice] = ExecutionMotionReader.Read(
|
||||||
|
prepared.EmitCandidateForValidation(choice), source.Location, null, token);
|
||||||
|
}
|
||||||
|
for (var endpoint = 0; endpoint < perimeters.Length; endpoint++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (Expansions >= stop) yield break;
|
||||||
|
var outside = perimeters[endpoint];
|
||||||
|
var chain = new List<ContourChoice>();
|
||||||
|
// This dominance heuristic is confined to recovery: emitted
|
||||||
|
// seams can differ for the same released subset. Never prune full DFS.
|
||||||
|
var failed = new HashSet<(int Mask, long X, long Y)>();
|
||||||
|
if (!Solve(0, node.Position, node.Checker)) continue;
|
||||||
|
var program = prepared.Emit(chain);
|
||||||
|
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
|
||||||
|
var checker = node.Checker.Copy();
|
||||||
|
CountExpansion(source);
|
||||||
|
if (!Check(source, execution, node.Position, checker, attempt)) continue;
|
||||||
|
var proposal = source.Propose(program, execution, chain, token);
|
||||||
|
yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node),
|
||||||
|
execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint);
|
||||||
|
|
||||||
|
bool Solve(int mask, Vector position, ReleasedContourState before)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (Expansions >= stop) return false;
|
||||||
|
var key = (mask, BitConverter.DoubleToInt64Bits(position.X),
|
||||||
|
BitConverter.DoubleToInt64Bits(position.Y));
|
||||||
|
if (failed.Contains(key)) return false;
|
||||||
|
if (mask == (1 << prepared.PerimeterOrdinal) - 1)
|
||||||
|
{
|
||||||
|
CountExpansion(source);
|
||||||
|
var findings = before.Copy().Check(Motion(outside), position, source.SourceOrdinal + 1, token);
|
||||||
|
rejected.AddRange(Map(snapshot, findings));
|
||||||
|
attempt.NoteCrossings(source.SourceOrdinal, findings);
|
||||||
|
if (findings.Count != 0) { failed.Add(key); return false; }
|
||||||
|
chain.Add(outside);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
var candidates = Enumerable.Range(0, holes.Length).Where(c => (mask & (1 << c)) == 0)
|
||||||
|
.SelectMany(c => holes[c])
|
||||||
|
.OrderBy(c => Motion(c).Motions[0].End.DistanceTo(position));
|
||||||
|
foreach (var choice in candidates)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
if (Expansions >= stop) return false;
|
||||||
|
CountExpansion(source);
|
||||||
|
var execution = Motion(choice);
|
||||||
|
var after = before.Copy();
|
||||||
|
var findings = after.Check(execution, position, source.SourceOrdinal + 1, token);
|
||||||
|
rejected.AddRange(Map(snapshot, findings));
|
||||||
|
attempt.NoteCrossings(source.SourceOrdinal, findings);
|
||||||
|
if (findings.Count != 0) continue;
|
||||||
|
chain.Add(choice);
|
||||||
|
if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true;
|
||||||
|
chain.RemoveAt(chain.Count - 1);
|
||||||
|
}
|
||||||
|
failed.Add(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
|
/// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
|
||||||
/// converted exactly once: rank the (fallback-complemented) catalogue toward the
|
/// converted exactly once: rank the (fallback-complemented) catalogue toward the
|
||||||
@@ -417,7 +617,7 @@ internal static class JointCuttingPlanSearch
|
|||||||
// One global->local conversion of target and arrival; geometry is already rotated.
|
// One global->local conversion of target and arrival; geometry is already rotated.
|
||||||
Vector? local = LookAheadCentre(node, sequence, source) is { } target
|
Vector? local = LookAheadCentre(node, sequence, source) is { } target
|
||||||
? target - source.Location : null;
|
? target - source.Location : null;
|
||||||
return SelectEntries(source, contour, local, arrival);
|
return SelectEntries(source, contour, local, arrival, SightTargets(node, sequence, source));
|
||||||
}
|
}
|
||||||
|
|
||||||
private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
|
private ContourEntryFeasibility Adapter(FixedProgramPlacement source) =>
|
||||||
@@ -436,13 +636,23 @@ internal static class JointCuttingPlanSearch
|
|||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<ContourChoice> SelectEntries(FixedProgramPlacement source, int contour,
|
private IReadOnlyList<ContourChoice> SelectEntries(FixedProgramPlacement source, int contour,
|
||||||
Vector? target, Vector arrival)
|
Vector? target, Vector arrival, Vector[] sightTargets = null)
|
||||||
{
|
{
|
||||||
// Last contour has no downstream target, but still needs its exact native
|
// Last contour has no downstream target, but still needs its exact native
|
||||||
// closest-arrival fallback. Keep target null in the ranker: arrival is not
|
// closest-arrival fallback. Keep target null in the ranker: arrival is not
|
||||||
// a next cut and must not acquire facing-side priority.
|
// a next cut and must not acquire facing-side priority.
|
||||||
var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token);
|
var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target ?? arrival, token);
|
||||||
var ordered = catalogue.RankTowardNextCut(target, arrival);
|
var ordered = catalogue.RankTowardNextCut(target, arrival);
|
||||||
|
// With no lead-out the tool leaves the part from its outside entry. From inside a
|
||||||
|
// slot or pocket the straight way to the next cut runs back over the part just
|
||||||
|
// cut, so entries with the clearest way out lead. Order only: every entry stays.
|
||||||
|
if (sightTargets is { Length: > 0 } && contour == source.Prepared.PerimeterOrdinal
|
||||||
|
&& source.Material is { IsComplete: true } material)
|
||||||
|
{
|
||||||
|
var perimeter = material.Rings[0];
|
||||||
|
ordered = ordered.OrderBy(c => sightTargets.Count(sight => PostVerificationGeometry.Crosses(
|
||||||
|
c.Choice.Point + source.Location, sight, perimeter, token))).ToList();
|
||||||
|
}
|
||||||
var selection = ContourEntrySelection.Select(ordered,
|
var selection = ContourEntrySelection.Select(ordered,
|
||||||
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
|
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
|
||||||
if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
|
if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
|
||||||
@@ -466,6 +676,12 @@ internal static class JointCuttingPlanSearch
|
|||||||
var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray();
|
var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray();
|
||||||
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
|
||||||
var localArrival = active.Arrival - source.Location;
|
var localArrival = active.Arrival - source.Location;
|
||||||
|
// The tool arrives inside a cutout from a part nested there. That cutout's centre
|
||||||
|
// says nothing about where its cut can be reached: route it from where the tool is,
|
||||||
|
// before a later hop to it has to cross the other parts nested in it.
|
||||||
|
foreach (var hole in holes)
|
||||||
|
if (prepared.HoleContains(hole, localArrival))
|
||||||
|
centres[hole] = localArrival;
|
||||||
var endpoint = perimeter.Point;
|
var endpoint = perimeter.Point;
|
||||||
var entries = new Dictionary<int, ContourChoice>();
|
var entries = new Dictionary<int, ContourChoice>();
|
||||||
var pierces = holes.ToDictionary(h => h, h => centres[h]);
|
var pierces = holes.ToDictionary(h => h, h => centres[h]);
|
||||||
@@ -558,6 +774,13 @@ internal static class JointCuttingPlanSearch
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void Restart()
|
||||||
|
{
|
||||||
|
progress = -1;
|
||||||
|
Deepest = null;
|
||||||
|
Crossed.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
|
internal void NoteCrossings(int ordinal, IEnumerable<PostVerificationFinding> findings)
|
||||||
{
|
{
|
||||||
if (Blocked != ordinal)
|
if (Blocked != ordinal)
|
||||||
@@ -593,7 +816,6 @@ internal static class JointCuttingPlanSearch
|
|||||||
private sealed class Frame(Node node)
|
private sealed class Frame(Node node)
|
||||||
{
|
{
|
||||||
internal Node Node { get; } = node;
|
internal Node Node { get; } = node;
|
||||||
internal Edge[] Children { get; set; }
|
internal IEnumerator<Edge> Children { get; set; }
|
||||||
internal int Next { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,22 @@ namespace OpenNest.Engine.Fill
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Compactor
|
public static class Compactor
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The plate's clamp zones as static push obstacles (C1). Unlike ordinary
|
||||||
|
/// already-overlapping parts, a clamp stays a wall even when a selected member
|
||||||
|
/// straddles it: dropping it would let other clear members move into the zone.
|
||||||
|
/// Empty zone lists change every caller's arithmetic by nothing (C2).
|
||||||
|
/// </summary>
|
||||||
|
private static List<Part> ZoneObstacles(Plate plate) =>
|
||||||
|
Jobs.InteractiveClamps.ObstacleParts(plate);
|
||||||
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate
|
var obstacleParts = ZoneObstacles(plate);
|
||||||
.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
obstacleParts.InsertRange(
|
||||||
.ToList();
|
0,
|
||||||
|
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
|
.ToList()
|
||||||
|
);
|
||||||
|
|
||||||
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
}
|
}
|
||||||
@@ -26,9 +37,12 @@ namespace OpenNest.Engine.Fill
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
public static double Push(List<Part> movingParts, Plate plate, double angle)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate
|
var obstacleParts = ZoneObstacles(plate);
|
||||||
.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
obstacleParts.InsertRange(
|
||||||
.ToList();
|
0,
|
||||||
|
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
|
.ToList()
|
||||||
|
);
|
||||||
|
|
||||||
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
|
||||||
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
|
||||||
@@ -214,9 +228,12 @@ namespace OpenNest.Engine.Fill
|
|||||||
PushDirection direction
|
PushDirection direction
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var obstacleParts = plate
|
var obstacleParts = ZoneObstacles(plate);
|
||||||
.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
obstacleParts.InsertRange(
|
||||||
.ToList();
|
0,
|
||||||
|
plate.Parts.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
|
||||||
|
.ToList()
|
||||||
|
);
|
||||||
|
|
||||||
return PushBoundingBox(
|
return PushBoundingBox(
|
||||||
movingParts,
|
movingParts,
|
||||||
@@ -374,5 +391,46 @@ namespace OpenNest.Engine.Fill
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// As <see cref="Settle(List{Part}, Box, double, double, int)"/>, settling inside the
|
||||||
|
/// plate's work area with its clamp zones as static obstacles: settling never parks
|
||||||
|
/// a part inside a keep-out (C1), and a clear cluster still compacts against zones
|
||||||
|
/// like any other obstacle. With no zones this behaves exactly like settling in the
|
||||||
|
/// plate's work area (C2).
|
||||||
|
/// </summary>
|
||||||
|
public static void Settle(
|
||||||
|
List<Part> parts,
|
||||||
|
Plate plate,
|
||||||
|
double threshold = 0.01,
|
||||||
|
int maxIterations = 20
|
||||||
|
) => Settle(parts, plate, plate.WorkArea(), threshold, maxIterations);
|
||||||
|
|
||||||
|
/// <summary>Keep a fill group's own work-area bounds while excluding plate clamps.</summary>
|
||||||
|
public static void Settle(
|
||||||
|
List<Part> parts,
|
||||||
|
Plate plate,
|
||||||
|
Box workArea,
|
||||||
|
double threshold = 0.01,
|
||||||
|
int maxIterations = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (parts.Count < 2)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var obstacles = Jobs.InteractiveClamps.ObstacleParts(plate);
|
||||||
|
obstacles.AddRange(plate.Parts.Where(part => !parts.Contains(part)
|
||||||
|
&& !part.BaseDrawing.IsCutOff));
|
||||||
|
|
||||||
|
for (var i = 0; i < maxIterations; i++)
|
||||||
|
{
|
||||||
|
var moved = 0.0;
|
||||||
|
moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Left);
|
||||||
|
moved += Push(parts, obstacles, workArea, plate.PartSpacing, PushDirection.Down);
|
||||||
|
|
||||||
|
if (moved < threshold)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+703
-142
File diff suppressed because it is too large.
Load diff
@@ -15,10 +15,18 @@ namespace OpenNest.Engine.Fill
|
|||||||
private readonly RemnantFinder finder;
|
private readonly RemnantFinder finder;
|
||||||
private readonly double spacing;
|
private readonly double spacing;
|
||||||
|
|
||||||
public RemnantFiller(Box workArea, double spacing)
|
/// <summary>
|
||||||
|
/// <paramref name="boxObstacles"/> are kept-out rectangles (e.g. inflated clamp
|
||||||
|
/// zones) treated like occupied space: they are excluded from the remnant areas
|
||||||
|
/// every fill attempt sees. Null keeps today's behaviour exactly.
|
||||||
|
/// </summary>
|
||||||
|
public RemnantFiller(Box workArea, double spacing, IEnumerable<Box> boxObstacles = null)
|
||||||
{
|
{
|
||||||
this.spacing = spacing;
|
this.spacing = spacing;
|
||||||
finder = new RemnantFinder(workArea);
|
finder = new RemnantFinder(workArea);
|
||||||
|
if (boxObstacles != null)
|
||||||
|
foreach (var box in boxObstacles)
|
||||||
|
finder.AddObstacle(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddObstacles(IEnumerable<Part> parts)
|
public void AddObstacles(IEnumerable<Part> parts)
|
||||||
|
|||||||
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user