Compare commits

14 Commits

Author SHA1 Message Date
aj a6c2235647 fix: let DrawRapids track actual tool position through sub-programs
Don't restore pos after SubProgramCall expansion in DrawRapids — the
machine moves from hole to hole sequentially, so rapids should connect
from the previous hole's end to the next hole's center.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:26:39 -04:00
aj 5c918a0978 fix: draw rapid move to hole center before sub-program lead-in
The rapid from the previous feature to the hole center is implied by
the SubProgramCall offset but wasn't being drawn. Now DrawRapids
renders this traverse before recursing into the sub-program.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:46:44 -04:00
aj 92461deb98 fix: apply SubProgramCall offset additively and restore curpos after expansion
ConvertMode.ToIncremental skips SubProgramCalls when computing deltas,
so all code paths that expand SubProgramCalls must: (1) set curpos to
savedPos + Offset before expanding, and (2) restore curpos afterward
so subsequent incremental codes get correct deltas.

Fixed in ConvertProgram, GraphicsHelper (AddProgram, AddProgramSplit),
PlateRenderer (DrawRapids, DrawProgramPiercePoints, GetFirstPiercePoint),
and CutDirectionArrows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:40:05 -04:00
aj bc859aa28c feat: handle SubProgramCall offsets in BoundingBox and Rotate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:47:40 -04:00
aj 09eac96a03 feat: handle SubProgramCalls in Cincinnati post feature splitting
SubProgramCalls are now treated as standalone features in the Cincinnati
post-processor. SplitByRapids emits them as single-element features
instead of splitting on rapids within sub-programs. A nest-level hole
sub-program registry deduplicates by content and assigns post numbers.
Sheet writers emit M98 calls with X/Y offsets for hole features, and
hole sub-program definitions are written after part sub-programs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:44:58 -04:00
aj df65414a9d feat: serialize and deserialize hole sub-programs in nest file format
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:40:13 -04:00
aj 4aed231611 feat: emit SubProgramCalls for circle holes in ContourCuttingStrategy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:35:56 -04:00
aj c641b3b68e feat: expand SubProgramCalls with Offset in ConvertProgram
Inline sub-program geometry into the parent geometry list using Offset
as the starting curpos, replacing the Shape-wrapping approach.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:31:13 -04:00
aj f3b27c32c3 feat: add SubPrograms dictionary to Program with deep-copy support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:28:37 -04:00
aj c270d8ea76 feat: add Offset property to SubProgramCall for hole positioning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:26:55 -04:00
aj de6877ac48 feat: add option to round lead-in angles for circle holes
Snaps lead-in angles on ArcCircle contours to a configurable
increment (default 5°), reducing unique hole variations from
infinite to 72 max. Rounding happens upstream in EmitContour
so the PlateView and post output stay in sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:41:33 -04:00
aj 3481764416 perf: use perimeter-only drawing in best fit pair evaluation
PairEvaluator was cloning the full CNC program (including all internal
cutouts) for every candidate. For parts with many holes (e.g. 952),
this caused O(n²) overlap checks and thousands of unnecessary polygon
tessellations per candidate.

Now extracts the perimeter shape once, builds a lightweight drawing
from it, and uses that for all Part.CreateAtOrigin calls. Cutouts are
irrelevant for best fit — only the outer boundary matters for pairing.

