Compare commits
12 Commits
640814fdf6
...
a6c2235647
| Author | SHA1 | Date | |
|---|---|---|---|
| a6c2235647 | |||
| 5c918a0978 | |||
| 92461deb98 | |||
| bc859aa28c | |||
| 09eac96a03 | |||
| df65414a9d | |||
| 4aed231611 | |||
| c641b3b68e | |||
| f3b27c32c3 | |||
| c270d8ea76 | |||
| de6877ac48 | |||
| 3481764416 |
@@ -1,5 +1,6 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.CNC.CuttingStrategy
|
||||
@@ -245,6 +246,13 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
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);
|
||||
@@ -255,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; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user