feat(posts): trigger the Cincinnati safety head raise by rapid length

The safety raise (M47 P<SafetyHeadraiseDistance>) used to fire only when
the next part had a different drawing name, which has nothing to do with
crash risk: a long rapid between copies of the same part got a plain M47,
while a short hop between neighbouring different drawings got the full
raise. Sheets are rarely flat (5/8" plate can be ~1.5" out of flat within
supplier tolerance), and longer rapids cross more of that bow.

Add SafetyHeadraiseRapidDistance (program units, default 12"): any rapid to
the next pierce longer than it takes the safety raise, regardless of
drawing. Unknown travel never does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 07:08:10 -04:00
1 parent efa4844049
commit abfc1a714a
11 files changed
+77 -51

No files matched your search

@@ -36,7 +36,6 @@ public class CincinnatiFeatureWriterTests
PartName = "BRACKET",
IsFirstFeatureOfPart = true,
IsLastFeatureOnSheet = false,
IsSafetyHeadraise = false,
IsExteriorFeature = false,
LibraryFile = "MILD10",
CutDistance = 18.0,
@@ -433,14 +432,32 @@ public class CincinnatiFeatureWriterTests
{
var config = DefaultConfig();
config.SafetyHeadraiseDistance = 2000;
config.SafetyHeadraiseRapidDistance = 6;
var ctx = SimpleContext();
ctx.IsSafetyHeadraise = true;
ctx.RapidDistance = 6.5;
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
Assert.Contains("M47 P2000 (Safety Headraise)", output);
}
[Theory]
[InlineData(6.0)]
[InlineData(null)]
public void SafetyHeadraise_SkipsRapidsNotLongerThanThreshold(double? rapidDistance)
{
var config = DefaultConfig();
config.SafetyHeadraiseDistance = 2000;
config.SafetyHeadraiseRapidDistance = 6;
var ctx = SimpleContext();
ctx.RapidDistance = rapidDistance;
ctx.IsLastFeatureOnSheet = false;
var output = WriteFeature(config, ctx);
Assert.DoesNotContain("Safety Headraise", output);
Assert.Contains("M47", output);
}
[Fact]
public void ExteriorM47Mode_UsesExteriorConfig()
{
@@ -81,17 +81,22 @@ public class CincinnatiPostRegressionTests
[Theory]
[InlineData(false)]
[InlineData(true)]
public void SafetyHeadraise_IsTheOneRaiseBeforeTheRapidToADifferentPart(bool partSubprograms)
public void SafetyHeadraise_IsTheOneRaiseBeforeEachLongRapid(bool partSubprograms)
{
// The second A is a long rapid from the first despite sharing its drawing.
var nest = SquaresNest(("A", new Vector(5, 5)), ("A", new Vector(20, 5)), ("B", new Vector(40, 30)));
var b = nest.Plates[0].Parts[2];
var parts = nest.Plates[0].Parts;
var config = Config(partSubprograms);
config.SafetyHeadraiseRapidDistance = 6;
var run = Run(Post(nest, partSubprograms));
var run = Run(Post(nest, config));
Assert.Equal(0, run.DoubleRaises);
Assert.Equal(0, run.HeadDownRapids);
var safety = Assert.Single(run.PierceLog, p => p.Raise == "M47 P2000");
Assert.True(Near(safety.Point, b.Location + new Vector(2, 2)), $"Safety raise before {safety.Point}");
var safety = run.PierceLog.Where(p => p.Raise == "M47 P2000").ToList();
Assert.Equal(2, safety.Count);
Assert.True(Near(safety[0].Point, parts[1].Location + new Vector(2, 2)), $"Safety raise before {safety[0].Point}");
Assert.True(Near(safety[1].Point, parts[2].Location + new Vector(2, 2)), $"Safety raise before {safety[1].Point}");
Assert.All(run.PierceLog.Where(p => p.Raise != "M47 P2000"), p => Assert.Equal("M47", p.Raise));
}
@@ -165,6 +165,8 @@ public class HoleCallOutputCharacterizationTests
PalletExchange = PalletMode.None,
KerfCompensation = KerfMode.PreApplied,
UseAntiDive = false,
// Pins plain raises; the safety raise has its own tests.
SafetyHeadraiseRapidDistance = null,
};
/// <param name="mapping">
@@ -18,12 +18,6 @@ public sealed class FeatureContext
public bool IsFirstFeatureOfPart { get; set; }
public bool IsLastFeatureOnSheet { get; set; }
/// <summary>
/// True when the rapid after this feature goes to a different part, so its head
/// raise is the safety head raise.
/// </summary>
public bool IsSafetyHeadraise { get; set; }
/// <summary>True when this feature cuts the part's exterior (selects ExteriorM47).</summary>
public bool IsExteriorFeature { get; set; }
public bool IsEtch { get; set; }
@@ -32,8 +26,8 @@ public sealed class FeatureContext
public double SheetDiagonal { get; set; }
/// <summary>
/// Rapid travel from this feature's end to the next pierce, used by
/// <see cref="M47Mode.Auto"/>. Null when unknown.
/// Rapid travel from this feature's end to the next pierce, used by the safety
/// head raise and <see cref="M47Mode.Auto"/>. Null when unknown.
/// </summary>
public double? RapidDistance { get; set; }
@@ -332,12 +326,7 @@ public sealed class CincinnatiFeatureWriter
private void WriteM47(TextWriter writer, FeatureContext ctx)
{
var line = CincinnatiHeadRaise.Line(
_config,
ctx.IsSafetyHeadraise,
ctx.IsExteriorFeature,
ctx.RapidDistance
);
var line = CincinnatiHeadRaise.Line(_config, ctx.IsExteriorFeature, ctx.RapidDistance);
if (line != null)
writer.WriteLine(line);
}
@@ -5,19 +5,13 @@ namespace OpenNest.Posts.Cincinnati;
/// </summary>
internal static class CincinnatiHeadRaise
{
/// <param name="isSafetyHeadraise">True when the rapid goes to a different part.</param>
/// <param name="isExterior">True when the feature just cut is a part exterior.</param>
/// <param name="rapidDistance">Travel to the next pierce; null when unknown.</param>
/// <returns>The block to write, or null for no head raise.</returns>
internal static string Line(
CincinnatiPostConfig config,
bool isSafetyHeadraise,
bool isExterior,
double? rapidDistance
)
internal static string Line(CincinnatiPostConfig config, bool isExterior, double? rapidDistance)
{
if (isSafetyHeadraise && config.SafetyHeadraiseDistance.HasValue)
return $"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)";
if (IsSafetyRapid(config, rapidDistance))
return SafetyLine(config);
var mode = isExterior ? config.ExteriorM47 : config.InteriorM47;
return mode switch
@@ -29,6 +23,19 @@ internal static class CincinnatiHeadRaise
};
}
/// <summary>
/// True when the rapid is long enough to cross warped plate, so it takes the
/// safety head raise. Unknown travel never does.
/// </summary>
internal static bool IsSafetyRapid(CincinnatiPostConfig config, double? rapidDistance) =>
config.SafetyHeadraiseDistance.HasValue
&& config.SafetyHeadraiseRapidDistance is { } threshold
&& rapidDistance is { } distance
&& distance > threshold;
internal static string SafetyLine(CincinnatiPostConfig config) =>
$"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)";
// Unknown travel, or no threshold configured, always gets the full raise.
private static bool IsLongRapid(CincinnatiPostConfig config, double? rapidDistance) =>
config.M47OverrideDistanceThreshold is not { } threshold
@@ -17,9 +17,9 @@ internal static class CincinnatiHoleCallWriter
/// 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 name="rapidDistance">
/// Travel from the call to the next pierce, which selects the safety head raise;
/// null when unknown.
/// </param>
internal static void Write(
TextWriter w,
@@ -31,7 +31,7 @@ internal static class CincinnatiHoleCallWriter
CoordinateFormatter fmt,
IReadOnlyDictionary<Program, int> holeSubprograms,
string partName = null,
bool isSafetyHeadraise = false
double? rapidDistance = null
)
{
var postSubNum =
@@ -66,8 +66,8 @@ internal static class CincinnatiHoleCallWriter
if (isLastFeature)
return;
if (isSafetyHeadraise && config.SafetyHeadraiseDistance.HasValue)
w.WriteLine($"M47 P{config.SafetyHeadraiseDistance.Value} (Safety Headraise)");
if (CincinnatiHeadRaise.IsSafetyRapid(config, rapidDistance))
w.WriteLine(CincinnatiHeadRaise.SafetyLine(config));
else
w.WriteLine("M47");
}
@@ -89,7 +89,6 @@ public sealed class CincinnatiPartSubprogramWriter
PartName = drawingName,
IsFirstFeatureOfPart = false,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = false,
IsExteriorFeature = exterior[i],
IsEtch = isEtch,
LibraryFile = isEtch ? etchLibrary : cutLibrary,
@@ -180,10 +180,17 @@ namespace OpenNest.Posts.Cincinnati
[Category("7. Safety")]
[DisplayName("Safety Headraise Distance")]
[Description(
"Safety head raise distance in machine units, posted as M47 P<distance> before the rapid to a different part. Null = disabled."
"Safety head raise distance in machine units, posted as M47 P<distance> before rapids longer than the safety headraise rapid distance. Null = disabled."
)]
public int? SafetyHeadraiseDistance { get; set; } = 2000;
[Category("7. Safety")]
[DisplayName("Safety Headraise Rapid Distance")]
[Description(
"Rapids to the next pierce longer than this get the safety head raise instead of the M47 mode's raise, so the head clears warped plate. Program units. Null = disabled."
)]
public double? SafetyHeadraiseRapidDistance { get; set; } = 12.0;
[Category("8. Pallet")]
[DisplayName("Pallet Exchange")]
[Description("When pallet exchange occurs (None, EndOfSheet, or StartAndEnd).")]
@@ -210,9 +210,8 @@ public sealed class CincinnatiSheetWriter
var next = i + 1 < items.Count ? items[i + 1] : null;
var isLastFeature = next == null;
// A head raise belongs before the rapid that leaves this item, so the safety
// raise goes on a part's last item when a different part follows.
var isSafetyHeadraise = next != null && next.Part.BaseDrawing.Name != partName;
// A head raise belongs before the rapid that leaves this item; its travel
// decides whether that raise is the safety raise.
double? rapidDistance =
next == null
? null
@@ -228,7 +227,6 @@ public sealed class CincinnatiSheetWriter
item,
i,
partName,
isSafetyHeadraise,
rapidDistance,
isLastFeature
);
@@ -250,7 +248,7 @@ public sealed class CincinnatiSheetWriter
_fmt,
_holeSubprograms,
isFirstFeatureOfPart ? partName : null,
isSafetyHeadraise
rapidDistance
);
continue;
}
@@ -262,7 +260,6 @@ public sealed class CincinnatiSheetWriter
PartName = partName,
IsFirstFeatureOfPart = isFirstFeatureOfPart,
IsLastFeatureOnSheet = isLastFeature,
IsSafetyHeadraise = isSafetyHeadraise,
IsExteriorFeature = item.IsExterior,
IsEtch = item.IsEtch,
LibraryFile = item.IsEtch ? etchLibrary : cutLibrary,
@@ -296,7 +293,6 @@ public sealed class CincinnatiSheetWriter
SheetItem item,
int index,
string partName,
bool isSafetyHeadraise,
double? rapidDistance,
bool isLastPart
)
@@ -325,7 +321,7 @@ public sealed class CincinnatiSheetWriter
// One head raise covers the rapid back and the rapid on. The part is complete,
// so the raise follows the exterior M47 mode.
var raise = CincinnatiHeadRaise.Line(_config, isSafetyHeadraise, true, rapidDistance);
var raise = CincinnatiHeadRaise.Line(_config, true, rapidDistance);
if (raise != null)
w.WriteLine(raise);
@@ -20,6 +20,7 @@
"ExteriorM47": "Always",
"M47OverrideDistanceThreshold": null,
"SafetyHeadraiseDistance": 2000,
"SafetyHeadraiseRapidDistance": 12.0,
"PalletExchange": "EndOfSheet",
"LeadInFeedratePercent": 0.5,
"LeadInArcLine2FeedratePercent": 0.5,
+8 -5
View File
@@ -115,8 +115,11 @@ Every raise is written at the end of the feature it follows, before the rapid
that leaves it, and there is exactly one raise per rapid. The block is chosen
in this order (`CincinnatiHeadRaise`):
1. `M47 P<SafetyHeadraiseDistance> (Safety Headraise)` when the next feature
belongs to a part with a different drawing name and the distance is set.
1. `M47 P<SafetyHeadraiseDistance> (Safety Headraise)` when the rapid travel to
the next pierce exceeds `SafetyHeadraiseRapidDistance` (program units, default
12) and both settings are set. Long rapids cross more of a warped sheet, so
the head goes higher; which part or drawing comes next does not matter.
Unknown travel (a hole call's rapid inside a part sub-program) never takes it.
2. Otherwise the M47 mode for the feature just cut: `ExteriorM47` after a
part's exterior, `InteriorM47` after anything else.
- `Always`: `M47`.
@@ -187,9 +190,9 @@ M47 ; head raise unless this is the last feature (§3.
`<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.
part sub-program (whose frame is already the part's). A hole call followed by
a long rapid takes the safety head raise on that call's `M47`, as an ordinary
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