Compare commits
165 Commits
560105f952
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| afbbc9ed79 | |||
| 6071e6fa14 | |||
| afdd386456 | |||
| 2db8c49838 | |||
| 80e8693da3 | |||
| d7eb3ebd7a | |||
| 4404d3a5d0 | |||
| d27dee3db9 | |||
| 7081c7b4d0 | |||
| a6e813bc85 | |||
| 98453243fc | |||
| 64874857a1 | |||
| 5d3fcb2dc8 | |||
| ae9a63b5ce | |||
| 596328148d | |||
| 6cd48a623d | |||
| 42243c7df0 | |||
| 4b10d4801c | |||
| f0bdaa14e6 | |||
| 79ddce346b | |||
| 20777541c0 | |||
| 7c8168b002 | |||
| 203bd4eeea | |||
| 02d15dea9c | |||
| a88937b716 | |||
| 986a0412b1 | |||
| e7f2ee80e2 | |||
| 31063d954d | |||
| fc1fee54cd | |||
| 094b522644 | |||
| 45dea4ec2b | |||
| 743bb25f7b | |||
| a34811bb6d | |||
| 9b460f77e5 | |||
| 85bf779f21 | |||
| 641c1cd461 | |||
| 4a5ed1b9c0 | |||
| c40941ed35 | |||
| d6184fdc8f | |||
| d61ec1747a | |||
| 7b815c9579 | |||
| 5568789902 | |||
| fd93cc9db2 | |||
| 740fd79adc | |||
| e1b6752ede | |||
| 18d9bbadfa | |||
| e27def388f | |||
| 356b989424 | |||
| c6652f7707 | |||
| df008081d1 | |||
| 0a294934ae | |||
| f711a2e4d6 | |||
| a4df4027f1 | |||
| 278bbe54ba | |||
| ca5eb53bc1 | |||
| bbc02f6f3f | |||
| 12173204d1 | |||
| cbabf5e9d1 | |||
| 1aac03c9ef | |||
| f46bcd4e4b | |||
| f29f086080 | |||
| 19001ea5be | |||
| 269746b8a4 | |||
| 35218a7435 | |||
| bd973c5f79 | |||
| d042bd1844 | |||
| ebdd489fdc | |||
| 885dec5f0e | |||
| 6106df929e | |||
| 965b9c8c1a | |||
| 98e90cc176 | |||
| d9005cccc3 | |||
| f208569e72 | |||
| 1ffe904892 | |||
| 4cc8b8f9b7 | |||
| 1f159d5dcc | |||
| f626fbe063 | |||
| d5b5ab57e3 | |||
| 6916f5ecca | |||
| e1bcb7498f | |||
| a7f8972722 | |||
| 6d1a3f5e2c | |||
| 52eca5f5c2 | |||
| 3bce45be5f | |||
| 3f0a4c57b5 | |||
| ededc7b6b4 | |||
| 5f74afeda1 | |||
| 574a8f2c38 | |||
| dd2892a9fe | |||
| 7056f8816f | |||
| c2a470f79c | |||
| 39f8a79cfd | |||
| df18b72881 | |||
| cd8adc97d6 | |||
| ba7aa39941 | |||
| 5d93ddb2c4 | |||
| 15b2043048 | |||
| aa8b6f3d9e | |||
| 3686d074e6 | |||
| 8f1a3fb6b7 | |||
| 60ce297d6a | |||
| addd7acc3c | |||
| d91ffccfa3 | |||
| adb8ed12d7 | |||
| 4acd8b8bad | |||
| d7b095cf2d | |||
| 499e0425b5 | |||
| c2c3e23024 | |||
| 5afb311ac7 | |||
| 765a862440 | |||
| 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 |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"opennest": {
|
||||
"command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe",
|
||||
"args": []
|
||||
"command": "cmd",
|
||||
"args": ["/c", "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/run.cmd"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
CLAUDE.md
13
CLAUDE.md
@@ -30,10 +30,12 @@ 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.
|
||||
- **Splitting** (`Splitting/`, `namespace OpenNest`): `DrawingSplitter` splits a Drawing into multiple pieces along split lines. `ISplitFeature` strategy pattern with implementations: `StraightSplit` (clean edge), `WeldGapTabSplit` (rectangular tab spacers on one side), `SpikeGrooveSplit` (interlocking spike/V-groove pairs). `AutoSplitCalculator` computes split lines for fit-to-plate and split-by-count modes. Supporting types: `SplitLine`, `SplitParameters`, `SplitFeatureResult`.
|
||||
- **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) → `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`.
|
||||
@@ -43,7 +45,7 @@ Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the
|
||||
- **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.
|
||||
@@ -77,15 +79,15 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
|
||||
### OpenNest (WinForms WinExe, depends on Core + Engine + IO)
|
||||
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.
|
||||
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), `SplitDrawingForm` (split oversized drawings into smaller pieces, launched from CadConverterForm), 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)
|
||||
|
||||
@@ -113,3 +115,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).
|
||||
|
||||
@@ -35,7 +35,8 @@ public static class NestRunner
|
||||
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
|
||||
var drawing = new Drawing(name);
|
||||
drawing.Program = pgm;
|
||||
|
||||
@@ -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();
|
||||
@@ -235,7 +255,8 @@ static class NestConsole
|
||||
return null;
|
||||
}
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||
|
||||
if (pgm == null)
|
||||
{
|
||||
@@ -382,6 +403,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 +522,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 +544,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;
|
||||
}
|
||||
}
|
||||
|
||||
96
OpenNest.Core/Bending/Bend.cs
Normal file
96
OpenNest.Core/Bending/Bend.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
namespace OpenNest.Bending
|
||||
{
|
||||
public class Bend
|
||||
{
|
||||
public static readonly Layer EtchLayer = new Layer("ETCH")
|
||||
{
|
||||
Color = Color.Green,
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
private const double DefaultEtchLength = 1.0;
|
||||
|
||||
public Vector StartPoint { get; set; }
|
||||
public Vector EndPoint { get; set; }
|
||||
public BendDirection Direction { get; set; }
|
||||
public double? Angle { get; set; }
|
||||
public double? Radius { get; set; }
|
||||
public string NoteText { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Entity SourceEntity { get; set; }
|
||||
|
||||
public double Length => StartPoint.DistanceTo(EndPoint);
|
||||
|
||||
public double AngleRadians => Angle.HasValue
|
||||
? OpenNest.Math.Angle.ToRadians(Angle.Value)
|
||||
: 0;
|
||||
|
||||
public Line ToLine() => new Line(StartPoint, EndPoint);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the angle of the bend line itself (not the bend angle).
|
||||
/// Used for grain direction comparison.
|
||||
/// </summary>
|
||||
public double LineAngle => StartPoint.AngleTo(EndPoint);
|
||||
|
||||
/// <summary>
|
||||
/// Generates etch mark entities for this bend (up bends only).
|
||||
/// Returns 1" dashes at each end of the bend line, or the full line if shorter than 3".
|
||||
/// </summary>
|
||||
public List<Line> GetEtchEntities(double etchLength = DefaultEtchLength)
|
||||
{
|
||||
var result = new List<Line>();
|
||||
if (Direction != BendDirection.Up)
|
||||
return result;
|
||||
|
||||
var length = Length;
|
||||
|
||||
if (length < etchLength * 3.0)
|
||||
{
|
||||
result.Add(CreateEtchLine(StartPoint, EndPoint));
|
||||
}
|
||||
else
|
||||
{
|
||||
var angle = StartPoint.AngleTo(EndPoint);
|
||||
var dx = System.Math.Cos(angle) * etchLength;
|
||||
var dy = System.Math.Sin(angle) * etchLength;
|
||||
|
||||
result.Add(CreateEtchLine(StartPoint, new Vector(StartPoint.X + dx, StartPoint.Y + dy)));
|
||||
result.Add(CreateEtchLine(new Vector(EndPoint.X - dx, EndPoint.Y - dy), EndPoint));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes existing etch entities from the list and regenerates from the given bends.
|
||||
/// </summary>
|
||||
public static void UpdateEtchEntities(List<Entity> entities, List<Bend> bends)
|
||||
{
|
||||
entities.RemoveAll(e => e.Layer == EtchLayer);
|
||||
if (bends == null) return;
|
||||
|
||||
foreach (var bend in bends)
|
||||
entities.AddRange(bend.GetEtchEntities());
|
||||
}
|
||||
|
||||
private static Line CreateEtchLine(Vector start, Vector end)
|
||||
{
|
||||
return new Line(start, end) { Layer = EtchLayer, Color = Color.Green };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var dir = Direction.ToString();
|
||||
var angle = Angle?.ToString("0.##") ?? "?";
|
||||
var radius = Radius?.ToString("0.###") ?? "?";
|
||||
return $"{dir} {angle}° R{radius}";
|
||||
}
|
||||
}
|
||||
}
|
||||
9
OpenNest.Core/Bending/BendDirection.cs
Normal file
9
OpenNest.Core/Bending/BendDirection.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Bending
|
||||
{
|
||||
public enum BendDirection
|
||||
{
|
||||
Unknown,
|
||||
Up,
|
||||
Down
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,10 @@ namespace OpenNest.Converters
|
||||
if (line.StartPoint != lastpt)
|
||||
pgm.MoveTo(line.StartPoint);
|
||||
|
||||
pgm.LineTo(line.EndPoint);
|
||||
var move = new LinearMove(line.EndPoint);
|
||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||
move.Layer = LayerType.Scribe;
|
||||
pgm.Codes.Add(move);
|
||||
|
||||
lastpt = line.EndPoint;
|
||||
return lastpt;
|
||||
|
||||
@@ -59,9 +59,11 @@ namespace OpenNest.Converters
|
||||
if (mode == Mode.Incremental)
|
||||
pt += curpos;
|
||||
|
||||
var layer = ConvertLayer(linearMove.Layer);
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = ConvertLayer(linearMove.Layer)
|
||||
Layer = layer,
|
||||
Color = layer.Color
|
||||
};
|
||||
geometry.Add(line);
|
||||
curpos = pt;
|
||||
@@ -76,7 +78,8 @@ namespace OpenNest.Converters
|
||||
|
||||
var line = new Line(curpos, pt)
|
||||
{
|
||||
Layer = SpecialLayers.Rapid
|
||||
Layer = SpecialLayers.Rapid,
|
||||
Color = SpecialLayers.Rapid.Color
|
||||
};
|
||||
geometry.Add(line);
|
||||
curpos = pt;
|
||||
@@ -103,9 +106,9 @@ namespace OpenNest.Converters
|
||||
var layer = ConvertLayer(arcMove.Layer);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer });
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
|
||||
else
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer });
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
|
||||
|
||||
curpos = endpt;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -56,10 +58,14 @@ namespace OpenNest
|
||||
|
||||
public Color Color { get; set; }
|
||||
|
||||
public bool IsCutOff { get; set; }
|
||||
|
||||
public NestConstraints Constraints { get; set; }
|
||||
|
||||
public SourceInfo Source { get; set; }
|
||||
|
||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
217
OpenNest.Core/Geometry/EllipseConverter.cs
Normal file
217
OpenNest.Core/Geometry/EllipseConverter.cs
Normal file
@@ -0,0 +1,217 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class EllipseConverter
|
||||
{
|
||||
private const int MaxSubdivisionDepth = 12;
|
||||
private const int DeviationSamples = 20;
|
||||
|
||||
internal static Vector EvaluatePoint(double semiMajor, double semiMinor, double rotation, Vector center, double t)
|
||||
{
|
||||
var x = semiMajor * System.Math.Cos(t);
|
||||
var y = semiMinor * System.Math.Sin(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
center.X + x * cos - y * sin,
|
||||
center.Y + x * sin + y * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateTangent(double semiMajor, double semiMinor, double rotation, double t)
|
||||
{
|
||||
var tx = -semiMajor * System.Math.Sin(t);
|
||||
var ty = semiMinor * System.Math.Cos(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
tx * cos - ty * sin,
|
||||
tx * sin + ty * cos);
|
||||
}
|
||||
|
||||
internal static Vector EvaluateNormal(double semiMajor, double semiMinor, double rotation, double t)
|
||||
{
|
||||
// Inward normal: perpendicular to tangent, pointing toward center of curvature.
|
||||
// In local coords: N(t) = (-b*cos(t), -a*sin(t))
|
||||
var nx = -semiMinor * System.Math.Cos(t);
|
||||
var ny = -semiMajor * System.Math.Sin(t);
|
||||
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
|
||||
return new Vector(
|
||||
nx * cos - ny * sin,
|
||||
nx * sin + ny * cos);
|
||||
}
|
||||
|
||||
internal static Vector IntersectNormals(Vector p1, Vector n1, Vector p2, Vector n2)
|
||||
{
|
||||
// Solve: p1 + s*n1 = p2 + t*n2
|
||||
var det = n1.X * (-n2.Y) - (-n2.X) * n1.Y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return Vector.Invalid;
|
||||
|
||||
var dx = p2.X - p1.X;
|
||||
var dy = p2.Y - p1.Y;
|
||||
var s = (dx * (-n2.Y) - dy * (-n2.X)) / det;
|
||||
|
||||
return new Vector(p1.X + s * n1.X, p1.Y + s * n1.Y);
|
||||
}
|
||||
|
||||
public static List<Entity> Convert(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double startParam, double endParam, double tolerance = 0.001)
|
||||
{
|
||||
if (tolerance <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(tolerance), "Tolerance must be positive.");
|
||||
if (semiMajor <= 0 || semiMinor <= 0)
|
||||
throw new ArgumentOutOfRangeException("Semi-axis lengths must be positive.");
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += Angle.TwoPI;
|
||||
|
||||
// True circle — emit a single arc (or two for full circle)
|
||||
if (System.Math.Abs(semiMajor - semiMinor) < Tolerance.Epsilon)
|
||||
return ConvertCircle(center, semiMajor, rotation, startParam, endParam);
|
||||
|
||||
var splits = GetInitialSplits(startParam, endParam);
|
||||
|
||||
var entities = new List<Entity>();
|
||||
for (var i = 0; i < splits.Count - 1; i++)
|
||||
FitSegment(center, semiMajor, semiMinor, rotation,
|
||||
splits[i], splits[i + 1], tolerance, entities, 0);
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> ConvertCircle(Vector center, double radius,
|
||||
double rotation, double startParam, double endParam)
|
||||
{
|
||||
var sweep = endParam - startParam;
|
||||
var isFull = System.Math.Abs(sweep - Angle.TwoPI) < 0.01;
|
||||
|
||||
if (isFull)
|
||||
{
|
||||
var startAngle1 = Angle.NormalizeRad(startParam + rotation);
|
||||
var midAngle = Angle.NormalizeRad(startParam + System.Math.PI + rotation);
|
||||
var endAngle2 = startAngle1;
|
||||
|
||||
return new List<Entity>
|
||||
{
|
||||
new Arc(center, radius, startAngle1, midAngle, false),
|
||||
new Arc(center, radius, midAngle, endAngle2, false)
|
||||
};
|
||||
}
|
||||
|
||||
var sa = Angle.NormalizeRad(startParam + rotation);
|
||||
var ea = Angle.NormalizeRad(endParam + rotation);
|
||||
return new List<Entity> { new Arc(center, radius, sa, ea, false) };
|
||||
}
|
||||
|
||||
private static List<double> GetInitialSplits(double startParam, double endParam)
|
||||
{
|
||||
var splits = new List<double> { startParam };
|
||||
|
||||
var firstQuadrant = System.Math.Ceiling(startParam / (System.Math.PI / 2)) * (System.Math.PI / 2);
|
||||
for (var q = firstQuadrant; q < endParam; q += System.Math.PI / 2)
|
||||
{
|
||||
if (q > startParam + 1e-10 && q < endParam - 1e-10)
|
||||
splits.Add(q);
|
||||
}
|
||||
|
||||
splits.Add(endParam);
|
||||
return splits;
|
||||
}
|
||||
|
||||
private static void FitSegment(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, double tolerance, List<Entity> results, int depth)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, center, t1);
|
||||
|
||||
if (p0.DistanceTo(p1) < 1e-10)
|
||||
return;
|
||||
|
||||
var n0 = EvaluateNormal(semiMajor, semiMinor, rotation, t0);
|
||||
var n1 = EvaluateNormal(semiMajor, semiMinor, rotation, t1);
|
||||
|
||||
var arcCenter = IntersectNormals(p0, n0, p1, n1);
|
||||
|
||||
if (!arcCenter.IsValid() || depth >= MaxSubdivisionDepth)
|
||||
{
|
||||
results.Add(new Line(p0, p1));
|
||||
return;
|
||||
}
|
||||
|
||||
var radius = p0.DistanceTo(arcCenter);
|
||||
var maxDev = MeasureDeviation(center, semiMajor, semiMinor, rotation,
|
||||
t0, t1, arcCenter, radius);
|
||||
|
||||
if (maxDev <= tolerance)
|
||||
{
|
||||
results.Add(CreateArc(arcCenter, radius, center, semiMajor, semiMinor, rotation, t0, t1));
|
||||
}
|
||||
else
|
||||
{
|
||||
var tMid = (t0 + t1) / 2.0;
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, t0, tMid, tolerance, results, depth + 1);
|
||||
FitSegment(center, semiMajor, semiMinor, rotation, tMid, t1, tolerance, results, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static double MeasureDeviation(Vector center, double semiMajor, double semiMinor,
|
||||
double rotation, double t0, double t1, Vector arcCenter, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i <= DeviationSamples; i++)
|
||||
{
|
||||
var t = t0 + (t1 - t0) * i / DeviationSamples;
|
||||
var p = EvaluatePoint(semiMajor, semiMinor, rotation, center, t);
|
||||
var dist = p.DistanceTo(arcCenter);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector arcCenter, double radius,
|
||||
Vector ellipseCenter, double semiMajor, double semiMinor, double rotation,
|
||||
double t0, double t1)
|
||||
{
|
||||
var p0 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t0);
|
||||
var p1 = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, t1);
|
||||
|
||||
var startAngle = System.Math.Atan2(p0.Y - arcCenter.Y, p0.X - arcCenter.X);
|
||||
var endAngle = System.Math.Atan2(p1.Y - arcCenter.Y, p1.X - arcCenter.X);
|
||||
|
||||
var pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
|
||||
var points = new List<Vector> { p0, pMid, p1 };
|
||||
var isReversed = SumSignedAngles(arcCenter, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(arcCenter, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -76,6 +76,9 @@ namespace OpenNest.Geometry
|
||||
if (line1 == line2)
|
||||
return false;
|
||||
|
||||
if (line1.Layer?.Name != line2.Layer?.Name)
|
||||
return false;
|
||||
|
||||
if (!line1.IsCollinearTo(line2))
|
||||
return false;
|
||||
|
||||
@@ -113,9 +116,9 @@ namespace OpenNest.Geometry
|
||||
var b = b1 < b2 ? b1 : b2;
|
||||
|
||||
if (!line1.IsVertical() && line1.Slope() < 0)
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b));
|
||||
lineOut = new Line(new Vector(l, t), new Vector(r, b)) { Layer = line1.Layer, Color = line1.Color };
|
||||
else
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t));
|
||||
lineOut = new Line(new Vector(l, b), new Vector(r, t)) { Layer = line1.Layer, Color = line1.Color };
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -127,28 +130,44 @@ namespace OpenNest.Geometry
|
||||
if (arc1 == arc2)
|
||||
return false;
|
||||
|
||||
if (arc1.Layer?.Name != arc2.Layer?.Name)
|
||||
return false;
|
||||
|
||||
if (arc1.Center != arc2.Center)
|
||||
return false;
|
||||
|
||||
if (!arc1.Radius.IsEqualTo(arc2.Radius))
|
||||
return false;
|
||||
|
||||
if (arc1.StartAngle > arc1.EndAngle)
|
||||
arc1.StartAngle -= Angle.TwoPI;
|
||||
var start1 = arc1.StartAngle;
|
||||
var end1 = arc1.EndAngle;
|
||||
var start2 = arc2.StartAngle;
|
||||
var end2 = arc2.EndAngle;
|
||||
|
||||
if (arc2.StartAngle > arc2.EndAngle)
|
||||
arc2.StartAngle -= Angle.TwoPI;
|
||||
if (start1 > end1)
|
||||
start1 -= Angle.TwoPI;
|
||||
|
||||
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle)
|
||||
if (start2 > end2)
|
||||
start2 -= Angle.TwoPI;
|
||||
|
||||
// Check that arcs are adjacent (endpoints touch), not overlapping
|
||||
var touch1 = end1.IsEqualTo(start2) || (end1 + Angle.TwoPI).IsEqualTo(start2);
|
||||
var touch2 = end2.IsEqualTo(start1) || (end2 + Angle.TwoPI).IsEqualTo(start1);
|
||||
if (!touch1 && !touch2)
|
||||
return false;
|
||||
|
||||
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle;
|
||||
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle;
|
||||
var startAngle = start1 < start2 ? start1 : start2;
|
||||
var endAngle = end1 > end2 ? end1 : end2;
|
||||
|
||||
// Don't merge if the result would be a full circle (start == end)
|
||||
var sweep = endAngle - startAngle;
|
||||
if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
|
||||
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle) { Layer = arc1.Layer, Color = arc1.Color };
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
703
OpenNest.Core/Geometry/GeometrySimplifier.cs
Normal file
703
OpenNest.Core/Geometry/GeometrySimplifier.cs
Normal file
@@ -0,0 +1,703 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry;
|
||||
|
||||
public class ArcCandidate
|
||||
{
|
||||
public int ShapeIndex { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
public int EndIndex { get; set; }
|
||||
public int LineCount => EndIndex - StartIndex + 1;
|
||||
public Arc FittedArc { get; set; }
|
||||
public double MaxDeviation { get; set; }
|
||||
public Box BoundingBox { get; set; }
|
||||
public bool IsSelected { get; set; } = true;
|
||||
/// <summary>First point of the original line segments this candidate covers.</summary>
|
||||
public Vector FirstPoint { get; set; }
|
||||
/// <summary>Last point of the original line segments this candidate covers.</summary>
|
||||
public Vector LastPoint { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A mirror axis defined by a point on the axis and a unit direction vector.
|
||||
/// </summary>
|
||||
public class MirrorAxisResult
|
||||
{
|
||||
public static readonly MirrorAxisResult None = new(Vector.Invalid, Vector.Invalid, 0);
|
||||
|
||||
public Vector Point { get; }
|
||||
public Vector Direction { get; }
|
||||
public double Score { get; }
|
||||
public bool IsValid => Point.IsValid();
|
||||
|
||||
public MirrorAxisResult(Vector point, Vector direction, double score)
|
||||
{
|
||||
Point = point;
|
||||
Direction = direction;
|
||||
Score = score;
|
||||
}
|
||||
|
||||
/// <summary>Reflects a point across this axis.</summary>
|
||||
public Vector Reflect(Vector p)
|
||||
{
|
||||
var dx = p.X - Point.X;
|
||||
var dy = p.Y - Point.Y;
|
||||
var dot = dx * Direction.X + dy * Direction.Y;
|
||||
return new Vector(
|
||||
p.X - 2 * (dx - dot * Direction.X),
|
||||
p.Y - 2 * (dy - dot * Direction.Y));
|
||||
}
|
||||
}
|
||||
|
||||
public class GeometrySimplifier
|
||||
{
|
||||
public double Tolerance { get; set; } = 0.004;
|
||||
public int MinLines { get; set; } = 3;
|
||||
|
||||
public List<ArcCandidate> Analyze(Shape shape)
|
||||
{
|
||||
var candidates = new List<ArcCandidate>();
|
||||
var entities = shape.Entities;
|
||||
var i = 0;
|
||||
|
||||
while (i < entities.Count)
|
||||
{
|
||||
if (entities[i] is not Line and not Arc)
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var runStart = i;
|
||||
var layerName = entities[i].Layer?.Name;
|
||||
var lineCount = 0;
|
||||
while (i < entities.Count && (entities[i] is Line || entities[i] is Arc) && entities[i].Layer?.Name == layerName)
|
||||
{
|
||||
if (entities[i] is Line) lineCount++;
|
||||
i++;
|
||||
}
|
||||
var runEnd = i - 1;
|
||||
|
||||
if (lineCount >= MinLines)
|
||||
FindCandidatesInRun(entities, runStart, runEnd, candidates);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
public Shape Apply(Shape shape, List<ArcCandidate> candidates)
|
||||
{
|
||||
var selected = candidates
|
||||
.Where(c => c.IsSelected)
|
||||
.OrderBy(c => c.StartIndex)
|
||||
.ToList();
|
||||
|
||||
var newEntities = new List<Entity>();
|
||||
var i = 0;
|
||||
|
||||
foreach (var candidate in selected)
|
||||
{
|
||||
while (i < candidate.StartIndex)
|
||||
{
|
||||
newEntities.Add(shape.Entities[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
newEntities.Add(candidate.FittedArc);
|
||||
i = candidate.EndIndex + 1;
|
||||
}
|
||||
|
||||
while (i < shape.Entities.Count)
|
||||
{
|
||||
newEntities.Add(shape.Entities[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
var result = new Shape();
|
||||
result.Entities.AddRange(newEntities);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the mirror axis of a shape by testing candidate axes through the
|
||||
/// centroid. Uses PCA to find principal directions, then also tests horizontal
|
||||
/// and vertical. Works for shapes rotated at any angle.
|
||||
/// </summary>
|
||||
public static MirrorAxisResult DetectMirrorAxis(Shape shape)
|
||||
{
|
||||
var midpoints = new List<Vector>();
|
||||
foreach (var e in shape.Entities)
|
||||
midpoints.Add(e.BoundingBox.Center);
|
||||
|
||||
if (midpoints.Count < 4) return MirrorAxisResult.None;
|
||||
|
||||
// Centroid
|
||||
var cx = 0.0;
|
||||
var cy = 0.0;
|
||||
foreach (var p in midpoints) { cx += p.X; cy += p.Y; }
|
||||
cx /= midpoints.Count;
|
||||
cy /= midpoints.Count;
|
||||
var centroid = new Vector(cx, cy);
|
||||
|
||||
// Covariance matrix for PCA
|
||||
var cxx = 0.0;
|
||||
var cxy = 0.0;
|
||||
var cyy = 0.0;
|
||||
foreach (var p in midpoints)
|
||||
{
|
||||
var dx = p.X - cx;
|
||||
var dy = p.Y - cy;
|
||||
cxx += dx * dx;
|
||||
cxy += dx * dy;
|
||||
cyy += dy * dy;
|
||||
}
|
||||
|
||||
// Eigenvectors of 2x2 symmetric matrix via analytic formula
|
||||
var trace = cxx + cyy;
|
||||
var det = cxx * cyy - cxy * cxy;
|
||||
var disc = System.Math.Sqrt(System.Math.Max(0, trace * trace / 4 - det));
|
||||
var lambda1 = trace / 2 + disc;
|
||||
var lambda2 = trace / 2 - disc;
|
||||
|
||||
var candidates = new List<Vector>();
|
||||
|
||||
// PCA eigenvectors (major and minor axes)
|
||||
if (System.Math.Abs(cxy) > 1e-10)
|
||||
{
|
||||
candidates.Add(Normalize(new Vector(lambda1 - cyy, cxy)));
|
||||
candidates.Add(Normalize(new Vector(lambda2 - cyy, cxy)));
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates.Add(new Vector(1, 0));
|
||||
candidates.Add(new Vector(0, 1));
|
||||
}
|
||||
|
||||
// Also always test pure horizontal and vertical
|
||||
candidates.Add(new Vector(1, 0));
|
||||
candidates.Add(new Vector(0, 1));
|
||||
|
||||
// Score each candidate axis
|
||||
var bestResult = MirrorAxisResult.None;
|
||||
foreach (var dir in candidates)
|
||||
{
|
||||
var score = MirrorMatchScore(midpoints, centroid, dir);
|
||||
if (score > bestResult.Score)
|
||||
bestResult = new MirrorAxisResult(centroid, dir, score);
|
||||
}
|
||||
|
||||
return bestResult.Score >= 0.8 ? bestResult : MirrorAxisResult.None;
|
||||
}
|
||||
|
||||
private static Vector Normalize(Vector v)
|
||||
{
|
||||
var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
|
||||
return len < 1e-10 ? new Vector(1, 0) : new Vector(v.X / len, v.Y / len);
|
||||
}
|
||||
|
||||
private static double MirrorMatchScore(List<Vector> points, Vector axisPoint, Vector axisDir)
|
||||
{
|
||||
var matchTol = 0.1;
|
||||
var matched = 0;
|
||||
|
||||
for (var i = 0; i < points.Count; i++)
|
||||
{
|
||||
var p = points[i];
|
||||
|
||||
// Distance from point to axis
|
||||
var dx = p.X - axisPoint.X;
|
||||
var dy = p.Y - axisPoint.Y;
|
||||
var dot = dx * axisDir.X + dy * axisDir.Y;
|
||||
var perpX = dx - dot * axisDir.X;
|
||||
var perpY = dy - dot * axisDir.Y;
|
||||
var dist = System.Math.Sqrt(perpX * perpX + perpY * perpY);
|
||||
|
||||
// Points on the axis count as matched
|
||||
if (dist < matchTol)
|
||||
{
|
||||
matched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reflect across axis and look for partner
|
||||
var mx = p.X - 2 * perpX;
|
||||
var my = p.Y - 2 * perpY;
|
||||
|
||||
for (var j = 0; j < points.Count; j++)
|
||||
{
|
||||
if (i == j) continue;
|
||||
var d = System.Math.Sqrt((points[j].X - mx) * (points[j].X - mx) +
|
||||
(points[j].Y - my) * (points[j].Y - my));
|
||||
if (d < matchTol)
|
||||
{
|
||||
matched++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (double)matched / points.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pairs candidates across a mirror axis and forces each pair to use
|
||||
/// the same arc (mirrored). The candidate with more lines or lower
|
||||
/// deviation is kept as the source.
|
||||
/// </summary>
|
||||
public void Symmetrize(List<ArcCandidate> candidates, MirrorAxisResult axis)
|
||||
{
|
||||
if (!axis.IsValid || candidates.Count < 2) return;
|
||||
|
||||
var paired = new HashSet<int>();
|
||||
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
if (paired.Contains(i)) continue;
|
||||
|
||||
var ci = candidates[i];
|
||||
var ciCenter = ci.BoundingBox.Center;
|
||||
|
||||
// Distance from candidate center to axis
|
||||
var dx = ciCenter.X - axis.Point.X;
|
||||
var dy = ciCenter.Y - axis.Point.Y;
|
||||
var dot = dx * axis.Direction.X + dy * axis.Direction.Y;
|
||||
var perpDist = System.Math.Sqrt((dx - dot * axis.Direction.X) * (dx - dot * axis.Direction.X) +
|
||||
(dy - dot * axis.Direction.Y) * (dy - dot * axis.Direction.Y));
|
||||
if (perpDist < 0.1) continue; // on the axis
|
||||
|
||||
var mirrorCenter = axis.Reflect(ciCenter);
|
||||
|
||||
var bestJ = -1;
|
||||
var bestDist = double.MaxValue;
|
||||
for (var j = i + 1; j < candidates.Count; j++)
|
||||
{
|
||||
if (paired.Contains(j)) continue;
|
||||
var d = mirrorCenter.DistanceTo(candidates[j].BoundingBox.Center);
|
||||
if (d < bestDist)
|
||||
{
|
||||
bestDist = d;
|
||||
bestJ = j;
|
||||
}
|
||||
}
|
||||
|
||||
var matchTol = System.Math.Max(ci.BoundingBox.Width, ci.BoundingBox.Length) * 0.5;
|
||||
if (bestJ < 0 || bestDist > matchTol) continue;
|
||||
|
||||
paired.Add(i);
|
||||
paired.Add(bestJ);
|
||||
|
||||
var cj = candidates[bestJ];
|
||||
var sourceIdx = i;
|
||||
var targetIdx = bestJ;
|
||||
if (cj.LineCount > ci.LineCount || (cj.LineCount == ci.LineCount && cj.MaxDeviation < ci.MaxDeviation))
|
||||
{
|
||||
sourceIdx = bestJ;
|
||||
targetIdx = i;
|
||||
}
|
||||
|
||||
var source = candidates[sourceIdx];
|
||||
var target = candidates[targetIdx];
|
||||
var mirrored = MirrorArc(source.FittedArc, axis);
|
||||
|
||||
// Only apply the mirrored arc if its endpoints are close enough to the
|
||||
// target's actual boundary points. Otherwise the mirror introduces gaps.
|
||||
var mirroredStart = mirrored.StartPoint();
|
||||
var mirroredEnd = mirrored.EndPoint();
|
||||
var startDist = mirroredStart.DistanceTo(target.FirstPoint);
|
||||
var endDist = mirroredEnd.DistanceTo(target.LastPoint);
|
||||
|
||||
if (startDist <= Tolerance && endDist <= Tolerance)
|
||||
{
|
||||
target.FittedArc = mirrored;
|
||||
target.MaxDeviation = source.MaxDeviation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Arc MirrorArc(Arc arc, MirrorAxisResult axis)
|
||||
{
|
||||
var mirrorCenter = axis.Reflect(arc.Center);
|
||||
|
||||
// Reflect start and end points, then compute new angles
|
||||
var sp = arc.StartPoint();
|
||||
var ep = arc.EndPoint();
|
||||
var mirrorSp = axis.Reflect(sp);
|
||||
var mirrorEp = axis.Reflect(ep);
|
||||
|
||||
// Mirroring reverses winding — swap start/end to preserve arc direction
|
||||
var mirrorStart = System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X);
|
||||
var mirrorEnd = System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X);
|
||||
|
||||
// Normalize to [0, 2pi)
|
||||
if (mirrorStart < 0) mirrorStart += Angle.TwoPI;
|
||||
if (mirrorEnd < 0) mirrorEnd += Angle.TwoPI;
|
||||
|
||||
var result = new Arc(mirrorCenter, arc.Radius, mirrorStart, mirrorEnd, arc.IsReversed);
|
||||
result.Layer = arc.Layer;
|
||||
result.Color = arc.Color;
|
||||
return result;
|
||||
}
|
||||
|
||||
private void FindCandidatesInRun(List<Entity> entities, int runStart, int runEnd, List<ArcCandidate> candidates)
|
||||
{
|
||||
var j = runStart;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
|
||||
while (j <= runEnd - MinLines + 1)
|
||||
{
|
||||
var result = TryFitArcAt(entities, j, runEnd, chainedTangent);
|
||||
if (result == null)
|
||||
{
|
||||
j++;
|
||||
chainedTangent = Vector.Invalid;
|
||||
continue;
|
||||
}
|
||||
|
||||
chainedTangent = ComputeEndTangent(result.Center, result.Points);
|
||||
candidates.Add(new ArcCandidate
|
||||
{
|
||||
StartIndex = j,
|
||||
EndIndex = result.EndIndex,
|
||||
FittedArc = CreateArc(result.Center, result.Radius, result.Points, entities[j]),
|
||||
MaxDeviation = result.Deviation,
|
||||
BoundingBox = result.Points.GetBoundingBox(),
|
||||
FirstPoint = result.Points[0],
|
||||
LastPoint = result.Points[^1],
|
||||
});
|
||||
|
||||
j = result.EndIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
private record ArcFitResult(Vector Center, double Radius, double Deviation, List<Vector> Points, int EndIndex);
|
||||
|
||||
private ArcFitResult TryFitArcAt(List<Entity> entities, int start, int runEnd, Vector chainedTangent)
|
||||
{
|
||||
var k = start + MinLines - 1;
|
||||
if (k > runEnd) return null;
|
||||
|
||||
var points = CollectPoints(entities, start, k);
|
||||
if (points.Count < 3) return null;
|
||||
|
||||
var startTangent = chainedTangent.IsValid()
|
||||
? chainedTangent
|
||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
||||
|
||||
var (center, radius, dev) = TryFit(points, startTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
// Extend the arc as far as possible
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
points = extPoints;
|
||||
}
|
||||
|
||||
// Reject arcs that subtend a tiny angle — these are nearly-straight lines
|
||||
// that happen to fit a huge circle. Applied after extension so that many small
|
||||
// segments can accumulate enough sweep to qualify.
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, points));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
return new ArcFitResult(center, radius, dev, points, k);
|
||||
}
|
||||
|
||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent)
|
||||
{
|
||||
var (center, radius, dev) = FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given direction at the
|
||||
/// first point. The center lies at the intersection of the normal at P1 (perpendicular
|
||||
/// to the tangent) and the perpendicular bisector of the chord P1->Pn, guaranteeing
|
||||
/// the arc passes through both endpoints and departs P1 in the given direction.
|
||||
/// </summary>
|
||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var bx = -dy / chordLen;
|
||||
var by = dx / chordLen;
|
||||
|
||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
if (tLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var nx = -tangent.Y / tLen;
|
||||
var ny = tangent.X / tLen;
|
||||
|
||||
var det = nx * by - ny * bx;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var t = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
||||
|
||||
var cx = p1.X + t * nx;
|
||||
var cy = p1.Y + t * ny;
|
||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the tangent direction at the last point of a fitted arc,
|
||||
/// used to chain tangent continuity to the next arc.
|
||||
/// </summary>
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
|
||||
if (totalAngle >= 0)
|
||||
return new Vector(-ry, rx);
|
||||
else
|
||||
return new Vector(ry, -rx);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc using the mirror axis approach. The center is constrained
|
||||
/// to the perpendicular bisector of the chord (P1->Pn), guaranteeing the arc
|
||||
/// passes exactly through both endpoints. Golden section search optimizes position.
|
||||
/// </summary>
|
||||
private (Vector center, double radius, double deviation) FitMirrorAxis(List<Vector> points)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var halfChord = chordLen / 2;
|
||||
var nx = -dy / chordLen;
|
||||
var ny = dx / chordLen;
|
||||
|
||||
var maxSagitta = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var proj = (points[i].X - mx) * nx + (points[i].Y - my) * ny;
|
||||
if (System.Math.Abs(proj) > System.Math.Abs(maxSagitta))
|
||||
maxSagitta = proj;
|
||||
}
|
||||
if (System.Math.Abs(maxSagitta) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dInit = (maxSagitta * maxSagitta - halfChord * halfChord) / (2 * maxSagitta);
|
||||
var range = System.Math.Max(System.Math.Abs(dInit) * 2, halfChord);
|
||||
|
||||
var dOpt = GoldenSectionMin(dInit - range, dInit + range,
|
||||
d => MaxRadialDeviation(points, mx + d * nx, my + d * ny,
|
||||
System.Math.Sqrt(halfChord * halfChord + d * d)));
|
||||
|
||||
var center = new Vector(mx + dOpt * nx, my + dOpt * ny);
|
||||
var radius = System.Math.Sqrt(halfChord * halfChord + dOpt * dOpt);
|
||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static double GoldenSectionMin(double low, double high, Func<double, double> eval)
|
||||
{
|
||||
var phi = (System.Math.Sqrt(5) - 1) / 2;
|
||||
for (var iter = 0; iter < 30; iter++)
|
||||
{
|
||||
var d1 = high - phi * (high - low);
|
||||
var d2 = low + phi * (high - low);
|
||||
if (eval(d1) < eval(d2))
|
||||
high = d2;
|
||||
else
|
||||
low = d1;
|
||||
if (high - low < 1e-6)
|
||||
break;
|
||||
}
|
||||
return (low + high) / 2;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectPoints(List<Entity> entities, int start, int end)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
|
||||
for (var i = start; i <= end; i++)
|
||||
{
|
||||
switch (entities[i])
|
||||
{
|
||||
case Line line:
|
||||
if (i == start)
|
||||
points.Add(line.StartPoint);
|
||||
points.Add(line.EndPoint);
|
||||
break;
|
||||
|
||||
case Arc arc:
|
||||
if (i == start)
|
||||
points.Add(arc.StartPoint());
|
||||
var segments = System.Math.Max(2, arc.SegmentsForTolerance(0.1));
|
||||
var arcPoints = arc.ToPoints(segments);
|
||||
for (var j = 1; j < arcPoints.Count; j++)
|
||||
points.Add(arcPoints[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points, Entity sourceEntity)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
// Normalize to [0, 2pi)
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
arc.Layer = sourceEntity.Layer;
|
||||
arc.Color = sourceEntity.Color;
|
||||
return arc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sums signed angular change traversing consecutive points around a center.
|
||||
/// Positive = CCW, negative = CW.
|
||||
/// </summary>
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Max deviation of intermediate points (excluding endpoints) from a circle.
|
||||
/// </summary>
|
||||
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var px = points[i].X - cx;
|
||||
var py = points[i].Y - cy;
|
||||
var dist = System.Math.Sqrt(px * px + py * py);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures the maximum distance from sampled points along the fitted arc
|
||||
/// back to the original line segments. This catches cases where points lie
|
||||
/// on a large circle but the arc bulges far from the original straight geometry.
|
||||
/// </summary>
|
||||
private static double MaxArcToSegmentDeviation(List<Vector> points, Vector center, double radius, bool isReversed)
|
||||
{
|
||||
var startAngle = System.Math.Atan2(points[0].Y - center.Y, points[0].X - center.X);
|
||||
var endAngle = System.Math.Atan2(points[^1].Y - center.Y, points[^1].X - center.X);
|
||||
|
||||
var sweep = endAngle - startAngle;
|
||||
if (isReversed)
|
||||
{
|
||||
if (sweep > 0) sweep -= Angle.TwoPI;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sweep < 0) sweep += Angle.TwoPI;
|
||||
}
|
||||
|
||||
var sampleCount = System.Math.Max(10, (int)(System.Math.Abs(sweep) * radius * 10));
|
||||
sampleCount = System.Math.Min(sampleCount, 100);
|
||||
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < sampleCount; i++)
|
||||
{
|
||||
var t = (double)i / sampleCount;
|
||||
var angle = startAngle + sweep * t;
|
||||
var px = center.X + radius * System.Math.Cos(angle);
|
||||
var py = center.Y + radius * System.Math.Sin(angle);
|
||||
var arcPt = new Vector(px, py);
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
for (var j = 0; j < points.Count - 1; j++)
|
||||
{
|
||||
var dist = DistanceToSegment(arcPt, points[j], points[j + 1]);
|
||||
if (dist < minDist) minDist = dist;
|
||||
}
|
||||
if (minDist > maxDev) maxDev = minDist;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static double DistanceToSegment(Vector p, Vector a, Vector b)
|
||||
{
|
||||
var dx = b.X - a.X;
|
||||
var dy = b.Y - a.Y;
|
||||
var lenSq = dx * dx + dy * dy;
|
||||
if (lenSq < 1e-20)
|
||||
return System.Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y));
|
||||
|
||||
var t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq;
|
||||
t = System.Math.Max(0, System.Math.Min(1, t));
|
||||
var projX = a.X + t * dx;
|
||||
var projY = a.Y + t * dy;
|
||||
return System.Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY));
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,14 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
internal static bool Intersects(Line line1, Line line2, out Vector pt)
|
||||
{
|
||||
if (!IntersectsUnbounded(line1, line2, out pt))
|
||||
return false;
|
||||
|
||||
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
|
||||
}
|
||||
|
||||
internal static bool IntersectsUnbounded(Line line1, Line line2, out Vector pt)
|
||||
{
|
||||
var a1 = line1.EndPoint.Y - line1.StartPoint.Y;
|
||||
var b1 = line1.StartPoint.X - line1.EndPoint.X;
|
||||
@@ -240,7 +248,7 @@ namespace OpenNest.Geometry
|
||||
var y = (a1 * c2 - a2 * c1) / d;
|
||||
|
||||
pt = new Vector(x, y);
|
||||
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool Intersects(Line line, Shape shape, out List<Vector> pts)
|
||||
@@ -249,9 +257,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>
|
||||
|
||||
@@ -534,7 +534,7 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
Vector intersection;
|
||||
|
||||
if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection))
|
||||
if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out intersection))
|
||||
{
|
||||
offsetLine.StartPoint = intersection;
|
||||
lastOffsetLine.EndPoint = intersection;
|
||||
@@ -558,6 +558,81 @@ namespace OpenNest.Geometry
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the shape outward by the given distance, detecting winding direction
|
||||
/// to choose the correct offset side. Falls back to the opposite side if the
|
||||
/// bounding box shrinks (indicating the offset went inward).
|
||||
/// </summary>
|
||||
public Shape OffsetOutward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
var side = poly.Vertices.Count >= 3 && poly.RotationDirection() == RotationType.CW
|
||||
? OffsetSide.Left
|
||||
: OffsetSide.Right;
|
||||
|
||||
var result = OffsetEntity(distance, side) as Shape;
|
||||
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
UpdateBounds();
|
||||
var originalBB = BoundingBox;
|
||||
result.UpdateBounds();
|
||||
var offsetBB = result.BoundingBox;
|
||||
|
||||
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
|
||||
{
|
||||
Trace.TraceWarning(
|
||||
"Shape.OffsetOutward: offset shrank bounding box " +
|
||||
$"(original={originalBB.Width:F3}x{originalBB.Length:F3}, " +
|
||||
$"offset={offsetBB.Width:F3}x{offsetBB.Length:F3}). " +
|
||||
"Retrying with opposite side.");
|
||||
|
||||
var opposite = side == OffsetSide.Left ? OffsetSide.Right : OffsetSide.Left;
|
||||
var retry = OffsetEntity(distance, opposite) as Shape;
|
||||
|
||||
if (retry != null)
|
||||
result = retry;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offsets the shape inward by the given distance.
|
||||
/// Normalizes to CCW winding before offsetting Left (which is inward for CCW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </summary>
|
||||
public Shape OffsetInward(double distance)
|
||||
{
|
||||
var poly = ToPolygon();
|
||||
|
||||
if (poly == null || poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CCW)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
// Create a reversed copy to avoid mutating shared entity objects.
|
||||
var copy = new Shape();
|
||||
|
||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
switch (Entities[i])
|
||||
{
|
||||
case Line l:
|
||||
copy.Entities.Add(new Line(l.EndPoint, l.StartPoint) { Layer = l.Layer });
|
||||
break;
|
||||
case Arc a:
|
||||
copy.Entities.Add(new Arc(a.Center, a.Radius, a.EndAngle, a.StartAngle, !a.IsReversed) { Layer = a.Layer });
|
||||
break;
|
||||
case Circle c:
|
||||
copy.Entities.Add(new Circle(c.Center, c.Radius) { Layer = c.Layer });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the closest point on the shape to the given point.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -21,9 +22,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];
|
||||
@@ -38,5 +42,52 @@ namespace OpenNest.Geometry
|
||||
public Shape Perimeter { get; set; }
|
||||
|
||||
public List<Shape> Cutouts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Ensures CNC-standard winding: perimeter CW (kerf left = outward),
|
||||
/// cutouts CCW (kerf left = inward). Reverses contours in-place as needed.
|
||||
/// </summary>
|
||||
public void NormalizeWinding()
|
||||
{
|
||||
EnsureWinding(Perimeter, RotationType.CW);
|
||||
|
||||
foreach (var cutout in Cutouts)
|
||||
EnsureWinding(cutout, RotationType.CCW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the entities in normalized winding order (perimeter first, then cutouts).
|
||||
/// </summary>
|
||||
public List<Entity> ToNormalizedEntities()
|
||||
{
|
||||
NormalizeWinding();
|
||||
var result = new List<Entity>(Perimeter.Entities);
|
||||
|
||||
foreach (var cutout in Cutouts)
|
||||
result.AddRange(cutout.Entities);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience method: builds a ShapeProfile from raw entities,
|
||||
/// normalizes winding, and returns the corrected entity list.
|
||||
/// </summary>
|
||||
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
||||
{
|
||||
var profile = new ShapeProfile(entities.ToList());
|
||||
return profile.ToNormalizedEntities();
|
||||
}
|
||||
|
||||
private static void EnsureWinding(Shape shape, RotationType desired)
|
||||
{
|
||||
var poly = shape.ToPolygon();
|
||||
|
||||
if (poly != null && poly.Vertices.Count >= 3
|
||||
&& poly.RotationDirection() != desired)
|
||||
{
|
||||
shape.Reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
247
OpenNest.Core/Geometry/SplineConverter.cs
Normal file
247
OpenNest.Core/Geometry/SplineConverter.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class SplineConverter
|
||||
{
|
||||
private const int MinPointsForArc = 3;
|
||||
|
||||
public static List<Entity> Convert(List<Vector> points, bool isClosed, double tolerance = 0.001)
|
||||
{
|
||||
if (points == null || points.Count < 2)
|
||||
return new List<Entity>();
|
||||
|
||||
var entities = new List<Entity>();
|
||||
var i = 0;
|
||||
var chainedTangent = Vector.Invalid;
|
||||
|
||||
while (i < points.Count - 1)
|
||||
{
|
||||
var result = TryFitArc(points, i, chainedTangent, tolerance);
|
||||
if (result != null)
|
||||
{
|
||||
entities.Add(result.Arc);
|
||||
chainedTangent = result.EndTangent;
|
||||
i = result.EndIndex;
|
||||
}
|
||||
else
|
||||
{
|
||||
entities.Add(new Line(points[i], points[i + 1]));
|
||||
chainedTangent = Vector.Invalid;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static ArcFitResult TryFitArc(List<Vector> points, int start,
|
||||
Vector chainedTangent, double tolerance)
|
||||
{
|
||||
var minEnd = start + MinPointsForArc - 1;
|
||||
if (minEnd >= points.Count)
|
||||
return null;
|
||||
|
||||
var hasTangent = chainedTangent.IsValid();
|
||||
|
||||
var subPoints = points.GetRange(start, MinPointsForArc);
|
||||
var (center, radius, dev) = hasTangent
|
||||
? FitWithStartTangent(subPoints, chainedTangent)
|
||||
: FitCircumscribed(subPoints);
|
||||
|
||||
if (!center.IsValid() || dev > tolerance)
|
||||
return null;
|
||||
|
||||
var endIdx = minEnd;
|
||||
while (endIdx + 1 < points.Count)
|
||||
{
|
||||
var extPoints = points.GetRange(start, endIdx + 1 - start + 1);
|
||||
var (nc, nr, nd) = hasTangent
|
||||
? FitWithStartTangent(extPoints, chainedTangent)
|
||||
: FitCircumscribed(extPoints);
|
||||
|
||||
if (!nc.IsValid() || nd > tolerance)
|
||||
break;
|
||||
|
||||
endIdx++;
|
||||
center = nc;
|
||||
radius = nr;
|
||||
dev = nd;
|
||||
}
|
||||
|
||||
var finalPoints = points.GetRange(start, endIdx - start + 1);
|
||||
var sweep = System.Math.Abs(SumSignedAngles(center, finalPoints));
|
||||
if (sweep < Angle.ToRadians(5))
|
||||
return null;
|
||||
|
||||
var arc = CreateArc(center, radius, finalPoints);
|
||||
var endTangent = ComputeEndTangent(center, finalPoints);
|
||||
|
||||
return new ArcFitResult(arc, endTangent, endIdx);
|
||||
}
|
||||
|
||||
private static (Vector center, double radius, double deviation) FitCircumscribed(
|
||||
List<Vector> points)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p0 = points[0];
|
||||
var pMid = points[points.Count / 2];
|
||||
var pEnd = points[^1];
|
||||
|
||||
// Find circumcenter by intersecting perpendicular bisectors of two chords
|
||||
var (center, radius) = Circumcenter(p0, pMid, pEnd);
|
||||
if (!center.IsValid())
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (center, radius, MaxRadialDeviation(points, center.X, center.Y, radius));
|
||||
}
|
||||
|
||||
private static (Vector center, double radius) Circumcenter(Vector a, Vector b, Vector c)
|
||||
{
|
||||
// Perpendicular bisector of chord a-b
|
||||
var m1x = (a.X + b.X) / 2;
|
||||
var m1y = (a.Y + b.Y) / 2;
|
||||
var d1x = -(b.Y - a.Y);
|
||||
var d1y = b.X - a.X;
|
||||
|
||||
// Perpendicular bisector of chord b-c
|
||||
var m2x = (b.X + c.X) / 2;
|
||||
var m2y = (b.Y + c.Y) / 2;
|
||||
var d2x = -(c.Y - b.Y);
|
||||
var d2y = c.X - b.X;
|
||||
|
||||
var det = d1x * d2y - d1y * d2x;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0);
|
||||
|
||||
var t = ((m2x - m1x) * d2y - (m2y - m1y) * d2x) / det;
|
||||
|
||||
var cx = m1x + t * d1x;
|
||||
var cy = m1y + t * d1y;
|
||||
var radius = System.Math.Sqrt((cx - a.X) * (cx - a.X) + (cy - a.Y) * (cy - a.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0);
|
||||
|
||||
return (new Vector(cx, cy), radius);
|
||||
}
|
||||
|
||||
private static (Vector center, double radius, double deviation) FitWithStartTangent(
|
||||
List<Vector> points, Vector tangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var mx = (p1.X + pn.X) / 2;
|
||||
var my = (p1.Y + pn.Y) / 2;
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
if (chordLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var bx = -dy / chordLen;
|
||||
var by = dx / chordLen;
|
||||
|
||||
var tLen = System.Math.Sqrt(tangent.X * tangent.X + tangent.Y * tangent.Y);
|
||||
if (tLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var nx = -tangent.Y / tLen;
|
||||
var ny = tangent.X / tLen;
|
||||
|
||||
var det = nx * by - ny * bx;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var s = ((mx - p1.X) * by - (my - p1.Y) * bx) / det;
|
||||
|
||||
var cx = p1.X + s * nx;
|
||||
var cy = p1.Y + s * ny;
|
||||
var radius = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
|
||||
if (radius < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||
}
|
||||
|
||||
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius)
|
||||
{
|
||||
var maxDev = 0.0;
|
||||
for (var i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
var px = points[i].X - cx;
|
||||
var py = points[i].Y - cy;
|
||||
var dist = System.Math.Sqrt(px * px + py * py);
|
||||
var dev = System.Math.Abs(dist - radius);
|
||||
if (dev > maxDev) maxDev = dev;
|
||||
}
|
||||
return maxDev;
|
||||
}
|
||||
|
||||
private static double SumSignedAngles(Vector center, List<Vector> points)
|
||||
{
|
||||
var total = 0.0;
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
{
|
||||
var a1 = System.Math.Atan2(points[i].Y - center.Y, points[i].X - center.X);
|
||||
var a2 = System.Math.Atan2(points[i + 1].Y - center.Y, points[i + 1].X - center.X);
|
||||
var da = a2 - a1;
|
||||
while (da > System.Math.PI) da -= Angle.TwoPI;
|
||||
while (da < -System.Math.PI) da += Angle.TwoPI;
|
||||
total += da;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static Vector ComputeEndTangent(Vector center, List<Vector> points)
|
||||
{
|
||||
var lastPt = points[^1];
|
||||
var totalAngle = SumSignedAngles(center, points);
|
||||
|
||||
var rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
|
||||
return totalAngle >= 0
|
||||
? new Vector(-ry, rx)
|
||||
: new Vector(ry, -rx);
|
||||
}
|
||||
|
||||
private static Arc CreateArc(Vector center, double radius, List<Vector> points)
|
||||
{
|
||||
var firstPoint = points[0];
|
||||
var lastPoint = points[^1];
|
||||
|
||||
var startAngle = System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X);
|
||||
var endAngle = System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X);
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
if (startAngle < 0) startAngle += Angle.TwoPI;
|
||||
if (endAngle < 0) endAngle += Angle.TwoPI;
|
||||
|
||||
return new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
}
|
||||
|
||||
private sealed class ArcFitResult
|
||||
{
|
||||
public Arc Arc { get; }
|
||||
public Vector EndTangent { get; }
|
||||
public int EndIndex { get; }
|
||||
|
||||
public ArcFitResult(Arc arc, Vector endTangent, int endIndex)
|
||||
{
|
||||
Arc = arc;
|
||||
EndTangent = endTangent;
|
||||
EndIndex = endIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<RootNamespace>OpenNest</RootNamespace>
|
||||
<AssemblyName>OpenNest.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="OpenNest.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Clipper2" Version="2.0.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -173,7 +174,53 @@ namespace OpenNest
|
||||
perimeter1.Offset(Location);
|
||||
perimeter2.Offset(part.Location);
|
||||
|
||||
return perimeter1.Intersects(perimeter2, out pts);
|
||||
if (!perimeter1.Intersects(perimeter2, out var rawPts))
|
||||
return false;
|
||||
|
||||
// Exclude intersection points that coincide with vertices of BOTH
|
||||
// perimeters — these are touch points (shared corners/endpoints),
|
||||
// not actual crossings where one shape enters the other's interior.
|
||||
var verts1 = CollectVertices(perimeter1);
|
||||
var verts2 = CollectVertices(perimeter2);
|
||||
|
||||
foreach (var pt in rawPts)
|
||||
{
|
||||
if (IsNearAnyVertex(pt, verts1) && IsNearAnyVertex(pt, verts2))
|
||||
continue;
|
||||
pts.Add(pt);
|
||||
}
|
||||
|
||||
return pts.Count > 0;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectVertices(Geometry.Shape shape)
|
||||
{
|
||||
var verts = new List<Vector>();
|
||||
foreach (var entity in shape.Entities)
|
||||
{
|
||||
switch (entity)
|
||||
{
|
||||
case Geometry.Line line:
|
||||
verts.Add(line.StartPoint);
|
||||
verts.Add(line.EndPoint);
|
||||
break;
|
||||
case Geometry.Arc arc:
|
||||
verts.Add(arc.StartPoint());
|
||||
verts.Add(arc.EndPoint());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return verts;
|
||||
}
|
||||
|
||||
private static bool IsNearAnyVertex(Vector pt, List<Vector> vertices)
|
||||
{
|
||||
foreach (var v in vertices)
|
||||
{
|
||||
if (pt.X.IsEqualTo(v.X) && pt.Y.IsEqualTo(v.Y))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public double Left
|
||||
|
||||
@@ -42,23 +42,17 @@ namespace OpenNest
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing + chordTolerance;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
// Add chord tolerance to compensate for inscribed polygon chords
|
||||
// being inside the actual offset arcs.
|
||||
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(polygon.ToLines());
|
||||
}
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -66,21 +60,17 @@ namespace OpenNest
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing + chordTolerance;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -104,21 +94,17 @@ namespace OpenNest
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, Vector facingDirection, double chordTolerance = 0.001)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing + chordTolerance;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
|
||||
AddOffsetDirectionalLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
if (offsetEntity == null)
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetDirectionalLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location, facingDirection);
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -189,5 +175,41 @@ namespace OpenNest
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static void AddOffsetLines(List<Line> lines, Shape offsetEntity,
|
||||
double chordTolerance, Vector location)
|
||||
{
|
||||
if (offsetEntity == null)
|
||||
return;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(location);
|
||||
lines.AddRange(polygon.ToLines());
|
||||
}
|
||||
|
||||
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
|
||||
double chordTolerance, Vector location, PushDirection facingDirection)
|
||||
{
|
||||
if (offsetEntity == null)
|
||||
return;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
|
||||
private static void AddOffsetDirectionalLines(List<Line> lines, Shape offsetEntity,
|
||||
double chordTolerance, Vector location, Vector facingDirection)
|
||||
{
|
||||
if (offsetEntity == null)
|
||||
return;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(location);
|
||||
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -85,11 +88,102 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public Material Material { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Material grain direction in radians. 0 = horizontal.
|
||||
/// </summary>
|
||||
public double GrainAngle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The parts that the plate contains.
|
||||
/// </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 +334,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 +357,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 +384,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 +408,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 +590,37 @@ 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];
|
||||
var b1 = part1.BoundingBox;
|
||||
|
||||
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];
|
||||
var b2 = part2.BoundingBox;
|
||||
|
||||
List<Vector> pts2;
|
||||
// Skip pairs whose bounding boxes don't meaningfully overlap.
|
||||
// Floating-point rounding can produce sub-epsilon overlaps for
|
||||
// parts that are merely edge-touching, so require the overlap
|
||||
// region to exceed Epsilon in both dimensions.
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (part1.Intersects(part2, out pts2))
|
||||
if (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (part1.Intersects(part2, out var pts2))
|
||||
pts.AddRange(pts2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,17 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public class RectangleShape : ShapeDefinition
|
||||
{
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
{
|
||||
new Line(0, 0, Width, 0),
|
||||
new Line(Width, 0, Width, Height),
|
||||
new Line(Width, Height, 0, Height),
|
||||
new Line(0, Height, 0, 0)
|
||||
new Line(0, 0, Length, 0),
|
||||
new Line(Length, 0, Length, Width),
|
||||
new Line(Length, Width, 0, Width),
|
||||
new Line(0, Width, 0, 0)
|
||||
};
|
||||
|
||||
return CreateDrawing(entities);
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public class RoundedRectangleShape : ShapeDefinition
|
||||
{
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
@@ -17,36 +17,36 @@ namespace OpenNest.Shapes
|
||||
|
||||
if (r <= 0)
|
||||
{
|
||||
entities.Add(new Line(0, 0, Width, 0));
|
||||
entities.Add(new Line(Width, 0, Width, Height));
|
||||
entities.Add(new Line(Width, Height, 0, Height));
|
||||
entities.Add(new Line(0, Height, 0, 0));
|
||||
entities.Add(new Line(0, 0, Length, 0));
|
||||
entities.Add(new Line(Length, 0, Length, Width));
|
||||
entities.Add(new Line(Length, Width, 0, Width));
|
||||
entities.Add(new Line(0, Width, 0, 0));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Bottom edge (left to right, above bottom-left arc to bottom-right arc)
|
||||
entities.Add(new Line(r, 0, Width - r, 0));
|
||||
entities.Add(new Line(r, 0, Length - r, 0));
|
||||
|
||||
// Bottom-right corner arc: center at (Width-r, r), from 270deg to 360deg
|
||||
entities.Add(new Arc(Width - r, r, r,
|
||||
// Bottom-right corner arc: center at (Length-r, r), from 270deg to 360deg
|
||||
entities.Add(new Arc(Length - r, r, r,
|
||||
Angle.ToRadians(270), Angle.ToRadians(360)));
|
||||
|
||||
// Right edge
|
||||
entities.Add(new Line(Width, r, Width, Height - r));
|
||||
entities.Add(new Line(Length, r, Length, Width - r));
|
||||
|
||||
// Top-right corner arc: center at (Width-r, Height-r), from 0deg to 90deg
|
||||
entities.Add(new Arc(Width - r, Height - r, r,
|
||||
// Top-right corner arc: center at (Length-r, Width-r), from 0deg to 90deg
|
||||
entities.Add(new Arc(Length - r, Width - r, r,
|
||||
Angle.ToRadians(0), Angle.ToRadians(90)));
|
||||
|
||||
// Top edge (right to left)
|
||||
entities.Add(new Line(Width - r, Height, r, Height));
|
||||
entities.Add(new Line(Length - r, Width, r, Width));
|
||||
|
||||
// Top-left corner arc: center at (r, Height-r), from 90deg to 180deg
|
||||
entities.Add(new Arc(r, Height - r, r,
|
||||
// Top-left corner arc: center at (r, Width-r), from 90deg to 180deg
|
||||
entities.Add(new Arc(r, Width - r, r,
|
||||
Angle.ToRadians(90), Angle.ToRadians(180)));
|
||||
|
||||
// Left edge
|
||||
entities.Add(new Line(0, Height - r, 0, r));
|
||||
entities.Add(new Line(0, Width - r, 0, r));
|
||||
|
||||
// Bottom-left corner arc: center at (r, r), from 180deg to 270deg
|
||||
entities.Add(new Arc(r, r, r,
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Drawing;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class SpecialLayers
|
||||
{
|
||||
public static readonly Layer Default = new Layer("0");
|
||||
public static readonly Layer Default = new Layer("0") { Color = Color.White };
|
||||
|
||||
public static readonly Layer Cut = new Layer("CUT");
|
||||
public static readonly Layer Cut = new Layer("CUT") { Color = Color.White };
|
||||
|
||||
public static readonly Layer Rapid = new Layer("RAPID");
|
||||
public static readonly Layer Rapid = new Layer("RAPID") { Color = Color.Gray };
|
||||
|
||||
public static readonly Layer Display = new Layer("DISPLAY");
|
||||
public static readonly Layer Display = new Layer("DISPLAY") { Color = Color.Cyan };
|
||||
|
||||
public static readonly Layer Leadin = new Layer("LEADIN");
|
||||
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Yellow };
|
||||
|
||||
public static readonly Layer Leadout = new Layer("LEADOUT");
|
||||
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Yellow };
|
||||
|
||||
public static readonly Layer Scribe = new Layer("SCRIBE");
|
||||
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
|
||||
}
|
||||
}
|
||||
|
||||
51
OpenNest.Core/Splitting/AutoSplitCalculator.cs
Normal file
51
OpenNest.Core/Splitting/AutoSplitCalculator.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public static class AutoSplitCalculator
|
||||
{
|
||||
public static List<SplitLine> FitToPlate(Box partBounds, double plateWidth, double plateHeight,
|
||||
double edgeSpacing, double featureOverhang)
|
||||
{
|
||||
var usableWidth = plateWidth - 2 * edgeSpacing - featureOverhang;
|
||||
var usableHeight = plateHeight - 2 * edgeSpacing - featureOverhang;
|
||||
|
||||
var lines = new List<SplitLine>();
|
||||
|
||||
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableWidth) - 1 : 0;
|
||||
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableHeight) - 1 : 0;
|
||||
|
||||
if (verticalSplits < 0) verticalSplits = 0;
|
||||
if (horizontalSplits < 0) horizontalSplits = 0;
|
||||
|
||||
for (var i = 1; i <= verticalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + usableWidth * i, CutOffAxis.Vertical));
|
||||
|
||||
for (var i = 1; i <= horizontalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + usableHeight * i, CutOffAxis.Horizontal));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static List<SplitLine> SplitByCount(Box partBounds, int horizontalPieces, int verticalPieces)
|
||||
{
|
||||
var lines = new List<SplitLine>();
|
||||
|
||||
if (verticalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Width / verticalPieces;
|
||||
for (var i = 1; i < verticalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
|
||||
}
|
||||
|
||||
if (horizontalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Length / horizontalPieces;
|
||||
for (var i = 1; i < horizontalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
565
OpenNest.Core/Splitting/DrawingSplitter.cs
Normal file
565
OpenNest.Core/Splitting/DrawingSplitter.cs
Normal file
@@ -0,0 +1,565 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
|
||||
/// </summary>
|
||||
public static class DrawingSplitter
|
||||
{
|
||||
public static List<Drawing> Split(Drawing drawing, List<SplitLine> splitLines, SplitParameters parameters)
|
||||
{
|
||||
if (splitLines.Count == 0)
|
||||
return new List<Drawing> { drawing };
|
||||
|
||||
var profile = BuildProfile(drawing);
|
||||
DecomposeCircles(profile);
|
||||
|
||||
var perimeter = profile.Perimeter;
|
||||
var bounds = perimeter.BoundingBox;
|
||||
|
||||
var sortedLines = splitLines
|
||||
.Where(l => IsLineInsideBounds(l, bounds))
|
||||
.OrderBy(l => l.Position)
|
||||
.ToList();
|
||||
|
||||
if (sortedLines.Count == 0)
|
||||
return new List<Drawing> { drawing };
|
||||
|
||||
var regions = BuildClipRegions(sortedLines, bounds);
|
||||
var feature = GetFeature(parameters.Type);
|
||||
|
||||
var results = new List<Drawing>();
|
||||
var pieceIndex = 1;
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
|
||||
if (pieceEntities.Count == 0)
|
||||
continue;
|
||||
|
||||
var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
|
||||
|
||||
var allEntities = new List<Entity>();
|
||||
allEntities.AddRange(pieceEntities);
|
||||
allEntities.AddRange(cutoutEntities);
|
||||
|
||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
||||
results.Add(piece);
|
||||
pieceIndex++;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static ShapeProfile BuildProfile(Drawing drawing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
return new ShapeProfile(entities);
|
||||
}
|
||||
|
||||
private static List<Entity> CollectCutouts(List<Shape> cutouts, Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
foreach (var cutout in cutouts)
|
||||
{
|
||||
if (IsCutoutInRegion(cutout, region))
|
||||
entities.AddRange(cutout.Entities);
|
||||
else if (DoesCutoutCrossSplitLine(cutout, splitLines))
|
||||
{
|
||||
var clipped = ClipCutoutToRegion(cutout, region, splitLines);
|
||||
if (clipped.Count > 0)
|
||||
entities.AddRange(clipped);
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex, Box region)
|
||||
{
|
||||
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
|
||||
var offsetX = -pieceBounds.X;
|
||||
var offsetY = -pieceBounds.Y;
|
||||
|
||||
foreach (var e in entities)
|
||||
e.Offset(offsetX, offsetY);
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(entities);
|
||||
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
|
||||
piece.Color = source.Color;
|
||||
piece.Priority = source.Priority;
|
||||
piece.Material = source.Material;
|
||||
piece.Constraints = source.Constraints;
|
||||
piece.Customer = source.Customer;
|
||||
piece.Source = source.Source;
|
||||
piece.Quantity.Required = source.Quantity.Required;
|
||||
|
||||
if (source.Bends != null && source.Bends.Count > 0)
|
||||
{
|
||||
piece.Bends = new List<Bending.Bend>();
|
||||
foreach (var bend in source.Bends)
|
||||
{
|
||||
var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region);
|
||||
if (clipped == null)
|
||||
continue;
|
||||
|
||||
piece.Bends.Add(new Bending.Bend
|
||||
{
|
||||
StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY),
|
||||
EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY),
|
||||
Direction = bend.Direction,
|
||||
Angle = bend.Angle,
|
||||
Radius = bend.Radius,
|
||||
NoteText = bend.NoteText,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return piece;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm.
|
||||
/// Returns the clipped start/end or null if the line is entirely outside.
|
||||
/// </summary>
|
||||
private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box)
|
||||
{
|
||||
var dx = end.X - start.X;
|
||||
var dy = end.Y - start.Y;
|
||||
double t0 = 0, t1 = 1;
|
||||
|
||||
double[] p = { -dx, dx, -dy, dy };
|
||||
double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y };
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon)
|
||||
{
|
||||
if (q[i] < -Math.Tolerance.Epsilon)
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var t = q[i] / p[i];
|
||||
if (p[i] < 0)
|
||||
t0 = System.Math.Max(t0, t);
|
||||
else
|
||||
t1 = System.Math.Min(t1, t);
|
||||
|
||||
if (t0 > t1)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy);
|
||||
var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy);
|
||||
return (clippedStart, clippedEnd);
|
||||
}
|
||||
|
||||
private static void DecomposeCircles(ShapeProfile profile)
|
||||
{
|
||||
DecomposeCirclesInShape(profile.Perimeter);
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
DecomposeCirclesInShape(cutout);
|
||||
}
|
||||
|
||||
private static void DecomposeCirclesInShape(Shape shape)
|
||||
{
|
||||
for (var i = shape.Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (shape.Entities[i] is Circle circle)
|
||||
{
|
||||
var arc1 = new Arc(circle.Center, circle.Radius, 0, System.Math.PI);
|
||||
var arc2 = new Arc(circle.Center, circle.Radius, System.Math.PI, System.Math.PI * 2);
|
||||
shape.Entities.RemoveAt(i);
|
||||
shape.Entities.Insert(i, arc2);
|
||||
shape.Entities.Insert(i, arc1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLineInsideBounds(SplitLine line, Box bounds)
|
||||
{
|
||||
return line.Axis == CutOffAxis.Vertical
|
||||
? line.Position > bounds.Left + OpenNest.Math.Tolerance.Epsilon
|
||||
&& line.Position < bounds.Right - OpenNest.Math.Tolerance.Epsilon
|
||||
: line.Position > bounds.Bottom + OpenNest.Math.Tolerance.Epsilon
|
||||
&& line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static List<Box> BuildClipRegions(List<SplitLine> sortedLines, Box bounds)
|
||||
{
|
||||
var verticals = sortedLines.Where(l => l.Axis == CutOffAxis.Vertical).OrderBy(l => l.Position).ToList();
|
||||
var horizontals = sortedLines.Where(l => l.Axis == CutOffAxis.Horizontal).OrderBy(l => l.Position).ToList();
|
||||
|
||||
var xEdges = new List<double> { bounds.Left };
|
||||
xEdges.AddRange(verticals.Select(v => v.Position));
|
||||
xEdges.Add(bounds.Right);
|
||||
|
||||
var yEdges = new List<double> { bounds.Bottom };
|
||||
yEdges.AddRange(horizontals.Select(h => h.Position));
|
||||
yEdges.Add(bounds.Top);
|
||||
|
||||
var regions = new List<Box>();
|
||||
for (var yi = 0; yi < yEdges.Count - 1; yi++)
|
||||
for (var xi = 0; xi < xEdges.Count - 1; xi++)
|
||||
regions.Add(new Box(xEdges[xi], yEdges[yi], xEdges[xi + 1] - xEdges[xi], yEdges[yi + 1] - yEdges[yi]));
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clip perimeter to a region by walking entities, splitting at split line crossings,
|
||||
/// and stitching in feature edges. No polygon clipping library needed.
|
||||
/// </summary>
|
||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
||||
{
|
||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||
var entities = new List<Entity>();
|
||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
||||
|
||||
foreach (var entity in perimeter.Entities)
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
||||
EnsurePerimeterWinding(entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static void ProcessEntity(Entity entity, Box region,
|
||||
List<SplitLine> boundarySplitLines, List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
||||
{
|
||||
// Find the first boundary split line this entity crosses
|
||||
SplitLine crossedLine = null;
|
||||
Vector? intersectionPt = null;
|
||||
|
||||
foreach (var sl in boundarySplitLines)
|
||||
{
|
||||
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
|
||||
{
|
||||
var pt = SplitLineIntersect.FindIntersection(entity, sl);
|
||||
if (pt != null)
|
||||
{
|
||||
crossedLine = sl;
|
||||
intersectionPt = pt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (crossedLine != null)
|
||||
{
|
||||
// Entity crosses a split line — split it and keep the half inside the region
|
||||
var regionSide = RegionSideOf(region, crossedLine);
|
||||
var startPt = GetStartPoint(entity);
|
||||
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
|
||||
var startInRegion = startSide == regionSide || startSide == 0;
|
||||
|
||||
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entity doesn't cross any boundary split line — check if it's inside the region
|
||||
var mid = MidPoint(entity);
|
||||
if (region.Contains(mid))
|
||||
entities.Add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
|
||||
SplitLine crossedLine, List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (first, second) = line.SplitAt(point);
|
||||
if (startInRegion)
|
||||
{
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
}
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
var (first, second) = arc.SplitAt(point);
|
||||
if (startInRegion)
|
||||
{
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns split lines whose position matches a boundary edge of the region.
|
||||
/// </summary>
|
||||
private static List<SplitLine> GetBoundarySplitLines(Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var result = new List<SplitLine>();
|
||||
foreach (var sl in splitLines)
|
||||
{
|
||||
if (sl.Axis == CutOffAxis.Vertical)
|
||||
{
|
||||
if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon
|
||||
|| System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon)
|
||||
result.Add(sl);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon
|
||||
|| System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon)
|
||||
result.Add(sl);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns -1 or +1 indicating which side of the split line the region center is on.
|
||||
/// </summary>
|
||||
private static int RegionSideOf(Box region, SplitLine sl)
|
||||
{
|
||||
return SplitLineIntersect.SideOf(region.Center, sl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the midpoint of an entity. For lines: average of endpoints.
|
||||
/// For arcs: point at the mid-angle.
|
||||
/// </summary>
|
||||
private static Vector MidPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return line.MidPoint;
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
var midAngle = (arc.StartAngle + arc.EndAngle) / 2;
|
||||
return new Vector(
|
||||
arc.Center.X + arc.Radius * System.Math.Cos(midAngle),
|
||||
arc.Center.Y + arc.Radius * System.Math.Sin(midAngle));
|
||||
}
|
||||
|
||||
return new Vector(0, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
|
||||
/// </summary>
|
||||
private static void InsertFeatureEdges(List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
||||
Box region, List<SplitLine> boundarySplitLines,
|
||||
ISplitFeature feature, SplitParameters parameters)
|
||||
{
|
||||
// Group split points by their split line
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
{
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
}
|
||||
|
||||
foreach (var kvp in groups)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
|
||||
// Pair each exit with the next entry
|
||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
|
||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
|
||||
|
||||
if (exits.Count == 0 || entries.Count == 0)
|
||||
continue;
|
||||
|
||||
// For each exit, find the matching entry to form the feature edge span
|
||||
// Sort exits and entries by their position along the split line
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
|
||||
// Pair them up: each exit with the next entry (or vice versa)
|
||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
{
|
||||
var exitPt = exits[i];
|
||||
var entryPt = entries[i];
|
||||
|
||||
var extentStart = isVertical
|
||||
? System.Math.Min(exitPt.Y, entryPt.Y)
|
||||
: System.Math.Min(exitPt.X, entryPt.X);
|
||||
var extentEnd = isVertical
|
||||
? System.Math.Max(exitPt.Y, entryPt.Y)
|
||||
: System.Math.Max(exitPt.X, entryPt.X);
|
||||
|
||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||
|
||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||
|
||||
if (featureEdge.Count > 0)
|
||||
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
|
||||
|
||||
entities.AddRange(featureEdge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
||||
{
|
||||
var featureStart = GetStartPoint(featureEdge[0]);
|
||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
||||
var isVertical = axis == CutOffAxis.Vertical;
|
||||
|
||||
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
|
||||
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
||||
|
||||
if (edgeGoesForward != featureGoesForward)
|
||||
{
|
||||
featureEdge = new List<Entity>(featureEdge);
|
||||
featureEdge.Reverse();
|
||||
foreach (var e in featureEdge)
|
||||
e.Reverse();
|
||||
}
|
||||
|
||||
return featureEdge;
|
||||
}
|
||||
|
||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
||||
shape.Reverse();
|
||||
|
||||
entities.Clear();
|
||||
entities.AddRange(shape.Entities);
|
||||
}
|
||||
|
||||
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||
{
|
||||
if (cutout.Entities.Count == 0) return false;
|
||||
var pt = GetStartPoint(cutout.Entities[0]);
|
||||
return region.Contains(pt);
|
||||
}
|
||||
|
||||
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||
{
|
||||
var bb = cutout.BoundingBox;
|
||||
foreach (var sl in splitLines)
|
||||
{
|
||||
if (sl.Axis == CutOffAxis.Vertical && bb.Left < sl.Position && bb.Right > sl.Position)
|
||||
return true;
|
||||
if (sl.Axis == CutOffAxis.Horizontal && bb.Bottom < sl.Position && bb.Top > sl.Position)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
||||
/// intersections, keeping portions inside the region, and closing gaps with
|
||||
/// straight lines. No polygon clipping library needed.
|
||||
/// </summary>
|
||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||
var entities = new List<Entity>();
|
||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
||||
|
||||
foreach (var entity in cutout.Entities)
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
// Close gaps with straight lines (connect exit→entry pairs)
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
{
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
}
|
||||
|
||||
foreach (var kvp in groups)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
|
||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
|
||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
entities.Add(new Line(exits[i], entries[i]));
|
||||
}
|
||||
|
||||
// Ensure CCW winding for cutouts
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
||||
shape.Reverse();
|
||||
|
||||
return shape.Entities;
|
||||
}
|
||||
|
||||
private static Vector GetStartPoint(Entity entity)
|
||||
{
|
||||
return entity switch
|
||||
{
|
||||
Line l => l.StartPoint,
|
||||
Arc a => a.StartPoint(),
|
||||
_ => new Vector(0, 0)
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector GetEndPoint(Entity entity)
|
||||
{
|
||||
return entity switch
|
||||
{
|
||||
Line l => l.EndPoint,
|
||||
Arc a => a.EndPoint(),
|
||||
_ => new Vector(0, 0)
|
||||
};
|
||||
}
|
||||
|
||||
private static ISplitFeature GetFeature(SplitType type)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
SplitType.Straight => new StraightSplit(),
|
||||
SplitType.WeldGapTabs => new WeldGapTabSplit(),
|
||||
SplitType.SpikeGroove => new SpikeGrooveSplit(),
|
||||
_ => new StraightSplit()
|
||||
};
|
||||
}
|
||||
}
|
||||
22
OpenNest.Core/Splitting/ISplitFeature.cs
Normal file
22
OpenNest.Core/Splitting/ISplitFeature.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public class SplitFeatureResult
|
||||
{
|
||||
public List<Entity> NegativeSideEdge { get; }
|
||||
public List<Entity> PositiveSideEdge { get; }
|
||||
|
||||
public SplitFeatureResult(List<Entity> negativeSideEdge, List<Entity> positiveSideEdge)
|
||||
{
|
||||
NegativeSideEdge = negativeSideEdge;
|
||||
PositiveSideEdge = positiveSideEdge;
|
||||
}
|
||||
}
|
||||
|
||||
public interface ISplitFeature
|
||||
{
|
||||
string Name { get; }
|
||||
SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters);
|
||||
}
|
||||
112
OpenNest.Core/Splitting/SpikeGrooveSplit.cs
Normal file
112
OpenNest.Core/Splitting/SpikeGrooveSplit.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Generates interlocking spike/V-groove pairs along the split edge.
|
||||
/// Spikes protrude from the positive side into the negative side.
|
||||
/// V-grooves on the negative side receive the spikes for self-alignment during welding.
|
||||
/// The weld gap (grooveDepth - spikeDepth) is the clearance at the tip when assembled.
|
||||
/// </summary>
|
||||
public class SpikeGrooveSplit : ISplitFeature
|
||||
{
|
||||
public string Name => "Spike / V-Groove";
|
||||
|
||||
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
|
||||
{
|
||||
var extent = extentEnd - extentStart;
|
||||
var pairCount = parameters.SpikePairCount;
|
||||
var spikeDepth = parameters.SpikeDepth;
|
||||
var grooveDepth = parameters.GrooveDepth;
|
||||
var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2);
|
||||
var spikeHalfWidth = spikeDepth * System.Math.Tan(angleRad);
|
||||
var grooveHalfWidth = grooveDepth * System.Math.Tan(angleRad);
|
||||
|
||||
var isVertical = line.Axis == CutOffAxis.Vertical;
|
||||
var pos = line.Position;
|
||||
|
||||
// Use custom positions if provided, otherwise place evenly with margin
|
||||
var pairPositions = new List<double>();
|
||||
if (line.FeaturePositions.Count > 0)
|
||||
{
|
||||
pairPositions.AddRange(line.FeaturePositions);
|
||||
}
|
||||
else if (pairCount == 1)
|
||||
{
|
||||
pairPositions.Add(extentStart + extent / 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
var margin = extent * 0.15;
|
||||
var usable = extent - 2 * margin;
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1));
|
||||
}
|
||||
|
||||
var negEntities = BuildGrooveSide(pairPositions, grooveHalfWidth, grooveDepth, extentStart, extentEnd, pos, isVertical);
|
||||
var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, extentStart, extentEnd, pos, isVertical);
|
||||
|
||||
return new SplitFeatureResult(negEntities, posEntities);
|
||||
}
|
||||
|
||||
private static List<Entity> BuildGrooveSide(List<double> pairPositions, double halfWidth, double depth,
|
||||
double extentStart, double extentEnd, double pos, bool isVertical)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
var cursor = extentStart;
|
||||
|
||||
foreach (var center in pairPositions)
|
||||
{
|
||||
var grooveStart = center - halfWidth;
|
||||
var grooveEnd = center + halfWidth;
|
||||
|
||||
if (grooveStart > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, grooveStart, isVertical));
|
||||
|
||||
entities.Add(MakeLine(pos, grooveStart, pos - depth, center, isVertical));
|
||||
entities.Add(MakeLine(pos - depth, center, pos, grooveEnd, isVertical));
|
||||
|
||||
cursor = grooveEnd;
|
||||
}
|
||||
|
||||
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, extentEnd, isVertical));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> BuildSpikeSide(List<double> pairPositions, double halfWidth, double depth,
|
||||
double extentStart, double extentEnd, double pos, bool isVertical)
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
var cursor = extentEnd;
|
||||
|
||||
for (var i = pairPositions.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var center = pairPositions[i];
|
||||
var spikeEnd = center + halfWidth;
|
||||
var spikeStart = center - halfWidth;
|
||||
|
||||
if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical));
|
||||
|
||||
entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical));
|
||||
entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical));
|
||||
|
||||
cursor = spikeStart;
|
||||
}
|
||||
|
||||
if (cursor > extentStart + OpenNest.Math.Tolerance.Epsilon)
|
||||
entities.Add(MakeLine(pos, cursor, pos, extentStart, isVertical));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static Line MakeLine(double splitAxis1, double along1, double splitAxis2, double along2, bool isVertical)
|
||||
{
|
||||
return isVertical
|
||||
? new Line(new Vector(splitAxis1, along1), new Vector(splitAxis2, along2))
|
||||
: new Line(new Vector(along1, splitAxis1), new Vector(along2, splitAxis2));
|
||||
}
|
||||
}
|
||||
39
OpenNest.Core/Splitting/SplitLine.cs
Normal file
39
OpenNest.Core/Splitting/SplitLine.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a split line at a position along an axis.
|
||||
/// For Vertical, Position is the X coordinate. For Horizontal, Position is the Y coordinate.
|
||||
/// </summary>
|
||||
public class SplitLine
|
||||
{
|
||||
public double Position { get; }
|
||||
public CutOffAxis Axis { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom center positions for features (tabs/spikes) along the split line.
|
||||
/// Values are absolute coordinates on the perpendicular axis.
|
||||
/// When empty, feature generators use their default even spacing.
|
||||
/// </summary>
|
||||
public List<double> FeaturePositions { get; set; } = new();
|
||||
|
||||
public SplitLine(double position, CutOffAxis axis)
|
||||
{
|
||||
Position = position;
|
||||
Axis = axis;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Line entity at the split position spanning the given extent range.
|
||||
/// For Vertical: line from (Position, extentStart) to (Position, extentEnd).
|
||||
/// For Horizontal: line from (extentStart, Position) to (extentEnd, Position).
|
||||
/// </summary>
|
||||
public Line ToLine(double extentStart, double extentEnd)
|
||||
{
|
||||
return Axis == CutOffAxis.Vertical
|
||||
? new Line(Position, extentStart, Position, extentEnd)
|
||||
: new Line(extentStart, Position, extentEnd, Position);
|
||||
}
|
||||
}
|
||||
81
OpenNest.Core/Splitting/SplitLineIntersect.cs
Normal file
81
OpenNest.Core/Splitting/SplitLineIntersect.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Static helpers for testing entity-splitline intersections.
|
||||
/// </summary>
|
||||
public static class SplitLineIntersect
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the intersection point between an entity and a split line.
|
||||
/// Returns null if no intersection or the entity doesn't straddle the split line.
|
||||
/// </summary>
|
||||
public static Vector? FindIntersection(Entity entity, SplitLine sl)
|
||||
{
|
||||
if (!CrossesSplitLine(entity, sl))
|
||||
return null;
|
||||
|
||||
var bbox = entity.BoundingBox;
|
||||
var margin = 1.0;
|
||||
|
||||
// Create a line at the split position spanning the entity's bbox extent (with margin)
|
||||
Line splitLine;
|
||||
|
||||
if (sl.Axis == CutOffAxis.Vertical)
|
||||
splitLine = sl.ToLine(bbox.Bottom - margin, bbox.Top + margin);
|
||||
else
|
||||
splitLine = sl.ToLine(bbox.Left - margin, bbox.Right + margin);
|
||||
|
||||
switch (entity.Type)
|
||||
{
|
||||
case EntityType.Line:
|
||||
var line = (Line)entity;
|
||||
if (Intersect.Intersects(line, splitLine, out var pt))
|
||||
return pt;
|
||||
return null;
|
||||
|
||||
case EntityType.Arc:
|
||||
var arc = (Arc)entity;
|
||||
if (Intersect.Intersects(arc, splitLine, out var pts))
|
||||
return pts.Count > 0 ? pts[0] : null;
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the entity's bounding box straddles the split line,
|
||||
/// meaning it extends to both sides of the split position (not just touching).
|
||||
/// </summary>
|
||||
public static bool CrossesSplitLine(Entity entity, SplitLine sl)
|
||||
{
|
||||
var bbox = entity.BoundingBox;
|
||||
|
||||
if (sl.Axis == CutOffAxis.Vertical)
|
||||
return bbox.Left < sl.Position - Tolerance.Epsilon
|
||||
&& bbox.Right > sl.Position + Tolerance.Epsilon;
|
||||
else
|
||||
return bbox.Bottom < sl.Position - Tolerance.Epsilon
|
||||
&& bbox.Top > sl.Position + Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns -1 if the point is below/left of the split line,
|
||||
/// +1 if above/right, or 0 if on the line (within tolerance).
|
||||
/// </summary>
|
||||
public static int SideOf(Vector pt, SplitLine sl)
|
||||
{
|
||||
var value = sl.Axis == CutOffAxis.Vertical ? pt.X : pt.Y;
|
||||
var diff = value - sl.Position;
|
||||
|
||||
if (System.Math.Abs(diff) <= Tolerance.Epsilon)
|
||||
return 0;
|
||||
|
||||
return diff < 0 ? -1 : 1;
|
||||
}
|
||||
}
|
||||
35
OpenNest.Core/Splitting/SplitParameters.cs
Normal file
35
OpenNest.Core/Splitting/SplitParameters.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace OpenNest;
|
||||
|
||||
public enum SplitType
|
||||
{
|
||||
Straight,
|
||||
WeldGapTabs,
|
||||
SpikeGroove
|
||||
}
|
||||
|
||||
public class SplitParameters
|
||||
{
|
||||
public SplitType Type { get; set; } = SplitType.Straight;
|
||||
|
||||
// Tab parameters
|
||||
public double TabWidth { get; set; } = 1.0;
|
||||
public double TabHeight { get; set; } = 0.125;
|
||||
public int TabCount { get; set; } = 3;
|
||||
|
||||
// Spike/Groove parameters
|
||||
public double SpikeDepth { get; set; } = 0.5;
|
||||
public double GrooveDepth { get; set; } = 0.625;
|
||||
public double SpikeWeldGap { get; set; } = 0.125;
|
||||
public double SpikeAngle { get; set; } = 60.0; // degrees
|
||||
public int SpikePairCount { get; set; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Max protrusion from the split edge (for auto-fit plate size calculation).
|
||||
/// </summary>
|
||||
public double FeatureOverhang => Type switch
|
||||
{
|
||||
SplitType.WeldGapTabs => TabHeight,
|
||||
SplitType.SpikeGroove => System.Math.Max(SpikeDepth, GrooveDepth),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
22
OpenNest.Core/Splitting/StraightSplit.cs
Normal file
22
OpenNest.Core/Splitting/StraightSplit.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
public class StraightSplit : ISplitFeature
|
||||
{
|
||||
public string Name => "Straight";
|
||||
|
||||
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
|
||||
{
|
||||
var (negEdge, posEdge) = line.Axis == CutOffAxis.Vertical
|
||||
? (new Line(new Vector(line.Position, extentStart), new Vector(line.Position, extentEnd)),
|
||||
new Line(new Vector(line.Position, extentEnd), new Vector(line.Position, extentStart)))
|
||||
: (new Line(new Vector(extentStart, line.Position), new Vector(extentEnd, line.Position)),
|
||||
new Line(new Vector(extentEnd, line.Position), new Vector(extentStart, line.Position)));
|
||||
|
||||
return new SplitFeatureResult(
|
||||
new List<Entity> { negEdge },
|
||||
new List<Entity> { posEdge });
|
||||
}
|
||||
}
|
||||
92
OpenNest.Core/Splitting/WeldGapTabSplit.cs
Normal file
92
OpenNest.Core/Splitting/WeldGapTabSplit.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Generates rectangular tabs on one side of the split edge (negative side).
|
||||
/// The positive side remains a straight line. Tabs act as weld-gap spacers.
|
||||
/// </summary>
|
||||
public class WeldGapTabSplit : ISplitFeature
|
||||
{
|
||||
public string Name => "Weld-Gap Tabs";
|
||||
|
||||
public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
|
||||
{
|
||||
var extent = extentEnd - extentStart;
|
||||
var tabCount = parameters.TabCount;
|
||||
var tabWidth = parameters.TabWidth;
|
||||
var tabHeight = parameters.TabHeight;
|
||||
|
||||
// Use custom positions if provided, otherwise evenly space
|
||||
var tabCenters = new List<double>();
|
||||
if (line.FeaturePositions.Count > 0)
|
||||
{
|
||||
tabCenters.AddRange(line.FeaturePositions);
|
||||
}
|
||||
else
|
||||
{
|
||||
var spacing = extent / (tabCount + 1);
|
||||
for (var i = 0; i < tabCount; i++)
|
||||
tabCenters.Add(extentStart + spacing * (i + 1));
|
||||
}
|
||||
|
||||
var negEntities = new List<Entity>();
|
||||
var isVertical = line.Axis == CutOffAxis.Vertical;
|
||||
var pos = line.Position;
|
||||
|
||||
// Tabs protrude toward the negative side (lower coordinate on the split axis)
|
||||
var tabDir = -1.0;
|
||||
|
||||
var cursor = extentStart;
|
||||
|
||||
for (var i = 0; i < tabCenters.Count; i++)
|
||||
{
|
||||
var tabCenter = tabCenters[i];
|
||||
var tabStart = tabCenter - tabWidth / 2;
|
||||
var tabEnd = tabCenter + tabWidth / 2;
|
||||
|
||||
if (isVertical)
|
||||
{
|
||||
if (tabStart > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(pos, cursor), new Vector(pos, tabStart)));
|
||||
|
||||
negEntities.Add(new Line(new Vector(pos, tabStart), new Vector(pos + tabDir * tabHeight, tabStart)));
|
||||
negEntities.Add(new Line(new Vector(pos + tabDir * tabHeight, tabStart), new Vector(pos + tabDir * tabHeight, tabEnd)));
|
||||
negEntities.Add(new Line(new Vector(pos + tabDir * tabHeight, tabEnd), new Vector(pos, tabEnd)));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tabStart > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(cursor, pos), new Vector(tabStart, pos)));
|
||||
|
||||
negEntities.Add(new Line(new Vector(tabStart, pos), new Vector(tabStart, pos + tabDir * tabHeight)));
|
||||
negEntities.Add(new Line(new Vector(tabStart, pos + tabDir * tabHeight), new Vector(tabEnd, pos + tabDir * tabHeight)));
|
||||
negEntities.Add(new Line(new Vector(tabEnd, pos + tabDir * tabHeight), new Vector(tabEnd, pos)));
|
||||
}
|
||||
|
||||
cursor = tabEnd;
|
||||
}
|
||||
|
||||
// Final segment from last tab to extent end
|
||||
if (isVertical)
|
||||
{
|
||||
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(pos, cursor), new Vector(pos, extentEnd)));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
|
||||
negEntities.Add(new Line(new Vector(cursor, pos), new Vector(extentEnd, pos)));
|
||||
}
|
||||
|
||||
// Positive side: plain straight line (reversed direction)
|
||||
var posEntities = new List<Entity>();
|
||||
if (isVertical)
|
||||
posEntities.Add(new Line(new Vector(pos, extentEnd), new Vector(pos, extentStart)));
|
||||
else
|
||||
posEntities.Add(new Line(new Vector(extentEnd, pos), new Vector(extentStart, pos)));
|
||||
|
||||
return new SplitFeatureResult(negEntities, posEntities);
|
||||
}
|
||||
}
|
||||
9
OpenNest.Data/CutOffConfig.cs
Normal file
9
OpenNest.Data/CutOffConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class CutOffConfig
|
||||
{
|
||||
public double PartClearance { get; set; } = 0.02;
|
||||
public double Overtravel { get; set; }
|
||||
public double MinSegmentLength { get; set; } = 0.05;
|
||||
public string Direction { get; set; } = "AwayFromOrigin";
|
||||
}
|
||||
174
OpenNest.Data/Defaults/CL-980.json
Normal file
174
OpenNest.Data/Defaults/CL-980.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"id": "00000000-0000-0000-0000-000000980001",
|
||||
"schemaVersion": 1,
|
||||
"name": "CL-980",
|
||||
"type": "laser",
|
||||
"units": "inches",
|
||||
"materials": [
|
||||
{
|
||||
"name": "Mild Steel",
|
||||
"grade": "A36",
|
||||
"density": 0.2836,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.075,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.105,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.135,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.1875,
|
||||
"kerf": 0.012,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.012,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x120", "60x120", "60x144" ]
|
||||
},
|
||||
{
|
||||
"value": 0.375,
|
||||
"kerf": 0.016,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.375, "angle": 90.0, "radius": 0.1875 },
|
||||
"leadOut": { "type": "Line", "length": 0.1875, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.625, "overtravel": 0.3125, "minSegmentLength": 1.25, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "60x120", "60x144", "72x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.500,
|
||||
"kerf": 0.020,
|
||||
"assistGas": "O2",
|
||||
"leadIn": { "type": "Arc", "length": 0.5, "angle": 90.0, "radius": 0.25 },
|
||||
"leadOut": { "type": "Line", "length": 0.25, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.75, "overtravel": 0.375, "minSegmentLength": 1.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "60x120", "60x144", "72x120" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Stainless Steel",
|
||||
"grade": "304",
|
||||
"density": 0.289,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.075,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.105,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.014,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Aluminum",
|
||||
"grade": "5052",
|
||||
"density": 0.097,
|
||||
"thicknesses": [
|
||||
{
|
||||
"value": 0.060,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.080,
|
||||
"kerf": 0.008,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.125, "angle": 90.0, "radius": 0.0625 },
|
||||
"leadOut": { "type": "Line", "length": 0.0625, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.25, "overtravel": 0.125, "minSegmentLength": 0.5, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.125,
|
||||
"kerf": 0.010,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.1875, "angle": 90.0, "radius": 0.09375 },
|
||||
"leadOut": { "type": "Line", "length": 0.09375, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.375, "overtravel": 0.1875, "minSegmentLength": 0.75, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
},
|
||||
{
|
||||
"value": 0.250,
|
||||
"kerf": 0.014,
|
||||
"assistGas": "N2",
|
||||
"leadIn": { "type": "Arc", "length": 0.25, "angle": 90.0, "radius": 0.125 },
|
||||
"leadOut": { "type": "Line", "length": 0.125, "angle": 90.0, "radius": 0.0 },
|
||||
"cutOff": { "partClearance": 0.5, "overtravel": 0.25, "minSegmentLength": 1.0, "direction": "AwayFromOrigin" },
|
||||
"plateSizes": [ "48x96", "48x120", "60x120" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
9
OpenNest.Data/IDataProvider.cs
Normal file
9
OpenNest.Data/IDataProvider.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public interface IDataProvider
|
||||
{
|
||||
IReadOnlyList<MachineSummary> GetMachines();
|
||||
MachineConfig? GetMachine(Guid id);
|
||||
void SaveMachine(MachineConfig machine);
|
||||
void DeleteMachine(Guid id);
|
||||
}
|
||||
9
OpenNest.Data/LeadConfig.cs
Normal file
9
OpenNest.Data/LeadConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class LeadConfig
|
||||
{
|
||||
public string Type { get; set; } = "Line";
|
||||
public double Length { get; set; }
|
||||
public double Angle { get; set; } = 90.0;
|
||||
public double Radius { get; set; }
|
||||
}
|
||||
112
OpenNest.Data/LocalJsonProvider.cs
Normal file
112
OpenNest.Data/LocalJsonProvider.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class LocalJsonProvider : IDataProvider
|
||||
{
|
||||
private readonly string _directory;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
||||
};
|
||||
|
||||
public LocalJsonProvider(string directory)
|
||||
{
|
||||
_directory = directory;
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
public IReadOnlyList<MachineSummary> GetMachines()
|
||||
{
|
||||
var summaries = new List<MachineSummary>();
|
||||
foreach (var file in Directory.GetFiles(_directory, "*.json"))
|
||||
{
|
||||
var machine = ReadFile(file);
|
||||
if (machine is not null)
|
||||
summaries.Add(new MachineSummary(machine.Id, machine.Name));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
public MachineConfig? GetMachine(Guid id)
|
||||
{
|
||||
var path = GetPath(id);
|
||||
return File.Exists(path) ? ReadFile(path) : null;
|
||||
}
|
||||
|
||||
public void SaveMachine(MachineConfig machine)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(machine, JsonOptions);
|
||||
var path = GetPath(machine.Id);
|
||||
WriteWithRetry(path, json);
|
||||
}
|
||||
|
||||
public void DeleteMachine(Guid id)
|
||||
{
|
||||
var path = GetPath(id);
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
private string GetPath(Guid id) => Path.Combine(_directory, $"{id}.json");
|
||||
|
||||
private static MachineConfig? ReadFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void EnsureDefaults()
|
||||
{
|
||||
if (Directory.GetFiles(_directory, "*.json").Length > 0)
|
||||
return;
|
||||
|
||||
var assembly = typeof(LocalJsonProvider).Assembly;
|
||||
var resourceName = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith("CL-980.json"));
|
||||
|
||||
if (resourceName is null) return;
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null) return;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
|
||||
var config = JsonSerializer.Deserialize<MachineConfig>(json, JsonOptions);
|
||||
if (config is null) return;
|
||||
|
||||
SaveMachine(config);
|
||||
}
|
||||
|
||||
private static void WriteWithRetry(string path, string json, int maxRetries = 3)
|
||||
{
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(path, json);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < maxRetries - 1)
|
||||
{
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
OpenNest.Data/MachineConfig.cs
Normal file
26
OpenNest.Data/MachineConfig.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class MachineConfig
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
public string Name { get; set; } = "";
|
||||
public MachineType Type { get; set; } = MachineType.Laser;
|
||||
public UnitSystem Units { get; set; } = UnitSystem.Inches;
|
||||
public List<MaterialConfig> Materials { get; set; } = new();
|
||||
|
||||
public ThicknessConfig? GetParameters(string material, double thickness)
|
||||
{
|
||||
var mat = GetMaterial(material);
|
||||
if (mat is null) return null;
|
||||
return mat.Thicknesses.FirstOrDefault(t => t.Value.IsEqualTo(thickness));
|
||||
}
|
||||
|
||||
public MaterialConfig? GetMaterial(string name)
|
||||
{
|
||||
return Materials.FirstOrDefault(m =>
|
||||
string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
3
OpenNest.Data/MachineSummary.cs
Normal file
3
OpenNest.Data/MachineSummary.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public record MachineSummary(Guid Id, string Name);
|
||||
8
OpenNest.Data/MachineType.cs
Normal file
8
OpenNest.Data/MachineType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public enum MachineType
|
||||
{
|
||||
Laser,
|
||||
Plasma,
|
||||
Waterjet
|
||||
}
|
||||
9
OpenNest.Data/MaterialConfig.cs
Normal file
9
OpenNest.Data/MaterialConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class MaterialConfig
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Grade { get; set; } = "";
|
||||
public double Density { get; set; }
|
||||
public List<ThicknessConfig> Thicknesses { get; set; } = new();
|
||||
}
|
||||
15
OpenNest.Data/OpenNest.Data.csproj
Normal file
15
OpenNest.Data/OpenNest.Data.csproj
Normal file
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Data</RootNamespace>
|
||||
<AssemblyName>OpenNest.Data</AssemblyName>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Defaults\CL-980.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
14
OpenNest.Data/ThicknessConfig.cs
Normal file
14
OpenNest.Data/ThicknessConfig.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public class ThicknessConfig
|
||||
{
|
||||
public double Value { get; set; }
|
||||
public double Kerf { get; set; }
|
||||
public string AssistGas { get; set; } = "";
|
||||
public LeadConfig LeadIn { get; set; } = new();
|
||||
public LeadConfig LeadOut { get; set; } = new();
|
||||
public CutOffConfig CutOff { get; set; } = new();
|
||||
public List<string> PlateSizes { get; set; } = new();
|
||||
}
|
||||
7
OpenNest.Data/UnitSystem.cs
Normal file
7
OpenNest.Data/UnitSystem.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public enum UnitSystem
|
||||
{
|
||||
Inches,
|
||||
Millimeters
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace OpenNest.Engine.BestFit
|
||||
public double MaxPlateHeight { get; set; }
|
||||
public double MaxAspectRatio { get; set; } = 5.0;
|
||||
public double MinUtilization { get; set; } = 0.3;
|
||||
public double UtilizationOverride { get; set; } = 0.75;
|
||||
|
||||
public void Apply(List<BestFitResult> results)
|
||||
{
|
||||
@@ -25,7 +26,7 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var aspect = result.LongestSide / result.ShortestSide;
|
||||
|
||||
if (aspect > MaxAspectRatio)
|
||||
if (aspect > MaxAspectRatio && result.Utilization < UtilizationOverride)
|
||||
{
|
||||
result.Keep = false;
|
||||
result.Reason = string.Format("Aspect ratio {0:F1} exceeds max {1}", aspect, MaxAspectRatio);
|
||||
|
||||
@@ -22,10 +22,11 @@ namespace OpenNest.Engine.BestFit
|
||||
if (perimeter == null)
|
||||
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).
|
||||
// Ensure CW winding for correct outward offset direction.
|
||||
definedShape.NormalizeWinding();
|
||||
|
||||
var inflated = halfSpacing > 0
|
||||
? (perimeter.OffsetEntity(halfSpacing, OffsetSide.Right) as Shape ?? perimeter)
|
||||
? (perimeter.OffsetOutward(halfSpacing) ?? perimeter)
|
||||
: perimeter;
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
|
||||
@@ -64,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;
|
||||
}
|
||||
@@ -94,8 +101,15 @@ namespace OpenNest
|
||||
|
||||
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>();
|
||||
}
|
||||
@@ -151,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ namespace OpenNest.Engine.Fill
|
||||
continue;
|
||||
|
||||
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||
var d = gap - partSpacing;
|
||||
var d = gap - partSpacing - 2 * ChordTolerance;
|
||||
if (d < 0) d = 0;
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
|
||||
@@ -3,6 +3,7 @@ using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
@@ -36,18 +37,44 @@ 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");
|
||||
// The iterative pair adjustment can shift parts enough to cause
|
||||
// genuine overlap. Fall back to the unadjusted column when this happens.
|
||||
if (HasOverlappingParts(adjusted))
|
||||
{
|
||||
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
|
||||
adjusted = column;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: 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;
|
||||
}
|
||||
@@ -323,6 +350,21 @@ namespace OpenNest.Engine.Fill
|
||||
if (copyDistance <= Tolerance.Epsilon)
|
||||
copyDistance = columnWidth + partSpacing;
|
||||
|
||||
// Safety: if the compacted test column overlaps the original column,
|
||||
// fall back to bbox-based spacing.
|
||||
var probe = new List<Part>(column);
|
||||
probe.AddRange(testColumn.Where(IsWithinWorkArea));
|
||||
if (HasOverlappingParts(probe))
|
||||
{
|
||||
Debug.WriteLine($"[FillExtents] Compacted column overlaps, falling back to bbox spacing");
|
||||
copyDistance = columnWidth + partSpacing;
|
||||
|
||||
// Rebuild test column at safe distance.
|
||||
testColumn.Clear();
|
||||
foreach (var part in column)
|
||||
testColumn.Add(part.CloneAtOffset(new Vector(copyDistance, 0)));
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[FillExtents] Column copy distance: {copyDistance:F2} (bbox width: {columnWidth:F2}, spacing: {partSpacing:F2})");
|
||||
|
||||
// Build all columns.
|
||||
@@ -368,5 +410,31 @@ namespace OpenNest.Engine.Fill
|
||||
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
||||
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,6 +287,65 @@ namespace OpenNest.Engine.Fill
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback tiling using bounding-box spacing when geometry-aware tiling
|
||||
/// produces overlapping parts.
|
||||
/// </summary>
|
||||
private List<Part> TilePatternBbox(Pattern basePattern, NestDirection direction)
|
||||
{
|
||||
var copyDistance = GetDimension(basePattern.BoundingBox, direction) + PartSpacing;
|
||||
|
||||
if (copyDistance <= 0)
|
||||
return new List<Part>();
|
||||
|
||||
var dim = GetDimension(basePattern.BoundingBox, direction);
|
||||
var start = GetStart(basePattern.BoundingBox, direction);
|
||||
var limit = GetLimit(direction);
|
||||
|
||||
var result = new List<Part>();
|
||||
var count = 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var nextPos = start + copyDistance * count;
|
||||
|
||||
if (nextPos + dim > limit + Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
var offset = MakeOffset(direction, copyDistance * count);
|
||||
|
||||
foreach (var part in basePattern.Parts)
|
||||
result.Add(part.CloneAtOffset(offset));
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a seed pattern containing a single part positioned at the work area origin.
|
||||
/// Returns an empty pattern if the part does not fit.
|
||||
@@ -325,10 +384,25 @@ namespace OpenNest.Engine.Fill
|
||||
var row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
|
||||
// Safety: if geometry-aware spacing produced overlapping parts,
|
||||
// fall back to bbox-based spacing for this axis.
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
|
||||
{
|
||||
row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePatternBbox(pattern, direction));
|
||||
}
|
||||
|
||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||
if (row.Count <= pattern.Parts.Count)
|
||||
{
|
||||
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
|
||||
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row))
|
||||
{
|
||||
row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePatternBbox(pattern, perpAxis));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Engine;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
@@ -28,15 +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, IFillComparer comparer = null)
|
||||
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,
|
||||
@@ -66,32 +71,60 @@ namespace OpenNest.Engine.Fill
|
||||
List<Part> best = null;
|
||||
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 batchEnd = System.Math.Min(batchStart + batchSize, candidates.Count);
|
||||
var batchCount = batchEnd - batchStart;
|
||||
var batchWorkArea = effectiveWorkArea;
|
||||
var minCountToBeat = best?.Count ?? 0;
|
||||
|
||||
if (comparer.IsBetter(filled, best, effectiveWorkArea))
|
||||
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;
|
||||
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 = {best?.Count ?? 0} 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,6 +133,10 @@ namespace OpenNest.Engine.Fill
|
||||
{
|
||||
Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
|
||||
}
|
||||
finally
|
||||
{
|
||||
FillStrategyRegistry.SetEnabled(null);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts");
|
||||
return best ?? new List<Part>();
|
||||
@@ -142,12 +179,172 @@ 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, comparer);
|
||||
|
||||
// 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 filler = new FillLinear(remnantBox, partSpacing);
|
||||
List<Part> parts = null;
|
||||
|
||||
foreach (var angle in new[] { 0.0, Angle.HalfPI })
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var result = FillHelpers.FillWithDirectionPreference(
|
||||
dir => filler.Fill(drawing, angle, dir),
|
||||
null, comparer, remnantBox);
|
||||
|
||||
if (result != null && result.Count > (parts?.Count ?? 0))
|
||||
parts = result;
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
if (perimeter != null)
|
||||
{
|
||||
var offsetEntity = perimeter.OffsetEntity(spacing, OffsetSide.Left) as Shape;
|
||||
var offsetEntity = perimeter.OffsetOutward(spacing);
|
||||
|
||||
if (offsetEntity != null)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public class StripeFiller
|
||||
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.
|
||||
@@ -38,6 +39,7 @@ public class StripeFiller
|
||||
_context = context;
|
||||
_primaryAxis = primaryAxis;
|
||||
_comparer = context.Policy?.Comparer ?? new DefaultFillComparer();
|
||||
_dedup = GridDedup.GetOrCreate(context.SharedState);
|
||||
}
|
||||
|
||||
public List<Part> Fill()
|
||||
@@ -93,9 +95,14 @@ public class StripeFiller
|
||||
}
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(_context.Progress, NestPhase.Custom,
|
||||
_context.PlateNumber, bestParts, workArea,
|
||||
$"{strategyName}: {i + 1}/{bestFits.Count} pairs, best = {bestParts?.Count ?? 0} parts");
|
||||
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>();
|
||||
@@ -110,6 +117,10 @@ public class StripeFiller
|
||||
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);
|
||||
|
||||
@@ -147,6 +158,15 @@ public class StripeFiller
|
||||
if (gridParts.Count == 0)
|
||||
return null;
|
||||
|
||||
// Reject results where bounding boxes overlap — the angle convergence
|
||||
// can produce slightly off-axis rotations where FillLinear's copy
|
||||
// distance calculation doesn't fully account for the rotated geometry.
|
||||
if (HasOverlappingParts(gridParts))
|
||||
{
|
||||
Debug.WriteLine($"[StripeFiller] Rejected grid: overlapping bounding boxes detected");
|
||||
return null;
|
||||
}
|
||||
|
||||
var allParts = new List<Part>(gridParts);
|
||||
|
||||
var remnantParts = FillRemnant(gridParts, primaryAxis);
|
||||
@@ -224,28 +244,29 @@ public class StripeFiller
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
FillStrategyRegistry.SetEnabled("Pairs", "RectBestFit", "Extents", "Linear");
|
||||
try
|
||||
var filler = new FillLinear(remnantBox, spacing);
|
||||
List<Part> best = null;
|
||||
|
||||
foreach (var angle in new[] { 0.0, Angle.HalfPI })
|
||||
{
|
||||
var engine = CreateRemnantEngine(_context.Plate);
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
var parts = engine.Fill(item, remnantBox, _context.Progress, _context.Token);
|
||||
_context.Token.ThrowIfCancellationRequested();
|
||||
var result = FillHelpers.FillWithDirectionPreference(
|
||||
dir => filler.Fill(drawing, angle, dir),
|
||||
null, _comparer, remnantBox);
|
||||
|
||||
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;
|
||||
if (result != null && result.Count > (best?.Count ?? 0))
|
||||
best = result;
|
||||
}
|
||||
finally
|
||||
|
||||
Debug.WriteLine($"[StripeFiller] Remnant linear: {best?.Count ?? 0} parts");
|
||||
|
||||
if (best != null && best.Count > 0)
|
||||
{
|
||||
FillStrategyRegistry.SetEnabled(null);
|
||||
FillResultCache.Store(drawing, remnantBox, spacing, best);
|
||||
return best;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static double FindAngleForTargetSpan(
|
||||
@@ -459,4 +480,34 @@ public class StripeFiller
|
||||
{
|
||||
return axis == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if any pair of parts geometrically overlap. Uses bounding box
|
||||
/// pre-filtering for performance, then falls back to shape intersection.
|
||||
/// </summary>
|
||||
private static bool HasOverlappingParts(List<Part> parts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var b1 = parts[i].BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < parts.Count; j++)
|
||||
{
|
||||
var b2 = parts[j].BoundingBox;
|
||||
|
||||
var overlapX = System.Math.Min(b1.Right, b2.Right)
|
||||
- System.Math.Max(b1.Left, b2.Left);
|
||||
var overlapY = System.Math.Min(b1.Top, b2.Top)
|
||||
- System.Math.Max(b1.Bottom, b2.Bottom);
|
||||
|
||||
if (overlapX <= Tolerance.Epsilon || overlapY <= Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (parts[i].Intersects(parts[j], out _))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace OpenNest
|
||||
|
||||
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 };
|
||||
|
||||
@@ -43,6 +43,8 @@ namespace OpenNest
|
||||
|
||||
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 };
|
||||
@@ -210,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,7 +243,7 @@ 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);
|
||||
}
|
||||
@@ -323,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,10 +21,6 @@ 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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,47 @@ 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();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all registered strategies regardless of enabled/disabled state.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<IFillStrategy> AllStrategies =>
|
||||
strategies.OrderBy(s => s.Order).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the names of all permanently disabled strategies.
|
||||
/// </summary>
|
||||
public static IReadOnlyCollection<string> DisabledNames => disabled;
|
||||
|
||||
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.
|
||||
|
||||
@@ -47,9 +47,14 @@ namespace OpenNest.Engine.Strategies
|
||||
best = result;
|
||||
}
|
||||
|
||||
NestEngineBase.ReportProgress(context.Progress, NestPhase.Linear,
|
||||
context.PlateNumber, best, workArea,
|
||||
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {best?.Count ?? 0} parts");
|
||||
NestEngineBase.ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
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,24 +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 comparer = context.Policy?.Comparer;
|
||||
var filler = new PairFiller(context.Plate.Size, context.Plate.PartSpacing, comparer);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
40
OpenNest.IO/Bending/BendDetectorRegistry.cs
Normal file
40
OpenNest.IO/Bending/BendDetectorRegistry.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using ACadSharp;
|
||||
using OpenNest.Bending;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.IO.Bending
|
||||
{
|
||||
public static class BendDetectorRegistry
|
||||
{
|
||||
private static readonly List<IBendDetector> detectors = new();
|
||||
|
||||
static BendDetectorRegistry()
|
||||
{
|
||||
Register(new SolidWorksBendDetector());
|
||||
}
|
||||
|
||||
public static void Register(IBendDetector detector)
|
||||
{
|
||||
detectors.Add(detector);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<IBendDetector> Detectors => detectors;
|
||||
|
||||
public static IBendDetector GetByName(string name)
|
||||
{
|
||||
return detectors.FirstOrDefault(d => d.Name == name);
|
||||
}
|
||||
|
||||
public static List<Bend> AutoDetect(CadDocument document)
|
||||
{
|
||||
foreach (var detector in detectors)
|
||||
{
|
||||
var bends = detector.DetectBends(document);
|
||||
if (bends.Count > 0)
|
||||
return bends;
|
||||
}
|
||||
return new List<Bend>();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
OpenNest.IO/Bending/IBendDetector.cs
Normal file
12
OpenNest.IO/Bending/IBendDetector.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ACadSharp;
|
||||
using OpenNest.Bending;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.IO.Bending
|
||||
{
|
||||
public interface IBendDetector
|
||||
{
|
||||
string Name { get; }
|
||||
List<Bend> DetectBends(CadDocument document);
|
||||
}
|
||||
}
|
||||
236
OpenNest.IO/Bending/SolidWorksBendDetector.cs
Normal file
236
OpenNest.IO/Bending/SolidWorksBendDetector.cs
Normal file
@@ -0,0 +1,236 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.Entities;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenNest.IO.Bending
|
||||
{
|
||||
public class SolidWorksBendDetector : IBendDetector
|
||||
{
|
||||
public string Name => "SolidWorks";
|
||||
|
||||
public double MaxBendRadius { get; set; } = 4.0;
|
||||
|
||||
private static readonly Regex BendNoteRegex = new Regex(
|
||||
@"(?<direction>UP|DOWN|DN)\s+(?<angle>\d+(\.\d+)?)[^A-Z\d]*R\s*(?<radius>\d+(\.\d+)?)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex MTextFormatRegex = new Regex(
|
||||
@"\\[fHCTQWASpOoLlKk][^;]*;|\\P|[{}]|%%[dDpPcC]",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex UnicodeEscapeRegex = new Regex(
|
||||
@"\\U\+([0-9A-Fa-f]{4})",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public List<Bend> DetectBends(CadDocument document)
|
||||
{
|
||||
var bendLines = FindBendLines(document);
|
||||
var bendNotes = FindBendNotes(document);
|
||||
|
||||
if (bendLines.Count == 0)
|
||||
return new List<Bend>();
|
||||
|
||||
var bends = new List<Bend>();
|
||||
|
||||
foreach (var line in bendLines)
|
||||
{
|
||||
var start = new Vector(line.StartPoint.X, line.StartPoint.Y);
|
||||
var end = new Vector(line.EndPoint.X, line.EndPoint.Y);
|
||||
|
||||
var bend = new Bend
|
||||
{
|
||||
StartPoint = start,
|
||||
EndPoint = end,
|
||||
Direction = BendDirection.Unknown
|
||||
};
|
||||
|
||||
var note = FindClosestBendNote(line, bendNotes);
|
||||
if (note != null)
|
||||
{
|
||||
var noteText = StripMTextFormatting(note.Value);
|
||||
bend.Direction = GetBendDirection(noteText);
|
||||
bend.NoteText = noteText;
|
||||
ParseBendNote(noteText, bend);
|
||||
}
|
||||
|
||||
if (!bend.Radius.HasValue || bend.Radius.Value <= MaxBendRadius)
|
||||
bends.Add(bend);
|
||||
}
|
||||
|
||||
PropagateCollinearBendNotes(bends);
|
||||
|
||||
return bends;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For bends without a note (e.g. split by a cutout), copy angle/radius/direction
|
||||
/// from a collinear bend that does have a note.
|
||||
/// </summary>
|
||||
private static void PropagateCollinearBendNotes(List<Bend> bends)
|
||||
{
|
||||
const double angleTolerance = 0.01; // radians
|
||||
const double distanceTolerance = 0.01;
|
||||
|
||||
foreach (var bend in bends)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(bend.NoteText))
|
||||
continue;
|
||||
|
||||
foreach (var other in bends)
|
||||
{
|
||||
if (string.IsNullOrEmpty(other.NoteText))
|
||||
continue;
|
||||
|
||||
if (!AreCollinear(bend, other, angleTolerance, distanceTolerance))
|
||||
continue;
|
||||
|
||||
bend.Direction = other.Direction;
|
||||
bend.Angle = other.Angle;
|
||||
bend.Radius = other.Radius;
|
||||
bend.NoteText = other.NoteText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AreCollinear(Bend a, Bend b, double angleTolerance, double distanceTolerance)
|
||||
{
|
||||
var angleA = a.StartPoint.AngleTo(a.EndPoint);
|
||||
var angleB = b.StartPoint.AngleTo(b.EndPoint);
|
||||
|
||||
// Normalize angle difference to [0, PI) since opposite directions are still collinear
|
||||
var diff = System.Math.Abs(angleA - angleB) % System.Math.PI;
|
||||
if (diff > angleTolerance && System.Math.PI - diff > angleTolerance)
|
||||
return false;
|
||||
|
||||
// Perpendicular distance from midpoint of A to the infinite line through B
|
||||
var midA = new Vector(
|
||||
(a.StartPoint.X + a.EndPoint.X) / 2.0,
|
||||
(a.StartPoint.Y + a.EndPoint.Y) / 2.0);
|
||||
|
||||
var dx = b.EndPoint.X - b.StartPoint.X;
|
||||
var dy = b.EndPoint.Y - b.StartPoint.Y;
|
||||
var len = System.Math.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (len < 1e-9)
|
||||
return false;
|
||||
|
||||
// 2D cross product gives signed perpendicular distance * length
|
||||
var vx = midA.X - b.StartPoint.X;
|
||||
var vy = midA.Y - b.StartPoint.Y;
|
||||
var perp = System.Math.Abs(vx * dy - vy * dx) / len;
|
||||
|
||||
return perp <= distanceTolerance;
|
||||
}
|
||||
|
||||
private List<ACadSharp.Entities.Line> FindBendLines(CadDocument document)
|
||||
{
|
||||
return document.Entities
|
||||
.OfType<ACadSharp.Entities.Line>()
|
||||
.Where(l => l.Layer?.Name == "BEND"
|
||||
&& (l.LineType?.Name?.Contains("CENTER") == true
|
||||
|| l.LineType?.Name == "CENTERX2"))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private List<MText> FindBendNotes(CadDocument document)
|
||||
{
|
||||
return document.Entities
|
||||
.OfType<MText>()
|
||||
.Where(t => GetBendDirection(t.Value) != BendDirection.Unknown)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static BendDirection GetBendDirection(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return BendDirection.Unknown;
|
||||
|
||||
var upper = text.ToUpperInvariant();
|
||||
|
||||
if (upper.Contains("UP"))
|
||||
return BendDirection.Up;
|
||||
|
||||
if (upper.Contains("DOWN") || upper.Contains("DN"))
|
||||
return BendDirection.Down;
|
||||
|
||||
return BendDirection.Unknown;
|
||||
}
|
||||
|
||||
private static void ParseBendNote(string text, Bend bend)
|
||||
{
|
||||
var normalized = text.ToUpperInvariant().Replace("SHARP", "R0");
|
||||
var match = BendNoteRegex.Match(normalized);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
if (double.TryParse(match.Groups["radius"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var radius))
|
||||
bend.Radius = radius;
|
||||
|
||||
if (double.TryParse(match.Groups["angle"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var angle))
|
||||
bend.Angle = angle;
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripMTextFormatting(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return text;
|
||||
|
||||
// Convert \U+XXXX DXF unicode escapes to actual characters
|
||||
var result = UnicodeEscapeRegex.Replace(text, m =>
|
||||
{
|
||||
var codePoint = int.Parse(m.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
return char.ConvertFromUtf32(codePoint);
|
||||
});
|
||||
|
||||
// Replace known DXF special characters
|
||||
result = result
|
||||
.Replace("%%d", "°").Replace("%%D", "°")
|
||||
.Replace("%%p", "±").Replace("%%P", "±")
|
||||
.Replace("%%c", "⌀").Replace("%%C", "⌀");
|
||||
|
||||
// Strip MText formatting codes and braces
|
||||
result = MTextFormatRegex.Replace(result, " ");
|
||||
|
||||
// Collapse multiple spaces
|
||||
return Regex.Replace(result.Trim(), @"\s+", " ");
|
||||
}
|
||||
|
||||
private MText FindClosestBendNote(ACadSharp.Entities.Line bendLine, List<MText> notes)
|
||||
{
|
||||
if (notes.Count == 0) return null;
|
||||
|
||||
MText closest = null;
|
||||
var closestDist = double.MaxValue;
|
||||
|
||||
foreach (var note in notes)
|
||||
{
|
||||
var notePos = new Vector(note.InsertPoint.X, note.InsertPoint.Y);
|
||||
var lineStart = new Vector(bendLine.StartPoint.X, bendLine.StartPoint.Y);
|
||||
var lineEnd = new Vector(bendLine.EndPoint.X, bendLine.EndPoint.Y);
|
||||
|
||||
var geomLine = new OpenNest.Geometry.Line(lineStart, lineEnd);
|
||||
var perpPoint = geomLine.ClosestPointTo(notePos);
|
||||
var dist = notePos.DistanceTo(perpPoint);
|
||||
|
||||
var maxAcceptable = note.Height * 2.0;
|
||||
if (dist > maxAcceptable) continue;
|
||||
|
||||
if (dist < closestDist)
|
||||
{
|
||||
closestDist = dist;
|
||||
closest = note;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
OpenNest.IO/Bom/BomAnalyzer.cs
Normal file
100
OpenNest.IO/Bom/BomAnalyzer.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.IO.Bom
|
||||
{
|
||||
public class BomAnalysis
|
||||
{
|
||||
public List<MaterialGroup> Groups { get; set; } = new List<MaterialGroup>();
|
||||
public List<BomItem> Skipped { get; set; } = new List<BomItem>();
|
||||
public List<BomItem> Unmatched { get; set; } = new List<BomItem>();
|
||||
}
|
||||
|
||||
public class MaterialGroup
|
||||
{
|
||||
public string Material { get; set; }
|
||||
public double Thickness { get; set; }
|
||||
public List<MatchedPart> Parts { get; set; } = new List<MatchedPart>();
|
||||
}
|
||||
|
||||
public class MatchedPart
|
||||
{
|
||||
public BomItem Item { get; set; }
|
||||
public string DxfPath { get; set; }
|
||||
}
|
||||
|
||||
public static class BomAnalyzer
|
||||
{
|
||||
public static BomAnalysis Analyze(List<BomItem> items, string dxfFolder)
|
||||
{
|
||||
var result = new BomAnalysis();
|
||||
|
||||
// Build a case-insensitive lookup of DXF files in the folder (if it exists)
|
||||
var folderExists = Directory.Exists(dxfFolder);
|
||||
var dxfFiles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (folderExists)
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(dxfFolder, "*.dxf"))
|
||||
{
|
||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
|
||||
dxfFiles[nameWithoutExt] = file;
|
||||
}
|
||||
}
|
||||
|
||||
// Partition items into: skipped, unmatched, or matched (grouped)
|
||||
var matched = new List<MatchedPart>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.FileName) || !item.Thickness.HasValue)
|
||||
{
|
||||
result.Skipped.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
var lookupName = item.FileName;
|
||||
|
||||
// Strip .dxf extension if the BOM includes it
|
||||
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase))
|
||||
lookupName = Path.GetFileNameWithoutExtension(lookupName);
|
||||
|
||||
if (!folderExists)
|
||||
{
|
||||
// No folder to search — group items without a DXF path
|
||||
matched.Add(new MatchedPart { Item = item, DxfPath = null });
|
||||
}
|
||||
else if (dxfFiles.TryGetValue(lookupName, out var dxfPath))
|
||||
{
|
||||
matched.Add(new MatchedPart { Item = item, DxfPath = dxfPath });
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Unmatched.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Group matched parts by material + thickness
|
||||
var groups = matched
|
||||
.GroupBy(p => new
|
||||
{
|
||||
Material = (p.Item.Material ?? "").ToUpperInvariant(),
|
||||
Thickness = p.Item.Thickness.Value
|
||||
})
|
||||
.Select(g => new MaterialGroup
|
||||
{
|
||||
Material = g.First().Item.Material ?? "",
|
||||
Thickness = g.Key.Thickness,
|
||||
Parts = g.ToList()
|
||||
})
|
||||
.OrderBy(g => g.Material)
|
||||
.ThenBy(g => g.Thickness)
|
||||
.ToList();
|
||||
|
||||
result.Groups = groups;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
OpenNest.IO/Bom/BomItem.cs
Normal file
35
OpenNest.IO/Bom/BomItem.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
namespace OpenNest.IO.Bom
|
||||
{
|
||||
public class BomItem
|
||||
{
|
||||
[Column("Item #", "Item Number", "Item Num")]
|
||||
public int? ItemNum { get; set; }
|
||||
|
||||
[Column("File Name")]
|
||||
public string FileName { get; set; }
|
||||
|
||||
[Column("Qty", "Quantity")]
|
||||
public int? Qty { get; set; }
|
||||
|
||||
[Column("Description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[Column("Part", "Part Name")]
|
||||
public string PartName { get; set; }
|
||||
|
||||
[Column("Config", "Configuration")]
|
||||
public string ConfigurationName { get; set; }
|
||||
|
||||
[Column("Thickness")]
|
||||
public double? Thickness { get; set; }
|
||||
|
||||
[Column("Material")]
|
||||
public string Material { get; set; }
|
||||
|
||||
[Column("K-Factor")]
|
||||
public double? KFactor { get; set; }
|
||||
|
||||
[Column("Default Bend Radius")]
|
||||
public double? DefaultBendRadius { get; set; }
|
||||
}
|
||||
}
|
||||
99
OpenNest.IO/Bom/BomReader.cs
Normal file
99
OpenNest.IO/Bom/BomReader.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using ClosedXML.Excel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace OpenNest.IO.Bom
|
||||
{
|
||||
public class BomReader : IDisposable
|
||||
{
|
||||
private readonly XLWorkbook workbook;
|
||||
private Dictionary<PropertyInfo, int> columnNameIndexDict;
|
||||
|
||||
public BomReader(string file)
|
||||
{
|
||||
workbook = new XLWorkbook(file);
|
||||
columnNameIndexDict = new Dictionary<PropertyInfo, int>();
|
||||
}
|
||||
|
||||
private IXLWorksheet GetPartsWorksheet()
|
||||
{
|
||||
if (!workbook.TryGetWorksheet("Parts", out var worksheet))
|
||||
throw new InvalidOperationException("BOM file does not contain a 'Parts' worksheet.");
|
||||
return worksheet;
|
||||
}
|
||||
|
||||
private void FindColumnIndexes(IXLWorksheet worksheet)
|
||||
{
|
||||
var lastColumn = worksheet.LastColumnUsed()?.ColumnNumber() ?? 0;
|
||||
var properties = typeof(BomItem).GetProperties();
|
||||
|
||||
foreach (var property in properties)
|
||||
{
|
||||
var column = property.GetCustomAttribute<ColumnAttribute>();
|
||||
|
||||
if (column == null)
|
||||
continue;
|
||||
|
||||
var classColumnNames = column.Names.Select(n => n.ToUpper());
|
||||
|
||||
for (var columnIndex = 1; columnIndex <= lastColumn; columnIndex++)
|
||||
{
|
||||
var cell = worksheet.Cell(1, columnIndex);
|
||||
if (cell.IsEmpty()) continue;
|
||||
|
||||
var excelColumnName = cell.GetString().ToUpper();
|
||||
var isMatch = classColumnNames.Any(n => n == excelColumnName);
|
||||
|
||||
if (!isMatch)
|
||||
continue;
|
||||
|
||||
columnNameIndexDict.Add(property, columnIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<BomItem> GetItems()
|
||||
{
|
||||
var worksheet = GetPartsWorksheet();
|
||||
|
||||
FindColumnIndexes(worksheet);
|
||||
|
||||
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
|
||||
var items = new List<BomItem>();
|
||||
|
||||
for (var rowIndex = 2; rowIndex <= lastRow; rowIndex++)
|
||||
{
|
||||
var item = new BomItem();
|
||||
|
||||
foreach (var dictItem in columnNameIndexDict)
|
||||
{
|
||||
var property = dictItem.Key;
|
||||
var excelColumnIndex = dictItem.Value;
|
||||
var cell = worksheet.Cell(rowIndex, excelColumnIndex);
|
||||
var type = property.PropertyType;
|
||||
|
||||
if (type == typeof(int?))
|
||||
property.SetValue(item, cell.ToIntOrNull());
|
||||
else if (type == typeof(string))
|
||||
property.SetValue(item, cell.IsEmpty() ? null : cell.GetString());
|
||||
else if (type == typeof(double?))
|
||||
property.SetValue(item, cell.ToDoubleOrNull());
|
||||
else
|
||||
throw new NotImplementedException($"Unsupported property type: {type}");
|
||||
}
|
||||
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
workbook?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
23
OpenNest.IO/Bom/CellExtensions.cs
Normal file
23
OpenNest.IO/Bom/CellExtensions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace OpenNest.IO.Bom
|
||||
{
|
||||
public static class CellExtensions
|
||||
{
|
||||
public static int? ToIntOrNull(this IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty()) return null;
|
||||
if (cell.DataType == XLDataType.Number) return (int)cell.GetDouble();
|
||||
if (int.TryParse(cell.GetString(), out var i)) return i;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static double? ToDoubleOrNull(this IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty()) return null;
|
||||
if (cell.DataType == XLDataType.Number) return cell.GetDouble();
|
||||
if (double.TryParse(cell.GetString(), out var result)) return result;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
OpenNest.IO/Bom/ColumnAttribute.cs
Normal file
15
OpenNest.IO/Bom/ColumnAttribute.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace OpenNest.IO.Bom
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class ColumnAttribute : Attribute
|
||||
{
|
||||
public ColumnAttribute(params string[] names)
|
||||
{
|
||||
Names = names;
|
||||
}
|
||||
|
||||
public string[] Names { get; }
|
||||
}
|
||||
}
|
||||
76
OpenNest.IO/Bom/Fraction.cs
Normal file
76
OpenNest.IO/Bom/Fraction.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace OpenNest.IO.Bom
|
||||
{
|
||||
public static class Fraction
|
||||
{
|
||||
public static readonly Regex FractionRegex =
|
||||
new Regex(@"((?<WholeNum>\d+)(\ |-))?(?<Fraction>\d+\/\d+)");
|
||||
|
||||
public static bool IsValid(string s)
|
||||
{
|
||||
return FractionRegex.IsMatch(s);
|
||||
}
|
||||
|
||||
public static double Parse(string s)
|
||||
{
|
||||
var match = FractionRegex.Match(s);
|
||||
|
||||
if (!match.Success)
|
||||
throw new FormatException("Invalid fraction format.");
|
||||
|
||||
var value = 0.0;
|
||||
|
||||
var wholeNumGroup = match.Groups["WholeNum"];
|
||||
var fractionGroup = match.Groups["Fraction"];
|
||||
|
||||
if (wholeNumGroup.Success)
|
||||
value = double.Parse(wholeNumGroup.Value);
|
||||
|
||||
if (fractionGroup.Success)
|
||||
{
|
||||
var parts = fractionGroup.Value.Split('/');
|
||||
var numerator = double.Parse(parts[0]);
|
||||
var denominator = double.Parse(parts[1]);
|
||||
value += System.Math.Round(numerator / denominator, 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static bool TryParse(string s, out double fraction)
|
||||
{
|
||||
try
|
||||
{
|
||||
fraction = Parse(s);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
fraction = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string ReplaceFractionsWithDecimals(string input)
|
||||
{
|
||||
var sb = new StringBuilder(input);
|
||||
|
||||
var fractionMatches = FractionRegex.Matches(sb.ToString())
|
||||
.Cast<Match>()
|
||||
.OrderByDescending(m => m.Index);
|
||||
|
||||
foreach (var fractionMatch in fractionMatches)
|
||||
{
|
||||
var decimalValue = Parse(fractionMatch.Value);
|
||||
sb.Remove(fractionMatch.Index, fractionMatch.Length);
|
||||
sb.Insert(fractionMatch.Index, decimalValue);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
OpenNest.IO/DxfImportResult.cs
Normal file
12
OpenNest.IO/DxfImportResult.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using ACadSharp;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public class DxfImportResult
|
||||
{
|
||||
public List<Entity> Entities { get; set; } = new();
|
||||
public CadDocument Document { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ namespace OpenNest.IO
|
||||
|
||||
foreach (var entity in doc.Entities)
|
||||
{
|
||||
// Skip bend/etch entities — bends are converted to Bend objects
|
||||
// separately via bend detection, and etch marks are generated from
|
||||
// bends during DXF export. Neither should be treated as cut geometry.
|
||||
if (IsNonCutLayer(entity.Layer?.Name))
|
||||
continue;
|
||||
|
||||
switch (entity)
|
||||
{
|
||||
case ACadSharp.Entities.Line line:
|
||||
@@ -39,7 +45,11 @@ namespace OpenNest.IO
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Spline spline:
|
||||
lines.AddRange(spline.ToOpenNest());
|
||||
foreach (var e in spline.ToOpenNest(SplinePrecision))
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.LwPolyline lwPolyline:
|
||||
@@ -51,7 +61,11 @@ namespace OpenNest.IO
|
||||
break;
|
||||
|
||||
case ACadSharp.Entities.Ellipse ellipse:
|
||||
lines.AddRange(ellipse.ToOpenNest(SplinePrecision));
|
||||
foreach (var e in ellipse.ToOpenNest())
|
||||
{
|
||||
if (e is Line l) lines.Add(l);
|
||||
else if (e is Arc a) arcs.Add(a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -65,6 +79,24 @@ namespace OpenNest.IO
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports a DXF file, returning both converted entities and the raw CadDocument
|
||||
/// for bend detection. The CadDocument is NOT disposed — caller can use it for
|
||||
/// additional analysis (e.g., MText extraction for bend notes).
|
||||
/// </summary>
|
||||
public DxfImportResult Import(string path)
|
||||
{
|
||||
using var reader = new DxfReader(path);
|
||||
var doc = reader.Read();
|
||||
var entities = GetGeometry(doc);
|
||||
|
||||
return new DxfImportResult
|
||||
{
|
||||
Entities = entities,
|
||||
Document = doc
|
||||
};
|
||||
}
|
||||
|
||||
public bool GetGeometry(Stream stream, out List<Entity> geometry)
|
||||
{
|
||||
var success = false;
|
||||
@@ -113,5 +145,11 @@ namespace OpenNest.IO
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
private static bool IsNonCutLayer(string layerName)
|
||||
{
|
||||
return string.Equals(layerName, "BEND", System.StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(layerName, "ETCH", System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ACadSharp.Entities;
|
||||
using CSMath;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -56,42 +57,46 @@ namespace OpenNest.IO
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Geometry.Line> ToOpenNest(this Spline spline)
|
||||
public static List<Geometry.Entity> ToOpenNest(this Spline spline, int precision)
|
||||
{
|
||||
var lines = new List<Geometry.Line>();
|
||||
var pts = spline.ControlPoints;
|
||||
|
||||
if (pts.Count == 0)
|
||||
return lines;
|
||||
|
||||
var layer = spline.Layer.ToOpenNest();
|
||||
var color = spline.ResolveColor();
|
||||
var lineTypeName = spline.ResolveLineTypeName();
|
||||
var lastPoint = pts[0].ToOpenNest();
|
||||
|
||||
for (var i = 1; i < pts.Count; i++)
|
||||
// Evaluate actual points on the spline curve (not control points)
|
||||
List<XYZ> curvePoints;
|
||||
try
|
||||
{
|
||||
var nextPoint = pts[i].ToOpenNest();
|
||||
|
||||
lines.Add(new Geometry.Line(lastPoint, nextPoint)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
|
||||
lastPoint = nextPoint;
|
||||
curvePoints = spline.PolygonalVertexes(precision > 0 ? precision : 200);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Spline curve evaluation failed: {ex.Message}");
|
||||
curvePoints = null;
|
||||
}
|
||||
|
||||
if (spline.IsClosed)
|
||||
lines.Add(new Geometry.Line(lastPoint, pts[0].ToOpenNest())
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
if (curvePoints == null || curvePoints.Count < 2)
|
||||
{
|
||||
// Fallback: use control points if evaluation fails
|
||||
curvePoints = new List<XYZ>(spline.ControlPoints);
|
||||
if (curvePoints.Count < 2)
|
||||
return new List<Geometry.Entity>();
|
||||
}
|
||||
|
||||
return lines;
|
||||
var points = new List<Vector>(curvePoints.Count);
|
||||
foreach (var pt in curvePoints)
|
||||
points.Add(pt.ToOpenNest());
|
||||
|
||||
var entities = SplineConverter.Convert(points, spline.IsClosed, tolerance: 0.001);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
entity.Layer = layer;
|
||||
entity.Color = color;
|
||||
entity.LineTypeName = lineTypeName;
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static List<Geometry.Line> ToOpenNest(this Polyline polyline)
|
||||
@@ -172,69 +177,32 @@ namespace OpenNest.IO
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static List<Geometry.Line> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, int precision = 200)
|
||||
public static List<Geometry.Entity> ToOpenNest(this ACadSharp.Entities.Ellipse ellipse, double tolerance = 0.001)
|
||||
{
|
||||
var lines = new List<Geometry.Line>();
|
||||
|
||||
var center = new Vector(ellipse.Center.X, ellipse.Center.Y);
|
||||
var majorAxis = new Vector(ellipse.MajorAxisEndPoint.X, ellipse.MajorAxisEndPoint.Y);
|
||||
var majorLength = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||
var minorLength = majorLength * ellipse.RadiusRatio;
|
||||
var semiMajor = System.Math.Sqrt(majorAxis.X * majorAxis.X + majorAxis.Y * majorAxis.Y);
|
||||
var semiMinor = semiMajor * ellipse.RadiusRatio;
|
||||
var rotation = System.Math.Atan2(majorAxis.Y, majorAxis.X);
|
||||
|
||||
var startParam = ellipse.StartParameter;
|
||||
var endParam = ellipse.EndParameter;
|
||||
|
||||
if (endParam <= startParam)
|
||||
endParam += System.Math.PI * 2.0;
|
||||
|
||||
var step = (endParam - startParam) / precision;
|
||||
|
||||
var points = new List<Vector>();
|
||||
|
||||
for (var i = 0; i <= precision; i++)
|
||||
{
|
||||
var t = startParam + step * i;
|
||||
var x = majorLength * System.Math.Cos(t);
|
||||
var y = minorLength * System.Math.Sin(t);
|
||||
|
||||
// Rotate by the major axis angle and translate to center
|
||||
var cos = System.Math.Cos(rotation);
|
||||
var sin = System.Math.Sin(rotation);
|
||||
var px = center.X + x * cos - y * sin;
|
||||
var py = center.Y + x * sin + y * cos;
|
||||
|
||||
points.Add(new Vector(px, py));
|
||||
}
|
||||
|
||||
var layer = ellipse.Layer.ToOpenNest();
|
||||
var color = ellipse.ResolveColor();
|
||||
var lineTypeName = ellipse.ResolveLineTypeName();
|
||||
|
||||
for (var i = 0; i < points.Count - 1; i++)
|
||||
var entities = EllipseConverter.Convert(center, semiMajor, semiMinor, rotation,
|
||||
startParam, endParam, tolerance);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
lines.Add(new Geometry.Line(points[i], points[i + 1])
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
entity.Layer = layer;
|
||||
entity.Color = color;
|
||||
entity.LineTypeName = lineTypeName;
|
||||
}
|
||||
|
||||
// Close the ellipse if it's a full ellipse
|
||||
if (lines.Count >= 2)
|
||||
{
|
||||
var first = lines.First();
|
||||
var last = lines.Last();
|
||||
lines.Add(new Geometry.Line(last.EndPoint, first.StartPoint)
|
||||
{
|
||||
Layer = layer,
|
||||
Color = color,
|
||||
LineTypeName = lineTypeName
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static Geometry.Layer ToOpenNest(this ACadSharp.Tables.Layer layer)
|
||||
|
||||
@@ -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();
|
||||
@@ -49,6 +50,7 @@ namespace OpenNest.IO
|
||||
public ConstraintsDto Constraints { get; init; } = new();
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SourceDto Source { get; init; } = new();
|
||||
public List<BendDto> Bends { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PlateDto
|
||||
@@ -61,7 +63,9 @@ namespace OpenNest.IO
|
||||
public double PartSpacing { get; init; }
|
||||
public MaterialDto Material { get; init; } = new();
|
||||
public SpacingDto EdgeSpacing { get; init; } = new();
|
||||
public double GrainAngle { get; init; }
|
||||
public List<PartDto> Parts { get; init; } = new();
|
||||
public List<CutOffDto> CutOffs { get; init; } = new();
|
||||
}
|
||||
|
||||
public record PartDto
|
||||
@@ -72,6 +76,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; }
|
||||
@@ -126,6 +139,18 @@ namespace OpenNest.IO
|
||||
public double Y { get; init; }
|
||||
}
|
||||
|
||||
public record BendDto
|
||||
{
|
||||
public double StartX { get; init; }
|
||||
public double StartY { get; init; }
|
||||
public double EndX { get; init; }
|
||||
public double EndY { get; init; }
|
||||
public string Direction { get; init; } = "Unknown";
|
||||
public double? Angle { get; init; }
|
||||
public double? Radius { get; init; }
|
||||
public string NoteText { get; init; } = "";
|
||||
}
|
||||
|
||||
public record BestFitSetDto
|
||||
{
|
||||
public double PlateWidth { get; init; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
@@ -91,6 +92,23 @@ namespace OpenNest.IO
|
||||
drawing.Source.Path = d.Source.Path;
|
||||
drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y);
|
||||
|
||||
if (d.Bends != null)
|
||||
{
|
||||
foreach (var b in d.Bends)
|
||||
{
|
||||
drawing.Bends.Add(new Bend
|
||||
{
|
||||
StartPoint = new Vector(b.StartX, b.StartY),
|
||||
EndPoint = new Vector(b.EndX, b.EndY),
|
||||
Direction = Enum.TryParse<BendDirection>(b.Direction, true, out var dir)
|
||||
? dir : BendDirection.Unknown,
|
||||
Angle = b.Angle,
|
||||
Radius = b.Radius,
|
||||
NoteText = b.NoteText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (programs.TryGetValue(d.Id, out var pgm))
|
||||
drawing.Program = pgm;
|
||||
|
||||
@@ -160,6 +178,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;
|
||||
@@ -185,6 +204,7 @@ namespace OpenNest.IO
|
||||
plate.PartSpacing = p.PartSpacing;
|
||||
plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density);
|
||||
plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top);
|
||||
plate.GrainAngle = p.GrainAngle;
|
||||
|
||||
foreach (var partDto in p.Parts)
|
||||
{
|
||||
@@ -197,6 +217,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using System;
|
||||
@@ -77,6 +78,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()
|
||||
@@ -139,7 +141,18 @@ namespace OpenNest.IO
|
||||
{
|
||||
Path = d.Source.Path ?? "",
|
||||
Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y }
|
||||
}
|
||||
},
|
||||
Bends = d.Bends?.Select(b => new BendDto
|
||||
{
|
||||
StartX = b.StartPoint.X,
|
||||
StartY = b.StartPoint.Y,
|
||||
EndX = b.EndPoint.X,
|
||||
EndY = b.EndPoint.Y,
|
||||
Direction = b.Direction.ToString(),
|
||||
Angle = b.Angle,
|
||||
Radius = b.Radius,
|
||||
NoteText = b.NoteText ?? ""
|
||||
}).ToList() ?? new List<BendDto>()
|
||||
});
|
||||
}
|
||||
return list;
|
||||
@@ -152,7 +165,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 +177,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 +211,9 @@ namespace OpenNest.IO
|
||||
Right = plate.EdgeSpacing.Right,
|
||||
Bottom = plate.EdgeSpacing.Bottom
|
||||
},
|
||||
Parts = parts
|
||||
Parts = parts,
|
||||
CutOffs = cutoffs,
|
||||
GrainAngle = plate.GrainAngle
|
||||
});
|
||||
}
|
||||
return list;
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<PackageReference Include="ACadSharp" Version="3.1.32" />
|
||||
<PackageReference Include="ClosedXML" Version="0.104.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
177
OpenNest.IO/SplitDxfWriter.cs
Normal file
177
OpenNest.IO/SplitDxfWriter.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using ACadSharp;
|
||||
using ACadSharp.Entities;
|
||||
using ACadSharp.IO;
|
||||
using ACadSharp.Tables;
|
||||
using CSMath;
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
// Disambiguate Entity — both ACadSharp.Entities and OpenNest.Geometry define it
|
||||
using GeoEntity = OpenNest.Geometry.Entity;
|
||||
|
||||
namespace OpenNest.IO
|
||||
{
|
||||
public class SplitDxfWriter
|
||||
{
|
||||
private const double DefaultEtchLength = 1.0;
|
||||
|
||||
public double EtchLength { get; set; } = DefaultEtchLength;
|
||||
|
||||
public void Write(string path, Drawing drawing)
|
||||
{
|
||||
var doc = new CadDocument();
|
||||
|
||||
var cutLayer = new ACadSharp.Tables.Layer("CUT") { Color = new Color(7) };
|
||||
var bendLayer = new ACadSharp.Tables.Layer("BEND") { Color = new Color(2) };
|
||||
var etchLayer = new ACadSharp.Tables.Layer("ETCH") { Color = new Color(3) };
|
||||
doc.Layers.Add(cutLayer);
|
||||
doc.Layers.Add(bendLayer);
|
||||
doc.Layers.Add(etchLayer);
|
||||
|
||||
var centerLineType = new LineType("CENTERX2");
|
||||
doc.LineTypes.Add(centerLineType);
|
||||
|
||||
WriteProgramEntities(doc, drawing.Program, cutLayer);
|
||||
|
||||
if (drawing.Bends != null)
|
||||
{
|
||||
foreach (var bend in drawing.Bends)
|
||||
{
|
||||
WriteBendLine(doc, bend, bendLayer, centerLineType);
|
||||
WriteEtchLines(doc, bend, etchLayer);
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = File.Create(path);
|
||||
using var writer = new DxfWriter(stream, doc, false);
|
||||
writer.Write();
|
||||
}
|
||||
|
||||
private static void WriteProgramEntities(CadDocument doc, CNC.Program program, ACadSharp.Tables.Layer layer)
|
||||
{
|
||||
var geometry = ConvertProgram.ToGeometry(program);
|
||||
WriteGeometryEntities(doc, geometry, layer);
|
||||
}
|
||||
|
||||
private static void WriteGeometryEntities(CadDocument doc, List<GeoEntity> geometry, ACadSharp.Tables.Layer layer)
|
||||
{
|
||||
foreach (var entity in geometry)
|
||||
{
|
||||
// Skip rapid moves
|
||||
if (entity.Layer == SpecialLayers.Rapid)
|
||||
continue;
|
||||
|
||||
switch (entity)
|
||||
{
|
||||
case OpenNest.Geometry.Line line:
|
||||
doc.Entities.Add(new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new XYZ(line.StartPoint.X, line.StartPoint.Y, 0),
|
||||
EndPoint = new XYZ(line.EndPoint.X, line.EndPoint.Y, 0),
|
||||
Layer = layer
|
||||
});
|
||||
break;
|
||||
|
||||
case OpenNest.Geometry.Arc arc:
|
||||
var startAngle = arc.StartAngle;
|
||||
var endAngle = arc.EndAngle;
|
||||
if (arc.IsReversed)
|
||||
OpenNest.Math.Generic.Swap(ref startAngle, ref endAngle);
|
||||
|
||||
doc.Entities.Add(new ACadSharp.Entities.Arc
|
||||
{
|
||||
Center = new XYZ(arc.Center.X, arc.Center.Y, 0),
|
||||
Radius = arc.Radius,
|
||||
StartAngle = startAngle,
|
||||
EndAngle = endAngle,
|
||||
Layer = layer
|
||||
});
|
||||
break;
|
||||
|
||||
case OpenNest.Geometry.Circle circle:
|
||||
doc.Entities.Add(new ACadSharp.Entities.Circle
|
||||
{
|
||||
Center = new XYZ(circle.Center.X, circle.Center.Y, 0),
|
||||
Radius = circle.Radius,
|
||||
Layer = layer
|
||||
});
|
||||
break;
|
||||
|
||||
case OpenNest.Geometry.Shape shape:
|
||||
WriteGeometryEntities(doc, shape.Entities, layer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteBendLine(CadDocument doc, Bend bend, ACadSharp.Tables.Layer layer, LineType lineType)
|
||||
{
|
||||
var line = new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new XYZ(bend.StartPoint.X, bend.StartPoint.Y, 0),
|
||||
EndPoint = new XYZ(bend.EndPoint.X, bend.EndPoint.Y, 0),
|
||||
Layer = layer,
|
||||
LineType = lineType
|
||||
};
|
||||
doc.Entities.Add(line);
|
||||
|
||||
if (!string.IsNullOrEmpty(bend.NoteText))
|
||||
{
|
||||
var midX = (bend.StartPoint.X + bend.EndPoint.X) / 2;
|
||||
var midY = (bend.StartPoint.Y + bend.EndPoint.Y) / 2;
|
||||
|
||||
var mtext = new MText
|
||||
{
|
||||
InsertPoint = new XYZ(midX, midY + 0.5, 0),
|
||||
Value = bend.NoteText,
|
||||
Height = 0.1,
|
||||
Layer = layer
|
||||
};
|
||||
doc.Entities.Add(mtext);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteEtchLines(CadDocument doc, Bend bend, ACadSharp.Tables.Layer layer)
|
||||
{
|
||||
if (bend.Direction != BendDirection.Up)
|
||||
return;
|
||||
|
||||
var start = bend.StartPoint;
|
||||
var end = bend.EndPoint;
|
||||
var length = bend.Length;
|
||||
|
||||
if (length < EtchLength * 3.0)
|
||||
{
|
||||
doc.Entities.Add(new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new XYZ(start.X, start.Y, 0),
|
||||
EndPoint = new XYZ(end.X, end.Y, 0),
|
||||
Layer = layer
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var angle = start.AngleTo(end);
|
||||
var dx = System.Math.Cos(angle) * EtchLength;
|
||||
var dy = System.Math.Sin(angle) * EtchLength;
|
||||
|
||||
doc.Entities.Add(new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new XYZ(start.X, start.Y, 0),
|
||||
EndPoint = new XYZ(start.X + dx, start.Y + dy, 0),
|
||||
Layer = layer
|
||||
});
|
||||
|
||||
doc.Entities.Add(new ACadSharp.Entities.Line
|
||||
{
|
||||
StartPoint = new XYZ(end.X, end.Y, 0),
|
||||
EndPoint = new XYZ(end.X - dx, end.Y - dy, 0),
|
||||
Layer = layer
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Shapes;
|
||||
using System.ComponentModel;
|
||||
@@ -57,6 +58,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(
|
||||
@@ -74,7 +104,8 @@ namespace OpenNest.Mcp.Tools
|
||||
if (geometry.Count == 0)
|
||||
return "Error: no geometry found in DXF file";
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||
|
||||
if (pgm == null)
|
||||
return "Error: failed to convert geometry to program";
|
||||
@@ -93,7 +124,7 @@ namespace OpenNest.Mcp.Tools
|
||||
[Description("Name for the drawing")] string name,
|
||||
[Description("Shape type: rectangle, circle, l_shape, t_shape, gcode")] string shape,
|
||||
[Description("Width of the shape (not used for circle or gcode)")] double width = 10,
|
||||
[Description("Height of the shape (not used for circle or gcode)")] double height = 10,
|
||||
[Description("Length of the shape (not used for circle or gcode)")] double length = 10,
|
||||
[Description("Radius for circle shape")] double radius = 5,
|
||||
[Description("G-code string (only used when shape is 'gcode')")] string gcode = null)
|
||||
{
|
||||
@@ -102,7 +133,7 @@ namespace OpenNest.Mcp.Tools
|
||||
switch (shape.ToLower())
|
||||
{
|
||||
case "rectangle":
|
||||
shapeDef = new RectangleShape { Name = name, Width = width, Height = height };
|
||||
shapeDef = new RectangleShape { Name = name, Width = width, Length = length };
|
||||
break;
|
||||
|
||||
case "circle":
|
||||
@@ -110,11 +141,11 @@ namespace OpenNest.Mcp.Tools
|
||||
break;
|
||||
|
||||
case "l_shape":
|
||||
shapeDef = new LShape { Name = name, Width = width, Height = height };
|
||||
shapeDef = new LShape { Name = name, Width = width, Height = length };
|
||||
break;
|
||||
|
||||
case "t_shape":
|
||||
shapeDef = new TShape { Name = name, Width = width, Height = height };
|
||||
shapeDef = new TShape { Name = name, Width = width, Height = length };
|
||||
break;
|
||||
|
||||
case "gcode":
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace OpenNest.Mcp.Tools
|
||||
[Description("X origin of the area")] double x,
|
||||
[Description("Y origin of the area")] double y,
|
||||
[Description("Width of the area")] double width,
|
||||
[Description("Height of the area")] double height,
|
||||
[Description("Length of the area")] double length,
|
||||
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
@@ -73,14 +73,14 @@ namespace OpenNest.Mcp.Tools
|
||||
var countBefore = plate.Parts.Count;
|
||||
var engine = NestEngineRegistry.Create(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
var area = new Box(x, y, width, height);
|
||||
var area = new Box(x, y, width, length);
|
||||
var success = engine.Fill(item, area);
|
||||
|
||||
var countAfter = plate.Parts.Count;
|
||||
var added = countAfter - countBefore;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{height:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}");
|
||||
sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{length:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}");
|
||||
sb.AppendLine($" Parts added: {added}");
|
||||
sb.AppendLine($" Total parts: {countAfter}");
|
||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||
|
||||
@@ -19,13 +19,13 @@ namespace OpenNest.Mcp.Tools
|
||||
[Description("Create a new plate with the given dimensions and spacing. Returns plate index and work area.")]
|
||||
public string CreatePlate(
|
||||
[Description("Plate width")] double width,
|
||||
[Description("Plate height")] double height,
|
||||
[Description("Plate length")] double length,
|
||||
[Description("Spacing between parts (default 0)")] double partSpacing = 0,
|
||||
[Description("Edge spacing on all sides (default 0)")] double edgeSpacing = 0,
|
||||
[Description("Quadrant 1-4 (default 1). 1=TopRight, 2=TopLeft, 3=BottomLeft, 4=BottomRight")] int quadrant = 1,
|
||||
[Description("Material name (optional)")] string material = null)
|
||||
{
|
||||
var plate = new Plate(width, height);
|
||||
var plate = new Plate(width, length);
|
||||
plate.PartSpacing = partSpacing;
|
||||
plate.EdgeSpacing = new Spacing(edgeSpacing, edgeSpacing);
|
||||
plate.Quadrant = quadrant;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
112
OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
Normal file
112
OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
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 = FeatureUtils.SplitByRapids(normalizedProgram.Codes);
|
||||
if (allFeatures.Count == 0)
|
||||
return;
|
||||
|
||||
// Classify and order: etch features first, then cut features
|
||||
var ordered = FeatureUtils.ClassifyAndOrder(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 = FeatureUtils.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})");
|
||||
}
|
||||
|
||||
/// <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));
|
||||
|
||||
/// <summary>
|
||||
/// Scans all plates and builds a mapping of unique part geometries to sub-program numbers,
|
||||
/// along with their normalized programs for writing.
|
||||
/// </summary>
|
||||
internal static (Dictionary<(int, long), int> mapping, List<(int subNum, string name, Program program)> entries)
|
||||
BuildRegistry(IEnumerable<Plate> plates, int startNumber)
|
||||
{
|
||||
var mapping = new Dictionary<(int, long), int>();
|
||||
var entries = new List<(int, string, Program)>();
|
||||
var nextSubNum = startNumber;
|
||||
|
||||
foreach (var plate in plates)
|
||||
{
|
||||
foreach (var part in plate.Parts)
|
||||
{
|
||||
if (part.BaseDrawing.IsCutOff) continue;
|
||||
var key = SubprogramKey(part);
|
||||
if (!mapping.ContainsKey(key))
|
||||
{
|
||||
var subNum = nextSubNum++;
|
||||
mapping[key] = subNum;
|
||||
|
||||
var pgm = part.Program.Clone() as Program;
|
||||
var bbox = pgm.BoundingBox();
|
||||
pgm.Offset(-bbox.Location.X, -bbox.Location.Y);
|
||||
|
||||
entries.Add((subNum, part.BaseDrawing.Name, pgm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (mapping, entries);
|
||||
}
|
||||
}
|
||||
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; } = "";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user