diff --git a/.gitignore b/.gitignore index 438b2ff..6a339e1 100644 --- a/.gitignore +++ b/.gitignore @@ -201,3 +201,6 @@ FakesAssemblies/ # Git worktrees .worktrees/ + +# Claude Code +.claude/ diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 75a5258..fe2c97e 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -135,7 +135,7 @@ if (!keepParts) plate.Parts.Clear(); Console.WriteLine($"Nest: {nest.Name}"); -Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}"); +Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); Console.WriteLine($"Drawing: {drawing.Name}"); if (!keepParts) diff --git a/OpenNest.Core/CNC/CuttingStrategy/AssignmentParameters.cs b/OpenNest.Core/CNC/CuttingStrategy/AssignmentParameters.cs new file mode 100644 index 0000000..cfd48ba --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/AssignmentParameters.cs @@ -0,0 +1,9 @@ +namespace OpenNest.CNC.CuttingStrategy +{ + public class AssignmentParameters + { + public SequenceMethod Method { get; set; } = SequenceMethod.Advanced; + public string Preference { get; set; } = "ILAT"; + public double MinGeometryLength { get; set; } = 0.01; + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs new file mode 100644 index 0000000..7ea880b --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class ContourCuttingStrategy + { + public CuttingParameters Parameters { get; set; } + + public Program Apply(Program partProgram, Plate plate) + { + var exitPoint = GetExitPoint(plate); + var entities = partProgram.ToGeometry(); + var profile = new ShapeProfile(entities); + + // Find closest point on perimeter from exit point + var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity); + + // Chain cutouts by nearest-neighbor from perimeter point, then reverse + // so farthest cutouts are cut first, nearest-to-perimeter cut last + var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint); + orderedCutouts.Reverse(); + + // Build output program: cutouts first (farthest to nearest), perimeter last + var result = new Program(); + var currentPoint = exitPoint; + + foreach (var cutout in orderedCutouts) + { + var contourType = DetectContourType(cutout); + var closestPt = cutout.ClosestPointTo(currentPoint, out var entity); + var normal = ComputeNormal(closestPt, entity, contourType); + var winding = DetermineWinding(cutout); + + var leadIn = SelectLeadIn(contourType); + var leadOut = SelectLeadOut(contourType); + + result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding)); + var reindexed = cutout.ReindexAt(closestPt, entity); + result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt)); + // TODO: MicrotabLeadOut — trim last cutting move by GapSize + result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding)); + + currentPoint = closestPt; + } + + // Perimeter last + { + var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); + var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); + var winding = DetermineWinding(profile.Perimeter); + + var leadIn = SelectLeadIn(ContourType.External); + var leadOut = SelectLeadOut(ContourType.External); + + result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding)); + var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity); + result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt)); + // TODO: MicrotabLeadOut — trim last cutting move by GapSize + result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding)); + } + + return result; + } + + private Vector GetExitPoint(Plate plate) + { + var w = plate.Size.Width; + var l = plate.Size.Length; + + return plate.Quadrant switch + { + 1 => new Vector(w, l), // Q1 origin BottomLeft -> exit TopRight + 2 => new Vector(0, l), // Q2 origin BottomRight -> exit TopLeft + 3 => new Vector(0, 0), // Q3 origin TopRight -> exit BottomLeft + 4 => new Vector(w, 0), // Q4 origin TopLeft -> exit BottomRight + _ => new Vector(w, l) + }; + } + + private List SequenceCutouts(List cutouts, Vector startPoint) + { + var remaining = new List(cutouts); + var ordered = new List(); + var currentPoint = startPoint; + + while (remaining.Count > 0) + { + var nearest = remaining[0]; + var nearestPt = nearest.ClosestPointTo(currentPoint); + var nearestDist = nearestPt.DistanceTo(currentPoint); + + for (var i = 1; i < remaining.Count; i++) + { + var pt = remaining[i].ClosestPointTo(currentPoint); + var dist = pt.DistanceTo(currentPoint); + if (dist < nearestDist) + { + nearest = remaining[i]; + nearestPt = pt; + nearestDist = dist; + } + } + + ordered.Add(nearest); + remaining.Remove(nearest); + currentPoint = nearestPt; + } + + return ordered; + } + + private ContourType DetectContourType(Shape cutout) + { + if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle) + return ContourType.ArcCircle; + + return ContourType.Internal; + } + + private double ComputeNormal(Vector point, Entity entity, ContourType contourType) + { + double normal; + + if (entity is Line line) + { + // Perpendicular to line direction + var tangent = line.EndPoint.AngleFrom(line.StartPoint); + normal = tangent + Math.Angle.HalfPI; + } + else if (entity is Arc arc) + { + // Radial direction from center to point + normal = point.AngleFrom(arc.Center); + } + else if (entity is Circle circle) + { + normal = point.AngleFrom(circle.Center); + } + else + { + normal = 0; + } + + // For internal contours, flip the normal (point into scrap) + if (contourType == ContourType.Internal || contourType == ContourType.ArcCircle) + normal += System.Math.PI; + + return Math.Angle.NormalizeRad(normal); + } + + private RotationType DetermineWinding(Shape shape) + { + // Use signed area: positive = CCW, negative = CW + var area = shape.Area(); + return area >= 0 ? RotationType.CCW : RotationType.CW; + } + + private LeadIn SelectLeadIn(ContourType contourType) + { + return contourType switch + { + ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn, + ContourType.Internal => Parameters.InternalLeadIn, + _ => Parameters.ExternalLeadIn + }; + } + + private LeadOut SelectLeadOut(ContourType contourType) + { + return contourType switch + { + ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut, + ContourType.Internal => Parameters.InternalLeadOut, + _ => Parameters.ExternalLeadOut + }; + } + + private List ConvertShapeToMoves(Shape shape, Vector startPoint) + { + var moves = new List(); + + foreach (var entity in shape.Entities) + { + if (entity is Line line) + { + moves.Add(new LinearMove(line.EndPoint)); + } + else if (entity is Arc arc) + { + moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)); + } + else if (entity is Circle circle) + { + moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation)); + } + else + { + throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}"); + } + } + + return moves; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourType.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourType.cs new file mode 100644 index 0000000..9205913 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourType.cs @@ -0,0 +1,9 @@ +namespace OpenNest.CNC.CuttingStrategy +{ + public enum ContourType + { + External, + Internal, + ArcCircle + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs new file mode 100644 index 0000000..3f7323c --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs @@ -0,0 +1,30 @@ +namespace OpenNest.CNC.CuttingStrategy +{ + public class CuttingParameters + { + public int Id { get; set; } + + public string MachineName { get; set; } + public string MaterialName { get; set; } + public string Grade { get; set; } + public double Thickness { get; set; } + + public double Kerf { get; set; } + public double PartSpacing { get; set; } + + public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); + public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); + + public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; + public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); + + public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn(); + public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut(); + + public Tab TabConfig { get; set; } + public bool TabsEnabled { get; set; } + + public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); + public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/ArcLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/ArcLeadIn.cs new file mode 100644 index 0000000..e76c9ce --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/ArcLeadIn.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class ArcLeadIn : LeadIn + { + public double Radius { get; set; } + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var arcCenter = new Vector( + contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle), + contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)); + + return new List + { + new RapidMove(piercePoint), + new ArcMove(contourStartPoint, arcCenter, winding) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle); + + return new Vector( + arcCenterX + Radius * System.Math.Cos(contourNormalAngle), + arcCenterY + Radius * System.Math.Sin(contourNormalAngle)); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs new file mode 100644 index 0000000..d30f0e6 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class CleanHoleLeadIn : LeadIn + { + public double LineLength { get; set; } + public double ArcRadius { get; set; } + public double Kerf { get; set; } + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + var arcCenter = new Vector(arcCenterX, arcCenterY); + + var lineAngle = contourNormalAngle + Angle.ToRadians(135.0); + var arcStart = new Vector( + arcCenterX + ArcRadius * System.Math.Cos(lineAngle), + arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(arcStart), + new ArcMove(contourStartPoint, arcCenter, winding) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + + var lineAngle = contourNormalAngle + Angle.ToRadians(135.0); + var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle); + var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle); + + return new Vector( + arcStartX + LineLength * System.Math.Cos(lineAngle), + arcStartY + LineLength * System.Math.Sin(lineAngle)); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LeadIn.cs new file mode 100644 index 0000000..83be504 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LeadIn.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public abstract class LeadIn + { + public abstract List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + + public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs new file mode 100644 index 0000000..a816593 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineArcLeadIn : LeadIn + { + public double LineLength { get; set; } + public double ApproachAngle { get; set; } = 135.0; + public double ArcRadius { get; set; } + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + var arcCenter = new Vector(arcCenterX, arcCenterY); + + var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + var arcStart = new Vector( + arcCenterX + ArcRadius * System.Math.Cos(lineAngle), + arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(arcStart), + new ArcMove(contourStartPoint, arcCenter, winding) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + + var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle); + var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle); + + return new Vector( + arcStartX + LineLength * System.Math.Cos(lineAngle), + arcStartY + LineLength * System.Math.Sin(lineAngle)); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLeadIn.cs new file mode 100644 index 0000000..e7922cf --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLeadIn.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineLeadIn : LeadIn + { + public double Length { get; set; } + public double ApproachAngle { get; set; } = 90.0; + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(contourStartPoint) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var approachAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + return new Vector( + contourStartPoint.X + Length * System.Math.Cos(approachAngle), + contourStartPoint.Y + Length * System.Math.Sin(approachAngle)); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLineLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLineLeadIn.cs new file mode 100644 index 0000000..f5700cb --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLineLeadIn.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineLineLeadIn : LeadIn + { + public double Length1 { get; set; } + public double ApproachAngle1 { get; set; } = 90.0; + public double Length2 { get; set; } + public double ApproachAngle2 { get; set; } = 90.0; + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1); + var midPoint = new Vector( + contourStartPoint.X + Length2 * System.Math.Cos(secondAngle), + contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(midPoint), + new LinearMove(contourStartPoint) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1); + var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle); + var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle); + + var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2); + return new Vector( + midX + Length1 * System.Math.Cos(firstAngle), + midY + Length1 * System.Math.Sin(firstAngle)); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadIns/NoLeadIn.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/NoLeadIn.cs new file mode 100644 index 0000000..7100487 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadIns/NoLeadIn.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class NoLeadIn : LeadIn + { + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List + { + new RapidMove(contourStartPoint) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + return contourStartPoint; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/ArcLeadOut.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/ArcLeadOut.cs new file mode 100644 index 0000000..95d8724 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/ArcLeadOut.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class ArcLeadOut : LeadOut + { + public double Radius { get; set; } + + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle); + var arcCenter = new Vector(arcCenterX, arcCenterY); + + var endPoint = new Vector( + arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2), + arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)); + + return new List + { + new ArcMove(endPoint, arcCenter, winding) + }; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LeadOut.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LeadOut.cs new file mode 100644 index 0000000..6915c5f --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LeadOut.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public abstract class LeadOut + { + public abstract List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LineLeadOut.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LineLeadOut.cs new file mode 100644 index 0000000..c72847b --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LineLeadOut.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineLeadOut : LeadOut + { + public double Length { get; set; } + public double ApproachAngle { get; set; } = 90.0; + + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var overcutAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + var endPoint = new Vector( + contourEndPoint.X + Length * System.Math.Cos(overcutAngle), + contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)); + + return new List + { + new LinearMove(endPoint) + }; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/MicrotabLeadOut.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/MicrotabLeadOut.cs new file mode 100644 index 0000000..13dc799 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/MicrotabLeadOut.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class MicrotabLeadOut : LeadOut + { + public double GapSize { get; set; } = 0.03; + + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List(); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/NoLeadOut.cs b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/NoLeadOut.cs new file mode 100644 index 0000000..8a45cc8 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/LeadOuts/NoLeadOut.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class NoLeadOut : LeadOut + { + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List(); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/SequenceParameters.cs b/OpenNest.Core/CNC/CuttingStrategy/SequenceParameters.cs new file mode 100644 index 0000000..910d333 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/SequenceParameters.cs @@ -0,0 +1,27 @@ +namespace OpenNest.CNC.CuttingStrategy +{ + // Values match PEP Technology's numbering scheme (value 6 intentionally skipped) + public enum SequenceMethod + { + RightSide = 1, + LeastCode = 2, + Advanced = 3, + BottomSide = 4, + EdgeStart = 5, + LeftSide = 7, + RightSideAlt = 8 + } + + public class SequenceParameters + { + public SequenceMethod Method { get; set; } = SequenceMethod.Advanced; + public double SmallCutoutWidth { get; set; } = 1.5; + public double SmallCutoutHeight { get; set; } = 1.5; + public double MediumCutoutWidth { get; set; } = 8.0; + public double MediumCutoutHeight { get; set; } = 8.0; + public double DistanceMediumSmall { get; set; } + public bool AlternateRowsColumns { get; set; } = true; + public bool AlternateCutoutsWithinRowColumn { get; set; } = true; + public double MinDistanceBetweenRowsColumns { get; set; } = 0.25; + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/Tabs/BreakerTab.cs b/OpenNest.Core/CNC/CuttingStrategy/Tabs/BreakerTab.cs new file mode 100644 index 0000000..3921491 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/Tabs/BreakerTab.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class BreakerTab : Tab + { + public double BreakerDepth { get; set; } + public double BreakerLeadInLength { get; set; } + public double BreakerAngle { get; set; } + + public override List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var codes = new List(); + + if (TabLeadOut != null) + codes.AddRange(TabLeadOut.Generate(tabStartPoint, contourNormalAngle, winding)); + + var scoreAngle = contourNormalAngle + System.Math.PI; + var scoreEnd = new Vector( + tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle), + tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)); + codes.Add(new LinearMove(scoreEnd)); + codes.Add(new RapidMove(tabEndPoint)); + + if (TabLeadIn != null) + codes.AddRange(TabLeadIn.Generate(tabEndPoint, contourNormalAngle, winding)); + + return codes; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/Tabs/MachineTab.cs b/OpenNest.Core/CNC/CuttingStrategy/Tabs/MachineTab.cs new file mode 100644 index 0000000..b6e5fbd --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/Tabs/MachineTab.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class MachineTab : Tab + { + public int MachineTabId { get; set; } + + public override List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List + { + new RapidMove(tabEndPoint) + }; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/Tabs/NormalTab.cs b/OpenNest.Core/CNC/CuttingStrategy/Tabs/NormalTab.cs new file mode 100644 index 0000000..de3e16c --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/Tabs/NormalTab.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class NormalTab : Tab + { + public double CutoutMinWidth { get; set; } + public double CutoutMinHeight { get; set; } + public double CutoutMaxWidth { get; set; } + public double CutoutMaxHeight { get; set; } + + public override List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var codes = new List(); + + if (TabLeadOut != null) + codes.AddRange(TabLeadOut.Generate(tabStartPoint, contourNormalAngle, winding)); + + codes.Add(new RapidMove(tabEndPoint)); + + if (TabLeadIn != null) + codes.AddRange(TabLeadIn.Generate(tabEndPoint, contourNormalAngle, winding)); + + return codes; + } + + public bool AppliesToCutout(double cutoutWidth, double cutoutHeight) + { + return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth + && cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight; + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/Tabs/Tab.cs b/OpenNest.Core/CNC/CuttingStrategy/Tabs/Tab.cs new file mode 100644 index 0000000..504eec5 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/Tabs/Tab.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public abstract class Tab + { + public double Size { get; set; } = 0.03; + public LeadIn TabLeadIn { get; set; } + public LeadOut TabLeadOut { get; set; } + + public abstract List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + } +} diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index 5d7393d..d48792f 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -155,6 +155,28 @@ namespace OpenNest.Geometry Center.Y + Radius * System.Math.Sin(EndAngle)); } + /// + /// Splits the arc at the given point, returning two sub-arcs. + /// Either half may be null if the split point coincides with an endpoint. + /// + /// The point at which to split the arc. + /// A tuple of (first, second) sub-arcs. + public (Arc first, Arc second) SplitAt(Vector point) + { + if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon) + return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed)); + + if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon) + return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null); + + var splitAngle = Angle.NormalizeRad(Center.AngleTo(point)); + + var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed); + var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed); + + return (firstArc, secondArc); + } + /// /// Returns true if the given arc has the same center point and radius as this. /// @@ -388,7 +410,7 @@ namespace OpenNest.Geometry boundingBox.X = minX; boundingBox.Y = minY; boundingBox.Width = maxX - minX; - boundingBox.Height = maxY - minY; + boundingBox.Length = maxY - minY; } public override Entity OffsetEntity(double distance, OffsetSide side) diff --git a/OpenNest.Core/Geometry/BoundingBox.cs b/OpenNest.Core/Geometry/BoundingBox.cs index de057c3..bd6ab20 100644 --- a/OpenNest.Core/Geometry/BoundingBox.cs +++ b/OpenNest.Core/Geometry/BoundingBox.cs @@ -13,7 +13,7 @@ namespace OpenNest.Geometry double minX = boxes[0].X; double minY = boxes[0].Y; double maxX = boxes[0].X + boxes[0].Width; - double maxY = boxes[0].Y + boxes[0].Height; + double maxY = boxes[0].Y + boxes[0].Length; foreach (var box in boxes) { diff --git a/OpenNest.Core/Geometry/Box.cs b/OpenNest.Core/Geometry/Box.cs index e7a9755..6d4bcdc 100644 --- a/OpenNest.Core/Geometry/Box.cs +++ b/OpenNest.Core/Geometry/Box.cs @@ -15,14 +15,14 @@ namespace OpenNest.Geometry { Location = new Vector(x, y); Width = w; - Height = h; + Length = h; } public Vector Location; public Vector Center { - get { return new Vector(X + Width * 0.5, Y + Height * 0.5); } + get { return new Vector(X + Width * 0.5, Y + Length * 0.5); } } public Size Size; @@ -45,10 +45,10 @@ namespace OpenNest.Geometry set { Size.Width = value; } } - public double Height + public double Length { - get { return Size.Height; } - set { Size.Height = value; } + get { return Size.Length; } + set { Size.Length = value; } } public void MoveTo(double x, double y) @@ -86,7 +86,7 @@ namespace OpenNest.Geometry public double Top { - get { return Y + Height; } + get { return Y + Length; } } public double Bottom @@ -96,12 +96,12 @@ namespace OpenNest.Geometry public double Area() { - return Width * Height; + return Width * Length; } public double Perimeter() { - return Width * 2 + Height * 2; + return Width * 2 + Length * 2; } public bool Intersects(Box box) @@ -197,12 +197,12 @@ namespace OpenNest.Geometry public Box Offset(double d) { - return new Box(X - d, Y - d, Width + d * 2, Height + d * 2); + return new Box(X - d, Y - d, Width + d * 2, Length + d * 2); } public override string ToString() { - return string.Format("[Box: X={0}, Y={1}, Width={2}, Height={3}]", X, Y, Width, Height); + return string.Format("[Box: X={0}, Y={1}, Width={2}, Length={3}]", X, Y, Width, Length); } } } diff --git a/OpenNest.Core/Geometry/BoxSplitter.cs b/OpenNest.Core/Geometry/BoxSplitter.cs index bc09e80..938e31b 100644 --- a/OpenNest.Core/Geometry/BoxSplitter.cs +++ b/OpenNest.Core/Geometry/BoxSplitter.cs @@ -23,7 +23,7 @@ var x = large.Left; var y = large.Bottom; var w = small.Left - x; - var h = large.Height; + var h = large.Length; return new Box(x, y, w, h); } @@ -49,7 +49,7 @@ var x = small.Right; var y = large.Bottom; var w = large.Right - x; - var h = large.Height; + var h = large.Length; return new Box(x, y, w, h); } diff --git a/OpenNest.Core/Geometry/Circle.cs b/OpenNest.Core/Geometry/Circle.cs index 49f9979..859d51a 100644 --- a/OpenNest.Core/Geometry/Circle.cs +++ b/OpenNest.Core/Geometry/Circle.cs @@ -263,7 +263,7 @@ namespace OpenNest.Geometry boundingBox.X = Center.X - Radius; boundingBox.Y = Center.Y - Radius; boundingBox.Width = Diameter; - boundingBox.Height = Diameter; + boundingBox.Length = Diameter; } public override Entity OffsetEntity(double distance, OffsetSide side) diff --git a/OpenNest.Core/Geometry/Line.cs b/OpenNest.Core/Geometry/Line.cs index 5ee18ff..0145f9e 100644 --- a/OpenNest.Core/Geometry/Line.cs +++ b/OpenNest.Core/Geometry/Line.cs @@ -381,12 +381,12 @@ namespace OpenNest.Geometry if (StartPoint.Y < EndPoint.Y) { boundingBox.Y = StartPoint.Y; - boundingBox.Height = EndPoint.Y - StartPoint.Y; + boundingBox.Length = EndPoint.Y - StartPoint.Y; } else { boundingBox.Y = EndPoint.Y; - boundingBox.Height = StartPoint.Y - EndPoint.Y; + boundingBox.Length = StartPoint.Y - EndPoint.Y; } } @@ -414,6 +414,25 @@ namespace OpenNest.Geometry return OffsetEntity(distance, side); } + /// + /// Splits the line at the given point, returning two sub-lines. + /// Either half may be null if the split point coincides with an endpoint. + /// + /// The point at which to split the line. + /// A tuple of (first, second) sub-lines. + public (Line first, Line second) SplitAt(Vector point) + { + var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon + ? null + : new Line(StartPoint, point); + + var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon + ? null + : new Line(point, EndPoint); + + return (first, second); + } + /// /// Gets the closest point on the line to the given point. /// diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index 8aa8ac2..9efd3d3 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -312,7 +312,7 @@ namespace OpenNest.Geometry boundingBox.X = minX; boundingBox.Y = minY; boundingBox.Width = maxX - minX; - boundingBox.Height = maxY - minY; + boundingBox.Length = maxY - minY; } public override Entity OffsetEntity(double distance, OffsetSide side) diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index 0654626..d439f7a 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -201,6 +201,68 @@ namespace OpenNest.Geometry return closestPt; } + /// + /// Returns a new shape with entities reordered so that the given point on + /// the given entity becomes the new start point of the contour. + /// + /// The point on the entity to reindex at. + /// The entity containing the point. + /// A new reindexed shape. + public Shape ReindexAt(Vector point, Entity entity) + { + // Circle case: return a new shape with just the circle + if (entity is Circle) + { + var result = new Shape(); + result.Entities.Add(entity); + return result; + } + + var i = Entities.IndexOf(entity); + if (i < 0) + throw new ArgumentException("Entity not found in shape", nameof(entity)); + + // Split the entity at the point + Entity firstHalf = null; + Entity secondHalf = null; + + if (entity is Line line) + { + var (f, s) = line.SplitAt(point); + firstHalf = f; + secondHalf = s; + } + else if (entity is Arc arc) + { + var (f, s) = arc.SplitAt(point); + firstHalf = f; + secondHalf = s; + } + + // Build reindexed entity list + var entities = new List(); + + // secondHalf of split entity (if not null) + if (secondHalf != null) + entities.Add(secondHalf); + + // Entities after the split index (wrapping) + for (var j = i + 1; j < Entities.Count; j++) + entities.Add(Entities[j]); + + // Entities before the split index (wrapping) + for (var j = 0; j < i; j++) + entities.Add(Entities[j]); + + // firstHalf of split entity (if not null) + if (firstHalf != null) + entities.Add(firstHalf); + + var reindexed = new Shape(); + reindexed.Entities.AddRange(entities); + return reindexed; + } + /// /// Converts the shape to a polygon. /// @@ -399,7 +461,7 @@ namespace OpenNest.Geometry public override Entity OffsetEntity(double distance, OffsetSide side) { var offsetShape = new Shape(); - var definedShape = new DefinedShape(this); + var definedShape = new ShapeProfile(this); Entity lastEntity = null; Entity lastOffsetEntity = null; diff --git a/OpenNest.Core/Geometry/DefinedShape.cs b/OpenNest.Core/Geometry/ShapeProfile.cs similarity index 87% rename from OpenNest.Core/Geometry/DefinedShape.cs rename to OpenNest.Core/Geometry/ShapeProfile.cs index 369f651..4889df3 100644 --- a/OpenNest.Core/Geometry/DefinedShape.cs +++ b/OpenNest.Core/Geometry/ShapeProfile.cs @@ -2,14 +2,14 @@ namespace OpenNest.Geometry { - public class DefinedShape + public class ShapeProfile { - public DefinedShape(Shape shape) + public ShapeProfile(Shape shape) { Update(shape.Entities); } - public DefinedShape(List entities) + public ShapeProfile(List entities) { Update(entities); } diff --git a/OpenNest.Core/Geometry/Size.cs b/OpenNest.Core/Geometry/Size.cs index 8d96063..ae77887 100644 --- a/OpenNest.Core/Geometry/Size.cs +++ b/OpenNest.Core/Geometry/Size.cs @@ -1,16 +1,16 @@ -using System; +using System; namespace OpenNest.Geometry { public struct Size { - public Size(double width, double height) + public Size(double width, double length) { - Height = height; + Length = length; Width = width; } - public double Height; + public double Length; public double Width; @@ -21,10 +21,10 @@ namespace OpenNest.Geometry if (a.Length > 2) throw new FormatException("Invalid size format."); - var height = double.Parse(a[0]); + var length = double.Parse(a[0]); var width = double.Parse(a[1]); - return new Size(width, height); + return new Size(width, length); } public static bool TryParse(string s, out Size size) @@ -44,12 +44,12 @@ namespace OpenNest.Geometry public override string ToString() { - return string.Format("{0} x {1}", Height, Width); + return string.Format("{0} x {1}", Length, Width); } public string ToString(int decimalPlaces) { - return string.Format("{0} x {1}", System.Math.Round(Height, decimalPlaces), System.Math.Round(Width, decimalPlaces)); + return string.Format("{0} x {1}", System.Math.Round(Length, decimalPlaces), System.Math.Round(Width, decimalPlaces)); } } } diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 09c3fe6..f190ce0 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -220,7 +220,7 @@ namespace OpenNest var part = new Part(BaseDrawing, clonedProgram, location + offset, new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y, - BoundingBox.Width, BoundingBox.Height)); + BoundingBox.Width, BoundingBox.Length)); return part; } diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 2614922..4bc49b7 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -117,7 +117,7 @@ namespace OpenNest { const double oneAndHalfPI = System.Math.PI * 1.5; - Size = new Size(Size.Height, Size.Width); + Size = new Size(Size.Length, Size.Width); if (rotationDirection == RotationType.CW) { @@ -128,7 +128,7 @@ namespace OpenNest switch (Quadrant) { case 1: - Offset(0, Size.Height); + Offset(0, Size.Length); break; case 2: @@ -136,7 +136,7 @@ namespace OpenNest break; case 3: - Offset(0, -Size.Height); + Offset(0, -Size.Length); break; case 4: @@ -165,7 +165,7 @@ namespace OpenNest break; case 2: - Offset(0, Size.Height); + Offset(0, Size.Length); break; case 3: @@ -173,7 +173,7 @@ namespace OpenNest break; case 4: - Offset(0, -Size.Height); + Offset(0, -Size.Length); break; default: @@ -200,19 +200,19 @@ namespace OpenNest switch (Quadrant) { case 1: - centerpt = new Vector(Size.Width * 0.5, Size.Height * 0.5); + centerpt = new Vector(Size.Width * 0.5, Size.Length * 0.5); break; case 2: - centerpt = new Vector(-Size.Width * 0.5, Size.Height * 0.5); + centerpt = new Vector(-Size.Width * 0.5, Size.Length * 0.5); break; case 3: - centerpt = new Vector(-Size.Width * 0.5, -Size.Height * 0.5); + centerpt = new Vector(-Size.Width * 0.5, -Size.Length * 0.5); break; case 4: - centerpt = new Vector(Size.Width * 0.5, -Size.Height * 0.5); + centerpt = new Vector(Size.Width * 0.5, -Size.Length * 0.5); break; default: @@ -308,12 +308,12 @@ namespace OpenNest case 3: plateBox.X = (float)-Size.Width; - plateBox.Y = (float)-Size.Height; + plateBox.Y = (float)-Size.Length; break; case 4: plateBox.X = 0; - plateBox.Y = (float)-Size.Height; + plateBox.Y = (float)-Size.Length; break; default: @@ -321,7 +321,7 @@ namespace OpenNest } plateBox.Width = Size.Width; - plateBox.Height = Size.Height; + plateBox.Length = Size.Length; if (!includeParts) return plateBox; @@ -341,7 +341,7 @@ namespace OpenNest ? partsBox.Right - boundingBox.X : plateBox.Right - boundingBox.X; - boundingBox.Height = partsBox.Top > plateBox.Top + boundingBox.Length = partsBox.Top > plateBox.Top ? partsBox.Top - boundingBox.Y : plateBox.Top - boundingBox.Y; @@ -359,7 +359,7 @@ namespace OpenNest box.X += EdgeSpacing.Left; box.Y += EdgeSpacing.Bottom; box.Width -= EdgeSpacing.Left + EdgeSpacing.Right; - box.Height -= EdgeSpacing.Top + EdgeSpacing.Bottom; + box.Length -= EdgeSpacing.Top + EdgeSpacing.Bottom; return box; } @@ -383,28 +383,28 @@ namespace OpenNest var bounds = Parts.GetBoundingBox(); double width; - double height; + double length; switch (Quadrant) { case 1: width = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; - height = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; + length = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; break; case 2: width = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; - height = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; + length = System.Math.Abs(bounds.Top) + EdgeSpacing.Top; break; case 3: width = System.Math.Abs(bounds.Left) + EdgeSpacing.Left; - height = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; + length = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; break; case 4: width = System.Math.Abs(bounds.Right) + EdgeSpacing.Right; - height = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; + length = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom; break; default: @@ -413,7 +413,7 @@ namespace OpenNest Size = new Size( Helper.RoundUpToNearest(width, roundingFactor), - Helper.RoundUpToNearest(height, roundingFactor)); + Helper.RoundUpToNearest(length, roundingFactor)); } /// @@ -422,7 +422,7 @@ namespace OpenNest /// public double Area() { - return Size.Width * Size.Height; + return Size.Width * Size.Length; } /// @@ -503,7 +503,7 @@ namespace OpenNest if (maxRight < work.Right) { - var strip = new Box(maxRight, work.Bottom, work.Right - maxRight, work.Height); + var strip = new Box(maxRight, work.Bottom, work.Right - maxRight, work.Length); if (strip.Area() > 1.0) results.Add(strip); } @@ -548,7 +548,7 @@ namespace OpenNest if (minLeft > work.Left) { - var strip = new Box(work.Left, work.Bottom, minLeft - work.Left, work.Height); + var strip = new Box(work.Left, work.Bottom, minLeft - work.Left, work.Length); if (strip.Area() > 1.0) results.Add(strip); } diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index a91f0c6..5224820 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -57,7 +57,7 @@ namespace OpenNest.Engine.BestFit var combinedBox = ((IEnumerable)new IBoundable[] { part1, part2 }).GetBoundingBox(); bestArea = combinedBox.Area(); bestWidth = combinedBox.Width; - bestHeight = combinedBox.Height; + bestHeight = combinedBox.Length; bestRotation = 0; } diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 4e09da2..f0e0771 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -64,15 +64,15 @@ namespace OpenNest.Engine.BestFit if (isHorizontalPush) { - perpMin = -(bbox2.Height + spacing); - perpMax = bbox1.Height + bbox2.Height + spacing; + perpMin = -(bbox2.Length + spacing); + perpMax = bbox1.Length + bbox2.Length + spacing; pushStartOffset = bbox1.Width + bbox2.Width + spacing * 2; } else { perpMin = -(bbox2.Width + spacing); perpMax = bbox1.Width + bbox2.Width + spacing; - pushStartOffset = bbox1.Height + bbox2.Height + spacing * 2; + pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2; } // Pre-compute part1's offset lines (half-spacing outward) diff --git a/OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs b/OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs index 9a7ddb0..f498ae3 100644 --- a/OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs +++ b/OpenNest.Engine/BestFit/Tiling/TileEvaluator.cs @@ -9,7 +9,7 @@ namespace OpenNest.Engine.BestFit.Tiling public TileResult Evaluate(BestFitResult bestFit, Plate plate) { var plateWidth = plate.Size.Width - plate.EdgeSpacing.Left - plate.EdgeSpacing.Right; - var plateHeight = plate.Size.Height - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom; + var plateHeight = plate.Size.Length - plate.EdgeSpacing.Top - plate.EdgeSpacing.Bottom; var result1 = TryTile(bestFit, plateWidth, plateHeight, false); var result2 = TryTile(bestFit, plateWidth, plateHeight, true); diff --git a/OpenNest.Engine/CirclePacking/FillEndEven.cs b/OpenNest.Engine/CirclePacking/FillEndEven.cs index e3d6786..b343301 100644 --- a/OpenNest.Engine/CirclePacking/FillEndEven.cs +++ b/OpenNest.Engine/CirclePacking/FillEndEven.cs @@ -17,10 +17,10 @@ namespace OpenNest.CirclePacking Bin.Right - item.BoundingBox.Right + Tolerance.Epsilon, Bin.Top - item.BoundingBox.Top + Tolerance.Epsilon); - var rows = System.Math.Floor((Bin.Height + Tolerance.Epsilon) / (item.Diameter)); + var rows = System.Math.Floor((Bin.Length + Tolerance.Epsilon) / (item.Diameter)); var diameter = item.Diameter; - var remaining = Bin.Height - diameter * rows; + var remaining = Bin.Length - diameter * rows; var radius = diameter * 0.5; if (remaining < radius) @@ -47,7 +47,7 @@ namespace OpenNest.CirclePacking } else { - var yoffset = (Bin.Height - diameter) / (2 * rows - 1); + var yoffset = (Bin.Length - diameter) / (2 * rows - 1); var xoffset = Trigonometry.Base(yoffset, diameter); var yodd = Bin.Y + yoffset; diff --git a/OpenNest.Engine/CirclePacking/FillEndOdd.cs b/OpenNest.Engine/CirclePacking/FillEndOdd.cs index f048da8..44c8321 100644 --- a/OpenNest.Engine/CirclePacking/FillEndOdd.cs +++ b/OpenNest.Engine/CirclePacking/FillEndOdd.cs @@ -71,12 +71,12 @@ namespace OpenNest.CirclePacking Bin.Right - item.BoundingBox.Right + Tolerance.Epsilon, Bin.Top - item.BoundingBox.Top + Tolerance.Epsilon); - var count = System.Math.Floor((bin.Height + Tolerance.Epsilon) / item.Diameter); + var count = System.Math.Floor((bin.Length + Tolerance.Epsilon) / item.Diameter); if (count == 0) return bin; - var yoffset = (bin.Height - item.Diameter) / (count - 1); + var yoffset = (bin.Length - item.Diameter) / (count - 1); var xoffset = Trigonometry.Base(yoffset * 0.5, item.Diameter); int column = 0; diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index a04d715..584f5fe 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -9,7 +9,7 @@ namespace OpenNest public FillLinear(Box workArea, double partSpacing) { PartSpacing = partSpacing; - WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Height); + WorkArea = new Box(workArea.X, workArea.Y, workArea.Width, workArea.Length); } public Box WorkArea { get; } @@ -34,7 +34,7 @@ namespace OpenNest private static double GetDimension(Box box, NestDirection direction) { - return direction == NestDirection.Horizontal ? box.Width : box.Height; + return direction == NestDirection.Horizontal ? box.Width : box.Length; } private static double GetStart(Box box, NestDirection direction) @@ -321,7 +321,7 @@ namespace OpenNest template.Offset(WorkArea.Location - template.BoundingBox.Location); if (template.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon || - template.BoundingBox.Height > WorkArea.Height + Tolerance.Epsilon) + template.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon) return pattern; pattern.Parts.Add(template); @@ -472,7 +472,7 @@ namespace OpenNest if (width <= Tolerance.Epsilon) return new List(); - remainingStrip = new Box(left, WorkArea.Y, width, WorkArea.Height); + remainingStrip = new Box(left, WorkArea.Y, width, WorkArea.Length); } // Build rotation set: always try cardinal orientations (0° and 90°), @@ -601,7 +601,7 @@ namespace OpenNest var basePattern = pattern.Clone(offset); if (basePattern.BoundingBox.Width > WorkArea.Width + Tolerance.Epsilon || - basePattern.BoundingBox.Height > WorkArea.Height + Tolerance.Epsilon) + basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon) return new List(); return FillRecursive(basePattern, primaryAxis, depth: 0); diff --git a/OpenNest.Engine/FillScore.cs b/OpenNest.Engine/FillScore.cs index e06cc65..8c1dd2b 100644 --- a/OpenNest.Engine/FillScore.cs +++ b/OpenNest.Engine/FillScore.cs @@ -80,7 +80,7 @@ namespace OpenNest if (maxRight < workArea.Right) { var width = workArea.Right - maxRight; - var height = workArea.Height; + var height = workArea.Length; if (System.Math.Min(width, height) >= MinRemnantDimension) largest = System.Math.Max(largest, width * height); diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 394660a..0893ed5 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -70,8 +70,8 @@ namespace OpenNest testPart.UpdateBounds(); - var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Height); - var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Height); + var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); + var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); if (workAreaShortSide < partLongestSide) { @@ -113,7 +113,7 @@ namespace OpenNest } var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; - Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Height:F1} | Angles: {angles.Count}"); + Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); // Try rectangle best-fit (mixes orientations to fill remnant strips). var rectResult = FillRectangleBestFit(item, workArea); @@ -143,7 +143,7 @@ namespace OpenNest var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); var best = FillPattern(engine, groupParts, angles, workArea); - Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Height:F1}"); + Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}"); if (groupParts.Count == 1) { @@ -213,7 +213,7 @@ namespace OpenNest private List FillWithPairs(NestItem item, Box workArea) { var bestFits = BestFitCache.GetOrCompute( - item.Drawing, Plate.Size.Width, Plate.Size.Height, + item.Drawing, Plate.Size.Width, Plate.Size.Length, Plate.PartSpacing); var candidates = SelectPairCandidates(bestFits, workArea); @@ -260,8 +260,8 @@ namespace OpenNest var kept = bestFits.Where(r => r.Keep).ToList(); var top = kept.Take(50).ToList(); - var workShortSide = System.Math.Min(workArea.Width, workArea.Height); - var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Height); + var workShortSide = System.Math.Min(workArea.Width, workArea.Length); + var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length); // When the work area is significantly narrower than the plate, // include all pairs that fit the narrow dimension. @@ -356,7 +356,7 @@ namespace OpenNest var refDim = horizontal ? sorted.Max(p => p.BoundingBox.Width) - : sorted.Max(p => p.BoundingBox.Height); + : sorted.Max(p => p.BoundingBox.Length); var gapThreshold = refDim * 0.5; var clusters = new List>(); @@ -425,7 +425,7 @@ namespace OpenNest if (stripWidth <= 0) return null; - stripBox = new Box(stripLeft, workArea.Y, stripWidth, workArea.Height); + stripBox = new Box(stripLeft, workArea.Y, stripWidth, workArea.Length); } else { @@ -438,7 +438,7 @@ namespace OpenNest stripBox = new Box(workArea.X, stripBottom, workArea.Width, stripHeight); } - Debug.WriteLine($"[TryStripRefill] Strip: {stripBox.Width:F1}x{stripBox.Height:F1} at ({stripBox.X:F1},{stripBox.Y:F1})"); + Debug.WriteLine($"[TryStripRefill] Strip: {stripBox.Width:F1}x{stripBox.Length:F1} at ({stripBox.X:F1},{stripBox.Y:F1})"); var stripParts = FindBestFill(item, stripBox); diff --git a/OpenNest.Engine/RectanglePacking/BinConverter.cs b/OpenNest.Engine/RectanglePacking/BinConverter.cs index 930f180..bf9f4cd 100644 --- a/OpenNest.Engine/RectanglePacking/BinConverter.cs +++ b/OpenNest.Engine/RectanglePacking/BinConverter.cs @@ -15,7 +15,7 @@ namespace OpenNest.RectanglePacking }; bin.Width += partSpacing; - bin.Height += partSpacing; + bin.Length += partSpacing; return bin; } @@ -25,7 +25,7 @@ namespace OpenNest.RectanglePacking var box = item.Drawing.Program.BoundingBox(); box.Width += partSpacing; - box.Height += partSpacing; + box.Length += partSpacing; return new Item { diff --git a/OpenNest.Engine/RectanglePacking/FillBestFit.cs b/OpenNest.Engine/RectanglePacking/FillBestFit.cs index 8b8d74d..fb37247 100644 --- a/OpenNest.Engine/RectanglePacking/FillBestFit.cs +++ b/OpenNest.Engine/RectanglePacking/FillBestFit.cs @@ -44,11 +44,11 @@ namespace OpenNest.RectanglePacking int normalColumns = 0; int rotateColumns = 0; - if (!BestCombination.FindFrom2(item.Width, item.Height, bin.Width, out normalColumns, out rotateColumns)) + if (!BestCombination.FindFrom2(item.Width, item.Length, bin.Width, out normalColumns, out rotateColumns)) return bin; - var normalRows = (int)System.Math.Floor((bin.Height + Tolerance.Epsilon) / item.Height); - var rotateRows = (int)System.Math.Floor((bin.Height + Tolerance.Epsilon) / item.Width); + var normalRows = (int)System.Math.Floor((bin.Length + Tolerance.Epsilon) / item.Length); + var rotateRows = (int)System.Math.Floor((bin.Length + Tolerance.Epsilon) / item.Width); item.Location = bin.Location; @@ -69,17 +69,17 @@ namespace OpenNest.RectanglePacking int normalRows = 0; int rotateRows = 0; - if (!BestCombination.FindFrom2(item.Height, item.Width, Bin.Height, out normalRows, out rotateRows)) + if (!BestCombination.FindFrom2(item.Length, item.Width, Bin.Length, out normalRows, out rotateRows)) return bin; var normalColumns = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width); - var rotateColumns = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Height); + var rotateColumns = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Length); item.Location = bin.Location; bin.Items.AddRange(FillGrid(item, normalRows, normalColumns, int.MaxValue)); - item.Location.Y += item.Height * normalRows; + item.Location.Y += item.Length * normalRows; item.Rotate(); bin.Items.AddRange(FillGrid(item, rotateRows, rotateColumns, int.MaxValue)); diff --git a/OpenNest.Engine/RectanglePacking/FillEngine.cs b/OpenNest.Engine/RectanglePacking/FillEngine.cs index 2164351..5cca401 100644 --- a/OpenNest.Engine/RectanglePacking/FillEngine.cs +++ b/OpenNest.Engine/RectanglePacking/FillEngine.cs @@ -28,7 +28,7 @@ namespace OpenNest.RectanglePacking for (var j = 0; j < innerCount; j++) { var x = (columnMajor ? i : j) * item.Width + item.X; - var y = (columnMajor ? j : i) * item.Height + item.Y; + var y = (columnMajor ? j : i) * item.Length + item.Y; var clone = item.Clone() as Item; clone.Location = new Vector(x, y); diff --git a/OpenNest.Engine/RectanglePacking/FillNoRotation.cs b/OpenNest.Engine/RectanglePacking/FillNoRotation.cs index f248276..f56727a 100644 --- a/OpenNest.Engine/RectanglePacking/FillNoRotation.cs +++ b/OpenNest.Engine/RectanglePacking/FillNoRotation.cs @@ -15,7 +15,7 @@ namespace OpenNest.RectanglePacking public override void Fill(Item item) { - var ycount = (int)System.Math.Floor((Bin.Height + Tolerance.Epsilon) / item.Height); + var ycount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length); var xcount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width); for (int i = 0; i < xcount; i++) @@ -24,7 +24,7 @@ namespace OpenNest.RectanglePacking for (int j = 0; j < ycount; j++) { - var y = item.Height * j + Bin.Y; + var y = item.Length * j + Bin.Y; var addedItem = item.Clone() as Item; addedItem.Location = new Vector(x, y); @@ -36,7 +36,7 @@ namespace OpenNest.RectanglePacking public override void Fill(Item item, int maxCount) { - var ycount = (int)System.Math.Floor((Bin.Height + Tolerance.Epsilon) / item.Height); + var ycount = (int)System.Math.Floor((Bin.Length + Tolerance.Epsilon) / item.Length); var xcount = (int)System.Math.Floor((Bin.Width + Tolerance.Epsilon) / item.Width); var count = ycount * xcount; @@ -60,7 +60,7 @@ namespace OpenNest.RectanglePacking columns = (int)System.Math.Ceiling((double)maxCount / rows); } - Bin.Items.AddRange(FillGrid(item, rows, columns, maxCount, columnMajor: item.Width > item.Height)); + Bin.Items.AddRange(FillGrid(item, rows, columns, maxCount, columnMajor: item.Width > item.Length)); } } } diff --git a/OpenNest.Engine/RectanglePacking/Item.cs b/OpenNest.Engine/RectanglePacking/Item.cs index d1c25e2..a78c9d5 100644 --- a/OpenNest.Engine/RectanglePacking/Item.cs +++ b/OpenNest.Engine/RectanglePacking/Item.cs @@ -12,7 +12,7 @@ namespace OpenNest.RectanglePacking public void Rotate() { - Generic.Swap(ref Size.Width, ref Size.Height); + Generic.Swap(ref Size.Width, ref Size.Length); IsRotated = !IsRotated; } @@ -38,7 +38,7 @@ namespace OpenNest.RectanglePacking double minX = items[0].X; double minY = items[0].Y; double maxX = items[0].X + items[0].Width; - double maxY = items[0].Y + items[0].Height; + double maxY = items[0].Y + items[0].Length; foreach (var box in items) { diff --git a/OpenNest.Engine/RectanglePacking/PackFirstFitDecreasing.cs b/OpenNest.Engine/RectanglePacking/PackFirstFitDecreasing.cs index 2e3ebbf..b290d4d 100644 --- a/OpenNest.Engine/RectanglePacking/PackFirstFitDecreasing.cs +++ b/OpenNest.Engine/RectanglePacking/PackFirstFitDecreasing.cs @@ -16,11 +16,11 @@ namespace OpenNest.RectanglePacking public override void Pack(List items) { - items = items.OrderBy(i => -i.Height).ToList(); + items = items.OrderBy(i => -i.Length).ToList(); foreach (var item in items) { - if (item.Height > Bin.Height) + if (item.Length > Bin.Length) continue; var level = FindLevel(item); @@ -36,7 +36,7 @@ namespace OpenNest.RectanglePacking { foreach (var level in levels) { - if (level.Height < item.Height) + if (level.Height < item.Length) continue; if (level.RemainingWidth < item.Width) @@ -58,12 +58,12 @@ namespace OpenNest.RectanglePacking var remaining = Bin.Top - y; - if (remaining < item.Height) + if (remaining < item.Length) return null; var level = new Level(Bin); level.Y = y; - level.Height = item.Height; + level.Height = item.Length; levels.Add(level); diff --git a/OpenNest.IO/DxfExporter.cs b/OpenNest.IO/DxfExporter.cs index 83de69d..aac67a3 100644 --- a/OpenNest.IO/DxfExporter.cs +++ b/OpenNest.IO/DxfExporter.cs @@ -145,29 +145,29 @@ namespace OpenNest.IO { case 1: pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, plate.Size.Height, 0); - pt3 = new XYZ(plate.Size.Width, plate.Size.Height, 0); + pt2 = new XYZ(0, plate.Size.Length, 0); + pt3 = new XYZ(plate.Size.Width, plate.Size.Length, 0); pt4 = new XYZ(plate.Size.Width, 0, 0); break; case 2: pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, plate.Size.Height, 0); - pt3 = new XYZ(-plate.Size.Width, plate.Size.Height, 0); + pt2 = new XYZ(0, plate.Size.Length, 0); + pt3 = new XYZ(-plate.Size.Width, plate.Size.Length, 0); pt4 = new XYZ(-plate.Size.Width, 0, 0); break; case 3: pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, -plate.Size.Height, 0); - pt3 = new XYZ(-plate.Size.Width, -plate.Size.Height, 0); + pt2 = new XYZ(0, -plate.Size.Length, 0); + pt3 = new XYZ(-plate.Size.Width, -plate.Size.Length, 0); pt4 = new XYZ(-plate.Size.Width, 0, 0); break; case 4: pt1 = new XYZ(0, 0, 0); - pt2 = new XYZ(0, -plate.Size.Height, 0); - pt3 = new XYZ(plate.Size.Width, -plate.Size.Height, 0); + pt2 = new XYZ(0, -plate.Size.Length, 0); + pt3 = new XYZ(plate.Size.Width, -plate.Size.Length, 0); pt4 = new XYZ(plate.Size.Width, 0, 0); break; diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs new file mode 100644 index 0000000..6801a31 --- /dev/null +++ b/OpenNest.IO/NestFormat.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace OpenNest.IO +{ + public static class NestFormat + { + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public record NestDto + { + public int Version { get; init; } = 2; + public string Name { get; init; } = ""; + public string Units { get; init; } = "Inches"; + public string Customer { get; init; } = ""; + public string DateCreated { get; init; } = ""; + public string DateLastModified { get; init; } = ""; + public string Notes { get; init; } = ""; + public PlateDefaultsDto PlateDefaults { get; init; } = new(); + public List Drawings { get; init; } = new(); + public List Plates { get; init; } = new(); + } + + public record PlateDefaultsDto + { + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + } + + public record DrawingDto + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Customer { get; init; } = ""; + public ColorDto Color { get; init; } = new(); + public QuantityDto Quantity { get; init; } = new(); + public int Priority { get; init; } + public ConstraintsDto Constraints { get; init; } = new(); + public MaterialDto Material { get; init; } = new(); + public SourceDto Source { get; init; } = new(); + } + + public record PlateDto + { + public int Id { get; init; } + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public int Quantity { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + public List Parts { get; init; } = new(); + } + + public record PartDto + { + public int DrawingId { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Rotation { get; init; } + } + + public record SizeDto + { + public double Width { get; init; } + public double Length { get; init; } + } + + public record MaterialDto + { + public string Name { get; init; } = ""; + public string Grade { get; init; } = ""; + public double Density { get; init; } + } + + public record SpacingDto + { + public double Left { get; init; } + public double Top { get; init; } + public double Right { get; init; } + public double Bottom { get; init; } + } + + public record ColorDto + { + public int A { get; init; } = 255; + public int R { get; init; } + public int G { get; init; } + public int B { get; init; } + } + + public record QuantityDto + { + public int Required { get; init; } + } + + public record ConstraintsDto + { + public double StepAngle { get; init; } + public double StartAngle { get; init; } + public double EndAngle { get; init; } + public bool Allow180Equivalent { get; init; } + } + + public record SourceDto + { + public string Path { get; init; } = ""; + public OffsetDto Offset { get; init; } = new(); + } + + public record OffsetDto + { + public double X { get; init; } + public double Y { get; init; } + } + } +} diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index e498bf1..5ce5715 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -1,45 +1,28 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.IO; using System.IO.Compression; using System.Linq; -using System.Text.RegularExpressions; -using System.Xml; +using System.Text.Json; using OpenNest.CNC; using OpenNest.Geometry; -using OpenNest.Math; +using static OpenNest.IO.NestFormat; namespace OpenNest.IO { public sealed class NestReader { - private ZipArchive zipArchive; - private Dictionary plateDict; - private Dictionary drawingDict; - private Dictionary programDict; - private Dictionary plateProgramDict; - private Stream stream; - private Nest nest; - - private NestReader() - { - plateDict = new Dictionary(); - drawingDict = new Dictionary(); - programDict = new Dictionary(); - plateProgramDict = new Dictionary(); - nest = new Nest(); - } + private readonly Stream stream; + private readonly ZipArchive zipArchive; public NestReader(string file) - : this() { stream = new FileStream(file, FileMode.Open, FileAccess.Read); zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); } public NestReader(Stream stream) - : this() { this.stream = stream; zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); @@ -47,52 +30,12 @@ namespace OpenNest.IO public Nest Read() { - const string plateExtensionPattern = "plate-\\d\\d\\d"; - const string programExtensionPattern = "program-\\d\\d\\d"; + var nestJson = ReadEntry("nest.json"); + var dto = JsonSerializer.Deserialize(nestJson, JsonOptions); - foreach (var entry in zipArchive.Entries) - { - var memstream = new MemoryStream(); - using (var entryStream = entry.Open()) - { - entryStream.CopyTo(memstream); - } - - memstream.Position = 0; - - switch (entry.FullName) - { - case "info": - ReadNestInfo(memstream); - continue; - - case "drawing-info": - ReadDrawingInfo(memstream); - continue; - - case "plate-info": - ReadPlateInfo(memstream); - continue; - } - - if (Regex.IsMatch(entry.FullName, programExtensionPattern)) - { - ReadProgram(memstream, entry.FullName); - continue; - } - - if (Regex.IsMatch(entry.FullName, plateExtensionPattern)) - { - ReadPlate(memstream, entry.FullName); - continue; - } - } - - LinkProgramsToDrawings(); - LinkPartsToPlates(); - - AddPlatesToNest(); - AddDrawingsToNest(); + var programs = ReadPrograms(dto.Drawings.Count); + var drawingMap = BuildDrawings(dto, programs); + var nest = BuildNest(dto, drawingMap); zipArchive.Dispose(); stream.Close(); @@ -100,374 +43,114 @@ namespace OpenNest.IO return nest; } - private void ReadNestInfo(Stream stream) + private string ReadEntry(string name) { - var reader = XmlReader.Create(stream); - var spacing = new Spacing(); + var entry = zipArchive.GetEntry(name) + ?? throw new InvalidDataException($"Nest file is missing required entry '{name}'."); + using var entryStream = entry.Open(); + using var reader = new StreamReader(entryStream); + return reader.ReadToEnd(); + } - while (reader.Read()) + private Dictionary ReadPrograms(int count) + { + var programs = new Dictionary(); + for (var i = 1; i <= count; i++) { - if (!reader.IsStartElement()) - continue; + var entry = zipArchive.GetEntry($"programs/program-{i}"); + if (entry == null) continue; - switch (reader.Name) + using var entryStream = entry.Open(); + var memStream = new MemoryStream(); + entryStream.CopyTo(memStream); + memStream.Position = 0; + + var reader = new ProgramReader(memStream); + programs[i] = reader.Read(); + } + return programs; + } + + private Dictionary BuildDrawings(NestDto dto, Dictionary programs) + { + var map = new Dictionary(); + foreach (var d in dto.Drawings) + { + var drawing = new Drawing(d.Name); + drawing.Customer = d.Customer; + drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B); + drawing.Quantity.Required = d.Quantity.Required; + drawing.Priority = d.Priority; + drawing.Constraints.StepAngle = d.Constraints.StepAngle; + drawing.Constraints.StartAngle = d.Constraints.StartAngle; + drawing.Constraints.EndAngle = d.Constraints.EndAngle; + drawing.Constraints.Allow180Equivalent = d.Constraints.Allow180Equivalent; + drawing.Material = new Material(d.Material.Name, d.Material.Grade, d.Material.Density); + drawing.Source.Path = d.Source.Path; + drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y); + + if (programs.TryGetValue(d.Id, out var pgm)) + drawing.Program = pgm; + + map[d.Id] = drawing; + } + return map; + } + + private Nest BuildNest(NestDto dto, Dictionary drawingMap) + { + var nest = new Nest(); + nest.Name = dto.Name; + + Units units; + if (Enum.TryParse(dto.Units, true, out units)) + nest.Units = units; + + nest.Customer = dto.Customer; + nest.DateCreated = DateTime.Parse(dto.DateCreated); + nest.DateLastModified = DateTime.Parse(dto.DateLastModified); + nest.Notes = dto.Notes; + + // Plate defaults + var pd = dto.PlateDefaults; + nest.PlateDefaults.Size = new OpenNest.Geometry.Size(pd.Size.Width, pd.Size.Length); + nest.PlateDefaults.Thickness = pd.Thickness; + nest.PlateDefaults.Quadrant = pd.Quadrant; + nest.PlateDefaults.PartSpacing = pd.PartSpacing; + nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density); + nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top); + + // Drawings + foreach (var d in drawingMap.OrderBy(k => k.Key)) + nest.Drawings.Add(d.Value); + + // Plates + foreach (var p in dto.Plates.OrderBy(p => p.Id)) + { + var plate = new Plate(); + plate.Size = new OpenNest.Geometry.Size(p.Size.Width, p.Size.Length); + plate.Thickness = p.Thickness; + plate.Quadrant = p.Quadrant; + plate.Quantity = p.Quantity; + plate.PartSpacing = p.PartSpacing; + plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density); + plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top); + + foreach (var partDto in p.Parts) { - case "Nest": - nest.Name = reader["name"]; - break; + if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg)) + continue; - case "Units": - Units units; - TryParseEnum(reader.ReadString(), out units); - nest.Units = units; - break; - - case "Customer": - nest.Customer = reader.ReadString(); - break; - - case "DateCreated": - nest.DateCreated = DateTime.Parse(reader.ReadString()); - break; - - case "DateLastModified": - nest.DateLastModified = DateTime.Parse(reader.ReadString()); - break; - - case "Notes": - nest.Notes = Uri.UnescapeDataString(reader.ReadString()); - break; - - case "Size": - nest.PlateDefaults.Size = OpenNest.Geometry.Size.Parse(reader.ReadString()); - break; - - case "Thickness": - nest.PlateDefaults.Thickness = double.Parse(reader.ReadString()); - break; - - case "Quadrant": - nest.PlateDefaults.Quadrant = int.Parse(reader.ReadString()); - break; - - case "PartSpacing": - nest.PlateDefaults.PartSpacing = double.Parse(reader.ReadString()); - break; - - case "Name": - nest.PlateDefaults.Material.Name = reader.ReadString(); - break; - - case "Grade": - nest.PlateDefaults.Material.Grade = reader.ReadString(); - break; - - case "Density": - nest.PlateDefaults.Material.Density = double.Parse(reader.ReadString()); - break; - - case "Left": - spacing.Left = double.Parse(reader.ReadString()); - break; - - case "Right": - spacing.Right = double.Parse(reader.ReadString()); - break; - - case "Top": - spacing.Top = double.Parse(reader.ReadString()); - break; - - case "Bottom": - spacing.Bottom = double.Parse(reader.ReadString()); - break; + var part = new Part(dwg); + part.Rotate(partDto.Rotation); + part.Offset(new Vector(partDto.X, partDto.Y)); + plate.Parts.Add(part); } + + nest.Plates.Add(plate); } - reader.Close(); - nest.PlateDefaults.EdgeSpacing = spacing; - } - - private void ReadDrawingInfo(Stream stream) - { - var reader = XmlReader.Create(stream); - Drawing drawing = null; - - while (reader.Read()) - { - if (!reader.IsStartElement()) - continue; - - switch (reader.Name) - { - case "Drawing": - var id = int.Parse(reader["id"]); - var name = reader["name"]; - - drawingDict.Add(id, (drawing = new Drawing(name))); - break; - - case "Customer": - drawing.Customer = reader.ReadString(); - break; - - case "Color": - { - var parts = reader.ReadString().Split(','); - - if (parts.Length == 3) - { - byte r = byte.Parse(parts[0]); - byte g = byte.Parse(parts[1]); - byte b = byte.Parse(parts[2]); - - drawing.Color = Color.FromArgb(r, g, b); - } - else if (parts.Length == 4) - { - byte a = byte.Parse(parts[0]); - byte r = byte.Parse(parts[1]); - byte g = byte.Parse(parts[2]); - byte b = byte.Parse(parts[3]); - - drawing.Color = Color.FromArgb(a, r, g, b); - } - } - break; - - case "Required": - drawing.Quantity.Required = int.Parse(reader.ReadString()); - break; - - case "Name": - drawing.Material.Name = reader.ReadString(); - break; - - case "Grade": - drawing.Material.Grade = reader.ReadString(); - break; - - case "Density": - drawing.Material.Density = double.Parse(reader.ReadString()); - break; - - case "Path": - drawing.Source.Path = reader.ReadString(); - break; - - case "Offset": - { - var parts = reader.ReadString().Split(','); - - if (parts.Length != 2) - continue; - - drawing.Source.Offset = new Vector(double.Parse(parts[0]), double.Parse(parts[1])); - } - break; - } - } - - reader.Close(); - } - - private void ReadPlateInfo(Stream stream) - { - var reader = XmlReader.Create(stream); - var spacing = new Spacing(); - Plate plate = null; - - while (reader.Read()) - { - if (!reader.IsStartElement()) - continue; - - switch (reader.Name) - { - case "Plate": - var id = int.Parse(reader["id"]); - - if (plate != null) - plate.EdgeSpacing = spacing; - - plateDict.Add(id, (plate = new Plate())); - break; - - case "Size": - plate.Size = OpenNest.Geometry.Size.Parse(reader.ReadString()); - break; - - case "Qty": - plate.Quantity = int.Parse(reader.ReadString()); - break; - - case "Thickness": - plate.Thickness = double.Parse(reader.ReadString()); - break; - - case "Quadrant": - plate.Quadrant = int.Parse(reader.ReadString()); - break; - - case "PartSpacing": - plate.PartSpacing = double.Parse(reader.ReadString()); - break; - - case "Name": - plate.Material.Name = reader.ReadString(); - break; - - case "Grade": - plate.Material.Grade = reader.ReadString(); - break; - - case "Density": - plate.Material.Density = double.Parse(reader.ReadString()); - break; - - case "Left": - spacing.Left = double.Parse(reader.ReadString()); - break; - - case "Right": - spacing.Right = double.Parse(reader.ReadString()); - break; - - case "Top": - spacing.Top = double.Parse(reader.ReadString()); - break; - - case "Bottom": - spacing.Bottom = double.Parse(reader.ReadString()); - break; - } - } - - if (plate != null) - plate.EdgeSpacing = spacing; - } - - private void ReadProgram(Stream stream, string name) - { - var id = GetProgramId(name); - var reader = new ProgramReader(stream); - var pgm = reader.Read(); - programDict.Add(id, pgm); - } - - private void ReadPlate(Stream stream, string name) - { - var id = GetPlateId(name); - var reader = new ProgramReader(stream); - var pgm = reader.Read(); - plateProgramDict.Add(id, pgm); - } - - private void LinkProgramsToDrawings() - { - foreach (var drawingItem in drawingDict) - { - Program pgm; - - if (programDict.TryGetValue(drawingItem.Key, out pgm)) - drawingItem.Value.Program = pgm; - } - } - - private void LinkPartsToPlates() - { - foreach (var plateProgram in plateProgramDict) - { - var parts = CreateParts(plateProgram.Value); - - Plate plate; - - if (!plateDict.TryGetValue(plateProgram.Key, out plate)) - plate = new Plate(); - - plate.Parts.AddRange(parts); - plateDict[plateProgram.Key] = plate; - } - } - - private void AddPlatesToNest() - { - var plates = plateDict.OrderBy(i => i.Key).Select(i => i.Value).ToList(); - nest.Plates.AddRange(plates); - } - - private void AddDrawingsToNest() - { - var drawings = drawingDict.OrderBy(i => i.Key).Select(i => i.Value).ToList(); - drawings.ForEach(d => nest.Drawings.Add(d)); - } - - private List CreateParts(Program pgm) - { - var parts = new List(); - var pos = Vector.Zero; - - for (int i = 0; i < pgm.Codes.Count; i++) - { - var code = pgm.Codes[i]; - - switch (code.Type) - { - case CodeType.RapidMove: - pos = ((RapidMove)code).EndPoint; - break; - - case CodeType.SubProgramCall: - var subpgm = (SubProgramCall)code; - var dwg = drawingDict[subpgm.Id]; - var part = new Part(dwg); - part.Rotate(Angle.ToRadians(subpgm.Rotation)); - part.Offset(pos); - parts.Add(part); - break; - } - } - - return parts; - } - - private int GetPlateId(string name) - { - return int.Parse(name.Replace("plate-", "")); - } - - private int GetProgramId(string name) - { - return int.Parse(name.Replace("program-", "")); - } - - public static T ParseEnum(string value) - { - return (T)Enum.Parse(typeof(T), value, true); - } - - public static bool TryParseEnum(string value, out T e) - { - try - { - e = ParseEnum(value); - return true; - } - catch - { - e = ParseEnum(typeof(T).GetEnumValues().GetValue(0).ToString()); - } - - return false; - } - - private enum NestInfoSection - { - None, - DefaultPlate, - Material, - EdgeSpacing, - Source + return nest; } } } diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 15f6f09..4202a3d 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -1,32 +1,22 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; -using System.Xml; +using System.Text.Json; using OpenNest.CNC; using OpenNest.Math; +using static OpenNest.IO.NestFormat; namespace OpenNest.IO { public sealed class NestWriter { - /// - /// Number of decimal places the output is round to. - /// This number must have more decimal places than Tolerance.Epsilon - /// private const int OutputPrecision = 10; - - /// - /// Fixed-point format string that avoids scientific notation. - /// ProgramReader treats 'E' as a code letter, so "6.66E-08" would be - /// split into X:"6.66" and E:"-08", corrupting the parsed value. - /// private const string CoordinateFormat = "0.##########"; private readonly Nest nest; - private ZipArchive zipArchive; private Dictionary drawingDict; public NestWriter(Nest nest) @@ -37,27 +27,21 @@ namespace OpenNest.IO public bool Write(string file) { - this.nest.DateLastModified = DateTime.Now; - + nest.DateLastModified = DateTime.Now; SetDrawingIds(); - using (var fileStream = new FileStream(file, FileMode.Create)) - using (zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create)) - { - AddNestInfo(); - AddPlates(); - AddPlateInfo(); - AddDrawings(); - AddDrawingInfo(); - } + using var fileStream = new FileStream(file, FileMode.Create); + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create); + + WriteNestJson(zipArchive); + WritePrograms(zipArchive); return true; } private void SetDrawingIds() { - int id = 1; - + var id = 1; foreach (var drawing in nest.Drawings) { drawingDict.Add(id, drawing); @@ -65,241 +49,156 @@ namespace OpenNest.IO } } - private void AddNestInfo() + private void WriteNestJson(ZipArchive zipArchive) { - var stream = new MemoryStream(); - var writer = XmlWriter.Create(stream, new XmlWriterSettings() - { - Indent = true - }); + var dto = BuildNestDto(); + var json = JsonSerializer.Serialize(dto, JsonOptions); - writer.WriteStartDocument(); - writer.WriteStartElement("Nest"); - writer.WriteAttributeString("name", nest.Name); - - writer.WriteElementString("Units", nest.Units.ToString()); - writer.WriteElementString("Customer", nest.Customer); - writer.WriteElementString("DateCreated", nest.DateCreated.ToString()); - writer.WriteElementString("DateLastModified", nest.DateLastModified.ToString()); - - writer.WriteStartElement("DefaultPlate"); - writer.WriteElementString("Size", nest.PlateDefaults.Size.ToString()); - writer.WriteElementString("Thickness", nest.PlateDefaults.Thickness.ToString()); - writer.WriteElementString("Quadrant", nest.PlateDefaults.Quadrant.ToString()); - writer.WriteElementString("PartSpacing", nest.PlateDefaults.PartSpacing.ToString()); - - writer.WriteStartElement("Material"); - writer.WriteElementString("Name", nest.PlateDefaults.Material.Name); - writer.WriteElementString("Grade", nest.PlateDefaults.Material.Grade); - writer.WriteElementString("Density", nest.PlateDefaults.Material.Density.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("EdgeSpacing"); - writer.WriteElementString("Left", nest.PlateDefaults.EdgeSpacing.Left.ToString()); - writer.WriteElementString("Top", nest.PlateDefaults.EdgeSpacing.Top.ToString()); - writer.WriteElementString("Right", nest.PlateDefaults.EdgeSpacing.Right.ToString()); - writer.WriteElementString("Bottom", nest.PlateDefaults.EdgeSpacing.Bottom.ToString()); - writer.WriteEndElement(); - - writer.WriteElementString("Notes", Uri.EscapeDataString(nest.Notes)); - - writer.WriteEndElement(); // DefaultPlate - writer.WriteEndElement(); // Nest - - writer.WriteEndDocument(); - - writer.Flush(); - writer.Close(); - - stream.Position = 0; - - var entry = zipArchive.CreateEntry("info"); - using (var entryStream = entry.Open()) - { - stream.CopyTo(entryStream); - } + var entry = zipArchive.CreateEntry("nest.json"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(json); } - private void AddPlates() + private NestDto BuildNestDto() { - int num = 1; - - foreach (var plate in nest.Plates) + return new NestDto { - var stream = new MemoryStream(); - var name = string.Format("plate-{0}", num.ToString().PadLeft(3, '0')); + Version = 2, + Name = nest.Name ?? "", + Units = nest.Units.ToString(), + Customer = nest.Customer ?? "", + DateCreated = nest.DateCreated.ToString("o"), + DateLastModified = nest.DateLastModified.ToString("o"), + Notes = nest.Notes ?? "", + PlateDefaults = BuildPlateDefaultsDto(), + Drawings = BuildDrawingDtos(), + Plates = BuildPlateDtos() + }; + } - WritePlate(stream, plate); - - var entry = zipArchive.CreateEntry(name); - using (var entryStream = entry.Open()) + private PlateDefaultsDto BuildPlateDefaultsDto() + { + var pd = nest.PlateDefaults; + return new PlateDefaultsDto + { + Size = new SizeDto { Width = pd.Size.Width, Length = pd.Size.Length }, + Thickness = pd.Thickness, + Quadrant = pd.Quadrant, + PartSpacing = pd.PartSpacing, + Material = new MaterialDto { - stream.CopyTo(entryStream); + Name = pd.Material.Name ?? "", + Grade = pd.Material.Grade ?? "", + Density = pd.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = pd.EdgeSpacing.Left, + Top = pd.EdgeSpacing.Top, + Right = pd.EdgeSpacing.Right, + Bottom = pd.EdgeSpacing.Bottom } - - num++; - } + }; } - private void AddPlateInfo() + private List BuildDrawingDtos() { - var stream = new MemoryStream(); - var writer = XmlWriter.Create(stream, new XmlWriterSettings() + var list = new List(); + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) { - Indent = true - }); + var d = kvp.Value; + list.Add(new DrawingDto + { + Id = kvp.Key, + Name = d.Name ?? "", + Customer = d.Customer ?? "", + Color = new ColorDto { A = d.Color.A, R = d.Color.R, G = d.Color.G, B = d.Color.B }, + Quantity = new QuantityDto { Required = d.Quantity.Required }, + Priority = d.Priority, + Constraints = new ConstraintsDto + { + StepAngle = d.Constraints.StepAngle, + StartAngle = d.Constraints.StartAngle, + EndAngle = d.Constraints.EndAngle, + Allow180Equivalent = d.Constraints.Allow180Equivalent + }, + Material = new MaterialDto + { + Name = d.Material.Name ?? "", + Grade = d.Material.Grade ?? "", + Density = d.Material.Density + }, + Source = new SourceDto + { + Path = d.Source.Path ?? "", + Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y } + } + }); + } + return list; + } - writer.WriteStartDocument(); - writer.WriteStartElement("Plates"); - writer.WriteAttributeString("count", nest.Plates.Count.ToString()); - - for (int i = 0; i < nest.Plates.Count; ++i) + private List BuildPlateDtos() + { + var list = new List(); + for (var i = 0; i < nest.Plates.Count; i++) { var plate = nest.Plates[i]; - - writer.WriteStartElement("Plate"); - writer.WriteAttributeString("id", (i + 1).ToString()); - - writer.WriteElementString("Quadrant", plate.Quadrant.ToString()); - writer.WriteElementString("Thickness", plate.Thickness.ToString()); - writer.WriteElementString("Size", plate.Size.ToString()); - writer.WriteElementString("Qty", plate.Quantity.ToString()); - writer.WriteElementString("PartSpacing", plate.PartSpacing.ToString()); - - writer.WriteStartElement("Material"); - writer.WriteElementString("Name", plate.Material.Name); - writer.WriteElementString("Grade", plate.Material.Grade); - writer.WriteElementString("Density", plate.Material.Density.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("EdgeSpacing"); - writer.WriteElementString("Left", plate.EdgeSpacing.Left.ToString()); - writer.WriteElementString("Top", plate.EdgeSpacing.Top.ToString()); - writer.WriteElementString("Right", plate.EdgeSpacing.Right.ToString()); - writer.WriteElementString("Bottom", plate.EdgeSpacing.Bottom.ToString()); - writer.WriteEndElement(); - - writer.WriteEndElement(); // Plate - writer.Flush(); - } - - writer.WriteEndElement(); // Plates - writer.WriteEndDocument(); - - writer.Flush(); - writer.Close(); - - stream.Position = 0; - - var entry = zipArchive.CreateEntry("plate-info"); - using (var entryStream = entry.Open()) - { - stream.CopyTo(entryStream); - } - } - - private void AddDrawings() - { - int num = 1; - - foreach (var dwg in nest.Drawings) - { - var stream = new MemoryStream(); - var name = string.Format("program-{0}", num.ToString().PadLeft(3, '0')); - - WriteDrawing(stream, dwg); - - var entry = zipArchive.CreateEntry(name); - using (var entryStream = entry.Open()) + var parts = new List(); + foreach (var part in plate.Parts) { - stream.CopyTo(entryStream); + var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault(); + parts.Add(new PartDto + { + DrawingId = match.Key, + X = part.Location.X, + Y = part.Location.Y, + Rotation = part.Rotation + }); } - num++; + list.Add(new PlateDto + { + Id = i + 1, + Size = new SizeDto { Width = plate.Size.Width, Length = plate.Size.Length }, + Thickness = plate.Thickness, + Quadrant = plate.Quadrant, + Quantity = plate.Quantity, + PartSpacing = plate.PartSpacing, + Material = new MaterialDto + { + Name = plate.Material.Name ?? "", + Grade = plate.Material.Grade ?? "", + Density = plate.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = plate.EdgeSpacing.Left, + Top = plate.EdgeSpacing.Top, + Right = plate.EdgeSpacing.Right, + Bottom = plate.EdgeSpacing.Bottom + }, + Parts = parts + }); } + return list; } - private void AddDrawingInfo() + private void WritePrograms(ZipArchive zipArchive) { - var stream = new MemoryStream(); - var writer = XmlWriter.Create(stream, new XmlWriterSettings() + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) { - Indent = true - }); + var name = $"programs/program-{kvp.Key}"; + var stream = new MemoryStream(); + WriteDrawing(stream, kvp.Value); - writer.WriteStartDocument(); - writer.WriteStartElement("Drawings"); - writer.WriteAttributeString("count", nest.Drawings.Count.ToString()); - - int id = 1; - - foreach (var drawing in nest.Drawings) - { - writer.WriteStartElement("Drawing"); - writer.WriteAttributeString("id", id.ToString()); - writer.WriteAttributeString("name", drawing.Name); - - writer.WriteElementString("Customer", drawing.Customer); - writer.WriteElementString("Color", string.Format("{0}, {1}, {2}, {3}", drawing.Color.A, drawing.Color.R, drawing.Color.G, drawing.Color.B)); - - writer.WriteStartElement("Quantity"); - writer.WriteElementString("Required", drawing.Quantity.Required.ToString()); - writer.WriteElementString("Nested", drawing.Quantity.Nested.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("Material"); - writer.WriteElementString("Name", drawing.Material.Name); - writer.WriteElementString("Grade", drawing.Material.Grade); - writer.WriteElementString("Density", drawing.Material.Density.ToString()); - writer.WriteEndElement(); - - writer.WriteStartElement("Source"); - writer.WriteElementString("Path", drawing.Source.Path); - writer.WriteElementString("Offset", string.Format("{0}, {1}", - drawing.Source.Offset.X, - drawing.Source.Offset.Y)); - writer.WriteEndElement(); // Source - - writer.WriteEndElement(); // Drawing - - id++; - } - - writer.WriteEndElement(); // Drawings - writer.WriteEndDocument(); - - writer.Flush(); - writer.Close(); - - stream.Position = 0; - - var entry = zipArchive.CreateEntry("drawing-info"); - using (var entryStream = entry.Open()) - { + var entry = zipArchive.CreateEntry(name); + using var entryStream = entry.Open(); stream.CopyTo(entryStream); } } - private void WritePlate(Stream stream, Plate plate) - { - var writer = new StreamWriter(stream); - writer.AutoFlush = true; - writer.WriteLine("G90"); - - foreach (var part in plate.Parts) - { - var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault(); - var id = match.Key; - - writer.WriteLine("G00X{0}Y{1}", - part.Location.X.ToString(CoordinateFormat), - part.Location.Y.ToString(CoordinateFormat)); - writer.WriteLine("G65P{0}R{1}", id, Angle.ToDegrees(part.Rotation)); - } - - stream.Position = 0; - } - private void WriteDrawing(Stream stream, Drawing drawing) { var program = drawing.Program; @@ -308,7 +207,7 @@ namespace OpenNest.IO writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); - for (int i = 0; i < drawing.Program.Length; ++i) + for (var i = 0; i < drawing.Program.Length; ++i) { var code = drawing.Program[i]; writer.WriteLine(GetCodeString(code)); diff --git a/OpenNest.Mcp/Tools/InputTools.cs b/OpenNest.Mcp/Tools/InputTools.cs index e3f1a28..1596959 100644 --- a/OpenNest.Mcp/Tools/InputTools.cs +++ b/OpenNest.Mcp/Tools/InputTools.cs @@ -40,10 +40,10 @@ namespace OpenNest.Mcp.Tools { var plate = nest.Plates[i]; var work = plate.WorkArea(); - sb.AppendLine($" Plate {i}: {plate.Size.Width:F1} x {plate.Size.Height:F1}, " + + sb.AppendLine($" Plate {i}: {plate.Size.Width:F1} x {plate.Size.Length:F1}, " + $"parts={plate.Parts.Count}, " + $"utilization={plate.Utilization():P1}, " + - $"work area={work.Width:F1} x {work.Height:F1}"); + $"work area={work.Width:F1} x {work.Length:F1}"); } sb.AppendLine($"Drawings: {nest.Drawings.Count}"); @@ -51,7 +51,7 @@ namespace OpenNest.Mcp.Tools foreach (var dwg in nest.Drawings) { var bbox = dwg.Program.BoundingBox(); - sb.AppendLine($" {dwg.Name}: bbox={bbox.Width:F2} x {bbox.Height:F2}, " + + sb.AppendLine($" {dwg.Name}: bbox={bbox.Width:F2} x {bbox.Length:F2}, " + $"required={dwg.Quantity.Required}, nested={dwg.Quantity.Nested}"); } @@ -85,7 +85,7 @@ namespace OpenNest.Mcp.Tools _session.Drawings.Add(drawing); var bbox = pgm.BoundingBox(); - return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Height:F2}"; + return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Length:F2}"; } [McpServerTool(Name = "create_drawing")] @@ -134,7 +134,7 @@ namespace OpenNest.Mcp.Tools _session.Drawings.Add(drawing); var bbox = pgm.BoundingBox(); - return $"Created drawing '{name}': bbox={bbox.Width:F2} x {bbox.Height:F2}"; + return $"Created drawing '{name}': bbox={bbox.Width:F2} x {bbox.Length:F2}"; } private static CncProgram CreateRectangle(double width, double height) diff --git a/OpenNest.Mcp/Tools/InspectionTools.cs b/OpenNest.Mcp/Tools/InspectionTools.cs index 50c95eb..905bd8d 100644 --- a/OpenNest.Mcp/Tools/InspectionTools.cs +++ b/OpenNest.Mcp/Tools/InspectionTools.cs @@ -32,13 +32,13 @@ namespace OpenNest.Mcp.Tools var sb = new StringBuilder(); sb.AppendLine($"Plate {plateIndex}:"); - sb.AppendLine($" Size: {plate.Size.Width:F1} x {plate.Size.Height:F1}"); + sb.AppendLine($" Size: {plate.Size.Width:F1} x {plate.Size.Length:F1}"); sb.AppendLine($" Quadrant: {plate.Quadrant}"); sb.AppendLine($" Thickness: {plate.Thickness:F2}"); sb.AppendLine($" Material: {plate.Material.Name}"); sb.AppendLine($" Part spacing: {plate.PartSpacing:F2}"); sb.AppendLine($" Edge spacing: L={plate.EdgeSpacing.Left:F2} B={plate.EdgeSpacing.Bottom:F2} R={plate.EdgeSpacing.Right:F2} T={plate.EdgeSpacing.Top:F2}"); - sb.AppendLine($" Work area: {work.X:F1},{work.Y:F1} {work.Width:F1}x{work.Height:F1}"); + sb.AppendLine($" Work area: {work.X:F1},{work.Y:F1} {work.Width:F1}x{work.Length:F1}"); sb.AppendLine($" Parts: {plate.Parts.Count}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Quantity: {plate.Quantity}"); @@ -57,7 +57,7 @@ namespace OpenNest.Mcp.Tools for (var i = 0; i < remnants.Count; i++) { var r = remnants[i]; - sb.AppendLine($" Remnant {i}: ({r.X:F1},{r.Y:F1}) {r.Width:F1}x{r.Height:F1}, area={r.Area():F1}"); + sb.AppendLine($" Remnant {i}: ({r.X:F1},{r.Y:F1}) {r.Width:F1}x{r.Length:F1}, area={r.Area():F1}"); } return sb.ToString(); @@ -90,7 +90,7 @@ namespace OpenNest.Mcp.Tools sb.AppendLine($" [{i}] {part.BaseDrawing.Name}: " + $"loc=({part.Location.X:F2},{part.Location.Y:F2}), " + $"rot={rotDeg:F1} deg, " + - $"bbox=({bbox.X:F2},{bbox.Y:F2} {bbox.Width:F2}x{bbox.Height:F2})"); + $"bbox=({bbox.X:F2},{bbox.Y:F2} {bbox.Width:F2}x{bbox.Length:F2})"); } if (plate.Parts.Count > limit) diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 46b865d..76cc3ca 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -121,7 +121,7 @@ namespace OpenNest.Mcp.Tools var added = plate.Parts.Count - countBefore; totalAdded += added; - sb.AppendLine($" Remnant {i}: ({remnant.X:F1},{remnant.Y:F1} {remnant.Width:F1}x{remnant.Height:F1}) -> {added} parts {(success ? "" : "(no fit)")}"); + sb.AppendLine($" Remnant {i}: ({remnant.X:F1},{remnant.Y:F1} {remnant.Width:F1}x{remnant.Length:F1}) -> {added} parts {(success ? "" : "(no fit)")}"); } sb.AppendLine($"Total parts added: {totalAdded}"); diff --git a/OpenNest.Mcp/Tools/SetupTools.cs b/OpenNest.Mcp/Tools/SetupTools.cs index d093738..154503d 100644 --- a/OpenNest.Mcp/Tools/SetupTools.cs +++ b/OpenNest.Mcp/Tools/SetupTools.cs @@ -41,11 +41,11 @@ namespace OpenNest.Mcp.Tools var work = plate.WorkArea(); var sb = new StringBuilder(); - sb.AppendLine($"Created plate {index}: {plate.Size.Width:F1} x {plate.Size.Height:F1}"); + sb.AppendLine($"Created plate {index}: {plate.Size.Width:F1} x {plate.Size.Length:F1}"); sb.AppendLine($" Quadrant: {plate.Quadrant}"); sb.AppendLine($" Part spacing: {plate.PartSpacing:F2}"); sb.AppendLine($" Edge spacing: L={plate.EdgeSpacing.Left:F2} B={plate.EdgeSpacing.Bottom:F2} R={plate.EdgeSpacing.Right:F2} T={plate.EdgeSpacing.Top:F2}"); - sb.AppendLine($" Work area: {work.Width:F1} x {work.Height:F1}"); + sb.AppendLine($" Work area: {work.Width:F1} x {work.Length:F1}"); return sb.ToString(); } diff --git a/OpenNest/Actions/ActionSelectArea.cs b/OpenNest/Actions/ActionSelectArea.cs index 631db37..d65c46b 100644 --- a/OpenNest/Actions/ActionSelectArea.cs +++ b/OpenNest/Actions/ActionSelectArea.cs @@ -95,7 +95,7 @@ namespace OpenNest.Actions var location = plateView.PointWorldToGraph(SelectedArea.Location); var size = new SizeF( plateView.LengthWorldToGui(SelectedArea.Width), - plateView.LengthWorldToGui(SelectedArea.Height)); + plateView.LengthWorldToGui(SelectedArea.Length)); var rect = new System.Drawing.RectangleF(location.X, location.Y - size.Height, size.Width, size.Height); diff --git a/OpenNest/Controls/DrawControl.cs b/OpenNest/Controls/DrawControl.cs index 98ebaf6..3d9f2fc 100644 --- a/OpenNest/Controls/DrawControl.cs +++ b/OpenNest/Controls/DrawControl.cs @@ -205,7 +205,7 @@ namespace OpenNest.Controls public virtual void ZoomToArea(Box box, bool redraw = true) { - ZoomToArea(box.X, box.Y, box.Width, box.Height, redraw); + ZoomToArea(box.X, box.Y, box.Width, box.Length, redraw); } public virtual void ZoomToArea(double x, double y, double width, double height, bool redraw = true) diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index fd91a16..8bd6d5e 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -384,13 +384,13 @@ namespace OpenNest.Controls var plateRect = new RectangleF { Width = LengthWorldToGui(Plate.Size.Width), - Height = LengthWorldToGui(Plate.Size.Height) + Height = LengthWorldToGui(Plate.Size.Length) }; var edgeSpacingRect = new RectangleF { Width = LengthWorldToGui(Plate.Size.Width - Plate.EdgeSpacing.Left - Plate.EdgeSpacing.Right), - Height = LengthWorldToGui(Plate.Size.Height - Plate.EdgeSpacing.Top - Plate.EdgeSpacing.Bottom) + Height = LengthWorldToGui(Plate.Size.Length - Plate.EdgeSpacing.Top - Plate.EdgeSpacing.Bottom) }; switch (Plate.Quadrant) @@ -410,17 +410,17 @@ namespace OpenNest.Controls break; case 3: - plateRect.Location = PointWorldToGraph(-Plate.Size.Width, -Plate.Size.Height); + plateRect.Location = PointWorldToGraph(-Plate.Size.Width, -Plate.Size.Length); edgeSpacingRect.Location = PointWorldToGraph( Plate.EdgeSpacing.Left - Plate.Size.Width, - Plate.EdgeSpacing.Bottom - Plate.Size.Height); + Plate.EdgeSpacing.Bottom - Plate.Size.Length); break; case 4: - plateRect.Location = PointWorldToGraph(0, -Plate.Size.Height); + plateRect.Location = PointWorldToGraph(0, -Plate.Size.Length); edgeSpacingRect.Location = PointWorldToGraph( Plate.EdgeSpacing.Left, - Plate.EdgeSpacing.Bottom - Plate.Size.Height); + Plate.EdgeSpacing.Bottom - Plate.Size.Length); break; default: @@ -590,7 +590,7 @@ namespace OpenNest.Controls { Location = PointWorldToGraph(box.Location), Width = LengthWorldToGui(box.Width), - Height = LengthWorldToGui(box.Height) + Height = LengthWorldToGui(box.Length) }; g.DrawRectangle(ColorScheme.BoundingBoxPen, rect.X, rect.Y - rect.Height, rect.Width, rect.Height); diff --git a/OpenNest/Forms/BestFitViewerForm.cs b/OpenNest/Forms/BestFitViewerForm.cs index 8d1f438..2b52a4d 100644 --- a/OpenNest/Forms/BestFitViewerForm.cs +++ b/OpenNest/Forms/BestFitViewerForm.cs @@ -56,7 +56,7 @@ namespace OpenNest.Forms var sw = Stopwatch.StartNew(); var results = BestFitCache.GetOrCompute( - drawing, plate.Size.Width, plate.Size.Height, plate.PartSpacing); + drawing, plate.Size.Width, plate.Size.Length, plate.PartSpacing); var findMs = sw.ElapsedMilliseconds; var total = results.Count; diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index dd2b4ef..074697a 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -112,7 +112,7 @@ namespace OpenNest.Forms drawing.Source.Path = item.Path; drawing.Quantity.Required = item.Quantity; - var shape = new DefinedShape(entities); + var shape = new ShapeProfile(entities); SetRotation(shape.Perimeter, RotationType.CW); diff --git a/OpenNest/Forms/EditNestInfoForm.cs b/OpenNest/Forms/EditNestInfoForm.cs index 1c9c950..5143fbf 100644 --- a/OpenNest/Forms/EditNestInfoForm.cs +++ b/OpenNest/Forms/EditNestInfoForm.cs @@ -173,7 +173,7 @@ namespace OpenNest.Forms return; } - if (TopSpacing + BottomSpacing >= size.Height) + if (TopSpacing + BottomSpacing >= size.Length) { applyButton.Enabled = false; return; diff --git a/OpenNest/Forms/EditPlateForm.cs b/OpenNest/Forms/EditPlateForm.cs index 4349cc5..1409a18 100644 --- a/OpenNest/Forms/EditPlateForm.cs +++ b/OpenNest/Forms/EditPlateForm.cs @@ -145,7 +145,7 @@ namespace OpenNest.Forms return; } - if (TopSpacing + BottomSpacing >= size.Height) + if (TopSpacing + BottomSpacing >= size.Length) { applyButton.Enabled = false; return; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 9a4341c..2cfeac9 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -43,8 +43,8 @@ namespace OpenNest.Forms UpdateStatus(); UpdateGpuStatus(); - if (GpuEvaluatorFactory.GpuAvailable) - BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing); + //if (GpuEvaluatorFactory.GpuAvailable) + // BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing); } private string GetNestName(DateTime date, int id) diff --git a/docs/superpowers/plans/2026-03-12-cutting-strategy.md b/docs/superpowers/plans/2026-03-12-cutting-strategy.md new file mode 100644 index 0000000..5bd55e6 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-cutting-strategy.md @@ -0,0 +1,1218 @@ +# Cutting Strategy Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add lead-in, lead-out, and tab classes to OpenNest.Core that generate ICode instructions for CNC cutting approach/exit geometry. + +**Architecture:** New `CuttingStrategy/` folder under `OpenNest.Core/CNC/` containing abstract base classes and concrete implementations for lead-ins, lead-outs, and tabs. A `ContourCuttingStrategy` orchestrator uses `ShapeProfile` + `ClosestPointTo` to sequence and apply cutting parameters. Original Drawing/Program geometry is never modified. + +**Tech Stack:** .NET 8, C#, OpenNest.Core (ICode, Program, Vector, Shape, ShapeProfile, Angle) + +**Spec:** `docs/superpowers/specs/2026-03-12-cutting-strategy-design.md` + +--- + +## Chunk 1: LeadIn Hierarchy + +### Task 1: LeadIn abstract base class + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/LeadIn.cs` + +- [ ] **Step 1: Create the abstract base class** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public abstract class LeadIn + { + public abstract List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + + public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/LeadIn.cs +git commit -m "feat: add LeadIn abstract base class" +``` + +--- + +### Task 2: NoLeadIn + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/NoLeadIn.cs` + +- [ ] **Step 1: Create NoLeadIn** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class NoLeadIn : LeadIn + { + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List + { + new RapidMove(contourStartPoint) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + return contourStartPoint; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/NoLeadIn.cs +git commit -m "feat: add NoLeadIn (Type 0)" +``` + +--- + +### Task 3: LineLeadIn + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLeadIn.cs` + +- [ ] **Step 1: Create LineLeadIn** + +Pierce point is offset from contour start along `contourNormalAngle + Angle.ToRadians(ApproachAngle)` by `Length`. + +```csharp +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineLeadIn : LeadIn + { + public double Length { get; set; } + public double ApproachAngle { get; set; } = 90.0; + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(contourStartPoint) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var approachAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + return new Vector( + contourStartPoint.X + Length * System.Math.Cos(approachAngle), + contourStartPoint.Y + Length * System.Math.Sin(approachAngle)); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLeadIn.cs +git commit -m "feat: add LineLeadIn (Type 1)" +``` + +--- + +### Task 4: LineArcLeadIn + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs` + +- [ ] **Step 1: Create LineArcLeadIn** + +Geometry: Pierce → [Line] → Arc start → [Arc] → Contour start. Arc center at `contourStartPoint + ArcRadius` along normal. Arc rotation uses `winding` parameter. + +```csharp +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineArcLeadIn : LeadIn + { + public double LineLength { get; set; } + public double ApproachAngle { get; set; } = 135.0; + public double ArcRadius { get; set; } + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + var arcCenter = new Vector(arcCenterX, arcCenterY); + + var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + var arcStart = new Vector( + arcCenterX + ArcRadius * System.Math.Cos(lineAngle), + arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(arcStart), + new ArcMove(contourStartPoint, arcCenter, winding) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + + var lineAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle); + var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle); + + return new Vector( + arcStartX + LineLength * System.Math.Cos(lineAngle), + arcStartY + LineLength * System.Math.Sin(lineAngle)); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineArcLeadIn.cs +git commit -m "feat: add LineArcLeadIn (Type 2)" +``` + +--- + +### Task 5: ArcLeadIn + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/ArcLeadIn.cs` + +- [ ] **Step 1: Create ArcLeadIn** + +Pierce point is diametrically opposite contour start on the arc circle. + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class ArcLeadIn : LeadIn + { + public double Radius { get; set; } + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var arcCenter = new Vector( + contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle), + contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle)); + + return new List + { + new RapidMove(piercePoint), + new ArcMove(contourStartPoint, arcCenter, winding) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var arcCenterX = contourStartPoint.X + Radius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + Radius * System.Math.Sin(contourNormalAngle); + + return new Vector( + arcCenterX + Radius * System.Math.Cos(contourNormalAngle), + arcCenterY + Radius * System.Math.Sin(contourNormalAngle)); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/ArcLeadIn.cs +git commit -m "feat: add ArcLeadIn (Type 3)" +``` + +--- + +### Task 6: LineLineLeadIn + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLineLeadIn.cs` + +- [ ] **Step 1: Create LineLineLeadIn** + +Two-segment approach: pierce → midpoint → contour start. + +```csharp +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineLineLeadIn : LeadIn + { + public double Length1 { get; set; } + public double ApproachAngle1 { get; set; } = 90.0; + public double Length2 { get; set; } + public double ApproachAngle2 { get; set; } = 90.0; + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1); + var midPoint = new Vector( + contourStartPoint.X + Length2 * System.Math.Cos(secondAngle), + contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle)); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(midPoint), + new LinearMove(contourStartPoint) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var secondAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle1); + var midX = contourStartPoint.X + Length2 * System.Math.Cos(secondAngle); + var midY = contourStartPoint.Y + Length2 * System.Math.Sin(secondAngle); + + var firstAngle = secondAngle + Angle.ToRadians(ApproachAngle2); + return new Vector( + midX + Length1 * System.Math.Cos(firstAngle), + midY + Length1 * System.Math.Sin(firstAngle)); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/LineLineLeadIn.cs +git commit -m "feat: add LineLineLeadIn (Type 5)" +``` + +--- + +### Task 7: CleanHoleLeadIn + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs` + +- [ ] **Step 1: Create CleanHoleLeadIn** + +Same geometry as LineArcLeadIn but with hard-coded 135° angle. Kerf property stored for the paired lead-out to use. + +```csharp +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class CleanHoleLeadIn : LeadIn + { + public double LineLength { get; set; } + public double ArcRadius { get; set; } + public double Kerf { get; set; } + + public override List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var piercePoint = GetPiercePoint(contourStartPoint, contourNormalAngle); + + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + var arcCenter = new Vector(arcCenterX, arcCenterY); + + var lineAngle = contourNormalAngle + Angle.ToRadians(135.0); + var arcStart = new Vector( + arcCenterX + ArcRadius * System.Math.Cos(lineAngle), + arcCenterY + ArcRadius * System.Math.Sin(lineAngle)); + + return new List + { + new RapidMove(piercePoint), + new LinearMove(arcStart), + new ArcMove(contourStartPoint, arcCenter, winding) + }; + } + + public override Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle) + { + var arcCenterX = contourStartPoint.X + ArcRadius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourStartPoint.Y + ArcRadius * System.Math.Sin(contourNormalAngle); + + var lineAngle = contourNormalAngle + Angle.ToRadians(135.0); + var arcStartX = arcCenterX + ArcRadius * System.Math.Cos(lineAngle); + var arcStartY = arcCenterY + ArcRadius * System.Math.Sin(lineAngle); + + return new Vector( + arcStartX + LineLength * System.Math.Cos(lineAngle), + arcStartY + LineLength * System.Math.Sin(lineAngle)); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadIns/CleanHoleLeadIn.cs +git commit -m "feat: add CleanHoleLeadIn" +``` + +--- + +## Chunk 2: LeadOut Hierarchy + +### Task 8: LeadOut abstract base class + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LeadOut.cs` + +- [ ] **Step 1: Create the abstract base class** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public abstract class LeadOut + { + public abstract List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LeadOut.cs +git commit -m "feat: add LeadOut abstract base class" +``` + +--- + +### Task 9: NoLeadOut + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadOuts/NoLeadOut.cs` + +- [ ] **Step 1: Create NoLeadOut** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class NoLeadOut : LeadOut + { + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List(); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadOuts/NoLeadOut.cs +git commit -m "feat: add NoLeadOut (Type 0)" +``` + +--- + +### Task 10: LineLeadOut + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LineLeadOut.cs` + +- [ ] **Step 1: Create LineLeadOut** + +```csharp +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class LineLeadOut : LeadOut + { + public double Length { get; set; } + public double ApproachAngle { get; set; } = 90.0; + + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var overcutAngle = contourNormalAngle + Angle.ToRadians(ApproachAngle); + var endPoint = new Vector( + contourEndPoint.X + Length * System.Math.Cos(overcutAngle), + contourEndPoint.Y + Length * System.Math.Sin(overcutAngle)); + + return new List + { + new LinearMove(endPoint) + }; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadOuts/LineLeadOut.cs +git commit -m "feat: add LineLeadOut (Type 1)" +``` + +--- + +### Task 11: ArcLeadOut + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadOuts/ArcLeadOut.cs` + +- [ ] **Step 1: Create ArcLeadOut** + +Arc curves away from the part. End point is a quarter turn from contour end. + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class ArcLeadOut : LeadOut + { + public double Radius { get; set; } + + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var arcCenterX = contourEndPoint.X + Radius * System.Math.Cos(contourNormalAngle); + var arcCenterY = contourEndPoint.Y + Radius * System.Math.Sin(contourNormalAngle); + var arcCenter = new Vector(arcCenterX, arcCenterY); + + var endPoint = new Vector( + arcCenterX + Radius * System.Math.Cos(contourNormalAngle + System.Math.PI / 2), + arcCenterY + Radius * System.Math.Sin(contourNormalAngle + System.Math.PI / 2)); + + return new List + { + new ArcMove(endPoint, arcCenter, winding) + }; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadOuts/ArcLeadOut.cs +git commit -m "feat: add ArcLeadOut (Type 3)" +``` + +--- + +### Task 12: MicrotabLeadOut + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/LeadOuts/MicrotabLeadOut.cs` + +- [ ] **Step 1: Create MicrotabLeadOut** + +Returns empty list — the `ContourCuttingStrategy` handles trimming the last cutting move. + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class MicrotabLeadOut : LeadOut + { + public double GapSize { get; set; } = 0.03; + + public override List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List(); + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/LeadOuts/MicrotabLeadOut.cs +git commit -m "feat: add MicrotabLeadOut (Type 4)" +``` + +--- + +## Chunk 3: Tab Hierarchy + +### Task 13: Tab abstract base class + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/Tabs/Tab.cs` + +- [ ] **Step 1: Create Tab base class** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public abstract class Tab + { + public double Size { get; set; } = 0.03; + public LeadIn TabLeadIn { get; set; } + public LeadOut TabLeadOut { get; set; } + + public abstract List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/Tabs/Tab.cs +git commit -m "feat: add Tab abstract base class" +``` + +--- + +### Task 14: NormalTab + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/Tabs/NormalTab.cs` + +- [ ] **Step 1: Create NormalTab** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class NormalTab : Tab + { + public double CutoutMinWidth { get; set; } + public double CutoutMinHeight { get; set; } + public double CutoutMaxWidth { get; set; } + public double CutoutMaxHeight { get; set; } + + public override List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var codes = new List(); + + if (TabLeadOut != null) + codes.AddRange(TabLeadOut.Generate(tabStartPoint, contourNormalAngle, winding)); + + codes.Add(new RapidMove(tabEndPoint)); + + if (TabLeadIn != null) + codes.AddRange(TabLeadIn.Generate(tabEndPoint, contourNormalAngle, winding)); + + return codes; + } + + public bool AppliesToCutout(double cutoutWidth, double cutoutHeight) + { + return cutoutWidth >= CutoutMinWidth && cutoutWidth <= CutoutMaxWidth + && cutoutHeight >= CutoutMinHeight && cutoutHeight <= CutoutMaxHeight; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/Tabs/NormalTab.cs +git commit -m "feat: add NormalTab" +``` + +--- + +### Task 15: BreakerTab + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/Tabs/BreakerTab.cs` + +- [ ] **Step 1: Create BreakerTab** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class BreakerTab : Tab + { + public double BreakerDepth { get; set; } + public double BreakerLeadInLength { get; set; } + public double BreakerAngle { get; set; } + + public override List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + var codes = new List(); + + if (TabLeadOut != null) + codes.AddRange(TabLeadOut.Generate(tabStartPoint, contourNormalAngle, winding)); + + var scoreAngle = contourNormalAngle + System.Math.PI; + var scoreEnd = new Vector( + tabStartPoint.X + BreakerDepth * System.Math.Cos(scoreAngle), + tabStartPoint.Y + BreakerDepth * System.Math.Sin(scoreAngle)); + codes.Add(new LinearMove(scoreEnd)); + codes.Add(new RapidMove(tabEndPoint)); + + if (TabLeadIn != null) + codes.AddRange(TabLeadIn.Generate(tabEndPoint, contourNormalAngle, winding)); + + return codes; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/Tabs/BreakerTab.cs +git commit -m "feat: add BreakerTab" +``` + +--- + +### Task 16: MachineTab + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/Tabs/MachineTab.cs` + +- [ ] **Step 1: Create MachineTab** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class MachineTab : Tab + { + public int MachineTabId { get; set; } + + public override List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW) + { + return new List + { + new RapidMove(tabEndPoint) + }; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/Tabs/MachineTab.cs +git commit -m "feat: add MachineTab" +``` + +--- + +## Chunk 4: Configuration Classes + +### Task 17: ContourType enum, SequenceParameters, AssignmentParameters + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/ContourType.cs` +- Create: `OpenNest.Core/CNC/CuttingStrategy/SequenceParameters.cs` +- Create: `OpenNest.Core/CNC/CuttingStrategy/AssignmentParameters.cs` + +- [ ] **Step 1: Create ContourType.cs** + +```csharp +namespace OpenNest.CNC.CuttingStrategy +{ + public enum ContourType + { + External, + Internal, + ArcCircle + } +} +``` + +- [ ] **Step 2: Create SequenceParameters.cs** + +```csharp +namespace OpenNest.CNC.CuttingStrategy +{ + // Values match PEP Technology's numbering scheme (value 6 intentionally skipped) + public enum SequenceMethod + { + RightSide = 1, + LeastCode = 2, + Advanced = 3, + BottomSide = 4, + EdgeStart = 5, + LeftSide = 7, + RightSideAlt = 8 + } + + public class SequenceParameters + { + public SequenceMethod Method { get; set; } = SequenceMethod.Advanced; + public double SmallCutoutWidth { get; set; } = 1.5; + public double SmallCutoutHeight { get; set; } = 1.5; + public double MediumCutoutWidth { get; set; } = 8.0; + public double MediumCutoutHeight { get; set; } = 8.0; + public double DistanceMediumSmall { get; set; } + public bool AlternateRowsColumns { get; set; } = true; + public bool AlternateCutoutsWithinRowColumn { get; set; } = true; + public double MinDistanceBetweenRowsColumns { get; set; } = 0.25; + } +} +``` + +- [ ] **Step 3: Create AssignmentParameters.cs** + +```csharp +namespace OpenNest.CNC.CuttingStrategy +{ + public class AssignmentParameters + { + public SequenceMethod Method { get; set; } = SequenceMethod.Advanced; + public string Preference { get; set; } = "ILAT"; + public double MinGeometryLength { get; set; } = 0.01; + } +} +``` + +- [ ] **Step 4: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/ContourType.cs \ + OpenNest.Core/CNC/CuttingStrategy/SequenceParameters.cs \ + OpenNest.Core/CNC/CuttingStrategy/AssignmentParameters.cs +git commit -m "feat: add ContourType, SequenceParameters, AssignmentParameters" +``` + +--- + +### Task 18: CuttingParameters + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs` + +- [ ] **Step 1: Create CuttingParameters** + +Note: `InternalLeadIn` default uses `ApproachAngle` (not `Angle`) per the naming convention. + +```csharp +namespace OpenNest.CNC.CuttingStrategy +{ + public class CuttingParameters + { + public int Id { get; set; } + + public string MachineName { get; set; } + public string MaterialName { get; set; } + public string Grade { get; set; } + public double Thickness { get; set; } + + public double Kerf { get; set; } + public double PartSpacing { get; set; } + + public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); + public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); + + public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }; + public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); + + public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn(); + public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut(); + + public Tab TabConfig { get; set; } + public bool TabsEnabled { get; set; } + + public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); + public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/CuttingParameters.cs +git commit -m "feat: add CuttingParameters" +``` + +--- + +## Chunk 5: ContourCuttingStrategy Orchestrator + +### Task 19: ContourCuttingStrategy + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` + +**Reference files:** +- `OpenNest.Core/Plate.cs` — `Quadrant` (int 1-4), `Size` (Size with `.Width`, `.Length`) +- `OpenNest.Core/CNC/Program.cs` — `ToGeometry()` returns `List`, `Codes` field +- `OpenNest.Core/Geometry/ShapeProfile.cs` — constructor takes `List`, has `.Perimeter` (Shape) and `.Cutouts` (List<Shape>) +- `OpenNest.Core/Geometry/Shape.cs` — `ClosestPointTo(Vector pt, out Entity entity)`, `Entities` list + +- [ ] **Step 1: Create ContourCuttingStrategy with exit point computation and contour type detection** + +```csharp +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public class ContourCuttingStrategy + { + public CuttingParameters Parameters { get; set; } + + public Program Apply(Program partProgram, Plate plate) + { + var exitPoint = GetExitPoint(plate); + var entities = partProgram.ToGeometry(); + var profile = new ShapeProfile(entities); + + // Find closest point on perimeter from exit point + var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity); + + // Chain cutouts by nearest-neighbor from perimeter point, then reverse + // so farthest cutouts are cut first, nearest-to-perimeter cut last + var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint); + orderedCutouts.Reverse(); + + // Build output program: cutouts first (farthest to nearest), perimeter last + var result = new Program(); + var currentPoint = exitPoint; + + foreach (var cutout in orderedCutouts) + { + var contourType = DetectContourType(cutout); + var closestPt = cutout.ClosestPointTo(currentPoint, out var entity); + var normal = ComputeNormal(closestPt, entity, contourType); + var winding = DetermineWinding(cutout); + + var leadIn = SelectLeadIn(contourType); + var leadOut = SelectLeadOut(contourType); + + result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding)); + // Contour re-indexing: split shape entities at closestPt so cutting + // starts there, convert to ICode, and add to result.Codes + throw new System.NotImplementedException("Contour re-indexing not yet implemented"); + result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding)); + + currentPoint = closestPt; + } + + // Perimeter last + { + var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); + var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); + var winding = DetermineWinding(profile.Perimeter); + + var leadIn = SelectLeadIn(ContourType.External); + var leadOut = SelectLeadOut(ContourType.External); + + result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding)); + throw new System.NotImplementedException("Contour re-indexing not yet implemented"); + result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding)); + } + + return result; + } + + private Vector GetExitPoint(Plate plate) + { + var w = plate.Size.Width; + var l = plate.Size.Length; + + return plate.Quadrant switch + { + 1 => new Vector(0, 0), // Q1 TopRight origin → exit BottomLeft + 2 => new Vector(w, 0), // Q2 TopLeft origin → exit BottomRight + 3 => new Vector(w, l), // Q3 BottomLeft origin → exit TopRight + 4 => new Vector(0, l), // Q4 BottomRight origin → exit TopLeft + _ => new Vector(0, 0) + }; + } + + private List SequenceCutouts(List cutouts, Vector startPoint) + { + var remaining = new List(cutouts); + var ordered = new List(); + var currentPoint = startPoint; + + while (remaining.Count > 0) + { + var nearest = remaining[0]; + var nearestPt = nearest.ClosestPointTo(currentPoint); + var nearestDist = nearestPt.DistanceTo(currentPoint); + + for (var i = 1; i < remaining.Count; i++) + { + var pt = remaining[i].ClosestPointTo(currentPoint); + var dist = pt.DistanceTo(currentPoint); + if (dist < nearestDist) + { + nearest = remaining[i]; + nearestPt = pt; + nearestDist = dist; + } + } + + ordered.Add(nearest); + remaining.Remove(nearest); + currentPoint = nearestPt; + } + + return ordered; + } + + private ContourType DetectContourType(Shape cutout) + { + if (cutout.Entities.Count == 1 && cutout.Entities[0] is Circle) + return ContourType.ArcCircle; + + return ContourType.Internal; + } + + private double ComputeNormal(Vector point, Entity entity, ContourType contourType) + { + double normal; + + if (entity is Line line) + { + // Perpendicular to line direction + var tangent = line.EndPoint.AngleFrom(line.StartPoint); + normal = tangent + Math.Angle.HalfPI; + } + else if (entity is Arc arc) + { + // Radial direction from center to point + normal = point.AngleFrom(arc.Center); + } + else if (entity is Circle circle) + { + normal = point.AngleFrom(circle.Center); + } + else + { + normal = 0; + } + + // For internal contours, flip the normal (point into scrap) + if (contourType == ContourType.Internal || contourType == ContourType.ArcCircle) + normal += System.Math.PI; + + return Math.Angle.NormalizeRad(normal); + } + + private RotationType DetermineWinding(Shape shape) + { + // Use signed area: positive = CCW, negative = CW + var area = shape.Area(); + return area >= 0 ? RotationType.CCW : RotationType.CW; + } + + private LeadIn SelectLeadIn(ContourType contourType) + { + return contourType switch + { + ContourType.ArcCircle => Parameters.ArcCircleLeadIn ?? Parameters.InternalLeadIn, + ContourType.Internal => Parameters.InternalLeadIn, + _ => Parameters.ExternalLeadIn + }; + } + + private LeadOut SelectLeadOut(ContourType contourType) + { + return contourType switch + { + ContourType.ArcCircle => Parameters.ArcCircleLeadOut ?? Parameters.InternalLeadOut, + ContourType.Internal => Parameters.InternalLeadOut, + _ => Parameters.ExternalLeadOut + }; + } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build OpenNest.Core` +Expected: success. If `Shape.Area()` signed-area convention is wrong for winding detection, adjust `DetermineWinding` — check `Shape.Area()` implementation to confirm sign convention. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +git commit -m "feat: add ContourCuttingStrategy orchestrator + +Exit point from plate quadrant, nearest-neighbor cutout +sequencing via ShapeProfile + ClosestPointTo, contour type +detection, and normal angle computation." +``` + +--- + +### Task 20: Full solution build verification + +- [ ] **Step 1: Build entire solution** + +Run: `dotnet build OpenNest.sln` +Expected: success with no errors. Warnings are acceptable. + +- [ ] **Step 2: Verify file structure** + +Run: `find OpenNest.Core/CNC/CuttingStrategy -name '*.cs' | sort` +Expected output should match the spec's file structure (21 files total). + +- [ ] **Step 3: Final commit if any fixups needed** + +```bash +git add -A OpenNest.Core/CNC/CuttingStrategy/ +git commit -m "chore: fixup cutting strategy build issues" +``` diff --git a/docs/superpowers/plans/2026-03-12-nest-file-format-v2.md b/docs/superpowers/plans/2026-03-12-nest-file-format-v2.md new file mode 100644 index 0000000..21a371a --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-nest-file-format-v2.md @@ -0,0 +1,767 @@ +# Nest File Format v2 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the XML+G-code nest file format with a single `nest.json` metadata file plus `programs/` folder inside the ZIP archive. + +**Architecture:** Add a `NestFormat` static class containing DTO records and shared JSON options. Rewrite `NestWriter` to serialize DTOs to JSON and write programs under `programs/`. Rewrite `NestReader` to deserialize JSON and read programs from `programs/`. Public API unchanged. + +**Tech Stack:** `System.Text.Json` (built into .NET 8, no new packages needed) + +**Spec:** `docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `OpenNest.IO/NestFormat.cs` | DTO records for JSON serialization + shared `JsonSerializerOptions` | +| Rewrite | `OpenNest.IO/NestWriter.cs` | Serialize nest to JSON + write programs to `programs/` folder | +| Rewrite | `OpenNest.IO/NestReader.cs` | Deserialize JSON + read programs from `programs/` folder | + +No other files change. `ProgramReader.cs`, `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs`, all domain model classes, and all caller sites remain untouched. + +--- + +## Chunk 1: DTO Records and JSON Options + +### Task 1: Create NestFormat.cs with DTO records + +**Files:** +- Create: `OpenNest.IO/NestFormat.cs` + +These DTOs are the JSON shape — flat records that map 1:1 with the spec's JSON schema. They live in `OpenNest.IO` because they're serialization concerns, not domain model. + +- [ ] **Step 1: Create `NestFormat.cs`** + +```csharp +using System.Text.Json; + +namespace OpenNest.IO +{ + public static class NestFormat + { + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public record NestDto + { + public int Version { get; init; } = 2; + public string Name { get; init; } = ""; + public string Units { get; init; } = "Inches"; + public string Customer { get; init; } = ""; + public string DateCreated { get; init; } = ""; + public string DateLastModified { get; init; } = ""; + public string Notes { get; init; } = ""; + public PlateDefaultsDto PlateDefaults { get; init; } = new(); + public List Drawings { get; init; } = new(); + public List Plates { get; init; } = new(); + } + + public record PlateDefaultsDto + { + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + } + + public record DrawingDto + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Customer { get; init; } = ""; + public ColorDto Color { get; init; } = new(); + public QuantityDto Quantity { get; init; } = new(); + public int Priority { get; init; } + public ConstraintsDto Constraints { get; init; } = new(); + public MaterialDto Material { get; init; } = new(); + public SourceDto Source { get; init; } = new(); + } + + public record PlateDto + { + public int Id { get; init; } + public SizeDto Size { get; init; } = new(); + public double Thickness { get; init; } + public int Quadrant { get; init; } = 1; + public int Quantity { get; init; } = 1; + public double PartSpacing { get; init; } + public MaterialDto Material { get; init; } = new(); + public SpacingDto EdgeSpacing { get; init; } = new(); + public List Parts { get; init; } = new(); + } + + public record PartDto + { + public int DrawingId { get; init; } + public double X { get; init; } + public double Y { get; init; } + public double Rotation { get; init; } + } + + public record SizeDto + { + public double Width { get; init; } + public double Height { get; init; } + } + + public record MaterialDto + { + public string Name { get; init; } = ""; + public string Grade { get; init; } = ""; + public double Density { get; init; } + } + + public record SpacingDto + { + public double Left { get; init; } + public double Top { get; init; } + public double Right { get; init; } + public double Bottom { get; init; } + } + + public record ColorDto + { + public int A { get; init; } = 255; + public int R { get; init; } + public int G { get; init; } + public int B { get; init; } + } + + public record QuantityDto + { + public int Required { get; init; } + } + + public record ConstraintsDto + { + public double StepAngle { get; init; } + public double StartAngle { get; init; } + public double EndAngle { get; init; } + public bool Allow180Equivalent { get; init; } + } + + public record SourceDto + { + public string Path { get; init; } = ""; + public OffsetDto Offset { get; init; } = new(); + } + + public record OffsetDto + { + public double X { get; init; } + public double Y { get; init; } + } + } +} +``` + +- [ ] **Step 2: Build to verify DTOs compile** + +Run: `dotnet build OpenNest.IO/OpenNest.IO.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.IO/NestFormat.cs +git commit -m "feat: add NestFormat DTOs for JSON nest file format v2" +``` + +--- + +## Chunk 2: Rewrite NestWriter + +### Task 2: Rewrite NestWriter to use JSON serialization + +**Files:** +- Rewrite: `OpenNest.IO/NestWriter.cs` + +The writer keeps the same public API: `NestWriter(Nest nest)` constructor and `bool Write(string file)`. Internally it builds a `NestDto` from the domain model, serializes it to `nest.json`, and writes each drawing's program to `programs/program-N`. + +The G-code writing methods (`WriteDrawing`, `GetCodeString`, `GetLayerString`) are preserved exactly — they write program G-code to streams, which is unchanged. The `WritePlate` method and all XML methods (`AddNestInfo`, `AddPlateInfo`, `AddDrawingInfo`) are removed. + +- [ ] **Step 1: Rewrite `NestWriter.cs`** + +Replace the entire file. Key changes: +- Remove `using System.Xml` +- Add `using System.Text.Json` +- Remove `AddNestInfo()`, `AddPlateInfo()`, `AddDrawingInfo()`, `AddPlates()`, `WritePlate()` methods +- Add `BuildNestDto()` method that maps domain model → DTOs +- `Write()` now serializes `NestDto` to `nest.json` and writes programs to `programs/program-N` +- Keep `WriteDrawing()`, `GetCodeString()`, `GetLayerString()` exactly as-is + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using OpenNest.CNC; +using OpenNest.Math; +using static OpenNest.IO.NestFormat; + +namespace OpenNest.IO +{ + public sealed class NestWriter + { + private const int OutputPrecision = 10; + private const string CoordinateFormat = "0.##########"; + + private readonly Nest nest; + private Dictionary drawingDict; + + public NestWriter(Nest nest) + { + this.drawingDict = new Dictionary(); + this.nest = nest; + } + + public bool Write(string file) + { + nest.DateLastModified = DateTime.Now; + SetDrawingIds(); + + using var fileStream = new FileStream(file, FileMode.Create); + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create); + + WriteNestJson(zipArchive); + WritePrograms(zipArchive); + + return true; + } + + private void SetDrawingIds() + { + var id = 1; + foreach (var drawing in nest.Drawings) + { + drawingDict.Add(id, drawing); + id++; + } + } + + private void WriteNestJson(ZipArchive zipArchive) + { + var dto = BuildNestDto(); + var json = JsonSerializer.Serialize(dto, JsonOptions); + + var entry = zipArchive.CreateEntry("nest.json"); + using var stream = entry.Open(); + using var writer = new StreamWriter(stream, Encoding.UTF8); + writer.Write(json); + } + + private NestDto BuildNestDto() + { + return new NestDto + { + Version = 2, + Name = nest.Name ?? "", + Units = nest.Units.ToString(), + Customer = nest.Customer ?? "", + DateCreated = nest.DateCreated.ToString("o"), + DateLastModified = nest.DateLastModified.ToString("o"), + Notes = nest.Notes ?? "", + PlateDefaults = BuildPlateDefaultsDto(), + Drawings = BuildDrawingDtos(), + Plates = BuildPlateDtos() + }; + } + + private PlateDefaultsDto BuildPlateDefaultsDto() + { + var pd = nest.PlateDefaults; + return new PlateDefaultsDto + { + Size = new SizeDto { Width = pd.Size.Width, Height = pd.Size.Height }, + Thickness = pd.Thickness, + Quadrant = pd.Quadrant, + PartSpacing = pd.PartSpacing, + Material = new MaterialDto + { + Name = pd.Material.Name ?? "", + Grade = pd.Material.Grade ?? "", + Density = pd.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = pd.EdgeSpacing.Left, + Top = pd.EdgeSpacing.Top, + Right = pd.EdgeSpacing.Right, + Bottom = pd.EdgeSpacing.Bottom + } + }; + } + + private List BuildDrawingDtos() + { + var list = new List(); + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) + { + var d = kvp.Value; + list.Add(new DrawingDto + { + Id = kvp.Key, + Name = d.Name ?? "", + Customer = d.Customer ?? "", + Color = new ColorDto { A = d.Color.A, R = d.Color.R, G = d.Color.G, B = d.Color.B }, + Quantity = new QuantityDto { Required = d.Quantity.Required }, + Priority = d.Priority, + Constraints = new ConstraintsDto + { + StepAngle = d.Constraints.StepAngle, + StartAngle = d.Constraints.StartAngle, + EndAngle = d.Constraints.EndAngle, + Allow180Equivalent = d.Constraints.Allow180Equivalent + }, + Material = new MaterialDto + { + Name = d.Material.Name ?? "", + Grade = d.Material.Grade ?? "", + Density = d.Material.Density + }, + Source = new SourceDto + { + Path = d.Source.Path ?? "", + Offset = new OffsetDto { X = d.Source.Offset.X, Y = d.Source.Offset.Y } + } + }); + } + return list; + } + + private List BuildPlateDtos() + { + var list = new List(); + for (var i = 0; i < nest.Plates.Count; i++) + { + var plate = nest.Plates[i]; + var parts = new List(); + foreach (var part in plate.Parts) + { + var match = drawingDict.Where(dwg => dwg.Value == part.BaseDrawing).FirstOrDefault(); + parts.Add(new PartDto + { + DrawingId = match.Key, + X = part.Location.X, + Y = part.Location.Y, + Rotation = part.Rotation + }); + } + + list.Add(new PlateDto + { + Id = i + 1, + Size = new SizeDto { Width = plate.Size.Width, Height = plate.Size.Height }, + Thickness = plate.Thickness, + Quadrant = plate.Quadrant, + Quantity = plate.Quantity, + PartSpacing = plate.PartSpacing, + Material = new MaterialDto + { + Name = plate.Material.Name ?? "", + Grade = plate.Material.Grade ?? "", + Density = plate.Material.Density + }, + EdgeSpacing = new SpacingDto + { + Left = plate.EdgeSpacing.Left, + Top = plate.EdgeSpacing.Top, + Right = plate.EdgeSpacing.Right, + Bottom = plate.EdgeSpacing.Bottom + }, + Parts = parts + }); + } + return list; + } + + private void WritePrograms(ZipArchive zipArchive) + { + foreach (var kvp in drawingDict.OrderBy(k => k.Key)) + { + var name = $"programs/program-{kvp.Key}"; + var stream = new MemoryStream(); + WriteDrawing(stream, kvp.Value); + + var entry = zipArchive.CreateEntry(name); + using var entryStream = entry.Open(); + stream.CopyTo(entryStream); + } + } + + private void WriteDrawing(Stream stream, Drawing drawing) + { + var program = drawing.Program; + var writer = new StreamWriter(stream); + writer.AutoFlush = true; + + writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); + + for (var i = 0; i < drawing.Program.Length; ++i) + { + var code = drawing.Program[i]; + writer.WriteLine(GetCodeString(code)); + } + + stream.Position = 0; + } + + private string GetCodeString(ICode code) + { + switch (code.Type) + { + case CodeType.ArcMove: + { + var sb = new StringBuilder(); + var arcMove = (ArcMove)code; + + var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat); + var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat); + var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat); + var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat); + + if (arcMove.Rotation == RotationType.CW) + sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j)); + else + sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j)); + + if (arcMove.Layer != LayerType.Cut) + sb.Append(GetLayerString(arcMove.Layer)); + + return sb.ToString(); + } + + case CodeType.Comment: + { + var comment = (Comment)code; + return ":" + comment.Value; + } + + case CodeType.LinearMove: + { + var sb = new StringBuilder(); + var linearMove = (LinearMove)code; + + sb.Append(string.Format("G01X{0}Y{1}", + System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat), + System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat))); + + if (linearMove.Layer != LayerType.Cut) + sb.Append(GetLayerString(linearMove.Layer)); + + return sb.ToString(); + } + + case CodeType.RapidMove: + { + var rapidMove = (RapidMove)code; + + return string.Format("G00X{0}Y{1}", + System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat), + System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)); + } + + case CodeType.SetFeedrate: + { + var setFeedrate = (Feedrate)code; + return "F" + setFeedrate.Value; + } + + case CodeType.SetKerf: + { + var setKerf = (Kerf)code; + + switch (setKerf.Value) + { + case KerfType.None: return "G40"; + case KerfType.Left: return "G41"; + case KerfType.Right: return "G42"; + } + + break; + } + + case CodeType.SubProgramCall: + { + var subProgramCall = (SubProgramCall)code; + break; + } + } + + return string.Empty; + } + + private string GetLayerString(LayerType layer) + { + switch (layer) + { + case LayerType.Display: + return ":DISPLAY"; + + case LayerType.Leadin: + return ":LEADIN"; + + case LayerType.Leadout: + return ":LEADOUT"; + + case LayerType.Scribe: + return ":SCRIBE"; + + default: + return string.Empty; + } + } + } +} +``` + +- [ ] **Step 2: Build to verify NestWriter compiles** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.IO/NestWriter.cs +git commit -m "feat: rewrite NestWriter to use JSON format v2" +``` + +--- + +## Chunk 3: Rewrite NestReader + +### Task 3: Rewrite NestReader to use JSON deserialization + +**Files:** +- Rewrite: `OpenNest.IO/NestReader.cs` + +The reader keeps the same public API: `NestReader(string file)`, `NestReader(Stream stream)`, and `Nest Read()`. Internally it reads `nest.json`, deserializes to `NestDto`, reads programs from `programs/program-N`, and assembles the domain model. + +All XML parsing, plate G-code parsing, dictionary-linking (`LinkProgramsToDrawings`, `LinkPartsToPlates`), and the helper enums/methods are removed. + +- [ ] **Step 1: Rewrite `NestReader.cs`** + +Replace the entire file: + +```csharp +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using OpenNest.CNC; +using OpenNest.Geometry; +using static OpenNest.IO.NestFormat; + +namespace OpenNest.IO +{ + public sealed class NestReader + { + private readonly Stream stream; + private readonly ZipArchive zipArchive; + + public NestReader(string file) + { + stream = new FileStream(file, FileMode.Open, FileAccess.Read); + zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); + } + + public NestReader(Stream stream) + { + this.stream = stream; + zipArchive = new ZipArchive(stream, ZipArchiveMode.Read); + } + + public Nest Read() + { + var nestJson = ReadEntry("nest.json"); + var dto = JsonSerializer.Deserialize(nestJson, JsonOptions); + + var programs = ReadPrograms(dto.Drawings.Count); + var drawingMap = BuildDrawings(dto, programs); + var nest = BuildNest(dto, drawingMap); + + zipArchive.Dispose(); + stream.Close(); + + return nest; + } + + private string ReadEntry(string name) + { + var entry = zipArchive.GetEntry(name) + ?? throw new InvalidDataException($"Nest file is missing required entry '{name}'."); + using var entryStream = entry.Open(); + using var reader = new StreamReader(entryStream); + return reader.ReadToEnd(); + } + + private Dictionary ReadPrograms(int count) + { + var programs = new Dictionary(); + for (var i = 1; i <= count; i++) + { + var entry = zipArchive.GetEntry($"programs/program-{i}"); + if (entry == null) continue; + + using var entryStream = entry.Open(); + var memStream = new MemoryStream(); + entryStream.CopyTo(memStream); + memStream.Position = 0; + + var reader = new ProgramReader(memStream); + programs[i] = reader.Read(); + } + return programs; + } + + private Dictionary BuildDrawings(NestDto dto, Dictionary programs) + { + var map = new Dictionary(); + foreach (var d in dto.Drawings) + { + var drawing = new Drawing(d.Name); + drawing.Customer = d.Customer; + drawing.Color = Color.FromArgb(d.Color.A, d.Color.R, d.Color.G, d.Color.B); + drawing.Quantity.Required = d.Quantity.Required; + drawing.Priority = d.Priority; + drawing.Constraints.StepAngle = d.Constraints.StepAngle; + drawing.Constraints.StartAngle = d.Constraints.StartAngle; + drawing.Constraints.EndAngle = d.Constraints.EndAngle; + drawing.Constraints.Allow180Equivalent = d.Constraints.Allow180Equivalent; + drawing.Material = new Material(d.Material.Name, d.Material.Grade, d.Material.Density); + drawing.Source.Path = d.Source.Path; + drawing.Source.Offset = new Vector(d.Source.Offset.X, d.Source.Offset.Y); + + if (programs.TryGetValue(d.Id, out var pgm)) + drawing.Program = pgm; + + map[d.Id] = drawing; + } + return map; + } + + private Nest BuildNest(NestDto dto, Dictionary drawingMap) + { + var nest = new Nest(); + nest.Name = dto.Name; + + Units units; + if (Enum.TryParse(dto.Units, true, out units)) + nest.Units = units; + + nest.Customer = dto.Customer; + nest.DateCreated = DateTime.Parse(dto.DateCreated); + nest.DateLastModified = DateTime.Parse(dto.DateLastModified); + nest.Notes = dto.Notes; + + // Plate defaults + var pd = dto.PlateDefaults; + nest.PlateDefaults.Size = new Size(pd.Size.Width, pd.Size.Height); + nest.PlateDefaults.Thickness = pd.Thickness; + nest.PlateDefaults.Quadrant = pd.Quadrant; + nest.PlateDefaults.PartSpacing = pd.PartSpacing; + nest.PlateDefaults.Material = new Material(pd.Material.Name, pd.Material.Grade, pd.Material.Density); + nest.PlateDefaults.EdgeSpacing = new Spacing(pd.EdgeSpacing.Left, pd.EdgeSpacing.Bottom, pd.EdgeSpacing.Right, pd.EdgeSpacing.Top); + + // Drawings + foreach (var d in drawingMap.OrderBy(k => k.Key)) + nest.Drawings.Add(d.Value); + + // Plates + foreach (var p in dto.Plates.OrderBy(p => p.Id)) + { + var plate = new Plate(); + plate.Size = new Size(p.Size.Width, p.Size.Height); + plate.Thickness = p.Thickness; + plate.Quadrant = p.Quadrant; + plate.Quantity = p.Quantity; + plate.PartSpacing = p.PartSpacing; + plate.Material = new Material(p.Material.Name, p.Material.Grade, p.Material.Density); + plate.EdgeSpacing = new Spacing(p.EdgeSpacing.Left, p.EdgeSpacing.Bottom, p.EdgeSpacing.Right, p.EdgeSpacing.Top); + + foreach (var partDto in p.Parts) + { + if (!drawingMap.TryGetValue(partDto.DrawingId, out var dwg)) + continue; + + var part = new Part(dwg); + part.Rotate(partDto.Rotation); + part.Offset(new Vector(partDto.X, partDto.Y)); + plate.Parts.Add(part); + } + + nest.Plates.Add(plate); + } + + return nest; + } + } +} +``` + +- [ ] **Step 2: Build to verify NestReader compiles** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.IO/NestReader.cs +git commit -m "feat: rewrite NestReader to use JSON format v2" +``` + +--- + +## Chunk 4: Smoke Test + +### Task 4: Manual smoke test via OpenNest.Console + +**Files:** None modified — this is a verification step. + +Use the `OpenNest.Console` project (or the MCP server) to verify round-trip: create a nest, save it, reload it, confirm data is intact. + +- [ ] **Step 1: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with no errors. + +- [ ] **Step 2: Round-trip test via MCP tools** + +Use the OpenNest MCP tools to: +1. Create a drawing (e.g. a rectangle via `create_drawing`) +2. Create a plate via `create_plate` +3. Fill the plate via `fill_plate` +4. Save the nest via the console app or verify `get_plate_info` shows parts +5. If a nest file exists on disk, load it with `load_nest` and verify `get_plate_info` returns the same data + +- [ ] **Step 3: Inspect the ZIP contents** + +Unzip a saved nest file and verify: +- `nest.json` exists with correct structure +- `programs/program-1` (etc.) exist with G-code content +- No `info`, `drawing-info`, `plate-info`, or `plate-NNN` files exist + +- [ ] **Step 4: Commit any fixes** + +If any issues were found and fixed, commit them: + +```bash +git add -u +git commit -m "fix: address issues found during nest format v2 smoke test" +``` diff --git a/docs/superpowers/specs/2026-03-12-contour-reindexing-design.md b/docs/superpowers/specs/2026-03-12-contour-reindexing-design.md new file mode 100644 index 0000000..d5ecce8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-contour-reindexing-design.md @@ -0,0 +1,134 @@ +# Contour Re-Indexing Design + +## Overview + +Add entity-splitting primitives and a `Shape.ReindexAt` method so that a closed contour can be reordered to start (and end) at an arbitrary point. Then wire this into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs. + +All geometry additions live on existing classes in `OpenNest.Geometry`. The strategy wiring is a change to the existing `ContourCuttingStrategy` in `OpenNest.CNC.CuttingStrategy`. + +## Entity Splitting Primitives + +### Line.SplitAt(Vector point) + +```csharp +public (Line first, Line second) SplitAt(Vector point) +``` + +- Returns two lines: `StartPoint → point` and `point → EndPoint`. +- If the point is at `StartPoint` (within `Tolerance.Epsilon` distance), `first` is null. +- If the point is at `EndPoint` (within `Tolerance.Epsilon` distance), `second` is null. +- The point is assumed to lie on the line (caller is responsible — it comes from `ClosestPointTo`). + +### Arc.SplitAt(Vector point) + +```csharp +public (Arc first, Arc second) SplitAt(Vector point) +``` + +- Computes `splitAngle = Center.AngleTo(point)`, normalized via `Angle.NormalizeRad`. +- First arc: same center, radius, direction — `StartAngle → splitAngle`. +- Second arc: same center, radius, direction — `splitAngle → EndAngle`. +- **Endpoint tolerance**: compare `point.DistanceTo(arc.StartPoint())` and `point.DistanceTo(arc.EndPoint())` rather than comparing angles directly. This avoids wrap-around issues at the 0/2π boundary. +- If the point is at `StartPoint()` (within `Tolerance.Epsilon` distance), `first` is null. +- If the point is at `EndPoint()` (within `Tolerance.Epsilon` distance), `second` is null. + +### Circle — no conversion needed + +Circles are kept as-is in `ReindexAt`. The `ConvertShapeToMoves` method handles circles directly by emitting an `ArcMove` from the start point back to itself (a full circle), matching the existing `ConvertGeometry.AddCircle` pattern. This avoids the problem of constructing a "full-sweep arc" where `StartAngle == EndAngle` would produce zero sweep. + +## Shape.ReindexAt + +```csharp +public Shape ReindexAt(Vector point, Entity entity) +``` + +- `point`: the start/end point for the reindexed contour (from `ClosestPointTo`). +- `entity`: the entity containing `point` (from `ClosestPointTo`'s `out` parameter). +- Returns a **new** Shape (does not modify the original). The new shape shares entity references with the original for unsplit entities — callers must not mutate either. +- Throws `ArgumentException` if `entity` is not found in `Entities`. + +### Algorithm + +1. If `entity` is a `Circle`: + - Return a new Shape with that single `Circle` entity and `point` stored for `ConvertShapeToMoves` to use as the start point. + +2. Find the index `i` of `entity` in `Entities`. Throw `ArgumentException` if not found. + +3. Split the entity at `point`: + - `Line` → `line.SplitAt(point)` → `(firstHalf, secondHalf)` + - `Arc` → `arc.SplitAt(point)` → `(firstHalf, secondHalf)` + +4. Build the new entity list (skip null entries): + - `secondHalf` (if not null) + - `Entities[i+1]`, `Entities[i+2]`, ..., `Entities[count-1]` (after the split) + - `Entities[0]`, `Entities[1]`, ..., `Entities[i-1]` (before the split, wrapping around) + - `firstHalf` (if not null) + +5. Return a new Shape with this entity list. + +### Edge Cases + +- **Point lands on entity boundary** (start/end of an entity): one half of the split is null. The reordering still works — it just starts from the next full entity. +- **Single-entity shape that is an Arc**: split produces two arcs, reorder is just `[secondHalf, firstHalf]`. +- **Single-entity Circle**: handled by step 1 — kept as Circle, converted to a full-circle ArcMove in `ConvertShapeToMoves`. + +## Wiring into ContourCuttingStrategy + +### Entity-to-ICode Conversion + +Add a private method to `ContourCuttingStrategy`: + +```csharp +private List ConvertShapeToMoves(Shape shape, Vector startPoint) +``` + +The `startPoint` parameter is needed for the Circle case (to know where the full-circle ArcMove starts). + +Iterates `shape.Entities` and converts each to cutting moves using **absolute coordinates** (consistent with `ConvertGeometry`): +- `Line` → `LinearMove(line.EndPoint)` +- `Arc` → `ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)` +- `Circle` → `ArcMove(startPoint, circle.Center, circle.Rotation)` — full circle from start point back to itself, matching `ConvertGeometry.AddCircle` +- Any other entity type → throw `InvalidOperationException` + +No `RapidMove` between entities — they are contiguous in a reindexed shape. The lead-in already positions the head at the shape's start point. + +### Replace NotImplementedException + +In `ContourCuttingStrategy.Apply()`, replace the two `throw new NotImplementedException(...)` blocks: + +**Cutout loop** (uses `cutout` shape variable): +```csharp +var reindexed = cutout.ReindexAt(closestPt, entity); +result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt)); +``` + +**Perimeter block** (uses `profile.Perimeter`): +```csharp +var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity); +result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt)); +``` + +The full sequence for each contour becomes: +1. Lead-in codes (rapid to pierce point, cutting moves to contour start) +2. Contour body (reindexed entity moves from `ConvertShapeToMoves`) +3. Lead-out codes (overcut moves away from contour) + +### MicrotabLeadOut Handling + +When the lead-out is `MicrotabLeadOut`, the last cutting move must be trimmed by `GapSize`. This is a separate concern from re-indexing — stub it with a TODO comment for now. The trimming logic will shorten the last `LinearMove` or `ArcMove` in the contour body. + +## Files Modified + +| File | Change | +|------|--------| +| `OpenNest.Core/Geometry/Line.cs` | Add `SplitAt(Vector)` method | +| `OpenNest.Core/Geometry/Arc.cs` | Add `SplitAt(Vector)` method | +| `OpenNest.Core/Geometry/Shape.cs` | Add `ReindexAt(Vector, Entity)` method | +| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add `ConvertShapeToMoves`, replace `NotImplementedException` blocks | + +## Out of Scope + +- **MicrotabLeadOut trimming** (trim last move by gap size — stubbed with TODO) +- **Tab insertion** (inserting tab codes mid-contour — already stubbed) +- **Lead-in editor UI** (interactive start point selection — separate feature) +- **Contour re-indexing for open shapes** (only closed contours supported) diff --git a/docs/superpowers/specs/2026-03-12-cutting-strategy-design.md b/docs/superpowers/specs/2026-03-12-cutting-strategy-design.md new file mode 100644 index 0000000..3f1bffb --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-cutting-strategy-design.md @@ -0,0 +1,420 @@ +# CNC Cutting Strategy Design + +## Overview + +Add lead-in, lead-out, and tab classes to `OpenNest.Core` that generate `ICode` instructions for CNC cutting approach/exit geometry. The strategy runs at nest-time — `ContourCuttingStrategy.Apply()` produces a new `Program` with lead-ins, lead-outs, start points, and contour ordering baked in. This modified program is what gets saved to the nest file and later fed to the post-processor for machine-specific G-code translation. The original `Drawing.Program` stays untouched; the strategy output lives on the `Part`. + +All new code lives in `OpenNest.Core/CNC/CuttingStrategy/`. + +## File Structure + +``` +OpenNest.Core/CNC/CuttingStrategy/ +├── LeadIns/ +│ ├── LeadIn.cs +│ ├── NoLeadIn.cs +│ ├── LineLeadIn.cs +│ ├── LineArcLeadIn.cs +│ ├── ArcLeadIn.cs +│ ├── LineLineLeadIn.cs +│ └── CleanHoleLeadIn.cs +├── LeadOuts/ +│ ├── LeadOut.cs +│ ├── NoLeadOut.cs +│ ├── LineLeadOut.cs +│ ├── ArcLeadOut.cs +│ └── MicrotabLeadOut.cs +├── Tabs/ +│ ├── Tab.cs +│ ├── NormalTab.cs +│ ├── BreakerTab.cs +│ └── MachineTab.cs +├── ContourType.cs +├── CuttingParameters.cs +├── ContourCuttingStrategy.cs +├── SequenceParameters.cs +└── AssignmentParameters.cs +``` + +## Namespace + +All classes use `namespace OpenNest.CNC.CuttingStrategy`. + +## Type Mappings from Original Spec + +The original spec used placeholder names. These are the correct codebase types: + +| Spec type | Actual type | Notes | +|-----------|------------|-------| +| `PointD` | `Vector` | `OpenNest.Geometry.Vector` — struct with `X`, `Y` fields | +| `CircularMove` | `ArcMove` | Constructor: `ArcMove(Vector endPoint, Vector centerPoint, RotationType rotation)` | +| `CircularDirection` | `RotationType` | Enum with `CW`, `CCW` | +| `value.ToRadians()` | `Angle.ToRadians(value)` | Static method on `OpenNest.Math.Angle` | +| `new Program(codes)` | Build manually | Create `Program()`, add to `.Codes` list | + +## LeadIn Hierarchy + +### Abstract Base: `LeadIn` + +```csharp +public abstract class LeadIn +{ + public abstract List Generate(Vector contourStartPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); + public abstract Vector GetPiercePoint(Vector contourStartPoint, double contourNormalAngle); +} +``` + +- `contourStartPoint`: where the contour cut begins (first point of the part profile). +- `contourNormalAngle`: normal angle (radians) at the contour start point, pointing **away from the part material** (outward from perimeter, into scrap for cutouts). +- `winding`: contour winding direction — arc-based lead-ins use this for their `ArcMove` rotation. +- `Generate` returns ICode instructions starting with a `RapidMove` to the pierce point, followed by cutting moves to reach the contour start. +- `GetPiercePoint` computes where the head rapids to before firing — useful for visualization and collision detection. + +### NoLeadIn (Type 0) + +Pierce directly on the contour start point. Returns a single `RapidMove(contourStartPoint)`. + +### LineLeadIn (Type 1) + +Straight line approach. + +Properties: +- `Length` (double): distance from pierce point to contour start (inches) +- `ApproachAngle` (double): approach angle in degrees relative to contour tangent. 90 = perpendicular, 135 = acute angle (common for plasma). Default: 90. + +Pierce point offset: `contourStartPoint + Length` along `contourNormalAngle + Angle.ToRadians(ApproachAngle)`. + +Generates: `RapidMove(piercePoint)` → `LinearMove(contourStartPoint)`. + +> **Note:** Properties are named `ApproachAngle` (not `Angle`) to avoid shadowing the `OpenNest.Math.Angle` static class. This applies to all lead-in/lead-out/tab classes. + +### LineArcLeadIn (Type 2) + +Line followed by tangential arc meeting the contour. Most common for plasma. + +Properties: +- `LineLength` (double): straight approach segment length +- `ApproachAngle` (double): line angle relative to contour. Default: 135. +- `ArcRadius` (double): radius of tangential arc + +Geometry: Pierce → [Line] → Arc start → [Arc] → Contour start. Arc center is at `contourStartPoint + ArcRadius` along normal. Arc rotation direction matches contour winding (CW for CW contours, CCW for CCW). + +Generates: `RapidMove(piercePoint)` → `LinearMove(arcStart)` → `ArcMove(contourStartPoint, arcCenter, rotation)`. + +### ArcLeadIn (Type 3) + +Pure arc approach, no straight line segment. + +Properties: +- `Radius` (double): arc radius + +Pierce point is diametrically opposite the contour start on the arc circle. Arc center at `contourStartPoint + Radius` along normal. + +Arc rotation direction matches contour winding. + +Generates: `RapidMove(piercePoint)` → `ArcMove(contourStartPoint, arcCenter, rotation)`. + +### LineLineLeadIn (Type 5) + +Two-segment straight line approach. + +Properties: +- `Length1` (double): first segment length +- `ApproachAngle1` (double): first segment angle. Default: 90. +- `Length2` (double): second segment length +- `ApproachAngle2` (double): direction change. Default: 90. + +Generates: `RapidMove(piercePoint)` → `LinearMove(midPoint)` → `LinearMove(contourStartPoint)`. + +### CleanHoleLeadIn + +Specialized for precision circular holes. Same geometry as `LineArcLeadIn` but with hard-coded 135° angle and a `Kerf` property. The overcut (cutting past start to close the hole) is handled at the lead-out, not here. + +Properties: +- `LineLength` (double) +- `ArcRadius` (double) +- `Kerf` (double) + +## LeadOut Hierarchy + +### Abstract Base: `LeadOut` + +```csharp +public abstract class LeadOut +{ + public abstract List Generate(Vector contourEndPoint, double contourNormalAngle, + RotationType winding = RotationType.CW); +} +``` + +- `contourEndPoint`: where the contour cut ends. For closed contours, same as start. +- Returns ICode instructions appended after the contour's last cut point. + +### NoLeadOut (Type 0) + +Returns empty list. Cut ends exactly at contour end. + +### LineLeadOut (Type 1) + +Straight line overcut past contour end. + +Properties: +- `Length` (double): overcut distance +- `ApproachAngle` (double): direction relative to contour tangent. Default: 90. + +Generates: `LinearMove(endPoint)` where endPoint is offset from contourEndPoint. + +### ArcLeadOut (Type 3) + +Arc overcut curving away from the part. + +Properties: +- `Radius` (double) + +Arc center at `contourEndPoint + Radius` along normal. End point is a quarter turn away. Arc rotation direction matches contour winding. + +Generates: `ArcMove(endPoint, arcCenter, rotation)`. + +### MicrotabLeadOut (Type 4) + +Stops short of contour end, leaving an uncut bridge. Laser only. + +Properties: +- `GapSize` (double): uncut material length. Default: 0.03". + +Does NOT add instructions — returns empty list. The `ContourCuttingStrategy` detects this type and trims the last cutting move by `GapSize` instead. + +## Tab Hierarchy + +Tabs are mid-contour features that temporarily lift the beam to leave bridges holding the part in place. + +### Abstract Base: `Tab` + +```csharp +public abstract class Tab +{ + public double Size { get; set; } = 0.03; + public LeadIn TabLeadIn { get; set; } + public LeadOut TabLeadOut { get; set; } + + public abstract List Generate( + Vector tabStartPoint, Vector tabEndPoint, double contourNormalAngle); +} +``` + +### NormalTab + +Standard tab: cut up to tab start, lift/rapid over gap, resume cutting. + +Additional properties: +- `CutoutMinWidth`, `CutoutMinHeight` (double): minimum cutout size to receive this tab +- `CutoutMaxWidth`, `CutoutMaxHeight` (double): maximum cutout size to receive this tab +- `AppliesToCutout(double width, double height)` method for size filtering + +Generates: TabLeadOut codes → `RapidMove(tabEndPoint)` → TabLeadIn codes. + +### BreakerTab + +Like NormalTab but adds a scoring cut into the part at the tab location to make snapping easier. + +Additional properties: +- `BreakerDepth` (double): how far the score cuts into the part +- `BreakerLeadInLength` (double) +- `BreakerAngle` (double) + +Generates: TabLeadOut codes → `LinearMove(scoreEnd)` → `RapidMove(tabEndPoint)` → TabLeadIn codes. + +### MachineTab + +Tab behavior configured at the CNC controller level. OpenNest just signals the controller. + +Additional properties: +- `MachineTabId` (int): passed to post-processor for M-code translation + +Returns a placeholder `RapidMove(tabEndPoint)` — the post-processor plugin replaces this with machine-specific commands. + +## CuttingParameters + +One instance per material/machine combination. Ties everything together. + +```csharp +public class CuttingParameters +{ + public int Id { get; set; } + + // Material/Machine identification + public string MachineName { get; set; } + public string MaterialName { get; set; } + public string Grade { get; set; } + public double Thickness { get; set; } + + // Kerf and spacing + public double Kerf { get; set; } + public double PartSpacing { get; set; } + + // External contour lead-in/out + public LeadIn ExternalLeadIn { get; set; } = new NoLeadIn(); + public LeadOut ExternalLeadOut { get; set; } = new NoLeadOut(); + + // Internal contour lead-in/out + public LeadIn InternalLeadIn { get; set; } = new LineLeadIn { Length = 0.125, Angle = 90 }; + public LeadOut InternalLeadOut { get; set; } = new NoLeadOut(); + + // Arc/circle specific (overrides internal for circular features) + public LeadIn ArcCircleLeadIn { get; set; } = new NoLeadIn(); + public LeadOut ArcCircleLeadOut { get; set; } = new NoLeadOut(); + + // Tab configuration + public Tab TabConfig { get; set; } + public bool TabsEnabled { get; set; } = false; + + // Sequencing and assignment + public SequenceParameters Sequencing { get; set; } = new SequenceParameters(); + public AssignmentParameters Assignment { get; set; } = new AssignmentParameters(); +} +``` + +## SequenceParameters and AssignmentParameters + +```csharp +// Values match PEP Technology's numbering scheme (value 6 intentionally skipped) +public enum SequenceMethod +{ + RightSide = 1, LeastCode = 2, Advanced = 3, + BottomSide = 4, EdgeStart = 5, LeftSide = 7, RightSideAlt = 8 +} + +public class SequenceParameters +{ + public SequenceMethod Method { get; set; } = SequenceMethod.Advanced; + public double SmallCutoutWidth { get; set; } = 1.5; + public double SmallCutoutHeight { get; set; } = 1.5; + public double MediumCutoutWidth { get; set; } = 8.0; + public double MediumCutoutHeight { get; set; } = 8.0; + public double DistanceMediumSmall { get; set; } + public bool AlternateRowsColumns { get; set; } = true; + public bool AlternateCutoutsWithinRowColumn { get; set; } = true; + public double MinDistanceBetweenRowsColumns { get; set; } = 0.25; +} + +public class AssignmentParameters +{ + public SequenceMethod Method { get; set; } = SequenceMethod.Advanced; + public string Preference { get; set; } = "ILAT"; + public double MinGeometryLength { get; set; } = 0.01; +} +``` + +## ContourCuttingStrategy + +The orchestrator. Uses `ShapeProfile` to decompose a part into perimeter + cutouts, then sequences and applies cutting parameters using nearest-neighbor chaining from an exit point. + +### Exit Point from Plate Quadrant + +The exit point is the **opposite corner** of the plate from the quadrant origin. This is where the head ends up after traversing the plate, and is the starting point for backwards nearest-neighbor sequencing. + +| Quadrant | Origin | Exit Point | +|----------|--------|------------| +| 1 | TopRight | BottomLeft (0, 0) | +| 2 | TopLeft | BottomRight (width, 0) | +| 3 | BottomLeft | TopRight (width, length) | +| 4 | BottomRight | TopLeft (0, length) | + +The exit point is derived from `Plate.Quadrant` and `Plate.Size` — not passed in manually. + +### Approach + +Instead of requiring `Program.GetStartPoint()` / `GetNormalAtStart()` (which don't exist), the strategy: + +1. Computes the **exit point** from the plate's quadrant and size +2. Converts the program to geometry via `Program.ToGeometry()` +3. Builds a `ShapeProfile` from the geometry — gives `Perimeter` (Shape) and `Cutouts` (List<Shape>) +4. Uses `Shape.ClosestPointTo(point, out Entity entity)` to find lead-in points and the entity for normal computation +5. Chains cutouts by nearest-neighbor distance from the perimeter closest point +6. Reverses the chain → cut order is cutouts first (nearest-last), perimeter last + +### Contour Re-Indexing + +After `ClosestPointTo` finds the lead-in point on a shape, the shape's entity list must be reordered so that cutting starts at that point. This means: + +1. Find which entity in `Shape.Entities` contains the closest point +2. Split that entity at the closest point into two segments +3. Reorder: second half of split entity → remaining entities in order → first half of split entity +4. The contour now starts and ends at the lead-in point (for closed contours) + +This produces the `List` for the contour body that goes between the lead-in and lead-out codes. + +### ContourType Detection + +- `ShapeProfile.Perimeter` → `ContourType.External` +- Each cutout in `ShapeProfile.Cutouts`: + - If single entity and entity is `Circle` → `ContourType.ArcCircle` + - Otherwise → `ContourType.Internal` + +### Normal Angle Computation + +Derived from the `out Entity` returned by `ClosestPointTo`: + +- **Line**: normal is perpendicular to line direction. Use the line's tangent angle, then add π/2 for the normal pointing away from the part interior. +- **Arc/Circle**: normal is radial direction from arc center to the closest point: `closestPoint.AngleFrom(arc.Center)`. + +Normal direction convention: always points **away from the part material** (outward from perimeter, inward toward scrap for cutouts). The lead-in approaches from this direction. + +### Arc Rotation Direction + +Lead-in/lead-out arcs must match the **contour winding direction**, not be hardcoded CW. Determine winding from the shape's entity traversal order. Pass the appropriate `RotationType` to `ArcMove`. + +### Method Signature + +```csharp +public class ContourCuttingStrategy +{ + public CuttingParameters Parameters { get; set; } + + /// + /// Apply cutting strategy to a part's program. + /// + /// Original part program (unmodified). + /// Plate for quadrant/size to compute exit point. + /// New Program with lead-ins, lead-outs, and tabs applied. Cutouts first, perimeter last. + public Program Apply(Program partProgram, Plate plate) + { + // 1. Compute exit point from plate quadrant + size + // 2. Convert to geometry, build ShapeProfile + // 3. Find closest point on perimeter from exitPoint + // 4. Chain cutouts by nearest-neighbor from perimeter point + // 5. Reverse chain → cut order + // 6. For each contour: + // a. Re-index shape entities to start at closest point + // b. Detect ContourType + // c. Compute normal angle from entity + // d. Select lead-in/out from CuttingParameters by ContourType + // e. Generate lead-in codes + contour body + lead-out codes + // 7. Handle MicrotabLeadOut by trimming last segment + // 8. Assemble and return new Program + } +} +``` + +### ContourType Enum + +```csharp +public enum ContourType +{ + External, + Internal, + ArcCircle +} +``` + +## Integration Point + +`ContourCuttingStrategy.Apply()` runs at nest-time (when parts are placed or cutting parameters are assigned), not at post-processing time. The output `Program` — with lead-ins, lead-outs, start points, and contour ordering — is stored on the `Part` and saved through the normal `NestWriter` path. The post-processor receives this already-complete program and only translates it to machine-specific G-code. + +## Out of Scope (Deferred) + +- **Serialization** of CuttingParameters (JSON/XML discriminators) +- **UI integration** (parameter editor forms in WinForms app) +- **Part.CutProgram property** (storing the strategy-applied program on `Part`, separate from `Drawing.Program`) +- **Tab insertion logic** (`InsertTabs` / `TrimLastSegment` — stubbed with `NotImplementedException`) diff --git a/docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md b/docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md new file mode 100644 index 0000000..639925d --- /dev/null +++ b/docs/superpowers/specs/2026-03-12-nest-file-format-v2-design.md @@ -0,0 +1,134 @@ +# Nest File Format v2 Design + +## Problem + +The current nest file format stores metadata across three separate XML files (`info`, `drawing-info`, `plate-info`) plus per-plate G-code files for part placements inside a ZIP archive. This results in ~400 lines of hand-written XML read/write code, fragile dictionary-linking to reconnect drawings/plates by ID after parsing, and the overhead of running the full G-code parser just to extract part positions. + +## Design + +### File Structure + +The nest file remains a ZIP archive. Contents: + +``` +nest.json +programs/ + program-1 + program-2 + ... +``` + +- **`nest.json`** — single JSON file containing all metadata and part placements. +- **`programs/program-N`** — G-code text for each drawing's CNC program (1-indexed, no zero-padding). Previously stored at the archive root as `program-NNN` (zero-padded). Parsed by `ProgramReader`, written by existing G-code serialization logic. Format unchanged. + +Plate G-code files (`plate-NNN`) are removed. Part placements are stored inline in `nest.json`. + +### JSON Schema + +```json +{ + "version": 2, + "name": "string", + "units": "Inches | Millimeters", + "customer": "string", + "dateCreated": "2026-03-12T10:30:00", + "dateLastModified": "2026-03-12T14:00:00", + "notes": "string (plain JSON, no URI-escaping)", + "plateDefaults": { + "size": { "width": 0.0, "height": 0.0 }, + "thickness": 0.0, + "quadrant": 1, + "partSpacing": 0.0, + "material": { "name": "string", "grade": "string", "density": 0.0 }, + "edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 } + }, + "drawings": [ + { + "id": 1, + "name": "string", + "customer": "string", + "color": { "a": 255, "r": 0, "g": 0, "b": 0 }, + "quantity": { "required": 0 }, + "priority": 0, + "constraints": { + "stepAngle": 0.0, + "startAngle": 0.0, + "endAngle": 0.0, + "allow180Equivalent": false + }, + "material": { "name": "string", "grade": "string", "density": 0.0 }, + "source": { + "path": "string", + "offset": { "x": 0.0, "y": 0.0 } + } + } + ], + "plates": [ + { + "id": 1, + "size": { "width": 0.0, "height": 0.0 }, + "thickness": 0.0, + "quadrant": 1, + "quantity": 1, + "partSpacing": 0.0, + "material": { "name": "string", "grade": "string", "density": 0.0 }, + "edgeSpacing": { "left": 0.0, "top": 0.0, "right": 0.0, "bottom": 0.0 }, + "parts": [ + { "drawingId": 1, "x": 0.0, "y": 0.0, "rotation": 0.0 } + ] + } + ] +} +``` + +Key details: +- **Version**: `"version": 2` at the top level for future format migration. +- Drawing `id` values are 1-indexed, matching `programs/program-N` filenames. +- Part `rotation` is stored in **radians** (matches internal domain model, no conversion needed). +- Part `drawingId` references the drawing's `id` in the `drawings` array. +- **Dates**: local time, serialized via `DateTime.ToString("o")` (ISO 8601 round-trip format with timezone offset). +- **Notes**: stored as plain JSON strings. The v1 URI-escaping (`Uri.EscapeDataString`) is not needed since JSON handles special characters natively. +- `quantity.required` is the only quantity persisted; `nested` is computed at load time from part placements. +- **Units**: enum values match the domain model: `Inches` or `Millimeters`. +- **Size**: uses `width`/`height` matching the `OpenNest.Geometry.Size` struct. +- **Drawing.Priority** and **Drawing.Constraints** (stepAngle, startAngle, endAngle, allow180Equivalent) are now persisted (v1 omitted these). +- **Empty collections**: `drawings` and `plates` arrays are always present (may be empty `[]`). The `programs/` folder is empty when there are no drawings. + +### Serialization Approach + +Use `System.Text.Json` with small DTO (Data Transfer Object) classes for serialization. The DTOs map between the domain model and the JSON structure, keeping serialization concerns out of the domain classes. + +### What Changes + +| File | Change | +|------|--------| +| `NestWriter.cs` | Replace all XML writing and plate G-code writing with JSON serialization. Programs written to `programs/` folder. | +| `NestReader.cs` | Replace all XML parsing, plate G-code parsing, and dictionary-linking with JSON deserialization. Programs read from `programs/` folder. | + +### What Stays the Same + +| File | Reason | +|------|--------| +| `ProgramReader.cs` | G-code parsing for CNC programs is unchanged. | +| `NestWriter` G-code writing (`WriteDrawing`, `GetCodeString`) | G-code serialization for programs is unchanged. | +| `DxfImporter.cs`, `DxfExporter.cs`, `Extensions.cs` | Unrelated to nest file format. | +| Domain model classes | No changes needed. | + +### Public API + +The public API is unchanged: +- `NestReader(string file)` and `NestReader(Stream stream)` constructors preserved. +- `NestReader.Read()` returns `Nest`. +- `NestWriter(Nest nest)` constructor preserved. +- `NestWriter.Write(string file)` returns `bool`. + +### Callers (no changes needed) + +- `MainForm.cs:329` — `new NestReader(path)` +- `MainForm.cs:363` — `new NestReader(dlg.FileName)` +- `EditNestForm.cs:212` — `new NestWriter(Nest)` +- `EditNestForm.cs:223` — `new NestWriter(nst)` +- `Document.cs:27` — `new NestWriter(Nest)` +- `OpenNest.Console/Program.cs:94` — `new NestReader(nestFile)` +- `OpenNest.Console/Program.cs:190` — `new NestWriter(nest)` +- `OpenNest.Mcp/InputTools.cs:30` — `new NestReader(path)`