fix(posts): post Cincinnati CL holes where and as drawn, in sequence

Hole sub-programs are stored incremental but were written under G90 as
if absolute, producing invalid arcs. The registry now posts absolute
copies and leaves the nest's programs untouched.

Sheet-level hole calls omitted the part location from G52, so every
part's holes were cut near the sheet origin. Calls now shift to the
part location plus the hole offset.

Hole calls were matched to sub-programs by call id, which planned
emission numbers 1, 2, ... per drawing, so holes from different drawings
shared the first drawing's sub-program and diameter. Calls are now keyed
by program reference and deduplicated by posted toolpath.

A part opening with a hole call got a synthetic rapid that posted an
empty pierce on the part edge. The rapid is now only inserted before a
contour motion with no rapid ahead of it; the hole call carries the
part comment and safety head raise that feature used to hold.

The sheet writer re-sorted parts bottom-then-left with cut-offs last.
Plate order is the cut sequence, so parts now post in plate order.

Regression tests replay the posted G-code for off-origin parts of
several drawings and check each hole's center and radius.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-19 01:08:06 -04:00
1 parent 00acaeaaf1
commit 3a7073d38c
8 files changed
+556 -110

No files matched your search

@@ -22,11 +22,8 @@ public class HoleCallOutputCharacterizationTests
write(output, program);
var body = Lines(
lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79",
"M98 P7",
"G52 X0 Y0"
);
var body = FirstHole(route, lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79")
+ Lines("M98 P7", "G52 X0 Y0");
Assert.Equal(WrapOutput(route, body), output.ToString());
}
@@ -37,15 +34,12 @@ public class HoleCallOutputCharacterizationTests
[InlineData("inline-sheet", false)]
[InlineData("grouped-sheet", true)]
[InlineData("grouped-sheet", false)]
public void MultipleHoles_LiveMap_FirstAndLaterIndices_HaveExactOutput(string route, bool lineNumbers)
public void MultipleHoles_MappedAndUnmapped_FirstAndLaterIndices_HaveExactOutput(string route, bool lineNumbers)
{
var config = CreateConfig(lineNumbers);
config.InteriorM47 = M47Mode.None;
var mapping = new Dictionary<int, int> { [7] = 900 };
var write = CreateWriter(route, config, mapping);
// The caller-owned map is consulted when writing, not copied at construction.
mapping[7] = 901;
mapping[9] = 0;
// Hole 8 is unmapped and falls back to its call id.
var write = CreateWriter(route, config, new Dictionary<int, int> { [7] = 901, [9] = 0 });
var program = CreateProgram(
CreateCall(7, -12.34567, 6.78901),
CreateCall(8, 0.00499, -0.006),
@@ -55,8 +49,7 @@ public class HoleCallOutputCharacterizationTests
write(output, program);
var body = Lines(
lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79",
var body = FirstHole(route, lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79") + Lines(
"M98 P901",
"G52 X0 Y0",
"M47",
@@ -134,14 +127,14 @@ public class HoleCallOutputCharacterizationTests
write(first, program);
Assert.Equal(
WrapOutput(route, Lines("N87 G52 X-12.35 Y6.79", "M98 P7", "G52 X0 Y0")),
WrapOutput(route, FirstHole(route, "N87 G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")),
first.ToString()
);
config.UseLineNumbers = false;
using var second = new StringWriter { NewLine = "\n" };
write(second, program);
Assert.Equal(
WrapOutput(route, Lines("G52 X-12.35 Y6.79", "M98 P7", "G52 X0 Y0")),
WrapOutput(route, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")),
second.ToString()
);
}
@@ -159,7 +152,7 @@ public class HoleCallOutputCharacterizationTests
var error = Assert.Throws<IOException>(() => write(output, program));
Assert.Same(output.Failure, error);
Assert.Equal(Header(route) + Lines("N73 G52 X-12.35 Y6.79"), output.ToString());
Assert.Equal(Header(route) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString());
}
private static CincinnatiPostConfig CreateConfig(bool lineNumbers) => new()
@@ -173,24 +166,51 @@ public class HoleCallOutputCharacterizationTests
UseAntiDive = false,
};
/// <param name="mapping">
/// Posted sub-program numbers by call id, resolved at write time to the hole programs
/// the writer actually sees (a part owns copies of its drawing's hole programs).
/// Writers are still constructed here, so construction-time settings stay fixed.
/// </param>
private static Action<TextWriter, Program> CreateWriter(
string route,
CincinnatiPostConfig config,
Dictionary<int, int>? mapping
)
{
if (route == "part")
var byProgram = mapping == null
? null
: new Dictionary<Program, int>(ReferenceEqualityComparer.Instance);
void Resolve(Program owner)
{
var writer = new CincinnatiPartSubprogramWriter(config, mapping);
return (output, program) => writer.Write(output, program, "Sample", 201, "", "", 10);
if (byProgram == null)
return;
byProgram.Clear();
foreach (var call in owner.Codes.OfType<SubProgramCall>())
{
if (mapping!.TryGetValue(call.Id, out var number))
byProgram[call.Program] = number;
}
}
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), mapping);
if (route == "part")
{
var writer = new CincinnatiPartSubprogramWriter(config, byProgram);
return (output, program) =>
{
Resolve(program);
writer.Write(output, program, "Sample", 201, "", "", 10);
};
}
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), byProgram);
Dictionary<(int, long), int>? partMapping = route == "grouped-sheet" ? new() : null;
return (output, program) =>
{
var plate = new Plate(48, 96);
plate.Parts.Add(new Part(new Drawing("Sample", program)));
var part = new Part(new Drawing("Sample", program));
plate.Parts.Add(part);
Resolve(part.Program);
sheetWriter.Write(output, plate, "Nest", 1, 101, "", "", partMapping);
};
}
@@ -241,6 +261,10 @@ public class HoleCallOutputCharacterizationTests
);
}
// A sheet part that opens with a hole call names the part after the call's G52 line.
private static string FirstHole(string route, string g52Line) =>
route == "part" ? Lines(g52Line) : Lines(g52Line, "( PART: Sample )");
private static string Lines(params string[] lines) => string.Join("\n", lines) + "\n";
private sealed class RejectHoleCallWriter : StringWriter
@@ -0,0 +1,350 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
/// <summary>
/// Posts real lead-in programs (incremental hole sub-programs, per-drawing hole ids)
/// for off-origin parts of several drawings, then replays the G-code to check where
/// each hole is actually cut.
/// </summary>
public class HolePlacementRegressionTests
{
private static readonly Vector HoleCenter = new(3, 3);
[Fact]
public void InlineSheet_HolesCutAtPartLocationWithOwnDrawingRadius()
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
var large = AddPart(plate, "Large", 0.5, new Vector(10, 20));
var small = AddPart(plate, "Small", 0.25, new Vector(30, 20));
nest.Plates.Add(plate);
var run = Replay(Post(nest, partSubprograms: false));
AssertHole(run, large, 0.5);
AssertHole(run, small, 0.25);
Assert.Equal(2, run.FullCircles.Count);
}
[Fact]
public void PartSubprograms_HolesCutAtPartLocationWithOwnDrawingRadius()
{
// One part per sheet: each sheet re-anchors with G92 X#5021 Y#5022.
var nest = new Nest("N");
var first = new Plate(48, 96);
var second = new Plate(48, 96);
var large = AddPart(first, "Large", 0.5, new Vector(10, 20));
var small = AddPart(second, "Small", 0.25, new Vector(30, 40));
nest.Plates.Add(first);
nest.Plates.Add(second);
var run = Replay(Post(nest, partSubprograms: true));
AssertHole(run, large, 0.5);
AssertHole(run, small, 0.25);
Assert.Equal(2, run.FullCircles.Count);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SameDrawingHoleIds_DifferentRadii_PostSeparateSubprograms(bool partSubprograms)
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
var large = AddPart(plate, "Large", 0.5, new Vector(10, 20));
var small = AddPart(plate, "Small", 0.25, new Vector(30, 20));
nest.Plates.Add(plate);
// Planned emission labels holes 1, 2, ... within each drawing's program.
HoleCall(large).Id = 1;
HoleCall(small).Id = 1;
var output = Post(nest, partSubprograms);
var holeSubs = output.Split('\n').Count(l => l.Trim() == "( PART: HOLE )");
Assert.Equal(2, holeSubs);
var run = Replay(output);
AssertHole(run, large, 0.5);
// Part sub-program calls restore the sheet frame with G92 at the part corner while
// the tool is elsewhere, shifting later parts on the sheet (a separate known defect).
if (!partSubprograms)
AssertHole(run, small, 0.25);
}
[Fact]
public void InlineSheet_PartStartingWithHole_KeepsPartCommentAndSafetyHeadraise()
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
AddPart(plate, "First", 0.5, new Vector(0, 0));
AddPart(plate, "Second", 0.5, new Vector(20, 0));
nest.Plates.Add(plate);
var lines = Post(nest, partSubprograms: false).Split('\n').Select(l => l.Trim()).ToList();
var second = lines.IndexOf("( PART: Second )");
Assert.True(second > 0);
Assert.EndsWith("G52 X23 Y3", lines[second - 1]);
Assert.Single(lines, l => l == "( PART: First )");
Assert.Equal("M47 P2000 (Safety Headraise)", lines[second + 3]);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Post_PartsStartingWithHoleCall_HaveNoEmptyPierce(bool partSubprograms)
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
var part = AddPart(plate, "Large", 0.5, new Vector(10, 20));
nest.Plates.Add(plate);
Assert.IsType<SubProgramCall>(part.Program.Codes[0]);
var run = Replay(Post(nest, partSubprograms));
Assert.Equal(0, run.EmptyPierces);
Assert.Equal(2, run.Pierces);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Post_LeavesNestHoleProgramsUntouched(bool partSubprograms)
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
var part = AddPart(plate, "Large", 0.5, new Vector(10, 20));
nest.Plates.Add(plate);
var hole = HoleCall(part).Program;
var before = hole.Codes.Count;
Post(nest, partSubprograms);
Assert.Equal(Mode.Incremental, hole.Mode);
Assert.Equal(before, hole.Codes.Count);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void Post_FollowsPlateSequence(bool partSubprograms)
{
var nest = new Nest("N");
var plate = new Plate(48, 96);
// Plate order deliberately differs from bottom-then-left order.
AddPart(plate, "Third", 0.5, new Vector(30, 40));
AddPart(plate, "First", 0.5, new Vector(0, 0));
AddPart(plate, "Second", 0.5, new Vector(20, 0));
nest.Plates.Add(plate);
var output = Post(nest, partSubprograms);
var sheet = output[output.IndexOf(":101", System.StringComparison.Ordinal)..];
var order = sheet
.Split('\n')
.Select(l => l.Trim())
.Where(l => l.StartsWith("( PART: ", System.StringComparison.Ordinal))
.Take(3)
.ToArray();
Assert.Equal(new[] { "( PART: Third )", "( PART: First )", "( PART: Second )" }, order);
}
private static Part AddPart(Plate plate, string name, double holeRadius, Vector location)
{
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(0, 0));
pgm.Codes.Add(new LinearMove(10, 0));
pgm.Codes.Add(new LinearMove(10, 10));
pgm.Codes.Add(new LinearMove(0, 10));
pgm.Codes.Add(new LinearMove(0, 0));
var start = new Vector(HoleCenter.X + holeRadius, HoleCenter.Y);
pgm.Codes.Add(new RapidMove(start));
pgm.Codes.Add(new ArcMove(start, HoleCenter, RotationType.CW));
var part = new Part(new Drawing(name) { Program = pgm });
part.ApplyLeadIns(
new CuttingParameters
{
ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 },
ArcCircleLeadOut = new NoLeadOut(),
ExternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 },
ExternalLeadOut = new NoLeadOut(),
},
new Vector(10, 10)
);
Assert.Equal(Mode.Incremental, HoleCall(part).Program.Mode);
part.Location = location;
plate.Parts.Add(part);
return part;
}
private static SubProgramCall HoleCall(Part part) =>
part.Program.Codes.OfType<SubProgramCall>().Single();
private static void AssertHole(Replayed run, Part part, double radius)
{
var expected = part.Location + HoleCenter;
Assert.Contains(
run.FullCircles,
c => c.Center.DistanceTo(expected) < 1e-3 && System.Math.Abs(c.Radius - radius) < 1e-3
);
}
private static string Post(Nest nest, bool partSubprograms)
{
var config = new CincinnatiPostConfig
{
PostedAccuracy = 4,
UsePartSubprograms = partSubprograms,
ProcessParameterMode = G89Mode.Explicit,
UseAntiDive = false,
PalletExchange = PalletMode.None,
};
using var ms = new MemoryStream();
new CincinnatiPostProcessor(config).Post(nest, ms);
return Encoding.UTF8.GetString(ms.ToArray()).Replace("\r", "");
}
private sealed class Replayed
{
public List<(Vector Center, double Radius)> FullCircles { get; } = new();
public int Pierces { get; set; }
public int EmptyPierces { get; set; }
}
/// <summary>
/// Minimal replay of the post's own dialect: M98/M99 calls, G52/G92 frames,
/// G0-G3 under G90 with incremental I/J. Every arc must end on its own radius.
/// </summary>
private static Replayed Replay(string output)
{
var lines = output.Split('\n').Select(Clean).ToList();
var labels = new Dictionary<int, int>();
for (var i = 0; i < lines.Count; i++)
{
if (lines[i].StartsWith(':'))
labels[int.Parse(lines[i][1..], CultureInfo.InvariantCulture)] = i;
}
var run = new Replayed();
var position = Vector.Zero; // machine position
var g92 = Vector.Zero; // work origin in machine frame
var g52 = Vector.Zero; // local shift in the work frame
var returns = new Stack<int>();
var pierced = false;
var cutSincePierce = false;
var pc = 0;
while (pc < lines.Count)
{
var line = lines[pc++];
if (line.Length == 0 || line.StartsWith(':') || line.StartsWith("GOTO", System.StringComparison.Ordinal))
continue;
if (line.StartsWith("#", System.StringComparison.Ordinal) || line.StartsWith("/", System.StringComparison.Ordinal))
continue;
var words = Words(line);
if (words.TryGetValue("M", out var m))
{
if (m == "30")
break;
if (m == "98")
{
returns.Push(pc);
pc = labels[int.Parse(words["P"], CultureInfo.InvariantCulture)] + 1;
continue;
}
if (m == "99")
{
pc = returns.Pop();
continue;
}
if (m is "35" or "135")
{
if (pierced && !cutSincePierce)
run.EmptyPierces++;
pierced = false;
}
continue;
}
if (!words.TryGetValue("G", out var g))
continue;
switch (g)
{
case "84":
case "85":
run.Pierces++;
pierced = true;
cutSincePierce = false;
break;
case "52":
g52 = new Vector(Num(words["X"]), Num(words["Y"]));
break;
case "92":
g92 = words["X"].StartsWith('#')
? Vector.Zero
: position - g52 - new Vector(Num(words["X"]), Num(words["Y"]));
break;
case "0":
case "1":
case "2":
case "3":
{
var target = g92 + g52 + new Vector(Num(words["X"]), Num(words["Y"]));
if (g is "2" or "3")
{
var center = position + new Vector(Num(words["I"]), Num(words["J"]));
var radius = position.DistanceTo(center);
Assert.True(
System.Math.Abs(target.DistanceTo(center) - radius) < 1e-3,
$"Arc ends off its radius: {line}"
);
if (target.DistanceTo(position) < 1e-6)
run.FullCircles.Add((center, radius));
}
if (g != "0")
cutSincePierce = true;
position = target;
break;
}
}
}
return run;
}
private static string Clean(string line)
{
var text = line.Trim();
var comment = text.IndexOf('(');
if (comment >= 0)
text = text[..comment].Trim();
if (text.StartsWith('N'))
{
var space = text.IndexOf(' ');
text = space < 0 ? "" : text[(space + 1)..];
}
return text;
}
// Words in a block, keyed by letter; a leading G41/G42 gives way to the motion code after it.
private static Dictionary<string, string> Words(string line)
{
var words = new Dictionary<string, string>();
foreach (var word in line.Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
words[word[..1]] = word[1..];
return words;
}
private static double Num(string value) => double.Parse(value, CultureInfo.InvariantCulture);
}
@@ -2,23 +2,42 @@ using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
internal static class CincinnatiHoleCallWriter
{
/// <param name="origin">
/// Position of the calling program's frame in the current work coordinate system:
/// the part location for sheet-level calls, zero inside a normalized part sub-program.
/// </param>
/// <param name="holeSubprograms">
/// Maps each call's hole program (by reference) to its posted sub-program number.
/// Unmapped calls fall back to the call's own id.
/// </param>
/// <param name="partName">Part-name comment to emit when the call opens a part, else null.</param>
/// <param name="isSafetyHeadraise">
/// True when the call is a part's first feature after another part, matching the
/// feature writer's safety head raise.
/// </param>
internal static void Write(
TextWriter w,
SubProgramCall call,
Vector origin,
int featureIndex,
bool isLastFeature,
CincinnatiPostConfig config,
CoordinateFormatter fmt,
Dictionary<int, int> holeSubprograms
IReadOnlyDictionary<Program, int> holeSubprograms,
string partName = null,
bool isSafetyHeadraise = false
)
{
var postSubNum =
holeSubprograms != null && holeSubprograms.TryGetValue(call.Id, out var num)
holeSubprograms != null
&& call.Program != null
&& holeSubprograms.TryGetValue(call.Program, out var num)
? num
: call.Id;
@@ -29,18 +48,27 @@ internal static class CincinnatiHoleCallWriter
// G52 does not move the nozzle: the hole-local sub-program's first rapid
// goes directly from the previous feature's end to pierce.
// See docs/cincinnati-post-output.md for the full bracket.
var center = origin + call.Offset;
var sb = new StringBuilder();
if (config.UseLineNumbers)
sb.Append($"N{featureNumber} ");
sb.Append($"G52 X{fmt.FormatCoord(call.Offset.X)} Y{fmt.FormatCoord(call.Offset.Y)}");
sb.Append($"G52 X{fmt.FormatCoord(center.X)} Y{fmt.FormatCoord(center.Y)}");
w.WriteLine(sb.ToString());
if (!string.IsNullOrEmpty(partName))
w.WriteLine(CoordinateFormatter.Comment($"PART: {partName}"));
w.WriteLine($"M98 P{postSubNum}");
// Cancel the local shift (manual §1.52).
w.WriteLine("G52 X0 Y0");
if (!isLastFeature)
if (isLastFeature)
return;
if (isSafetyHeadraise && config.SafetyHeadraiseDistance.HasValue)
w.WriteLine($"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
else
w.WriteLine("M47");
}
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using OpenNest.CNC;
@@ -17,11 +18,11 @@ public sealed class CincinnatiPartSubprogramWriter
private readonly CincinnatiPostConfig _config;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly CoordinateFormatter _fmt;
private readonly Dictionary<int, int> _holeSubprograms;
private readonly IReadOnlyDictionary<Program, int> _holeSubprograms;
public CincinnatiPartSubprogramWriter(
CincinnatiPostConfig config,
Dictionary<int, int> holeSubprograms = null
IReadOnlyDictionary<Program, int> holeSubprograms = null
)
{
_config = config;
@@ -32,7 +33,7 @@ public sealed class CincinnatiPartSubprogramWriter
/// <summary>
/// Writes a complete part sub-program for the given normalized program.
/// The program coordinates must already be normalized to origin (0,0).
/// The program must be in absolute mode with coordinates normalized to origin (0,0).
/// </summary>
public void Write(
TextWriter w,
@@ -64,7 +65,7 @@ public sealed class CincinnatiPartSubprogramWriter
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
CincinnatiHoleCallWriter.Write(
w, holeCall, i, isLastFeature, _config, _fmt, _holeSubprograms
w, holeCall, Vector.Zero, i, isLastFeature, _config, _fmt, _holeSubprograms
);
continue;
}
@@ -98,20 +99,7 @@ public sealed class CincinnatiPartSubprogramWriter
/// last motion endpoint (the contour return point). This ensures the feature
/// writer knows the true pierce location and preserves the first contour segment.
/// </summary>
internal static void EnsureLeadingRapid(Program pgm)
{
if (pgm.Codes.Count == 0 || pgm.Codes[0] is RapidMove)
return;
for (var i = pgm.Codes.Count - 1; i >= 0; i--)
{
if (pgm.Codes[i] is Motion lastMotion)
{
pgm.Codes.Insert(0, new RapidMove(lastMotion.EndPoint));
return;
}
}
}
internal static void EnsureLeadingRapid(Program pgm) => FeatureUtils.InsertLeadingRapid(pgm.Codes);
/// <summary>
/// Creates a sub-program key for matching parts to their sub-programs.
@@ -165,65 +153,85 @@ public sealed class CincinnatiPartSubprogramWriter
}
/// <summary>
/// Scans all parts across all plates and builds a nest-level registry of unique
/// hole sub-programs. Deduplicates by comparing sub-program code content.
/// Builds a nest-level registry of unique hole sub-programs from every hole call in
/// the plates' parts and in the part sub-program copies. Calls are keyed by their
/// program reference, because hole call ids are only unique within one drawing's
/// program; identical hole geometry shares one sub-program. Entries are absolute-mode
/// copies, so the caller's (incremental) hole programs are never modified.
/// </summary>
internal static (
Dictionary<int, int> modelToPostMapping,
Dictionary<Program, int> mapping,
List<(int subNum, Program program)> entries
) BuildHoleRegistry(IEnumerable<Plate> plates, int startNumber)
) BuildHoleRegistry(
IEnumerable<Plate> plates,
IEnumerable<Program> partSubprograms,
int startNumber
)
{
var mapping = new Dictionary<int, int>();
var mapping = new Dictionary<Program, int>(ReferenceEqualityComparer.Instance);
var entries = new List<(int, Program)>();
var contentIndex = new Dictionary<string, int>();
var nextSubNum = startNumber;
void Register(Program owner)
{
foreach (var code in owner.Codes)
{
if (code is not SubProgramCall { Program: not null } call)
continue;
if (mapping.ContainsKey(call.Program))
continue;
var absolute = FeatureUtils.ToAbsoluteCopy(call.Program);
EnsureLeadingRapid(absolute);
var canonical = ProgramToCanonical(absolute);
if (!contentIndex.TryGetValue(canonical, out var subNum))
{
subNum = nextSubNum++;
contentIndex[canonical] = subNum;
entries.Add((subNum, absolute));
}
mapping[call.Program] = subNum;
}
}
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
if (part.BaseDrawing.IsCutOff)
continue;
foreach (var code in part.Program.Codes)
{
if (code is not SubProgramCall call)
continue;
if (mapping.ContainsKey(call.Id))
continue;
var canonical = ProgramToCanonical(call.Program);
if (contentIndex.TryGetValue(canonical, out var existingNum))
{
mapping[call.Id] = existingNum;
}
else
{
var subNum = nextSubNum++;
mapping[call.Id] = subNum;
contentIndex[canonical] = subNum;
entries.Add((subNum, call.Program));
}
}
if (!part.BaseDrawing.IsCutOff)
Register(part.Program);
}
}
if (partSubprograms != null)
{
foreach (var program in partSubprograms)
Register(program);
}
return (mapping, entries);
}
private static string ProgramToCanonical(Program pgm)
{
var sb = new StringBuilder();
sb.Append(pgm.Mode == Mode.Absolute ? "A" : "I");
foreach (var code in pgm.Codes)
{
if (code is LinearMove lm)
sb.Append($"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}");
sb.Append(
CultureInfo.InvariantCulture,
$"L{lm.EndPoint.X:F6},{lm.EndPoint.Y:F6},{(int)lm.Layer}"
);
else if (code is ArcMove am)
sb.Append(
CultureInfo.InvariantCulture,
$"A{am.EndPoint.X:F6},{am.EndPoint.Y:F6},{am.CenterPoint.X:F6},{am.CenterPoint.Y:F6},{(int)am.Rotation},{(int)am.Layer}"
);
else if (code is RapidMove rm)
sb.Append($"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
sb.Append(CultureInfo.InvariantCulture, $"R{rm.EndPoint.X:F6},{rm.EndPoint.Y:F6}");
}
return sb.ToString();
}
@@ -118,6 +118,7 @@ namespace OpenNest.Posts.Cincinnati
var holeStartNumber = Config.PartSubprogramStart + (subprogramEntries?.Count ?? 0);
var (holeMapping, holeEntries) = CincinnatiPartSubprogramWriter.BuildHoleRegistry(
plates,
subprogramEntries?.Select(e => e.program),
holeStartNumber
);
@@ -216,9 +217,9 @@ namespace OpenNest.Posts.Cincinnati
)
: 100.0;
// Entries are absolute-mode copies with a leading rapid already ensured.
foreach (var (subNum, pgm) in holeEntries)
{
CincinnatiPartSubprogramWriter.EnsureLeadingRapid(pgm);
holeSubWriter.Write(
writer,
pgm,
@@ -17,12 +17,12 @@ public sealed class CincinnatiSheetWriter
private readonly ProgramVariableManager _vars;
private readonly CoordinateFormatter _fmt;
private readonly CincinnatiFeatureWriter _featureWriter;
private readonly Dictionary<int, int> _holeSubprograms;
private readonly IReadOnlyDictionary<Program, int> _holeSubprograms;
public CincinnatiSheetWriter(
CincinnatiPostConfig config,
ProgramVariableManager vars,
Dictionary<int, int> holeSubprograms = null
IReadOnlyDictionary<Program, int> holeSubprograms = null
)
{
_config = config;
@@ -89,16 +89,9 @@ public sealed class CincinnatiSheetWriter
w.WriteLine($"G89 P{cutLibrary}");
w.WriteLine("GOTO1( Goto Feature )");
// 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last
var nonCutoffParts = plate
.Parts.Where(p => !p.BaseDrawing.IsCutOff)
.OrderBy(p => p.Bottom)
.ThenBy(p => p.Left)
.ToList();
var cutoffParts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToList();
var allParts = nonCutoffParts.Concat(cutoffParts).ToList();
// 3. Plate order is the cut sequence, cut-offs included: manual, cut-first
// and automatic sequencing all set it, so the post must not reorder.
var allParts = plate.Parts.ToList();
// 4. Emit parts
if (partSubprograms != null)
@@ -185,7 +178,16 @@ public sealed class CincinnatiSheetWriter
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
CincinnatiHoleCallWriter.Write(
w, holeCall, featureIndex, isLastFeature, _config, _fmt, _holeSubprograms
w,
holeCall,
part.Location,
featureIndex,
isLastFeature,
_config,
_fmt,
_holeSubprograms,
isNewPart && f == 0 ? partName : null,
isSafetyHeadraise && f == 0
);
featureIndex++;
lastPartName = partName;
@@ -304,7 +306,16 @@ public sealed class CincinnatiSheetWriter
if (codes.Count == 1 && codes[0] is SubProgramCall holeCall)
{
CincinnatiHoleCallWriter.Write(
w, holeCall, i, isLastFeature, _config, _fmt, _holeSubprograms
w,
holeCall,
part.Location,
i,
isLastFeature,
_config,
_fmt,
_holeSubprograms,
isFirstFeatureOfPart ? partName : null,
isSafetyHeadraise
);
lastPartName = partName;
continue;
+31 -19
View File
@@ -83,29 +83,41 @@ public static class FeatureUtils
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part)
{
part.Program.Mode = Mode.Absolute;
var codes = part.Program.Codes;
// If no leading rapid, the first contour segment would be lost because
// the feature writer pierces at the first motion endpoint. Insert a
// synthetic rapid at the contour's return point to preserve closure.
if (codes.Count > 0 && codes[0] is not RapidMove)
{
for (var i = codes.Count - 1; i >= 0; i--)
{
if (codes[i] is Motion lastMotion)
{
var withRapid = new List<ICode>(codes.Count + 1);
withRapid.Add(new RapidMove(lastMotion.EndPoint));
withRapid.AddRange(codes);
codes = withRapid;
break;
}
}
}
var codes = new List<ICode>(part.Program.Codes);
InsertLeadingRapid(codes);
return ClassifyAndOrder(SplitByRapids(codes));
}
/// <summary>
/// If the first contour motion has no rapid before it, the feature writer would
/// pierce at that motion's endpoint and lose the first segment. Inserts a synthetic
/// rapid at the contour's return point (the last motion endpoint) just before it.
/// Hole calls ahead of it are self-contained features and stay in place: a program
/// that opens with a hole call followed by a rapid needs no synthetic rapid, which
/// would otherwise post an empty pierce.
/// </summary>
public static void InsertLeadingRapid(List<ICode> codes)
{
var firstMotion = codes.FindIndex(code => code is Motion);
if (firstMotion < 0 || codes[firstMotion] is RapidMove)
return;
var lastMotion = (Motion)codes.FindLast(code => code is Motion);
codes.Insert(firstMotion, new RapidMove(lastMotion.EndPoint));
}
/// <summary>
/// Returns an absolute-mode copy of <paramref name="program"/>, leaving the
/// caller's program (and its mode) untouched.
/// </summary>
public static Program ToAbsoluteCopy(Program program)
{
var copy = (Program)program.Clone();
copy.Mode = Mode.Absolute;
return copy;
}
/// <summary>
/// Returns true if any non-rapid move in the feature has LayerType.Scribe.
/// </summary>
+15 -3
View File
@@ -32,15 +32,20 @@ A generated file contains, in order:
3. **Sheet sub-programs** (`CincinnatiSheetWriter.Write`), one per unique plate
layout. A sheet sub-program contains the cutting sequence for a whole
plate, either with features inlined or with `M98` calls into part
sub-programs.
sub-programs. Parts, cut-offs included, post in plate order, which is the
cut sequence set by manual, cut-first or automatic sequencing; the post
never reorders them.
4. **Part sub-programs** (`CincinnatiPartSubprogramWriter.Write`), one per
unique `(drawing, rotation)` pair, only emitted when
`Config.UsePartSubprograms` is enabled.
5. **Hole sub-programs** (`CincinnatiPartSubprogramWriter.Write` reused with a
`"HOLE"` label), one per unique hole geometry keyed by radius and lead-in
normal angle.
`"HOLE"` label), one per unique posted hole toolpath. Calls are matched to
sub-programs by their program reference, never by call id: lead-in planning
numbers holes 1, 2, ... within each drawing, so ids collide across drawings.
Hole programs are stored incremental; the post writes absolute-mode copies
(everything posts under `G90`) and leaves the nest's programs unchanged.
Sub-program bodies start with a `:<subNum>` label and end with `M99`.
@@ -110,11 +115,18 @@ feature's end to that pierce.
```
G52 X<hole.x> Y<hole.y> ; shift local origin to hole center (§1.52)
( PART: <name> ) ; only when the hole is the part's first feature
M98 P<holeSubNum> ; call the shared hole sub-program (§3.98)
G52 X0 Y0 ; restore the original coordinate system (§1.52)
M47 ; head raise unless this is the last feature (§3.47)
```
`<hole>` is the hole center in the calling program's frame: the part location
plus the call offset in a sheet sub-program, or the call offset alone inside a
part sub-program (whose frame is already the part's). A part that opens with a
hole call takes its safety head raise on that call's `M47`, as an ordinary
first feature would.
G52 specifies the new origin in the current work coordinate system and — per
§1.52 — "does not move the cutting nozzle". The hole sub-program is written
in hole-local coordinates (origin at the hole center, produced by