75x speedup on a 952-hole rectangle (30s → 0.4s).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:14:02 -04:00
aj 640814fdf6 fix: marshal timer callbacks to UI thread to prevent GDI+ threading exception
System.Timers.Timer fires on thread pool threads, causing GraphicsPath
objects to be accessed concurrently by hover detection and OnPaint,
triggering "Object is currently in use elsewhere" in DrawParts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:41:18 -04:00
aj 6a30828fad feat: optimize external lead-in placement using next-part pierce points
External lead-ins now sit on the line between the last internal cutout
and the next part's first pierce point, minimizing rapid travel. Cutout
sequencing starts from the bounding box corner opposite the origin and
iterates 3 times to converge the perimeter lead-in and internal sequence.
LeadInAssigner and PlateProcessor both use a two-pass approach: first
pass collects pierce points, second pass refines with next-part knowledge.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:33:55 -04:00
25 changed files with 1072 additions and 91 deletions
@@ -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,14 +26,43 @@ 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);
@@ -36,9 +71,6 @@ namespace OpenNest.CNC.CuttingStrategy
foreach (var entry in cutoutEntries)
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);
result.Mode = Mode.Incremental;
@@ -187,6 +219,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 +263,62 @@ 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);
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size);
subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint));
subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding));
subPgm.Mode = Mode.Incremental;
// Deduplicate: check if an identical sub-program already exists
var key = ComputeSubProgramKey(circle.Radius, normal);
if (!program.SubPrograms.ContainsKey(key))
program.SubPrograms[key] = subPgm;
program.Codes.Add(new SubProgramCall
{
Id = key,
Program = program.SubPrograms[key],
Offset = holeCenter
});
return;
}
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
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);
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; }
+20 -1
View File
@@ -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);
}
@@ -420,7 +433,10 @@ namespace OpenNest.CNC
case CodeType.SubProgramCall:
{
var subpgm = (SubProgramCall)code;
var box = subpgm.Program.BoundingBox(ref pos);
var subPos = subpgm.Offset.X != 0 || subpgm.Offset.Y != 0
? new Vector(subpgm.Offset.X, subpgm.Offset.Y)
: pos;
var box = subpgm.Program.BoundingBox(ref subPos);
if (box.Left < minX)
minX = box.Left;
@@ -460,6 +476,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;
}
+11 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Math;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.CNC
{
@@ -35,6 +36,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,11 +85,13 @@ 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()
{
if (Offset.X != 0 || Offset.Y != 0)
return string.Format("G65 P{0} X{1} Y{2}", Id, Offset.X, Offset.Y);
return string.Format("G65 P{0} R{1}", Id, Rotation);
}
}
+15 -5
View File
@@ -41,12 +41,22 @@ 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;
var savedPos = curpos;
// Position the sub-program at savedPos + Offset.
// savedPos is the base position ((0,0) here, Part.Location in rendering).
// Offset is the hole center in drawing-local coordinates.
curpos = new Vector(savedPos.X + subpgm.Offset.X, savedPos.Y + subpgm.Offset.Y);
AddProgram(subpgm.Program, ref mode, ref curpos, ref geometry);
mode = savedMode;
// Restore curpos: ConvertMode.ToIncremental skips SubProgramCalls
// when computing deltas, so subsequent incremental codes expect
// curpos to be where it was before the call.
curpos = savedPos;
break;
}
}
+6 -1
View File
@@ -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;
+35 -33
View File
@@ -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;
+43 -4
View File
@@ -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;
}
}
}
+40 -10
View File
@@ -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);
}
+58
View File
@@ -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>)>();
+29 -3
View File
@@ -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}";
}
}
+16 -1
View File
@@ -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,5 +1,6 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
@@ -136,4 +137,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();
}
}
@@ -89,9 +89,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;
@@ -135,6 +141,23 @@ namespace OpenNest.Posts.Cincinnati
}
}
// Hole sub-programs (SubProgramCall definitions)
if (holeEntries.Count > 0)
{
var holeSubWriter = new CincinnatiPartSubprogramWriter(Config);
var sheetDiagonal = firstPlate != null
? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width
+ firstPlate.Size.Length * firstPlate.Size.Length)
: 100.0;
foreach (var (subNum, pgm) in holeEntries)
{
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
holeSubWriter.Write(writer, pgm, "HOLE", subNum,
initialCutLibrary, etchLibrary, sheetDiagonal);
}
}
writer.Flush();
}
@@ -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,25 @@ 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;
var sb = new StringBuilder();
if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"M98 P{postSubNum} X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}");
w.WriteLine(sb.ToString());
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 +260,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;
+10 -1
View File
@@ -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,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,319 @@
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 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);
}
}
@@ -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;
}
}
+5
View File
@@ -18,7 +18,12 @@ namespace OpenNest.Controls
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
{
var savedPos = pos;
pos = new Vector(savedPos.X + subpgm.Offset.X, savedPos.Y + subpgm.Offset.Y);
DrawProgram(g, view, subpgm.Program, ref pos, pen, spacing, arrowSize);
pos = savedPos;
}
continue;
}
+37 -1
View File
@@ -28,6 +28,9 @@ namespace OpenNest.Controls
private readonly NumericUpDown nudAutoTabMax;
private readonly NumericUpDown nudPierceClearance;
private readonly CheckBox chkRoundLeadInAngles;
private readonly NumericUpDown nudLeadInAngleIncrement;
private readonly Button btnAutoAssign;
private bool suppressEvents;
@@ -162,7 +165,7 @@ namespace OpenNest.Controls
{
HeaderText = "Pierce",
Dock = DockStyle.Top,
ExpandedHeight = 60,
ExpandedHeight = 90,
IsExpanded = true
};
@@ -176,6 +179,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
{
@@ -218,6 +249,8 @@ namespace OpenNest.Controls
TabsEnabled = chkTabsEnabled.Checked,
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
PierceClearance = (double)nudPierceClearance.Value,
RoundLeadInAngles = chkRoundLeadInAngles.Checked,
LeadInAngleIncrement = (double)nudLeadInAngleIncrement.Value,
AutoTabMinSize = (double)nudAutoTabMin.Value,
AutoTabMaxSize = (double)nudAutoTabMax.Value
};
@@ -238,6 +271,9 @@ namespace OpenNest.Controls
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;
+21
View File
@@ -404,6 +404,9 @@ namespace OpenNest.Controls
{
for (var i = 0; i < pgm.Length; i++)
{
if (pgm[i] is SubProgramCall call && call.Program != null)
return GetFirstPiercePoint(call.Program, partLocation + call.Offset);
if (pgm[i] is Motion motion)
{
if (pgm.Mode == Mode.Incremental)
@@ -428,7 +431,20 @@ namespace OpenNest.Controls
var program = subpgm.Program;
if (program != null)
{
var holePos = new Vector(pos.X + subpgm.Offset.X, pos.Y + subpgm.Offset.Y);
// Draw rapid from current position to hole center
if (!(skipFirstRapid && !firstRapidSkipped))
DrawLine(g, pos, holePos, view.ColorScheme.RapidPen);
else
firstRapidSkipped = true;
pos = holePos;
DrawRapids(g, program, ref pos);
// Don't restore pos — let it advance so the next hole's
// rapid starts from where this one ended.
}
}
else
{
@@ -489,7 +505,12 @@ namespace OpenNest.Controls
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
{
var savedPos = pos;
pos = new Vector(savedPos.X + subpgm.Offset.X, savedPos.Y + subpgm.Offset.Y);
DrawProgramPiercePoints(g, subpgm.Program, ref pos, brush, pen);
pos = savedPos;
}
}
else
{
+14 -14
View File
@@ -621,30 +621,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;
@@ -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; }
}
+6
View File
@@ -147,7 +147,10 @@ namespace OpenNest
{
cutPath.StartFigure();
leadPath.StartFigure();
var savedPos = curpos;
curpos = new Vector(savedPos.X + subpgm.Offset.X, savedPos.Y + subpgm.Offset.Y);
AddProgramSplit(cutPath, leadPath, subpgm.Program, mode, ref curpos);
curpos = savedPos;
}
mode = tmpmode;
break;
@@ -305,7 +308,10 @@ namespace OpenNest
if (subpgm.Program != null)
{
var savedPos = curpos;
curpos = new Vector(savedPos.X + subpgm.Offset.X, savedPos.Y + subpgm.Offset.Y);
AddProgram(path, subpgm.Program, mode, ref curpos);
curpos = savedPos;
}
mode = tmpmode;