Compare commits
71 Commits
e9678c73b2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b970629a59 | |||
| 072915abf2 | |||
| aeeb2e4074 | |||
| a2f7219db3 | |||
| 7e4040ba08 | |||
| 0246073b31 | |||
| 4801895321 | |||
| 833abfe72e | |||
| 379000bbd8 | |||
| 5936272ce4 | |||
| da8e7e6fd3 | |||
| 53d24ddaf1 | |||
| 8efdc8720c | |||
| ca8a0942ab | |||
| 8c3659a439 | |||
| 95a0815484 | |||
| e9caa9b8eb | |||
| 95a0db1983 | |||
| a323dcc230 | |||
| 24cd18da88 | |||
| 5d26efb552 | |||
| 60c4545a17 | |||
| 4db51b8cdf | |||
| 1c561d880e | |||
| 17fc9c6cab | |||
| 4287c5fa46 | |||
| a735884ee9 | |||
| 22554b0fa3 | |||
| 48b4849a88 | |||
| f79df4d426 | |||
| ebb18d9b49 | |||
| 31a9e6dbad | |||
| a576f9fafa | |||
| 9453bb51ce | |||
| ad58332a5d | |||
| d4f60d5e8e | |||
| 3ea05257eb | |||
| 7e49ed620b | |||
| 57bd0447e9 | |||
| 07d6f08e8b | |||
| 2f19f47a85 | |||
| d58a446eac | |||
| 5fc7d1989a | |||
| 3f6bc2b2a1 | |||
| 7681a1bad0 | |||
| a548d5329a | |||
| 07012033c7 | |||
| 92b17b2963 | |||
| b6ee04f038 | |||
| 8ffdacd6c0 | |||
| ccd402c50f | |||
| b1e872577c | |||
| 9903478d3e | |||
| 93a8981d0a | |||
| 00e7866506 | |||
| 560105f952 | |||
| 266f8a83e6 | |||
| 0b7697e9c0 | |||
| 83124eb38d | |||
| 24beb8ada1 | |||
| ee83f17afe | |||
| 99546e7eef | |||
| 4586a53590 | |||
| 1a41eeb81d | |||
| f894ffd27c | |||
| 0ec22f2207 | |||
| 3f3d95a5e4 | |||
| 811d23510e | |||
| 0597a11a23 | |||
| 2ae1d513cf | |||
| 904d30d05d |
15
CLAUDE.md
15
CLAUDE.md
@@ -30,19 +30,21 @@ Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
|
||||
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
|
||||
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
|
||||
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
|
||||
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
|
||||
|
||||
### OpenNest.Engine (class library, depends on Core)
|
||||
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry).
|
||||
Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine.
|
||||
|
||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
|
||||
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases) → `VerticalRemnantEngine` (optimizes for right-side drop), `HorizontalRemnantEngine` (optimizes for top-side drop). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
|
||||
- **IFillComparer**: Interface enabling engine-specific scoring. `DefaultFillComparer` (count-then-density), `VerticalRemnantComparer` (minimize X-extent), `HorizontalRemnantComparer` (minimize Y-extent). Engines provide their comparer via `CreateComparer()` factory, grouped into `FillPolicy` on `FillContext`.
|
||||
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
|
||||
- **Fill/** (`namespace OpenNest.Engine.Fill`): Fill algorithms — `FillLinear` (grid-based), `FillExtents` (extents-based pair tiling), `PairFiller` (interlocking pairs), `ShrinkFiller`, `RemnantFiller`/`RemnantFinder`, `Compactor` (post-fill gravity compaction), `FillScore` (lexicographic comparison: count > utilization > compactness), `Pattern`/`PatternTiler`, `PartBoundary`, `RotationAnalysis`, `AngleCandidateBuilder`, `BestCombination`, `AccumulatingProgress`.
|
||||
- **Strategies/** (`namespace OpenNest.Engine.Strategies`): Pluggable fill strategy layer — `IFillStrategy` interface, `FillContext`, `FillStrategyRegistry` (auto-discovers strategies via reflection, supports plugin DLLs), `FillHelpers`. Built-in strategies: `LinearFillStrategy`, `PairsFillStrategy`, `RectBestFitStrategy`, `ExtentsFillStrategy`.
|
||||
- **BestFit/** (`namespace OpenNest.Engine.BestFit`): NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
|
||||
- **RectanglePacking/** (`namespace OpenNest.RectanglePacking`): `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
|
||||
- **CirclePacking/** (`namespace OpenNest.CirclePacking`): Alternative packing for circular parts.
|
||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): NFP-based nesting (not yet integrated) — `AutoNester` (mixed-part nesting with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`NestResult`.
|
||||
- **Nfp/** (`namespace OpenNest.Engine.Nfp`): Internal NFP-based single-part placement utilities — `AutoNester` (NFP placement with simulated annealing), `BottomLeftFill` (BLF placement), `NfpCache` (computed NFP caching), `SimulatedAnnealing` (optimizer), `INestOptimizer`/`OptimizationResult`. Not exposed as a nest engine; used internally for individual part placement.
|
||||
- **ML/** (`namespace OpenNest.Engine.ML`): `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
|
||||
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
|
||||
- `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
|
||||
@@ -78,13 +80,13 @@ The UI application with MDI interface.
|
||||
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
|
||||
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`.
|
||||
- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`, `ActionCutOff`.
|
||||
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
|
||||
|
||||
## File Format
|
||||
|
||||
Nest files (`.nest`, ZIP-based) use v2 JSON format:
|
||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation)
|
||||
- `nest.json` — single JSON file containing all nest metadata: nest info (name, units, customer, dates, notes), plate defaults (size, thickness, quadrant, spacing, material, edge spacing), drawings array (id, name, color, quantity, priority, rotation constraints, material, source), and plates array (id, size, material, edge spacing, parts with drawingId/x/y/rotation, cutoffs with x/y/axis/startLimit/endLimit)
|
||||
- `programs/program-N` — G-code text for each drawing's cut program (N = drawing id)
|
||||
- `bestfits/bestfit-N` — JSON array of best-fit pair evaluation results per drawing, keyed by plate size/spacing (optional, only present if best-fit data was computed)
|
||||
|
||||
@@ -100,6 +102,8 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
|
||||
|
||||
Always keep `README.md` and `CLAUDE.md` up to date when making changes that affect project structure, architecture, build instructions, dependencies, or key patterns. If you add a new project, change a namespace, modify the build process, or alter significant behavior, update both files as part of the same change.
|
||||
|
||||
**Do not commit** design specs, implementation plans, or other temporary planning documents (`docs/superpowers/` etc.) to the repository. These are working documents only — keep them local and untracked.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- OpenNest.Core uses multiple namespaces: `OpenNest` (root domain), `OpenNest.CNC`, `OpenNest.Geometry`, `OpenNest.Converters`, `OpenNest.Math`, `OpenNest.Collections`.
|
||||
@@ -110,3 +114,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
||||
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
|
||||
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
|
||||
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
|
||||
- **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).
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
|
||||
return NestConsole.Run(args);
|
||||
@@ -20,6 +21,12 @@ static class NestConsole
|
||||
if (options == null)
|
||||
return 0; // --help was requested
|
||||
|
||||
if (options.ListPosts)
|
||||
{
|
||||
ListPostProcessors(options);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.InputFiles.Count == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
@@ -68,6 +75,7 @@ static class NestConsole
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
Save(nest, options);
|
||||
PostProcess(nest, options);
|
||||
|
||||
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
|
||||
}
|
||||
@@ -120,6 +128,18 @@ static class NestConsole
|
||||
case "--engine" when i + 1 < args.Length:
|
||||
NestEngineRegistry.ActiveEngineName = args[++i];
|
||||
break;
|
||||
case "--post" when i + 1 < args.Length:
|
||||
o.PostName = args[++i];
|
||||
break;
|
||||
case "--post-output" when i + 1 < args.Length:
|
||||
o.PostOutput = args[++i];
|
||||
break;
|
||||
case "--posts-dir" when i + 1 < args.Length:
|
||||
o.PostsDir = args[++i];
|
||||
break;
|
||||
case "--list-posts":
|
||||
o.ListPosts = true;
|
||||
break;
|
||||
case "--help":
|
||||
case "-h":
|
||||
PrintUsage();
|
||||
@@ -382,6 +402,100 @@ static class NestConsole
|
||||
Console.WriteLine($"Saved: {outputFile}");
|
||||
}
|
||||
|
||||
static string ResolvePostsDir(Options options)
|
||||
{
|
||||
if (options.PostsDir != null)
|
||||
return options.PostsDir;
|
||||
|
||||
var exePath = Assembly.GetEntryAssembly()?.Location
|
||||
?? typeof(NestConsole).Assembly.Location;
|
||||
return Path.Combine(Path.GetDirectoryName(exePath), "Posts");
|
||||
}
|
||||
|
||||
static List<IPostProcessor> LoadPostProcessors(string postsDir)
|
||||
{
|
||||
var processors = new List<IPostProcessor>();
|
||||
|
||||
if (!Directory.Exists(postsDir))
|
||||
return processors;
|
||||
|
||||
foreach (var file in Directory.GetFiles(postsDir, "*.dll"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Assembly.LoadFrom(file);
|
||||
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
if (!typeof(IPostProcessor).IsAssignableFrom(type) || type.IsInterface || type.IsAbstract)
|
||||
continue;
|
||||
|
||||
if (Activator.CreateInstance(type) is IPostProcessor processor)
|
||||
processors.Add(processor);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Warning: failed to load post processor from {Path.GetFileName(file)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return processors;
|
||||
}
|
||||
|
||||
static void ListPostProcessors(Options options)
|
||||
{
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
|
||||
if (processors.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"No post processors found in: {postsDir}");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Post processors ({postsDir}):");
|
||||
|
||||
foreach (var p in processors)
|
||||
Console.WriteLine($" {p.Name,-30} {p.Description}");
|
||||
}
|
||||
|
||||
static void PostProcess(Nest nest, Options options)
|
||||
{
|
||||
if (options.PostName == null)
|
||||
return;
|
||||
|
||||
var postsDir = ResolvePostsDir(options);
|
||||
var processors = LoadPostProcessors(postsDir);
|
||||
var post = processors.FirstOrDefault(p =>
|
||||
p.Name.Equals(options.PostName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (post == null)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: post processor '{options.PostName}' not found");
|
||||
|
||||
if (processors.Count > 0)
|
||||
Console.Error.WriteLine($"Available: {string.Join(", ", processors.Select(p => p.Name))}");
|
||||
else
|
||||
Console.Error.WriteLine($"No post processors found in: {postsDir}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var outputFile = options.PostOutput;
|
||||
|
||||
if (outputFile == null)
|
||||
{
|
||||
var firstInput = options.InputFiles[0];
|
||||
outputFile = Path.Combine(
|
||||
Path.GetDirectoryName(firstInput),
|
||||
$"{Path.GetFileNameWithoutExtension(firstInput)}.cnc");
|
||||
}
|
||||
|
||||
post.Post(nest, outputFile);
|
||||
Console.WriteLine($"Post: {post.Name} -> {outputFile}");
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
|
||||
@@ -407,6 +521,10 @@ static class NestConsole
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||
Console.Error.WriteLine(" --list-posts List available post processors and exit");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
|
||||
@@ -425,5 +543,9 @@ static class NestConsole
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
public string PostOutput;
|
||||
public string PostsDir;
|
||||
public bool ListPosts;
|
||||
}
|
||||
}
|
||||
|
||||
18
OpenNest.Core/CNC/ProgramVariable.cs
Normal file
18
OpenNest.Core/CNC/ProgramVariable.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public sealed class ProgramVariable
|
||||
{
|
||||
public int Number { get; }
|
||||
public string Name { get; }
|
||||
public string Expression { get; set; }
|
||||
|
||||
public ProgramVariable(int number, string name, string expression = null)
|
||||
{
|
||||
Number = number;
|
||||
Name = name;
|
||||
Expression = expression;
|
||||
}
|
||||
|
||||
public string Reference => $"#{Number}";
|
||||
}
|
||||
}
|
||||
43
OpenNest.Core/CNC/ProgramVariableManager.cs
Normal file
43
OpenNest.Core/CNC/ProgramVariableManager.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public sealed class ProgramVariableManager
|
||||
{
|
||||
private readonly Dictionary<int, ProgramVariable> _variables = new();
|
||||
|
||||
public ProgramVariable GetOrCreate(string name, int number, string expression = null)
|
||||
{
|
||||
if (_variables.TryGetValue(number, out var existing))
|
||||
return existing;
|
||||
|
||||
var variable = new ProgramVariable(number, name, expression);
|
||||
_variables[number] = variable;
|
||||
return variable;
|
||||
}
|
||||
|
||||
public List<string> EmitDeclarations()
|
||||
{
|
||||
return _variables.Values
|
||||
.Where(v => v.Expression != null)
|
||||
.OrderBy(v => v.Number)
|
||||
.Select(v => $"{v.Reference}={v.Expression} ({FormatComment(v.Name)})")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FormatComment(string name)
|
||||
{
|
||||
// "LeadInFeedrate" -> "LEAD IN FEEDRATE"
|
||||
var sb = new StringBuilder();
|
||||
foreach (var c in name)
|
||||
{
|
||||
if (char.IsUpper(c) && sb.Length > 0)
|
||||
sb.Append(' ');
|
||||
sb.Append(char.ToUpper(c));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
211
OpenNest.Core/CutOff.cs
Normal file
211
OpenNest.Core/CutOff.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum CutOffAxis
|
||||
{
|
||||
Horizontal,
|
||||
Vertical
|
||||
}
|
||||
|
||||
public class CutOff
|
||||
{
|
||||
public Vector Position { get; set; }
|
||||
public CutOffAxis Axis { get; set; }
|
||||
public double? StartLimit { get; set; }
|
||||
public double? EndLimit { get; set; }
|
||||
public Drawing Drawing { get; private set; }
|
||||
|
||||
public CutOff(Vector position, CutOffAxis axis)
|
||||
{
|
||||
Position = position;
|
||||
Axis = axis;
|
||||
Drawing = new Drawing(GetName()) { IsCutOff = true };
|
||||
}
|
||||
|
||||
public void Regenerate(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache = null)
|
||||
{
|
||||
var segments = ComputeSegments(plate, settings, cache);
|
||||
var program = BuildProgram(segments, settings);
|
||||
Drawing.Program = program;
|
||||
}
|
||||
|
||||
private string GetName()
|
||||
{
|
||||
var axisChar = Axis == CutOffAxis.Vertical ? "V" : "H";
|
||||
var coord = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
|
||||
return $"CutOff-{axisChar}-{coord:F2}";
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> ComputeSegments(Plate plate, CutOffSettings settings, Dictionary<Part, Entity> cache)
|
||||
{
|
||||
var bounds = plate.BoundingBox(includeParts: false);
|
||||
|
||||
double lineStart, lineEnd, cutPosition;
|
||||
|
||||
if (Axis == CutOffAxis.Vertical)
|
||||
{
|
||||
cutPosition = Position.X;
|
||||
lineStart = StartLimit ?? bounds.Y;
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel);
|
||||
}
|
||||
else
|
||||
{
|
||||
cutPosition = Position.Y;
|
||||
lineStart = StartLimit ?? bounds.X;
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel);
|
||||
}
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Entity perimeter = null;
|
||||
cache?.TryGetValue(part, out perimeter);
|
||||
var partExclusions = GetPartExclusions(part, perimeter, cutPosition, lineStart, lineEnd, settings.PartClearance);
|
||||
exclusions.AddRange(partExclusions);
|
||||
}
|
||||
|
||||
exclusions.Sort((a, b) => a.Start.CompareTo(b.Start));
|
||||
var merged = new List<(double Start, double End)>();
|
||||
foreach (var ex in exclusions)
|
||||
{
|
||||
if (merged.Count > 0 && ex.Start <= merged[^1].End)
|
||||
merged[^1] = (merged[^1].Start, System.Math.Max(merged[^1].End, ex.End));
|
||||
else
|
||||
merged.Add(ex);
|
||||
}
|
||||
|
||||
var segments = new List<(double Start, double End)>();
|
||||
var current = lineStart;
|
||||
|
||||
foreach (var ex in merged)
|
||||
{
|
||||
var clampedStart = System.Math.Max(ex.Start, lineStart);
|
||||
var clampedEnd = System.Math.Min(ex.End, lineEnd);
|
||||
|
||||
if (clampedStart > current)
|
||||
segments.Add((current, clampedStart));
|
||||
|
||||
current = System.Math.Max(current, clampedEnd);
|
||||
}
|
||||
|
||||
if (current < lineEnd)
|
||||
segments.Add((current, lineEnd));
|
||||
|
||||
segments = segments.Where(s => (s.End - s.Start) >= settings.MinSegmentLength).ToList();
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static readonly List<(double Start, double End)> EmptyExclusions = new();
|
||||
|
||||
private List<(double Start, double End)> GetPartExclusions(
|
||||
Part part, Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
var (partMin, partMax) = AxisBounds(bb, clearance);
|
||||
var (partStart, partEnd) = CrossAxisBounds(bb, clearance);
|
||||
|
||||
if (cutPosition < partMin || cutPosition > partMax)
|
||||
return EmptyExclusions;
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
var perimeterExclusions = IntersectPerimeter(perimeter, cutPosition, lineStart, lineEnd, clearance);
|
||||
if (perimeterExclusions != null)
|
||||
return perimeterExclusions;
|
||||
}
|
||||
|
||||
return new List<(double Start, double End)> { (partStart, partEnd) };
|
||||
}
|
||||
|
||||
private List<(double Start, double End)> IntersectPerimeter(
|
||||
Entity perimeter, double cutPosition, double lineStart, double lineEnd, double clearance)
|
||||
{
|
||||
var target = OffsetOutward(perimeter, clearance) ?? perimeter;
|
||||
var usedOffset = target != perimeter;
|
||||
var cutLine = new Line(MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd));
|
||||
|
||||
if (!target.Intersects(cutLine, out var pts) || pts.Count < 2)
|
||||
return null;
|
||||
|
||||
var coords = pts
|
||||
.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X)
|
||||
.OrderBy(c => c)
|
||||
.ToList();
|
||||
|
||||
if (coords.Count % 2 != 0)
|
||||
return null;
|
||||
|
||||
var padding = usedOffset ? 0 : clearance;
|
||||
var result = new List<(double Start, double End)>();
|
||||
for (var i = 0; i < coords.Count; i += 2)
|
||||
result.Add((coords[i] - padding, coords[i + 1] + padding));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Entity OffsetOutward(Entity perimeter, double clearance)
|
||||
{
|
||||
if (clearance <= 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left);
|
||||
offset?.UpdateBounds();
|
||||
return offset;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Vector MakePoint(double cutCoord, double lineCoord) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? new Vector(cutCoord, lineCoord)
|
||||
: new Vector(lineCoord, cutCoord);
|
||||
|
||||
private (double Min, double Max) AxisBounds(Box bb, double clearance) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? (bb.X - clearance, bb.X + bb.Width + clearance)
|
||||
: (bb.Y - clearance, bb.Y + bb.Length + clearance);
|
||||
|
||||
private (double Start, double End) CrossAxisBounds(Box bb, double clearance) =>
|
||||
Axis == CutOffAxis.Vertical
|
||||
? (bb.Y - clearance, bb.Y + bb.Length + clearance)
|
||||
: (bb.X - clearance, bb.X + bb.Width + clearance);
|
||||
|
||||
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
|
||||
{
|
||||
var program = new Program();
|
||||
|
||||
if (segments.Count == 0)
|
||||
return program;
|
||||
|
||||
var toward = settings.CutDirection == CutDirection.TowardOrigin;
|
||||
segments = toward
|
||||
? segments.OrderByDescending(s => s.Start).ToList()
|
||||
: segments.OrderBy(s => s.Start).ToList();
|
||||
|
||||
var cutPos = Axis == CutOffAxis.Vertical ? Position.X : Position.Y;
|
||||
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
var (from, to) = toward ? (seg.End, seg.Start) : (seg.Start, seg.End);
|
||||
program.Codes.Add(new RapidMove(MakePoint(cutPos, from)));
|
||||
program.Codes.Add(new LinearMove(MakePoint(cutPos, to)));
|
||||
}
|
||||
|
||||
return program;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
OpenNest.Core/CutOffSettings.cs
Normal file
16
OpenNest.Core/CutOffSettings.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum CutDirection
|
||||
{
|
||||
TowardOrigin,
|
||||
AwayFromOrigin
|
||||
}
|
||||
|
||||
public class CutOffSettings
|
||||
{
|
||||
public double PartClearance { get; set; } = 0.02;
|
||||
public double Overtravel { get; set; }
|
||||
public double MinSegmentLength { get; set; } = 0.05;
|
||||
public CutDirection CutDirection { get; set; } = CutDirection.AwayFromOrigin;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ namespace OpenNest
|
||||
|
||||
public Color Color { get; set; }
|
||||
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
@@ -247,7 +247,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
public static class EntityExtensions
|
||||
{
|
||||
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
|
||||
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
|
||||
@@ -286,17 +286,35 @@ namespace OpenNest.Geometry
|
||||
|
||||
case EntityType.Shape:
|
||||
var shape = (Shape)entity;
|
||||
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
|
||||
return subResult;
|
||||
points.AddRange(shape.Entities.CollectPoints());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
public static BoundingRectangleResult FindBestRotation(this List<Entity> entities, double startAngle = 0, double endAngle = Angle.TwoPI)
|
||||
{
|
||||
// Check for Shape entity first (recursive case returns early)
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
if (entity.Type == EntityType.Shape)
|
||||
{
|
||||
var shape = (Shape)entity;
|
||||
var subResult = shape.Entities.FindBestRotation(startAngle, endAngle);
|
||||
return subResult;
|
||||
}
|
||||
}
|
||||
|
||||
var points = entities.CollectPoints();
|
||||
|
||||
if (points.Count == 0)
|
||||
return new BoundingRectangleResult(startAngle, 0, 0);
|
||||
|
||||
var hull = ConvexHull.Compute(points);
|
||||
|
||||
bool constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
|
||||
var constrained = !startAngle.IsEqualTo(0) || !endAngle.IsEqualTo(Angle.TwoPI);
|
||||
|
||||
return constrained
|
||||
? RotatingCalipers.MinimumBoundingRectangle(hull, startAngle, endAngle)
|
||||
|
||||
@@ -249,9 +249,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var geo in shape.Entities)
|
||||
{
|
||||
List<Vector> pts3;
|
||||
geo.Intersects(line, out pts3);
|
||||
pts.AddRange(pts3);
|
||||
if (geo.Intersects(line, out var pts3))
|
||||
pts.AddRange(pts3);
|
||||
}
|
||||
|
||||
return pts.Count > 0;
|
||||
|
||||
@@ -317,12 +317,68 @@ namespace OpenNest.Geometry
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (Vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
var isClosed = IsClosed();
|
||||
var count = isClosed ? Vertices.Count - 1 : Vertices.Count;
|
||||
if (count < 3)
|
||||
return null;
|
||||
|
||||
var ccw = CalculateArea() > 0;
|
||||
var outward = ccw ? OffsetSide.Left : OffsetSide.Right;
|
||||
var sign = side == outward ? 1.0 : -1.0;
|
||||
var d = distance * sign;
|
||||
|
||||
var normals = new Vector[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var next = (i + 1) % count;
|
||||
var dx = Vertices[next].X - Vertices[i].X;
|
||||
var dy = Vertices[next].Y - Vertices[i].Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (len < Tolerance.Epsilon)
|
||||
return null;
|
||||
normals[i] = new Vector(-dy / len * d, dx / len * d);
|
||||
}
|
||||
|
||||
var result = new Polygon();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var prev = (i - 1 + count) % count;
|
||||
|
||||
var a1 = new Vector(Vertices[prev].X + normals[prev].X, Vertices[prev].Y + normals[prev].Y);
|
||||
var a2 = new Vector(Vertices[i].X + normals[prev].X, Vertices[i].Y + normals[prev].Y);
|
||||
var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y);
|
||||
var b2 = new Vector(Vertices[(i + 1) % count].X + normals[i].X, Vertices[(i + 1) % count].Y + normals[i].Y);
|
||||
|
||||
var edgeA = new Line(a1, a2);
|
||||
var edgeB = new Line(b1, b2);
|
||||
|
||||
if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid())
|
||||
result.Vertices.Add(pt);
|
||||
else
|
||||
result.Vertices.Add(new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y));
|
||||
}
|
||||
|
||||
result.Close();
|
||||
result.RemoveSelfIntersections();
|
||||
result.UpdateBounds();
|
||||
return result;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, Vector pt)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
var left = OffsetEntity(distance, OffsetSide.Left);
|
||||
var right = OffsetEntity(distance, OffsetSide.Right);
|
||||
|
||||
if (left == null) return right;
|
||||
if (right == null) return left;
|
||||
|
||||
var distLeft = left.ClosestPointTo(pt).DistanceTo(pt);
|
||||
var distRight = right.ClosestPointTo(pt).DistanceTo(pt);
|
||||
|
||||
return distLeft > distRight ? left : right;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,9 +21,12 @@ namespace OpenNest.Geometry
|
||||
Perimeter = shapes[0];
|
||||
Cutouts = new List<Shape>();
|
||||
|
||||
for (int i = 1; i < shapes.Count; i++)
|
||||
for (var i = 1; i < shapes.Count; i++)
|
||||
{
|
||||
if (shapes[i].Left < Perimeter.Left)
|
||||
var bb = shapes[i].BoundingBox;
|
||||
var perimBB = Perimeter.BoundingBox;
|
||||
|
||||
if (bb.Width * bb.Length > perimBB.Width * perimBB.Length)
|
||||
{
|
||||
Cutouts.Add(Perimeter);
|
||||
Perimeter = shapes[i];
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace OpenNest
|
||||
|
||||
public string Notes { get; set; }
|
||||
|
||||
public string AssistGas { get; set; } = "";
|
||||
|
||||
public Units Units { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
|
||||
@@ -47,17 +47,20 @@ namespace OpenNest
|
||||
Parts = new ObservableList<Part>();
|
||||
Parts.ItemAdded += Parts_PartAdded;
|
||||
Parts.ItemRemoved += Parts_PartRemoved;
|
||||
CutOffs = new ObservableList<CutOff>();
|
||||
Quadrant = 1;
|
||||
}
|
||||
|
||||
private void Parts_PartAdded(object sender, ItemAddedEventArgs<Part> e)
|
||||
{
|
||||
e.Item.BaseDrawing.Quantity.Nested += Quantity;
|
||||
if (!e.Item.BaseDrawing.IsCutOff)
|
||||
e.Item.BaseDrawing.Quantity.Nested += Quantity;
|
||||
}
|
||||
|
||||
private void Parts_PartRemoved(object sender, ItemRemovedEventArgs<Part> e)
|
||||
{
|
||||
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
||||
if (!e.Item.BaseDrawing.IsCutOff)
|
||||
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,6 +93,92 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public ObservableList<Part> Parts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The cut-off lines defined on this plate.
|
||||
/// </summary>
|
||||
public ObservableList<CutOff> CutOffs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Regenerates all cut-off drawings and materializes them as parts.
|
||||
/// Existing cut-off parts are removed first, then each cut-off is
|
||||
/// regenerated and added back if it produces any geometry.
|
||||
/// </summary>
|
||||
public void RegenerateCutOffs(CutOffSettings settings)
|
||||
{
|
||||
// Remove existing cut-off parts
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
var cache = BuildPerimeterCache(this);
|
||||
|
||||
// Regenerate and materialize each cut-off
|
||||
foreach (var cutoff in CutOffs)
|
||||
{
|
||||
cutoff.Regenerate(this, settings, cache);
|
||||
|
||||
if (cutoff.Drawing.Program.Codes.Count == 0)
|
||||
continue;
|
||||
|
||||
var part = new Part(cutoff.Drawing);
|
||||
Parts.Add(part);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a dictionary mapping each non-cut-off part to its perimeter entity.
|
||||
/// Closed shapes use ShapeProfile; open contours fall back to ConvexHull.
|
||||
/// </summary>
|
||||
public static Dictionary<Part, Geometry.Entity> BuildPerimeterCache(Plate plate)
|
||||
{
|
||||
var cache = new Dictionary<Part, Geometry.Entity>();
|
||||
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
Geometry.Entity perimeter = null;
|
||||
try
|
||||
{
|
||||
var entities = Converters.ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count > 0)
|
||||
{
|
||||
var profile = new Geometry.ShapeProfile(entities);
|
||||
|
||||
if (profile.Perimeter.IsClosed())
|
||||
{
|
||||
perimeter = profile.Perimeter;
|
||||
perimeter.Offset(part.Location);
|
||||
}
|
||||
else
|
||||
{
|
||||
var points = entities.CollectPoints();
|
||||
if (points.Count >= 3)
|
||||
{
|
||||
var hull = Geometry.ConvexHull.Compute(points);
|
||||
hull.Offset(part.Location);
|
||||
perimeter = hull;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
perimeter = null;
|
||||
}
|
||||
|
||||
cache[part] = perimeter;
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of times to cut the plate.
|
||||
/// </summary>
|
||||
@@ -240,11 +329,20 @@ namespace OpenNest
|
||||
/// <param name="angle"></param>
|
||||
public void Rotate(double angle)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Rotate(angle);
|
||||
}
|
||||
|
||||
foreach (var cutoff in CutOffs)
|
||||
cutoff.Position = cutoff.Position.Rotate(angle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -254,11 +352,24 @@ namespace OpenNest
|
||||
/// <param name="origin"></param>
|
||||
public void Rotate(double angle, Vector origin)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Rotate(angle, origin);
|
||||
}
|
||||
|
||||
foreach (var cutoff in CutOffs)
|
||||
{
|
||||
var pos = cutoff.Position - origin;
|
||||
pos = pos.Rotate(angle);
|
||||
cutoff.Position = pos + origin;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -268,11 +379,22 @@ namespace OpenNest
|
||||
/// <param name="y"></param>
|
||||
public void Offset(double x, double y)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
// Remove cut-off parts before transforming
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Offset(x, y);
|
||||
}
|
||||
|
||||
// Transform cut-off positions
|
||||
foreach (var cutoff in CutOffs)
|
||||
cutoff.Position = new Vector(cutoff.Position.X + x, cutoff.Position.Y + y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -281,11 +403,20 @@ namespace OpenNest
|
||||
/// <param name="voffset"></param>
|
||||
public void Offset(Vector voffset)
|
||||
{
|
||||
for (int i = 0; i < Parts.Count; ++i)
|
||||
for (var i = Parts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (Parts[i].BaseDrawing.IsCutOff)
|
||||
Parts.RemoveAt(i);
|
||||
}
|
||||
|
||||
for (var i = 0; i < Parts.Count; ++i)
|
||||
{
|
||||
var part = Parts[i];
|
||||
part.Offset(voffset);
|
||||
}
|
||||
|
||||
foreach (var cutoff in CutOffs)
|
||||
cutoff.Position = new Vector(cutoff.Position.X + voffset.X, cutoff.Position.Y + voffset.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -454,24 +585,23 @@ namespace OpenNest
|
||||
/// <returns>Returns a number between 0.0 and 1.0</returns>
|
||||
public double Utilization()
|
||||
{
|
||||
return Parts.Sum(part => part.BaseDrawing.Area) / Area();
|
||||
return Parts.Where(p => !p.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area) / Area();
|
||||
}
|
||||
|
||||
public bool HasOverlappingParts(out List<Vector> pts)
|
||||
{
|
||||
pts = new List<Vector>();
|
||||
var realParts = Parts.Where(p => !p.BaseDrawing.IsCutOff).ToList();
|
||||
|
||||
for (int i = 0; i < Parts.Count; i++)
|
||||
for (var i = 0; i < realParts.Count; i++)
|
||||
{
|
||||
var part1 = Parts[i];
|
||||
var part1 = realParts[i];
|
||||
|
||||
for (int j = i + 1; j < Parts.Count; j++)
|
||||
for (var j = i + 1; j < realParts.Count; j++)
|
||||
{
|
||||
var part2 = Parts[j];
|
||||
var part2 = realParts[j];
|
||||
|
||||
List<Vector> pts2;
|
||||
|
||||
if (part1.Intersects(part2, out pts2))
|
||||
if (part1.Intersects(part2, out var pts2))
|
||||
pts.AddRange(pts2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,17 @@ namespace OpenNest.Engine.BestFit
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
// OffsetSide.Right = outward for CCW perimeters (standard for outer contours).
|
||||
// Detect winding direction to choose the correct outward offset side.
|
||||
var outwardSide = OffsetSide.Right;
|
||||
if (halfSpacing > 0)
|
||||
{
|
||||
var testPoly = perimeter.ToPolygon();
|
||||
if (testPoly.Vertices.Count >= 3 && testPoly.RotationDirection() == RotationType.CW)
|
||||
outwardSide = OffsetSide.Left;
|
||||
}
|
||||
|
||||
var inflated = halfSpacing > 0
|
||||
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
|
||||
? (perimeter.OffsetEntity(halfSpacing, outwardSide) as Shape ?? perimeter)
|
||||
: perimeter;
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
|
||||
@@ -26,6 +26,16 @@ namespace OpenNest
|
||||
set => angleBuilder.ForceFullSweep = value;
|
||||
}
|
||||
|
||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
||||
{
|
||||
return angleBuilder.Build(item, bestRotation, workArea);
|
||||
}
|
||||
|
||||
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
||||
{
|
||||
angleBuilder.RecordProductive(angleResults);
|
||||
}
|
||||
|
||||
// --- Public Fill API ---
|
||||
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
@@ -42,6 +52,7 @@ namespace OpenNest
|
||||
PlateNumber = PlateNumber,
|
||||
Token = token,
|
||||
Progress = progress,
|
||||
Policy = BuildPolicy(),
|
||||
};
|
||||
|
||||
RunPipeline(context);
|
||||
@@ -53,10 +64,17 @@ namespace OpenNest
|
||||
var best = context.CurrentBest ?? new List<Part>();
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, ShrinkAxis.Width);
|
||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
|
||||
|
||||
ReportProgress(progress, WinnerPhase, PlateNumber, best, workArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = WinnerPhase,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = BuildProgressSummary(),
|
||||
IsOverallBest = true,
|
||||
});
|
||||
|
||||
return best;
|
||||
}
|
||||
@@ -78,13 +96,20 @@ namespace OpenNest
|
||||
PhaseResults.Clear();
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
|
||||
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea);
|
||||
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer);
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
|
||||
|
||||
Debug.WriteLine($"[Fill(groupParts,Box)] Linear pattern: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}");
|
||||
|
||||
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Linear,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = BuildProgressSummary(),
|
||||
IsOverallBest = true,
|
||||
});
|
||||
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
@@ -105,12 +130,12 @@ namespace OpenNest
|
||||
|
||||
// --- RunPipeline: strategy-based orchestration ---
|
||||
|
||||
private void RunPipeline(FillContext context)
|
||||
protected virtual void RunPipeline(FillContext context)
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
|
||||
var angles = angleBuilder.Build(context.Item, bestRotation, context.WorkArea);
|
||||
var angles = BuildAngles(context.Item, bestRotation, context.WorkArea);
|
||||
context.SharedState["AngleCandidates"] = angles;
|
||||
|
||||
try
|
||||
@@ -131,7 +156,7 @@ namespace OpenNest
|
||||
// during progress reporting.
|
||||
PhaseResults.Add(phaseResult);
|
||||
|
||||
if (IsBetterFill(result, context.CurrentBest, context.WorkArea))
|
||||
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
@@ -140,9 +165,15 @@ namespace OpenNest
|
||||
|
||||
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
{
|
||||
ReportProgress(context.Progress, context.WinnerPhase, PlateNumber,
|
||||
context.CurrentBest, context.WorkArea, BuildProgressSummary(),
|
||||
isOverallBest: true);
|
||||
ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
Phase = context.WinnerPhase,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = context.CurrentBest,
|
||||
WorkArea = context.WorkArea,
|
||||
Description = BuildProgressSummary(),
|
||||
IsOverallBest = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +182,7 @@ namespace OpenNest
|
||||
Debug.WriteLine("[RunPipeline] Cancelled, returning current best");
|
||||
}
|
||||
|
||||
angleBuilder.RecordProductive(context.AngleResults);
|
||||
RecordProductiveAngles(context.AngleResults);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ namespace OpenNest.Engine.Fill
|
||||
combined.AddRange(previousParts);
|
||||
combined.AddRange(value.BestParts);
|
||||
value.BestParts = combined;
|
||||
value.BestPartCount = combined.Count;
|
||||
}
|
||||
|
||||
inner.Report(value);
|
||||
|
||||
@@ -7,74 +7,30 @@ namespace OpenNest
|
||||
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
|
||||
{
|
||||
overallLength += Tolerance.Epsilon;
|
||||
|
||||
if (length1 > overallLength)
|
||||
{
|
||||
if (length2 > overallLength)
|
||||
{
|
||||
count1 = 0;
|
||||
count2 = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
count1 = 0;
|
||||
count2 = (int)System.Math.Floor(overallLength / length2);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (length2 > overallLength)
|
||||
{
|
||||
count1 = (int)System.Math.Floor(overallLength / length1);
|
||||
count2 = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
var maxCountLength1 = (int)System.Math.Floor(overallLength / length1);
|
||||
|
||||
count1 = maxCountLength1;
|
||||
count1 = 0;
|
||||
count2 = 0;
|
||||
|
||||
var remnant = overallLength - maxCountLength1 * length1;
|
||||
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
|
||||
var bestRemnant = overallLength + 1;
|
||||
|
||||
if (remnant.IsEqualTo(0))
|
||||
return true;
|
||||
|
||||
for (int countLength1 = 0; countLength1 <= maxCountLength1; ++countLength1)
|
||||
for (var c1 = 0; c1 <= maxCount1; c1++)
|
||||
{
|
||||
var remnant1 = overallLength - countLength1 * length1;
|
||||
var remaining = overallLength - c1 * length1;
|
||||
var c2 = (int)System.Math.Floor(remaining / length2);
|
||||
var remnant = remaining - c2 * length2;
|
||||
|
||||
if (remnant1 >= length2)
|
||||
{
|
||||
var countLength2 = (int)System.Math.Floor(remnant1 / length2);
|
||||
var remnant2 = remnant1 - length2 * countLength2;
|
||||
if (!(remnant < bestRemnant))
|
||||
continue;
|
||||
|
||||
if (!(remnant2 < remnant))
|
||||
continue;
|
||||
count1 = c1;
|
||||
count2 = c2;
|
||||
bestRemnant = remnant;
|
||||
|
||||
count1 = countLength1;
|
||||
count2 = countLength2;
|
||||
|
||||
if (remnant2.IsEqualTo(0))
|
||||
break;
|
||||
|
||||
remnant = remnant2;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!(remnant1 < remnant))
|
||||
continue;
|
||||
|
||||
count1 = countLength1;
|
||||
count2 = 0;
|
||||
|
||||
if (remnant1.IsEqualTo(0))
|
||||
break;
|
||||
|
||||
remnant = remnant1;
|
||||
}
|
||||
if (remnant.IsEqualTo(0))
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
return count1 > 0 || count2 > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
OpenNest.Engine/Fill/DefaultFillComparer.cs
Normal file
23
OpenNest.Engine/Fill/DefaultFillComparer.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Ranks fill results by count first, then density.
|
||||
/// This is the original scoring logic used by DefaultNestEngine.
|
||||
/// </summary>
|
||||
public class DefaultFillComparer : IFillComparer
|
||||
{
|
||||
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,18 +36,36 @@ namespace OpenNest.Engine.Fill
|
||||
if (column.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
|
||||
column, workArea, $"Extents: initial column {column.Count} parts");
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = column,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: initial column {column.Count} parts",
|
||||
});
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
|
||||
adjusted, workArea, $"Extents: adjusted column {adjusted.Count} parts");
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: adjusted column {adjusted.Count} parts",
|
||||
});
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Extents, plateNumber,
|
||||
result, workArea, $"Extents: {result.Count} parts total");
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = result,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: {result.Count} parts total",
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
97
OpenNest.Engine/Fill/FillResultCache.cs
Normal file
97
OpenNest.Engine/Fill/FillResultCache.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Fill;
|
||||
|
||||
/// <summary>
|
||||
/// Caches fill results by drawing and box dimensions so repeated fills
|
||||
/// of the same size don't recompute. Parts are stored normalized to origin
|
||||
/// and offset to the actual location on retrieval.
|
||||
/// </summary>
|
||||
public static class FillResultCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<CacheKey, List<Part>> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a cached fill result for the given drawing and box dimensions,
|
||||
/// offset to the target location. Returns null on cache miss.
|
||||
/// </summary>
|
||||
public static List<Part> Get(Drawing drawing, Box targetBox, double spacing)
|
||||
{
|
||||
var key = new CacheKey(drawing, targetBox.Width, targetBox.Length, spacing);
|
||||
|
||||
if (!_cache.TryGetValue(key, out var cached) || cached.Count == 0)
|
||||
return null;
|
||||
|
||||
var offset = targetBox.Location;
|
||||
var result = new List<Part>(cached.Count);
|
||||
|
||||
foreach (var part in cached)
|
||||
result.Add(part.CloneAtOffset(offset));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores a fill result normalized to origin (0,0).
|
||||
/// </summary>
|
||||
public static void Store(Drawing drawing, Box sourceBox, double spacing, List<Part> parts)
|
||||
{
|
||||
if (parts == null || parts.Count == 0)
|
||||
return;
|
||||
|
||||
var key = new CacheKey(drawing, sourceBox.Width, sourceBox.Length, spacing);
|
||||
|
||||
if (_cache.ContainsKey(key))
|
||||
return;
|
||||
|
||||
var offset = new Vector(-sourceBox.X, -sourceBox.Y);
|
||||
var normalized = new List<Part>(parts.Count);
|
||||
|
||||
foreach (var part in parts)
|
||||
normalized.Add(part.CloneAtOffset(offset));
|
||||
|
||||
_cache.TryAdd(key, normalized);
|
||||
}
|
||||
|
||||
public static void Clear() => _cache.Clear();
|
||||
|
||||
public static int Count => _cache.Count;
|
||||
|
||||
private readonly struct CacheKey : System.IEquatable<CacheKey>
|
||||
{
|
||||
public readonly Drawing Drawing;
|
||||
public readonly double Width;
|
||||
public readonly double Height;
|
||||
public readonly double Spacing;
|
||||
|
||||
public CacheKey(Drawing drawing, double width, double height, double spacing)
|
||||
{
|
||||
Drawing = drawing;
|
||||
Width = System.Math.Round(width, 2);
|
||||
Height = System.Math.Round(height, 2);
|
||||
Spacing = spacing;
|
||||
}
|
||||
|
||||
public bool Equals(CacheKey other) =>
|
||||
ReferenceEquals(Drawing, other.Drawing) &&
|
||||
Width == other.Width && Height == other.Height &&
|
||||
Spacing == other.Spacing;
|
||||
|
||||
public override bool Equals(object obj) => obj is CacheKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = RuntimeHelpers.GetHashCode(Drawing);
|
||||
hash = hash * 397 ^ Width.GetHashCode();
|
||||
hash = hash * 397 ^ Height.GetHashCode();
|
||||
hash = hash * 397 ^ Spacing.GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
75
OpenNest.Engine/Fill/GridDedup.cs
Normal file
75
OpenNest.Engine/Fill/GridDedup.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Fill;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks evaluated grid configurations so duplicate pattern/direction/workArea
|
||||
/// combinations can be skipped across fill strategies.
|
||||
/// </summary>
|
||||
public class GridDedup
|
||||
{
|
||||
public const string SharedStateKey = "GridDedup";
|
||||
|
||||
private readonly ConcurrentDictionary<GridKey, byte> _seen = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this configuration has NOT been seen before (i.e., should be evaluated).
|
||||
/// Returns false if it's a duplicate.
|
||||
/// </summary>
|
||||
public bool TryAdd(Box patternBox, Box workArea, NestDirection dir)
|
||||
{
|
||||
var key = new GridKey(patternBox, workArea, dir);
|
||||
return _seen.TryAdd(key, 0);
|
||||
}
|
||||
|
||||
public int Count => _seen.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a GridDedup from FillContext.SharedState.
|
||||
/// </summary>
|
||||
public static GridDedup GetOrCreate(System.Collections.Generic.Dictionary<string, object> sharedState)
|
||||
{
|
||||
if (sharedState.TryGetValue(SharedStateKey, out var existing))
|
||||
return (GridDedup)existing;
|
||||
|
||||
var dedup = new GridDedup();
|
||||
sharedState[SharedStateKey] = dedup;
|
||||
return dedup;
|
||||
}
|
||||
|
||||
private readonly struct GridKey : IEquatable<GridKey>
|
||||
{
|
||||
private readonly int _patternW, _patternL, _workW, _workL, _dir;
|
||||
|
||||
public GridKey(Box patternBox, Box workArea, NestDirection dir)
|
||||
{
|
||||
_patternW = (int)System.Math.Round(patternBox.Width * 10);
|
||||
_patternL = (int)System.Math.Round(patternBox.Length * 10);
|
||||
_workW = (int)System.Math.Round(workArea.Width * 10);
|
||||
_workL = (int)System.Math.Round(workArea.Length * 10);
|
||||
_dir = (int)dir;
|
||||
}
|
||||
|
||||
public bool Equals(GridKey other) =>
|
||||
_patternW == other._patternW && _patternL == other._patternL &&
|
||||
_workW == other._workW && _workL == other._workL &&
|
||||
_dir == other._dir;
|
||||
|
||||
public override bool Equals(object obj) => obj is GridKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = _patternW;
|
||||
hash = hash * 397 ^ _patternL;
|
||||
hash = hash * 397 ^ _workW;
|
||||
hash = hash * 397 ^ _workL;
|
||||
hash = hash * 397 ^ _dir;
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
OpenNest.Engine/Fill/HorizontalRemnantComparer.cs
Normal file
49
OpenNest.Engine/Fill/HorizontalRemnantComparer.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Ranks fill results to minimize Y-extent (preserve top-side horizontal remnant).
|
||||
/// Tiebreak chain: count > smallest Y-extent > highest density.
|
||||
/// </summary>
|
||||
public class HorizontalRemnantComparer : IFillComparer
|
||||
{
|
||||
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
if (candidate.Count != current.Count)
|
||||
return candidate.Count > current.Count;
|
||||
|
||||
var candExtent = YExtent(candidate);
|
||||
var currExtent = YExtent(current);
|
||||
|
||||
if (!candExtent.IsEqualTo(currExtent))
|
||||
return candExtent < currExtent;
|
||||
|
||||
return FillScore.Compute(candidate, workArea).Density
|
||||
> FillScore.Compute(current, workArea).Density;
|
||||
}
|
||||
|
||||
private static double YExtent(List<Part> parts)
|
||||
{
|
||||
var minY = double.MaxValue;
|
||||
var maxY = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
if (bb.Bottom < minY) minY = bb.Bottom;
|
||||
if (bb.Top > maxY) maxY = bb.Top;
|
||||
}
|
||||
|
||||
return maxY - minY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace OpenNest.Engine.Fill
|
||||
/// <summary>
|
||||
/// Composes <see cref="RemnantFiller"/> and <see cref="ShrinkFiller"/> with
|
||||
/// dual-direction shrink selection. Wraps the caller's fill function in a
|
||||
/// closure that tries both <see cref="ShrinkAxis.Height"/> and
|
||||
/// closure that tries both <see cref="ShrinkAxis.Length"/> and
|
||||
/// <see cref="ShrinkAxis.Width"/>, picks the better <see cref="FillScore"/>,
|
||||
/// and passes the wrapper to <see cref="RemnantFiller.FillItems"/>.
|
||||
/// </summary>
|
||||
@@ -31,7 +31,8 @@ namespace OpenNest.Engine.Fill
|
||||
double spacing,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null,
|
||||
int plateNumber = 0)
|
||||
int plateNumber = 0,
|
||||
Func<NestItem, Box, List<Part>> widthFillFunc = null)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new IterativeShrinkResult();
|
||||
@@ -72,6 +73,8 @@ namespace OpenNest.Engine.Fill
|
||||
// include them in progress reports.
|
||||
var placedSoFar = new List<Part>();
|
||||
|
||||
var wFillFunc = widthFillFunc ?? fillFunc;
|
||||
|
||||
Func<NestItem, Box, List<Part>> shrinkWrapper = (ni, box) =>
|
||||
{
|
||||
var target = ni.Quantity > 0 ? ni.Quantity : 0;
|
||||
@@ -82,9 +85,9 @@ namespace OpenNest.Engine.Fill
|
||||
ShrinkResult widthResult = null;
|
||||
|
||||
Parallel.Invoke(
|
||||
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token,
|
||||
() => heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Length, token,
|
||||
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar),
|
||||
() => widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token,
|
||||
() => widthResult = ShrinkFiller.Shrink(wFillFunc, ni, box, spacing, ShrinkAxis.Width, token,
|
||||
targetCount: target, progress: progress, plateNumber: plateNumber, placedParts: placedSoFar)
|
||||
);
|
||||
|
||||
@@ -108,8 +111,15 @@ namespace OpenNest.Engine.Fill
|
||||
var allParts = new List<Part>(placedSoFar.Count + best.Count);
|
||||
allParts.AddRange(placedSoFar);
|
||||
allParts.AddRange(best);
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
|
||||
allParts, box, $"Shrink: {best.Count} parts placed", isOverallBest: true);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = allParts,
|
||||
WorkArea = box,
|
||||
Description = $"Shrink: {best.Count} parts placed",
|
||||
IsOverallBest = true,
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulate for the next item's progress reports.
|
||||
|
||||
@@ -7,6 +7,8 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
@@ -27,13 +29,19 @@ namespace OpenNest.Engine.Fill
|
||||
private const int EarlyExitMinTried = 10;
|
||||
private const int EarlyExitStaleLimit = 10;
|
||||
|
||||
private readonly Plate plate;
|
||||
private readonly Size plateSize;
|
||||
private readonly double partSpacing;
|
||||
private readonly IFillComparer comparer;
|
||||
private readonly GridDedup dedup;
|
||||
|
||||
public PairFiller(Size plateSize, double partSpacing)
|
||||
public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null)
|
||||
{
|
||||
this.plateSize = plateSize;
|
||||
this.partSpacing = partSpacing;
|
||||
this.plate = plate;
|
||||
this.plateSize = plate.Size;
|
||||
this.partSpacing = plate.PartSpacing;
|
||||
this.comparer = comparer ?? new DefaultFillComparer();
|
||||
this.dedup = dedup ?? new GridDedup();
|
||||
}
|
||||
|
||||
public PairFillResult Fill(NestItem item, Box workArea,
|
||||
@@ -61,37 +69,62 @@ namespace OpenNest.Engine.Fill
|
||||
int plateNumber, CancellationToken token, IProgress<NestProgress> progress)
|
||||
{
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
var sinceImproved = 0;
|
||||
var effectiveWorkArea = workArea;
|
||||
var batchSize = System.Math.Max(2, Environment.ProcessorCount);
|
||||
|
||||
var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0;
|
||||
var partBox = drawing.Program.BoundingBox();
|
||||
var partArea = System.Math.Max(partBox.Width * partBox.Length, 1);
|
||||
|
||||
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
for (var batchStart = 0; batchStart < candidates.Count; batchStart += batchSize)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var filled = EvaluateCandidate(candidates[i], drawing, effectiveWorkArea);
|
||||
var score = FillScore.Compute(filled, effectiveWorkArea);
|
||||
var batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count);
|
||||
var batchCount = batchEnd - batchStart;
|
||||
var batchWorkArea = effectiveWorkArea;
|
||||
var minCountToBeat = best?.Count ?? 0;
|
||||
|
||||
if (score > bestScore)
|
||||
var results = new List<Part>[batchCount];
|
||||
Parallel.For(0, batchCount,
|
||||
new ParallelOptions { CancellationToken = token },
|
||||
j =>
|
||||
{
|
||||
results[j] = EvaluateCandidate(
|
||||
candidates[batchStart + j], drawing, batchWorkArea,
|
||||
minCountToBeat, maxUtilization, partArea, token);
|
||||
});
|
||||
|
||||
for (var j = 0; j < batchCount; j++)
|
||||
{
|
||||
best = filled;
|
||||
bestScore = score;
|
||||
sinceImproved = 0;
|
||||
effectiveWorkArea = TryReduceWorkArea(filled, targetCount, workArea, effectiveWorkArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
if (comparer.IsBetter(results[j], best, effectiveWorkArea))
|
||||
{
|
||||
best = results[j];
|
||||
sinceImproved = 0;
|
||||
effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea);
|
||||
}
|
||||
else
|
||||
{
|
||||
sinceImproved++;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Pairs,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = $"Pairs: {batchStart + j + 1}/{candidates.Count} candidates, best = {best?.Count ?? 0} parts",
|
||||
});
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
|
||||
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
|
||||
|
||||
if (i + 1 >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
|
||||
if (batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit)
|
||||
{
|
||||
Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
|
||||
Debug.WriteLine($"[PairFiller] Early exit at {batchEnd}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -100,8 +133,12 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
|
||||
}
|
||||
finally
|
||||
{
|
||||
FillStrategyRegistry.SetEnabled(null);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
|
||||
Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts");
|
||||
return best ?? new List<Part>();
|
||||
}
|
||||
|
||||
@@ -142,12 +179,162 @@ namespace OpenNest.Engine.Fill
|
||||
System.Math.Min(newTop - workArea.Y, workArea.Length));
|
||||
}
|
||||
|
||||
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing, Box workArea)
|
||||
private List<Part> EvaluateCandidate(BestFitResult candidate, Drawing drawing,
|
||||
Box workArea, int minCountToBeat, double maxUtilization, double partArea,
|
||||
CancellationToken token)
|
||||
{
|
||||
var pairParts = candidate.BuildParts(drawing);
|
||||
var engine = new FillLinear(workArea, partSpacing);
|
||||
var angles = BuildTilingAngles(candidate);
|
||||
return FillHelpers.FillPattern(engine, pairParts, angles, workArea);
|
||||
|
||||
// Phase 1: evaluate all grids (fast)
|
||||
var grids = new List<(List<Part> Parts, NestDirection Dir)>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
|
||||
if (pattern.Parts.Count == 0)
|
||||
continue;
|
||||
|
||||
var engine = new FillLinear(workArea, partSpacing);
|
||||
foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
||||
{
|
||||
if (!dedup.TryAdd(pattern.BoundingBox, workArea, dir))
|
||||
continue;
|
||||
|
||||
var gridParts = engine.Fill(pattern, dir);
|
||||
if (gridParts != null && gridParts.Count > 0)
|
||||
grids.Add((gridParts, dir));
|
||||
}
|
||||
}
|
||||
|
||||
if (grids.Count == 0)
|
||||
return null;
|
||||
|
||||
// Sort by count descending so we try the best grids first
|
||||
grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count));
|
||||
|
||||
// Early abort: if the best grid + optimistic remnant can't beat the global best, skip Phase 2
|
||||
if (minCountToBeat > 0)
|
||||
{
|
||||
var topCount = grids[0].Parts.Count;
|
||||
var optimisticRemnant = EstimateRemnantUpperBound(
|
||||
grids[0].Parts, workArea, maxUtilization, partArea);
|
||||
if (topCount + optimisticRemnant <= minCountToBeat)
|
||||
{
|
||||
Debug.WriteLine($"[PairFiller] Skipping candidate: grid {topCount} + estimate {optimisticRemnant} <= best {minCountToBeat}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: try remnant for each grid, skip if grid is too far behind
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var (gridParts, dir) in grids)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// If this grid + max possible remnant can't beat current best, skip
|
||||
if (best != null)
|
||||
{
|
||||
var remnantBound = EstimateRemnantUpperBound(
|
||||
gridParts, workArea, maxUtilization, partArea);
|
||||
if (gridParts.Count + remnantBound <= best.Count)
|
||||
break; // sorted descending, so remaining are even smaller
|
||||
}
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, drawing, workArea, token);
|
||||
List<Part> total;
|
||||
if (remnantParts != null && remnantParts.Count > 0)
|
||||
{
|
||||
total = new List<Part>(gridParts.Count + remnantParts.Count);
|
||||
total.AddRange(gridParts);
|
||||
total.AddRange(remnantParts);
|
||||
}
|
||||
else
|
||||
{
|
||||
total = gridParts;
|
||||
}
|
||||
|
||||
if (comparer.IsBetter(total, best, workArea))
|
||||
best = total;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private int EstimateRemnantUpperBound(List<Part> gridParts, Box workArea,
|
||||
double maxUtilization, double partArea)
|
||||
{
|
||||
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
|
||||
|
||||
// L-shaped remnant: top strip (full width) + right strip (grid height only)
|
||||
var topHeight = System.Math.Max(0, workArea.Top - gridBox.Top);
|
||||
var rightWidth = System.Math.Max(0, workArea.Right - gridBox.Right);
|
||||
|
||||
var topArea = workArea.Width * topHeight;
|
||||
var rightArea = rightWidth * System.Math.Min(gridBox.Top - workArea.Y, workArea.Length);
|
||||
var remnantArea = topArea + rightArea;
|
||||
|
||||
return (int)(remnantArea * maxUtilization / partArea) + 1;
|
||||
}
|
||||
|
||||
private List<Part> FillRemnant(List<Part> gridParts, Drawing drawing,
|
||||
Box workArea, CancellationToken token)
|
||||
{
|
||||
var gridBox = ((IEnumerable<IBoundable>)gridParts).GetBoundingBox();
|
||||
var partBox = drawing.Program.BoundingBox();
|
||||
var minDim = System.Math.Min(partBox.Width, partBox.Length) + 2 * partSpacing;
|
||||
|
||||
List<Part> bestRemnant = null;
|
||||
|
||||
// Try top remnant (full width, above grid)
|
||||
var topY = gridBox.Top + partSpacing;
|
||||
var topLength = workArea.Top - topY;
|
||||
if (topLength >= minDim)
|
||||
{
|
||||
var topBox = new Box(workArea.X, topY, workArea.Width, topLength);
|
||||
var parts = FillRemnantBox(drawing, topBox, token);
|
||||
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
|
||||
bestRemnant = parts;
|
||||
}
|
||||
|
||||
// Try right remnant (full height, right of grid)
|
||||
var rightX = gridBox.Right + partSpacing;
|
||||
var rightWidth = workArea.Right - rightX;
|
||||
if (rightWidth >= minDim)
|
||||
{
|
||||
var rightBox = new Box(rightX, workArea.Y, rightWidth, workArea.Length);
|
||||
var parts = FillRemnantBox(drawing, rightBox, token);
|
||||
if (parts != null && parts.Count > (bestRemnant?.Count ?? 0))
|
||||
bestRemnant = parts;
|
||||
}
|
||||
|
||||
return bestRemnant;
|
||||
}
|
||||
|
||||
private List<Part> FillRemnantBox(Drawing drawing, Box remnantBox, CancellationToken token)
|
||||
{
|
||||
var cachedResult = FillResultCache.Get(drawing, remnantBox, partSpacing);
|
||||
if (cachedResult != null)
|
||||
{
|
||||
Debug.WriteLine($"[PairFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
var remnantEngine = NestEngineRegistry.Create(plate);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var parts = remnantEngine.Fill(item, remnantBox, null, token);
|
||||
|
||||
Debug.WriteLine($"[PairFiller] Remnant: {parts?.Count ?? 0} parts in " +
|
||||
$"{remnantBox.Width:F2}x{remnantBox.Length:F2}");
|
||||
|
||||
if (parts != null && parts.Count > 0)
|
||||
{
|
||||
FillResultCache.Store(drawing, remnantBox, partSpacing, parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<double> BuildTilingAngles(BestFitResult candidate)
|
||||
|
||||
@@ -102,11 +102,21 @@ namespace OpenNest.Engine.Fill
|
||||
if (placed == null)
|
||||
continue;
|
||||
|
||||
// Remove the topmost bounding box part to create a clean
|
||||
// rectangular obstacle boundary. Without this, gaps between
|
||||
// individual bounding boxes cause the next drawing to fill
|
||||
// into inter-row spaces, producing an interleaved layout.
|
||||
if (placed.Count > 1)
|
||||
RemoveTopmostPart(placed);
|
||||
|
||||
allParts.AddRange(placed);
|
||||
localQty[item.Drawing.Name] = System.Math.Max(0, qty - placed.Count);
|
||||
|
||||
foreach (var p in placed)
|
||||
finder.AddObstacle(p.BoundingBox.Offset(spacing));
|
||||
// Add the envelope of all placed parts as a single obstacle
|
||||
// rather than individual bounding boxes, preventing the
|
||||
// remnant finder from seeing inter-part gaps.
|
||||
var envelope = ComputeEnvelope(placed, spacing);
|
||||
finder.AddObstacle(envelope);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -114,6 +124,39 @@ namespace OpenNest.Engine.Fill
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RemoveTopmostPart(List<Part> parts)
|
||||
{
|
||||
var topIdx = 0;
|
||||
|
||||
for (var i = 1; i < parts.Count; i++)
|
||||
{
|
||||
if (parts[i].BoundingBox.Top > parts[topIdx].BoundingBox.Top)
|
||||
topIdx = i;
|
||||
}
|
||||
|
||||
parts.RemoveAt(topIdx);
|
||||
}
|
||||
|
||||
private static Box ComputeEnvelope(List<Part> parts, double spacing)
|
||||
{
|
||||
var left = double.MaxValue;
|
||||
var bottom = double.MaxValue;
|
||||
var right = double.MinValue;
|
||||
var top = double.MinValue;
|
||||
|
||||
foreach (var p in parts)
|
||||
{
|
||||
var bb = p.BoundingBox;
|
||||
if (bb.Left < left) left = bb.Left;
|
||||
if (bb.Bottom < bottom) bottom = bb.Bottom;
|
||||
if (bb.Right > right) right = bb.Right;
|
||||
if (bb.Top > top) top = bb.Top;
|
||||
}
|
||||
|
||||
return new Box(left - spacing, bottom - spacing,
|
||||
right - left + spacing * 2, top - bottom + spacing * 2);
|
||||
}
|
||||
|
||||
private static List<Part> TryFillInRemnants(
|
||||
NestItem item,
|
||||
int qty,
|
||||
|
||||
@@ -7,7 +7,7 @@ using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
public enum ShrinkAxis { Width, Height }
|
||||
public enum ShrinkAxis { Width, Length }
|
||||
|
||||
public class ShrinkResult
|
||||
{
|
||||
@@ -79,8 +79,14 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
var desc = $"Shrink {axis}: {bestParts.Count} parts, dim={dim:F1}";
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Custom, plateNumber,
|
||||
allParts, workArea, desc);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = allParts,
|
||||
WorkArea = workArea,
|
||||
Description = desc,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,7 +101,7 @@ namespace OpenNest.Engine.Fill
|
||||
if (bbox.Width <= 0 || bbox.Length <= 0)
|
||||
return box;
|
||||
|
||||
var maxDim = axis == ShrinkAxis.Height ? box.Length : box.Width;
|
||||
var maxDim = axis == ShrinkAxis.Length ? box.Length : box.Width;
|
||||
|
||||
// Use FillBestFit for a fast, accurate rectangle count on the full box.
|
||||
var bin = new Bin { Size = new Size(box.Width, box.Length) };
|
||||
@@ -115,7 +121,7 @@ namespace OpenNest.Engine.Fill
|
||||
if (estimate <= 0 || estimate >= maxDim)
|
||||
return box;
|
||||
|
||||
return axis == ShrinkAxis.Height
|
||||
return axis == ShrinkAxis.Length
|
||||
? new Box(box.X, box.Y, box.Width, estimate)
|
||||
: new Box(box.X, box.Y, estimate, box.Length);
|
||||
}
|
||||
|
||||
473
OpenNest.Engine/Fill/StripeFiller.cs
Normal file
473
OpenNest.Engine/Fill/StripeFiller.cs
Normal file
@@ -0,0 +1,473 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace OpenNest.Engine.Fill;
|
||||
|
||||
public class StripeFiller
|
||||
{
|
||||
private const int MaxPairCandidates = 5;
|
||||
private const int MaxConvergenceIterations = 20;
|
||||
private const int AngleSamples = 36;
|
||||
|
||||
private readonly FillContext _context;
|
||||
private readonly NestDirection _primaryAxis;
|
||||
private readonly IFillComparer _comparer;
|
||||
private readonly GridDedup _dedup;
|
||||
|
||||
/// <summary>
|
||||
/// When true, only complete stripes are placed — no partial rows/columns.
|
||||
/// </summary>
|
||||
public bool CompleteStripesOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Factory to create the engine used for filling the remnant strip.
|
||||
/// Defaults to NestEngineRegistry.Create (uses the user's selected engine).
|
||||
/// </summary>
|
||||
public Func<Plate, NestEngineBase> CreateRemnantEngine { get; set; }
|
||||
= NestEngineRegistry.Create;
|
||||
|
||||
public StripeFiller(FillContext context, NestDirection primaryAxis)
|
||||
{
|
||||
_context = context;
|
||||
_primaryAxis = primaryAxis;
|
||||
_comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
||||
_dedup = GridDedup.GetOrCreate(context.SharedState);
|
||||
}
|
||||
|
||||
public List<Part> Fill()
|
||||
{
|
||||
var bestFits = GetPairCandidates();
|
||||
if (bestFits.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var workArea = _context.WorkArea;
|
||||
var spacing = _context.Plate.PartSpacing;
|
||||
var drawing = _context.Item.Drawing;
|
||||
var strategyName = _primaryAxis == NestDirection.Horizontal ? "Row" : "Column";
|
||||
|
||||
List<Part> bestParts = null;
|
||||
|
||||
for (var i = 0; i < bestFits.Count; i++)
|
||||
{
|
||||
_context.Token.ThrowIfCancellationRequested();
|
||||
|
||||
var candidate = bestFits[i];
|
||||
var pairParts = candidate.BuildParts(drawing);
|
||||
|
||||
foreach (var axis in new[] { NestDirection.Horizontal, NestDirection.Vertical })
|
||||
{
|
||||
var perpAxis = axis == NestDirection.Horizontal
|
||||
? NestDirection.Vertical : NestDirection.Horizontal;
|
||||
var sheetSpan = GetDimension(workArea, axis);
|
||||
var dirLabel = axis == NestDirection.Horizontal ? "Row" : "Col";
|
||||
|
||||
var expandResult = ConvergeStripeAngle(
|
||||
pairParts, sheetSpan, spacing, axis, _context.Token);
|
||||
var shrinkResult = ConvergeStripeAngleShrink(
|
||||
pairParts, sheetSpan, spacing, axis, _context.Token);
|
||||
|
||||
foreach (var (angle, waste, count) in new[] { expandResult, shrinkResult })
|
||||
{
|
||||
if (count <= 0)
|
||||
continue;
|
||||
|
||||
var result = BuildGrid(pairParts, angle, axis, perpAxis);
|
||||
|
||||
if (result == null || result.Count == 0)
|
||||
continue;
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] {strategyName} candidate {i} {dirLabel}: " +
|
||||
$"angle={Angle.ToDegrees(angle):F1}°, N={count}, waste={waste:F2}, " +
|
||||
$"grid={result.Count} parts");
|
||||
|
||||
if (_comparer.IsBetter(result, bestParts, workArea))
|
||||
{
|
||||
bestParts = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(_context.Progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Custom,
|
||||
PlateNumber = _context.PlateNumber,
|
||||
Parts = bestParts,
|
||||
WorkArea = workArea,
|
||||
Description = $"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts",
|
||||
});
|
||||
}
|
||||
|
||||
return bestParts ?? new List<Part>();
|
||||
}
|
||||
|
||||
private List<Part> BuildGrid(List<Part> pairParts, double angle,
|
||||
NestDirection primaryAxis, NestDirection perpAxis)
|
||||
{
|
||||
var workArea = _context.WorkArea;
|
||||
var spacing = _context.Plate.PartSpacing;
|
||||
|
||||
var rotatedPattern = FillHelpers.BuildRotatedPattern(pairParts, angle);
|
||||
var perpDim = GetDimension(rotatedPattern.BoundingBox, perpAxis);
|
||||
var stripeBox = MakeStripeBox(workArea, perpDim, primaryAxis);
|
||||
|
||||
if (!_dedup.TryAdd(rotatedPattern.BoundingBox, workArea, primaryAxis))
|
||||
return null;
|
||||
|
||||
var stripeEngine = new FillLinear(stripeBox, spacing);
|
||||
var stripeParts = stripeEngine.Fill(rotatedPattern, primaryAxis);
|
||||
|
||||
if (stripeParts == null || stripeParts.Count == 0)
|
||||
return null;
|
||||
|
||||
var partsPerStripe = stripeParts.Count;
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] Stripe: {partsPerStripe} parts, " +
|
||||
$"box={stripeBox.Width:F2}x{stripeBox.Length:F2}");
|
||||
|
||||
var stripePattern = new Pattern();
|
||||
stripePattern.Parts.AddRange(stripeParts);
|
||||
stripePattern.UpdateBounds();
|
||||
|
||||
var gridEngine = new FillLinear(workArea, spacing);
|
||||
var gridParts = gridEngine.Fill(stripePattern, perpAxis);
|
||||
|
||||
if (gridParts == null || gridParts.Count == 0)
|
||||
return null;
|
||||
|
||||
if (CompleteStripesOnly)
|
||||
{
|
||||
var completeCount = gridParts.Count / partsPerStripe * partsPerStripe;
|
||||
if (completeCount < gridParts.Count)
|
||||
{
|
||||
Debug.WriteLine($"[StripeFiller] CompleteOnly: {gridParts.Count} → {completeCount} " +
|
||||
$"(dropped {gridParts.Count - completeCount} partial)");
|
||||
gridParts = gridParts.GetRange(0, completeCount);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] Grid: {gridParts.Count} parts");
|
||||
|
||||
if (gridParts.Count == 0)
|
||||
return null;
|
||||
|
||||
var allParts = new List<Part>(gridParts);
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
||||
if (remnantParts != null)
|
||||
{
|
||||
Debug.WriteLine($"[StripeFiller] Remnant: {remnantParts.Count} parts");
|
||||
allParts.AddRange(remnantParts);
|
||||
}
|
||||
|
||||
return allParts;
|
||||
}
|
||||
|
||||
private List<BestFitResult> GetPairCandidates()
|
||||
{
|
||||
List<BestFitResult> bestFits;
|
||||
|
||||
if (_context.SharedState.TryGetValue("BestFits", out var cached))
|
||||
bestFits = (List<BestFitResult>)cached;
|
||||
else
|
||||
bestFits = BestFitCache.GetOrCompute(
|
||||
_context.Item.Drawing,
|
||||
_context.Plate.Size.Length,
|
||||
_context.Plate.Size.Width,
|
||||
_context.Plate.PartSpacing);
|
||||
|
||||
return bestFits
|
||||
.Where(r => r.Keep)
|
||||
.Take(MaxPairCandidates)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Box MakeStripeBox(Box workArea, double perpDim, NestDirection primaryAxis)
|
||||
{
|
||||
return primaryAxis == NestDirection.Horizontal
|
||||
? new Box(workArea.X, workArea.Y, workArea.Width, perpDim)
|
||||
: new Box(workArea.X, workArea.Y, perpDim, workArea.Length);
|
||||
}
|
||||
|
||||
private List<Part> FillRemnant(List<Part> gridParts, NestDirection primaryAxis)
|
||||
{
|
||||
var workArea = _context.WorkArea;
|
||||
var spacing = _context.Plate.PartSpacing;
|
||||
var drawing = _context.Item.Drawing;
|
||||
|
||||
var gridBox = gridParts.GetBoundingBox();
|
||||
var minDim = System.Math.Min(
|
||||
drawing.Program.BoundingBox().Width,
|
||||
drawing.Program.BoundingBox().Length);
|
||||
|
||||
Box remnantBox;
|
||||
|
||||
if (primaryAxis == NestDirection.Horizontal)
|
||||
{
|
||||
var remnantY = gridBox.Top + spacing;
|
||||
var remnantLength = workArea.Top - remnantY;
|
||||
if (remnantLength < minDim)
|
||||
return null;
|
||||
remnantBox = new Box(workArea.X, remnantY, workArea.Width, remnantLength);
|
||||
}
|
||||
else
|
||||
{
|
||||
var remnantX = gridBox.Right + spacing;
|
||||
var remnantWidth = workArea.Right - remnantX;
|
||||
if (remnantWidth < minDim)
|
||||
return null;
|
||||
remnantBox = new Box(remnantX, workArea.Y, remnantWidth, workArea.Length);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] Remnant box: {remnantBox.Width:F2}x{remnantBox.Length:F2}");
|
||||
|
||||
var cachedResult = FillResultCache.Get(drawing, remnantBox, spacing);
|
||||
if (cachedResult != null)
|
||||
{
|
||||
Debug.WriteLine($"[StripeFiller] Remnant CACHE HIT: {cachedResult.Count} parts");
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
||||
try
|
||||
{
|
||||
var engine = CreateRemnantEngine(_context.Plate);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] Remnant engine ({engine.Name}): {parts?.Count ?? 0} parts, " +
|
||||
$"winner={engine.WinnerPhase}");
|
||||
|
||||
if (parts != null && parts.Count > 0)
|
||||
{
|
||||
FillResultCache.Store(drawing, remnantBox, spacing, parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FillStrategyRegistry.SetEnabled(null);
|
||||
}
|
||||
}
|
||||
|
||||
public static double FindAngleForTargetSpan(
|
||||
List<Part> patternParts, double targetSpan, NestDirection axis)
|
||||
{
|
||||
var bestAngle = 0.0;
|
||||
var bestDiff = double.MaxValue;
|
||||
var samples = new (double angle, double span)[AngleSamples + 1];
|
||||
|
||||
for (var i = 0; i <= AngleSamples; i++)
|
||||
{
|
||||
var angle = i * Angle.HalfPI / AngleSamples;
|
||||
var span = GetRotatedSpan(patternParts, angle, axis);
|
||||
samples[i] = (angle, span);
|
||||
|
||||
var diff = System.Math.Abs(span - targetSpan);
|
||||
if (diff < bestDiff)
|
||||
{
|
||||
bestDiff = diff;
|
||||
bestAngle = angle;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDiff < Tolerance.Epsilon)
|
||||
return bestAngle;
|
||||
|
||||
for (var i = 0; i < samples.Length - 1; i++)
|
||||
{
|
||||
var (a1, s1) = samples[i];
|
||||
var (a2, s2) = samples[i + 1];
|
||||
|
||||
if ((s1 <= targetSpan && targetSpan <= s2) ||
|
||||
(s2 <= targetSpan && targetSpan <= s1))
|
||||
{
|
||||
var result = BisectForTarget(patternParts, a1, a2, targetSpan, axis);
|
||||
var resultSpan = GetRotatedSpan(patternParts, result, axis);
|
||||
var resultDiff = System.Math.Abs(resultSpan - targetSpan);
|
||||
|
||||
if (resultDiff < bestDiff)
|
||||
{
|
||||
bestDiff = resultDiff;
|
||||
bestAngle = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestAngle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the rotation angle that orients the pair with its short side
|
||||
/// along the given axis. Returns 0 if already oriented, PI/2 if rotated.
|
||||
/// </summary>
|
||||
private static double OrientShortSideAlong(List<Part> patternParts, NestDirection axis)
|
||||
{
|
||||
var box = FillHelpers.BuildRotatedPattern(patternParts, 0).BoundingBox;
|
||||
var span0 = GetDimension(box, axis);
|
||||
var perpSpan0 = axis == NestDirection.Horizontal ? box.Length : box.Width;
|
||||
|
||||
if (span0 <= perpSpan0)
|
||||
return 0;
|
||||
|
||||
return Angle.HalfPI;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Iteratively finds the rotation angle where N copies of the pattern
|
||||
/// span the given dimension with minimal waste by expanding pair width.
|
||||
/// Returns (angle, waste, pairCount).
|
||||
/// </summary>
|
||||
public static (double Angle, double Waste, int Count) ConvergeStripeAngle(
|
||||
List<Part> patternParts, double sheetSpan, double spacing,
|
||||
NestDirection axis, CancellationToken token = default)
|
||||
{
|
||||
var startAngle = OrientShortSideAlong(patternParts, axis);
|
||||
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries fitting N+1 narrower pairs by shrinking the pair width.
|
||||
/// Complements ConvergeStripeAngle which only expands.
|
||||
/// </summary>
|
||||
public static (double Angle, double Waste, int Count) ConvergeStripeAngleShrink(
|
||||
List<Part> patternParts, double sheetSpan, double spacing,
|
||||
NestDirection axis, CancellationToken token = default)
|
||||
{
|
||||
var baseAngle = OrientShortSideAlong(patternParts, axis);
|
||||
var naturalPattern = FillHelpers.BuildRotatedPattern(patternParts, baseAngle);
|
||||
var naturalSpan = GetDimension(naturalPattern.BoundingBox, axis);
|
||||
|
||||
if (naturalSpan + spacing <= 0)
|
||||
return (0, double.MaxValue, 0);
|
||||
|
||||
var naturalN = (int)System.Math.Floor((sheetSpan + spacing) / (naturalSpan + spacing));
|
||||
var targetN = naturalN + 1;
|
||||
var targetSpan = (sheetSpan + spacing) / targetN - spacing;
|
||||
|
||||
if (targetSpan <= 0)
|
||||
return (0, double.MaxValue, 0);
|
||||
|
||||
var startAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
|
||||
return ConvergeFromAngle(patternParts, startAngle, sheetSpan, spacing, axis, token);
|
||||
}
|
||||
|
||||
private static (double Angle, double Waste, int Count) ConvergeFromAngle(
|
||||
List<Part> patternParts, double startAngle, double sheetSpan,
|
||||
double spacing, NestDirection axis, CancellationToken token)
|
||||
{
|
||||
var bestWaste = double.MaxValue;
|
||||
var bestAngle = startAngle;
|
||||
var bestCount = 0;
|
||||
var tolerance = sheetSpan * 0.001;
|
||||
var currentAngle = startAngle;
|
||||
|
||||
for (var iteration = 0; iteration < MaxConvergenceIterations; iteration++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var rotated = FillHelpers.BuildRotatedPattern(patternParts, currentAngle);
|
||||
var pairSpan = GetDimension(rotated.BoundingBox, axis);
|
||||
var perpDim = axis == NestDirection.Horizontal
|
||||
? rotated.BoundingBox.Length : rotated.BoundingBox.Width;
|
||||
|
||||
if (pairSpan + spacing <= 0)
|
||||
break;
|
||||
|
||||
var stripeBox = axis == NestDirection.Horizontal
|
||||
? new Box(0, 0, sheetSpan, perpDim)
|
||||
: new Box(0, 0, perpDim, sheetSpan);
|
||||
var engine = new FillLinear(stripeBox, spacing);
|
||||
var filled = engine.Fill(rotated, axis);
|
||||
var n = filled?.Count ?? 0;
|
||||
|
||||
if (n <= 0)
|
||||
break;
|
||||
|
||||
var filledBox = ((IEnumerable<IBoundable>)filled).GetBoundingBox();
|
||||
var remaining = sheetSpan - GetDimension(filledBox, axis);
|
||||
|
||||
Debug.WriteLine($"[Converge] iter={iteration}: angle={Angle.ToDegrees(currentAngle):F2}°, " +
|
||||
$"pairSpan={pairSpan:F4}, perpDim={perpDim:F4}, N={n}, waste={remaining:F3}");
|
||||
|
||||
if (remaining < bestWaste)
|
||||
{
|
||||
bestWaste = remaining;
|
||||
bestAngle = currentAngle;
|
||||
bestCount = n;
|
||||
}
|
||||
|
||||
if (remaining <= tolerance)
|
||||
break;
|
||||
|
||||
var bboxN = (int)System.Math.Floor((sheetSpan + spacing) / (pairSpan + spacing));
|
||||
if (bboxN <= 0) bboxN = 1;
|
||||
var delta = remaining / bboxN;
|
||||
var targetSpan = pairSpan + delta;
|
||||
|
||||
var prevAngle = currentAngle;
|
||||
currentAngle = FindAngleForTargetSpan(patternParts, targetSpan, axis);
|
||||
|
||||
if (System.Math.Abs(currentAngle - prevAngle) < Tolerance.Epsilon)
|
||||
break;
|
||||
}
|
||||
|
||||
return (bestAngle, bestWaste, bestCount);
|
||||
}
|
||||
|
||||
private static double BisectForTarget(
|
||||
List<Part> patternParts, double lo, double hi,
|
||||
double targetSpan, NestDirection axis)
|
||||
{
|
||||
var bestAngle = lo;
|
||||
var bestDiff = double.MaxValue;
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var mid = (lo + hi) / 2;
|
||||
var span = GetRotatedSpan(patternParts, mid, axis);
|
||||
var diff = System.Math.Abs(span - targetSpan);
|
||||
|
||||
if (diff < bestDiff)
|
||||
{
|
||||
bestDiff = diff;
|
||||
bestAngle = mid;
|
||||
}
|
||||
|
||||
if (diff < Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
var loSpan = GetRotatedSpan(patternParts, lo, axis);
|
||||
if ((loSpan < targetSpan && span < targetSpan) ||
|
||||
(loSpan > targetSpan && span > targetSpan))
|
||||
lo = mid;
|
||||
else
|
||||
hi = mid;
|
||||
}
|
||||
|
||||
return bestAngle;
|
||||
}
|
||||
|
||||
private static double GetRotatedSpan(
|
||||
List<Part> patternParts, double angle, NestDirection axis)
|
||||
{
|
||||
var rotated = FillHelpers.BuildRotatedPattern(patternParts, angle);
|
||||
return axis == NestDirection.Horizontal
|
||||
? rotated.BoundingBox.Width
|
||||
: rotated.BoundingBox.Length;
|
||||
}
|
||||
|
||||
private static double GetDimension(Box box, NestDirection axis)
|
||||
{
|
||||
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
}
|
||||
}
|
||||
49
OpenNest.Engine/Fill/VerticalRemnantComparer.cs
Normal file
49
OpenNest.Engine/Fill/VerticalRemnantComparer.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Ranks fill results to minimize X-extent (preserve right-side vertical remnant).
|
||||
/// Tiebreak chain: count > smallest X-extent > highest density.
|
||||
/// </summary>
|
||||
public class VerticalRemnantComparer : IFillComparer
|
||||
{
|
||||
public bool IsBetter(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
if (candidate.Count != current.Count)
|
||||
return candidate.Count > current.Count;
|
||||
|
||||
var candExtent = XExtent(candidate);
|
||||
var currExtent = XExtent(current);
|
||||
|
||||
if (!candExtent.IsEqualTo(currExtent))
|
||||
return candExtent < currExtent;
|
||||
|
||||
return FillScore.Compute(candidate, workArea).Density
|
||||
> FillScore.Compute(current, workArea).Density;
|
||||
}
|
||||
|
||||
private static double XExtent(List<Part> parts)
|
||||
{
|
||||
var minX = double.MaxValue;
|
||||
var maxX = double.MinValue;
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var bb = part.BoundingBox;
|
||||
if (bb.Left < minX) minX = bb.Left;
|
||||
if (bb.Right > maxX) maxX = bb.Right;
|
||||
}
|
||||
|
||||
return maxX - minX;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
OpenNest.Engine/HorizontalRemnantEngine.cs
Normal file
44
OpenNest.Engine/HorizontalRemnantEngine.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optimizes for the largest top-side horizontal drop.
|
||||
/// Scores by count first, then minimizes Y-extent.
|
||||
/// Prefers vertical nest direction and angles that keep parts narrow in Y.
|
||||
/// </summary>
|
||||
public class HorizontalRemnantEngine : DefaultNestEngine
|
||||
{
|
||||
public HorizontalRemnantEngine(Plate plate) : base(plate) { }
|
||||
|
||||
public override string Name => "Horizontal Remnant";
|
||||
|
||||
public override string Description => "Optimizes for largest top-side horizontal drop";
|
||||
|
||||
protected override IFillComparer CreateComparer() => new HorizontalRemnantComparer();
|
||||
|
||||
public override NestDirection? PreferredDirection => NestDirection.Vertical;
|
||||
|
||||
public override ShrinkAxis TrimAxis => ShrinkAxis.Length;
|
||||
|
||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
||||
{
|
||||
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
||||
baseAngles.Sort((a, b) => RotatedHeight(item, a).CompareTo(RotatedHeight(item, b)));
|
||||
return baseAngles;
|
||||
}
|
||||
|
||||
private static double RotatedHeight(NestItem item, double angle)
|
||||
{
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var cos = System.Math.Abs(System.Math.Cos(angle));
|
||||
var sin = System.Math.Abs(System.Math.Sin(angle));
|
||||
return bb.Length * cos + bb.Width * sin;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
OpenNest.Engine/IFillComparer.cs
Normal file
14
OpenNest.Engine/IFillComparer.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether a candidate fill result is better than the current best.
|
||||
/// Implementations must be stateless and thread-safe.
|
||||
/// </summary>
|
||||
public interface IFillComparer
|
||||
{
|
||||
bool IsBetter(List<Part> candidate, List<Part> current, Box workArea);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -31,6 +33,27 @@ namespace OpenNest
|
||||
|
||||
public abstract string Description { get; }
|
||||
|
||||
// --- Engine policy ---
|
||||
|
||||
private IFillComparer _comparer;
|
||||
|
||||
protected IFillComparer Comparer => _comparer ??= CreateComparer();
|
||||
|
||||
protected virtual IFillComparer CreateComparer() => new DefaultFillComparer();
|
||||
|
||||
public virtual NestDirection? PreferredDirection => null;
|
||||
|
||||
public virtual ShrinkAxis TrimAxis => ShrinkAxis.Width;
|
||||
|
||||
public virtual List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
||||
{
|
||||
return new List<double> { bestRotation, bestRotation + OpenNest.Math.Angle.HalfPI };
|
||||
}
|
||||
|
||||
protected virtual void RecordProductiveAngles(List<AngleResult> angleResults) { }
|
||||
|
||||
protected FillPolicy BuildPolicy() => new FillPolicy(Comparer, PreferredDirection);
|
||||
|
||||
// --- Virtual methods (side-effect-free, return parts) ---
|
||||
|
||||
public virtual List<Part> Fill(NestItem item, Box workArea,
|
||||
@@ -189,55 +212,26 @@ namespace OpenNest
|
||||
// --- Protected utilities ---
|
||||
|
||||
internal static void ReportProgress(
|
||||
IProgress<NestProgress> progress,
|
||||
NestPhase phase,
|
||||
int plateNumber,
|
||||
List<Part> best,
|
||||
Box workArea,
|
||||
string description,
|
||||
bool isOverallBest = false)
|
||||
IProgress<NestProgress> progress, ProgressReport report)
|
||||
{
|
||||
if (progress == null || best == null || best.Count == 0)
|
||||
if (progress == null || report.Parts == null || report.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var score = FillScore.Compute(best, workArea);
|
||||
var clonedParts = new List<Part>(best.Count);
|
||||
var totalPartArea = 0.0;
|
||||
|
||||
foreach (var part in best)
|
||||
{
|
||||
var clonedParts = new List<Part>(report.Parts.Count);
|
||||
foreach (var part in report.Parts)
|
||||
clonedParts.Add((Part)part.Clone());
|
||||
totalPartArea += part.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
var bounds = best.GetBoundingBox();
|
||||
|
||||
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
|
||||
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
|
||||
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
|
||||
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
|
||||
Debug.WriteLine(msg);
|
||||
try
|
||||
{
|
||||
System.IO.File.AppendAllText(
|
||||
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
|
||||
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n");
|
||||
}
|
||||
catch { }
|
||||
Debug.WriteLine($"[Progress] Phase={report.Phase}, Plate={report.PlateNumber}, " +
|
||||
$"Parts={clonedParts.Count} | {report.Description}");
|
||||
|
||||
progress.Report(new NestProgress
|
||||
{
|
||||
Phase = phase,
|
||||
PlateNumber = plateNumber,
|
||||
BestPartCount = score.Count,
|
||||
BestDensity = score.Density,
|
||||
NestedWidth = bounds.Width,
|
||||
NestedLength = bounds.Length,
|
||||
NestedArea = totalPartArea,
|
||||
Phase = report.Phase,
|
||||
PlateNumber = report.PlateNumber,
|
||||
BestParts = clonedParts,
|
||||
Description = description,
|
||||
ActiveWorkArea = workArea,
|
||||
IsOverallBest = isOverallBest,
|
||||
Description = report.Description,
|
||||
ActiveWorkArea = report.WorkArea,
|
||||
IsOverallBest = report.IsOverallBest,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -249,21 +243,13 @@ namespace OpenNest
|
||||
var parts = new List<string>(PhaseResults.Count);
|
||||
|
||||
foreach (var r in PhaseResults)
|
||||
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
|
||||
parts.Add($"{r.Phase.ShortName()}: {r.PartCount}");
|
||||
|
||||
return string.Join(" | ", parts);
|
||||
}
|
||||
|
||||
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
if (candidate == null || candidate.Count == 0)
|
||||
return false;
|
||||
|
||||
if (current == null || current.Count == 0)
|
||||
return true;
|
||||
|
||||
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
}
|
||||
=> Comparer.IsBetter(candidate, current, workArea);
|
||||
|
||||
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
|
||||
{
|
||||
@@ -310,17 +296,5 @@ namespace OpenNest
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static string FormatPhaseName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.Pairs: return "Pairs";
|
||||
case NestPhase.Linear: return "Linear";
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Extents: return "Extents";
|
||||
case NestPhase.Custom: return "Custom";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,13 @@ namespace OpenNest
|
||||
"Strip-based nesting for mixed-drawing layouts",
|
||||
plate => new StripNestEngine(plate));
|
||||
|
||||
Register("NFP",
|
||||
"NFP-based mixed-part nesting with simulated annealing",
|
||||
plate => new NfpNestEngine(plate));
|
||||
Register("Vertical Remnant",
|
||||
"Optimizes for largest right-side vertical drop",
|
||||
plate => new VerticalRemnantEngine(plate));
|
||||
|
||||
Register("Horizontal Remnant",
|
||||
"Optimizes for largest top-side horizontal drop",
|
||||
plate => new HorizontalRemnantEngine(plate));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Field)]
|
||||
internal class ShortNameAttribute(string name) : Attribute
|
||||
{
|
||||
public string Name { get; } = name;
|
||||
}
|
||||
|
||||
public enum NestPhase
|
||||
{
|
||||
Linear,
|
||||
RectBestFit,
|
||||
Pairs,
|
||||
Nfp,
|
||||
Extents,
|
||||
Custom
|
||||
[Description("Trying rotations..."), ShortName("Linear")] Linear,
|
||||
[Description("Trying best fit..."), ShortName("BestFit")] RectBestFit,
|
||||
[Description("Trying pairs..."), ShortName("Pairs")] Pairs,
|
||||
[Description("Trying NFP..."), ShortName("NFP")] Nfp,
|
||||
[Description("Trying extents..."), ShortName("Extents")] Extents,
|
||||
[Description("Custom"), ShortName("Custom")] Custom
|
||||
}
|
||||
|
||||
public static class NestPhaseExtensions
|
||||
{
|
||||
private static readonly ConcurrentDictionary<NestPhase, string> DisplayNames = new();
|
||||
private static readonly ConcurrentDictionary<NestPhase, string> ShortNames = new();
|
||||
|
||||
public static string DisplayName(this NestPhase phase)
|
||||
{
|
||||
return DisplayNames.GetOrAdd(phase, p =>
|
||||
{
|
||||
var field = typeof(NestPhase).GetField(p.ToString());
|
||||
var attr = field?.GetCustomAttribute<DescriptionAttribute>();
|
||||
return attr?.Description ?? p.ToString();
|
||||
});
|
||||
}
|
||||
|
||||
public static string ShortName(this NestPhase phase)
|
||||
{
|
||||
return ShortNames.GetOrAdd(phase, p =>
|
||||
{
|
||||
var field = typeof(NestPhase).GetField(p.ToString());
|
||||
var attr = field?.GetCustomAttribute<ShortNameAttribute>();
|
||||
return attr?.Name ?? p.ToString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class PhaseResult
|
||||
@@ -34,18 +70,93 @@ namespace OpenNest
|
||||
public int PartCount { get; set; }
|
||||
}
|
||||
|
||||
internal readonly struct ProgressReport
|
||||
{
|
||||
public NestPhase Phase { get; init; }
|
||||
public int PlateNumber { get; init; }
|
||||
public List<Part> Parts { get; init; }
|
||||
public Box WorkArea { get; init; }
|
||||
public string Description { get; init; }
|
||||
public bool IsOverallBest { get; init; }
|
||||
}
|
||||
|
||||
public class NestProgress
|
||||
{
|
||||
public NestPhase Phase { get; set; }
|
||||
public int PlateNumber { get; set; }
|
||||
public int BestPartCount { get; set; }
|
||||
public double BestDensity { get; set; }
|
||||
public double NestedWidth { get; set; }
|
||||
public double NestedLength { get; set; }
|
||||
public double NestedArea { get; set; }
|
||||
public List<Part> BestParts { get; set; }
|
||||
|
||||
private List<Part> bestParts;
|
||||
public List<Part> BestParts
|
||||
{
|
||||
get => bestParts;
|
||||
set { bestParts = value; cachedParts = null; }
|
||||
}
|
||||
|
||||
public string Description { get; set; }
|
||||
public Box ActiveWorkArea { get; set; }
|
||||
public bool IsOverallBest { get; set; }
|
||||
|
||||
public int BestPartCount => BestParts?.Count ?? 0;
|
||||
|
||||
private List<Part> cachedParts;
|
||||
private Box cachedBounds;
|
||||
private double cachedPartArea;
|
||||
|
||||
private void EnsureCache()
|
||||
{
|
||||
if (cachedParts == bestParts) return;
|
||||
cachedParts = bestParts;
|
||||
if (bestParts == null || bestParts.Count == 0)
|
||||
{
|
||||
cachedBounds = default;
|
||||
cachedPartArea = 0;
|
||||
return;
|
||||
}
|
||||
cachedBounds = bestParts.GetBoundingBox();
|
||||
cachedPartArea = 0;
|
||||
foreach (var p in bestParts)
|
||||
cachedPartArea += p.BaseDrawing.Area;
|
||||
}
|
||||
|
||||
public double BestDensity
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
var bboxArea = cachedBounds.Width * cachedBounds.Length;
|
||||
return bboxArea > 0 ? cachedPartArea / bboxArea : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public double NestedWidth
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
return cachedBounds.Width;
|
||||
}
|
||||
}
|
||||
|
||||
public double NestedLength
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
return cachedBounds.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public double NestedArea
|
||||
{
|
||||
get
|
||||
{
|
||||
if (BestParts == null || BestParts.Count == 0) return 0;
|
||||
EnsureCache();
|
||||
return cachedPartArea;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +74,15 @@ namespace OpenNest.Engine.Nfp
|
||||
|
||||
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
|
||||
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
|
||||
$"NFP: {parts.Count} parts, {result.Iterations} iterations", isOverallBest: true);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Nfp,
|
||||
PlateNumber = 0,
|
||||
Parts = parts,
|
||||
WorkArea = workArea,
|
||||
Description = $"NFP: {parts.Count} parts, {result.Iterations} iterations",
|
||||
IsOverallBest = true,
|
||||
});
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -277,8 +277,15 @@ namespace OpenNest.Engine.Nfp
|
||||
private static void ReportBest(IProgress<NestProgress> progress, List<Part> parts,
|
||||
Box workArea, string description)
|
||||
{
|
||||
NestEngineBase.ReportProgress(progress, NestPhase.Nfp, 0, parts, workArea,
|
||||
description, isOverallBest: true);
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Nfp,
|
||||
PlateNumber = 0,
|
||||
Parts = parts,
|
||||
WorkArea = workArea,
|
||||
Description = description,
|
||||
IsOverallBest = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Nfp;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class NfpNestEngine : NestEngineBase
|
||||
{
|
||||
public NfpNestEngine(Plate plate) : base(plate)
|
||||
{
|
||||
}
|
||||
|
||||
public override string Name => "NFP";
|
||||
|
||||
public override string Description => "NFP-based mixed-part nesting with simulated annealing";
|
||||
|
||||
public override List<Part> Fill(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.Fill(groupParts, workArea, progress, token);
|
||||
}
|
||||
|
||||
public override List<Part> PackArea(Box box, List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
return inner.PackArea(box, items, progress, token);
|
||||
}
|
||||
|
||||
public override List<Part> Nest(List<NestItem> items,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
var parts = AutoNester.Nest(items, Plate, progress, token);
|
||||
|
||||
// Compact placed parts toward the origin to close gaps.
|
||||
Compactor.Settle(parts, Plate.WorkArea(), Plate.PartSpacing);
|
||||
|
||||
// Deduct placed quantities from original items.
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item.Quantity <= 0)
|
||||
continue;
|
||||
|
||||
var placed = parts.FindAll(p => p.BaseDrawing.Name == item.Drawing.Name).Count;
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - placed);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
OpenNest.Engine/Strategies/ColumnFillStrategy.cs
Normal file
17
OpenNest.Engine/Strategies/ColumnFillStrategy.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Fill;
|
||||
|
||||
namespace OpenNest.Engine.Strategies;
|
||||
|
||||
public class ColumnFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Column";
|
||||
public NestPhase Phase => NestPhase.Custom;
|
||||
public int Order => 160;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new StripeFiller(context, NestDirection.Vertical) { CompleteStripesOnly = true };
|
||||
return filler.Fill();
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ namespace OpenNest.Engine.Strategies
|
||||
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
||||
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
@@ -30,12 +30,8 @@ namespace OpenNest.Engine.Strategies
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
if (result != null && result.Count > 0)
|
||||
{
|
||||
var score = FillScore.Compute(result, context.WorkArea);
|
||||
if (best == null || score > bestScore)
|
||||
{
|
||||
if (best == null || comparer.IsBetter(result, best, context.WorkArea))
|
||||
best = result;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ namespace OpenNest.Engine.Strategies
|
||||
public int PlateNumber { get; init; }
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
public FillPolicy Policy { get; init; }
|
||||
|
||||
public List<Part> CurrentBest { get; set; }
|
||||
/// <summary>For progress reporting only; comparisons use Policy.Comparer.</summary>
|
||||
public FillScore CurrentBestScore { get; set; }
|
||||
public NestPhase WinnerPhase { get; set; }
|
||||
public List<PhaseResult> PhaseResults { get; } = new();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
@@ -29,7 +30,7 @@ namespace OpenNest.Engine.Strategies
|
||||
return pattern;
|
||||
}
|
||||
|
||||
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
|
||||
public static List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea, IFillComparer comparer = null)
|
||||
{
|
||||
var results = new ConcurrentBag<(List<Part> Parts, FillScore Score)>();
|
||||
|
||||
@@ -54,14 +55,59 @@ namespace OpenNest.Engine.Strategies
|
||||
|
||||
foreach (var res in results)
|
||||
{
|
||||
if (best == null || res.Score > bestScore)
|
||||
if (comparer != null)
|
||||
{
|
||||
best = res.Parts;
|
||||
bestScore = res.Score;
|
||||
if (best == null || comparer.IsBetter(res.Parts, best, workArea))
|
||||
best = res.Parts;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (best == null || res.Score > bestScore)
|
||||
{
|
||||
best = res.Parts;
|
||||
bestScore = res.Score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a fill function with direction preference logic.
|
||||
/// If preferred is null, tries both directions and returns the better result.
|
||||
/// If preferred is set, tries preferred first; only tries other if preferred yields zero.
|
||||
/// </summary>
|
||||
public static List<Part> FillWithDirectionPreference(
|
||||
Func<NestDirection, List<Part>> fillFunc,
|
||||
NestDirection? preferred,
|
||||
IFillComparer comparer,
|
||||
Box workArea)
|
||||
{
|
||||
if (preferred == null)
|
||||
{
|
||||
var h = fillFunc(NestDirection.Horizontal);
|
||||
var v = fillFunc(NestDirection.Vertical);
|
||||
|
||||
if ((h == null || h.Count == 0) && (v == null || v.Count == 0))
|
||||
return new List<Part>();
|
||||
|
||||
if (h == null || h.Count == 0) return v;
|
||||
if (v == null || v.Count == 0) return h;
|
||||
|
||||
return comparer.IsBetter(h, v, workArea) ? h : v;
|
||||
}
|
||||
|
||||
var other = preferred == NestDirection.Horizontal
|
||||
? NestDirection.Vertical
|
||||
: NestDirection.Horizontal;
|
||||
|
||||
var pref = fillFunc(preferred.Value);
|
||||
if (pref != null && pref.Count > 0)
|
||||
return pref;
|
||||
|
||||
var fallback = fillFunc(other);
|
||||
return fallback ?? new List<Part>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
OpenNest.Engine/Strategies/FillPolicy.cs
Normal file
8
OpenNest.Engine/Strategies/FillPolicy.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace OpenNest.Engine.Strategies
|
||||
{
|
||||
/// <summary>
|
||||
/// Groups engine scoring and direction policy into a single object.
|
||||
/// Set by the engine, consumed by strategies via FillContext.Policy.
|
||||
/// </summary>
|
||||
public record FillPolicy(IFillComparer Comparer, NestDirection? PreferredDirection = null);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace OpenNest.Engine.Strategies
|
||||
private static readonly List<IFillStrategy> strategies = new();
|
||||
private static List<IFillStrategy> sorted;
|
||||
private static HashSet<string> enabledFilter;
|
||||
private static readonly HashSet<string> disabled = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static FillStrategyRegistry()
|
||||
{
|
||||
@@ -19,9 +20,36 @@ namespace OpenNest.Engine.Strategies
|
||||
}
|
||||
|
||||
public static IReadOnlyList<IFillStrategy> Strategies =>
|
||||
sorted ??= (enabledFilter != null
|
||||
? strategies.Where(s => enabledFilter.Contains(s.Name)).OrderBy(s => s.Order).ToList()
|
||||
: strategies.OrderBy(s => s.Order).ToList());
|
||||
sorted ??= FilterStrategies();
|
||||
|
||||
private static List<IFillStrategy> FilterStrategies()
|
||||
{
|
||||
var source = enabledFilter != null
|
||||
? strategies.Where(s => enabledFilter.Contains(s.Name))
|
||||
: strategies.Where(s => !disabled.Contains(s.Name));
|
||||
return source.OrderBy(s => s.Order).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently disables strategies by name. They remain registered
|
||||
/// but are excluded from the default pipeline.
|
||||
/// </summary>
|
||||
public static void Disable(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
disabled.Add(name);
|
||||
sorted = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-enables a previously disabled strategy.
|
||||
/// </summary>
|
||||
public static void Enable(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
disabled.Remove(name);
|
||||
sorted = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restricts the active strategies to only those whose names are listed.
|
||||
|
||||
@@ -17,8 +17,9 @@ namespace OpenNest.Engine.Strategies
|
||||
: new List<double> { 0, Angle.HalfPI };
|
||||
|
||||
var workArea = context.WorkArea;
|
||||
var comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
||||
var preferred = context.Policy?.PreferredDirection;
|
||||
List<Part> best = null;
|
||||
var bestScore = default(FillScore);
|
||||
|
||||
for (var ai = 0; ai < angles.Count; ai++)
|
||||
{
|
||||
@@ -26,48 +27,34 @@ namespace OpenNest.Engine.Strategies
|
||||
|
||||
var angle = angles[ai];
|
||||
var engine = new FillLinear(workArea, context.Plate.PartSpacing);
|
||||
var h = engine.Fill(context.Item.Drawing, angle, NestDirection.Horizontal);
|
||||
var v = engine.Fill(context.Item.Drawing, angle, NestDirection.Vertical);
|
||||
|
||||
var result = FillHelpers.FillWithDirectionPreference(
|
||||
dir => engine.Fill(context.Item.Drawing, angle, dir),
|
||||
preferred, comparer, workArea);
|
||||
|
||||
var angleDeg = Angle.ToDegrees(angle);
|
||||
|
||||
if (h != null && h.Count > 0)
|
||||
if (result != null && result.Count > 0)
|
||||
{
|
||||
var scoreH = FillScore.Compute(h, workArea);
|
||||
context.AngleResults.Add(new AngleResult
|
||||
{
|
||||
AngleDeg = angleDeg,
|
||||
Direction = NestDirection.Horizontal,
|
||||
PartCount = h.Count
|
||||
Direction = preferred ?? NestDirection.Horizontal,
|
||||
PartCount = result.Count
|
||||
});
|
||||
|
||||
if (best == null || scoreH > bestScore)
|
||||
{
|
||||
best = h;
|
||||
bestScore = scoreH;
|
||||
}
|
||||
if (best == null || comparer.IsBetter(result, best, workArea))
|
||||
best = result;
|
||||
}
|
||||
|
||||
if (v != null && v.Count > 0)
|
||||
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
var scoreV = FillScore.Compute(v, workArea);
|
||||
context.AngleResults.Add(new AngleResult
|
||||
{
|
||||
AngleDeg = angleDeg,
|
||||
Direction = NestDirection.Vertical,
|
||||
PartCount = v.Count
|
||||
});
|
||||
|
||||
if (best == null || scoreV > bestScore)
|
||||
{
|
||||
best = v;
|
||||
bestScore = scoreV;
|
||||
}
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||
context.PlateNumber, best, workArea,
|
||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
|
||||
Phase = NestPhase.Linear,
|
||||
PlateNumber = context.PlateNumber,
|
||||
Parts = best,
|
||||
WorkArea = workArea,
|
||||
Description = $"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts",
|
||||
});
|
||||
}
|
||||
|
||||
return best ?? new List<Part>();
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
using OpenNest.Engine.Fill;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Strategies
|
||||
{
|
||||
public class PairsFillStrategy : IFillStrategy
|
||||
{
|
||||
private static readonly AsyncLocal<bool> active = new();
|
||||
|
||||
public string Name => "Pairs";
|
||||
public NestPhase Phase => NestPhase.Pairs;
|
||||
public int Order => 100;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing);
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
// Prevent recursive PairFiller — remnant fills within PairFiller
|
||||
// create a new engine that runs the full pipeline, which would
|
||||
// invoke PairsFillStrategy again, causing deep recursion.
|
||||
if (active.Value)
|
||||
return null;
|
||||
|
||||
context.SharedState["BestFits"] = result.BestFits;
|
||||
active.Value = true;
|
||||
try
|
||||
{
|
||||
var comparer = context.Policy?.Comparer;
|
||||
var dedup = GridDedup.GetOrCreate(context.SharedState);
|
||||
var filler = new PairFiller(context.Plate, comparer, dedup);
|
||||
var result = filler.Fill(context.Item, context.WorkArea,
|
||||
context.PlateNumber, context.Token, context.Progress);
|
||||
|
||||
return result.Parts;
|
||||
context.SharedState["BestFits"] = result.BestFits;
|
||||
|
||||
return result.Parts;
|
||||
}
|
||||
finally
|
||||
{
|
||||
active.Value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
17
OpenNest.Engine/Strategies/RowFillStrategy.cs
Normal file
17
OpenNest.Engine/Strategies/RowFillStrategy.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.Fill;
|
||||
|
||||
namespace OpenNest.Engine.Strategies;
|
||||
|
||||
public class RowFillStrategy : IFillStrategy
|
||||
{
|
||||
public string Name => "Row";
|
||||
public NestPhase Phase => NestPhase.Custom;
|
||||
public int Order => 150;
|
||||
|
||||
public List<Part> Fill(FillContext context)
|
||||
{
|
||||
var filler = new StripeFiller(context, NestDirection.Horizontal) { CompleteStripesOnly = true };
|
||||
return filler.Fill();
|
||||
}
|
||||
}
|
||||
@@ -77,17 +77,23 @@ namespace OpenNest
|
||||
// Phase 1: Iterative shrink-fill for multi-quantity items.
|
||||
if (fillItems.Count > 0)
|
||||
{
|
||||
// Pass progress through so the UI shows intermediate results
|
||||
// during the initial BestFitCache computation and fill phases.
|
||||
Func<NestItem, Box, List<Part>> fillFunc = (ni, b) =>
|
||||
// Use direction-specific engines: height shrink benefits from
|
||||
// minimizing Y-extent, width shrink from minimizing X-extent.
|
||||
Func<NestItem, Box, List<Part>> heightFillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new DefaultNestEngine(Plate);
|
||||
var inner = new HorizontalRemnantEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
Func<NestItem, Box, List<Part>> widthFillFunc = (ni, b) =>
|
||||
{
|
||||
var inner = new VerticalRemnantEngine(Plate);
|
||||
return inner.Fill(ni, b, progress, token);
|
||||
};
|
||||
|
||||
var shrinkResult = IterativeShrinkFiller.Fill(
|
||||
fillItems, workArea, fillFunc, Plate.PartSpacing, token,
|
||||
progress, PlateNumber);
|
||||
fillItems, workArea, heightFillFunc, Plate.PartSpacing, token,
|
||||
progress, PlateNumber, widthFillFunc);
|
||||
|
||||
allParts.AddRange(shrinkResult.Parts);
|
||||
|
||||
|
||||
42
OpenNest.Engine/VerticalRemnantEngine.cs
Normal file
42
OpenNest.Engine/VerticalRemnantEngine.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optimizes for the largest right-side vertical drop.
|
||||
/// Scores by count first, then minimizes X-extent.
|
||||
/// Prefers horizontal nest direction and angles that keep parts narrow in X.
|
||||
/// </summary>
|
||||
public class VerticalRemnantEngine : DefaultNestEngine
|
||||
{
|
||||
public VerticalRemnantEngine(Plate plate) : base(plate) { }
|
||||
|
||||
public override string Name => "Vertical Remnant";
|
||||
|
||||
public override string Description => "Optimizes for largest right-side vertical drop";
|
||||
|
||||
protected override IFillComparer CreateComparer() => new VerticalRemnantComparer();
|
||||
|
||||
public override NestDirection? PreferredDirection => NestDirection.Horizontal;
|
||||
|
||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
||||
{
|
||||
var baseAngles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
|
||||
baseAngles.Sort((a, b) => RotatedWidth(item, a).CompareTo(RotatedWidth(item, b)));
|
||||
return baseAngles;
|
||||
}
|
||||
|
||||
private static double RotatedWidth(NestItem item, double angle)
|
||||
{
|
||||
var bb = item.Drawing.Program.BoundingBox();
|
||||
var cos = System.Math.Abs(System.Math.Cos(angle));
|
||||
var sin = System.Math.Abs(System.Math.Sin(angle));
|
||||
return bb.Width * cos + bb.Length * sin;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ namespace OpenNest.IO
|
||||
public string DateCreated { get; init; } = "";
|
||||
public string DateLastModified { get; init; } = "";
|
||||
public string Notes { get; init; } = "";
|
||||
public string AssistGas { get; init; } = "";
|
||||
public PlateDefaultsDto PlateDefaults { get; init; } = new();
|
||||
public List<DrawingDto> Drawings { get; init; } = new();
|
||||
public List<PlateDto> Plates { get; init; } = new();
|
||||
@@ -62,6 +63,7 @@ namespace OpenNest.IO
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
public List<CutOffDto> CutOffs { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PartDto
|
||||
@@ -72,6 +74,15 @@ namespace OpenNest.IO
|
||||
public double Rotation { get; init; }
|
||||
}
|
||||
|
||||
public record CutOffDto
|
||||
{
|
||||
public double X { get; init; }
|
||||
public double Y { get; init; }
|
||||
public string Axis { get; init; } = "vertical";
|
||||
public double? StartLimit { get; init; }
|
||||
public double? EndLimit { get; init; }
|
||||
}
|
||||
|
||||
public record SizeDto
|
||||
{
|
||||
public double Width { get; init; }
|
||||
|
||||
@@ -160,6 +160,7 @@ namespace OpenNest.IO
|
||||
nest.DateCreated = DateTime.Parse(dto.DateCreated);
|
||||
nest.DateLastModified = DateTime.Parse(dto.DateLastModified);
|
||||
nest.Notes = dto.Notes;
|
||||
nest.AssistGas = dto.AssistGas ?? "";
|
||||
|
||||
// Plate defaults
|
||||
var pd = dto.PlateDefaults;
|
||||
@@ -197,6 +198,25 @@ namespace OpenNest.IO
|
||||
plate.Parts.Add(part);
|
||||
}
|
||||
|
||||
// Cut-offs
|
||||
if (p.CutOffs != null)
|
||||
{
|
||||
foreach (var cutoffDto in p.CutOffs)
|
||||
{
|
||||
var axis = cutoffDto.Axis?.ToLowerInvariant() == "horizontal"
|
||||
? CutOffAxis.Horizontal
|
||||
: CutOffAxis.Vertical;
|
||||
var cutoff = new CutOff(new Vector(cutoffDto.X, cutoffDto.Y), axis)
|
||||
{
|
||||
StartLimit = cutoffDto.StartLimit,
|
||||
EndLimit = cutoffDto.EndLimit
|
||||
};
|
||||
plate.CutOffs.Add(cutoff);
|
||||
}
|
||||
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
}
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ namespace OpenNest.IO
|
||||
DateCreated = nest.DateCreated.ToString("o"),
|
||||
DateLastModified = nest.DateLastModified.ToString("o"),
|
||||
Notes = nest.Notes ?? "",
|
||||
AssistGas = nest.AssistGas ?? "",
|
||||
PlateDefaults = BuildPlateDefaultsDto(),
|
||||
Drawings = BuildDrawingDtos(),
|
||||
Plates = BuildPlateDtos()
|
||||
@@ -152,7 +153,7 @@ namespace OpenNest.IO
|
||||
{
|
||||
var plate = nest.Plates[i];
|
||||
var parts = new List<PartDto>();
|
||||
foreach (var part in plate.Parts)
|
||||
foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff))
|
||||
{
|
||||
var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault();
|
||||
parts.Add(new PartDto
|
||||
@@ -164,6 +165,19 @@ namespace OpenNest.IO
|
||||
});
|
||||
}
|
||||
|
||||
var cutoffs = new List<CutOffDto>();
|
||||
foreach (var cutoff in plate.CutOffs)
|
||||
{
|
||||
cutoffs.Add(new CutOffDto
|
||||
{
|
||||
X = cutoff.Position.X,
|
||||
Y = cutoff.Position.Y,
|
||||
Axis = cutoff.Axis == CutOffAxis.Vertical ? "vertical" : "horizontal",
|
||||
StartLimit = cutoff.StartLimit,
|
||||
EndLimit = cutoff.EndLimit
|
||||
});
|
||||
}
|
||||
|
||||
list.Add(new PlateDto
|
||||
{
|
||||
Id = i + 1,
|
||||
@@ -185,7 +199,8 @@ namespace OpenNest.IO
|
||||
Right = plate.EdgeSpacing.Right,
|
||||
Bottom = plate.EdgeSpacing.Bottom
|
||||
},
|
||||
Parts = parts
|
||||
Parts = parts,
|
||||
CutOffs = cutoffs
|
||||
});
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -57,6 +57,35 @@ namespace OpenNest.Mcp.Tools
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "save_nest")]
|
||||
[Description("Save the current session (all drawings and plates) to a .nest file.")]
|
||||
public string SaveNest(
|
||||
[Description("Absolute path for the output .nest file")] string path,
|
||||
[Description("Name for the nest (optional)")] string name = null)
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Name = name ?? Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
foreach (var drawing in _session.AllDrawings())
|
||||
nest.Drawings.Add(drawing);
|
||||
|
||||
foreach (var plate in _session.AllPlates())
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
if (nest.Drawings.Count == 0)
|
||||
return "Error: no drawings in session to save";
|
||||
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var writer = new NestWriter(nest);
|
||||
if (!writer.Write(path))
|
||||
return "Error: failed to write nest file";
|
||||
|
||||
return $"Saved nest to {path}\n Drawings: {nest.Drawings.Count}\n Plates: {nest.Plates.Count}";
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "import_dxf")]
|
||||
[Description("Import a DXF file as a new drawing. Returns drawing name and bounding box.")]
|
||||
public string ImportDxf(
|
||||
|
||||
240
OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
Normal file
240
OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Data class carrying all context needed to emit one Cincinnati-format G-code feature block.
|
||||
/// </summary>
|
||||
public sealed class FeatureContext
|
||||
{
|
||||
public List<ICode> Codes { get; set; } = new();
|
||||
public int FeatureNumber { get; set; }
|
||||
public string PartName { get; set; } = "";
|
||||
public bool IsFirstFeatureOfPart { get; set; }
|
||||
public bool IsLastFeatureOnSheet { get; set; }
|
||||
public bool IsSafetyHeadraise { get; set; }
|
||||
public bool IsExteriorFeature { get; set; }
|
||||
public bool IsEtch { get; set; }
|
||||
public string LibraryFile { get; set; } = "";
|
||||
public double CutDistance { get; set; }
|
||||
public double SheetDiagonal { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits one Cincinnati-format G-code feature block (one contour) to a TextWriter.
|
||||
/// Handles rapid positioning, pierce, kerf compensation, anti-dive, feedrate modal
|
||||
/// suppression, arc I/J conversion (absolute to incremental), and M47 head raise.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiFeatureWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly SpeedClassifier _speedClassifier;
|
||||
|
||||
public CincinnatiFeatureWriter(CincinnatiPostConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_speedClassifier = new SpeedClassifier();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete feature block for the given context.
|
||||
/// </summary>
|
||||
public void Write(TextWriter writer, FeatureContext ctx)
|
||||
{
|
||||
var currentPos = Vector.Zero;
|
||||
var lastFeedVar = "";
|
||||
var kerfEmitted = false;
|
||||
|
||||
// Find the pierce point from the first rapid move
|
||||
var piercePoint = FindPiercePoint(ctx.Codes);
|
||||
|
||||
// 1. Rapid to pierce point (with line number if configured)
|
||||
WriteRapidToPierce(writer, ctx.FeatureNumber, piercePoint);
|
||||
|
||||
// 2. Part name comment on first feature of each part
|
||||
if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName))
|
||||
writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}"));
|
||||
|
||||
// 3. G89 process params
|
||||
if (_config.ProcessParameterMode == G89Mode.LibraryFile)
|
||||
{
|
||||
var lib = ctx.LibraryFile;
|
||||
if (!string.IsNullOrEmpty(lib))
|
||||
{
|
||||
var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal);
|
||||
var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal);
|
||||
writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})");
|
||||
}
|
||||
else
|
||||
{
|
||||
writer.WriteLine("(WARNING: No library found)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Pierce/beam on — G85 for etch (no pierce), G84 for cut
|
||||
writer.WriteLine(ctx.IsEtch ? "G85" : "G84");
|
||||
|
||||
// 5. Anti-dive off
|
||||
if (_config.UseAntiDive)
|
||||
writer.WriteLine("M130 (ANTI DIVE OFF)");
|
||||
|
||||
// Update current position to pierce point
|
||||
currentPos = piercePoint;
|
||||
|
||||
// 6. Lead-in + contour moves with kerf comp and feedrate variables
|
||||
foreach (var code in ctx.Codes)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
continue; // skip rapids in contour (already handled above)
|
||||
|
||||
if (code is LinearMove linear)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Kerf compensation on first cutting move (skip for etch)
|
||||
if (!ctx.IsEtch && !kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide)
|
||||
{
|
||||
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
|
||||
kerfEmitted = true;
|
||||
}
|
||||
|
||||
sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y)}");
|
||||
|
||||
// Feedrate — etch always uses process feedrate
|
||||
var feedVar = ctx.IsEtch ? "#148" : GetFeedVariable(linear.Layer);
|
||||
if (feedVar != lastFeedVar)
|
||||
{
|
||||
sb.Append($" F{feedVar}");
|
||||
lastFeedVar = feedVar;
|
||||
}
|
||||
|
||||
writer.WriteLine(sb.ToString());
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Kerf compensation on first cutting move (skip for etch)
|
||||
if (!ctx.IsEtch && !kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide)
|
||||
{
|
||||
sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41 " : "G42 ");
|
||||
kerfEmitted = true;
|
||||
}
|
||||
|
||||
// G2 = CW, G3 = CCW
|
||||
var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3";
|
||||
sb.Append($"{gCode} X{_fmt.FormatCoord(arc.EndPoint.X)} Y{_fmt.FormatCoord(arc.EndPoint.Y)}");
|
||||
|
||||
// Convert absolute center to incremental I/J
|
||||
var i = arc.CenterPoint.X - currentPos.X;
|
||||
var j = arc.CenterPoint.Y - currentPos.Y;
|
||||
sb.Append($" I{_fmt.FormatCoord(i)} J{_fmt.FormatCoord(j)}");
|
||||
|
||||
// Feedrate — etch always uses process feedrate, cut uses layer-based
|
||||
var isFullCircle = IsFullCircle(currentPos, arc.EndPoint);
|
||||
var feedVar = ctx.IsEtch ? "#148"
|
||||
: isFullCircle ? "[#148*#128]"
|
||||
: GetFeedVariable(arc.Layer);
|
||||
if (feedVar != lastFeedVar)
|
||||
{
|
||||
sb.Append($" F{feedVar}");
|
||||
lastFeedVar = feedVar;
|
||||
}
|
||||
|
||||
writer.WriteLine(sb.ToString());
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Cancel kerf compensation
|
||||
if (kerfEmitted)
|
||||
writer.WriteLine("G40");
|
||||
|
||||
// 8. Beam off
|
||||
writer.WriteLine(_config.UseSpeedGas ? "M135" : "M35");
|
||||
|
||||
// 9. Anti-dive on
|
||||
if (_config.UseAntiDive)
|
||||
writer.WriteLine("M131 (ANTI DIVE ON)");
|
||||
|
||||
// 10. Head raise (unless last feature on sheet)
|
||||
if (!ctx.IsLastFeatureOnSheet)
|
||||
WriteM47(writer, ctx);
|
||||
}
|
||||
|
||||
private Vector FindPiercePoint(List<ICode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
return rapid.EndPoint;
|
||||
}
|
||||
|
||||
// If no rapid move, use the endpoint of the first motion
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is Motion motion)
|
||||
return motion.EndPoint;
|
||||
}
|
||||
|
||||
return Vector.Zero;
|
||||
}
|
||||
|
||||
private void WriteRapidToPierce(TextWriter writer, int featureNumber, Vector piercePoint)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{featureNumber} ");
|
||||
|
||||
sb.Append($"G0 X{_fmt.FormatCoord(piercePoint.X)} Y{_fmt.FormatCoord(piercePoint.Y)}");
|
||||
|
||||
writer.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
private void WriteM47(TextWriter writer, FeatureContext ctx)
|
||||
{
|
||||
if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
|
||||
{
|
||||
writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
|
||||
return;
|
||||
}
|
||||
|
||||
var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47;
|
||||
|
||||
switch (mode)
|
||||
{
|
||||
case M47Mode.Always:
|
||||
writer.WriteLine("M47");
|
||||
break;
|
||||
case M47Mode.BlockDelete:
|
||||
writer.WriteLine("/M47");
|
||||
break;
|
||||
case M47Mode.None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetFeedVariable(LayerType layer)
|
||||
{
|
||||
return layer switch
|
||||
{
|
||||
LayerType.Leadin => "#126",
|
||||
LayerType.Cut => "#148",
|
||||
_ => "#148"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsFullCircle(Vector start, Vector end)
|
||||
{
|
||||
return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y);
|
||||
}
|
||||
}
|
||||
150
OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
Normal file
150
OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a Cincinnati-format part sub-program definition.
|
||||
/// Each sub-program contains the complete cutting sequence for one unique part geometry
|
||||
/// (drawing + rotation), with coordinates normalized to origin (0,0).
|
||||
/// Called via M98 from sheet sub-programs.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiPartSubprogramWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||
|
||||
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete part sub-program for the given normalized program.
|
||||
/// The program coordinates must already be normalized to origin (0,0).
|
||||
/// </summary>
|
||||
public void Write(TextWriter w, Program normalizedProgram, string drawingName,
|
||||
int subNumber, string cutLibrary, string etchLibrary, double sheetDiagonal)
|
||||
{
|
||||
var allFeatures = SplitFeatures(normalizedProgram.Codes);
|
||||
if (allFeatures.Count == 0)
|
||||
return;
|
||||
|
||||
// Classify and order: etch features first, then cut features
|
||||
var ordered = OrderFeatures(allFeatures);
|
||||
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($":{subNumber}");
|
||||
w.WriteLine(CoordinateFormatter.Comment($"PART: {drawingName}"));
|
||||
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var (codes, isEtch) = ordered[i];
|
||||
var featureNumber = i == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
var cutDistance = ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
Codes = codes,
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = drawingName,
|
||||
IsFirstFeatureOfPart = false,
|
||||
IsLastFeatureOnSheet = i == ordered.Count - 1,
|
||||
IsSafetyHeadraise = false,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
LibraryFile = isEtch ? etchLibrary : cutLibrary,
|
||||
CutDistance = cutDistance,
|
||||
SheetDiagonal = sheetDiagonal
|
||||
};
|
||||
|
||||
_featureWriter.Write(w, ctx);
|
||||
}
|
||||
|
||||
w.WriteLine("G0 X0 Y0");
|
||||
w.WriteLine($"M99 (END OF {drawingName})");
|
||||
}
|
||||
|
||||
internal static List<(List<ICode> codes, bool isEtch)> OrderFeatures(List<List<ICode>> features)
|
||||
{
|
||||
var result = new List<(List<ICode>, bool)>();
|
||||
var etch = new List<List<ICode>>();
|
||||
var cut = new List<List<ICode>>();
|
||||
|
||||
foreach (var f in features)
|
||||
{
|
||||
if (CincinnatiSheetWriter.IsFeatureEtch(f))
|
||||
etch.Add(f);
|
||||
else
|
||||
cut.Add(f);
|
||||
}
|
||||
|
||||
foreach (var f in etch)
|
||||
result.Add((f, true));
|
||||
foreach (var f in cut)
|
||||
result.Add((f, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sub-program key for matching parts to their sub-programs.
|
||||
/// </summary>
|
||||
internal static (int drawingId, long rotationKey) SubprogramKey(Part part) =>
|
||||
(part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6));
|
||||
|
||||
internal static List<List<ICode>> SplitFeatures(List<ICode> codes)
|
||||
{
|
||||
var features = new List<List<ICode>>();
|
||||
List<ICode> current = null;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
{
|
||||
if (current != null)
|
||||
features.Add(current);
|
||||
current = new List<ICode> { code };
|
||||
}
|
||||
else
|
||||
{
|
||||
current ??= new List<ICode>();
|
||||
current.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null && current.Count > 0)
|
||||
features.Add(current);
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
internal static double ComputeCutDistance(List<ICode> codes)
|
||||
{
|
||||
var distance = 0.0;
|
||||
var currentPos = Vector.Zero;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
currentPos = rapid.EndPoint;
|
||||
else if (code is LinearMove linear)
|
||||
{
|
||||
distance += currentPos.DistanceTo(linear.EndPoint);
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
distance += currentPos.DistanceTo(arc.EndPoint);
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
}
|
||||
}
|
||||
304
OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs
Normal file
304
OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs
Normal file
@@ -0,0 +1,304 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
/// <summary>
|
||||
/// Specifies how coordinate positioning is handled between parts.
|
||||
/// </summary>
|
||||
public enum CoordinateMode
|
||||
{
|
||||
/// <summary>Set absolute position.</summary>
|
||||
G92,
|
||||
|
||||
/// <summary>Use relative/incremental positioning.</summary>
|
||||
G91,
|
||||
|
||||
/// <summary>Use machine coordinate system.</summary>
|
||||
G53
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how G89 (hole drilling/tapping parameters) are provided.
|
||||
/// </summary>
|
||||
public enum G89Mode
|
||||
{
|
||||
/// <summary>Use external library file for G89 parameters.</summary>
|
||||
LibraryFile,
|
||||
|
||||
/// <summary>Explicitly define G89 parameters in the program.</summary>
|
||||
Explicit
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies where kerf compensation is applied.
|
||||
/// </summary>
|
||||
public enum KerfMode
|
||||
{
|
||||
/// <summary>Controller side (using cutter compensation codes).</summary>
|
||||
ControllerSide,
|
||||
|
||||
/// <summary>Pre-applied to part geometry during post-processing.</summary>
|
||||
PreApplied
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies which side of the cut line kerf compensation is applied to.
|
||||
/// </summary>
|
||||
public enum KerfSide
|
||||
{
|
||||
/// <summary>Kerf applied to the left side of the cut.</summary>
|
||||
Left,
|
||||
|
||||
/// <summary>Kerf applied to the right side of the cut.</summary>
|
||||
Right
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies how M47 (optional stop) commands are used.
|
||||
/// </summary>
|
||||
public enum M47Mode
|
||||
{
|
||||
/// <summary>Always include M47.</summary>
|
||||
Always,
|
||||
|
||||
/// <summary>Include M47 with block delete functionality.</summary>
|
||||
BlockDelete,
|
||||
|
||||
/// <summary>Automatically determine M47 placement.</summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>Do not use M47.</summary>
|
||||
None
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specifies when pallet exchange occurs.
|
||||
/// </summary>
|
||||
public enum PalletMode
|
||||
{
|
||||
/// <summary>No pallet exchange.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Pallet exchange at end of sheet.</summary>
|
||||
EndOfSheet,
|
||||
|
||||
/// <summary>Pallet exchange at start and end of sheet.</summary>
|
||||
StartAndEnd
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for Cincinnati post processor.
|
||||
/// Defines machine-specific parameters, output format, and cutting strategies.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiPostConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the configuration name/identifier.
|
||||
/// Default: "CL940"
|
||||
/// </summary>
|
||||
public string ConfigurationName { get; set; } = "CL940";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the units for posted output.
|
||||
/// Default: Units.Inches
|
||||
/// </summary>
|
||||
public Units PostedUnits { get; set; } = Units.Inches;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the decimal accuracy for numeric output.
|
||||
/// Default: 4
|
||||
/// </summary>
|
||||
public int PostedAccuracy { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how coordinate positioning is handled between parts.
|
||||
/// Default: CoordinateMode.G92
|
||||
/// </summary>
|
||||
public CoordinateMode CoordModeBetweenParts { get; set; } = CoordinateMode.G92;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use subprograms for sheet operations.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool UseSheetSubprograms { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the starting subprogram number for sheet operations.
|
||||
/// Default: 101
|
||||
/// </summary>
|
||||
public int SheetSubprogramStart { get; set; } = 101;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use M98 sub-programs for part geometry.
|
||||
/// When enabled, each unique part geometry is written as a reusable sub-program
|
||||
/// called via M98, reducing output size for nests with repeated parts.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool UsePartSubprograms { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the starting sub-program number for part geometry sub-programs.
|
||||
/// Default: 200
|
||||
/// </summary>
|
||||
public int PartSubprogramStart { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the subprogram number for variable declarations.
|
||||
/// Default: 100
|
||||
/// </summary>
|
||||
public int VariableDeclarationSubprogram { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how G89 parameters are provided.
|
||||
/// Default: G89Mode.LibraryFile
|
||||
/// </summary>
|
||||
public G89Mode ProcessParameterMode { get; set; } = G89Mode.LibraryFile;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default assist gas when Nest.AssistGas is empty.
|
||||
/// Default: "O2"
|
||||
/// </summary>
|
||||
public string DefaultAssistGas { get; set; } = "O2";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the gas used for etch operations.
|
||||
/// Independent of the cutting assist gas — etch typically requires a specific gas.
|
||||
/// Default: "N2"
|
||||
/// </summary>
|
||||
public string DefaultEtchGas { get; set; } = "N2";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the material-to-library mapping for cut operations.
|
||||
/// Each entry maps (material, thickness, gas) to a G89 library file.
|
||||
/// </summary>
|
||||
public List<MaterialLibraryEntry> MaterialLibraries { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the gas-to-library mapping for etch operations.
|
||||
/// Each entry maps a gas type to a G89 etch library file.
|
||||
/// </summary>
|
||||
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use exact stop mode (G61).
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool UseExactStopMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets where kerf compensation is applied.
|
||||
/// Default: KerfMode.ControllerSide
|
||||
/// </summary>
|
||||
public KerfMode KerfCompensation { get; set; } = KerfMode.ControllerSide;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default side for kerf compensation.
|
||||
/// Default: KerfSide.Left
|
||||
/// </summary>
|
||||
public KerfSide DefaultKerfSide { get; set; } = KerfSide.Left;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how M47 is used in interior cuts.
|
||||
/// Default: M47Mode.Always
|
||||
/// </summary>
|
||||
public M47Mode InteriorM47 { get; set; } = M47Mode.Always;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how M47 is used in exterior cuts.
|
||||
/// Default: M47Mode.Always
|
||||
/// </summary>
|
||||
public M47Mode ExteriorM47 { get; set; } = M47Mode.Always;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the safety head raise distance (in machine units).
|
||||
/// Default: 2000
|
||||
/// </summary>
|
||||
public int? SafetyHeadraiseDistance { get; set; } = 2000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the distance threshold for M47 override.
|
||||
/// Default: null
|
||||
/// </summary>
|
||||
public double? M47OverrideDistanceThreshold { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use anti-dive functionality.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool UseAntiDive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use smart rapids optimization.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool UseSmartRapids { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets when pallet exchange occurs.
|
||||
/// Default: PalletMode.EndOfSheet
|
||||
/// </summary>
|
||||
public PalletMode PalletExchange { get; set; } = PalletMode.EndOfSheet;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use line numbers in output.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool UseLineNumbers { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the starting line number for features.
|
||||
/// Default: 1
|
||||
/// </summary>
|
||||
public int FeatureLineNumberStart { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to use speed/gas commands.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool UseSpeedGas { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the feedrate percentage for lead-in moves.
|
||||
/// Default: 0.5 (50%)
|
||||
/// </summary>
|
||||
public double LeadInFeedratePercent { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the feedrate percentage for lead-in arc-to-line moves.
|
||||
/// Default: 0.5 (50%)
|
||||
/// </summary>
|
||||
public double LeadInArcLine2FeedratePercent { get; set; } = 0.5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the feedrate multiplier for circular cuts.
|
||||
/// Default: 0.8 (80%)
|
||||
/// </summary>
|
||||
public double CircleFeedrateMultiplier { get; set; } = 0.8;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the variable number for sheet width.
|
||||
/// Default: 110
|
||||
/// </summary>
|
||||
public int SheetWidthVariable { get; set; } = 110;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the variable number for sheet length.
|
||||
/// Default: 111
|
||||
/// </summary>
|
||||
public int SheetLengthVariable { get; set; } = 111;
|
||||
}
|
||||
|
||||
public class MaterialLibraryEntry
|
||||
{
|
||||
public string Material { get; set; } = "";
|
||||
public double Thickness { get; set; }
|
||||
public string Gas { get; set; } = "";
|
||||
public string Library { get; set; } = "";
|
||||
}
|
||||
|
||||
public class EtchLibraryEntry
|
||||
{
|
||||
public string Gas { get; set; } = "";
|
||||
public string Library { get; set; } = "";
|
||||
}
|
||||
}
|
||||
179
OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
Normal file
179
OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class CincinnatiPostProcessor : IPostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public string Name => "Cincinnati CL-707";
|
||||
public string Author => "OpenNest";
|
||||
public string Description => "Cincinnati CL-707/CL-800/CL-900/CL-940/CLX family";
|
||||
|
||||
public CincinnatiPostConfig Config { get; }
|
||||
|
||||
public CincinnatiPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
Config = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, JsonOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
Config = new CincinnatiPostConfig();
|
||||
SaveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public CincinnatiPostProcessor(CincinnatiPostConfig config)
|
||||
{
|
||||
Config = config;
|
||||
}
|
||||
|
||||
public void SaveConfig()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
var json = JsonSerializer.Serialize(Config, JsonOptions);
|
||||
File.WriteAllText(configPath, json);
|
||||
}
|
||||
|
||||
private static string GetConfigPath()
|
||||
{
|
||||
var assemblyPath = typeof(CincinnatiPostProcessor).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)
|
||||
{
|
||||
// 1. Create variable manager and register standard variables
|
||||
var vars = CreateVariableManager();
|
||||
|
||||
// 2. Filter to non-empty plates
|
||||
var plates = nest.Plates
|
||||
.Where(p => p.Parts.Count > 0)
|
||||
.ToList();
|
||||
|
||||
// 3. Resolve gas and library files
|
||||
var resolver = new MaterialLibraryResolver(Config);
|
||||
var gas = MaterialLibraryResolver.ResolveGas(nest, Config);
|
||||
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
|
||||
|
||||
// Resolve cut library from first plate for preamble
|
||||
var firstPlate = plates.FirstOrDefault();
|
||||
var initialCutLibrary = firstPlate != null
|
||||
? resolver.ResolveCutLibrary(firstPlate.Material?.Name ?? "", firstPlate.Thickness, gas)
|
||||
: "";
|
||||
|
||||
// 4. Build part sub-program registry (if enabled)
|
||||
Dictionary<(int, long), int> partSubprograms = null;
|
||||
List<(int subNum, string name, Program program)> subprogramEntries = null;
|
||||
|
||||
if (Config.UsePartSubprograms)
|
||||
{
|
||||
partSubprograms = new Dictionary<(int, long), int>();
|
||||
subprogramEntries = new List<(int, string, Program)>();
|
||||
var nextSubNum = Config.PartSubprogramStart;
|
||||
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff) continue;
|
||||
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
|
||||
if (!partSubprograms.ContainsKey(key))
|
||||
{
|
||||
var subNum = nextSubNum++;
|
||||
partSubprograms[key] = subNum;
|
||||
|
||||
// Create normalized program at origin
|
||||
var pgm = part.Program.Clone() as Program;
|
||||
var bbox = pgm.BoundingBox();
|
||||
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
|
||||
|
||||
subprogramEntries.Add((subNum, part.BaseDrawing.Name, pgm));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create writers
|
||||
var preamble = new CincinnatiPreambleWriter(Config);
|
||||
var sheetWriter = new CincinnatiSheetWriter(Config, vars);
|
||||
|
||||
// 6. Build material description from first plate
|
||||
var material = firstPlate?.Material;
|
||||
var materialDesc = material != null
|
||||
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}"
|
||||
: "";
|
||||
|
||||
// 7. Write to stream
|
||||
using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
|
||||
|
||||
// Main program
|
||||
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates.Count, initialCutLibrary);
|
||||
|
||||
// Variable declaration subprogram
|
||||
preamble.WriteVariableDeclaration(writer, vars);
|
||||
|
||||
// Sheet subprograms
|
||||
for (var i = 0; i < plates.Count; i++)
|
||||
{
|
||||
var plate = plates[i];
|
||||
var sheetIndex = i + 1;
|
||||
var subNumber = Config.SheetSubprogramStart + i;
|
||||
var cutLibrary = resolver.ResolveCutLibrary(plate.Material?.Name ?? "", plate.Thickness, gas);
|
||||
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber,
|
||||
cutLibrary, etchLibrary, partSubprograms);
|
||||
}
|
||||
|
||||
// Part sub-programs (if enabled)
|
||||
if (subprogramEntries != null)
|
||||
{
|
||||
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
|
||||
var sheetDiagonal = firstPlate != null
|
||||
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
|
||||
+ firstPlate.Size.Length * firstPlate.Size.Length)
|
||||
: 100.0;
|
||||
|
||||
foreach (var (subNum, name, pgm) in subprogramEntries)
|
||||
{
|
||||
partSubWriter.Write(writer, pgm, name, subNum,
|
||||
initialCutLibrary, etchLibrary, sheetDiagonal);
|
||||
}
|
||||
}
|
||||
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
public void Post(Nest nest, string outputFile)
|
||||
{
|
||||
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
|
||||
Post(nest, fs);
|
||||
}
|
||||
|
||||
private ProgramVariableManager CreateVariableManager()
|
||||
{
|
||||
var vars = new ProgramVariableManager();
|
||||
vars.GetOrCreate("ProcessFeedrate", 148); // Set by G89, no expression
|
||||
vars.GetOrCreate("LeadInFeedrate", 126, $"[#148*{Config.LeadInFeedratePercent}]");
|
||||
vars.GetOrCreate("LeadInArcLine2Feedrate", 127, $"[#148*{Config.LeadInArcLine2FeedratePercent}]");
|
||||
vars.GetOrCreate("CircleFeedrate", 128, Config.CircleFeedrateMultiplier.ToString("0.#"));
|
||||
return vars;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs
Normal file
75
OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using OpenNest;
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Emits the main program header and variable declaration subprogram
|
||||
/// for a Cincinnati laser post-processor output file.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiPreambleWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
|
||||
public CincinnatiPreambleWriter(CincinnatiPostConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the main program header block.
|
||||
/// </summary>
|
||||
/// <param name="initialLibrary">Resolved G89 library file for the initial process setup.</param>
|
||||
public void WriteMainProgram(TextWriter w, string nestName, string materialDescription,
|
||||
int sheetCount, string initialLibrary)
|
||||
{
|
||||
w.WriteLine(CoordinateFormatter.Comment($"NEST {nestName}"));
|
||||
w.WriteLine(CoordinateFormatter.Comment($"CONFIGURATION - {_config.ConfigurationName}"));
|
||||
w.WriteLine(CoordinateFormatter.Comment(DateTime.Now.ToString("MM-dd-yyyy hh:mm:ss tt", System.Globalization.CultureInfo.InvariantCulture)));
|
||||
|
||||
if (!string.IsNullOrEmpty(materialDescription))
|
||||
w.WriteLine(CoordinateFormatter.Comment($"Material = {materialDescription}"));
|
||||
|
||||
if (_config.UseExactStopMode)
|
||||
w.WriteLine("G61");
|
||||
|
||||
w.WriteLine(CoordinateFormatter.Comment("MAIN PROGRAM"));
|
||||
|
||||
w.WriteLine(_config.PostedUnits == Units.Millimeters ? "G21" : "G20");
|
||||
|
||||
w.WriteLine("M42");
|
||||
|
||||
if (_config.ProcessParameterMode == G89Mode.LibraryFile && !string.IsNullOrEmpty(initialLibrary))
|
||||
w.WriteLine($"G89 P {initialLibrary}");
|
||||
|
||||
w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)");
|
||||
|
||||
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
|
||||
|
||||
for (var i = 1; i <= sheetCount; i++)
|
||||
{
|
||||
var subNum = _config.SheetSubprogramStart + (i - 1);
|
||||
w.WriteLine($"N{i} M98 P{subNum} (SHEET {i})");
|
||||
}
|
||||
|
||||
w.WriteLine("M42");
|
||||
w.WriteLine("M30 (END OF MAIN)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the variable declaration subprogram block.
|
||||
/// </summary>
|
||||
public void WriteVariableDeclaration(TextWriter w, ProgramVariableManager vars)
|
||||
{
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($":{_config.VariableDeclarationSubprogram}");
|
||||
w.WriteLine("(Variable Declaration Start)");
|
||||
|
||||
foreach (var line in vars.EmitDeclarations())
|
||||
w.WriteLine(line);
|
||||
|
||||
w.WriteLine("M99 (Variable Declaration End)");
|
||||
}
|
||||
}
|
||||
336
OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs
Normal file
336
OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
/// <summary>
|
||||
/// Emits one Cincinnati-format sheet subprogram per plate.
|
||||
/// Supports two modes: inline features (default) or M98 sub-program calls per part.
|
||||
/// </summary>
|
||||
public sealed class CincinnatiSheetWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly ProgramVariableManager _vars;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||
|
||||
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars)
|
||||
{
|
||||
_config = config;
|
||||
_vars = vars;
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a complete sheet subprogram for the given plate.
|
||||
/// </summary>
|
||||
/// <param name="cutLibrary">Resolved G89 library file for cut operations.</param>
|
||||
/// <param name="etchLibrary">Resolved G89 library file for etch operations.</param>
|
||||
/// <param name="partSubprograms">
|
||||
/// Optional mapping of (drawingId, rotationKey) to sub-program number.
|
||||
/// When provided, non-cutoff parts are emitted as M98 calls instead of inline features.
|
||||
/// </param>
|
||||
public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber,
|
||||
string cutLibrary, string etchLibrary,
|
||||
Dictionary<(int, long), int> partSubprograms = null)
|
||||
{
|
||||
if (plate.Parts.Count == 0)
|
||||
return;
|
||||
|
||||
var width = plate.Size.Width;
|
||||
var length = plate.Size.Length;
|
||||
var sheetDiagonal = System.Math.Sqrt(width * width + length * length);
|
||||
var varDeclSub = _config.VariableDeclarationSubprogram;
|
||||
var partCount = plate.Parts.Count(p => !p.BaseDrawing.IsCutOff);
|
||||
|
||||
// 1. Sheet header
|
||||
w.WriteLine("(*****************************************************)");
|
||||
w.WriteLine($"( START OF {nestName}.{sheetIndex:D3} )");
|
||||
w.WriteLine($":{subNumber}");
|
||||
w.WriteLine($"( Sheet {sheetIndex} )");
|
||||
w.WriteLine($"( Layout {sheetIndex} )");
|
||||
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(length)} X {_fmt.FormatCoord(width)} )");
|
||||
w.WriteLine($"( Total parts on sheet = {partCount} )");
|
||||
w.WriteLine($"#{_config.SheetWidthVariable}={_fmt.FormatCoord(width)} (SHEET WIDTH FOR CUTOFFS)");
|
||||
w.WriteLine($"#{_config.SheetLengthVariable}={_fmt.FormatCoord(length)} (SHEET LENGTH FOR CUTOFFS)");
|
||||
|
||||
// 2. Coordinate setup
|
||||
w.WriteLine("M42");
|
||||
w.WriteLine("N10000");
|
||||
w.WriteLine("G92 X#5021 Y#5022");
|
||||
if (!string.IsNullOrEmpty(cutLibrary))
|
||||
w.WriteLine($"G89 P {cutLibrary}");
|
||||
w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)");
|
||||
w.WriteLine("G90");
|
||||
w.WriteLine("M47");
|
||||
if (!string.IsNullOrEmpty(cutLibrary))
|
||||
w.WriteLine($"G89 P {cutLibrary}");
|
||||
w.WriteLine("GOTO1( Goto Feature )");
|
||||
|
||||
// 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last
|
||||
var nonCutoffParts = plate.Parts
|
||||
.Where(p => !p.BaseDrawing.IsCutOff)
|
||||
.OrderBy(p => p.Bottom)
|
||||
.ThenBy(p => p.Left)
|
||||
.ToList();
|
||||
|
||||
var cutoffParts = plate.Parts
|
||||
.Where(p => p.BaseDrawing.IsCutOff)
|
||||
.ToList();
|
||||
|
||||
var allParts = nonCutoffParts.Concat(cutoffParts).ToList();
|
||||
|
||||
// 4. Emit parts
|
||||
if (partSubprograms != null)
|
||||
WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms);
|
||||
else
|
||||
WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal);
|
||||
|
||||
// 5. Footer
|
||||
w.WriteLine("M42");
|
||||
w.WriteLine("G0 X0 Y0");
|
||||
if (_config.PalletExchange != PalletMode.None)
|
||||
w.WriteLine($"N{sheetIndex + 1} M50");
|
||||
w.WriteLine($"M99 (END OF {nestName}.{sheetIndex:D3})");
|
||||
}
|
||||
|
||||
private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts,
|
||||
string cutLibrary, string etchLibrary, double sheetDiagonal,
|
||||
Dictionary<(int, long), int> partSubprograms)
|
||||
{
|
||||
var lastPartName = "";
|
||||
var featureIndex = 0;
|
||||
|
||||
for (var p = 0; p < allParts.Count; p++)
|
||||
{
|
||||
var part = allParts[p];
|
||||
var partName = part.BaseDrawing.Name;
|
||||
var isNewPart = partName != lastPartName;
|
||||
var isSafetyHeadraise = isNewPart && lastPartName != "";
|
||||
var isLastPart = p == allParts.Count - 1;
|
||||
|
||||
var key = CincinnatiPartSubprogramWriter.SubprogramKey(part);
|
||||
partSubprograms.TryGetValue(key, out var subNum);
|
||||
var hasSubprogram = !part.BaseDrawing.IsCutOff && subNum != 0;
|
||||
|
||||
if (hasSubprogram)
|
||||
{
|
||||
WriteSubprogramCall(w, part, subNum, featureIndex, partName,
|
||||
isSafetyHeadraise, isLastPart);
|
||||
featureIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Inline features for cutoffs or parts without sub-programs
|
||||
var features = SplitAndOrderFeatures(part);
|
||||
for (var f = 0; f < features.Count; f++)
|
||||
{
|
||||
var (codes, isEtch) = features[f];
|
||||
var featureNumber = featureIndex == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + featureIndex + 1;
|
||||
|
||||
var isLastFeature = isLastPart && f == features.Count - 1;
|
||||
var cutDistance = ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
Codes = codes,
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = partName,
|
||||
IsFirstFeatureOfPart = isNewPart && f == 0,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = isSafetyHeadraise && f == 0,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
LibraryFile = isEtch ? etchLibrary : cutLibrary,
|
||||
CutDistance = cutDistance,
|
||||
SheetDiagonal = sheetDiagonal
|
||||
};
|
||||
|
||||
_featureWriter.Write(w, ctx);
|
||||
featureIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
lastPartName = partName;
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteSubprogramCall(TextWriter w, Part part, int subNum,
|
||||
int featureIndex, string partName, bool isSafetyHeadraise, bool isLastPart)
|
||||
{
|
||||
// Safety headraise before rapid to new part
|
||||
if (isSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue)
|
||||
w.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
|
||||
|
||||
// Rapid to part position (bounding box lower-left)
|
||||
var featureNumber = featureIndex == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + featureIndex + 1;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (_config.UseLineNumbers)
|
||||
sb.Append($"N{featureNumber} ");
|
||||
sb.Append($"G0 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
|
||||
w.WriteLine(sb.ToString());
|
||||
|
||||
// Part name comment
|
||||
w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}"));
|
||||
|
||||
// Set local coordinate system at part position
|
||||
w.WriteLine("G92 X0 Y0");
|
||||
|
||||
// Call part sub-program
|
||||
w.WriteLine($"M98 P{subNum} ({partName})");
|
||||
|
||||
// Restore sheet coordinate system
|
||||
w.WriteLine($"G92 X{_fmt.FormatCoord(part.Left)} Y{_fmt.FormatCoord(part.Bottom)}");
|
||||
|
||||
// Head raise (unless last part on sheet)
|
||||
if (!isLastPart)
|
||||
w.WriteLine("M47");
|
||||
}
|
||||
|
||||
private void WritePartsInline(TextWriter w, List<Part> allParts,
|
||||
string cutLibrary, string etchLibrary, double sheetDiagonal)
|
||||
{
|
||||
// Split and classify features, ordering etch before cut per part
|
||||
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
|
||||
foreach (var part in allParts)
|
||||
{
|
||||
var partFeatures = SplitAndOrderFeatures(part);
|
||||
foreach (var (codes, isEtch) in partFeatures)
|
||||
features.Add((part, codes, isEtch));
|
||||
}
|
||||
|
||||
// Emit features
|
||||
var lastPartName = "";
|
||||
for (var i = 0; i < features.Count; i++)
|
||||
{
|
||||
var (part, codes, isEtch) = features[i];
|
||||
var partName = part.BaseDrawing.Name;
|
||||
var isFirstFeatureOfPart = partName != lastPartName;
|
||||
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
|
||||
var isLastFeature = i == features.Count - 1;
|
||||
|
||||
var featureNumber = i == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
|
||||
var cutDistance = ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
{
|
||||
Codes = codes,
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = partName,
|
||||
IsFirstFeatureOfPart = isFirstFeatureOfPart,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = isSafetyHeadraise,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
LibraryFile = isEtch ? etchLibrary : cutLibrary,
|
||||
CutDistance = cutDistance,
|
||||
SheetDiagonal = sheetDiagonal
|
||||
};
|
||||
|
||||
_featureWriter.Write(w, ctx);
|
||||
lastPartName = partName;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a part's program into features (by rapids), classifies each as etch or cut,
|
||||
/// and orders etch features before cut features.
|
||||
/// </summary>
|
||||
public static List<(List<ICode> codes, bool isEtch)> SplitAndOrderFeatures(Part part)
|
||||
{
|
||||
var etchFeatures = new List<List<ICode>>();
|
||||
var cutFeatures = new List<List<ICode>>();
|
||||
List<ICode> current = null;
|
||||
|
||||
foreach (var code in part.Program.Codes)
|
||||
{
|
||||
if (code is RapidMove)
|
||||
{
|
||||
if (current != null)
|
||||
ClassifyAndAdd(current, etchFeatures, cutFeatures);
|
||||
current = new List<ICode> { code };
|
||||
}
|
||||
else
|
||||
{
|
||||
current ??= new List<ICode>();
|
||||
current.Add(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null && current.Count > 0)
|
||||
ClassifyAndAdd(current, etchFeatures, cutFeatures);
|
||||
|
||||
// Etch features first, then cut features
|
||||
var result = new List<(List<ICode>, bool)>();
|
||||
foreach (var f in etchFeatures)
|
||||
result.Add((f, true));
|
||||
foreach (var f in cutFeatures)
|
||||
result.Add((f, false));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ClassifyAndAdd(List<ICode> codes,
|
||||
List<List<ICode>> etchFeatures, List<List<ICode>> cutFeatures)
|
||||
{
|
||||
if (IsFeatureEtch(codes))
|
||||
etchFeatures.Add(codes);
|
||||
else
|
||||
cutFeatures.Add(codes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A feature is etch if any non-rapid move has LayerType.Scribe.
|
||||
/// </summary>
|
||||
public static bool IsFeatureEtch(List<ICode> codes)
|
||||
{
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is LinearMove linear && linear.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
if (code is ArcMove arc && arc.Layer == LayerType.Scribe)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double ComputeCutDistance(List<ICode> codes)
|
||||
{
|
||||
var distance = 0.0;
|
||||
var currentPos = Vector.Zero;
|
||||
|
||||
foreach (var code in codes)
|
||||
{
|
||||
if (code is RapidMove rapid)
|
||||
{
|
||||
currentPos = rapid.EndPoint;
|
||||
}
|
||||
else if (code is LinearMove linear)
|
||||
{
|
||||
distance += currentPos.DistanceTo(linear.EndPoint);
|
||||
currentPos = linear.EndPoint;
|
||||
}
|
||||
else if (code is ArcMove arc)
|
||||
{
|
||||
distance += currentPos.DistanceTo(arc.EndPoint);
|
||||
currentPos = arc.EndPoint;
|
||||
}
|
||||
}
|
||||
|
||||
return distance;
|
||||
}
|
||||
}
|
||||
24
OpenNest.Posts.Cincinnati/CoordinateFormatter.cs
Normal file
24
OpenNest.Posts.Cincinnati/CoordinateFormatter.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class CoordinateFormatter
|
||||
{
|
||||
private readonly int _accuracy;
|
||||
private readonly string _format;
|
||||
|
||||
public CoordinateFormatter(int accuracy)
|
||||
{
|
||||
_accuracy = accuracy;
|
||||
_format = "0." + new string('#', accuracy);
|
||||
}
|
||||
|
||||
public string FormatCoord(double value)
|
||||
{
|
||||
return System.Math.Round(value, _accuracy)
|
||||
.ToString(_format, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string Comment(string text) => $"( {text} )";
|
||||
|
||||
public static string InlineComment(string text) => $"({text})";
|
||||
}
|
||||
}
|
||||
42
OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs
Normal file
42
OpenNest.Posts.Cincinnati/MaterialLibraryResolver.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati;
|
||||
|
||||
public sealed class MaterialLibraryResolver
|
||||
{
|
||||
private const double ThicknessTolerance = 0.001;
|
||||
|
||||
private readonly List<MaterialLibraryEntry> _materialLibraries;
|
||||
private readonly List<EtchLibraryEntry> _etchLibraries;
|
||||
|
||||
public MaterialLibraryResolver(CincinnatiPostConfig config)
|
||||
{
|
||||
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
|
||||
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
|
||||
}
|
||||
|
||||
public string ResolveCutLibrary(string materialName, double thickness, string gas)
|
||||
{
|
||||
var entry = _materialLibraries.FirstOrDefault(e =>
|
||||
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
|
||||
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
|
||||
string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return entry?.Library ?? "";
|
||||
}
|
||||
|
||||
public string ResolveEtchLibrary(string gas)
|
||||
{
|
||||
var entry = _etchLibraries.FirstOrDefault(e =>
|
||||
string.Equals(e.Gas, gas, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return entry?.Library ?? "";
|
||||
}
|
||||
|
||||
public static string ResolveGas(Nest nest, CincinnatiPostConfig config)
|
||||
{
|
||||
return !string.IsNullOrEmpty(nest.AssistGas) ? nest.AssistGas : config.DefaultAssistGas;
|
||||
}
|
||||
}
|
||||
16
OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.csproj
Normal file
16
OpenNest.Posts.Cincinnati/OpenNest.Posts.Cincinnati.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Posts.Cincinnati</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToPostsDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PostsDir)" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
31
OpenNest.Posts.Cincinnati/SpeedClassifier.cs
Normal file
31
OpenNest.Posts.Cincinnati/SpeedClassifier.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class SpeedClassifier
|
||||
{
|
||||
public double FastThreshold { get; set; } = 0.5;
|
||||
public double SlowThreshold { get; set; } = 0.1;
|
||||
|
||||
public string Classify(double contourLength, double sheetDiagonal)
|
||||
{
|
||||
var ratio = contourLength / sheetDiagonal;
|
||||
if (ratio >= FastThreshold) return "FAST";
|
||||
if (ratio <= SlowThreshold) return "SLOW";
|
||||
return "MEDIUM";
|
||||
}
|
||||
|
||||
public string FormatCutDist(double contourLength, double sheetDiagonal)
|
||||
{
|
||||
return $"CutDist={FormatValue(contourLength)}/{FormatValue(sheetDiagonal)}";
|
||||
}
|
||||
|
||||
private static string FormatValue(double value)
|
||||
{
|
||||
// Cincinnati convention: no leading zero for values < 1 (e.g., ".8702" not "0.8702")
|
||||
var rounded = System.Math.Round(value, 4);
|
||||
var str = rounded.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (rounded > 0 && rounded < 1 && str.StartsWith("0."))
|
||||
return str.Substring(1);
|
||||
return str;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class AccumulatingProgressTests
|
||||
var accumulating = new AccumulatingProgress(inner, previous);
|
||||
|
||||
var newParts = new List<Part> { TestHelpers.MakePartAt(20, 0, 10) };
|
||||
accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
|
||||
accumulating.Report(new NestProgress { BestParts = newParts });
|
||||
|
||||
Assert.NotNull(inner.Last);
|
||||
Assert.Equal(2, inner.Last.BestParts.Count);
|
||||
@@ -32,7 +32,7 @@ public class AccumulatingProgressTests
|
||||
var accumulating = new AccumulatingProgress(inner, new List<Part>());
|
||||
|
||||
var newParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
|
||||
accumulating.Report(new NestProgress { BestParts = newParts });
|
||||
|
||||
Assert.NotNull(inner.Last);
|
||||
Assert.Single(inner.Last.BestParts);
|
||||
|
||||
150
OpenNest.Tests/BestCombinationTests.cs
Normal file
150
OpenNest.Tests/BestCombinationTests.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class BestCombinationTests
|
||||
{
|
||||
[Fact]
|
||||
public void BothFit_FindsZeroRemnant()
|
||||
{
|
||||
// 100 = 0*30 + 5*20 (algorithm iterates from countLength1=0, finds zero remnant first)
|
||||
var result = BestCombination.FindFrom2(30, 20, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 100.0 - (c1 * 30.0 + c2 * 20.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLength1Fits_ReturnsMaxCount1()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 200, 50, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(5, c1);
|
||||
Assert.Equal(0, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnlyLength2Fits_ReturnsMaxCount2()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(200, 10, 50, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(5, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeitherFits_ReturnsFalse()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(100, 200, 50, out var c1, out var c2);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(0, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Length1FillsExactly_ZeroRemnant()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(25, 10, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 100.0 - (c1 * 25.0 + c2 * 10.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixMinimizesRemnant()
|
||||
{
|
||||
// 7 and 3 into 20: best is 2*7 + 2*3 = 20 (zero remnant)
|
||||
var result = BestCombination.FindFrom2(7, 3, 20, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, c1);
|
||||
Assert.Equal(2, c2);
|
||||
Assert.True(c1 * 7 + c2 * 3 <= 20);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PrefersLessRemnant_OverMoreOfLength1()
|
||||
{
|
||||
// 6 and 5 into 17:
|
||||
// all length1: 2*6=12, remnant=5 -> actually 2*6+1*5=17 perfect
|
||||
var result = BestCombination.FindFrom2(6, 5, 17, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 17.0 - (c1 * 6.0 + c2 * 5.0), 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EqualLengths_FillsWithLength1()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 10, 50, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(5, c1 + c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallLengths_LargeOverall()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(3, 7, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
var used = c1 * 3.0 + c2 * 7.0;
|
||||
Assert.True(used <= 100);
|
||||
Assert.True(100 - used < 3); // remnant less than smallest piece
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Length2IsBetter_SoleCandidate()
|
||||
{
|
||||
// length1=9, length2=5, overall=10:
|
||||
// length1 alone: 1*9=9 remnant=1
|
||||
// length2 alone: 2*5=10 remnant=0
|
||||
var result = BestCombination.FindFrom2(9, 5, 10, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(2, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FractionalLengths_WorkCorrectly()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(2.5, 3.5, 12, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
var used = c1 * 2.5 + c2 * 3.5;
|
||||
Assert.True(used <= 12.0 + 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverallExactlyOneOfEach()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(40, 60, 100, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, c1);
|
||||
Assert.Equal(1, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverallSmallerThanEither_ReturnsFalse()
|
||||
{
|
||||
var result = BestCombination.FindFrom2(10, 20, 5, out var c1, out var c2);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, c1);
|
||||
Assert.Equal(0, c2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroRemnant_StopsEarly()
|
||||
{
|
||||
// 4 and 6 into 24: 0*4+4*6=24 or 3*4+2*6=24 or 6*4+0*6=24
|
||||
// Algorithm iterates from 0 length1 upward, finds zero remnant and breaks
|
||||
var result = BestCombination.FindFrom2(4, 6, 24, out var c1, out var c2);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(0.0, 24.0 - (c1 * 4.0 + c2 * 6.0), 5);
|
||||
}
|
||||
}
|
||||
484
OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
Normal file
484
OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
Normal file
@@ -0,0 +1,484 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class CincinnatiFeatureWriterTests
|
||||
{
|
||||
private static CincinnatiPostConfig DefaultConfig() => new()
|
||||
{
|
||||
UseLineNumbers = true,
|
||||
FeatureLineNumberStart = 1,
|
||||
UseAntiDive = true,
|
||||
KerfCompensation = KerfMode.ControllerSide,
|
||||
DefaultKerfSide = KerfSide.Left,
|
||||
ProcessParameterMode = G89Mode.LibraryFile,
|
||||
InteriorM47 = M47Mode.Always,
|
||||
ExteriorM47 = M47Mode.Always,
|
||||
UseSpeedGas = false,
|
||||
PostedAccuracy = 4,
|
||||
SafetyHeadraiseDistance = 2000
|
||||
};
|
||||
|
||||
private static FeatureContext SimpleContext(List<ICode>? codes = null) => new()
|
||||
{
|
||||
Codes = codes ?? new List<ICode>
|
||||
{
|
||||
new RapidMove(13.401, 57.4895),
|
||||
new LinearMove(14.0, 57.5) { Layer = LayerType.Leadin },
|
||||
new LinearMove(20.0, 57.5) { Layer = LayerType.Cut }
|
||||
},
|
||||
FeatureNumber = 1,
|
||||
PartName = "BRACKET",
|
||||
IsFirstFeatureOfPart = true,
|
||||
IsLastFeatureOnSheet = false,
|
||||
IsSafetyHeadraise = false,
|
||||
IsExteriorFeature = false,
|
||||
LibraryFile = "MILD10",
|
||||
CutDistance = 18.0,
|
||||
SheetDiagonal = 30.0
|
||||
};
|
||||
|
||||
private static string WriteFeature(CincinnatiPostConfig config, FeatureContext ctx)
|
||||
{
|
||||
var writer = new CincinnatiFeatureWriter(config);
|
||||
using var sw = new StringWriter();
|
||||
writer.Write(sw, ctx);
|
||||
return sw.ToString();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RapidToPiercePoint_WithLineNumber()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.StartsWith("N1 G0 X13.401 Y57.4895", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RapidToPiercePoint_WithoutLineNumber()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.UseLineNumbers = false;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.StartsWith("G0 X13.401 Y57.4895", lines[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G84_PierceEmitted()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("G84", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AntiDive_M130M131_EmittedWhenEnabled()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.UseAntiDive = true;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("M130 (ANTI DIVE OFF)", output);
|
||||
Assert.Contains("M131 (ANTI DIVE ON)", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AntiDive_NotEmittedWhenDisabled()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.UseAntiDive = false;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.DoesNotContain("M130", output);
|
||||
Assert.DoesNotContain("M131", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KerfCompensation_G41G40_EmittedWhenControllerSide()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.ControllerSide;
|
||||
config.DefaultKerfSide = KerfSide.Left;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("G41", output);
|
||||
Assert.Contains("G40", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KerfCompensation_G42_EmittedForRightSide()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.ControllerSide;
|
||||
config.DefaultKerfSide = KerfSide.Right;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("G42", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KerfCompensation_NotEmittedWhenPreApplied()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.PreApplied;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.DoesNotContain("G41", output);
|
||||
Assert.DoesNotContain("G42", output);
|
||||
Assert.DoesNotContain("G40", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M35_BeamOffEmitted()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.UseSpeedGas = false;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("M35", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M135_BeamOffEmittedWhenSpeedGas()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.UseSpeedGas = true;
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("M135", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M47_EmittedWhenNotLastFeature()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsLastFeatureOnSheet = false;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("M47", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M47_OmittedWhenLastFeatureOnSheet()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsLastFeatureOnSheet = true;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
// M47 should not appear, but M35 should still be there
|
||||
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.DoesNotContain(lines, l => l.Trim() == "M47" || l.Trim() == "/M47");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M47_BlockDeleteMode_EmitsSlashM47()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.InteriorM47 = M47Mode.BlockDelete;
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsExteriorFeature = false;
|
||||
ctx.IsLastFeatureOnSheet = false;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("/M47", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void M47_NoneMode_NoM47Emitted()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.InteriorM47 = M47Mode.None;
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsExteriorFeature = false;
|
||||
ctx.IsLastFeatureOnSheet = false;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.DoesNotContain(lines, l => l.Trim() == "M47" || l.Trim() == "/M47");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArcIJ_ConvertedFromAbsoluteToIncremental()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
// Arc starts at rapid endpoint (10, 20), center at (15, 20) absolute
|
||||
// So incremental I = 15 - 10 = 5, J = 20 - 20 = 0
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(10.0, 20.0),
|
||||
new ArcMove(
|
||||
endPoint: new Vector(10.0, 20.0),
|
||||
centerPoint: new Vector(15.0, 20.0),
|
||||
rotation: RotationType.CW
|
||||
) { Layer = LayerType.Cut }
|
||||
};
|
||||
|
||||
var ctx = SimpleContext(codes);
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
// Should contain incremental I=5, J=0
|
||||
Assert.Contains("I5", output);
|
||||
Assert.Contains("J0", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArcMove_G2ForCW_G3ForCCW()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var cwCodes = new List<ICode>
|
||||
{
|
||||
new RapidMove(10.0, 20.0),
|
||||
new ArcMove(new Vector(20.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut }
|
||||
};
|
||||
var ccwCodes = new List<ICode>
|
||||
{
|
||||
new RapidMove(10.0, 20.0),
|
||||
new ArcMove(new Vector(20.0, 20.0), new Vector(15.0, 20.0), RotationType.CCW) { Layer = LayerType.Cut }
|
||||
};
|
||||
|
||||
var cwOutput = WriteFeature(config, SimpleContext(cwCodes));
|
||||
var ccwOutput = WriteFeature(config, SimpleContext(ccwCodes));
|
||||
|
||||
Assert.Contains("G2 X", cwOutput);
|
||||
Assert.Contains("G3 X", ccwOutput);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartNameComment_EmittedOnFirstFeature()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsFirstFeatureOfPart = true;
|
||||
ctx.PartName = "FLANGE";
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("( PART: FLANGE )", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartNameComment_NotEmittedOnSubsequentFeatures()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsFirstFeatureOfPart = false;
|
||||
ctx.PartName = "FLANGE";
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.DoesNotContain("PART:", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G89_EmittedWithLibraryFile()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.ProcessParameterMode = G89Mode.LibraryFile;
|
||||
var ctx = SimpleContext();
|
||||
ctx.LibraryFile = "MILD10";
|
||||
ctx.CutDistance = 18.0;
|
||||
ctx.SheetDiagonal = 30.0;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("G89 P MILD10", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void G89_WarningEmittedWhenNoLibrary()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.ProcessParameterMode = G89Mode.LibraryFile;
|
||||
var ctx = SimpleContext();
|
||||
ctx.LibraryFile = "";
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("WARNING: No library found", output);
|
||||
Assert.DoesNotContain("G89 P", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Etch_UsesG85InsteadOfG84()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsEtch = true;
|
||||
ctx.LibraryFile = "EtchN2.lib";
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("G85", output);
|
||||
Assert.DoesNotContain("G84", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Etch_SkipsKerfCompensation()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.ControllerSide;
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsEtch = true;
|
||||
ctx.LibraryFile = "EtchN2.lib";
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.DoesNotContain("G41", output);
|
||||
Assert.DoesNotContain("G42", output);
|
||||
Assert.DoesNotContain("G40", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Etch_AllMovesUseProcessFeedrate()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.PreApplied;
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(1.0, 1.0),
|
||||
new LinearMove(2.0, 1.0) { Layer = LayerType.Leadin },
|
||||
new LinearMove(3.0, 1.0) { Layer = LayerType.Cut }
|
||||
};
|
||||
var ctx = SimpleContext(codes);
|
||||
ctx.IsEtch = true;
|
||||
ctx.LibraryFile = "EtchN2.lib";
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
// Should use #148 for all moves, not #126 for lead-in
|
||||
Assert.DoesNotContain("F#126", output);
|
||||
Assert.Contains("F#148", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeedrateModalSuppression_OnlyEmitsOnChange()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.PreApplied; // simplify output
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(1.0, 1.0),
|
||||
new LinearMove(2.0, 1.0) { Layer = LayerType.Cut },
|
||||
new LinearMove(3.0, 1.0) { Layer = LayerType.Cut },
|
||||
new LinearMove(4.0, 1.0) { Layer = LayerType.Cut }
|
||||
};
|
||||
var ctx = SimpleContext(codes);
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
// F#148 should appear only once (on the first cut move)
|
||||
var count = CountOccurrences(output, "F#148");
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeadinFeedrate_UsesVariable126()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.PreApplied; // simplify output
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(1.0, 1.0),
|
||||
new LinearMove(2.0, 1.0) { Layer = LayerType.Leadin }
|
||||
};
|
||||
var ctx = SimpleContext(codes);
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("F#126", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullCircleArc_UsesMultipliedFeedrate()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.KerfCompensation = KerfMode.PreApplied;
|
||||
// Full circle: start == end
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(10.0, 20.0),
|
||||
new ArcMove(new Vector(10.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut }
|
||||
};
|
||||
var ctx = SimpleContext(codes);
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("F[#148*#128]", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafetyHeadraise_EmitsM47WithDistance()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.SafetyHeadraiseDistance = 2000;
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsSafetyHeadraise = true;
|
||||
ctx.IsLastFeatureOnSheet = false;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("M47 P2000 (Safety Headraise)", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExteriorM47Mode_UsesExteriorConfig()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.ExteriorM47 = M47Mode.BlockDelete;
|
||||
config.InteriorM47 = M47Mode.Always;
|
||||
var ctx = SimpleContext();
|
||||
ctx.IsExteriorFeature = true;
|
||||
ctx.IsLastFeatureOnSheet = false;
|
||||
var output = WriteFeature(config, ctx);
|
||||
|
||||
Assert.Contains("/M47", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutputSequence_CorrectOrder()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
var ctx = SimpleContext();
|
||||
var output = WriteFeature(config, ctx);
|
||||
var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Find indices of key lines
|
||||
var rapidIdx = Array.FindIndex(lines, l => l.Contains("G0 X"));
|
||||
var partIdx = Array.FindIndex(lines, l => l.Contains("PART:"));
|
||||
var g89Idx = Array.FindIndex(lines, l => l.Contains("G89"));
|
||||
var g84Idx = Array.FindIndex(lines, l => l.Contains("G84"));
|
||||
var m130Idx = Array.FindIndex(lines, l => l.Contains("M130"));
|
||||
var g40Idx = Array.FindIndex(lines, l => l.Contains("G40"));
|
||||
var m35Idx = Array.FindIndex(lines, l => l.Contains("M35"));
|
||||
var m131Idx = Array.FindIndex(lines, l => l.Contains("M131"));
|
||||
var m47Idx = Array.FindIndex(lines, l => l.Trim() == "M47");
|
||||
|
||||
Assert.True(rapidIdx < partIdx, "Rapid should come before part comment");
|
||||
Assert.True(partIdx < g89Idx, "Part comment should come before G89");
|
||||
Assert.True(g89Idx < g84Idx, "G89 should come before G84");
|
||||
Assert.True(g84Idx < m130Idx, "G84 should come before M130");
|
||||
Assert.True(g40Idx < m35Idx, "G40 should come before M35");
|
||||
Assert.True(m35Idx < m131Idx, "M35 should come before M131");
|
||||
Assert.True(m131Idx < m47Idx, "M131 should come before M47");
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string pattern)
|
||||
{
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = text.IndexOf(pattern, idx, StringComparison.Ordinal)) != -1)
|
||||
{
|
||||
count++;
|
||||
idx += pattern.Length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
335
OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs
Normal file
335
OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class CincinnatiPostProcessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Post_ProducesOutput_ForSinglePlateNest()
|
||||
{
|
||||
var nest = CreateTestNest();
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
ConfigurationName = "CL940",
|
||||
PostedAccuracy = 4
|
||||
};
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
post.Post(nest, ms);
|
||||
|
||||
var output = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Main program elements
|
||||
Assert.Contains("( NEST TestNest )", output);
|
||||
Assert.Contains("( CONFIGURATION - CL940 )", output);
|
||||
Assert.Contains("G20", output);
|
||||
Assert.Contains("M30 (END OF MAIN)", output);
|
||||
|
||||
// Variable declaration
|
||||
Assert.Contains(":100", output);
|
||||
Assert.Contains("#126=", output);
|
||||
|
||||
// Sheet subprogram
|
||||
Assert.Contains(":101", output);
|
||||
Assert.Contains("( Sheet 1 )", output);
|
||||
Assert.Contains("G84", output);
|
||||
Assert.Contains("M99", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_ImplementsIPostProcessor()
|
||||
{
|
||||
var post = new CincinnatiPostProcessor(new CincinnatiPostConfig());
|
||||
IPostProcessor pp = post;
|
||||
|
||||
Assert.Equal("Cincinnati CL-707", pp.Name);
|
||||
Assert.Equal("OpenNest", pp.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_SkipsEmptyPlates()
|
||||
{
|
||||
var nest = new Nest("TestNest");
|
||||
nest.Plates.Add(new Plate(48, 96)); // empty plate
|
||||
var plate2 = new Plate(48, 96);
|
||||
plate2.Parts.Add(new Part(new Drawing("Part1", CreateSquareProgram())));
|
||||
nest.Plates.Add(plate2);
|
||||
|
||||
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
post.Post(nest, ms);
|
||||
|
||||
var output = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Should only have one sheet subprogram call in main
|
||||
Assert.Contains("N1 M98 P101 (SHEET 1)", output);
|
||||
Assert.DoesNotContain("SHEET 2", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_ToFile_CreatesFile()
|
||||
{
|
||||
var nest = CreateTestNest();
|
||||
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
var tempFile = Path.GetTempFileName() + ".CNC";
|
||||
|
||||
try
|
||||
{
|
||||
post.Post(nest, tempFile);
|
||||
Assert.True(File.Exists(tempFile));
|
||||
var content = File.ReadAllText(tempFile);
|
||||
Assert.Contains("M30", content);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile))
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Config_RoundTripsAsJson()
|
||||
{
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
ConfigurationName = "CL940_CORONA",
|
||||
DefaultAssistGas = "N2",
|
||||
DefaultEtchGas = "N2",
|
||||
PostedUnits = Units.Inches,
|
||||
KerfCompensation = KerfMode.ControllerSide,
|
||||
UseAntiDive = true,
|
||||
MaterialLibraries = new()
|
||||
{
|
||||
new MaterialLibraryEntry { Material = "Mild Steel", Thickness = 0.135, Gas = "N2", Library = "MS135N2PANEL.lib" }
|
||||
},
|
||||
EtchLibraries = new()
|
||||
{
|
||||
new EtchLibraryEntry { Gas = "N2", Library = "EtchN2.lib" }
|
||||
}
|
||||
};
|
||||
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
var json = JsonSerializer.Serialize(config, opts);
|
||||
var deserialized = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, opts);
|
||||
|
||||
Assert.Equal("CL940_CORONA", deserialized.ConfigurationName);
|
||||
Assert.Equal("N2", deserialized.DefaultAssistGas);
|
||||
Assert.Equal("N2", deserialized.DefaultEtchGas);
|
||||
Assert.Equal(Units.Inches, deserialized.PostedUnits);
|
||||
Assert.Equal(KerfMode.ControllerSide, deserialized.KerfCompensation);
|
||||
Assert.True(deserialized.UseAntiDive);
|
||||
Assert.Single(deserialized.MaterialLibraries);
|
||||
Assert.Equal("MS135N2PANEL.lib", deserialized.MaterialLibraries[0].Library);
|
||||
Assert.Single(deserialized.EtchLibraries);
|
||||
Assert.Equal("EtchN2.lib", deserialized.EtchLibraries[0].Library);
|
||||
|
||||
// Enums serialize as strings
|
||||
Assert.Contains("\"Inches\"", json);
|
||||
Assert.Contains("\"ControllerSide\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParameterlessConstructor_LoadsOrCreatesConfig()
|
||||
{
|
||||
// The parameterless constructor reads from a .json file next to the assembly,
|
||||
// or creates defaults if none exists. Either way, Config should be non-null.
|
||||
var post = new CincinnatiPostProcessor();
|
||||
Assert.NotNull(post.Config);
|
||||
Assert.Equal("CL940", post.Config.ConfigurationName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_WithPartSubprograms_WritesM98Calls()
|
||||
{
|
||||
var nest = CreateTestNest();
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
PostedAccuracy = 4,
|
||||
UsePartSubprograms = true,
|
||||
PartSubprogramStart = 200
|
||||
};
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
post.Post(nest, ms);
|
||||
|
||||
var output = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Sheet should contain M98 call to part sub-program
|
||||
Assert.Contains("M98 P200", output);
|
||||
|
||||
// Should have G92 for local coordinate positioning
|
||||
Assert.Contains("G92 X0 Y0", output);
|
||||
|
||||
// Part sub-program definition
|
||||
Assert.Contains(":200", output);
|
||||
Assert.Contains("G84", output);
|
||||
|
||||
// Sub-program ends with G0 X0 Y0 and M99
|
||||
Assert.Contains("G0 X0 Y0", output);
|
||||
Assert.Contains("M99 (END OF Square)", output);
|
||||
|
||||
// G92 restore after M98 call
|
||||
Assert.Contains("G92 X", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_WithPartSubprograms_ReusesSameSubprogram()
|
||||
{
|
||||
var nest = new Nest("TestNest");
|
||||
var drawing = new Drawing("Square", CreateSquareProgram());
|
||||
var plate = new Plate(48, 96);
|
||||
plate.Parts.Add(new Part(drawing, new Vector(5, 5)));
|
||||
plate.Parts.Add(new Part(drawing, new Vector(20, 5)));
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
PostedAccuracy = 4,
|
||||
UsePartSubprograms = true,
|
||||
PartSubprogramStart = 200
|
||||
};
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
post.Post(nest, ms);
|
||||
|
||||
var output = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Both parts should call the same sub-program
|
||||
var m98Count = System.Text.RegularExpressions.Regex.Matches(output, @"M98 P200\b").Count;
|
||||
Assert.Equal(2, m98Count);
|
||||
|
||||
// Only one sub-program definition
|
||||
var subDefCount = System.Text.RegularExpressions.Regex.Matches(output, ":200").Count;
|
||||
Assert.Equal(1, subDefCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_WithPartSubprograms_DifferentRotationsGetSeparateSubprograms()
|
||||
{
|
||||
var nest = new Nest("TestNest");
|
||||
var drawing = new Drawing("Square", CreateSquareProgram());
|
||||
var plate = new Plate(48, 96);
|
||||
|
||||
var part1 = new Part(drawing, new Vector(5, 5));
|
||||
plate.Parts.Add(part1);
|
||||
|
||||
var part2 = new Part(drawing, new Vector(20, 5));
|
||||
part2.Rotate(System.Math.PI / 2); // 90 degrees
|
||||
plate.Parts.Add(part2);
|
||||
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
PostedAccuracy = 4,
|
||||
UsePartSubprograms = true,
|
||||
PartSubprogramStart = 200
|
||||
};
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
post.Post(nest, ms);
|
||||
|
||||
var output = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Should have two different sub-programs
|
||||
Assert.Contains(":200", output);
|
||||
Assert.Contains(":201", output);
|
||||
Assert.Contains("M98 P200", output);
|
||||
Assert.Contains("M98 P201", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_WithPartSubprograms_CutoffsAreInline()
|
||||
{
|
||||
var nest = new Nest("TestNest");
|
||||
var drawing = new Drawing("Square", CreateSquareProgram());
|
||||
var cutoffDrawing = new Drawing("CutOff", CreateSquareProgram()) { IsCutOff = true };
|
||||
|
||||
var plate = new Plate(48, 96);
|
||||
plate.Parts.Add(new Part(drawing, new Vector(5, 5)));
|
||||
plate.Parts.Add(new Part(cutoffDrawing, new Vector(0, 30)));
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
PostedAccuracy = 4,
|
||||
UsePartSubprograms = true,
|
||||
PartSubprogramStart = 200
|
||||
};
|
||||
var post = new CincinnatiPostProcessor(config);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
post.Post(nest, ms);
|
||||
|
||||
var output = Encoding.UTF8.GetString(ms.ToArray());
|
||||
|
||||
// Regular part uses sub-program
|
||||
Assert.Contains("M98 P200", output);
|
||||
Assert.Contains(":200", output);
|
||||
|
||||
// Cutoff should NOT have its own sub-program
|
||||
Assert.DoesNotContain(":201", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Post_WithPartSubprograms_ConfigRoundTrips()
|
||||
{
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
UsePartSubprograms = true,
|
||||
PartSubprogramStart = 300
|
||||
};
|
||||
|
||||
var opts = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
var json = JsonSerializer.Serialize(config, opts);
|
||||
var deserialized = JsonSerializer.Deserialize<CincinnatiPostConfig>(json, opts);
|
||||
|
||||
Assert.True(deserialized.UsePartSubprograms);
|
||||
Assert.Equal(300, deserialized.PartSubprogramStart);
|
||||
}
|
||||
|
||||
private static Nest CreateTestNest()
|
||||
{
|
||||
var nest = new Nest("TestNest");
|
||||
var drawing = new Drawing("Square", CreateSquareProgram());
|
||||
nest.Drawings.Add(drawing);
|
||||
|
||||
var plate = new Plate(48.0, 96.0);
|
||||
plate.Parts.Add(new Part(drawing, new Vector(10, 10)));
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static Program CreateSquareProgram()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(0, 0));
|
||||
pgm.Codes.Add(new LinearMove(2, 0));
|
||||
pgm.Codes.Add(new LinearMove(2, 2));
|
||||
pgm.Codes.Add(new LinearMove(0, 2));
|
||||
pgm.Codes.Add(new LinearMove(0, 0));
|
||||
return pgm;
|
||||
}
|
||||
}
|
||||
97
OpenNest.Tests/Cincinnati/CincinnatiPreambleWriterTests.cs
Normal file
97
OpenNest.Tests/Cincinnati/CincinnatiPreambleWriterTests.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class CincinnatiPreambleWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void WriteMainProgram_EmitsHeader()
|
||||
{
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
ConfigurationName = "CL940",
|
||||
PostedUnits = Units.Inches
|
||||
};
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var writer = new CincinnatiPreambleWriter(config);
|
||||
|
||||
writer.WriteMainProgram(sw, "TestNest", "Mild Steel, 10GA", 2, "MS135N2PANEL.lib");
|
||||
|
||||
var output = sb.ToString();
|
||||
Assert.Contains("( NEST TestNest )", output);
|
||||
Assert.Contains("( CONFIGURATION - CL940 )", output);
|
||||
Assert.Contains("G20", output);
|
||||
Assert.Contains("M42", output);
|
||||
Assert.Contains("G89 P MS135N2PANEL.lib", output);
|
||||
Assert.Contains("M98 P100 (Variable Declaration)", output);
|
||||
Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output);
|
||||
Assert.Contains("N1 M98 P101 (SHEET 1)", output);
|
||||
Assert.Contains("N2 M98 P102 (SHEET 2)", output);
|
||||
Assert.Contains("M30 (END OF MAIN)", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteMainProgram_EmitsG21ForMetric()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { PostedUnits = Units.Millimeters };
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var writer = new CincinnatiPreambleWriter(config);
|
||||
|
||||
writer.WriteMainProgram(sw, "Test", "", 1, "");
|
||||
|
||||
Assert.Contains("G21", sb.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteMainProgram_EmitsG61_WhenExactStop()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { UseExactStopMode = true };
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var writer = new CincinnatiPreambleWriter(config);
|
||||
|
||||
writer.WriteMainProgram(sw, "Test", "", 1, "");
|
||||
|
||||
Assert.Contains("G61", sb.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteMainProgram_OmitsG61_WhenNotExactStop()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { UseExactStopMode = false };
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var writer = new CincinnatiPreambleWriter(config);
|
||||
|
||||
writer.WriteMainProgram(sw, "Test", "", 1, "");
|
||||
|
||||
Assert.DoesNotContain("G61", sb.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteVariableDeclaration_EmitsSubprogram()
|
||||
{
|
||||
var config = new CincinnatiPostConfig();
|
||||
var vars = new ProgramVariableManager();
|
||||
vars.GetOrCreate("LeadInFeedrate", 126, "[#148*0.5]");
|
||||
vars.GetOrCreate("CircleFeedrate", 128, ".8");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var writer = new CincinnatiPreambleWriter(config);
|
||||
|
||||
writer.WriteVariableDeclaration(sw, vars);
|
||||
|
||||
var output = sb.ToString();
|
||||
Assert.Contains(":100", output);
|
||||
Assert.Contains("(Variable Declaration Start)", output);
|
||||
Assert.Contains("#126=", output);
|
||||
Assert.Contains("#128=", output);
|
||||
Assert.Contains("M99 (Variable Declaration End)", output);
|
||||
}
|
||||
}
|
||||
192
OpenNest.Tests/Cincinnati/CincinnatiSheetWriterTests.cs
Normal file
192
OpenNest.Tests/Cincinnati/CincinnatiSheetWriterTests.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class CincinnatiSheetWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void WriteSheet_EmitsSheetHeader()
|
||||
{
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
PostedAccuracy = 4
|
||||
};
|
||||
var plate = new Plate(48.0, 96.0);
|
||||
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
|
||||
|
||||
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "MS135N2PANEL.lib", "EtchN2.lib");
|
||||
|
||||
var output = sb.ToString();
|
||||
Assert.Contains(":101", output);
|
||||
Assert.Contains("( Sheet 1 )", output);
|
||||
Assert.Contains("#110=", output);
|
||||
Assert.Contains("#111=", output);
|
||||
Assert.Contains("G92 X#5021 Y#5022", output);
|
||||
Assert.Contains("G89 P MS135N2PANEL.lib", output);
|
||||
Assert.Contains("M99", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteSheet_EmitsReturnToOriginAndPalletExchange()
|
||||
{
|
||||
var config = new CincinnatiPostConfig
|
||||
{
|
||||
PalletExchange = PalletMode.EndOfSheet,
|
||||
PostedAccuracy = 4
|
||||
};
|
||||
var plate = new Plate(48.0, 96.0);
|
||||
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
|
||||
|
||||
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
|
||||
|
||||
var output = sb.ToString();
|
||||
Assert.Contains("M42", output);
|
||||
Assert.Contains("G0 X0 Y0", output);
|
||||
Assert.Contains("M50", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteSheet_SkipsEmptyPlate()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
|
||||
var plate = new Plate(48.0, 96.0);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
|
||||
|
||||
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
|
||||
|
||||
Assert.Equal("", sb.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteSheet_SplitsMultiContourParts()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
|
||||
var pgm = new Program();
|
||||
// First contour (hole)
|
||||
pgm.Codes.Add(new RapidMove(1, 1));
|
||||
pgm.Codes.Add(new LinearMove(2, 1));
|
||||
pgm.Codes.Add(new LinearMove(2, 2));
|
||||
pgm.Codes.Add(new LinearMove(1, 1));
|
||||
// Second contour (exterior)
|
||||
pgm.Codes.Add(new RapidMove(0, 0));
|
||||
pgm.Codes.Add(new LinearMove(5, 0));
|
||||
pgm.Codes.Add(new LinearMove(5, 5));
|
||||
pgm.Codes.Add(new LinearMove(0, 0));
|
||||
|
||||
var plate = new Plate(48.0, 96.0);
|
||||
plate.Parts.Add(new Part(new Drawing("MultiContour", pgm)));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
|
||||
|
||||
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
|
||||
|
||||
var output = sb.ToString();
|
||||
// Should have two G84 pierce commands (one per contour)
|
||||
var g84Count = output.Split('\n').Count(l => l.Trim() == "G84");
|
||||
Assert.Equal(2, g84Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteSheet_EtchFeaturesOrderedBeforeCut()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { PostedAccuracy = 4 };
|
||||
var pgm = new Program();
|
||||
// Cut contour first in program
|
||||
pgm.Codes.Add(new RapidMove(0, 0));
|
||||
pgm.Codes.Add(new LinearMove(5, 0) { Layer = LayerType.Cut });
|
||||
pgm.Codes.Add(new LinearMove(5, 5) { Layer = LayerType.Cut });
|
||||
// Etch contour second in program
|
||||
pgm.Codes.Add(new RapidMove(1, 1));
|
||||
pgm.Codes.Add(new LinearMove(2, 1) { Layer = LayerType.Scribe });
|
||||
pgm.Codes.Add(new LinearMove(2, 2) { Layer = LayerType.Scribe });
|
||||
|
||||
var plate = new Plate(48.0, 96.0);
|
||||
plate.Parts.Add(new Part(new Drawing("MixedPart", pgm)));
|
||||
|
||||
var sb = new StringBuilder();
|
||||
using var sw = new StringWriter(sb);
|
||||
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
|
||||
|
||||
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "MS250O2.lib", "EtchN2.lib");
|
||||
|
||||
var output = sb.ToString();
|
||||
// Etch (G85) should appear before cut (G84)
|
||||
var g85Idx = output.IndexOf("G85");
|
||||
var g84Idx = output.IndexOf("G84");
|
||||
Assert.True(g85Idx >= 0, "G85 should be present for etch");
|
||||
Assert.True(g84Idx >= 0, "G84 should be present for cut");
|
||||
Assert.True(g85Idx < g84Idx, "G85 (etch) should come before G84 (cut)");
|
||||
|
||||
// Etch uses etch library
|
||||
Assert.Contains("G89 P EtchN2.lib", output);
|
||||
// Cut uses cut library
|
||||
Assert.Contains("G89 P MS250O2.lib", output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFeatureEtch_ReturnsTrueForScribeLayer()
|
||||
{
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(0, 0),
|
||||
new LinearMove(1, 0) { Layer = LayerType.Scribe },
|
||||
new LinearMove(1, 1) { Layer = LayerType.Scribe }
|
||||
};
|
||||
|
||||
Assert.True(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFeatureEtch_ReturnsFalseForCutLayer()
|
||||
{
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(0, 0),
|
||||
new LinearMove(1, 0) { Layer = LayerType.Cut },
|
||||
new LinearMove(1, 1) { Layer = LayerType.Cut }
|
||||
};
|
||||
|
||||
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFeatureEtch_ReturnsFalseForRapidsOnly()
|
||||
{
|
||||
var codes = new List<ICode>
|
||||
{
|
||||
new RapidMove(0, 0)
|
||||
};
|
||||
|
||||
Assert.False(CincinnatiSheetWriter.IsFeatureEtch(codes));
|
||||
}
|
||||
|
||||
private static Program CreateSimpleProgram()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(0, 0));
|
||||
pgm.Codes.Add(new LinearMove(1, 0));
|
||||
pgm.Codes.Add(new LinearMove(1, 1));
|
||||
pgm.Codes.Add(new LinearMove(0, 1));
|
||||
pgm.Codes.Add(new LinearMove(0, 0));
|
||||
return pgm;
|
||||
}
|
||||
}
|
||||
40
OpenNest.Tests/Cincinnati/CoordinateFormatterTests.cs
Normal file
40
OpenNest.Tests/Cincinnati/CoordinateFormatterTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class CoordinateFormatterTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(13.401, 4, "13.401")]
|
||||
[InlineData(13.0, 4, "13")]
|
||||
[InlineData(0.0, 4, "0")]
|
||||
[InlineData(57.4895, 4, "57.4895")]
|
||||
[InlineData(13.401, 3, "13.401")]
|
||||
[InlineData(13.4016, 3, "13.402")]
|
||||
public void FormatCoord_FormatsCorrectly(double value, int accuracy, string expected)
|
||||
{
|
||||
var formatter = new CoordinateFormatter(accuracy);
|
||||
Assert.Equal(expected, formatter.FormatCoord(value));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-5.25, 4, "-5.25")]
|
||||
[InlineData(-0.001, 4, "-0.001")]
|
||||
public void FormatCoord_HandlesNegatives(double value, int accuracy, string expected)
|
||||
{
|
||||
var formatter = new CoordinateFormatter(accuracy);
|
||||
Assert.Equal(expected, formatter.FormatCoord(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comment_FormatsWithSpaces()
|
||||
{
|
||||
Assert.Equal("( hello )", CoordinateFormatter.Comment("hello"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InlineComment_FormatsWithoutSpaces()
|
||||
{
|
||||
Assert.Equal("(hello)", CoordinateFormatter.InlineComment("hello"));
|
||||
}
|
||||
}
|
||||
160
OpenNest.Tests/Cincinnati/MaterialLibraryResolverTests.cs
Normal file
160
OpenNest.Tests/Cincinnati/MaterialLibraryResolverTests.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class MaterialLibraryResolverTests
|
||||
{
|
||||
private static CincinnatiPostConfig ConfigWithLibraries() => new()
|
||||
{
|
||||
DefaultAssistGas = "O2",
|
||||
DefaultEtchGas = "N2",
|
||||
MaterialLibraries = new()
|
||||
{
|
||||
new MaterialLibraryEntry { Material = "Mild Steel", Thickness = 0.250, Gas = "O2", Library = "MS250O2.lib" },
|
||||
new MaterialLibraryEntry { Material = "Mild Steel", Thickness = 0.250, Gas = "N2", Library = "MS250N2.lib" },
|
||||
new MaterialLibraryEntry { Material = "Aluminum", Thickness = 0.125, Gas = "N2", Library = "AL125N2.lib" },
|
||||
new MaterialLibraryEntry { Material = "Stainless Steel", Thickness = 0.375, Gas = "AIR", Library = "SS375AIR.lib" }
|
||||
},
|
||||
EtchLibraries = new()
|
||||
{
|
||||
new EtchLibraryEntry { Gas = "N2", Library = "EtchN2.lib" },
|
||||
new EtchLibraryEntry { Gas = "O2", Library = "EtchO2.lib" },
|
||||
new EtchLibraryEntry { Gas = "AIR", Library = "EtchAIR.lib" }
|
||||
}
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_ExactMatch()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "O2");
|
||||
Assert.Equal("MS250O2.lib", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_CaseInsensitiveMaterial()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("mild steel", 0.250, "O2");
|
||||
Assert.Equal("MS250O2.lib", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_CaseInsensitiveGas()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "o2");
|
||||
Assert.Equal("MS250O2.lib", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_ThicknessWithinTolerance()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("Mild Steel", 0.2505, "O2");
|
||||
Assert.Equal("MS250O2.lib", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_ThicknessOutsideTolerance_ReturnsEmpty()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("Mild Steel", 0.260, "O2");
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("Titanium", 0.250, "O2");
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_WrongGas_ReturnsEmpty()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "AIR");
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_DifferentGasSameMaterial()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var o2 = resolver.ResolveCutLibrary("Mild Steel", 0.250, "O2");
|
||||
var n2 = resolver.ResolveCutLibrary("Mild Steel", 0.250, "N2");
|
||||
Assert.Equal("MS250O2.lib", o2);
|
||||
Assert.Equal("MS250N2.lib", n2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCutLibrary_EmptyList_ReturnsEmpty()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { MaterialLibraries = new() };
|
||||
var resolver = new MaterialLibraryResolver(config);
|
||||
var result = resolver.ResolveCutLibrary("Mild Steel", 0.250, "O2");
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEtchLibrary_ExactMatch()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveEtchLibrary("N2");
|
||||
Assert.Equal("EtchN2.lib", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEtchLibrary_CaseInsensitive()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveEtchLibrary("n2");
|
||||
Assert.Equal("EtchN2.lib", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEtchLibrary_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
var resolver = new MaterialLibraryResolver(ConfigWithLibraries());
|
||||
var result = resolver.ResolveEtchLibrary("Argon");
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEtchLibrary_EmptyList_ReturnsEmpty()
|
||||
{
|
||||
var config = new CincinnatiPostConfig { EtchLibraries = new() };
|
||||
var resolver = new MaterialLibraryResolver(config);
|
||||
var result = resolver.ResolveEtchLibrary("N2");
|
||||
Assert.Equal("", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveGas_UsesNestAssistGas_WhenSet()
|
||||
{
|
||||
var nest = new Nest("Test") { AssistGas = "N2" };
|
||||
var config = new CincinnatiPostConfig { DefaultAssistGas = "O2" };
|
||||
var result = MaterialLibraryResolver.ResolveGas(nest, config);
|
||||
Assert.Equal("N2", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveGas_FallsBackToConfig_WhenNestEmpty()
|
||||
{
|
||||
var nest = new Nest("Test") { AssistGas = "" };
|
||||
var config = new CincinnatiPostConfig { DefaultAssistGas = "O2" };
|
||||
var result = MaterialLibraryResolver.ResolveGas(nest, config);
|
||||
Assert.Equal("O2", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveGas_FallsBackToConfig_WhenNestNull()
|
||||
{
|
||||
var nest = new Nest("Test");
|
||||
var config = new CincinnatiPostConfig { DefaultAssistGas = "AIR" };
|
||||
var result = MaterialLibraryResolver.ResolveGas(nest, config);
|
||||
Assert.Equal("AIR", result);
|
||||
}
|
||||
}
|
||||
71
OpenNest.Tests/Cincinnati/ProgramVariableManagerTests.cs
Normal file
71
OpenNest.Tests/Cincinnati/ProgramVariableManagerTests.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class ProgramVariableManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsNewVariable()
|
||||
{
|
||||
var mgr = new ProgramVariableManager();
|
||||
var v = mgr.GetOrCreate("LeadInFeedrate", 126);
|
||||
Assert.Equal(126, v.Number);
|
||||
Assert.Equal("LeadInFeedrate", v.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_ReturnsSameVariable_WhenCalledTwice()
|
||||
{
|
||||
var mgr = new ProgramVariableManager();
|
||||
var v1 = mgr.GetOrCreate("LeadInFeedrate", 126);
|
||||
var v2 = mgr.GetOrCreate("LeadInFeedrate", 126);
|
||||
Assert.Same(v1, v2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_WithExpression_SetsExpression()
|
||||
{
|
||||
var mgr = new ProgramVariableManager();
|
||||
var v = mgr.GetOrCreate("LeadInFeedrate", 126, "[#148*0.5]");
|
||||
Assert.Equal("[#148*0.5]", v.Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCreate_WithLiteral_SetsExpression()
|
||||
{
|
||||
var mgr = new ProgramVariableManager();
|
||||
var v = mgr.GetOrCreate("CircleFeedrate", 128, ".8");
|
||||
Assert.Equal(".8", v.Expression);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reference_ReturnsHashNumber()
|
||||
{
|
||||
var v = new ProgramVariable(126, "LeadInFeedrate");
|
||||
Assert.Equal("#126", v.Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitDeclarations_ProducesCorrectLines()
|
||||
{
|
||||
var mgr = new ProgramVariableManager();
|
||||
mgr.GetOrCreate("LeadInFeedrate", 126, "[#148*0.5]");
|
||||
mgr.GetOrCreate("CircleFeedrate", 128, ".8");
|
||||
|
||||
var lines = mgr.EmitDeclarations();
|
||||
|
||||
Assert.Contains("#126=[#148*0.5] (LEAD IN FEEDRATE)", lines);
|
||||
Assert.Contains("#128=.8 (CIRCLE FEEDRATE)", lines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmitDeclarations_SkipsVariablesWithNoExpression()
|
||||
{
|
||||
var mgr = new ProgramVariableManager();
|
||||
mgr.GetOrCreate("ProcessFeedrate", 148);
|
||||
|
||||
var lines = mgr.EmitDeclarations();
|
||||
|
||||
Assert.Empty(lines);
|
||||
}
|
||||
}
|
||||
27
OpenNest.Tests/Cincinnati/SpeedClassifierTests.cs
Normal file
27
OpenNest.Tests/Cincinnati/SpeedClassifierTests.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using OpenNest.Posts.Cincinnati;
|
||||
|
||||
namespace OpenNest.Tests.Cincinnati;
|
||||
|
||||
public class SpeedClassifierTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(20.0, 10.0, "FAST")]
|
||||
[InlineData(5.0, 10.0, "FAST")]
|
||||
[InlineData(4.9, 10.0, "MEDIUM")]
|
||||
[InlineData(0.5, 10.0, "SLOW")]
|
||||
public void Classify_ReturnsExpectedClass(double contourLength, double sheetDiagonal, string expected)
|
||||
{
|
||||
var classifier = new SpeedClassifier();
|
||||
Assert.Equal(expected, classifier.Classify(contourLength, sheetDiagonal));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.8702, 3.927, "CutDist=.8702/3.927")]
|
||||
[InlineData(18.9722, 3.927, "CutDist=18.9722/3.927")]
|
||||
[InlineData(0.0, 10.0, "CutDist=0/10")]
|
||||
public void FormatCutDist_IncludesLengthAndDiagonal(double contour, double diag, string expected)
|
||||
{
|
||||
var classifier = new SpeedClassifier();
|
||||
Assert.Equal(expected, classifier.FormatCutDist(contour, diag));
|
||||
}
|
||||
}
|
||||
370
OpenNest.Tests/CutOffGeometryTests.cs
Normal file
370
OpenNest.Tests/CutOffGeometryTests.cs
Normal file
@@ -0,0 +1,370 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class CutOffGeometryTests
|
||||
{
|
||||
private static readonly CutOffSettings ZeroClearance = new() { PartClearance = 0.0 };
|
||||
|
||||
private static double TotalCutLength(Program program, CutOffAxis axis = CutOffAxis.Vertical)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < program.Codes.Count - 1; i += 2)
|
||||
{
|
||||
if (program.Codes[i] is RapidMove rapid &&
|
||||
program.Codes[i + 1] is LinearMove linear)
|
||||
{
|
||||
total += axis == CutOffAxis.Vertical
|
||||
? System.Math.Abs(rapid.EndPoint.Y - linear.EndPoint.Y)
|
||||
: System.Math.Abs(rapid.EndPoint.X - linear.EndPoint.X);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Program MakeSquare(double size)
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return pgm;
|
||||
}
|
||||
|
||||
private static Program MakeCircle(double radius)
|
||||
{
|
||||
// Rapid to (radius, 0) relative to center at (0, 0),
|
||||
// then full-circle arc back to same point.
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(radius, 0)));
|
||||
pgm.Codes.Add(new ArcMove(new Vector(radius, 0), new Vector(0, 0)));
|
||||
return pgm;
|
||||
}
|
||||
|
||||
private static Program MakeDiamond(double halfSize)
|
||||
{
|
||||
// Diamond: points at (half,0), (2*half,half), (half,2*half), (0,half)
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(halfSize, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(halfSize * 2, halfSize)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(halfSize, halfSize * 2)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, halfSize)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(halfSize, 0)));
|
||||
return pgm;
|
||||
}
|
||||
|
||||
private static Program MakeTriangle(double width, double height)
|
||||
{
|
||||
// Right triangle: (0,0) -> (width,0) -> (0,height) -> (0,0)
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(width, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, height)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return pgm;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Square_GeometryExclusionMatchesBoundingBox()
|
||||
{
|
||||
// For a square, geometry and BB should produce the same exclusion.
|
||||
var drawing = new Drawing("sq", MakeSquare(20));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(10, 10);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
// Vertical cut at X=20 (through the middle of the square).
|
||||
// BB exclusion Y = [10, 30]. Geometry should give the same.
|
||||
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance);
|
||||
|
||||
var codes = cutoff.Drawing.Program.Codes;
|
||||
// Two segments: before and after the square → 4 codes
|
||||
Assert.Equal(4, codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Circle_GeometryExclusionNarrowerThanBoundingBox()
|
||||
{
|
||||
// Circle radius=10, center at (10,10) after placement.
|
||||
// BB = (0,0,20,20). Vertical cut at X=2 clips the circle edge.
|
||||
// BB would exclude full Y=[0,20].
|
||||
// Geometry: at X=2, the chord is much narrower.
|
||||
var drawing = new Drawing("circ", MakeCircle(10));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(0, 0);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
|
||||
// Cut at X=2: inside the BB but near the edge of the circle.
|
||||
var cutoff = new CutOff(new Vector(2, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance, cache);
|
||||
|
||||
// The circle chord at X=2 from center (10,0) is much shorter than 20.
|
||||
// With geometry, we get a tighter exclusion, so the segments should
|
||||
// cover more of the plate than with BB.
|
||||
// Total cut length should be greater than 80 (BB would give 100-20=80)
|
||||
var totalCutLength = TotalCutLength(cutoff.Drawing.Program);
|
||||
Assert.True(totalCutLength > 80, $"Geometry should give more cut length than BB. Got {totalCutLength:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diamond_GeometryExclusionNarrowerThanBoundingBox()
|
||||
{
|
||||
// Diamond half=10 → points at (10,0), (20,10), (10,20), (0,10).
|
||||
// BB = (0,0,20,20).
|
||||
// Vertical cut at X=5: BB excludes Y=[0,20].
|
||||
// Diamond edge at X=5: intersects at Y=5 and Y=15 → exclusion [5,15].
|
||||
var drawing = new Drawing("dia", MakeDiamond(10));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(0, 0);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var cutoff = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance, cache);
|
||||
|
||||
// BB would exclude full 20 → cut length = 80.
|
||||
// Geometry excludes only 10 → cut length = 90.
|
||||
var totalCutLength = TotalCutLength(cutoff.Drawing.Program);
|
||||
Assert.True(totalCutLength > 85, $"Diamond geometry should give more cut than BB. Got {totalCutLength:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Triangle_AsymmetricExclusion()
|
||||
{
|
||||
// Right triangle: (0,0)→(30,0)→(0,30)→(0,0) placed at (10,10).
|
||||
// Vertical cut at X=20 (10 into the triangle from left).
|
||||
// The hypotenuse from (40,10) to (10,40): at X=20, Y = 30.
|
||||
// So geometry exclusion should be roughly [10, 30], not [10, 40] like BB.
|
||||
var drawing = new Drawing("tri", MakeTriangle(30, 30));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(10, 10);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance, cache);
|
||||
|
||||
// BB would exclude [10,40] = 30 → cut = 70.
|
||||
// Geometry excludes [10,30] = 20 → cut = 80.
|
||||
var totalCutLength = TotalCutLength(cutoff.Drawing.Program);
|
||||
Assert.True(totalCutLength > 75, $"Triangle geometry should give more cut than BB. Got {totalCutLength:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutLineMissesPart_NoExclusion()
|
||||
{
|
||||
var drawing = new Drawing("sq", MakeSquare(10));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(50, 50);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
// Vertical cut at X=5: well outside the part at X=[50,60].
|
||||
var cutoff = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance);
|
||||
|
||||
// Single full-length segment → 2 codes
|
||||
Assert.Equal(2, cutoff.Drawing.Program.Codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalCut_Circle_UsesGeometry()
|
||||
{
|
||||
var drawing = new Drawing("circ", MakeCircle(10));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(0, 0);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
|
||||
// Horizontal cut at Y=2: near the edge of the circle.
|
||||
var cutoff = new CutOff(new Vector(0, 2), CutOffAxis.Horizontal);
|
||||
cutoff.Regenerate(plate, ZeroClearance, cache);
|
||||
|
||||
// BB would exclude X=[0,20] → cut = 80.
|
||||
// Circle chord at Y=2 is much shorter → cut > 80.
|
||||
var totalCutLength = TotalCutLength(cutoff.Drawing.Program, CutOffAxis.Horizontal);
|
||||
Assert.True(totalCutLength > 80, $"Circle horizontal cut should use geometry. Got {totalCutLength:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clearance_ExpandsGeometryExclusion()
|
||||
{
|
||||
var drawing = new Drawing("sq", MakeSquare(20));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(10, 10);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var settings = new CutOffSettings { PartClearance = 5.0 };
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, settings, cache);
|
||||
|
||||
// Square at Y=[10,30]. With 5 clearance → exclusion [5,35].
|
||||
// Segments: [0,5] and [35,100] → 4 codes.
|
||||
Assert.Equal(4, cutoff.Drawing.Program.Codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPerimeterCache_OpenContourGetsConvexHull()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(new Drawing("open", pgm)));
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
Assert.Single(cache);
|
||||
|
||||
var perimeter = cache[plate.Parts[0]];
|
||||
Assert.NotNull(perimeter);
|
||||
Assert.IsType<Polygon>(perimeter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullCache_FallsBackToBoundingBox()
|
||||
{
|
||||
// Without a cache, should still work (using BB fallback).
|
||||
var drawing = new Drawing("sq", MakeSquare(20));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(10, 10);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance, null);
|
||||
|
||||
Assert.True(cutoff.Drawing.Program.Codes.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleParts_IndependentExclusions()
|
||||
{
|
||||
var plate = new Plate(100, 100);
|
||||
|
||||
var sq1 = new Drawing("sq1", MakeSquare(10));
|
||||
var p1 = Part.CreateAtOrigin(sq1);
|
||||
p1.Location = new Vector(10, 10);
|
||||
plate.Parts.Add(p1);
|
||||
|
||||
var sq2 = new Drawing("sq2", MakeSquare(10));
|
||||
var p2 = Part.CreateAtOrigin(sq2);
|
||||
p2.Location = new Vector(10, 50);
|
||||
plate.Parts.Add(p2);
|
||||
|
||||
// Vertical cut at X=15 crosses both parts.
|
||||
var cutoff = new CutOff(new Vector(15, 0), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, ZeroClearance);
|
||||
|
||||
// 3 segments: before p1, between p1 and p2, after p2 → 6 codes
|
||||
Assert.Equal(6, cutoff.Drawing.Program.Codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectPoints_LinesAndArcs_ReturnsAllPoints()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
{
|
||||
new Line(new Vector(0, 0), new Vector(10, 0)),
|
||||
new Arc(new Vector(5, 5), 5, 0, System.Math.PI)
|
||||
};
|
||||
|
||||
var points = entities.CollectPoints();
|
||||
|
||||
// Line: 2 points. Arc: 2 endpoints + 4 cardinals = 6. Total = 8.
|
||||
Assert.Equal(8, points.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatePerimeterCache_ReturnsOneEntryPerPart()
|
||||
{
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(new Drawing("a", MakeSquare(10))));
|
||||
plate.Parts.Add(new Part(new Drawing("b", MakeCircle(5))));
|
||||
plate.Parts.Add(new Part(new Drawing("c", MakeDiamond(8))));
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
Assert.Equal(3, cache.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlatePerimeterCache_SkipsCutOffParts()
|
||||
{
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(new Drawing("real", MakeSquare(10))));
|
||||
plate.Parts.Add(new Part(new Drawing("cutoff", new Program()) { IsCutOff = true }));
|
||||
|
||||
var cache = Plate.BuildPerimeterCache(plate);
|
||||
Assert.Single(cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegenerateCutOffs_UsesGeometryExclusions()
|
||||
{
|
||||
// Circle radius=10 at origin. Vertical cut at X=2.
|
||||
// With geometry: tighter exclusion than BB.
|
||||
var drawing = new Drawing("circ", MakeCircle(10));
|
||||
var plate = new Plate(100, 100);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
var cutoff = new CutOff(new Vector(2, 0), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(cutoff);
|
||||
plate.RegenerateCutOffs(new CutOffSettings { PartClearance = 0 });
|
||||
|
||||
// Find the materialized cut-off part
|
||||
var cutPart = plate.Parts.First(p => p.BaseDrawing.IsCutOff);
|
||||
// BB would give 80 (100 - 20). Geometry should give more.
|
||||
var totalCutLength = TotalCutLength(cutPart.BaseDrawing.Program);
|
||||
Assert.True(totalCutLength > 80, $"RegenerateCutOffs should use geometry. Got {totalCutLength:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShapeProfile_SelectsLargestShapeAsPerimeter()
|
||||
{
|
||||
// Outer square: (5,0)→(25,0)→(25,20)→(5,20)→(5,0)
|
||||
// Inner cutout: (0,5)→(10,5)→(10,15)→(0,15)→(0,5)
|
||||
// The cutout has Left=0, perimeter has Left=5.
|
||||
// Old heuristic would pick the cutout as perimeter.
|
||||
var outer = new Shape();
|
||||
outer.Entities.Add(new Line(new Vector(5, 0), new Vector(25, 0)));
|
||||
outer.Entities.Add(new Line(new Vector(25, 0), new Vector(25, 20)));
|
||||
outer.Entities.Add(new Line(new Vector(25, 20), new Vector(5, 20)));
|
||||
outer.Entities.Add(new Line(new Vector(5, 20), new Vector(5, 0)));
|
||||
|
||||
var inner = new Shape();
|
||||
inner.Entities.Add(new Line(new Vector(0, 5), new Vector(10, 5)));
|
||||
inner.Entities.Add(new Line(new Vector(10, 5), new Vector(10, 15)));
|
||||
inner.Entities.Add(new Line(new Vector(10, 15), new Vector(0, 15)));
|
||||
inner.Entities.Add(new Line(new Vector(0, 15), new Vector(0, 5)));
|
||||
|
||||
// Combine all entities (simulating what ShapeBuilder.GetShapes would produce)
|
||||
var entities = new List<Entity>();
|
||||
entities.AddRange(inner.Entities); // inner first — worst case for old heuristic
|
||||
entities.AddRange(outer.Entities);
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
// Perimeter should be the outer (larger) shape
|
||||
var bb = profile.Perimeter.BoundingBox;
|
||||
Assert.Equal(20.0, bb.Width, 1);
|
||||
Assert.Equal(20.0, bb.Length, 1);
|
||||
}
|
||||
}
|
||||
119
OpenNest.Tests/CutOffSerializationTests.cs
Normal file
119
OpenNest.Tests/CutOffSerializationTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class CutOffSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void RoundTrip_CutOffsPreserved()
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Name = "test";
|
||||
nest.DateCreated = DateTime.Now;
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
var drawing = new Drawing("part1", pgm);
|
||||
nest.Drawings.Add(drawing);
|
||||
|
||||
var plate = new Plate(100, 50);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
plate.CutOffs.Add(new CutOff(new Vector(62.0, 24.0), CutOffAxis.Vertical));
|
||||
plate.CutOffs.Add(new CutOff(new Vector(48.0, 30.0), CutOffAxis.Horizontal));
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
var writer = new NestWriter(nest);
|
||||
writer.Write(stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var reader = new NestReader(stream);
|
||||
var loaded = reader.Read();
|
||||
|
||||
Assert.Single(loaded.Plates);
|
||||
var loadedPlate = loaded.Plates[0];
|
||||
|
||||
Assert.Equal(2, loadedPlate.CutOffs.Count);
|
||||
Assert.Equal(CutOffAxis.Vertical, loadedPlate.CutOffs[0].Axis);
|
||||
Assert.Equal(62.0, loadedPlate.CutOffs[0].Position.X, 5);
|
||||
Assert.Equal(24.0, loadedPlate.CutOffs[0].Position.Y, 5);
|
||||
Assert.Equal(CutOffAxis.Horizontal, loadedPlate.CutOffs[1].Axis);
|
||||
|
||||
Assert.Single(loadedPlate.Parts.Where(p => !p.BaseDrawing.IsCutOff));
|
||||
Assert.Single(loaded.Drawings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestWriter_SkipsCutOffPartsInPartsList()
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Name = "test";
|
||||
nest.DateCreated = DateTime.Now;
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
var drawing = new Drawing("part1", pgm);
|
||||
nest.Drawings.Add(drawing);
|
||||
|
||||
var plate = new Plate(100, 50);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
plate.CutOffs.Add(new CutOff(new Vector(50, 25), CutOffAxis.Vertical));
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
Assert.Equal(2, plate.Parts.Count);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
var writer = new NestWriter(nest);
|
||||
writer.Write(stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var reader = new NestReader(stream);
|
||||
var loaded = reader.Read();
|
||||
|
||||
Assert.Single(loaded.Plates[0].Parts.Where(p => !p.BaseDrawing.IsCutOff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_LimitsPreserved()
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.Name = "test";
|
||||
nest.DateCreated = DateTime.Now;
|
||||
nest.DateLastModified = DateTime.Now;
|
||||
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
var drawing = new Drawing("part1", pgm);
|
||||
nest.Drawings.Add(drawing);
|
||||
|
||||
var plate = new Plate(100, 50);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
plate.CutOffs.Add(new CutOff(new Vector(85, 30), CutOffAxis.Horizontal) { EndLimit = 85.0 });
|
||||
plate.CutOffs.Add(new CutOff(new Vector(85, 30), CutOffAxis.Vertical) { StartLimit = 30.0 });
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
nest.Plates.Add(plate);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
var writer = new NestWriter(nest);
|
||||
writer.Write(stream);
|
||||
|
||||
stream.Position = 0;
|
||||
var reader = new NestReader(stream);
|
||||
var loaded = reader.Read();
|
||||
|
||||
var loadedPlate = loaded.Plates[0];
|
||||
Assert.Equal(85.0, loadedPlate.CutOffs[0].EndLimit);
|
||||
Assert.Null(loadedPlate.CutOffs[0].StartLimit);
|
||||
Assert.Equal(30.0, loadedPlate.CutOffs[1].StartLimit);
|
||||
Assert.Null(loadedPlate.CutOffs[1].EndLimit);
|
||||
}
|
||||
}
|
||||
266
OpenNest.Tests/CutOffTests.cs
Normal file
266
OpenNest.Tests/CutOffTests.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class CutOffTests
|
||||
{
|
||||
[Fact]
|
||||
public void Drawing_IsCutOff_DefaultsFalse()
|
||||
{
|
||||
var drawing = new Drawing("test", new Program());
|
||||
Assert.False(drawing.IsCutOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plate_CutOffPart_DoesNotIncrementQuantity()
|
||||
{
|
||||
var drawing = new Drawing("cutoff", new Program()) { IsCutOff = true };
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
Assert.Equal(0, drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plate_Utilization_ExcludesCutOffParts()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Geometry.Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(10, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(0, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(0, 0)));
|
||||
var realDrawing = new Drawing("real", pgm);
|
||||
var cutoffDrawing = new Drawing("cutoff", new Program()) { IsCutOff = true };
|
||||
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(realDrawing));
|
||||
plate.Parts.Add(new Part(cutoffDrawing));
|
||||
|
||||
var utilization = plate.Utilization();
|
||||
var expected = realDrawing.Area / plate.Area();
|
||||
Assert.Equal(expected, utilization, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plate_HasOverlappingParts_SkipsCutOffParts()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Geometry.Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(10, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(0, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Geometry.Vector(0, 0)));
|
||||
|
||||
var realDrawing = new Drawing("real", pgm);
|
||||
var cutoffDrawing = new Drawing("cutoff", pgm) { IsCutOff = true };
|
||||
|
||||
var plate = new Plate(100, 100);
|
||||
plate.Parts.Add(new Part(realDrawing));
|
||||
plate.Parts.Add(new Part(cutoffDrawing));
|
||||
|
||||
var hasOverlap = plate.HasOverlappingParts(out var pts);
|
||||
Assert.False(hasOverlap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_VerticalCut_GeneratesFullLineOnEmptyPlate()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var settings = new CutOffSettings();
|
||||
var cutoff = new CutOff(new Vector(25, 20), CutOffAxis.Vertical);
|
||||
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
Assert.NotNull(cutoff.Drawing);
|
||||
Assert.True(cutoff.Drawing.IsCutOff);
|
||||
Assert.True(cutoff.Drawing.Program.Codes.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_HorizontalCut_GeneratesFullLineOnEmptyPlate()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var settings = new CutOffSettings();
|
||||
var cutoff = new CutOff(new Vector(25, 20), CutOffAxis.Horizontal);
|
||||
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
var codes = cutoff.Drawing.Program.Codes;
|
||||
Assert.Equal(2, codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_VerticalCut_TrimsAroundPart()
|
||||
{
|
||||
// Create a 10x10 part at the origin, then move it to (20,20)
|
||||
// so the bounding box is Box(20,20,10,10) and doesn't span the origin.
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("sq", pgm);
|
||||
|
||||
var plate = new Plate(50, 50);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
part.Location = new Vector(20, 20);
|
||||
plate.Parts.Add(part);
|
||||
|
||||
// Vertical cut at X=25 runs along Y from 0 to 50.
|
||||
// Part BB at (20,20,10,10) with clearance 1 → exclusion X=[19,31], Y=[19,31].
|
||||
// X=25 is within [19,31] so exclusion applies: skip Y=[19,31].
|
||||
// Segments: (0, 19) and (31, 50) → 2 segments → 4 codes.
|
||||
var settings = new CutOffSettings { PartClearance = 1.0 };
|
||||
var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
var codes = cutoff.Drawing.Program.Codes;
|
||||
Assert.Equal(4, codes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_ShortSegment_FilteredByMinLength()
|
||||
{
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(20, 0.02)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(30, 0.02)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(30, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(20, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(20, 0.02)));
|
||||
var drawing = new Drawing("sq", pgm);
|
||||
|
||||
var plate = new Plate(50, 50);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
|
||||
var settings = new CutOffSettings { PartClearance = 0.0, MinSegmentLength = 0.05 };
|
||||
var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
var rapidCount = cutoff.Drawing.Program.Codes.Count(c => c is RapidMove);
|
||||
var lineCount = cutoff.Drawing.Program.Codes.Count(c => c is LinearMove);
|
||||
Assert.Equal(rapidCount, lineCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_Overtravel_ExtendsFarEnd()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var settings = new CutOffSettings { Overtravel = 2.0 };
|
||||
var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical);
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
// Plate(100, 50) = Width=100, Length=50. Vertical cut runs along Y (Width axis).
|
||||
// BoundingBox Y extent = Size.Width = 100. With 2" overtravel = 102.
|
||||
// Default AwayFromOrigin: RapidMove to near end (0), LinearMove to far end (102).
|
||||
var linearMoves = cutoff.Drawing.Program.Codes.OfType<LinearMove>().ToList();
|
||||
Assert.Single(linearMoves);
|
||||
Assert.Equal(102.0, linearMoves[0].EndPoint.Y, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_StartLimit_TruncatesNearEnd()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var settings = new CutOffSettings();
|
||||
var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical)
|
||||
{
|
||||
StartLimit = 20.0
|
||||
};
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
// AwayFromOrigin: RapidMove to near end (StartLimit=20), LinearMove to far end (100).
|
||||
var rapidMoves = cutoff.Drawing.Program.Codes.OfType<RapidMove>().ToList();
|
||||
Assert.Single(rapidMoves);
|
||||
Assert.Equal(20.0, rapidMoves[0].EndPoint.Y, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_EndLimit_TruncatesFarEnd()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var settings = new CutOffSettings();
|
||||
var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical)
|
||||
{
|
||||
EndLimit = 80.0
|
||||
};
|
||||
cutoff.Regenerate(plate, settings);
|
||||
|
||||
// AwayFromOrigin: RapidMove to near end (0), LinearMove to far end (EndLimit=80).
|
||||
var linearMoves = cutoff.Drawing.Program.Codes.OfType<LinearMove>().ToList();
|
||||
Assert.Single(linearMoves);
|
||||
Assert.Equal(80.0, linearMoves[0].EndPoint.Y, 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CutOff_BothLimits_LShapedCornerCut()
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var settings = new CutOffSettings { PartClearance = 0 };
|
||||
|
||||
var hCut = new CutOff(new Vector(85, 30), CutOffAxis.Horizontal)
|
||||
{
|
||||
EndLimit = 85.0
|
||||
};
|
||||
hCut.Regenerate(plate, settings);
|
||||
|
||||
var vCut = new CutOff(new Vector(85, 30), CutOffAxis.Vertical)
|
||||
{
|
||||
StartLimit = 30.0
|
||||
};
|
||||
vCut.Regenerate(plate, settings);
|
||||
|
||||
Assert.True(hCut.Drawing.Program.Codes.Count > 0);
|
||||
Assert.True(vCut.Drawing.Program.Codes.Count > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plate_RegenerateCutOffs_MaterializesParts()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var cutoff = new CutOff(new Geometry.Vector(25, 10), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(cutoff);
|
||||
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
|
||||
Assert.Single(plate.Parts);
|
||||
Assert.True(plate.Parts[0].BaseDrawing.IsCutOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plate_RegenerateCutOffs_ReplacesOldParts()
|
||||
{
|
||||
var plate = new Plate(100, 50);
|
||||
var cutoff = new CutOff(new Geometry.Vector(25, 10), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(cutoff);
|
||||
|
||||
var settings = new CutOffSettings();
|
||||
plate.RegenerateCutOffs(settings);
|
||||
plate.RegenerateCutOffs(settings);
|
||||
|
||||
Assert.Single(plate.Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plate_RegenerateCutOffs_DoesNotAffectRegularParts()
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Geometry.Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Geometry.Vector(5, 5)));
|
||||
var drawing = new Drawing("real", pgm);
|
||||
|
||||
var plate = new Plate(100, 50);
|
||||
plate.Parts.Add(new Part(drawing));
|
||||
|
||||
var cutoff = new CutOff(new Geometry.Vector(25, 10), CutOffAxis.Vertical);
|
||||
plate.CutOffs.Add(cutoff);
|
||||
|
||||
plate.RegenerateCutOffs(new CutOffSettings());
|
||||
|
||||
Assert.Equal(2, plate.Parts.Count);
|
||||
Assert.False(plate.Parts[0].BaseDrawing.IsCutOff);
|
||||
Assert.True(plate.Parts[1].BaseDrawing.IsCutOff);
|
||||
}
|
||||
}
|
||||
173
OpenNest.Tests/FillComparerTests.cs
Normal file
173
OpenNest.Tests/FillComparerTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class DefaultFillComparerTests
|
||||
{
|
||||
private readonly IFillComparer comparer = new DefaultFillComparer();
|
||||
private readonly Box workArea = new(0, 0, 100, 100);
|
||||
|
||||
[Fact]
|
||||
public void NullCandidate_ReturnsFalse()
|
||||
{
|
||||
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
Assert.False(comparer.IsBetter(null, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyCandidate_ReturnsFalse()
|
||||
{
|
||||
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
Assert.False(comparer.IsBetter(new List<Part>(), current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullCurrent_ReturnsTrue()
|
||||
{
|
||||
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
Assert.True(comparer.IsBetter(candidate, null, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HigherCount_Wins()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(20, 0, 10),
|
||||
TestHelpers.MakePartAt(40, 0, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(20, 0, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCount_HigherDensityWins()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(12, 0, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(50, 0, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
}
|
||||
|
||||
public class VerticalRemnantComparerTests
|
||||
{
|
||||
private readonly IFillComparer comparer = new VerticalRemnantComparer();
|
||||
private readonly Box workArea = new(0, 0, 100, 100);
|
||||
|
||||
[Fact]
|
||||
public void HigherCount_WinsRegardlessOfExtent()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(40, 0, 10),
|
||||
TestHelpers.MakePartAt(80, 0, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(12, 0, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCount_SmallerXExtent_Wins()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(12, 0, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(50, 0, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCount_SameExtent_HigherDensityWins()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(40, 0, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(40, 40, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullCandidate_ReturnsFalse()
|
||||
{
|
||||
var current = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
Assert.False(comparer.IsBetter(null, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullCurrent_ReturnsTrue()
|
||||
{
|
||||
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
Assert.True(comparer.IsBetter(candidate, null, workArea));
|
||||
}
|
||||
}
|
||||
|
||||
public class HorizontalRemnantComparerTests
|
||||
{
|
||||
private readonly IFillComparer comparer = new HorizontalRemnantComparer();
|
||||
private readonly Box workArea = new(0, 0, 100, 100);
|
||||
|
||||
[Fact]
|
||||
public void SameCount_SmallerYExtent_Wins()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(0, 12, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(0, 50, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HigherCount_WinsRegardlessOfExtent()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(0, 40, 10),
|
||||
TestHelpers.MakePartAt(0, 80, 10)
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(0, 12, 10)
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
}
|
||||
50
OpenNest.Tests/FillPolicyTests.cs
Normal file
50
OpenNest.Tests/FillPolicyTests.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class FillWithDirectionPreferenceTests
|
||||
{
|
||||
private readonly IFillComparer comparer = new DefaultFillComparer();
|
||||
private readonly Box workArea = new(0, 0, 100, 100);
|
||||
|
||||
[Fact]
|
||||
public void NullPreference_TriesBothDirections_ReturnsBetter()
|
||||
{
|
||||
var hParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(12, 0, 10) };
|
||||
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
|
||||
var result = FillHelpers.FillWithDirectionPreference(
|
||||
dir => dir == NestDirection.Horizontal ? hParts : vParts,
|
||||
null, comparer, workArea);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferredDirection_UsedFirst_WhenProducesResults()
|
||||
{
|
||||
var hParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(12, 0, 10) };
|
||||
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10), TestHelpers.MakePartAt(0, 12, 10), TestHelpers.MakePartAt(0, 24, 10) };
|
||||
|
||||
var result = FillHelpers.FillWithDirectionPreference(
|
||||
dir => dir == NestDirection.Horizontal ? hParts : vParts,
|
||||
NestDirection.Horizontal, comparer, workArea);
|
||||
|
||||
Assert.Equal(2, result.Count); // H has results, so H is returned (preferred)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferredDirection_FallsBack_WhenPreferredReturnsEmpty()
|
||||
{
|
||||
var vParts = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
|
||||
var result = FillHelpers.FillWithDirectionPreference(
|
||||
dir => dir == NestDirection.Horizontal ? new List<Part>() : vParts,
|
||||
NestDirection.Horizontal, comparer, workArea);
|
||||
|
||||
Assert.Equal(1, result.Count); // Falls back to V
|
||||
}
|
||||
}
|
||||
28
OpenNest.Tests/NestPhaseExtensionsTests.cs
Normal file
28
OpenNest.Tests/NestPhaseExtensionsTests.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NestPhaseExtensionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(NestPhase.Linear, "Trying rotations...")]
|
||||
[InlineData(NestPhase.RectBestFit, "Trying best fit...")]
|
||||
[InlineData(NestPhase.Pairs, "Trying pairs...")]
|
||||
[InlineData(NestPhase.Nfp, "Trying NFP...")]
|
||||
[InlineData(NestPhase.Extents, "Trying extents...")]
|
||||
[InlineData(NestPhase.Custom, "Custom")]
|
||||
public void DisplayName_ReturnsDescription(NestPhase phase, string expected)
|
||||
{
|
||||
Assert.Equal(expected, phase.DisplayName());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NestPhase.Linear, "Linear")]
|
||||
[InlineData(NestPhase.RectBestFit, "BestFit")]
|
||||
[InlineData(NestPhase.Pairs, "Pairs")]
|
||||
[InlineData(NestPhase.Nfp, "NFP")]
|
||||
[InlineData(NestPhase.Extents, "Extents")]
|
||||
[InlineData(NestPhase.Custom, "Custom")]
|
||||
public void ShortName_ReturnsShortLabel(NestPhase phase, string expected)
|
||||
{
|
||||
Assert.Equal(expected, phase.ShortName());
|
||||
}
|
||||
}
|
||||
100
OpenNest.Tests/NestProgressTests.cs
Normal file
100
OpenNest.Tests/NestProgressTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class NestProgressTests
|
||||
{
|
||||
[Fact]
|
||||
public void BestPartCount_NullParts_ReturnsZero()
|
||||
{
|
||||
var progress = new NestProgress { BestParts = null };
|
||||
Assert.Equal(0, progress.BestPartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestPartCount_ReturnsBestPartsCount()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(2, progress.BestPartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestDensity_NullParts_ReturnsZero()
|
||||
{
|
||||
var progress = new NestProgress { BestParts = null };
|
||||
Assert.Equal(0, progress.BestDensity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BestDensity_MatchesFillScoreFormula()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(5, 0, 5),
|
||||
};
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var progress = new NestProgress { BestParts = parts, ActiveWorkArea = workArea };
|
||||
Assert.Equal(1.0, progress.BestDensity, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedWidth_ReturnsPartsSpan()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(15, progress.NestedWidth, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedLength_ReturnsPartsSpan()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(0, 10, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(15, progress.NestedLength, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NestedArea_ReturnsSumOfPartAreas()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
var progress = new NestProgress { BestParts = parts };
|
||||
Assert.Equal(50, progress.NestedArea, precision: 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingBestParts_InvalidatesCache()
|
||||
{
|
||||
var parts1 = new List<Part> { TestHelpers.MakePartAt(0, 0, 5) };
|
||||
var parts2 = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 5),
|
||||
TestHelpers.MakePartAt(10, 0, 5),
|
||||
};
|
||||
|
||||
var progress = new NestProgress { BestParts = parts1 };
|
||||
Assert.Equal(1, progress.BestPartCount);
|
||||
Assert.Equal(25, progress.NestedArea, precision: 4);
|
||||
|
||||
progress.BestParts = parts2;
|
||||
Assert.Equal(2, progress.BestPartCount);
|
||||
Assert.Equal(50, progress.NestedArea, precision: 4);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,11 +16,15 @@ public class PairFillerTests
|
||||
return new Drawing("rect", pgm);
|
||||
}
|
||||
|
||||
private static Plate MakePlate(double width, double length, double spacing = 0.5)
|
||||
{
|
||||
return new Plate { Size = new Size(width, length), PartSpacing = spacing };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_ReturnsPartsForSimpleDrawing()
|
||||
{
|
||||
var plateSize = new Size(120, 60);
|
||||
var filler = new PairFiller(plateSize, 0.5);
|
||||
var filler = new PairFiller(MakePlate(120, 60));
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
@@ -33,8 +37,7 @@ public class PairFillerTests
|
||||
[Fact]
|
||||
public void Fill_EmptyResult_WhenPartTooLarge()
|
||||
{
|
||||
var plateSize = new Size(10, 10);
|
||||
var filler = new PairFiller(plateSize, 0.5);
|
||||
var filler = new PairFiller(MakePlate(10, 10));
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
|
||||
var workArea = new Box(0, 0, 10, 10);
|
||||
|
||||
@@ -50,8 +53,7 @@ public class PairFillerTests
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var plateSize = new Size(120, 60);
|
||||
var filler = new PairFiller(plateSize, 0.5);
|
||||
var filler = new PairFiller(MakePlate(120, 60));
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
|
||||
@@ -36,6 +36,47 @@ public class PolygonHelperTests
|
||||
$"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCWWinding()
|
||||
{
|
||||
// CW winding (standard CNC convention): (0,0)→(0,10)→(10,10)→(10,0)→(0,0)
|
||||
var drawing = TestHelpers.MakeSquareDrawing(10);
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width,
|
||||
$"Inflated width {withSpacing.Polygon.BoundingBox.Width:F3} should be > original {noSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Length > noSpacing.Polygon.BoundingBox.Length,
|
||||
$"Inflated length {withSpacing.Polygon.BoundingBox.Length:F3} should be > original {noSpacing.Polygon.BoundingBox.Length:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCCWWinding()
|
||||
{
|
||||
// CCW winding: (0,0)→(10,0)→(10,10)→(0,10)→(0,0)
|
||||
var pgm = new CNC.Program();
|
||||
pgm.Codes.Add(new CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 10)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 10)));
|
||||
pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("ccw-square", pgm);
|
||||
|
||||
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
|
||||
var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1);
|
||||
|
||||
noSpacing.Polygon.UpdateBounds();
|
||||
withSpacing.Polygon.UpdateBounds();
|
||||
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Width > noSpacing.Polygon.BoundingBox.Width,
|
||||
$"Inflated width {withSpacing.Polygon.BoundingBox.Width:F3} should be > original {noSpacing.Polygon.BoundingBox.Width:F3}");
|
||||
Assert.True(withSpacing.Polygon.BoundingBox.Length > noSpacing.Polygon.BoundingBox.Length,
|
||||
$"Inflated length {withSpacing.Polygon.BoundingBox.Length:F3} should be > original {noSpacing.Polygon.BoundingBox.Length:F3}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing()
|
||||
{
|
||||
|
||||
92
OpenNest.Tests/RemnantEngineTests.cs
Normal file
92
OpenNest.Tests/RemnantEngineTests.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests;
|
||||
|
||||
public class RemnantEngineTests
|
||||
{
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerticalRemnantEngine_UsesVerticalRemnantComparer()
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var engine = new VerticalRemnantEngine(plate);
|
||||
Assert.Equal("Vertical Remnant", engine.Name);
|
||||
Assert.Equal(NestDirection.Horizontal, engine.PreferredDirection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalRemnantEngine_UsesHorizontalRemnantComparer()
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var engine = new HorizontalRemnantEngine(plate);
|
||||
Assert.Equal("Horizontal Remnant", engine.Name);
|
||||
Assert.Equal(NestDirection.Vertical, engine.PreferredDirection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerticalRemnantEngine_Fill_ProducesResults()
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var engine = new VerticalRemnantEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(parts.Count > 0, "VerticalRemnantEngine should fill parts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HorizontalRemnantEngine_Fill_ProducesResults()
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var engine = new HorizontalRemnantEngine(plate);
|
||||
var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
|
||||
|
||||
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(parts.Count > 0, "HorizontalRemnantEngine should fill parts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_ContainsBothRemnantEngines()
|
||||
{
|
||||
var names = NestEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
|
||||
Assert.Contains("Vertical Remnant", names);
|
||||
Assert.Contains("Horizontal Remnant", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerticalRemnantEngine_ProducesTighterXExtent_ThanDefault()
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
|
||||
var defaultEngine = new DefaultNestEngine(plate);
|
||||
var remnantEngine = new VerticalRemnantEngine(plate);
|
||||
|
||||
var defaultParts = defaultEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
var remnantParts = remnantEngine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(defaultParts.Count > 0);
|
||||
Assert.True(remnantParts.Count > 0);
|
||||
|
||||
var defaultXExtent = defaultParts.Max(p => p.BoundingBox.Right) - defaultParts.Min(p => p.BoundingBox.Left);
|
||||
var remnantXExtent = remnantParts.Max(p => p.BoundingBox.Right) - remnantParts.Min(p => p.BoundingBox.Left);
|
||||
|
||||
Assert.True(remnantXExtent <= defaultXExtent + 0.01,
|
||||
$"Remnant X-extent ({remnantXExtent:F1}) should be <= default ({defaultXExtent:F1})");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ public class EdgeStartSequencerTests
|
||||
{
|
||||
var plate = new Plate(60, 120);
|
||||
var edgePart = MakePartAt(1, 1);
|
||||
var centerPart = MakePartAt(25, 55);
|
||||
var centerPart = MakePartAt(25, 25);
|
||||
var midPart = MakePartAt(10, 10);
|
||||
plate.Parts.Add(edgePart);
|
||||
plate.Parts.Add(centerPart);
|
||||
|
||||
@@ -30,7 +30,7 @@ public class ShrinkFillerTests
|
||||
return engine.Fill(ni, b, null, System.Threading.CancellationToken.None);
|
||||
};
|
||||
|
||||
var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height);
|
||||
var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Length);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Parts.Count > 0);
|
||||
@@ -73,7 +73,7 @@ public class ShrinkFillerTests
|
||||
new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
|
||||
var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0,
|
||||
ShrinkAxis.Height, token: cts.Token);
|
||||
ShrinkAxis.Length, token: cts.Token);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Parts.Count > 0);
|
||||
@@ -97,7 +97,7 @@ public class ShrinkFillerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimToCount_Height_KeepsPartsNearestToOrigin()
|
||||
public void TrimToCount_Length_KeepsPartsNearestToOrigin()
|
||||
{
|
||||
var parts = new List<Part>
|
||||
{
|
||||
@@ -107,7 +107,7 @@ public class ShrinkFillerTests
|
||||
TestHelpers.MakePartAt(0, 30, 5), // Top = 35
|
||||
};
|
||||
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Height);
|
||||
var trimmed = ShrinkFiller.TrimToCount(parts, 2, ShrinkAxis.Length);
|
||||
|
||||
Assert.Equal(2, trimmed.Count);
|
||||
Assert.True(trimmed.All(p => p.BoundingBox.Top <= 15));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
@@ -24,8 +25,8 @@ public class FillPipelineTests
|
||||
|
||||
engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
|
||||
|
||||
Assert.True(engine.PhaseResults.Count >= 4,
|
||||
$"Expected phase results from all strategies, got {engine.PhaseResults.Count}");
|
||||
Assert.True(engine.PhaseResults.Count >= FillStrategyRegistry.Strategies.Count,
|
||||
$"Expected phase results from all active strategies, got {engine.PhaseResults.Count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,7 +42,8 @@ public class FillPipelineTests
|
||||
Assert.True(engine.WinnerPhase == NestPhase.Pairs ||
|
||||
engine.WinnerPhase == NestPhase.Linear ||
|
||||
engine.WinnerPhase == NestPhase.RectBestFit ||
|
||||
engine.WinnerPhase == NestPhase.Extents);
|
||||
engine.WinnerPhase == NestPhase.Extents ||
|
||||
engine.WinnerPhase == NestPhase.Custom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using OpenNest.Engine.Strategies;
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
@@ -9,11 +10,13 @@ public class FillStrategyRegistryTests
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
|
||||
Assert.True(strategies.Count >= 4, $"Expected at least 4 built-in strategies, got {strategies.Count}");
|
||||
Assert.True(strategies.Count >= 6, $"Expected at least 6 built-in strategies, got {strategies.Count}");
|
||||
Assert.Contains(strategies, s => s.Name == "Pairs");
|
||||
Assert.Contains(strategies, s => s.Name == "RectBestFit");
|
||||
Assert.Contains(strategies, s => s.Name == "Extents");
|
||||
Assert.Contains(strategies, s => s.Name == "Linear");
|
||||
Assert.Contains(strategies, s => s.Name == "Row");
|
||||
Assert.Contains(strategies, s => s.Name == "Column");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -34,4 +37,19 @@ public class FillStrategyRegistryTests
|
||||
|
||||
Assert.Equal("Linear", last.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_RowAndColumnOrderedBetweenPairsAndRectBestFit()
|
||||
{
|
||||
var strategies = FillStrategyRegistry.Strategies;
|
||||
var pairsOrder = strategies.First(s => s.Name == "Pairs").Order;
|
||||
var rectOrder = strategies.First(s => s.Name == "RectBestFit").Order;
|
||||
var rowOrder = strategies.First(s => s.Name == "Row").Order;
|
||||
var colOrder = strategies.First(s => s.Name == "Column").Order;
|
||||
|
||||
Assert.True(rowOrder > pairsOrder, "Row should run after Pairs");
|
||||
Assert.True(colOrder > pairsOrder, "Column should run after Pairs");
|
||||
Assert.True(rowOrder < rectOrder, "Row should run before RectBestFit");
|
||||
Assert.True(colOrder < rectOrder, "Column should run before RectBestFit");
|
||||
}
|
||||
}
|
||||
|
||||
217
OpenNest.Tests/Strategies/StripeFillerTests.cs
Normal file
217
OpenNest.Tests/Strategies/StripeFillerTests.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.Strategies;
|
||||
|
||||
public class StripeFillerTests
|
||||
{
|
||||
private static Drawing MakeRectDrawing(double w, double h, string name = "rect")
|
||||
{
|
||||
var pgm = new OpenNest.CNC.Program();
|
||||
pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h)));
|
||||
pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
|
||||
return new Drawing(name, pgm);
|
||||
}
|
||||
|
||||
private static Pattern MakeRectPattern(double w, double h)
|
||||
{
|
||||
var drawing = MakeRectDrawing(w, h);
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
var pattern = new Pattern();
|
||||
pattern.Parts.Add(part);
|
||||
pattern.UpdateBounds();
|
||||
return pattern;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a simple side-by-side pair BestFitResult for a rectangular drawing.
|
||||
/// Places two copies next to each other along the X axis with the given spacing.
|
||||
/// </summary>
|
||||
private static List<BestFitResult> MakeSideBySideBestFits(
|
||||
Drawing drawing, double spacing)
|
||||
{
|
||||
var bb = drawing.Program.BoundingBox();
|
||||
var w = bb.Width;
|
||||
var h = bb.Length;
|
||||
|
||||
var candidate = new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = 0,
|
||||
Part2Rotation = 0,
|
||||
Part2Offset = new Vector(w + spacing, 0),
|
||||
Spacing = spacing,
|
||||
};
|
||||
|
||||
var pairWidth = 2 * w + spacing;
|
||||
var result = new BestFitResult
|
||||
{
|
||||
Candidate = candidate,
|
||||
BoundingWidth = pairWidth,
|
||||
BoundingHeight = h,
|
||||
RotatedArea = pairWidth * h,
|
||||
TrueArea = 2 * w * h,
|
||||
OptimalRotation = 0,
|
||||
Keep = true,
|
||||
Reason = "Valid",
|
||||
HullAngles = new List<double>(),
|
||||
};
|
||||
|
||||
return new List<BestFitResult> { result };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindAngleForTargetSpan_ZeroAngle_WhenAlreadyMatches()
|
||||
{
|
||||
var pattern = MakeRectPattern(20, 10);
|
||||
var angle = StripeFiller.FindAngleForTargetSpan(
|
||||
pattern.Parts, 20.0, NestDirection.Horizontal);
|
||||
|
||||
Assert.True(System.Math.Abs(angle) < 0.05,
|
||||
$"Expected angle near 0, got {OpenNest.Math.Angle.ToDegrees(angle):F1}°");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindAngleForTargetSpan_FindsLargerSpan()
|
||||
{
|
||||
var pattern = MakeRectPattern(20, 10);
|
||||
var angle = StripeFiller.FindAngleForTargetSpan(
|
||||
pattern.Parts, 22.0, NestDirection.Horizontal);
|
||||
|
||||
var rotated = FillHelpers.BuildRotatedPattern(pattern.Parts, angle);
|
||||
var span = rotated.BoundingBox.Width;
|
||||
Assert.True(System.Math.Abs(span - 22.0) < 0.5,
|
||||
$"Expected span ~22, got {span:F2} at {OpenNest.Math.Angle.ToDegrees(angle):F1}°");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindAngleForTargetSpan_ReturnsClosest_WhenUnreachable()
|
||||
{
|
||||
var pattern = MakeRectPattern(20, 10);
|
||||
var angle = StripeFiller.FindAngleForTargetSpan(
|
||||
pattern.Parts, 30.0, NestDirection.Horizontal);
|
||||
|
||||
Assert.True(angle >= 0 && angle <= System.Math.PI / 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvergeStripeAngle_ReducesWaste()
|
||||
{
|
||||
var pattern = MakeRectPattern(20, 10);
|
||||
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
|
||||
pattern.Parts, 120.0, 0.5, NestDirection.Horizontal);
|
||||
|
||||
Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}");
|
||||
Assert.True(waste < 18.0, $"Expected waste < 18, got {waste:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvergeStripeAngle_HandlesExactFit()
|
||||
{
|
||||
// 10x5 pattern: short side (5) oriented along axis, so more pairs fit
|
||||
var pattern = MakeRectPattern(10, 5);
|
||||
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
|
||||
pattern.Parts, 100.0, 0.0, NestDirection.Horizontal);
|
||||
|
||||
Assert.True(count >= 10, $"Expected at least 10 pairs, got {count}");
|
||||
Assert.True(waste < 1.0, $"Expected low waste, got {waste:F2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvergeStripeAngle_Vertical()
|
||||
{
|
||||
var pattern = MakeRectPattern(10, 20);
|
||||
var (angle, waste, count) = StripeFiller.ConvergeStripeAngle(
|
||||
pattern.Parts, 120.0, 0.5, NestDirection.Vertical);
|
||||
|
||||
Assert.True(count >= 5, $"Expected at least 5 pairs, got {count}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_ProducesPartsForSimpleDrawing()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
var bestFits = MakeSideBySideBestFits(drawing, 0.5);
|
||||
|
||||
var context = new OpenNest.Engine.Strategies.FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = plate,
|
||||
PlateNumber = 0,
|
||||
Token = System.Threading.CancellationToken.None,
|
||||
Progress = null,
|
||||
};
|
||||
context.SharedState["BestFits"] = bestFits;
|
||||
|
||||
var filler = new StripeFiller(context, NestDirection.Horizontal);
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.True(parts.Count > 0, "Expected parts from stripe fill");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_VerticalProducesParts()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
var bestFits = MakeSideBySideBestFits(drawing, 0.5);
|
||||
|
||||
var context = new OpenNest.Engine.Strategies.FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = plate,
|
||||
PlateNumber = 0,
|
||||
Token = System.Threading.CancellationToken.None,
|
||||
Progress = null,
|
||||
};
|
||||
context.SharedState["BestFits"] = bestFits;
|
||||
|
||||
var filler = new StripeFiller(context, NestDirection.Vertical);
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.True(parts.Count > 0, "Expected parts from column fill");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fill_ReturnsEmpty_WhenNoBestFits()
|
||||
{
|
||||
var plate = new Plate(60, 120) { PartSpacing = 0.5 };
|
||||
var drawing = MakeRectDrawing(20, 10);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var workArea = new Box(0, 0, 120, 60);
|
||||
|
||||
var context = new OpenNest.Engine.Strategies.FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = plate,
|
||||
PlateNumber = 0,
|
||||
Token = System.Threading.CancellationToken.None,
|
||||
Progress = null,
|
||||
};
|
||||
context.SharedState["BestFits"] = new List<OpenNest.Engine.BestFit.BestFitResult>();
|
||||
|
||||
var filler = new StripeFiller(context, NestDirection.Horizontal);
|
||||
var parts = filler.Fill();
|
||||
|
||||
Assert.NotNull(parts);
|
||||
Assert.Empty(parts);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ internal static class TestHelpers
|
||||
{
|
||||
public static Part MakePartAt(double x, double y, double size = 1)
|
||||
{
|
||||
// CW winding matches CNC convention (OffsetSide.Left = outward)
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("test", pgm);
|
||||
return new Part(drawing, new Vector(x, y));
|
||||
@@ -27,24 +28,26 @@ internal static class TestHelpers
|
||||
|
||||
public static Drawing MakeSquareDrawing(double size = 10)
|
||||
{
|
||||
// CW winding matches CNC convention (OffsetSide.Left = outward)
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("square", pgm);
|
||||
}
|
||||
|
||||
public static Drawing MakeLShapeDrawing()
|
||||
{
|
||||
// CW winding matches CNC convention (OffsetSide.Left = outward)
|
||||
var pgm = new Program();
|
||||
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 10)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(5, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 5)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
return new Drawing("lshape", pgm);
|
||||
}
|
||||
|
||||
29
OpenNest.sln
29
OpenNest.sln
@@ -1,9 +1,12 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.0.0
|
||||
# Visual Studio Version 18
|
||||
VisualStudioVersion = 18.4.11612.150
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest", "OpenNest\OpenNest.csproj", "{1F1E40E0-5C53-474F-A258-69C9C3FAC15A}"
|
||||
ProjectSection(ProjectDependencies) = postProject
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {FB1B2EB2-9D80-4499-BA93-B4E2F295A532}
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Core", "OpenNest.Core\OpenNest.Core.csproj", "{5A5FDE8D-F8DB-440E-866C-C4807E1686CF}"
|
||||
EndProject
|
||||
@@ -23,6 +26,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Api", "OpenNest.Api\OpenNest.Api.csproj", "{44D2810A-16EF-46A4-859C-B897147D8D3C}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostProcessors", "PostProcessors", "{4052CFAC-1F12-48BE-872D-F503C3B65D7E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -153,8 +160,26 @@ Global
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{44D2810A-16EF-46A4-859C-B897147D8D3C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x64.Build.0 = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -167,6 +167,9 @@ namespace OpenNest.Actions
|
||||
}
|
||||
|
||||
parts.ForEach(p => plateView.Plate.Parts.Add(p.BasePart.Clone() as Part));
|
||||
|
||||
if (plateView.Plate.CutOffs.Count > 0)
|
||||
plateView.Plate.RegenerateCutOffs(plateView.CutOffSettings);
|
||||
}
|
||||
|
||||
private void Fill()
|
||||
@@ -184,7 +187,25 @@ namespace OpenNest.Actions
|
||||
|
||||
var boxes = new List<Box>();
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
boxes.Add(part.BoundingBox.Offset(plate.PartSpacing));
|
||||
}
|
||||
|
||||
var plateBounds = plate.BoundingBox(includeParts: false);
|
||||
foreach (var cutoff in plate.CutOffs)
|
||||
{
|
||||
Box cutoffBox;
|
||||
|
||||
if (cutoff.Axis == CutOffAxis.Vertical)
|
||||
cutoffBox = new Box(cutoff.Position.X, plateBounds.Y, 0, plateBounds.Length);
|
||||
else
|
||||
cutoffBox = new Box(plateBounds.X, cutoff.Position.Y, plateBounds.Width, 0);
|
||||
|
||||
boxes.Add(cutoffBox.Offset(plate.PartSpacing));
|
||||
}
|
||||
|
||||
var pt = plateView.CurrentPoint;
|
||||
var vertical = SpatialQuery.GetLargestBoxVertically(pt, bounds, boxes);
|
||||
|
||||
139
OpenNest/Actions/ActionCutOff.cs
Normal file
139
OpenNest/Actions/ActionCutOff.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Controls;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest.Actions
|
||||
{
|
||||
[DisplayName("Sheet Cut-Off")]
|
||||
public class ActionCutOff : Action
|
||||
{
|
||||
private CutOff previewCutOff;
|
||||
private CutOffSettings settings;
|
||||
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
|
||||
private Dictionary<Part, Entity> perimeterCache;
|
||||
private readonly Timer debounceTimer;
|
||||
private bool regeneratePending;
|
||||
|
||||
public ActionCutOff(PlateView plateView)
|
||||
: base(plateView)
|
||||
{
|
||||
settings = plateView.CutOffSettings;
|
||||
debounceTimer = new Timer { Interval = 16 };
|
||||
debounceTimer.Tick += OnDebounce;
|
||||
ConnectEvents();
|
||||
}
|
||||
|
||||
public override void ConnectEvents()
|
||||
{
|
||||
perimeterCache = Plate.BuildPerimeterCache(plateView.Plate);
|
||||
|
||||
plateView.MouseMove += OnMouseMove;
|
||||
plateView.MouseDown += OnMouseDown;
|
||||
plateView.KeyDown += OnKeyDown;
|
||||
plateView.Paint += OnPaint;
|
||||
}
|
||||
|
||||
public override void DisconnectEvents()
|
||||
{
|
||||
debounceTimer.Stop();
|
||||
debounceTimer.Dispose();
|
||||
plateView.MouseMove -= OnMouseMove;
|
||||
plateView.MouseDown -= OnMouseDown;
|
||||
plateView.KeyDown -= OnKeyDown;
|
||||
plateView.Paint -= OnPaint;
|
||||
|
||||
previewCutOff = null;
|
||||
perimeterCache = null;
|
||||
plateView.Invalidate();
|
||||
}
|
||||
|
||||
public override void CancelAction() { }
|
||||
|
||||
public override bool IsBusy() => false;
|
||||
|
||||
private void OnMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
regeneratePending = true;
|
||||
debounceTimer.Start();
|
||||
}
|
||||
|
||||
private void OnDebounce(object sender, System.EventArgs e)
|
||||
{
|
||||
debounceTimer.Stop();
|
||||
|
||||
if (!regeneratePending)
|
||||
return;
|
||||
|
||||
regeneratePending = false;
|
||||
var pt = plateView.CurrentPoint;
|
||||
previewCutOff = new CutOff(pt, lockedAxis);
|
||||
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
|
||||
plateView.Invalidate();
|
||||
}
|
||||
|
||||
private void OnMouseDown(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (e.Button != MouseButtons.Left)
|
||||
return;
|
||||
|
||||
var pt = plateView.CurrentPoint;
|
||||
var cutoff = new CutOff(pt, lockedAxis);
|
||||
|
||||
plateView.Plate.CutOffs.Add(cutoff);
|
||||
plateView.Plate.RegenerateCutOffs(settings);
|
||||
plateView.Invalidate();
|
||||
}
|
||||
|
||||
private void OnKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode == Keys.Space)
|
||||
{
|
||||
lockedAxis = lockedAxis == CutOffAxis.Vertical
|
||||
? CutOffAxis.Horizontal
|
||||
: CutOffAxis.Vertical;
|
||||
|
||||
if (previewCutOff != null)
|
||||
{
|
||||
previewCutOff = new CutOff(plateView.CurrentPoint, lockedAxis);
|
||||
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
|
||||
plateView.Invalidate();
|
||||
}
|
||||
}
|
||||
else if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
plateView.SetAction(typeof(ActionSelect));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPaint(object sender, PaintEventArgs e)
|
||||
{
|
||||
if (previewCutOff?.Drawing?.Program == null)
|
||||
return;
|
||||
|
||||
var program = previewCutOff.Drawing.Program;
|
||||
if (program.Codes.Count == 0)
|
||||
return;
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(128, 64, 64, 64), 1.5f / plateView.ViewScale)
|
||||
{
|
||||
DashStyle = DashStyle.Dash
|
||||
};
|
||||
|
||||
for (var i = 0; i < program.Codes.Count - 1; i += 2)
|
||||
{
|
||||
if (program.Codes[i] is RapidMove rapid &&
|
||||
program.Codes[i + 1] is LinearMove linear)
|
||||
{
|
||||
var pt1 = plateView.PointWorldToGraph(rapid.EndPoint);
|
||||
var pt2 = plateView.PointWorldToGraph(linear.EndPoint);
|
||||
e.Graphics.DrawLine(pen, pt1, pt2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,12 @@ namespace OpenNest.Actions
|
||||
plateView.Invalidate();
|
||||
status = Status.SetFirstPoint;
|
||||
}
|
||||
else if (plateView.SelectedParts.Count > 0)
|
||||
{
|
||||
// Part drag completed — regenerate cut-off programs
|
||||
if (plateView.Plate.CutOffs.Count > 0)
|
||||
plateView.Plate.RegenerateCutOffs(plateView.CutOffSettings);
|
||||
}
|
||||
}
|
||||
|
||||
private void plateView_Paint(object sender, PaintEventArgs e)
|
||||
|
||||
@@ -157,7 +157,31 @@ namespace OpenNest.Actions
|
||||
public void Update()
|
||||
{
|
||||
foreach (var part in plateView.Plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff)
|
||||
continue;
|
||||
|
||||
boxes.Add(part.BoundingBox.Offset(plateView.Plate.PartSpacing));
|
||||
}
|
||||
|
||||
// Add thin obstacle boxes from cutoff definitions so that
|
||||
// the area selection correctly treats cutoffs as boundaries.
|
||||
// Cutoff Parts have inflated bounding boxes (their programs use
|
||||
// absolute coordinates, causing BoundingBox to span from origin)
|
||||
// so we derive the position directly from the CutOff definition.
|
||||
var plateBounds = plateView.Plate.BoundingBox(includeParts: false);
|
||||
|
||||
foreach (var cutoff in plateView.Plate.CutOffs)
|
||||
{
|
||||
Box cutoffBox;
|
||||
|
||||
if (cutoff.Axis == CutOffAxis.Vertical)
|
||||
cutoffBox = new Box(cutoff.Position.X, plateBounds.Y, 0, plateBounds.Length);
|
||||
else
|
||||
cutoffBox = new Box(plateBounds.X, cutoff.Position.Y, plateBounds.Width, 0);
|
||||
|
||||
boxes.Add(cutoffBox.Offset(plateView.Plate.PartSpacing));
|
||||
}
|
||||
|
||||
Bounds = plateView.Plate.WorkArea();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ namespace OpenNest.Controls
|
||||
|
||||
public bool HideDepletedParts { get; set; }
|
||||
|
||||
public bool HideQuantity { get; set; }
|
||||
|
||||
protected override void OnDrawItem(DrawItemEventArgs e)
|
||||
{
|
||||
if (e.Index >= Items.Count || e.Index <= -1)
|
||||
@@ -38,8 +40,15 @@ namespace OpenNest.Controls
|
||||
if (dwg == null)
|
||||
return;
|
||||
|
||||
var isComplete = dwg.Quantity.Nested > 0 && dwg.Quantity.Remaining == 0;
|
||||
var bgBrush = isComplete ? SystemBrushes.Info : Brushes.White;
|
||||
var isSelected = (e.State & DrawItemState.Selected) != 0;
|
||||
Brush bgBrush;
|
||||
|
||||
if (isSelected)
|
||||
bgBrush = SystemBrushes.Highlight;
|
||||
else if (!HideQuantity && dwg.Quantity.Nested > 0 && dwg.Quantity.Remaining == 0)
|
||||
bgBrush = SystemBrushes.Info;
|
||||
else
|
||||
bgBrush = Brushes.White;
|
||||
|
||||
e.Graphics.FillRectangle(bgBrush, e.Bounds);
|
||||
|
||||
@@ -57,19 +66,32 @@ namespace OpenNest.Controls
|
||||
|
||||
pt.X += imageSize.Width + 10;
|
||||
|
||||
e.Graphics.DrawString(dwg.Name, nameFont, Brushes.Black, pt);
|
||||
var textBrush = isSelected ? SystemBrushes.HighlightText : Brushes.Black;
|
||||
var detailBrush = isSelected ? SystemBrushes.HighlightText : Brushes.Gray;
|
||||
|
||||
e.Graphics.DrawString(dwg.Name, nameFont, textBrush, pt);
|
||||
|
||||
var bounds = dwg.Program.BoundingBox();
|
||||
var text1 = string.Format("{0} of {1} nested", dwg.Quantity.Nested, dwg.Quantity.Required);
|
||||
var text2 = bounds.Size.ToString(4);
|
||||
var text3 = string.Format("{0} sq/{1}", System.Math.Round(dwg.Area, 4), UnitsHelper.GetShortString(Units));
|
||||
|
||||
pt.Y += 22;
|
||||
e.Graphics.DrawString(text1, Font, Brushes.Gray, pt);
|
||||
pt.Y += 18;
|
||||
e.Graphics.DrawString(text2, Font, Brushes.Gray, pt);
|
||||
pt.Y += 18;
|
||||
e.Graphics.DrawString(text3, Font, Brushes.Gray, pt);
|
||||
if (HideQuantity)
|
||||
{
|
||||
pt.Y += 22;
|
||||
e.Graphics.DrawString(text2, Font, detailBrush, pt);
|
||||
pt.Y += 18;
|
||||
e.Graphics.DrawString(text3, Font, detailBrush, pt);
|
||||
}
|
||||
else
|
||||
{
|
||||
var text1 = string.Format("{0} of {1} nested", dwg.Quantity.Nested, dwg.Quantity.Required);
|
||||
pt.Y += 22;
|
||||
e.Graphics.DrawString(text1, Font, detailBrush, pt);
|
||||
pt.Y += 18;
|
||||
e.Graphics.DrawString(text2, Font, detailBrush, pt);
|
||||
pt.Y += 18;
|
||||
e.Graphics.DrawString(text3, Font, detailBrush, pt);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnMouseMove(MouseEventArgs e)
|
||||
|
||||
@@ -59,16 +59,6 @@ namespace OpenNest.Controls
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDisplayName(NestPhase phase)
|
||||
{
|
||||
switch (phase)
|
||||
{
|
||||
case NestPhase.RectBestFit: return "BestFit";
|
||||
case NestPhase.Nfp: return "NFP";
|
||||
default: return phase.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
base.OnPaint(e);
|
||||
@@ -134,7 +124,7 @@ namespace OpenNest.Controls
|
||||
}
|
||||
|
||||
// Label
|
||||
var label = GetDisplayName(phase);
|
||||
var label = phase.ShortName();
|
||||
var font = isVisited || isActive ? BoldLabelFont : LabelFont;
|
||||
var brush = isVisited || isActive ? activeTextBrush : pendingTextBrush;
|
||||
var labelSize = g.MeasureString(label, font);
|
||||
|
||||
@@ -30,6 +30,10 @@ namespace OpenNest.Controls
|
||||
private Plate plate;
|
||||
private Action currentAction;
|
||||
private Action previousAction;
|
||||
private CutOffSettings cutOffSettings = new CutOffSettings();
|
||||
private CutOff selectedCutOff;
|
||||
private bool draggingCutOff;
|
||||
private Dictionary<Part, Geometry.Entity> dragPerimeterCache;
|
||||
protected List<LayoutPart> parts;
|
||||
private List<LayoutPart> stationaryParts = new List<LayoutPart>();
|
||||
private List<LayoutPart> activeParts = new List<LayoutPart>();
|
||||
@@ -134,6 +138,27 @@ namespace OpenNest.Controls
|
||||
|
||||
public bool FillParts { get; set; }
|
||||
|
||||
public CutOffSettings CutOffSettings
|
||||
{
|
||||
get => cutOffSettings;
|
||||
set
|
||||
{
|
||||
cutOffSettings = value;
|
||||
Plate?.RegenerateCutOffs(value);
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public CutOff SelectedCutOff
|
||||
{
|
||||
get => selectedCutOff;
|
||||
set
|
||||
{
|
||||
selectedCutOff = value;
|
||||
Invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public double RotateIncrementAngle { get; set; }
|
||||
|
||||
public double OffsetIncrementDistance { get; set; }
|
||||
@@ -211,6 +236,22 @@ namespace OpenNest.Controls
|
||||
if (e.Button == MouseButtons.Middle)
|
||||
middleMouseDownPoint = e.Location;
|
||||
|
||||
if (e.Button == MouseButtons.Left && currentAction is ActionSelect)
|
||||
{
|
||||
var hitCutOff = GetCutOffAtPoint(CurrentPoint, 5.0 / ViewScale);
|
||||
if (hitCutOff != null)
|
||||
{
|
||||
SelectedCutOff = hitCutOff;
|
||||
draggingCutOff = true;
|
||||
dragPerimeterCache = Plate.BuildPerimeterCache(Plate);
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedCutOff = null;
|
||||
}
|
||||
}
|
||||
|
||||
base.OnMouseDown(e);
|
||||
}
|
||||
|
||||
@@ -228,6 +269,15 @@ namespace OpenNest.Controls
|
||||
}
|
||||
}
|
||||
|
||||
if (draggingCutOff && selectedCutOff != null)
|
||||
{
|
||||
draggingCutOff = false;
|
||||
dragPerimeterCache = null;
|
||||
Plate.RegenerateCutOffs(cutOffSettings);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnMouseUp(e);
|
||||
}
|
||||
|
||||
@@ -284,6 +334,18 @@ namespace OpenNest.Controls
|
||||
|
||||
lastPoint = e.Location;
|
||||
|
||||
if (draggingCutOff && selectedCutOff != null)
|
||||
{
|
||||
if (selectedCutOff.Axis == CutOffAxis.Vertical)
|
||||
selectedCutOff.Position = new Vector(CurrentPoint.X, selectedCutOff.Position.Y);
|
||||
else
|
||||
selectedCutOff.Position = new Vector(selectedCutOff.Position.X, CurrentPoint.Y);
|
||||
|
||||
selectedCutOff.Regenerate(Plate, cutOffSettings, dragPerimeterCache);
|
||||
Invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnMouseMove(e);
|
||||
}
|
||||
|
||||
@@ -300,7 +362,17 @@ namespace OpenNest.Controls
|
||||
switch (e.KeyCode)
|
||||
{
|
||||
case Keys.Delete:
|
||||
RemoveSelectedParts();
|
||||
if (selectedCutOff != null)
|
||||
{
|
||||
Plate.CutOffs.Remove(selectedCutOff);
|
||||
selectedCutOff = null;
|
||||
Plate.RegenerateCutOffs(cutOffSettings);
|
||||
Invalidate();
|
||||
}
|
||||
else
|
||||
{
|
||||
RemoveSelectedParts();
|
||||
}
|
||||
break;
|
||||
|
||||
case Keys.F:
|
||||
@@ -390,6 +462,7 @@ namespace OpenNest.Controls
|
||||
|
||||
DrawPlate(e.Graphics);
|
||||
DrawParts(e.Graphics);
|
||||
DrawCutOffs(e.Graphics);
|
||||
DrawActiveWorkArea(e.Graphics);
|
||||
DrawDebugRemnants(e.Graphics);
|
||||
|
||||
@@ -541,6 +614,59 @@ namespace OpenNest.Controls
|
||||
DrawRapids(g);
|
||||
}
|
||||
|
||||
private void DrawCutOffs(Graphics g)
|
||||
{
|
||||
if (Plate?.CutOffs == null || Plate.CutOffs.Count == 0)
|
||||
return;
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(64, 64, 64), 1.5f);
|
||||
using var selectedPen = new Pen(Color.FromArgb(0, 120, 255), 3.5f);
|
||||
|
||||
foreach (var cutoff in Plate.CutOffs)
|
||||
{
|
||||
var program = cutoff.Drawing?.Program;
|
||||
if (program == null || program.Codes.Count == 0)
|
||||
continue;
|
||||
|
||||
var activePen = cutoff == selectedCutOff ? selectedPen : pen;
|
||||
|
||||
for (var i = 0; i < program.Codes.Count - 1; i += 2)
|
||||
{
|
||||
if (program.Codes[i] is RapidMove rapid &&
|
||||
program.Codes[i + 1] is LinearMove linear)
|
||||
{
|
||||
DrawLine(g, rapid.EndPoint, linear.EndPoint, activePen);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public CutOff GetCutOffAtPoint(Vector point, double tolerance)
|
||||
{
|
||||
if (Plate?.CutOffs == null)
|
||||
return null;
|
||||
|
||||
foreach (var cutoff in Plate.CutOffs)
|
||||
{
|
||||
var program = cutoff.Drawing?.Program;
|
||||
if (program == null)
|
||||
continue;
|
||||
|
||||
for (var i = 0; i < program.Codes.Count - 1; i += 2)
|
||||
{
|
||||
if (program.Codes[i] is RapidMove rapid &&
|
||||
program.Codes[i + 1] is LinearMove linear)
|
||||
{
|
||||
var line = new Geometry.Line(rapid.EndPoint, linear.EndPoint);
|
||||
if (line.ClosestPointTo(point).DistanceTo(point) <= tolerance)
|
||||
return cutoff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void DrawOffsetGeometry(Graphics g)
|
||||
{
|
||||
using (var offsetPen = new Pen(Color.FromArgb(120, 255, 100, 100)))
|
||||
@@ -966,6 +1092,10 @@ namespace OpenNest.Controls
|
||||
if (parts.Count > 0 && (!cts.IsCancellationRequested || progressForm.Accepted))
|
||||
{
|
||||
AcceptPreviewParts(parts);
|
||||
|
||||
if (Plate.CutOffs.Count > 0)
|
||||
Plate.RegenerateCutOffs(cutOffSettings);
|
||||
|
||||
sw.Stop();
|
||||
Status = $"Fill: {parts.Count} parts in {sw.ElapsedMilliseconds} ms";
|
||||
}
|
||||
@@ -1098,24 +1228,20 @@ namespace OpenNest.Controls
|
||||
var bounds = parts.GetBoundingBox();
|
||||
var center = bounds.Center;
|
||||
var anchor = bounds.Location;
|
||||
var rotatedPrograms = new HashSet<Program>();
|
||||
|
||||
for (int i = 0; i < SelectedParts.Count; ++i)
|
||||
for (var i = 0; i < SelectedParts.Count; ++i)
|
||||
{
|
||||
var part = SelectedParts[i];
|
||||
var basePart = part.BasePart;
|
||||
|
||||
if (rotatedPrograms.Add(basePart.Program))
|
||||
basePart.Program.Rotate(angle);
|
||||
|
||||
part.Location = part.Location.Rotate(angle, center);
|
||||
basePart.UpdateBounds();
|
||||
part.BasePart.Rotate(angle, center);
|
||||
}
|
||||
|
||||
var diff = anchor - parts.GetBoundingBox().Location;
|
||||
|
||||
for (int i = 0; i < SelectedParts.Count; ++i)
|
||||
for (var i = 0; i < SelectedParts.Count; ++i)
|
||||
SelectedParts[i].Offset(diff);
|
||||
|
||||
if (Plate.CutOffs.Count > 0)
|
||||
Plate.RegenerateCutOffs(cutOffSettings);
|
||||
}
|
||||
|
||||
protected override void UpdateMatrix()
|
||||
|
||||
259
OpenNest/Forms/BestFitViewerForm.Designer.cs
generated
259
OpenNest/Forms/BestFitViewerForm.Designer.cs
generated
@@ -13,134 +13,157 @@ namespace OpenNest.Forms
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.gridPanel = new System.Windows.Forms.TableLayoutPanel();
|
||||
this.toolbarPanel = new System.Windows.Forms.Panel();
|
||||
this.lblDrawing = new System.Windows.Forms.Label();
|
||||
this.cboDrawing = new System.Windows.Forms.ComboBox();
|
||||
this.navPanel = new System.Windows.Forms.Panel();
|
||||
this.btnPrev = new System.Windows.Forms.Button();
|
||||
this.btnNext = new System.Windows.Forms.Button();
|
||||
this.txtPage = new System.Windows.Forms.TextBox();
|
||||
this.lblPageCount = new System.Windows.Forms.Label();
|
||||
this.toolbarPanel.SuspendLayout();
|
||||
this.navPanel.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
splitContainer = new System.Windows.Forms.SplitContainer();
|
||||
drawingListBox = new OpenNest.Controls.DrawingListBox();
|
||||
gridPanel = new System.Windows.Forms.TableLayoutPanel();
|
||||
navPanel = new System.Windows.Forms.Panel();
|
||||
btnPrev = new System.Windows.Forms.Button();
|
||||
txtPage = new System.Windows.Forms.TextBox();
|
||||
lblPageCount = new System.Windows.Forms.Label();
|
||||
btnNext = new System.Windows.Forms.Button();
|
||||
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
|
||||
splitContainer.Panel1.SuspendLayout();
|
||||
splitContainer.Panel2.SuspendLayout();
|
||||
splitContainer.SuspendLayout();
|
||||
navPanel.SuspendLayout();
|
||||
SuspendLayout();
|
||||
//
|
||||
// splitContainer
|
||||
//
|
||||
splitContainer.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
splitContainer.FixedPanel = System.Windows.Forms.FixedPanel.Panel1;
|
||||
splitContainer.Location = new System.Drawing.Point(0, 0);
|
||||
splitContainer.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
splitContainer.Name = "splitContainer";
|
||||
//
|
||||
// splitContainer.Panel1
|
||||
//
|
||||
splitContainer.Panel1.Controls.Add(drawingListBox);
|
||||
splitContainer.Panel1MinSize = 180;
|
||||
//
|
||||
// splitContainer.Panel2
|
||||
//
|
||||
splitContainer.Panel2.Controls.Add(gridPanel);
|
||||
splitContainer.Panel2.Controls.Add(navPanel);
|
||||
splitContainer.Size = new System.Drawing.Size(792, 486);
|
||||
splitContainer.SplitterDistance = 280;
|
||||
splitContainer.SplitterWidth = 6;
|
||||
splitContainer.TabIndex = 0;
|
||||
//
|
||||
// drawingListBox
|
||||
//
|
||||
drawingListBox.BorderStyle = System.Windows.Forms.BorderStyle.None;
|
||||
drawingListBox.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
drawingListBox.DrawMode = System.Windows.Forms.DrawMode.OwnerDrawVariable;
|
||||
drawingListBox.FormattingEnabled = true;
|
||||
drawingListBox.HideDepletedParts = false;
|
||||
drawingListBox.ItemHeight = 85;
|
||||
drawingListBox.Location = new System.Drawing.Point(0, 0);
|
||||
drawingListBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
drawingListBox.Name = "drawingListBox";
|
||||
drawingListBox.Size = new System.Drawing.Size(280, 486);
|
||||
drawingListBox.TabIndex = 0;
|
||||
drawingListBox.Units = Units.Inches;
|
||||
//
|
||||
// gridPanel
|
||||
//
|
||||
this.gridPanel.ColumnCount = 5;
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.gridPanel.Location = new System.Drawing.Point(0, 32);
|
||||
this.gridPanel.Name = "gridPanel";
|
||||
this.gridPanel.RowCount = 3;
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F));
|
||||
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
||||
this.gridPanel.Size = new System.Drawing.Size(1200, 732);
|
||||
this.gridPanel.TabIndex = 0;
|
||||
//
|
||||
// toolbarPanel
|
||||
//
|
||||
this.toolbarPanel.Controls.Add(this.lblDrawing);
|
||||
this.toolbarPanel.Controls.Add(this.cboDrawing);
|
||||
this.toolbarPanel.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
this.toolbarPanel.Location = new System.Drawing.Point(0, 0);
|
||||
this.toolbarPanel.Name = "toolbarPanel";
|
||||
this.toolbarPanel.Size = new System.Drawing.Size(1200, 32);
|
||||
this.toolbarPanel.TabIndex = 2;
|
||||
//
|
||||
// lblDrawing
|
||||
//
|
||||
this.lblDrawing.Location = new System.Drawing.Point(6, 0);
|
||||
this.lblDrawing.Name = "lblDrawing";
|
||||
this.lblDrawing.Size = new System.Drawing.Size(55, 32);
|
||||
this.lblDrawing.TabIndex = 0;
|
||||
this.lblDrawing.Text = "Drawing:";
|
||||
this.lblDrawing.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
|
||||
//
|
||||
// cboDrawing
|
||||
//
|
||||
this.cboDrawing.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.cboDrawing.Location = new System.Drawing.Point(64, 5);
|
||||
this.cboDrawing.Name = "cboDrawing";
|
||||
this.cboDrawing.Size = new System.Drawing.Size(250, 21);
|
||||
this.cboDrawing.TabIndex = 1;
|
||||
//
|
||||
//
|
||||
gridPanel.ColumnCount = 5;
|
||||
gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
|
||||
gridPanel.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
gridPanel.Location = new System.Drawing.Point(0, 0);
|
||||
gridPanel.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
gridPanel.Name = "gridPanel";
|
||||
gridPanel.RowCount = 3;
|
||||
gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
||||
gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F));
|
||||
gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
||||
gridPanel.Size = new System.Drawing.Size(506, 444);
|
||||
gridPanel.TabIndex = 0;
|
||||
//
|
||||
// navPanel
|
||||
//
|
||||
this.navPanel.Controls.Add(this.btnPrev);
|
||||
this.navPanel.Controls.Add(this.txtPage);
|
||||
this.navPanel.Controls.Add(this.lblPageCount);
|
||||
this.navPanel.Controls.Add(this.btnNext);
|
||||
this.navPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
this.navPanel.Location = new System.Drawing.Point(0, 764);
|
||||
this.navPanel.Name = "navPanel";
|
||||
this.navPanel.Size = new System.Drawing.Size(1200, 36);
|
||||
this.navPanel.TabIndex = 1;
|
||||
//
|
||||
//
|
||||
navPanel.Controls.Add(btnPrev);
|
||||
navPanel.Controls.Add(txtPage);
|
||||
navPanel.Controls.Add(lblPageCount);
|
||||
navPanel.Controls.Add(btnNext);
|
||||
navPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
|
||||
navPanel.Location = new System.Drawing.Point(0, 444);
|
||||
navPanel.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
navPanel.Name = "navPanel";
|
||||
navPanel.Size = new System.Drawing.Size(506, 42);
|
||||
navPanel.TabIndex = 1;
|
||||
//
|
||||
// btnPrev
|
||||
//
|
||||
this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
this.btnPrev.Name = "btnPrev";
|
||||
this.btnPrev.Size = new System.Drawing.Size(80, 28);
|
||||
this.btnPrev.TabIndex = 0;
|
||||
this.btnPrev.Text = "< Prev";
|
||||
this.btnPrev.Click += new System.EventHandler(this.btnPrev_Click);
|
||||
//
|
||||
//
|
||||
btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
btnPrev.Location = new System.Drawing.Point(0, 0);
|
||||
btnPrev.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
btnPrev.Name = "btnPrev";
|
||||
btnPrev.Size = new System.Drawing.Size(93, 32);
|
||||
btnPrev.TabIndex = 0;
|
||||
btnPrev.Text = "< Prev";
|
||||
btnPrev.Click += btnPrev_Click;
|
||||
//
|
||||
// txtPage
|
||||
//
|
||||
this.txtPage.Name = "txtPage";
|
||||
this.txtPage.Size = new System.Drawing.Size(40, 20);
|
||||
this.txtPage.TabIndex = 1;
|
||||
this.txtPage.Text = "1";
|
||||
this.txtPage.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
|
||||
this.txtPage.KeyDown += new System.Windows.Forms.KeyEventHandler(this.txtPage_KeyDown);
|
||||
//
|
||||
//
|
||||
txtPage.Location = new System.Drawing.Point(0, 0);
|
||||
txtPage.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
txtPage.Name = "txtPage";
|
||||
txtPage.Size = new System.Drawing.Size(46, 23);
|
||||
txtPage.TabIndex = 1;
|
||||
txtPage.Text = "1";
|
||||
txtPage.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
|
||||
txtPage.KeyDown += txtPage_KeyDown;
|
||||
//
|
||||
// lblPageCount
|
||||
//
|
||||
this.lblPageCount.Name = "lblPageCount";
|
||||
this.lblPageCount.Size = new System.Drawing.Size(50, 28);
|
||||
this.lblPageCount.TabIndex = 2;
|
||||
this.lblPageCount.Text = "/ 1";
|
||||
this.lblPageCount.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
|
||||
//
|
||||
//
|
||||
lblPageCount.Location = new System.Drawing.Point(0, 0);
|
||||
lblPageCount.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
|
||||
lblPageCount.Name = "lblPageCount";
|
||||
lblPageCount.Size = new System.Drawing.Size(58, 32);
|
||||
lblPageCount.TabIndex = 2;
|
||||
lblPageCount.Text = "/ 1";
|
||||
lblPageCount.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
|
||||
//
|
||||
// btnNext
|
||||
//
|
||||
this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
this.btnNext.Name = "btnNext";
|
||||
this.btnNext.Size = new System.Drawing.Size(80, 28);
|
||||
this.btnNext.TabIndex = 3;
|
||||
this.btnNext.Text = "Next >";
|
||||
this.btnNext.Click += new System.EventHandler(this.btnNext_Click);
|
||||
//
|
||||
//
|
||||
btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
|
||||
btnNext.Location = new System.Drawing.Point(0, 0);
|
||||
btnNext.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
btnNext.Name = "btnNext";
|
||||
btnNext.Size = new System.Drawing.Size(93, 32);
|
||||
btnNext.TabIndex = 3;
|
||||
btnNext.Text = "Next >";
|
||||
btnNext.Click += btnNext_Click;
|
||||
//
|
||||
// BestFitViewerForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(1200, 800);
|
||||
this.Controls.Add(this.gridPanel);
|
||||
this.Controls.Add(this.toolbarPanel);
|
||||
this.Controls.Add(this.navPanel);
|
||||
this.KeyPreview = true;
|
||||
this.Name = "BestFitViewerForm";
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "Best-Fit Viewer";
|
||||
this.WindowState = System.Windows.Forms.FormWindowState.Maximized;
|
||||
this.toolbarPanel.ResumeLayout(false);
|
||||
this.navPanel.ResumeLayout(false);
|
||||
this.navPanel.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
//
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
ClientSize = new System.Drawing.Size(792, 486);
|
||||
Controls.Add(splitContainer);
|
||||
KeyPreview = true;
|
||||
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
|
||||
Name = "BestFitViewerForm";
|
||||
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
Text = "Best-Fit Viewer";
|
||||
WindowState = System.Windows.Forms.FormWindowState.Maximized;
|
||||
splitContainer.Panel1.ResumeLayout(false);
|
||||
splitContainer.Panel2.ResumeLayout(false);
|
||||
((System.ComponentModel.ISupportInitialize)splitContainer).EndInit();
|
||||
splitContainer.ResumeLayout(false);
|
||||
navPanel.ResumeLayout(false);
|
||||
navPanel.PerformLayout();
|
||||
ResumeLayout(false);
|
||||
}
|
||||
|
||||
private System.Windows.Forms.SplitContainer splitContainer;
|
||||
private Controls.DrawingListBox drawingListBox;
|
||||
private System.Windows.Forms.TableLayoutPanel gridPanel;
|
||||
private System.Windows.Forms.Panel toolbarPanel;
|
||||
private System.Windows.Forms.Label lblDrawing;
|
||||
private System.Windows.Forms.ComboBox cboDrawing;
|
||||
private System.Windows.Forms.Panel navPanel;
|
||||
private System.Windows.Forms.Button btnPrev;
|
||||
private System.Windows.Forms.Button btnNext;
|
||||
|
||||
@@ -41,11 +41,12 @@ namespace OpenNest.Forms
|
||||
private int currentPage;
|
||||
private int pageCount;
|
||||
private CancellationTokenSource computeCts;
|
||||
private Label lblLoading;
|
||||
|
||||
public BestFitResult SelectedResult { get; private set; }
|
||||
public Drawing SelectedDrawing => activeDrawing;
|
||||
|
||||
public BestFitViewerForm(DrawingCollection drawings, Plate plate)
|
||||
public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches)
|
||||
{
|
||||
this.drawings = drawings.ToList();
|
||||
this.plate = plate;
|
||||
@@ -53,10 +54,12 @@ namespace OpenNest.Forms
|
||||
DoubleBuffered = true;
|
||||
InitializeComponent();
|
||||
|
||||
drawingListBox.Units = units;
|
||||
drawingListBox.HideQuantity = true;
|
||||
foreach (var d in drawings)
|
||||
cboDrawing.Items.Add(d.Name);
|
||||
cboDrawing.SelectedIndex = 0;
|
||||
cboDrawing.SelectedIndexChanged += cboDrawing_SelectedIndexChanged;
|
||||
drawingListBox.Items.Add(d);
|
||||
drawingListBox.SelectedIndex = 0;
|
||||
drawingListBox.SelectedIndexChanged += drawingListBox_SelectedIndexChanged;
|
||||
|
||||
navPanel.SizeChanged += (s, ev) => CenterNavControls();
|
||||
Shown += BestFitViewerForm_Shown;
|
||||
@@ -93,13 +96,13 @@ namespace OpenNest.Forms
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
private void cboDrawing_SelectedIndexChanged(object sender, EventArgs e)
|
||||
private void drawingListBox_SelectedIndexChanged(object sender, EventArgs e)
|
||||
{
|
||||
var index = cboDrawing.SelectedIndex;
|
||||
if (index < 0 || index >= drawings.Count)
|
||||
var drawing = drawingListBox.SelectedItem as Drawing;
|
||||
if (drawing == null)
|
||||
return;
|
||||
|
||||
activeDrawing = drawings[index];
|
||||
activeDrawing = drawing;
|
||||
LoadResultsAsync();
|
||||
}
|
||||
|
||||
@@ -145,7 +148,6 @@ namespace OpenNest.Forms
|
||||
private void SetLoading(bool loading)
|
||||
{
|
||||
Cursor = loading ? Cursors.WaitCursor : Cursors.Default;
|
||||
cboDrawing.Enabled = !loading;
|
||||
btnPrev.Enabled = !loading;
|
||||
btnNext.Enabled = !loading;
|
||||
txtPage.Enabled = !loading;
|
||||
@@ -155,8 +157,34 @@ namespace OpenNest.Forms
|
||||
Text = "Best-Fit Viewer — Computing...";
|
||||
gridPanel.SuspendLayout();
|
||||
gridPanel.Controls.Clear();
|
||||
lblLoading = null;
|
||||
EnsureLoadingLabel();
|
||||
lblLoading.Text = string.Format("Computing best fits for {0}...", activeDrawing.Name);
|
||||
gridPanel.ResumeLayout(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (lblLoading != null)
|
||||
lblLoading.Visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureLoadingLabel()
|
||||
{
|
||||
if (lblLoading != null)
|
||||
return;
|
||||
|
||||
lblLoading = new Label
|
||||
{
|
||||
AutoSize = false,
|
||||
TextAlign = ContentAlignment.MiddleCenter,
|
||||
ForeColor = Color.Gray,
|
||||
Font = new Font(Font.FontFamily, 14f),
|
||||
Dock = DockStyle.Fill
|
||||
};
|
||||
gridPanel.Controls.Add(lblLoading, 0, 0);
|
||||
gridPanel.SetColumnSpan(lblLoading, Columns);
|
||||
gridPanel.SetRowSpan(lblLoading, Rows);
|
||||
}
|
||||
|
||||
private static ComputeResult ComputeResults(Drawing drawing, double length, double width, double spacing)
|
||||
|
||||
120
OpenNest/Forms/BestFitViewerForm.resx
Normal file
120
OpenNest/Forms/BestFitViewerForm.resx
Normal file
@@ -0,0 +1,120 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -458,6 +458,7 @@ namespace OpenNest.Forms
|
||||
PlateView.ZoomToPlate();
|
||||
PlateView.Refresh();
|
||||
UpdatePlateList();
|
||||
UpdatePlateHeader();
|
||||
}
|
||||
|
||||
public void SelectAllParts()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user