diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs new file mode 100644 index 0000000..7a01984 --- /dev/null +++ b/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.IO; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Posts.Cincinnati; + +/// +/// Writes a Cincinnati-format part sub-program definition. +/// Each sub-program contains the complete cutting sequence for one unique part geometry +/// (drawing + rotation), with coordinates normalized to origin (0,0). +/// Called via M98 from sheet sub-programs. +/// +public sealed class CincinnatiPartSubprogramWriter +{ + private readonly CincinnatiPostConfig _config; + private readonly CincinnatiFeatureWriter _featureWriter; + + public CincinnatiPartSubprogramWriter(CincinnatiPostConfig config) + { + _config = config; + _featureWriter = new CincinnatiFeatureWriter(config); + } + + /// + /// Writes a complete part sub-program for the given normalized program. + /// The program coordinates must already be normalized to origin (0,0). + /// + public void Write(TextWriter w, Program normalizedProgram, string drawingName, + int subNumber, string libraryFile, double sheetDiagonal) + { + var features = SplitFeatures(normalizedProgram.Codes); + if (features.Count == 0) + return; + + w.WriteLine("(*****************************************************)"); + w.WriteLine($":{subNumber}"); + w.WriteLine(CoordinateFormatter.Comment($"PART: {drawingName}")); + + for (var i = 0; i < features.Count; i++) + { + var codes = features[i]; + var featureNumber = i == 0 + ? _config.FeatureLineNumberStart + : 1000 + i + 1; + var cutDistance = ComputeCutDistance(codes); + + var ctx = new FeatureContext + { + Codes = codes, + FeatureNumber = featureNumber, + PartName = drawingName, + IsFirstFeatureOfPart = false, + IsLastFeatureOnSheet = i == features.Count - 1, + IsSafetyHeadraise = false, + IsExteriorFeature = false, + LibraryFile = libraryFile, + CutDistance = cutDistance, + SheetDiagonal = sheetDiagonal + }; + + _featureWriter.Write(w, ctx); + } + + w.WriteLine("G0X0Y0"); + w.WriteLine($"M99(END OF {drawingName})"); + } + + /// + /// Creates a sub-program key for matching parts to their sub-programs. + /// + internal static (int drawingId, long rotationKey) SubprogramKey(Part part) => + (part.BaseDrawing.Id, (long)System.Math.Round(part.Rotation * 1e6)); + + internal static List> SplitFeatures(List codes) + { + var features = new List>(); + List current = null; + + foreach (var code in codes) + { + if (code is RapidMove) + { + if (current != null) + features.Add(current); + current = new List { code }; + } + else + { + current ??= new List(); + current.Add(code); + } + } + + if (current != null && current.Count > 0) + features.Add(current); + + return features; + } + + internal static double ComputeCutDistance(List codes) + { + var distance = 0.0; + var currentPos = Vector.Zero; + + foreach (var code in codes) + { + if (code is RapidMove rapid) + currentPos = rapid.EndPoint; + else if (code is LinearMove linear) + { + distance += currentPos.DistanceTo(linear.EndPoint); + currentPos = linear.EndPoint; + } + else if (code is ArcMove arc) + { + distance += currentPos.DistanceTo(arc.EndPoint); + currentPos = arc.EndPoint; + } + } + + return distance; + } +} diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs b/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs index 6bee170..bf26be8 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs @@ -126,6 +126,20 @@ namespace OpenNest.Posts.Cincinnati /// public int SheetSubprogramStart { get; set; } = 101; + /// + /// Gets or sets whether to use M98 sub-programs for part geometry. + /// When enabled, each unique part geometry is written as a reusable sub-program + /// called via M98, reducing output size for nests with repeated parts. + /// Default: false + /// + public bool UsePartSubprograms { get; set; } = false; + + /// + /// Gets or sets the starting sub-program number for part geometry sub-programs. + /// Default: 200 + /// + public int PartSubprogramStart { get; set; } = 200; + /// /// Gets or sets the subprogram number for variable declarations. /// Default: 100 diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs index 1f990d7..a80f42f 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs @@ -68,17 +68,49 @@ namespace OpenNest.Posts.Cincinnati .Where(p => p.Parts.Count > 0) .ToList(); - // 3. Create writers + // 3. Build part sub-program registry (if enabled) + Dictionary<(int, long), int> partSubprograms = null; + List<(int subNum, string name, Program program)> subprogramEntries = null; + + if (Config.UsePartSubprograms) + { + partSubprograms = new Dictionary<(int, long), int>(); + subprogramEntries = new List<(int, string, Program)>(); + var nextSubNum = Config.PartSubprogramStart; + + foreach (var plate in plates) + { + foreach (var part in plate.Parts) + { + if (part.BaseDrawing.IsCutOff) continue; + var key = CincinnatiPartSubprogramWriter.SubprogramKey(part); + if (!partSubprograms.ContainsKey(key)) + { + var subNum = nextSubNum++; + partSubprograms[key] = subNum; + + // Create normalized program at origin + var pgm = part.Program.Clone() as Program; + var bbox = pgm.BoundingBox(); + pgm.Offset(-bbox.Location.X, -bbox.Location.Y); + + subprogramEntries.Add((subNum, part.BaseDrawing.Name, pgm)); + } + } + } + } + + // 4. Create writers var preamble = new CincinnatiPreambleWriter(Config); var sheetWriter = new CincinnatiSheetWriter(Config, vars); - // 4. Build material description from first plate + // 5. Build material description from first plate var material = plates.FirstOrDefault()?.Material; var materialDesc = material != null ? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}" : ""; - // 5. Write to stream + // 6. Write to stream using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true); // Main program @@ -92,7 +124,25 @@ namespace OpenNest.Posts.Cincinnati { var sheetIndex = i + 1; var subNumber = Config.SheetSubprogramStart + i; - sheetWriter.Write(writer, plates[i], nest.Name ?? "NEST", sheetIndex, subNumber); + sheetWriter.Write(writer, plates[i], nest.Name ?? "NEST", sheetIndex, subNumber, + partSubprograms); + } + + // Part sub-programs (if enabled) + if (subprogramEntries != null) + { + var partSubWriter = new CincinnatiPartSubprogramWriter(Config); + var firstPlate = plates.FirstOrDefault(); + var sheetDiagonal = firstPlate != null + ? System.Math.Sqrt(firstPlate.Size.Width * firstPlate.Size.Width + + firstPlate.Size.Length * firstPlate.Size.Length) + : 100.0; + + foreach (var (subNum, name, pgm) in subprogramEntries) + { + partSubWriter.Write(writer, pgm, name, subNum, + Config.DefaultLibraryFile ?? "", sheetDiagonal); + } } writer.Flush(); diff --git a/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index 0f280b8..38ba8f3 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using OpenNest.CNC; using OpenNest.Geometry; @@ -9,7 +10,7 @@ namespace OpenNest.Posts.Cincinnati; /// /// Emits one Cincinnati-format sheet subprogram per plate. -/// Splits each part's codes at RapidMove boundaries to handle multi-contour parts. +/// Supports two modes: inline features (default) or M98 sub-program calls per part. /// public sealed class CincinnatiSheetWriter { @@ -29,7 +30,12 @@ public sealed class CincinnatiSheetWriter /// /// Writes a complete sheet subprogram for the given plate. /// - public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber) + /// + /// Optional mapping of (drawingId, rotationKey) to sub-program number. + /// When provided, non-cutoff parts are emitted as M98 calls instead of inline features. + /// + public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber, + Dictionary<(int, long), int> partSubprograms = null) { if (plate.Parts.Count == 0) return; @@ -78,7 +84,120 @@ public sealed class CincinnatiSheetWriter var allParts = nonCutoffParts.Concat(cutoffParts).ToList(); - // 4. Multi-contour splitting + // 4. Emit parts + if (partSubprograms != null) + WritePartsWithSubprograms(w, allParts, libraryFile, sheetDiagonal, partSubprograms); + else + WritePartsInline(w, allParts, libraryFile, sheetDiagonal); + + // 5. Footer + w.WriteLine("M42"); + w.WriteLine("G0X0Y0"); + if (_config.PalletExchange != PalletMode.None) + w.WriteLine($"N{sheetIndex + 1}M50"); + w.WriteLine($"M99(END OF {nestName}.{sheetIndex:D3})"); + } + + private void WritePartsWithSubprograms(TextWriter w, List allParts, + string libraryFile, double sheetDiagonal, + Dictionary<(int, long), int> partSubprograms) + { + var lastPartName = ""; + var featureIndex = 0; + + for (var p = 0; p < allParts.Count; p++) + { + var part = allParts[p]; + var partName = part.BaseDrawing.Name; + var isNewPart = partName != lastPartName; + var isSafetyHeadraise = isNewPart && lastPartName != ""; + var isLastPart = p == allParts.Count - 1; + + var key = CincinnatiPartSubprogramWriter.SubprogramKey(part); + partSubprograms.TryGetValue(key, out var subNum); + var hasSubprogram = !part.BaseDrawing.IsCutOff && subNum != 0; + + if (hasSubprogram) + { + WriteSubprogramCall(w, part, subNum, featureIndex, partName, + isSafetyHeadraise, isLastPart); + featureIndex++; + } + else + { + // Inline features for cutoffs or parts without sub-programs + var features = SplitPartFeatures(part); + for (var f = 0; f < features.Count; f++) + { + var featureNumber = featureIndex == 0 + ? _config.FeatureLineNumberStart + : 1000 + featureIndex + 1; + + var isLastFeature = isLastPart && f == features.Count - 1; + var cutDistance = ComputeCutDistance(features[f]); + + var ctx = new FeatureContext + { + Codes = features[f], + FeatureNumber = featureNumber, + PartName = partName, + IsFirstFeatureOfPart = isNewPart && f == 0, + IsLastFeatureOnSheet = isLastFeature, + IsSafetyHeadraise = isSafetyHeadraise && f == 0, + IsExteriorFeature = false, + LibraryFile = libraryFile, + CutDistance = cutDistance, + SheetDiagonal = sheetDiagonal + }; + + _featureWriter.Write(w, ctx); + featureIndex++; + } + } + + lastPartName = partName; + } + } + + private void WriteSubprogramCall(TextWriter w, Part part, int subNum, + int featureIndex, string partName, bool isSafetyHeadraise, bool isLastPart) + { + // Safety headraise before rapid to new part + if (isSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue) + w.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value}(Safety Headraise)"); + + // Rapid to part position (bounding box lower-left) + var featureNumber = featureIndex == 0 + ? _config.FeatureLineNumberStart + : 1000 + featureIndex + 1; + + var sb = new StringBuilder(); + if (_config.UseLineNumbers) + sb.Append($"N{featureNumber}"); + sb.Append($"G0X{_fmt.FormatCoord(part.Left)}Y{_fmt.FormatCoord(part.Bottom)}"); + w.WriteLine(sb.ToString()); + + // Part name comment + w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}")); + + // Set local coordinate system at part position + w.WriteLine("G92X0Y0"); + + // Call part sub-program + w.WriteLine($"M98P{subNum}({partName})"); + + // Restore sheet coordinate system + w.WriteLine($"G92X{_fmt.FormatCoord(part.Left)}Y{_fmt.FormatCoord(part.Bottom)}"); + + // Head raise (unless last part on sheet) + if (!isLastPart) + w.WriteLine("M47"); + } + + private void WritePartsInline(TextWriter w, List allParts, + string libraryFile, double sheetDiagonal) + { + // Multi-contour splitting var features = new List<(Part part, List codes)>(); foreach (var part in allParts) { @@ -101,7 +220,7 @@ public sealed class CincinnatiSheetWriter features.Add((part, current)); } - // 5. Emit features + // Emit features var lastPartName = ""; for (var i = 0; i < features.Count; i++) { @@ -111,12 +230,10 @@ public sealed class CincinnatiSheetWriter var isSafetyHeadraise = partName != lastPartName && lastPartName != ""; var isLastFeature = i == features.Count - 1; - // Feature numbering: first = FeatureLineNumberStart, then 1002, 1003, etc. var featureNumber = i == 0 ? _config.FeatureLineNumberStart : 1000 + i + 1; - // Compute cut distance for this feature var cutDistance = ComputeCutDistance(codes); var ctx = new FeatureContext @@ -136,13 +253,32 @@ public sealed class CincinnatiSheetWriter _featureWriter.Write(w, ctx); lastPartName = partName; } + } - // 6. Footer - w.WriteLine("M42"); - w.WriteLine("G0X0Y0"); - if (_config.PalletExchange != PalletMode.None) - w.WriteLine($"N{sheetIndex + 1}M50"); - w.WriteLine($"M99(END OF {nestName}.{sheetIndex:D3})"); + private static List> SplitPartFeatures(Part part) + { + var features = new List>(); + List current = null; + + foreach (var code in part.Program.Codes) + { + if (code is RapidMove) + { + if (current != null) + features.Add(current); + current = new List { code }; + } + else + { + current ??= new List(); + current.Add(code); + } + } + + if (current != null && current.Count > 0) + features.Add(current); + + return features; } private static double ComputeCutDistance(List codes) diff --git a/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs index ee5efe5..3ed710c 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs @@ -139,6 +139,163 @@ public class CincinnatiPostProcessorTests Assert.Equal("CL940", post.Config.ConfigurationName); } + [Fact] + public void Post_WithPartSubprograms_WritesM98Calls() + { + var nest = CreateTestNest(); + var config = new CincinnatiPostConfig + { + PostedAccuracy = 4, + UsePartSubprograms = true, + PartSubprogramStart = 200 + }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + + // Sheet should contain M98 call to part sub-program + Assert.Contains("M98P200", output); + + // Should have G92 for local coordinate positioning + Assert.Contains("G92X0Y0", output); + + // Part sub-program definition + Assert.Contains(":200", output); + Assert.Contains("G84", output); + + // Sub-program ends with G0X0Y0 and M99 + Assert.Contains("G0X0Y0", output); + Assert.Contains("M99(END OF Square)", output); + + // G92 restore after M98 call + Assert.Contains("G92X", output); + } + + [Fact] + public void Post_WithPartSubprograms_ReusesSameSubprogram() + { + var nest = new Nest("TestNest"); + var drawing = new Drawing("Square", CreateSquareProgram()); + var plate = new Plate(48, 96); + plate.Parts.Add(new Part(drawing, new Vector(5, 5))); + plate.Parts.Add(new Part(drawing, new Vector(20, 5))); + nest.Plates.Add(plate); + + var config = new CincinnatiPostConfig + { + PostedAccuracy = 4, + UsePartSubprograms = true, + PartSubprogramStart = 200 + }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + + // Both parts should call the same sub-program + var m98Count = System.Text.RegularExpressions.Regex.Matches(output, "M98P200").Count; + Assert.Equal(2, m98Count); + + // Only one sub-program definition + var subDefCount = System.Text.RegularExpressions.Regex.Matches(output, ":200").Count; + Assert.Equal(1, subDefCount); + } + + [Fact] + public void Post_WithPartSubprograms_DifferentRotationsGetSeparateSubprograms() + { + var nest = new Nest("TestNest"); + var drawing = new Drawing("Square", CreateSquareProgram()); + var plate = new Plate(48, 96); + + var part1 = new Part(drawing, new Vector(5, 5)); + plate.Parts.Add(part1); + + var part2 = new Part(drawing, new Vector(20, 5)); + part2.Rotate(System.Math.PI / 2); // 90 degrees + plate.Parts.Add(part2); + + nest.Plates.Add(plate); + + var config = new CincinnatiPostConfig + { + PostedAccuracy = 4, + UsePartSubprograms = true, + PartSubprogramStart = 200 + }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + + // Should have two different sub-programs + Assert.Contains(":200", output); + Assert.Contains(":201", output); + Assert.Contains("M98P200", output); + Assert.Contains("M98P201", output); + } + + [Fact] + public void Post_WithPartSubprograms_CutoffsAreInline() + { + var nest = new Nest("TestNest"); + var drawing = new Drawing("Square", CreateSquareProgram()); + var cutoffDrawing = new Drawing("CutOff", CreateSquareProgram()) { IsCutOff = true }; + + var plate = new Plate(48, 96); + plate.Parts.Add(new Part(drawing, new Vector(5, 5))); + plate.Parts.Add(new Part(cutoffDrawing, new Vector(0, 30))); + nest.Plates.Add(plate); + + var config = new CincinnatiPostConfig + { + PostedAccuracy = 4, + UsePartSubprograms = true, + PartSubprogramStart = 200 + }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + + // Regular part uses sub-program + Assert.Contains("M98P200", output); + Assert.Contains(":200", output); + + // Cutoff should NOT have its own sub-program + Assert.DoesNotContain(":201", output); + } + + [Fact] + public void Post_WithPartSubprograms_ConfigRoundTrips() + { + var config = new CincinnatiPostConfig + { + UsePartSubprograms = true, + PartSubprogramStart = 300 + }; + + var opts = new JsonSerializerOptions + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + var json = JsonSerializer.Serialize(config, opts); + var deserialized = JsonSerializer.Deserialize(json, opts); + + Assert.True(deserialized.UsePartSubprograms); + Assert.Equal(300, deserialized.PartSubprogramStart); + } + private static Nest CreateTestNest() { var nest = new Nest("TestNest");