Compare commits
52 Commits
v0.1.0
..
a3ae61d993
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ae61d993 | |||
| 838a247ef9 | |||
| a5e5e78c4e | |||
| c386e462b2 | |||
| 2c0457d503 | |||
| b03b3eb4d9 | |||
| 29c2872819 | |||
| 3e96c62f33 | |||
| 6880dee489 | |||
| 0e45c13515 | |||
| 54def611fa | |||
| b1d094104a | |||
| 9d66b78a11 | |||
| eddbbca7ef | |||
| 4e7b5304a0 | |||
| 06485053fc | |||
| 92a57d33df | |||
| 6adc5b0967 | |||
| d215d02844 | |||
| 57863e16e9 | |||
| 091e750e1b | |||
| 87b965f895 | |||
| 08f60690a7 | |||
| a4609c816c | |||
| 5a4272696e | |||
| 2cf03be360 | |||
| 041e184d93 | |||
| 26df3174ea | |||
| 0f5aace126 | |||
| 399f8dda6e | |||
| d921558b9c | |||
| bf3e3e1f42 | |||
| e120ece014 | |||
| 264e8264be | |||
| 24babe353e | |||
| e63be93051 | |||
| ba3c3cbea3 | |||
| 572fa06a21 | |||
| a6c2235647 | |||
| 5c918a0978 | |||
| 92461deb98 | |||
| bc859aa28c | |||
| 09eac96a03 | |||
| df65414a9d | |||
| 4aed231611 | |||
| c641b3b68e | |||
| f3b27c32c3 | |||
| c270d8ea76 | |||
| de6877ac48 | |||
| 3481764416 | |||
| 640814fdf6 | |||
| 6a30828fad |
@@ -211,8 +211,5 @@ FakesAssemblies/
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
docs/superpowers/
|
docs/superpowers/
|
||||||
|
|
||||||
# Documentation (manuals, templates, etc.)
|
|
||||||
docs/
|
|
||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
|
|||||||
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
- `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
|
||||||
- `ProgramReader` — G-code text parser.
|
- `ProgramReader` — G-code text parser.
|
||||||
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
|
||||||
|
- `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers.
|
||||||
|
- `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`.
|
||||||
|
|
||||||
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
### OpenNest.Console (console app, depends on Core + Engine + IO)
|
||||||
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`).
|
||||||
@@ -117,3 +119,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
|||||||
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
|
- `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).
|
- **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.
|
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||||
|
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
|
|
||||||
namespace OpenNest.Api;
|
namespace OpenNest.Api;
|
||||||
@@ -30,15 +28,21 @@ public static class NestRunner
|
|||||||
if (!File.Exists(part.DxfPath))
|
if (!File.Exists(part.DxfPath))
|
||||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||||
|
|
||||||
var geometry = Dxf.GetGeometry(part.DxfPath);
|
Drawing drawing;
|
||||||
if (geometry.Count == 0)
|
try
|
||||||
|
{
|
||||||
|
drawing = CadImporter.ImportDrawing(part.DxfPath,
|
||||||
|
new CadImportOptions { Quantity = part.Quantity });
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Failed to import DXF: {part.DxfPath}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||||
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
throw new InvalidOperationException($"Failed to import DXF: {part.DxfPath}");
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
var name = Path.GetFileNameWithoutExtension(part.DxfPath);
|
|
||||||
var drawing = new Drawing(name);
|
|
||||||
drawing.Program = pgm;
|
|
||||||
drawings.Add(drawing);
|
drawings.Add(drawing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using OpenNest;
|
using OpenNest;
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using System;
|
using System;
|
||||||
@@ -241,25 +240,15 @@ static class NestConsole
|
|||||||
|
|
||||||
static Drawing ImportDxf(string path)
|
static Drawing ImportDxf(string path)
|
||||||
{
|
{
|
||||||
var geometry = Dxf.GetGeometry(path);
|
try
|
||||||
|
|
||||||
if (geometry.Count == 0)
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"Error: failed to read DXF file or no geometry found: {path}");
|
return CadImporter.ImportDrawing(path);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"Error: failed to import DXF '{path}': {ex.Message}");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
|
|
||||||
if (pgm == null)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"Error: failed to convert geometry: {path}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var name = Path.GetFileNameWithoutExtension(path);
|
|
||||||
return new Drawing(name, pgm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void ApplyTemplate(Plate plate, Options options)
|
static void ApplyTemplate(Plate plate, Options options)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace OpenNest.CNC.CuttingStrategy
|
namespace OpenNest.CNC.CuttingStrategy
|
||||||
@@ -11,6 +12,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
||||||
|
|
||||||
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
public CuttingResult Apply(Program partProgram, Vector approachPoint)
|
||||||
|
{
|
||||||
|
return Apply(partProgram, approachPoint, Vector.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CuttingResult Apply(Program partProgram, Vector approachPoint, Vector nextPartStart)
|
||||||
{
|
{
|
||||||
var entities = partProgram.ToGeometry();
|
var entities = partProgram.ToGeometry();
|
||||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||||
@@ -20,25 +26,59 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
var profile = new ShapeProfile(entities);
|
var profile = new ShapeProfile(entities);
|
||||||
|
|
||||||
// Forward pass: sequence cutouts nearest-neighbor from perimeter
|
// Start from the bounding box corner opposite the origin (max X, max Y)
|
||||||
var perimeterPoint = profile.Perimeter.ClosestPointTo(approachPoint, out _);
|
var bbox = entities.GetBoundingBox();
|
||||||
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint);
|
var startCorner = new Vector(bbox.Right, bbox.Top);
|
||||||
|
|
||||||
|
// Initial pass: sequence cutouts from bbox corner
|
||||||
|
var seedPoint = startCorner;
|
||||||
|
var orderedCutouts = SequenceCutouts(profile.Cutouts, seedPoint);
|
||||||
orderedCutouts.Reverse();
|
orderedCutouts.Reverse();
|
||||||
|
|
||||||
// Backward pass: walk from perimeter back through cutting order
|
var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
|
||||||
// so each lead-in faces the next cutout to be cut, not the previous
|
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||||
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterPoint);
|
|
||||||
|
Vector perimeterPt;
|
||||||
|
Entity perimeterEntity;
|
||||||
|
|
||||||
|
if (!double.IsNaN(nextPartStart.X) && cutoutEntries.Count > 0)
|
||||||
|
{
|
||||||
|
// Iterate: each pass refines the perimeter lead-in which changes
|
||||||
|
// the internal sequence which changes the last cutout position
|
||||||
|
for (var iter = 0; iter < 3; iter++)
|
||||||
|
{
|
||||||
|
var lastCutoutPt = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||||
|
perimeterSeed = FindPerimeterIntersection(profile.Perimeter, lastCutoutPt, nextPartStart, out _);
|
||||||
|
|
||||||
|
orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterSeed);
|
||||||
|
orderedCutouts.Reverse();
|
||||||
|
cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalLastCutout = cutoutEntries[cutoutEntries.Count - 1].Point;
|
||||||
|
perimeterPt = FindPerimeterIntersection(profile.Perimeter, finalLastCutout, nextPartStart, out perimeterEntity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var perimeterRef = cutoutEntries.Count > 0 ? cutoutEntries[0].Point : approachPoint;
|
||||||
|
perimeterPt = profile.Perimeter.ClosestPointTo(perimeterRef, out perimeterEntity);
|
||||||
|
}
|
||||||
|
|
||||||
var result = new Program(Mode.Absolute);
|
var result = new Program(Mode.Absolute);
|
||||||
|
|
||||||
EmitScribeContours(result, scribeEntities);
|
EmitScribeContours(result, scribeEntities);
|
||||||
|
|
||||||
foreach (var entry in cutoutEntries)
|
foreach (var entry in cutoutEntries)
|
||||||
|
{
|
||||||
|
if (!entry.Shape.IsClosed())
|
||||||
|
EmitRawContour(result, entry.Shape);
|
||||||
|
else
|
||||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||||
|
}
|
||||||
|
|
||||||
// Perimeter last
|
if (!profile.Perimeter.IsClosed())
|
||||||
var lastRefPoint = cutoutEntries.Count > 0 ? cutoutEntries[cutoutEntries.Count - 1].Point : approachPoint;
|
EmitRawContour(result, profile.Perimeter);
|
||||||
var perimeterPt = profile.Perimeter.ClosestPointTo(lastRefPoint, out var perimeterEntity);
|
else
|
||||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||||
|
|
||||||
result.Mode = Mode.Incremental;
|
result.Mode = Mode.Incremental;
|
||||||
@@ -67,10 +107,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
// Find the target shape that contains the clicked entity
|
// Find the target shape that contains the clicked entity
|
||||||
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||||
|
|
||||||
// Emit cutouts — only the target gets lead-in/out
|
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||||
foreach (var cutout in profile.Cutouts)
|
foreach (var cutout in profile.Cutouts)
|
||||||
{
|
{
|
||||||
if (cutout == targetShape)
|
if (!cutout.IsClosed())
|
||||||
|
{
|
||||||
|
EmitRawContour(result, cutout);
|
||||||
|
}
|
||||||
|
else if (cutout == targetShape)
|
||||||
{
|
{
|
||||||
var ct = DetectContourType(cutout);
|
var ct = DetectContourType(cutout);
|
||||||
EmitContour(result, cutout, point, matchedEntity, ct);
|
EmitContour(result, cutout, point, matchedEntity, ct);
|
||||||
@@ -82,7 +126,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Emit perimeter
|
// Emit perimeter
|
||||||
if (profile.Perimeter == targetShape)
|
if (!profile.Perimeter.IsClosed())
|
||||||
|
{
|
||||||
|
EmitRawContour(result, profile.Perimeter);
|
||||||
|
}
|
||||||
|
else if (profile.Perimeter == targetShape)
|
||||||
{
|
{
|
||||||
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
|
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
|
||||||
}
|
}
|
||||||
@@ -187,6 +235,40 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
return new List<ContourEntry>(entries);
|
return new List<ContourEntry>(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Vector FindPerimeterIntersection(Shape perimeter, Vector lastCutout, Vector nextPartStart, out Entity entity)
|
||||||
|
{
|
||||||
|
var ray = new Line(lastCutout, nextPartStart);
|
||||||
|
|
||||||
|
if (perimeter.Intersects(ray, out var pts) && pts.Count > 0)
|
||||||
|
{
|
||||||
|
// Pick the intersection closest to the last cutout
|
||||||
|
var best = pts[0];
|
||||||
|
var bestDist = best.DistanceTo(lastCutout);
|
||||||
|
|
||||||
|
for (var i = 1; i < pts.Count; i++)
|
||||||
|
{
|
||||||
|
var dist = pts[i].DistanceTo(lastCutout);
|
||||||
|
if (dist < bestDist)
|
||||||
|
{
|
||||||
|
best = pts[i];
|
||||||
|
bestDist = dist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return perimeter.ClosestPointTo(best, out entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: closest point on perimeter to the last cutout
|
||||||
|
return perimeter.ClosestPointTo(lastCutout, out entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ComputeSubProgramKey(double radius, double normalAngle)
|
||||||
|
{
|
||||||
|
var r = System.Math.Round(radius, 6);
|
||||||
|
var a = System.Math.Round(normalAngle, 6);
|
||||||
|
return HashCode.Combine(r, a);
|
||||||
|
}
|
||||||
|
|
||||||
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
|
||||||
{
|
{
|
||||||
var contourType = forceType ?? DetectContourType(shape);
|
var contourType = forceType ?? DetectContourType(shape);
|
||||||
@@ -197,16 +279,62 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
var leadOut = SelectLeadOut(contourType);
|
var leadOut = SelectLeadOut(contourType);
|
||||||
|
|
||||||
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
if (contourType == ContourType.ArcCircle && entity is Circle circle)
|
||||||
|
{
|
||||||
|
if (Parameters.RoundLeadInAngles && Parameters.LeadInAngleIncrement > 0)
|
||||||
|
{
|
||||||
|
var increment = Angle.ToRadians(Parameters.LeadInAngleIncrement);
|
||||||
|
normal = System.Math.Round(normal / increment) * increment;
|
||||||
|
normal = Angle.NormalizeRad(normal);
|
||||||
|
|
||||||
|
var outwardAngle = normal - System.Math.PI;
|
||||||
|
point = new Vector(
|
||||||
|
circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle),
|
||||||
|
circle.Center.Y + circle.Radius * System.Math.Sin(outwardAngle));
|
||||||
|
}
|
||||||
|
|
||||||
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
leadIn = ClampLeadInForCircle(leadIn, circle, point, normal);
|
||||||
|
|
||||||
|
// Build hole sub-program relative to (0,0)
|
||||||
|
var holeCenter = circle.Center;
|
||||||
|
var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y);
|
||||||
|
var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation };
|
||||||
|
var relativeShape = new Shape();
|
||||||
|
relativeShape.Entities.Add(relativeCircle);
|
||||||
|
|
||||||
|
var subPgm = new Program(Mode.Absolute);
|
||||||
|
subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding));
|
||||||
|
var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle);
|
||||||
|
|
||||||
|
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
||||||
|
reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
|
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
|
||||||
|
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
|
||||||
|
subPgm.Mode = Mode.Incremental;
|
||||||
|
|
||||||
|
// Deduplicate: check if an identical sub-program already exists
|
||||||
|
var key = ComputeSubProgramKey(circle.Radius, normal);
|
||||||
|
if (!program.SubPrograms.ContainsKey(key))
|
||||||
|
program.SubPrograms[key] = subPgm;
|
||||||
|
|
||||||
|
program.Codes.Add(new SubProgramCall
|
||||||
|
{
|
||||||
|
Id = key,
|
||||||
|
Program = program.SubPrograms[key],
|
||||||
|
Offset = holeCenter
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
|
||||||
|
|
||||||
var reindexed = shape.ReindexAt(point, entity);
|
var reindexedShape = shape.ReindexAt(point, entity);
|
||||||
|
|
||||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
||||||
reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size);
|
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||||
|
|
||||||
program.Codes.AddRange(ConvertShapeToMoves(reindexed, point));
|
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
|||||||
|
|
||||||
public double PierceClearance { get; set; } = 0.0625;
|
public double PierceClearance { get; set; } = 0.0625;
|
||||||
|
|
||||||
|
public bool RoundLeadInAngles { get; set; }
|
||||||
|
public double LeadInAngleIncrement { get; set; } = 5.0;
|
||||||
|
|
||||||
public double AutoTabMinSize { get; set; }
|
public double AutoTabMinSize { get; set; }
|
||||||
public double AutoTabMaxSize { get; set; }
|
public double AutoTabMaxSize { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public Dictionary<int, Program> SubPrograms { get; } = new();
|
||||||
|
|
||||||
private Mode mode;
|
private Mode mode;
|
||||||
|
|
||||||
public Program(Mode mode = Mode.Absolute)
|
public Program(Mode mode = Mode.Absolute)
|
||||||
@@ -87,6 +89,17 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
|
|
||||||
|
if (subpgm.Offset.X != 0 || subpgm.Offset.Y != 0)
|
||||||
|
{
|
||||||
|
var cos = System.Math.Cos(angle);
|
||||||
|
var sin = System.Math.Sin(angle);
|
||||||
|
var dx = subpgm.Offset.X - origin.X;
|
||||||
|
var dy = subpgm.Offset.Y - origin.Y;
|
||||||
|
subpgm.Offset = new Geometry.Vector(
|
||||||
|
origin.X + dx * cos - dy * sin,
|
||||||
|
origin.Y + dx * sin + dy * cos);
|
||||||
|
}
|
||||||
|
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null)
|
||||||
subpgm.Program.Rotate(angle, origin);
|
subpgm.Program.Rotate(angle, origin);
|
||||||
}
|
}
|
||||||
@@ -275,6 +288,10 @@ namespace OpenNest.CNC
|
|||||||
|
|
||||||
private Box BoundingBox(ref Vector pos)
|
private Box BoundingBox(ref Vector pos)
|
||||||
{
|
{
|
||||||
|
// Capture the frame origin at entry. Sub-program Offsets and
|
||||||
|
// absolute-mode endpoints are relative to this fixed origin.
|
||||||
|
var frameOrigin = pos;
|
||||||
|
|
||||||
double minX = 0.0;
|
double minX = 0.0;
|
||||||
double minY = 0.0;
|
double minY = 0.0;
|
||||||
double maxX = 0.0;
|
double maxX = 0.0;
|
||||||
@@ -290,7 +307,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var line = (LinearMove)code;
|
var line = (LinearMove)code;
|
||||||
var pt = Mode == Mode.Absolute ?
|
var pt = Mode == Mode.Absolute ?
|
||||||
line.EndPoint :
|
frameOrigin + line.EndPoint :
|
||||||
line.EndPoint + pos;
|
line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
@@ -312,7 +329,7 @@ namespace OpenNest.CNC
|
|||||||
{
|
{
|
||||||
var line = (RapidMove)code;
|
var line = (RapidMove)code;
|
||||||
var pt = Mode == Mode.Absolute
|
var pt = Mode == Mode.Absolute
|
||||||
? line.EndPoint
|
? frameOrigin + line.EndPoint
|
||||||
: line.EndPoint + pos;
|
: line.EndPoint + pos;
|
||||||
|
|
||||||
if (pt.X > maxX)
|
if (pt.X > maxX)
|
||||||
@@ -345,8 +362,8 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
endpt = arc.EndPoint;
|
endpt = frameOrigin + arc.EndPoint;
|
||||||
centerpt = arc.CenterPoint;
|
centerpt = frameOrigin + arc.CenterPoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
double minX1;
|
double minX1;
|
||||||
@@ -420,6 +437,12 @@ namespace OpenNest.CNC
|
|||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
|
if (subpgm.Program == null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Sub-program frame origin in this program's frame
|
||||||
|
// is frameOrigin + Offset, regardless of current pos.
|
||||||
|
pos = frameOrigin + subpgm.Offset;
|
||||||
var box = subpgm.Program.BoundingBox(ref pos);
|
var box = subpgm.Program.BoundingBox(ref pos);
|
||||||
|
|
||||||
if (box.Left < minX)
|
if (box.Left < minX)
|
||||||
@@ -460,6 +483,9 @@ namespace OpenNest.CNC
|
|||||||
foreach (var kvp in Variables)
|
foreach (var kvp in Variables)
|
||||||
pgm.Variables[kvp.Key] = kvp.Value;
|
pgm.Variables[kvp.Key] = kvp.Value;
|
||||||
|
|
||||||
|
foreach (var kvp in SubPrograms)
|
||||||
|
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
|
||||||
|
|
||||||
return pgm;
|
return pgm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using OpenNest.Math;
|
using System.Text;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Math;
|
||||||
|
|
||||||
namespace OpenNest.CNC
|
namespace OpenNest.CNC
|
||||||
{
|
{
|
||||||
@@ -35,6 +37,12 @@ namespace OpenNest.CNC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the offset (position) at which the sub-program is executed.
|
||||||
|
/// For hole sub-programs, this is the hole center.
|
||||||
|
/// </summary>
|
||||||
|
public Vector Offset { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the rotation of the program in degrees.
|
/// Gets or sets the rotation of the program in degrees.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -78,12 +86,18 @@ namespace OpenNest.CNC
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public ICode Clone()
|
public ICode Clone()
|
||||||
{
|
{
|
||||||
return new SubProgramCall(program, Rotation);
|
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return string.Format("G65 P{0} R{1}", Id, Rotation);
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"G65 P{Id}");
|
||||||
|
if (Offset.X != 0 || Offset.Y != 0)
|
||||||
|
sb.Append($" X{Offset.X} Y{Offset.Y}");
|
||||||
|
if (Rotation != 0)
|
||||||
|
sb.Append($" R{Rotation}");
|
||||||
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Converters
|
namespace OpenNest.Converters
|
||||||
@@ -9,7 +9,6 @@ namespace OpenNest.Converters
|
|||||||
/// Converts the program to absolute coordinates.
|
/// Converts the program to absolute coordinates.
|
||||||
/// Does NOT check program mode before converting.
|
/// Does NOT check program mode before converting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pgm"></param>
|
|
||||||
public static void ToAbsolute(Program pgm)
|
public static void ToAbsolute(Program pgm)
|
||||||
{
|
{
|
||||||
var pos = new Vector(0, 0);
|
var pos = new Vector(0, 0);
|
||||||
@@ -17,21 +16,27 @@ namespace OpenNest.Converters
|
|||||||
for (int i = 0; i < pgm.Codes.Count; ++i)
|
for (int i = 0; i < pgm.Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
var code = pgm.Codes[i];
|
var code = pgm.Codes[i];
|
||||||
var motion = code as Motion;
|
|
||||||
|
|
||||||
if (motion != null)
|
if (code is SubProgramCall subCall && subCall.Program != null)
|
||||||
{
|
{
|
||||||
motion.Offset(pos);
|
// Sub-program is placed at Offset in this program's frame.
|
||||||
|
// After it runs, the tool is at Offset + (sub's end in its own frame).
|
||||||
|
pos = ComputeEndPosition(subCall.Program, subCall.Offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code is Motion motion)
|
||||||
|
{
|
||||||
|
motion.Offset(pos.X, pos.Y);
|
||||||
pos = motion.EndPoint;
|
pos = motion.EndPoint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts the program to intermental coordinates.
|
/// Converts the program to incremental coordinates.
|
||||||
/// Does NOT check program mode before converting.
|
/// Does NOT check program mode before converting.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pgm"></param>
|
|
||||||
public static void ToIncremental(Program pgm)
|
public static void ToIncremental(Program pgm)
|
||||||
{
|
{
|
||||||
var pos = new Vector(0, 0);
|
var pos = new Vector(0, 0);
|
||||||
@@ -39,9 +44,16 @@ namespace OpenNest.Converters
|
|||||||
for (int i = 0; i < pgm.Codes.Count; ++i)
|
for (int i = 0; i < pgm.Codes.Count; ++i)
|
||||||
{
|
{
|
||||||
var code = pgm.Codes[i];
|
var code = pgm.Codes[i];
|
||||||
var motion = code as Motion;
|
|
||||||
|
|
||||||
if (motion != null)
|
if (code is SubProgramCall subCall && subCall.Program != null)
|
||||||
|
{
|
||||||
|
// Sub-program is placed at Offset in this program's frame,
|
||||||
|
// regardless of where the tool was before the call.
|
||||||
|
pos = ComputeEndPosition(subCall.Program, subCall.Offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code is Motion motion)
|
||||||
{
|
{
|
||||||
var pos2 = motion.EndPoint;
|
var pos2 = motion.EndPoint;
|
||||||
motion.Offset(-pos.X, -pos.Y);
|
motion.Offset(-pos.X, -pos.Y);
|
||||||
@@ -49,5 +61,37 @@ namespace OpenNest.Converters
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the tool position after executing <paramref name="pgm"/>,
|
||||||
|
/// given that the program's frame origin is at <paramref name="startPos"/>
|
||||||
|
/// in the caller's frame. Walks nested sub-program calls recursively.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector ComputeEndPosition(Program pgm, Vector startPos)
|
||||||
|
{
|
||||||
|
var pos = startPos;
|
||||||
|
|
||||||
|
for (int i = 0; i < pgm.Codes.Count; ++i)
|
||||||
|
{
|
||||||
|
var code = pgm.Codes[i];
|
||||||
|
|
||||||
|
if (code is SubProgramCall subCall && subCall.Program != null)
|
||||||
|
{
|
||||||
|
// Nested sub's frame origin in the caller's frame is startPos + Offset.
|
||||||
|
pos = ComputeEndPosition(subCall.Program, startPos + subCall.Offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code is Motion motion)
|
||||||
|
{
|
||||||
|
if (pgm.Mode == Mode.Incremental)
|
||||||
|
pos = pos + motion.EndPoint;
|
||||||
|
else
|
||||||
|
pos = startPos + motion.EndPoint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
private static void AddProgram(Program program, ref Mode mode, ref Vector curpos, ref List<Entity> geometry)
|
||||||
{
|
{
|
||||||
|
// Capture the frame origin at entry. Sub-program Offsets are relative
|
||||||
|
// to this fixed origin, not to the current tool position.
|
||||||
|
var frameOrigin = curpos;
|
||||||
mode = program.Mode;
|
mode = program.Mode;
|
||||||
|
|
||||||
for (int i = 0; i < program.Length; ++i)
|
for (int i = 0; i < program.Length; ++i)
|
||||||
@@ -41,12 +44,15 @@ namespace OpenNest.Converters
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
var tmpmode = mode;
|
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
var geoProgram = new Shape();
|
var savedMode = mode;
|
||||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geoProgram.Entities);
|
|
||||||
geometry.Add(geoProgram);
|
// The sub-program's frame origin in this program's frame is
|
||||||
mode = tmpmode;
|
// frameOrigin + Offset — independent of current tool position.
|
||||||
|
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
||||||
|
|
||||||
|
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
|
||||||
|
mode = savedMode;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
|
get { return Diameter * System.Math.PI * SweepAngle() / Angle.TwoPI; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Arc(center, radius, startAngle, endAngle, reversed);
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction.
|
/// Reverses the rotation direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Circumference(); }
|
get { return Circumference(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Circle(center, radius) { Rotation = Rotation };
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction.
|
/// Reverses the rotation direction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -251,6 +251,23 @@ namespace OpenNest.Geometry
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public abstract bool Intersects(Shape shape, out List<Vector> pts);
|
public abstract bool Intersects(Shape shape, out List<Vector> pts);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a deep copy of the entity with a new Id.
|
||||||
|
/// </summary>
|
||||||
|
public abstract Entity Clone();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies common Entity properties from this instance to the target.
|
||||||
|
/// </summary>
|
||||||
|
protected void CopyBaseTo(Entity target)
|
||||||
|
{
|
||||||
|
target.Color = Color;
|
||||||
|
target.Layer = Layer;
|
||||||
|
target.LineTypeName = LineTypeName;
|
||||||
|
target.IsVisible = IsVisible;
|
||||||
|
target.Tag = Tag;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Type of entity.
|
/// Type of entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -259,6 +276,14 @@ namespace OpenNest.Geometry
|
|||||||
|
|
||||||
public static class EntityExtensions
|
public static class EntityExtensions
|
||||||
{
|
{
|
||||||
|
public static List<Entity> CloneAll(this IEnumerable<Entity> entities)
|
||||||
|
{
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var e in entities)
|
||||||
|
result.Add(e.Clone());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
public static List<Vector> CollectPoints(this IEnumerable<Entity> entities)
|
||||||
{
|
{
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
|
|||||||
@@ -257,6 +257,13 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Line(pt1, pt2);
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reversed the line.
|
/// Reversed the line.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
|
|||||||
get { return Perimeter(); }
|
get { return Perimeter(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction of the polygon.
|
/// Reverses the rotation direction of the polygon.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
|
|||||||
return polygon;
|
return polygon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Entity Clone()
|
||||||
|
{
|
||||||
|
var copy = new Shape();
|
||||||
|
foreach (var e in Entities)
|
||||||
|
copy.Entities.Add(e.Clone());
|
||||||
|
CopyBaseTo(copy);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reverses the rotation direction of the shape.
|
/// Reverses the rotation direction of the shape.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
public static List<Entity> NormalizeEntities(IEnumerable<Entity> entities)
|
||||||
{
|
{
|
||||||
var profile = new ShapeProfile(entities.ToList());
|
var cloned = entities.CloneAll();
|
||||||
|
var profile = new ShapeProfile(cloned);
|
||||||
return profile.ToNormalizedEntities();
|
return profile.ToNormalizedEntities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,50 +306,39 @@ namespace OpenNest.Geometry
|
|||||||
var minDist = double.MaxValue;
|
var minDist = double.MaxValue;
|
||||||
var vx = vertex.X;
|
var vx = vertex.X;
|
||||||
var vy = vertex.Y;
|
var vy = vertex.Y;
|
||||||
|
var horizontal = IsHorizontalDirection(direction);
|
||||||
|
|
||||||
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
|
// Pruning: edges are sorted by their perpendicular min-coordinate.
|
||||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
// For horizontal push, prune by Y range; for vertical push, prune by X range.
|
||||||
{
|
|
||||||
for (var i = 0; i < edges.Length; i++)
|
for (var i = 0; i < edges.Length; i++)
|
||||||
{
|
{
|
||||||
var e1 = edges[i].start + edgeOffset;
|
var e1 = edges[i].start + edgeOffset;
|
||||||
var e2 = edges[i].end + edgeOffset;
|
var e2 = edges[i].end + edgeOffset;
|
||||||
|
|
||||||
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
|
double perpValue, edgeMin, edgeMax;
|
||||||
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
|
if (horizontal)
|
||||||
|
{
|
||||||
|
perpValue = vy;
|
||||||
|
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||||
|
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
perpValue = vx;
|
||||||
|
edgeMin = e1.X < e2.X ? e1.X : e2.X;
|
||||||
|
edgeMax = e1.X > e2.X ? e1.X : e2.X;
|
||||||
|
}
|
||||||
|
|
||||||
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
|
// Since edges are sorted by edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
|
||||||
if (vy < minY - Tolerance.Epsilon)
|
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
if (vy > maxY + Tolerance.Epsilon)
|
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||||
if (d < minDist) minDist = d;
|
if (d < minDist) minDist = d;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else // Up/Down
|
|
||||||
{
|
|
||||||
for (var i = 0; i < edges.Length; i++)
|
|
||||||
{
|
|
||||||
var e1 = edges[i].start + edgeOffset;
|
|
||||||
var e2 = edges[i].end + edgeOffset;
|
|
||||||
|
|
||||||
var minX = e1.X < e2.X ? e1.X : e2.X;
|
|
||||||
var maxX = e1.X > e2.X ? e1.X : e2.X;
|
|
||||||
|
|
||||||
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
|
|
||||||
if (vx < minX - Tolerance.Epsilon)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (vx > maxX + Tolerance.Epsilon)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
|
||||||
if (d < minDist) minDist = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDist;
|
return minDist;
|
||||||
}
|
}
|
||||||
@@ -642,22 +631,49 @@ namespace OpenNest.Geometry
|
|||||||
{
|
{
|
||||||
for (var i = 0; i < arcEntities.Count; i++)
|
for (var i = 0; i < arcEntities.Count; i++)
|
||||||
{
|
{
|
||||||
if (arcEntities[i] is Arc arc)
|
if (arcEntities[i] is not Arc arc)
|
||||||
{
|
continue;
|
||||||
|
|
||||||
|
var cx = arc.Center.X;
|
||||||
|
var cy = arc.Center.Y;
|
||||||
|
var r = arc.Radius;
|
||||||
|
|
||||||
for (var j = 0; j < lineEntities.Count; j++)
|
for (var j = 0; j < lineEntities.Count; j++)
|
||||||
{
|
{
|
||||||
if (lineEntities[j] is Line line)
|
if (lineEntities[j] is not Line line)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var p1x = line.pt1.X;
|
||||||
|
var p1y = line.pt1.Y;
|
||||||
|
var ex = line.pt2.X - p1x;
|
||||||
|
var ey = line.pt2.Y - p1y;
|
||||||
|
|
||||||
|
var det = ex * dirY - ey * dirX;
|
||||||
|
if (System.Math.Abs(det) < Tolerance.Epsilon)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// The directional distance from an arc point at angle θ to the
|
||||||
|
// line is t(θ) = [A + r·(ey·cosθ − ex·sinθ)] / det.
|
||||||
|
// dt/dθ = 0 at θ = atan2(−ex, ey) and θ + π.
|
||||||
|
var theta1 = Angle.NormalizeRad(System.Math.Atan2(-ex, ey));
|
||||||
|
var theta2 = Angle.NormalizeRad(theta1 + System.Math.PI);
|
||||||
|
|
||||||
|
for (var k = 0; k < 2; k++)
|
||||||
{
|
{
|
||||||
var linePt = line.ClosestPointTo(arc.Center);
|
var theta = k == 0 ? theta1 : theta2;
|
||||||
var arcPt = arc.ClosestPointTo(linePt);
|
|
||||||
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
|
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
continue;
|
||||||
|
|
||||||
|
var qx = cx + r * System.Math.Cos(theta);
|
||||||
|
var qy = cy + r * System.Math.Sin(theta);
|
||||||
|
|
||||||
|
var d = RayEdgeDistance(qx, qy, p1x, p1y, line.pt2.X, line.pt2.Y,
|
||||||
dirX, dirY);
|
dirX, dirY);
|
||||||
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return minDist;
|
return minDist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,10 +62,15 @@ namespace OpenNest
|
|||||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||||
|
|
||||||
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint)
|
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint)
|
||||||
|
{
|
||||||
|
ApplyLeadIns(parameters, approachPoint, Geometry.Vector.Invalid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ApplyLeadIns(CNC.CuttingStrategy.CuttingParameters parameters, Vector approachPoint, Vector nextPartStart)
|
||||||
{
|
{
|
||||||
preLeadInRotation = Rotation;
|
preLeadInRotation = Rotation;
|
||||||
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
||||||
var result = strategy.Apply(Program, approachPoint);
|
var result = strategy.Apply(Program, approachPoint, nextPartStart);
|
||||||
Program = result.Program;
|
Program = result.Program;
|
||||||
CuttingParameters = parameters;
|
CuttingParameters = parameters;
|
||||||
HasManualLeadIns = true;
|
HasManualLeadIns = true;
|
||||||
|
|||||||
@@ -126,20 +126,10 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
var result = new List<Entity>(source.Count);
|
var result = new List<Entity>(source.Count);
|
||||||
|
|
||||||
for (var i = 0; i < source.Count; i++)
|
foreach (var entity in source)
|
||||||
{
|
{
|
||||||
var entity = source[i];
|
var copy = entity.Clone();
|
||||||
Entity copy;
|
copy.Offset(location);
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
result.Add(copy);
|
result.Add(copy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using OpenNest.Collections;
|
using OpenNest.Collections;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -548,6 +549,65 @@ namespace OpenNest
|
|||||||
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
Rounding.RoundUpToNearest(xExtent, roundingFactor));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sizes the plate using the <see cref="PlateSizes"/> catalog: small
|
||||||
|
/// layouts snap to an increment, larger ones round up to the next
|
||||||
|
/// standard mill sheet. The plate's long-axis orientation (X vs Y)
|
||||||
|
/// is preserved. Does nothing if the plate has no parts.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
if (Parts.Count == 0)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
var bounds = Parts.GetBoundingBox();
|
||||||
|
|
||||||
|
// Quadrant-aware extents relative to the plate origin, matching AutoSize.
|
||||||
|
double xExtent;
|
||||||
|
double yExtent;
|
||||||
|
|
||||||
|
switch (Quadrant)
|
||||||
|
{
|
||||||
|
case 1:
|
||||||
|
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
|
||||||
|
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
|
||||||
|
yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
|
||||||
|
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
|
||||||
|
yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlateSizes.Recommend takes (short, long); canonicalize then map
|
||||||
|
// the result back so the plate's long axis stays aligned with the
|
||||||
|
// parts' long axis.
|
||||||
|
var shortDim = System.Math.Min(xExtent, yExtent);
|
||||||
|
var longDim = System.Math.Max(xExtent, yExtent);
|
||||||
|
var result = PlateSizes.Recommend(shortDim, longDim, options);
|
||||||
|
|
||||||
|
// Plate convention: Length = X axis, Width = Y axis.
|
||||||
|
if (xExtent >= yExtent)
|
||||||
|
Size = new Size(result.Width, result.Length); // X is the long axis
|
||||||
|
else
|
||||||
|
Size = new Size(result.Length, result.Width); // Y is the long axis
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the area of the top surface of the plate.
|
/// Gets the area of the top surface of the plate.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -3,31 +3,33 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace OpenNest.Shapes
|
namespace OpenNest.Shapes
|
||||||
{
|
{
|
||||||
public class FlangeShape : ShapeDefinition
|
public class PipeFlangeShape : ShapeDefinition
|
||||||
{
|
{
|
||||||
public double NominalPipeSize { get; set; }
|
|
||||||
public double OD { get; set; }
|
public double OD { get; set; }
|
||||||
public double HoleDiameter { get; set; }
|
public double HoleDiameter { get; set; }
|
||||||
public double HolePatternDiameter { get; set; }
|
public double HolePatternDiameter { get; set; }
|
||||||
public int HoleCount { get; set; }
|
public int HoleCount { get; set; }
|
||||||
|
public string PipeSize { get; set; }
|
||||||
|
public double PipeClearance { get; set; }
|
||||||
|
public bool Blind { get; set; }
|
||||||
|
|
||||||
public override void SetPreviewDefaults()
|
public override void SetPreviewDefaults()
|
||||||
{
|
{
|
||||||
NominalPipeSize = 2;
|
|
||||||
OD = 7.5;
|
OD = 7.5;
|
||||||
HoleDiameter = 0.875;
|
HoleDiameter = 0.875;
|
||||||
HolePatternDiameter = 5.5;
|
HolePatternDiameter = 5.5;
|
||||||
HoleCount = 8;
|
HoleCount = 8;
|
||||||
|
PipeSize = "2";
|
||||||
|
PipeClearance = 0.0625;
|
||||||
|
Blind = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Drawing GetDrawing()
|
public override Drawing GetDrawing()
|
||||||
{
|
{
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
|
|
||||||
// Outer circle
|
|
||||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||||
|
|
||||||
// Bolt holes evenly spaced on the bolt circle
|
|
||||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||||
var holeRadius = HoleDiameter / 2.0;
|
var holeRadius = HoleDiameter / 2.0;
|
||||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||||
@@ -40,6 +42,12 @@ namespace OpenNest.Shapes
|
|||||||
entities.Add(new Circle(cx, cy, holeRadius));
|
entities.Add(new Circle(cx, cy, holeRadius));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Blind && !string.IsNullOrEmpty(PipeSize) && PipeSizes.TryGetOD(PipeSize, out var pipeOD))
|
||||||
|
{
|
||||||
|
var boreDiameter = pipeOD + PipeClearance;
|
||||||
|
entities.Add(new Circle(0, 0, boreDiameter / 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
return CreateDrawing(entities);
|
return CreateDrawing(entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
public static class PipeSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double OuterDiameter);
|
||||||
|
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new Entry("1/8", 0.405),
|
||||||
|
new Entry("1/4", 0.540),
|
||||||
|
new Entry("3/8", 0.675),
|
||||||
|
new Entry("1/2", 0.840),
|
||||||
|
new Entry("3/4", 1.050),
|
||||||
|
new Entry("1", 1.315),
|
||||||
|
new Entry("1 1/4", 1.660),
|
||||||
|
new Entry("1 1/2", 1.900),
|
||||||
|
new Entry("2", 2.375),
|
||||||
|
new Entry("2 1/2", 2.875),
|
||||||
|
new Entry("3", 3.500),
|
||||||
|
new Entry("3 1/2", 4.000),
|
||||||
|
new Entry("4", 4.500),
|
||||||
|
new Entry("4 1/2", 5.000),
|
||||||
|
new Entry("5", 5.563),
|
||||||
|
new Entry("6", 6.625),
|
||||||
|
new Entry("7", 7.625),
|
||||||
|
new Entry("8", 8.625),
|
||||||
|
new Entry("9", 9.625),
|
||||||
|
new Entry("10", 10.750),
|
||||||
|
new Entry("11", 11.750),
|
||||||
|
new Entry("12", 12.750),
|
||||||
|
new Entry("14", 14.000),
|
||||||
|
new Entry("16", 16.000),
|
||||||
|
new Entry("18", 18.000),
|
||||||
|
new Entry("20", 20.000),
|
||||||
|
new Entry("24", 24.000),
|
||||||
|
new Entry("26", 26.000),
|
||||||
|
new Entry("28", 28.000),
|
||||||
|
new Entry("30", 30.000),
|
||||||
|
new Entry("32", 32.000),
|
||||||
|
new Entry("34", 34.000),
|
||||||
|
new Entry("36", 36.000),
|
||||||
|
new Entry("42", 42.000),
|
||||||
|
new Entry("48", 48.000),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool TryGetOD(string label, out double outerDiameter)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.Label == label)
|
||||||
|
{
|
||||||
|
outerDiameter = entry.OuterDiameter;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outerDiameter = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all pipe sizes whose outer diameter is less than or equal to <paramref name="maxOD"/>.
|
||||||
|
/// The bound is inclusive.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<Entry> GetFittingSizes(double maxOD)
|
||||||
|
{
|
||||||
|
foreach (var entry in All)
|
||||||
|
{
|
||||||
|
if (entry.OuterDiameter <= maxOD)
|
||||||
|
{
|
||||||
|
yield return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Shapes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Catalog of standard mill sheet sizes (inches) with helpers for matching
|
||||||
|
/// a bounding box to a recommended plate size. Uses the project-wide
|
||||||
|
/// (Width, Length) convention where Width is the short dimension and
|
||||||
|
/// Length is the long dimension.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlateSizes
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Label, double Width, double Length)
|
||||||
|
{
|
||||||
|
public double Area => Width * Length;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if a part of the given dimensions fits within this entry
|
||||||
|
/// in either orientation.
|
||||||
|
/// </summary>
|
||||||
|
public bool Fits(double width, double length) =>
|
||||||
|
(width <= Width && length <= Length) || (width <= Length && length <= Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard mill sheet sizes (inches), sorted by area ascending.
|
||||||
|
/// Canonical orientation: Width <= Length.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<Entry> All { get; } = new[]
|
||||||
|
{
|
||||||
|
new Entry("48x96", 48, 96), // 4608
|
||||||
|
new Entry("48x120", 48, 120), // 5760
|
||||||
|
new Entry("48x144", 48, 144), // 6912
|
||||||
|
new Entry("60x120", 60, 120), // 7200
|
||||||
|
new Entry("60x144", 60, 144), // 8640
|
||||||
|
new Entry("72x120", 72, 120), // 8640
|
||||||
|
new Entry("72x144", 72, 144), // 10368
|
||||||
|
new Entry("96x240", 96, 240), // 23040
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a standard size by label. Case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
public static bool TryGet(string label, out Entry entry)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
foreach (var candidate in All)
|
||||||
|
{
|
||||||
|
if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
entry = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the given bounding box. The box's
|
||||||
|
/// spatial axes are normalized to (short, long) so neither the bbox
|
||||||
|
/// orientation nor Box's internal Length/Width naming matters.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
var a = bbox.Width;
|
||||||
|
var b = bbox.Length;
|
||||||
|
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for the envelope of the given boxes.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(IEnumerable<Box> boxes, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
if (boxes == null)
|
||||||
|
throw new ArgumentNullException(nameof(boxes));
|
||||||
|
|
||||||
|
var hasAny = false;
|
||||||
|
var minX = double.PositiveInfinity;
|
||||||
|
var minY = double.PositiveInfinity;
|
||||||
|
var maxX = double.NegativeInfinity;
|
||||||
|
var maxY = double.NegativeInfinity;
|
||||||
|
|
||||||
|
foreach (var box in boxes)
|
||||||
|
{
|
||||||
|
hasAny = true;
|
||||||
|
if (box.Left < minX) minX = box.Left;
|
||||||
|
if (box.Bottom < minY) minY = box.Bottom;
|
||||||
|
if (box.Right > maxX) maxX = box.Right;
|
||||||
|
if (box.Top > maxY) maxY = box.Top;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAny)
|
||||||
|
throw new ArgumentException("At least one box is required.", nameof(boxes));
|
||||||
|
|
||||||
|
var b = maxX - minX;
|
||||||
|
var a = maxY - minY;
|
||||||
|
return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recommends a plate size for a (width, length) pair.
|
||||||
|
/// Inputs are treated as orientation-independent.
|
||||||
|
/// </summary>
|
||||||
|
public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null)
|
||||||
|
{
|
||||||
|
options ??= new PlateSizeOptions();
|
||||||
|
|
||||||
|
var w = width + 2 * options.Margin;
|
||||||
|
var l = length + 2 * options.Margin;
|
||||||
|
|
||||||
|
// Canonicalize (short, long) — Fits handles rotation anyway, but
|
||||||
|
// normalizing lets the below-min comparison use the narrower
|
||||||
|
// MinSheet dimensions consistently.
|
||||||
|
if (w > l)
|
||||||
|
(w, l) = (l, w);
|
||||||
|
|
||||||
|
// Below full-sheet threshold: snap each dimension up to the nearest increment.
|
||||||
|
if (w <= options.MinSheetWidth && l <= options.MinSheetLength)
|
||||||
|
return SnapResult(w, l, options.SnapIncrement);
|
||||||
|
|
||||||
|
var catalog = BuildCatalog(options.AllowedSizes);
|
||||||
|
|
||||||
|
var best = PickBest(catalog, w, l, options.Selection);
|
||||||
|
if (best.HasValue)
|
||||||
|
return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label);
|
||||||
|
|
||||||
|
// Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet).
|
||||||
|
return SnapResult(w, l, options.SnapIncrement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateSizeResult SnapResult(double width, double length, double increment)
|
||||||
|
{
|
||||||
|
if (increment <= 0)
|
||||||
|
return new PlateSizeResult(width, length, null);
|
||||||
|
|
||||||
|
return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SnapUp(double value, double increment)
|
||||||
|
{
|
||||||
|
var steps = System.Math.Ceiling(value / increment);
|
||||||
|
return steps * increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<Entry> BuildCatalog(IReadOnlyList<string> allowedSizes)
|
||||||
|
{
|
||||||
|
if (allowedSizes == null || allowedSizes.Count == 0)
|
||||||
|
return All;
|
||||||
|
|
||||||
|
var result = new List<Entry>(allowedSizes.Count);
|
||||||
|
foreach (var label in allowedSizes)
|
||||||
|
{
|
||||||
|
if (TryParseEntry(label, out var entry))
|
||||||
|
result.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseEntry(string label, out Entry entry)
|
||||||
|
{
|
||||||
|
if (TryGet(label, out entry))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100").
|
||||||
|
if (!string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
var parts = label.Split(new[] { 'x', 'X' }, 2);
|
||||||
|
if (parts.Length == 2
|
||||||
|
&& double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a)
|
||||||
|
&& double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b)
|
||||||
|
&& a > 0 && b > 0)
|
||||||
|
{
|
||||||
|
var width = System.Math.Min(a, b);
|
||||||
|
var length = System.Math.Max(a, b);
|
||||||
|
entry = new Entry(label.Trim(), width, length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Entry? PickBest(IReadOnlyList<Entry> catalog, double width, double length, PlateSizeSelection selection)
|
||||||
|
{
|
||||||
|
var fitting = catalog.Where(e => e.Fits(width, length));
|
||||||
|
|
||||||
|
fitting = selection switch
|
||||||
|
{
|
||||||
|
PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area),
|
||||||
|
_ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var candidate in fitting)
|
||||||
|
return candidate;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel)
|
||||||
|
{
|
||||||
|
public bool IsStandard => MatchedLabel != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PlateSizeOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength
|
||||||
|
/// the result is snapped to <see cref="SnapIncrement"/> instead of routed to a
|
||||||
|
/// standard sheet. Default 48" x 48".
|
||||||
|
/// </summary>
|
||||||
|
public double MinSheetWidth { get; set; } = 48;
|
||||||
|
public double MinSheetLength { get; set; } = 48;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Increment used for below-threshold rounding and oversize fallback. Default 1".
|
||||||
|
/// </summary>
|
||||||
|
public double SnapIncrement { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extra clearance added to each side of the bounding box before matching.
|
||||||
|
/// </summary>
|
||||||
|
public double Margin { get; set; } = 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional whitelist. When non-empty, only these sizes are considered.
|
||||||
|
/// Entries may be standard catalog labels (e.g. "48x96") or arbitrary
|
||||||
|
/// "WxL" strings (e.g. "50x100").
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> AllowedSizes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tiebreaker when multiple sheets can contain the bounding box.
|
||||||
|
/// </summary>
|
||||||
|
public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum PlateSizeSelection
|
||||||
|
{
|
||||||
|
/// <summary>Pick the cheapest sheet that contains the bbox (smallest area).</summary>
|
||||||
|
SmallestArea,
|
||||||
|
/// <summary>Prefer narrower-width sheets (e.g. 48-wide before 60-wide).</summary>
|
||||||
|
NarrowestFirst,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,20 @@ public static class DrawingSplitter
|
|||||||
var regions = BuildClipRegions(sortedLines, bounds);
|
var regions = BuildClipRegions(sortedLines, bounds);
|
||||||
var feature = GetFeature(parameters.Type);
|
var feature = GetFeature(parameters.Type);
|
||||||
|
|
||||||
|
// Polygonize cutouts once. Used for trimming feature edges (so cut lines
|
||||||
|
// don't travel through a cutout interior) and for hole/containment tests
|
||||||
|
// in the final component-assembly pass.
|
||||||
|
var cutoutPolygons = profile.Cutouts
|
||||||
|
.Select(c => c.ToPolygon())
|
||||||
|
.Where(p => p != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var results = new List<Drawing>();
|
var results = new List<Drawing>();
|
||||||
var pieceIndex = 1;
|
var pieceIndex = 1;
|
||||||
|
|
||||||
foreach (var region in regions)
|
foreach (var region in regions)
|
||||||
{
|
{
|
||||||
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
|
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons);
|
||||||
if (pieceEntities.Count == 0)
|
if (pieceEntities.Count == 0)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -47,10 +55,17 @@ public static class DrawingSplitter
|
|||||||
allEntities.AddRange(pieceEntities);
|
allEntities.AddRange(pieceEntities);
|
||||||
allEntities.AddRange(cutoutEntities);
|
allEntities.AddRange(cutoutEntities);
|
||||||
|
|
||||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
// A single region may yield multiple physically-disjoint pieces when an
|
||||||
|
// interior cutout spans across it. Group the region's entities into
|
||||||
|
// connected closed loops, nest holes by containment, and emit one
|
||||||
|
// Drawing per outer loop (with its contained holes).
|
||||||
|
foreach (var pieceOfRegion in AssemblePieces(allEntities))
|
||||||
|
{
|
||||||
|
var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region);
|
||||||
results.Add(piece);
|
results.Add(piece);
|
||||||
pieceIndex++;
|
pieceIndex++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
@@ -218,98 +233,106 @@ public static class DrawingSplitter
|
|||||||
/// and stitching in feature edges. No polygon clipping library needed.
|
/// and stitching in feature edges. No polygon clipping library needed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in perimeter.Entities)
|
foreach (var entity in perimeter.Entities)
|
||||||
{
|
ProcessEntity(entity, region, entities);
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entities.Count == 0)
|
if (entities.Count == 0)
|
||||||
return new List<Entity>();
|
return new List<Entity>();
|
||||||
|
|
||||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||||
EnsurePerimeterWinding(entities);
|
// Winding is handled later in AssemblePieces, once connected components
|
||||||
|
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ProcessEntity(Entity entity, Box region,
|
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||||
List<SplitLine> boundarySplitLines, List<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
|
||||||
// Find the first boundary split line this entity crosses
|
|
||||||
SplitLine crossedLine = null;
|
|
||||||
Vector? intersectionPt = null;
|
|
||||||
|
|
||||||
foreach (var sl in boundarySplitLines)
|
|
||||||
{
|
|
||||||
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
|
|
||||||
{
|
|
||||||
var pt = SplitLineIntersect.FindIntersection(entity, sl);
|
|
||||||
if (pt != null)
|
|
||||||
{
|
|
||||||
crossedLine = sl;
|
|
||||||
intersectionPt = pt;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (crossedLine != null)
|
|
||||||
{
|
|
||||||
// Entity crosses a split line — split it and keep the half inside the region
|
|
||||||
var regionSide = RegionSideOf(region, crossedLine);
|
|
||||||
var startPt = GetStartPoint(entity);
|
|
||||||
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
|
|
||||||
var startInRegion = startSide == regionSide || startSide == 0;
|
|
||||||
|
|
||||||
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Entity doesn't cross any boundary split line — check if it's inside the region
|
|
||||||
var mid = MidPoint(entity);
|
|
||||||
if (region.Contains(mid))
|
|
||||||
entities.Add(entity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
|
|
||||||
SplitLine crossedLine, List<Entity> entities,
|
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
|
||||||
{
|
{
|
||||||
if (entity is Line line)
|
if (entity is Line line)
|
||||||
{
|
{
|
||||||
var (first, second) = line.SplitAt(point);
|
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||||
if (startInRegion)
|
if (clipped == null) return;
|
||||||
|
if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return;
|
||||||
|
entities.Add(new Line(clipped.Value.Start, clipped.Value.End));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity is Arc arc)
|
||||||
{
|
{
|
||||||
if (first != null) entities.Add(first);
|
foreach (var sub in ClipArcToRegion(arc, region))
|
||||||
splitPoints.Add((point, crossedLine, true));
|
entities.Add(sub);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clips an arc against the four edges of a region box. Returns the sub-arcs
|
||||||
|
/// whose midpoints lie inside the region. Uses line-arc intersection to find
|
||||||
|
/// split points, then iteratively bisects the arc at each crossing.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Arc> ClipArcToRegion(Arc arc, Box region)
|
||||||
{
|
{
|
||||||
splitPoints.Add((point, crossedLine, false));
|
var edges = new[]
|
||||||
if (second != null) entities.Add(second);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (entity is Arc arc)
|
|
||||||
{
|
{
|
||||||
var (first, second) = arc.SplitAt(point);
|
new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)),
|
||||||
if (startInRegion)
|
new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)),
|
||||||
|
new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)),
|
||||||
|
new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom))
|
||||||
|
};
|
||||||
|
|
||||||
|
var arcs = new List<Arc> { arc };
|
||||||
|
|
||||||
|
foreach (var edge in edges)
|
||||||
{
|
{
|
||||||
if (first != null) entities.Add(first);
|
var next = new List<Arc>();
|
||||||
splitPoints.Add((point, crossedLine, true));
|
foreach (var a in arcs)
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
splitPoints.Add((point, crossedLine, false));
|
if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0)
|
||||||
if (second != null) entities.Add(second);
|
{
|
||||||
|
next.Add(a);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split the arc at each intersection that actually lies on one of
|
||||||
|
// the working sub-arcs. Prior splits may make some original hits
|
||||||
|
// moot for the sub-arc that now holds them.
|
||||||
|
var working = new List<Arc> { a };
|
||||||
|
foreach (var pt in pts)
|
||||||
|
{
|
||||||
|
var replaced = new List<Arc>();
|
||||||
|
foreach (var w in working)
|
||||||
|
{
|
||||||
|
var onArc = OpenNest.Math.Angle.IsBetweenRad(
|
||||||
|
w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed);
|
||||||
|
if (!onArc)
|
||||||
|
{
|
||||||
|
replaced.Add(w);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (first, second) = w.SplitAt(pt);
|
||||||
|
if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first);
|
||||||
|
if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second);
|
||||||
|
}
|
||||||
|
working = replaced;
|
||||||
|
}
|
||||||
|
next.AddRange(working);
|
||||||
|
}
|
||||||
|
arcs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new List<Arc>();
|
||||||
|
foreach (var a in arcs)
|
||||||
|
{
|
||||||
|
if (region.Contains(a.MidPoint()))
|
||||||
|
result.Add(a);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -365,104 +388,157 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
|
/// For each boundary split line of the region, generates a feature edge that
|
||||||
|
/// spans the full region boundary along that split line and trims it against
|
||||||
|
/// interior cutouts. This produces one (or zero) feature edge per contiguous
|
||||||
|
/// material interval on the boundary, handling corner regions (one perimeter
|
||||||
|
/// crossing), spanning cutouts (two holes puncturing the line), and
|
||||||
|
/// normal mid-part splits uniformly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static void InsertFeatureEdges(List<Entity> entities,
|
private static void InsertFeatureEdges(List<Entity> entities,
|
||||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
|
||||||
Box region, List<SplitLine> boundarySplitLines,
|
Box region, List<SplitLine> boundarySplitLines,
|
||||||
ISplitFeature feature, SplitParameters parameters)
|
ISplitFeature feature, SplitParameters parameters,
|
||||||
|
List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
// Group split points by their split line
|
foreach (var sl in boundarySplitLines)
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
|
||||||
foreach (var sp in splitPoints)
|
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
var extentEnd = isVertical ? region.Top : region.Right;
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||||
{
|
|
||||||
var sl = kvp.Key;
|
|
||||||
var points = kvp.Value;
|
|
||||||
|
|
||||||
// Pair each exit with the next entry
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
|
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
|
|
||||||
|
|
||||||
if (exits.Count == 0 || entries.Count == 0)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// For each exit, find the matching entry to form the feature edge span
|
|
||||||
// Sort exits and entries by their position along the split line
|
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
|
||||||
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
|
|
||||||
// Pair them up: each exit with the next entry (or vice versa)
|
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
|
||||||
for (var i = 0; i < pairCount; i++)
|
|
||||||
{
|
|
||||||
var exitPt = exits[i];
|
|
||||||
var entryPt = entries[i];
|
|
||||||
|
|
||||||
var extentStart = isVertical
|
|
||||||
? System.Math.Min(exitPt.Y, entryPt.Y)
|
|
||||||
: System.Math.Min(exitPt.X, entryPt.X);
|
|
||||||
var extentEnd = isVertical
|
|
||||||
? System.Math.Max(exitPt.Y, entryPt.Y)
|
|
||||||
: System.Math.Max(exitPt.X, entryPt.X);
|
|
||||||
|
|
||||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||||
|
|
||||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||||
|
|
||||||
if (featureEdge.Count > 0)
|
// Trim any line segments that cross a cutout — cut lines must never
|
||||||
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
|
// travel through a hole.
|
||||||
|
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
|
||||||
|
|
||||||
entities.AddRange(featureEdge);
|
entities.AddRange(featureEdge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
/// <summary>
|
||||||
|
/// Subtracts any portions of line entities in <paramref name="featureEdge"/> that
|
||||||
|
/// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are
|
||||||
|
/// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap
|
||||||
|
/// tabs, spike-groove) can be added later if a test demands it.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Entity> TrimFeatureEdgeAgainstCutouts(List<Entity> featureEdge, List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var featureStart = GetStartPoint(featureEdge[0]);
|
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
|
||||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
|
||||||
var isVertical = axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
|
|
||||||
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
|
|
||||||
|
|
||||||
if (edgeGoesForward != featureGoesForward)
|
|
||||||
{
|
|
||||||
featureEdge = new List<Entity>(featureEdge);
|
|
||||||
featureEdge.Reverse();
|
|
||||||
foreach (var e in featureEdge)
|
|
||||||
e.Reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
return featureEdge;
|
return featureEdge;
|
||||||
|
|
||||||
|
var result = new List<Entity>();
|
||||||
|
foreach (var entity in featureEdge)
|
||||||
|
{
|
||||||
|
if (entity is Line line)
|
||||||
|
result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons));
|
||||||
|
else
|
||||||
|
result.Add(entity);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
/// <summary>
|
||||||
|
/// Returns the sub-segments of <paramref name="line"/> that lie outside every
|
||||||
|
/// cutout polygon. Handles the common axis-aligned feature-edge case exactly.
|
||||||
|
/// </summary>
|
||||||
|
private static List<Line> SubtractCutoutsFromLine(Line line, List<Polygon> cutoutPolygons)
|
||||||
{
|
{
|
||||||
var shape = new Shape();
|
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
|
||||||
shape.Entities.AddRange(entities);
|
var ts = new List<double> { 0.0, 1.0 };
|
||||||
var poly = shape.ToPolygon();
|
foreach (var poly in cutoutPolygons)
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
{
|
||||||
shape.Reverse();
|
var polyLines = poly.ToLines();
|
||||||
|
foreach (var edge in polyLines)
|
||||||
|
{
|
||||||
|
if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t))
|
||||||
|
{
|
||||||
|
if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon)
|
||||||
|
ts.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entities.Clear();
|
ts.Sort();
|
||||||
entities.AddRange(shape.Entities);
|
|
||||||
|
var segments = new List<Line>();
|
||||||
|
for (var i = 0; i < ts.Count - 1; i++)
|
||||||
|
{
|
||||||
|
var t0 = ts[i];
|
||||||
|
var t1 = ts[i + 1];
|
||||||
|
if (t1 - t0 < Math.Tolerance.Epsilon) continue;
|
||||||
|
|
||||||
|
var tMid = (t0 + t1) * 0.5;
|
||||||
|
var mid = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid);
|
||||||
|
|
||||||
|
var insideCutout = false;
|
||||||
|
foreach (var poly in cutoutPolygons)
|
||||||
|
{
|
||||||
|
if (poly.ContainsPoint(mid))
|
||||||
|
{
|
||||||
|
insideCutout = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (insideCutout) continue;
|
||||||
|
|
||||||
|
var p0 = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0);
|
||||||
|
var p1 = new Vector(
|
||||||
|
line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1,
|
||||||
|
line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1);
|
||||||
|
|
||||||
|
segments.Add(new Line(p0, p1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Segment-segment intersection. On hit, returns the parameter t along segment AB
|
||||||
|
/// (0 = a0, 1 = a1) via <paramref name="tOnA"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA)
|
||||||
|
{
|
||||||
|
tOnA = 0;
|
||||||
|
var rx = a1.X - a0.X;
|
||||||
|
var ry = a1.Y - a0.Y;
|
||||||
|
var sx = b1.X - b0.X;
|
||||||
|
var sy = b1.Y - b0.Y;
|
||||||
|
|
||||||
|
var denom = rx * sy - ry * sx;
|
||||||
|
if (System.Math.Abs(denom) < Math.Tolerance.Epsilon)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var dx = b0.X - a0.X;
|
||||||
|
var dy = b0.Y - a0.Y;
|
||||||
|
var t = (dx * sy - dy * sx) / denom;
|
||||||
|
var u = (dx * ry - dy * rx) / denom;
|
||||||
|
|
||||||
|
if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false;
|
||||||
|
if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false;
|
||||||
|
|
||||||
|
tOnA = t;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
private static bool IsCutoutInRegion(Shape cutout, Box region)
|
||||||
{
|
{
|
||||||
if (cutout.Entities.Count == 0) return false;
|
if (cutout.Entities.Count == 0) return false;
|
||||||
var pt = GetStartPoint(cutout.Entities[0]);
|
var bb = cutout.BoundingBox;
|
||||||
return region.Contains(pt);
|
// Fully contained iff the cutout's bounding box fits inside the region.
|
||||||
|
return bb.Left >= region.Left - Math.Tolerance.Epsilon
|
||||||
|
&& bb.Right <= region.Right + Math.Tolerance.Epsilon
|
||||||
|
&& bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon
|
||||||
|
&& bb.Top <= region.Top + Math.Tolerance.Epsilon;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
private static bool DoesCutoutCrossSplitLine(Shape cutout, List<SplitLine> splitLines)
|
||||||
@@ -479,57 +555,135 @@ public static class DrawingSplitter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||||
/// intersections, keeping portions inside the region, and closing gaps with
|
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||||
/// straight lines. No polygon clipping library needed.
|
/// it deliberately does NOT emit synthetic closing lines at the region boundary.
|
||||||
|
///
|
||||||
|
/// Rationale: a closing line on the region boundary would overlap the split-line
|
||||||
|
/// feature edge and reintroduce a cut through the cutout interior. The feature
|
||||||
|
/// edge (trimmed against cutouts in <see cref="InsertFeatureEdges"/>) and these
|
||||||
|
/// cutout fragments are stitched together later by <see cref="AssemblePieces"/>
|
||||||
|
/// using endpoint connectivity, which produces the correct closed loops — one
|
||||||
|
/// loop per physically-connected strip of material.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||||
{
|
{
|
||||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
|
||||||
var entities = new List<Entity>();
|
var entities = new List<Entity>();
|
||||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
|
||||||
|
|
||||||
foreach (var entity in cutout.Entities)
|
foreach (var entity in cutout.Entities)
|
||||||
{
|
ProcessEntity(entity, region, entities);
|
||||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
return entities;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entities.Count == 0)
|
/// <summary>
|
||||||
return new List<Entity>();
|
/// Groups a region's entities into closed components and nests holes inside
|
||||||
|
/// outer loops by point-in-polygon containment. Returns one entity list per
|
||||||
// Close gaps with straight lines (connect exit→entry pairs)
|
/// output <see cref="Drawing"/> — outer loop first, then its contained holes.
|
||||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
/// Each outer loop is normalized to CW winding and each hole to CCW.
|
||||||
foreach (var sp in splitPoints)
|
/// </summary>
|
||||||
|
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
|
||||||
{
|
{
|
||||||
if (!groups.ContainsKey(sp.Line))
|
var pieces = new List<List<Entity>>();
|
||||||
groups[sp.Line] = new List<(Vector, bool)>();
|
if (entities.Count == 0) return pieces;
|
||||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
|
||||||
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
|
if (shapes.Count == 0) return pieces;
|
||||||
|
|
||||||
|
// Polygonize every shape once so we can run containment tests.
|
||||||
|
var polygons = new List<Polygon>(shapes.Count);
|
||||||
|
foreach (var s in shapes)
|
||||||
|
polygons.Add(s.ToPolygon());
|
||||||
|
|
||||||
|
// Classify each shape as outer or hole using nesting by containment.
|
||||||
|
// Shape A is contained in shape B iff A's bounding box is strictly inside
|
||||||
|
// B's bounding box AND a representative vertex of A lies inside B's polygon.
|
||||||
|
// The bbox pre-check avoids the ambiguity of bbox-center tests when two
|
||||||
|
// shapes share a center (e.g., an outer half and a centered cutout).
|
||||||
|
var isHole = new bool[shapes.Count];
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
|
{
|
||||||
|
var bbA = shapes[i].BoundingBox;
|
||||||
|
var repA = FirstVertexOf(shapes[i]);
|
||||||
|
|
||||||
|
for (var j = 0; j < shapes.Count; j++)
|
||||||
|
{
|
||||||
|
if (i == j) continue;
|
||||||
|
if (polygons[j] == null) continue;
|
||||||
|
if (polygons[j].Vertices.Count < 3) continue;
|
||||||
|
|
||||||
|
var bbB = shapes[j].BoundingBox;
|
||||||
|
if (!BoxContainsBox(bbB, bbA)) continue;
|
||||||
|
if (!polygons[j].ContainsPoint(repA)) continue;
|
||||||
|
|
||||||
|
isHole[i] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kvp in groups)
|
// For each outer, attach the holes that fall inside it.
|
||||||
|
for (var i = 0; i < shapes.Count; i++)
|
||||||
{
|
{
|
||||||
var sl = kvp.Key;
|
if (isHole[i]) continue;
|
||||||
var points = kvp.Value;
|
|
||||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
|
||||||
|
|
||||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
var outer = shapes[i];
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
var outerPoly = polygons[i];
|
||||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
|
||||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
|
||||||
|
|
||||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
// Enforce perimeter winding = CW.
|
||||||
for (var i = 0; i < pairCount; i++)
|
if (outerPoly != null && outerPoly.Vertices.Count >= 3
|
||||||
entities.Add(new Line(exits[i], entries[i]));
|
&& outerPoly.RotationDirection() != RotationType.CW)
|
||||||
|
outer.Reverse();
|
||||||
|
|
||||||
|
var piece = new List<Entity>();
|
||||||
|
piece.AddRange(outer.Entities);
|
||||||
|
|
||||||
|
for (var j = 0; j < shapes.Count; j++)
|
||||||
|
{
|
||||||
|
if (!isHole[j]) continue;
|
||||||
|
if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue;
|
||||||
|
|
||||||
|
var bbJ = shapes[j].BoundingBox;
|
||||||
|
if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue;
|
||||||
|
|
||||||
|
var rep = FirstVertexOf(shapes[j]);
|
||||||
|
if (!polygons[i].ContainsPoint(rep)) continue;
|
||||||
|
|
||||||
|
var hole = shapes[j];
|
||||||
|
var holePoly = polygons[j];
|
||||||
|
if (holePoly != null && holePoly.Vertices.Count >= 3
|
||||||
|
&& holePoly.RotationDirection() != RotationType.CCW)
|
||||||
|
hole.Reverse();
|
||||||
|
|
||||||
|
piece.AddRange(hole.Entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure CCW winding for cutouts
|
pieces.Add(piece);
|
||||||
var shape = new Shape();
|
}
|
||||||
shape.Entities.AddRange(entities);
|
|
||||||
var poly = shape.ToPolygon();
|
|
||||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
|
||||||
shape.Reverse();
|
|
||||||
|
|
||||||
return shape.Entities;
|
return pieces;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the first vertex of a shape (start point of its first entity). Used as
|
||||||
|
/// a representative for containment testing: if bbox pre-check says the whole
|
||||||
|
/// shape is inside another, testing one vertex is sufficient to confirm.
|
||||||
|
/// </summary>
|
||||||
|
private static Vector FirstVertexOf(Shape shape)
|
||||||
|
{
|
||||||
|
if (shape.Entities.Count == 0)
|
||||||
|
return new Vector(0, 0);
|
||||||
|
return GetStartPoint(shape.Entities[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True iff box <paramref name="inner"/> is entirely inside box
|
||||||
|
/// <paramref name="outer"/> (tolerant comparison).
|
||||||
|
/// </summary>
|
||||||
|
private static bool BoxContainsBox(Box outer, Box inner)
|
||||||
|
{
|
||||||
|
var eps = Math.Tolerance.Epsilon;
|
||||||
|
return inner.Left >= outer.Left - eps
|
||||||
|
&& inner.Right <= outer.Right + eps
|
||||||
|
&& inner.Bottom >= outer.Bottom - eps
|
||||||
|
&& inner.Top <= outer.Top + eps;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
|
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
|
||||||
{
|
{
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
return new List<BestFitResult>();
|
||||||
|
|
||||||
|
// Build a perimeter-only drawing once — all candidates share the same drawing.
|
||||||
|
// This avoids cloning the full program (with all cutouts) for every candidate.
|
||||||
|
var perimeterDrawing = CreatePerimeterDrawing(candidates[0].Drawing);
|
||||||
|
|
||||||
var resultBag = new ConcurrentBag<BestFitResult>();
|
var resultBag = new ConcurrentBag<BestFitResult>();
|
||||||
|
|
||||||
Parallel.ForEach(candidates, c =>
|
Parallel.ForEach(candidates, c =>
|
||||||
{
|
{
|
||||||
resultBag.Add(Evaluate(c));
|
resultBag.Add(Evaluate(c, perimeterDrawing));
|
||||||
});
|
});
|
||||||
|
|
||||||
return resultBag.ToList();
|
return resultBag.ToList();
|
||||||
@@ -27,18 +34,24 @@ namespace OpenNest.Engine.BestFit
|
|||||||
|
|
||||||
public BestFitResult Evaluate(PairCandidate candidate)
|
public BestFitResult Evaluate(PairCandidate candidate)
|
||||||
{
|
{
|
||||||
var drawing = candidate.Drawing;
|
var perimeterDrawing = CreatePerimeterDrawing(candidate.Drawing);
|
||||||
|
return Evaluate(candidate, perimeterDrawing);
|
||||||
|
}
|
||||||
|
|
||||||
var part1 = Part.CreateAtOrigin(drawing);
|
private BestFitResult Evaluate(PairCandidate candidate, Drawing perimeterDrawing)
|
||||||
|
{
|
||||||
|
var part1 = Part.CreateAtOrigin(perimeterDrawing);
|
||||||
|
|
||||||
var part2 = Part.CreateAtOrigin(drawing, candidate.Part2Rotation);
|
var part2 = Part.CreateAtOrigin(perimeterDrawing, candidate.Part2Rotation);
|
||||||
part2.Location = candidate.Part2Offset;
|
part2.Location = candidate.Part2Offset;
|
||||||
part2.UpdateBounds();
|
part2.UpdateBounds();
|
||||||
|
|
||||||
// Check overlap via shape intersection
|
// Overlap check — perimeter vs perimeter
|
||||||
var overlaps = CheckOverlap(part1, part2);
|
var shape1 = GetPerimeterShape(part1);
|
||||||
|
var shape2 = GetPerimeterShape(part2);
|
||||||
|
var overlaps = shape1 != null && shape2 != null && shape1.Intersects(shape2, out _);
|
||||||
|
|
||||||
// Collect all polygon vertices for convex hull / optimal rotation
|
// Convex hull vertices from perimeter polygons only
|
||||||
var allPoints = GetPartVertices(part1);
|
var allPoints = GetPartVertices(part1);
|
||||||
allPoints.AddRange(GetPartVertices(part2));
|
allPoints.AddRange(GetPartVertices(part2));
|
||||||
|
|
||||||
@@ -66,7 +79,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
hullAngles = new List<double> { 0 };
|
hullAngles = new List<double> { 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
var trueArea = drawing.Area * 2;
|
var trueArea = candidate.Drawing.Area * 2;
|
||||||
|
|
||||||
// Normalize to landscape (width >= height) for consistent display.
|
// Normalize to landscape (width >= height) for consistent display.
|
||||||
if (bestHeight > bestWidth)
|
if (bestHeight > bestWidth)
|
||||||
@@ -91,38 +104,29 @@ namespace OpenNest.Engine.BestFit
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CheckOverlap(Part part1, Part part2)
|
private static Drawing CreatePerimeterDrawing(Drawing source)
|
||||||
{
|
{
|
||||||
var shapes1 = GetPartShapes(part1);
|
var entities = ConvertProgram.ToGeometry(source.Program)
|
||||||
var shapes2 = GetPartShapes(part2);
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
var profile = new ShapeProfile(entities);
|
||||||
for (var i = 0; i < shapes1.Count; i++)
|
var program = ConvertGeometry.ToProgram(profile.Perimeter);
|
||||||
{
|
return new Drawing(source.Name, program);
|
||||||
for (var j = 0; j < shapes2.Count; j++)
|
|
||||||
{
|
|
||||||
List<Vector> pts;
|
|
||||||
|
|
||||||
if (shapes1[i].Intersects(shapes2[j], out pts))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
private static Shape GetPerimeterShape(Part part)
|
||||||
}
|
|
||||||
|
|
||||||
private List<Shape> GetPartShapes(Part part)
|
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
shapes.ForEach(s => s.Offset(part.Location));
|
if (shapes.Count == 0) return null;
|
||||||
return shapes;
|
shapes[0].Offset(part.Location);
|
||||||
|
return shapes[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Vector> GetPartVertices(Part part)
|
private static List<Vector> GetPartVertices(Part part)
|
||||||
{
|
{
|
||||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
var shapes = ShapeBuilder.GetShapes(entities);
|
var shapes = ShapeBuilder.GetShapes(entities);
|
||||||
var points = new List<Vector>();
|
var points = new List<Vector>();
|
||||||
|
|
||||||
@@ -130,9 +134,7 @@ namespace OpenNest.Engine.BestFit
|
|||||||
{
|
{
|
||||||
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
|
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
|
||||||
polygon.Offset(part.Location);
|
polygon.Offset(part.Location);
|
||||||
|
points.AddRange(polygon.Vertices);
|
||||||
foreach (var vertex in polygon.Vertices)
|
|
||||||
points.Add(vertex);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return points;
|
return points;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
using OpenNest.Engine.Sequencing;
|
using OpenNest.Engine.Sequencing;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace OpenNest.Engine
|
namespace OpenNest.Engine
|
||||||
@@ -15,14 +17,28 @@ namespace OpenNest.Engine
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
|
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
|
||||||
var currentPoint = PlateHelper.GetExitPoint(plate);
|
var exitPoint = PlateHelper.GetExitPoint(plate);
|
||||||
|
|
||||||
foreach (var sp in sequenced)
|
// Pass 1: assign lead-ins to establish pierce points
|
||||||
|
var piercePoints = AssignPass(sequenced, parameters, exitPoint, nextPiercePoints: null);
|
||||||
|
|
||||||
|
// Pass 2: re-assign with knowledge of next part's start point
|
||||||
|
AssignPass(sequenced, parameters, exitPoint, nextPiercePoints: piercePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Vector[] AssignPass(List<SequencedPart> sequenced, CuttingParameters parameters,
|
||||||
|
Vector exitPoint, Vector[] nextPiercePoints)
|
||||||
{
|
{
|
||||||
var part = sp.Part;
|
var piercePoints = new Vector[sequenced.Count];
|
||||||
|
var currentPoint = exitPoint;
|
||||||
|
|
||||||
|
for (var i = 0; i < sequenced.Count; i++)
|
||||||
|
{
|
||||||
|
var part = sequenced[i].Part;
|
||||||
|
|
||||||
if (part.LeadInsLocked)
|
if (part.LeadInsLocked)
|
||||||
{
|
{
|
||||||
|
piercePoints[i] = GetPiercePoint(part);
|
||||||
currentPoint = part.Location;
|
currentPoint = part.Location;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -31,10 +47,33 @@ namespace OpenNest.Engine
|
|||||||
part.RemoveLeadIns();
|
part.RemoveLeadIns();
|
||||||
|
|
||||||
var localApproach = currentPoint - part.Location;
|
var localApproach = currentPoint - part.Location;
|
||||||
part.ApplyLeadIns(parameters, localApproach);
|
|
||||||
|
|
||||||
|
if (nextPiercePoints != null && i + 1 < sequenced.Count)
|
||||||
|
{
|
||||||
|
var nextStart = nextPiercePoints[i + 1] - part.Location;
|
||||||
|
part.ApplyLeadIns(parameters, localApproach, nextStart);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
part.ApplyLeadIns(parameters, localApproach);
|
||||||
|
}
|
||||||
|
|
||||||
|
piercePoints[i] = GetPiercePoint(part);
|
||||||
currentPoint = part.Location;
|
currentPoint = part.Location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return piercePoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector GetPiercePoint(Part part)
|
||||||
|
{
|
||||||
|
foreach (var code in part.Program.Codes)
|
||||||
|
{
|
||||||
|
if (code is CNC.Motion motion)
|
||||||
|
return motion.EndPoint + part.Location;
|
||||||
|
}
|
||||||
|
|
||||||
|
return part.Location;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,38 @@ namespace OpenNest.Engine
|
|||||||
public PlateProcessingResult Process(Plate plate)
|
public PlateProcessingResult Process(Plate plate)
|
||||||
{
|
{
|
||||||
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
|
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
|
||||||
|
var exitPoint = PlateHelper.GetExitPoint(plate);
|
||||||
|
|
||||||
|
// Pass 1: process each part to collect pierce points
|
||||||
|
var piercePoints = new Vector[sequenced.Count];
|
||||||
|
var currentPoint = exitPoint;
|
||||||
|
|
||||||
|
for (var i = 0; i < sequenced.Count; i++)
|
||||||
|
{
|
||||||
|
var part = sequenced[i].Part;
|
||||||
|
|
||||||
|
if (!part.HasManualLeadIns && CuttingStrategy != null)
|
||||||
|
{
|
||||||
|
var localApproach = ToPartLocal(currentPoint, part);
|
||||||
|
var result = CuttingStrategy.Apply(part.Program, localApproach);
|
||||||
|
piercePoints[i] = ToPlateSpace(GetProgramStartPoint(result.Program), part);
|
||||||
|
currentPoint = ToPlateSpace(result.LastCutPoint, part);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
piercePoints[i] = ToPlateSpace(GetProgramStartPoint(part.Program), part);
|
||||||
|
currentPoint = ToPlateSpace(GetProgramEndPoint(part.Program), part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: re-process with next part's start point for perimeter lead-in refinement
|
||||||
var results = new List<ProcessedPart>(sequenced.Count);
|
var results = new List<ProcessedPart>(sequenced.Count);
|
||||||
var cutAreas = new List<Shape>();
|
var cutAreas = new List<Shape>();
|
||||||
var currentPoint = PlateHelper.GetExitPoint(plate);
|
currentPoint = exitPoint;
|
||||||
|
|
||||||
foreach (var sp in sequenced)
|
for (var i = 0; i < sequenced.Count; i++)
|
||||||
{
|
{
|
||||||
var part = sp.Part;
|
var part = sequenced[i].Part;
|
||||||
|
|
||||||
// Compute approach point in part-local space
|
|
||||||
var localApproach = ToPartLocal(currentPoint, part);
|
var localApproach = ToPartLocal(currentPoint, part);
|
||||||
|
|
||||||
Program processedProgram;
|
Program processedProgram;
|
||||||
@@ -33,7 +56,18 @@ namespace OpenNest.Engine
|
|||||||
|
|
||||||
if (!part.HasManualLeadIns && CuttingStrategy != null)
|
if (!part.HasManualLeadIns && CuttingStrategy != null)
|
||||||
{
|
{
|
||||||
var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
|
CuttingResult cuttingResult;
|
||||||
|
|
||||||
|
if (i + 1 < sequenced.Count)
|
||||||
|
{
|
||||||
|
var nextStart = ToPartLocal(piercePoints[i + 1], part);
|
||||||
|
cuttingResult = CuttingStrategy.Apply(part.Program, localApproach, nextStart);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
|
||||||
|
}
|
||||||
|
|
||||||
processedProgram = cuttingResult.Program;
|
processedProgram = cuttingResult.Program;
|
||||||
lastCutLocal = cuttingResult.LastCutPoint;
|
lastCutLocal = cuttingResult.LastCutPoint;
|
||||||
}
|
}
|
||||||
@@ -43,11 +77,9 @@ namespace OpenNest.Engine
|
|||||||
lastCutLocal = GetProgramEndPoint(part.Program);
|
lastCutLocal = GetProgramEndPoint(part.Program);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pierce point: program start point in plate space
|
|
||||||
var pierceLocal = GetProgramStartPoint(processedProgram);
|
var pierceLocal = GetProgramStartPoint(processedProgram);
|
||||||
var piercePoint = ToPlateSpace(pierceLocal, part);
|
var piercePoint = ToPlateSpace(pierceLocal, part);
|
||||||
|
|
||||||
// Plan rapid from currentPoint to pierce point
|
|
||||||
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
|
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
|
||||||
|
|
||||||
results.Add(new ProcessedPart
|
results.Add(new ProcessedPart
|
||||||
@@ -57,12 +89,10 @@ namespace OpenNest.Engine
|
|||||||
RapidPath = rapidPath
|
RapidPath = rapidPath
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update cut areas with part perimeter
|
|
||||||
var perimeter = GetPartPerimeter(part);
|
var perimeter = GetPartPerimeter(part);
|
||||||
if (perimeter != null)
|
if (perimeter != null)
|
||||||
cutAreas.Add(perimeter);
|
cutAreas.Add(perimeter);
|
||||||
|
|
||||||
// Update current point to last cut point in plate space
|
|
||||||
currentPoint = ToPlateSpace(lastCutLocal, part);
|
currentPoint = ToPlateSpace(lastCutLocal, part);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending
|
|||||||
{
|
{
|
||||||
return document.Entities
|
return document.Entities
|
||||||
.OfType<ACadSharp.Entities.Line>()
|
.OfType<ACadSharp.Entities.Line>()
|
||||||
.Where(l => l.Layer?.Name == "BEND"
|
.Where(l => (l.Layer?.Name == "BEND" || l.Layer?.Name == "0")
|
||||||
&& (l.LineType?.Name?.Contains("CENTER") == true
|
&& (l.LineType?.Name?.Contains("CENTER") == true
|
||||||
|| l.LineType?.Name == "CENTERX2"))
|
|| l.LineType?.Name == "CENTERX2"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Options controlling how <see cref="CadImporter"/> loads a CAD file
|
||||||
|
/// and builds a <see cref="Drawing"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class CadImportOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Detector name to use for bend detection. Null = auto-detect.
|
||||||
|
/// </summary>
|
||||||
|
public string BendDetectorName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When false, skips bend detection entirely. Default true.
|
||||||
|
/// </summary>
|
||||||
|
public bool DetectBends { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override the drawing name. Null = filename without extension.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Required quantity on the produced drawing. Default 1.
|
||||||
|
/// </summary>
|
||||||
|
public int Quantity { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Customer name on the produced drawing. Default null.
|
||||||
|
/// </summary>
|
||||||
|
public string Customer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a default options instance.
|
||||||
|
/// </summary>
|
||||||
|
public static CadImportOptions Default => new CadImportOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.Bending;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Intermediate result of <see cref="CadImporter.Import"/>. Holds raw loaded
|
||||||
|
/// geometry and detected bends. Callers may mutate <see cref="Entities"/> and
|
||||||
|
/// <see cref="Bends"/> before passing to <see cref="CadImporter.BuildDrawing"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class CadImportResult
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// All entities loaded from the source file, including promoted bend
|
||||||
|
/// source entities. Mutable.
|
||||||
|
/// </summary>
|
||||||
|
public List<Entity> Entities { get; set; } = new List<Entity>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bends detected during import. Mutable — callers may add, remove,
|
||||||
|
/// or replace entries before building the drawing.
|
||||||
|
/// </summary>
|
||||||
|
public List<Bend> Bends { get; set; } = new List<Bend>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bounding box of <see cref="Entities"/> at import time. May be stale
|
||||||
|
/// if callers mutate <see cref="Entities"/>; recompute if needed.
|
||||||
|
/// </summary>
|
||||||
|
public Box Bounds { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Absolute path to the source file.
|
||||||
|
/// </summary>
|
||||||
|
public string SourcePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default drawing name (filename without extension, unless overridden).
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Bending;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO.Bending;
|
||||||
|
|
||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Shared service that converts a CAD source file into a fully-populated
|
||||||
|
/// <see cref="Drawing"/>. Used by the UI, console, MCP, API, and training
|
||||||
|
/// tools so all code paths produce identical drawings.
|
||||||
|
/// </summary>
|
||||||
|
public static class CadImporter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Load a DXF file, run bend detection, and return a mutable result
|
||||||
|
/// ready for interactive editing or direct conversion to a Drawing.
|
||||||
|
/// </summary>
|
||||||
|
public static CadImportResult Import(string path, CadImportOptions options = null)
|
||||||
|
{
|
||||||
|
options ??= CadImportOptions.Default;
|
||||||
|
|
||||||
|
var dxf = Dxf.Import(path);
|
||||||
|
|
||||||
|
var bends = new List<Bend>();
|
||||||
|
if (options.DetectBends && dxf.Document != null)
|
||||||
|
{
|
||||||
|
bends = options.BendDetectorName == null
|
||||||
|
? BendDetectorRegistry.AutoDetect(dxf.Document)
|
||||||
|
: BendDetectorRegistry.GetByName(options.BendDetectorName)
|
||||||
|
?.DetectBends(dxf.Document)
|
||||||
|
?? new List<Bend>();
|
||||||
|
}
|
||||||
|
|
||||||
|
Bend.UpdateEtchEntities(dxf.Entities, bends);
|
||||||
|
|
||||||
|
return new CadImportResult
|
||||||
|
{
|
||||||
|
Entities = dxf.Entities,
|
||||||
|
Bends = bends,
|
||||||
|
Bounds = dxf.Entities.GetBoundingBox(),
|
||||||
|
SourcePath = path,
|
||||||
|
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience for headless callers: Import a file and build a Drawing
|
||||||
|
/// in a single call, using all loaded entities and detected bends.
|
||||||
|
/// </summary>
|
||||||
|
public static Drawing ImportDrawing(string path, CadImportOptions options = null)
|
||||||
|
{
|
||||||
|
options ??= CadImportOptions.Default;
|
||||||
|
var result = Import(path, options);
|
||||||
|
return BuildDrawing(
|
||||||
|
result,
|
||||||
|
result.Entities,
|
||||||
|
result.Bends,
|
||||||
|
options.Quantity,
|
||||||
|
options.Customer,
|
||||||
|
editedProgram: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a fully-populated <see cref="Drawing"/> from an import result plus
|
||||||
|
/// the caller's current entity and bend state. UI callers pass the currently
|
||||||
|
/// visible subset; headless callers pass the full lists.
|
||||||
|
///
|
||||||
|
/// The produced drawing has:
|
||||||
|
/// - Program generated from the visible entities, with its first rapid moved
|
||||||
|
/// to the origin and the pierce location stored in Source.Offset
|
||||||
|
/// - SourceEntities containing all non-bend-source entities from the result
|
||||||
|
/// - SuppressedEntityIds containing entities whose layer or IsVisible is false
|
||||||
|
/// - Bends copied from the provided list
|
||||||
|
/// - Customer, Quantity, Source.Path from options / result
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="result">Import result from <see cref="Import"/>.</param>
|
||||||
|
/// <param name="entities">
|
||||||
|
/// Entities to build the program from. Typically the currently visible subset.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="bends">Bends to attach to the drawing.</param>
|
||||||
|
/// <param name="quantity">Required quantity.</param>
|
||||||
|
/// <param name="customer">Customer name, or null.</param>
|
||||||
|
/// <param name="editedProgram">
|
||||||
|
/// When non-null, replaces the generated program (used by the UI to honor
|
||||||
|
/// in-place G-code edits). Source.Offset is still populated from the
|
||||||
|
/// generated program so round-trips stay consistent.
|
||||||
|
/// </param>
|
||||||
|
public static Drawing BuildDrawing(
|
||||||
|
CadImportResult result,
|
||||||
|
IEnumerable<Entity> entities,
|
||||||
|
IEnumerable<Bend> bends,
|
||||||
|
int quantity,
|
||||||
|
string customer,
|
||||||
|
OpenNest.CNC.Program editedProgram)
|
||||||
|
{
|
||||||
|
var visible = entities as IList<Entity> ?? new List<Entity>(entities);
|
||||||
|
var bendList = bends as IList<Bend> ?? new List<Bend>(bends);
|
||||||
|
|
||||||
|
var normalized = ShapeProfile.NormalizeEntities(visible);
|
||||||
|
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||||
|
|
||||||
|
var offset = Vector.Zero;
|
||||||
|
if (pgm != null && pgm.Codes.Count > 0 && pgm[0].Type == OpenNest.CNC.CodeType.RapidMove)
|
||||||
|
{
|
||||||
|
var rapid = (OpenNest.CNC.RapidMove)pgm[0];
|
||||||
|
offset = rapid.EndPoint;
|
||||||
|
pgm.Offset(-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
var drawing = new Drawing(result.Name)
|
||||||
|
{
|
||||||
|
Color = Drawing.GetNextColor(),
|
||||||
|
Customer = customer,
|
||||||
|
};
|
||||||
|
drawing.Source.Path = result.SourcePath;
|
||||||
|
drawing.Source.Offset = offset;
|
||||||
|
drawing.Quantity.Required = quantity;
|
||||||
|
drawing.Bends.AddRange(bendList);
|
||||||
|
drawing.Program = editedProgram ?? pgm;
|
||||||
|
|
||||||
|
var bendSources = new HashSet<Entity>(
|
||||||
|
bendList.Where(b => b.SourceEntity != null).Select(b => b.SourceEntity));
|
||||||
|
|
||||||
|
drawing.SourceEntities = result.Entities
|
||||||
|
.Where(e => !bendSources.Contains(e))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
drawing.SuppressedEntityIds = new HashSet<System.Guid>(
|
||||||
|
drawing.SourceEntities
|
||||||
|
.Where(e => !(e.Layer != null && e.Layer.IsVisible && e.IsVisible))
|
||||||
|
.Select(e => e.Id));
|
||||||
|
|
||||||
|
return drawing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,68 @@ namespace OpenNest.IO
|
|||||||
|
|
||||||
var reader = new ProgramReader(memStream);
|
var reader = new ProgramReader(memStream);
|
||||||
programs[i] = reader.Read();
|
programs[i] = reader.Read();
|
||||||
|
|
||||||
|
// Read sub-programs if present
|
||||||
|
var subsEntry = zipArchive.GetEntry($"programs/program-{i}-subs");
|
||||||
|
if (subsEntry != null)
|
||||||
|
{
|
||||||
|
using var subsStream = subsEntry.Open();
|
||||||
|
ReadSubPrograms(programs[i], subsStream);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return programs;
|
return programs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ReadSubPrograms(Program parent, Stream stream)
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var currentId = -1;
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
string line;
|
||||||
|
while ((line = reader.ReadLine()) != null)
|
||||||
|
{
|
||||||
|
var trimmed = line.Trim();
|
||||||
|
|
||||||
|
if (trimmed.StartsWith(":") && int.TryParse(trimmed.Substring(1), out var id))
|
||||||
|
{
|
||||||
|
// Flush previous sub-program
|
||||||
|
if (currentId >= 0 && lines.Count > 0)
|
||||||
|
parent.SubPrograms[currentId] = ParseSubProgram(lines);
|
||||||
|
|
||||||
|
currentId = id;
|
||||||
|
lines.Clear();
|
||||||
|
}
|
||||||
|
else if (trimmed == "M99")
|
||||||
|
{
|
||||||
|
if (currentId >= 0 && lines.Count > 0)
|
||||||
|
parent.SubPrograms[currentId] = ParseSubProgram(lines);
|
||||||
|
|
||||||
|
currentId = -1;
|
||||||
|
lines.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lines.Add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up SubProgramCall.Program references
|
||||||
|
foreach (var code in parent.Codes)
|
||||||
|
{
|
||||||
|
if (code is SubProgramCall call && parent.SubPrograms.TryGetValue(call.Id, out var sub))
|
||||||
|
call.Program = sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Program ParseSubProgram(List<string> lines)
|
||||||
|
{
|
||||||
|
var text = string.Join("\n", lines);
|
||||||
|
var memStream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
|
||||||
|
var reader = new ProgramReader(memStream);
|
||||||
|
return reader.Read();
|
||||||
|
}
|
||||||
|
|
||||||
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
|
private Dictionary<int, (List<Entity> entities, HashSet<Guid> suppressed)> ReadEntitySets(int count)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
|
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
|
||||||
|
|||||||
@@ -308,9 +308,33 @@ namespace OpenNest.IO
|
|||||||
WriteDrawing(stream, kvp.Value);
|
WriteDrawing(stream, kvp.Value);
|
||||||
|
|
||||||
var entry = zipArchive.CreateEntry(name);
|
var entry = zipArchive.CreateEntry(name);
|
||||||
using var entryStream = entry.Open();
|
using (var entryStream = entry.Open())
|
||||||
|
{
|
||||||
stream.CopyTo(entryStream);
|
stream.CopyTo(entryStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Write sub-programs if present
|
||||||
|
if (kvp.Value.Program.SubPrograms.Count > 0)
|
||||||
|
WriteSubPrograms(zipArchive, kvp.Key, kvp.Value.Program.SubPrograms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteSubPrograms(ZipArchive zipArchive, int drawingId, Dictionary<int, Program> subPrograms)
|
||||||
|
{
|
||||||
|
var entry = zipArchive.CreateEntry($"programs/program-{drawingId}-subs");
|
||||||
|
using var entryStream = entry.Open();
|
||||||
|
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
|
||||||
|
|
||||||
|
foreach (var kvp in subPrograms.OrderBy(k => k.Key))
|
||||||
|
{
|
||||||
|
writer.WriteLine($":{kvp.Key}");
|
||||||
|
writer.WriteLine(kvp.Value.Mode == Mode.Absolute ? "G90" : "G91");
|
||||||
|
|
||||||
|
foreach (var code in kvp.Value.Codes)
|
||||||
|
writer.WriteLine(GetCodeString(code));
|
||||||
|
|
||||||
|
writer.WriteLine("M99");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEntities(ZipArchive zipArchive)
|
private void WriteEntities(ZipArchive zipArchive)
|
||||||
@@ -448,7 +472,9 @@ namespace OpenNest.IO
|
|||||||
case CodeType.SubProgramCall:
|
case CodeType.SubProgramCall:
|
||||||
{
|
{
|
||||||
var subProgramCall = (SubProgramCall)code;
|
var subProgramCall = (SubProgramCall)code;
|
||||||
break;
|
var x = System.Math.Round(subProgramCall.Offset.X, OutputPrecision).ToString(CoordinateFormat);
|
||||||
|
var y = System.Math.Round(subProgramCall.Offset.Y, OutputPrecision).ToString(CoordinateFormat);
|
||||||
|
return $"G65P{subProgramCall.Id}X{x}Y{y}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,8 @@ namespace OpenNest.IO
|
|||||||
{
|
{
|
||||||
var p = 0;
|
var p = 0;
|
||||||
var r = 0.0;
|
var r = 0.0;
|
||||||
|
var x = 0.0;
|
||||||
|
var y = 0.0;
|
||||||
|
|
||||||
while (section == CodeSection.SubProgram)
|
while (section == CodeSection.SubProgram)
|
||||||
{
|
{
|
||||||
@@ -395,13 +397,26 @@ namespace OpenNest.IO
|
|||||||
r = double.Parse(code.Value);
|
r = double.Parse(code.Value);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'X':
|
||||||
|
x = double.Parse(code.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Y':
|
||||||
|
y = double.Parse(code.Value);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
section = CodeSection.Unknown;
|
section = CodeSection.Unknown;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
program.Codes.Add(new SubProgramCall() { Id = p, Rotation = r });
|
program.Codes.Add(new SubProgramCall
|
||||||
|
{
|
||||||
|
Id = p,
|
||||||
|
Rotation = r,
|
||||||
|
Offset = new Geometry.Vector(x, y)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Code GetNextCode()
|
private Code GetNextCode()
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.Shapes;
|
using OpenNest.Shapes;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
@@ -96,24 +94,18 @@ namespace OpenNest.Mcp.Tools
|
|||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return $"Error: file not found: {path}";
|
return $"Error: file not found: {path}";
|
||||||
|
|
||||||
var geometry = Dxf.GetGeometry(path);
|
try
|
||||||
|
{
|
||||||
if (geometry.Count == 0)
|
var drawing = CadImporter.ImportDrawing(path, new CadImportOptions { Name = name });
|
||||||
return "Error: failed to read DXF file or no geometry found";
|
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(geometry);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
|
|
||||||
if (pgm == null)
|
|
||||||
return "Error: failed to convert geometry to program";
|
|
||||||
|
|
||||||
var drawingName = name ?? Path.GetFileNameWithoutExtension(path);
|
|
||||||
var drawing = new Drawing(drawingName, pgm);
|
|
||||||
drawing.Color = Drawing.GetNextColor();
|
|
||||||
_session.Drawings.Add(drawing);
|
_session.Drawings.Add(drawing);
|
||||||
|
|
||||||
var bbox = pgm.BoundingBox();
|
var bbox = drawing.Program.BoundingBox();
|
||||||
return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
|
return $"Imported drawing '{drawing.Name}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error: failed to import '{path}': {ex.Message}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool(Name = "create_drawing")]
|
[McpServerTool(Name = "create_drawing")]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
@@ -136,4 +137,61 @@ public sealed class CincinnatiPartSubprogramWriter
|
|||||||
|
|
||||||
return (mapping, entries);
|
return (mapping, entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans all parts across all plates and builds a nest-level registry of unique
|
||||||
|
/// hole sub-programs. Deduplicates by comparing sub-program code content.
|
||||||
|
/// </summary>
|
||||||
|
internal static (Dictionary<int, int> modelToPostMapping, List<(int subNum, Program program)> entries)
|
||||||
|
BuildHoleRegistry(IEnumerable<Plate> plates, int startNumber)
|
||||||
|
{
|
||||||
|
var mapping = new Dictionary<int, int>();
|
||||||
|
var entries = new List<(int, Program)>();
|
||||||
|
var contentIndex = new Dictionary<string, int>();
|
||||||
|
var nextSubNum = startNumber;
|
||||||
|
|
||||||
|
foreach (var plate in plates)
|
||||||
|
{
|
||||||
|
foreach (var part in plate.Parts)
|
||||||
|
{
|
||||||
|
if (part.BaseDrawing.IsCutOff) continue;
|
||||||
|
foreach (var code in part.Program.Codes)
|
||||||
|
{
|
||||||
|
if (code is not SubProgramCall call) continue;
|
||||||
|
if (mapping.ContainsKey(call.Id)) continue;
|
||||||
|
|
||||||
|
var canonical = ProgramToCanonical(call.Program);
|
||||||
|
if (contentIndex.TryGetValue(canonical, out var existingNum))
|
||||||
|
{
|
||||||
|
mapping[call.Id] = existingNum;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var subNum = nextSubNum++;
|
||||||
|
mapping[call.Id] = subNum;
|
||||||
|
contentIndex[canonical] = subNum;
|
||||||
|
entries.Add((subNum, call.Program));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (mapping, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ProgramToCanonical(Program pgm)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I");
|
||||||
|
foreach (var code in pgm.Codes)
|
||||||
|
{
|
||||||
|
if (code is LinearMove lm)
|
||||||
|
sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}");
|
||||||
|
else if (code is ArcMove am)
|
||||||
|
sb.Append($"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}");
|
||||||
|
else if (code is RapidMove rm)
|
||||||
|
sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,9 +89,15 @@ namespace OpenNest.Posts.Cincinnati
|
|||||||
if (Config.UsePartSubprograms)
|
if (Config.UsePartSubprograms)
|
||||||
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
|
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
|
||||||
|
|
||||||
|
// 5b. Build hole sub-program registry (SubProgramCalls across all parts)
|
||||||
|
var holeStartNumber = Config.PartSubprogramStart
|
||||||
|
+ (subprogramEntries?.Count ?? 0);
|
||||||
|
var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry(plates, holeStartNumber);
|
||||||
|
|
||||||
// 6. Create writers
|
// 6. Create writers
|
||||||
var preamble = new CincinnatiPreambleWriter(Config);
|
var preamble = new CincinnatiPreambleWriter(Config);
|
||||||
var sheetWriter = new CincinnatiSheetWriter(Config, vars);
|
var sheetWriter = new CincinnatiSheetWriter(Config, vars,
|
||||||
|
holeMapping.Count > 0 ? holeMapping : null);
|
||||||
|
|
||||||
// 7. Build material description from nest
|
// 7. Build material description from nest
|
||||||
var material = nest.Material;
|
var material = nest.Material;
|
||||||
@@ -135,6 +141,23 @@ namespace OpenNest.Posts.Cincinnati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hole sub-programs (SubProgramCall definitions)
|
||||||
|
if (holeEntries.Count > 0)
|
||||||
|
{
|
||||||
|
var holeSubWriter = new CincinnatiPartSubprogramWriter(Config);
|
||||||
|
var sheetDiagonal = firstPlate != null
|
||||||
|
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
|
||||||
|
+ firstPlate.Size.Length * firstPlate.Size.Length)
|
||||||
|
: 100.0;
|
||||||
|
|
||||||
|
foreach (var (subNum, pgm) in holeEntries)
|
||||||
|
{
|
||||||
|
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
|
||||||
|
holeSubWriter.Write(writer, pgm, "HOLE", subNum,
|
||||||
|
initialCutLibrary, etchLibrary, sheetDiagonal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writer.Flush();
|
writer.Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ public sealed class CincinnatiSheetWriter
|
|||||||
private readonly ProgramVariableManager _vars;
|
private readonly ProgramVariableManager _vars;
|
||||||
private readonly CoordinateFormatter _fmt;
|
private readonly CoordinateFormatter _fmt;
|
||||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||||
|
private readonly Dictionary<int, int> _holeSubprograms;
|
||||||
|
|
||||||
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars)
|
public CincinnatiSheetWriter(CincinnatiPostConfig config, ProgramVariableManager vars,
|
||||||
|
Dictionary<int, int> holeSubprograms = null)
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_vars = vars;
|
_vars = vars;
|
||||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||||
|
_holeSubprograms = holeSubprograms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -132,11 +135,21 @@ public sealed class CincinnatiSheetWriter
|
|||||||
for (var f = 0; f < features.Count; f++)
|
for (var f = 0; f < features.Count; f++)
|
||||||
{
|
{
|
||||||
var (codes, isEtch) = features[f];
|
var (codes, isEtch) = features[f];
|
||||||
|
var isLastFeature = isLastPart && f == features.Count - 1;
|
||||||
|
|
||||||
|
// SubProgramCall features are emitted as M98 hole calls
|
||||||
|
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||||
|
{
|
||||||
|
WriteHoleSubprogramCall(w, holeCall, featureIndex, isLastFeature);
|
||||||
|
featureIndex++;
|
||||||
|
lastPartName = partName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var featureNumber = featureIndex == 0
|
var featureNumber = featureIndex == 0
|
||||||
? _config.FeatureLineNumberStart
|
? _config.FeatureLineNumberStart
|
||||||
: 1000 + featureIndex + 1;
|
: 1000 + featureIndex + 1;
|
||||||
|
|
||||||
var isLastFeature = isLastPart && f == features.Count - 1;
|
|
||||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||||
|
|
||||||
var ctx = new FeatureContext
|
var ctx = new FeatureContext
|
||||||
@@ -204,6 +217,36 @@ public sealed class CincinnatiSheetWriter
|
|||||||
w.WriteLine("M47");
|
w.WriteLine("M47");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void WriteHoleSubprogramCall(TextWriter w, SubProgramCall call, int featureIndex, bool isLastFeature)
|
||||||
|
{
|
||||||
|
var postSubNum = _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num)
|
||||||
|
? num : call.Id;
|
||||||
|
|
||||||
|
var featureNumber = featureIndex == 0
|
||||||
|
? _config.FeatureLineNumberStart
|
||||||
|
: 1000 + featureIndex + 1;
|
||||||
|
|
||||||
|
// Shift the local origin to the hole center via G52 (manual §1.52).
|
||||||
|
// G52 does not move the nozzle, so the sub-program's first rapid
|
||||||
|
// (the lead-in to the pierce point) takes the tool straight from the
|
||||||
|
// previous feature's end to pierce. The hole sub-program is authored
|
||||||
|
// in hole-local coordinates and resolves to `hole + local` under the
|
||||||
|
// shift. See docs/cincinnati-post-output.md for the full bracket.
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
if (_config.UseLineNumbers)
|
||||||
|
sb.Append($"N{featureNumber} ");
|
||||||
|
sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
|
||||||
|
w.WriteLine(sb.ToString());
|
||||||
|
|
||||||
|
w.WriteLine($"M98 P{postSubNum}");
|
||||||
|
|
||||||
|
// Cancel the local shift (manual §1.52).
|
||||||
|
w.WriteLine("G52 X0 Y0");
|
||||||
|
|
||||||
|
if (!isLastFeature)
|
||||||
|
w.WriteLine("M47");
|
||||||
|
}
|
||||||
|
|
||||||
private void WritePartsInline(TextWriter w, List<Part> allParts,
|
private void WritePartsInline(TextWriter w, List<Part> allParts,
|
||||||
string cutLibrary, string etchLibrary, double sheetDiagonal,
|
string cutLibrary, string etchLibrary, double sheetDiagonal,
|
||||||
double plateWidth, double plateLength,
|
double plateWidth, double plateLength,
|
||||||
@@ -228,6 +271,14 @@ public sealed class CincinnatiSheetWriter
|
|||||||
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
|
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
|
||||||
var isLastFeature = i == features.Count - 1;
|
var isLastFeature = i == features.Count - 1;
|
||||||
|
|
||||||
|
// SubProgramCall features are emitted as M98 hole calls
|
||||||
|
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||||
|
{
|
||||||
|
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
|
||||||
|
lastPartName = partName;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var featureNumber = i == 0
|
var featureNumber = i == 0
|
||||||
? _config.FeatureLineNumberStart
|
? _config.FeatureLineNumberStart
|
||||||
: 1000 + i + 1;
|
: 1000 + i + 1;
|
||||||
|
|||||||
@@ -21,7 +21,16 @@ public static class FeatureUtils
|
|||||||
|
|
||||||
foreach (var code in codes)
|
foreach (var code in codes)
|
||||||
{
|
{
|
||||||
if (code is RapidMove)
|
if (code is SubProgramCall)
|
||||||
|
{
|
||||||
|
// Flush any pending feature
|
||||||
|
if (current != null)
|
||||||
|
features.Add(current);
|
||||||
|
// SubProgramCall is its own feature
|
||||||
|
features.Add(new List<ICode> { code });
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
else if (code is RapidMove)
|
||||||
{
|
{
|
||||||
if (current != null)
|
if (current != null)
|
||||||
features.Add(current);
|
features.Add(current);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Converters;
|
||||||
|
|
||||||
|
public class SubProgramExpansionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ToGeometry_ExpandsSubProgramCall_WithOffset()
|
||||||
|
{
|
||||||
|
// Sub-program: a small line relative to (0,0)
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(0.5, 0));
|
||||||
|
|
||||||
|
// Main program: call sub at offset (10,20)
|
||||||
|
var main = new Program(Mode.Absolute);
|
||||||
|
main.SubPrograms[1] = sub;
|
||||||
|
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(10, 20) });
|
||||||
|
|
||||||
|
var geometry = ConvertProgram.ToGeometry(main);
|
||||||
|
|
||||||
|
// The sub-program's line should be offset by (10,20)
|
||||||
|
// Sub emits incremental (0.5,0) from current position.
|
||||||
|
// Since offset is (10,20), the line goes from (10,20) to (10.5,20).
|
||||||
|
Assert.True(geometry.Count > 0);
|
||||||
|
var line = geometry.OfType<Line>().FirstOrDefault();
|
||||||
|
Assert.NotNull(line);
|
||||||
|
Assert.Equal(10.5, line.EndPoint.X, 4);
|
||||||
|
Assert.Equal(20, line.EndPoint.Y, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToGeometry_MultipleSubProgramCalls_DifferentOffsets()
|
||||||
|
{
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(1, 0));
|
||||||
|
|
||||||
|
var main = new Program(Mode.Absolute);
|
||||||
|
main.SubPrograms[1] = sub;
|
||||||
|
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(0, 0) });
|
||||||
|
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(5, 5) });
|
||||||
|
|
||||||
|
var geometry = ConvertProgram.ToGeometry(main);
|
||||||
|
var lines = geometry.OfType<Line>().ToList();
|
||||||
|
|
||||||
|
Assert.Equal(2, lines.Count);
|
||||||
|
// First call at (0,0): line from (0,0) to (1,0)
|
||||||
|
Assert.Equal(1, lines[0].EndPoint.X, 4);
|
||||||
|
Assert.Equal(0, lines[0].EndPoint.Y, 4);
|
||||||
|
// Second call at (5,5): line from (5,5) to (6,5)
|
||||||
|
Assert.Equal(6, lines[1].EndPoint.X, 4);
|
||||||
|
Assert.Equal(5, lines[1].EndPoint.Y, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.CNC.CuttingStrategy;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.CuttingStrategy;
|
||||||
|
|
||||||
|
public class HoleSubProgramTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SubProgramCall_Offset_DefaultsToZero()
|
||||||
|
{
|
||||||
|
var call = new SubProgramCall();
|
||||||
|
Assert.Equal(0, call.Offset.X);
|
||||||
|
Assert.Equal(0, call.Offset.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubProgramCall_Offset_StoresValue()
|
||||||
|
{
|
||||||
|
var call = new SubProgramCall { Offset = new Vector(1.5, 2.5) };
|
||||||
|
Assert.Equal(1.5, call.Offset.X);
|
||||||
|
Assert.Equal(2.5, call.Offset.Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubProgramCall_Clone_CopiesOffset()
|
||||||
|
{
|
||||||
|
var call = new SubProgramCall { Id = 1, Offset = new Vector(3, 4) };
|
||||||
|
var clone = (SubProgramCall)call.Clone();
|
||||||
|
Assert.Equal(3, clone.Offset.X);
|
||||||
|
Assert.Equal(4, clone.Offset.Y);
|
||||||
|
Assert.Equal(1, clone.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubProgramCall_ToString_IncludesOffset()
|
||||||
|
{
|
||||||
|
var call = new SubProgramCall { Id = 1000, Offset = new Vector(1.5, 2.5) };
|
||||||
|
var str = call.ToString();
|
||||||
|
Assert.Contains("P1000", str);
|
||||||
|
Assert.Contains("X1.5", str);
|
||||||
|
Assert.Contains("Y2.5", str);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubProgramCall_ToString_IncludesOffsetAndRotation()
|
||||||
|
{
|
||||||
|
var call = new SubProgramCall { Id = 1000, Offset = new Vector(1.5, 2.5), Rotation = 30 };
|
||||||
|
var str = call.ToString();
|
||||||
|
Assert.Contains("P1000", str);
|
||||||
|
Assert.Contains("X1.5", str);
|
||||||
|
Assert.Contains("Y2.5", str);
|
||||||
|
Assert.Contains("R30", str);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SubProgramCall_ToString_OmitsZeroFields()
|
||||||
|
{
|
||||||
|
var call = new SubProgramCall { Id = 1000 };
|
||||||
|
var str = call.ToString();
|
||||||
|
Assert.Equal("G65 P1000", str);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_SubPrograms_EmptyByDefault()
|
||||||
|
{
|
||||||
|
var pgm = new Program();
|
||||||
|
Assert.NotNull(pgm.SubPrograms);
|
||||||
|
Assert.Empty(pgm.SubPrograms);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_SubPrograms_StoresAndRetrieves()
|
||||||
|
{
|
||||||
|
var pgm = new Program();
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(0.1, 0.2));
|
||||||
|
|
||||||
|
pgm.SubPrograms[1] = sub;
|
||||||
|
|
||||||
|
Assert.Single(pgm.SubPrograms);
|
||||||
|
Assert.Same(sub, pgm.SubPrograms[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_Clone_DeepCopiesSubPrograms()
|
||||||
|
{
|
||||||
|
var pgm = new Program();
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(0.1, 0.2));
|
||||||
|
pgm.SubPrograms[1] = sub;
|
||||||
|
|
||||||
|
var clone = (Program)pgm.Clone();
|
||||||
|
|
||||||
|
Assert.Single(clone.SubPrograms);
|
||||||
|
Assert.NotSame(sub, clone.SubPrograms[1]);
|
||||||
|
Assert.Equal(Mode.Incremental, clone.SubPrograms[1].Mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_CircleHole_EmitsSubProgramCall()
|
||||||
|
{
|
||||||
|
// Create a program with a square perimeter and a circle hole at (5, 5) radius 0.5
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
// Square perimeter
|
||||||
|
pgm.Codes.Add(new RapidMove(0, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 0));
|
||||||
|
// Circle hole at (5, 5) radius 0.5
|
||||||
|
pgm.Codes.Add(new RapidMove(5.5, 5));
|
||||||
|
pgm.Codes.Add(new ArcMove(new Vector(5.5, 5), new Vector(5, 5), RotationType.CW));
|
||||||
|
|
||||||
|
var strategy = new ContourCuttingStrategy
|
||||||
|
{
|
||||||
|
Parameters = new CuttingParameters
|
||||||
|
{
|
||||||
|
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
|
||||||
|
ArcCircleLeadOut = new NoLeadOut()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = strategy.Apply(pgm, new Vector(10, 10));
|
||||||
|
|
||||||
|
// Should contain at least one SubProgramCall
|
||||||
|
var calls = result.Program.Codes.OfType<SubProgramCall>().ToList();
|
||||||
|
Assert.Single(calls);
|
||||||
|
|
||||||
|
// The call's offset should be approximately at the hole center (5, 5)
|
||||||
|
var call = calls[0];
|
||||||
|
Assert.Equal(5, call.Offset.X, 1);
|
||||||
|
Assert.Equal(5, call.Offset.Y, 1);
|
||||||
|
|
||||||
|
// The parent program should have a sub-program registered
|
||||||
|
Assert.True(result.Program.SubPrograms.ContainsKey(call.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_TwoIdenticalCircles_ShareSubProgram()
|
||||||
|
{
|
||||||
|
// Square perimeter with two identical circle holes at different positions
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
// Square perimeter
|
||||||
|
pgm.Codes.Add(new RapidMove(0, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 0));
|
||||||
|
// Circle 1 at (2, 2) radius 0.5
|
||||||
|
pgm.Codes.Add(new RapidMove(2.5, 2));
|
||||||
|
pgm.Codes.Add(new ArcMove(new Vector(2.5, 2), new Vector(2, 2), RotationType.CW));
|
||||||
|
// Circle 2 at (6, 6) radius 0.5
|
||||||
|
pgm.Codes.Add(new RapidMove(6.5, 6));
|
||||||
|
pgm.Codes.Add(new ArcMove(new Vector(6.5, 6), new Vector(6, 6), RotationType.CW));
|
||||||
|
|
||||||
|
var strategy = new ContourCuttingStrategy
|
||||||
|
{
|
||||||
|
Parameters = new CuttingParameters
|
||||||
|
{
|
||||||
|
RoundLeadInAngles = true,
|
||||||
|
LeadInAngleIncrement = 5.0,
|
||||||
|
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
|
||||||
|
ArcCircleLeadOut = new NoLeadOut()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = strategy.Apply(pgm, new Vector(10, 10));
|
||||||
|
|
||||||
|
var calls = result.Program.Codes.OfType<SubProgramCall>().ToList();
|
||||||
|
Assert.Equal(2, calls.Count);
|
||||||
|
|
||||||
|
// Both calls should reference the same sub-program ID (same radius, same quantized angle)
|
||||||
|
Assert.Equal(calls[0].Id, calls[1].Id);
|
||||||
|
|
||||||
|
// But different offsets
|
||||||
|
Assert.NotEqual(calls[0].Offset.X, calls[1].Offset.X);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Apply_HoleCenters_PreservedInGeometry()
|
||||||
|
{
|
||||||
|
// Square perimeter 10x10 with two circle holes at known positions
|
||||||
|
var holeCenter1 = new Vector(3, 3);
|
||||||
|
var holeCenter2 = new Vector(7, 5);
|
||||||
|
var holeRadius = 0.5;
|
||||||
|
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
// Perimeter
|
||||||
|
pgm.Codes.Add(new RapidMove(0, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 0));
|
||||||
|
// Hole 1 at (3, 3)
|
||||||
|
pgm.Codes.Add(new RapidMove(holeCenter1.X + holeRadius, holeCenter1.Y));
|
||||||
|
pgm.Codes.Add(new ArcMove(
|
||||||
|
new Vector(holeCenter1.X + holeRadius, holeCenter1.Y),
|
||||||
|
holeCenter1, RotationType.CW));
|
||||||
|
// Hole 2 at (7, 5)
|
||||||
|
pgm.Codes.Add(new RapidMove(holeCenter2.X + holeRadius, holeCenter2.Y));
|
||||||
|
pgm.Codes.Add(new ArcMove(
|
||||||
|
new Vector(holeCenter2.X + holeRadius, holeCenter2.Y),
|
||||||
|
holeCenter2, RotationType.CW));
|
||||||
|
|
||||||
|
var strategy = new ContourCuttingStrategy
|
||||||
|
{
|
||||||
|
Parameters = new CuttingParameters
|
||||||
|
{
|
||||||
|
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
|
||||||
|
ArcCircleLeadOut = new NoLeadOut()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = strategy.Apply(pgm, new Vector(10, 10));
|
||||||
|
|
||||||
|
// Convert to geometry — this is what PlateView renders
|
||||||
|
var geometry = ConvertProgram.ToGeometry(result.Program);
|
||||||
|
var circles = geometry.OfType<Circle>().ToList();
|
||||||
|
|
||||||
|
Assert.Equal(2, circles.Count);
|
||||||
|
|
||||||
|
// Circle centers must match the original hole positions
|
||||||
|
var center1 = circles[0].Center;
|
||||||
|
var center2 = circles[1].Center;
|
||||||
|
|
||||||
|
Assert.Equal(holeCenter1.X, center1.X, 2);
|
||||||
|
Assert.Equal(holeCenter1.Y, center1.Y, 2);
|
||||||
|
Assert.Equal(holeCenter2.X, center2.X, 2);
|
||||||
|
Assert.Equal(holeCenter2.Y, center2.Y, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Part_ApplyLeadIns_HolesAndPerimeter_CorrectPositions()
|
||||||
|
{
|
||||||
|
// Build a drawing with a square and two holes
|
||||||
|
var holeCenter1 = new Vector(3, 3);
|
||||||
|
var holeCenter2 = new Vector(7, 5);
|
||||||
|
var holeRadius = 0.5;
|
||||||
|
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
pgm.Codes.Add(new RapidMove(0, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 0));
|
||||||
|
pgm.Codes.Add(new RapidMove(holeCenter1.X + holeRadius, holeCenter1.Y));
|
||||||
|
pgm.Codes.Add(new ArcMove(
|
||||||
|
new Vector(holeCenter1.X + holeRadius, holeCenter1.Y),
|
||||||
|
holeCenter1, RotationType.CW));
|
||||||
|
pgm.Codes.Add(new RapidMove(holeCenter2.X + holeRadius, holeCenter2.Y));
|
||||||
|
pgm.Codes.Add(new ArcMove(
|
||||||
|
new Vector(holeCenter2.X + holeRadius, holeCenter2.Y),
|
||||||
|
holeCenter2, RotationType.CW));
|
||||||
|
|
||||||
|
var drawing = new Drawing("TestPart") { Program = pgm };
|
||||||
|
var part = new Part(drawing);
|
||||||
|
|
||||||
|
var parameters = new CuttingParameters
|
||||||
|
{
|
||||||
|
RoundLeadInAngles = true,
|
||||||
|
LeadInAngleIncrement = 5.0,
|
||||||
|
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
|
||||||
|
ArcCircleLeadOut = new NoLeadOut(),
|
||||||
|
ExternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 },
|
||||||
|
ExternalLeadOut = new NoLeadOut()
|
||||||
|
};
|
||||||
|
|
||||||
|
part.ApplyLeadIns(parameters, new Vector(10, 10));
|
||||||
|
|
||||||
|
// Convert to geometry — this is what PlateView renders
|
||||||
|
var geometry = ConvertProgram.ToGeometry(part.Program);
|
||||||
|
var circles = geometry.OfType<Circle>().ToList();
|
||||||
|
var lines = geometry.OfType<Line>().Where(l => l.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
|
||||||
|
// Hole circles must be at correct positions
|
||||||
|
Assert.Equal(2, circles.Count);
|
||||||
|
Assert.Equal(holeCenter1.X, circles[0].Center.X, 2);
|
||||||
|
Assert.Equal(holeCenter1.Y, circles[0].Center.Y, 2);
|
||||||
|
Assert.Equal(holeCenter2.X, circles[1].Center.X, 2);
|
||||||
|
Assert.Equal(holeCenter2.Y, circles[1].Center.Y, 2);
|
||||||
|
Assert.Equal(holeRadius, circles[0].Radius, 2);
|
||||||
|
Assert.Equal(holeRadius, circles[1].Radius, 2);
|
||||||
|
|
||||||
|
// Perimeter lines must stay within the original 10x10 bounding box.
|
||||||
|
// This catches the mode conversion bug where perimeter gets shifted
|
||||||
|
// by the last hole's position.
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
Assert.True(line.StartPoint.X >= -1 && line.StartPoint.X <= 11,
|
||||||
|
$"Perimeter line start X={line.StartPoint.X} is outside the 10x10 part bounds");
|
||||||
|
Assert.True(line.StartPoint.Y >= -1 && line.StartPoint.Y <= 11,
|
||||||
|
$"Perimeter line start Y={line.StartPoint.Y} is outside the 10x10 part bounds");
|
||||||
|
Assert.True(line.EndPoint.X >= -1 && line.EndPoint.X <= 11,
|
||||||
|
$"Perimeter line end X={line.EndPoint.X} is outside the 10x10 part bounds");
|
||||||
|
Assert.True(line.EndPoint.Y >= -1 && line.EndPoint.Y <= 11,
|
||||||
|
$"Perimeter line end Y={line.EndPoint.Y} is outside the 10x10 part bounds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_BoundingBox_IncludesSubProgramOffset()
|
||||||
|
{
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(1, 0));
|
||||||
|
|
||||||
|
var main = new Program(Mode.Absolute);
|
||||||
|
main.SubPrograms[1] = sub;
|
||||||
|
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(10, 20) });
|
||||||
|
|
||||||
|
var box = main.BoundingBox();
|
||||||
|
|
||||||
|
// Sub-program line goes from (10,20) to (11,20)
|
||||||
|
Assert.True(box.Right >= 11);
|
||||||
|
Assert.True(box.Top >= 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Program_Rotate_RotatesSubProgramCallOffsets()
|
||||||
|
{
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(1, 0));
|
||||||
|
|
||||||
|
var main = new Program(Mode.Absolute);
|
||||||
|
main.SubPrograms[1] = sub;
|
||||||
|
main.Codes.Add(new SubProgramCall { Id = 1, Program = sub, Offset = new Vector(10, 0) });
|
||||||
|
|
||||||
|
// Rotate 90 degrees CCW around origin
|
||||||
|
main.Rotate(System.Math.PI / 2);
|
||||||
|
|
||||||
|
var call = main.Codes.OfType<SubProgramCall>().First();
|
||||||
|
// (10, 0) rotated 90 CCW = (0, 10)
|
||||||
|
Assert.Equal(0, call.Offset.X, 1);
|
||||||
|
Assert.Equal(10, call.Offset.Y, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,76 @@ namespace OpenNest.Tests.Fill
|
|||||||
{
|
{
|
||||||
public class CompactorTests
|
public class CompactorTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DirectionalDistance_ArcVsInclinedLine_DoesNotOverPush()
|
||||||
|
{
|
||||||
|
// Arc (top semicircle) pushed upward toward a 45° inclined line.
|
||||||
|
// The critical angle on the arc gives a shorter distance than any
|
||||||
|
// sampled vertex (endpoints + cardinal extremes).
|
||||||
|
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
|
||||||
|
var line = new Line(new Vector(3, 4), new Vector(7, 6));
|
||||||
|
|
||||||
|
var moving = new List<Entity> { arc };
|
||||||
|
var stationary = new List<Entity> { line };
|
||||||
|
var direction = new Vector(0, 1); // push up
|
||||||
|
|
||||||
|
var dist = SpatialQuery.DirectionalDistance(moving, stationary, direction);
|
||||||
|
|
||||||
|
// Move the arc up by the computed distance, then verify no overlap.
|
||||||
|
// The topmost reachable point on the arc at the critical angle θ ≈ 2.034
|
||||||
|
// (between π/2 and π) should just touch the line.
|
||||||
|
Assert.True(dist < double.MaxValue, "Should find a finite distance");
|
||||||
|
Assert.True(dist > 0, "Should be a positive distance");
|
||||||
|
|
||||||
|
// Verify: after moving, the closest point on the arc should be within
|
||||||
|
// tolerance of the line, not past it.
|
||||||
|
var theta = System.Math.Atan2(
|
||||||
|
line.pt2.X - line.pt1.X, -(line.pt2.Y - line.pt1.Y));
|
||||||
|
theta = OpenNest.Math.Angle.NormalizeRad(theta + System.Math.PI);
|
||||||
|
var qx = arc.Center.X + arc.Radius * System.Math.Cos(theta);
|
||||||
|
var qy = arc.Center.Y + arc.Radius * System.Math.Sin(theta) + dist;
|
||||||
|
|
||||||
|
// The moved point should be on or just touching the line, not past it.
|
||||||
|
// Line equation: (y - 4) / (x - 3) = (6 - 4) / (7 - 3) = 0.5
|
||||||
|
// y = 0.5x + 2.5
|
||||||
|
var lineYAtQx = 0.5 * qx + 2.5;
|
||||||
|
Assert.True(qy <= lineYAtQx + 0.001,
|
||||||
|
$"Arc point ({qx:F4}, {qy:F4}) should not be past line (line Y={lineYAtQx:F4} at X={qx:F4}). " +
|
||||||
|
$"dist={dist:F6}, overshot by {qy - lineYAtQx:F6}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DirectionalDistance_ArcVsInclinedLine_BetterThanVertexSampling()
|
||||||
|
{
|
||||||
|
// Same geometry — verify the analytical Phase 3 finds a shorter
|
||||||
|
// distance than the Phase 1/2 vertex sampling alone would.
|
||||||
|
var arc = new Arc(5, 0, 2, 0, System.Math.PI);
|
||||||
|
var line = new Line(new Vector(3, 4), new Vector(7, 6));
|
||||||
|
|
||||||
|
// Phase 1/2 vertex-only distance: sample arc endpoints + cardinal extreme.
|
||||||
|
var vertices = new[]
|
||||||
|
{
|
||||||
|
new Vector(7, 0), // arc endpoint θ=0
|
||||||
|
new Vector(3, 0), // arc endpoint θ=π
|
||||||
|
new Vector(5, 2), // cardinal extreme θ=π/2
|
||||||
|
};
|
||||||
|
|
||||||
|
var vertexMin = double.MaxValue;
|
||||||
|
foreach (var v in vertices)
|
||||||
|
{
|
||||||
|
var d = SpatialQuery.RayEdgeDistance(v.X, v.Y,
|
||||||
|
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y, 0, 1);
|
||||||
|
if (d < vertexMin) vertexMin = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full directional distance (includes Phase 3 arc-to-line).
|
||||||
|
var moving = new List<Entity> { arc };
|
||||||
|
var stationary = new List<Entity> { line };
|
||||||
|
var fullDist = SpatialQuery.DirectionalDistance(moving, stationary, new Vector(0, 1));
|
||||||
|
|
||||||
|
Assert.True(fullDist < vertexMin,
|
||||||
|
$"Full distance ({fullDist:F6}) should be less than vertex-only ({vertexMin:F6})");
|
||||||
|
}
|
||||||
private static Drawing MakeRectDrawing(double w, double h)
|
private static Drawing MakeRectDrawing(double w, double h)
|
||||||
{
|
{
|
||||||
var pgm = new OpenNest.CNC.Program();
|
var pgm = new OpenNest.CNC.Program();
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO
|
||||||
|
{
|
||||||
|
public class CadImporterTests
|
||||||
|
{
|
||||||
|
private static string TestDxf =>
|
||||||
|
Path.Combine("Bending", "TestData", "4526 A14 PT11.dxf");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_LoadsEntitiesAndDetectsBends()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotEmpty(result.Entities);
|
||||||
|
Assert.NotNull(result.Bends);
|
||||||
|
Assert.NotNull(result.Bounds);
|
||||||
|
Assert.Equal(TestDxf, result.SourcePath);
|
||||||
|
Assert.Equal("4526 A14 PT11", result.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_WhenDetectBendsFalse_ReturnsEmptyBends()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf, new CadImportOptions { DetectBends = false });
|
||||||
|
|
||||||
|
Assert.Empty(result.Bends);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_WhenNameOverrideProvided_UsesOverride()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf, new CadImportOptions { Name = "custom" });
|
||||||
|
|
||||||
|
Assert.Equal("custom", result.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Import_WhenNamedDetectorDoesNotExist_ReturnsEmptyBends()
|
||||||
|
{
|
||||||
|
// Exercises the named-detector branch: when BendDetectorName doesn't
|
||||||
|
// match any registered detector, bends should be an empty list
|
||||||
|
// (not a crash, and no fall-through to auto-detect).
|
||||||
|
var result = CadImporter.Import(TestDxf,
|
||||||
|
new CadImportOptions { BendDetectorName = "__nonexistent__" });
|
||||||
|
|
||||||
|
Assert.Empty(result.Bends);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDrawing_ProducesDrawingWithProgramAndMetadata()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf);
|
||||||
|
|
||||||
|
var drawing = CadImporter.BuildDrawing(
|
||||||
|
result,
|
||||||
|
result.Entities,
|
||||||
|
result.Bends,
|
||||||
|
quantity: 5,
|
||||||
|
customer: "ACME",
|
||||||
|
editedProgram: null);
|
||||||
|
|
||||||
|
Assert.NotNull(drawing);
|
||||||
|
Assert.Equal("4526 A14 PT11", drawing.Name);
|
||||||
|
Assert.Equal("ACME", drawing.Customer);
|
||||||
|
Assert.Equal(5, drawing.Quantity.Required);
|
||||||
|
Assert.Equal(TestDxf, drawing.Source.Path);
|
||||||
|
Assert.NotNull(drawing.Program);
|
||||||
|
Assert.NotEmpty(drawing.Program.Codes);
|
||||||
|
Assert.NotNull(drawing.SourceEntities);
|
||||||
|
Assert.NotEmpty(drawing.SourceEntities);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDrawing_ExtractsFirstRapidAsSourceOffset()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf);
|
||||||
|
|
||||||
|
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends,
|
||||||
|
quantity: 1, customer: null, editedProgram: null);
|
||||||
|
|
||||||
|
Assert.NotNull(drawing.Source.Offset);
|
||||||
|
// After offset extraction, the program's first rapid must start at origin.
|
||||||
|
var firstRapid = (OpenNest.CNC.RapidMove)drawing.Program.Codes[0];
|
||||||
|
Assert.Equal(0, firstRapid.EndPoint.X, 6);
|
||||||
|
Assert.Equal(0, firstRapid.EndPoint.Y, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDrawing_WhenEntityHidden_TracksSuppressedId()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf);
|
||||||
|
// Suppress the first non-bend-source entity
|
||||||
|
var bendSources = result.Bends
|
||||||
|
.Where(b => b.SourceEntity != null)
|
||||||
|
.Select(b => b.SourceEntity)
|
||||||
|
.ToHashSet();
|
||||||
|
var hidden = result.Entities.First(e => !bendSources.Contains(e));
|
||||||
|
hidden.IsVisible = false;
|
||||||
|
|
||||||
|
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends,
|
||||||
|
quantity: 1, customer: null, editedProgram: null);
|
||||||
|
|
||||||
|
Assert.Contains(hidden.Id, drawing.SuppressedEntityIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDrawing_WhenEditedProgramProvided_UsesEditedProgram()
|
||||||
|
{
|
||||||
|
var result = CadImporter.Import(TestDxf);
|
||||||
|
var edited = new OpenNest.CNC.Program();
|
||||||
|
edited.MoveTo(new OpenNest.Geometry.Vector(0, 0));
|
||||||
|
|
||||||
|
var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends,
|
||||||
|
quantity: 1, customer: null, editedProgram: edited);
|
||||||
|
|
||||||
|
Assert.Same(edited, drawing.Program);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ImportDrawing_ComposesImportAndBuild()
|
||||||
|
{
|
||||||
|
var drawing = CadImporter.ImportDrawing(TestDxf,
|
||||||
|
new CadImportOptions { Quantity = 3, Customer = "ACME" });
|
||||||
|
|
||||||
|
Assert.NotNull(drawing);
|
||||||
|
Assert.Equal("4526 A14 PT11", drawing.Name);
|
||||||
|
Assert.Equal(3, drawing.Quantity.Required);
|
||||||
|
Assert.Equal("ACME", drawing.Customer);
|
||||||
|
Assert.NotNull(drawing.Program);
|
||||||
|
Assert.NotNull(drawing.SourceEntities);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO;
|
||||||
|
|
||||||
|
public class SubProgramSerializationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NestWriter_WritesSubProgramCall_WithOffset()
|
||||||
|
{
|
||||||
|
var nest = CreateNestWithHoleSubProgram();
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
var writer = new NestWriter(nest);
|
||||||
|
writer.Write(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var reader = new NestReader(stream);
|
||||||
|
var loaded = reader.Read();
|
||||||
|
|
||||||
|
var drawing = loaded.Drawings.First();
|
||||||
|
var calls = drawing.Program.Codes.OfType<SubProgramCall>().ToList();
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal(5, calls[0].Offset.X, 1);
|
||||||
|
Assert.Equal(5, calls[0].Offset.Y, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NestWriter_WritesSubPrograms_AndRestoresOnLoad()
|
||||||
|
{
|
||||||
|
var nest = CreateNestWithHoleSubProgram();
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
var writer = new NestWriter(nest);
|
||||||
|
writer.Write(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
|
||||||
|
var reader = new NestReader(stream);
|
||||||
|
var loaded = reader.Read();
|
||||||
|
|
||||||
|
var drawing = loaded.Drawings.First();
|
||||||
|
Assert.True(drawing.Program.SubPrograms.Count > 0);
|
||||||
|
|
||||||
|
var call = drawing.Program.Codes.OfType<SubProgramCall>().First();
|
||||||
|
Assert.True(drawing.Program.SubPrograms.ContainsKey(call.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Nest CreateNestWithHoleSubProgram()
|
||||||
|
{
|
||||||
|
var sub = new Program(Mode.Incremental);
|
||||||
|
sub.Codes.Add(new LinearMove(0.1, 0) { Layer = LayerType.Leadin });
|
||||||
|
sub.Codes.Add(new ArcMove(new Vector(0, 0), new Vector(-0.5, 0), RotationType.CW));
|
||||||
|
|
||||||
|
var pgm = new Program(Mode.Absolute);
|
||||||
|
pgm.SubPrograms[42] = sub;
|
||||||
|
pgm.Codes.Add(new SubProgramCall { Id = 42, Program = sub, Offset = new Vector(5, 5) });
|
||||||
|
// Add perimeter so the drawing has non-zero geometry
|
||||||
|
pgm.Codes.Add(new RapidMove(0, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 0));
|
||||||
|
pgm.Codes.Add(new LinearMove(10, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 10));
|
||||||
|
pgm.Codes.Add(new LinearMove(0, 0));
|
||||||
|
|
||||||
|
var drawing = new Drawing("TestPart") { Program = pgm };
|
||||||
|
var nest = new Nest();
|
||||||
|
nest.Drawings.Add(drawing);
|
||||||
|
|
||||||
|
var plate = new Plate { Size = new Size(48, 96) };
|
||||||
|
plate.Parts.Add(new Part(drawing));
|
||||||
|
nest.Plates.Add(plate);
|
||||||
|
|
||||||
|
return nest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@
|
|||||||
<Content Include="Bending\TestData\**\*">
|
<Content Include="Bending\TestData\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Splitting\TestData\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
public class PlateSnapToStandardSizeTests
|
||||||
|
{
|
||||||
|
private static Part MakeRectPart(double x, double y, double length, double width)
|
||||||
|
{
|
||||||
|
var pgm = new Program();
|
||||||
|
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(length, width)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, width)));
|
||||||
|
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||||
|
var drawing = new Drawing("test", pgm);
|
||||||
|
var part = new Part(drawing);
|
||||||
|
part.Offset(x, y);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_SmallParts_SnapsToIncrement()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200); // oversized starting size
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 10, 20));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
// 10x20 is well below 48x48 MinSheet -> snap to integer increment.
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
Assert.Equal(10, plate.Size.Length); // X axis
|
||||||
|
Assert.Equal(20, plate.Size.Width); // Y axis
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_SmallPartsWithFractionalIncrement_UsesIncrement()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 10.3, 20.7));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize(new PlateSizeOptions { SnapIncrement = 0.25 });
|
||||||
|
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
Assert.Equal(10.5, plate.Size.Length, 4);
|
||||||
|
Assert.Equal(20.75, plate.Size.Width, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_40x90Part_SnapsToStandard48x96_XLong()
|
||||||
|
{
|
||||||
|
// Part is 90 long (X) x 40 wide (Y) -> X is the long axis.
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 90, 40));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
Assert.Equal(96, plate.Size.Length); // X axis = long
|
||||||
|
Assert.Equal(48, plate.Size.Width); // Y axis = short
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_90TallPart_SnapsToStandard48x96_YLong()
|
||||||
|
{
|
||||||
|
// Part is 40 long (X) x 90 wide (Y) -> Y is the long axis.
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 40, 90));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
Assert.Equal(48, plate.Size.Length); // X axis = short
|
||||||
|
Assert.Equal(96, plate.Size.Width); // Y axis = long
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_JustOver48_PicksNextStandardSize()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 100, 50));
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
Assert.Equal(120, plate.Size.Length); // X long
|
||||||
|
Assert.Equal(60, plate.Size.Width);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_EmptyPlate_DoesNotModifySize()
|
||||||
|
{
|
||||||
|
var plate = new Plate(60, 120);
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
Assert.Equal(60, plate.Size.Width);
|
||||||
|
Assert.Equal(120, plate.Size.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SnapToStandardSize_MultipleParts_UsesCombinedEnvelope()
|
||||||
|
{
|
||||||
|
var plate = new Plate(200, 200);
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 0, 30, 40));
|
||||||
|
plate.Parts.Add(MakeRectPart(30, 0, 30, 40)); // combined X-extent = 60
|
||||||
|
plate.Parts.Add(MakeRectPart(0, 40, 60, 60)); // combined extent = 60 x 100
|
||||||
|
|
||||||
|
var result = plate.SnapToStandardSize();
|
||||||
|
|
||||||
|
// 60 x 100 fits 60x120 standard sheet, Y is the long axis.
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
Assert.Equal(60, plate.Size.Length); // X
|
||||||
|
Assert.Equal(120, plate.Size.Width); // Y long
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using OpenNest.Shapes;
|
|
||||||
|
|
||||||
namespace OpenNest.Tests.Shapes;
|
|
||||||
|
|
||||||
public class FlangeShapeTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_BoundingBoxMatchesOD()
|
|
||||||
{
|
|
||||||
var shape = new FlangeShape
|
|
||||||
{
|
|
||||||
OD = 10,
|
|
||||||
HoleDiameter = 1,
|
|
||||||
HolePatternDiameter = 7,
|
|
||||||
HoleCount = 4
|
|
||||||
};
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
var bbox = drawing.Program.BoundingBox();
|
|
||||||
Assert.Equal(10, bbox.Width, 0.01);
|
|
||||||
Assert.Equal(10, bbox.Length, 0.01);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_AreaExcludesBoltHoles()
|
|
||||||
{
|
|
||||||
var shape = new FlangeShape
|
|
||||||
{
|
|
||||||
OD = 10,
|
|
||||||
HoleDiameter = 1,
|
|
||||||
HolePatternDiameter = 7,
|
|
||||||
HoleCount = 4
|
|
||||||
};
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
// Area = pi * 5^2 - 4 * pi * 0.5^2 = pi * (25 - 1) = pi * 24
|
|
||||||
var expectedArea = System.Math.PI * 24;
|
|
||||||
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetDrawing_DefaultName_IsFlange()
|
|
||||||
{
|
|
||||||
var shape = new FlangeShape
|
|
||||||
{
|
|
||||||
OD = 10,
|
|
||||||
HoleDiameter = 1,
|
|
||||||
HolePatternDiameter = 7,
|
|
||||||
HoleCount = 4
|
|
||||||
};
|
|
||||||
var drawing = shape.GetDrawing();
|
|
||||||
|
|
||||||
Assert.Equal("Flange", drawing.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void LoadFromJson_ProducesCorrectDrawing()
|
|
||||||
{
|
|
||||||
var json = """
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"Name": "2in-150#",
|
|
||||||
"NominalPipeSize": 2.0,
|
|
||||||
"OD": 6.0,
|
|
||||||
"HoleDiameter": 0.75,
|
|
||||||
"HolePatternDiameter": 4.75,
|
|
||||||
"HoleCount": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "2in-300#",
|
|
||||||
"NominalPipeSize": 2.0,
|
|
||||||
"OD": 6.5,
|
|
||||||
"HoleDiameter": 0.75,
|
|
||||||
"HolePatternDiameter": 5.0,
|
|
||||||
"HoleCount": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
""";
|
|
||||||
|
|
||||||
var tempFile = Path.GetTempFileName();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
File.WriteAllText(tempFile, json);
|
|
||||||
|
|
||||||
var flanges = ShapeDefinition.LoadFromJson<FlangeShape>(tempFile);
|
|
||||||
|
|
||||||
Assert.Equal(2, flanges.Count);
|
|
||||||
|
|
||||||
var first = flanges[0];
|
|
||||||
Assert.Equal("2in-150#", first.Name);
|
|
||||||
var drawing = first.GetDrawing();
|
|
||||||
var bbox = drawing.Program.BoundingBox();
|
|
||||||
Assert.Equal(6, bbox.Width, 0.01);
|
|
||||||
|
|
||||||
var second = flanges[1];
|
|
||||||
Assert.Equal("2in-300#", second.Name);
|
|
||||||
Assert.Equal(8, second.HoleCount);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
File.Delete(tempFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class PipeFlangeShapeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_BoundingBoxMatchesOD()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var bbox = drawing.Program.BoundingBox();
|
||||||
|
Assert.Equal(10, bbox.Width, 0.01);
|
||||||
|
Assert.Equal(10, bbox.Length, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_AreaExcludesBoltHoles()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
Blind = true
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_DefaultName_IsPipeFlange()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
Assert.Equal("PipeFlange", drawing.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_WithPipeSize_CutsCenterBoreAtPipeODPlusClearance()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = "2", // OD = 2.375
|
||||||
|
PipeClearance = 0.125,
|
||||||
|
Blind = false
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
// Expected bore diameter = 2.375 + 0.125 = 2.5
|
||||||
|
// Area = pi * (5^2 - 0.5^2 * 4 - 1.25^2) = pi * (25 - 1 - 1.5625) = pi * 22.4375
|
||||||
|
var expectedArea = System.Math.PI * 22.4375;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_Blind_OmitsCenterBore()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = "2",
|
||||||
|
PipeClearance = 0.125,
|
||||||
|
Blind = true
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
// With Blind=true, area = outer - 4 bolt holes = pi * (25 - 1) = pi * 24
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetDrawing_UnknownPipeSize_OmitsCenterBore()
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = "not-a-real-pipe",
|
||||||
|
PipeClearance = 0.125,
|
||||||
|
Blind = false
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
// Unknown pipe size → no bore, area matches blind case
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
public void GetDrawing_NullOrEmptyPipeSize_OmitsCenterBore(string pipeSize)
|
||||||
|
{
|
||||||
|
var shape = new PipeFlangeShape
|
||||||
|
{
|
||||||
|
OD = 10,
|
||||||
|
HoleDiameter = 1,
|
||||||
|
HolePatternDiameter = 7,
|
||||||
|
HoleCount = 4,
|
||||||
|
PipeSize = pipeSize,
|
||||||
|
PipeClearance = 0.125
|
||||||
|
};
|
||||||
|
var drawing = shape.GetDrawing();
|
||||||
|
|
||||||
|
var expectedArea = System.Math.PI * 24;
|
||||||
|
Assert.Equal(expectedArea, drawing.Area, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_ProducesCorrectDrawing()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Name": "2in-150#",
|
||||||
|
"PipeSize": "2",
|
||||||
|
"PipeClearance": 0.0625,
|
||||||
|
"OD": 6.0,
|
||||||
|
"HoleDiameter": 0.75,
|
||||||
|
"HolePatternDiameter": 4.75,
|
||||||
|
"HoleCount": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "2in-300#",
|
||||||
|
"PipeSize": "2",
|
||||||
|
"PipeClearance": 0.0625,
|
||||||
|
"OD": 6.5,
|
||||||
|
"HoleDiameter": 0.75,
|
||||||
|
"HolePatternDiameter": 5.0,
|
||||||
|
"HoleCount": 8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
|
||||||
|
var tempFile = Path.GetTempFileName();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(tempFile, json);
|
||||||
|
|
||||||
|
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(tempFile);
|
||||||
|
|
||||||
|
Assert.Equal(2, flanges.Count);
|
||||||
|
|
||||||
|
var first = flanges[0];
|
||||||
|
Assert.Equal("2in-150#", first.Name);
|
||||||
|
Assert.Equal("2", first.PipeSize);
|
||||||
|
Assert.Equal(0.0625, first.PipeClearance, 0.0001);
|
||||||
|
var drawing = first.GetDrawing();
|
||||||
|
var bbox = drawing.Program.BoundingBox();
|
||||||
|
Assert.Equal(6, bbox.Width, 0.01);
|
||||||
|
|
||||||
|
var second = flanges[1];
|
||||||
|
Assert.Equal("2in-300#", second.Name);
|
||||||
|
Assert.Equal(8, second.HoleCount);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(tempFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadFromJson_RealShippedConfig_LoadsAllEntries()
|
||||||
|
{
|
||||||
|
// Resolve the repo-relative config path from the test binary location.
|
||||||
|
var dir = AppDomain.CurrentDomain.BaseDirectory;
|
||||||
|
while (dir != null && !File.Exists(Path.Combine(dir, "OpenNest.sln")))
|
||||||
|
dir = Path.GetDirectoryName(dir);
|
||||||
|
|
||||||
|
Assert.NotNull(dir);
|
||||||
|
|
||||||
|
var configPath = Path.Combine(dir, "OpenNest", "Configurations", "PipeFlangeShape.json");
|
||||||
|
Assert.True(File.Exists(configPath), $"Config missing at {configPath}");
|
||||||
|
|
||||||
|
var flanges = ShapeDefinition.LoadFromJson<PipeFlangeShape>(configPath);
|
||||||
|
|
||||||
|
Assert.NotEmpty(flanges);
|
||||||
|
foreach (var f in flanges)
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(f.PipeSize));
|
||||||
|
Assert.True(PipeSizes.TryGetOD(f.PipeSize, out _),
|
||||||
|
$"Unknown PipeSize '{f.PipeSize}' in entry '{f.Name}'");
|
||||||
|
Assert.Equal(0.0625, f.PipeClearance, 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class PipeSizesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void All_ContainsExpectedCount()
|
||||||
|
{
|
||||||
|
Assert.Equal(35, PipeSizes.All.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_IsSortedByOuterDiameterAscending()
|
||||||
|
{
|
||||||
|
for (var i = 1; i < PipeSizes.All.Count; i++)
|
||||||
|
Assert.True(PipeSizes.All[i].OuterDiameter > PipeSizes.All[i - 1].OuterDiameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("1/8", 0.405)]
|
||||||
|
[InlineData("1/2", 0.840)]
|
||||||
|
[InlineData("2", 2.375)]
|
||||||
|
[InlineData("2 1/2", 2.875)]
|
||||||
|
[InlineData("12", 12.750)]
|
||||||
|
[InlineData("48", 48.000)]
|
||||||
|
public void TryGetOD_KnownLabel_ReturnsExpectedOD(string label, double expected)
|
||||||
|
{
|
||||||
|
Assert.True(PipeSizes.TryGetOD(label, out var od));
|
||||||
|
Assert.Equal(expected, od, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetOD_UnknownLabel_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(PipeSizes.TryGetOD("bogus", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFittingSizes_FiltersByMaxOD()
|
||||||
|
{
|
||||||
|
var results = PipeSizes.GetFittingSizes(3.0).ToList();
|
||||||
|
|
||||||
|
Assert.Contains(results, e => e.Label == "2 1/2");
|
||||||
|
Assert.DoesNotContain(results, e => e.Label == "3");
|
||||||
|
Assert.DoesNotContain(results, e => e.Label == "4");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFittingSizes_ExactBoundary_IsInclusive()
|
||||||
|
{
|
||||||
|
// NPS 3 has OD 3.500; passing maxOD = 3.500 should include it.
|
||||||
|
var results = PipeSizes.GetFittingSizes(3.500).ToList();
|
||||||
|
|
||||||
|
Assert.Contains(results, e => e.Label == "3");
|
||||||
|
Assert.DoesNotContain(results, e => e.Label == "3 1/2");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFittingSizes_MaxSmallerThanSmallest_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
Assert.Empty(PipeSizes.GetFittingSizes(0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Shapes;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Shapes;
|
||||||
|
|
||||||
|
public class PlateSizesTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void All_IsNotEmpty()
|
||||||
|
{
|
||||||
|
Assert.NotEmpty(PlateSizes.All);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_DoesNotContain48x48()
|
||||||
|
{
|
||||||
|
// 48x48 is not a standard sheet - it's the default MinSheet threshold only.
|
||||||
|
Assert.DoesNotContain(PlateSizes.All, e => e.Width == 48 && e.Length == 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_Smallest_Is48x96()
|
||||||
|
{
|
||||||
|
var smallest = PlateSizes.All.OrderBy(e => e.Area).First();
|
||||||
|
Assert.Equal(48, smallest.Width);
|
||||||
|
Assert.Equal(96, smallest.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_SortedByAreaAscending()
|
||||||
|
{
|
||||||
|
for (var i = 1; i < PlateSizes.All.Count; i++)
|
||||||
|
Assert.True(PlateSizes.All[i].Area >= PlateSizes.All[i - 1].Area);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void All_Entries_AreCanonical_WidthLessOrEqualLength()
|
||||||
|
{
|
||||||
|
foreach (var entry in PlateSizes.All)
|
||||||
|
Assert.True(entry.Width <= entry.Length, $"{entry.Label} not in canonical orientation");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(40, 40, true)] // small - fits trivially
|
||||||
|
[InlineData(48, 96, true)] // exact
|
||||||
|
[InlineData(96, 48, true)] // rotated exact
|
||||||
|
[InlineData(90, 40, true)] // rotated
|
||||||
|
[InlineData(49, 97, false)] // just over in both dims
|
||||||
|
[InlineData(50, 50, false)] // too wide in both orientations
|
||||||
|
public void Entry_Fits_RespectsRotation(double w, double h, bool expected)
|
||||||
|
{
|
||||||
|
var entry = new PlateSizes.Entry("48x96", 48, 96);
|
||||||
|
Assert.Equal(expected, entry.Fits(w, h));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_KnownLabel_ReturnsEntry()
|
||||||
|
{
|
||||||
|
Assert.True(PlateSizes.TryGet("48x96", out var entry));
|
||||||
|
Assert.Equal(48, entry.Width);
|
||||||
|
Assert.Equal(96, entry.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
Assert.True(PlateSizes.TryGet("48X96", out var entry));
|
||||||
|
Assert.Equal(48, entry.Width);
|
||||||
|
Assert.Equal(96, entry.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_UnknownLabel_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(PlateSizes.TryGet("bogus", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BelowMin_SnapsToDefaultIncrementOfOne()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 10.3, 20.7);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(11, result.Width);
|
||||||
|
Assert.Equal(21, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BelowMin_UsesCustomIncrement()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 10.3, 20.7);
|
||||||
|
var options = new PlateSizeOptions { SnapIncrement = 0.25 };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(10.5, result.Width, 4);
|
||||||
|
Assert.Equal(20.75, result.Length, 4);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_ExactlyAtMin_Snaps()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 48, 48);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(48, result.Width);
|
||||||
|
Assert.Equal(48, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AboveMin_PicksSmallestContainingStandardSheet()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 40, 90);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(48, result.Width);
|
||||||
|
Assert.Equal(96, result.Length);
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AboveMin_WithRotation_PicksSmallestSheet()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 90, 40);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_JustOver48_PicksNextStandardSize()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 50, 100);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(60, result.Width);
|
||||||
|
Assert.Equal(120, result.Length);
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_MarginIsAppliedPerSide()
|
||||||
|
{
|
||||||
|
// 46 + 2*1 = 48 (fits exactly), 94 + 2*1 = 96 (fits exactly)
|
||||||
|
var bbox = new Box(0, 0, 46, 94);
|
||||||
|
var options = new PlateSizeOptions { Margin = 1 };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_MarginPushesToNextSheet()
|
||||||
|
{
|
||||||
|
// 47 + 2 = 49 > 48, so 48x96 no longer fits -> next standard
|
||||||
|
var bbox = new Box(0, 0, 47, 95);
|
||||||
|
var options = new PlateSizeOptions { Margin = 1 };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.NotEqual("48x96", result.MatchedLabel);
|
||||||
|
Assert.True(result.Width >= 49);
|
||||||
|
Assert.True(result.Length >= 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AllowedSizes_StandardLabelWhitelist()
|
||||||
|
{
|
||||||
|
// 60x120 is the only option; 50x50 is above min so it routes to standard
|
||||||
|
var bbox = new Box(0, 0, 50, 50);
|
||||||
|
var options = new PlateSizeOptions { AllowedSizes = new[] { "60x120" } };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal("60x120", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_AllowedSizes_ArbitraryWxHString()
|
||||||
|
{
|
||||||
|
// 50x100 isn't in the standard catalog but is valid as an ad-hoc entry.
|
||||||
|
// bbox 49x99 doesn't fit 48x96 or 48x120, does fit 50x100 and 60x120,
|
||||||
|
// but only 50x100 is allowed.
|
||||||
|
var bbox = new Box(0, 0, 49, 99);
|
||||||
|
var options = new PlateSizeOptions { AllowedSizes = new[] { "50x100" } };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Width);
|
||||||
|
Assert.Equal(100, result.Length);
|
||||||
|
Assert.Equal("50x100", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_NothingFits_FallsBackToSnapUp()
|
||||||
|
{
|
||||||
|
// Larger than any catalog sheet
|
||||||
|
var bbox = new Box(0, 0, 100, 300);
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox);
|
||||||
|
|
||||||
|
Assert.Equal(100, result.Width);
|
||||||
|
Assert.Equal(300, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_NothingFitsInAllowedList_FallsBackToSnapUp()
|
||||||
|
{
|
||||||
|
// Only 48x96 allowed, but bbox is too big for it
|
||||||
|
var bbox = new Box(0, 0, 50, 100);
|
||||||
|
var options = new PlateSizeOptions { AllowedSizes = new[] { "48x96" } };
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Width);
|
||||||
|
Assert.Equal(100, result.Length);
|
||||||
|
Assert.Null(result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BoxEnumerable_CombinesIntoEnvelope()
|
||||||
|
{
|
||||||
|
// Two boxes that together span 0..40 x 0..90 -> fits 48x96
|
||||||
|
var boxes = new[]
|
||||||
|
{
|
||||||
|
new Box(0, 0, 40, 50),
|
||||||
|
new Box(0, 40, 30, 50),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(boxes);
|
||||||
|
|
||||||
|
Assert.Equal("48x96", result.MatchedLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_BoxEnumerable_Empty_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<System.ArgumentException>(
|
||||||
|
() => PlateSizes.Recommend(System.Array.Empty<Box>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PlateSizeOptions_Defaults()
|
||||||
|
{
|
||||||
|
var options = new PlateSizeOptions();
|
||||||
|
|
||||||
|
Assert.Equal(48, options.MinSheetWidth);
|
||||||
|
Assert.Equal(48, options.MinSheetLength);
|
||||||
|
Assert.Equal(1.0, options.SnapIncrement);
|
||||||
|
Assert.Equal(0, options.Margin);
|
||||||
|
Assert.Null(options.AllowedSizes);
|
||||||
|
Assert.Equal(PlateSizeSelection.SmallestArea, options.Selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_NarrowestFirst_PicksNarrowerSheetOverSmallerArea()
|
||||||
|
{
|
||||||
|
// Hypothetical: bbox (47, 47) fits both 48x96 (area 4608) and some narrower option.
|
||||||
|
// With SmallestArea: picks 48x96 (it's already the smallest 48-wide).
|
||||||
|
// With NarrowestFirst: also picks 48x96 since that's the narrowest.
|
||||||
|
// Better test: AllowedSizes = ["60x120", "48x120"] with bbox that fits both.
|
||||||
|
// 48x120 (area 5760) is narrower; 60x120 (area 7200) has more area.
|
||||||
|
// SmallestArea picks 48x120; NarrowestFirst also picks 48x120. Both pick the same.
|
||||||
|
//
|
||||||
|
// Real divergence: AllowedSizes = ["60x120", "72x120"] with bbox 55x100.
|
||||||
|
// 60x120 has narrower width (60) AND smaller area (7200 vs 8640), so both agree.
|
||||||
|
//
|
||||||
|
// To force divergence: AllowedSizes = ["60x96", "48x144"] with bbox 47x95.
|
||||||
|
// 60x96 area = 5760, 48x144 area = 6912. SmallestArea -> 60x96.
|
||||||
|
// NarrowestFirst width 48 < 60 -> 48x144.
|
||||||
|
var bbox = new Box(0, 0, 47, 95);
|
||||||
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
AllowedSizes = new[] { "60x96", "48x144" },
|
||||||
|
Selection = PlateSizeSelection.NarrowestFirst,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(48, result.Width);
|
||||||
|
Assert.Equal(144, result.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Recommend_SmallestArea_PicksSmallerAreaOverNarrowerWidth()
|
||||||
|
{
|
||||||
|
var bbox = new Box(0, 0, 47, 95);
|
||||||
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
AllowedSizes = new[] { "60x96", "48x144" },
|
||||||
|
Selection = PlateSizeSelection.SmallestArea,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = PlateSizes.Recommend(bbox, options);
|
||||||
|
|
||||||
|
Assert.Equal(60, result.Width);
|
||||||
|
Assert.Equal(96, result.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -384,6 +384,161 @@ public class DrawingSplitterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips()
|
||||||
|
{
|
||||||
|
// 255x55 outer rectangle with a 235x35 interior slot centered at (10,10)-(245,45).
|
||||||
|
// 4 vertical splits at x = 55, 110, 165, 220.
|
||||||
|
//
|
||||||
|
// Expected: regions R2/R3/R4 are entirely "over" the slot horizontally, so the
|
||||||
|
// surviving material in each is two physically disjoint strips (upper + lower).
|
||||||
|
// R1 and R5 each have a solid edge that connects the top and bottom strips, so
|
||||||
|
// they remain single (notched) pieces.
|
||||||
|
//
|
||||||
|
// Total output drawings: 1 (R1) + 2 (R2) + 2 (R3) + 2 (R4) + 1 (R5) = 8.
|
||||||
|
var outerEntities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(new Vector(0, 0), new Vector(255, 0)),
|
||||||
|
new Line(new Vector(255, 0), new Vector(255, 55)),
|
||||||
|
new Line(new Vector(255, 55), new Vector(0, 55)),
|
||||||
|
new Line(new Vector(0, 55), new Vector(0, 0))
|
||||||
|
};
|
||||||
|
var slotEntities = new List<Entity>
|
||||||
|
{
|
||||||
|
new Line(new Vector(10, 10), new Vector(245, 10)),
|
||||||
|
new Line(new Vector(245, 10), new Vector(245, 45)),
|
||||||
|
new Line(new Vector(245, 45), new Vector(10, 45)),
|
||||||
|
new Line(new Vector(10, 45), new Vector(10, 10))
|
||||||
|
};
|
||||||
|
var allEntities = new List<Entity>();
|
||||||
|
allEntities.AddRange(outerEntities);
|
||||||
|
allEntities.AddRange(slotEntities);
|
||||||
|
|
||||||
|
var drawing = new Drawing("SLOT", ConvertGeometry.ToProgram(allEntities));
|
||||||
|
var originalArea = drawing.Area;
|
||||||
|
|
||||||
|
var splitLines = new List<SplitLine>
|
||||||
|
{
|
||||||
|
new SplitLine(55.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(110.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(165.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(220.0, CutOffAxis.Vertical)
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
|
||||||
|
|
||||||
|
// R1 (0..55) → 1 notched piece, height 55
|
||||||
|
// R2 (55..110) → upper strip + lower strip, each height 10
|
||||||
|
// R3 (110..165)→ upper strip + lower strip, each height 10
|
||||||
|
// R4 (165..220)→ upper strip + lower strip, each height 10
|
||||||
|
// R5 (220..255)→ 1 notched piece, height 55
|
||||||
|
Assert.Equal(8, results.Count);
|
||||||
|
|
||||||
|
// Area preservation: sum of all output areas equals (outer − slot).
|
||||||
|
var totalArea = results.Sum(d => d.Area);
|
||||||
|
Assert.Equal(originalArea, totalArea, 1);
|
||||||
|
|
||||||
|
// Box.Length = X-extent, Box.Width = Y-extent.
|
||||||
|
// Exactly 6 strips (Y-extent ~10mm) from the three middle regions, and
|
||||||
|
// exactly 2 notched pieces (Y-extent 55mm) from R1 and R5.
|
||||||
|
var strips = results
|
||||||
|
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 10.0) < 0.5)
|
||||||
|
.ToList();
|
||||||
|
var notched = results
|
||||||
|
.Where(d => System.Math.Abs(d.Program.BoundingBox().Width - 55.0) < 0.5)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Equal(6, strips.Count);
|
||||||
|
Assert.Equal(2, notched.Count);
|
||||||
|
|
||||||
|
// Each piece should form a closed perimeter (no dangling edges, no gaps).
|
||||||
|
foreach (var piece in results)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(piece.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
|
||||||
|
Assert.True(entities.Count >= 3, $"{piece.Name} must have at least 3 edges");
|
||||||
|
|
||||||
|
for (var i = 0; i < entities.Count; i++)
|
||||||
|
{
|
||||||
|
var end = GetEndPoint(entities[i]);
|
||||||
|
var nextStart = GetStartPoint(entities[(i + 1) % entities.Count]);
|
||||||
|
var gap = end.DistanceTo(nextStart);
|
||||||
|
Assert.True(gap < 0.01,
|
||||||
|
$"{piece.Name} gap of {gap:F4} between edge {i} end and edge {(i + 1) % entities.Count} start");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Split_DxfFile_WithSpanningSlot_HasNoCutLinesThroughCutout()
|
||||||
|
{
|
||||||
|
// Real DXF regression: 255x55 plate with a centered slot cutout, split into
|
||||||
|
// five columns. Exercises the same path as the synthetic
|
||||||
|
// Split_RectangleWithSpanningSlot_ProducesDisconnectedStrips test but through
|
||||||
|
// the full DXF import pipeline.
|
||||||
|
var path = Path.Combine(AppContext.BaseDirectory, "Splitting", "TestData", "split_test.dxf");
|
||||||
|
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
|
||||||
|
|
||||||
|
var imported = OpenNest.IO.Dxf.Import(path);
|
||||||
|
var profile = new OpenNest.Geometry.ShapeProfile(imported.Entities);
|
||||||
|
|
||||||
|
// Normalize to origin so the split line positions are predictable.
|
||||||
|
var bb = profile.Perimeter.BoundingBox;
|
||||||
|
var offsetX = -bb.X;
|
||||||
|
var offsetY = -bb.Y;
|
||||||
|
foreach (var e in profile.Perimeter.Entities) e.Offset(offsetX, offsetY);
|
||||||
|
foreach (var cutout in profile.Cutouts)
|
||||||
|
foreach (var e in cutout.Entities) e.Offset(offsetX, offsetY);
|
||||||
|
|
||||||
|
var allEntities = new List<Entity>();
|
||||||
|
allEntities.AddRange(profile.Perimeter.Entities);
|
||||||
|
foreach (var cutout in profile.Cutouts) allEntities.AddRange(cutout.Entities);
|
||||||
|
|
||||||
|
var drawing = new Drawing("SPLITTEST", ConvertGeometry.ToProgram(allEntities));
|
||||||
|
var originalArea = drawing.Area;
|
||||||
|
|
||||||
|
// Part is ~255x55 with an interior slot. Split into 5 columns (55mm each).
|
||||||
|
var splitLines = new List<SplitLine>
|
||||||
|
{
|
||||||
|
new SplitLine(55.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(110.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(165.0, CutOffAxis.Vertical),
|
||||||
|
new SplitLine(220.0, CutOffAxis.Vertical)
|
||||||
|
};
|
||||||
|
|
||||||
|
var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters { Type = SplitType.Straight });
|
||||||
|
|
||||||
|
// Area must be preserved within tolerance (floating-point coords in the DXF).
|
||||||
|
var totalArea = results.Sum(d => d.Area);
|
||||||
|
Assert.Equal(originalArea, totalArea, 0);
|
||||||
|
|
||||||
|
// At least one region must yield more than one physical strip — that's the
|
||||||
|
// whole point of the fix: a cutout that spans a region disconnects it.
|
||||||
|
Assert.True(results.Count > splitLines.Count + 1,
|
||||||
|
$"Expected more than {splitLines.Count + 1} pieces (some regions split into strips), got {results.Count}");
|
||||||
|
|
||||||
|
// Every output drawing must resolve into fully-closed shapes (outer loop
|
||||||
|
// and any hole loops), with no dangling geometry. A piece that contains
|
||||||
|
// a cutout will have its entities span more than one connected loop.
|
||||||
|
foreach (var piece in results)
|
||||||
|
{
|
||||||
|
var entities = ConvertProgram.ToGeometry(piece.Program)
|
||||||
|
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||||
|
|
||||||
|
Assert.True(entities.Count >= 3, $"{piece.Name} has only {entities.Count} entities");
|
||||||
|
|
||||||
|
var shapes = OpenNest.Geometry.ShapeBuilder.GetShapes(entities);
|
||||||
|
Assert.NotEmpty(shapes);
|
||||||
|
|
||||||
|
foreach (var shape in shapes)
|
||||||
|
{
|
||||||
|
Assert.True(shape.IsClosed(),
|
||||||
|
$"{piece.Name} contains an open chain of {shape.Entities.Count} entities");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Vector GetStartPoint(Entity entity)
|
private static Vector GetStartPoint(Entity entity)
|
||||||
{
|
{
|
||||||
return entity switch
|
return entity switch
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
|||||||
using OpenNest;
|
using OpenNest;
|
||||||
using OpenNest.Engine.BestFit;
|
using OpenNest.Engine.BestFit;
|
||||||
using OpenNest.Engine.ML;
|
using OpenNest.Engine.ML;
|
||||||
using OpenNest.Geometry;
|
|
||||||
using OpenNest.Gpu;
|
using OpenNest.Gpu;
|
||||||
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.Training;
|
using OpenNest.Training;
|
||||||
using System;
|
using System;
|
||||||
@@ -128,17 +128,26 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entities = Dxf.GetGeometry(file);
|
Drawing drawing;
|
||||||
if (entities.Count == 0)
|
try
|
||||||
|
{
|
||||||
|
drawing = CadImporter.ImportDrawing(file,
|
||||||
|
new CadImportOptions { DetectBends = false, Name = Path.GetFileName(file) });
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($" - SKIP ({ex.Message})");
|
||||||
|
skippedGeometry++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine(" - SKIP (no geometry)");
|
Console.WriteLine(" - SKIP (no geometry)");
|
||||||
skippedGeometry++;
|
skippedGeometry++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var drawing = new Drawing(Path.GetFileName(file));
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(entities);
|
|
||||||
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(normalized);
|
|
||||||
drawing.UpdateArea();
|
drawing.UpdateArea();
|
||||||
drawing.Color = PartColors[colorIndex % PartColors.Length];
|
drawing.Color = PartColors[colorIndex % PartColors.Length];
|
||||||
colorIndex++;
|
colorIndex++;
|
||||||
|
|||||||
+366
-230
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,12 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
public static void DrawProgram(Graphics g, DrawControl view, Program pgm, ref Vector pos,
|
public static void DrawProgram(Graphics g, DrawControl view, Program pgm, ref Vector pos,
|
||||||
Pen pen, double spacing, float arrowSize)
|
Pen pen, double spacing, float arrowSize)
|
||||||
|
{
|
||||||
|
DrawProgram(g, view, pgm, pos, ref pos, pen, spacing, arrowSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawProgram(Graphics g, DrawControl view, Program pgm, Vector basePos, ref Vector pos,
|
||||||
|
Pen pen, double spacing, float arrowSize)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < pgm.Length; ++i)
|
for (var i = 0; i < pgm.Length; ++i)
|
||||||
{
|
{
|
||||||
@@ -18,7 +24,11 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null)
|
||||||
DrawProgram(g, view, subpgm.Program, ref pos, pen, spacing, arrowSize);
|
{
|
||||||
|
var holeBase = basePos + subpgm.Offset;
|
||||||
|
pos = holeBase;
|
||||||
|
DrawProgram(g, view, subpgm.Program, holeBase, ref pos, pen, spacing, arrowSize);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +36,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
var endpt = pgm.Mode == Mode.Incremental
|
var endpt = pgm.Mode == Mode.Incremental
|
||||||
? motion.EndPoint + pos
|
? motion.EndPoint + pos
|
||||||
: motion.EndPoint;
|
: motion.EndPoint + basePos;
|
||||||
|
|
||||||
if (code.Type == CodeType.LinearMove)
|
if (code.Type == CodeType.LinearMove)
|
||||||
{
|
{
|
||||||
@@ -41,7 +51,7 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
var center = pgm.Mode == Mode.Incremental
|
var center = pgm.Mode == Mode.Incremental
|
||||||
? arc.CenterPoint + pos
|
? arc.CenterPoint + pos
|
||||||
: arc.CenterPoint;
|
: arc.CenterPoint + basePos;
|
||||||
DrawArcArrows(g, view, pos, endpt, center, arc.Rotation, pen, spacing, arrowSize);
|
DrawArcArrows(g, view, pos, endpt, center, arc.Rotation, pen, spacing, arrowSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ namespace OpenNest.Controls
|
|||||||
private readonly NumericUpDown nudAutoTabMax;
|
private readonly NumericUpDown nudAutoTabMax;
|
||||||
private readonly NumericUpDown nudPierceClearance;
|
private readonly NumericUpDown nudPierceClearance;
|
||||||
|
|
||||||
|
private readonly CheckBox chkRoundLeadInAngles;
|
||||||
|
private readonly NumericUpDown nudLeadInAngleIncrement;
|
||||||
|
|
||||||
private readonly Button btnAutoAssign;
|
private readonly Button btnAutoAssign;
|
||||||
|
|
||||||
private bool suppressEvents;
|
private bool suppressEvents;
|
||||||
@@ -162,7 +165,7 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
HeaderText = "Pierce",
|
HeaderText = "Pierce",
|
||||||
Dock = DockStyle.Top,
|
Dock = DockStyle.Top,
|
||||||
ExpandedHeight = 60,
|
ExpandedHeight = 90,
|
||||||
IsExpanded = true
|
IsExpanded = true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,6 +179,34 @@ namespace OpenNest.Controls
|
|||||||
nudPierceClearance = CreateNumeric(130, 3, 0.0625, 0.0625);
|
nudPierceClearance = CreateNumeric(130, 3, 0.0625, 0.0625);
|
||||||
piercePanel.ContentPanel.Controls.Add(nudPierceClearance);
|
piercePanel.ContentPanel.Controls.Add(nudPierceClearance);
|
||||||
|
|
||||||
|
chkRoundLeadInAngles = new CheckBox
|
||||||
|
{
|
||||||
|
Text = "Round Lead-In Angles",
|
||||||
|
Location = new Point(12, 32),
|
||||||
|
AutoSize = true
|
||||||
|
};
|
||||||
|
chkRoundLeadInAngles.CheckedChanged += (s, e) =>
|
||||||
|
{
|
||||||
|
nudLeadInAngleIncrement.Enabled = chkRoundLeadInAngles.Checked;
|
||||||
|
OnParametersChanged();
|
||||||
|
};
|
||||||
|
piercePanel.ContentPanel.Controls.Add(chkRoundLeadInAngles);
|
||||||
|
|
||||||
|
piercePanel.ContentPanel.Controls.Add(new Label
|
||||||
|
{
|
||||||
|
Text = "Increment:",
|
||||||
|
Location = new Point(175, 34),
|
||||||
|
AutoSize = true
|
||||||
|
});
|
||||||
|
|
||||||
|
nudLeadInAngleIncrement = CreateNumeric(245, 31, 5, 1);
|
||||||
|
nudLeadInAngleIncrement.DecimalPlaces = 0;
|
||||||
|
nudLeadInAngleIncrement.Minimum = 1;
|
||||||
|
nudLeadInAngleIncrement.Maximum = 90;
|
||||||
|
nudLeadInAngleIncrement.Enabled = false;
|
||||||
|
nudLeadInAngleIncrement.ValueChanged += (s, e) => OnParametersChanged();
|
||||||
|
piercePanel.ContentPanel.Controls.Add(nudLeadInAngleIncrement);
|
||||||
|
|
||||||
// Auto-Assign button — wrapped in a panel for Dock.Top with padding
|
// Auto-Assign button — wrapped in a panel for Dock.Top with padding
|
||||||
btnAutoAssign = new Button
|
btnAutoAssign = new Button
|
||||||
{
|
{
|
||||||
@@ -218,6 +249,8 @@ namespace OpenNest.Controls
|
|||||||
TabsEnabled = chkTabsEnabled.Checked,
|
TabsEnabled = chkTabsEnabled.Checked,
|
||||||
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
|
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
|
||||||
PierceClearance = (double)nudPierceClearance.Value,
|
PierceClearance = (double)nudPierceClearance.Value,
|
||||||
|
RoundLeadInAngles = chkRoundLeadInAngles.Checked,
|
||||||
|
LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value,
|
||||||
AutoTabMinSize = (double)nudAutoTabMin.Value,
|
AutoTabMinSize = (double)nudAutoTabMin.Value,
|
||||||
AutoTabMaxSize = (double)nudAutoTabMax.Value
|
AutoTabMaxSize = (double)nudAutoTabMax.Value
|
||||||
};
|
};
|
||||||
@@ -238,6 +271,9 @@ namespace OpenNest.Controls
|
|||||||
if (p.TabConfig != null)
|
if (p.TabConfig != null)
|
||||||
nudTabWidth.Value = (decimal)p.TabConfig.Size;
|
nudTabWidth.Value = (decimal)p.TabConfig.Size;
|
||||||
nudPierceClearance.Value = (decimal)p.PierceClearance;
|
nudPierceClearance.Value = (decimal)p.PierceClearance;
|
||||||
|
chkRoundLeadInAngles.Checked = p.RoundLeadInAngles;
|
||||||
|
nudLeadInAngleIncrement.Value = (decimal)p.LeadInAngleIncrement;
|
||||||
|
nudLeadInAngleIncrement.Enabled = p.RoundLeadInAngles;
|
||||||
nudAutoTabMin.Value = (decimal)p.AutoTabMinSize;
|
nudAutoTabMin.Value = (decimal)p.AutoTabMinSize;
|
||||||
nudAutoTabMax.Value = (decimal)p.AutoTabMaxSize;
|
nudAutoTabMax.Value = (decimal)p.AutoTabMaxSize;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace OpenNest.Controls
|
|||||||
public event EventHandler FilterChanged;
|
public event EventHandler FilterChanged;
|
||||||
public event EventHandler<int> BendLineSelected;
|
public event EventHandler<int> BendLineSelected;
|
||||||
public event EventHandler<int> BendLineRemoved;
|
public event EventHandler<int> BendLineRemoved;
|
||||||
|
public event EventHandler<int> BendLineEdited;
|
||||||
public event EventHandler AddBendLineClicked;
|
public event EventHandler AddBendLineClicked;
|
||||||
|
|
||||||
public FilterPanel()
|
public FilterPanel()
|
||||||
@@ -51,6 +52,18 @@ namespace OpenNest.Controls
|
|||||||
bendLinesList.SelectedIndexChanged += (s, e) =>
|
bendLinesList.SelectedIndexChanged += (s, e) =>
|
||||||
BendLineSelected?.Invoke(this, bendLinesList.SelectedIndex);
|
BendLineSelected?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
|
|
||||||
|
var bendEditLink = new LinkLabel
|
||||||
|
{
|
||||||
|
Text = "Edit",
|
||||||
|
AutoSize = true,
|
||||||
|
Font = new Font("Segoe UI", 8f)
|
||||||
|
};
|
||||||
|
bendEditLink.LinkClicked += (s, e) =>
|
||||||
|
{
|
||||||
|
if (bendLinesList.SelectedIndex >= 0)
|
||||||
|
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
var bendDeleteLink = new LinkLabel
|
var bendDeleteLink = new LinkLabel
|
||||||
{
|
{
|
||||||
Text = "Remove",
|
Text = "Remove",
|
||||||
@@ -63,6 +76,12 @@ namespace OpenNest.Controls
|
|||||||
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
|
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bendLinesList.DoubleClick += (s, e) =>
|
||||||
|
{
|
||||||
|
if (bendLinesList.SelectedIndex >= 0)
|
||||||
|
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
|
||||||
|
};
|
||||||
|
|
||||||
bendAddLink = new LinkLabel
|
bendAddLink = new LinkLabel
|
||||||
{
|
{
|
||||||
Text = "Add Bend Line",
|
Text = "Add Bend Line",
|
||||||
@@ -80,6 +99,7 @@ namespace OpenNest.Controls
|
|||||||
WrapContents = false
|
WrapContents = false
|
||||||
};
|
};
|
||||||
bendLinksPanel.Controls.Add(bendAddLink);
|
bendLinksPanel.Controls.Add(bendAddLink);
|
||||||
|
bendLinksPanel.Controls.Add(bendEditLink);
|
||||||
bendLinksPanel.Controls.Add(bendDeleteLink);
|
bendLinksPanel.Controls.Add(bendDeleteLink);
|
||||||
|
|
||||||
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
|
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
|
||||||
|
|||||||
@@ -395,8 +395,8 @@ namespace OpenNest.Controls
|
|||||||
var piercePoint = GetFirstPiercePoint(pgm, part.Location);
|
var piercePoint = GetFirstPiercePoint(pgm, part.Location);
|
||||||
DrawLine(g, pos, piercePoint, view.ColorScheme.RapidPen);
|
DrawLine(g, pos, piercePoint, view.ColorScheme.RapidPen);
|
||||||
|
|
||||||
pos = part.Location;
|
pos = piercePoint;
|
||||||
DrawRapids(g, pgm, ref pos, skipFirstRapid: true);
|
DrawRapids(g, pgm, part.Location, ref pos, skipFirstRapid: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,17 +404,18 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
for (var i = 0; i < pgm.Length; i++)
|
for (var i = 0; i < pgm.Length; i++)
|
||||||
{
|
{
|
||||||
|
if (pgm[i] is SubProgramCall call && call.Program != null)
|
||||||
|
return GetFirstPiercePoint(call.Program, partLocation + call.Offset);
|
||||||
|
|
||||||
if (pgm[i] is Motion motion)
|
if (pgm[i] is Motion motion)
|
||||||
{
|
{
|
||||||
if (pgm.Mode == Mode.Incremental)
|
|
||||||
return motion.EndPoint + partLocation;
|
return motion.EndPoint + partLocation;
|
||||||
return motion.EndPoint;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return partLocation;
|
return partLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawRapids(Graphics g, Program pgm, ref Vector pos, bool skipFirstRapid = false)
|
private void DrawRapids(Graphics g, Program pgm, Vector basePos, ref Vector pos, bool skipFirstRapid = false)
|
||||||
{
|
{
|
||||||
var firstRapidSkipped = false;
|
var firstRapidSkipped = false;
|
||||||
|
|
||||||
@@ -422,47 +423,47 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
var code = pgm[i];
|
var code = pgm[i];
|
||||||
|
|
||||||
if (code.Type == CodeType.SubProgramCall)
|
if (code is SubProgramCall { Program: { } program } call)
|
||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
// A SubProgramCall is a coordinate-frame shift, not a physical
|
||||||
var program = subpgm.Program;
|
// rapid to the hole center. The Cincinnati post emits it as a
|
||||||
|
// G52 bracket, so the physical rapid is the sub-program's first
|
||||||
|
// motion, which goes straight from here to the lead-in pierce.
|
||||||
|
// Look ahead for that pierce point and draw the direct rapid,
|
||||||
|
// then recurse with skipFirstRapid so the sub doesn't also draw
|
||||||
|
// its first rapid on top. See docs/cincinnati-post-output.md.
|
||||||
|
var holeBase = basePos + call.Offset;
|
||||||
|
var firstPierce = GetFirstPiercePoint(program, holeBase);
|
||||||
|
|
||||||
if (program != null)
|
if (ShouldDrawRapid(skipFirstRapid, ref firstRapidSkipped))
|
||||||
DrawRapids(g, program, ref pos);
|
DrawLine(g, pos, firstPierce, view.ColorScheme.RapidPen);
|
||||||
|
|
||||||
|
var subPos = holeBase;
|
||||||
|
DrawRapids(g, program, holeBase, ref subPos, skipFirstRapid: true);
|
||||||
|
pos = subPos;
|
||||||
}
|
}
|
||||||
else
|
else if (code is Motion motion)
|
||||||
{
|
{
|
||||||
var motion = code as Motion;
|
var endpt = pgm.Mode == Mode.Incremental
|
||||||
|
? motion.EndPoint + pos
|
||||||
|
: motion.EndPoint;
|
||||||
|
|
||||||
if (motion != null)
|
if (code.Type == CodeType.RapidMove && ShouldDrawRapid(skipFirstRapid, ref firstRapidSkipped))
|
||||||
{
|
|
||||||
if (pgm.Mode == Mode.Incremental)
|
|
||||||
{
|
|
||||||
var endpt = motion.EndPoint + pos;
|
|
||||||
|
|
||||||
if (code.Type == CodeType.RapidMove)
|
|
||||||
{
|
|
||||||
if (skipFirstRapid && !firstRapidSkipped)
|
|
||||||
firstRapidSkipped = true;
|
|
||||||
else
|
|
||||||
DrawLine(g, pos, endpt, view.ColorScheme.RapidPen);
|
DrawLine(g, pos, endpt, view.ColorScheme.RapidPen);
|
||||||
}
|
|
||||||
pos = endpt;
|
pos = endpt;
|
||||||
}
|
}
|
||||||
else
|
}
|
||||||
{
|
}
|
||||||
if (code.Type == CodeType.RapidMove)
|
|
||||||
|
private static bool ShouldDrawRapid(bool skipFirstRapid, ref bool firstRapidSkipped)
|
||||||
{
|
{
|
||||||
if (skipFirstRapid && !firstRapidSkipped)
|
if (skipFirstRapid && !firstRapidSkipped)
|
||||||
|
{
|
||||||
firstRapidSkipped = true;
|
firstRapidSkipped = true;
|
||||||
else
|
return false;
|
||||||
DrawLine(g, pos, motion.EndPoint, view.ColorScheme.RapidPen);
|
|
||||||
}
|
|
||||||
pos = motion.EndPoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawAllPiercePoints(Graphics g)
|
private void DrawAllPiercePoints(Graphics g)
|
||||||
@@ -475,11 +476,11 @@ namespace OpenNest.Controls
|
|||||||
var part = view.Plate.Parts[i];
|
var part = view.Plate.Parts[i];
|
||||||
var pgm = part.Program;
|
var pgm = part.Program;
|
||||||
var pos = part.Location;
|
var pos = part.Location;
|
||||||
DrawProgramPiercePoints(g, pgm, ref pos, brush, pen);
|
DrawProgramPiercePoints(g, pgm, part.Location, ref pos, brush, pen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DrawProgramPiercePoints(Graphics g, Program pgm, ref Vector pos, Brush brush, Pen pen)
|
private void DrawProgramPiercePoints(Graphics g, Program pgm, Vector basePos, ref Vector pos, Brush brush, Pen pen)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < pgm.Length; ++i)
|
for (var i = 0; i < pgm.Length; ++i)
|
||||||
{
|
{
|
||||||
@@ -489,7 +490,11 @@ namespace OpenNest.Controls
|
|||||||
{
|
{
|
||||||
var subpgm = (SubProgramCall)code;
|
var subpgm = (SubProgramCall)code;
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null)
|
||||||
DrawProgramPiercePoints(g, subpgm.Program, ref pos, brush, pen);
|
{
|
||||||
|
var holeBase = basePos + subpgm.Offset;
|
||||||
|
pos = holeBase;
|
||||||
|
DrawProgramPiercePoints(g, subpgm.Program, holeBase, ref pos, brush, pen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -498,7 +503,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
var endpt = pgm.Mode == Mode.Incremental
|
var endpt = pgm.Mode == Mode.Incremental
|
||||||
? motion.EndPoint + pos
|
? motion.EndPoint + pos
|
||||||
: motion.EndPoint;
|
: motion.EndPoint + basePos;
|
||||||
|
|
||||||
if (code.Type == CodeType.RapidMove)
|
if (code.Type == CodeType.RapidMove)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -621,15 +621,21 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
private void redrawTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
private void redrawTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
||||||
{
|
{
|
||||||
Invalidate();
|
if (IsDisposed || !IsHandleCreated) return;
|
||||||
|
BeginInvoke(new System.Action(Invalidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void hoverTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
private void hoverTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
||||||
|
{
|
||||||
|
if (IsDisposed || !IsHandleCreated) return;
|
||||||
|
BeginInvoke(new System.Action(HoverCheck));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HoverCheck()
|
||||||
{
|
{
|
||||||
var graphPt = PointControlToGraph(hoverPoint);
|
var graphPt = PointControlToGraph(hoverPoint);
|
||||||
LayoutPart hitPart = null;
|
LayoutPart hitPart = null;
|
||||||
try
|
|
||||||
{
|
|
||||||
for (var i = parts.Count - 1; i >= 0; --i)
|
for (var i = parts.Count - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
if (parts[i].Path.GetBounds().Contains(graphPt) &&
|
if (parts[i].Path.GetBounds().Contains(graphPt) &&
|
||||||
@@ -639,12 +645,6 @@ namespace OpenNest.Controls
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (InvalidOperationException)
|
|
||||||
{
|
|
||||||
// GraphicsPath in use by paint thread — skip this hover tick
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hoveredPart = hitPart;
|
hoveredPart = hitPart;
|
||||||
showTooltip = hitPart != null;
|
showTooltip = hitPart != null;
|
||||||
|
|||||||
@@ -209,14 +209,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
private static Entity CloneEntity(Entity entity, Color color)
|
private static Entity CloneEntity(Entity entity, Color color)
|
||||||
{
|
{
|
||||||
Entity clone = entity switch
|
var clone = entity.Clone();
|
||||||
{
|
|
||||||
Line line => new Line(line.StartPoint, line.EndPoint) { Layer = line.Layer, IsVisible = line.IsVisible },
|
|
||||||
Arc arc => new Arc(arc.Center, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed) { Layer = arc.Layer, IsVisible = arc.IsVisible },
|
|
||||||
Circle circle => new Circle(circle.Center, circle.Radius) { Layer = circle.Layer, IsVisible = circle.IsVisible },
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
if (clone != null)
|
|
||||||
clone.Color = color;
|
clone.Color = color;
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,5 +99,17 @@ namespace OpenNest.Forms
|
|||||||
public double BendAngle => (double)numAngle.Value;
|
public double BendAngle => (double)numAngle.Value;
|
||||||
|
|
||||||
public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null;
|
public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null;
|
||||||
|
|
||||||
|
public void LoadBend(Bend bend)
|
||||||
|
{
|
||||||
|
cboDirection.SelectedIndex = bend.Direction == BendDirection.Up ? 1 : 0;
|
||||||
|
if (bend.Angle.HasValue)
|
||||||
|
numAngle.Value = (decimal)bend.Angle.Value;
|
||||||
|
if (bend.Radius.HasValue)
|
||||||
|
{
|
||||||
|
chkRadius.Checked = true;
|
||||||
|
numRadius.Value = (decimal)bend.Radius.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
using OpenNest.Bending;
|
|
||||||
using OpenNest.CNC;
|
|
||||||
using OpenNest.Converters;
|
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.IO.Bending;
|
|
||||||
using OpenNest.IO.Bom;
|
using OpenNest.IO.Bom;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
@@ -470,33 +466,9 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = Dxf.Import(part.DxfPath);
|
var drawing = CadImporter.ImportDrawing(part.DxfPath,
|
||||||
|
new CadImportOptions { Quantity = part.Qty ?? 1 });
|
||||||
var bends = new List<Bend>();
|
|
||||||
if (result.Document != null)
|
|
||||||
bends = BendDetectorRegistry.AutoDetect(result.Document);
|
|
||||||
Bend.UpdateEtchEntities(result.Entities, bends);
|
|
||||||
|
|
||||||
var drawingName = Path.GetFileNameWithoutExtension(part.DxfPath);
|
|
||||||
var drawing = new Drawing(drawingName);
|
|
||||||
drawing.Color = Drawing.GetNextColor();
|
|
||||||
drawing.Source.Path = part.DxfPath;
|
|
||||||
drawing.Quantity.Required = part.Qty ?? 1;
|
|
||||||
drawing.Material = new Material(material);
|
drawing.Material = new Material(material);
|
||||||
if (bends.Count > 0)
|
|
||||||
drawing.Bends.AddRange(bends);
|
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(result.Entities);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
|
|
||||||
if (pgm.Codes.Count > 0 && pgm[0].Type == CodeType.RapidMove)
|
|
||||||
{
|
|
||||||
var rapid = (RapidMove)pgm[0];
|
|
||||||
drawing.Source.Offset = rapid.EndPoint;
|
|
||||||
pgm.Offset(-rapid.EndPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawing.Program = pgm;
|
|
||||||
nest.Drawings.Add(drawing);
|
nest.Drawings.Add(drawing);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using OpenNest.Converters;
|
|||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.IO.Bending;
|
using OpenNest.IO.Bending;
|
||||||
using OpenNest.Properties;
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
@@ -42,6 +41,7 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.FilterChanged += OnFilterChanged;
|
filterPanel.FilterChanged += OnFilterChanged;
|
||||||
filterPanel.BendLineSelected += OnBendLineSelected;
|
filterPanel.BendLineSelected += OnBendLineSelected;
|
||||||
filterPanel.BendLineRemoved += OnBendLineRemoved;
|
filterPanel.BendLineRemoved += OnBendLineRemoved;
|
||||||
|
filterPanel.BendLineEdited += OnBendLineEdited;
|
||||||
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
||||||
entityView1.LinePicked += OnLinePicked;
|
entityView1.LinePicked += OnLinePicked;
|
||||||
entityView1.PickCancelled += OnPickCancelled;
|
entityView1.PickCancelled += OnPickCancelled;
|
||||||
@@ -74,36 +74,24 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = Dxf.Import(file);
|
var options = new CadImportOptions
|
||||||
|
{
|
||||||
|
BendDetectorName = detectorIndex == 0 ? null : detectorName,
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = CadImporter.Import(file, options);
|
||||||
if (result.Entities.Count == 0)
|
if (result.Entities.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Compute bounds
|
|
||||||
var bounds = result.Entities.GetBoundingBox();
|
|
||||||
|
|
||||||
// Detect bends (detectorIndex/Name captured on UI thread)
|
|
||||||
var bends = new List<Bend>();
|
|
||||||
if (result.Document != null)
|
|
||||||
{
|
|
||||||
bends = detectorIndex == 0
|
|
||||||
? BendDetectorRegistry.AutoDetect(result.Document)
|
|
||||||
: BendDetectorRegistry.GetByName(detectorName)
|
|
||||||
?.DetectBends(result.Document)
|
|
||||||
?? new List<Bend>();
|
|
||||||
}
|
|
||||||
|
|
||||||
Bend.UpdateEtchEntities(result.Entities, bends);
|
|
||||||
|
|
||||||
var item = new FileListItem
|
var item = new FileListItem
|
||||||
{
|
{
|
||||||
Name = Path.GetFileNameWithoutExtension(file),
|
Name = result.Name,
|
||||||
Entities = result.Entities,
|
Entities = result.Entities,
|
||||||
Path = file,
|
Path = result.SourcePath,
|
||||||
Quantity = 1,
|
Quantity = 1,
|
||||||
Customer = string.Empty,
|
Customer = string.Empty,
|
||||||
Bends = bends,
|
Bends = result.Bends,
|
||||||
Bounds = bounds,
|
Bounds = result.Bounds,
|
||||||
EntityCount = result.Entities.Count
|
EntityCount = result.Entities.Count
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +293,29 @@ namespace OpenNest.Forms
|
|||||||
entityView1.Invalidate();
|
entityView1.Invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnBendLineEdited(object sender, int index)
|
||||||
|
{
|
||||||
|
var item = CurrentItem;
|
||||||
|
if (item == null || index < 0 || index >= item.Bends.Count) return;
|
||||||
|
|
||||||
|
var bend = item.Bends[index];
|
||||||
|
using var dialog = new BendLineDialog();
|
||||||
|
dialog.LoadBend(bend);
|
||||||
|
|
||||||
|
if (dialog.ShowDialog(this) != DialogResult.OK) return;
|
||||||
|
|
||||||
|
bend.Direction = dialog.Direction;
|
||||||
|
bend.Angle = dialog.BendAngle;
|
||||||
|
bend.Radius = dialog.BendRadius;
|
||||||
|
|
||||||
|
Bend.UpdateEtchEntities(item.Entities, item.Bends);
|
||||||
|
entityView1.Entities.Clear();
|
||||||
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
|
entityView1.Bends = item.Bends;
|
||||||
|
filterPanel.LoadItem(item.Entities, item.Bends);
|
||||||
|
entityView1.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
private void OnQuantityChanged(object sender, EventArgs e)
|
private void OnQuantityChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var item = CurrentItem;
|
var item = CurrentItem;
|
||||||
@@ -368,7 +379,6 @@ namespace OpenNest.Forms
|
|||||||
: Path.GetTempPath();
|
: Path.GetTempPath();
|
||||||
|
|
||||||
var index = fileList.SelectedIndex;
|
var index = fileList.SelectedIndex;
|
||||||
var newItems = new List<string>();
|
|
||||||
|
|
||||||
var splitWriter = new SplitDxfWriter();
|
var splitWriter = new SplitDxfWriter();
|
||||||
var splitItems = new List<FileListItem>();
|
var splitItems = new List<FileListItem>();
|
||||||
@@ -381,7 +391,6 @@ namespace OpenNest.Forms
|
|||||||
var splitPath = GetUniquePath(Path.Combine(writableDir, splitName));
|
var splitPath = GetUniquePath(Path.Combine(writableDir, splitName));
|
||||||
|
|
||||||
splitWriter.Write(splitPath, splitDrawing);
|
splitWriter.Write(splitPath, splitDrawing);
|
||||||
newItems.Add(splitPath);
|
|
||||||
|
|
||||||
// Re-import geometry but keep bends from the split drawing
|
// Re-import geometry but keep bends from the split drawing
|
||||||
var result = Dxf.Import(splitPath);
|
var result = Dxf.Import(splitPath);
|
||||||
@@ -669,53 +678,35 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
foreach (var item in fileList.Items)
|
foreach (var item in fileList.Items)
|
||||||
{
|
{
|
||||||
var entities = item.Entities.Where(e => e.Layer.IsVisible && e.IsVisible).ToList();
|
var visible = item.Entities
|
||||||
|
.Where(e => e.Layer.IsVisible && e.IsVisible)
|
||||||
if (entities.Count == 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var drawing = new Drawing(item.Name);
|
|
||||||
drawing.Color = Drawing.GetNextColor();
|
|
||||||
drawing.Customer = item.Customer;
|
|
||||||
drawing.Source.Path = item.Path;
|
|
||||||
drawing.Quantity.Required = item.Quantity;
|
|
||||||
|
|
||||||
// Copy bends
|
|
||||||
if (item.Bends != null)
|
|
||||||
drawing.Bends.AddRange(item.Bends);
|
|
||||||
|
|
||||||
var normalized = ShapeProfile.NormalizeEntities(entities);
|
|
||||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
|
||||||
var firstCode = pgm[0];
|
|
||||||
|
|
||||||
if (firstCode.Type == CodeType.RapidMove)
|
|
||||||
{
|
|
||||||
var rapid = (RapidMove)firstCode;
|
|
||||||
drawing.Source.Offset = rapid.EndPoint;
|
|
||||||
pgm.Offset(-rapid.EndPoint);
|
|
||||||
// Keep the rapid (now at origin) — it marks the contour
|
|
||||||
// start and is needed by the post for correct pierce placement.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item == CurrentItem && programEditor.IsDirty && programEditor.Program != null)
|
|
||||||
drawing.Program = programEditor.Program;
|
|
||||||
else
|
|
||||||
drawing.Program = pgm;
|
|
||||||
|
|
||||||
// Store all entities with stable GUIDs; track suppressed by ID
|
|
||||||
var bendSources = new HashSet<Entity>(
|
|
||||||
(item.Bends ?? new List<Bend>())
|
|
||||||
.Where(b => b.SourceEntity != null)
|
|
||||||
.Select(b => b.SourceEntity));
|
|
||||||
|
|
||||||
drawing.SourceEntities = item.Entities
|
|
||||||
.Where(e => !bendSources.Contains(e))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
drawing.SuppressedEntityIds = new HashSet<Guid>(
|
if (visible.Count == 0)
|
||||||
drawing.SourceEntities
|
continue;
|
||||||
.Where(e => !(e.Layer.IsVisible && e.IsVisible))
|
|
||||||
.Select(e => e.Id));
|
// Rebuild a CadImportResult from the FileListItem's current state so
|
||||||
|
// BuildDrawing sees the user's edits (filters, suppressions, new bends).
|
||||||
|
var result = new CadImportResult
|
||||||
|
{
|
||||||
|
Entities = item.Entities,
|
||||||
|
Bends = item.Bends ?? new List<Bend>(),
|
||||||
|
Bounds = item.Bounds,
|
||||||
|
SourcePath = item.Path,
|
||||||
|
Name = item.Name,
|
||||||
|
};
|
||||||
|
|
||||||
|
var editedProgram = (item == CurrentItem && programEditor.IsDirty && programEditor.Program != null)
|
||||||
|
? programEditor.Program
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var drawing = CadImporter.BuildDrawing(
|
||||||
|
result,
|
||||||
|
visible,
|
||||||
|
result.Bends,
|
||||||
|
item.Quantity,
|
||||||
|
item.Customer,
|
||||||
|
editedProgram);
|
||||||
|
|
||||||
drawings.Add(drawing);
|
drawings.Add(drawing);
|
||||||
|
|
||||||
@@ -780,9 +771,6 @@ namespace OpenNest.Forms
|
|||||||
item.SuppressedEntityIds = null;
|
item.SuppressedEntityIds = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static Color GetNextColor() => Drawing.GetNextColor();
|
|
||||||
|
|
||||||
private static bool IsDirectoryWritable(string path)
|
private static bool IsDirectoryWritable(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ namespace OpenNest.Forms
|
|||||||
TabsEnabled = p.TabsEnabled,
|
TabsEnabled = p.TabsEnabled,
|
||||||
TabWidth = p.TabConfig?.Size ?? 0.25,
|
TabWidth = p.TabConfig?.Size ?? 0.25,
|
||||||
PierceClearance = p.PierceClearance,
|
PierceClearance = p.PierceClearance,
|
||||||
|
RoundLeadInAngles = p.RoundLeadInAngles,
|
||||||
|
LeadInAngleIncrement = p.LeadInAngleIncrement,
|
||||||
AutoTabMinSize = p.AutoTabMinSize,
|
AutoTabMinSize = p.AutoTabMinSize,
|
||||||
AutoTabMaxSize = p.AutoTabMaxSize
|
AutoTabMaxSize = p.AutoTabMaxSize
|
||||||
};
|
};
|
||||||
@@ -47,6 +49,8 @@ namespace OpenNest.Forms
|
|||||||
TabsEnabled = dto.TabsEnabled,
|
TabsEnabled = dto.TabsEnabled,
|
||||||
TabConfig = new NormalTab { Size = dto.TabWidth },
|
TabConfig = new NormalTab { Size = dto.TabWidth },
|
||||||
PierceClearance = dto.PierceClearance,
|
PierceClearance = dto.PierceClearance,
|
||||||
|
RoundLeadInAngles = dto.RoundLeadInAngles,
|
||||||
|
LeadInAngleIncrement = dto.LeadInAngleIncrement > 0 ? dto.LeadInAngleIncrement : 5.0,
|
||||||
AutoTabMinSize = dto.AutoTabMinSize,
|
AutoTabMinSize = dto.AutoTabMinSize,
|
||||||
AutoTabMaxSize = dto.AutoTabMaxSize
|
AutoTabMaxSize = dto.AutoTabMaxSize
|
||||||
};
|
};
|
||||||
@@ -111,6 +115,8 @@ namespace OpenNest.Forms
|
|||||||
public bool TabsEnabled { get; set; }
|
public bool TabsEnabled { get; set; }
|
||||||
public double TabWidth { get; set; }
|
public double TabWidth { get; set; }
|
||||||
public double PierceClearance { get; set; }
|
public double PierceClearance { get; set; }
|
||||||
|
public bool RoundLeadInAngles { get; set; }
|
||||||
|
public double LeadInAngleIncrement { get; set; }
|
||||||
public double AutoTabMinSize { get; set; }
|
public double AutoTabMinSize { get; set; }
|
||||||
public double AutoTabMaxSize { get; set; }
|
public double AutoTabMaxSize { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using OpenNest.Engine.Sequencing;
|
|||||||
using OpenNest.IO;
|
using OpenNest.IO;
|
||||||
using OpenNest.Math;
|
using OpenNest.Math;
|
||||||
using OpenNest.Properties;
|
using OpenNest.Properties;
|
||||||
|
using OpenNest.Shapes;
|
||||||
using System;
|
using System;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
@@ -453,7 +454,11 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
public void ResizePlateToFitParts()
|
public void ResizePlateToFitParts()
|
||||||
{
|
{
|
||||||
PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
|
var options = new PlateSizeOptions
|
||||||
|
{
|
||||||
|
SnapIncrement = Settings.Default.AutoSizePlateFactor,
|
||||||
|
};
|
||||||
|
PlateView.Plate.SnapToStandardSize(options);
|
||||||
PlateView.ZoomToPlate();
|
PlateView.ZoomToPlate();
|
||||||
PlateView.Refresh();
|
PlateView.Refresh();
|
||||||
UpdatePlateList();
|
UpdatePlateList();
|
||||||
|
|||||||
@@ -180,6 +180,43 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
y += 18;
|
y += 18;
|
||||||
|
|
||||||
|
Control editor;
|
||||||
|
if (prop.PropertyType == typeof(bool))
|
||||||
|
{
|
||||||
|
var cb = new CheckBox
|
||||||
|
{
|
||||||
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
|
AutoSize = true,
|
||||||
|
Checked = sourceValues != null && (bool)prop.GetValue(sourceValues)
|
||||||
|
};
|
||||||
|
cb.CheckedChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = cb;
|
||||||
|
}
|
||||||
|
else if (prop.PropertyType == typeof(string) && prop.Name == "PipeSize")
|
||||||
|
{
|
||||||
|
var combo = new ComboBox
|
||||||
|
{
|
||||||
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
|
Width = panelWidth,
|
||||||
|
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
|
||||||
|
DropDownStyle = ComboBoxStyle.DropDownList
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial population: every entry; the filter runs on first UpdatePreview.
|
||||||
|
foreach (var entry in PipeSizes.All)
|
||||||
|
combo.Items.Add(entry.Label);
|
||||||
|
|
||||||
|
var initial = sourceValues != null ? (string)prop.GetValue(sourceValues) : null;
|
||||||
|
if (!string.IsNullOrEmpty(initial) && combo.Items.Contains(initial))
|
||||||
|
combo.SelectedItem = initial;
|
||||||
|
else if (combo.Items.Count > 0)
|
||||||
|
combo.SelectedIndex = 0;
|
||||||
|
|
||||||
|
combo.SelectedIndexChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = combo;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
var tb = new TextBox
|
var tb = new TextBox
|
||||||
{
|
{
|
||||||
Location = new Point(parametersPanel.Padding.Left, y),
|
Location = new Point(parametersPanel.Padding.Left, y),
|
||||||
@@ -196,11 +233,13 @@ namespace OpenNest.Forms
|
|||||||
}
|
}
|
||||||
|
|
||||||
tb.TextChanged += (s, ev) => UpdatePreview();
|
tb.TextChanged += (s, ev) => UpdatePreview();
|
||||||
|
editor = tb;
|
||||||
|
}
|
||||||
|
|
||||||
parameterBindings.Add(new ParameterBinding { Property = prop, Control = tb });
|
parameterBindings.Add(new ParameterBinding { Property = prop, Control = editor });
|
||||||
|
|
||||||
parametersPanel.Controls.Add(label);
|
parametersPanel.Controls.Add(label);
|
||||||
parametersPanel.Controls.Add(tb);
|
parametersPanel.Controls.Add(editor);
|
||||||
|
|
||||||
y += 30;
|
y += 30;
|
||||||
}
|
}
|
||||||
@@ -212,6 +251,8 @@ namespace OpenNest.Forms
|
|||||||
{
|
{
|
||||||
if (suppressPreview || selectedEntry == null) return;
|
if (suppressPreview || selectedEntry == null) return;
|
||||||
|
|
||||||
|
UpdatePipeSizeFilter();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var shape = CreateShapeFromInputs();
|
var shape = CreateShapeFromInputs();
|
||||||
@@ -223,9 +264,17 @@ namespace OpenNest.Forms
|
|||||||
if (drawing?.Program != null)
|
if (drawing?.Program != null)
|
||||||
{
|
{
|
||||||
var bb = drawing.Program.BoundingBox();
|
var bb = drawing.Program.BoundingBox();
|
||||||
previewBox.SetInfo(
|
var info = string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width);
|
||||||
nameTextBox.Text,
|
|
||||||
string.Format("{0:F3} x {1:F3}", bb.Size.Length, bb.Size.Width));
|
if (shape is PipeFlangeShape flange
|
||||||
|
&& !flange.Blind
|
||||||
|
&& !string.IsNullOrEmpty(flange.PipeSize)
|
||||||
|
&& !PipeSizes.TryGetOD(flange.PipeSize, out _))
|
||||||
|
{
|
||||||
|
info += " — Invalid pipe size, no bore cut";
|
||||||
|
}
|
||||||
|
|
||||||
|
previewBox.SetInfo(nameTextBox.Text, info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -234,6 +283,72 @@ namespace OpenNest.Forms
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdatePipeSizeFilter()
|
||||||
|
{
|
||||||
|
// Find the PipeSize combo and the numeric inputs it depends on.
|
||||||
|
ComboBox pipeCombo = null;
|
||||||
|
double holePattern = 0, holeDia = 0, clearance = 0;
|
||||||
|
bool blind = false;
|
||||||
|
|
||||||
|
foreach (var binding in parameterBindings)
|
||||||
|
{
|
||||||
|
var name = binding.Property.Name;
|
||||||
|
if (name == "PipeSize" && binding.Control is ComboBox cb)
|
||||||
|
pipeCombo = cb;
|
||||||
|
else if (name == "HolePatternDiameter" && binding.Control is TextBox tb1)
|
||||||
|
double.TryParse(tb1.Text, out holePattern);
|
||||||
|
else if (name == "HoleDiameter" && binding.Control is TextBox tb2)
|
||||||
|
double.TryParse(tb2.Text, out holeDia);
|
||||||
|
else if (name == "PipeClearance" && binding.Control is TextBox tb3)
|
||||||
|
double.TryParse(tb3.Text, out clearance);
|
||||||
|
else if (name == "Blind" && binding.Control is CheckBox chk)
|
||||||
|
blind = chk.Checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pipeCombo == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Disable when blind, but keep visible with the selection preserved.
|
||||||
|
pipeCombo.Enabled = !blind;
|
||||||
|
|
||||||
|
// Compute filter: pipeOD + clearance < HolePatternDiameter - HoleDiameter.
|
||||||
|
var maxPipeOD = holePattern - holeDia - clearance;
|
||||||
|
var fittingLabels = PipeSizes.GetFittingSizes(maxPipeOD).Select(e => e.Label).ToList();
|
||||||
|
|
||||||
|
// Sequence-equal on existing items — no-op if unchanged (avoids flicker).
|
||||||
|
var currentLabels = pipeCombo.Items.Cast<string>().ToList();
|
||||||
|
if (currentLabels.SequenceEqual(fittingLabels))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var previousSelection = pipeCombo.SelectedItem as string;
|
||||||
|
|
||||||
|
pipeCombo.BeginUpdate();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
pipeCombo.Items.Clear();
|
||||||
|
foreach (var label in fittingLabels)
|
||||||
|
pipeCombo.Items.Add(label);
|
||||||
|
|
||||||
|
if (fittingLabels.Count == 0)
|
||||||
|
{
|
||||||
|
// No pipe fits — leave unselected.
|
||||||
|
}
|
||||||
|
else if (previousSelection != null && fittingLabels.Contains(previousSelection))
|
||||||
|
{
|
||||||
|
pipeCombo.SelectedItem = previousSelection;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Select the largest (last, since PipeSizes.All is sorted ascending).
|
||||||
|
pipeCombo.SelectedIndex = fittingLabels.Count - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pipeCombo.EndUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ShapeDefinition CreateShapeFromInputs()
|
private ShapeDefinition CreateShapeFromInputs()
|
||||||
{
|
{
|
||||||
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
var shape = (ShapeDefinition)Activator.CreateInstance(selectedEntry.ShapeType);
|
||||||
@@ -241,6 +356,19 @@ namespace OpenNest.Forms
|
|||||||
|
|
||||||
foreach (var binding in parameterBindings)
|
foreach (var binding in parameterBindings)
|
||||||
{
|
{
|
||||||
|
if (binding.Property.PropertyType == typeof(bool))
|
||||||
|
{
|
||||||
|
var cb = (CheckBox)binding.Control;
|
||||||
|
binding.Property.SetValue(shape, cb.Checked);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.Control is ComboBox combo)
|
||||||
|
{
|
||||||
|
binding.Property.SetValue(shape, combo.SelectedItem?.ToString());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var tb = (TextBox)binding.Control;
|
var tb = (TextBox)binding.Control;
|
||||||
|
|
||||||
if (binding.Property.PropertyType == typeof(int))
|
if (binding.Property.PropertyType == typeof(int))
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ namespace OpenNest
|
|||||||
private static void AddProgramSplit(GraphicsPath cutPath, GraphicsPath leadPath,
|
private static void AddProgramSplit(GraphicsPath cutPath, GraphicsPath leadPath,
|
||||||
Program pgm, Mode mode, ref Vector curpos)
|
Program pgm, Mode mode, ref Vector curpos)
|
||||||
{
|
{
|
||||||
|
// Capture the frame origin at entry. Sub-program Offsets are relative
|
||||||
|
// to this fixed origin, not to the current tool position.
|
||||||
|
var frameOrigin = curpos;
|
||||||
mode = pgm.Mode;
|
mode = pgm.Mode;
|
||||||
|
|
||||||
for (var i = 0; i < pgm.Length; ++i)
|
for (var i = 0; i < pgm.Length; ++i)
|
||||||
@@ -147,6 +150,7 @@ namespace OpenNest
|
|||||||
{
|
{
|
||||||
cutPath.StartFigure();
|
cutPath.StartFigure();
|
||||||
leadPath.StartFigure();
|
leadPath.StartFigure();
|
||||||
|
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
||||||
AddProgramSplit(cutPath, leadPath, subpgm.Program, mode, ref curpos);
|
AddProgramSplit(cutPath, leadPath, subpgm.Program, mode, ref curpos);
|
||||||
}
|
}
|
||||||
mode = tmpmode;
|
mode = tmpmode;
|
||||||
@@ -237,6 +241,9 @@ namespace OpenNest
|
|||||||
|
|
||||||
private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos)
|
private static void AddProgram(GraphicsPath path, Program pgm, Mode mode, ref Vector curpos)
|
||||||
{
|
{
|
||||||
|
// Capture the frame origin at entry. Sub-program Offsets are relative
|
||||||
|
// to this fixed origin, not to the current tool position.
|
||||||
|
var frameOrigin = curpos;
|
||||||
mode = pgm.Mode;
|
mode = pgm.Mode;
|
||||||
GraphicsPath currentFigure = null;
|
GraphicsPath currentFigure = null;
|
||||||
|
|
||||||
@@ -305,6 +312,7 @@ namespace OpenNest
|
|||||||
|
|
||||||
if (subpgm.Program != null)
|
if (subpgm.Program != null)
|
||||||
{
|
{
|
||||||
|
curpos = new Vector(frameOrigin.X + subpgm.Offset.X, frameOrigin.Y + subpgm.Offset.Y);
|
||||||
AddProgram(path, subpgm.Program, mode, ref curpos);
|
AddProgram(path, subpgm.Program, mode, ref curpos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,32 +2,52 @@
|
|||||||
|
|
||||||
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
|
A Windows desktop application for CNC nesting — imports DXF drawings, arranges parts on material plates, and exports layouts as DXF or G-code for cutting.
|
||||||
|
|
||||||

|
<p>
|
||||||
|
<a href="screenshots/screenshot-nest-1.png"><img src="screenshots/screenshot-nest-1.png" width="420" alt="OpenNest - parts nested on a 36x36 plate"></a>
|
||||||
|
<a href="screenshots/screenshot-nest-2.png"><img src="screenshots/screenshot-nest-2.png" width="420" alt="OpenNest - 44 parts nested on a 60x120 plate"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
|
OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and arranges the parts to make efficient use of material. The result can be exported as DXF files or post-processed into G-code that your CNC cutting machine understands.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **DXF/DWG Import & Export** — Load part drawings from DXF or DWG files and export completed nest layouts as DXF
|
### Import & Export
|
||||||
- **Multiple Fill Strategies** — Grid-based linear fill, interlocking pair fill, rectangle bin packing, extents-based tiling, and more via a pluggable strategy system
|
|
||||||
- **Best-Fit Pair Nesting** — NFP-based (No Fit Polygon) pair evaluation finds tight-fitting interlocking orientations between parts
|
|
||||||
- **GPU Acceleration** — Optional ILGPU-based bitmap overlap detection for faster best-fit evaluation
|
|
||||||
- **Part Rotation** — Automatically tries different rotation angles to find better fits, with optional ML-based angle prediction (ONNX)
|
|
||||||
- **Gravity Compaction** — After placing parts, pushes them together using polygon-based directional distance to close gaps between irregular shapes
|
|
||||||
- **Multi-Plate Support** — Work with multiple plates of different sizes and materials in a single nest
|
|
||||||
- **Sheet Cut-Offs** — Automatically cut the plate to size after nesting, with geometry-aware clearance that avoids placed parts
|
|
||||||
- **Drawing Splitting** — Split oversized parts into pieces that fit your plate, with straight cuts, weld-gap tabs, or interlocking spike-groove joints
|
|
||||||
- **BOM Import** — Read bills of materials from Excel spreadsheets to batch-import part lists with quantities
|
|
||||||
- **Bend Line Detection** — Import bend lines from DXF files with pluggable detectors (SolidWorks flat pattern support built in)
|
|
||||||
- **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting, with snap-to-endpoint/midpoint placement
|
|
||||||
- **Contour & Program Editing** — Inline G-code editor with contour reordering, direction arrows, and cut direction reversal
|
|
||||||
- **G-code Output** — Post-process nested layouts to G-code via plugin post-processors
|
|
||||||
- **User-Defined Variables** — Define named variables in G-code (`diameter = 0.3`) referenced with `$name` syntax; Cincinnati post emits numbered machine variables (`#200`) so operators can adjust values at the control
|
|
||||||
- **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts
|
|
||||||
- **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view
|
|
||||||
- **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs
|
|
||||||
|
|
||||||

|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **DXF/DWG Import** | Load part drawings from AutoCAD DXF or DWG files via ACadSharp |
|
||||||
|
| **DXF Export** | Export completed nest layouts back to DXF for downstream tools |
|
||||||
|
| **BOM Import** | Batch-import part lists with quantities from Excel spreadsheets |
|
||||||
|
| **Bend Line Detection** | Import bend lines from DXF via pluggable detectors (SolidWorks flat pattern built in) |
|
||||||
|
| **Built-in Shapes** | 12 parametric shapes (circles, rectangles, L/T/flange, etc.) for quick parts |
|
||||||
|
|
||||||
|
### Nesting
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Pluggable Engines** | Default multi-phase, Vertical Remnant, Horizontal Remnant, plus custom plugin DLLs |
|
||||||
|
| **Fill Strategies** | Linear grid, interlocking pairs, rectangle best-fit, and extents-based tiling |
|
||||||
|
| **Best-Fit Pair Nesting** | NFP-based pair evaluation finds tight interlocking orientations between parts |
|
||||||
|
| **Gravity Compaction** | Polygon-based directional push to close gaps after filling |
|
||||||
|
| **Part Rotation** | Automatic angle sweep to find better fits across allowed orientations |
|
||||||
|
| **Multi-Plate Support** | Manage multiple plates of different sizes and materials in one nest |
|
||||||
|
|
||||||
|
### Plate Operations
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Sheet Cut-Offs** | Auto-generated trim cuts with geometry-aware clearance around placed parts |
|
||||||
|
| **Drawing Splitting** | Split oversized parts with straight cuts, weld-gap tabs, or spike-groove joints |
|
||||||
|
| **Interactive Editing** | Zoom, pan, select, clone, rotate, push, and manually arrange parts |
|
||||||
|
|
||||||
|
### CNC Output
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Lead-Ins, Lead-Outs & Tabs** | Configurable approach/exit paths and holding tabs with snap placement |
|
||||||
|
| **Contour & Program Editing** | Inline G-code editor with contour reordering and cut-direction reversal |
|
||||||
|
| **User-Defined Variables** | Named G-code variables (`$name`) emitted as machine variables (`#200+`) at post time |
|
||||||
|
| **Post-Processors** | Plugin-based G-code generation; Cincinnati CL-707/800/900/940/CLX included |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -61,6 +81,15 @@ Or open `OpenNest.sln` in Visual Studio and run the `OpenNest` project.
|
|||||||
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
|
5. **Add cut-offs** — Optionally add horizontal/vertical cut-off lines to trim unused plate material
|
||||||
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
6. **Export** — Save as a `.nest` file, export to DXF, or post-process to G-code
|
||||||
|
|
||||||
|
### CAD Converter
|
||||||
|
|
||||||
|
The CAD Converter turns DXF/DWG files into nest-ready drawings. Toggle layers, colors, and linetypes to exclude construction geometry; review detected bend lines; and preview the generated cut program with contour ordering before accepting the drawing into the nest.
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="screenshots/screenshot-cad-converter-1.png"><img src="screenshots/screenshot-cad-converter-1.png" width="420" alt="CAD Converter — layer, color, and linetype filtering"></a>
|
||||||
|
<a href="screenshots/screenshot-cad-converter-2.png"><img src="screenshots/screenshot-cad-converter-2.png" width="420" alt="CAD Converter — contour list and G-code preview"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Command-Line Interface
|
## Command-Line Interface
|
||||||
|
|
||||||
OpenNest includes a CLI for batch nesting without the GUI — useful for automation, scripting, and CI pipelines.
|
OpenNest includes a CLI for batch nesting without the GUI — useful for automation, scripting, and CI pipelines.
|
||||||
@@ -172,6 +201,8 @@ Oversized parts that don't fit on a single plate can be split into smaller piece
|
|||||||
|
|
||||||
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
|
The split system supports fit-to-plate (auto-calculates split lines) and split-by-count modes, with an interactive UI for adjusting split positions and feature parameters.
|
||||||
|
|
||||||
|
**Cutout-aware clipping.** Split lines are trimmed against interior cutouts so cut paths never travel through a hole. Lines are Liang-Barsky clipped at region boundaries and arcs/circles are iteratively split at their intersections with the region box, so a cutout that straddles a split correctly contributes material to both sides. When a cutout fully spans the region between two splits, the material breaks into physically disconnected strips — the splitter detects the connected components via endpoint connectivity, nests any remaining holes inside their outer loops by bounding-box and point-in-polygon containment, and emits one drawing per strip.
|
||||||
|
|
||||||
## Post-Processors
|
## Post-Processors
|
||||||
|
|
||||||
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
|
Post-processors convert nested layouts into machine-specific G-code. They are loaded as plugin DLLs from the `Posts/` directory at runtime.
|
||||||
@@ -212,16 +243,11 @@ Custom post-processors implement the `IPostProcessor` interface and are auto-dis
|
|||||||
|
|
||||||
Nest files (`.nest`) are ZIP archives containing:
|
Nest files (`.nest`) are ZIP archives containing:
|
||||||
|
|
||||||
- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs)
|
- `nest.json` — JSON metadata: nest info (name, customer, units, material, thickness, assist gas, salvage rate), plate defaults, plate options (alternative sizes with cost), drawings (with bend lines, material, source path, rotation constraints), and plates (size, quadrant, grain angle, parts with manual lead-in flags, cut-offs)
|
||||||
- `programs/program-N` — G-code text for each drawing's cut program (may include variable definitions and `$name` references)
|
- `programs/program-N` — G-code text for drawing N's cut program (may include variable definitions and `$name` references)
|
||||||
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional)
|
- `programs/program-N-subs` — Sub-program definitions for drawing N (M98/G65-callable blocks for repeated features like holes)
|
||||||
|
- `entities/entities-N` — Original source entities for drawing N (preserved from DXF import with per-entity suppression state for round-trip editing)
|
||||||
## Roadmap
|
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results for drawing N, keyed by plate size and spacing (optional)
|
||||||
|
|
||||||
- **NFP-based auto-nesting** — Simulated annealing optimizer and NFP placement exist in the engine but aren't exposed as a selectable engine yet
|
|
||||||
- **Geometry simplifier** — Replace consecutive small line segments with fitted arcs to reduce program size and improve nesting performance
|
|
||||||
- **Shape library UI** — 12 built-in parametric shapes exist in code; needs a browsable library UI for quick access
|
|
||||||
- **Additional post-processors** — Plugin interface is in place; more machine-specific post-processors planned
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,212 @@
|
|||||||
|
# Cincinnati Post Output Reference
|
||||||
|
|
||||||
|
Reference for the G-code structure emitted by `OpenNest.Posts.Cincinnati`.
|
||||||
|
Every code listed here maps to a section in the Cincinnati Laser Programming
|
||||||
|
Manual (`docs/CINCINNATI LASER PROGRAMMING MANUAL.pdf`, EM-423 R-02/11).
|
||||||
|
Section numbers in parentheses (e.g. `§1.52`) refer to the manual.
|
||||||
|
|
||||||
|
If you add a new emission in the post, either cite the manual section it maps
|
||||||
|
to, or flag it here as a known custom extension. "Custom code" in this project
|
||||||
|
means something that is not documented in the manual but that the Cincinnati
|
||||||
|
control is known to accept — none exist today and we should not introduce any
|
||||||
|
without confirming the control behavior.
|
||||||
|
|
||||||
|
## Overall file structure
|
||||||
|
|
||||||
|
A generated file contains, in order:
|
||||||
|
|
||||||
|
1. **Main program** (`CincinnatiPreambleWriter.WriteMainProgram`)
|
||||||
|
Preamble, unit/mode setup, initial library, variable-declaration call, one
|
||||||
|
`M98 P<sheetSubNum>` call per plate quantity, and `M30` to end.
|
||||||
|
|
||||||
|
2. **Variable declaration sub-program** (`CincinnatiPreambleWriter.WriteVariableDeclaration`)
|
||||||
|
Machine variables (`#number = value`) used across the nest, terminated
|
||||||
|
with `M99`.
|
||||||
|
|
||||||
|
3. **Sheet sub-programs** (`CincinnatiSheetWriter.Write`), one per unique plate
|
||||||
|
layout. A sheet sub-program contains the cutting sequence for a whole
|
||||||
|
plate, either with features inlined or with `M98` calls into part
|
||||||
|
sub-programs.
|
||||||
|
|
||||||
|
4. **Part sub-programs** (`CincinnatiPartSubprogramWriter.Write`), one per
|
||||||
|
unique `(drawing, rotation)` pair, only emitted when
|
||||||
|
`Config.UsePartSubprograms` is enabled.
|
||||||
|
|
||||||
|
5. **Hole sub-programs** (`CincinnatiPartSubprogramWriter.Write` reused with a
|
||||||
|
`"HOLE"` label), one per unique hole geometry keyed by radius and lead-in
|
||||||
|
normal angle.
|
||||||
|
|
||||||
|
Sub-program bodies start with a `:<subNum>` label and end with `M99`.
|
||||||
|
|
||||||
|
## Feature blocks
|
||||||
|
|
||||||
|
A "feature" is a single contour: lead-in → cut moves → lead-out. Each feature
|
||||||
|
block in a sheet or sub-program output follows this order
|
||||||
|
(`CincinnatiFeatureWriter.Write`):
|
||||||
|
|
||||||
|
1. `G0 X_ Y_` — rapid to the pierce point (§1.00).
|
||||||
|
2. Optional part-name comment, only on the first feature of each part.
|
||||||
|
3. `G89 P<library>` — load process parameters (§2.89). `P` is a library file
|
||||||
|
name; the `(...)` trailing comment carries speed-class info.
|
||||||
|
4. `G84` (cut) or `G85` (etch / no-pierce) — pierce and start cut, or start
|
||||||
|
cut without pierce (§2.84 / §2.85).
|
||||||
|
5. `M130 (ANTI DIVE OFF)` — disable anti-dive, only if configured (§3.130).
|
||||||
|
6. Contour moves:
|
||||||
|
- `G41` (left) or `G42` (right) kerf compensation on the first cut move
|
||||||
|
(§1.41 / §1.42), suppressed for etch features.
|
||||||
|
- `G1 X_ Y_ [F<feedvar>]` — linear cut move (§1.01). Feedrate references a
|
||||||
|
machine variable such as `#148` and is emitted only when it changes.
|
||||||
|
- `G2 X_ Y_ I_ J_ [F<feedvar>]` (CW) or `G3` (CCW) — arc (§1.02 / §1.03).
|
||||||
|
`I`/`J` are incremental offsets from the current position to the center.
|
||||||
|
7. `G40` — cancel kerf compensation (§1.40), only if it was applied.
|
||||||
|
8. `M35` (or `M135` if SpeedGas is enabled) — beam off (§3.35 / §3.135).
|
||||||
|
9. `M131 (ANTI DIVE ON)` — re-enable anti-dive (§3.131).
|
||||||
|
10. `M47` or `M47 P<distance>` — raise Z-axis, unless this is the last feature
|
||||||
|
on the sheet (§3.47). A leading `/` (block delete, §5.6) is prepended when
|
||||||
|
the configured override distance exceeds the default.
|
||||||
|
|
||||||
|
Sheet sub-program and sheet-level feature calls add `G92 X#5021 Y#5022`
|
||||||
|
(§1.92) at the top so the local origin is anchored to the machine's current
|
||||||
|
absolute position (`#5021`/`#5022` are the machine X/Y system variables).
|
||||||
|
|
||||||
|
## Sub-program call patterns
|
||||||
|
|
||||||
|
There are two distinct call-site patterns, depending on whether the call
|
||||||
|
targets a whole-part sub-program or a hole sub-program.
|
||||||
|
|
||||||
|
### Part sub-program call (`WriteSubprogramCall`)
|
||||||
|
|
||||||
|
Used when `Config.UsePartSubprograms` is enabled. The tool physically rapids
|
||||||
|
to the part corner, then G92 sets the current position as the local origin,
|
||||||
|
the sub-program executes in its own local coordinate frame, and G92 restores
|
||||||
|
the original absolute position after return.
|
||||||
|
|
||||||
|
```
|
||||||
|
G0 X<left> Y<bottom> ; rapid to part bounding box corner (§1.00)
|
||||||
|
(PART: <name>)
|
||||||
|
G92 X0 Y0 ; set local origin at current position (§1.92)
|
||||||
|
M98 P<partSubNum> (<name>) ; call the part sub-program (§3.98)
|
||||||
|
G92 X<left> Y<bottom> ; restore the sheet coordinate system (§1.92)
|
||||||
|
M47 ; head raise unless this is the last part (§3.47)
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern uses G92 because the tool is physically positioned at the part
|
||||||
|
corner first. The sub-program's coordinates are part-local, so they are
|
||||||
|
interpreted against the new origin until G92 restores the sheet frame.
|
||||||
|
|
||||||
|
### Hole sub-program call (`WriteHoleSubprogramCall`)
|
||||||
|
|
||||||
|
Used for the `SubProgramCall` codes that a `ContourCuttingStrategy` emits for
|
||||||
|
each circular hole. Unlike parts, we do **not** want a physical rapid to the
|
||||||
|
hole center before calling — the sub-program's first rapid is the lead-in to
|
||||||
|
the pierce point, and the machine should travel directly from the previous
|
||||||
|
feature's end to that pierce.
|
||||||
|
|
||||||
|
```
|
||||||
|
G52 X<hole.x> Y<hole.y> ; shift local origin to hole center (§1.52)
|
||||||
|
M98 P<holeSubNum> ; call the shared hole sub-program (§3.98)
|
||||||
|
G52 X0 Y0 ; restore the original coordinate system (§1.52)
|
||||||
|
M47 ; head raise unless this is the last feature (§3.47)
|
||||||
|
```
|
||||||
|
|
||||||
|
G52 specifies the new origin in the current work coordinate system and — per
|
||||||
|
§1.52 — "does not move the cutting nozzle". The hole sub-program is written
|
||||||
|
in hole-local coordinates (origin at the hole center, produced by
|
||||||
|
`ContourCuttingStrategy`), so its first `G0 X_ Y_` resolves to `hole + local`
|
||||||
|
in absolute terms. That is the first physical motion, and it takes the tool
|
||||||
|
straight from wherever it was to the lead-in pierce point. G52 X0 Y0 cancels
|
||||||
|
the shift after `M99` returns control.
|
||||||
|
|
||||||
|
## G-code reference
|
||||||
|
|
||||||
|
These are every G/M code the post emits, grouped by category. Anything here is
|
||||||
|
documented in the programming manual. Anything not here should be audited the
|
||||||
|
next time the post is edited.
|
||||||
|
|
||||||
|
### Motion modes and contouring
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `G0 X_ Y_` | Rapid traverse | §1.00 |
|
||||||
|
| `G1 X_ Y_ F_` | Linear feedrate move | §1.01 |
|
||||||
|
| `G2 X_ Y_ I_ J_ F_` | Clockwise arc | §1.02 |
|
||||||
|
| `G3 X_ Y_ I_ J_ F_` | Counter-clockwise arc | §1.03 |
|
||||||
|
|
||||||
|
### Units and coordinate mode
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `G20` | Inch mode | §1.20 |
|
||||||
|
| `G21` | Metric mode | §1.21 |
|
||||||
|
| `G90` | Absolute mode | §1.90 |
|
||||||
|
|
||||||
|
### Kerf compensation
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `G40` | Cancel kerf compensation | §1.40 |
|
||||||
|
| `G41` | Kerf compensation, left side | §1.41 |
|
||||||
|
| `G42` | Kerf compensation, right side | §1.42 |
|
||||||
|
|
||||||
|
### Work coordinate systems
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `G52 X_ Y_` | Temporary local work coordinate offset. Does not move the tool. `G52 X0 Y0` cancels. | §1.52 |
|
||||||
|
| `G92 X_ Y_` | Sets the current tool position to `(X, Y)` in the work coordinate system, implicitly redefining the WCS origin. | §1.92 |
|
||||||
|
|
||||||
|
### Exact stop
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `G61` | Exact stop mode | §1.61 |
|
||||||
|
|
||||||
|
### Cutting operations (custom Cincinnati G-codes)
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `G84` | Pierce and start cut | §2.84 |
|
||||||
|
| `G85` | Start cut without pierce (used for etch) | §2.85 |
|
||||||
|
| `G89 P<file>` | Load process parameters from a library file | §2.89 |
|
||||||
|
| `G121` | Enable non-stop cutting (Smart Rapids) | §2.121 |
|
||||||
|
|
||||||
|
### Program flow
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `M30` | End of main program with rewind | §3.30 |
|
||||||
|
| `M98 P_` | Sub-program call. **Takes only `P` and `L` — not `X`/`Y`.** | §3.98 |
|
||||||
|
| `M99` | Return from sub-program | §3.99 |
|
||||||
|
|
||||||
|
### Machine state
|
||||||
|
|
||||||
|
| Code | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `M35` | Beam off | §3.35 |
|
||||||
|
| `M42` | Retract Z-axis | §3.42 |
|
||||||
|
| `M47 [P<dist>]` | Raise Z-axis, optionally by a distance | §3.47 |
|
||||||
|
| `M50` | Switch pallets | §3.50 |
|
||||||
|
| `M130` | Anti-dive off | §3.130 |
|
||||||
|
| `M131` | Anti-dive on | §3.131 |
|
||||||
|
| `M135` | Discharge current off (keeps assist gas on) | §3.135 |
|
||||||
|
|
||||||
|
### Comments, labels, and block delete
|
||||||
|
|
||||||
|
| Syntax | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `(text)` | Inline comment | §5.4 |
|
||||||
|
| `:<number>` | Sub-program label | §3.98 |
|
||||||
|
| `/<block>` | Block delete — operator can toggle the line off | §5.6 |
|
||||||
|
| `N<number>` | Line number, used by M99 P / GOTO targets | §5.5 |
|
||||||
|
|
||||||
|
## System variables referenced
|
||||||
|
|
||||||
|
| Variable | Description | Manual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `#148` | Default cut feedrate variable (used in `F#148`) | §2.89 |
|
||||||
|
| `#5021` | Current machine X position | §6 (table of system variables) |
|
||||||
|
| `#5022` | Current machine Y position | §6 (table of system variables) |
|
||||||
|
|
||||||
|
Project-defined variables start at `Config.SheetWidthVariable` /
|
||||||
|
`Config.SheetLengthVariable` and at `Config.UserVariableStart`. Those ranges
|
||||||
|
are documented in `CincinnatiPostConfig.cs`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Reference in New Issue
Block a user