using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
///
/// 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.
///
public sealed class GravographPass
{
public IEnumerable> Polylines { get; set; }
public int FeedMmPerSec { get; set; }
public double DepthInches { get; set; }
/// When true, park to origin and prompt the operator before this pass.
public bool PauseBefore { get; set; }
public string PauseMessage { get; set; } = "";
}
///
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
///
/// Scale: 80 steps/mm = 2032 steps/inch. Y (and Z) are negated on the wire.
/// Deltas are signed big-endian int16 (max ±32767 steps ≈ ±16 inches per move).
///
public sealed class GravographISWriter
{
// 93-byte preamble — captured from GravoStyle'98 with the trailing
// job-specific travel block stripped. The VS, VZ and DZ operands are
// patched by the writer to reflect feed and depth options.
//
// The original capture ended with a DR command (FF FD 44 52 00 00)
// followed by three 8-byte int16 records — same format as PU/PD —
// that carried a chunked travel from the head's parked position to
// the original job's first vertex (cumulative ΔX ≈ 1", ΔY ≈ 47").
// Those frozen deltas have nothing to do with our job geometry, so
// replaying them sends the head to a fixed point regardless of where
// the operator set zero. Stripped for the same reason as the captured
// fixed return-to-home block.
private static readonly byte[] PreambleTemplate = new byte[]
{
0x21,
0x41,
0x53,
0x20,
0x33,
0x38,
0x3b,
0x01,
0x90,
0x01,
0xf4,
0x01,
0x90,
0x01,
0xf4,
0x01,
0x90,
0x01,
0xf4,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x09,
0x00,
0x00,
0x03,
0xe8,
0x05,
0x06,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0xff,
0xfd,
0x32,
0x44,
0x00,
0x00,
0xff,
0xfd,
0x4d,
0x43,
0x00,
0x01,
0xff,
0xfd,
0x4f,
0x55,
0xff,
0xfb,
0xff,
0xfd,
0x4f,
0x55,
0xff,
0xfa,
0xff,
0xfd,
0x50,
0x5a,
0x00,
0x00,
0xff,
0xfd,
0x56,
0x53,
0x00,
0x23,
0xff,
0xfd,
0x56,
0x5a,
0x00,
0x23,
0xff,
0xfd,
0x44,
0x5a,
0x01,
0xfc,
};
// Stripped 36-byte postamble: lift, aux off, motor off, operator beep,
// job-finish. The 24-byte return-to-home block that appears in GravoStyle's
// captured postamble between MC and OP is intentionally OMITTED — those
// three 8-byte int16 records carry chunked job-specific return deltas
// (each record is [word1:int16][param:int16][ΔX:int16][ΔY:int16], same
// format as PU/PD records; the original capture chunked the long Y return
// across three records because each delta has to fit in int16). Reusing
// GravoStyle's frozen deltas on different geometry overshoots the X-axis
// limit. We emit calculated return deltas for the current job instead.
// The writer now replaces the captured fixed return block with a calculated
// lift + PU travel to the operator-set origin before these final commands.
private static readonly byte[] EndJobBytes = new byte[]
{
0xff,
0xfd,
0x4f,
0x55,
0xff,
0xfa, // OU 0xFFFA aux off
0xff,
0xfd,
0x4f,
0x55,
0xff,
0xfb, // OU 0xFFFB aux off
0xff,
0xfd,
0x4d,
0x43,
0x00,
0x00, // MC 0x0000 motor off
0xff,
0xfd,
0x4f,
0x50,
0x00,
0x00, // OP 0x0000 operator beep
0xff,
0xfd,
0x4a,
0x46,
0x00,
0x00, // JF 0x0000 job finish
};
// 80 steps/mm × 25.4 mm/in
internal const int StepsPerInch = 2032;
public GravographISWriterOptions Options { get; }
public GravographISWriter()
: this(new GravographISWriterOptions()) { }
public GravographISWriter(GravographISWriterOptions options)
{
Options = options ?? throw new ArgumentNullException(nameof(options));
}
///
/// Writes the full byte stream (preamble + geometry + postamble) for the given
/// polylines. Polyline coordinates are in inches, relative to the operator-set
/// work origin. The writer emits a leading DR travel to the first polyline
/// start before lowering for the first cut.
///
public void Write(IEnumerable> 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
);
}
///
/// 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 is
/// set, parks to the operator origin and emits an operator pause before cutting.
///
public void Write(IReadOnlyList 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)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
// wire steps. The first polyline gets a leading DR travel from this
// origin before PD lowers for cutting. Used by the envelope guard to
// catch bad records before they ship to the engraver.
var headX = 0;
var headY = 0;
var envelopeXSteps = (int)
System.Math.Round(
Options.WorkEnvelopeXMm * StepsPerMm,
MidpointRounding.AwayFromZero
);
var envelopeYSteps = (int)
System.Math.Round(
Options.WorkEnvelopeYMm * StepsPerMm,
MidpointRounding.AwayFromZero
);
var firstPolyline = true;
var polyIndex = 0;
var currentFeed = firstFeed;
var currentDepth = firstDepth;
for (var p = 0; p < passes.Count; p++)
{
var pass = passes[p];
if (p > 0)
{
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;
}
}
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);
if (Options.ReturnToOriginAtEnd && !firstPolyline)
{
WriteTravel(
output,
(byte)'P',
(byte)'U',
checked(-headX),
checked(-headY),
ref headX,
ref headY,
envelopeXSteps,
envelopeYSteps,
polyIndex
);
}
output.Write(EndJobBytes, 0, EndJobBytes.Length);
}
private void WritePolyline(
Stream output,
IReadOnlyList 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,
int envXSteps,
int envYSteps,
int polyIndex,
int segment,
bool isTravel
)
{
if (!Options.EnvelopeGuardEnabled)
return;
// Wire frame: X is identity to input; Y is negated. With the operator
// origin set at the upper-left of the work envelope and an OpenNest
// quadrant-4 plate, valid part coordinates are +X/right and -Y/down:
// wireX ∈ [0, +envXSteps]
// wireY ∈ [0, +envYSteps]
if (wireX >= 0 && wireX <= envXSteps && wireY >= 0 && wireY <= envYSteps)
return;
var inputX = wireX / (double)StepsPerInch;
var inputY = -wireY / (double)StepsPerInch;
var kind = isTravel ? "pen-up travel" : "cut segment";
throw new InvalidOperationException(
$"Polyline {polyIndex} {kind} (segment {segment}) would place the head at "
+ $"({inputX:F3}\", {inputY:F3}\"), outside the {Options.WorkEnvelopeXMm}×{Options.WorkEnvelopeYMm} mm "
+ $"work envelope from upper-left origin. Refusing to emit the record."
);
}
private static short DepthInStepsAsInt16(double depthInches)
{
var steps = (long)
System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
if (steps < short.MinValue || steps > short.MaxValue)
throw new ArgumentOutOfRangeException(
nameof(depthInches),
$"Depth {depthInches} in. → {steps} steps overflows int16."
);
return (short)steps;
}
private static (int x, int y) ToWire(Vector v)
{
// Inches -> steps. With upper-left origin in OpenNest quadrant 4,
// negative input Y is down; Y is negated on the wire.
var x = (int)System.Math.Round(v.X * StepsPerInch, MidpointRounding.AwayFromZero);
var y = (int)System.Math.Round(-v.Y * StepsPerInch, MidpointRounding.AwayFromZero);
return (x, y);
}
private void WriteTravel(
Stream s,
byte c0,
byte c1,
int dx,
int dy,
ref int headX,
ref int headY,
int envelopeXSteps,
int envelopeYSteps,
int polyIndex
)
{
if (dx == 0 && dy == 0)
return;
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
s.WriteByte(0x00);
s.WriteByte(0x00);
var chunks = System.Math.Max(
(int)System.Math.Ceiling(System.Math.Abs(dx) / (double)short.MaxValue),
(int)System.Math.Ceiling(System.Math.Abs(dy) / (double)short.MaxValue)
);
if (chunks < 1)
chunks = 1;
var emittedX = 0;
var emittedY = 0;
for (var i = 1; i <= chunks; i++)
{
var targetX = (int)
System.Math.Round(dx * (i / (double)chunks), MidpointRounding.AwayFromZero);
var targetY = (int)
System.Math.Round(dy * (i / (double)chunks), MidpointRounding.AwayFromZero);
var chunkX = checked(targetX - emittedX);
var chunkY = checked(targetY - emittedY);
EnsureEnvelope(
headX + chunkX,
headY + chunkY,
envelopeXSteps,
envelopeYSteps,
polyIndex,
segment: 0,
isTravel: true
);
WriteRecord(s, chunkX, chunkY);
emittedX = targetX;
emittedY = targetY;
headX += chunkX;
headY += chunkY;
}
}
private static void WriteLiftOnly(Stream s)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte((byte)'P');
s.WriteByte((byte)'U');
s.WriteByte(0x00);
s.WriteByte(0x01);
}
private static void WriteCommandWithRecord(Stream s, byte c0, byte c1, int dx, int dy)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
// Records-follow flag (0x0000) emitted once per PU/PD packet.
s.WriteByte(0x00);
s.WriteByte(0x00);
WriteRecord(s, dx, dy);
}
private static void WriteRecord(Stream s, int dx, int dy)
{
if (
dx < short.MinValue
|| dx > short.MaxValue
|| dy < short.MinValue
|| dy > short.MaxValue
)
{
throw new InvalidOperationException(
$"Move delta ({dx}, {dy}) steps overflows signed int16 — split moves upstream."
);
}
int word1;
int param;
var absDx = (double)System.Math.Abs(dx);
var absDy = (double)System.Math.Abs(dy);
var len = System.Math.Sqrt(absDx * absDx + absDy * absDy);
if (len < 1.0)
{
// Zero-length lift (PU 00 01) is the dedicated form; for a record-carrying
// packet a true zero-length move shouldn't occur, but stay numerically safe.
word1 = 16384;
param = 1;
}
else
{
var maxAbs = System.Math.Max(absDx, absDy);
word1 = (int)
System.Math.Round(16384.0 * maxAbs / len, MidpointRounding.AwayFromZero);
param = (int)System.Math.Round(len / 22.4, MidpointRounding.AwayFromZero);
if (param < 1)
param = 1;
if (param > 180)
param = 180;
if (word1 > 16384)
word1 = 16384;
}
WriteBigEndianInt16(s, (short)word1);
WriteBigEndianInt16(s, (short)param);
WriteBigEndianInt16(s, (short)dx);
WriteBigEndianInt16(s, (short)dy);
}
private static void WriteBigEndianInt16(Stream s, short value)
{
s.WriteByte((byte)((value >> 8) & 0xFF));
s.WriteByte((byte)(value & 0xFF));
}
// Locates the operand of a command (FF FD ) and overwrites it.
// Throws if the command isn't present — that would mean the preamble was mis-edited.
private static void PatchOperand(byte[] buffer, byte c0, byte c1, short value)
{
for (int i = 0; i <= buffer.Length - 6; i++)
{
if (
buffer[i] == 0xFF
&& buffer[i + 1] == 0xFD
&& buffer[i + 2] == c0
&& buffer[i + 3] == c1
)
{
buffer[i + 4] = (byte)((value >> 8) & 0xFF);
buffer[i + 5] = (byte)(value & 0xFF);
return;
}
}
throw new InvalidOperationException(
$"Command '{(char)c0}{(char)c1}' not found in preamble template."
);
}
}
}