diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs index af24799..cf27b78 100644 --- a/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs +++ b/OpenNest.Engine.Qwen38FlashNext/Engine/JobSolver.cs @@ -18,32 +18,25 @@ internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex); /// internal sealed class JobSolver { + private NestJobResultBuilder _builder = null!; private readonly NestJob _job; private readonly PartPreparation _prep; - private readonly Dictionary _remaining; - private readonly Dictionary _placed; - private readonly Dictionary _used; - private readonly List _sheets = new(); - - private sealed record CommittedSheet(int StockIndex, List Placements); + private int _sheetCount; public JobSolver(NestJob job, PartPreparation prep) { _job = job; _prep = prep; - _remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal); - _placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal); - _used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal); } - private static readonly bool _diag = - Environment.GetEnvironmentVariable("QWEN_NEST_DIAG") == "1"; + internal static bool Diagnostics { get; set; } + private static bool _diag => Diagnostics; private void Diag(string message) { if (_diag) Console.Error.WriteLine( - $"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " + + $"[qwen] sheets={_sheetCount} placed={_job.Parts.Sum(p => _builder.Placed(p.Id))} " + $"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " + $"gc2={GC.CollectionCount(2)} {message}" ); @@ -51,14 +44,18 @@ internal sealed class JobSolver public NestJobResult Solve(IProgress? progress, CancellationToken token) { + _builder = new NestJobResultBuilder(_job, progress); var reason = NestJobStopReason.Completed; while (true) { token.ThrowIfCancellationRequested(); var outstanding = OutstandingDemands(); if (outstanding.Count == 0) + { + reason = _builder.IsComplete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound; break; - if (_job.Options.MaxPlates is int cap && _sheets.Count >= cap) + } + if (_job.Options.MaxPlates is int cap && _sheetCount >= cap) { reason = NestJobStopReason.PlateLimitReached; break; @@ -75,25 +72,16 @@ internal sealed class JobSolver } CommitSheet(attempt.Packer); - progress?.Report( - new NestJobProgress( - NestJobStage.PlateCommitted, - _job.Plates[attempt.StockIndex].Id, - _sheets.Count - 1, - _sheets.Count, - _placed.Values.Sum() - ) - ); } - return BuildResult(reason); + return _builder.Build(reason); } private List OutstandingDemands() { var demands = new List(); foreach (var model in _prep.Models) - if (_remaining[model.Id] > 0) + if ((model.Quantity - _builder.Placed(model.Id)) > 0) demands.Add(model); // This engine's own ordering: priority first, then the tallest-then-largest // part first (a part's thinnest orientation extent), then id for determinism. @@ -189,7 +177,7 @@ internal sealed class JobSolver for (var index = 0; index < _job.Plates.Count; index++) { var stock = _job.Plates[index]; - if (stock.Quantity is int quantity && _used[stock.Id] >= quantity) + if (stock.Quantity is int quantity && _builder.SheetsUsed(stock) >= quantity) continue; token.ThrowIfCancellationRequested(); @@ -197,21 +185,14 @@ internal sealed class JobSolver new NestJobProgress( NestJobStage.EvaluatingCandidate, stock.Id, - _sheets.Count, - _sheets.Count, - _placed.Values.Sum() + _sheetCount, + _sheetCount, + _job.Parts.Sum(p => _builder.Placed(p.Id)) ) ); var packer = SheetPacker.Create(stock, _prep, index); - var fillWatch = System.Diagnostics.Stopwatch.StartNew(); FillSheet(packer, outstanding, token); - fillWatch.Stop(); - if (_diag) - Diag( - $"trial stock {stock.Id}: placed={packer.Placed.Count} " + - $"{fillWatch.ElapsedMilliseconds}ms {packer.DiagStats()}" - ); if (packer.Placed.Count == 0) continue; @@ -261,7 +242,7 @@ internal sealed class JobSolver { var available = new Dictionary(StringComparer.Ordinal); foreach (var model in outstanding) - available[model.Id] = _remaining[model.Id]; + available[model.Id] = (model.Quantity - _builder.Placed(model.Id)); // One drain pass per requirement, in demand order. Gap-filling retries are // deliberately NOT an unbounded loop: a sheet's failed-insert scans get more @@ -274,7 +255,6 @@ internal sealed class JobSolver continue; if (!packer.CanEverFit(model)) continue; - var modelWatch = System.Diagnostics.Stopwatch.StartNew(); while (available[model.Id] > 0 && !packer.IsFull) { token.ThrowIfCancellationRequested(); @@ -282,32 +262,30 @@ internal sealed class JobSolver break; available[model.Id]--; } - modelWatch.Stop(); - if (_diag && modelWatch.ElapsedMilliseconds > 200) - Diag($" fill model {model.Id}: placed={packer.Placed.Count} {modelWatch.ElapsedMilliseconds}ms {packer.DiagStats()}"); + } - // Gap-fill pass: a part that could not fit between two early placements may - // fit the gaps a later model leaves behind. Failed-insert scans cost about a - // full candidate sweep each, so the pass is hard time-boxed - on a crowded - // sheet the untried budget was measured at minutes per job, well over the - // benchmark's wall; the box keeps worst case near the first pass's cost. - var retryWatch = System.Diagnostics.Stopwatch.StartNew(); + // Count failed insertion sweeps, independent of machine speed and load. + // Successful insertions are bounded by the outstanding quantities. + var retries = GapFillFailedInsertBudget; var retryAgain = true; - while (retryAgain && retryWatch.ElapsedMilliseconds < GapFillMilliseconds) + while (retryAgain && retries > 0) { retryAgain = false; foreach (var model in outstanding) { if (available[model.Id] <= 0 || packer.IsFull) continue; - if (retryWatch.ElapsedMilliseconds >= GapFillMilliseconds) + if (retries <= 0) break; while (available[model.Id] > 0) { token.ThrowIfCancellationRequested(); if (!packer.TryInsert(model, out _)) + { + retries--; break; + } available[model.Id]--; retryAgain = true; } @@ -321,18 +299,12 @@ internal sealed class JobSolver /// the seams; 12% lower job cost than span-first on a real production job), 2 = largest footprint /// first, 0 = smallest worst-case extent first (original). /// - private static readonly int DemandOrderMode = - int.TryParse(Environment.GetEnvironmentVariable("QWEN_DEMAND_ORDER"), out var m) - ? m - : 1; + internal static int DemandOrderMode { get; set; } = 1; - /// Wall-clock budget for one sheet's gap-fill pass. - private static readonly int GapFillMilliseconds = - int.TryParse(Environment.GetEnvironmentVariable("QWEN_GAPFILL_MS"), out var ms) - ? ms - : 120; + /// Maximum failed insertion sweeps per sheet gap-fill pass. + internal static int GapFillFailedInsertBudget { get; set; } = 8; - /// Trial-sheet metrics; costPerArea = plate area / part area placed. + /// Trial-sheet metrics; costPerArea = net sheet area / material area placed. private readonly record struct TrialScore( int count, int priorityHits, @@ -343,10 +315,9 @@ internal sealed class JobSolver /// /// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost /// function (total plate area); count-first is the conservative fill policy. - /// Env override exists for A/B measurement. + /// Internal setting permits controlled A/B tests. /// - private static readonly bool CostFirstScoring = - Environment.GetEnvironmentVariable("QWEN_COST_FIRST") != "0"; + internal static bool CostFirstScoring { get; set; } = true; private TrialScore ScoreTrial(SheetPacker packer) { @@ -356,7 +327,8 @@ internal sealed class JobSolver if (placed.Model.Priority < bestPriority) bestPriority = placed.Model.Priority; var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority); - var area = packer.Stock.Size.Width * packer.Stock.Size.Length; + var area = NestJobCost.NetSheetArea(_job, new NestJobPlateResult(0, packer.Stock, + packer.Placed.Select(p => new NestJobPlacement(p.Model.Id, 0, p.X, p.Y, p.Orientation.Angle)))); var placedArea = 0.0; foreach (var placed in packer.Placed) placedArea += placed.Model.Area; @@ -366,74 +338,18 @@ internal sealed class JobSolver private void CommitSheet(SheetPacker packer) { - _sheets.Add(new CommittedSheet(packer.StockIndex, packer.Placed)); - _used[packer.Stock.Id]++; - foreach (var placed in packer.Placed) - { - _placed[placed.Model.Id]++; - _remaining[placed.Model.Id]--; - } + _builder.AddSheet(packer.Stock, packer.Placed.Select(p => + (p.Model.Id, p.X, p.Y, p.Orientation.Angle))); + _sheetCount++; } private bool AnyStockAvailable() { foreach (var stock in _job.Plates) - if (stock.Quantity is null || _used[stock.Id] < stock.Quantity.Value) + if (stock.Quantity is null || _builder.SheetsUsed(stock) < stock.Quantity.Value) return true; return false; } - private NestJobResult BuildResult(NestJobStopReason reason) - { - var instanceIndex = new Dictionary(StringComparer.Ordinal); - var plates = new List(); - foreach (var sheet in _sheets) - { - var placements = new List(sheet.Placements.Count); - foreach (var placed in sheet.Placements) - { - instanceIndex.TryGetValue(placed.Model.Id, out var next); - instanceIndex[placed.Model.Id] = next + 1; - placements.Add( - new NestJobPlacement( - placed.Model.Id, - next, - placed.X, - placed.Y, - placed.Orientation.Angle - ) - ); - } - plates.Add( - new NestJobPlateResult(plates.Count, _job.Plates[sheet.StockIndex], placements) - ); - } - var fulfillment = _job.Parts - .Select(part => new PartFulfillment( - part.Id, - part.Quantity, - _placed[part.Id], - _remaining[part.Id] - )) - .ToList(); - - var stockUsage = _job.Plates - .Select(stock => new StockUsage( - stock.Id, - _used[stock.Id], - stock.Quantity is int quantity ? quantity - _used[stock.Id] : null - )) - .ToList(); - - return new NestJobResult( - reason == NestJobStopReason.Completed - ? NestJobStatus.Complete - : NestJobStatus.Incomplete, - reason, - plates, - fulfillment, - stockUsage - ); - } } diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs index 50a049d..77a82ec 100644 --- a/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs +++ b/OpenNest.Engine.Qwen38FlashNext/Engine/PartPreparation.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using OpenNest.Converters; using OpenNest.Engine.Jobs; -using OpenNest.Engine.Jobs.Adapters; using OpenNest.Geometry; using OpenNest.Math; @@ -53,10 +51,11 @@ internal sealed class PartModel /// Material area (perimeter minus holes), from the analytic shapes. public double Area { get; } + internal List? Angles { get; set; } /// /// Chord tolerance for the engine's internal collision polygons. Must stay FINER - /// than the validator's OutlineTolerance (0.001): any chord cuts the cap off a + /// than NestTolerances.ValidationOutline (0.001): any chord cuts the cap off a /// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a /// subset of the validator's material in notched regions and admits real spacing /// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator, @@ -70,47 +69,11 @@ internal sealed class PartModel /// public static PartModel? TryCreate(NestJobPart part) { - try - { - var entities = new List(); - foreach ( - var entity in ConvertProgram.ToGeometry( - DrawingJobMapper.ToProgram(part.Geometry) - ) - ) - if (SpecialLayers.IsMaterial(entity.Layer)) - entities.Add(entity); - if (entities.Count == 0) - return null; - - var profile = new ShapeProfile(entities); - if (profile.Perimeter == null) - return null; - profile.NormalizeWinding(); - - var area = Math.Abs(profile.Perimeter.Area()); - foreach (var cutout in profile.Cutouts) - area -= Math.Abs(cutout.Area()); - if (!double.IsFinite(area) || area <= Tolerance.Epsilon) - return null; - - return new PartModel( - part.Id, - part.Quantity, - part.Priority, - part.Rotation, - profile, - profile.Perimeter, - new List(profile.Cutouts), - area - ); - } - catch (Exception) - { - // Malformed snapshots are unplaceable, not fatal: report them unplaced so - // the rest of the job still nests. - return null; - } + var geometry = JobPartGeometry.TryRead(part.Geometry); + if (geometry == null || geometry.MaterialArea <= Tolerance.Epsilon) return null; + // Read normalizes winding before the collision and offset preparation below. + return new PartModel(part.Id, part.Quantity, part.Priority, part.Rotation, + geometry.Profile, geometry.Perimeter, geometry.Cutouts.ToList(), geometry.MaterialArea); } } @@ -441,84 +404,15 @@ internal sealed class PartPreparation /// public static List CandidateAngles(PartModel model) { - var angles = new List(); - var policy = model.Rotation; - if (policy.Kind == RotationPolicyKind.Automatic) - { - angles.Add(0); - angles.Add(Math.PI / 2); - angles.Add(Math.PI); - angles.Add(3 * Math.PI / 2); - try - { - var hull = ConvexHull.Compute( - model - .PerimeterShape - .ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true) - .Vertices - ); - var obb = RotatingCalipers.MinimumBoundingRectangle(hull); - var normalized = OpenNest.Math.Angle.NormalizeRad(obb.Angle); - if (normalized > 0.001 && normalized < Math.PI - 0.001) - { - angles.Add(normalized); - angles.Add(OpenNest.Math.Angle.NormalizeRad(normalized + Math.PI)); - } - } - catch (Exception) - { - // A calipers failure only costs candidate angles, never correctness. - } - } - else if (policy.Kind == RotationPolicyKind.Fixed) - { - angles.Add(policy.Start); - if (policy.Allow180Equivalent) - angles.Add(policy.Start + Math.PI); - } - else - { - // BoundedSweep: enumerate the exact step grid the policy allows. - var count = (int)Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9); - if (count < 0) - count = 0; - if (count > 4000) - count = 4000; - for (var i = 0; i <= count; i++) - { - angles.Add(policy.Start + i * policy.Step); - if (policy.Allow180Equivalent) - angles.Add(policy.Start + i * policy.Step + Math.PI); - } - } - - // Normalize to [0, 2pi), deduplicate, preserve first-seen order (deterministic). - var unique = new List(); - foreach (var angle in angles) - { - var normalized = OpenNest.Math.Angle.NormalizeRad(angle); - if (normalized < 0) - normalized += 2 * Math.PI; - var duplicate = false; - foreach (var existing in unique) - if (Math.Abs(SignedDelta(existing, normalized)) < 1e-9) - { - duplicate = true; - break; - } - if (!duplicate) - unique.Add(normalized); - } - return unique; + if (model.Angles != null) return model.Angles; + var angles = model.Rotation.Kind == RotationPolicyKind.Automatic + ? RotationCandidates.ForShape(model.Rotation, model.PerimeterShape) + : model.Rotation.EnumerateAngles(maxSamples: 4000); + // Perimeter symmetry does not establish symmetry of the cutouts. + return model.Angles = (model.CutoutShapes.Count == 0 + ? RotationCandidates.DistinctOutlines(model.PerimeterShape, angles) + : angles).ToList(); } - private static double SignedDelta(double a, double b) - { - var delta = (a - b) % (2 * Math.PI); - if (delta > Math.PI) - delta -= 2 * Math.PI; - if (delta < -Math.PI) - delta += 2 * Math.PI; - return delta; - } + } diff --git a/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs b/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs index b334acc..12cb349 100644 --- a/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs +++ b/OpenNest.Engine.Qwen38FlashNext/Engine/SheetPacker.cs @@ -115,12 +115,11 @@ internal sealed class SheetPacker StockIndex = stockIndex; Preparation = prep; Spacing = stock.PartSpacing; - var left = stock.Quadrant is 1 or 4 ? 0.0 : -stock.Size.Length; - var bottom = stock.Quadrant is 1 or 2 ? 0.0 : -stock.Size.Width; - _workLeft = left + stock.EdgeSpacing.Left; - _workBottom = bottom + stock.EdgeSpacing.Bottom; - _workRight = left + stock.Size.Length - stock.EdgeSpacing.Right; - _workTop = bottom + stock.Size.Width - stock.EdgeSpacing.Top; + var work = stock.WorkArea; + _workLeft = work.Left; + _workBottom = work.Bottom; + _workRight = work.Right; + _workTop = work.Top; WorkWidth = _workRight - _workLeft; WorkHeight = _workTop - _workBottom; @@ -159,7 +158,7 @@ internal sealed class SheetPacker foreach (var angle in PartPreparation.CandidateAngles(model)) { var orientation = Preparation.Oriented(model, angle, 0); - if (orientation.Width <= WorkWidth + 1e-9 && orientation.Height <= WorkHeight + 1e-9) + if (Stock.Fits(orientation.Width, orientation.Height)) return true; } return false; @@ -647,8 +646,7 @@ internal sealed class SheetPacker } } - private static readonly bool VerifyFastClear = - Environment.GetEnvironmentVariable("QWEN_VERIFY_FASTCLEAR") == "1"; + internal static bool VerifyFastClear { get; set; } internal long DiagFastClearMismatch; internal long DiagTriMismatch; diff --git a/OpenNest.Engine.Qwen38FlashNext/README.md b/OpenNest.Engine.Qwen38FlashNext/README.md index 42842d8..b040d78 100644 --- a/OpenNest.Engine.Qwen38FlashNext/README.md +++ b/OpenNest.Engine.Qwen38FlashNext/README.md @@ -7,8 +7,7 @@ or select over any built-in engine, nester, filler, or runner. ## Algorithm Bottom-left greedy insertion over convex No-Fit-Polygons, with an exact material-clearance -gate, driven sheet by sheet by a greedy demand scheduler. All geometry math is the engine's -own (`Engine/`); it calls no built-in nester, filler, or runner. +gate, driven sheet by sheet by a greedy demand scheduler. Placement and collision preparation remain in `Engine/`; it calls no built-in nester, filler, or runner. - **`PartPreparation`** rebuilds each snapshot into a closed contour topology (perimeter + cutouts; rapids/scribe marks dropped), flattens it circumscribed (the collision polygon @@ -46,9 +45,9 @@ own (`Engine/`); it calls no built-in nester, filler, or runner. - **`JobSolver`** walks demands in its own order (priority, then largest material area - big parts first lay down the sheet skeleton the small parts fill against; measured 12% lower job cost than smallest-extent-first on the production job below) and drains each greedily, - then a time-boxed gap-fill pass. For the next sheet it trials *every* available stock - size independently and commits the trial delivering the cheapest plate area per unit of - part area placed (the benchmark's cost function), breaking ties by priority coverage, + then a gap-fill pass capped at eight failed insertion sweeps. For the next sheet it trials *every* available stock + size independently and commits the trial delivering the cheapest `NestJobCost.NetSheetArea` per unit of + material area placed (the benchmark's cost function), breaking ties by priority coverage, instance count, then plate area; lost trials change no job state. The job stops on met demand, exhausted stock, no further placement, or the plate cap. Deterministic: identical input, identical layout. @@ -82,7 +81,7 @@ to ~110 s via the cached-triangulation exact gate and the fast shell prefilter. ## Tests `tests/` holds acceptance tests whose layouts are checked by the benchmark's own -`NestValidator` (bounds, spacing, quantities, stock, rotation), plus NFP geometry tests and a +`NestLayoutCheck` through the shared `Engine.Testing` kit (bounds, spacing, quantities, stock, rotation and accounting), plus NFP geometry tests and a rotated-concave spacing regression test. ```bash @@ -107,3 +106,23 @@ dotnet /OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll < `` is the OpenNest checkout root. Or build and deploy in one step with `./Build-Engines.ps1 -Engines Qwen38FlashNext`. The engine appears in reports as `Qwen38FlashNextNestingEngine`. + +## Shared services and determinism + +`JobPartGeometry.TryRead` provides normalized material topology. Stock bounds/fit and +result accounting/progress use the shared stock API and `NestJobResultBuilder`. +`ForShape` supplies Automatic rotations; `EnumerateAngles(maxSamples: 4000)` preserves +the sweep effort cap. Solid parts use cached `DistinctOutlines`; holed parts retain every +legal candidate because perimeter symmetry alone cannot establish cutout symmetry. +CollisionTolerance remains 0.0005, below `NestTolerances.ValidationOutline`. + +Gap fill now counts failed insertion sweeps (default eight), with successful insertions +bounded by demand. Internal test settings replace every QWEN environment switch. No +stopwatch affects placement or diagnostics. Only the host cancellation token limits wall +time. The shared determinism contract compares repeated and fresh solves. + +On the five synthetic salvage jobs, every layout remained valid and complete; total cost +fell from 7660.01 to 7572.05, with no job worse. Aggregate measured solve time remained +below one second. These small fixtures do not calibrate production-scale retry costs; +the eight-sweep default bounds effort independently of hardware. See +[PR 5 results](../MIGRATION-PR5.md); older production numbers above describe the old version. diff --git a/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj b/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj index 59916ea..743eee0 100644 --- a/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj +++ b/OpenNest.Engine.Qwen38FlashNext/tests/OpenNest.Engine.Qwen38FlashNext.Tests.csproj @@ -10,8 +10,7 @@ + - - diff --git a/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs b/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs index 9e45d04..a89d911 100644 --- a/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs +++ b/OpenNest.Engine.Qwen38FlashNext/tests/Qwen38FlashNextNestingEngineTests.cs @@ -1,7 +1,9 @@ +using OpenNest.Engine.Testing; +using static OpenNest.Engine.Testing.JobBuilder; +using static OpenNest.Engine.Testing.Shapes; using System; using System.Collections.Generic; using System.Linq; -using OpenNest.Benchmark; using OpenNest.CNC; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; @@ -30,7 +32,7 @@ public class Qwen38FlashNextNestingEngineTests var result = new Qwen38FlashNextNestingEngine().Solve(job); - AssertValid(job, result); + LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Single(result.Plates); Assert.Equal(12, result.Plates[0].Placements.Count); @@ -55,7 +57,7 @@ public class Qwen38FlashNextNestingEngineTests var result = new Qwen38FlashNextNestingEngine().Solve(job); - AssertValid(job, result); + LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); } @@ -76,7 +78,7 @@ public class Qwen38FlashNextNestingEngineTests var result = new Qwen38FlashNextNestingEngine().Solve(job); - AssertValid(job, result); + LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); } @@ -87,7 +89,7 @@ public class Qwen38FlashNextNestingEngineTests var result = new Qwen38FlashNextNestingEngine().Solve(job); - AssertValid(job, result); + LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.True(result.Plates.Count > 1); } @@ -102,7 +104,7 @@ public class Qwen38FlashNextNestingEngineTests var result = new Qwen38FlashNextNestingEngine().Solve(job); - AssertValid(job, result); + LayoutAssert.Valid(job, result); var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge"); Assert.Equal(1, huge.Unplaced); } @@ -120,7 +122,7 @@ public class Qwen38FlashNextNestingEngineTests var result = new Qwen38FlashNextNestingEngine().Solve(job); - AssertValid(job, result); + LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); } @@ -138,65 +140,6 @@ public class Qwen38FlashNextNestingEngineTests Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex)); } - // ---- helpers ------------------------------------------------------------------------- - - private static void AssertValid(NestJob job, NestJobResult result) - { - var materialized = NestResultMaterializer.Materialize(job, result); - var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList(); - var requirements = job.Parts.ToDictionary( - p => materialized.DrawingsByPartId[p.Id], - p => (p.Id, p.Quantity), - ReferenceEqualityComparer.Instance - ); - var validation = NestValidator.Validate(runs, requirements); - NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation); - Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations)); - - foreach (var f in result.Fulfillment) - Assert.Equal(f.Requested, f.Placed + f.Unplaced); - } - - private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) => - new(parts, stock, options); - - private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) => - new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation); - - /// Y extent. - /// X extent. - private static NestPlateStock Stock( - string id, - double width, - double length, - double spacing = 0, - Spacing edge = default, - int quadrant = 1, - int? quantity = null - ) => new(id, new Size(width, length), quantity, spacing, edge, quadrant); - - private static Program Polyline(params (double X, double Y)[] points) - { - var program = new Program(); - program.Codes.Add(new RapidMove(points[0].X, points[0].Y)); - foreach (var (x, y) in points.Skip(1)) - program.Codes.Add(new LinearMove(x, y)); - program.Codes.Add(new LinearMove(points[0].X, points[0].Y)); - return program; - } - - private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h)); - - private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h)); - - private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h)); - - private static Program Disc(double r) - { - var program = new Program(); - program.Codes.Add(new RapidMove(r, 0)); - program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW)); - program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW)); - return program; - } } + +public sealed class Qwen38FlashNextContractTests : EngineContractTests { }