mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 14:41:37 -05:00
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>
284 lines
10 KiB
C#
284 lines
10 KiB
C#
using OpenNest.CNC;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Posts.Cincinnati;
|
|
|
|
namespace OpenNest.Tests.Cincinnati;
|
|
|
|
public class HoleCallOutputCharacterizationTests
|
|
{
|
|
[Theory]
|
|
[InlineData("part", true)]
|
|
[InlineData("part", false)]
|
|
[InlineData("inline-sheet", true)]
|
|
[InlineData("inline-sheet", false)]
|
|
[InlineData("grouped-sheet", true)]
|
|
[InlineData("grouped-sheet", false)]
|
|
public void SingleHole_NullMap_FirstAndLastFeature_HasExactOutput(string route, bool lineNumbers)
|
|
{
|
|
var config = CreateConfig(lineNumbers);
|
|
var program = CreateProgram(CreateCall(7, -12.34567, 6.78901));
|
|
var write = CreateWriter(route, config, null);
|
|
using var output = new StringWriter { NewLine = "\n" };
|
|
|
|
write(output, program);
|
|
|
|
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());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("part", true)]
|
|
[InlineData("part", false)]
|
|
[InlineData("inline-sheet", true)]
|
|
[InlineData("inline-sheet", false)]
|
|
[InlineData("grouped-sheet", true)]
|
|
[InlineData("grouped-sheet", false)]
|
|
public void MultipleHoles_MappedAndUnmapped_FirstAndLaterIndices_HaveExactOutput(string route, bool lineNumbers)
|
|
{
|
|
var config = CreateConfig(lineNumbers);
|
|
config.InteriorM47 = M47Mode.None;
|
|
// 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),
|
|
CreateCall(9, 1.1, -2.2)
|
|
);
|
|
using var output = new StringWriter { NewLine = "\n" };
|
|
|
|
write(output, program);
|
|
|
|
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",
|
|
lineNumbers ? "N1002 G52 X0 Y-0.01" : "G52 X0 Y-0.01",
|
|
"M98 P8",
|
|
"G52 X0 Y0",
|
|
"M47",
|
|
lineNumbers ? "N1003 G52 X1.1 Y-2.2" : "G52 X1.1 Y-2.2",
|
|
"M98 P0",
|
|
"G52 X0 Y0"
|
|
);
|
|
Assert.Equal(WrapOutput(route, body), output.ToString());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("part", true)]
|
|
[InlineData("part", false)]
|
|
[InlineData("inline-sheet", true)]
|
|
[InlineData("inline-sheet", false)]
|
|
[InlineData("grouped-sheet", true)]
|
|
[InlineData("grouped-sheet", false)]
|
|
public void HoleBetweenOrdinaryFeatures_PreservesFeatureSequence(string route, bool lineNumbers)
|
|
{
|
|
var config = CreateConfig(lineNumbers);
|
|
var program = CreateProgram(
|
|
new RapidMove(1, 1),
|
|
new LinearMove(2, 1),
|
|
new LinearMove(1, 1),
|
|
CreateCall(7, -12.34567, 6.78901),
|
|
new RapidMove(3, 3),
|
|
new LinearMove(4, 3),
|
|
new LinearMove(3, 3)
|
|
);
|
|
var write = CreateWriter(route, config, new Dictionary<int, int>());
|
|
using var output = new StringWriter { NewLine = "\n" };
|
|
|
|
write(output, program);
|
|
|
|
var body = Lines(lineNumbers ? "N73 G0 X1 Y1" : "G0 X1 Y1");
|
|
if (route != "part")
|
|
body += Lines("( PART: Sample )");
|
|
body += Lines(
|
|
"G84",
|
|
"G1 X2 Y1 F#148",
|
|
"G1 X1 Y1",
|
|
"M35",
|
|
"M47",
|
|
lineNumbers ? "N1002 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79",
|
|
"M98 P7",
|
|
"G52 X0 Y0",
|
|
"M47",
|
|
lineNumbers ? "N1003 G0 X3 Y3" : "G0 X3 Y3",
|
|
"G84",
|
|
"G1 X4 Y3 F#148",
|
|
"G1 X3 Y3",
|
|
"M35"
|
|
);
|
|
Assert.Equal(WrapOutput(route, body), output.ToString());
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("part")]
|
|
[InlineData("inline-sheet")]
|
|
[InlineData("grouped-sheet")]
|
|
public void ConfigChanges_AreLiveExceptConstructionTimePrecision(string route)
|
|
{
|
|
var config = CreateConfig(false);
|
|
var write = CreateWriter(route, config, null);
|
|
var program = CreateProgram(CreateCall(7, -12.34567, 6.78901));
|
|
config.UseLineNumbers = true;
|
|
config.FeatureLineNumberStart = 87;
|
|
config.PostedAccuracy = 5;
|
|
using var first = new StringWriter { NewLine = "\n" };
|
|
|
|
write(first, program);
|
|
|
|
Assert.Equal(
|
|
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, FirstHole(route, "G52 X-12.35 Y6.79") + Lines("M98 P7", "G52 X0 Y0")),
|
|
second.ToString()
|
|
);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("part")]
|
|
[InlineData("inline-sheet")]
|
|
[InlineData("grouped-sheet")]
|
|
public void HoleWriteFailure_PropagatesWithoutResetOrLaterFeature(string route)
|
|
{
|
|
var write = CreateWriter(route, CreateConfig(true), null);
|
|
var program = CreateProgram(CreateCall(7, -12.34567, 6.78901), CreateCall(8, 1, 2));
|
|
using var output = new RejectHoleCallWriter();
|
|
|
|
var error = Assert.Throws<IOException>(() => write(output, program));
|
|
|
|
Assert.Same(output.Failure, error);
|
|
Assert.Equal(Header(route) + FirstHole(route, "N73 G52 X-12.35 Y6.79"), output.ToString());
|
|
}
|
|
|
|
private static CincinnatiPostConfig CreateConfig(bool lineNumbers) => new()
|
|
{
|
|
PostedAccuracy = 2,
|
|
FeatureLineNumberStart = 73,
|
|
UseLineNumbers = lineNumbers,
|
|
PalletExchange = PalletMode.None,
|
|
ProcessParameterMode = G89Mode.Explicit,
|
|
KerfCompensation = KerfMode.PreApplied,
|
|
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
|
|
)
|
|
{
|
|
var byProgram = mapping == null
|
|
? null
|
|
: new Dictionary<Program, int>(ReferenceEqualityComparer.Instance);
|
|
|
|
void Resolve(Program owner)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
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);
|
|
var part = new Part(new Drawing("Sample", program));
|
|
plate.Parts.Add(part);
|
|
Resolve(part.Program);
|
|
sheetWriter.Write(output, plate, "Nest", 1, 101, "", "", partMapping);
|
|
};
|
|
}
|
|
|
|
private static Program CreateProgram(params ICode[] codes)
|
|
{
|
|
var program = new Program();
|
|
program.Codes.AddRange(codes);
|
|
foreach (var call in codes.OfType<SubProgramCall>())
|
|
program.SubPrograms[call.Id] = call.Program;
|
|
return program;
|
|
}
|
|
|
|
private static SubProgramCall CreateCall(int id, double x, double y)
|
|
{
|
|
var hole = new Program();
|
|
hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin });
|
|
hole.Codes.Add(new ArcMove(new Vector(1, 0), Vector.Zero, RotationType.CW));
|
|
hole.Mode = Mode.Incremental;
|
|
return new SubProgramCall { Id = id, Program = hole, Offset = new Vector(x, y) };
|
|
}
|
|
|
|
// Literal snapshots deliberately do not call the production emitter/formatter.
|
|
private static string WrapOutput(string route, string body) => Header(route) + body +
|
|
(route == "part" ? Lines("M99 (END OF Sample)") : Lines("M42", "M99 (END OF Nest.001)"));
|
|
|
|
private static string Header(string route)
|
|
{
|
|
if (route == "part")
|
|
return Lines("(*****************************************************)", ":201", "( PART: Sample )");
|
|
|
|
return Lines(
|
|
"(*****************************************************)",
|
|
"( START OF Nest.001 )",
|
|
":101",
|
|
"( Layout 1 )",
|
|
"( SHEET NAME = 48 X 96 )",
|
|
"( Total parts on sheet = 1 )",
|
|
"#110=48 (SHEET WIDTH FOR CUTOFFS)",
|
|
"#111=96 (SHEET LENGTH FOR CUTOFFS)",
|
|
"M42",
|
|
"N10000",
|
|
"G92 X#5021 Y#5022",
|
|
"M98 P100 (Variable Declaration)",
|
|
"G90",
|
|
"M47",
|
|
"GOTO1( Goto Feature )"
|
|
);
|
|
}
|
|
|
|
// 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
|
|
{
|
|
public readonly IOException Failure = new("Rejected hole call");
|
|
|
|
public RejectHoleCallWriter() => NewLine = "\n";
|
|
|
|
public override void WriteLine(string? value)
|
|
{
|
|
if (value == "M98 P7")
|
|
throw Failure;
|
|
base.WriteLine(value);
|
|
}
|
|
}
|
|
}
|