Compare commits
74 Commits
v0.1.0
..
d2f9597b0c
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f9597b0c | |||
| c40dcf0e25 | |||
| 28653e3a9f | |||
| 7c3246c6e7 | |||
| bd48f57ce0 | |||
| a6ec21accc | |||
| 320cf40f41 | |||
| 3beca10429 | |||
| 8bea5dac6c | |||
| 12f8bbf8f5 | |||
| d15790b948 | |||
| d80f76e386 | |||
| 07bce8699a | |||
| 9b84508ff4 | |||
| 6fdf0ad3c5 | |||
| 4f7bfcc3ad | |||
| 3c53d6fecd | |||
| e239967a7b | |||
| 9d57d3875a | |||
| 0e299d7f6f | |||
| c6f544c5d7 | |||
| 9563094c2b | |||
| 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/
|
||||
docs/superpowers/
|
||||
|
||||
# Documentation (manuals, templates, etc.)
|
||||
docs/
|
||||
|
||||
# Launch settings
|
||||
**/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).
|
||||
- `ProgramReader` — G-code text parser.
|
||||
- `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)
|
||||
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.
|
||||
- **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.
|
||||
- **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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
@@ -30,15 +28,21 @@ public static class NestRunner
|
||||
if (!File.Exists(part.DxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {part.DxfPath}", part.DxfPath);
|
||||
|
||||
var geometry = Dxf.GetGeometry(part.DxfPath);
|
||||
if (geometry.Count == 0)
|
||||
Drawing drawing;
|
||||
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}");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
@@ -42,7 +41,6 @@ static class NestConsole
|
||||
}
|
||||
}
|
||||
|
||||
using var log = SetUpLog(options);
|
||||
var nest = LoadOrCreateNest(options);
|
||||
|
||||
if (nest == null)
|
||||
@@ -69,10 +67,6 @@ static class NestConsole
|
||||
|
||||
var overlapCount = CheckOverlaps(plate, options);
|
||||
|
||||
// Flush and close the log before printing results.
|
||||
Trace.Flush();
|
||||
log?.Dispose();
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
Save(nest, options);
|
||||
PostProcess(nest, options);
|
||||
@@ -113,9 +107,6 @@ static class NestConsole
|
||||
case "--no-save":
|
||||
o.NoSave = true;
|
||||
break;
|
||||
case "--no-log":
|
||||
o.NoLog = true;
|
||||
break;
|
||||
case "--keep-parts":
|
||||
o.KeepParts = true;
|
||||
break;
|
||||
@@ -154,21 +145,6 @@ static class NestConsole
|
||||
return o;
|
||||
}
|
||||
|
||||
static StreamWriter SetUpLog(Options options)
|
||||
{
|
||||
if (options.NoLog)
|
||||
return null;
|
||||
|
||||
var baseDir = Path.GetDirectoryName(options.InputFiles[0]);
|
||||
var logDir = Path.Combine(baseDir, "test-harness-logs");
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
||||
var writer = new StreamWriter(logFile) { AutoFlush = true };
|
||||
Trace.Listeners.Add(new TextWriterTraceListener(writer));
|
||||
Console.WriteLine($"Debug log: {logFile}");
|
||||
return writer;
|
||||
}
|
||||
|
||||
static Nest LoadOrCreateNest(Options options)
|
||||
{
|
||||
var nestFile = options.InputFiles.FirstOrDefault(f =>
|
||||
@@ -241,25 +217,15 @@ static class NestConsole
|
||||
|
||||
static Drawing ImportDxf(string path)
|
||||
{
|
||||
var geometry = Dxf.GetGeometry(path);
|
||||
|
||||
if (geometry.Count == 0)
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -514,7 +480,6 @@ static class NestConsole
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.Error.WriteLine(" --post <name> Run a post processor after nesting");
|
||||
Console.Error.WriteLine(" --post-output <path> Output file for post processor (default: <input>.cnc)");
|
||||
Console.Error.WriteLine(" --posts-dir <path> Directory containing post processor DLLs (default: Posts/)");
|
||||
@@ -533,7 +498,6 @@ static class NestConsole
|
||||
public Size? PlateSize;
|
||||
public bool CheckOverlaps;
|
||||
public bool NoSave;
|
||||
public bool NoLog;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public string TemplateFile;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
@@ -11,6 +12,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
private record ContourEntry(Shape Shape, Vector Point, Entity Entity);
|
||||
|
||||
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();
|
||||
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
|
||||
@@ -20,26 +26,60 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
|
||||
// Forward pass: sequence cutouts nearest-neighbor from perimeter
|
||||
var perimeterPoint = profile.Perimeter.ClosestPointTo(approachPoint, out _);
|
||||
var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint);
|
||||
// Start from the bounding box corner opposite the origin (max X, max Y)
|
||||
var bbox = entities.GetBoundingBox();
|
||||
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();
|
||||
|
||||
// Backward pass: walk from perimeter back through cutting order
|
||||
// so each lead-in faces the next cutout to be cut, not the previous
|
||||
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterPoint);
|
||||
var perimeterSeed = profile.Perimeter.ClosestPointTo(seedPoint, out _);
|
||||
var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterSeed);
|
||||
|
||||
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);
|
||||
|
||||
EmitScribeContours(result, scribeEntities);
|
||||
|
||||
foreach (var entry in cutoutEntries)
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
{
|
||||
if (!entry.Shape.IsClosed())
|
||||
EmitRawContour(result, entry.Shape);
|
||||
else
|
||||
EmitContour(result, entry.Shape, entry.Point, entry.Entity);
|
||||
}
|
||||
|
||||
// Perimeter last
|
||||
var lastRefPoint = cutoutEntries.Count > 0 ? cutoutEntries[cutoutEntries.Count - 1].Point : approachPoint;
|
||||
var perimeterPt = profile.Perimeter.ClosestPointTo(lastRefPoint, out var perimeterEntity);
|
||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||
if (!profile.Perimeter.IsClosed())
|
||||
EmitRawContour(result, profile.Perimeter);
|
||||
else
|
||||
EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External);
|
||||
|
||||
result.Mode = Mode.Incremental;
|
||||
|
||||
@@ -67,10 +107,14 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
// Find the target shape that contains the clicked entity
|
||||
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
|
||||
|
||||
// Emit cutouts — only the target gets lead-in/out
|
||||
// Emit cutouts — only the target gets lead-in/out (skip open contours)
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
{
|
||||
if (cutout == targetShape)
|
||||
if (!cutout.IsClosed())
|
||||
{
|
||||
EmitRawContour(result, cutout);
|
||||
}
|
||||
else if (cutout == targetShape)
|
||||
{
|
||||
var ct = DetectContourType(cutout);
|
||||
EmitContour(result, cutout, point, matchedEntity, ct);
|
||||
@@ -82,7 +126,11 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -187,6 +235,40 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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)
|
||||
{
|
||||
var contourType = forceType ?? DetectContourType(shape);
|
||||
@@ -197,16 +279,59 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
var leadOut = SelectLeadOut(contourType);
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
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));
|
||||
|
||||
var reindexed = shape.ReindexAt(point, entity);
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
|
||||
reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size);
|
||||
if (Parameters.TabsEnabled && Parameters.TabConfig != null && contourType == ContourType.External)
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
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 AutoTabMaxSize { get; set; }
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace OpenNest.CNC
|
||||
|
||||
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Dictionary<int, Program> SubPrograms { get; } = new();
|
||||
|
||||
private Mode mode;
|
||||
|
||||
public Program(Mode mode = Mode.Absolute)
|
||||
@@ -87,6 +89,17 @@ namespace OpenNest.CNC
|
||||
{
|
||||
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)
|
||||
subpgm.Program.Rotate(angle, origin);
|
||||
}
|
||||
@@ -115,6 +128,12 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var code = Codes[i];
|
||||
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + x, subpgm.Offset.Y + y);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
continue;
|
||||
|
||||
@@ -137,6 +156,12 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var code = Codes[i];
|
||||
|
||||
if (code is SubProgramCall subpgm)
|
||||
{
|
||||
subpgm.Offset = new Geometry.Vector(
|
||||
subpgm.Offset.X + voffset.X, subpgm.Offset.Y + voffset.Y);
|
||||
}
|
||||
|
||||
if (code is Motion == false)
|
||||
continue;
|
||||
|
||||
@@ -275,6 +300,10 @@ namespace OpenNest.CNC
|
||||
|
||||
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 minY = 0.0;
|
||||
double maxX = 0.0;
|
||||
@@ -290,7 +319,7 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var line = (LinearMove)code;
|
||||
var pt = Mode == Mode.Absolute ?
|
||||
line.EndPoint :
|
||||
frameOrigin + line.EndPoint :
|
||||
line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
@@ -312,7 +341,7 @@ namespace OpenNest.CNC
|
||||
{
|
||||
var line = (RapidMove)code;
|
||||
var pt = Mode == Mode.Absolute
|
||||
? line.EndPoint
|
||||
? frameOrigin + line.EndPoint
|
||||
: line.EndPoint + pos;
|
||||
|
||||
if (pt.X > maxX)
|
||||
@@ -345,8 +374,8 @@ namespace OpenNest.CNC
|
||||
}
|
||||
else
|
||||
{
|
||||
endpt = arc.EndPoint;
|
||||
centerpt = arc.CenterPoint;
|
||||
endpt = frameOrigin + arc.EndPoint;
|
||||
centerpt = frameOrigin + arc.CenterPoint;
|
||||
}
|
||||
|
||||
double minX1;
|
||||
@@ -420,6 +449,12 @@ namespace OpenNest.CNC
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
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);
|
||||
|
||||
if (box.Left < minX)
|
||||
@@ -460,6 +495,9 @@ namespace OpenNest.CNC
|
||||
foreach (var kvp in Variables)
|
||||
pgm.Variables[kvp.Key] = kvp.Value;
|
||||
|
||||
foreach (var kvp in SubPrograms)
|
||||
pgm.SubPrograms[kvp.Key] = (Program)kvp.Value.Clone();
|
||||
|
||||
return pgm;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC
|
||||
{
|
||||
public static class RapidEnumerator
|
||||
{
|
||||
public readonly record struct Segment(Vector From, Vector To);
|
||||
|
||||
public static List<Segment> Enumerate(Program pgm, Vector basePos, Vector startPos)
|
||||
{
|
||||
var results = new List<Segment>();
|
||||
|
||||
// Draw the rapid from the previous tool position to the program's first
|
||||
// pierce point. This also primes pos so the interior walk interprets
|
||||
// Incremental deltas from the correct absolute location (basePos), which
|
||||
// matters for raw pre-lead-in programs that are emitted Incremental.
|
||||
var firstPierce = FirstPiercePoint(pgm, basePos);
|
||||
results.Add(new Segment(startPos, firstPierce));
|
||||
|
||||
var pos = firstPierce;
|
||||
Walk(pgm, basePos, ref pos, skipFirst: true, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; i++)
|
||||
{
|
||||
if (pgm[i] is SubProgramCall call && call.Program != null)
|
||||
return FirstPiercePoint(call.Program, basePos + call.Offset);
|
||||
|
||||
if (pgm[i] is Motion motion)
|
||||
return motion.EndPoint + basePos;
|
||||
}
|
||||
return basePos;
|
||||
}
|
||||
|
||||
private static void Walk(Program pgm, Vector basePos, ref Vector pos, bool skipFirst, List<Segment> results)
|
||||
{
|
||||
var skipped = !skipFirst;
|
||||
|
||||
for (var i = 0; i < pgm.Length; ++i)
|
||||
{
|
||||
var code = pgm[i];
|
||||
|
||||
if (code is SubProgramCall { Program: { } program } call)
|
||||
{
|
||||
var holeBase = basePos + call.Offset;
|
||||
var firstPierce = FirstPiercePoint(program, holeBase);
|
||||
|
||||
if (!skipped)
|
||||
skipped = true;
|
||||
else
|
||||
results.Add(new Segment(pos, firstPierce));
|
||||
|
||||
var subPos = holeBase;
|
||||
Walk(program, holeBase, ref subPos, skipFirst: true, results);
|
||||
pos = subPos;
|
||||
}
|
||||
else if (code is Motion motion)
|
||||
{
|
||||
var endpt = pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.RapidMove)
|
||||
{
|
||||
if (!skipped)
|
||||
skipped = true;
|
||||
else
|
||||
results.Add(new Segment(pos, endpt));
|
||||
}
|
||||
|
||||
pos = endpt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using OpenNest.Math;
|
||||
using System.Text;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
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>
|
||||
/// Gets or sets the rotation of the program in degrees.
|
||||
/// </summary>
|
||||
@@ -78,12 +86,18 @@ namespace OpenNest.CNC
|
||||
/// <returns></returns>
|
||||
public ICode Clone()
|
||||
{
|
||||
return new SubProgramCall(program, Rotation);
|
||||
return new SubProgramCall(program, Rotation) { Id = Id, Offset = Offset };
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
namespace OpenNest.Converters
|
||||
@@ -9,7 +9,6 @@ namespace OpenNest.Converters
|
||||
/// Converts the program to absolute coordinates.
|
||||
/// Does NOT check program mode before converting.
|
||||
/// </summary>
|
||||
/// <param name="pgm"></param>
|
||||
public static void ToAbsolute(Program pgm)
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
@@ -17,21 +16,27 @@ namespace OpenNest.Converters
|
||||
for (int i = 0; i < pgm.Codes.Count; ++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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts the program to intermental coordinates.
|
||||
/// Converts the program to incremental coordinates.
|
||||
/// Does NOT check program mode before converting.
|
||||
/// </summary>
|
||||
/// <param name="pgm"></param>
|
||||
public static void ToIncremental(Program pgm)
|
||||
{
|
||||
var pos = new Vector(0, 0);
|
||||
@@ -39,9 +44,16 @@ namespace OpenNest.Converters
|
||||
for (int i = 0; i < pgm.Codes.Count; ++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;
|
||||
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)
|
||||
{
|
||||
// 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;
|
||||
|
||||
for (int i = 0; i < program.Length; ++i)
|
||||
@@ -41,12 +44,15 @@ namespace OpenNest.Converters
|
||||
break;
|
||||
|
||||
case CodeType.SubProgramCall:
|
||||
var tmpmode = mode;
|
||||
var subpgm = (SubProgramCall)code;
|
||||
var geoProgram = new Shape();
|
||||
AddProgram(subpgm.Program, ref mode, ref curpos, ref geoProgram.Entities);
|
||||
geometry.Add(geoProgram);
|
||||
mode = tmpmode;
|
||||
var savedMode = mode;
|
||||
|
||||
// The sub-program's frame origin in this program's frame is
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace OpenNest
|
||||
private static int nextColorIndex;
|
||||
private Program program;
|
||||
|
||||
public static readonly Color[] PartColors = new Color[]
|
||||
public static Color[] PartColors = new Color[]
|
||||
{
|
||||
Color.FromArgb(205, 92, 92), // Indian Red
|
||||
Color.FromArgb(148, 103, 189), // Medium Purple
|
||||
|
||||
@@ -267,6 +267,13 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Reverses the rotation direction.
|
||||
/// </summary>
|
||||
|
||||
@@ -165,6 +165,13 @@ namespace OpenNest.Geometry
|
||||
get { return Circumference(); }
|
||||
}
|
||||
|
||||
public override Entity Clone()
|
||||
{
|
||||
var copy = new Circle(center, radius) { Rotation = Rotation };
|
||||
CopyBaseTo(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the rotation direction.
|
||||
/// </summary>
|
||||
|
||||
@@ -251,6 +251,23 @@ namespace OpenNest.Geometry
|
||||
/// <returns></returns>
|
||||
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>
|
||||
/// Type of entity.
|
||||
/// </summary>
|
||||
@@ -259,6 +276,14 @@ namespace OpenNest.Geometry
|
||||
|
||||
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)
|
||||
{
|
||||
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>
|
||||
/// Reversed the line.
|
||||
/// </summary>
|
||||
|
||||
@@ -168,6 +168,13 @@ namespace OpenNest.Geometry
|
||||
get { return Perimeter(); }
|
||||
}
|
||||
|
||||
public override Entity Clone()
|
||||
{
|
||||
var copy = new Polygon { Vertices = new List<Vector>(Vertices) };
|
||||
CopyBaseTo(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverses the rotation direction of the polygon.
|
||||
/// </summary>
|
||||
|
||||
@@ -349,6 +349,15 @@ namespace OpenNest.Geometry
|
||||
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>
|
||||
/// Reverses the rotation direction of the shape.
|
||||
/// </summary>
|
||||
|
||||
@@ -75,7 +75,8 @@ namespace OpenNest.Geometry
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -306,49 +306,38 @@ namespace OpenNest.Geometry
|
||||
var minDist = double.MaxValue;
|
||||
var vx = vertex.X;
|
||||
var vy = vertex.Y;
|
||||
var horizontal = IsHorizontalDirection(direction);
|
||||
|
||||
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
// Pruning: edges are sorted by their perpendicular min-coordinate.
|
||||
// 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 e2 = edges[i].end + edgeOffset;
|
||||
|
||||
double perpValue, edgeMin, edgeMax;
|
||||
if (horizontal)
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
|
||||
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
|
||||
if (vy < minY - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (vy > maxY + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
perpValue = vy;
|
||||
edgeMin = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
edgeMax = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
}
|
||||
}
|
||||
else // Up/Down
|
||||
{
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
else
|
||||
{
|
||||
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;
|
||||
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 edgeMin, if perpValue < edgeMin, all subsequent edges are also past.
|
||||
if (perpValue < edgeMin - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (perpValue > edgeMax + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
@@ -642,19 +631,46 @@ namespace OpenNest.Geometry
|
||||
{
|
||||
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 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++)
|
||||
{
|
||||
if (lineEntities[j] is Line line)
|
||||
{
|
||||
var linePt = line.ClosestPointTo(arc.Center);
|
||||
var arcPt = arc.ClosestPointTo(linePt);
|
||||
var d = RayEdgeDistance(arcPt.X, arcPt.Y,
|
||||
line.pt1.X, line.pt1.Y, line.pt2.X, line.pt2.Y,
|
||||
dirX, dirY);
|
||||
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||
}
|
||||
var theta = k == 0 ? theta1 : theta2;
|
||||
|
||||
if (!Angle.IsBetweenRad(theta, arc.StartAngle, arc.EndAngle, arc.IsReversed))
|
||||
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);
|
||||
if (d < minDist) { minDist = d; if (d <= 0) return 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IMaterialProvidingPostProcessor
|
||||
{
|
||||
IEnumerable<string> GetMaterialNames();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public interface IPostProcessorNestAware
|
||||
{
|
||||
void PrepareForNest(Nest nest);
|
||||
}
|
||||
}
|
||||
@@ -62,10 +62,15 @@ namespace OpenNest
|
||||
public CNC.CuttingStrategy.CuttingParameters CuttingParameters { get; set; }
|
||||
|
||||
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;
|
||||
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
|
||||
var result = strategy.Apply(Program, approachPoint);
|
||||
var result = strategy.Apply(Program, approachPoint, nextPartStart);
|
||||
Program = result.Program;
|
||||
CuttingParameters = parameters;
|
||||
HasManualLeadIns = true;
|
||||
|
||||
@@ -126,20 +126,10 @@ namespace OpenNest
|
||||
{
|
||||
var result = new List<Entity>(source.Count);
|
||||
|
||||
for (var i = 0; i < source.Count; i++)
|
||||
foreach (var entity in source)
|
||||
{
|
||||
var entity = source[i];
|
||||
Entity copy;
|
||||
|
||||
if (entity is Line line)
|
||||
copy = new Line(line.StartPoint + location, line.EndPoint + location);
|
||||
else if (entity is Arc arc)
|
||||
copy = new Arc(arc.Center + location, arc.Radius, arc.StartAngle, arc.EndAngle, arc.IsReversed);
|
||||
else if (entity is Circle circle)
|
||||
copy = new Circle(circle.Center + location, circle.Radius);
|
||||
else
|
||||
continue;
|
||||
|
||||
var copy = entity.Clone();
|
||||
copy.Offset(location);
|
||||
result.Add(copy);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using OpenNest.Collections;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -548,6 +549,65 @@ namespace OpenNest
|
||||
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>
|
||||
/// Gets the area of the top surface of the plate.
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace OpenNest.Shapes
|
||||
{
|
||||
public double Diameter { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Circle {Dim(Diameter)} Dia";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Diameter = 8;
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
||||
public double Base { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Isosceles Triangle {Dim(Base)}x{Dim(Height)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Base = 8;
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
|
||||
public double LegWidth { get; set; }
|
||||
public double LegHeight { get; set; }
|
||||
|
||||
public override string GenerateName() => $"L {Dim(Width)}x{Dim(Height)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
|
||||
@@ -3,33 +3,40 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public class OctagonShape : ShapeDefinition
|
||||
public class NgonShape : ShapeDefinition
|
||||
{
|
||||
public int Sides { get; set; }
|
||||
public double Width { get; set; }
|
||||
|
||||
public override string GenerateName() => $"{Sides}-Sided Polygon {Dim(Width)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Sides = 8;
|
||||
Width = 8;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var n = Sides < 3 ? 3 : Sides;
|
||||
var center = Width / 2.0;
|
||||
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / 8.0));
|
||||
var circumRadius = Width / (2.0 * System.Math.Cos(System.Math.PI / n));
|
||||
var step = 2.0 * System.Math.PI / n;
|
||||
var start = System.Math.PI / n;
|
||||
|
||||
var vertices = new Vector[8];
|
||||
for (var i = 0; i < 8; i++)
|
||||
var vertices = new Vector[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var angle = System.Math.PI / 8.0 + i * System.Math.PI / 4.0;
|
||||
var angle = start + i * step;
|
||||
vertices[i] = new Vector(
|
||||
center + circumRadius * System.Math.Cos(angle),
|
||||
center + circumRadius * System.Math.Sin(angle));
|
||||
}
|
||||
|
||||
var entities = new List<Entity>();
|
||||
for (var i = 0; i < 8; i++)
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var next = (i + 1) % 8;
|
||||
var next = (i + 1) % n;
|
||||
entities.Add(new Line(vertices[i], vertices[next]));
|
||||
}
|
||||
|
||||
@@ -3,31 +3,41 @@ using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Shapes
|
||||
{
|
||||
public class FlangeShape : ShapeDefinition
|
||||
public class PipeFlangeShape : ShapeDefinition
|
||||
{
|
||||
public double NominalPipeSize { get; set; }
|
||||
public double OD { get; set; }
|
||||
public double HoleDiameter { get; set; }
|
||||
public double HolePatternDiameter { get; set; }
|
||||
public int HoleCount { get; set; }
|
||||
public string PipeSize { get; set; }
|
||||
public double PipeClearance { get; set; }
|
||||
public bool Blind { get; set; }
|
||||
|
||||
public override string GenerateName()
|
||||
{
|
||||
var name = $"Pipe Flange {Dim(OD)} OD";
|
||||
if (!string.IsNullOrEmpty(PipeSize))
|
||||
name += $" {PipeSize} Pipe";
|
||||
return name;
|
||||
}
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
NominalPipeSize = 2;
|
||||
OD = 7.5;
|
||||
HoleDiameter = 0.875;
|
||||
HolePatternDiameter = 5.5;
|
||||
HoleCount = 8;
|
||||
PipeSize = "2";
|
||||
PipeClearance = 0.0625;
|
||||
Blind = false;
|
||||
}
|
||||
|
||||
public override Drawing GetDrawing()
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
|
||||
// Outer circle
|
||||
entities.Add(new Circle(0, 0, OD / 2.0));
|
||||
|
||||
// Bolt holes evenly spaced on the bolt circle
|
||||
var boltCircleRadius = HolePatternDiameter / 2.0;
|
||||
var holeRadius = HoleDiameter / 2.0;
|
||||
var angleStep = 2.0 * System.Math.PI / HoleCount;
|
||||
@@ -40,6 +50,12 @@ namespace OpenNest.Shapes
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
||||
public double Length { get; set; }
|
||||
public double Width { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Rectangle {Dim(Length)}x{Dim(Width)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
||||
public double Width { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Right Triangle {Dim(Width)}x{Dim(Height)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 8;
|
||||
|
||||
@@ -8,6 +8,8 @@ namespace OpenNest.Shapes
|
||||
public double OuterDiameter { get; set; }
|
||||
public double InnerDiameter { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Ring {Dim(OuterDiameter)}x{Dim(InnerDiameter)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
OuterDiameter = 10;
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
|
||||
public double Width { get; set; }
|
||||
public double Radius { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Rounded Rectangle {Dim(Length)}x{Dim(Width)} R{Dim(Radius)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Length = 12;
|
||||
|
||||
@@ -26,6 +26,14 @@ namespace OpenNest.Shapes
|
||||
|
||||
public abstract Drawing GetDrawing();
|
||||
|
||||
public virtual string GenerateName()
|
||||
{
|
||||
var typeName = GetType().Name;
|
||||
return typeName.EndsWith("Shape")
|
||||
? typeName.Substring(0, typeName.Length - 5)
|
||||
: typeName;
|
||||
}
|
||||
|
||||
public virtual void SetPreviewDefaults() { }
|
||||
|
||||
public static List<T> LoadFromJson<T>(string path) where T : ShapeDefinition
|
||||
@@ -34,6 +42,8 @@ namespace OpenNest.Shapes
|
||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOptions);
|
||||
}
|
||||
|
||||
protected static string Dim(double value) => value.ToString("0.###");
|
||||
|
||||
protected Drawing CreateDrawing(List<Entity> entities)
|
||||
{
|
||||
var pgm = ConvertGeometry.ToProgram(entities);
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace OpenNest.Shapes
|
||||
public double StemWidth { get; set; }
|
||||
public double BarHeight { get; set; }
|
||||
|
||||
public override string GenerateName() => $"T {Dim(Width)}x{Dim(Height)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
Width = 10;
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace OpenNest.Shapes
|
||||
public double BottomWidth { get; set; }
|
||||
public double Height { get; set; }
|
||||
|
||||
public override string GenerateName() => $"Trapezoid {Dim(TopWidth)}x{Dim(BottomWidth)}x{Dim(Height)}";
|
||||
|
||||
public override void SetPreviewDefaults()
|
||||
{
|
||||
TopWidth = 6;
|
||||
|
||||
@@ -32,12 +32,20 @@ public static class DrawingSplitter
|
||||
var regions = BuildClipRegions(sortedLines, bounds);
|
||||
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 pieceIndex = 1;
|
||||
|
||||
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)
|
||||
continue;
|
||||
|
||||
@@ -47,9 +55,16 @@ public static class DrawingSplitter
|
||||
allEntities.AddRange(pieceEntities);
|
||||
allEntities.AddRange(cutoutEntities);
|
||||
|
||||
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
|
||||
results.Add(piece);
|
||||
pieceIndex++;
|
||||
// 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);
|
||||
pieceIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
@@ -218,100 +233,108 @@ public static class DrawingSplitter
|
||||
/// and stitching in feature edges. No polygon clipping library needed.
|
||||
/// </summary>
|
||||
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
|
||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
|
||||
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters,
|
||||
List<Polygon> cutoutPolygons)
|
||||
{
|
||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||
var entities = new List<Entity>();
|
||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
||||
|
||||
foreach (var entity in perimeter.Entities)
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
}
|
||||
ProcessEntity(entity, region, entities);
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
|
||||
EnsurePerimeterWinding(entities);
|
||||
InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons);
|
||||
// Winding is handled later in AssemblePieces, once connected components
|
||||
// are known. At this stage the piece may still be multiple disjoint loops.
|
||||
return entities;
|
||||
}
|
||||
|
||||
private static void ProcessEntity(Entity entity, Box region,
|
||||
List<SplitLine> boundarySplitLines, List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
||||
{
|
||||
// Find the first boundary split line this entity crosses
|
||||
SplitLine crossedLine = null;
|
||||
Vector? intersectionPt = null;
|
||||
|
||||
foreach (var sl in boundarySplitLines)
|
||||
{
|
||||
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
|
||||
{
|
||||
var pt = SplitLineIntersect.FindIntersection(entity, sl);
|
||||
if (pt != null)
|
||||
{
|
||||
crossedLine = sl;
|
||||
intersectionPt = pt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (crossedLine != null)
|
||||
{
|
||||
// Entity crosses a split line — split it and keep the half inside the region
|
||||
var regionSide = RegionSideOf(region, crossedLine);
|
||||
var startPt = GetStartPoint(entity);
|
||||
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
|
||||
var startInRegion = startSide == regionSide || startSide == 0;
|
||||
|
||||
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Entity doesn't cross any boundary split line — check if it's inside the region
|
||||
var mid = MidPoint(entity);
|
||||
if (region.Contains(mid))
|
||||
entities.Add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
|
||||
SplitLine crossedLine, List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
|
||||
private static void ProcessEntity(Entity entity, Box region, List<Entity> entities)
|
||||
{
|
||||
if (entity is Line line)
|
||||
{
|
||||
var (first, second) = line.SplitAt(point);
|
||||
if (startInRegion)
|
||||
{
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
}
|
||||
var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region);
|
||||
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;
|
||||
}
|
||||
else if (entity is Arc arc)
|
||||
|
||||
if (entity is Arc arc)
|
||||
{
|
||||
var (first, second) = arc.SplitAt(point);
|
||||
if (startInRegion)
|
||||
{
|
||||
if (first != null) entities.Add(first);
|
||||
splitPoints.Add((point, crossedLine, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
splitPoints.Add((point, crossedLine, false));
|
||||
if (second != null) entities.Add(second);
|
||||
}
|
||||
foreach (var sub in ClipArcToRegion(arc, region))
|
||||
entities.Add(sub);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var edges = new[]
|
||||
{
|
||||
new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)),
|
||||
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)
|
||||
{
|
||||
var next = new List<Arc>();
|
||||
foreach (var a in arcs)
|
||||
{
|
||||
if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0)
|
||||
{
|
||||
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>
|
||||
/// Returns split lines whose position matches a boundary edge of the region.
|
||||
/// </summary>
|
||||
@@ -365,104 +388,157 @@ public static class DrawingSplitter
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static void InsertFeatureEdges(List<Entity> entities,
|
||||
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
|
||||
Box region, List<SplitLine> boundarySplitLines,
|
||||
ISplitFeature feature, SplitParameters parameters)
|
||||
ISplitFeature feature, SplitParameters parameters,
|
||||
List<Polygon> cutoutPolygons)
|
||||
{
|
||||
// Group split points by their split line
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
foreach (var sl in boundarySplitLines)
|
||||
{
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
}
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
var extentStart = isVertical ? region.Bottom : region.Left;
|
||||
var extentEnd = isVertical ? region.Top : region.Right;
|
||||
|
||||
foreach (var kvp in groups)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
|
||||
// Pair each exit with the next entry
|
||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
|
||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
|
||||
|
||||
if (exits.Count == 0 || entries.Count == 0)
|
||||
if (extentEnd - extentStart < Math.Tolerance.Epsilon)
|
||||
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();
|
||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||
|
||||
// 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++)
|
||||
// Trim any line segments that cross a cutout — cut lines must never
|
||||
// travel through a hole.
|
||||
featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons);
|
||||
|
||||
entities.AddRange(featureEdge);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
if (cutoutPolygons.Count == 0 || featureEdge.Count == 0)
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
// Collect parameter values t in [0,1] where the line crosses any cutout edge.
|
||||
var ts = new List<double> { 0.0, 1.0 };
|
||||
foreach (var poly in cutoutPolygons)
|
||||
{
|
||||
var polyLines = poly.ToLines();
|
||||
foreach (var edge in polyLines)
|
||||
{
|
||||
var exitPt = exits[i];
|
||||
var entryPt = entries[i];
|
||||
|
||||
var extentStart = isVertical
|
||||
? System.Math.Min(exitPt.Y, entryPt.Y)
|
||||
: System.Math.Min(exitPt.X, entryPt.X);
|
||||
var extentEnd = isVertical
|
||||
? System.Math.Max(exitPt.Y, entryPt.Y)
|
||||
: System.Math.Max(exitPt.X, entryPt.X);
|
||||
|
||||
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
|
||||
|
||||
var isNegativeSide = RegionSideOf(region, sl) < 0;
|
||||
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
|
||||
|
||||
if (featureEdge.Count > 0)
|
||||
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
|
||||
|
||||
entities.AddRange(featureEdge);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
|
||||
{
|
||||
var featureStart = GetStartPoint(featureEdge[0]);
|
||||
var featureEnd = GetEndPoint(featureEdge[^1]);
|
||||
var isVertical = axis == CutOffAxis.Vertical;
|
||||
ts.Sort();
|
||||
|
||||
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)
|
||||
var segments = new List<Line>();
|
||||
for (var i = 0; i < ts.Count - 1; i++)
|
||||
{
|
||||
featureEdge = new List<Entity>(featureEdge);
|
||||
featureEdge.Reverse();
|
||||
foreach (var e in featureEdge)
|
||||
e.Reverse();
|
||||
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 featureEdge;
|
||||
return segments;
|
||||
}
|
||||
|
||||
private static void EnsurePerimeterWinding(List<Entity> entities)
|
||||
/// <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)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CW)
|
||||
shape.Reverse();
|
||||
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;
|
||||
|
||||
entities.Clear();
|
||||
entities.AddRange(shape.Entities);
|
||||
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)
|
||||
{
|
||||
if (cutout.Entities.Count == 0) return false;
|
||||
var pt = GetStartPoint(cutout.Entities[0]);
|
||||
return region.Contains(pt);
|
||||
var bb = cutout.BoundingBox;
|
||||
// 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)
|
||||
@@ -479,57 +555,135 @@ public static class DrawingSplitter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clip a cutout shape to a region by walking entities, splitting at split line
|
||||
/// intersections, keeping portions inside the region, and closing gaps with
|
||||
/// straight lines. No polygon clipping library needed.
|
||||
/// Clip a cutout shape to a region by walking entities and splitting at split-line
|
||||
/// crossings. Only returns the cutout-edge fragments that lie inside the region —
|
||||
/// 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>
|
||||
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
|
||||
{
|
||||
var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
|
||||
var entities = new List<Entity>();
|
||||
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
|
||||
|
||||
foreach (var entity in cutout.Entities)
|
||||
ProcessEntity(entity, region, entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Groups a region's entities into closed components and nests holes inside
|
||||
/// outer loops by point-in-polygon containment. Returns one entity list per
|
||||
/// output <see cref="Drawing"/> — outer loop first, then its contained holes.
|
||||
/// Each outer loop is normalized to CW winding and each hole to CCW.
|
||||
/// </summary>
|
||||
private static List<List<Entity>> AssemblePieces(List<Entity> entities)
|
||||
{
|
||||
var pieces = new List<List<Entity>>();
|
||||
if (entities.Count == 0) return pieces;
|
||||
|
||||
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++)
|
||||
{
|
||||
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (entities.Count == 0)
|
||||
return new List<Entity>();
|
||||
|
||||
// Close gaps with straight lines (connect exit→entry pairs)
|
||||
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
|
||||
foreach (var sp in splitPoints)
|
||||
// For each outer, attach the holes that fall inside it.
|
||||
for (var i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
if (!groups.ContainsKey(sp.Line))
|
||||
groups[sp.Line] = new List<(Vector, bool)>();
|
||||
groups[sp.Line].Add((sp.Point, sp.IsExit));
|
||||
if (isHole[i]) continue;
|
||||
|
||||
var outer = shapes[i];
|
||||
var outerPoly = polygons[i];
|
||||
|
||||
// Enforce perimeter winding = CW.
|
||||
if (outerPoly != null && outerPoly.Vertices.Count >= 3
|
||||
&& 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);
|
||||
}
|
||||
|
||||
pieces.Add(piece);
|
||||
}
|
||||
|
||||
foreach (var kvp in groups)
|
||||
{
|
||||
var sl = kvp.Key;
|
||||
var points = kvp.Value;
|
||||
var isVertical = sl.Axis == CutOffAxis.Vertical;
|
||||
return pieces;
|
||||
}
|
||||
|
||||
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
|
||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
|
||||
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
|
||||
/// <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]);
|
||||
}
|
||||
|
||||
var pairCount = System.Math.Min(exits.Count, entries.Count);
|
||||
for (var i = 0; i < pairCount; i++)
|
||||
entities.Add(new Line(exits[i], entries[i]));
|
||||
}
|
||||
|
||||
// Ensure CCW winding for cutouts
|
||||
var shape = new Shape();
|
||||
shape.Entities.AddRange(entities);
|
||||
var poly = shape.ToPolygon();
|
||||
if (poly != null && poly.RotationDirection() != RotationType.CCW)
|
||||
shape.Reverse();
|
||||
|
||||
return shape.Entities;
|
||||
/// <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)
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
public class NfpSlideStrategy : IBestFitStrategy
|
||||
{
|
||||
private static readonly string LogPath = Path.Combine(
|
||||
System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop),
|
||||
"nfp-slide-debug.log");
|
||||
|
||||
private static readonly object LogLock = new object();
|
||||
|
||||
private readonly double _part2Rotation;
|
||||
private readonly Polygon _stationaryPerimeter;
|
||||
private readonly Polygon _stationaryHull;
|
||||
@@ -46,12 +38,6 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var hull = ConvexHull.Compute(result.Polygon.Vertices);
|
||||
|
||||
Log($"=== Create: drawing={drawing.Name}, rotation={Angle.ToDegrees(part2Rotation):F1}deg ===");
|
||||
Log($" Perimeter: {result.Polygon.Vertices.Count} verts, bounds={FormatBounds(result.Polygon)}");
|
||||
Log($" Hull: {hull.Vertices.Count} verts, bounds={FormatBounds(hull)}");
|
||||
Log($" Correction: ({result.Correction.X:F4}, {result.Correction.Y:F4})");
|
||||
Log($" ProgramBBox: {drawing.Program.BoundingBox()}");
|
||||
|
||||
return new NfpSlideStrategy(part2Rotation, type, description,
|
||||
result.Polygon, hull, result.Correction);
|
||||
}
|
||||
@@ -63,40 +49,17 @@ namespace OpenNest.Engine.BestFit
|
||||
if (stepSize <= 0)
|
||||
return candidates;
|
||||
|
||||
Log($"--- GenerateCandidates: drawing={drawing.Name}, part2Rot={Angle.ToDegrees(_part2Rotation):F1}deg, spacing={spacing}, stepSize={stepSize} ---");
|
||||
|
||||
// Orbiting polygon: same shape rotated to Part2's angle.
|
||||
var orbitingPerimeter = PolygonHelper.RotatePolygon(_stationaryPerimeter, _part2Rotation, reNormalize: true);
|
||||
var orbitingPoly = ConvexHull.Compute(orbitingPerimeter.Vertices);
|
||||
|
||||
Log($" Stationary hull: {_stationaryHull.Vertices.Count} verts, bounds={FormatBounds(_stationaryHull)}");
|
||||
Log($" Orbiting perimeter (rotated): {orbitingPerimeter.Vertices.Count} verts, bounds={FormatBounds(orbitingPerimeter)}");
|
||||
Log($" Orbiting hull: {orbitingPoly.Vertices.Count} verts, bounds={FormatBounds(orbitingPoly)}");
|
||||
|
||||
var nfp = NoFitPolygon.ComputeConvex(_stationaryHull, orbitingPoly);
|
||||
|
||||
if (nfp == null || nfp.Vertices.Count < 3)
|
||||
{
|
||||
Log($" NFP failed or degenerate (verts={nfp?.Vertices.Count ?? 0})");
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var verts = nfp.Vertices;
|
||||
var vertCount = nfp.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
|
||||
Log($" NFP: {verts.Count} verts (closed={nfp.IsClosed()}, walking {vertCount}), bounds={FormatBounds(nfp)}");
|
||||
Log($" Correction: ({_correction.X:F4}, {_correction.Y:F4})");
|
||||
|
||||
// Log NFP vertices
|
||||
for (var v = 0; v < vertCount; v++)
|
||||
Log($" NFP vert[{v}]: ({verts[v].X:F4}, {verts[v].Y:F4}) -> corrected: ({verts[v].X - _correction.X:F4}, {verts[v].Y - _correction.Y:F4})");
|
||||
|
||||
// Compare with what RotationSlideStrategy would produce
|
||||
var part1 = Part.CreateAtOrigin(drawing);
|
||||
var part2 = Part.CreateAtOrigin(drawing, _part2Rotation);
|
||||
Log($" Part1 (rot=0): loc=({part1.Location.X:F4}, {part1.Location.Y:F4}), bbox={part1.BoundingBox}");
|
||||
Log($" Part2 (rot={Angle.ToDegrees(_part2Rotation):F1}): loc=({part2.Location.X:F4}, {part2.Location.Y:F4}), bbox={part2.BoundingBox}");
|
||||
|
||||
var testNumber = 0;
|
||||
|
||||
for (var i = 0; i < vertCount; i++)
|
||||
@@ -125,20 +88,6 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
// Log overlap check for vertex candidates (first few)
|
||||
var checkCount = System.Math.Min(vertCount, 8);
|
||||
for (var c = 0; c < checkCount; c++)
|
||||
{
|
||||
var cand = candidates[c];
|
||||
var p2 = Part.CreateAtOrigin(drawing, cand.Part2Rotation);
|
||||
p2.Location = cand.Part2Offset;
|
||||
var overlaps = part1.Intersects(p2, out _);
|
||||
Log($" Candidate[{c}]: offset=({cand.Part2Offset.X:F4}, {cand.Part2Offset.Y:F4}), overlaps={overlaps}");
|
||||
}
|
||||
|
||||
Log($" Total candidates: {candidates.Count}");
|
||||
Log("");
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -160,20 +109,5 @@ namespace OpenNest.Engine.BestFit
|
||||
Spacing = spacing
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatBounds(Polygon polygon)
|
||||
{
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
return $"[({bb.Left:F4}, {bb.Bottom:F4})-({bb.Right:F4}, {bb.Top:F4}), {bb.Width:F2}x{bb.Length:F2}]";
|
||||
}
|
||||
|
||||
private static void Log(string message)
|
||||
{
|
||||
lock (LogLock)
|
||||
{
|
||||
File.AppendAllText(LogPath, message + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,18 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
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>();
|
||||
|
||||
Parallel.ForEach(candidates, c =>
|
||||
{
|
||||
resultBag.Add(Evaluate(c));
|
||||
resultBag.Add(Evaluate(c, perimeterDrawing));
|
||||
});
|
||||
|
||||
return resultBag.ToList();
|
||||
@@ -27,18 +34,24 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
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.UpdateBounds();
|
||||
|
||||
// Check overlap via shape intersection
|
||||
var overlaps = CheckOverlap(part1, part2);
|
||||
// Overlap check — perimeter vs perimeter
|
||||
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);
|
||||
allPoints.AddRange(GetPartVertices(part2));
|
||||
|
||||
@@ -66,7 +79,7 @@ namespace OpenNest.Engine.BestFit
|
||||
hullAngles = new List<double> { 0 };
|
||||
}
|
||||
|
||||
var trueArea = drawing.Area * 2;
|
||||
var trueArea = candidate.Drawing.Area * 2;
|
||||
|
||||
// Normalize to landscape (width >= height) for consistent display.
|
||||
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 shapes2 = GetPartShapes(part2);
|
||||
|
||||
for (var i = 0; i < shapes1.Count; i++)
|
||||
{
|
||||
for (var j = 0; j < shapes2.Count; j++)
|
||||
{
|
||||
List<Vector> pts;
|
||||
|
||||
if (shapes1[i].Intersects(shapes2[j], out pts))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
var entities = ConvertProgram.ToGeometry(source.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||
var profile = new ShapeProfile(entities);
|
||||
var program = ConvertGeometry.ToProgram(profile.Perimeter);
|
||||
return new Drawing(source.Name, program);
|
||||
}
|
||||
|
||||
private List<Shape> GetPartShapes(Part part)
|
||||
private static Shape GetPerimeterShape(Part part)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(part.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
shapes.ForEach(s => s.Offset(part.Location));
|
||||
return shapes;
|
||||
if (shapes.Count == 0) return null;
|
||||
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)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid);
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
|
||||
var shapes = ShapeBuilder.GetShapes(entities);
|
||||
var points = new List<Vector>();
|
||||
|
||||
@@ -130,9 +134,7 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
var polygon = shape.ToPolygonWithTolerance(ChordTolerance);
|
||||
polygon.Offset(part.Location);
|
||||
|
||||
foreach (var vertex in polygon.Vertices)
|
||||
points.Add(vertex);
|
||||
points.AddRange(polygon.Vertices);
|
||||
}
|
||||
|
||||
return points;
|
||||
|
||||
@@ -61,92 +61,91 @@ namespace OpenNest.Engine.Fill
|
||||
: NestDirection.Horizontal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the slide distance for the push algorithm, returning the
|
||||
/// geometry-aware copy distance along the given axis.
|
||||
/// </summary>
|
||||
private double ComputeCopyDistance(double bboxDim, double slideDistance)
|
||||
{
|
||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||
return bboxDim + PartSpacing;
|
||||
|
||||
// The geometry-aware slide can produce a copy distance smaller than
|
||||
// the part itself when inflated corner/arc vertices interact spuriously.
|
||||
// Clamp to bboxDim + PartSpacing to prevent bounding box overlap.
|
||||
return System.Math.Max(bboxDim - slideDistance, bboxDim + PartSpacing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the geometry-aware copy distance between two identical parts along an axis.
|
||||
/// Both parts are inflated by half-spacing for symmetric spacing.
|
||||
/// Uses native Line/Arc entities (inflated by half-spacing) so curves are handled
|
||||
/// exactly without polygon sampling error.
|
||||
/// </summary>
|
||||
private double FindCopyDistance(Part partA, NestDirection direction, PartBoundary boundary)
|
||||
private double FindCopyDistance(Part partA, NestDirection direction)
|
||||
{
|
||||
var bboxDim = GetDimension(partA.BoundingBox, direction);
|
||||
var pushDir = GetPushDirection(direction);
|
||||
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
||||
var offset = MakeOffset(direction, startOffset);
|
||||
|
||||
var locationBOffset = MakeOffset(direction, bboxDim);
|
||||
var stationaryEntities = PartGeometry.GetOffsetPerimeterEntities(partA, HalfSpacing);
|
||||
var movingEntities = PartGeometry.GetOffsetPerimeterEntities(
|
||||
partA.CloneAtOffset(offset), HalfSpacing);
|
||||
|
||||
// Use the most efficient array-based overload to avoid all allocations.
|
||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
|
||||
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
|
||||
pushDir);
|
||||
movingEntities, stationaryEntities, pushDir);
|
||||
|
||||
return ComputeCopyDistance(bboxDim, slideDistance);
|
||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||
return bboxDim + PartSpacing;
|
||||
|
||||
return startOffset - slideDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the geometry-aware copy distance between two identical patterns along an axis.
|
||||
/// Checks every pair of parts across adjacent patterns so that multi-part
|
||||
/// patterns (e.g. interlocking pairs) maintain spacing between ALL parts.
|
||||
/// Both sides are inflated by half-spacing for symmetric spacing.
|
||||
/// Checks every pair of parts across adjacent pattern copies so multi-part patterns
|
||||
/// (e.g. interlocking pairs) maintain spacing between ALL parts. Uses native entity
|
||||
/// geometry inflated by half-spacing — same primitive the Compactor uses — so arcs
|
||||
/// are exact and no bbox clamp is needed.
|
||||
/// </summary>
|
||||
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary[] boundaries)
|
||||
private double FindPatternCopyDistance(Pattern patternA, NestDirection direction)
|
||||
{
|
||||
if (patternA.Parts.Count <= 1)
|
||||
return FindSinglePartPatternCopyDistance(patternA, direction, boundaries[0]);
|
||||
if (patternA.Parts.Count == 1)
|
||||
return FindCopyDistance(patternA.Parts[0], direction);
|
||||
|
||||
var bboxDim = GetDimension(patternA.BoundingBox, direction);
|
||||
var pushDir = GetPushDirection(direction);
|
||||
var opposite = SpatialQuery.OppositeDirection(pushDir);
|
||||
var dirVec = SpatialQuery.DirectionToOffset(pushDir, 1.0);
|
||||
|
||||
// bboxDim already spans max(upper) - min(lower) across all parts,
|
||||
// so the start offset just needs to push beyond that plus spacing.
|
||||
var startOffset = bboxDim + PartSpacing + Tolerance.Epsilon;
|
||||
var offset = MakeOffset(direction, startOffset);
|
||||
|
||||
var maxCopyDistance = FindMaxPairDistance(
|
||||
patternA.Parts, boundaries, offset, pushDir, opposite, startOffset);
|
||||
var parts = patternA.Parts;
|
||||
var stationaryBoxes = new Box[parts.Count];
|
||||
var movingBoxes = new Box[parts.Count];
|
||||
var stationaryEntities = new List<Entity>[parts.Count];
|
||||
var movingEntities = new List<Entity>[parts.Count];
|
||||
|
||||
// The copy distance must be at least bboxDim + PartSpacing to prevent
|
||||
// bounding box overlap. Cross-pair slides can underestimate when the
|
||||
// circumscribed polygon boundary overshoots the true arc, creating
|
||||
// spurious contacts between diagonal parts in adjacent copies.
|
||||
return System.Math.Max(maxCopyDistance, bboxDim + PartSpacing);
|
||||
}
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
stationaryBoxes[i] = parts[i].BoundingBox;
|
||||
movingBoxes[i] = stationaryBoxes[i].Translate(offset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests every pair of parts across adjacent pattern copies and returns the
|
||||
/// maximum copy distance found. Returns 0 if no valid slide was found.
|
||||
/// </summary>
|
||||
private static double FindMaxPairDistance(
|
||||
List<Part> parts, PartBoundary[] boundaries, Vector offset,
|
||||
PushDirection pushDir, PushDirection opposite, double startOffset)
|
||||
{
|
||||
var maxCopyDistance = 0.0;
|
||||
|
||||
for (var j = 0; j < parts.Count; j++)
|
||||
{
|
||||
var movingEdges = boundaries[j].GetEdges(pushDir);
|
||||
var locationB = parts[j].Location + offset;
|
||||
var movingBox = movingBoxes[j];
|
||||
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var stationaryBox = stationaryBoxes[i];
|
||||
|
||||
// Skip if stationary is already ahead of moving in the push direction
|
||||
// (sliding forward would take them further apart).
|
||||
if (SpatialQuery.DirectionalGap(movingBox, stationaryBox, opposite) > 0)
|
||||
continue;
|
||||
|
||||
// Skip if bboxes can't overlap along the axis perpendicular to the push.
|
||||
if (!SpatialQuery.PerpendicularOverlap(movingBox, stationaryBox, dirVec))
|
||||
continue;
|
||||
|
||||
stationaryEntities[i] ??= PartGeometry.GetOffsetPerimeterEntities(
|
||||
parts[i], HalfSpacing);
|
||||
movingEntities[j] ??= PartGeometry.GetOffsetPerimeterEntities(
|
||||
parts[j].CloneAtOffset(offset), HalfSpacing);
|
||||
|
||||
var slideDistance = SpatialQuery.DirectionalDistance(
|
||||
movingEdges, locationB,
|
||||
boundaries[i].GetEdges(opposite), parts[i].Location,
|
||||
pushDir);
|
||||
movingEntities[j], stationaryEntities[i], pushDir);
|
||||
|
||||
if (slideDistance >= double.MaxValue || slideDistance < 0)
|
||||
continue;
|
||||
@@ -161,86 +160,15 @@ namespace OpenNest.Engine.Fill
|
||||
return maxCopyDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast path for single-part patterns — no cross-part conflicts possible.
|
||||
/// </summary>
|
||||
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
|
||||
{
|
||||
var template = patternA.Parts[0];
|
||||
return FindCopyDistance(template, direction, boundary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets offset boundary lines for all parts in a pattern using a shared boundary.
|
||||
/// </summary>
|
||||
private static List<Line> GetPatternLines(Pattern pattern, PartBoundary boundary, PushDirection direction)
|
||||
{
|
||||
var lines = new List<Line>();
|
||||
|
||||
foreach (var part in pattern.Parts)
|
||||
lines.AddRange(boundary.GetLines(part.Location, direction));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets boundary lines for all parts in a pattern, with an additional
|
||||
/// location offset applied. Avoids cloning the pattern.
|
||||
/// </summary>
|
||||
private static List<Line> GetOffsetPatternLines(Pattern pattern, Vector offset, PartBoundary boundary, PushDirection direction)
|
||||
{
|
||||
var lines = new List<Line>();
|
||||
|
||||
foreach (var part in pattern.Parts)
|
||||
lines.AddRange(boundary.GetLines(part.Location + offset, direction));
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates boundaries for all parts in a pattern. Parts that share the same
|
||||
/// program geometry (same drawing and rotation) reuse the same boundary instance.
|
||||
/// </summary>
|
||||
private PartBoundary[] CreateBoundaries(Pattern pattern)
|
||||
{
|
||||
var boundaries = new PartBoundary[pattern.Parts.Count];
|
||||
var cache = new List<(Drawing drawing, double rotation, PartBoundary boundary)>();
|
||||
|
||||
for (var i = 0; i < pattern.Parts.Count; i++)
|
||||
{
|
||||
var part = pattern.Parts[i];
|
||||
PartBoundary found = null;
|
||||
|
||||
foreach (var entry in cache)
|
||||
{
|
||||
if (entry.drawing == part.BaseDrawing && entry.rotation.IsEqualTo(part.Rotation))
|
||||
{
|
||||
found = entry.boundary;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found == null)
|
||||
{
|
||||
found = new PartBoundary(part, HalfSpacing);
|
||||
cache.Add((part.BaseDrawing, part.Rotation, found));
|
||||
}
|
||||
|
||||
boundaries[i] = found;
|
||||
}
|
||||
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tiles a pattern along the given axis, returning the cloned parts
|
||||
/// (does not include the original pattern's parts). For multi-part
|
||||
/// patterns, also adds individual parts from the next incomplete copy
|
||||
/// that still fit within the work area.
|
||||
/// </summary>
|
||||
private List<Part> TilePattern(Pattern basePattern, NestDirection direction, PartBoundary[] boundaries)
|
||||
private List<Part> TilePattern(Pattern basePattern, NestDirection direction)
|
||||
{
|
||||
var copyDistance = FindPatternCopyDistance(basePattern, direction, boundaries);
|
||||
var copyDistance = FindPatternCopyDistance(basePattern, direction);
|
||||
|
||||
if (copyDistance <= 0)
|
||||
return new List<Part>();
|
||||
@@ -394,11 +322,10 @@ namespace OpenNest.Engine.Fill
|
||||
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
||||
{
|
||||
var perpAxis = PerpendicularAxis(direction);
|
||||
var boundaries = CreateBoundaries(pattern);
|
||||
|
||||
// Step 1: Tile along primary axis
|
||||
var row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
row.AddRange(TilePattern(pattern, direction));
|
||||
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a1, out var b1))
|
||||
{
|
||||
@@ -410,7 +337,7 @@ namespace OpenNest.Engine.Fill
|
||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||
if (row.Count <= pattern.Parts.Count)
|
||||
{
|
||||
row.AddRange(TilePattern(pattern, perpAxis, boundaries));
|
||||
row.AddRange(TilePattern(pattern, perpAxis));
|
||||
|
||||
if (pattern.Parts.Count > 1 && HasOverlappingParts(row, out var a2, out var b2))
|
||||
{
|
||||
@@ -427,9 +354,8 @@ namespace OpenNest.Engine.Fill
|
||||
rowPattern.Parts.AddRange(row);
|
||||
rowPattern.UpdateBounds();
|
||||
|
||||
var rowBoundaries = CreateBoundaries(rowPattern);
|
||||
var gridResult = new List<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis));
|
||||
|
||||
if (HasOverlappingParts(gridResult, out var a3, out var b3))
|
||||
{
|
||||
@@ -481,9 +407,8 @@ namespace OpenNest.Engine.Fill
|
||||
return seed;
|
||||
|
||||
var template = seed.Parts[0];
|
||||
var boundary = new PartBoundary(template, HalfSpacing);
|
||||
|
||||
var copyDistance = FindCopyDistance(template, direction, boundary);
|
||||
var copyDistance = FindCopyDistance(template, direction);
|
||||
|
||||
if (copyDistance <= 0)
|
||||
return seed;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Engine.Sequencing;
|
||||
using OpenNest.Geometry;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Engine
|
||||
@@ -15,14 +17,28 @@ namespace OpenNest.Engine
|
||||
return;
|
||||
|
||||
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 piercePoints = new Vector[sequenced.Count];
|
||||
var currentPoint = exitPoint;
|
||||
|
||||
for (var i = 0; i < sequenced.Count; i++)
|
||||
{
|
||||
var part = sp.Part;
|
||||
var part = sequenced[i].Part;
|
||||
|
||||
if (part.LeadInsLocked)
|
||||
{
|
||||
piercePoints[i] = GetPiercePoint(part);
|
||||
currentPoint = part.Location;
|
||||
continue;
|
||||
}
|
||||
@@ -31,10 +47,33 @@ namespace OpenNest.Engine
|
||||
part.RemoveLeadIns();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 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;
|
||||
|
||||
// Compute approach point in part-local space
|
||||
var part = sequenced[i].Part;
|
||||
var localApproach = ToPartLocal(currentPoint, part);
|
||||
|
||||
Program processedProgram;
|
||||
@@ -33,7 +56,18 @@ namespace OpenNest.Engine
|
||||
|
||||
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;
|
||||
lastCutLocal = cuttingResult.LastCutPoint;
|
||||
}
|
||||
@@ -43,11 +77,9 @@ namespace OpenNest.Engine
|
||||
lastCutLocal = GetProgramEndPoint(part.Program);
|
||||
}
|
||||
|
||||
// Pierce point: program start point in plate space
|
||||
var pierceLocal = GetProgramStartPoint(processedProgram);
|
||||
var piercePoint = ToPlateSpace(pierceLocal, part);
|
||||
|
||||
// Plan rapid from currentPoint to pierce point
|
||||
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
|
||||
|
||||
results.Add(new ProcessedPart
|
||||
@@ -57,12 +89,10 @@ namespace OpenNest.Engine
|
||||
RapidPath = rapidPath
|
||||
});
|
||||
|
||||
// Update cut areas with part perimeter
|
||||
var perimeter = GetPartPerimeter(part);
|
||||
if (perimeter != null)
|
||||
cutAreas.Add(perimeter);
|
||||
|
||||
// Update current point to last cut point in plate space
|
||||
currentPoint = ToPlateSpace(lastCutLocal, part);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace OpenNest.Engine.Strategies
|
||||
public int PlateNumber { get; init; }
|
||||
public CancellationToken Token { get; init; }
|
||||
public IProgress<NestProgress> Progress { get; init; }
|
||||
public FillPolicy Policy { get; init; }
|
||||
public FillPolicy Policy { get; init; } = new FillPolicy(new DefaultFillComparer());
|
||||
public int MaxQuantity { get; init; }
|
||||
public PartType PartType { get; set; }
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ namespace OpenNest.IO.Bending
|
||||
{
|
||||
return document.Entities
|
||||
.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 == "CENTERX2"))
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var result = new Dictionary<int, (List<Entity>, HashSet<Guid>)>();
|
||||
|
||||
@@ -308,8 +308,32 @@ namespace OpenNest.IO
|
||||
WriteDrawing(stream, kvp.Value);
|
||||
|
||||
var entry = zipArchive.CreateEntry(name);
|
||||
using var entryStream = entry.Open();
|
||||
stream.CopyTo(entryStream);
|
||||
using (var entryStream = entry.Open())
|
||||
{
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +472,9 @@ namespace OpenNest.IO
|
||||
case CodeType.SubProgramCall:
|
||||
{
|
||||
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 r = 0.0;
|
||||
var x = 0.0;
|
||||
var y = 0.0;
|
||||
|
||||
while (section == CodeSection.SubProgram)
|
||||
{
|
||||
@@ -395,13 +397,26 @@ namespace OpenNest.IO
|
||||
r = double.Parse(code.Value);
|
||||
break;
|
||||
|
||||
case 'X':
|
||||
x = double.Parse(code.Value);
|
||||
break;
|
||||
|
||||
case 'Y':
|
||||
y = double.Parse(code.Value);
|
||||
break;
|
||||
|
||||
default:
|
||||
section = CodeSection.Unknown;
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Shapes;
|
||||
using System.ComponentModel;
|
||||
@@ -96,24 +94,18 @@ namespace OpenNest.Mcp.Tools
|
||||
if (!File.Exists(path))
|
||||
return $"Error: file not found: {path}";
|
||||
|
||||
var geometry = Dxf.GetGeometry(path);
|
||||
try
|
||||
{
|
||||
var drawing = CadImporter.ImportDrawing(path, new CadImportOptions { Name = name });
|
||||
_session.Drawings.Add(drawing);
|
||||
|
||||
if (geometry.Count == 0)
|
||||
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);
|
||||
|
||||
var bbox = pgm.BoundingBox();
|
||||
return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Length:F2}";
|
||||
var bbox = drawing.Program.BoundingBox();
|
||||
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")]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
@@ -15,11 +16,16 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
{
|
||||
private readonly CincinnatiPostConfig _config;
|
||||
private readonly CincinnatiFeatureWriter _featureWriter;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
private readonly Dictionary<int, int> _holeSubprograms;
|
||||
|
||||
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config)
|
||||
public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config,
|
||||
Dictionary<int, int> holeSubprograms = null)
|
||||
{
|
||||
_config = config;
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_holeSubprograms = holeSubprograms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +49,15 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
for (var i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var (codes, isEtch) = ordered[i];
|
||||
var isLastFeature = i == ordered.Count - 1;
|
||||
|
||||
// SubProgramCall features are emitted as M98 hole calls
|
||||
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
|
||||
{
|
||||
WriteHoleSubprogramCall(w, holeCall, i, isLastFeature);
|
||||
continue;
|
||||
}
|
||||
|
||||
var featureNumber = i == 0
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
@@ -54,7 +69,7 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
FeatureNumber = featureNumber,
|
||||
PartName = drawingName,
|
||||
IsFirstFeatureOfPart = false,
|
||||
IsLastFeatureOnSheet = i == ordered.Count - 1,
|
||||
IsLastFeatureOnSheet = isLastFeature,
|
||||
IsSafetyHeadraise = false,
|
||||
IsExteriorFeature = false,
|
||||
IsEtch = isEtch,
|
||||
@@ -69,6 +84,30 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
w.WriteLine($"M99 (END OF {drawingName})");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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}");
|
||||
|
||||
w.WriteLine("G52 X0 Y0");
|
||||
|
||||
if (!isLastFeature)
|
||||
w.WriteLine("M47");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If the program has no leading rapid, inserts a synthetic rapid at the
|
||||
/// last motion endpoint (the contour return point). This ensures the feature
|
||||
@@ -136,4 +175,61 @@ public sealed class CincinnatiPartSubprogramWriter
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
@@ -277,6 +279,24 @@ namespace OpenNest.Posts.Cincinnati
|
||||
[DisplayName("Etch Libraries")]
|
||||
[Description("Gas-to-library mapping for etch operations.")]
|
||||
public List<EtchLibraryEntry> EtchLibraries { get; set; } = new();
|
||||
|
||||
[Category("B. Libraries")]
|
||||
[DisplayName("Selected Library")]
|
||||
[Description("Overrides Material/Thickness/Gas auto-resolution. Pick an existing entry from Material Libraries, or leave blank to auto-resolve.")]
|
||||
[TypeConverter(typeof(MaterialLibraryNameConverter))]
|
||||
public string SelectedLibrary { get; set; } = "";
|
||||
|
||||
public string FindBestLibrary(string materialName, double thickness)
|
||||
{
|
||||
if (MaterialLibraries == null || string.IsNullOrEmpty(materialName))
|
||||
return "";
|
||||
|
||||
return MaterialLibraries
|
||||
.Where(e => string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => System.Math.Abs(e.Thickness - thickness))
|
||||
.Select(e => e.Library)
|
||||
.FirstOrDefault() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
public class MaterialLibraryEntry
|
||||
|
||||
@@ -9,7 +9,7 @@ using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor
|
||||
public sealed class CincinnatiPostProcessor : IConfigurablePostProcessor, IPostProcessorNestAware, IMaterialProvidingPostProcessor
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -25,6 +25,23 @@ namespace OpenNest.Posts.Cincinnati
|
||||
|
||||
object IConfigurablePostProcessor.Config => Config;
|
||||
|
||||
public IEnumerable<string> GetMaterialNames()
|
||||
{
|
||||
if (Config?.MaterialLibraries == null)
|
||||
return System.Array.Empty<string>();
|
||||
|
||||
return Config.MaterialLibraries
|
||||
.Select(e => e.Material)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s));
|
||||
}
|
||||
|
||||
public void PrepareForNest(Nest nest)
|
||||
{
|
||||
var materialName = nest?.Material?.Name ?? "";
|
||||
var thickness = nest?.Thickness ?? 0.0;
|
||||
Config.SelectedLibrary = Config.FindBestLibrary(materialName, thickness);
|
||||
}
|
||||
|
||||
public CincinnatiPostProcessor()
|
||||
{
|
||||
var configPath = GetConfigPath();
|
||||
@@ -89,9 +106,15 @@ namespace OpenNest.Posts.Cincinnati
|
||||
if (Config.UsePartSubprograms)
|
||||
(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
|
||||
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
|
||||
var material = nest.Material;
|
||||
@@ -122,7 +145,8 @@ namespace OpenNest.Posts.Cincinnati
|
||||
// Part sub-programs (if enabled)
|
||||
if (subprogramEntries != null)
|
||||
{
|
||||
var partSubWriter = new CincinnatiPartSubprogramWriter(Config);
|
||||
var partSubWriter = new CincinnatiPartSubprogramWriter(Config,
|
||||
holeMapping.Count > 0 ? holeMapping : null);
|
||||
var sheetDiagonal = firstPlate != null
|
||||
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
|
||||
+ firstPlate.Size.Length * firstPlate.Size.Length)
|
||||
@@ -135,6 +159,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,16 @@ public sealed class CincinnatiSheetWriter
|
||||
private readonly ProgramVariableManager _vars;
|
||||
private readonly CoordinateFormatter _fmt;
|
||||
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;
|
||||
_vars = vars;
|
||||
_fmt = new CoordinateFormatter(config.PostedAccuracy);
|
||||
_featureWriter = new CincinnatiFeatureWriter(config);
|
||||
_holeSubprograms = holeSubprograms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,11 +135,21 @@ public sealed class CincinnatiSheetWriter
|
||||
for (var f = 0; f < features.Count; 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
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + featureIndex + 1;
|
||||
|
||||
var isLastFeature = isLastPart && f == features.Count - 1;
|
||||
var cutDistance = FeatureUtils.ComputeCutDistance(codes);
|
||||
|
||||
var ctx = new FeatureContext
|
||||
@@ -204,6 +217,36 @@ public sealed class CincinnatiSheetWriter
|
||||
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,
|
||||
string cutLibrary, string etchLibrary, double sheetDiagonal,
|
||||
double plateWidth, double plateLength,
|
||||
@@ -228,6 +271,14 @@ public sealed class CincinnatiSheetWriter
|
||||
var isSafetyHeadraise = partName != lastPartName && lastPartName != "";
|
||||
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
|
||||
? _config.FeatureLineNumberStart
|
||||
: 1000 + i + 1;
|
||||
|
||||
@@ -21,7 +21,16 @@ public static class FeatureUtils
|
||||
|
||||
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)
|
||||
features.Add(current);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace OpenNest.Posts.Cincinnati
|
||||
{
|
||||
public sealed class MaterialLibraryNameConverter : StringConverter
|
||||
{
|
||||
public override bool GetStandardValuesSupported(ITypeDescriptorContext context) => true;
|
||||
|
||||
public override bool GetStandardValuesExclusive(ITypeDescriptorContext context) => false;
|
||||
|
||||
public override StandardValuesCollection GetStandardValues(ITypeDescriptorContext context)
|
||||
{
|
||||
var config = context?.Instance as CincinnatiPostConfig;
|
||||
var names = new List<string> { "" };
|
||||
|
||||
if (config?.MaterialLibraries != null)
|
||||
{
|
||||
names.AddRange(config.MaterialLibraries
|
||||
.Select(e => e.Library)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return new StandardValuesCollection(names);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,20 @@ public sealed class MaterialLibraryResolver
|
||||
|
||||
private readonly List<MaterialLibraryEntry> _materialLibraries;
|
||||
private readonly List<EtchLibraryEntry> _etchLibraries;
|
||||
private readonly string _selectedLibrary;
|
||||
|
||||
public MaterialLibraryResolver(CincinnatiPostConfig config)
|
||||
{
|
||||
_materialLibraries = config.MaterialLibraries ?? new List<MaterialLibraryEntry>();
|
||||
_etchLibraries = config.EtchLibraries ?? new List<EtchLibraryEntry>();
|
||||
_selectedLibrary = config.SelectedLibrary ?? "";
|
||||
}
|
||||
|
||||
public string ResolveCutLibrary(string materialName, double thickness, string gas)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_selectedLibrary))
|
||||
return EnsureLibExtension(_selectedLibrary);
|
||||
|
||||
var entry = _materialLibraries.FirstOrDefault(e =>
|
||||
string.Equals(e.Material, materialName, StringComparison.OrdinalIgnoreCase) &&
|
||||
System.Math.Abs(e.Thickness - thickness) <= ThicknessTolerance &&
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="OpenNest.Posts.Cincinnati.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<Target Name="CopyToPostsDir" AfterTargets="Build">
|
||||
<PropertyGroup>
|
||||
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
|
||||
<ConfigJson>$(MSBuildProjectDirectory)\OpenNest.Posts.Cincinnati.json</ConfigJson>
|
||||
<DeployedConfigJson>$(PostsDir)OpenNest.Posts.Cincinnati.json</DeployedConfigJson>
|
||||
</PropertyGroup>
|
||||
<MakeDir Directories="$(PostsDir)" />
|
||||
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
|
||||
<Copy SourceFiles="$(ConfigJson)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" Condition="!Exists('$(DeployedConfigJson)')" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"ConfigurationName": "CL940",
|
||||
"PostedUnits": "Inches",
|
||||
"PostedAccuracy": 4,
|
||||
"UseLineNumbers": true,
|
||||
"FeatureLineNumberStart": 1,
|
||||
"UseSheetSubprograms": true,
|
||||
"SheetSubprogramStart": 101,
|
||||
"UsePartSubprograms": false,
|
||||
"PartSubprogramStart": 200,
|
||||
"VariableDeclarationSubprogram": 100,
|
||||
"CoordModeBetweenParts": "G92",
|
||||
"ProcessParameterMode": "LibraryFile",
|
||||
"DefaultAssistGas": "O2",
|
||||
"DefaultEtchGas": "N2",
|
||||
"UseExactStopMode": false,
|
||||
"UseSpeedGas": false,
|
||||
"UseAntiDive": true,
|
||||
"UseSmartRapids": false,
|
||||
"KerfCompensation": "ControllerSide",
|
||||
"DefaultKerfSide": "Left",
|
||||
"InteriorM47": "Always",
|
||||
"ExteriorM47": "Always",
|
||||
"M47OverrideDistanceThreshold": null,
|
||||
"SafetyHeadraiseDistance": 2000,
|
||||
"PalletExchange": "EndOfSheet",
|
||||
"LeadInFeedratePercent": 0.5,
|
||||
"LeadInArcLine2FeedratePercent": 0.5,
|
||||
"LeadOutFeedratePercent": 0.5,
|
||||
"CircleFeedrateMultiplier": 0.8,
|
||||
"ArcFeedrate": "None",
|
||||
"ArcFeedrateRanges": [
|
||||
{ "MaxRadius": 0.125, "FeedratePercent": 0.25, "VariableNumber": 123 },
|
||||
{ "MaxRadius": 0.75, "FeedratePercent": 0.5, "VariableNumber": 124 },
|
||||
{ "MaxRadius": 4.5, "FeedratePercent": 0.8, "VariableNumber": 125 }
|
||||
],
|
||||
"UserVariableStart": 200,
|
||||
"SheetWidthVariable": 110,
|
||||
"SheetLengthVariable": 111,
|
||||
"MaterialLibraries": [
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "AIR", "Library": "AL032AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "N2", "Library": "AL032N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.032, "Gas": "O2", "Library": "AL032O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "AIR", "Library": "AL050AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "N2", "Library": "AL050N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.050, "Gas": "O2", "Library": "AL050O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "AIR", "Library": "AL063AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "N2", "Library": "AL063N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.063, "Gas": "O2", "Library": "AL063O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "AIR", "Library": "AL080AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "N2", "Library": "AL080N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.080, "Gas": "O2", "Library": "AL080O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "AIR", "Library": "AL090AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "N2", "Library": "AL090N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.090, "Gas": "O2", "Library": "AL090O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "AIR", "Library": "AL100AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "N2", "Library": "AL100N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.100, "Gas": "O2", "Library": "AL100O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "AIR", "Library": "AL125AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "N2", "Library": "AL125N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.125, "Gas": "O2", "Library": "AL125O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "AIR", "Library": "AL190AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "N2", "Library": "AL190N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.190, "Gas": "O2", "Library": "AL190O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "AIR", "Library": "AL250AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "N2", "Library": "AL250N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.250, "Gas": "O2", "Library": "AL250O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "AIR", "Library": "AL375AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "N2", "Library": "AL375N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.375, "Gas": "O2", "Library": "AL375O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "AIR", "Library": "AL500AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "N2", "Library": "AL500N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.500, "Gas": "O2", "Library": "AL500O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.625, "Gas": "N2", "Library": "AL625N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "AIR", "Library": "AL750AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "N2", "Library": "AL750N2" },
|
||||
{ "Material": "Aluminum", "Thickness": 0.750, "Gas": "O2", "Library": "AL750O2" },
|
||||
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "AIR", "Library": "AL1000AIR" },
|
||||
{ "Material": "Aluminum", "Thickness": 1.000, "Gas": "N2", "Library": "AL1000N2" },
|
||||
|
||||
{ "Material": "Galvanized Steel", "Thickness": 0.135, "Gas": "N2", "Library": "GALV135N2" },
|
||||
{ "Material": "Galvanized Steel", "Thickness": 0.188, "Gas": "N2", "Library": "GALV188N2" },
|
||||
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "MS036AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.036, "Gas": "N2", "Library": "MS036N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "MS048AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.048, "Gas": "N2", "Library": "MS048N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "MS060AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.060, "Gas": "N2", "Library": "MS060N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "MS075AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.075, "Gas": "N2", "Library": "MS075N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.090, "Gas": "N2", "Library": "MS090N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "MS105AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.105, "Gas": "N2", "Library": "MS105N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "MS120AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.120, "Gas": "N2", "Library": "MS120N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "MS135AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2FE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.135, "Gas": "N2", "Library": "MS135N2Panel" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "MS188AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "N2", "Library": "MS188N2FLOORPLATE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.188, "Gas": "O2", "Library": "MS188O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "MS250AIR" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "N2", "Library": "MS250N2FLOORPLATE" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.250, "Gas": "O2", "Library": "MS250O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.313, "Gas": "O2", "Library": "MS313O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.375, "Gas": "O2", "Library": "MS375O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "N2", "Library": "MS500N2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.500, "Gas": "O2", "Library": "MS500O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.625, "Gas": "O2", "Library": "MS625O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 0.750, "Gas": "O2", "Library": "MS750O2" },
|
||||
{ "Material": "Carbon Steel", "Thickness": 1.000, "Gas": "O2", "Library": "MS1000O2" },
|
||||
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "AIR", "Library": "SS036AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.036, "Gas": "N2", "Library": "SS036N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "AIR", "Library": "SS048AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.048, "Gas": "N2", "Library": "SS048N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "AIR", "Library": "SS060AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.060, "Gas": "N2", "Library": "SS060N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "AIR", "Library": "SS075AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.075, "Gas": "N2", "Library": "SS075N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "AIR", "Library": "SS105AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.105, "Gas": "N2", "Library": "SS105N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "AIR", "Library": "SS120AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.120, "Gas": "N2", "Library": "SS120N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "AIR", "Library": "SS135AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.135, "Gas": "N2", "Library": "SS135N2FE" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "AIR", "Library": "SS188AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.188, "Gas": "N2", "Library": "SS188N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "AIR", "Library": "SS250AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.250, "Gas": "N2", "Library": "SS250N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.313, "Gas": "N2", "Library": "SS313N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "AIR", "Library": "SS375AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.375, "Gas": "N2", "Library": "SS375N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "AIR", "Library": "SS500AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.500, "Gas": "N2", "Library": "SS500N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.625, "Gas": "N2", "Library": "SS625N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "AIR", "Library": "SS750AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 0.750, "Gas": "N2", "Library": "SS750N2" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "AIR", "Library": "SS1000AIR" },
|
||||
{ "Material": "Stainless Steel", "Thickness": 1.000, "Gas": "N2", "Library": "SS1000N2" },
|
||||
|
||||
{ "Material": "Phenolic", "Thickness": 0.0, "Gas": "", "Library": "Phenolic" },
|
||||
{ "Material": "Gasket", "Thickness": 0.250, "Gas": "N2", "Library": "GASKET250N2" }
|
||||
],
|
||||
"EtchLibraries": [
|
||||
{ "Gas": "AIR", "Library": "EtchAIR" },
|
||||
{ "Gas": "N2", "Library": "EtchN2" },
|
||||
{ "Gas": "N2", "Library": "EtchN2_fast" },
|
||||
{ "Gas": "N2", "Library": "Etchn2_no_mark_pvc" },
|
||||
{ "Gas": "O2", "Library": "EtchO2" },
|
||||
{ "Gas": "O2", "Library": "ETCHO2FINE" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Tests.CNC
|
||||
{
|
||||
public class RapidEnumeratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Enumerate_AbsoluteProgram_OffsetsMotionsByBasePos()
|
||||
{
|
||||
var pgm = new Program(Mode.Absolute);
|
||||
pgm.Codes.Add(new RapidMove(1, 0));
|
||||
pgm.Codes.Add(new LinearMove(2, 0));
|
||||
pgm.Codes.Add(new RapidMove(3, 3));
|
||||
|
||||
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
|
||||
|
||||
// Origin → first pierce, then interior rapid from contour end to next rapid target.
|
||||
Assert.Equal(2, segments.Count);
|
||||
Assert.Equal(new Vector(0, 0), segments[0].From);
|
||||
Assert.Equal(new Vector(101, 200), segments[0].To);
|
||||
Assert.Equal(new Vector(102, 200), segments[1].From);
|
||||
Assert.Equal(new Vector(103, 203), segments[1].To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enumerate_IncrementalProgram_InterpretsDeltasFromBasePos()
|
||||
{
|
||||
// Pre-lead-in raw program: first rapid normalized to (0,0), Mode=Incremental
|
||||
// (matches ConvertGeometry.ToProgram output).
|
||||
var pgm = new Program(Mode.Incremental);
|
||||
pgm.Codes.Add(new RapidMove(0, 0));
|
||||
pgm.Codes.Add(new LinearMove(5, 0));
|
||||
pgm.Codes.Add(new LinearMove(0, 5));
|
||||
pgm.Codes.Add(new RapidMove(1, 1));
|
||||
|
||||
var segments = RapidEnumerator.Enumerate(pgm, basePos: new Vector(100, 200), startPos: new Vector(0, 0));
|
||||
|
||||
Assert.Equal(2, segments.Count);
|
||||
// First rapid: plate origin → part pierce at basePos.
|
||||
Assert.Equal(new Vector(0, 0), segments[0].From);
|
||||
Assert.Equal(new Vector(100, 200), segments[0].To);
|
||||
// Interior rapid: after deltas (5,0) and (0,5) from basePos, rapid delta (1,1).
|
||||
Assert.Equal(new Vector(105, 205), segments[1].From);
|
||||
Assert.Equal(new Vector(106, 206), segments[1].To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enumerate_SubProgramCall_RapidEndsAtAbsoluteHolePierce()
|
||||
{
|
||||
// Main program: lead-in rapid, a line, then a SubProgramCall for a hole.
|
||||
// Sub-program (incremental) starts with RapidMove(radius, 0) to the hole pierce.
|
||||
var sub = new Program(Mode.Incremental);
|
||||
sub.Codes.Add(new RapidMove(0.5, 0));
|
||||
sub.Codes.Add(new LinearMove(0, 0.1));
|
||||
|
||||
var pgm = new Program(Mode.Absolute);
|
||||
pgm.Codes.Add(new RapidMove(0.2, 0.3)); // first pierce (perimeter lead-in)
|
||||
pgm.Codes.Add(new LinearMove(1.0, 1.0)); // contour move
|
||||
pgm.Codes.Add(new SubProgramCall
|
||||
{
|
||||
Id = 1,
|
||||
Program = sub,
|
||||
Offset = new Vector(2, 2), // hole center (drawing-local)
|
||||
});
|
||||
|
||||
var basePos = new Vector(100, 200); // part.Location
|
||||
var segments = RapidEnumerator.Enumerate(pgm, basePos, startPos: new Vector(0, 0));
|
||||
|
||||
// Expected rapids:
|
||||
// 1. origin → first pierce (0.2+100, 0.3+200) = (100.2, 200.3)
|
||||
// 2. end of contour (1+100, 1+200) = (101, 201) → hole pierce (2+100+0.5, 2+200) = (102.5, 202)
|
||||
// The sub's internal first rapid is skipped (already drawn in #2).
|
||||
Assert.Equal(2, segments.Count);
|
||||
|
||||
Assert.Equal(new Vector(0, 0), segments[0].From);
|
||||
Assert.Equal(new Vector(100.2, 200.3), segments[0].To);
|
||||
|
||||
Assert.Equal(new Vector(101, 201), segments[1].From);
|
||||
Assert.Equal(new Vector(102.5, 202), segments[1].To);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
[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)
|
||||
{
|
||||
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\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Splitting\TestData\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</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,51 @@
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.Shapes;
|
||||
|
||||
public class NgonShapeTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDrawing_Octagon_BoundingBoxFitsWithinExpectedSize()
|
||||
{
|
||||
var shape = new NgonShape { Sides = 8, Width = 20 };
|
||||
var drawing = shape.GetDrawing();
|
||||
|
||||
var bbox = drawing.Program.BoundingBox();
|
||||
// Corner-to-corner is larger than flat-to-flat
|
||||
Assert.True(bbox.Width >= 20 - 0.01);
|
||||
Assert.True(bbox.Length >= 20 - 0.01);
|
||||
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
|
||||
Assert.True(bbox.Width < 22);
|
||||
Assert.True(bbox.Length < 22);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
[InlineData(5)]
|
||||
[InlineData(6)]
|
||||
[InlineData(8)]
|
||||
[InlineData(12)]
|
||||
public void GetDrawing_HasOneLinearMovePerSide(int sides)
|
||||
{
|
||||
var shape = new NgonShape { Sides = sides, Width = 20 };
|
||||
var drawing = shape.GetDrawing();
|
||||
|
||||
var moves = drawing.Program.Codes
|
||||
.OfType<OpenNest.CNC.LinearMove>()
|
||||
.Count();
|
||||
Assert.Equal(sides, moves);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDrawing_ClampsSidesBelowThreeToTriangle()
|
||||
{
|
||||
var shape = new NgonShape { Sides = 2, Width = 20 };
|
||||
var drawing = shape.GetDrawing();
|
||||
|
||||
var moves = drawing.Program.Codes
|
||||
.OfType<OpenNest.CNC.LinearMove>()
|
||||
.Count();
|
||||
Assert.Equal(3, moves);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.Shapes;
|
||||
|
||||
public class OctagonShapeTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetDrawing_BoundingBoxFitsWithinExpectedSize()
|
||||
{
|
||||
var shape = new OctagonShape { Width = 20 };
|
||||
var drawing = shape.GetDrawing();
|
||||
|
||||
var bbox = drawing.Program.BoundingBox();
|
||||
// Corner-to-corner is larger than flat-to-flat
|
||||
Assert.True(bbox.Width >= 20 - 0.01);
|
||||
Assert.True(bbox.Length >= 20 - 0.01);
|
||||
// But should not be wildly larger (corner-to-corner ~ width / cos(22.5deg) ~ width * 1.0824)
|
||||
Assert.True(bbox.Width < 22);
|
||||
Assert.True(bbox.Length < 22);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDrawing_HasEightEdges()
|
||||
{
|
||||
var shape = new OctagonShape { Width = 20 };
|
||||
var drawing = shape.GetDrawing();
|
||||
|
||||
// An octagon program should have 8 linear moves (one per edge)
|
||||
var moves = drawing.Program.Codes
|
||||
.OfType<OpenNest.CNC.LinearMove>()
|
||||
.Count();
|
||||
Assert.Equal(8, moves);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
return entity switch
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
using OpenNest;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.ML;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Gpu;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Training;
|
||||
using System;
|
||||
@@ -128,17 +128,26 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
||||
continue;
|
||||
}
|
||||
|
||||
var entities = Dxf.GetGeometry(file);
|
||||
if (entities.Count == 0)
|
||||
Drawing drawing;
|
||||
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)");
|
||||
skippedGeometry++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var drawing = new Drawing(Path.GetFileName(file));
|
||||
var normalized = ShapeProfile.NormalizeEntities(entities);
|
||||
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(normalized);
|
||||
drawing.UpdateArea();
|
||||
drawing.Color = PartColors[colorIndex % PartColors.Length];
|
||||
colorIndex++;
|
||||
|
||||
@@ -13,10 +13,13 @@ namespace OpenNest
|
||||
private Color edgeSpacingColor;
|
||||
private Color previewPartColor;
|
||||
|
||||
public static Color[] PartColors => Drawing.PartColors;
|
||||
public string Name { get; set; } = "Unnamed";
|
||||
|
||||
public Color[] PartColors { get; set; } = Drawing.PartColors;
|
||||
|
||||
public static readonly ColorScheme Default = new ColorScheme
|
||||
{
|
||||
Name = "Classic",
|
||||
BackgroundColor = Color.DarkGray,
|
||||
LayoutOutlineColor = Color.Gray,
|
||||
LayoutFillColor = Color.WhiteSmoke,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Properties;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class ColorSchemeRegistry
|
||||
{
|
||||
private static readonly Dictionary<string, ColorScheme> builtIns =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Classic"] = BuildClassic(),
|
||||
["Pastel"] = BuildPastel(),
|
||||
["Dark"] = BuildDark()
|
||||
};
|
||||
|
||||
private static List<ColorScheme> diskCache;
|
||||
|
||||
public static IEnumerable<ColorScheme> AllSchemes
|
||||
{
|
||||
get
|
||||
{
|
||||
diskCache ??= LoadDiskSchemes().ToList();
|
||||
return builtIns.Values.Concat(diskCache);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Refresh() => diskCache = null;
|
||||
|
||||
public static ColorScheme Get(string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return builtIns["Classic"];
|
||||
|
||||
var hit = AllSchemes.FirstOrDefault(
|
||||
s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
return hit ?? builtIns["Classic"];
|
||||
}
|
||||
|
||||
public static void ApplyActiveFromSettings()
|
||||
{
|
||||
var name = Settings.Default.ActiveColorScheme;
|
||||
var scheme = Get(name);
|
||||
Apply(scheme);
|
||||
}
|
||||
|
||||
public static void Apply(ColorScheme scheme)
|
||||
{
|
||||
var d = ColorScheme.Default;
|
||||
d.Name = scheme.Name;
|
||||
d.BackgroundColor = scheme.BackgroundColor;
|
||||
d.LayoutOutlineColor = scheme.LayoutOutlineColor;
|
||||
d.LayoutFillColor = scheme.LayoutFillColor;
|
||||
d.BoundingBoxColor = scheme.BoundingBoxColor;
|
||||
d.RapidColor = scheme.RapidColor;
|
||||
d.OriginColor = scheme.OriginColor;
|
||||
d.EdgeSpacingColor = scheme.EdgeSpacingColor;
|
||||
d.PreviewPartColor = scheme.PreviewPartColor;
|
||||
d.PartColors = scheme.PartColors;
|
||||
|
||||
Drawing.PartColors = scheme.PartColors;
|
||||
|
||||
RecolorOpenNests(scheme.PartColors);
|
||||
}
|
||||
|
||||
private static void RecolorOpenNests(Color[] palette)
|
||||
{
|
||||
foreach (Form f in Application.OpenForms)
|
||||
{
|
||||
if (f is not EditNestForm enf)
|
||||
continue;
|
||||
|
||||
var i = 0;
|
||||
foreach (var drawing in enf.Nest.Drawings)
|
||||
{
|
||||
if (drawing.IsCutOff)
|
||||
continue;
|
||||
drawing.Color = palette[i % palette.Length];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ColorScheme> LoadDiskSchemes()
|
||||
{
|
||||
var dir = Path.Combine(AppContext.BaseDirectory, "Schemes");
|
||||
if (!Directory.Exists(dir))
|
||||
yield break;
|
||||
|
||||
foreach (var path in Directory.GetFiles(dir, "*.json"))
|
||||
{
|
||||
ColorScheme scheme;
|
||||
try
|
||||
{
|
||||
scheme = ColorSchemeSerializer.Deserialize(File.ReadAllText(path));
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!builtIns.ContainsKey(scheme.Name))
|
||||
yield return scheme;
|
||||
}
|
||||
}
|
||||
|
||||
private static ColorScheme BuildClassic() => new ColorScheme
|
||||
{
|
||||
Name = "Classic",
|
||||
BackgroundColor = Color.DarkGray,
|
||||
LayoutOutlineColor = Color.Gray,
|
||||
LayoutFillColor = Color.WhiteSmoke,
|
||||
BoundingBoxColor = Color.FromArgb(128, 128, 255),
|
||||
RapidColor = Color.DodgerBlue,
|
||||
OriginColor = Color.Gray,
|
||||
EdgeSpacingColor = Color.FromArgb(180, 180, 180),
|
||||
PreviewPartColor = Color.FromArgb(255, 140, 0),
|
||||
PartColors = new[]
|
||||
{
|
||||
Color.FromArgb(205, 92, 92),
|
||||
Color.FromArgb(148, 103, 189),
|
||||
Color.FromArgb(75, 180, 175),
|
||||
Color.FromArgb(210, 190, 75),
|
||||
Color.FromArgb(190, 85, 175),
|
||||
Color.FromArgb(185, 115, 85),
|
||||
Color.FromArgb(120, 100, 190),
|
||||
Color.FromArgb(200, 100, 140),
|
||||
Color.FromArgb(80, 175, 155),
|
||||
Color.FromArgb(195, 160, 85),
|
||||
Color.FromArgb(175, 95, 160),
|
||||
Color.FromArgb(215, 130, 130),
|
||||
}
|
||||
};
|
||||
|
||||
private static ColorScheme BuildPastel() => new ColorScheme
|
||||
{
|
||||
Name = "Pastel",
|
||||
BackgroundColor = Color.FromArgb(70, 75, 85),
|
||||
LayoutOutlineColor = Color.FromArgb(180, 180, 190),
|
||||
LayoutFillColor = Color.FromArgb(245, 245, 248),
|
||||
BoundingBoxColor = Color.FromArgb(128, 128, 255),
|
||||
RapidColor = Color.DodgerBlue,
|
||||
OriginColor = Color.FromArgb(160, 160, 160),
|
||||
EdgeSpacingColor = Color.FromArgb(200, 200, 210),
|
||||
PreviewPartColor = Color.FromArgb(255, 140, 0),
|
||||
PartColors = new[]
|
||||
{
|
||||
Color.FromArgb(122, 179, 209), Color.FromArgb(254, 229, 174),
|
||||
Color.FromArgb(143, 177, 229), Color.FromArgb(167, 172, 227),
|
||||
Color.FromArgb(216, 249, 195), Color.FromArgb(209, 168, 216),
|
||||
Color.FromArgb(222, 157, 190), Color.FromArgb(176, 255, 240),
|
||||
Color.FromArgb(235, 205, 153), Color.FromArgb(177, 225, 180),
|
||||
Color.FromArgb(125, 202, 241), Color.FromArgb(187, 206, 151),
|
||||
Color.FromArgb(251, 175, 190), Color.FromArgb(129, 226, 227),
|
||||
Color.FromArgb(255, 253, 207), Color.FromArgb(235, 205, 255),
|
||||
Color.FromArgb(255, 197, 168), Color.FromArgb(116, 213, 234),
|
||||
Color.FromArgb(190, 169, 122), Color.FromArgb(213, 159, 135),
|
||||
Color.FromArgb(124, 184, 155), Color.FromArgb(255, 189, 214),
|
||||
Color.FromArgb(146, 222, 255), Color.FromArgb(177, 173, 125),
|
||||
Color.FromArgb(177, 166, 202), Color.FromArgb(197, 208, 255),
|
||||
Color.FromArgb(255, 209, 243), Color.FromArgb(210, 255, 237),
|
||||
Color.FromArgb(255, 237, 204), Color.FromArgb(167, 233, 255),
|
||||
Color.FromArgb(182, 220, 255), Color.FromArgb(159, 177, 142),
|
||||
Color.FromArgb(190, 248, 255), Color.FromArgb(187, 169, 136),
|
||||
Color.FromArgb(199, 162, 168), Color.FromArgb(250, 255, 239),
|
||||
Color.FromArgb(222, 233, 255), Color.FromArgb(255, 234, 225),
|
||||
Color.FromArgb(240, 249, 255), Color.FromArgb(152, 176, 176),
|
||||
}
|
||||
};
|
||||
|
||||
private static ColorScheme BuildDark() => new ColorScheme
|
||||
{
|
||||
Name = "Dark",
|
||||
BackgroundColor = Color.FromArgb(30, 30, 34),
|
||||
LayoutOutlineColor = Color.FromArgb(90, 90, 95),
|
||||
LayoutFillColor = Color.FromArgb(50, 50, 55),
|
||||
BoundingBoxColor = Color.FromArgb(100, 160, 220),
|
||||
RapidColor = Color.FromArgb(255, 200, 50),
|
||||
OriginColor = Color.FromArgb(120, 120, 130),
|
||||
EdgeSpacingColor = Color.FromArgb(90, 90, 100),
|
||||
PreviewPartColor = Color.FromArgb(255, 170, 60),
|
||||
PartColors = new[]
|
||||
{
|
||||
Color.FromArgb(255, 85, 85), // Neon Red
|
||||
Color.FromArgb(80, 220, 255), // Electric Cyan
|
||||
Color.FromArgb(255, 200, 50), // Amber
|
||||
Color.FromArgb(130, 255, 130), // Lime Green
|
||||
Color.FromArgb(255, 130, 220), // Hot Pink
|
||||
Color.FromArgb(255, 165, 70), // Tangerine
|
||||
Color.FromArgb(100, 180, 255), // Sky Blue
|
||||
Color.FromArgb(200, 160, 255), // Lavender
|
||||
Color.FromArgb(50, 230, 180), // Mint
|
||||
Color.FromArgb(255, 255, 100), // Lemon
|
||||
Color.FromArgb(255, 120, 120), // Salmon
|
||||
Color.FromArgb(140, 230, 255), // Ice Blue
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public static class ColorSchemeSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public static string Serialize(ColorScheme scheme)
|
||||
{
|
||||
var dto = new ColorSchemeDto
|
||||
{
|
||||
Name = scheme.Name,
|
||||
BackgroundColor = ToHex(scheme.BackgroundColor),
|
||||
LayoutOutlineColor = ToHex(scheme.LayoutOutlineColor),
|
||||
LayoutFillColor = ToHex(scheme.LayoutFillColor),
|
||||
BoundingBoxColor = ToHex(scheme.BoundingBoxColor),
|
||||
RapidColor = ToHex(scheme.RapidColor),
|
||||
OriginColor = ToHex(scheme.OriginColor),
|
||||
EdgeSpacingColor = ToHex(scheme.EdgeSpacingColor),
|
||||
PreviewPartColor = ToHex(scheme.PreviewPartColor),
|
||||
PartColors = scheme.PartColors.Select(ToHex).ToArray()
|
||||
};
|
||||
return JsonSerializer.Serialize(dto, JsonOptions);
|
||||
}
|
||||
|
||||
public static ColorScheme Deserialize(string json)
|
||||
{
|
||||
var dto = JsonSerializer.Deserialize<ColorSchemeDto>(json, JsonOptions)
|
||||
?? throw new JsonException("ColorScheme JSON was null");
|
||||
|
||||
return new ColorScheme
|
||||
{
|
||||
Name = dto.Name ?? "Unnamed",
|
||||
BackgroundColor = FromHex(dto.BackgroundColor),
|
||||
LayoutOutlineColor = FromHex(dto.LayoutOutlineColor),
|
||||
LayoutFillColor = FromHex(dto.LayoutFillColor),
|
||||
BoundingBoxColor = FromHex(dto.BoundingBoxColor),
|
||||
RapidColor = FromHex(dto.RapidColor),
|
||||
OriginColor = FromHex(dto.OriginColor),
|
||||
EdgeSpacingColor = FromHex(dto.EdgeSpacingColor),
|
||||
PreviewPartColor = FromHex(dto.PreviewPartColor),
|
||||
PartColors = (dto.PartColors ?? new string[0]).Select(FromHex).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToHex(Color c) =>
|
||||
"#" + c.R.ToString("X2") + c.G.ToString("X2") + c.B.ToString("X2");
|
||||
|
||||
private static Color FromHex(string hex)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hex))
|
||||
return Color.Black;
|
||||
var h = hex.TrimStart('#');
|
||||
if (h.Length < 6)
|
||||
return Color.Black;
|
||||
var r = byte.Parse(h.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
var g = byte.Parse(h.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
var b = byte.Parse(h.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
return Color.FromArgb(r, g, b);
|
||||
}
|
||||
|
||||
private class ColorSchemeDto
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string BackgroundColor { get; set; }
|
||||
public string LayoutOutlineColor { get; set; }
|
||||
public string LayoutFillColor { get; set; }
|
||||
public string BoundingBoxColor { get; set; }
|
||||
public string RapidColor { get; set; }
|
||||
public string OriginColor { get; set; }
|
||||
public string EdgeSpacingColor { get; set; }
|
||||
public string PreviewPartColor { get; set; }
|
||||
public string[] PartColors { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,
|
||||
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)
|
||||
{
|
||||
@@ -18,7 +24,11 @@ namespace OpenNest.Controls
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -26,7 +36,7 @@ namespace OpenNest.Controls
|
||||
|
||||
var endpt = pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint;
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.LinearMove)
|
||||
{
|
||||
@@ -41,7 +51,7 @@ namespace OpenNest.Controls
|
||||
{
|
||||
var center = pgm.Mode == Mode.Incremental
|
||||
? arc.CenterPoint + pos
|
||||
: arc.CenterPoint;
|
||||
: arc.CenterPoint + basePos;
|
||||
DrawArcArrows(g, view, pos, endpt, center, arc.Rotation, pen, spacing, arrowSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,15 @@ namespace OpenNest.Controls
|
||||
|
||||
private readonly CheckBox chkTabsEnabled;
|
||||
private readonly NumericUpDown nudTabWidth;
|
||||
private readonly RadioButton rbTabAll;
|
||||
private readonly RadioButton rbAutoTab;
|
||||
private readonly NumericUpDown nudAutoTabMin;
|
||||
private readonly NumericUpDown nudAutoTabMax;
|
||||
private readonly NumericUpDown nudPierceClearance;
|
||||
|
||||
private readonly CheckBox chkRoundLeadInAngles;
|
||||
private readonly NumericUpDown nudLeadInAngleIncrement;
|
||||
|
||||
private readonly Button btnAutoAssign;
|
||||
|
||||
private bool suppressEvents;
|
||||
@@ -109,7 +114,7 @@ namespace OpenNest.Controls
|
||||
{
|
||||
HeaderText = "Tabs",
|
||||
Dock = DockStyle.Top,
|
||||
ExpandedHeight = 120,
|
||||
ExpandedHeight = 160,
|
||||
IsExpanded = false
|
||||
};
|
||||
|
||||
@@ -119,50 +124,84 @@ namespace OpenNest.Controls
|
||||
Location = new Point(12, 4),
|
||||
AutoSize = true
|
||||
};
|
||||
chkTabsEnabled.CheckedChanged += (s, e) =>
|
||||
{
|
||||
nudTabWidth.Enabled = chkTabsEnabled.Checked;
|
||||
OnParametersChanged();
|
||||
};
|
||||
tabsPanel.ContentPanel.Controls.Add(chkTabsEnabled);
|
||||
|
||||
tabsPanel.ContentPanel.Controls.Add(new Label
|
||||
{
|
||||
Text = "Width:",
|
||||
Text = "Tab Size:",
|
||||
Location = new Point(160, 6),
|
||||
AutoSize = true
|
||||
});
|
||||
|
||||
nudTabWidth = CreateNumeric(215, 3, 0.25, 0.0625);
|
||||
nudTabWidth = CreateNumeric(225, 3, 0.25, 0.0625);
|
||||
nudTabWidth.Enabled = false;
|
||||
tabsPanel.ContentPanel.Controls.Add(nudTabWidth);
|
||||
|
||||
rbTabAll = new RadioButton
|
||||
{
|
||||
Text = "Tab all parts",
|
||||
Location = new Point(28, 28),
|
||||
AutoSize = true,
|
||||
Enabled = false,
|
||||
Checked = true
|
||||
};
|
||||
tabsPanel.ContentPanel.Controls.Add(rbTabAll);
|
||||
|
||||
rbAutoTab = new RadioButton
|
||||
{
|
||||
Text = "Auto-tab when smallest part dimension is between:",
|
||||
Location = new Point(28, 50),
|
||||
AutoSize = true,
|
||||
Enabled = false
|
||||
};
|
||||
tabsPanel.ContentPanel.Controls.Add(rbAutoTab);
|
||||
|
||||
tabsPanel.ContentPanel.Controls.Add(new Label
|
||||
{
|
||||
Text = "Auto-Tab Min Size:",
|
||||
Location = new Point(12, 32),
|
||||
Text = "Min:",
|
||||
Location = new Point(44, 76),
|
||||
AutoSize = true
|
||||
});
|
||||
|
||||
nudAutoTabMin = CreateNumeric(140, 29, 0, 0.0625);
|
||||
nudAutoTabMin = CreateNumeric(77, 73, 0, 0.0625);
|
||||
nudAutoTabMin.Enabled = false;
|
||||
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMin);
|
||||
|
||||
tabsPanel.ContentPanel.Controls.Add(new Label
|
||||
{
|
||||
Text = "Auto-Tab Max Size:",
|
||||
Location = new Point(12, 58),
|
||||
Text = "Max:",
|
||||
Location = new Point(210, 76),
|
||||
AutoSize = true
|
||||
});
|
||||
|
||||
nudAutoTabMax = CreateNumeric(140, 55, 0, 0.0625);
|
||||
nudAutoTabMax = CreateNumeric(245, 73, 0, 0.0625);
|
||||
nudAutoTabMax.Enabled = false;
|
||||
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMax);
|
||||
|
||||
chkTabsEnabled.CheckedChanged += (s, e) =>
|
||||
{
|
||||
var enabled = chkTabsEnabled.Checked;
|
||||
nudTabWidth.Enabled = enabled;
|
||||
rbTabAll.Enabled = enabled;
|
||||
rbAutoTab.Enabled = enabled;
|
||||
nudAutoTabMin.Enabled = enabled && rbAutoTab.Checked;
|
||||
nudAutoTabMax.Enabled = enabled && rbAutoTab.Checked;
|
||||
OnParametersChanged();
|
||||
};
|
||||
|
||||
rbTabAll.CheckedChanged += (s, e) =>
|
||||
{
|
||||
nudAutoTabMin.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked;
|
||||
nudAutoTabMax.Enabled = chkTabsEnabled.Checked && rbAutoTab.Checked;
|
||||
OnParametersChanged();
|
||||
};
|
||||
|
||||
// Pierce section
|
||||
var piercePanel = new CollapsiblePanel
|
||||
{
|
||||
HeaderText = "Pierce",
|
||||
Dock = DockStyle.Top,
|
||||
ExpandedHeight = 60,
|
||||
ExpandedHeight = 90,
|
||||
IsExpanded = true
|
||||
};
|
||||
|
||||
@@ -176,6 +215,34 @@ namespace OpenNest.Controls
|
||||
nudPierceClearance = CreateNumeric(130, 3, 0.0625, 0.0625);
|
||||
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
|
||||
btnAutoAssign = new Button
|
||||
{
|
||||
@@ -215,11 +282,13 @@ namespace OpenNest.Controls
|
||||
InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut),
|
||||
ArcCircleLeadIn = BuildLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn),
|
||||
ArcCircleLeadOut = BuildLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut),
|
||||
TabsEnabled = chkTabsEnabled.Checked,
|
||||
TabsEnabled = chkTabsEnabled.Checked && rbTabAll.Checked,
|
||||
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
|
||||
PierceClearance = (double)nudPierceClearance.Value,
|
||||
AutoTabMinSize = (double)nudAutoTabMin.Value,
|
||||
AutoTabMaxSize = (double)nudAutoTabMax.Value
|
||||
RoundLeadInAngles = chkRoundLeadInAngles.Checked,
|
||||
LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value,
|
||||
AutoTabMinSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMin.Value : 0,
|
||||
AutoTabMaxSize = chkTabsEnabled.Checked && rbAutoTab.Checked ? (double)nudAutoTabMax.Value : 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,10 +303,16 @@ namespace OpenNest.Controls
|
||||
LoadLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn, p.ArcCircleLeadIn);
|
||||
LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut);
|
||||
|
||||
chkTabsEnabled.Checked = p.TabsEnabled;
|
||||
var hasAutoTab = p.AutoTabMinSize > 0 || p.AutoTabMaxSize > 0;
|
||||
chkTabsEnabled.Checked = p.TabsEnabled || hasAutoTab;
|
||||
rbAutoTab.Checked = hasAutoTab;
|
||||
rbTabAll.Checked = !hasAutoTab;
|
||||
if (p.TabConfig != null)
|
||||
nudTabWidth.Value = (decimal)p.TabConfig.Size;
|
||||
nudPierceClearance.Value = (decimal)p.PierceClearance;
|
||||
chkRoundLeadInAngles.Checked = p.RoundLeadInAngles;
|
||||
nudLeadInAngleIncrement.Value = (decimal)p.LeadInAngleIncrement;
|
||||
nudLeadInAngleIncrement.Enabled = p.RoundLeadInAngles;
|
||||
nudAutoTabMin.Value = (decimal)p.AutoTabMinSize;
|
||||
nudAutoTabMax.Value = (decimal)p.AutoTabMaxSize;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace OpenNest.Controls
|
||||
public event EventHandler FilterChanged;
|
||||
public event EventHandler<int> BendLineSelected;
|
||||
public event EventHandler<int> BendLineRemoved;
|
||||
public event EventHandler<int> BendLineEdited;
|
||||
public event EventHandler AddBendLineClicked;
|
||||
|
||||
public FilterPanel()
|
||||
@@ -51,6 +52,18 @@ namespace OpenNest.Controls
|
||||
bendLinesList.SelectedIndexChanged += (s, e) =>
|
||||
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
|
||||
{
|
||||
Text = "Remove",
|
||||
@@ -63,6 +76,12 @@ namespace OpenNest.Controls
|
||||
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
|
||||
};
|
||||
|
||||
bendLinesList.DoubleClick += (s, e) =>
|
||||
{
|
||||
if (bendLinesList.SelectedIndex >= 0)
|
||||
BendLineEdited?.Invoke(this, bendLinesList.SelectedIndex);
|
||||
};
|
||||
|
||||
bendAddLink = new LinkLabel
|
||||
{
|
||||
Text = "Add Bend Line",
|
||||
@@ -80,6 +99,7 @@ namespace OpenNest.Controls
|
||||
WrapContents = false
|
||||
};
|
||||
bendLinksPanel.Controls.Add(bendAddLink);
|
||||
bendLinksPanel.Controls.Add(bendEditLink);
|
||||
bendLinksPanel.Controls.Add(bendDeleteLink);
|
||||
|
||||
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
|
||||
|
||||
@@ -385,82 +385,18 @@ namespace OpenNest.Controls
|
||||
|
||||
private void DrawRapids(Graphics g)
|
||||
{
|
||||
var pen = view.ColorScheme.RapidPen;
|
||||
var pos = new Vector(0, 0);
|
||||
|
||||
for (var i = 0; i < view.Plate.Parts.Count; ++i)
|
||||
{
|
||||
var part = view.Plate.Parts[i];
|
||||
var pgm = part.Program;
|
||||
var segments = RapidEnumerator.Enumerate(part.Program, part.Location, pos);
|
||||
|
||||
var piercePoint = GetFirstPiercePoint(pgm, part.Location);
|
||||
DrawLine(g, pos, piercePoint, view.ColorScheme.RapidPen);
|
||||
|
||||
pos = part.Location;
|
||||
DrawRapids(g, pgm, ref pos, skipFirstRapid: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector GetFirstPiercePoint(Program pgm, Vector partLocation)
|
||||
{
|
||||
for (var i = 0; i < pgm.Length; i++)
|
||||
{
|
||||
if (pgm[i] is Motion motion)
|
||||
foreach (var seg in segments)
|
||||
{
|
||||
if (pgm.Mode == Mode.Incremental)
|
||||
return motion.EndPoint + partLocation;
|
||||
return motion.EndPoint;
|
||||
}
|
||||
}
|
||||
return partLocation;
|
||||
}
|
||||
|
||||
private void DrawRapids(Graphics g, Program pgm, ref Vector pos, bool skipFirstRapid = false)
|
||||
{
|
||||
var firstRapidSkipped = false;
|
||||
|
||||
for (var i = 0; i < pgm.Length; ++i)
|
||||
{
|
||||
var code = pgm[i];
|
||||
|
||||
if (code.Type == CodeType.SubProgramCall)
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
var program = subpgm.Program;
|
||||
|
||||
if (program != null)
|
||||
DrawRapids(g, program, ref pos);
|
||||
}
|
||||
else
|
||||
{
|
||||
var motion = code as Motion;
|
||||
|
||||
if (motion != null)
|
||||
{
|
||||
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);
|
||||
}
|
||||
pos = endpt;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (code.Type == CodeType.RapidMove)
|
||||
{
|
||||
if (skipFirstRapid && !firstRapidSkipped)
|
||||
firstRapidSkipped = true;
|
||||
else
|
||||
DrawLine(g, pos, motion.EndPoint, view.ColorScheme.RapidPen);
|
||||
}
|
||||
pos = motion.EndPoint;
|
||||
}
|
||||
}
|
||||
DrawLine(g, seg.From, seg.To, pen);
|
||||
pos = seg.To;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,11 +411,11 @@ namespace OpenNest.Controls
|
||||
var part = view.Plate.Parts[i];
|
||||
var pgm = part.Program;
|
||||
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)
|
||||
{
|
||||
@@ -489,7 +425,11 @@ namespace OpenNest.Controls
|
||||
{
|
||||
var subpgm = (SubProgramCall)code;
|
||||
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
|
||||
{
|
||||
@@ -498,7 +438,7 @@ namespace OpenNest.Controls
|
||||
|
||||
var endpt = pgm.Mode == Mode.Incremental
|
||||
? motion.EndPoint + pos
|
||||
: motion.EndPoint;
|
||||
: motion.EndPoint + basePos;
|
||||
|
||||
if (code.Type == CodeType.RapidMove)
|
||||
{
|
||||
|
||||
@@ -464,6 +464,9 @@ namespace OpenNest.Controls
|
||||
|
||||
protected override void OnPaint(PaintEventArgs e)
|
||||
{
|
||||
if (BackColor != ColorScheme.BackgroundColor)
|
||||
BackColor = ColorScheme.BackgroundColor;
|
||||
|
||||
e.Graphics.SmoothingMode = SmoothingMode.HighSpeed;
|
||||
|
||||
if (DrawOrigin)
|
||||
@@ -621,30 +624,30 @@ namespace OpenNest.Controls
|
||||
|
||||
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)
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated) return;
|
||||
BeginInvoke(new System.Action(HoverCheck));
|
||||
}
|
||||
|
||||
private void HoverCheck()
|
||||
{
|
||||
var graphPt = PointControlToGraph(hoverPoint);
|
||||
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) &&
|
||||
parts[i].Path.IsVisible(graphPt))
|
||||
{
|
||||
if (parts[i].Path.GetBounds().Contains(graphPt) &&
|
||||
parts[i].Path.IsVisible(graphPt))
|
||||
{
|
||||
hitPart = parts[i];
|
||||
break;
|
||||
}
|
||||
hitPart = parts[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// GraphicsPath in use by paint thread — skip this hover tick
|
||||
return;
|
||||
}
|
||||
|
||||
hoveredPart = hitPart;
|
||||
showTooltip = hitPart != null;
|
||||
|
||||
@@ -209,15 +209,8 @@ namespace OpenNest.Controls
|
||||
|
||||
private static Entity CloneEntity(Entity entity, Color color)
|
||||
{
|
||||
Entity clone = entity switch
|
||||
{
|
||||
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;
|
||||
var clone = entity.Clone();
|
||||
clone.Color = color;
|
||||
return clone;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,5 +99,17 @@ namespace OpenNest.Forms
|
||||
public double BendAngle => (double)numAngle.Value;
|
||||
|
||||
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.IO;
|
||||
using OpenNest.IO.Bending;
|
||||
using OpenNest.IO.Bom;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -470,33 +466,9 @@ namespace OpenNest.Forms
|
||||
|
||||
try
|
||||
{
|
||||
var result = Dxf.Import(part.DxfPath);
|
||||
|
||||
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;
|
||||
var drawing = CadImporter.ImportDrawing(part.DxfPath,
|
||||
new CadImportOptions { Quantity = part.Qty ?? 1 });
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -5,7 +5,6 @@ using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bending;
|
||||
using OpenNest.Properties;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
@@ -42,6 +41,7 @@ namespace OpenNest.Forms
|
||||
filterPanel.FilterChanged += OnFilterChanged;
|
||||
filterPanel.BendLineSelected += OnBendLineSelected;
|
||||
filterPanel.BendLineRemoved += OnBendLineRemoved;
|
||||
filterPanel.BendLineEdited += OnBendLineEdited;
|
||||
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
||||
entityView1.LinePicked += OnLinePicked;
|
||||
entityView1.PickCancelled += OnPickCancelled;
|
||||
@@ -74,36 +74,24 @@ namespace OpenNest.Forms
|
||||
{
|
||||
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)
|
||||
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
|
||||
{
|
||||
Name = Path.GetFileNameWithoutExtension(file),
|
||||
Name = result.Name,
|
||||
Entities = result.Entities,
|
||||
Path = file,
|
||||
Path = result.SourcePath,
|
||||
Quantity = 1,
|
||||
Customer = string.Empty,
|
||||
Bends = bends,
|
||||
Bounds = bounds,
|
||||
Bends = result.Bends,
|
||||
Bounds = result.Bounds,
|
||||
EntityCount = result.Entities.Count
|
||||
};
|
||||
|
||||
@@ -305,6 +293,29 @@ namespace OpenNest.Forms
|
||||
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)
|
||||
{
|
||||
var item = CurrentItem;
|
||||
@@ -368,7 +379,6 @@ namespace OpenNest.Forms
|
||||
: Path.GetTempPath();
|
||||
|
||||
var index = fileList.SelectedIndex;
|
||||
var newItems = new List<string>();
|
||||
|
||||
var splitWriter = new SplitDxfWriter();
|
||||
var splitItems = new List<FileListItem>();
|
||||
@@ -381,7 +391,6 @@ namespace OpenNest.Forms
|
||||
var splitPath = GetUniquePath(Path.Combine(writableDir, splitName));
|
||||
|
||||
splitWriter.Write(splitPath, splitDrawing);
|
||||
newItems.Add(splitPath);
|
||||
|
||||
// Re-import geometry but keep bends from the split drawing
|
||||
var result = Dxf.Import(splitPath);
|
||||
@@ -669,53 +678,35 @@ namespace OpenNest.Forms
|
||||
|
||||
foreach (var item in fileList.Items)
|
||||
{
|
||||
var entities = item.Entities.Where(e => e.Layer.IsVisible && e.IsVisible).ToList();
|
||||
|
||||
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))
|
||||
var visible = item.Entities
|
||||
.Where(e => e.Layer.IsVisible && e.IsVisible)
|
||||
.ToList();
|
||||
|
||||
drawing.SuppressedEntityIds = new HashSet<Guid>(
|
||||
drawing.SourceEntities
|
||||
.Where(e => !(e.Layer.IsVisible && e.IsVisible))
|
||||
.Select(e => e.Id));
|
||||
if (visible.Count == 0)
|
||||
continue;
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -780,9 +771,6 @@ namespace OpenNest.Forms
|
||||
item.SuppressedEntityIds = null;
|
||||
}
|
||||
|
||||
|
||||
private static Color GetNextColor() => Drawing.GetNextColor();
|
||||
|
||||
private static bool IsDirectoryWritable(string path)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace OpenNest.Forms
|
||||
TabsEnabled = p.TabsEnabled,
|
||||
TabWidth = p.TabConfig?.Size ?? 0.25,
|
||||
PierceClearance = p.PierceClearance,
|
||||
RoundLeadInAngles = p.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = p.LeadInAngleIncrement,
|
||||
AutoTabMinSize = p.AutoTabMinSize,
|
||||
AutoTabMaxSize = p.AutoTabMaxSize
|
||||
};
|
||||
@@ -47,6 +49,8 @@ namespace OpenNest.Forms
|
||||
TabsEnabled = dto.TabsEnabled,
|
||||
TabConfig = new NormalTab { Size = dto.TabWidth },
|
||||
PierceClearance = dto.PierceClearance,
|
||||
RoundLeadInAngles = dto.RoundLeadInAngles,
|
||||
LeadInAngleIncrement = dto.LeadInAngleIncrement > 0 ? dto.LeadInAngleIncrement : 5.0,
|
||||
AutoTabMinSize = dto.AutoTabMinSize,
|
||||
AutoTabMaxSize = dto.AutoTabMaxSize
|
||||
};
|
||||
@@ -111,6 +115,8 @@ namespace OpenNest.Forms
|
||||
public bool TabsEnabled { get; set; }
|
||||
public double TabWidth { get; set; }
|
||||
public double PierceClearance { get; set; }
|
||||
public bool RoundLeadInAngles { get; set; }
|
||||
public double LeadInAngleIncrement { get; set; }
|
||||
public double AutoTabMinSize { get; set; }
|
||||
public double AutoTabMaxSize { get; set; }
|
||||
}
|
||||
|
||||
Generated
+12
-21
@@ -47,11 +47,9 @@
|
||||
drawingListBox1 = new OpenNest.Controls.DrawingListBox();
|
||||
toolStrip2 = new System.Windows.Forms.ToolStrip();
|
||||
toolStripButton2 = new System.Windows.Forms.ToolStripButton();
|
||||
toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
|
||||
shapeLibraryButton = new System.Windows.Forms.ToolStripButton();
|
||||
editDrawingsButton = new System.Windows.Forms.ToolStripButton();
|
||||
toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator();
|
||||
toolStripButton3 = new System.Windows.Forms.ToolStripButton();
|
||||
toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
|
||||
hideNestedButton = new System.Windows.Forms.ToolStripButton();
|
||||
((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit();
|
||||
splitContainer.Panel1.SuspendLayout();
|
||||
@@ -81,8 +79,8 @@
|
||||
//
|
||||
// tabControl1
|
||||
//
|
||||
tabControl1.Controls.Add(tabPage1);
|
||||
tabControl1.Controls.Add(tabPage2);
|
||||
tabControl1.Controls.Add(tabPage1);
|
||||
tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
tabControl1.ItemSize = new System.Drawing.Size(100, 22);
|
||||
tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
@@ -219,7 +217,7 @@
|
||||
//
|
||||
toolStrip2.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden;
|
||||
toolStrip2.ImageScalingSize = new System.Drawing.Size(20, 20);
|
||||
toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, toolStripSeparator4, editDrawingsButton, toolStripSeparator1, toolStripButton3, toolStripSeparator2, hideNestedButton });
|
||||
toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, shapeLibraryButton, editDrawingsButton, toolStripButton3, hideNestedButton });
|
||||
toolStrip2.Location = new System.Drawing.Point(4, 3);
|
||||
toolStrip2.Name = "toolStrip2";
|
||||
toolStrip2.Size = new System.Drawing.Size(265, 27);
|
||||
@@ -238,10 +236,15 @@
|
||||
toolStripButton2.Text = "Import Drawings";
|
||||
toolStripButton2.Click += ImportDrawings_Click;
|
||||
//
|
||||
// toolStripSeparator4
|
||||
// shapeLibraryButton
|
||||
//
|
||||
toolStripSeparator4.Name = "toolStripSeparator4";
|
||||
toolStripSeparator4.Size = new System.Drawing.Size(6, 27);
|
||||
shapeLibraryButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
|
||||
shapeLibraryButton.Image = Properties.Resources.shapes;
|
||||
shapeLibraryButton.Name = "shapeLibraryButton";
|
||||
shapeLibraryButton.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0);
|
||||
shapeLibraryButton.Size = new System.Drawing.Size(34, 24);
|
||||
shapeLibraryButton.Text = "Shape Library";
|
||||
shapeLibraryButton.Click += ShapeLibrary_Click;
|
||||
//
|
||||
// editDrawingsButton
|
||||
//
|
||||
@@ -253,11 +256,6 @@
|
||||
editDrawingsButton.Text = "Edit Drawings in Converter";
|
||||
editDrawingsButton.Click += EditDrawingsInConverter_Click;
|
||||
//
|
||||
// toolStripSeparator1
|
||||
//
|
||||
toolStripSeparator1.Name = "toolStripSeparator1";
|
||||
toolStripSeparator1.Size = new System.Drawing.Size(6, 27);
|
||||
//
|
||||
// toolStripButton3
|
||||
//
|
||||
toolStripButton3.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image;
|
||||
@@ -269,11 +267,6 @@
|
||||
toolStripButton3.Text = "Cleanup unused Drawings";
|
||||
toolStripButton3.Click += CleanUnusedDrawings_Click;
|
||||
//
|
||||
// toolStripSeparator2
|
||||
//
|
||||
toolStripSeparator2.Name = "toolStripSeparator2";
|
||||
toolStripSeparator2.Size = new System.Drawing.Size(6, 27);
|
||||
//
|
||||
// hideNestedButton
|
||||
//
|
||||
hideNestedButton.CheckOnClick = true;
|
||||
@@ -329,11 +322,9 @@
|
||||
private System.Windows.Forms.ColumnHeader utilColumn;
|
||||
private System.Windows.Forms.ToolStrip toolStrip2;
|
||||
private System.Windows.Forms.ToolStripButton toolStripButton2;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
||||
private System.Windows.Forms.ToolStripButton shapeLibraryButton;
|
||||
private System.Windows.Forms.ToolStripButton editDrawingsButton;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator1;
|
||||
private System.Windows.Forms.ToolStripButton toolStripButton3;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator2;
|
||||
private System.Windows.Forms.ToolStripButton hideNestedButton;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
||||
private System.Windows.Forms.ToolStripButton toolStripLabel1;
|
||||
|
||||
@@ -7,6 +7,7 @@ using OpenNest.Engine.Sequencing;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Properties;
|
||||
using OpenNest.Shapes;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
@@ -453,7 +454,11 @@ namespace OpenNest.Forms
|
||||
|
||||
public void ResizePlateToFitParts()
|
||||
{
|
||||
PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
|
||||
var options = new PlateSizeOptions
|
||||
{
|
||||
SnapIncrement = Settings.Default.AutoSizePlateFactor,
|
||||
};
|
||||
PlateView.Plate.SnapToStandardSize(options);
|
||||
PlateView.ZoomToPlate();
|
||||
PlateView.Refresh();
|
||||
UpdatePlateList();
|
||||
@@ -870,6 +875,18 @@ namespace OpenNest.Forms
|
||||
Import();
|
||||
}
|
||||
|
||||
private void ShapeLibrary_Click(object sender, EventArgs e)
|
||||
{
|
||||
var form = new ShapeLibraryForm(Nest.Drawings.Select(d => d.Name));
|
||||
form.ShowDialog();
|
||||
|
||||
var drawings = form.GetDrawings();
|
||||
if (drawings.Count == 0) return;
|
||||
|
||||
drawings.ForEach(d => Nest.Drawings.Add(d));
|
||||
UpdateDrawingList();
|
||||
}
|
||||
|
||||
private void EditDrawingsInConverter_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (Nest.Drawings.Count == 0)
|
||||
|
||||
+4
-3
@@ -63,7 +63,7 @@
|
||||
this.textBox2 = new System.Windows.Forms.TextBox();
|
||||
this.label5 = new System.Windows.Forms.Label();
|
||||
this.labelMaterial = new System.Windows.Forms.Label();
|
||||
this.materialBox = new System.Windows.Forms.TextBox();
|
||||
this.materialBox = new System.Windows.Forms.ComboBox();
|
||||
this.tabPage2 = new System.Windows.Forms.TabPage();
|
||||
this.tabPage3 = new System.Windows.Forms.TabPage();
|
||||
this.notesBox = new System.Windows.Forms.TextBox();
|
||||
@@ -516,9 +516,10 @@
|
||||
// materialBox
|
||||
//
|
||||
this.materialBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.materialBox.FormattingEnabled = true;
|
||||
this.materialBox.Location = new System.Drawing.Point(135, 159);
|
||||
this.materialBox.Name = "materialBox";
|
||||
this.materialBox.Size = new System.Drawing.Size(224, 22);
|
||||
this.materialBox.Size = new System.Drawing.Size(224, 24);
|
||||
this.materialBox.TabIndex = 11;
|
||||
//
|
||||
// label3
|
||||
@@ -729,6 +730,6 @@
|
||||
private System.Windows.Forms.RadioButton radioButton2;
|
||||
private System.Windows.Forms.Label label5;
|
||||
private System.Windows.Forms.Label labelMaterial;
|
||||
private System.Windows.Forms.TextBox materialBox;
|
||||
private System.Windows.Forms.ComboBox materialBox;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace OpenNest.Forms
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
foreach (var name in PostProcessorMaterials.Names)
|
||||
materialBox.Items.Add(name);
|
||||
|
||||
timer = new Timer
|
||||
{
|
||||
SynchronizingObject = this,
|
||||
|
||||
@@ -71,6 +71,7 @@ namespace OpenNest.Forms
|
||||
NestEngineRegistry.LoadPlugins(enginesDir);
|
||||
|
||||
OptionsForm.ApplyDisabledStrategies();
|
||||
ColorSchemeRegistry.ApplyActiveFromSettings();
|
||||
|
||||
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
||||
engineComboBox.Items.Add(engine.Name);
|
||||
@@ -351,6 +352,9 @@ namespace OpenNest.Forms
|
||||
postProcessorMenuItem.Tag = postProcessor;
|
||||
postProcessorMenuItem.Click += PostProcessor_Click;
|
||||
mnuNestPost.DropDownItems.Add(postProcessorMenuItem);
|
||||
|
||||
if (postProcessor is IMaterialProvidingPostProcessor materialProvider)
|
||||
PostProcessorMaterials.AddFrom(materialProvider);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -833,7 +837,7 @@ namespace OpenNest.Forms
|
||||
{
|
||||
if (activeForm == null) return;
|
||||
|
||||
var form = new ShapeLibraryForm();
|
||||
var form = new ShapeLibraryForm(activeForm.Nest.Drawings.Select(d => d.Name));
|
||||
form.ShowDialog();
|
||||
|
||||
var drawings = form.GetDrawings();
|
||||
@@ -1157,6 +1161,9 @@ namespace OpenNest.Forms
|
||||
if (postProcessor == null)
|
||||
return;
|
||||
|
||||
if (postProcessor is IPostProcessorNestAware nestAware)
|
||||
nestAware.PrepareForNest(activeForm.Nest);
|
||||
|
||||
if (postProcessor is IConfigurablePostProcessor configurable)
|
||||
{
|
||||
using var configForm = new PostProcessorConfigForm(configurable);
|
||||
|
||||
Generated
+31
-5
@@ -42,6 +42,8 @@
|
||||
this.bottomPanel1 = new OpenNest.Controls.BottomPanel();
|
||||
this.strategyGrid = new System.Windows.Forms.DataGridView();
|
||||
this.strategyGroupBox = new System.Windows.Forms.GroupBox();
|
||||
this.colorSchemeLabel = new System.Windows.Forms.Label();
|
||||
this.colorSchemeCombo = new System.Windows.Forms.ComboBox();
|
||||
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
|
||||
this.tableLayoutPanel1.SuspendLayout();
|
||||
this.bottomPanel1.SuspendLayout();
|
||||
@@ -95,15 +97,18 @@
|
||||
this.tableLayoutPanel1.Controls.Add(this.label1, 0, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.textBox1, 1, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.label3, 0, 0);
|
||||
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.colorSchemeLabel, 0, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.colorSchemeCombo, 1, 2);
|
||||
this.tableLayoutPanel1.Controls.Add(this.checkBox1, 0, 3);
|
||||
this.tableLayoutPanel1.Controls.Add(this.numericUpDown1, 1, 1);
|
||||
this.tableLayoutPanel1.Controls.Add(this.button1, 3, 0);
|
||||
this.tableLayoutPanel1.Location = new System.Drawing.Point(12, 12);
|
||||
this.tableLayoutPanel1.Name = "tableLayoutPanel1";
|
||||
this.tableLayoutPanel1.RowCount = 3;
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F));
|
||||
this.tableLayoutPanel1.RowCount = 4;
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 25F));
|
||||
this.tableLayoutPanel1.Size = new System.Drawing.Size(684, 160);
|
||||
this.tableLayoutPanel1.TabIndex = 0;
|
||||
//
|
||||
@@ -198,6 +203,25 @@
|
||||
this.strategyGroupBox.TabStop = false;
|
||||
this.strategyGroupBox.Text = "Fill Strategies";
|
||||
//
|
||||
// colorSchemeLabel
|
||||
//
|
||||
this.colorSchemeLabel.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.colorSchemeLabel.AutoSize = true;
|
||||
this.colorSchemeLabel.Location = new System.Drawing.Point(3, 92);
|
||||
this.colorSchemeLabel.Name = "colorSchemeLabel";
|
||||
this.colorSchemeLabel.Size = new System.Drawing.Size(145, 16);
|
||||
this.colorSchemeLabel.TabIndex = 10;
|
||||
this.colorSchemeLabel.Text = "Color scheme:";
|
||||
//
|
||||
// colorSchemeCombo
|
||||
//
|
||||
this.colorSchemeCombo.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.colorSchemeCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.colorSchemeCombo.Location = new System.Drawing.Point(154, 89);
|
||||
this.colorSchemeCombo.Name = "colorSchemeCombo";
|
||||
this.colorSchemeCombo.Size = new System.Drawing.Size(130, 24);
|
||||
this.colorSchemeCombo.TabIndex = 11;
|
||||
//
|
||||
// OptionsForm
|
||||
//
|
||||
this.AcceptButton = this.saveButton;
|
||||
@@ -239,5 +263,7 @@
|
||||
private System.Windows.Forms.Button button1;
|
||||
private System.Windows.Forms.DataGridView strategyGrid;
|
||||
private System.Windows.Forms.GroupBox strategyGroupBox;
|
||||
private System.Windows.Forms.Label colorSchemeLabel;
|
||||
private System.Windows.Forms.ComboBox colorSchemeCombo;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user