diff --git a/OpenNest.Posts.GravographIS/GravographISPort.cs b/OpenNest.Posts.GravographIS/GravographISPort.cs
new file mode 100644
index 0000000..29e9bc0
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/GravographISPort.cs
@@ -0,0 +1,99 @@
+using System;
+using System.IO.Ports;
+using System.Threading;
+
+namespace OpenNest.Posts.GravographIS
+{
+ ///
+ /// Serial streamer for the Gravograph IS8000. 9600 8-N-1; flow control is
+ /// configurable and defaults to RTS/CTS (the controller is buffered and drops
+ /// CTS to apply backpressure). The job is sent in modest chunks rather than as
+ /// one giant write so the handshake can pause the write mid-stream.
+ ///
+ public sealed class GravographISPort : IDisposable
+ {
+ private SerialPort port;
+
+ public const int DefaultBaudRate = 9600;
+ public const int DefaultChunkSize = 256;
+ public const int DefaultWriteTimeoutMs = 30000;
+
+ public int ChunkSize { get; set; } = DefaultChunkSize;
+ public int WriteTimeoutMs { get; set; } = DefaultWriteTimeoutMs;
+
+ public bool IsOpen => port != null && port.IsOpen;
+
+ ///
+ /// Opens the port at the controller's required line settings (9600 8-N-1)
+ /// with the given . Throws if the port is
+ /// already open or if opening fails.
+ ///
+ public void Open(string portName, Handshake handshake = Handshake.RequestToSend)
+ {
+ if (string.IsNullOrWhiteSpace(portName))
+ throw new ArgumentException("Port name is required.", nameof(portName));
+ if (port != null)
+ throw new InvalidOperationException("Port is already open.");
+
+ port = new SerialPort(portName, DefaultBaudRate, Parity.None, 8, StopBits.One)
+ {
+ Handshake = handshake,
+ WriteTimeout = WriteTimeoutMs,
+ ReadTimeout = WriteTimeoutMs,
+ // DTR/RTS are needed for some USB-serial bridges and for RTS/CTS flow:
+ DtrEnable = true,
+ RtsEnable = handshake != Handshake.RequestToSend &&
+ handshake != Handshake.RequestToSendXOnXOff,
+ };
+
+ port.Open();
+ }
+
+ ///
+ /// Streams the encoded job to the port in chunks. Cancellable. The chunked
+ /// write is intentional — Write() blocks until the OS accepts the bytes,
+ /// which with RTS/CTS or XOn/XOff yields cleanly when the controller's
+ /// buffer is full.
+ ///
+ public void StreamJob(byte[] data, CancellationToken cancellationToken = default)
+ {
+ if (data == null) throw new ArgumentNullException(nameof(data));
+ if (port == null || !port.IsOpen)
+ throw new InvalidOperationException("Port is not open.");
+
+ var chunk = ChunkSize > 0 ? ChunkSize : DefaultChunkSize;
+ var offset = 0;
+
+ while (offset < data.Length)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var count = System.Math.Min(chunk, data.Length - offset);
+ port.Write(data, offset, count);
+ offset += count;
+ }
+
+ // Block until the OS has handed the last bytes to the line. SerialPort
+ // doesn't expose flush-and-drain directly; BaseStream.Flush is a no-op
+ // on Windows, so this is best-effort.
+ try { port.BaseStream.Flush(); }
+ catch { /* ignored — Flush is advisory on SerialPort */ }
+ }
+
+ public void Close()
+ {
+ if (port == null) return;
+ try
+ {
+ if (port.IsOpen) port.Close();
+ }
+ finally
+ {
+ port.Dispose();
+ port = null;
+ }
+ }
+
+ public void Dispose() => Close();
+ }
+}
diff --git a/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs b/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs
new file mode 100644
index 0000000..1e7c67e
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs
@@ -0,0 +1,61 @@
+using System;
+using System.IO;
+using System.IO.Ports;
+using System.Threading;
+
+namespace OpenNest.Posts.GravographIS
+{
+ ///
+ /// IPostProcessor implementation for the Gravograph IS8000.
+ /// writes the binary HPGL bytes. For serial streaming, use .
+ ///
+ public sealed class GravographISPostProcessor : IPostProcessor
+ {
+ public string Name => "Gravograph IS8000";
+ public string Author => "OpenNest";
+ public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
+
+ public GravographISWriterOptions WriterOptions { get; } = new GravographISWriterOptions();
+
+ public NestPolylineExtractor Extractor { get; } = new NestPolylineExtractor();
+
+ public double StitchTolerance { get; set; } = PolylinePrePass.DefaultStitchTolerance;
+
+ public bool AllowReverse { get; set; } = true;
+
+ 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);
+ }
+
+ public void Post(Nest nest, string outputFile)
+ {
+ using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
+ Post(nest, fs);
+ }
+
+ ///
+ /// Buffers the encoded job in memory, then streams it to the named COM port.
+ ///
+ public void Stream(Nest nest, string portName,
+ Handshake handshake = Handshake.RequestToSend,
+ CancellationToken cancellationToken = default)
+ {
+ byte[] bytes;
+ using (var ms = new MemoryStream())
+ {
+ Post(nest, ms);
+ bytes = ms.ToArray();
+ }
+
+ using var port = new GravographISPort();
+ port.Open(portName, handshake);
+ port.StreamJob(bytes, cancellationToken);
+ }
+ }
+}
diff --git a/OpenNest.Posts.GravographIS/GravographISWriter.cs b/OpenNest.Posts.GravographIS/GravographISWriter.cs
new file mode 100644
index 0000000..285eb38
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/GravographISWriter.cs
@@ -0,0 +1,327 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using OpenNest.Geometry;
+
+namespace OpenNest.Posts.GravographIS
+{
+ ///
+ /// 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));
+ if (output == null) throw new ArgumentNullException(nameof(output));
+
+ 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());
+ 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;
+
+ foreach (var poly in polylines)
+ {
+ polyIndex++;
+ if (poly == null || poly.Count < 2)
+ continue;
+
+ 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;
+ }
+
+ 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 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 short DepthInStepsAsInt16()
+ {
+ var steps = (long)System.Math.Round(Options.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.");
+ 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.");
+ }
+ }
+}
diff --git a/OpenNest.Posts.GravographIS/GravographISWriterOptions.cs b/OpenNest.Posts.GravographIS/GravographISWriterOptions.cs
new file mode 100644
index 0000000..4eabf01
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/GravographISWriterOptions.cs
@@ -0,0 +1,24 @@
+namespace OpenNest.Posts.GravographIS
+{
+ public sealed class GravographISWriterOptions
+ {
+ public double DepthInches { get; set; } = 0.25;
+
+ public int FeedMmPerSec { get; set; } = 35;
+
+ // IS8000 work envelope in millimeters, from the operator-set upper-left
+ // work origin. Defaults to the catalog 0.610 m x 1.220 m bed. With an
+ // OpenNest quadrant-4 plate, motion is allowed right (+X) and down (-Y).
+ public double WorkEnvelopeXMm { get; set; } = 610.0;
+ public double WorkEnvelopeYMm { get; set; } = 1220.0;
+
+ // When true, the writer throws an InvalidOperationException naming the
+ // offending polyline and segment before any out-of-envelope record is
+ // emitted. Disable only for off-machine encoding tests.
+ public bool EnvelopeGuardEnabled { get; set; } = true;
+
+ // When true, lift at the end of the last cut and return to the
+ // operator-set origin before shutting the job down.
+ public bool ReturnToOriginAtEnd { get; set; } = true;
+ }
+}
diff --git a/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs b/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs
new file mode 100644
index 0000000..b4e9055
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Posts.GravographIS
+{
+ ///
+ /// Lifts polylines out of an OpenNest for the Gravograph
+ /// backend. Walks each 's , breaks
+ /// polylines at rapid moves, and tessellates arcs to a chord-deviation
+ /// tolerance (the wire format takes line segments only).
+ ///
+ public sealed class NestPolylineExtractor
+ {
+ public double ArcChordToleranceInches { get; set; } = 0.001;
+
+ ///
+ /// Extracts polylines from every non-cutoff part in every plate of the nest,
+ /// returning them in plate coordinates (inches).
+ ///
+ public List> Extract(Nest nest)
+ {
+ if (nest == null) throw new ArgumentNullException(nameof(nest));
+
+ var result = new List>();
+
+ foreach (var plate in nest.Plates)
+ {
+ foreach (var part in plate.Parts)
+ {
+ if (part.BaseDrawing != null && part.BaseDrawing.IsCutOff)
+ continue;
+
+ ExtractPart(part, result);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Extracts polylines for a single part. Public so callers driving the
+ /// writer directly (e.g. from a console one-off) can use it.
+ ///
+ public List> ExtractPart(Part part)
+ {
+ var list = new List>();
+ ExtractPart(part, list);
+ return list;
+ }
+
+ private void ExtractPart(Part part, List> sink)
+ {
+ var program = part.Program;
+ if (program == null) return;
+
+ // The walk below treats Motion.EndPoint as absolute. Convert a working
+ // copy to absolute mode so G91 programs (the form OpenNest's UI writes)
+ // produce correct geometry. Cloning keeps part.Program untouched.
+ if (program.Mode == Mode.Incremental)
+ {
+ program = (Program)program.Clone();
+ program.Mode = Mode.Absolute;
+ }
+
+ var offset = part.Location;
+ var pos = new Vector(0, 0);
+ List current = null;
+
+ foreach (var code in program.Codes)
+ {
+ if (code is Motion m && m.Suppressed)
+ continue;
+
+ switch (code)
+ {
+ case RapidMove rapid:
+ {
+ FlushCurrent(sink, ref current);
+ pos = rapid.EndPoint;
+ break;
+ }
+
+ case LinearMove linear:
+ {
+ if (current == null)
+ {
+ current = new List { pos + offset };
+ }
+ var end = linear.EndPoint;
+ current.Add(end + offset);
+ pos = end;
+ break;
+ }
+
+ case ArcMove arc:
+ {
+ if (current == null)
+ {
+ current = new List { pos + offset };
+ }
+ TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
+ pos = arc.EndPoint;
+ break;
+ }
+ }
+ }
+
+ FlushCurrent(sink, ref current);
+ }
+
+ private static void FlushCurrent(List> sink, ref List current)
+ {
+ if (current != null && current.Count >= 2)
+ sink.Add(current);
+ current = null;
+ }
+
+ // Sample points along an arc to within chordTol of the true curve. start is
+ // the arc's start point (current pen position), arc.CenterPoint is absolute
+ // (G-code I/J in this codebase are stored as the absolute center), arc.EndPoint
+ // is absolute end. The starting point is assumed to already be in the polyline;
+ // intermediate samples and the endpoint are appended.
+ private static void TessellateArc(Vector start, ArcMove arc, Vector offset,
+ double chordTol, List sink)
+ {
+ var c = arc.CenterPoint;
+ var r = c.DistanceTo(start);
+ if (r < 1e-9)
+ {
+ sink.Add(arc.EndPoint + offset);
+ return;
+ }
+
+ var a0 = System.Math.Atan2(start.Y - c.Y, start.X - c.X);
+ var a1 = System.Math.Atan2(arc.EndPoint.Y - c.Y, arc.EndPoint.X - c.X);
+
+ double sweep;
+ if (arc.Rotation == RotationType.CW)
+ {
+ sweep = a0 - a1;
+ if (sweep <= 0) sweep += 2 * System.Math.PI;
+ }
+ else
+ {
+ sweep = a1 - a0;
+ if (sweep <= 0) sweep += 2 * System.Math.PI;
+ }
+
+ // Treat a near-zero sweep with coincident start/end as a full circle.
+ if (sweep < 1e-9 &&
+ System.Math.Abs(start.X - arc.EndPoint.X) < 1e-9 &&
+ System.Math.Abs(start.Y - arc.EndPoint.Y) < 1e-9)
+ {
+ sweep = 2 * System.Math.PI;
+ }
+
+ // Max angle step from chord-deviation tolerance: dev = r * (1 - cos(t/2)).
+ var maxAngleStep = 2.0 * System.Math.Acos(System.Math.Max(0.0, 1.0 - chordTol / r));
+ if (double.IsNaN(maxAngleStep) || maxAngleStep <= 0)
+ maxAngleStep = System.Math.PI / 32;
+
+ var steps = (int)System.Math.Ceiling(sweep / maxAngleStep);
+ if (steps < 1) steps = 1;
+
+ var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0;
+ for (int i = 1; i < steps; i++)
+ {
+ var t = sweep * (i / (double)steps);
+ var ang = a0 + direction * t;
+ var pt = new Vector(c.X + r * System.Math.Cos(ang), c.Y + r * System.Math.Sin(ang));
+ sink.Add(pt + offset);
+ }
+
+ sink.Add(arc.EndPoint + offset);
+ }
+ }
+}
diff --git a/OpenNest.Posts.GravographIS/OpenNest.Posts.GravographIS.csproj b/OpenNest.Posts.GravographIS/OpenNest.Posts.GravographIS.csproj
new file mode 100644
index 0000000..a359c3d
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/OpenNest.Posts.GravographIS.csproj
@@ -0,0 +1,20 @@
+
+
+ net8.0-windows
+ OpenNest.Posts.GravographIS
+
+
+
+
+
+
+
+
+
+
+ ..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\
+
+
+
+
+
diff --git a/OpenNest.Posts.GravographIS/PolylinePrePass.cs b/OpenNest.Posts.GravographIS/PolylinePrePass.cs
new file mode 100644
index 0000000..1b67b8f
--- /dev/null
+++ b/OpenNest.Posts.GravographIS/PolylinePrePass.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+namespace OpenNest.Posts.GravographIS
+{
+ ///
+ /// Geometry pre-pass for the Gravograph IS8000 backend. The machine is a dumb
+ /// executor — it never reorders geometry and always lifts between separate
+ /// entities — so we stitch shared-endpoint polylines together and reorder by
+ /// nearest-neighbor before encoding.
+ ///
+ public static class PolylinePrePass
+ {
+ public const double DefaultStitchTolerance = 1e-6;
+
+ ///
+ /// Joins polylines whose endpoints coincide (within )
+ /// into single continuous polylines. Polylines with fewer than two points are
+ /// dropped. Direction is reversed as needed to make a join. Each input polyline
+ /// is copied — the inputs are not mutated.
+ ///
+ public static List> Stitch(
+ IEnumerable> polylines,
+ double tolerance = DefaultStitchTolerance)
+ {
+ if (polylines == null) throw new ArgumentNullException(nameof(polylines));
+
+ var segs = new List>();
+ foreach (var p in polylines)
+ {
+ if (p == null || p.Count < 2)
+ continue;
+ segs.Add(new List(p));
+ }
+
+ bool changed;
+ do
+ {
+ changed = false;
+ for (int i = 0; i < segs.Count; i++)
+ {
+ var a = segs[i];
+
+ for (int j = 0; j < segs.Count; j++)
+ {
+ if (i == j) continue;
+ var b = segs[j];
+
+ // a-end ↔ b-start: append b to a (skip duplicated joint)
+ if (Near(a[a.Count - 1], b[0], tolerance))
+ {
+ for (int k = 1; k < b.Count; k++) a.Add(b[k]);
+ segs.RemoveAt(j);
+ if (j < i) i--;
+ changed = true;
+ break;
+ }
+
+ // a-end ↔ b-end: append reversed b to a
+ if (Near(a[a.Count - 1], b[b.Count - 1], tolerance))
+ {
+ for (int k = b.Count - 2; k >= 0; k--) a.Add(b[k]);
+ segs.RemoveAt(j);
+ if (j < i) i--;
+ changed = true;
+ break;
+ }
+
+ // a-start ↔ b-end: prepend b to a
+ if (Near(a[0], b[b.Count - 1], tolerance))
+ {
+ var combined = new List(b.Count + a.Count - 1);
+ combined.AddRange(b);
+ for (int k = 1; k < a.Count; k++) combined.Add(a[k]);
+ segs[i] = combined;
+ segs.RemoveAt(j);
+ if (j < i) i--;
+ changed = true;
+ break;
+ }
+
+ // a-start ↔ b-start: prepend reversed b to a
+ if (Near(a[0], b[0], tolerance))
+ {
+ var combined = new List(b.Count + a.Count - 1);
+ for (int k = b.Count - 1; k >= 0; k--) combined.Add(b[k]);
+ for (int k = 1; k < a.Count; k++) combined.Add(a[k]);
+ segs[i] = combined;
+ segs.RemoveAt(j);
+ if (j < i) i--;
+ changed = true;
+ break;
+ }
+ }
+
+ if (changed) break;
+ }
+ }
+ while (changed);
+
+ return segs;
+ }
+
+ ///
+ /// Greedy nearest-neighbor ordering of polylines starting from
+ /// (defaults to 0,0 = the work origin = the first
+ /// polyline's first point on the wire). When
+ /// is true a polyline may be reversed if its tail is closer than its head.
+ ///
+ public static List> Reorder(
+ IEnumerable> polylines,
+ bool allowReverse = true,
+ Vector? origin = null)
+ {
+ if (polylines == null) throw new ArgumentNullException(nameof(polylines));
+
+ var pool = new List>();
+ foreach (var p in polylines)
+ {
+ if (p == null || p.Count < 2)
+ continue;
+ pool.Add(new List(p));
+ }
+
+ var ordered = new List>(pool.Count);
+ var current = origin ?? new Vector(0, 0);
+
+ while (pool.Count > 0)
+ {
+ var bestIdx = -1;
+ var bestReverse = false;
+ var bestDistSq = double.PositiveInfinity;
+
+ for (int i = 0; i < pool.Count; i++)
+ {
+ var p = pool[i];
+ var dHead = SquaredDistance(current, p[0]);
+ if (dHead < bestDistSq)
+ {
+ bestDistSq = dHead;
+ bestIdx = i;
+ bestReverse = false;
+ }
+
+ if (allowReverse)
+ {
+ var dTail = SquaredDistance(current, p[p.Count - 1]);
+ if (dTail < bestDistSq)
+ {
+ bestDistSq = dTail;
+ bestIdx = i;
+ bestReverse = true;
+ }
+ }
+ }
+
+ var pick = pool[bestIdx];
+ pool.RemoveAt(bestIdx);
+ if (bestReverse)
+ pick.Reverse();
+ ordered.Add(pick);
+ current = pick[pick.Count - 1];
+ }
+
+ return ordered;
+ }
+
+ ///
+ /// Convenience: stitch then reorder.
+ ///
+ public static List> Prepare(
+ IEnumerable> polylines,
+ double stitchTolerance = DefaultStitchTolerance,
+ bool allowReverse = true,
+ Vector? origin = null)
+ {
+ var stitched = Stitch(polylines, stitchTolerance);
+ return Reorder(stitched, allowReverse, origin);
+ }
+
+ private static bool Near(Vector a, Vector b, double tol)
+ {
+ var dx = a.X - b.X;
+ var dy = a.Y - b.Y;
+ return (dx * dx + dy * dy) <= tol * tol;
+ }
+
+ private static double SquaredDistance(Vector a, Vector b)
+ {
+ var dx = a.X - b.X;
+ var dy = a.Y - b.Y;
+ return dx * dx + dy * dy;
+ }
+ }
+}
diff --git a/OpenNest.Tests/GravographIS/EnvelopeGuardTests.cs b/OpenNest.Tests/GravographIS/EnvelopeGuardTests.cs
new file mode 100644
index 0000000..fb52061
--- /dev/null
+++ b/OpenNest.Tests/GravographIS/EnvelopeGuardTests.cs
@@ -0,0 +1,165 @@
+using System.Collections.Generic;
+using System.IO;
+using OpenNest.Geometry;
+using OpenNest.Posts.GravographIS;
+
+namespace OpenNest.Tests.GravographIS;
+
+public class EnvelopeGuardTests
+{
+ // 0.610 m / 0.0125 mm/step = 48 800 steps = 24.0157 inches
+ // 1.220 m / 0.0125 mm/step = 97 600 steps = 48.0315 inches
+
+ [Fact]
+ public void NegativeX_FromOrigin_Throws()
+ {
+ // Operator origin is upper-left; quadrant 4 walks right/down. A cut that walks
+ // left of origin in -X must be refused.
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(-1, 0) },
+ };
+
+ var ex = Assert.Throws(() =>
+ {
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms);
+ });
+
+ Assert.Contains("Polyline 1", ex.Message);
+ Assert.Contains("cut segment", ex.Message);
+ Assert.Contains("segment 1", ex.Message);
+ }
+
+ [Fact]
+ public void PositiveY_FromOrigin_Throws()
+ {
+ // Positive input-Y is above the upper-left origin in quadrant 4.
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(0, 1) },
+ };
+
+ var ex = Assert.Throws(() =>
+ {
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms);
+ });
+
+ Assert.Contains("Polyline 1", ex.Message);
+ }
+
+ [Fact]
+ public void XExceedsEnvelope_Throws_AndNamesSegment()
+ {
+ // 25" in X is past the 0.610 m (~24.02") envelope.
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(10, 0), new Vector(25, 0) },
+ };
+
+ var ex = Assert.Throws(() =>
+ {
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms);
+ });
+
+ Assert.Contains("Polyline 1", ex.Message);
+ Assert.Contains("segment 2", ex.Message); // 0→10 ok; 10→25 trips
+ Assert.Contains("25.000\"", ex.Message);
+ }
+
+ [Fact]
+ public void YExceedsEnvelope_Throws()
+ {
+ // -49" in Y is past the 1.220 m (~48.03") envelope.
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(0, -49) },
+ };
+
+ Assert.Throws(() =>
+ {
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms);
+ });
+ }
+
+ [Fact]
+ public void PenUpTravel_OutsideEnvelope_AlsoThrows_AndIsLabeledTravel()
+ {
+ // Polyline 1 ends in-envelope; the PU travel to polyline 2 leaves it.
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ new[] { new Vector(30, 0), new Vector(30, -1) },
+ };
+
+ var ex = Assert.Throws(() =>
+ {
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms);
+ });
+
+ Assert.Contains("Polyline 2", ex.Message);
+ Assert.Contains("pen-up travel", ex.Message);
+ }
+
+ [Fact]
+ public void RightAtEnvelopeCorner_IsAllowed()
+ {
+ // Walk to (24", -48") in int16-sized hops (each delta < 16.1"). The
+ // catalog envelope is 24.02" × 48.03", so this lands just inside.
+ var polylines = new List>
+ {
+ new[]
+ {
+ new Vector(0, 0),
+ new Vector(8, -16),
+ new Vector(16, -32),
+ new Vector(24, -48),
+ },
+ };
+
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms); // no throw
+ Assert.True(ms.Length > 0);
+ }
+
+ [Fact]
+ public void EnvelopeGuard_CanBeDisabled_ForOffMachineEncoding()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(-5, 0) },
+ };
+
+ var opts = new GravographISWriterOptions { EnvelopeGuardEnabled = false };
+
+ using var ms = new MemoryStream();
+ new GravographISWriter(opts).Write(polylines, ms); // no throw
+ Assert.True(ms.Length > 0);
+ }
+
+ [Fact]
+ public void CustomEnvelope_TightensTheCheck()
+ {
+ // Restrict to 1" × 1" — a 2" line in -Y now overshoots.
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(0, -2) },
+ };
+
+ var opts = new GravographISWriterOptions
+ {
+ WorkEnvelopeXMm = 25.4,
+ WorkEnvelopeYMm = 25.4,
+ };
+
+ Assert.Throws(() =>
+ {
+ using var ms = new MemoryStream();
+ new GravographISWriter(opts).Write(polylines, ms);
+ });
+ }
+}
diff --git a/OpenNest.Tests/GravographIS/GravographISWriterTests.cs b/OpenNest.Tests/GravographIS/GravographISWriterTests.cs
new file mode 100644
index 0000000..6528b53
--- /dev/null
+++ b/OpenNest.Tests/GravographIS/GravographISWriterTests.cs
@@ -0,0 +1,223 @@
+using System.Collections.Generic;
+using System.IO;
+using OpenNest.Geometry;
+using OpenNest.Posts.GravographIS;
+
+namespace OpenNest.Tests.GravographIS;
+
+public class GravographISWriterTests
+{
+ // 93-byte preamble captured from GravoStyle'98 (VS/VZ=35, DZ=508 → matches defaults).
+ // The original capture ended with a DR command (FF FD 44 52) followed by three
+ // 8-byte int16 records carrying a chunked job-specific travel (~1" X, ~47" Y).
+ // Stripped from the writer (see GravographISWriter.PreambleTemplate) because
+ // those frozen deltas send the head to a fixed point regardless of the job. The
+ // writer now emits a job-specific leading DR travel from operator zero instead.
+ private const string PreambleHex =
+ "21 41 53 20 33 38 3b 01 90 01 f4 01 90 01 f4 01 90 01 f4 00 00 00 00 00 00 00 00 00 00 " +
+ "00 00 00 09 00 00 03 e8 05 06 00 00 00 00 00 00 ff fd 32 44 00 00 ff fd 4d 43 00 01 ff fd " +
+ "4f 55 ff fb ff fd 4f 55 ff fa ff fd 50 5a 00 00 ff fd 56 53 00 23 ff fd 56 5a 00 23 ff fd " +
+ "44 5a 01 fc";
+
+ // Legacy 36-byte tail with lift, aux off, motor off, operator beep, job finish.
+ // Byte-exact capture tests disable dynamic return-to-origin to preserve this form.
+ private const string PostambleHex =
+ "ff fd 50 55 00 01 ff fd 4f 55 ff fa ff fd 4f 55 ff fb ff fd 4d 43 00 00 " +
+ "ff fd 4f 50 00 00 ff fd 4a 46 00 00";
+
+ [Fact]
+ public void TestA_SingleTwoInchVerticalLine_IsByteExact()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(1, 1), new Vector(1, 3) },
+ };
+
+ var writer = new GravographISWriter(new GravographISWriterOptions
+ {
+ DepthInches = 0.25,
+ FeedMmPerSec = 35,
+ EnvelopeGuardEnabled = false,
+ ReturnToOriginAtEnd = false,
+ });
+
+ using var ms = new MemoryStream();
+ writer.Write(polylines, ms);
+
+ const string GeomHex =
+ "ff fd 44 52 00 00 2d 41 00 80 07 f0 f8 10 " +
+ "ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20";
+ var expected = HexToBytes(PreambleHex + " " + GeomHex + " " + PostambleHex);
+
+ Assert.Equal(expected, ms.ToArray());
+ }
+
+ [Fact]
+ public void TestB_FourLines_IsByteExact()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(1, 1), new Vector(1, 3) },
+ new[] { new Vector(4, 1), new Vector(4, 3) },
+ new[] { new Vector(4, 5), new Vector(4, 7) },
+ new[] { new Vector(1, 5), new Vector(1, 7) },
+ };
+
+ var writer = new GravographISWriter(new GravographISWriterOptions
+ {
+ DepthInches = 0.25,
+ FeedMmPerSec = 35,
+ EnvelopeGuardEnabled = false,
+ ReturnToOriginAtEnd = false,
+ });
+
+ using var ms = new MemoryStream();
+ writer.Write(polylines, ms);
+
+ const string GeomHex =
+ "ff fd 44 52 00 00 2d 41 00 80 07 f0 f8 10 " +
+ "ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
+ "ff fd 50 55 00 00 35 40 00 b4 17 d0 0f e0 " +
+ "ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
+ "ff fd 50 55 00 00 40 00 00 b4 00 00 f0 20 " +
+ "ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20 " +
+ "ff fd 50 55 00 00 35 40 00 b4 e8 30 0f e0 " +
+ "ff fd 50 44 00 00 40 00 00 b4 00 00 f0 20";
+ var expected = HexToBytes(PreambleHex + " " + GeomHex + " " + PostambleHex);
+
+ Assert.Equal(expected, ms.ToArray());
+ }
+
+ [Fact]
+ public void LeadingDR_TravelsToFirstPolylineStartBeforePD()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(2, 2), new Vector(3, 2) },
+ };
+
+ using var ms = new MemoryStream();
+ new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false }).Write(polylines, ms);
+
+ var bytes = ms.ToArray();
+ // First command after the 93-byte preamble must be DR to the first point,
+ // followed by PD for the first cut.
+ Assert.Equal(0xFF, bytes[93]);
+ Assert.Equal(0xFD, bytes[94]);
+ Assert.Equal((byte)'D', bytes[95]);
+ Assert.Equal((byte)'R', bytes[96]);
+
+ Assert.Equal(0xFF, bytes[107]);
+ Assert.Equal(0xFD, bytes[108]);
+ Assert.Equal((byte)'P', bytes[109]);
+ Assert.Equal((byte)'D', bytes[110]);
+ }
+
+ [Fact]
+ public void LeadingDR_LongTravel_IsChunked()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(1, 47), new Vector(2, 47) },
+ };
+
+ using var ms = new MemoryStream();
+ new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false }).Write(polylines, ms);
+
+ var bytes = ms.ToArray();
+ Assert.Equal((byte)'D', bytes[95]);
+ Assert.Equal((byte)'R', bytes[96]);
+ Assert.Equal((byte)'P', bytes[125]);
+ Assert.Equal((byte)'D', bytes[126]);
+ }
+
+ [Fact]
+ public void OptionsPatchVsVzDz()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(0.5, 0) },
+ };
+
+ using var ms = new MemoryStream();
+ new GravographISWriter(new GravographISWriterOptions
+ {
+ DepthInches = 0.125, // 254 steps = 0x00FE
+ FeedMmPerSec = 50, // 0x0032
+ }).Write(polylines, ms);
+
+ var bytes = ms.ToArray();
+ AssertOperand(bytes, (byte)'V', (byte)'S', 0x00, 0x32);
+ AssertOperand(bytes, (byte)'V', (byte)'Z', 0x00, 0x32);
+ AssertOperand(bytes, (byte)'D', (byte)'Z', 0x00, 0xFE);
+ }
+
+ [Fact]
+ public void ReturnsToOriginAfterFinalLift_ByDefault()
+ {
+ var polylines = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0), new Vector(1, -1) },
+ };
+
+ using var ms = new MemoryStream();
+ new GravographISWriter().Write(polylines, ms);
+
+ var bytes = ms.ToArray();
+ var liftIndex = LastIndexOfCommand(bytes, (byte)'P', (byte)'U', 0x00, 0x01);
+ Assert.True(liftIndex >= 0);
+
+ Assert.Equal(0xFF, bytes[liftIndex + 6]);
+ Assert.Equal(0xFD, bytes[liftIndex + 7]);
+ Assert.Equal((byte)'P', bytes[liftIndex + 8]);
+ Assert.Equal((byte)'U', bytes[liftIndex + 9]);
+
+ var dx = ReadInt16(bytes, liftIndex + 16);
+ var dy = ReadInt16(bytes, liftIndex + 18);
+ Assert.Equal(-GravographISWriter.StepsPerInch, dx);
+ Assert.Equal(-GravographISWriter.StepsPerInch, dy);
+ }
+
+ private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
+ {
+ for (var i = 0; i < bytes.Length - 5; i++)
+ {
+ if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
+ {
+ Assert.Equal(hi, bytes[i + 4]);
+ Assert.Equal(lo, bytes[i + 5]);
+ return;
+ }
+ }
+ Assert.Fail($"Command {(char)c0}{(char)c1} not found in stream.");
+ }
+
+ private static int LastIndexOfCommand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
+ {
+ for (var i = bytes.Length - 6; i >= 0; i--)
+ {
+ if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD &&
+ bytes[i + 2] == c0 && bytes[i + 3] == c1 &&
+ bytes[i + 4] == hi && bytes[i + 5] == lo)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private static short ReadInt16(byte[] bytes, int offset)
+ {
+ return unchecked((short)((bytes[offset] << 8) | bytes[offset + 1]));
+ }
+
+ internal static byte[] HexToBytes(string hex)
+ {
+ var clean = hex.Replace(" ", string.Empty).Replace("\n", string.Empty).Replace("\r", string.Empty);
+ var bytes = new byte[clean.Length / 2];
+ for (var i = 0; i < bytes.Length; i++)
+ bytes[i] = System.Convert.ToByte(clean.Substring(i * 2, 2), 16);
+ return bytes;
+ }
+}
diff --git a/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs b/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs
new file mode 100644
index 0000000..bc6cd7f
--- /dev/null
+++ b/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs
@@ -0,0 +1,37 @@
+using OpenNest;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.Posts.GravographIS;
+
+namespace OpenNest.Tests.GravographIS;
+
+public class NestPolylineExtractorTests
+{
+ [Fact]
+ public void ExtractPart_IncrementalProgram_ProducesAbsoluteCoordinates()
+ {
+ // 1x1 square in G91 (incremental) mode — the form OpenNest's UI writes
+ // to .nest files. Without absolute-mode handling the extractor plotted
+ // each EndPoint as if it were absolute, producing a 2x2 diamond.
+ var program = new Program(Mode.Incremental);
+ program.Codes.Add(new RapidMove(new Vector(0, 0)));
+ program.Codes.Add(new LinearMove(1, 0));
+ program.Codes.Add(new LinearMove(0, 1));
+ program.Codes.Add(new LinearMove(-1, 0));
+ program.Codes.Add(new LinearMove(0, -1));
+
+ var drawing = new Drawing("Square 1x1", program);
+ var part = new Part(drawing, new Vector(0.25, 46.75));
+
+ var polylines = new NestPolylineExtractor().ExtractPart(part);
+
+ Assert.Single(polylines);
+ var poly = polylines[0];
+ Assert.Equal(5, poly.Count);
+ Assert.Equal(new Vector(0.25, 46.75), poly[0]);
+ Assert.Equal(new Vector(1.25, 46.75), poly[1]);
+ Assert.Equal(new Vector(1.25, 47.75), poly[2]);
+ Assert.Equal(new Vector(0.25, 47.75), poly[3]);
+ Assert.Equal(new Vector(0.25, 46.75), poly[4]);
+ }
+}
diff --git a/OpenNest.Tests/GravographIS/PolylinePrePassTests.cs b/OpenNest.Tests/GravographIS/PolylinePrePassTests.cs
new file mode 100644
index 0000000..112d9f2
--- /dev/null
+++ b/OpenNest.Tests/GravographIS/PolylinePrePassTests.cs
@@ -0,0 +1,164 @@
+using System.Collections.Generic;
+using OpenNest.Geometry;
+using OpenNest.Posts.GravographIS;
+
+namespace OpenNest.Tests.GravographIS;
+
+public class PolylinePrePassTests
+{
+ [Fact]
+ public void Stitch_TwoConnectedSegments_BecomeOnePolyline()
+ {
+ var inputs = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ new[] { new Vector(1, 0), new Vector(1, 1) },
+ };
+
+ var stitched = PolylinePrePass.Stitch(inputs);
+
+ Assert.Single(stitched);
+ Assert.Equal(3, stitched[0].Count);
+ Assert.Equal(new Vector(0, 0), stitched[0][0]);
+ Assert.Equal(new Vector(1, 0), stitched[0][1]);
+ Assert.Equal(new Vector(1, 1), stitched[0][2]);
+ }
+
+ [Fact]
+ public void Stitch_FourSegmentsFormingClosedSquare_BecomeOnePolyline()
+ {
+ var inputs = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ new[] { new Vector(1, 0), new Vector(1, 1) },
+ new[] { new Vector(1, 1), new Vector(0, 1) },
+ new[] { new Vector(0, 1), new Vector(0, 0) },
+ };
+
+ var stitched = PolylinePrePass.Stitch(inputs);
+
+ Assert.Single(stitched);
+ // Four edges + closing return-to-start = five vertices.
+ Assert.Equal(5, stitched[0].Count);
+ }
+
+ [Fact]
+ public void Stitch_ReversesOneSegmentToMakeAJoin()
+ {
+ // Second segment is given backward; stitcher should reverse it.
+ var inputs = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ new[] { new Vector(2, 0), new Vector(1, 0) },
+ };
+
+ var stitched = PolylinePrePass.Stitch(inputs);
+
+ Assert.Single(stitched);
+ Assert.Equal(3, stitched[0].Count);
+ Assert.Equal(new Vector(0, 0), stitched[0][0]);
+ Assert.Equal(new Vector(2, 0), stitched[0][stitched[0].Count - 1]);
+ }
+
+ [Fact]
+ public void Stitch_DisjointSegments_StayDistinct()
+ {
+ var inputs = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ new[] { new Vector(5, 5), new Vector(6, 5) },
+ };
+
+ var stitched = PolylinePrePass.Stitch(inputs);
+
+ Assert.Equal(2, stitched.Count);
+ }
+
+ [Fact]
+ public void Stitch_DropsZeroAndSinglePointPolylines()
+ {
+ var inputs = new List>
+ {
+ new Vector[] { },
+ new[] { new Vector(0, 0) },
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ };
+
+ var stitched = PolylinePrePass.Stitch(inputs);
+
+ Assert.Single(stitched);
+ Assert.Equal(2, stitched[0].Count);
+ }
+
+ [Fact]
+ public void Reorder_ReducesTotalPenUpTravelVsWorstCase()
+ {
+ // Three short polylines at (0,0), (10,0), (5,0). The greedy NN starting
+ // from origin should pick (0,0)→(5,0)→(10,0) (travels of 4 + 4 ≈ 8) over
+ // the worst-case input order (0,0)→(10,0)→(5,0) (travels 9 + 4 ≈ 13).
+ var inputs = new List>
+ {
+ new[] { new Vector(0, 0), new Vector(1, 0) },
+ new[] { new Vector(10, 0), new Vector(11, 0) },
+ new[] { new Vector(5, 0), new Vector(6, 0) },
+ };
+
+ var reordered = PolylinePrePass.Reorder(inputs);
+
+ Assert.Equal(3, reordered.Count);
+ var travelBefore = TotalPenUpTravel(inputs);
+ var travelAfter = TotalPenUpTravel(reordered);
+ Assert.True(travelAfter < travelBefore,
+ $"Expected reorder to reduce pen-up travel; before={travelBefore}, after={travelAfter}");
+ }
+
+ [Fact]
+ public void Reorder_ReversesPolylineIfTailIsCloser()
+ {
+ // Origin (0,0); a single polyline whose tail is much closer to origin
+ // than its head. Reorder should flip it.
+ var inputs = new List>
+ {
+ new[] { new Vector(10, 0), new Vector(0.5, 0) },
+ };
+
+ var reordered = PolylinePrePass.Reorder(inputs, allowReverse: true);
+
+ Assert.Single(reordered);
+ Assert.Equal(new Vector(0.5, 0), reordered[0][0]);
+ Assert.Equal(new Vector(10, 0), reordered[0][1]);
+ }
+
+ [Fact]
+ public void Reorder_ReverseDisabled_KeepsDirection()
+ {
+ var inputs = new List>
+ {
+ new[] { new Vector(10, 0), new Vector(0.5, 0) },
+ };
+
+ var reordered = PolylinePrePass.Reorder(inputs, allowReverse: false);
+
+ Assert.Single(reordered);
+ Assert.Equal(new Vector(10, 0), reordered[0][0]);
+ Assert.Equal(new Vector(0.5, 0), reordered[0][1]);
+ }
+
+ private static double TotalPenUpTravel(IEnumerable> polylines)
+ {
+ var total = 0.0;
+ Vector? last = null;
+ foreach (var p in polylines)
+ {
+ if (p == null || p.Count < 2) continue;
+ if (last.HasValue)
+ {
+ var dx = p[0].X - last.Value.X;
+ var dy = p[0].Y - last.Value.Y;
+ total += System.Math.Sqrt(dx * dx + dy * dy);
+ }
+ last = p[p.Count - 1];
+ }
+ return total;
+ }
+}
diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj
index be3afca..10e5115 100644
--- a/OpenNest.Tests/OpenNest.Tests.csproj
+++ b/OpenNest.Tests/OpenNest.Tests.csproj
@@ -27,6 +27,7 @@
+
diff --git a/OpenNest.sln b/OpenNest.sln
index 20c64be..cadc297 100644
--- a/OpenNest.sln
+++ b/OpenNest.sln
@@ -30,6 +30,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostProcessors", "PostProce
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS", "OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj", "{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
EndProject
Global
@@ -186,12 +188,25 @@ Global
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = Release|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x64.Build.0 = Debug|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Debug|x86.Build.0 = Debug|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|Any CPU.Build.0 = Release|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.ActiveCfg = Release|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
+ {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
diff --git a/tools/StreamGravographJob/Program.cs b/tools/StreamGravographJob/Program.cs
new file mode 100644
index 0000000..d0778ee
--- /dev/null
+++ b/tools/StreamGravographJob/Program.cs
@@ -0,0 +1,266 @@
+using System;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.IO.Ports;
+using System.Runtime.InteropServices;
+using System.Threading;
+using Microsoft.Win32.SafeHandles;
+
+if (args.Length < 2)
+{
+ Console.Error.WriteLine("Usage:");
+ Console.Error.WriteLine(" StreamGravographJob [chunk=256] [flow=rtscts|xonxoff|none]");
+ Console.Error.WriteLine(" StreamGravographJob --gen # name: testA | testB | miniB | miniSquare");
+ Console.Error.WriteLine(" StreamGravographJob --inspect-nest ");
+ Console.Error.WriteLine(" StreamGravographJob --from-nest ");
+ return 2;
+}
+
+// Inspect a .nest file: extract polylines via the post-processor pipeline and
+// report dimensions / bounding box / pen-up travel — no bytes written.
+if (args[0] == "--inspect-nest")
+{
+ if (args.Length < 2) { Console.Error.WriteLine("--inspect-nest requires "); return 2; }
+ var nestPath = args[1];
+ if (!File.Exists(nestPath)) { Console.Error.WriteLine($"Not found: {nestPath}"); return 3; }
+
+ using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
+ var reader = new OpenNest.IO.NestReader(fs);
+ var nest = reader.Read();
+
+ Console.WriteLine($"Nest: {nest.Name}");
+ Console.WriteLine($"Units: {nest.Units}");
+ Console.WriteLine($"Plates: {nest.Plates.Count}");
+ var plateIdx = 0;
+ foreach (var plate in nest.Plates)
+ {
+ plateIdx++;
+ Console.WriteLine($" Plate {plateIdx}: size={plate.Size.Length} x {plate.Size.Width}, quadrant={plate.Quadrant}, parts={plate.Parts.Count}");
+ }
+
+ var polylines = new OpenNest.Posts.GravographIS.NestPolylineExtractor().Extract(nest);
+ if (polylines.Count == 0)
+ {
+ Console.WriteLine("No polylines extracted.");
+ return 0;
+ }
+
+ double minX = double.PositiveInfinity, minY = double.PositiveInfinity;
+ double maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
+ int totalPts = 0;
+ foreach (var p in polylines)
+ {
+ foreach (var v in p)
+ {
+ if (v.X < minX) minX = v.X;
+ if (v.X > maxX) maxX = v.X;
+ if (v.Y < minY) minY = v.Y;
+ if (v.Y > maxY) maxY = v.Y;
+ }
+ totalPts += p.Count;
+ }
+ Console.WriteLine($"Polylines: {polylines.Count}, total points: {totalPts}");
+ Console.WriteLine($"Bounding box (inches): X ∈ [{minX:F3}, {maxX:F3}] Y ∈ [{minY:F3}, {maxY:F3}]");
+ Console.WriteLine($"Extents: {maxX - minX:F3}\" × {maxY - minY:F3}\"");
+
+ // After running the pre-pass (stitch + reorder from origin) — what the writer will actually consume.
+ var prepared = OpenNest.Posts.GravographIS.PolylinePrePass.Prepare(polylines);
+ Console.WriteLine($"After stitch+reorder: {prepared.Count} polylines");
+
+ Console.WriteLine();
+ Console.WriteLine("--- Vertex dump (prepared, upper-left origin, with segment deltas) ---");
+ var pi = 0;
+ foreach (var poly in prepared)
+ {
+ pi++;
+ Console.WriteLine($"Polyline {pi}: {poly.Count} points");
+ var cumX = 0.0; var cumY = 0.0;
+ for (var i = 0; i < poly.Count; i++)
+ {
+ var v = poly[i];
+ if (i == 0)
+ {
+ Console.WriteLine($" [{i}] ({v.X,7:F3}, {v.Y,7:F3}) first DR travel from upper-left origin=({v.X,+7:F3}, {v.Y,+7:F3})");
+ }
+ else
+ {
+ var dx = v.X - poly[i - 1].X;
+ var dy = v.Y - poly[i - 1].Y;
+ cumX += dx;
+ cumY += dy;
+ Console.WriteLine($" [{i}] ({v.X,7:F3}, {v.Y,7:F3}) Δ=({dx,+7:F3}, {dy,+7:F3}) cum from origin=({cumX,+7:F3}, {cumY,+7:F3})");
+ }
+ }
+ }
+ return 0;
+}
+
+// Convert a .nest file to a .prn job via the full post-processor pipeline.
+if (args[0] == "--from-nest")
+{
+ if (args.Length < 3) { Console.Error.WriteLine("--from-nest requires "); return 2; }
+ var nestPath = args[1];
+ var outFile = args[2];
+ if (!File.Exists(nestPath)) { Console.Error.WriteLine($"Not found: {nestPath}"); return 3; }
+
+ using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
+ var nest = new OpenNest.IO.NestReader(fs).Read();
+ var post = new OpenNest.Posts.GravographIS.GravographISPostProcessor();
+ post.Post(nest, outFile);
+
+ var size = new FileInfo(outFile).Length;
+ Console.WriteLine($"Wrote {size} bytes → {outFile}");
+ return 0;
+}
+
+// Generator mode: run the live writer to produce a captured-test file on disk.
+if (args[0] == "--gen")
+{
+ if (args.Length < 3) { Console.Error.WriteLine("--gen requires "); return 2; }
+ var preset = args[1];
+ var outFile = args[2];
+ var polylines = preset.ToLowerInvariant() switch
+ {
+ "testa" => new System.Collections.Generic.List>
+ {
+ new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
+ },
+ "testb" => new System.Collections.Generic.List>
+ {
+ new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
+ new[] { new OpenNest.Geometry.Vector(4, 1), new OpenNest.Geometry.Vector(4, 3) },
+ new[] { new OpenNest.Geometry.Vector(4, 5), new OpenNest.Geometry.Vector(4, 7) },
+ new[] { new OpenNest.Geometry.Vector(1, 5), new OpenNest.Geometry.Vector(1, 7) },
+ },
+ // Same 4-polyline topology as testB (vertical lines + diagonal PU travels between them),
+ // shrunk to a 0.5" × 1.5" footprint so it stays right near the operator-set work origin.
+ "minib" => new System.Collections.Generic.List>
+ {
+ new[] { new OpenNest.Geometry.Vector(0, 0), new OpenNest.Geometry.Vector(0, 0.5) },
+ new[] { new OpenNest.Geometry.Vector(0.5, 0), new OpenNest.Geometry.Vector(0.5, 0.5) },
+ new[] { new OpenNest.Geometry.Vector(0.5, 1), new OpenNest.Geometry.Vector(0.5, 1.5) },
+ new[] { new OpenNest.Geometry.Vector(0, 1), new OpenNest.Geometry.Vector(0, 1.5) },
+ },
+ // Closed 0.5" square as a SINGLE polyline of 5 points → 4-segment PD packet.
+ // Exercises multi-segment PD (one FF FD 50 44 00 00 followed by 4 records,
+ // no intermediate lifts) and bi-directional motion (X+, Y+, X−, Y−).
+ // Returns the head to its starting point so no manual jog needed after.
+ "minisquare" => new System.Collections.Generic.List>
+ {
+ new[]
+ {
+ new OpenNest.Geometry.Vector(0, 0),
+ new OpenNest.Geometry.Vector(0.5, 0),
+ new OpenNest.Geometry.Vector(0.5, 0.5),
+ new OpenNest.Geometry.Vector(0, 0.5),
+ new OpenNest.Geometry.Vector(0, 0),
+ },
+ },
+ _ => throw new ArgumentException($"Unknown preset '{preset}' (try testA, testB, miniB, or miniSquare)."),
+ };
+
+ using var outFs = new FileStream(outFile, FileMode.Create, FileAccess.Write);
+ new OpenNest.Posts.GravographIS.GravographISWriter().Write(polylines, outFs);
+ Console.WriteLine($"Wrote {new FileInfo(outFile).Length} bytes via live writer → {outFile}");
+ return 0;
+}
+
+var file = args[0];
+var portName = args[1];
+var chunk = args.Length > 2 ? int.Parse(args[2]) : 256;
+var flowArg = args.Length > 3 ? args[3] : "rtscts";
+
+var handshake = flowArg.ToLowerInvariant() switch
+{
+ "rtscts" or "rts" or "cts" => Handshake.RequestToSend,
+ "xonxoff" or "xon" or "xoff" => Handshake.XOnXOff,
+ "none" => Handshake.None,
+ _ => throw new ArgumentException($"Unknown flow control '{flowArg}'."),
+};
+
+if (!File.Exists(file))
+{
+ Console.Error.WriteLine($"File not found: {file}");
+ return 3;
+}
+
+var bytes = File.ReadAllBytes(file);
+Console.WriteLine($"File: {file}");
+Console.WriteLine($"Size: {bytes.Length} bytes");
+Console.WriteLine($"Header: {BitConverter.ToString(bytes, 0, Math.Min(7, bytes.Length)).Replace('-', ' ')}");
+
+var ports = SerialPort.GetPortNames();
+Array.Sort(ports);
+Console.WriteLine($"Available COM ports: {string.Join(", ", ports)}");
+if (Array.IndexOf(ports, portName) < 0)
+{
+ Console.Error.WriteLine($"{portName} not in available ports.");
+ return 4;
+}
+
+using var port = new SerialPort(portName, 9600, Parity.None, 8, StopBits.One)
+{
+ Handshake = handshake,
+ WriteTimeout = 30000,
+ ReadTimeout = 30000,
+ WriteBufferSize = 4096,
+ DtrEnable = true,
+};
+
+// Probe with the same CreateFile flags SerialStream uses, in this same process,
+// so we can tell SerialStream-specific failures apart from process-level access denials.
+{
+ const uint GENERIC_RW = 0x80000000u | 0x40000000u;
+ const uint OPEN_EXISTING = 3;
+ const uint FILE_FLAG_OVERLAPPED = 0x40000000u;
+ var devName = @"\\.\" + portName;
+ var handle = NativeMethods.CreateFileW(devName, GENERIC_RW, 0, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, IntPtr.Zero);
+ var err = Marshal.GetLastWin32Error();
+ if (handle.IsInvalid)
+ {
+ Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) FAILED: win32={err} ({new Win32Exception(err).Message})");
+ }
+ else
+ {
+ Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) OK — closing.");
+ handle.Close();
+ }
+}
+
+Console.WriteLine($"Opening {portName} 9600 8N1 handshake={handshake}...");
+port.Open();
+Console.WriteLine("Opened.");
+
+var sw = Stopwatch.StartNew();
+try
+{
+ for (var i = 0; i < bytes.Length; i += chunk)
+ {
+ var n = Math.Min(chunk, bytes.Length - i);
+ port.Write(bytes, i, n);
+ }
+ try { port.BaseStream.Flush(); } catch { /* advisory */ }
+ Thread.Sleep(500);
+}
+finally
+{
+ sw.Stop();
+ port.Close();
+}
+
+Console.WriteLine($"Sent {bytes.Length} bytes in {sw.ElapsedMilliseconds} ms. Port closed.");
+return 0;
+
+internal static class NativeMethods
+{
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateFileW")]
+ internal static extern SafeFileHandle CreateFileW(
+ string lpFileName,
+ uint dwDesiredAccess,
+ uint dwShareMode,
+ IntPtr lpSecurityAttributes,
+ uint dwCreationDisposition,
+ uint dwFlagsAndAttributes,
+ IntPtr hTemplateFile);
+}
diff --git a/tools/StreamGravographJob/StreamGravographJob.csproj b/tools/StreamGravographJob/StreamGravographJob.csproj
new file mode 100644
index 0000000..eb28800
--- /dev/null
+++ b/tools/StreamGravographJob/StreamGravographJob.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net8.0-windows
+ enable
+ enable
+ OpenNest.Tools.StreamGravographJob
+
+
+
+
+
+
+