Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a05391d94 | ||
|
|
ea4bd836cd |
@@ -134,4 +134,3 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||
|
||||
@@ -48,13 +48,13 @@ namespace OpenNest.Benchmark
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(int maxPlates)
|
||||
public NestJob BuildNestJob(int maxPlates, double salvageRate = 0, double minimumSalvageDimension = 0)
|
||||
{
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
|
||||
var stock = CandidateSizes.Select(size =>
|
||||
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
|
||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
|
||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates, salvageRate, minimumSalvageDimension));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace OpenNest.Benchmark
|
||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines,
|
||||
double salvageRate = 0, double minimumSalvageDimension = 0, string outputDirectory = null)
|
||||
{
|
||||
var results = new List<JobResult>(jobs.Count * engines.Count);
|
||||
|
||||
@@ -31,21 +32,22 @@ namespace OpenNest.Benchmark
|
||||
{
|
||||
foreach (var engineInfo in engines)
|
||||
{
|
||||
results.Add(RunOne(job, engineInfo));
|
||||
results.Add(RunOne(job, engineInfo, salvageRate, minimumSalvageDimension, outputDirectory));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
|
||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo,
|
||||
double salvageRate, double minimumSalvageDimension, string outputDirectory)
|
||||
{
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var nestJob = job.BuildNestJob(MaxPlates);
|
||||
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
||||
var engine = engineInfo.Factory();
|
||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
||||
@@ -70,6 +72,35 @@ namespace OpenNest.Benchmark
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
if (validation.Valid && outputDirectory != null)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(outputDirectory);
|
||||
// Keep names and job metadata for a useful inspectable output; never modify source.
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
|
||||
materialized.Nest.Name = source.Name;
|
||||
materialized.Nest.Units = source.Units;
|
||||
materialized.Nest.Material = source.Material;
|
||||
materialized.Nest.Thickness = source.Thickness;
|
||||
materialized.Nest.SalvageRate = salvageRate;
|
||||
foreach (var request in job.Requests)
|
||||
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request.Drawing.Name;
|
||||
var path = System.IO.Path.Combine(outputDirectory, $"{job.Name}-{engineInfo.Name}.nest");
|
||||
if (System.IO.Path.GetFullPath(path) == System.IO.Path.GetFullPath(job.SourceFile))
|
||||
throw new InvalidOperationException("Output must not overwrite the source nest.");
|
||||
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||
var report = new
|
||||
{
|
||||
Source = job.SourceFile, Engine = engineInfo.Name, jobResult.Status, jobResult.StopReason,
|
||||
Requested = requested, Placed = totalPlaced, SheetArea = plateArea, PlacedArea = placedArea,
|
||||
SalvageRate = salvageRate, MinimumSalvageDimension = minimumSalvageDimension,
|
||||
EstimatedNetArea = jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p)),
|
||||
Fulfillment = jobResult.Fulfillment, StockUsage = jobResult.StockUsage,
|
||||
Plates = jobResult.Plates, validation.Violations
|
||||
};
|
||||
System.IO.File.WriteAllText(System.IO.Path.ChangeExtension(path, ".json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(report,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
return new JobResult
|
||||
|
||||
@@ -70,7 +70,7 @@ static class BenchmarkConsole
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var results = BenchmarkRunner.Run(jobs, engines);
|
||||
var results = BenchmarkRunner.Run(jobs, engines, options.SalvageRate, options.MinimumSalvageDimension, options.OutputDirectory);
|
||||
|
||||
Report.PrintDetailed(results);
|
||||
Report.PrintSummary(results);
|
||||
@@ -111,6 +111,16 @@ static class BenchmarkConsole
|
||||
o.CsvPath = args[++i];
|
||||
break;
|
||||
|
||||
case "--salvage-rate" when i + 1 < args.Length:
|
||||
o.SalvageRate = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case "--min-salvage-dimension" when i + 1 < args.Length:
|
||||
o.MinimumSalvageDimension = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case "--output" when i + 1 < args.Length:
|
||||
o.OutputDirectory = args[++i];
|
||||
break;
|
||||
|
||||
case "--help":
|
||||
PrintUsage();
|
||||
return null;
|
||||
@@ -162,6 +172,9 @@ static class BenchmarkConsole
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
|
||||
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
|
||||
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
|
||||
Console.Error.WriteLine(" --salvage-rate <0..1> Fraction of eligible offcut area credited (default 0)");
|
||||
Console.Error.WriteLine(" --min-salvage-dimension <value> Both offcut dimensions must qualify; 0 disables credit");
|
||||
Console.Error.WriteLine(" --output <directory> Save valid layouts as .nest plus detailed JSON reports");
|
||||
Console.Error.WriteLine(" --help Show this message");
|
||||
}
|
||||
|
||||
@@ -172,5 +185,8 @@ static class BenchmarkConsole
|
||||
public double? PartSpacing;
|
||||
public List<string> EngineNames = new();
|
||||
public string CsvPath;
|
||||
public string OutputDirectory;
|
||||
public double SalvageRate;
|
||||
public double MinimumSalvageDimension;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,15 +87,14 @@ namespace OpenNest.Converters
|
||||
|
||||
lastpt = endpt;
|
||||
|
||||
var layer = ClassifyLayer(arc);
|
||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||
{
|
||||
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||
pgm.LineTo(endpt);
|
||||
}
|
||||
else
|
||||
{
|
||||
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
||||
}
|
||||
|
||||
return lastpt;
|
||||
@@ -108,7 +107,7 @@ namespace OpenNest.Converters
|
||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -119,22 +118,13 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
}
|
||||
|
||||
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
|
||||
// separate tool pass; everything else keeps the move's default Cut layer.
|
||||
private static LayerType ClassifyLayer(Entity geo)
|
||||
{
|
||||
var name = geo.Layer?.Name;
|
||||
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
return LayerType.Scribe;
|
||||
|
||||
return LayerType.Cut;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,14 +57,13 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc that passes exactly through both the first and last points
|
||||
/// while matching the given endpoint tangents as closely as possible. For any
|
||||
/// circle through two points, the tangents at those points make equal mirrored
|
||||
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||
/// two requested ones — when the requested tangents are consistent with a single
|
||||
/// circular arc, both are matched exactly.
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
@@ -73,39 +72,42 @@ namespace OpenNest.Geometry
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var ux = dx / chordLen;
|
||||
var uy = dy / chordLen;
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
|
||||
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||
var theta = (theta1 + theta2) / 2;
|
||||
|
||||
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||
var d = -halfChord / System.Math.Tan(theta);
|
||||
|
||||
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double SignedAngle(double ux, double uy, Vector to)
|
||||
{
|
||||
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||
if (len < 1e-10) return 0;
|
||||
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -286,8 +286,10 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
||||
/// keep the full piece triangle (acceptable for visual shading).
|
||||
/// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
|
||||
/// triangle equals the union of its clips against each triangle edge's outside
|
||||
/// half-space, so overlap confined to a cutout disappears while any material
|
||||
/// sliver outside the hole survives.
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||
{
|
||||
@@ -295,29 +297,25 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var holeTri in holeTris)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
|
||||
continue;
|
||||
|
||||
var next = new List<Polygon>();
|
||||
|
||||
foreach (var piece in current)
|
||||
{
|
||||
var pieceTris = TriangulateWithBounds(piece);
|
||||
|
||||
foreach (var pieceTri in pieceTris)
|
||||
if (!BoundingBoxesOverlap(piece.BoundingBox, holeTri.BoundingBox))
|
||||
{
|
||||
var inside = ClipConvex(pieceTri, holeTri);
|
||||
if (inside == null)
|
||||
{
|
||||
// No overlap with hole - keep
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
|
||||
{
|
||||
// Partial overlap - keep the piece (conservative)
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
// else: fully inside hole - discard
|
||||
next.Add(piece);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pieceTri in TriangulateWithBounds(piece))
|
||||
{
|
||||
var holeVerts = holeTri.Vertices;
|
||||
var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
|
||||
var survived = false;
|
||||
for (var i = 0; i < holeCount; i++)
|
||||
survived |= AddIfPositiveArea(next,
|
||||
ClipOutsideHalfSpace(pieceTri, holeVerts[i], holeVerts[(i + 1) % holeCount]));
|
||||
if (!survived) continue; // piece lies entirely within the hole
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,5 +324,40 @@ namespace OpenNest.Geometry
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
|
||||
/// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross < -Epsilon).
|
||||
/// </summary>
|
||||
private static List<Vector> ClipOutsideHalfSpace(Polygon piece, Vector edgeStart, Vector edgeEnd)
|
||||
{
|
||||
var verts = piece.Vertices;
|
||||
var count = piece.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
var kept = new List<Vector>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var current = verts[i];
|
||||
var next = verts[(i + 1) % count];
|
||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
||||
if (!currentInside) kept.Add(current);
|
||||
if (currentInside == nextInside) continue;
|
||||
var intersection = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (intersection.IsValid()) kept.Add(intersection);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static bool AddIfPositiveArea(List<Polygon> polygons, List<Vector> vertices)
|
||||
{
|
||||
if (vertices.Count < 3) return false;
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(vertices);
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
if (polygon.Area() <= Tolerance.Epsilon) return false;
|
||||
polygons.Add(polygon);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,8 +374,11 @@ public class GeometrySimplifier
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||
var endTangent = EstimateEndTangent(entities, k, points);
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var endTangent = GetExitDirection(entities[k]);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
@@ -383,10 +386,8 @@ public class GeometrySimplifier
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
if (extPoints.Count < 3) break;
|
||||
|
||||
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
@@ -406,172 +407,37 @@ public class GeometrySimplifier
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
{
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
continue;
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
continue;
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
return (Vector.Invalid, 0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields fit attempts in preference order. A trusted tangent (chained from the
|
||||
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
|
||||
/// exactly on its side; otherwise the tangency error is balanced between both
|
||||
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
|
||||
/// passes exactly through both endpoints, so no gaps are introduced.
|
||||
/// </summary>
|
||||
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
|
||||
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||
{
|
||||
if (start.Trusted && !end.Trusted)
|
||||
{
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
else if (end.Trusted && !start.Trusted)
|
||||
{
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
|
||||
yield return ArcFit.FitWithStartTangent(points, start.Direction);
|
||||
yield return FitWithEndTangent(points, end.Direction);
|
||||
}
|
||||
|
||||
yield return FitMirrorAxis(points);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits an arc through both endpoints with an exact tangent at the last point,
|
||||
/// by running the start-tangent fit on the reversed point sequence.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithEndTangent(
|
||||
List<Vector> points, Vector endTangent)
|
||||
{
|
||||
var reversed = new List<Vector>(points);
|
||||
reversed.Reverse();
|
||||
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
|
||||
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
|
||||
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
|
||||
/// vertices and only guide the fit.
|
||||
/// </summary>
|
||||
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
|
||||
|
||||
/// <summary>Segment-length ratio above which a neighboring line counts as a true
|
||||
/// straight edge (rather than another chord of the tessellated curve).</summary>
|
||||
private const double NeighborEdgeFactor = 3.0;
|
||||
|
||||
private static TangentEstimate EstimateStartTangent(
|
||||
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
|
||||
{
|
||||
if (chainedTangent.IsValid())
|
||||
return new TangentEstimate(chainedTangent, true);
|
||||
|
||||
if (entities[start] is Arc startArc)
|
||||
return new TangentEstimate(GetEntryDirection(startArc), true);
|
||||
|
||||
var firstChordLen = points[0].DistanceTo(points[1]);
|
||||
if (start > 0)
|
||||
{
|
||||
var prev = entities[start - 1];
|
||||
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
|
||||
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
{
|
||||
if (prev is Arc)
|
||||
return new TangentEstimate(GetExitDirection(prev), true);
|
||||
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
|
||||
return new TangentEstimate(GetExitDirection(prevLine), true);
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
}
|
||||
}
|
||||
|
||||
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
|
||||
{
|
||||
if (entities[k] is Arc endArc)
|
||||
return new TangentEstimate(GetExitDirection(endArc), true);
|
||||
|
||||
var lastChordLen = points[^1].DistanceTo(points[^2]);
|
||||
if (k + 1 < entities.Count)
|
||||
{
|
||||
var next = entities[k + 1];
|
||||
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
|
||||
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
|
||||
{
|
||||
if (next is Arc nextArc)
|
||||
return new TangentEstimate(GetEntryDirection(nextArc), true);
|
||||
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
|
||||
return new TangentEstimate(GetExitDirection(nextLine), true);
|
||||
}
|
||||
}
|
||||
|
||||
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
|
||||
if (points.Count >= 3)
|
||||
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
|
||||
return new TangentEstimate(chord, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estimates the curve tangent at a polyline vertex from the circle through it and
|
||||
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
|
||||
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
|
||||
/// Falls back to the travel direction when the three points are collinear.
|
||||
/// </summary>
|
||||
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
|
||||
{
|
||||
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
|
||||
if (System.Math.Abs(d) < 1e-14)
|
||||
return travel;
|
||||
|
||||
var sqA = at.X * at.X + at.Y * at.Y;
|
||||
var sqB = b.X * b.X + b.Y * b.Y;
|
||||
var sqC = c.X * c.X + c.Y * c.Y;
|
||||
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
|
||||
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
|
||||
|
||||
var tangent = new Vector(-(at.Y - cy), at.X - cx);
|
||||
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
|
||||
tangent = new Vector(-tangent.X, -tangent.Y);
|
||||
return tangent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entry direction (tangent at start point) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetEntryDirection(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
|
||||
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
|
||||
@@ -41,6 +41,33 @@ public class NestJobValidationTests
|
||||
Assert.Equal(2, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallCornerOverlapIsRejected()
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(job,
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("part", 1, 9, 9, 0)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(10.0, 0.0)]
|
||||
[InlineData(10.0, 10.0)]
|
||||
public void BoundaryContactWithZeroSpacingIsAccepted(double x, double y)
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
var result = Solve(job,
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("part", 1, x, y, 0));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownOrOverproducingCandidateFailsBeforeCommitWithoutChangingInput()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class StockLadderTests
|
||||
{
|
||||
private static NestJobPart Rectangle(string id, int quantity, double x = 4, double y = 4,
|
||||
RotationPolicy? rotation = null) => new(id,
|
||||
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(x, y)), quantity,
|
||||
rotation: rotation ?? RotationPolicy.Fixed(0));
|
||||
|
||||
[Fact]
|
||||
public void MergesEquivalentDemandOntoLargerSheetAndReturnsFiniteStock()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 5) }, new[]
|
||||
{
|
||||
new NestPlateStock("small", new Size(10, 10), 2),
|
||||
new NestPlateStock("large", new Size(10, 18), 1)
|
||||
});
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
|
||||
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "small").Used);
|
||||
Assert.Equal(2, result.StockUsage.Single(s => s.StockId == "small").Remaining);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiniteStockAndPlateLimitDoNotOverproduce()
|
||||
{
|
||||
var parts = new[] { Rectangle("a", 9) };
|
||||
var stock = new[] { new NestPlateStock("only", new Size(10, 10), 1) };
|
||||
var job = new NestJob(parts, stock);
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(4, result.Fulfillment[0].Placed);
|
||||
Verify(job, result);
|
||||
job = new NestJob(parts, new[] { new NestPlateStock("only", new Size(10, 10)) }, new NestJobOptions(maxPlates: 1));
|
||||
result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
Assert.Single(result.Plates);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstrainedLargeSinglePrecedesSmallFillers()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("small", 12, 2, 2), Rectangle("large", 1, 12, 6) }, new[]
|
||||
{
|
||||
new NestPlateStock("small-sheet", new Size(10, 10)),
|
||||
new NestPlateStock("large-sheet", new Size(10, 18))
|
||||
});
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal("large", result.Plates[0].Placements[0].PartId);
|
||||
Assert.Contains(result.Plates[0].Placements, p => p.PartId == "small");
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)]
|
||||
public void GeometrySpacingRotationsAndQuadrantsAreValidated(int quadrant)
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 6, 3, 5, RotationPolicy.Fixed(System.Math.PI / 2)) },
|
||||
new[] { new NestPlateStock("sheet", new Size(12, 18), partSpacing: 0.25,
|
||||
edgeSpacing: new Spacing { Left = 0.5, Right = 0.5, Top = 0.5, Bottom = 0.5 }, quadrant: quadrant) });
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpossibleDemandTerminatesWithoutUsingUnlimitedStock()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 1, 100, 100) },
|
||||
new[] { new NestPlateStock("sheet", new Size(10, 10)) });
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Empty(result.Plates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationBeforeAndDuringTrialNeverReturnsPartialSuccess()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 1) }, new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
|
||||
{
|
||||
cts.Cancel();
|
||||
return new PlateCandidate(Array.Empty<NestJobPlacement>());
|
||||
}));
|
||||
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cts.Token));
|
||||
Assert.Throws<OperationCanceledException>(() => new StockLadderNestingEngine().Solve(job, token: cts.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] [InlineData(true)]
|
||||
public void RejectsOverlappingOrOverproducingNester(bool overproduce)
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 2) }, new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
|
||||
new PlateCandidate(overproduce
|
||||
? Enumerable.Repeat(new NestJobPlacement("a", 0, 0, 0, 0), 3)
|
||||
: new[] { new NestJobPlacement("a", 0, 50, 0, 0) })));
|
||||
Assert.Throws<InvalidOperationException>(() => engine.Solve(job));
|
||||
// Direct full-demand overlap check, not masked by the single-part feasibility probe limit.
|
||||
Assert.Throws<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(
|
||||
new PlateCandidate(new[] { new NestJobPlacement("a", 0, 0, 0, 0), new NestJobPlacement("a", 1, 1, 1, 0) }),
|
||||
job.Plates[0], new Dictionary<string, int> { ["a"] = 2 }, job.Parts.ToDictionary(p => p.Id)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SalvageCreditsOnlyOneUsableEdgeRectangleAndDefaultsToZero()
|
||||
{
|
||||
var part = Rectangle("a", 1);
|
||||
var stock = new NestPlateStock("s", new Size(10, 10));
|
||||
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("a", 0, 0, 0, 0) });
|
||||
NestJob Job(double rate, double min) => new(new[] { part }, new[] { stock },
|
||||
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: min));
|
||||
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 0), sheet));
|
||||
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 7), sheet));
|
||||
Assert.Equal(70, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 5), sheet), 6);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: double.NaN));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: 1.1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRepackRetainsAllDemandAndFiniteStockAccounting()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 5) }, new[]
|
||||
{
|
||||
new NestPlateStock("small", new Size(10, 10), 2),
|
||||
new NestPlateStock("large", new Size(10, 18), 1)
|
||||
});
|
||||
var fullDemandLargeTrials = 0;
|
||||
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
|
||||
{
|
||||
var quantity = Assert.Single(request.Parts).Quantity;
|
||||
if (request.Stock.Id == "large" && quantity == 5) fullDemandLargeTrials++;
|
||||
// Deliberately fail to reproduce the fifth piece on the cheaper merged sheet.
|
||||
return new PlateCandidate(Enumerable.Range(0, System.Math.Min(quantity, 4))
|
||||
.Select(i => new NestJobPlacement("a", i, i % 2 * 4, i / 2 * 4, 0)));
|
||||
}));
|
||||
var result = engine.Solve(job);
|
||||
Assert.True(fullDemandLargeTrials >= 2); // Construction AND equivalent-demand repack ran.
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
Assert.All(result.Plates, sheet => Assert.Equal("small", sheet.StockId));
|
||||
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
|
||||
Assert.Equal(0, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "large").Used);
|
||||
Assert.Equal(1, result.StockUsage.Single(s => s.StockId == "large").Remaining);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3.0, false)]
|
||||
[InlineData(4.0001, true)]
|
||||
public void OpenMarkMustRemainInsideClosedMaterial(double endX, bool reject)
|
||||
{
|
||||
var program = TestDrawingFactory.Rectangle(4, 4);
|
||||
program.MoveTo(2, 2);
|
||||
program.LineTo(endX, 2);
|
||||
var part = new NestJobPart("exterior-mark", PartGeometrySnapshot.FromProgram(program), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
if (reject)
|
||||
{
|
||||
var error = Assert.Throws<ArgumentException>(() => new StockLadderNestingEngine().Solve(job));
|
||||
Assert.Contains("Open geometry leaves the closed material region", error.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Verify(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Verify(NestJob job, NestJobResult result)
|
||||
{
|
||||
var parts = job.Parts.ToDictionary(p => p.Id);
|
||||
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(sheet.Placements), sheet.Stock, remaining, parts);
|
||||
foreach (var pose in sheet.Placements) remaining[pose.PartId]--;
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
var poses = result.Plates.SelectMany(p => p.Placements).Where(p => p.PartId == part.Id).ToList();
|
||||
Assert.Equal(Enumerable.Range(0, poses.Count), poses.Select(p => p.InstanceIndex));
|
||||
var fulfillment = result.Fulfillment.Single(p => p.PartId == part.Id);
|
||||
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
|
||||
Assert.Equal(poses.Count, fulfillment.Placed);
|
||||
}
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
var count = result.Plates.Count(p => p.StockId == stock.Id);
|
||||
var usage = result.StockUsage.Single(s => s.StockId == stock.Id);
|
||||
Assert.Equal(count, usage.Used);
|
||||
Assert.Equal(stock.Quantity - count, usage.Remaining);
|
||||
Assert.True(stock.Quantity == null || count <= stock.Quantity);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CallbackNester(Func<PlatePlacementRequest, PlateCandidate> callback) : IPlateNester
|
||||
{
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => callback(request);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,27 @@ namespace OpenNest;
|
||||
/// <summary>Immutable per-job options; selection never changes the legacy global registry.</summary>
|
||||
public sealed class NestJobOptions
|
||||
{
|
||||
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null)
|
||||
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null,
|
||||
double salvageRate = 0, double minimumSalvageDimension = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
|
||||
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
|
||||
if (!double.IsFinite(salvageRate) || salvageRate < 0 || salvageRate > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(salvageRate));
|
||||
if (!double.IsFinite(minimumSalvageDimension) || minimumSalvageDimension < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(minimumSalvageDimension));
|
||||
PlacementStrategy = placementStrategy;
|
||||
MaxPlates = maxPlates;
|
||||
SalvageRate = salvageRate;
|
||||
MinimumSalvageDimension = minimumSalvageDimension;
|
||||
}
|
||||
|
||||
/// <summary>Fraction of eligible edge-offcut area credited by StockLadder (0..1).</summary>
|
||||
public double SalvageRate { get; }
|
||||
/// <summary>Both offcut dimensions must meet this caller-supplied minimum in job units.
|
||||
/// Zero disables credit; scraps and holes are never credited.</summary>
|
||||
public double MinimumSalvageDimension { get; }
|
||||
|
||||
public string PlacementStrategy { get; }
|
||||
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
|
||||
public int? MaxPlates { get; }
|
||||
|
||||
@@ -76,14 +76,131 @@ internal static class NestJobPlacementValidator
|
||||
|
||||
var contours = ShapeBuilder.GetShapes(cutEntities);
|
||||
if (contours.Count == 0) throw new ArgumentException("Geometry must contain a closed contour.");
|
||||
var closedEntities = new List<Entity>();
|
||||
var marks = new List<Shape>();
|
||||
foreach (var contour in contours)
|
||||
ValidateContour(contour);
|
||||
{
|
||||
if (contour.IsClosed())
|
||||
{
|
||||
ValidateContour(contour);
|
||||
closedEntities.AddRange(contour.Entities);
|
||||
}
|
||||
else marks.Add(contour);
|
||||
}
|
||||
if (closedEntities.Count == 0)
|
||||
throw new ArgumentException("Geometry must contain a closed outer contour.");
|
||||
|
||||
var profile = new ShapeProfile(cutEntities);
|
||||
// ShapeProfile selects the outer profile, but does not validate containment and
|
||||
// treats open chains as cutouts. Only validated closed contours may define material.
|
||||
var profile = new ShapeProfile(closedEntities);
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
ValidateInternalChain(cutout, profile.Perimeter, new List<Shape>());
|
||||
foreach (var mark in marks)
|
||||
ValidateMark(mark, profile.Perimeter, profile.Cutouts);
|
||||
profile.NormalizeWinding();
|
||||
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
|
||||
}
|
||||
|
||||
private static void ValidateMark(Shape mark, Shape perimeter, List<Shape> holes)
|
||||
{
|
||||
const double chordTolerance = 0.00001;
|
||||
var boundaries = new List<Shape> { perimeter };
|
||||
boundaries.AddRange(holes);
|
||||
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
|
||||
foreach (var entity in mark.Entities)
|
||||
{
|
||||
if (entity.Length <= Epsilon || entity is not (Line or Arc))
|
||||
throw new ArgumentException("Unsupported or degenerate internal mark.");
|
||||
var parameters = new List<double> { 0, 1 };
|
||||
foreach (var boundary in boundaries)
|
||||
{
|
||||
entity.Intersects(boundary, out var intersections);
|
||||
foreach (var point in intersections)
|
||||
AddParameter(point);
|
||||
// Include endpoints of coincident edges (parallel intersections may be empty).
|
||||
foreach (var point in boundary.Entities.CollectPoints())
|
||||
if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
|
||||
AddParameter(point);
|
||||
}
|
||||
parameters.Sort();
|
||||
for (var index = 0; index < parameters.Count; index++)
|
||||
{
|
||||
Check(PointAt(parameters[index]));
|
||||
if (index > 0) Check(PointAt((parameters[index - 1] + parameters[index]) / 2));
|
||||
}
|
||||
|
||||
void AddParameter(Vector point)
|
||||
{
|
||||
if (!point.IsValid()) throw new ArgumentException("Indeterminate mark intersection.");
|
||||
var value = entity is Line line
|
||||
? line.StartPoint.DistanceTo(point) / line.Length
|
||||
: Angle.NormalizeRad(((Arc)entity).IsReversed
|
||||
? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point)
|
||||
: ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle) / ((Arc)entity).SweepAngle();
|
||||
if (value >= 0 && value <= 1) parameters.Add(value);
|
||||
}
|
||||
Vector PointAt(double value)
|
||||
{
|
||||
if (entity is Line line) return line.StartPoint + (line.EndPoint - line.StartPoint) * value;
|
||||
var arc = (Arc)entity;
|
||||
var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value;
|
||||
return arc.Center + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius;
|
||||
}
|
||||
void Check(Vector point)
|
||||
{
|
||||
for (var index = 0; index < boundaries.Count; index++)
|
||||
{
|
||||
// Exact analytic boundary contact is allowed; near-boundary uncertainty is not.
|
||||
var onBoundary = false;
|
||||
foreach (var edge in boundaries[index].Entities)
|
||||
if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) onBoundary = true;
|
||||
if (onBoundary) continue;
|
||||
foreach (var edge in polygons[index].ToLines())
|
||||
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
|
||||
throw new ArgumentException("Internal mark is too close to a material boundary.");
|
||||
var inside = StrictlyInside(polygons[index], point);
|
||||
if (index == 0 ? !inside : inside)
|
||||
throw new ArgumentException("Open geometry leaves the closed material region.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateInternalChain(Shape chain, Shape perimeter, List<Shape> holes)
|
||||
{
|
||||
// A connected analytic entity cannot leave material without crossing its boundary.
|
||||
// Reject contact too: conservative, rather than guessing at tangent/collinear cuts.
|
||||
// The witness point is farther than the polygonization error from every boundary.
|
||||
const double chordTolerance = 0.00001;
|
||||
var boundaries = new List<Shape> { perimeter };
|
||||
boundaries.AddRange(holes);
|
||||
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
|
||||
foreach (var entity in chain.Entities)
|
||||
{
|
||||
if (entity.Length <= Epsilon)
|
||||
throw new ArgumentException("Geometry contains a zero-length internal edge.");
|
||||
var point = entity switch
|
||||
{
|
||||
Line line => line.StartPoint,
|
||||
Arc arc => arc.StartPoint(),
|
||||
Circle circle => circle.Center.Offset(circle.Radius, 0),
|
||||
_ => throw new ArgumentException("Unsupported internal geometry.")
|
||||
};
|
||||
if (!StrictlyInside(polygons[0], point))
|
||||
throw new ArgumentException("Open or disconnected geometry lies outside the closed perimeter.");
|
||||
for (var index = 0; index < boundaries.Count; index++)
|
||||
{
|
||||
if (index > 0 && polygons[index].ContainsPoint(point))
|
||||
throw new ArgumentException("Internal geometry lies in a cutout.");
|
||||
foreach (var edge in polygons[index].ToLines())
|
||||
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
|
||||
throw new ArgumentException("Internal geometry is too close to a material boundary.");
|
||||
if (entity.Intersects(boundaries[index]))
|
||||
throw new ArgumentException("Internal geometry crosses or touches a material boundary.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateContour(Shape contour)
|
||||
{
|
||||
if (!contour.IsClosed())
|
||||
@@ -145,65 +262,10 @@ internal static class NestJobPlacementValidator
|
||||
return false;
|
||||
// True material overlap requires shared interior area, not boundary touching.
|
||||
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
|
||||
return InteriorOverlap(leftPoly, left, rightPoly, right);
|
||||
}
|
||||
|
||||
private static bool InteriorOverlap(Polygon leftPoly, ShapeTopology left, Polygon rightPoly, ShapeTopology right)
|
||||
{
|
||||
// The intersection of two polygons is either empty, a region of positive area (true overlap),
|
||||
// or a zero-area line/point (boundary contact). Test the interior of the intersection region:
|
||||
// a point strictly inside BOTH perimeters and outside both parts' holes proves shared material.
|
||||
foreach (var point in InteriorWitnessPoints(leftPoly, rightPoly))
|
||||
{
|
||||
if (StrictlyInside(leftPoly, point) && !InAnyHole(left, point) &&
|
||||
StrictlyInside(rightPoly, point) && !InAnyHole(right, point))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points that lie in the interior of the perimeter-perimeter intersection when one exists.
|
||||
/// For each pair of crossing edges, the two interior-side vertices (one from each polygon)
|
||||
/// have their midpoint inside both perimeters; that midpoint is a witness of positive-area
|
||||
/// overlap. For containment, an interior vertex of the inner perimeter witnesses it.
|
||||
/// </summary>
|
||||
private static IEnumerable<Vector> InteriorWitnessPoints(Polygon left, Polygon right)
|
||||
{
|
||||
foreach (var l in left.ToLines())
|
||||
foreach (var r in right.ToLines())
|
||||
if (l.Intersects(r, out var pt) && pt.IsValid())
|
||||
{
|
||||
yield return Midpoint(l, pt);
|
||||
yield return Midpoint(r, pt);
|
||||
}
|
||||
// Containment: an interior point of one polygon inside the other. Use a point pulled
|
||||
// toward the centroid of each polygon from a vertex (guaranteed interior for simple shapes).
|
||||
foreach (var poly in new[] { left, right })
|
||||
{
|
||||
foreach (var vertex in poly.Vertices)
|
||||
{
|
||||
var centroid = Centroid(poly);
|
||||
yield return (vertex + centroid) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector Midpoint(Line line, Vector point)
|
||||
{
|
||||
var other = line.StartPoint.DistanceTo(point) <= line.EndPoint.DistanceTo(point)
|
||||
? line.EndPoint
|
||||
: line.StartPoint;
|
||||
return (other + point) * 0.5;
|
||||
}
|
||||
|
||||
private static Vector Centroid(Polygon polygon)
|
||||
{
|
||||
var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count;
|
||||
var sum = Vector.Zero;
|
||||
for (var i = 0; i < n; i++)
|
||||
sum += polygon.Vertices[i];
|
||||
return sum / n;
|
||||
// Collision checks this by clipping triangulated polygons and rejecting zero-area
|
||||
// slivers, so it catches containment and small corner intersections that a witness
|
||||
// probe can miss, while contact stays legal; cutouts are subtracted from both sides.
|
||||
return Collision.HasOverlap(leftPoly, rightPoly, ToPolygons(left.Cutouts), ToPolygons(right.Cutouts));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -243,14 +305,6 @@ internal static class NestJobPlacementValidator
|
||||
private static double IsLeft(Vector p1, Vector p2, Vector p) =>
|
||||
(p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X);
|
||||
|
||||
private static bool InAnyHole(ShapeTopology topology, Vector point)
|
||||
{
|
||||
foreach (var cutout in topology.Cutouts)
|
||||
if (ToPolygon(cutout).ContainsPoint(point))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double Distance(ShapeTopology left, ShapeTopology right)
|
||||
{
|
||||
var result = double.PositiveInfinity;
|
||||
|
||||
@@ -31,7 +31,7 @@ public static class NestJobValidator
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}.", nameof(job), exception);
|
||||
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}. {exception.Message}", nameof(job), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public static class NestingEngineRegistry
|
||||
|
||||
static NestingEngineRegistry()
|
||||
{
|
||||
Register("StockLadder", "Caller-stock constrained-first fill and equivalent-demand area repacking",
|
||||
() => new StockLadderNestingEngine());
|
||||
|
||||
Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
|
||||
() => new FixedStrategyNestingEngine("Default"));
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Constrained-order linear fills in conservative rectangular free regions.
|
||||
/// Regions are only search hints; every accepted pose passes the job geometry validator.</summary>
|
||||
internal sealed class OrderedPlateNester : IPlateNester
|
||||
{
|
||||
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
|
||||
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea();
|
||||
var poses = new List<NestJobPlacement>();
|
||||
var obstacles = new List<Box>();
|
||||
var requirements = request.Parts.ToDictionary(p => p.Id);
|
||||
var demand = request.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||
foreach (var requirement in request.Parts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!drawings.TryGetValue(requirement.Id, out var drawing))
|
||||
drawings.Add(requirement.Id, drawing = DrawingJobMapper.CreateDrawing(requirement));
|
||||
var left = requirement.Quantity;
|
||||
while (left > 0)
|
||||
{
|
||||
var regions = new RemnantFinder(work, obstacles).FindRemnants();
|
||||
List<Part> best = null;
|
||||
foreach (var region in regions)
|
||||
{
|
||||
foreach (var angle in Angles(requirement.Rotation))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// FillLinear uses actual line/arc geometry for copy distances.
|
||||
var parts = new FillLinear(region, request.Stock.PartSpacing)
|
||||
.Fill(drawing, angle, NestDirection.Horizontal).Take(left).ToList();
|
||||
if (parts.Count == 0 || (best != null && parts.Count <= best.Count)) continue;
|
||||
var trial = poses.Concat(parts.Select(p => new NestJobPlacement(requirement.Id, 0,
|
||||
p.Location.X, p.Location.Y, p.Rotation))).ToList();
|
||||
try
|
||||
{
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(trial), request.Stock, demand, requirements);
|
||||
best = parts;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Geometry kernels are proposal generators, never the acceptance gate.
|
||||
}
|
||||
if (best?.Count == left) break;
|
||||
}
|
||||
if (best?.Count == left) break;
|
||||
}
|
||||
if (best == null) break;
|
||||
foreach (var part in best)
|
||||
{
|
||||
poses.Add(new NestJobPlacement(requirement.Id, 0, part.Location.X, part.Location.Y, part.Rotation));
|
||||
obstacles.Add(part.BoundingBox.Offset(request.Stock.PartSpacing));
|
||||
}
|
||||
left -= best.Count;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new PlateCandidate(poses);
|
||||
}
|
||||
|
||||
private static IEnumerable<double> Angles(RotationPolicy policy)
|
||||
{
|
||||
if (policy.Kind == RotationPolicyKind.Fixed)
|
||||
{
|
||||
yield return policy.Start;
|
||||
yield break;
|
||||
}
|
||||
// A bounded deterministic search, not a proof that an unplaced part cannot fit.
|
||||
if (policy.Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
yield return 0;
|
||||
yield return System.Math.PI / 2;
|
||||
yield return System.Math.PI;
|
||||
yield return 3 * System.Math.PI / 2;
|
||||
for (var degrees = 5; degrees < 180; degrees += 5)
|
||||
if (degrees != 90) yield return degrees * System.Math.PI / 180;
|
||||
yield break;
|
||||
}
|
||||
for (var index = 0L; ; index++)
|
||||
{
|
||||
var angle = policy.Start + index * policy.Step;
|
||||
if (angle > policy.End + 1e-9) yield break;
|
||||
yield return angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
|
||||
/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional.
|
||||
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
|
||||
/// </summary>
|
||||
public sealed class StockLadderNestingEngine : INestingEngine
|
||||
{
|
||||
private readonly Func<IPlateNester> factory;
|
||||
public StockLadderNestingEngine() : this(() => new OrderedPlateNester()) { }
|
||||
public StockLadderNestingEngine(Func<IPlateNester> factory) =>
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.Validate(job);
|
||||
var nester = factory() ?? throw new InvalidOperationException("Null plate nester.");
|
||||
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
|
||||
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
||||
var areas = job.Parts.ToDictionary(p => p.Id, p => DrawingJobMapper.CreateDrawing(p).Area);
|
||||
var sheets = new List<NestJobPlateResult>();
|
||||
var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet<string>());
|
||||
|
||||
// Probe actual validated single-part placements, not bounding-box fit assertions.
|
||||
foreach (var part in job.Parts)
|
||||
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
|
||||
{
|
||||
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
|
||||
if (probe.Placements.Count != 0) feasible[part.Id].Add(stock.Id);
|
||||
}
|
||||
var ordered = job.Parts.OrderBy(p => p.Priority)
|
||||
.ThenBy(p => feasible[p.Id].Count).ThenByDescending(p => areas[p.Id]).ToList();
|
||||
var reason = NestJobStopReason.Completed;
|
||||
while (remaining.Values.Any(n => n > 0))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (job.Options.MaxPlates <= sheets.Count)
|
||||
{
|
||||
if (Consolidate()) continue;
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
var available = job.Plates.Where(s => s.Quantity == null || used[s.Id] < s.Quantity).ToList();
|
||||
if (available.Count == 0)
|
||||
{
|
||||
if (Consolidate()) continue;
|
||||
reason = NestJobStopReason.StockExhausted;
|
||||
break;
|
||||
}
|
||||
var anchor = ordered.FirstOrDefault(p => remaining[p.Id] > 0 &&
|
||||
available.Any(s => feasible[p.Id].Contains(s.Id)));
|
||||
if (anchor == null)
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
NestJobPlateResult winner = null;
|
||||
var score = double.PositiveInfinity;
|
||||
foreach (var stock in available.Where(s => feasible[anchor.Id].Contains(s.Id)))
|
||||
{
|
||||
// Pin the constrained anchor before fillers, including quantity-one requirements.
|
||||
var requests = new[] { anchor }.Concat(ordered.Where(p => p.Id != anchor.Id))
|
||||
.Where(p => remaining[p.Id] > 0).Select(p => WithQuantity(p, remaining[p.Id]));
|
||||
var candidate = Trial(stock, requests);
|
||||
if (!candidate.Placements.Any(p => p.PartId == anchor.Id)) continue;
|
||||
var sheet = new NestJobPlateResult(sheets.Count, stock, candidate.Placements);
|
||||
// Initial construction only: material area, never raw part counts. Repacking below
|
||||
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
|
||||
var value = EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
|
||||
if (value < score - 1e-9)
|
||||
{
|
||||
winner = sheet;
|
||||
score = value;
|
||||
}
|
||||
}
|
||||
if (winner == null)
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
sheets.Add(winner);
|
||||
used[winner.StockId]++;
|
||||
foreach (var pose in winner.Placements) remaining[pose.PartId]--;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.StockId,
|
||||
sheets.Count - 1, sheets.Count, sheets.Sum(s => s.Placements.Count)));
|
||||
}
|
||||
Consolidate();
|
||||
token.ThrowIfCancellationRequested();
|
||||
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0);
|
||||
var final = sheets.Select((sheet, index) => new NestJobPlateResult(index, sheet.Stock,
|
||||
sheet.Placements.Select(p => p with { InstanceIndex = placed[p.PartId]++ }).ToList())).ToList();
|
||||
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
reason, final, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
|
||||
job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id])));
|
||||
|
||||
PlateCandidate Trial(NestPlateStock stock, IEnumerable<NestJobPart> requirements)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var request = new PlatePlacementRequest(stock, requirements);
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
|
||||
sheets.Count, sheets.Count, sheets.Sum(s => s.Placements.Count)));
|
||||
var candidate = nester.Place(request, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.ValidateCandidate(candidate, stock, request.Parts.ToDictionary(p => p.Id, p => p.Quantity), parts);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
bool Consolidate()
|
||||
{
|
||||
var changed = false;
|
||||
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
|
||||
for (var index = 0; index < sheets.Count; index++)
|
||||
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
|
||||
{
|
||||
var old = sheets.Skip(index).Take(count).ToList();
|
||||
var demand = old.SelectMany(s => s.Placements).GroupBy(p => p.PartId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var baseline = old.Sum(s => EstimateNetArea(job, s));
|
||||
NestJobPlateResult replacement = null;
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var returned = old.Count(s => s.StockId == stock.Id);
|
||||
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) continue;
|
||||
// Even the maximum possible salvage credit cannot beat the incumbent.
|
||||
var lowerBound = stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
|
||||
if (lowerBound >= baseline - 1e-9) continue;
|
||||
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id))) continue;
|
||||
var candidate = Trial(stock, ordered.Where(p => demand.ContainsKey(p.Id))
|
||||
.Select(p => WithQuantity(p, demand[p.Id])));
|
||||
var actual = candidate.Placements.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
|
||||
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) continue;
|
||||
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
|
||||
var cost = EstimateNetArea(job, trial);
|
||||
if (cost >= baseline - 1e-9) continue;
|
||||
baseline = cost;
|
||||
replacement = trial;
|
||||
}
|
||||
if (replacement == null) continue;
|
||||
// No accounting changes until the entire equivalent-demand candidate is valid.
|
||||
foreach (var sheet in old) used[sheet.StockId]--;
|
||||
used[replacement.StockId]++;
|
||||
sheets.RemoveRange(index, count);
|
||||
sheets.Insert(index, replacement);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
private static NestJobPart WithQuantity(NestJobPart part, int quantity) =>
|
||||
new(part.Id, part.Geometry, quantity, part.Priority, part.Rotation);
|
||||
|
||||
/// <summary>Full physical sheet area minus a conservative offcut estimate. Credits only ONE
|
||||
/// empty full-span edge rectangle outside every placed bounding box plus part clearance, within
|
||||
/// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified
|
||||
/// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled.</summary>
|
||||
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
|
||||
{
|
||||
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
|
||||
var minimum = job.Options.MinimumSalvageDimension;
|
||||
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) return area;
|
||||
var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
|
||||
var parts = job.Parts.ToDictionary(p => p.Id);
|
||||
var boxes = sheet.Placements.Select(p =>
|
||||
{
|
||||
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
|
||||
part.Rotate(p.Rotation);
|
||||
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
|
||||
part.UpdateBounds();
|
||||
return part.BoundingBox;
|
||||
}).ToList();
|
||||
var gap = sheet.Stock.PartSpacing;
|
||||
var candidates = new[]
|
||||
{
|
||||
(work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
|
||||
(work.Length, work.Top - boxes.Max(b => b.Top) - gap),
|
||||
(boxes.Min(b => b.Left) - work.Left - gap, work.Width),
|
||||
(work.Right - boxes.Max(b => b.Right) - gap, work.Width)
|
||||
};
|
||||
var salvage = candidates.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
|
||||
.Select(c => c.Item1 * c.Item2).DefaultIfEmpty(0).Max();
|
||||
return area - job.Options.SalvageRate * salvage;
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,6 @@ namespace OpenNest.IO
|
||||
public ExportContext()
|
||||
{
|
||||
Document = new CadDocument();
|
||||
Document.Header.Version = ACadVersion.AC1018;
|
||||
|
||||
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
||||
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
|
||||
/// configuration PropertyGrid and persisted to JSON.
|
||||
/// </summary>
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public sealed class LayerCutConfig
|
||||
{
|
||||
[DisplayName("Feed (mm/sec)")]
|
||||
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
|
||||
public int FeedMmPerSec { get; set; } = 10;
|
||||
|
||||
[DisplayName("Depth (inches)")]
|
||||
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
|
||||
public double Depth { get; set; } = 0.25;
|
||||
|
||||
[DisplayName("Pause Before")]
|
||||
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
[DisplayName("Pause Message")]
|
||||
[Description("Message shown on the controller during the pause.")]
|
||||
public string PauseMessage { get; set; } = "";
|
||||
|
||||
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
|
||||
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
|
||||
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
|
||||
/// The cut block carries the tool-change pause by default.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostConfig
|
||||
{
|
||||
[Category("Engrave (Scribe)")]
|
||||
[DisplayName("Engrave")]
|
||||
[Description("Parameters for engrave/scribe geometry (text).")]
|
||||
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 10,
|
||||
Depth = 0.25,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
};
|
||||
|
||||
[Category("Cut")]
|
||||
[DisplayName("Cut")]
|
||||
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
|
||||
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
|
||||
{
|
||||
FeedMmPerSec = 3,
|
||||
Depth = 0.25,
|
||||
PauseBefore = true,
|
||||
PauseMessage = "Change tool",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cut config a polyline of the given layer should use, or null
|
||||
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
|
||||
/// </summary>
|
||||
public LayerCutConfig ConfigFor(LayerType layer)
|
||||
{
|
||||
switch (layer)
|
||||
{
|
||||
case LayerType.Scribe:
|
||||
return Engrave;
|
||||
case LayerType.Cut:
|
||||
case LayerType.Leadin:
|
||||
case LayerType.Leadout:
|
||||
return Cut;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Ports;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
|
||||
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
|
||||
///
|
||||
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
|
||||
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
|
||||
/// The cut pass pauses by default so the operator can swap/adjust the tool.
|
||||
/// </summary>
|
||||
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
|
||||
public sealed class GravographISPostProcessor : IPostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public string Name => "Gravograph IS8000";
|
||||
public string Author => "OpenNest";
|
||||
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
|
||||
@@ -37,53 +23,14 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
public bool AllowReverse { get; set; } = true;
|
||||
|
||||
public GravographISPostConfig Config { get; }
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public GravographISPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
Config = JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
|
||||
?? new GravographISPostConfig();
|
||||
}
|
||||
else
|
||||
{
|
||||
Config = new GravographISPostConfig();
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public GravographISPostProcessor(GravographISPostConfig config)
|
||||
{
|
||||
Config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||
File.WriteAllText(configPath, json);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
|
||||
var dir = Path.GetDirectoryName(assemblyPath);
|
||||
var name = Path.GetFileNameWithoutExtension(assemblyPath);
|
||||
return Path.Combine(dir, name + ".json");
|
||||
}
|
||||
|
||||
public void Post(Nest nest, Stream outputStream)
|
||||
{
|
||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
|
||||
|
||||
var passes = BuildPasses(Extractor.ExtractLayered(nest));
|
||||
new GravographISWriter(WriterOptions).Write(passes, outputStream);
|
||||
var polylines = Extractor.Extract(nest);
|
||||
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
|
||||
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
|
||||
}
|
||||
|
||||
public void Post(Nest nest, string outputFile)
|
||||
@@ -92,52 +39,6 @@ namespace OpenNest.Posts.GravographIS
|
||||
Post(nest, fs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
|
||||
/// first, then cut. Each group is stitch/reverse-optimized independently.
|
||||
/// Geometry whose layer maps to no config (Display) is skipped. When only one
|
||||
/// group is present, a single pass is returned (and so the writer emits no pause).
|
||||
/// </summary>
|
||||
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
|
||||
{
|
||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
var engrave = new List<IReadOnlyList<Vector>>();
|
||||
var cut = new List<IReadOnlyList<Vector>>();
|
||||
|
||||
foreach (var poly in polylines)
|
||||
{
|
||||
if (poly == null) continue;
|
||||
var block = Config.ConfigFor(poly.Layer);
|
||||
if (block == null)
|
||||
continue; // non-cutting (Display) geometry
|
||||
if (ReferenceEquals(block, Config.Engrave))
|
||||
engrave.Add(poly.Points);
|
||||
else
|
||||
cut.Add(poly.Points);
|
||||
}
|
||||
|
||||
var passes = new List<GravographPass>();
|
||||
if (engrave.Count > 0)
|
||||
passes.Add(MakePass(Config.Engrave, engrave));
|
||||
if (cut.Count > 0)
|
||||
passes.Add(MakePass(Config.Cut, cut));
|
||||
|
||||
return passes;
|
||||
}
|
||||
|
||||
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
|
||||
{
|
||||
return new GravographPass
|
||||
{
|
||||
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
|
||||
FeedMmPerSec = block.FeedMmPerSec,
|
||||
DepthInches = block.Depth,
|
||||
PauseBefore = block.PauseBefore,
|
||||
PauseMessage = block.PauseMessage ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffers the encoded job in memory, then streams it to the named COM port.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,30 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
|
||||
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
|
||||
/// post builds one pass for engrave and one for cut.
|
||||
/// </summary>
|
||||
public sealed class GravographPass
|
||||
{
|
||||
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
|
||||
|
||||
public int FeedMmPerSec { get; set; }
|
||||
|
||||
public double DepthInches { get; set; }
|
||||
|
||||
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
|
||||
public bool PauseBefore { get; set; }
|
||||
|
||||
public string PauseMessage { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
|
||||
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
|
||||
@@ -104,40 +84,12 @@ namespace OpenNest.Posts.GravographIS
|
||||
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
|
||||
{
|
||||
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
|
||||
|
||||
// A single pass at the configured feed/depth — byte-identical to the
|
||||
// original single-group output (no transitions, no pause).
|
||||
Write(new[]
|
||||
{
|
||||
new GravographPass
|
||||
{
|
||||
Polylines = polylines,
|
||||
FeedMmPerSec = Options.FeedMmPerSec,
|
||||
DepthInches = Options.DepthInches,
|
||||
PauseBefore = false,
|
||||
PauseMessage = "",
|
||||
},
|
||||
}, output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the full byte stream for an ordered list of tool passes. The preamble
|
||||
/// carries the first pass's feed/depth; each later pass emits an inline feed
|
||||
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
|
||||
/// set, parks to the operator origin and emits an operator pause before cutting.
|
||||
/// </summary>
|
||||
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
|
||||
{
|
||||
if (passes == null) throw new ArgumentNullException(nameof(passes));
|
||||
if (output == null) throw new ArgumentNullException(nameof(output));
|
||||
|
||||
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
|
||||
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
|
||||
|
||||
var preamble = (byte[])PreambleTemplate.Clone();
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
|
||||
PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
|
||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
|
||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
|
||||
output.Write(preamble, 0, preamble.Length);
|
||||
|
||||
// Cumulative head position from the operator-set upper-left origin, in
|
||||
@@ -153,51 +105,45 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
var firstPolyline = true;
|
||||
var polyIndex = 0;
|
||||
var currentFeed = firstFeed;
|
||||
var currentDepth = firstDepth;
|
||||
|
||||
for (var p = 0; p < passes.Count; p++)
|
||||
foreach (var poly in polylines)
|
||||
{
|
||||
var pass = passes[p];
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
|
||||
if (p > 0)
|
||||
var (startX, startY) = ToWire(poly[0]);
|
||||
WriteTravel(output,
|
||||
firstPolyline ? (byte)'D' : (byte)'P',
|
||||
firstPolyline ? (byte)'R' : (byte)'U',
|
||||
checked(startX - headX), checked(startY - headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
|
||||
// PD command + single records-follow flag, then one record per segment.
|
||||
output.WriteByte(0xFF);
|
||||
output.WriteByte(0xFD);
|
||||
output.WriteByte((byte)'P');
|
||||
output.WriteByte((byte)'D');
|
||||
output.WriteByte(0x00);
|
||||
output.WriteByte(0x00);
|
||||
|
||||
var prevX = startX;
|
||||
var prevY = startY;
|
||||
for (int i = 1; i < poly.Count; i++)
|
||||
{
|
||||
if (pass.PauseBefore)
|
||||
{
|
||||
// Park: lift Z, then rapid (pen-up) back to the operator origin so
|
||||
// the head is clear of the work while the tool is swapped.
|
||||
WriteLiftOnly(output);
|
||||
WriteTravel(output, (byte)'P', (byte)'U',
|
||||
checked(-headX), checked(-headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
WritePauseCore(output, pass.PauseMessage);
|
||||
}
|
||||
|
||||
if (pass.FeedMmPerSec != currentFeed)
|
||||
{
|
||||
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
|
||||
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
|
||||
currentFeed = pass.FeedMmPerSec;
|
||||
}
|
||||
|
||||
if (pass.DepthInches != currentDepth)
|
||||
{
|
||||
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
|
||||
currentDepth = pass.DepthInches;
|
||||
}
|
||||
var (cx, cy) = ToWire(poly[i]);
|
||||
var dx = checked(cx - prevX);
|
||||
var dy = checked(cy - prevY);
|
||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||
polyIndex, segment: i, isTravel: false);
|
||||
WriteRecord(output, dx, dy);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
headX += dx;
|
||||
headY += dy;
|
||||
}
|
||||
|
||||
if (pass.Polylines == null) continue;
|
||||
|
||||
foreach (var poly in pass.Polylines)
|
||||
{
|
||||
polyIndex++;
|
||||
if (poly == null || poly.Count < 2)
|
||||
continue;
|
||||
|
||||
WritePolyline(output, poly, ref firstPolyline, ref headX, ref headY,
|
||||
envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
}
|
||||
firstPolyline = false;
|
||||
}
|
||||
|
||||
WriteLiftOnly(output);
|
||||
@@ -210,89 +156,6 @@ namespace OpenNest.Posts.GravographIS
|
||||
output.Write(EndJobBytes, 0, EndJobBytes.Length);
|
||||
}
|
||||
|
||||
private void WritePolyline(Stream output, IReadOnlyList<Vector> poly,
|
||||
ref bool firstPolyline, ref int headX, ref int headY,
|
||||
int envelopeXSteps, int envelopeYSteps, int polyIndex)
|
||||
{
|
||||
var (startX, startY) = ToWire(poly[0]);
|
||||
WriteTravel(output,
|
||||
firstPolyline ? (byte)'D' : (byte)'P',
|
||||
firstPolyline ? (byte)'R' : (byte)'U',
|
||||
checked(startX - headX), checked(startY - headY),
|
||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||
|
||||
// PD command + single records-follow flag, then one record per segment.
|
||||
output.WriteByte(0xFF);
|
||||
output.WriteByte(0xFD);
|
||||
output.WriteByte((byte)'P');
|
||||
output.WriteByte((byte)'D');
|
||||
output.WriteByte(0x00);
|
||||
output.WriteByte(0x00);
|
||||
|
||||
var prevX = startX;
|
||||
var prevY = startY;
|
||||
for (int i = 1; i < poly.Count; i++)
|
||||
{
|
||||
var (cx, cy) = ToWire(poly[i]);
|
||||
var dx = checked(cx - prevX);
|
||||
var dy = checked(cy - prevY);
|
||||
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
|
||||
polyIndex, segment: i, isTravel: false);
|
||||
WriteRecord(output, dx, dy);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
headX += dx;
|
||||
headY += dy;
|
||||
}
|
||||
|
||||
firstPolyline = false;
|
||||
}
|
||||
|
||||
// The operator pause, minus the leading lift/park which the caller emits.
|
||||
// Stops the spindle (MC off), turns off aux, writes the console message, then
|
||||
// restarts the spindle (MC on) so the job resumes when the operator presses start.
|
||||
private static void WritePauseCore(Stream s, string message)
|
||||
{
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
|
||||
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
|
||||
WriteMessagePackets(s, message);
|
||||
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
|
||||
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
|
||||
}
|
||||
|
||||
// Console label packets carry two ASCII chars each; an odd-length message is
|
||||
// space-padded to a whole number of packets so widths stay 2 bytes.
|
||||
private static void WriteMessagePackets(Stream s, string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return;
|
||||
|
||||
var chars = Encoding.ASCII.GetBytes(message);
|
||||
for (var i = 0; i < chars.Length; i += 2)
|
||||
{
|
||||
var c0 = chars[i];
|
||||
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
|
||||
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
|
||||
{
|
||||
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
|
||||
}
|
||||
|
||||
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
s.WriteByte(0xFF);
|
||||
s.WriteByte(0xFD);
|
||||
s.WriteByte(c0);
|
||||
s.WriteByte(c1);
|
||||
s.WriteByte(hi);
|
||||
s.WriteByte(lo);
|
||||
}
|
||||
|
||||
private const double StepsPerMm = 80.0;
|
||||
|
||||
private void EnsureEnvelope(int wireX, int wireY,
|
||||
@@ -318,11 +181,11 @@ namespace OpenNest.Posts.GravographIS
|
||||
$"work envelope from upper-left origin. Refusing to emit the record.");
|
||||
}
|
||||
|
||||
private static short DepthInStepsAsInt16(double depthInches)
|
||||
private short DepthInStepsAsInt16()
|
||||
{
|
||||
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero);
|
||||
if (steps < short.MinValue || steps > short.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16.");
|
||||
throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16.");
|
||||
return (short)steps;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
/// <summary>
|
||||
/// A polyline together with the <see cref="LayerType"/> of the moves that
|
||||
/// produced it. The Gravograph post groups by layer to emit separate engrave
|
||||
/// and cut passes (with a tool-change pause between them).
|
||||
/// </summary>
|
||||
public sealed class LayeredPolyline
|
||||
{
|
||||
public LayeredPolyline(List<Vector> points, LayerType layer)
|
||||
{
|
||||
Points = points;
|
||||
Layer = layer;
|
||||
}
|
||||
|
||||
public List<Vector> Points { get; }
|
||||
|
||||
public LayerType Layer { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
|
||||
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
|
||||
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
|
||||
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
|
||||
/// line segments only).
|
||||
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
|
||||
/// tolerance (the wire format takes line segments only).
|
||||
/// </summary>
|
||||
public sealed class NestPolylineExtractor
|
||||
{
|
||||
@@ -37,31 +17,13 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines from every non-cutoff part in every plate of the nest,
|
||||
/// returning them in plate coordinates (inches). Layer information is dropped;
|
||||
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
|
||||
/// returning them in plate coordinates (inches).
|
||||
/// </summary>
|
||||
public List<List<Vector>> Extract(Nest nest)
|
||||
{
|
||||
return ExtractLayered(nest).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts polylines for a single part without layer information.
|
||||
/// </summary>
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
{
|
||||
return ExtractPartLayered(part).Select(p => p.Points).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
|
||||
/// in plate coordinates (inches). Each polyline is layer-uniform.
|
||||
/// </summary>
|
||||
public List<LayeredPolyline> ExtractLayered(Nest nest)
|
||||
{
|
||||
if (nest == null) throw new ArgumentNullException(nameof(nest));
|
||||
|
||||
var result = new List<LayeredPolyline>();
|
||||
var result = new List<List<Vector>>();
|
||||
|
||||
foreach (var plate in nest.Plates)
|
||||
{
|
||||
@@ -78,17 +40,17 @@ namespace OpenNest.Posts.GravographIS
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts layer-tagged polylines for a single part. Public so callers
|
||||
/// driving the writer directly (e.g. from a console one-off) can use it.
|
||||
/// Extracts polylines for a single part. Public so callers driving the
|
||||
/// writer directly (e.g. from a console one-off) can use it.
|
||||
/// </summary>
|
||||
public List<LayeredPolyline> ExtractPartLayered(Part part)
|
||||
public List<List<Vector>> ExtractPart(Part part)
|
||||
{
|
||||
var list = new List<LayeredPolyline>();
|
||||
var list = new List<List<Vector>>();
|
||||
ExtractPart(part, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
private void ExtractPart(Part part, List<LayeredPolyline> sink)
|
||||
private void ExtractPart(Part part, List<List<Vector>> sink)
|
||||
{
|
||||
var program = part.Program;
|
||||
if (program == null) return;
|
||||
@@ -105,7 +67,6 @@ namespace OpenNest.Posts.GravographIS
|
||||
var offset = part.Location;
|
||||
var pos = new Vector(0, 0);
|
||||
List<Vector> current = null;
|
||||
var currentLayer = LayerType.Cut;
|
||||
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
@@ -116,14 +77,17 @@ namespace OpenNest.Posts.GravographIS
|
||||
{
|
||||
case RapidMove rapid:
|
||||
{
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
FlushCurrent(sink, ref current);
|
||||
pos = rapid.EndPoint;
|
||||
break;
|
||||
}
|
||||
|
||||
case LinearMove linear:
|
||||
{
|
||||
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { pos + offset };
|
||||
}
|
||||
var end = linear.EndPoint;
|
||||
current.Add(end + offset);
|
||||
pos = end;
|
||||
@@ -132,7 +96,10 @@ namespace OpenNest.Posts.GravographIS
|
||||
|
||||
case ArcMove arc:
|
||||
{
|
||||
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { pos + offset };
|
||||
}
|
||||
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
|
||||
pos = arc.EndPoint;
|
||||
break;
|
||||
@@ -140,33 +107,13 @@ namespace OpenNest.Posts.GravographIS
|
||||
}
|
||||
}
|
||||
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
FlushCurrent(sink, ref current);
|
||||
}
|
||||
|
||||
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
|
||||
// seeded at `seed` (the current pen position). When the layer changes
|
||||
// mid-chain the previous polyline is flushed and a new one begins at the
|
||||
// shared seam vertex so engrave and cut passes stay geometrically continuous.
|
||||
private static void StartOrSplit(List<LayeredPolyline> sink, ref List<Vector> current,
|
||||
ref LayerType currentLayer, LayerType moveLayer, Vector seed)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
else if (moveLayer != currentLayer)
|
||||
{
|
||||
FlushCurrent(sink, ref current, currentLayer);
|
||||
current = new List<Vector> { seed };
|
||||
currentLayer = moveLayer;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlushCurrent(List<LayeredPolyline> sink, ref List<Vector> current, LayerType layer)
|
||||
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> current)
|
||||
{
|
||||
if (current != null && current.Count >= 2)
|
||||
sink.Add(new LayeredPolyline(current, layer));
|
||||
sink.Add(current);
|
||||
current = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Converters;
|
||||
|
||||
public class ConvertGeometryLayerTests
|
||||
{
|
||||
private static Program ProgramFor(Entity entity)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(entity);
|
||||
return ConvertGeometry.ToProgram(shape);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_EtchLayer_TagsScribe()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddArc_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(arc);
|
||||
|
||||
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||
Assert.NotEmpty(arcs);
|
||||
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCircle_EngraveLayer_TagsScribe()
|
||||
{
|
||||
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
|
||||
|
||||
var pgm = ProgramFor(circle);
|
||||
|
||||
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||
Assert.NotEmpty(arcs);
|
||||
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddLine_DefaultLayer_StaysCut()
|
||||
{
|
||||
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
|
||||
|
||||
var pgm = ProgramFor(line);
|
||||
|
||||
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
|
||||
}
|
||||
}
|
||||
@@ -131,111 +131,6 @@ public class GeometrySimplifierTests
|
||||
Assert.IsType<Arc>(result.Entities[6]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_FilletBetweenTangentLines_ArcIsTangentToLines()
|
||||
{
|
||||
// A 90-degree fillet (r=0.3, center origin, 270deg..360deg CCW) between two
|
||||
// long tangent lines, approximated by 8 chords whose interior vertices bulge
|
||||
// radially outward within tolerance (simulates real DXF tessellation noise).
|
||||
var r = 0.3;
|
||||
var deltas = new[] { 0.0, 0.002, 0.003, 0.0035, 0.0035, 0.0035, 0.003, 0.002, 0.0 };
|
||||
var pts = new List<Vector>();
|
||||
for (var i = 0; i <= 8; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(270 + 11.25 * i);
|
||||
var radius = r + deltas[i];
|
||||
pts.Add(new Vector(radius * System.Math.Cos(ang), radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(-2, -r), pts[0]));
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||
shape.Entities.Add(new Line(pts[^1], new Vector(r, 2)));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Single(candidates);
|
||||
var arc = candidates[0].FittedArc;
|
||||
|
||||
// Arc must pass exactly through the run's boundary vertices (no gaps)
|
||||
Assert.True(arc.StartPoint().DistanceTo(pts[0]) < 1e-6);
|
||||
Assert.True(arc.EndPoint().DistanceTo(pts[^1]) < 1e-6);
|
||||
|
||||
// Arc must be tangent to the adjacent straight edges at its endpoints
|
||||
var startDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.StartPoint()), new Vector(1, 0));
|
||||
var endDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.EndPoint()), new Vector(0, 1));
|
||||
Assert.True(startDelta < 0.3, $"Arc start not tangent to incoming line: off by {startDelta:F3} deg");
|
||||
Assert.True(endDelta < 0.3, $"Arc end not tangent to outgoing line: off by {endDelta:F3} deg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_CompoundCurve_AdjacentArcsAreTangentAtJunction()
|
||||
{
|
||||
// Two tangent-continuous arcs of different radii (r=0.2 sweeping 60deg, then
|
||||
// r=0.6 sweeping 40deg), tessellated into chords with slight radial noise.
|
||||
// The fitted arcs must stay tangent-continuous at their junction.
|
||||
var c1 = new Vector(0, 0);
|
||||
var r1 = 0.2;
|
||||
var deltas1 = new[] { 0.0, 0.001, 0.0005, -0.0005, -0.001, -0.0005, 0.0 };
|
||||
var pts = new List<Vector>();
|
||||
for (var i = 0; i <= 6; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(10 * i);
|
||||
var radius = r1 + deltas1[i];
|
||||
pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
// Second arc center along the junction radius so tangents match at the junction
|
||||
var junctionAngle = OpenNest.Math.Angle.ToRadians(60);
|
||||
var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle));
|
||||
var r2 = 0.6;
|
||||
var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2));
|
||||
var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 };
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i);
|
||||
var radius = r2 + deltas2[i];
|
||||
pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang)));
|
||||
}
|
||||
|
||||
var shape = new Shape();
|
||||
for (var i = 0; i < pts.Count - 1; i++)
|
||||
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||
|
||||
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||
var candidates = simplifier.Analyze(shape);
|
||||
|
||||
Assert.Equal(2, candidates.Count);
|
||||
var arcA = candidates[0].FittedArc;
|
||||
var arcB = candidates[1].FittedArc;
|
||||
|
||||
// Arcs must share the junction vertex exactly
|
||||
Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6);
|
||||
|
||||
// Tangent continuity across the junction
|
||||
var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint()));
|
||||
Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction");
|
||||
}
|
||||
|
||||
private static Vector ArcTangentAt(Arc arc, Vector pt)
|
||||
{
|
||||
var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X);
|
||||
return arc.IsReversed
|
||||
? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang))
|
||||
: new Vector(-System.Math.Sin(ang), System.Math.Cos(ang));
|
||||
}
|
||||
|
||||
private static double AngleBetweenDeg(Vector v1, Vector v2)
|
||||
{
|
||||
var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y);
|
||||
var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y);
|
||||
var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2);
|
||||
dot = System.Math.Max(-1, System.Math.Min(1, dot));
|
||||
return System.Math.Acos(dot) * 180.0 / System.Math.PI;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
||||
{
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Posts.GravographIS;
|
||||
|
||||
namespace OpenNest.Tests.GravographIS;
|
||||
|
||||
public class GravographISPostConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_EngraveBlock_NoPause_FasterFeed()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
|
||||
Assert.Equal(10, config.Engrave.FeedMmPerSec);
|
||||
Assert.False(config.Engrave.PauseBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_CutBlock_PausesToChangeTool()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
|
||||
Assert.Equal(3, config.Cut.FeedMmPerSec);
|
||||
Assert.True(config.Cut.PauseBefore);
|
||||
Assert.Equal("Change tool", config.Cut.PauseMessage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LayerType.Scribe)]
|
||||
public void ConfigFor_Scribe_ReturnsEngraveBlock(LayerType layer)
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Same(config.Engrave, config.ConfigFor(layer));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LayerType.Cut)]
|
||||
[InlineData(LayerType.Leadin)]
|
||||
[InlineData(LayerType.Leadout)]
|
||||
public void ConfigFor_CutLayers_ReturnCutBlock(LayerType layer)
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Same(config.Cut, config.ConfigFor(layer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigFor_Display_ReturnsNull_SoItIsSkipped()
|
||||
{
|
||||
var config = new GravographISPostConfig();
|
||||
Assert.Null(config.ConfigFor(LayerType.Display));
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.GravographIS;
|
||||
|
||||
namespace OpenNest.Tests.GravographIS;
|
||||
|
||||
public class GravographISPostProcessorTests
|
||||
{
|
||||
private static LayeredPolyline Poly(LayerType layer, params Vector[] pts)
|
||||
=> new LayeredPolyline(new List<Vector>(pts), layer);
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_EngraveAndCut_OrdersEngraveFirstThenCutWithPause()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||
Poly(LayerType.Scribe, new Vector(0, 0), new Vector(0, 1)),
|
||||
});
|
||||
|
||||
Assert.Equal(2, passes.Count);
|
||||
|
||||
Assert.Equal(post.Config.Engrave.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
Assert.False(passes[0].PauseBefore);
|
||||
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[1].FeedMmPerSec);
|
||||
Assert.True(passes[1].PauseBefore);
|
||||
Assert.Equal("Change tool", passes[1].PauseMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_CutOnly_IsSinglePass()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||
});
|
||||
|
||||
Assert.Single(passes);
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPasses_SkipsDisplayGeometry()
|
||||
{
|
||||
var post = new GravographISPostProcessor();
|
||||
|
||||
var passes = post.BuildPasses(new[]
|
||||
{
|
||||
Poly(LayerType.Display, new Vector(0, 0), new Vector(1, 0)),
|
||||
Poly(LayerType.Cut, new Vector(0, 0), new Vector(0, 1)),
|
||||
});
|
||||
|
||||
Assert.Single(passes);
|
||||
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Config_IsExposedThroughConfigurableInterface()
|
||||
{
|
||||
var post = new GravographISPostProcessor(new GravographISPostConfig());
|
||||
OpenNest.IConfigurablePostProcessor configurable = post;
|
||||
Assert.Same(post.Config, configurable.Config);
|
||||
}
|
||||
}
|
||||
@@ -178,101 +178,6 @@ public class GravographISWriterTests
|
||||
Assert.Equal(-GravographISWriter.StepsPerInch, dy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_PauseBeforeCut_EmitsPauseSequenceBetweenGroups()
|
||||
{
|
||||
var engrave = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } };
|
||||
var cut = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } };
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = engrave, FeedMmPerSec = 10, DepthInches = 0.25 },
|
||||
new GravographPass { Polylines = cut, FeedMmPerSec = 3, DepthInches = 0.25, PauseBefore = true, PauseMessage = "Hi" },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions
|
||||
{
|
||||
EnvelopeGuardEnabled = false,
|
||||
ReturnToOriginAtEnd = false,
|
||||
}).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var mcOff = IndexOf(bytes, 0, (byte)'M', (byte)'C', 0x00, 0x00);
|
||||
var ouFb = IndexOf(bytes, mcOff, (byte)'O', (byte)'U', 0xFF, 0xFB);
|
||||
var ouFa = IndexOf(bytes, ouFb, (byte)'O', (byte)'U', 0xFF, 0xFA);
|
||||
var lbBegin = IndexOf(bytes, ouFa, (byte)'L', (byte)'B', 0x00, 0x00);
|
||||
var lbMsg = IndexOf(bytes, lbBegin, (byte)'L', (byte)'B', (byte)'H', (byte)'i');
|
||||
var nr = IndexOf(bytes, lbMsg, (byte)'N', (byte)'R', 0x00, 0x01);
|
||||
var lbEnd = IndexOf(bytes, nr, (byte)'L', (byte)'B', 0x00, 0x01);
|
||||
var mcOn = IndexOf(bytes, lbEnd, (byte)'M', (byte)'C', 0x00, 0x01);
|
||||
|
||||
Assert.True(mcOff >= 0, "motor-off (MC 0000) not found");
|
||||
Assert.True(mcOff < ouFb && ouFb < ouFa && ouFa < lbBegin && lbBegin < lbMsg
|
||||
&& lbMsg < nr && nr < lbEnd && lbEnd < mcOn,
|
||||
"pause commands out of order");
|
||||
|
||||
// Resume sets the cut feed inline (VS 0x0003) after the motor restarts.
|
||||
var vsCut = IndexOf(bytes, mcOn, (byte)'V', (byte)'S', 0x00, 0x03);
|
||||
Assert.True(vsCut > mcOn, "cut feed not set after resume");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_PauseOddMessage_SpacePadsLastPacket()
|
||||
{
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = true, PauseMessage = "abc" },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
var lbAb = IndexOf(bytes, 0, (byte)'L', (byte)'B', (byte)'a', (byte)'b');
|
||||
var lbCPad = IndexOf(bytes, lbAb, (byte)'L', (byte)'B', (byte)'c', 0x20);
|
||||
Assert.True(lbAb >= 0, "first message packet 'ab' not found");
|
||||
Assert.True(lbCPad > lbAb, "odd packet not space-padded to 'c '");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Passes_DifferentFeeds_NoPause_EmitsInlineFeedChangeNoMessage()
|
||||
{
|
||||
var passes = new List<GravographPass>
|
||||
{
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
|
||||
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = false },
|
||||
};
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
Assert.True(IndexOf(bytes, 0, (byte)'V', (byte)'S', 0x00, 0x03) >= 0, "inline cut feed change missing");
|
||||
Assert.True(IndexOfCmd(bytes, (byte)'L', (byte)'B') < 0, "no LB message expected without a pause");
|
||||
}
|
||||
|
||||
private static int IndexOf(byte[] bytes, int from, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
for (var i = System.Math.Max(0, from); i <= bytes.Length - 6; i++)
|
||||
{
|
||||
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 &&
|
||||
bytes[i + 3] == c1 && bytes[i + 4] == hi && bytes[i + 5] == lo)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int IndexOfCmd(byte[] bytes, byte c0, byte c1)
|
||||
{
|
||||
for (var i = 0; i <= bytes.Length - 4; i++)
|
||||
{
|
||||
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
|
||||
{
|
||||
for (var i = 0; i < bytes.Length - 5; i++)
|
||||
|
||||
@@ -34,45 +34,4 @@ public class NestPolylineExtractorTests
|
||||
Assert.Equal(new Vector(0.25, 47.75), poly[3]);
|
||||
Assert.Equal(new Vector(0.25, 46.75), poly[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPartLayered_SplitsContinuousChainAtLayerChange()
|
||||
{
|
||||
// A single continuous chain (no rapid) that switches from Scribe to Cut
|
||||
// must be split into two layer-uniform polylines sharing the seam vertex,
|
||||
// so the post can emit a tool-change pause between engrave and cut.
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
|
||||
program.Codes.Add(new LinearMove(2, 0) { Layer = LayerType.Scribe });
|
||||
program.Codes.Add(new LinearMove(2, 1) { Layer = LayerType.Cut });
|
||||
program.Codes.Add(new LinearMove(3, 1) { Layer = LayerType.Cut });
|
||||
|
||||
var drawing = new Drawing("Mixed", program);
|
||||
var part = new Part(drawing, new Vector(0, 0));
|
||||
|
||||
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
|
||||
|
||||
Assert.Equal(2, polylines.Count);
|
||||
|
||||
Assert.Equal(LayerType.Scribe, polylines[0].Layer);
|
||||
Assert.Equal(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }, polylines[0].Points);
|
||||
|
||||
Assert.Equal(LayerType.Cut, polylines[1].Layer);
|
||||
Assert.Equal(new[] { new Vector(2, 0), new Vector(2, 1), new Vector(3, 1) }, polylines[1].Points);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPartLayered_UniformChain_IsSinglePolyline()
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
program.Codes.Add(new LinearMove(1, 0));
|
||||
program.Codes.Add(new LinearMove(1, 1));
|
||||
|
||||
var part = new Part(new Drawing("Cut", program), new Vector(0, 0));
|
||||
|
||||
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
|
||||
|
||||
Assert.Single(polylines);
|
||||
Assert.Equal(LayerType.Cut, polylines[0].Layer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,71 @@ OpenNest.sln
|
||||
| **OpenNest.Benchmark** | Runs every registered whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by material utilization, so competing engines — each owning its own multi-plate strategy — can be compared head-to-head. |
|
||||
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
|
||||
|
||||
### StockLadder whole-job baseline
|
||||
|
||||
Select `new StockLadderNestingEngine().Solve(job)` or the whole-job registry's
|
||||
`StockLadder` engine (benchmark: `--engines StockLadder`). This does not switch the
|
||||
legacy desktop single-plate engine. Supply every allowed `NestPlateStock` explicitly;
|
||||
no stock sizes are invented. Stock quantity `null` means unlimited, `0` unavailable,
|
||||
and a positive quantity is finite inventory. The benchmark's `--sheet-sizes` pool
|
||||
uses unlimited quantities; use the job API for finite stock.
|
||||
|
||||
```csharp
|
||||
var job = new NestJob(parts, callerStocks,
|
||||
new NestJobOptions(maxPlates: 100, salvageRate: 0,
|
||||
minimumSalvageDimension: 0));
|
||||
var result = new StockLadderNestingEngine().Solve(job, token: cancellationToken);
|
||||
```
|
||||
|
||||
Construction orders by priority, then validated stock-fit scarcity, then part area,
|
||||
pins an anchor before fillers, and ranks candidate sheets by estimated net sheet
|
||||
area per placed part area. Repacking tries single-sheet replacements and adjacent
|
||||
pairs into one sheet, accepting only strictly lower estimated net area with exactly
|
||||
the same demand. Failed trials leave placements and finite stock accounting intact.
|
||||
|
||||
Salvage is an **area estimate**, not price or certified recoverable material.
|
||||
`salvageRate` defaults to `0` (allowed range 0–1); `minimumSalvageDimension` defaults
|
||||
to `0`, which also disables credit. With both enabled, only the largest qualifying
|
||||
full-span edge rectangle outside placed bounding boxes plus part spacing is credited,
|
||||
within the usable work area; both dimensions must meet the minimum in job units.
|
||||
Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand
|
||||
valuation is modeled. Benchmark ranking still uses gross material utilization.
|
||||
|
||||
This is a tested deterministic heuristic baseline, **not an optimal or production-
|
||||
certified solver**. Conservative rectangular free-region hints and linear fills can
|
||||
miss concave interlocks and feasible layouts. Automatic rotation tries cardinal
|
||||
angles plus 5-degree increments below 180 degrees; fixed/range policies are honored.
|
||||
Repacking is bounded local search, not a global stock/demand search or fixed-point
|
||||
optimality proof. `NoPlacementFound` is not proof of impossibility. Cancellation is
|
||||
cooperative (the benchmark requests it after five minutes), not process isolation.
|
||||
Geometry acceptance remains strict, including open marks leaving closed material.
|
||||
|
||||
Benchmark export example (use a separate output directory):
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.Benchmark -- input.nest \
|
||||
--engines StockLadder --sheet-sizes 48x96,48x120,48x144,60x96,60x120,60x144,72x96,72x120,72x144 \
|
||||
--salvage-rate 0 --min-salvage-dimension 0 \
|
||||
--output ./stockladder-output --csv ./stockladder.csv
|
||||
```
|
||||
|
||||
`--output` writes validated layouts as `.nest` plus JSON containing status, stop
|
||||
reason, fulfillment, stock usage, poses, and gross/estimated net area. Valid but
|
||||
incomplete layouts may be exported: inspect status and fulfillment. Thrown/invalid
|
||||
runs do not export layouts. The console can exit zero despite a reported `CRASH`;
|
||||
inspect the report, not just the process exit code. Export does not certify cutting
|
||||
readiness and must not overwrite the source.
|
||||
|
||||
**Known real-input blocker (no successful real-file result):**
|
||||
`/srv/shared/P260805-10_dxf/P260805-10.nest` requests 219 pieces from 69 drawings.
|
||||
With the nine caller-supplied sizes above, strict validation rejects drawing ID `57`,
|
||||
`4980 A01 PT75`: its open mark from `(-5.21875, -1.807287)` to
|
||||
`(-4.21875, -1.807287)` starts `0.0001` outside the perimeter's vertical edge at
|
||||
`x = -5.21865`. Error: `Geometry must contain usable closed edges: 57. Open geometry
|
||||
leaves the closed material region. (Parameter 'job')`. No snapping, clipping, or
|
||||
source geometry changes were made. Source SHA-256:
|
||||
`9e839fd51072587ec4f3173dc2f39ef1ea8ae460971889c2a3b91fa54b61091d`.
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.
|
||||
|
||||
Reference in New Issue
Block a user