Compare commits
292 Commits
f208569e72
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 786b6e2e88 | |||
| ba89967448 | |||
| b566d984b0 | |||
| c1e6092e83 | |||
| df86d4367b | |||
| 40026ab4dc | |||
| b18a82df7a | |||
| f090a2e299 | |||
| 55192a4888 | |||
| 7c28a35ad8 | |||
| b2a723ca60 | |||
| 3dca25c601 | |||
| ebc1a5f980 | |||
| b729f92cd6 | |||
| 5d6e018b81 | |||
| 5163b02f89 | |||
| a59911b38a | |||
| 810e37cacf | |||
| 8dfa45c446 | |||
| b223f69572 | |||
| 98c574c2ad | |||
| 30f1008fa9 | |||
| 41c20eaf75 | |||
| 3a97253473 | |||
| 3eab3c5946 | |||
| 0e05ad04ea | |||
| 5ac985dc0f | |||
| 865754611c | |||
| 9db326ee5d | |||
| 25faba430c | |||
| 089df67627 | |||
| 11884e712d | |||
| 6bed736cf0 | |||
| c20a079874 | |||
| 804a7fd9c1 | |||
| 3c4d00baa4 | |||
| 959ab15491 | |||
| cca70db547 | |||
| 62d9dce0b1 | |||
| 1f88453d4c | |||
| 0697bebbc2 | |||
| beadb14acc | |||
| 09f1140f54 | |||
| 7c918a2378 | |||
| feb08a5f60 | |||
| f1fd211ba5 | |||
| fd3c2462df | |||
| a4773748a1 | |||
| af57153269 | |||
| 35e89600d0 | |||
| 89a4e6b981 | |||
| ebad3577dd | |||
| a8dc275da4 | |||
| d84becdaee | |||
| 9cba3a6cd7 | |||
| e93523d7a2 | |||
| 3bdbf21881 | |||
| a8e42fb4b5 | |||
| ea3c6afbdd | |||
| ba88ac253a | |||
| 250fdefaea | |||
| e92208b8c0 | |||
| 297ebee45b | |||
| 1eba3e7cde | |||
| d65f3460a9 | |||
| ede06b1bf6 | |||
| 51eea6d1e6 | |||
| 3d23ad8073 | |||
| 107fd86066 | |||
| d12f0cee3e | |||
| d93b69c524 | |||
| a65598615e | |||
| ed082a6799 | |||
| c9b17619ef | |||
| f78cc78a65 | |||
| 37130e8a28 | |||
| 6f19fe1822 | |||
| 81c167320d | |||
| 981188f65e | |||
| ffd060bf61 | |||
| a360452da3 | |||
| b3e9e5e28b | |||
| 7380a43349 | |||
| 59e00cd707 | |||
| 44cb6e4a2b | |||
| 5949c3ca1f | |||
| ef15421915 | |||
| 943c262ad2 | |||
| 301831e096 | |||
| fce287e649 | |||
| 7e86313d7c | |||
| c5943e22eb | |||
| e50a7c82cf | |||
| 7a893ef50f | |||
| 925a1c7751 | |||
| 036b48e273 | |||
| bd9b0369cf | |||
| 93391c4b8f | |||
| ebab795f86 | |||
| 9f9111975d | |||
| 25ee193ae6 | |||
| 5bcad9667b | |||
| 64945220b9 | |||
| ec0baad585 | |||
| f26edb824d | |||
| aae593a73e | |||
| 36d8f7fb11 | |||
| 52ad5b4575 | |||
| 7416f8ae3f | |||
| 46e3104dfc | |||
| 27afa04e4a | |||
| 95b9613e2d | |||
| 3bc9301e22 | |||
| 1040db414f | |||
| 287023d802 | |||
| 3a24e76dbd | |||
| a6e2845261 | |||
| 97d897e885 | |||
| 9db7abcd37 | |||
| 3e340e67e0 | |||
| 7a6c407edd | |||
| 9f76659d5d | |||
| a8341e9e99 | |||
| fb067187b4 | |||
| 5c66fb3b72 | |||
| 5bd4c89999 | |||
| dd93c230dd | |||
| d6ffd8efc9 | |||
| 68c3a904e8 | |||
| d57e2ca54b | |||
| 904eeb38c2 | |||
| e1bb723169 | |||
| aa156fff57 | |||
| d3a439181c | |||
| bb70ae26d3 | |||
| 35dc954017 | |||
| 0cae9e88e7 | |||
| 5d824a1aff | |||
| 8a293bcc9d | |||
| 24b89689c5 | |||
| 3da5d1c70c | |||
| d3ec4eb3e2 | |||
| cb446e1057 | |||
| f3ca021fad | |||
| ffe32fc38c | |||
| 27bbe99e7e | |||
| 5a9a06a6a0 | |||
| c1f1c829dc | |||
| e8fe01aea2 | |||
| 7b7d2cd8d1 | |||
| 6ca0e9da92 | |||
| bcaa4a03ee | |||
| 54c6f1bc89 | |||
| 429e4b63e1 | |||
| 159b54a1ec | |||
| 568539d5b1 | |||
| d7fa4bef43 | |||
| 7c58cfa749 | |||
| 525cbc6f12 | |||
| 134771aa23 | |||
| 59a66173e1 | |||
| a2b7be44f8 | |||
| e94a556f23 | |||
| 428dbdb03c | |||
| e860ca3f4a | |||
| a399c89f58 | |||
| d16ef36d34 | |||
| 5307c5c85a | |||
| 21321740d6 | |||
| 7f8c708d3f | |||
| ab4f806820 | |||
| c9b5ee1918 | |||
| f34dce95da | |||
| a2a19938d3 | |||
| c064c7647a | |||
| 8a712b9755 | |||
| 82de512f44 | |||
| f903cbe18a | |||
| 3d4204db7b | |||
| 722f758e94 | |||
| 9b2322abe9 | |||
| b15375cca5 | |||
| e3b388464d | |||
| ab09f835d3 | |||
| f8b0fb573b | |||
| 6ce501da11 | |||
| 05037bc928 | |||
| f83df3a55a | |||
| 84ad39414a | |||
| fdb4a2373a | |||
| 3a0267c041 | |||
| 036f723876 | |||
| 21a5d3b026 | |||
| 0607c6c7c5 | |||
| c8cfeb3c6b | |||
| d4f424f274 | |||
| 028b1fabfc | |||
| a7c2fcffe6 | |||
| b834813889 | |||
| 4fa6100722 | |||
| 8f2fbee02c | |||
| 230a11d32e | |||
| 953429dae9 | |||
| 1c2b569ff4 | |||
| 048b10a1e9 | |||
| 3022982f6d | |||
| cc85493a0c | |||
| 3da287cdc0 | |||
| d1a701a7f7 | |||
| 17f786c9e8 | |||
| b7a8e2662c | |||
| 912a47c5e8 | |||
| a85213a524 | |||
| fb696aaf58 | |||
| d854a1f5d2 | |||
| abc707f1d9 | |||
| 61b917c398 | |||
| e3e51611d5 | |||
| 8104bd3626 | |||
| ae262b8a77 | |||
| 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 |
@@ -209,6 +209,10 @@ FakesAssemblies/
|
||||
# Claude Code
|
||||
.claude/
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Documentation (manuals, templates, etc.)
|
||||
docs/
|
||||
|
||||
# Launch settings
|
||||
**/Properties/launchSettings.json
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,10 @@ Eight projects form a layered architecture:
|
||||
Domain model, geometry, and CNC primitives organized into namespaces:
|
||||
|
||||
- **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning.
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, and `RotatingCalipers`.
|
||||
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
|
||||
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
|
||||
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
|
||||
- **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.
|
||||
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). 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.
|
||||
@@ -116,3 +116,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
||||
- `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).
|
||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||
|
||||
@@ -25,17 +25,17 @@ public static class NestRunner
|
||||
|
||||
// 1. Import DXFs → Drawings
|
||||
var drawings = new List<Drawing>();
|
||||
var importer = new DxfImporter();
|
||||
|
||||
foreach (var part in request.Parts)
|
||||
{
|
||||
if (!File.Exists(part.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||
|
||||
if (!importer.GetGeometry(part.DxfPath, out var geometry) || geometry.Count == 0)
|
||||
var geometry = Dxf.GetGeometry(part.DxfPath);
|
||||
if (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;
|
||||
@@ -58,6 +58,8 @@ public static class NestRunner
|
||||
|
||||
// 3. Multi-plate loop
|
||||
var nest = new Nest();
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
var remaining = items.Select(item => item.Quantity).ToList();
|
||||
|
||||
while (remaining.Any(q => q > 0))
|
||||
@@ -66,9 +68,7 @@ public static class NestRunner
|
||||
|
||||
var plate = new Plate(request.SheetSize)
|
||||
{
|
||||
Thickness = request.Thickness,
|
||||
PartSpacing = request.Spacing,
|
||||
Material = new Material(request.Material)
|
||||
};
|
||||
|
||||
// Build items for this pass with remaining quantities
|
||||
|
||||
@@ -241,21 +241,16 @@ static class NestConsole
|
||||
|
||||
static Drawing ImportDxf(string path)
|
||||
{
|
||||
var importer = new DxfImporter();
|
||||
|
||||
if (!importer.GetGeometry(path, out var geometry))
|
||||
{
|
||||
Console.Error.WriteLine($"Error: failed to read DXF file: {path}");
|
||||
return null;
|
||||
}
|
||||
var geometry = Dxf.GetGeometry(path);
|
||||
|
||||
if (geometry.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
|
||||
Console.Error.WriteLine($"Error: failed to read DXF file or no geometry found: {path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var pgm = ConvertGeometry.ToProgram(geometry);
|
||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||
|
||||
if (pgm == null)
|
||||
{
|
||||
@@ -278,10 +273,9 @@ static class NestConsole
|
||||
return;
|
||||
}
|
||||
|
||||
var templatePlate = new NestReader(options.TemplateFile).Read().PlateDefaults.CreateNew();
|
||||
plate.Thickness = templatePlate.Thickness;
|
||||
var templateNest = new NestReader(options.TemplateFile).Read();
|
||||
var templatePlate = templateNest.PlateDefaults.CreateNew();
|
||||
plate.Quadrant = templatePlate.Quadrant;
|
||||
plate.Material = templatePlate.Material;
|
||||
plate.EdgeSpacing = templatePlate.EdgeSpacing;
|
||||
plate.PartSpacing = templatePlate.PartSpacing;
|
||||
Console.WriteLine($"Template: {options.TemplateFile}");
|
||||
|
||||
+17
-42
@@ -125,61 +125,36 @@ namespace OpenNest
|
||||
parts.ForEach(part => Bottom(fixedPart, part));
|
||||
}
|
||||
|
||||
public static void EvenlyDistributeHorizontally(List<Part> parts)
|
||||
public static void EvenlyDistributeHorizontally(List<Part> parts) =>
|
||||
EvenlyDistribute(parts, horizontal: true);
|
||||
|
||||
public static void EvenlyDistributeVertically(List<Part> parts) =>
|
||||
EvenlyDistribute(parts, horizontal: false);
|
||||
|
||||
private static void EvenlyDistribute(List<Part> parts, bool horizontal)
|
||||
{
|
||||
if (parts.Count < 3)
|
||||
return;
|
||||
|
||||
var list = new List<Part>(parts);
|
||||
list.Sort((p1, p2) => p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X));
|
||||
list.Sort((p1, p2) => horizontal
|
||||
? p1.BoundingBox.Center.X.CompareTo(p2.BoundingBox.Center.X)
|
||||
: p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
|
||||
|
||||
var lastIndex = list.Count - 1;
|
||||
|
||||
var first = list[0];
|
||||
var last = list[lastIndex];
|
||||
var start = horizontal ? list[0].BoundingBox.Center.X : list[0].BoundingBox.Center.Y;
|
||||
var end = horizontal ? list[lastIndex].BoundingBox.Center.X : list[lastIndex].BoundingBox.Center.Y;
|
||||
|
||||
var start = first.BoundingBox.Center.X;
|
||||
var end = last.BoundingBox.Center.X;
|
||||
var diff = end - start;
|
||||
var spacing = (end - start) / lastIndex;
|
||||
|
||||
var spacing = diff / lastIndex;
|
||||
|
||||
for (int i = 1; i < lastIndex; ++i)
|
||||
for (var i = 1; i < lastIndex; ++i)
|
||||
{
|
||||
var part = list[i];
|
||||
var newX = start + i * spacing;
|
||||
var curX = part.BoundingBox.Center.X;
|
||||
var cur = horizontal ? part.BoundingBox.Center.X : part.BoundingBox.Center.Y;
|
||||
var delta = start + i * spacing - cur;
|
||||
|
||||
part.Offset(newX - curX, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static void EvenlyDistributeVertically(List<Part> parts)
|
||||
{
|
||||
if (parts.Count < 3)
|
||||
return;
|
||||
|
||||
var list = new List<Part>(parts);
|
||||
list.Sort((p1, p2) => p1.BoundingBox.Center.Y.CompareTo(p2.BoundingBox.Center.Y));
|
||||
|
||||
var lastIndex = list.Count - 1;
|
||||
|
||||
var first = list[0];
|
||||
var last = list[lastIndex];
|
||||
|
||||
var start = first.BoundingBox.Center.Y;
|
||||
var end = last.BoundingBox.Center.Y;
|
||||
var diff = end - start;
|
||||
|
||||
var spacing = diff / lastIndex;
|
||||
|
||||
for (int i = 1; i < lastIndex; ++i)
|
||||
{
|
||||
var part = list[i];
|
||||
var newX = start + i * spacing;
|
||||
var curX = part.BoundingBox.Center.Y;
|
||||
|
||||
part.Offset(0, newX - curX);
|
||||
part.Offset(horizontal ? delta : 0, horizontal ? 0 : delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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;
|
||||
private const string BendEtchTag = "BendEtch";
|
||||
|
||||
public Vector StartPoint { get; set; }
|
||||
public Vector EndPoint { get; set; }
|
||||
public BendDirection Direction { get; set; }
|
||||
@@ -12,6 +23,9 @@ namespace OpenNest.Bending
|
||||
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
|
||||
@@ -26,6 +40,52 @@ namespace OpenNest.Bending
|
||||
/// </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.Tag == BendEtchTag);
|
||||
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, Tag = BendEtchTag };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var dir = Direction.ToString();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -65,7 +66,9 @@ namespace OpenNest.CNC
|
||||
{
|
||||
return new ArcMove(EndPoint, CenterPoint, Rotation)
|
||||
{
|
||||
Layer = Layer
|
||||
Layer = Layer,
|
||||
Suppressed = Suppressed,
|
||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
@@ -7,69 +8,221 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public CuttingParameters Parameters { get; set; }
|
||||
|
||||
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
||||
|
||||
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
||||
{
|
||||
var exitPoint = approachPoint;
|
||||
var entities = partProgram.ToGeometry();
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
// Find closest point on perimeter from exit point
|
||||
var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity);
|
||||
|
||||
// Chain cutouts by nearest-neighbor from perimeter point, then reverse
|
||||
// so farthest cutouts are cut first, nearest-to-perimeter cut last
|
||||
// Forward pass: sequence cutouts nearest-neighbor from perimeter
|
||||
var perimeterPoint = profile.Perimeter.ClosestPointTo(approachPoint, out _);
|
||||
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint);
|
||||
orderedCutouts.Reverse();
|
||||
|
||||
// Build output program: cutouts first (farthest to nearest), perimeter last
|
||||
var result = new Program();
|
||||
var currentPoint = exitPoint;
|
||||
// Backward pass: walk from perimeter back through cutting order
|
||||
// so each lead-in faces the next cutout to be cut, not the previous
|
||||
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterPoint);
|
||||
|
||||
foreach (var cutout in orderedCutouts)
|
||||
{
|
||||
var contourType = DetectContourType(cutout);
|
||||
var closestPt = cutout.ClosestPointTo(currentPoint, out var entity);
|
||||
var normal = ComputeNormal(closestPt, entity, contourType);
|
||||
var winding = DetermineWinding(cutout);
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
var leadIn = SelectLeadIn(contourType);
|
||||
var leadOut = SelectLeadOut(contourType);
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
|
||||
result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding));
|
||||
var reindexed = cutout.ReindexAt(closestPt, entity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding));
|
||||
|
||||
currentPoint = closestPt;
|
||||
}
|
||||
|
||||
var lastCutPoint = exitPoint;
|
||||
foreach (var entry in cutoutEntries)
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
|
||||
// Perimeter last
|
||||
{
|
||||
var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity);
|
||||
lastCutPoint = perimeterPt;
|
||||
var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External);
|
||||
var winding = DetermineWinding(profile.Perimeter);
|
||||
var lastRefPoint = cutoutEntries.Count > 0 ? cutoutEntries[cutoutEntries.Count - 1].Point : approachPoint;
|
||||
var perimeterPt = profile.Perimeter.ClosestPointTo(lastRefPoint, out var perimeterEntity);
|
||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||
|
||||
var leadIn = SelectLeadIn(ContourType.External);
|
||||
var leadOut = SelectLeadOut(ContourType.External);
|
||||
|
||||
result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding));
|
||||
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
|
||||
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
|
||||
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
|
||||
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
|
||||
}
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
Program = result,
|
||||
LastCutPoint = lastCutPoint
|
||||
LastCutPoint = perimeterPt
|
||||
};
|
||||
}
|
||||
|
||||
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
|
||||
{
|
||||
var entities = partProgram.ToGeometry();
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
|
||||
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
var result = new Program(Mode.Absolute);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
|
||||
// Find the target shape that contains the clicked entity
|
||||
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||
|
||||
// Emit cutouts — only the target gets lead-in/out
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
if (cutout == targetShape)
|
||||
{
|
||||
var ct = DetectContourType(cutout);
|
||||
EmitContour(result, cutout, point, matchedEntity, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
EmitRawContour(result, cutout);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit perimeter
|
||||
if (profile.Perimeter == targetShape)
|
||||
{
|
||||
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
|
||||
}
|
||||
else
|
||||
{
|
||||
EmitRawContour(result, profile.Perimeter);
|
||||
}
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
return new CuttingResult
|
||||
{
|
||||
Program = result,
|
||||
LastCutPoint = point
|
||||
};
|
||||
}
|
||||
|
||||
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
|
||||
{
|
||||
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
|
||||
if (matched != null)
|
||||
return (profile.Perimeter, matched);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
matched = FindMatchingEntity(cutout, clickedEntity);
|
||||
if (matched != null)
|
||||
return (cutout, matched);
|
||||
}
|
||||
|
||||
// Fallback: closest shape, use closest point to find entity
|
||||
var best = profile.Perimeter;
|
||||
var bestPt = profile.Perimeter.ClosestPointTo(point, out var bestEntity);
|
||||
var bestDist = bestPt.DistanceTo(point);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var pt = cutout.ClosestPointTo(point, out var cutoutEntity);
|
||||
var dist = pt.DistanceTo(point);
|
||||
if (dist < bestDist)
|
||||
{
|
||||
best = cutout;
|
||||
bestEntity = cutoutEntity;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
return (best, bestEntity);
|
||||
}
|
||||
|
||||
private static Entity FindMatchingEntity(Shape shape, Entity clickedEntity)
|
||||
{
|
||||
foreach (var shapeEntity in shape.Entities)
|
||||
{
|
||||
if (shapeEntity.GetType() != clickedEntity.GetType())
|
||||
continue;
|
||||
|
||||
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
|
||||
{
|
||||
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
|
||||
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
|
||||
return shapeEntity;
|
||||
}
|
||||
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
|
||||
{
|
||||
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
|
||||
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
|
||||
return shapeEntity;
|
||||
}
|
||||
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
|
||||
{
|
||||
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
|
||||
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
|
||||
return shapeEntity;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void EmitRawContour(Program program, Shape shape)
|
||||
{
|
||||
var startPoint = GetShapeStartPoint(shape);
|
||||
program.Codes.Add(new RapidMove(startPoint));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
|
||||
}
|
||||
|
||||
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
|
||||
{
|
||||
var entries = new ContourEntry[cutouts.Count];
|
||||
var currentPoint = startPoint;
|
||||
|
||||
// Walk backward through cutting order (from perimeter outward)
|
||||
// so each cutout's lead-in point faces the next cutout to be cut
|
||||
for (var i = cutouts.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var closestPt = cutouts[i].ClosestPointTo(currentPoint, out var entity);
|
||||
entries[i] = new ContourEntry(cutouts[i], closestPt, entity);
|
||||
currentPoint = closestPt;
|
||||
}
|
||||
|
||||
return new List<ContourEntry>(entries);
|
||||
}
|
||||
|
||||
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
var winding = DetermineWinding(shape);
|
||||
var normal = ComputeNormal(point, entity, contourType, winding);
|
||||
|
||||
var leadIn = SelectLeadIn(contourType);
|
||||
var leadOut = SelectLeadOut(contourType);
|
||||
|
||||
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||
|
||||
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
||||
|
||||
var reindexed = shape.ReindexAt(point, entity);
|
||||
|
||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
||||
reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size);
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexed, point));
|
||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||
}
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
{
|
||||
if (scribeEntities.Count == 0) return;
|
||||
|
||||
var shapes = ShapeBuilder.GetShapes(scribeEntities);
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var startPt = GetShapeStartPoint(shape);
|
||||
program.Codes.Add(new RapidMove(startPt));
|
||||
program.Codes.AddRange(ConvertShapeToMoves(shape, startPt, LayerType.Scribe));
|
||||
}
|
||||
}
|
||||
|
||||
private List<Shape> SequenceCutouts(List<Shape> cutouts, Vector startPoint)
|
||||
{
|
||||
var remaining = new List<Shape>(cutouts);
|
||||
@@ -102,7 +255,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private ContourType DetectContourType(Shape cutout)
|
||||
public static ContourType DetectContourType(Shape cutout)
|
||||
{
|
||||
if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle)
|
||||
return ContourType.ArcCircle;
|
||||
@@ -110,23 +263,33 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return ContourType.Internal;
|
||||
}
|
||||
|
||||
private double ComputeNormal(Vector point, Entity entity, ContourType contourType)
|
||||
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
double normal;
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
// Perpendicular to line direction
|
||||
// Perpendicular to line direction: tangent + π/2 = left side.
|
||||
// Left side = outward for CW winding; for CCW winding, outward
|
||||
// is on the right side, so flip.
|
||||
var tangent = line.EndPoint.AngleFrom(line.StartPoint);
|
||||
normal = tangent + Math.Angle.HalfPI;
|
||||
if (winding == RotationType.CCW)
|
||||
normal += System.Math.PI;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
// Radial direction from center to point
|
||||
// Radial direction from center to point.
|
||||
// Flip when the arc direction differs from the contour winding —
|
||||
// that indicates a concave feature where radial points inward.
|
||||
normal = point.AngleFrom(arc.Center);
|
||||
if (arc.Rotation != winding)
|
||||
normal += System.Math.PI;
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Radial outward — always correct regardless of winding
|
||||
normal = point.AngleFrom(circle.Center);
|
||||
}
|
||||
else
|
||||
@@ -141,11 +304,61 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return Math.Angle.NormalizeRad(normal);
|
||||
}
|
||||
|
||||
private RotationType DetermineWinding(Shape shape)
|
||||
public static RotationType DetermineWinding(Shape shape)
|
||||
{
|
||||
// Use signed area: positive = CCW, negative = CW
|
||||
var area = shape.Area();
|
||||
return area >= 0 ? RotationType.CCW : RotationType.CW;
|
||||
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
|
||||
return circle.Rotation;
|
||||
|
||||
var polygon = shape.ToPolygon();
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return RotationType.CCW;
|
||||
|
||||
return polygon.RotationDirection();
|
||||
}
|
||||
|
||||
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
|
||||
{
|
||||
if (leadIn is NoLeadIn || Parameters.PierceClearance <= 0)
|
||||
return leadIn;
|
||||
|
||||
var piercePoint = leadIn.GetPiercePoint(contourPoint, normalAngle);
|
||||
var maxRadius = circle.Radius - Parameters.PierceClearance;
|
||||
if (maxRadius <= 0)
|
||||
return leadIn;
|
||||
|
||||
var distFromCenter = piercePoint.DistanceTo(circle.Center);
|
||||
if (distFromCenter <= maxRadius)
|
||||
return leadIn;
|
||||
|
||||
// Compute max distance from contourPoint toward piercePoint that stays
|
||||
// inside a circle of radius maxRadius centered at circle.Center.
|
||||
// Solve: |contourPoint + t*d - center|^2 = maxRadius^2
|
||||
var currentDist = contourPoint.DistanceTo(piercePoint);
|
||||
if (currentDist < Math.Tolerance.Epsilon)
|
||||
return leadIn;
|
||||
|
||||
var dx = (piercePoint.X - contourPoint.X) / currentDist;
|
||||
var dy = (piercePoint.Y - contourPoint.Y) / currentDist;
|
||||
var vx = contourPoint.X - circle.Center.X;
|
||||
var vy = contourPoint.Y - circle.Center.Y;
|
||||
|
||||
var b = 2.0 * (vx * dx + vy * dy);
|
||||
var c = vx * vx + vy * vy - maxRadius * maxRadius;
|
||||
var discriminant = b * b - 4.0 * c;
|
||||
|
||||
if (discriminant < 0)
|
||||
return leadIn;
|
||||
|
||||
var t = (-b + System.Math.Sqrt(discriminant)) / 2.0;
|
||||
if (t <= 0)
|
||||
return leadIn;
|
||||
|
||||
var scale = t / currentDist;
|
||||
if (scale >= 1.0)
|
||||
return leadIn;
|
||||
|
||||
return leadIn.Scale(scale);
|
||||
}
|
||||
|
||||
private LeadIn SelectLeadIn(ContourType contourType)
|
||||
@@ -168,7 +381,71 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
};
|
||||
}
|
||||
|
||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint)
|
||||
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
|
||||
{
|
||||
var tabCircle = new Circle(center, tabSize);
|
||||
var entities = new List<Entity>(shape.Entities);
|
||||
|
||||
// Trim end: walk backward removing entities inside the tab circle
|
||||
while (entities.Count > 0)
|
||||
{
|
||||
var entity = entities[entities.Count - 1];
|
||||
if (entity.Intersects(tabCircle, out var pts) && pts.Count > 0)
|
||||
{
|
||||
// Find intersection furthest from center (furthest along path from end)
|
||||
var best = pts[0];
|
||||
var bestDist = best.DistanceTo(center);
|
||||
for (var j = 1; j < pts.Count; j++)
|
||||
{
|
||||
var dist = pts[j].DistanceTo(center);
|
||||
if (dist > bestDist)
|
||||
{
|
||||
best = pts[j];
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (first, _) = line.SplitAt(best);
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
if (first != null)
|
||||
entities.Add(first);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
var (first, _) = arc.SplitAt(best);
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
if (first != null)
|
||||
entities.Add(first);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// No intersection — entity is entirely inside circle, remove it
|
||||
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
|
||||
{
|
||||
entities.RemoveAt(entities.Count - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
var result = new Shape();
|
||||
result.Entities.AddRange(entities);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Vector EntityStartPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line) return line.StartPoint;
|
||||
if (entity is Arc arc) return arc.StartPoint();
|
||||
return Vector.Zero;
|
||||
}
|
||||
|
||||
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display)
|
||||
{
|
||||
var moves = new List<ICode>();
|
||||
|
||||
@@ -176,15 +453,15 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
moves.Add(new LinearMove(line.EndPoint));
|
||||
moves.Add(new LinearMove(line.EndPoint) { Layer = layer });
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW));
|
||||
moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation));
|
||||
moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation) { Layer = layer });
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,5 +471,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
private static Vector GetShapeStartPoint(Shape shape)
|
||||
{
|
||||
var first = shape.Entities[0];
|
||||
if (first is Line line) return line.StartPoint;
|
||||
if (first is Arc arc) return arc.StartPoint();
|
||||
if (first is Circle circle) return new Vector(circle.Center.X + circle.Radius, circle.Center.Y);
|
||||
return Vector.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn();
|
||||
public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut();
|
||||
|
||||
public double PierceClearance { get; set; } = 0.0625;
|
||||
|
||||
public double AutoTabMinSize { get; set; }
|
||||
public double AutoTabMaxSize { get; set; }
|
||||
|
||||
public Tab TabConfig { get; set; }
|
||||
public bool TabsEnabled { get; set; }
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,5 +32,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
arcCenterX + Radius * System.Math.Cos(contourNormalAngle),
|
||||
arcCenterY + Radius * System.Math.Sin(contourNormalAngle));
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new ArcLeadIn { Radius = Radius * factor };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new CleanHoleLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, Kerf = Kerf };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
RotationType winding = RotationType.CW);
|
||||
|
||||
public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle);
|
||||
|
||||
public virtual LeadIn Scale(double factor) => this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(arcStart),
|
||||
new ArcMove(contourStartPoint, arcCenter, winding)
|
||||
new LinearMove(arcStart) { Layer = LayerType.Leadin },
|
||||
new ArcMove(contourStartPoint, arcCenter, winding) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,5 +45,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
arcStartX + LineLength * System.Math.Cos(lineAngle),
|
||||
arcStartY + LineLength * System.Math.Sin(lineAngle));
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineArcLeadIn { LineLength = LineLength * factor, ArcRadius = ArcRadius * factor, ApproachAngle = ApproachAngle };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,19 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(contourStartPoint)
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
{
|
||||
var approachAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var approachAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle);
|
||||
return new Vector(
|
||||
contourStartPoint.X + Length * System.Math.Cos(approachAngle),
|
||||
contourStartPoint.Y + Length * System.Math.Sin(approachAngle));
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineLeadIn { Length = Length * factor, ApproachAngle = ApproachAngle };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle);
|
||||
|
||||
var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1);
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midPoint = new Vector(
|
||||
contourStartPoint.X + Length2 * System.Math.Cos(secondAngle),
|
||||
contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle));
|
||||
@@ -24,14 +24,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return new List<ICode>
|
||||
{
|
||||
new RapidMove(piercePoint),
|
||||
new LinearMove(midPoint),
|
||||
new LinearMove(contourStartPoint)
|
||||
new LinearMove(midPoint) { Layer = LayerType.Leadin },
|
||||
new LinearMove(contourStartPoint) { Layer = LayerType.Leadin }
|
||||
};
|
||||
}
|
||||
|
||||
public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle)
|
||||
{
|
||||
var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1);
|
||||
var secondAngle = contourNormalAngle - Angle.HalfPI + Angle.ToRadians(ApproachAngle1);
|
||||
var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle);
|
||||
var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle);
|
||||
|
||||
@@ -40,5 +40,8 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
midX + Length1 * System.Math.Cos(firstAngle),
|
||||
midY + Length1 * System.Math.Sin(firstAngle));
|
||||
}
|
||||
|
||||
public override LeadIn Scale(double factor) =>
|
||||
new LineLineLeadIn { Length1 = Length1 * factor, ApproachAngle1 = ApproachAngle1, Length2 = Length2 * factor, ApproachAngle2 = ApproachAngle2 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new ArcMove(endPoint, arcCenter, winding)
|
||||
new ArcMove(endPoint, arcCenter, winding) { Layer = LayerType.Leadout }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
var overcutAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle);
|
||||
var overcutAngle = contourNormalAngle + Angle.HalfPI - Angle.ToRadians(ApproachAngle);
|
||||
var endPoint = new Vector(
|
||||
contourEndPoint.X + Length * System.Math.Cos(overcutAngle),
|
||||
contourEndPoint.Y + Length * System.Math.Sin(overcutAngle));
|
||||
|
||||
return new List<ICode>
|
||||
{
|
||||
new LinearMove(endPoint)
|
||||
new LinearMove(endPoint) { Layer = LayerType.Leadout }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
public class MicrotabLeadOut : LeadOut
|
||||
{
|
||||
public double GapSize { get; set; } = 0.03;
|
||||
|
||||
public override List<ICode> Generate(Vector contourEndPoint, double contourNormalAngle,
|
||||
RotationType winding = RotationType.CW)
|
||||
{
|
||||
return new List<ICode>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
public double Value { get; set; }
|
||||
|
||||
public string VariableRef { get; set; }
|
||||
|
||||
public CodeType Type
|
||||
{
|
||||
get { return CodeType.SetFeedrate; }
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
public ICode Clone()
|
||||
{
|
||||
return new Feedrate(Value);
|
||||
return new Feedrate(Value) { VariableRef = VariableRef };
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -31,7 +32,9 @@ namespace OpenNest.CNC
|
||||
{
|
||||
return new LinearMove(EndPoint)
|
||||
{
|
||||
Layer = Layer
|
||||
Layer = Layer,
|
||||
Suppressed = Suppressed,
|
||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -12,6 +13,10 @@ namespace OpenNest.CNC
|
||||
|
||||
public int Feedrate { get; set; }
|
||||
|
||||
public bool Suppressed { get; set; }
|
||||
|
||||
public Dictionary<string, string> VariableRefs { get; set; }
|
||||
|
||||
protected Motion()
|
||||
{
|
||||
Feedrate = CNC.Feedrate.UseDefault;
|
||||
@@ -20,21 +25,25 @@ namespace OpenNest.CNC
|
||||
public virtual void Rotate(double angle)
|
||||
{
|
||||
EndPoint = EndPoint.Rotate(angle);
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public virtual void Rotate(double angle, Vector origin)
|
||||
{
|
||||
EndPoint = EndPoint.Rotate(angle, origin);
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public virtual void Offset(double x, double y)
|
||||
{
|
||||
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public virtual void Offset(Vector voffset)
|
||||
{
|
||||
EndPoint += voffset;
|
||||
VariableRefs = null;
|
||||
}
|
||||
|
||||
public abstract CodeType Type { get; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
@@ -9,6 +10,8 @@ namespace OpenNest.CNC
|
||||
{
|
||||
public List<ICode> Codes;
|
||||
|
||||
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private Mode mode;
|
||||
|
||||
public Program(Mode mode = Mode.Absolute)
|
||||
@@ -51,37 +54,7 @@ namespace OpenNest.CNC
|
||||
mode = Mode.Absolute;
|
||||
}
|
||||
|
||||
public virtual void Rotate(double angle)
|
||||
{
|
||||
var mode = Mode;
|
||||
|
||||
SetModeAbs();
|
||||
|
||||
for (int i = 0; i < Codes.Count; ++i)
|
||||
{
|
||||
var code = Codes[i];
|
||||
|
||||
if (code.Type == CodeType.SubProgramCall)
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
|
||||
if (subpgm.Program != null)
|
||||
subpgm.Program.Rotate(angle);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
continue;
|
||||
|
||||
var code2 = (Motion)code;
|
||||
|
||||
code2.Rotate(angle);
|
||||
}
|
||||
|
||||
if (mode == Mode.Incremental)
|
||||
SetModeInc();
|
||||
|
||||
Rotation = Angle.NormalizeRad(Rotation + angle);
|
||||
}
|
||||
public virtual void Rotate(double angle) => Rotate(angle, new Vector(0, 0));
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
@@ -484,6 +457,9 @@ namespace OpenNest.CNC
|
||||
|
||||
pgm.Codes.AddRange(codes);
|
||||
|
||||
foreach (var kvp in Variables)
|
||||
pgm.Variables[kvp.Key] = kvp.Value;
|
||||
|
||||
return pgm;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
@@ -26,7 +27,11 @@ namespace OpenNest.CNC
|
||||
|
||||
public override ICode Clone()
|
||||
{
|
||||
return new RapidMove(EndPoint);
|
||||
return new RapidMove(EndPoint)
|
||||
{
|
||||
Suppressed = Suppressed,
|
||||
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
|
||||
};
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public sealed class VariableDefinition
|
||||
{
|
||||
public string Name { get; }
|
||||
public string Expression { get; }
|
||||
public double Value { get; }
|
||||
public bool Inline { get; }
|
||||
public bool Global { get; }
|
||||
|
||||
public VariableDefinition(string name, string expression, double value,
|
||||
bool inline = false, bool global = false)
|
||||
{
|
||||
Name = name;
|
||||
Expression = expression;
|
||||
Value = value;
|
||||
Inline = inline;
|
||||
Global = global;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,8 @@ namespace OpenNest.Collections
|
||||
public bool Remove(T item)
|
||||
{
|
||||
var success = items.Remove(item);
|
||||
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
|
||||
if (success)
|
||||
ItemRemoved?.Invoke(this, new ItemRemovedEventArgs<T>(item, success));
|
||||
return success;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
{
|
||||
public enum ContourClassification
|
||||
{
|
||||
Perimeter,
|
||||
Hole,
|
||||
Etch,
|
||||
Open
|
||||
}
|
||||
|
||||
public sealed class ContourInfo
|
||||
{
|
||||
public Shape Shape { get; }
|
||||
public ContourClassification Type { get; private set; }
|
||||
public string Label { get; private set; }
|
||||
|
||||
private ContourInfo(Shape shape, ContourClassification type, string label)
|
||||
{
|
||||
Shape = shape;
|
||||
Type = type;
|
||||
Label = label;
|
||||
}
|
||||
|
||||
public string DirectionLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Type == ContourClassification.Open || Type == ContourClassification.Etch)
|
||||
return "Open";
|
||||
var poly = Shape.ToPolygon();
|
||||
if (poly == null || poly.Vertices.Count < 3)
|
||||
return "?";
|
||||
return poly.RotationDirection() == RotationType.CW ? "CW" : "CCW";
|
||||
}
|
||||
}
|
||||
|
||||
public string DimensionLabel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Shape.Entities.Count == 1 && Shape.Entities[0] is Circle c)
|
||||
return $"Circle R{c.Radius:0.#}";
|
||||
Shape.UpdateBounds();
|
||||
var box = Shape.BoundingBox;
|
||||
return $"{box.Width:0.#} x {box.Length:0.#}";
|
||||
}
|
||||
}
|
||||
|
||||
public void Reverse()
|
||||
{
|
||||
Shape.Reverse();
|
||||
}
|
||||
|
||||
public void SetLabel(string label)
|
||||
{
|
||||
Label = label;
|
||||
}
|
||||
|
||||
public static List<ContourInfo> Classify(List<Shape> shapes)
|
||||
{
|
||||
if (shapes.Count == 0)
|
||||
return new List<ContourInfo>();
|
||||
|
||||
// Ensure bounding boxes are up to date before comparing
|
||||
foreach (var s in shapes)
|
||||
s.UpdateBounds();
|
||||
|
||||
// Find perimeter — largest bounding box area
|
||||
var perimeterIndex = 0;
|
||||
var maxArea = shapes[0].BoundingBox.Area();
|
||||
for (var i = 1; i < shapes.Count; i++)
|
||||
{
|
||||
var area = shapes[i].BoundingBox.Area();
|
||||
if (area > maxArea)
|
||||
{
|
||||
maxArea = area;
|
||||
perimeterIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
var result = new List<ContourInfo>();
|
||||
var holeCount = 0;
|
||||
var etchCount = 0;
|
||||
var openCount = 0;
|
||||
|
||||
// Non-perimeter shapes first (matches CNC cut order: holes before perimeter)
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
if (i == perimeterIndex) continue;
|
||||
var shape = shapes[i];
|
||||
var type = ClassifyShape(shape);
|
||||
|
||||
string label;
|
||||
switch (type)
|
||||
{
|
||||
case ContourClassification.Hole:
|
||||
holeCount++;
|
||||
label = $"Hole {holeCount}";
|
||||
break;
|
||||
case ContourClassification.Etch:
|
||||
etchCount++;
|
||||
label = etchCount == 1 ? "Etch" : $"Etch {etchCount}";
|
||||
break;
|
||||
default:
|
||||
openCount++;
|
||||
label = openCount == 1 ? "Open" : $"Open {openCount}";
|
||||
break;
|
||||
}
|
||||
|
||||
result.Add(new ContourInfo(shape, type, label));
|
||||
}
|
||||
|
||||
// Perimeter last
|
||||
result.Add(new ContourInfo(shapes[perimeterIndex], ContourClassification.Perimeter, "Perimeter"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ContourClassification ClassifyShape(Shape shape)
|
||||
{
|
||||
// Check etch layer — all entities must be on ETCH layer
|
||||
if (shape.Entities.Count > 0 &&
|
||||
shape.Entities.All(e => string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase)))
|
||||
return ContourClassification.Etch;
|
||||
|
||||
if (shape.IsClosed())
|
||||
return ContourClassification.Hole;
|
||||
|
||||
return ContourClassification.Open;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ namespace OpenNest.Converters
|
||||
if (startpt != lastpt)
|
||||
pgm.MoveTo(startpt);
|
||||
|
||||
pgm.ArcTo(startpt, circle.Center, RotationType.CCW);
|
||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
||||
|
||||
lastpt = startpt;
|
||||
return lastpt;
|
||||
@@ -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;
|
||||
|
||||
@@ -106,7 +106,7 @@ namespace OpenNest.Converters
|
||||
var layer = ConvertLayer(arcMove.Layer);
|
||||
|
||||
if (startAngle.IsEqualTo(endAngle))
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color });
|
||||
geometry.Add(new Circle(center, radius) { Layer = layer, Color = layer.Color, Rotation = arcMove.Rotation });
|
||||
else
|
||||
geometry.Add(new Arc(center, radius, startAngle, endAngle, arcMove.Rotation == RotationType.CW) { Layer = layer, Color = layer.Color });
|
||||
|
||||
|
||||
@@ -50,13 +50,13 @@ namespace OpenNest
|
||||
{
|
||||
cutPosition = Position.X;
|
||||
lineStart = StartLimit ?? bounds.Y;
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Length + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.Y + bounds.Width + settings.Overtravel);
|
||||
}
|
||||
else
|
||||
{
|
||||
cutPosition = Position.Y;
|
||||
lineStart = StartLimit ?? bounds.X;
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Width + settings.Overtravel);
|
||||
lineEnd = EndLimit ?? (bounds.X + bounds.Length + settings.Overtravel);
|
||||
}
|
||||
|
||||
var exclusions = new List<(double Start, double End)>();
|
||||
@@ -176,13 +176,13 @@ namespace OpenNest
|
||||
|
||||
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);
|
||||
? (bb.X - clearance, bb.X + bb.Length + clearance)
|
||||
: (bb.Y - clearance, bb.Y + bb.Width + 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);
|
||||
? (bb.Y - clearance, bb.Y + bb.Width + clearance)
|
||||
: (bb.X - clearance, bb.X + bb.Length + clearance);
|
||||
|
||||
private Program BuildProgram(List<(double Start, double End)> segments, CutOffSettings settings)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
@@ -12,8 +13,32 @@ namespace OpenNest
|
||||
public class Drawing
|
||||
{
|
||||
private static int nextId;
|
||||
private static int nextColorIndex;
|
||||
private Program program;
|
||||
|
||||
public static readonly Color[] PartColors = new Color[]
|
||||
{
|
||||
Color.FromArgb(205, 92, 92), // Indian Red
|
||||
Color.FromArgb(148, 103, 189), // Medium Purple
|
||||
Color.FromArgb(75, 180, 175), // Teal
|
||||
Color.FromArgb(210, 190, 75), // Goldenrod
|
||||
Color.FromArgb(190, 85, 175), // Orchid
|
||||
Color.FromArgb(185, 115, 85), // Sienna
|
||||
Color.FromArgb(120, 100, 190), // Slate Blue
|
||||
Color.FromArgb(200, 100, 140), // Rose
|
||||
Color.FromArgb(80, 175, 155), // Sea Green
|
||||
Color.FromArgb(195, 160, 85), // Dark Khaki
|
||||
Color.FromArgb(175, 95, 160), // Plum
|
||||
Color.FromArgb(215, 130, 130), // Light Coral
|
||||
};
|
||||
|
||||
public static Color GetNextColor()
|
||||
{
|
||||
var color = PartColors[nextColorIndex % PartColors.Length];
|
||||
nextColorIndex++;
|
||||
return color;
|
||||
}
|
||||
|
||||
public Drawing()
|
||||
: this(string.Empty, new Program())
|
||||
{
|
||||
@@ -66,6 +91,18 @@ namespace OpenNest
|
||||
|
||||
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||
|
||||
/// <summary>
|
||||
/// Complete set of source entities with stable GUIDs.
|
||||
/// Null when the drawing was created from G-code or an older nest file.
|
||||
/// </summary>
|
||||
public List<Entity> SourceEntities { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// IDs of entities in <see cref="SourceEntities"/> that are suppressed (hidden).
|
||||
/// Suppressed entities are excluded from the active Program but preserved for re-enabling.
|
||||
/// </summary>
|
||||
public HashSet<Guid> SuppressedEntityIds { get; set; } = new HashSet<Guid>();
|
||||
|
||||
public double Area { get; protected set; }
|
||||
|
||||
public void UpdateArea()
|
||||
|
||||
@@ -155,6 +155,17 @@ namespace OpenNest.Geometry
|
||||
Center.Y + Radius * System.Math.Sin(EndAngle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mid point of the arc (point at the angle midway between start and end).
|
||||
/// </summary>
|
||||
public Vector MidPoint()
|
||||
{
|
||||
var midAngle = StartAngle + (IsReversed ? -SweepAngle() / 2 : SweepAngle() / 2);
|
||||
return new Vector(
|
||||
Center.X + Radius * System.Math.Cos(midAngle),
|
||||
Center.Y + Radius * System.Math.Sin(midAngle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits the arc at the given point, returning two sub-arcs.
|
||||
/// Either half may be null if the split point coincides with an endpoint.
|
||||
@@ -409,8 +420,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
boundingBox.X = minX;
|
||||
boundingBox.Y = minY;
|
||||
boundingBox.Width = maxX - minX;
|
||||
boundingBox.Length = maxY - minY;
|
||||
boundingBox.Length = maxX - minX;
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared arc-fitting utilities used by SplineConverter and GeometrySimplifier.
|
||||
/// </summary>
|
||||
internal static class ArcFit
|
||||
{
|
||||
/// <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>
|
||||
internal 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
||||
/// the first and last points. The center lies at the intersection of the normals
|
||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
||||
/// deviation includes any endpoint gap at Pn.
|
||||
/// </summary>
|
||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||
{
|
||||
if (points.Count < 3)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var p1 = points[0];
|
||||
var pn = points[^1];
|
||||
|
||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
||||
if (stLen < 1e-10 || etLen < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Normal to start tangent at P1 (perpendicular)
|
||||
var n1x = -startTangent.Y / stLen;
|
||||
var n1y = startTangent.X / stLen;
|
||||
|
||||
// Normal to end tangent at Pn
|
||||
var n2x = -endTangent.Y / etLen;
|
||||
var n2y = endTangent.X / etLen;
|
||||
|
||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
||||
if (System.Math.Abs(det) < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
var dx = pn.X - p1.X;
|
||||
var dy = pn.Y - p1.Y;
|
||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
||||
|
||||
var cx = p1.X + t1 * n1x;
|
||||
var cy = p1.Y + t1 * n1y;
|
||||
|
||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
||||
if (r1 < 1e-10)
|
||||
return (Vector.Invalid, 0, double.MaxValue);
|
||||
|
||||
// Measure endpoint gap at Pn
|
||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
||||
var endpointDev = System.Math.Abs(r2 - r1);
|
||||
|
||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the maximum radial deviation of interior points from a circle.
|
||||
/// </summary>
|
||||
internal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
double minX = boxes[0].X;
|
||||
double minY = boxes[0].Y;
|
||||
double maxX = boxes[0].X + boxes[0].Width;
|
||||
double maxY = boxes[0].Y + boxes[0].Length;
|
||||
double maxX = boxes[0].Right;
|
||||
double maxY = boxes[0].Top;
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
|
||||
@@ -14,15 +14,15 @@ namespace OpenNest.Geometry
|
||||
public Box(double x, double y, double w, double h)
|
||||
{
|
||||
Location = new Vector(x, y);
|
||||
Width = w;
|
||||
Length = h;
|
||||
Length = w;
|
||||
Width = h;
|
||||
}
|
||||
|
||||
public Vector Location;
|
||||
|
||||
public Vector Center
|
||||
{
|
||||
get { return new Vector(X + Width * 0.5, Y + Length * 0.5); }
|
||||
get { return new Vector(X + Length * 0.5, Y + Width * 0.5); }
|
||||
}
|
||||
|
||||
public Size Size;
|
||||
@@ -76,12 +76,12 @@ namespace OpenNest.Geometry
|
||||
|
||||
public Box Translate(double x, double y)
|
||||
{
|
||||
return new Box(X + x, Y + y, Width, Length);
|
||||
return new Box(X + x, Y + y, Length, Width);
|
||||
}
|
||||
|
||||
public Box Translate(Vector offset)
|
||||
{
|
||||
return new Box(X + offset.X, Y + offset.Y, Width, Length);
|
||||
return new Box(X + offset.X, Y + offset.Y, Length, Width);
|
||||
}
|
||||
|
||||
public double Left
|
||||
@@ -91,12 +91,12 @@ namespace OpenNest.Geometry
|
||||
|
||||
public double Right
|
||||
{
|
||||
get { return X + Width; }
|
||||
get { return X + Length; }
|
||||
}
|
||||
|
||||
public double Top
|
||||
{
|
||||
get { return Y + Length; }
|
||||
get { return Y + Width; }
|
||||
}
|
||||
|
||||
public double Bottom
|
||||
@@ -207,7 +207,7 @@ namespace OpenNest.Geometry
|
||||
|
||||
public Box Offset(double d)
|
||||
{
|
||||
return new Box(X - d, Y - d, Width + d * 2, Length + d * 2);
|
||||
return new Box(X - d, Y - d, Length + d * 2, Width + d * 2);
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
var x = large.Left;
|
||||
var y = small.Top;
|
||||
var w = large.Width;
|
||||
var w = large.Length;
|
||||
var h = large.Top - y;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
@@ -23,7 +23,7 @@
|
||||
var x = large.Left;
|
||||
var y = large.Bottom;
|
||||
var w = small.Left - x;
|
||||
var h = large.Length;
|
||||
var h = large.Width;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
var x = large.Left;
|
||||
var y = large.Bottom;
|
||||
var w = large.Width;
|
||||
var w = large.Length;
|
||||
var h = small.Top - y;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
@@ -49,7 +49,7 @@
|
||||
var x = small.Right;
|
||||
var y = large.Bottom;
|
||||
var w = large.Right - x;
|
||||
var h = large.Length;
|
||||
var h = large.Width;
|
||||
|
||||
return new Box(x, y, w, h);
|
||||
}
|
||||
|
||||
@@ -137,7 +137,9 @@ namespace OpenNest.Geometry
|
||||
public List<Vector> ToPoints(int segments = 1000, bool circumscribe = false)
|
||||
{
|
||||
var points = new List<Vector>();
|
||||
var stepAngle = Angle.TwoPI / segments;
|
||||
var stepAngle = Rotation == RotationType.CW
|
||||
? -Angle.TwoPI / segments
|
||||
: Angle.TwoPI / segments;
|
||||
|
||||
var r = circumscribe && segments > 0
|
||||
? Radius / System.Math.Cos(stepAngle / 2.0)
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public static class Collision
|
||||
{
|
||||
public static CollisionResult Check(Polygon a, Polygon b,
|
||||
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
||||
{
|
||||
// Step 1: Bounding box pre-filter
|
||||
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||
return CollisionResult.None;
|
||||
|
||||
// Step 2: Quick intersection test for crossing points
|
||||
var intersectionPoints = FindCrossingPoints(a, b);
|
||||
|
||||
// Step 3: Convex decomposition
|
||||
var trisA = TriangulateWithBounds(a);
|
||||
var trisB = TriangulateWithBounds(b);
|
||||
|
||||
// Step 4: Clip all triangle pairs
|
||||
var regions = new List<Polygon>();
|
||||
|
||||
foreach (var triA in trisA)
|
||||
{
|
||||
foreach (var triB in trisB)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(triA.BoundingBox, triB.BoundingBox))
|
||||
continue;
|
||||
|
||||
var clipped = ClipConvex(triA, triB);
|
||||
if (clipped != null)
|
||||
regions.Add(clipped);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Hole subtraction
|
||||
if (regions.Count > 0)
|
||||
regions = SubtractHoles(regions, holesA, holesB);
|
||||
|
||||
if (regions.Count == 0)
|
||||
return new CollisionResult(false, regions, intersectionPoints);
|
||||
|
||||
// Step 6: Build result
|
||||
return new CollisionResult(true, regions, intersectionPoints);
|
||||
}
|
||||
|
||||
public static bool HasOverlap(Polygon a, Polygon b,
|
||||
List<Polygon> holesA = null, List<Polygon> holesB = null)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(a.BoundingBox, b.BoundingBox))
|
||||
return false;
|
||||
|
||||
// Full check is needed: crossing points alone miss containment cases
|
||||
// (one polygon entirely inside another has zero edge crossings).
|
||||
return Check(a, b, holesA, holesB).Overlaps;
|
||||
}
|
||||
|
||||
public static List<CollisionResult> CheckAll(List<Polygon> polygons,
|
||||
List<List<Polygon>> holes = null)
|
||||
{
|
||||
var results = new List<CollisionResult>();
|
||||
|
||||
for (var i = 0; i < polygons.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < polygons.Count; j++)
|
||||
{
|
||||
var holesA = holes != null && i < holes.Count ? holes[i] : null;
|
||||
var holesB = holes != null && j < holes.Count ? holes[j] : null;
|
||||
var result = Check(polygons[i], polygons[j], holesA, holesB);
|
||||
|
||||
if (result.Overlaps)
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static bool HasAnyOverlap(List<Polygon> polygons,
|
||||
List<List<Polygon>> holes = null)
|
||||
{
|
||||
for (var i = 0; i < polygons.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < polygons.Count; j++)
|
||||
{
|
||||
var holesA = holes != null && i < holes.Count ? holes[i] : null;
|
||||
var holesB = holes != null && j < holes.Count ? holes[j] : null;
|
||||
|
||||
if (HasOverlap(polygons[i], polygons[j], holesA, holesB))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool BoundingBoxesOverlap(Box a, Box b)
|
||||
{
|
||||
var overlapX = System.Math.Min(a.Right, b.Right)
|
||||
- System.Math.Max(a.Left, b.Left);
|
||||
var overlapY = System.Math.Min(a.Top, b.Top)
|
||||
- System.Math.Max(a.Bottom, b.Bottom);
|
||||
|
||||
return overlapX > Tolerance.Epsilon && overlapY > Tolerance.Epsilon;
|
||||
}
|
||||
|
||||
private static List<Vector> FindCrossingPoints(Polygon a, Polygon b)
|
||||
{
|
||||
if (!Intersect.Intersects(a, b, out var rawPts))
|
||||
return new List<Vector>();
|
||||
|
||||
// Filter boundary contacts (vertex touches)
|
||||
var vertsA = CollectVertices(a);
|
||||
var vertsB = CollectVertices(b);
|
||||
var filtered = new List<Vector>();
|
||||
|
||||
foreach (var pt in rawPts)
|
||||
{
|
||||
if (IsNearAnyVertex(pt, vertsA) || IsNearAnyVertex(pt, vertsB))
|
||||
continue;
|
||||
filtered.Add(pt);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private static List<Vector> CollectVertices(Polygon polygon)
|
||||
{
|
||||
var verts = new List<Vector>(polygon.Vertices.Count);
|
||||
foreach (var v in polygon.Vertices)
|
||||
verts.Add(v);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triangulates a polygon and ensures each triangle has its bounding box updated.
|
||||
/// </summary>
|
||||
private static List<Polygon> TriangulateWithBounds(Polygon polygon)
|
||||
{
|
||||
var tris = ConvexDecomposition.Triangulate(polygon);
|
||||
foreach (var tri in tris)
|
||||
tri.UpdateBounds();
|
||||
return tris;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sutherland-Hodgman polygon clipping. Clips subject against each edge
|
||||
/// of clip. Both must be convex. Returns null if no overlap.
|
||||
/// </summary>
|
||||
private static Polygon ClipConvex(Polygon subject, Polygon clip)
|
||||
{
|
||||
var output = new List<Vector>(subject.Vertices);
|
||||
|
||||
// Remove closing vertex if present
|
||||
if (output.Count > 1 && output[0].X == output[output.Count - 1].X
|
||||
&& output[0].Y == output[output.Count - 1].Y)
|
||||
output.RemoveAt(output.Count - 1);
|
||||
|
||||
var clipVerts = new List<Vector>(clip.Vertices);
|
||||
if (clipVerts.Count > 1 && clipVerts[0].X == clipVerts[clipVerts.Count - 1].X
|
||||
&& clipVerts[0].Y == clipVerts[clipVerts.Count - 1].Y)
|
||||
clipVerts.RemoveAt(clipVerts.Count - 1);
|
||||
|
||||
for (var i = 0; i < clipVerts.Count; i++)
|
||||
{
|
||||
if (output.Count == 0)
|
||||
return null;
|
||||
|
||||
var edgeStart = clipVerts[i];
|
||||
var edgeEnd = clipVerts[(i + 1) % clipVerts.Count];
|
||||
var input = output;
|
||||
output = new List<Vector>();
|
||||
|
||||
for (var j = 0; j < input.Count; j++)
|
||||
{
|
||||
var current = input[j];
|
||||
var next = input[(j + 1) % input.Count];
|
||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
||||
|
||||
if (currentInside)
|
||||
{
|
||||
output.Add(current);
|
||||
if (!nextInside)
|
||||
{
|
||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (ix.IsValid())
|
||||
output.Add(ix);
|
||||
}
|
||||
}
|
||||
else if (nextInside)
|
||||
{
|
||||
var ix = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (ix.IsValid())
|
||||
output.Add(ix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (output.Count < 3)
|
||||
return null;
|
||||
|
||||
var result = new Polygon();
|
||||
result.Vertices.AddRange(output);
|
||||
result.Close();
|
||||
result.UpdateBounds();
|
||||
|
||||
// Reject degenerate slivers
|
||||
if (result.Area() < Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cross product of vectors (edgeStart->edgeEnd) and (edgeStart->point).
|
||||
/// Positive = point is left of edge (inside for CCW polygon).
|
||||
/// </summary>
|
||||
private static double Cross(Vector edgeStart, Vector edgeEnd, Vector point)
|
||||
{
|
||||
return (edgeEnd.X - edgeStart.X) * (point.Y - edgeStart.Y)
|
||||
- (edgeEnd.Y - edgeStart.Y) * (point.X - edgeStart.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intersection of lines (a1->a2) and (b1->b2). Returns Vector.Invalid if parallel.
|
||||
/// </summary>
|
||||
private static Vector LineIntersection(Vector a1, Vector a2, Vector b1, Vector b2)
|
||||
{
|
||||
var d1x = a2.X - a1.X;
|
||||
var d1y = a2.Y - a1.Y;
|
||||
var d2x = b2.X - b1.X;
|
||||
var d2y = b2.Y - b1.Y;
|
||||
var cross = d1x * d2y - d1y * d2x;
|
||||
|
||||
if (System.Math.Abs(cross) < Tolerance.Epsilon)
|
||||
return Vector.Invalid;
|
||||
|
||||
var t = ((b1.X - a1.X) * d2y - (b1.Y - a1.Y) * d2x) / cross;
|
||||
return new Vector(a1.X + t * d1x, a1.Y + t * d1y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts holes from overlap regions.
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractHoles(List<Polygon> regions,
|
||||
List<Polygon> holesA, List<Polygon> holesB)
|
||||
{
|
||||
var allHoles = new List<Polygon>();
|
||||
if (holesA != null) allHoles.AddRange(holesA);
|
||||
if (holesB != null) allHoles.AddRange(holesB);
|
||||
|
||||
if (allHoles.Count == 0)
|
||||
return regions;
|
||||
|
||||
foreach (var hole in allHoles)
|
||||
{
|
||||
var holeTris = TriangulateWithBounds(hole);
|
||||
var surviving = new List<Polygon>();
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
var pieces = SubtractTriangles(region, holeTris);
|
||||
surviving.AddRange(pieces);
|
||||
}
|
||||
|
||||
regions = surviving;
|
||||
|
||||
if (regions.Count == 0)
|
||||
break;
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
||||
/// keep the full piece triangle (acceptable for visual shading).
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||
{
|
||||
var current = new List<Polygon> { region };
|
||||
|
||||
foreach (var holeTri in holeTris)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
|
||||
continue;
|
||||
|
||||
var next = new List<Polygon>();
|
||||
|
||||
foreach (var piece in current)
|
||||
{
|
||||
var pieceTris = TriangulateWithBounds(piece);
|
||||
|
||||
foreach (var pieceTri in pieceTris)
|
||||
{
|
||||
var inside = ClipConvex(pieceTri, holeTri);
|
||||
if (inside == null)
|
||||
{
|
||||
// No overlap with hole - keep
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
|
||||
{
|
||||
// Partial overlap - keep the piece (conservative)
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
// else: fully inside hole - discard
|
||||
}
|
||||
}
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public class CollisionResult
|
||||
{
|
||||
public static readonly CollisionResult None = new(false, new List<Polygon>(), new List<Vector>());
|
||||
|
||||
public CollisionResult(bool overlaps, List<Polygon> overlapRegions, List<Vector> intersectionPoints)
|
||||
{
|
||||
Overlaps = overlaps;
|
||||
OverlapRegions = overlapRegions;
|
||||
IntersectionPoints = intersectionPoints;
|
||||
OverlapArea = overlapRegions.Sum(r => r.Area());
|
||||
}
|
||||
|
||||
public bool Overlaps { get; }
|
||||
public IReadOnlyList<Polygon> OverlapRegions { get; }
|
||||
public IReadOnlyList<Vector> IntersectionPoints { get; }
|
||||
public double OverlapArea { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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);
|
||||
}
|
||||
|
||||
internal static Vector Circumcenter(Vector a, Vector b, Vector c)
|
||||
{
|
||||
var ax = a.X - c.X;
|
||||
var ay = a.Y - c.Y;
|
||||
var bx = b.X - c.X;
|
||||
var by = b.Y - c.Y;
|
||||
var D = 2.0 * (ax * by - ay * bx);
|
||||
|
||||
if (System.Math.Abs(D) < 1e-10)
|
||||
return Vector.Invalid;
|
||||
|
||||
var a2 = ax * ax + ay * ay;
|
||||
var b2 = bx * bx + by * by;
|
||||
var ux = (by * a2 - ay * b2) / D;
|
||||
var uy = (ax * b2 - bx * a2) / D;
|
||||
|
||||
return new Vector(ux + c.X, uy + c.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 pMid = EvaluatePoint(semiMajor, semiMinor, rotation, ellipseCenter, (t0 + t1) / 2);
|
||||
|
||||
// Use circumcircle of (p0, pMid, p1) so the arc passes through both
|
||||
// endpoints exactly, eliminating gaps between adjacent arcs.
|
||||
var cc = Circumcenter(p0, pMid, p1);
|
||||
if (cc.IsValid())
|
||||
{
|
||||
arcCenter = cc;
|
||||
radius = p0.DistanceTo(cc);
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
@@ -10,10 +11,16 @@ namespace OpenNest.Geometry
|
||||
|
||||
protected Entity()
|
||||
{
|
||||
Id = Guid.NewGuid();
|
||||
Layer = OpenNest.Geometry.Layer.Default;
|
||||
boundingBox = new Box();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this entity, stable across edit sessions.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Entity color (resolved from DXF ByLayer/ByBlock to actual color).
|
||||
/// </summary>
|
||||
@@ -29,6 +36,11 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
public bool IsVisible { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional tag for identifying generated entities (e.g. bend etch marks).
|
||||
/// </summary>
|
||||
public string Tag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Smallest box that contains the entity.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,65 +7,46 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
public static class GeometryOptimizer
|
||||
{
|
||||
public static void Optimize(IList<Arc> arcs)
|
||||
public static void Optimize(IList<Arc> arcs) =>
|
||||
MergePass(arcs,
|
||||
(list, item, i) => list.GetCoradialArs(item, i),
|
||||
(Arc a, Arc b, out Arc joined) => TryJoinArcs(a, b, out joined));
|
||||
|
||||
public static void Optimize(IList<Line> lines) =>
|
||||
MergePass(lines,
|
||||
(list, item, i) => list.GetCollinearLines(item, i),
|
||||
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
|
||||
|
||||
private delegate bool TryJoin<T>(T a, T b, out T joined);
|
||||
|
||||
private static void MergePass<T>(IList<T> items,
|
||||
Func<IList<T>, T, int, List<T>> findCandidates,
|
||||
TryJoin<T> tryJoin) where T : class
|
||||
{
|
||||
for (int i = 0; i < arcs.Count; ++i)
|
||||
for (var i = 0; i < items.Count; ++i)
|
||||
{
|
||||
var arc = arcs[i];
|
||||
|
||||
var coradialArcs = arcs.GetCoradialArs(arc, i);
|
||||
int index = 0;
|
||||
|
||||
while (index < coradialArcs.Count)
|
||||
{
|
||||
Arc arc2 = coradialArcs[index];
|
||||
Arc joinArc;
|
||||
|
||||
if (!TryJoinArcs(arc, arc2, out joinArc))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
coradialArcs.Remove(arc2);
|
||||
arcs.Remove(arc2);
|
||||
|
||||
arc = joinArc;
|
||||
index = 0;
|
||||
}
|
||||
|
||||
arcs[i] = arc;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Optimize(IList<Line> lines)
|
||||
{
|
||||
for (int i = 0; i < lines.Count; ++i)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
var collinearLines = lines.GetCollinearLines(line, i);
|
||||
var item = items[i];
|
||||
var candidates = findCandidates(items, item, i);
|
||||
var index = 0;
|
||||
|
||||
while (index < collinearLines.Count)
|
||||
while (index < candidates.Count)
|
||||
{
|
||||
Line line2 = collinearLines[index];
|
||||
Line joinLine;
|
||||
var candidate = candidates[index];
|
||||
|
||||
if (!TryJoinLines(line, line2, out joinLine))
|
||||
if (!tryJoin(item, candidate, out var joined))
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
collinearLines.Remove(line2);
|
||||
lines.Remove(line2);
|
||||
candidates.Remove(candidate);
|
||||
items.Remove(candidate);
|
||||
|
||||
line = joinLine;
|
||||
item = joined;
|
||||
index = 0;
|
||||
}
|
||||
|
||||
lines[i] = line;
|
||||
items[i] = item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +57,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 +97,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 +111,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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
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;
|
||||
|
||||
var centroid = new Vector(
|
||||
midpoints.Average(p => p.X),
|
||||
midpoints.Average(p => p.Y));
|
||||
var cx = centroid.X;
|
||||
var cy = centroid.Y;
|
||||
|
||||
// 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 double NormalizeAngle(double angle) =>
|
||||
angle < 0 ? angle + Angle.TwoPI : angle;
|
||||
|
||||
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 PerpendicularDistance(Vector point, Vector axisPoint, Vector axisDir)
|
||||
{
|
||||
var dx = point.X - axisPoint.X;
|
||||
var dy = point.Y - axisPoint.Y;
|
||||
var dot = dx * axisDir.X + dy * axisDir.Y;
|
||||
var px = dx - dot * axisDir.X;
|
||||
var py = dy - dot * axisDir.Y;
|
||||
return System.Math.Sqrt(px * px + py * py);
|
||||
}
|
||||
|
||||
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];
|
||||
var dist = PerpendicularDistance(p, axisPoint, axisDir);
|
||||
|
||||
// Points on the axis count as matched
|
||||
if (dist < matchTol)
|
||||
{
|
||||
matched++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reflect across axis and look for partner
|
||||
var reflected = new MirrorAxisResult(axisPoint, axisDir, 0).Reflect(p);
|
||||
|
||||
for (var j = 0; j < points.Count; j++)
|
||||
{
|
||||
if (i == j) continue;
|
||||
var d = reflected.DistanceTo(points[j]);
|
||||
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;
|
||||
if (PerpendicularDistance(ciCenter, axis.Point, axis.Direction) < 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 = NormalizeAngle(System.Math.Atan2(mirrorEp.Y - mirrorCenter.Y, mirrorEp.X - mirrorCenter.X));
|
||||
var mirrorEnd = NormalizeAngle(System.Math.Atan2(mirrorSp.Y - mirrorCenter.Y, mirrorSp.X - mirrorCenter.X));
|
||||
|
||||
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);
|
||||
var arc = CreateArc(result.Center, result.Radius, result.Points, entities[j]);
|
||||
candidates.Add(new ArcCandidate
|
||||
{
|
||||
StartIndex = j,
|
||||
EndIndex = result.EndIndex,
|
||||
FittedArc = arc,
|
||||
MaxDeviation = result.Deviation,
|
||||
BoundingBox = result.Points.GetBoundingBox(),
|
||||
FirstPoint = arc.StartPoint(),
|
||||
LastPoint = arc.EndPoint(),
|
||||
});
|
||||
|
||||
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 endTangent = GetExitDirection(entities[k]);
|
||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||
if (!center.IsValid()) return null;
|
||||
|
||||
// Extend the arc as far as possible
|
||||
while (k + 1 <= runEnd)
|
||||
{
|
||||
var extPoints = CollectPoints(entities, start, k + 1);
|
||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
||||
if (!nc.IsValid()) break;
|
||||
|
||||
k++;
|
||||
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, Vector endTangent)
|
||||
{
|
||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
||||
if (endTangent.IsValid())
|
||||
{
|
||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
||||
if (dc.IsValid() && dd <= Tolerance)
|
||||
{
|
||||
var isRev = SumSignedAngles(dc, points) < 0;
|
||||
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
|
||||
if (aDev <= Tolerance)
|
||||
return (dc, dr, System.Math.Max(dd, aDev));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to start-tangent-only, then mirror axis
|
||||
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
(center, radius, dev) = FitMirrorAxis(points);
|
||||
if (!center.IsValid() || dev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
// Check that the arc doesn't bulge away from the original line segments
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
|
||||
if (arcDev > Tolerance)
|
||||
return (Vector.Invalid, 0, 0);
|
||||
|
||||
return (center, radius, System.Math.Max(dev, arcDev));
|
||||
}
|
||||
|
||||
/// <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 rx = lastPt.X - center.X;
|
||||
var ry = lastPt.Y - center.Y;
|
||||
var sign = SumSignedAngles(center, points) >= 0 ? 1 : -1;
|
||||
return new Vector(-sign * ry, sign * 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 => ArcFit.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, ArcFit.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 = NormalizeAngle(System.Math.Atan2(firstPoint.Y - center.Y, firstPoint.X - center.X));
|
||||
var endAngle = NormalizeAngle(System.Math.Atan2(lastPoint.Y - center.Y, lastPoint.X - center.X));
|
||||
var isReversed = SumSignedAngles(center, points) < 0;
|
||||
|
||||
var arc = new Arc(center, radius, startAngle, endAngle, isReversed);
|
||||
arc.Layer = sourceEntity.Layer;
|
||||
arc.Color = sourceEntity.Color;
|
||||
return arc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the exit direction (tangent at endpoint) of an entity.
|
||||
/// </summary>
|
||||
private static Vector GetExitDirection(Entity entity) => entity switch
|
||||
{
|
||||
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
|
||||
Arc arc => arc.IsReversed
|
||||
? new Vector(System.Math.Sin(arc.EndAngle), -System.Math.Cos(arc.EndAngle))
|
||||
: new Vector(-System.Math.Sin(arc.EndAngle), System.Math.Cos(arc.EndAngle)),
|
||||
_ => Vector.Invalid,
|
||||
};
|
||||
|
||||
/// <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>
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
@@ -370,23 +370,23 @@ namespace OpenNest.Geometry
|
||||
if (StartPoint.X < EndPoint.X)
|
||||
{
|
||||
boundingBox.X = StartPoint.X;
|
||||
boundingBox.Width = EndPoint.X - StartPoint.X;
|
||||
boundingBox.Length = EndPoint.X - StartPoint.X;
|
||||
}
|
||||
else
|
||||
{
|
||||
boundingBox.X = EndPoint.X;
|
||||
boundingBox.Width = StartPoint.X - EndPoint.X;
|
||||
boundingBox.Length = StartPoint.X - EndPoint.X;
|
||||
}
|
||||
|
||||
if (StartPoint.Y < EndPoint.Y)
|
||||
{
|
||||
boundingBox.Y = StartPoint.Y;
|
||||
boundingBox.Length = EndPoint.Y - StartPoint.Y;
|
||||
boundingBox.Width = EndPoint.Y - StartPoint.Y;
|
||||
}
|
||||
else
|
||||
{
|
||||
boundingBox.Y = EndPoint.Y;
|
||||
boundingBox.Length = StartPoint.Y - EndPoint.Y;
|
||||
boundingBox.Width = StartPoint.Y - EndPoint.Y;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -311,8 +311,8 @@ namespace OpenNest.Geometry
|
||||
|
||||
boundingBox.X = minX;
|
||||
boundingBox.Y = minY;
|
||||
boundingBox.Width = maxX - minX;
|
||||
boundingBox.Length = maxY - minY;
|
||||
boundingBox.Length = maxX - minX;
|
||||
boundingBox.Width = maxY - minY;
|
||||
}
|
||||
|
||||
public override Entity OffsetEntity(double distance, OffsetSide side)
|
||||
|
||||
@@ -532,9 +532,29 @@ namespace OpenNest.Geometry
|
||||
Line line, Line offsetLine,
|
||||
double distance, OffsetSide side, Shape offsetShape)
|
||||
{
|
||||
Vector intersection;
|
||||
// Determine if this is a convex corner using the cross product of
|
||||
// the original line directions. Convex corners need an arc; concave
|
||||
// corners use the line intersection (miter join).
|
||||
var d1 = lastLine.EndPoint - lastLine.StartPoint;
|
||||
var d2 = line.EndPoint - line.StartPoint;
|
||||
var cross = d1.X * d2.Y - d1.Y * d2.X;
|
||||
|
||||
if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out intersection))
|
||||
var isConvex = (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) ||
|
||||
(side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon);
|
||||
|
||||
if (isConvex)
|
||||
{
|
||||
var arc = new Arc(
|
||||
line.StartPoint,
|
||||
distance,
|
||||
line.StartPoint.AngleTo(lastOffsetLine.EndPoint),
|
||||
line.StartPoint.AngleTo(offsetLine.StartPoint),
|
||||
side == OffsetSide.Left
|
||||
);
|
||||
|
||||
offsetShape.Entities.Add(arc);
|
||||
}
|
||||
else if (Intersect.IntersectsUnbounded(offsetLine, lastOffsetLine, out var intersection))
|
||||
{
|
||||
offsetLine.StartPoint = intersection;
|
||||
lastOffsetLine.EndPoint = intersection;
|
||||
@@ -559,43 +579,73 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// Offsets the shape outward by the given distance.
|
||||
/// Normalizes to CW winding before offsetting Left (which is outward for CW),
|
||||
/// making the method independent of the original contour winding direction.
|
||||
/// </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 (poly == null || poly.Vertices.Count < 3
|
||||
|| poly.RotationDirection() == RotationType.CW)
|
||||
return OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
|
||||
if (result == null)
|
||||
return null;
|
||||
// Shape is CCW — reverse to CW so Left offset goes outward.
|
||||
var copy = new Shape();
|
||||
|
||||
UpdateBounds();
|
||||
var originalBB = BoundingBox;
|
||||
result.UpdateBounds();
|
||||
var offsetBB = result.BoundingBox;
|
||||
|
||||
if (offsetBB.Width < originalBB.Width || offsetBB.Length < originalBB.Length)
|
||||
for (var i = Entities.Count - 1; i >= 0; i--)
|
||||
{
|
||||
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;
|
||||
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, Rotation = RotationType.CW });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
|
||||
/// <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, Rotation = RotationType.CCW });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return copy.OffsetEntity(distance, OffsetSide.Left) as Shape;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
@@ -41,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,98 @@ namespace OpenNest.Geometry
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves ray-circle intersection, returning the two parametric t values.
|
||||
/// Returns false if no real intersection exists.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
private static bool SolveRayCircle(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double dirX, double dirY,
|
||||
out double t1, out double t2)
|
||||
{
|
||||
var ox = vx - cx;
|
||||
var oy = vy - cy;
|
||||
|
||||
var a = dirX * dirX + dirY * dirY;
|
||||
var b = 2.0 * (ox * dirX + oy * dirY);
|
||||
var c = ox * ox + oy * oy - r * r;
|
||||
|
||||
var discriminant = b * b - 4.0 * a * c;
|
||||
if (discriminant < 0)
|
||||
{
|
||||
t1 = t2 = double.MaxValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqrtD = System.Math.Sqrt(discriminant);
|
||||
var inv2a = 1.0 / (2.0 * a);
|
||||
t1 = (-b - sqrtD) * inv2a;
|
||||
t2 = (-b + sqrtD) * inv2a;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the distance from a point along a direction to an arc.
|
||||
/// Solves ray-circle intersection, then constrains hits to the arc's
|
||||
/// angular span. Returns double.MaxValue if no hit.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
public static double RayArcDistance(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double startAngle, double endAngle, bool reversed,
|
||||
double dirX, double dirY)
|
||||
{
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||
return double.MaxValue;
|
||||
|
||||
var best = double.MaxValue;
|
||||
|
||||
if (t1 > -Tolerance.Epsilon)
|
||||
{
|
||||
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
|
||||
vy + t1 * dirY - cy, vx + t1 * dirX - cx));
|
||||
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||
best = t1 > Tolerance.Epsilon ? t1 : 0;
|
||||
}
|
||||
|
||||
if (t2 > -Tolerance.Epsilon && t2 < best)
|
||||
{
|
||||
var hitAngle = Angle.NormalizeRad(System.Math.Atan2(
|
||||
vy + t2 * dirY - cy, vx + t2 * dirX - cx));
|
||||
if (Angle.IsBetweenRad(hitAngle, startAngle, endAngle, reversed))
|
||||
best = t2 > Tolerance.Epsilon ? t2 : 0;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the distance from a point along a direction to a full circle.
|
||||
/// Returns double.MaxValue if no hit.
|
||||
/// </summary>
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
|
||||
public static double RayCircleDistance(
|
||||
double vx, double vy,
|
||||
double cx, double cy, double r,
|
||||
double dirX, double dirY)
|
||||
{
|
||||
if (!SolveRayCircle(vx, vy, cx, cy, r, dirX, dirY, out var t1, out var t2))
|
||||
return double.MaxValue;
|
||||
|
||||
if (t1 > Tolerance.Epsilon) return t1;
|
||||
if (t1 >= -Tolerance.Epsilon) return 0;
|
||||
if (t2 > Tolerance.Epsilon) return t2;
|
||||
if (t2 >= -Tolerance.Epsilon) return 0;
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction before
|
||||
/// any edge of movingLines contacts any edge of stationaryLines.
|
||||
@@ -111,57 +203,7 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1);
|
||||
movingVertices.Add(movingLines[i].pt2);
|
||||
}
|
||||
|
||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
||||
|
||||
// Sort edges for pruning if not already sorted (usually they aren't here)
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
|
||||
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
|
||||
|
||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
return DirectionalDistance(movingLines, 0, 0, stationaryLines, direction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -176,21 +218,10 @@ namespace OpenNest.Geometry
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1 + movingOffset);
|
||||
movingVertices.Add(movingLines[i].pt2 + movingOffset);
|
||||
}
|
||||
var movingVertices = CollectVertices(movingLines, movingOffset);
|
||||
|
||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
||||
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
var stationaryEdges = ToEdgeArray(stationaryLines);
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
@@ -200,21 +231,10 @@ namespace OpenNest.Geometry
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
|
||||
|
||||
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
var movingEdges = ToEdgeArray(movingLines);
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -253,15 +273,11 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Extract unique vertices from moving edges.
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < movingEdges.Length; i++)
|
||||
{
|
||||
movingVertices.Add(movingEdges[i].start + movingOffset);
|
||||
movingVertices.Add(movingEdges[i].end + movingOffset);
|
||||
}
|
||||
SortEdgesForPruning(stationaryEdges, direction);
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = CollectVertices(movingEdges, movingOffset);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
@@ -270,12 +286,9 @@ namespace OpenNest.Geometry
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryEdges.Length; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
|
||||
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
|
||||
}
|
||||
SortEdgesForPruning(movingEdges, opposite);
|
||||
|
||||
var stationaryVertices = CollectVertices(stationaryEdges, stationaryOffset);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -467,12 +480,7 @@ namespace OpenNest.Geometry
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
movingVertices.Add(movingLines[i].pt1);
|
||||
movingVertices.Add(movingLines[i].pt2);
|
||||
}
|
||||
var movingVertices = CollectVertices(movingLines, Vector.Zero);
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
@@ -487,12 +495,7 @@ namespace OpenNest.Geometry
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
var stationaryVertices = CollectVertices(stationaryLines, Vector.Zero);
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
@@ -507,6 +510,284 @@ namespace OpenNest.Geometry
|
||||
return minDist;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along a push direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Delegates to the Vector-based overload.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities, List<Entity> stationaryEntities, PushDirection direction)
|
||||
{
|
||||
return DirectionalDistance(movingEntities, stationaryEntities, DirectionToOffset(direction, 1.0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum translation distance along an arbitrary unit direction
|
||||
/// before any vertex/edge of movingEntities contacts any vertex/edge of
|
||||
/// stationaryEntities. Works with native Line, Arc, and Circle entities
|
||||
/// without tessellation.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
List<Entity> movingEntities, List<Entity> stationaryEntities, Vector direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var dirX = direction.X;
|
||||
var dirY = direction.Y;
|
||||
|
||||
var movingVertices = ExtractEntityVertices(movingEntities);
|
||||
|
||||
for (var v = 0; v < movingVertices.Length; v++)
|
||||
{
|
||||
var vx = movingVertices[v].X;
|
||||
var vy = movingVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], dirX, dirY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var stationaryVertices = ExtractEntityVertices(stationaryEntities);
|
||||
|
||||
for (var v = 0; v < stationaryVertices.Length; v++)
|
||||
{
|
||||
var vx = stationaryVertices[v].X;
|
||||
var vy = stationaryVertices[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, movingEntities[j], oppX, oppY);
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Arc-to-line closest-point check.
|
||||
// Phases 1-2 sample arc endpoints and cardinal extremes, but the actual
|
||||
// closest point on a small corner arc to a straight edge may lie between
|
||||
// those samples. Use ClosestPointTo to find it and fire a ray from there.
|
||||
minDist = ArcToLineClosestDistance(movingEntities, stationaryEntities, dirX, dirY, minDist);
|
||||
if (minDist <= 0) return 0;
|
||||
minDist = ArcToLineClosestDistance(stationaryEntities, movingEntities, oppX, oppY, minDist);
|
||||
if (minDist <= 0) return 0;
|
||||
|
||||
// Phase 4: Curve-to-curve direct distance.
|
||||
// The vertex-to-entity approach misses the closest contact between two
|
||||
// curved entities (circles/arcs) because only a few cardinal vertices are
|
||||
// sampled. The true closest contact along the push direction is found by
|
||||
// treating it as a ray from one center to an expanded circle at the other
|
||||
// center (radius = r1 + r2).
|
||||
for (var i = 0; i < movingEntities.Count; i++)
|
||||
{
|
||||
var me = movingEntities[i];
|
||||
if (!TryGetCurveParams(me, out var mcx, out var mcy, out var mr))
|
||||
continue;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var se = stationaryEntities[j];
|
||||
if (!TryGetCurveParams(se, out var scx, out var scy, out var sr))
|
||||
continue;
|
||||
|
||||
var d = RayCircleDistance(mcx, mcy, scx, scy, mr + sr, dirX, dirY);
|
||||
|
||||
if (d >= minDist)
|
||||
continue;
|
||||
|
||||
// For arcs, verify the contact point falls within both arcs' angular ranges.
|
||||
if (me is Arc || se is Arc)
|
||||
{
|
||||
var mx = mcx + d * dirX;
|
||||
var my = mcy + d * dirY;
|
||||
var toCx = scx - mx;
|
||||
var toCy = scy - my;
|
||||
|
||||
if (me is Arc mArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (se is Arc sArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
|
||||
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0) return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double ArcToLineClosestDistance(
|
||||
List<Entity> arcEntities, List<Entity> lineEntities,
|
||||
double dirX, double dirY, double minDist)
|
||||
{
|
||||
for (var i = 0; i < arcEntities.Count; i++)
|
||||
{
|
||||
if (arcEntities[i] is Arc arc)
|
||||
{
|
||||
for (var j = 0; j < lineEntities.Count; j++)
|
||||
{
|
||||
if (lineEntities[j] is Line line)
|
||||
{
|
||||
var linePt = line.ClosestPointTo(arc.Center);
|
||||
var arcPt = arc.ClosestPointTo(linePt);
|
||||
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
|
||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
||||
dirX, dirY);
|
||||
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minDist;
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx, double vy, Entity entity, double dirX, double dirY)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return RayEdgeDistance(vx, vy,
|
||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return RayArcDistance(vx, vy,
|
||||
arc.Center.X, arc.Center.Y, arc.Radius,
|
||||
arc.StartAngle, arc.EndAngle, arc.IsReversed,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return RayCircleDistance(vx, vy,
|
||||
circle.Center.X, circle.Center.Y, circle.Radius,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractEntityVertices(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
vertices.Add(line.pt1);
|
||||
vertices.Add(line.pt2);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
vertices.Add(arc.StartPoint());
|
||||
vertices.Add(arc.EndPoint());
|
||||
AddArcExtremeVertices(vertices, arc);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremeVertices(HashSet<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
|
||||
if (arc.IsReversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
private static HashSet<Vector> CollectVertices(List<Line> lines, Vector offset)
|
||||
{
|
||||
return CollectVertices(ToEdgeArray(lines), offset);
|
||||
}
|
||||
|
||||
private static HashSet<Vector> CollectVertices((Vector start, Vector end)[] edges, Vector offset)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
vertices.Add(edges[i].start + offset);
|
||||
vertices.Add(edges[i].end + offset);
|
||||
}
|
||||
return vertices;
|
||||
}
|
||||
|
||||
private static (Vector start, Vector end)[] ToEdgeArray(List<Line> lines)
|
||||
{
|
||||
var edges = new (Vector start, Vector end)[lines.Count];
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
edges[i] = (lines[i].pt1, lines[i].pt2);
|
||||
return edges;
|
||||
}
|
||||
|
||||
private static void SortEdgesForPruning((Vector start, Vector end)[] edges, PushDirection direction)
|
||||
{
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
System.Array.Sort(edges, (a, b) =>
|
||||
System.Math.Min(a.start.Y, a.end.Y).CompareTo(System.Math.Min(b.start.Y, b.end.Y)));
|
||||
else
|
||||
System.Array.Sort(edges, (a, b) =>
|
||||
System.Math.Min(a.start.X, a.end.X).CompareTo(System.Math.Min(b.start.X, b.end.X)));
|
||||
}
|
||||
|
||||
private static bool TryGetCurveParams(Entity entity, out double cx, out double cy, out double r)
|
||||
{
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
cx = circle.Center.X; cy = circle.Center.Y; r = circle.Radius;
|
||||
return true;
|
||||
}
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
cx = arc.Center.X; cy = arc.Center.Y; r = arc.Radius;
|
||||
return true;
|
||||
}
|
||||
cx = cy = r = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double BoxProjectionMin(Box box, double dx, double dy)
|
||||
{
|
||||
var x = dx >= 0 ? box.Left : box.Right;
|
||||
@@ -523,177 +804,17 @@ namespace OpenNest.Geometry
|
||||
|
||||
#endregion
|
||||
|
||||
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
|
||||
{
|
||||
var closestDistance = double.MaxValue;
|
||||
|
||||
for (int i = 0; i < boxes.Count; i++)
|
||||
{
|
||||
var compareBox = boxes[i];
|
||||
|
||||
RelativePosition pos;
|
||||
|
||||
if (!box.IsHorizontalTo(compareBox, out pos))
|
||||
continue;
|
||||
|
||||
if (pos != RelativePosition.Right)
|
||||
continue;
|
||||
|
||||
var distance = box.Left - compareBox.Right;
|
||||
|
||||
if (distance < closestDistance)
|
||||
closestDistance = distance;
|
||||
}
|
||||
|
||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
||||
}
|
||||
|
||||
public static double ClosestDistanceRight(Box box, List<Box> boxes)
|
||||
{
|
||||
var closestDistance = double.MaxValue;
|
||||
|
||||
for (int i = 0; i < boxes.Count; i++)
|
||||
{
|
||||
var compareBox = boxes[i];
|
||||
|
||||
RelativePosition pos;
|
||||
|
||||
if (!box.IsHorizontalTo(compareBox, out pos))
|
||||
continue;
|
||||
|
||||
if (pos != RelativePosition.Left)
|
||||
continue;
|
||||
|
||||
var distance = compareBox.Left - box.Right;
|
||||
|
||||
if (distance < closestDistance)
|
||||
closestDistance = distance;
|
||||
}
|
||||
|
||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
||||
}
|
||||
|
||||
public static double ClosestDistanceUp(Box box, List<Box> boxes)
|
||||
{
|
||||
var closestDistance = double.MaxValue;
|
||||
|
||||
for (int i = 0; i < boxes.Count; i++)
|
||||
{
|
||||
var compareBox = boxes[i];
|
||||
|
||||
RelativePosition pos;
|
||||
|
||||
if (!box.IsVerticalTo(compareBox, out pos))
|
||||
continue;
|
||||
|
||||
if (pos != RelativePosition.Bottom)
|
||||
continue;
|
||||
|
||||
var distance = compareBox.Bottom - box.Top;
|
||||
|
||||
if (distance < closestDistance)
|
||||
closestDistance = distance;
|
||||
}
|
||||
|
||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
||||
}
|
||||
|
||||
public static double ClosestDistanceDown(Box box, List<Box> boxes)
|
||||
{
|
||||
var closestDistance = double.MaxValue;
|
||||
|
||||
for (int i = 0; i < boxes.Count; i++)
|
||||
{
|
||||
var compareBox = boxes[i];
|
||||
|
||||
RelativePosition pos;
|
||||
|
||||
if (!box.IsVerticalTo(compareBox, out pos))
|
||||
continue;
|
||||
|
||||
if (pos != RelativePosition.Top)
|
||||
continue;
|
||||
|
||||
var distance = box.Bottom - compareBox.Top;
|
||||
|
||||
if (distance < closestDistance)
|
||||
closestDistance = distance;
|
||||
}
|
||||
|
||||
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
|
||||
}
|
||||
|
||||
public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes)
|
||||
{
|
||||
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
|
||||
|
||||
#region Find Top/Bottom Limits
|
||||
|
||||
var top = double.MaxValue;
|
||||
var btm = double.MinValue;
|
||||
|
||||
foreach (var box in verticalBoxes)
|
||||
{
|
||||
var boxBtm = box.Bottom;
|
||||
var boxTop = box.Top;
|
||||
|
||||
if (boxBtm > pt.Y && boxBtm < top)
|
||||
top = boxBtm;
|
||||
|
||||
else if (box.Top < pt.Y && boxTop > btm)
|
||||
btm = boxTop;
|
||||
}
|
||||
|
||||
if (top == double.MaxValue)
|
||||
{
|
||||
if (bounds.Top > pt.Y)
|
||||
top = bounds.Top;
|
||||
else return Box.Empty;
|
||||
}
|
||||
|
||||
if (btm == double.MinValue)
|
||||
{
|
||||
if (bounds.Bottom < pt.Y)
|
||||
btm = bounds.Bottom;
|
||||
else return Box.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
|
||||
return Box.Empty;
|
||||
|
||||
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
|
||||
|
||||
#region Find Left/Right Limits
|
||||
|
||||
var lft = double.MinValue;
|
||||
var rgt = double.MaxValue;
|
||||
|
||||
foreach (var box in horizontalBoxes)
|
||||
{
|
||||
var boxLft = box.Left;
|
||||
var boxRgt = box.Right;
|
||||
|
||||
if (boxLft > pt.X && boxLft < rgt)
|
||||
rgt = boxLft;
|
||||
|
||||
else if (boxRgt < pt.X && boxRgt > lft)
|
||||
lft = boxRgt;
|
||||
}
|
||||
|
||||
if (rgt == double.MaxValue)
|
||||
{
|
||||
if (bounds.Right > pt.X)
|
||||
rgt = bounds.Right;
|
||||
else return Box.Empty;
|
||||
}
|
||||
|
||||
if (lft == double.MinValue)
|
||||
{
|
||||
if (bounds.Left < pt.X)
|
||||
lft = bounds.Left;
|
||||
else return Box.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
|
||||
return Box.Empty;
|
||||
|
||||
return new Box(lft, btm, rgt - lft, top - btm);
|
||||
}
|
||||
@@ -702,75 +823,77 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList();
|
||||
|
||||
#region Find Left/Right Limits
|
||||
|
||||
var lft = double.MinValue;
|
||||
var rgt = double.MaxValue;
|
||||
|
||||
foreach (var box in horizontalBoxes)
|
||||
{
|
||||
var boxLft = box.Left;
|
||||
var boxRgt = box.Right;
|
||||
|
||||
if (boxLft > pt.X && boxLft < rgt)
|
||||
rgt = boxLft;
|
||||
|
||||
else if (boxRgt < pt.X && boxRgt > lft)
|
||||
lft = boxRgt;
|
||||
}
|
||||
|
||||
if (rgt == double.MaxValue)
|
||||
{
|
||||
if (bounds.Right > pt.X)
|
||||
rgt = bounds.Right;
|
||||
else return Box.Empty;
|
||||
}
|
||||
|
||||
if (lft == double.MinValue)
|
||||
{
|
||||
if (bounds.Left < pt.X)
|
||||
lft = bounds.Left;
|
||||
else return Box.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
if (!FindHorizontalLimits(pt, bounds, horizontalBoxes, out var lft, out var rgt))
|
||||
return Box.Empty;
|
||||
|
||||
var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList();
|
||||
|
||||
#region Find Top/Bottom Limits
|
||||
if (!FindVerticalLimits(pt, bounds, verticalBoxes, out var top, out var btm))
|
||||
return Box.Empty;
|
||||
|
||||
var top = double.MaxValue;
|
||||
var btm = double.MinValue;
|
||||
return new Box(lft, btm, rgt - lft, top - btm);
|
||||
}
|
||||
|
||||
foreach (var box in verticalBoxes)
|
||||
private static bool FindVerticalLimits(Vector pt, Box bounds, List<Box> boxes, out double top, out double btm)
|
||||
{
|
||||
top = double.MaxValue;
|
||||
btm = double.MinValue;
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
var boxBtm = box.Bottom;
|
||||
var boxTop = box.Top;
|
||||
|
||||
if (boxBtm > pt.Y && boxBtm < top)
|
||||
top = boxBtm;
|
||||
|
||||
else if (box.Top < pt.Y && boxTop > btm)
|
||||
btm = boxTop;
|
||||
}
|
||||
|
||||
if (top == double.MaxValue)
|
||||
{
|
||||
if (bounds.Top > pt.Y)
|
||||
top = bounds.Top;
|
||||
else return Box.Empty;
|
||||
if (bounds.Top > pt.Y) top = bounds.Top;
|
||||
else return false;
|
||||
}
|
||||
|
||||
if (btm == double.MinValue)
|
||||
{
|
||||
if (bounds.Bottom < pt.Y)
|
||||
btm = bounds.Bottom;
|
||||
else return Box.Empty;
|
||||
if (bounds.Bottom < pt.Y) btm = bounds.Bottom;
|
||||
else return false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Box(lft, btm, rgt - lft, top - btm);
|
||||
private static bool FindHorizontalLimits(Vector pt, Box bounds, List<Box> boxes, out double lft, out double rgt)
|
||||
{
|
||||
lft = double.MinValue;
|
||||
rgt = double.MaxValue;
|
||||
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
var boxLft = box.Left;
|
||||
var boxRgt = box.Right;
|
||||
|
||||
if (boxLft > pt.X && boxLft < rgt)
|
||||
rgt = boxLft;
|
||||
else if (boxRgt < pt.X && boxRgt > lft)
|
||||
lft = boxRgt;
|
||||
}
|
||||
|
||||
if (rgt == double.MaxValue)
|
||||
{
|
||||
if (bounds.Right > pt.X) rgt = bounds.Right;
|
||||
else return false;
|
||||
}
|
||||
|
||||
if (lft == double.MinValue)
|
||||
{
|
||||
if (bounds.Left < pt.X) lft = bounds.Left;
|
||||
else return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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) =>
|
||||
ArcFit.FitWithStartTangent(points, tangent);
|
||||
|
||||
private static double MaxRadialDeviation(List<Vector> points, double cx, double cy, double radius) =>
|
||||
ArcFit.MaxRadialDeviation(points, cx, cy, radius);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IConfigurablePostProcessor : IPostProcessor
|
||||
{
|
||||
object Config { get; }
|
||||
|
||||
void SaveConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace OpenNest.Math
|
||||
{
|
||||
/// <summary>
|
||||
/// Recursive descent parser for simple arithmetic expressions supporting
|
||||
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
|
||||
/// </summary>
|
||||
public static class ExpressionEvaluator
|
||||
{
|
||||
public static double Evaluate(string expression, IReadOnlyDictionary<string, double> variables)
|
||||
{
|
||||
var parser = new Parser(expression, variables);
|
||||
var result = parser.ParseExpression();
|
||||
parser.SkipWhitespace();
|
||||
if (!parser.IsEnd)
|
||||
throw new FormatException($"Unexpected character at position {parser.Position}: '{parser.Current}'");
|
||||
return result;
|
||||
}
|
||||
|
||||
private ref struct Parser
|
||||
{
|
||||
private readonly ReadOnlySpan<char> _input;
|
||||
private readonly IReadOnlyDictionary<string, double> _variables;
|
||||
private int _pos;
|
||||
|
||||
public Parser(string input, IReadOnlyDictionary<string, double> variables)
|
||||
{
|
||||
_input = input.AsSpan();
|
||||
_variables = variables;
|
||||
_pos = 0;
|
||||
}
|
||||
|
||||
public int Position => _pos;
|
||||
public bool IsEnd => _pos >= _input.Length;
|
||||
public char Current => _input[_pos];
|
||||
|
||||
public void SkipWhitespace()
|
||||
{
|
||||
while (_pos < _input.Length && _input[_pos] == ' ')
|
||||
_pos++;
|
||||
}
|
||||
|
||||
// Expression = Term (('+' | '-') Term)*
|
||||
public double ParseExpression()
|
||||
{
|
||||
SkipWhitespace();
|
||||
var left = ParseTerm();
|
||||
|
||||
while (true)
|
||||
{
|
||||
SkipWhitespace();
|
||||
if (IsEnd) break;
|
||||
|
||||
var op = Current;
|
||||
if (op != '+' && op != '-') break;
|
||||
|
||||
_pos++;
|
||||
SkipWhitespace();
|
||||
var right = ParseTerm();
|
||||
|
||||
left = op == '+' ? left + right : left - right;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
// Term = Unary (('*' | '/') Unary)*
|
||||
private double ParseTerm()
|
||||
{
|
||||
var left = ParseUnary();
|
||||
|
||||
while (true)
|
||||
{
|
||||
SkipWhitespace();
|
||||
if (IsEnd) break;
|
||||
|
||||
var op = Current;
|
||||
if (op != '*' && op != '/') break;
|
||||
|
||||
_pos++;
|
||||
SkipWhitespace();
|
||||
var right = ParseUnary();
|
||||
|
||||
left = op == '*' ? left * right : left / right;
|
||||
}
|
||||
|
||||
return left;
|
||||
}
|
||||
|
||||
// Unary = ('-' | '+')? Primary
|
||||
private double ParseUnary()
|
||||
{
|
||||
SkipWhitespace();
|
||||
if (!IsEnd && Current == '-')
|
||||
{
|
||||
_pos++;
|
||||
return -ParsePrimary();
|
||||
}
|
||||
if (!IsEnd && Current == '+')
|
||||
{
|
||||
_pos++;
|
||||
}
|
||||
return ParsePrimary();
|
||||
}
|
||||
|
||||
// Primary = '(' Expression ')' | '$' Identifier | Number
|
||||
private double ParsePrimary()
|
||||
{
|
||||
SkipWhitespace();
|
||||
|
||||
if (IsEnd)
|
||||
throw new FormatException("Unexpected end of expression.");
|
||||
|
||||
if (Current == '(')
|
||||
{
|
||||
_pos++; // consume '('
|
||||
var value = ParseExpression();
|
||||
SkipWhitespace();
|
||||
if (IsEnd || Current != ')')
|
||||
throw new FormatException("Expected closing parenthesis.");
|
||||
_pos++; // consume ')'
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Current == '$')
|
||||
{
|
||||
_pos++; // consume '$'
|
||||
var start = _pos;
|
||||
while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
|
||||
_pos++;
|
||||
if (_pos == start)
|
||||
throw new FormatException("Expected variable name after '$'.");
|
||||
var name = _input.Slice(start, _pos - start).ToString();
|
||||
if (!_variables.TryGetValue(name, out var varValue))
|
||||
throw new KeyNotFoundException($"Undefined variable: ${name}");
|
||||
return varValue;
|
||||
}
|
||||
|
||||
// Number
|
||||
var numStart = _pos;
|
||||
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
|
||||
_pos++;
|
||||
|
||||
if (_pos == numStart)
|
||||
throw new FormatException($"Unexpected character '{Current}' at position {_pos}.");
|
||||
|
||||
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
|
||||
if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
|
||||
throw new FormatException($"Invalid number: '{numSpan}'");
|
||||
|
||||
return number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-16
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
@@ -21,6 +22,7 @@ namespace OpenNest
|
||||
Plates.ItemRemoved += Plates_PlateRemoved;
|
||||
Drawings = new DrawingCollection();
|
||||
PlateDefaults = new PlateSettings();
|
||||
Material = new Material();
|
||||
Customer = string.Empty;
|
||||
Notes = string.Empty;
|
||||
}
|
||||
@@ -38,6 +40,10 @@ namespace OpenNest
|
||||
|
||||
public string AssistGas { get; set; } = "";
|
||||
|
||||
public double Thickness { get; set; }
|
||||
|
||||
public Material Material { get; set; }
|
||||
|
||||
public Units Units { get; set; }
|
||||
|
||||
public DateTime DateCreated { get; set; }
|
||||
@@ -46,6 +52,10 @@ namespace OpenNest
|
||||
|
||||
public PlateSettings PlateDefaults { get; set; }
|
||||
|
||||
public List<PlateOption> PlateOptions { get; set; } = new();
|
||||
|
||||
public double SalvageRate { get; set; } = 0.5;
|
||||
|
||||
public Plate CreatePlate()
|
||||
{
|
||||
var plate = PlateDefaults.CreateNew();
|
||||
@@ -84,18 +94,6 @@ namespace OpenNest
|
||||
set { plate.Quadrant = value; }
|
||||
}
|
||||
|
||||
public double Thickness
|
||||
{
|
||||
get { return plate.Thickness; }
|
||||
set { plate.Thickness = value; }
|
||||
}
|
||||
|
||||
public Material Material
|
||||
{
|
||||
get { return plate.Material; }
|
||||
set { plate.Material = value; }
|
||||
}
|
||||
|
||||
public Size Size
|
||||
{
|
||||
get { return plate.Size; }
|
||||
@@ -116,9 +114,7 @@ namespace OpenNest
|
||||
|
||||
public void SetFromExisting(Plate plate)
|
||||
{
|
||||
Thickness = plate.Thickness;
|
||||
Quadrant = plate.Quadrant;
|
||||
Material = plate.Material;
|
||||
Size = plate.Size;
|
||||
EdgeSpacing = plate.EdgeSpacing;
|
||||
PartSpacing = plate.PartSpacing;
|
||||
@@ -128,11 +124,9 @@ namespace OpenNest
|
||||
{
|
||||
return new Plate()
|
||||
{
|
||||
Thickness = Thickness,
|
||||
Size = Size,
|
||||
EdgeSpacing = EdgeSpacing,
|
||||
PartSpacing = PartSpacing,
|
||||
Material = Material,
|
||||
Quadrant = Quadrant,
|
||||
Quantity = 1
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+69
-6
@@ -1,6 +1,7 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -21,6 +22,7 @@ namespace OpenNest
|
||||
{
|
||||
private Vector location;
|
||||
private bool ownsProgram;
|
||||
private double preLeadInRotation;
|
||||
|
||||
public readonly Drawing BaseDrawing;
|
||||
|
||||
@@ -55,12 +57,56 @@ namespace OpenNest
|
||||
|
||||
public bool HasManualLeadIns { get; set; }
|
||||
|
||||
public bool LeadInsLocked { get; set; }
|
||||
|
||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||
|
||||
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint)
|
||||
{
|
||||
preLeadInRotation = Rotation;
|
||||
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
||||
var result = strategy.Apply(Program, approachPoint);
|
||||
Program = result.Program;
|
||||
CuttingParameters = parameters;
|
||||
HasManualLeadIns = true;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
public void ApplySingleLeadIn(CNC.CuttingStrategy.CuttingParameters parameters,
|
||||
Geometry.Vector point, Geometry.Entity entity, CNC.CuttingStrategy.ContourType contourType)
|
||||
{
|
||||
preLeadInRotation = Rotation;
|
||||
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
||||
var result = strategy.ApplySingle(Program, point, entity, contourType);
|
||||
Program = result.Program;
|
||||
CuttingParameters = parameters;
|
||||
HasManualLeadIns = true;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
public void RemoveLeadIns()
|
||||
{
|
||||
var rotation = preLeadInRotation;
|
||||
var location = Location;
|
||||
Program = BaseDrawing.Program.Clone() as Program;
|
||||
ownsProgram = true;
|
||||
|
||||
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||
Program.Rotate(rotation);
|
||||
|
||||
Location = location;
|
||||
HasManualLeadIns = false;
|
||||
LeadInsLocked = false;
|
||||
CuttingParameters = null;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the rotation of the part in radians.
|
||||
/// </summary>
|
||||
public double Rotation
|
||||
{
|
||||
get { return Program.Rotation; }
|
||||
get { return HasManualLeadIns ? preLeadInRotation : Program.Rotation; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,6 +118,7 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
@@ -85,6 +132,7 @@ namespace OpenNest
|
||||
EnsureOwnedProgram();
|
||||
Program.Rotate(angle);
|
||||
location = Location.Rotate(angle, origin);
|
||||
preLeadInRotation = Program.Rotation;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
@@ -142,7 +190,14 @@ namespace OpenNest
|
||||
{
|
||||
var rotation = Rotation;
|
||||
Program = BaseDrawing.Program.Clone() as Program;
|
||||
Program.Rotate(Program.Rotation - rotation);
|
||||
|
||||
if (!Math.Tolerance.IsEqualTo(rotation, 0))
|
||||
Program.Rotate(rotation);
|
||||
|
||||
HasManualLeadIns = false;
|
||||
LeadInsLocked = false;
|
||||
CuttingParameters = null;
|
||||
UpdateBounds();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -170,10 +225,18 @@ namespace OpenNest
|
||||
if (perimeter1 == null || perimeter2 == null)
|
||||
return false;
|
||||
|
||||
perimeter1.Offset(Location);
|
||||
perimeter2.Offset(part.Location);
|
||||
var polygon1 = perimeter1.ToPolygon();
|
||||
var polygon2 = perimeter2.ToPolygon();
|
||||
|
||||
return perimeter1.Intersects(perimeter2, out pts);
|
||||
if (polygon1 == null || polygon2 == null)
|
||||
return false;
|
||||
|
||||
polygon1.Offset(Location);
|
||||
polygon2.Offset(part.Location);
|
||||
|
||||
var result = Geometry.Collision.Check(polygon1, polygon2);
|
||||
pts = result.IntersectionPoints.ToList();
|
||||
return result.Overlaps;
|
||||
}
|
||||
|
||||
public double Left
|
||||
@@ -221,7 +284,7 @@ namespace OpenNest
|
||||
var part = new Part(BaseDrawing, Program,
|
||||
location + offset,
|
||||
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
|
||||
BoundingBox.Width, BoundingBox.Length));
|
||||
BoundingBox.Length, BoundingBox.Width));
|
||||
|
||||
return part;
|
||||
}
|
||||
|
||||
+170
-37
@@ -39,25 +39,130 @@ namespace OpenNest
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
|
||||
/// <summary>
|
||||
/// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied,
|
||||
/// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs.
|
||||
/// </summary>
|
||||
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
|
||||
var lines = new List<Line>();
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
|
||||
foreach (var shape in shapes)
|
||||
var offsetShape = profile.Perimeter.OffsetOutward(spacing);
|
||||
if (offsetShape == null)
|
||||
return new List<Entity>();
|
||||
|
||||
// Offset the shape's entities to the part's location.
|
||||
// OffsetOutward creates a new Shape, so mutating is safe.
|
||||
foreach (var entity in offsetShape.Entities)
|
||||
entity.Offset(part.Location);
|
||||
|
||||
return offsetShape.Entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all entities (perimeter + cutouts) with spacing offset applied,
|
||||
/// without tessellation. Perimeter is offset outward, cutouts inward.
|
||||
/// </summary>
|
||||
public static List<Entity> GetOffsetPartEntities(Part part, double spacing)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var entities = new List<Entity>();
|
||||
|
||||
var perimeter = profile.Perimeter.OffsetOutward(spacing);
|
||||
if (perimeter != null)
|
||||
{
|
||||
// Add chord tolerance to compensate for inscribed polygon chords
|
||||
// being inside the actual offset arcs.
|
||||
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
|
||||
foreach (var entity in perimeter.Entities)
|
||||
entity.Offset(part.Location);
|
||||
entities.AddRange(perimeter.Entities);
|
||||
}
|
||||
|
||||
if (offsetEntity == null)
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
var inset = cutout.OffsetInward(spacing);
|
||||
if (inset == null) continue;
|
||||
foreach (var entity in inset.Entities)
|
||||
entity.Offset(part.Location);
|
||||
entities.AddRange(inset.Entities);
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns perimeter entities at the part's world location, without tessellation
|
||||
/// or spacing offset.
|
||||
/// </summary>
|
||||
public static List<Entity> GetPerimeterEntities(Part part)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
|
||||
return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all entities (perimeter + cutouts) at the part's world location,
|
||||
/// without tessellation or spacing offset.
|
||||
/// </summary>
|
||||
public static List<Entity> GetPartEntities(Part part)
|
||||
{
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location);
|
||||
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
entities.AddRange(CopyEntitiesAtLocation(cutout.Entities, part.Location));
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static List<Entity> CopyEntitiesAtLocation(List<Entity> source, Vector location)
|
||||
{
|
||||
var result = new List<Entity>(source.Count);
|
||||
|
||||
for (var i = 0; i < source.Count; i++)
|
||||
{
|
||||
var entity = source[i];
|
||||
Entity copy;
|
||||
|
||||
if (entity is Line line)
|
||||
copy = new Line(line.StartPoint + location, line.EndPoint + location);
|
||||
else if (entity is Arc arc)
|
||||
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
else if (entity is Circle circle)
|
||||
copy = new Circle(circle.Center + location, circle.Radius);
|
||||
else
|
||||
continue;
|
||||
|
||||
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
|
||||
polygon.RemoveSelfIntersections();
|
||||
polygon.Offset(part.Location);
|
||||
lines.AddRange(polygon.ToLines());
|
||||
result.Add(copy);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001,
|
||||
bool perimeterOnly = false)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList());
|
||||
var lines = new List<Line>();
|
||||
var totalSpacing = spacing;
|
||||
|
||||
AddOffsetLines(lines, profile.Perimeter.OffsetOutward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
|
||||
if (!perimeterOnly)
|
||||
{
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
AddOffsetLines(lines, cutout.OffsetInward(totalSpacing),
|
||||
chordTolerance, part.Location);
|
||||
}
|
||||
|
||||
return lines;
|
||||
@@ -66,21 +171,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;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
|
||||
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 +205,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;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var offsetEntity = shape.OffsetOutward(spacing + chordTolerance);
|
||||
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 +286,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-23
@@ -43,7 +43,6 @@ namespace OpenNest
|
||||
{
|
||||
EdgeSpacing = new Spacing();
|
||||
Size = size;
|
||||
Material = new Material();
|
||||
Parts = new ObservableList<Part>();
|
||||
Parts.ItemAdded += Parts_PartAdded;
|
||||
Parts.ItemRemoved += Parts_PartRemoved;
|
||||
@@ -63,11 +62,6 @@ namespace OpenNest
|
||||
e.Item.BaseDrawing.Quantity.Nested -= Quantity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thickness of the plate.
|
||||
/// </summary>
|
||||
public double Thickness { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The spacing between parts.
|
||||
/// </summary>
|
||||
@@ -83,10 +77,7 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public Size Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Material the plate is made out of.
|
||||
/// </summary>
|
||||
public Material Material { get; set; }
|
||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Material grain direction in radians. 0 = horizontal.
|
||||
@@ -433,7 +424,7 @@ namespace OpenNest
|
||||
{
|
||||
var plateBox = new Box();
|
||||
|
||||
// Convention: Size.Length = X axis (horizontal), Size.Width = Y axis (vertical)
|
||||
// Width = Y axis (vertical), Length = X axis (horizontal)
|
||||
switch (Quadrant)
|
||||
{
|
||||
case 1:
|
||||
@@ -460,8 +451,8 @@ namespace OpenNest
|
||||
return new Box();
|
||||
}
|
||||
|
||||
plateBox.Width = Size.Length;
|
||||
plateBox.Length = Size.Width;
|
||||
plateBox.Width = Size.Width;
|
||||
plateBox.Length = Size.Length;
|
||||
|
||||
if (!includeParts)
|
||||
return plateBox;
|
||||
@@ -477,11 +468,11 @@ namespace OpenNest
|
||||
? partsBox.Bottom
|
||||
: plateBox.Bottom;
|
||||
|
||||
boundingBox.Width = partsBox.Right > plateBox.Right
|
||||
boundingBox.Length = partsBox.Right > plateBox.Right
|
||||
? partsBox.Right - boundingBox.X
|
||||
: plateBox.Right - boundingBox.X;
|
||||
|
||||
boundingBox.Length = partsBox.Top > plateBox.Top
|
||||
boundingBox.Width = partsBox.Top > plateBox.Top
|
||||
? partsBox.Top - boundingBox.Y
|
||||
: plateBox.Top - boundingBox.Y;
|
||||
|
||||
@@ -498,8 +489,8 @@ namespace OpenNest
|
||||
|
||||
box.X += EdgeSpacing.Left;
|
||||
box.Y += EdgeSpacing.Bottom;
|
||||
box.Width -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||
box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||
box.Length -= EdgeSpacing.Left + EdgeSpacing.Right;
|
||||
box.Width -= EdgeSpacing.Top + EdgeSpacing.Bottom;
|
||||
|
||||
return box;
|
||||
}
|
||||
@@ -569,19 +560,17 @@ namespace OpenNest
|
||||
/// <summary>
|
||||
/// Gets the volume of the plate.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public double Volume()
|
||||
public double Volume(double thickness)
|
||||
{
|
||||
return Area() * Thickness;
|
||||
return Area() * thickness;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the weight of the plate.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public double Weight()
|
||||
public double Weight(double thickness, double density)
|
||||
{
|
||||
return Volume() * Material.Density;
|
||||
return Volume(thickness) * density;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -601,10 +590,24 @@ namespace OpenNest
|
||||
for (var i = 0; i < realParts.Count; i++)
|
||||
{
|
||||
var part1 = realParts[i];
|
||||
var b1 = part1.BoundingBox;
|
||||
|
||||
for (var j = i + 1; j < realParts.Count; j++)
|
||||
{
|
||||
var part2 = realParts[j];
|
||||
var b2 = part2.BoundingBox;
|
||||
|
||||
// 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 (overlapX <= Math.Tolerance.Epsilon || overlapY <= Math.Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
if (part1.Intersects(part2, out var pts2))
|
||||
pts.AddRange(pts2);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using OpenNest.Collections;
|
||||
using System;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateChangedEventArgs : EventArgs
|
||||
{
|
||||
public Plate Plate { get; }
|
||||
public int Index { get; }
|
||||
|
||||
public PlateChangedEventArgs(Plate plate, int index)
|
||||
{
|
||||
Plate = plate;
|
||||
Index = index;
|
||||
}
|
||||
}
|
||||
|
||||
public class PlateManager : IDisposable
|
||||
{
|
||||
private readonly Nest nest;
|
||||
private bool disposed;
|
||||
private bool suppressNavigation;
|
||||
private bool batching;
|
||||
private Plate subscribedLast;
|
||||
private Plate subscribedSecondToLast;
|
||||
|
||||
public event EventHandler<PlateChangedEventArgs> CurrentPlateChanged;
|
||||
public event EventHandler PlateListChanged;
|
||||
|
||||
public PlateManager(Nest nest)
|
||||
{
|
||||
this.nest = nest;
|
||||
nest.Plates.ItemAdded += OnPlateAdded;
|
||||
nest.Plates.ItemRemoved += OnPlateRemoved;
|
||||
}
|
||||
|
||||
public int CurrentIndex { get; private set; }
|
||||
|
||||
public Plate CurrentPlate => nest.Plates.Count > 0 ? nest.Plates[CurrentIndex] : null;
|
||||
|
||||
public int Count => nest.Plates.Count;
|
||||
|
||||
public bool IsFirst => Count == 0 || CurrentIndex <= 0;
|
||||
|
||||
public bool IsLast => CurrentIndex + 1 >= Count;
|
||||
|
||||
public bool CanRemoveCurrent => Count > 1 && CurrentPlate != null && CurrentPlate.Parts.Count > 0;
|
||||
|
||||
public void LoadFirst()
|
||||
{
|
||||
if (Count == 0)
|
||||
return;
|
||||
|
||||
CurrentIndex = 0;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public void LoadLast()
|
||||
{
|
||||
if (Count == 0)
|
||||
return;
|
||||
|
||||
CurrentIndex = Count - 1;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public bool LoadNext()
|
||||
{
|
||||
if (CurrentIndex + 1 >= Count)
|
||||
return false;
|
||||
|
||||
CurrentIndex++;
|
||||
FireCurrentPlateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool LoadPrevious()
|
||||
{
|
||||
if (Count == 0 || CurrentIndex - 1 < 0)
|
||||
return false;
|
||||
|
||||
CurrentIndex--;
|
||||
FireCurrentPlateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void LoadAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= Count)
|
||||
return;
|
||||
|
||||
CurrentIndex = index;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public void EnsureSentinel()
|
||||
{
|
||||
suppressNavigation = true;
|
||||
try
|
||||
{
|
||||
if (Count == 0 || nest.Plates[^1].Parts.Count > 0)
|
||||
nest.CreatePlate();
|
||||
|
||||
while (Count > 1
|
||||
&& nest.Plates[^1].Parts.Count == 0
|
||||
&& nest.Plates[^2].Parts.Count == 0)
|
||||
{
|
||||
nest.Plates.RemoveAt(Count - 1);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
suppressNavigation = false;
|
||||
}
|
||||
|
||||
SubscribeToTailPlates();
|
||||
}
|
||||
|
||||
public void BeginBatch()
|
||||
{
|
||||
batching = true;
|
||||
}
|
||||
|
||||
public void EndBatch()
|
||||
{
|
||||
batching = false;
|
||||
EnsureSentinel();
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
public Plate GetOrCreateEmpty()
|
||||
{
|
||||
for (var i = Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (nest.Plates[i].Parts.Count == 0)
|
||||
return nest.Plates[i];
|
||||
}
|
||||
|
||||
return nest.CreatePlate();
|
||||
}
|
||||
|
||||
public void RemoveCurrent()
|
||||
{
|
||||
if (Count < 2)
|
||||
return;
|
||||
|
||||
nest.Plates.RemoveAt(CurrentIndex);
|
||||
}
|
||||
|
||||
private void SubscribeToTailPlates()
|
||||
{
|
||||
UnsubscribeFromTailPlates();
|
||||
|
||||
if (Count > 0)
|
||||
{
|
||||
subscribedLast = nest.Plates[^1];
|
||||
subscribedLast.PartAdded += OnTailPartAdded;
|
||||
subscribedLast.PartRemoved += OnTailPartRemoved;
|
||||
}
|
||||
|
||||
if (Count > 1)
|
||||
{
|
||||
subscribedSecondToLast = nest.Plates[^2];
|
||||
subscribedSecondToLast.PartAdded += OnTailPartAdded;
|
||||
subscribedSecondToLast.PartRemoved += OnTailPartRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromTailPlates()
|
||||
{
|
||||
if (subscribedLast != null)
|
||||
{
|
||||
subscribedLast.PartAdded -= OnTailPartAdded;
|
||||
subscribedLast.PartRemoved -= OnTailPartRemoved;
|
||||
subscribedLast = null;
|
||||
}
|
||||
|
||||
if (subscribedSecondToLast != null)
|
||||
{
|
||||
subscribedSecondToLast.PartAdded -= OnTailPartAdded;
|
||||
subscribedSecondToLast.PartRemoved -= OnTailPartRemoved;
|
||||
subscribedSecondToLast = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTailPartAdded(object sender, ItemAddedEventArgs<Part> e)
|
||||
{
|
||||
if (!batching)
|
||||
EnsureSentinel();
|
||||
}
|
||||
|
||||
private void OnTailPartRemoved(object sender, ItemRemovedEventArgs<Part> e)
|
||||
{
|
||||
if (!batching)
|
||||
EnsureSentinel();
|
||||
}
|
||||
|
||||
private void OnPlateAdded(object sender, ItemAddedEventArgs<Plate> e)
|
||||
{
|
||||
if (!suppressNavigation && !batching)
|
||||
EnsureSentinel();
|
||||
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
if (!suppressNavigation)
|
||||
{
|
||||
CurrentIndex = Count - 1;
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPlateRemoved(object sender, ItemRemovedEventArgs<Plate> e)
|
||||
{
|
||||
if (CurrentIndex >= Count && Count > 0)
|
||||
CurrentIndex = Count - 1;
|
||||
|
||||
if (!suppressNavigation && !batching)
|
||||
EnsureSentinel();
|
||||
|
||||
PlateListChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
if (!suppressNavigation)
|
||||
FireCurrentPlateChanged();
|
||||
}
|
||||
|
||||
private void FireCurrentPlateChanged()
|
||||
{
|
||||
CurrentPlateChanged?.Invoke(this, new PlateChangedEventArgs(CurrentPlate, CurrentIndex));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
return;
|
||||
|
||||
disposed = true;
|
||||
UnsubscribeFromTailPlates();
|
||||
nest.Plates.ItemAdded -= OnPlateAdded;
|
||||
nest.Plates.ItemRemoved -= OnPlateRemoved;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateOptimizerResult
|
||||
{
|
||||
public List<Part> Parts { get; set; } = new();
|
||||
public PlateOption ChosenSize { get; set; }
|
||||
public double NetCost { get; set; }
|
||||
public double Utilization { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public class PlateOption
|
||||
{
|
||||
public double Width { get; set; }
|
||||
public double Length { get; set; }
|
||||
public double Cost { get; set; }
|
||||
|
||||
public double Area => Width * Length;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public double Diameter { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Diameter = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -11,6 +11,15 @@ namespace OpenNest.Shapes
|
||||
public double HolePatternDiameter { get; set; }
|
||||
public int HoleCount { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
NominalPipeSize = 2;
|
||||
OD = 7.5;
|
||||
HoleDiameter = 0.875;
|
||||
HolePatternDiameter = 5.5;
|
||||
HoleCount = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double Base { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Base = 8;
|
||||
Height = 10;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var midX = Base / 2.0;
|
||||
|
||||
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
|
||||
public double LegWidth { get; set; }
|
||||
public double LegHeight { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
Height = 10;
|
||||
LegWidth = 3;
|
||||
LegHeight = 3;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var lw = LegWidth > 0 ? LegWidth : Width / 2.0;
|
||||
|
||||
@@ -7,6 +7,11 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public double Width { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var center = Width / 2.0;
|
||||
|
||||
@@ -5,17 +5,23 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public class RectangleShape : ShapeDefinition
|
||||
{
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
Width = 6;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
Height = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -8,6 +8,12 @@ namespace OpenNest.Shapes
|
||||
public double OuterDiameter { get; set; }
|
||||
public double InnerDiameter { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
OuterDiameter = 10;
|
||||
InnerDiameter = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>
|
||||
|
||||
@@ -6,10 +6,17 @@ 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 void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
Width = 6;
|
||||
Radius = 1;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var r = Radius;
|
||||
@@ -17,36 +24,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,
|
||||
|
||||
@@ -26,6 +26,8 @@ namespace OpenNest.Shapes
|
||||
|
||||
public abstract Drawing GetDrawing();
|
||||
|
||||
public virtual void SetPreviewDefaults() { }
|
||||
|
||||
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
|
||||
@@ -10,6 +10,14 @@ namespace OpenNest.Shapes
|
||||
public double StemWidth { get; set; }
|
||||
public double BarHeight { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 10;
|
||||
Height = 8;
|
||||
StemWidth = 3;
|
||||
BarHeight = 3;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var sw = StemWidth > 0 ? StemWidth : Width / 3.0;
|
||||
|
||||
@@ -9,6 +9,13 @@ namespace OpenNest.Shapes
|
||||
public double BottomWidth { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
TopWidth = 6;
|
||||
BottomWidth = 10;
|
||||
Height = 6;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var offset = (BottomWidth - TopWidth) / 2.0;
|
||||
|
||||
@@ -13,9 +13,9 @@ namespace OpenNest
|
||||
|
||||
public static readonly Layer Display = new Layer("DISPLAY") { Color = Color.Cyan };
|
||||
|
||||
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Yellow };
|
||||
public static readonly Layer Leadin = new Layer("LEADIN") { Color = Color.Brown };
|
||||
|
||||
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Yellow };
|
||||
public static readonly Layer Leadout = new Layer("LEADOUT") { Color = Color.Brown };
|
||||
|
||||
public static readonly Layer Scribe = new Layer("SCRIBE") { Color = Color.Magenta };
|
||||
}
|
||||
|
||||
@@ -13,25 +13,17 @@ public static class AutoSplitCalculator
|
||||
|
||||
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;
|
||||
var verticalSplits = usableWidth > 0 ? (int)System.Math.Ceiling(partBounds.Length / usableWidth) - 1 : 0;
|
||||
var horizontalSplits = usableHeight > 0 ? (int)System.Math.Ceiling(partBounds.Width / usableHeight) - 1 : 0;
|
||||
|
||||
if (verticalSplits < 0) verticalSplits = 0;
|
||||
if (horizontalSplits < 0) horizontalSplits = 0;
|
||||
|
||||
if (verticalSplits > 0)
|
||||
{
|
||||
var spacing = partBounds.Width / (verticalSplits + 1);
|
||||
for (var i = 1; i <= verticalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
|
||||
}
|
||||
for (var i = 1; i <= verticalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.X + usableWidth * i, CutOffAxis.Vertical));
|
||||
|
||||
if (horizontalSplits > 0)
|
||||
{
|
||||
var spacing = partBounds.Length / (horizontalSplits + 1);
|
||||
for (var i = 1; i <= horizontalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
||||
}
|
||||
for (var i = 1; i <= horizontalSplits; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + usableHeight * i, CutOffAxis.Horizontal));
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -42,14 +34,14 @@ public static class AutoSplitCalculator
|
||||
|
||||
if (verticalPieces > 1)
|
||||
{
|
||||
var spacing = partBounds.Width / verticalPieces;
|
||||
var spacing = partBounds.Length / 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;
|
||||
var spacing = partBounds.Width / horizontalPieces;
|
||||
for (var i = 1; i < horizontalPieces; i++)
|
||||
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class DrawingSplitter
|
||||
allEntities.AddRange(pieceEntities);
|
||||
allEntities.AddRange(cutoutEntities);
|
||||
|
||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex);
|
||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
||||
results.Add(piece);
|
||||
pieceIndex++;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ public static class DrawingSplitter
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex)
|
||||
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;
|
||||
@@ -98,9 +98,69 @@ public static class DrawingSplitter
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public record MachineSummary(Guid Id, string Name);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OpenNest.Data;
|
||||
|
||||
public enum MachineType
|
||||
{
|
||||
Laser,
|
||||
Plasma,
|
||||
Waterjet
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
@@ -16,7 +17,8 @@ namespace OpenNest.Engine.BestFit
|
||||
if (!result.Keep)
|
||||
continue;
|
||||
|
||||
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight))
|
||||
if (result.ShortestSide > System.Math.Min(MaxPlateWidth, MaxPlateHeight) ||
|
||||
result.LongestSide > System.Math.Max(MaxPlateWidth, MaxPlateHeight))
|
||||
{
|
||||
result.Keep = false;
|
||||
result.Reason = "Exceeds plate dimensions";
|
||||
@@ -25,7 +27,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);
|
||||
|
||||
@@ -4,6 +4,7 @@ using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -49,6 +50,8 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var allCandidates = candidateBags.SelectMany(c => c).ToList();
|
||||
|
||||
Debug.WriteLine($"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates");
|
||||
|
||||
var results = _evaluator.EvaluateAll(allCandidates);
|
||||
|
||||
_filter.Apply(results);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -17,7 +18,6 @@ namespace OpenNest.Engine.BestFit
|
||||
var allMovingVerts = ExtractUniqueVertices(movingTemplateLines);
|
||||
var allStationaryVerts = ExtractUniqueVertices(stationaryLines);
|
||||
|
||||
// Pre-filter vertices per unique direction (typically 4 cardinal directions).
|
||||
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
@@ -43,7 +43,6 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary edges
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
@@ -66,7 +65,6 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving edges (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
@@ -95,6 +93,253 @@ namespace OpenNest.Engine.BestFit
|
||||
return results;
|
||||
}
|
||||
|
||||
public double[] ComputeDistances(
|
||||
List<Entity> stationaryEntities,
|
||||
List<Entity> movingEntities,
|
||||
SlideOffset[] offsets)
|
||||
{
|
||||
var count = offsets.Length;
|
||||
var results = new double[count];
|
||||
|
||||
var allMovingVerts = ExtractVerticesFromEntities(movingEntities);
|
||||
var allStationaryVerts = ExtractVerticesFromEntities(stationaryEntities);
|
||||
|
||||
var movingCurves = ExtractCurveParams(movingEntities);
|
||||
var stationaryCurves = ExtractCurveParams(stationaryEntities);
|
||||
|
||||
var vertexCache = new Dictionary<(double, double), (Vector[] leading, Vector[] facing)>();
|
||||
|
||||
foreach (var offset in offsets)
|
||||
{
|
||||
var key = (offset.DirX, offset.DirY);
|
||||
if (vertexCache.ContainsKey(key))
|
||||
continue;
|
||||
|
||||
var leading = FilterVerticesByProjection(allMovingVerts, offset.DirX, offset.DirY, keepHigh: true);
|
||||
var facing = FilterVerticesByProjection(allStationaryVerts, offset.DirX, offset.DirY, keepHigh: false);
|
||||
vertexCache[key] = (leading, facing);
|
||||
}
|
||||
|
||||
System.Threading.Tasks.Parallel.For(0, count, i =>
|
||||
{
|
||||
var offset = offsets[i];
|
||||
var dirX = offset.DirX;
|
||||
var dirY = offset.DirY;
|
||||
var oppX = -dirX;
|
||||
var oppY = -dirY;
|
||||
|
||||
var (leadingMoving, facingStationary) = vertexCache[(dirX, dirY)];
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Leading moving vertices → stationary entities
|
||||
for (var v = 0; v < leadingMoving.Length; v++)
|
||||
{
|
||||
var vx = leadingMoving[v].X + offset.Dx;
|
||||
var vy = leadingMoving[v].Y + offset.Dy;
|
||||
|
||||
for (var j = 0; j < stationaryEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(vx, vy, stationaryEntities[j], 0, 0, dirX, dirY);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Facing stationary vertices → moving entities (opposite direction)
|
||||
for (var v = 0; v < facingStationary.Length; v++)
|
||||
{
|
||||
var svx = facingStationary[v].X;
|
||||
var svy = facingStationary[v].Y;
|
||||
|
||||
for (var j = 0; j < movingEntities.Count; j++)
|
||||
{
|
||||
var d = RayEntityDistance(svx, svy, movingEntities[j], offset.Dx, offset.Dy, oppX, oppY);
|
||||
|
||||
if (d < minDist)
|
||||
{
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Curve-to-curve direct distance.
|
||||
// Vertex sampling misses the true contact between two curved entities
|
||||
// when the approach angle doesn't align with a sampled vertex.
|
||||
for (var m = 0; m < movingCurves.Length; m++)
|
||||
{
|
||||
var mc = movingCurves[m];
|
||||
var mcx = mc.Cx + offset.Dx;
|
||||
var mcy = mc.Cy + offset.Dy;
|
||||
|
||||
for (var s = 0; s < stationaryCurves.Length; s++)
|
||||
{
|
||||
var sc = stationaryCurves[s];
|
||||
var d = SpatialQuery.RayCircleDistance(
|
||||
mcx, mcy, sc.Cx, sc.Cy, mc.Radius + sc.Radius, dirX, dirY);
|
||||
|
||||
if (d >= minDist || d == double.MaxValue)
|
||||
continue;
|
||||
|
||||
if (mc.Entity is Arc || sc.Entity is Arc)
|
||||
{
|
||||
var mx = mcx + d * dirX;
|
||||
var my = mcy + d * dirY;
|
||||
var toCx = sc.Cx - mx;
|
||||
var toCy = sc.Cy - my;
|
||||
|
||||
if (mc.Entity is Arc mArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(toCy, toCx));
|
||||
if (!Angle.IsBetweenRad(angle, mArc.StartAngle, mArc.EndAngle, mArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sc.Entity is Arc sArc)
|
||||
{
|
||||
var angle = Angle.NormalizeRad(System.Math.Atan2(-toCy, -toCx));
|
||||
if (!Angle.IsBetweenRad(angle, sArc.StartAngle, sArc.EndAngle, sArc.IsReversed))
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
minDist = d;
|
||||
if (d <= 0) { results[i] = 0; return; }
|
||||
}
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private readonly struct CurveParams
|
||||
{
|
||||
public readonly Entity Entity;
|
||||
public readonly double Cx, Cy, Radius;
|
||||
|
||||
public CurveParams(Entity entity, double cx, double cy, double radius)
|
||||
{
|
||||
Entity = entity;
|
||||
Cx = cx;
|
||||
Cy = cy;
|
||||
Radius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurveParams[] ExtractCurveParams(List<Entity> entities)
|
||||
{
|
||||
var curves = new List<CurveParams>();
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
if (entities[i] is Circle circle)
|
||||
curves.Add(new CurveParams(circle, circle.Center.X, circle.Center.Y, circle.Radius));
|
||||
else if (entities[i] is Arc arc)
|
||||
curves.Add(new CurveParams(arc, arc.Center.X, arc.Center.Y, arc.Radius));
|
||||
}
|
||||
return curves.ToArray();
|
||||
}
|
||||
|
||||
private static double RayEntityDistance(
|
||||
double vx, double vy, Entity entity,
|
||||
double entityOffsetX, double entityOffsetY,
|
||||
double dirX, double dirY)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
return SpatialQuery.RayEdgeDistance(
|
||||
vx, vy,
|
||||
line.StartPoint.X + entityOffsetX, line.StartPoint.Y + entityOffsetY,
|
||||
line.EndPoint.X + entityOffsetX, line.EndPoint.Y + entityOffsetY,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
return SpatialQuery.RayArcDistance(
|
||||
vx, vy,
|
||||
arc.Center.X + entityOffsetX, arc.Center.Y + entityOffsetY,
|
||||
arc.Radius,
|
||||
arc.StartAngle, arc.EndAngle, arc.IsReversed,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
if (entity is Circle circle)
|
||||
{
|
||||
return SpatialQuery.RayCircleDistance(
|
||||
vx, vy,
|
||||
circle.Center.X + entityOffsetX, circle.Center.Y + entityOffsetY,
|
||||
circle.Radius,
|
||||
dirX, dirY);
|
||||
}
|
||||
|
||||
return double.MaxValue;
|
||||
}
|
||||
|
||||
private static Vector[] ExtractVerticesFromEntities(List<Entity> entities)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
|
||||
for (var i = 0; i < entities.Count; i++)
|
||||
{
|
||||
var entity = entities[i];
|
||||
|
||||
if (entity is Line line)
|
||||
{
|
||||
vertices.Add(line.StartPoint);
|
||||
vertices.Add(line.EndPoint);
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
{
|
||||
vertices.Add(arc.StartPoint());
|
||||
vertices.Add(arc.EndPoint());
|
||||
AddArcExtremes(vertices, arc);
|
||||
}
|
||||
else if (entity is Circle circle)
|
||||
{
|
||||
// Four cardinal points
|
||||
vertices.Add(new Vector(circle.Center.X + circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X - circle.Radius, circle.Center.Y));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y + circle.Radius));
|
||||
vertices.Add(new Vector(circle.Center.X, circle.Center.Y - circle.Radius));
|
||||
}
|
||||
}
|
||||
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
private static void AddArcExtremes(HashSet<Vector> points, Arc arc)
|
||||
{
|
||||
var a1 = arc.StartAngle;
|
||||
var a2 = arc.EndAngle;
|
||||
var reversed = arc.IsReversed;
|
||||
|
||||
if (reversed)
|
||||
Generic.Swap(ref a1, ref a2);
|
||||
|
||||
// Right (0°)
|
||||
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
|
||||
|
||||
// Top (90°)
|
||||
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
|
||||
|
||||
// Left (180°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
|
||||
|
||||
// Bottom (270°)
|
||||
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
|
||||
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
|
||||
}
|
||||
|
||||
private static Vector[] ExtractUniqueVertices(List<Line> lines)
|
||||
{
|
||||
var vertices = new HashSet<Vector>();
|
||||
@@ -106,11 +351,6 @@ namespace OpenNest.Engine.BestFit
|
||||
return vertices.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters vertices by their projection onto the push direction.
|
||||
/// keepHigh=true returns the leading half (front face, closest to target).
|
||||
/// keepHigh=false returns the facing half (side facing the approaching part).
|
||||
/// </summary>
|
||||
private static Vector[] FilterVerticesByProjection(
|
||||
Vector[] vertices, double dirX, double dirY, bool keepHigh)
|
||||
{
|
||||
|
||||
@@ -36,6 +36,16 @@ namespace OpenNest.Engine.BestFit
|
||||
flatOffsets, count, directions);
|
||||
}
|
||||
|
||||
public double[] ComputeDistances(
|
||||
List<Entity> stationaryEntities,
|
||||
List<Entity> movingEntities,
|
||||
SlideOffset[] offsets)
|
||||
{
|
||||
// GPU path doesn't support native entities yet — fall back to CPU.
|
||||
var cpu = new CpuDistanceComputer();
|
||||
return cpu.ComputeDistances(stationaryEntities, movingEntities, offsets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a unit direction vector to a PushDirection int for the GPU interface.
|
||||
/// Left=0, Down=1, Right=2, Up=3.
|
||||
|
||||
@@ -9,5 +9,10 @@ namespace OpenNest.Engine.BestFit
|
||||
List<Line> stationaryLines,
|
||||
List<Line> movingTemplateLines,
|
||||
SlideOffset[] offsets);
|
||||
|
||||
double[] ComputeDistances(
|
||||
List<Entity> stationaryEntities,
|
||||
List<Entity> movingEntities,
|
||||
SlideOffset[] offsets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,18 +22,11 @@ namespace OpenNest.Engine.BestFit
|
||||
if (perimeter == null)
|
||||
return new PolygonExtractionResult(null, Vector.Zero);
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
// Detect winding direction to choose the correct outward offset side.
|
||||
var outwardSide = OffsetSide.Right;
|
||||
if (halfSpacing > 0)
|
||||
{
|
||||
var testPoly = perimeter.ToPolygon();
|
||||
if (testPoly.Vertices.Count >= 3 && testPoly.RotationDirection() == RotationType.CW)
|
||||
outwardSide = OffsetSide.Left;
|
||||
}
|
||||
// Ensure CW winding for correct outward offset direction.
|
||||
definedShape.NormalizeWinding();
|
||||
|
||||
var inflated = halfSpacing > 0
|
||||
? (perimeter.OffsetEntity(halfSpacing, outwardSide) as Shape ?? perimeter)
|
||||
? (perimeter.OffsetOutward(halfSpacing) ?? perimeter)
|
||||
: perimeter;
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
|
||||
@@ -36,8 +36,8 @@ namespace OpenNest.Engine.BestFit
|
||||
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
|
||||
|
||||
var halfSpacing = spacing / 2;
|
||||
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
|
||||
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
|
||||
var part1Entities = PartGeometry.GetOffsetPerimeterEntities(part1, halfSpacing);
|
||||
var part2Entities = PartGeometry.GetOffsetPerimeterEntities(part2Template, halfSpacing);
|
||||
|
||||
var bbox1 = part1.BoundingBox;
|
||||
var bbox2 = part2Template.BoundingBox;
|
||||
@@ -48,7 +48,7 @@ namespace OpenNest.Engine.BestFit
|
||||
return candidates;
|
||||
|
||||
var distances = _distanceComputer.ComputeDistances(
|
||||
part1Lines, part2TemplateLines, offsets);
|
||||
part1Entities, part2Entities, offsets);
|
||||
|
||||
var testNumber = 0;
|
||||
|
||||
@@ -89,15 +89,20 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
if (isHorizontalPush)
|
||||
{
|
||||
perpMin = -(bbox2.Length + spacing);
|
||||
perpMax = bbox1.Length + bbox2.Length + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
// Perpendicular sweep along Y → Width; push extent along X → Length
|
||||
// Trim to offsets where the parts overlap by at least 50%.
|
||||
var halfOverlap = bbox2.Width * 0.5;
|
||||
perpMin = -(halfOverlap - spacing);
|
||||
perpMax = bbox1.Width + halfOverlap + spacing;
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
perpMin = -(bbox2.Width + spacing);
|
||||
perpMax = bbox1.Width + bbox2.Width + spacing;
|
||||
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
|
||||
// Perpendicular sweep along X → Length; push extent along Y → Width
|
||||
var halfOverlap = bbox2.Length * 0.5;
|
||||
perpMin = -(halfOverlap - spacing);
|
||||
perpMax = bbox1.Length + halfOverlap + spacing;
|
||||
pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2;
|
||||
}
|
||||
|
||||
var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
|
||||
|
||||
@@ -27,7 +27,11 @@ namespace OpenNest.CirclePacking
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private Bin FillHorizontal(Item item)
|
||||
private Bin FillHorizontal(Item item) => FillAxis(item, horizontal: true);
|
||||
|
||||
private Bin FillVertical(Item item) => FillAxis(item, horizontal: false);
|
||||
|
||||
private Bin FillAxis(Item item, bool horizontal)
|
||||
{
|
||||
var bin = Bin.Clone() as Bin;
|
||||
|
||||
@@ -35,65 +39,36 @@ namespace OpenNest.CirclePacking
|
||||
bin.Right - item.BoundingBox.Right + Tolerance.Epsilon,
|
||||
bin.Top - item.BoundingBox.Top + Tolerance.Epsilon);
|
||||
|
||||
var count = System.Math.Floor((bin.Width + Tolerance.Epsilon) / item.Diameter);
|
||||
var primarySize = horizontal ? bin.Width : bin.Length;
|
||||
var count = System.Math.Floor((primarySize + Tolerance.Epsilon) / item.Diameter);
|
||||
|
||||
if (count == 0)
|
||||
return bin;
|
||||
|
||||
var xoffset = (bin.Width - item.Diameter) / (count - 1);
|
||||
var yoffset = Trigonometry.Height(xoffset * 0.5, item.Diameter);
|
||||
var primaryOffset = (primarySize - item.Diameter) / (count - 1);
|
||||
var secondaryOffset = horizontal
|
||||
? Trigonometry.Height(primaryOffset * 0.5, item.Diameter)
|
||||
: Trigonometry.Base(primaryOffset * 0.5, item.Diameter);
|
||||
|
||||
int row = 0;
|
||||
var outerStart = horizontal ? bin.Y : bin.X;
|
||||
var outerMax = horizontal ? max.Y : max.X;
|
||||
var innerStart = horizontal ? bin.X : bin.Y;
|
||||
var innerMax = horizontal ? max.X : max.Y;
|
||||
|
||||
for (var y = bin.Y; y <= max.Y; y += yoffset)
|
||||
var stripe = 0;
|
||||
|
||||
for (var outer = outerStart; outer <= outerMax; outer += secondaryOffset)
|
||||
{
|
||||
var x = row.IsOdd() ? bin.X + xoffset * 0.5 : bin.X;
|
||||
var inner = stripe.IsOdd() ? innerStart + primaryOffset * 0.5 : innerStart;
|
||||
|
||||
for (; x <= max.X; x += xoffset)
|
||||
for (; inner <= innerMax; inner += primaryOffset)
|
||||
{
|
||||
var addedItem = item.Clone() as Item;
|
||||
addedItem.Center = new Vector(x, y);
|
||||
|
||||
addedItem.Center = horizontal ? new Vector(inner, outer) : new Vector(outer, inner);
|
||||
bin.Items.Add(addedItem);
|
||||
}
|
||||
|
||||
row++;
|
||||
}
|
||||
|
||||
return bin;
|
||||
}
|
||||
|
||||
private Bin FillVertical(Item item)
|
||||
{
|
||||
var bin = Bin.Clone() as Bin;
|
||||
|
||||
var max = new Vector(
|
||||
Bin.Right - item.BoundingBox.Right + Tolerance.Epsilon,
|
||||
Bin.Top - item.BoundingBox.Top + Tolerance.Epsilon);
|
||||
|
||||
var count = System.Math.Floor((bin.Length + Tolerance.Epsilon) / item.Diameter);
|
||||
|
||||
if (count == 0)
|
||||
return bin;
|
||||
|
||||
var yoffset = (bin.Length - item.Diameter) / (count - 1);
|
||||
var xoffset = Trigonometry.Base(yoffset * 0.5, item.Diameter);
|
||||
|
||||
int column = 0;
|
||||
|
||||
for (var x = bin.X; x <= max.X; x += xoffset)
|
||||
{
|
||||
var y = column.IsOdd() ? bin.Y + yoffset * 0.5 : bin.Y;
|
||||
|
||||
for (; y <= max.Y; y += yoffset)
|
||||
{
|
||||
var addedItem = item.Clone() as Item;
|
||||
addedItem.Center = new Vector(x, y);
|
||||
|
||||
bin.Items.Add(addedItem);
|
||||
}
|
||||
|
||||
column++;
|
||||
stripe++;
|
||||
}
|
||||
|
||||
return bin;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.RectanglePacking;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -26,9 +29,9 @@ namespace OpenNest
|
||||
set => angleBuilder.ForceFullSweep = value;
|
||||
}
|
||||
|
||||
public override List<double> BuildAngles(NestItem item, double bestRotation, Box workArea)
|
||||
public override List<double> BuildAngles(NestItem item, ClassificationResult classification, Box workArea)
|
||||
{
|
||||
return angleBuilder.Build(item, bestRotation, workArea);
|
||||
return angleBuilder.Build(item, classification, workArea);
|
||||
}
|
||||
|
||||
protected override void RecordProductiveAngles(List<AngleResult> angleResults)
|
||||
@@ -44,24 +47,50 @@ namespace OpenNest
|
||||
PhaseResults.Clear();
|
||||
AngleResults.Clear();
|
||||
|
||||
var context = new FillContext
|
||||
// Fast path: for very small quantities, skip the full strategy pipeline.
|
||||
if (item.Quantity > 0 && item.Quantity <= 2)
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = Plate,
|
||||
PlateNumber = PlateNumber,
|
||||
Token = token,
|
||||
Progress = progress,
|
||||
Policy = BuildPolicy(),
|
||||
};
|
||||
var fast = TryFillSmallQuantity(item, workArea);
|
||||
if (fast != null && fast.Count >= item.Quantity)
|
||||
{
|
||||
Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}");
|
||||
WinnerPhase = NestPhase.Pairs;
|
||||
ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = WinnerPhase,
|
||||
PlateNumber = PlateNumber,
|
||||
Parts = fast,
|
||||
WorkArea = workArea,
|
||||
Description = $"Fast path: {fast.Count} parts",
|
||||
IsOverallBest = true,
|
||||
});
|
||||
return fast;
|
||||
}
|
||||
}
|
||||
|
||||
RunPipeline(context);
|
||||
// For low quantities, shrink the work area in both dimensions to avoid
|
||||
// running expensive strategies against the full plate.
|
||||
var effectiveWorkArea = workArea;
|
||||
if (item.Quantity > 0)
|
||||
{
|
||||
effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing);
|
||||
|
||||
// PhaseResults already synced during RunPipeline.
|
||||
AngleResults.AddRange(context.AngleResults);
|
||||
WinnerPhase = context.WinnerPhase;
|
||||
if (effectiveWorkArea != workArea)
|
||||
Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " +
|
||||
$"from {workArea.Width:F1}x{workArea.Length:F1} " +
|
||||
$"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}");
|
||||
}
|
||||
|
||||
var best = context.CurrentBest ?? new List<Part>();
|
||||
var best = RunFillPipeline(item, effectiveWorkArea, progress, token);
|
||||
|
||||
// Fallback: if the reduced area didn't yield enough, retry with full area.
|
||||
if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea)
|
||||
{
|
||||
Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area");
|
||||
PhaseResults.Clear();
|
||||
AngleResults.Clear();
|
||||
best = RunFillPipeline(item, workArea, progress, token);
|
||||
}
|
||||
|
||||
if (item.Quantity > 0 && best.Count > item.Quantity)
|
||||
best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis);
|
||||
@@ -79,6 +108,180 @@ namespace OpenNest
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast path for qty 1-2: place a single part or a best-fit pair
|
||||
/// without running the full strategy pipeline.
|
||||
/// </summary>
|
||||
private List<Part> TryFillSmallQuantity(NestItem item, Box workArea)
|
||||
{
|
||||
if (item.Quantity == 1)
|
||||
return TryPlaceSingle(item.Drawing, workArea);
|
||||
|
||||
if (item.Quantity == 2)
|
||||
return TryPlaceBestFitPair(item.Drawing, workArea);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Part> TryPlaceSingle(Drawing drawing, Box workArea)
|
||||
{
|
||||
var part = Part.CreateAtOrigin(drawing);
|
||||
if (part.BoundingBox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||
part.BoundingBox.Length > workArea.Length + Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
part.Offset(workArea.Location - part.BoundingBox.Location);
|
||||
return new List<Part> { part };
|
||||
}
|
||||
|
||||
private List<Part> TryPlaceBestFitPair(Drawing drawing, Box workArea)
|
||||
{
|
||||
var bestFits = BestFitCache.GetOrCompute(
|
||||
drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing);
|
||||
|
||||
List<Part> bestPlacement = null;
|
||||
|
||||
foreach (var fit in bestFits)
|
||||
{
|
||||
if (!fit.Keep)
|
||||
continue;
|
||||
|
||||
// Skip pairs that can't possibly fit the work area in either orientation.
|
||||
if (fit.ShortestSide > System.Math.Min(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||
continue;
|
||||
if (fit.LongestSide > System.Math.Max(workArea.Width, workArea.Length) + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var landscape = fit.BuildParts(drawing);
|
||||
var portrait = RotatePair90(landscape);
|
||||
|
||||
var lFits = TryOffsetToWorkArea(landscape, workArea);
|
||||
var pFits = TryOffsetToWorkArea(portrait, workArea);
|
||||
|
||||
// Pick the better orientation for this pair.
|
||||
List<Part> candidate = null;
|
||||
if (lFits && pFits)
|
||||
candidate = IsBetterFill(portrait, landscape, workArea) ? portrait : landscape;
|
||||
else if (lFits)
|
||||
candidate = landscape;
|
||||
else if (pFits)
|
||||
candidate = portrait;
|
||||
|
||||
if (candidate == null)
|
||||
continue;
|
||||
|
||||
if (bestPlacement == null || IsBetterFill(candidate, bestPlacement, workArea))
|
||||
bestPlacement = candidate;
|
||||
}
|
||||
|
||||
return bestPlacement;
|
||||
}
|
||||
|
||||
private static List<Part> RotatePair90(List<Part> parts)
|
||||
{
|
||||
var rotated = new List<Part>(parts.Count);
|
||||
foreach (var p in parts)
|
||||
rotated.Add((Part)p.Clone());
|
||||
|
||||
var bbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
|
||||
var center = bbox.Center;
|
||||
|
||||
foreach (var p in rotated)
|
||||
p.Rotate(-Angle.HalfPI, center);
|
||||
|
||||
var newBbox = ((IEnumerable<IBoundable>)rotated).GetBoundingBox();
|
||||
var offset = new Vector(-newBbox.Left, -newBbox.Bottom);
|
||||
foreach (var p in rotated)
|
||||
{
|
||||
p.Offset(offset);
|
||||
p.UpdateBounds();
|
||||
}
|
||||
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static bool TryOffsetToWorkArea(List<Part> parts, Box workArea)
|
||||
{
|
||||
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox();
|
||||
if (bbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||
bbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var offset = workArea.Location - bbox.Location;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
p.Offset(offset);
|
||||
p.UpdateBounds();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shrinks the work area in both dimensions proportionally when the
|
||||
/// requested quantity is much less than the plate capacity.
|
||||
/// </summary>
|
||||
private static Box ShrinkWorkArea(NestItem item, Box workArea, double spacing)
|
||||
{
|
||||
var bbox = item.Drawing.Program.BoundingBox();
|
||||
if (bbox.Width <= 0 || bbox.Length <= 0)
|
||||
return workArea;
|
||||
|
||||
var bin = new Bin { Size = new Size(workArea.Width, workArea.Length) };
|
||||
var packItem = new Item { Size = new Size(bbox.Width + spacing, bbox.Length + spacing) };
|
||||
var packer = new FillBestFit(bin);
|
||||
packer.Fill(packItem);
|
||||
var fullCount = bin.Items.Count;
|
||||
|
||||
if (fullCount <= 0 || fullCount <= item.Quantity)
|
||||
return workArea;
|
||||
|
||||
// Scale both dimensions by sqrt(ratio) so the area shrinks
|
||||
// proportionally. 2x margin gives strategies room to optimize.
|
||||
var ratio = (double)item.Quantity / fullCount;
|
||||
var scale = System.Math.Sqrt(ratio) * 2.0;
|
||||
|
||||
var newWidth = workArea.Width * scale;
|
||||
var newLength = workArea.Length * scale;
|
||||
|
||||
// Ensure at least one part fits.
|
||||
var minWidth = bbox.Width + spacing * 2;
|
||||
var minLength = bbox.Length + spacing * 2;
|
||||
newWidth = System.Math.Max(newWidth, minWidth);
|
||||
newLength = System.Math.Max(newLength, minLength);
|
||||
|
||||
// Clamp to original dimensions.
|
||||
newWidth = System.Math.Min(newWidth, workArea.Width);
|
||||
newLength = System.Math.Min(newLength, workArea.Length);
|
||||
|
||||
if (newWidth >= workArea.Width && newLength >= workArea.Length)
|
||||
return workArea;
|
||||
|
||||
return new Box(workArea.X, workArea.Y, newLength, newWidth);
|
||||
}
|
||||
|
||||
private List<Part> RunFillPipeline(NestItem item, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
var context = new FillContext
|
||||
{
|
||||
Item = item,
|
||||
WorkArea = workArea,
|
||||
Plate = Plate,
|
||||
PlateNumber = PlateNumber,
|
||||
Token = token,
|
||||
Progress = progress,
|
||||
Policy = BuildPolicy(),
|
||||
MaxQuantity = item.Quantity,
|
||||
};
|
||||
|
||||
RunPipeline(context);
|
||||
|
||||
AngleResults.AddRange(context.AngleResults);
|
||||
WinnerPhase = context.WinnerPhase;
|
||||
|
||||
return context.CurrentBest ?? new List<Part>();
|
||||
}
|
||||
|
||||
public override List<Part> Fill(List<Part> groupParts, Box workArea,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
@@ -94,7 +297,7 @@ namespace OpenNest
|
||||
|
||||
// Multi-part group: linear pattern fill only.
|
||||
PhaseResults.Clear();
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing);
|
||||
var engine = new FillLinear(workArea, Plate.PartSpacing) { Label = "GroupPattern" };
|
||||
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
|
||||
var best = FillHelpers.FillPattern(engine, groupParts, angles, workArea, Comparer);
|
||||
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
|
||||
@@ -132,10 +335,12 @@ namespace OpenNest
|
||||
|
||||
protected virtual void RunPipeline(FillContext context)
|
||||
{
|
||||
var bestRotation = RotationAnalysis.FindBestRotation(context.Item);
|
||||
context.SharedState["BestRotation"] = bestRotation;
|
||||
var classification = PartClassifier.Classify(context.Item.Drawing);
|
||||
context.PartType = classification.Type;
|
||||
context.SharedState["BestRotation"] = classification.PrimaryAngle;
|
||||
context.SharedState["Classification"] = classification;
|
||||
|
||||
var angles = BuildAngles(context.Item, bestRotation, context.WorkArea);
|
||||
var angles = BuildAngles(context.Item, classification, context.WorkArea);
|
||||
context.SharedState["AngleCandidates"] = angles;
|
||||
|
||||
try
|
||||
@@ -143,6 +348,7 @@ namespace OpenNest
|
||||
foreach (var strategy in FillStrategyRegistry.Strategies)
|
||||
{
|
||||
context.Token.ThrowIfCancellationRequested();
|
||||
context.ActivePhase = strategy.Phase;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = strategy.Fill(context);
|
||||
@@ -156,14 +362,18 @@ namespace OpenNest
|
||||
// during progress reporting.
|
||||
PhaseResults.Add(phaseResult);
|
||||
|
||||
if (context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea))
|
||||
// FillContext.ReportProgress updates CurrentBest during the
|
||||
// strategy's angle sweep. This catches strategies that return a
|
||||
// result without reporting it (e.g. RectBestFit).
|
||||
var improved = context.Policy.Comparer.IsBetter(result, context.CurrentBest, context.WorkArea);
|
||||
if (improved)
|
||||
{
|
||||
context.CurrentBest = result;
|
||||
context.CurrentBestScore = FillScore.Compute(result, context.WorkArea);
|
||||
context.WinnerPhase = strategy.Phase;
|
||||
}
|
||||
|
||||
if (context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
if (improved && context.CurrentBest != null && context.CurrentBest.Count > 0)
|
||||
{
|
||||
ReportProgress(context.Progress, new ProgressReport
|
||||
{
|
||||
|
||||
@@ -7,31 +7,68 @@ using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds candidate rotation angles for single-item fill. Encapsulates the
|
||||
/// full pipeline: base angles, narrow-area sweep, ML prediction, and
|
||||
/// known-good pruning across fills.
|
||||
/// </summary>
|
||||
public class AngleCandidateBuilder
|
||||
{
|
||||
private readonly HashSet<double> knownGoodAngles = new();
|
||||
|
||||
public bool ForceFullSweep { get; set; }
|
||||
|
||||
public List<double> Build(NestItem item, double bestRotation, Box workArea)
|
||||
public List<double> Build(NestItem item, ClassificationResult classification, Box workArea)
|
||||
{
|
||||
var baseAngles = new[] { bestRotation, bestRotation + Angle.HalfPI };
|
||||
// User constraints always take precedence over classification.
|
||||
if (HasExplicitConstraints(item))
|
||||
return BuildFromConstraints(item);
|
||||
|
||||
switch (classification.Type)
|
||||
{
|
||||
case PartType.Circle:
|
||||
return new List<double> { 0 };
|
||||
|
||||
case PartType.Rectangle:
|
||||
return new List<double> { classification.PrimaryAngle, classification.PrimaryAngle + Angle.HalfPI };
|
||||
|
||||
default:
|
||||
return BuildIrregularAngles(item, classification.PrimaryAngle, workArea);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasExplicitConstraints(NestItem item)
|
||||
{
|
||||
// Default NestConstraints: Start=0, End=0. Both zero = no constraints.
|
||||
return !(item.RotationStart.IsEqualTo(0) && item.RotationEnd.IsEqualTo(0));
|
||||
}
|
||||
|
||||
private static List<double> BuildFromConstraints(NestItem item)
|
||||
{
|
||||
var angles = new List<double>();
|
||||
var step = item.StepAngle > Tolerance.Epsilon ? item.StepAngle : Angle.ToRadians(5);
|
||||
|
||||
for (var a = item.RotationStart; a <= item.RotationEnd + Tolerance.Epsilon; a += step)
|
||||
{
|
||||
if (!ContainsAngle(angles, a))
|
||||
angles.Add(a);
|
||||
}
|
||||
|
||||
if (angles.Count == 0)
|
||||
angles.Add(item.RotationStart);
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
private List<double> BuildIrregularAngles(NestItem item, double primaryAngle, Box workArea)
|
||||
{
|
||||
var baseAngles = new[] { primaryAngle, primaryAngle + Angle.HalfPI };
|
||||
|
||||
if (knownGoodAngles.Count > 0 && !ForceFullSweep)
|
||||
return BuildPrunedList(baseAngles);
|
||||
|
||||
var angles = new List<double>(baseAngles);
|
||||
|
||||
if (ForceFullSweep)
|
||||
AddSweepAngles(angles);
|
||||
// Full 5-degree sweep for irregular parts.
|
||||
AddSweepAngles(angles);
|
||||
|
||||
if (!ForceFullSweep && angles.Count > 2)
|
||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||
// ML prediction complements the sweep when available.
|
||||
angles = ApplyMlPrediction(item, workArea, baseAngles, angles);
|
||||
|
||||
return angles;
|
||||
}
|
||||
@@ -64,7 +101,14 @@ namespace OpenNest.Engine.Fill
|
||||
mlAngles.Add(b);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} angles -> {mlAngles.Count} predicted");
|
||||
// Merge ML angles into the existing sweep so both contribute.
|
||||
foreach (var a in fallback)
|
||||
{
|
||||
if (!ContainsAngle(mlAngles, a))
|
||||
mlAngles.Add(a);
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[AngleCandidateBuilder] ML: {fallback.Count} sweep + {predicted.Count} predicted = {mlAngles.Count} total");
|
||||
return mlAngles;
|
||||
}
|
||||
|
||||
@@ -86,10 +130,6 @@ namespace OpenNest.Engine.Fill
|
||||
return angles.Any(existing => existing.IsEqualTo(angle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records angles that produced results. These are used to prune
|
||||
/// subsequent Build() calls.
|
||||
/// </summary>
|
||||
public void RecordProductive(List<AngleResult> angleResults)
|
||||
{
|
||||
foreach (var ar in angleResults)
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
internal record CombinationResult(bool Found, int Count1, int Count2);
|
||||
|
||||
internal static class BestCombination
|
||||
{
|
||||
public static bool FindFrom2(double length1, double length2, double overallLength, out int count1, out int count2)
|
||||
public static CombinationResult FindFrom2(double length1, double length2, double overallLength)
|
||||
{
|
||||
overallLength += Tolerance.Epsilon;
|
||||
count1 = 0;
|
||||
count2 = 0;
|
||||
var count1 = 0;
|
||||
var count2 = 0;
|
||||
|
||||
var maxCount1 = (int)System.Math.Floor(overallLength / length1);
|
||||
var bestRemnant = overallLength + 1;
|
||||
@@ -30,7 +32,7 @@ namespace OpenNest
|
||||
break;
|
||||
}
|
||||
|
||||
return count1 > 0 || count2 > 0;
|
||||
return new CombinationResult(count1 > 0 || count2 > 0, count1, count2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@ namespace OpenNest.Engine.Fill
|
||||
/// </summary>
|
||||
public static class Compactor
|
||||
{
|
||||
private const double ChordTolerance = 0.001;
|
||||
|
||||
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
|
||||
{
|
||||
var obstacleParts = plate.Parts
|
||||
@@ -44,7 +42,7 @@ namespace OpenNest.Engine.Fill
|
||||
var opposite = -direction;
|
||||
|
||||
var obstacleBoxes = new Box[obstacleParts.Count];
|
||||
var obstacleLines = new List<Line>[obstacleParts.Count];
|
||||
var obstacleEntities = new List<Entity>[obstacleParts.Count];
|
||||
|
||||
for (var i = 0; i < obstacleParts.Count; i++)
|
||||
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
|
||||
@@ -61,7 +59,19 @@ namespace OpenNest.Engine.Fill
|
||||
distance = edgeDist;
|
||||
|
||||
var movingBox = moving.BoundingBox;
|
||||
List<Line> movingLines = null;
|
||||
List<Entity> movingEntities = null;
|
||||
|
||||
// Check if any obstacle is inside the moving part — only then
|
||||
// do we need cutout entities on the moving part.
|
||||
var needCutouts = false;
|
||||
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||
{
|
||||
if (movingBox.Contains(obstacleBoxes[i]))
|
||||
{
|
||||
needCutouts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < obstacleBoxes.Length; i++)
|
||||
{
|
||||
@@ -76,15 +86,19 @@ namespace OpenNest.Engine.Fill
|
||||
if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction))
|
||||
continue;
|
||||
|
||||
movingLines ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
|
||||
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
|
||||
movingEntities ??= halfSpacing > 0
|
||||
? (needCutouts
|
||||
? PartGeometry.GetOffsetPartEntities(moving, halfSpacing)
|
||||
: PartGeometry.GetOffsetPerimeterEntities(moving, halfSpacing))
|
||||
: (needCutouts
|
||||
? PartGeometry.GetPartEntities(moving)
|
||||
: PartGeometry.GetPerimeterEntities(moving));
|
||||
|
||||
obstacleLines[i] ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
|
||||
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
|
||||
obstacleEntities[i] ??= halfSpacing > 0
|
||||
? PartGeometry.GetOffsetPerimeterEntities(obstacleParts[i], halfSpacing)
|
||||
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
|
||||
|
||||
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
|
||||
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
}
|
||||
@@ -157,7 +171,7 @@ namespace OpenNest.Engine.Fill
|
||||
continue;
|
||||
|
||||
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
|
||||
var d = gap - partSpacing - 2 * ChordTolerance;
|
||||
var d = gap - partSpacing - 0.002;
|
||||
if (d < 0) d = 0;
|
||||
if (d < distance)
|
||||
distance = d;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using OpenNest.Engine.Strategies;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
@@ -23,56 +24,41 @@ namespace OpenNest.Engine.Fill
|
||||
}
|
||||
|
||||
public List<Part> Fill(Drawing drawing, double rotationAngle = 0,
|
||||
int plateNumber = 0,
|
||||
CancellationToken token = default,
|
||||
IProgress<NestProgress> progress = null,
|
||||
List<Engine.BestFit.BestFitResult> bestFits = null)
|
||||
Action<List<Part>, string> reportProgress = null)
|
||||
{
|
||||
var pair = BuildPair(drawing, rotationAngle);
|
||||
if (pair == null)
|
||||
return new List<Part>();
|
||||
|
||||
var column = BuildColumn(pair.Value.part1, pair.Value.part2, pair.Value.pairBbox);
|
||||
var column = BuildColumn(pair.Value);
|
||||
if (column.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = column,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: initial column {column.Count} parts",
|
||||
});
|
||||
reportProgress?.Invoke(column, $"Extents: initial column {column.Count} parts");
|
||||
|
||||
var adjusted = AdjustColumn(pair.Value, column, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
// The iterative pair adjustment can shift parts enough to cause
|
||||
// genuine overlap. Fall back to the unadjusted column when this happens.
|
||||
if (HasOverlappingParts(adjusted))
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = adjusted,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: adjusted column {adjusted.Count} parts",
|
||||
});
|
||||
Debug.WriteLine("[FillExtents] Adjusted column has overlaps, using unadjusted");
|
||||
adjusted = column;
|
||||
}
|
||||
|
||||
reportProgress?.Invoke(adjusted, $"Extents: column {adjusted.Count} parts");
|
||||
|
||||
var result = RepeatColumns(adjusted, token);
|
||||
|
||||
NestEngineBase.ReportProgress(progress, new ProgressReport
|
||||
{
|
||||
Phase = NestPhase.Extents,
|
||||
PlateNumber = plateNumber,
|
||||
Parts = result,
|
||||
WorkArea = workArea,
|
||||
Description = $"Extents: {result.Count} parts total",
|
||||
});
|
||||
reportProgress?.Invoke(result, $"Extents: {result.Count} parts total");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Step 1: Pair Construction ---
|
||||
|
||||
private (Part part1, Part part2, Box pairBbox)? BuildPair(Drawing drawing, double rotationAngle)
|
||||
private PartPair? BuildPair(Drawing drawing, double rotationAngle)
|
||||
{
|
||||
var part1 = Part.CreateAtOrigin(drawing, rotationAngle);
|
||||
var part2 = Part.CreateAtOrigin(drawing, rotationAngle + System.Math.PI);
|
||||
@@ -87,7 +73,7 @@ namespace OpenNest.Engine.Fill
|
||||
var boundary2 = new PartBoundary(part2, halfSpacing);
|
||||
|
||||
// Position part2 to the right of part1 at bounding box width distance.
|
||||
var startOffset = part1.BoundingBox.Width + part2.BoundingBox.Width + partSpacing;
|
||||
var startOffset = part1.BoundingBox.Length + part2.BoundingBox.Length + partSpacing;
|
||||
part2.Offset(startOffset, 0);
|
||||
part2.UpdateBounds();
|
||||
|
||||
@@ -102,46 +88,40 @@ namespace OpenNest.Engine.Fill
|
||||
part2.UpdateBounds();
|
||||
}
|
||||
|
||||
// Re-anchor pair to work area origin.
|
||||
var pairBbox = ((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
||||
var anchor = new Vector(workArea.X - pairBbox.Left, workArea.Y - pairBbox.Bottom);
|
||||
part1.Offset(anchor);
|
||||
part2.Offset(anchor);
|
||||
part1.UpdateBounds();
|
||||
part2.UpdateBounds();
|
||||
|
||||
pairBbox = ((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
||||
|
||||
// Verify pair fits in work area.
|
||||
if (pairBbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||
pairBbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||
var pair = AnchorToWorkArea(part1, part2);
|
||||
if (pair == null)
|
||||
return null;
|
||||
|
||||
return (part1, part2, pairBbox);
|
||||
// Verify pair fits in work area.
|
||||
if (pair.Value.Bbox.Width > workArea.Width + Tolerance.Epsilon ||
|
||||
pair.Value.Bbox.Length > workArea.Length + Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
// --- Step 2: Build Column (tile vertically) ---
|
||||
|
||||
private List<Part> BuildColumn(Part part1, Part part2, Box pairBbox)
|
||||
private List<Part> BuildColumn(PartPair pair)
|
||||
{
|
||||
var column = new List<Part> { (Part)part1.Clone(), (Part)part2.Clone() };
|
||||
var column = new List<Part> { (Part)pair.Part1.Clone(), (Part)pair.Part2.Clone() };
|
||||
|
||||
// Find geometry-aware copy distance for the pair vertically.
|
||||
var boundary1 = new PartBoundary(part1, halfSpacing);
|
||||
var boundary2 = new PartBoundary(part2, halfSpacing);
|
||||
var boundary1 = new PartBoundary(pair.Part1, halfSpacing);
|
||||
var boundary2 = new PartBoundary(pair.Part2, halfSpacing);
|
||||
|
||||
// Compute vertical copy distance using bounding boxes as starting point,
|
||||
// then slide down to find true geometry distance.
|
||||
var pairHeight = pairBbox.Length;
|
||||
var pairHeight = pair.Bbox.Width;
|
||||
var testOffset = new Vector(0, pairHeight);
|
||||
|
||||
// Create test parts for slide distance measurement.
|
||||
var testPart1 = part1.CloneAtOffset(testOffset);
|
||||
var testPart2 = part2.CloneAtOffset(testOffset);
|
||||
var testPart1 = pair.Part1.CloneAtOffset(testOffset);
|
||||
var testPart2 = pair.Part2.CloneAtOffset(testOffset);
|
||||
|
||||
// Find minimum distance from test pair sliding down toward original pair.
|
||||
var copyDistance = FindVerticalCopyDistance(
|
||||
part1, part2, testPart1, testPart2,
|
||||
pair.Part1, pair.Part2, testPart1, testPart2,
|
||||
boundary1, boundary2, pairHeight);
|
||||
|
||||
if (copyDistance <= 0)
|
||||
@@ -150,13 +130,13 @@ namespace OpenNest.Engine.Fill
|
||||
var count = 1;
|
||||
while (true)
|
||||
{
|
||||
var nextBottom = pairBbox.Bottom + copyDistance * count;
|
||||
var nextBottom = pair.Bbox.Bottom + copyDistance * count;
|
||||
if (nextBottom + pairHeight > workArea.Top + Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
var offset = new Vector(0, copyDistance * count);
|
||||
column.Add(part1.CloneAtOffset(offset));
|
||||
column.Add(part2.CloneAtOffset(offset));
|
||||
column.Add(pair.Part1.CloneAtOffset(offset));
|
||||
column.Add(pair.Part2.CloneAtOffset(offset));
|
||||
count++;
|
||||
}
|
||||
|
||||
@@ -170,23 +150,20 @@ namespace OpenNest.Engine.Fill
|
||||
double pairHeight)
|
||||
{
|
||||
// Check all 4 combinations: test parts sliding down toward original parts.
|
||||
var slidePairs = new[]
|
||||
{
|
||||
(moving: boundary1, movingLoc: testPart1.Location, stationary: boundary1, stationaryLoc: origPart1.Location),
|
||||
(moving: boundary1, movingLoc: testPart1.Location, stationary: boundary2, stationaryLoc: origPart2.Location),
|
||||
(moving: boundary2, movingLoc: testPart2.Location, stationary: boundary1, stationaryLoc: origPart1.Location),
|
||||
(moving: boundary2, movingLoc: testPart2.Location, stationary: boundary2, stationaryLoc: origPart2.Location),
|
||||
};
|
||||
|
||||
var minSlide = double.MaxValue;
|
||||
|
||||
// Test1 -> Orig1
|
||||
var d = SlideDistance(boundary1, testPart1.Location, boundary1, origPart1.Location, PushDirection.Down);
|
||||
if (d < minSlide) minSlide = d;
|
||||
|
||||
// Test1 -> Orig2
|
||||
d = SlideDistance(boundary1, testPart1.Location, boundary2, origPart2.Location, PushDirection.Down);
|
||||
if (d < minSlide) minSlide = d;
|
||||
|
||||
// Test2 -> Orig1
|
||||
d = SlideDistance(boundary2, testPart2.Location, boundary1, origPart1.Location, PushDirection.Down);
|
||||
if (d < minSlide) minSlide = d;
|
||||
|
||||
// Test2 -> Orig2
|
||||
d = SlideDistance(boundary2, testPart2.Location, boundary2, origPart2.Location, PushDirection.Down);
|
||||
if (d < minSlide) minSlide = d;
|
||||
foreach (var (moving, movingLoc, stationary, stationaryLoc) in slidePairs)
|
||||
{
|
||||
var d = SlideDistance(moving, movingLoc, stationary, stationaryLoc, PushDirection.Down);
|
||||
if (d < minSlide) minSlide = d;
|
||||
}
|
||||
|
||||
if (minSlide >= double.MaxValue || minSlide < 0)
|
||||
return pairHeight + partSpacing;
|
||||
@@ -216,12 +193,9 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
// --- Step 3: Iterative Adjustment ---
|
||||
|
||||
private List<Part> AdjustColumn(
|
||||
(Part part1, Part part2, Box pairBbox) pair,
|
||||
List<Part> column,
|
||||
CancellationToken token)
|
||||
private List<Part> AdjustColumn(PartPair pair, List<Part> column, CancellationToken token)
|
||||
{
|
||||
var originalPairWidth = pair.pairBbox.Width;
|
||||
var originalPairWidth = pair.Bbox.Length;
|
||||
|
||||
for (var iteration = 0; iteration < MaxIterations; iteration++)
|
||||
{
|
||||
@@ -252,7 +226,7 @@ namespace OpenNest.Engine.Fill
|
||||
if (adjusted == null)
|
||||
break;
|
||||
|
||||
var newColumn = BuildColumn(adjusted.Value.part1, adjusted.Value.part2, adjusted.Value.pairBbox);
|
||||
var newColumn = BuildColumn(adjusted.Value);
|
||||
if (newColumn.Count == 0)
|
||||
break;
|
||||
|
||||
@@ -263,9 +237,7 @@ namespace OpenNest.Engine.Fill
|
||||
return column;
|
||||
}
|
||||
|
||||
private (Part part1, Part part2, Box pairBbox)? TryAdjustPair(
|
||||
(Part part1, Part part2, Box pairBbox) pair,
|
||||
double adjustment, double originalPairWidth)
|
||||
private PartPair? TryAdjustPair(PartPair pair, double adjustment, double originalPairWidth)
|
||||
{
|
||||
// Try shifting part2 up first.
|
||||
var result = TryShiftDirection(pair, adjustment, originalPairWidth);
|
||||
@@ -277,13 +249,11 @@ namespace OpenNest.Engine.Fill
|
||||
return TryShiftDirection(pair, -adjustment, originalPairWidth);
|
||||
}
|
||||
|
||||
private (Part part1, Part part2, Box pairBbox)? TryShiftDirection(
|
||||
(Part part1, Part part2, Box pairBbox) pair,
|
||||
double verticalShift, double originalPairWidth)
|
||||
private PartPair? TryShiftDirection(PartPair pair, double verticalShift, double originalPairWidth)
|
||||
{
|
||||
// Clone parts so we don't mutate the originals.
|
||||
var p1 = (Part)pair.part1.Clone();
|
||||
var p2 = (Part)pair.part2.Clone();
|
||||
var p1 = (Part)pair.Part1.Clone();
|
||||
var p2 = (Part)pair.Part2.Clone();
|
||||
|
||||
// Separate: shift part2 right so bounding boxes don't touch.
|
||||
p2.Offset(partSpacing, 0);
|
||||
@@ -299,20 +269,12 @@ namespace OpenNest.Engine.Fill
|
||||
Compactor.Push(moving, obstacles, workArea, partSpacing, PushDirection.Left);
|
||||
|
||||
// Check if the pair got wider.
|
||||
var newBbox = ((IEnumerable<IBoundable>)new IBoundable[] { p1, p2 }).GetBoundingBox();
|
||||
var newBbox = PairBbox(p1, p2);
|
||||
|
||||
if (newBbox.Width > originalPairWidth + Tolerance.Epsilon)
|
||||
if (newBbox.Length > originalPairWidth + Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
// Re-anchor to work area origin.
|
||||
var anchor = new Vector(workArea.X - newBbox.Left, workArea.Y - newBbox.Bottom);
|
||||
p1.Offset(anchor);
|
||||
p2.Offset(anchor);
|
||||
p1.UpdateBounds();
|
||||
p2.UpdateBounds();
|
||||
|
||||
newBbox = ((IEnumerable<IBoundable>)new IBoundable[] { p1, p2 }).GetBoundingBox();
|
||||
return (p1, p2, newBbox);
|
||||
return AnchorToWorkArea(p1, p2);
|
||||
}
|
||||
|
||||
// --- Step 4: Horizontal Repetition ---
|
||||
@@ -322,69 +284,35 @@ namespace OpenNest.Engine.Fill
|
||||
if (column.Count == 0)
|
||||
return column;
|
||||
|
||||
var columnBbox = ((IEnumerable<IBoundable>)column).GetBoundingBox();
|
||||
var columnWidth = columnBbox.Width;
|
||||
var pattern = new Pattern();
|
||||
pattern.Parts.AddRange(column);
|
||||
pattern.UpdateBounds();
|
||||
|
||||
// Create a test column shifted right by columnWidth + spacing.
|
||||
var testOffset = columnWidth + partSpacing;
|
||||
var testColumn = new List<Part>(column.Count);
|
||||
foreach (var part in column)
|
||||
testColumn.Add(part.CloneAtOffset(new Vector(testOffset, 0)));
|
||||
|
||||
// Compact the test column left against the original column.
|
||||
var distanceMoved = Compactor.Push(testColumn, column, workArea, partSpacing, PushDirection.Left);
|
||||
|
||||
// Derive the true copy distance from where the test column ended up.
|
||||
var testBbox = ((IEnumerable<IBoundable>)testColumn).GetBoundingBox();
|
||||
var copyDistance = testBbox.Left - columnBbox.Left;
|
||||
|
||||
if (copyDistance <= Tolerance.Epsilon)
|
||||
copyDistance = columnWidth + partSpacing;
|
||||
|
||||
Debug.WriteLine($"[FillExtents] Column copy distance: {copyDistance:F2} (bbox width: {columnWidth:F2}, spacing: {partSpacing:F2})");
|
||||
|
||||
// Build all columns.
|
||||
var result = new List<Part>(column);
|
||||
|
||||
// Add the test column we already computed as column 2.
|
||||
foreach (var part in testColumn)
|
||||
{
|
||||
if (IsWithinWorkArea(part))
|
||||
result.Add(part);
|
||||
}
|
||||
|
||||
// Tile additional columns at the copy distance.
|
||||
var colIndex = 2;
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
var offset = new Vector(copyDistance * colIndex, 0);
|
||||
var anyFit = false;
|
||||
|
||||
foreach (var part in column)
|
||||
{
|
||||
var clone = part.CloneAtOffset(offset);
|
||||
if (IsWithinWorkArea(clone))
|
||||
{
|
||||
result.Add(clone);
|
||||
anyFit = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyFit)
|
||||
break;
|
||||
|
||||
colIndex++;
|
||||
}
|
||||
|
||||
return result;
|
||||
var linear = new FillLinear(workArea, partSpacing);
|
||||
return linear.Fill(pattern, NestDirection.Horizontal);
|
||||
}
|
||||
|
||||
private bool IsWithinWorkArea(Part part)
|
||||
// --- Helpers ---
|
||||
|
||||
private PartPair? AnchorToWorkArea(Part part1, Part part2)
|
||||
{
|
||||
return part.BoundingBox.Right <= workArea.Right + Tolerance.Epsilon &&
|
||||
part.BoundingBox.Top <= workArea.Top + Tolerance.Epsilon &&
|
||||
part.BoundingBox.Left >= workArea.Left - Tolerance.Epsilon &&
|
||||
part.BoundingBox.Bottom >= workArea.Bottom - Tolerance.Epsilon;
|
||||
var bbox = PairBbox(part1, part2);
|
||||
var anchor = new Vector(workArea.X - bbox.Left, workArea.Y - bbox.Bottom);
|
||||
part1.Offset(anchor);
|
||||
part2.Offset(anchor);
|
||||
part1.UpdateBounds();
|
||||
part2.UpdateBounds();
|
||||
|
||||
bbox = PairBbox(part1, part2);
|
||||
return new PartPair(part1, part2, bbox);
|
||||
}
|
||||
|
||||
private static Box PairBbox(Part part1, Part part2) =>
|
||||
((IEnumerable<IBoundable>)new IBoundable[] { part1, part2 }).GetBoundingBox();
|
||||
|
||||
private static bool HasOverlappingParts(List<Part> parts) =>
|
||||
FillHelpers.HasOverlappingParts(parts);
|
||||
|
||||
private readonly record struct PartPair(Part Part1, Part Part2, Box Bbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenNest.Engine.Fill
|
||||
@@ -10,7 +11,7 @@ namespace OpenNest.Engine.Fill
|
||||
public FillLinear(Box workArea, double partSpacing)
|
||||
{
|
||||
PartSpacing = partSpacing;
|
||||
WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Length);
|
||||
WorkArea = new Box(workArea.X, workArea.Y, workArea.Length, workArea.Width);
|
||||
}
|
||||
|
||||
public Box WorkArea { get; }
|
||||
@@ -19,6 +20,11 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
public double HalfSpacing => PartSpacing / 2;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic label set by callers to identify the engine/context in overlap logs.
|
||||
/// </summary>
|
||||
public string Label { get; set; }
|
||||
|
||||
private static Vector MakeOffset(NestDirection direction, double distance)
|
||||
{
|
||||
return direction == NestDirection.Horizontal
|
||||
@@ -35,7 +41,7 @@ namespace OpenNest.Engine.Fill
|
||||
|
||||
private static double GetDimension(Box box, NestDirection direction)
|
||||
{
|
||||
return direction == NestDirection.Horizontal ? box.Width : box.Length;
|
||||
return direction == NestDirection.Horizontal ? box.Length : box.Width;
|
||||
}
|
||||
|
||||
private static double GetStart(Box box, NestDirection direction)
|
||||
@@ -113,10 +119,11 @@ namespace OpenNest.Engine.Fill
|
||||
var maxCopyDistance = FindMaxPairDistance(
|
||||
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
|
||||
|
||||
if (maxCopyDistance < Tolerance.Epsilon)
|
||||
return bboxDim + PartSpacing;
|
||||
|
||||
return maxCopyDistance;
|
||||
// The copy distance must be at least bboxDim + PartSpacing to prevent
|
||||
// bounding box overlap. Cross-pair slides can underestimate when the
|
||||
// circumscribed polygon boundary overshoots the true arc, creating
|
||||
// spurious contacts between diagonal parts in adjacent copies.
|
||||
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -287,6 +294,74 @@ 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, out int overlapA, out int overlapB)
|
||||
{
|
||||
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 _))
|
||||
{
|
||||
overlapA = i;
|
||||
overlapB = j;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overlapA = -1;
|
||||
overlapB = -1;
|
||||
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 +400,25 @@ namespace OpenNest.Engine.Fill
|
||||
var row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
|
||||
{
|
||||
LogOverlap("Step1-Primary", direction, pattern, row, a1, b1);
|
||||
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, out var a2, out var b2))
|
||||
{
|
||||
LogOverlap("Step1-PerpOnly", perpAxis, pattern, row, a2, b2);
|
||||
row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePatternBbox(pattern, perpAxis));
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -341,9 +431,45 @@ namespace OpenNest.Engine.Fill
|
||||
var gridResult = new List<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
||||
|
||||
if (HasOverlappingParts(gridResult, out var a3, out var b3))
|
||||
{
|
||||
LogOverlap("Step2-Perp", perpAxis, rowPattern, gridResult, a3, b3);
|
||||
gridResult = new List<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePatternBbox(rowPattern, perpAxis));
|
||||
}
|
||||
|
||||
return gridResult;
|
||||
}
|
||||
|
||||
private void LogOverlap(string step, NestDirection tilingDir,
|
||||
Pattern pattern, List<Part> parts, int idxA, int idxB)
|
||||
{
|
||||
var pa = parts[idxA];
|
||||
var pb = parts[idxB];
|
||||
var ba = pa.BoundingBox;
|
||||
var bb = pb.BoundingBox;
|
||||
|
||||
Debug.WriteLine($"[FillLinear] OVERLAP FALLBACK ({Label ?? "unknown"})");
|
||||
Debug.WriteLine($" Step: {step}, TilingDir: {tilingDir}");
|
||||
Debug.WriteLine($" WorkArea: ({WorkArea.X:F4},{WorkArea.Y:F4}) {WorkArea.Width:F4}x{WorkArea.Length:F4}, Spacing: {PartSpacing}");
|
||||
Debug.WriteLine($" Pattern: {pattern.Parts.Count} parts, bbox {pattern.BoundingBox.Width:F4}x{pattern.BoundingBox.Length:F4}");
|
||||
Debug.WriteLine($" Total parts after tiling: {parts.Count}");
|
||||
Debug.WriteLine($" Overlapping pair [{idxA}] vs [{idxB}]:");
|
||||
Debug.WriteLine($" [{idxA}]: drawing={pa.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pa.Rotation):F2}° " +
|
||||
$"loc=({pa.Location.X:F4},{pa.Location.Y:F4}) bbox=({ba.Left:F4},{ba.Bottom:F4})-({ba.Right:F4},{ba.Top:F4})");
|
||||
Debug.WriteLine($" [{idxB}]: drawing={pb.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(pb.Rotation):F2}° " +
|
||||
$"loc=({pb.Location.X:F4},{pb.Location.Y:F4}) bbox=({bb.Left:F4},{bb.Bottom:F4})-({bb.Right:F4},{bb.Top:F4})");
|
||||
|
||||
// Log all pattern seed parts for reproduction
|
||||
Debug.WriteLine($" Pattern seed parts:");
|
||||
for (var i = 0; i < pattern.Parts.Count; i++)
|
||||
{
|
||||
var p = pattern.Parts[i];
|
||||
Debug.WriteLine($" [{i}]: drawing={p.BaseDrawing?.Name ?? "?"} rot={Angle.ToDegrees(p.Rotation):F2}° " +
|
||||
$"loc=({p.Location.X:F4},{p.Location.Y:F4}) bbox={p.BoundingBox.Width:F4}x{p.BoundingBox.Length:F4}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fills a single row of identical parts along one axis using geometry-aware spacing.
|
||||
/// </summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user