From 987a5e25bcb39f56c384480f252666a66729e1a6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 23 May 2026 12:40:53 -0400 Subject: [PATCH] Add Gravograph IS post processor --- .../GravographISPort.cs | 99 ++++++ .../GravographISPostProcessor.cs | 61 ++++ .../GravographISWriter.cs | 327 ++++++++++++++++++ .../GravographISWriterOptions.cs | 24 ++ .../NestPolylineExtractor.cs | 179 ++++++++++ .../OpenNest.Posts.GravographIS.csproj | 20 ++ .../PolylinePrePass.cs | 196 +++++++++++ .../GravographIS/EnvelopeGuardTests.cs | 165 +++++++++ .../GravographIS/GravographISWriterTests.cs | 223 ++++++++++++ .../NestPolylineExtractorTests.cs | 37 ++ .../GravographIS/PolylinePrePassTests.cs | 164 +++++++++ OpenNest.Tests/OpenNest.Tests.csproj | 1 + OpenNest.sln | 15 + tools/StreamGravographJob/Program.cs | 266 ++++++++++++++ .../StreamGravographJob.csproj | 14 + 15 files changed, 1791 insertions(+) create mode 100644 OpenNest.Posts.GravographIS/GravographISPort.cs create mode 100644 OpenNest.Posts.GravographIS/GravographISPostProcessor.cs create mode 100644 OpenNest.Posts.GravographIS/GravographISWriter.cs create mode 100644 OpenNest.Posts.GravographIS/GravographISWriterOptions.cs create mode 100644 OpenNest.Posts.GravographIS/NestPolylineExtractor.cs create mode 100644 OpenNest.Posts.GravographIS/OpenNest.Posts.GravographIS.csproj create mode 100644 OpenNest.Posts.GravographIS/PolylinePrePass.cs create mode 100644 OpenNest.Tests/GravographIS/EnvelopeGuardTests.cs create mode 100644 OpenNest.Tests/GravographIS/GravographISWriterTests.cs create mode 100644 OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs create mode 100644 OpenNest.Tests/GravographIS/PolylinePrePassTests.cs create mode 100644 tools/StreamGravographJob/Program.cs create mode 100644 tools/StreamGravographJob/StreamGravographJob.csproj 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 + + + + + + +