fix: improve arc-tangency fitting and add layered engrave/cut passes for GravographIS

GeometrySimplifier/ArcFit now fit arcs that pass exactly through run
endpoints while balancing tangency error between trusted and estimated
directions, fixing arcs that previously bulged or broke tangent
continuity at fillet/compound-curve junctions.

GravographIS post processor gains per-layer (engrave/cut) tool passes
via a new GravographISPostConfig, so ENGRAVE/ETCH-tagged geometry runs
as a separate scribe pass with its own feed/depth and an operator
pause before the cut pass (spring-floated spindle needs a tool swap).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
aj
2026-08-06 23:15:18 -04:00
co-authored by Claude Opus 4.6
parent e493d83899
commit a085339ba9
15 changed files with 1084 additions and 138 deletions
@@ -0,0 +1,81 @@
using System.ComponentModel;
using OpenNest.CNC;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
/// configuration PropertyGrid and persisted to JSON.
/// </summary>
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class LayerCutConfig
{
[DisplayName("Feed (mm/sec)")]
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
public int FeedMmPerSec { get; set; } = 10;
[DisplayName("Depth (inches)")]
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
public double Depth { get; set; } = 0.25;
[DisplayName("Pause Before")]
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
public bool PauseBefore { get; set; }
[DisplayName("Pause Message")]
[Description("Message shown on the controller during the pause.")]
public string PauseMessage { get; set; } = "";
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
}
/// <summary>
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
/// The cut block carries the tool-change pause by default.
/// </summary>
public sealed class GravographISPostConfig
{
[Category("Engrave (Scribe)")]
[DisplayName("Engrave")]
[Description("Parameters for engrave/scribe geometry (text).")]
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
{
FeedMmPerSec = 10,
Depth = 0.25,
PauseBefore = false,
PauseMessage = "",
};
[Category("Cut")]
[DisplayName("Cut")]
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
{
FeedMmPerSec = 3,
Depth = 0.25,
PauseBefore = true,
PauseMessage = "Change tool",
};
/// <summary>
/// Returns the cut config a polyline of the given layer should use, or null
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
/// </summary>
public LayerCutConfig ConfigFor(LayerType layer)
{
switch (layer)
{
case LayerType.Scribe:
return Engrave;
case LayerType.Cut:
case LayerType.Leadin:
case LayerType.Leadout:
return Cut;
default:
return null;
}
}
}
}
@@ -1,16 +1,30 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Ports;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
///
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
/// The cut pass pauses by default so the operator can swap/adjust the tool.
/// </summary>
public sealed class GravographISPostProcessor : IPostProcessor
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
public string Name => "Gravograph IS8000";
public string Author => "OpenNest";
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
@@ -23,14 +37,53 @@ namespace OpenNest.Posts.GravographIS
public bool AllowReverse { get; set; } = true;
public GravographISPostConfig Config { get; }
object IConfigurablePostProcessor.Config => Config;
public GravographISPostProcessor()
{
var configPath = GetConfigPath();
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
Config = JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
?? new GravographISPostConfig();
}
else
{
Config = new GravographISPostConfig();
SaveConfig();
}
}
public GravographISPostProcessor(GravographISPostConfig config)
{
Config = config ?? throw new ArgumentNullException(nameof(config));
}
public void SaveConfig()
{
var configPath = GetConfigPath();
var json = JsonSerializer.Serialize(Config, JsonOptions);
File.WriteAllText(configPath, json);
}
private static string GetConfigPath()
{
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
var dir = Path.GetDirectoryName(assemblyPath);
var name = Path.GetFileNameWithoutExtension(assemblyPath);
return Path.Combine(dir, name + ".json");
}
public void Post(Nest nest, Stream outputStream)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
var polylines = Extractor.Extract(nest);
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
var passes = BuildPasses(Extractor.ExtractLayered(nest));
new GravographISWriter(WriterOptions).Write(passes, outputStream);
}
public void Post(Nest nest, string outputFile)
@@ -39,6 +92,52 @@ namespace OpenNest.Posts.GravographIS
Post(nest, fs);
}
/// <summary>
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
/// first, then cut. Each group is stitch/reverse-optimized independently.
/// Geometry whose layer maps to no config (Display) is skipped. When only one
/// group is present, a single pass is returned (and so the writer emits no pause).
/// </summary>
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var engrave = new List<IReadOnlyList<Vector>>();
var cut = new List<IReadOnlyList<Vector>>();
foreach (var poly in polylines)
{
if (poly == null) continue;
var block = Config.ConfigFor(poly.Layer);
if (block == null)
continue; // non-cutting (Display) geometry
if (ReferenceEquals(block, Config.Engrave))
engrave.Add(poly.Points);
else
cut.Add(poly.Points);
}
var passes = new List<GravographPass>();
if (engrave.Count > 0)
passes.Add(MakePass(Config.Engrave, engrave));
if (cut.Count > 0)
passes.Add(MakePass(Config.Cut, cut));
return passes;
}
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
{
return new GravographPass
{
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
FeedMmPerSec = block.FeedMmPerSec,
DepthInches = block.Depth,
PauseBefore = block.PauseBefore,
PauseMessage = block.PauseMessage ?? "",
};
}
/// <summary>
/// Buffers the encoded job in memory, then streams it to the named COM port.
/// </summary>
+176 -39
View File
@@ -1,10 +1,30 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
/// post builds one pass for engrave and one for cut.
/// </summary>
public sealed class GravographPass
{
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
public int FeedMmPerSec { get; set; }
public double DepthInches { get; set; }
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
public bool PauseBefore { get; set; }
public string PauseMessage { get; set; } = "";
}
/// <summary>
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
@@ -84,12 +104,40 @@ namespace OpenNest.Posts.GravographIS
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
// A single pass at the configured feed/depth — byte-identical to the
// original single-group output (no transitions, no pause).
Write(new[]
{
new GravographPass
{
Polylines = polylines,
FeedMmPerSec = Options.FeedMmPerSec,
DepthInches = Options.DepthInches,
PauseBefore = false,
PauseMessage = "",
},
}, output);
}
/// <summary>
/// Writes the full byte stream for an ordered list of tool passes. The preamble
/// carries the first pass's feed/depth; each later pass emits an inline feed
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
/// set, parks to the operator origin and emits an operator pause before cutting.
/// </summary>
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
{
if (passes == null) throw new ArgumentNullException(nameof(passes));
if (output == null) throw new ArgumentNullException(nameof(output));
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
var preamble = (byte[])PreambleTemplate.Clone();
PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
output.Write(preamble, 0, preamble.Length);
// Cumulative head position from the operator-set upper-left origin, in
@@ -105,45 +153,51 @@ namespace OpenNest.Posts.GravographIS
var firstPolyline = true;
var polyIndex = 0;
var currentFeed = firstFeed;
var currentDepth = firstDepth;
foreach (var poly in polylines)
for (var p = 0; p < passes.Count; p++)
{
polyIndex++;
if (poly == null || poly.Count < 2)
continue;
var pass = passes[p];
var (startX, startY) = ToWire(poly[0]);
WriteTravel(output,
firstPolyline ? (byte)'D' : (byte)'P',
firstPolyline ? (byte)'R' : (byte)'U',
checked(startX - headX), checked(startY - headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
// PD command + single records-follow flag, then one record per segment.
output.WriteByte(0xFF);
output.WriteByte(0xFD);
output.WriteByte((byte)'P');
output.WriteByte((byte)'D');
output.WriteByte(0x00);
output.WriteByte(0x00);
var prevX = startX;
var prevY = startY;
for (int i = 1; i < poly.Count; i++)
if (p > 0)
{
var (cx, cy) = ToWire(poly[i]);
var dx = checked(cx - prevX);
var dy = checked(cy - prevY);
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
polyIndex, segment: i, isTravel: false);
WriteRecord(output, dx, dy);
prevX = cx;
prevY = cy;
headX += dx;
headY += dy;
if (pass.PauseBefore)
{
// Park: lift Z, then rapid (pen-up) back to the operator origin so
// the head is clear of the work while the tool is swapped.
WriteLiftOnly(output);
WriteTravel(output, (byte)'P', (byte)'U',
checked(-headX), checked(-headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
WritePauseCore(output, pass.PauseMessage);
}
if (pass.FeedMmPerSec != currentFeed)
{
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
currentFeed = pass.FeedMmPerSec;
}
if (pass.DepthInches != currentDepth)
{
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
currentDepth = pass.DepthInches;
}
}
firstPolyline = false;
if (pass.Polylines == null) continue;
foreach (var poly in pass.Polylines)
{
polyIndex++;
if (poly == null || poly.Count < 2)
continue;
WritePolyline(output, poly, ref firstPolyline, ref headX, ref headY,
envelopeXSteps, envelopeYSteps, polyIndex);
}
}
WriteLiftOnly(output);
@@ -156,6 +210,89 @@ namespace OpenNest.Posts.GravographIS
output.Write(EndJobBytes, 0, EndJobBytes.Length);
}
private void WritePolyline(Stream output, IReadOnlyList<Vector> poly,
ref bool firstPolyline, ref int headX, ref int headY,
int envelopeXSteps, int envelopeYSteps, int polyIndex)
{
var (startX, startY) = ToWire(poly[0]);
WriteTravel(output,
firstPolyline ? (byte)'D' : (byte)'P',
firstPolyline ? (byte)'R' : (byte)'U',
checked(startX - headX), checked(startY - headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
// PD command + single records-follow flag, then one record per segment.
output.WriteByte(0xFF);
output.WriteByte(0xFD);
output.WriteByte((byte)'P');
output.WriteByte((byte)'D');
output.WriteByte(0x00);
output.WriteByte(0x00);
var prevX = startX;
var prevY = startY;
for (int i = 1; i < poly.Count; i++)
{
var (cx, cy) = ToWire(poly[i]);
var dx = checked(cx - prevX);
var dy = checked(cy - prevY);
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
polyIndex, segment: i, isTravel: false);
WriteRecord(output, dx, dy);
prevX = cx;
prevY = cy;
headX += dx;
headY += dy;
}
firstPolyline = false;
}
// The operator pause, minus the leading lift/park which the caller emits.
// Stops the spindle (MC off), turns off aux, writes the console message, then
// restarts the spindle (MC on) so the job resumes when the operator presses start.
private static void WritePauseCore(Stream s, string message)
{
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
WriteMessagePackets(s, message);
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
}
// Console label packets carry two ASCII chars each; an odd-length message is
// space-padded to a whole number of packets so widths stay 2 bytes.
private static void WriteMessagePackets(Stream s, string message)
{
if (string.IsNullOrEmpty(message)) return;
var chars = Encoding.ASCII.GetBytes(message);
for (var i = 0; i < chars.Length; i += 2)
{
var c0 = chars[i];
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
}
}
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
{
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
}
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
s.WriteByte(hi);
s.WriteByte(lo);
}
private const double StepsPerMm = 80.0;
private void EnsureEnvelope(int wireX, int wireY,
@@ -181,11 +318,11 @@ namespace OpenNest.Posts.GravographIS
$"work envelope from upper-left origin. Refusing to emit the record.");
}
private short DepthInStepsAsInt16()
private static short DepthInStepsAsInt16(double depthInches)
{
var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero);
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
if (steps < short.MinValue || steps > short.MaxValue)
throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16.");
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16.");
return (short)steps;
}
@@ -1,15 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// A polyline together with the <see cref="LayerType"/> of the moves that
/// produced it. The Gravograph post groups by layer to emit separate engrave
/// and cut passes (with a tool-change pause between them).
/// </summary>
public sealed class LayeredPolyline
{
public LayeredPolyline(List<Vector> points, LayerType layer)
{
Points = points;
Layer = layer;
}
public List<Vector> Points { get; }
public LayerType Layer { get; }
}
/// <summary>
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
/// tolerance (the wire format takes line segments only).
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
/// line segments only).
/// </summary>
public sealed class NestPolylineExtractor
{
@@ -17,13 +37,31 @@ namespace OpenNest.Posts.GravographIS
/// <summary>
/// Extracts polylines from every non-cutoff part in every plate of the nest,
/// returning them in plate coordinates (inches).
/// returning them in plate coordinates (inches). Layer information is dropped;
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
/// </summary>
public List<List<Vector>> Extract(Nest nest)
{
return ExtractLayered(nest).Select(p => p.Points).ToList();
}
/// <summary>
/// Extracts polylines for a single part without layer information.
/// </summary>
public List<List<Vector>> ExtractPart(Part part)
{
return ExtractPartLayered(part).Select(p => p.Points).ToList();
}
/// <summary>
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
/// in plate coordinates (inches). Each polyline is layer-uniform.
/// </summary>
public List<LayeredPolyline> ExtractLayered(Nest nest)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
var result = new List<List<Vector>>();
var result = new List<LayeredPolyline>();
foreach (var plate in nest.Plates)
{
@@ -40,17 +78,17 @@ namespace OpenNest.Posts.GravographIS
}
/// <summary>
/// Extracts polylines for a single part. Public so callers driving the
/// writer directly (e.g. from a console one-off) can use it.
/// Extracts layer-tagged polylines for a single part. Public so callers
/// driving the writer directly (e.g. from a console one-off) can use it.
/// </summary>
public List<List<Vector>> ExtractPart(Part part)
public List<LayeredPolyline> ExtractPartLayered(Part part)
{
var list = new List<List<Vector>>();
var list = new List<LayeredPolyline>();
ExtractPart(part, list);
return list;
}
private void ExtractPart(Part part, List<List<Vector>> sink)
private void ExtractPart(Part part, List<LayeredPolyline> sink)
{
var program = part.Program;
if (program == null) return;
@@ -67,6 +105,7 @@ namespace OpenNest.Posts.GravographIS
var offset = part.Location;
var pos = new Vector(0, 0);
List<Vector> current = null;
var currentLayer = LayerType.Cut;
foreach (var code in program.Codes)
{
@@ -77,17 +116,14 @@ namespace OpenNest.Posts.GravographIS
{
case RapidMove rapid:
{
FlushCurrent(sink, ref current);
FlushCurrent(sink, ref current, currentLayer);
pos = rapid.EndPoint;
break;
}
case LinearMove linear:
{
if (current == null)
{
current = new List<Vector> { pos + offset };
}
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
var end = linear.EndPoint;
current.Add(end + offset);
pos = end;
@@ -96,10 +132,7 @@ namespace OpenNest.Posts.GravographIS
case ArcMove arc:
{
if (current == null)
{
current = new List<Vector> { pos + offset };
}
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
pos = arc.EndPoint;
break;
@@ -107,13 +140,33 @@ namespace OpenNest.Posts.GravographIS
}
}
FlushCurrent(sink, ref current);
FlushCurrent(sink, ref current, currentLayer);
}
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> current)
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
// seeded at `seed` (the current pen position). When the layer changes
// mid-chain the previous polyline is flushed and a new one begins at the
// shared seam vertex so engrave and cut passes stay geometrically continuous.
private static void StartOrSplit(List<LayeredPolyline> sink, ref List<Vector> current,
ref LayerType currentLayer, LayerType moveLayer, Vector seed)
{
if (current == null)
{
current = new List<Vector> { seed };
currentLayer = moveLayer;
}
else if (moveLayer != currentLayer)
{
FlushCurrent(sink, ref current, currentLayer);
current = new List<Vector> { seed };
currentLayer = moveLayer;
}
}
private static void FlushCurrent(List<LayeredPolyline> sink, ref List<Vector> current, LayerType layer)
{
if (current != null && current.Count >= 2)
sink.Add(current);
sink.Add(new LayeredPolyline(current, layer));
current = null;
}