Add Gravograph IS post processor

This commit is contained in:
2026-05-23 12:40:53 -04:00
parent 86582d28c3
commit 987a5e25bc
15 changed files with 1791 additions and 0 deletions
@@ -0,0 +1,99 @@
using System;
using System.IO.Ports;
using System.Threading;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>
/// Opens the port at the controller's required line settings (9600 8-N-1)
/// with the given <paramref name="handshake"/>. Throws if the port is
/// already open or if opening fails.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
@@ -0,0 +1,61 @@
using System;
using System.IO;
using System.IO.Ports;
using System.Threading;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/>
/// writes the binary HPGL bytes. For serial streaming, use <see cref="Stream(Nest, string, Handshake, CancellationToken)"/>.
/// </summary>
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);
}
/// <summary>
/// Buffers the encoded job in memory, then streams it to the named COM port.
/// </summary>
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);
}
}
}
@@ -0,0 +1,327 @@
using System;
using System.Collections.Generic;
using System.IO;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
///
/// 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).
/// </summary>
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));
}
/// <summary>
/// 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.
/// </summary>
public void Write(IEnumerable<IReadOnlyList<Vector>> 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 <c0> <c1> <hi> <lo>) 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.");
}
}
}
@@ -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;
}
}
@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
/// polylines at rapid moves, and tessellates arcs to a chord-deviation
/// tolerance (the wire format takes line segments only).
/// </summary>
public sealed class NestPolylineExtractor
{
public double ArcChordToleranceInches { get; set; } = 0.001;
/// <summary>
/// Extracts polylines from every non-cutoff part in every plate of the nest,
/// returning them in plate coordinates (inches).
/// </summary>
public List<List<Vector>> Extract(Nest nest)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
var result = new List<List<Vector>>();
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;
}
/// <summary>
/// Extracts polylines for a single part. Public so callers driving the
/// writer directly (e.g. from a console one-off) can use it.
/// </summary>
public List<List<Vector>> ExtractPart(Part part)
{
var list = new List<List<Vector>>();
ExtractPart(part, list);
return list;
}
private void ExtractPart(Part part, List<List<Vector>> 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<Vector> 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<Vector> { pos + offset };
}
var end = linear.EndPoint;
current.Add(end + offset);
pos = end;
break;
}
case ArcMove arc:
{
if (current == null)
{
current = new List<Vector> { pos + offset };
}
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
pos = arc.EndPoint;
break;
}
}
}
FlushCurrent(sink, ref current);
}
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> 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<Vector> 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);
}
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Posts.GravographIS</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
<Target Name="CopyToPostsDir" AfterTargets="Build">
<PropertyGroup>
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
</Target>
</Project>
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// 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.
/// </summary>
public static class PolylinePrePass
{
public const double DefaultStitchTolerance = 1e-6;
/// <summary>
/// Joins polylines whose endpoints coincide (within <paramref name="tolerance"/>)
/// 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.
/// </summary>
public static List<List<Vector>> Stitch(
IEnumerable<IReadOnlyList<Vector>> polylines,
double tolerance = DefaultStitchTolerance)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var segs = new List<List<Vector>>();
foreach (var p in polylines)
{
if (p == null || p.Count < 2)
continue;
segs.Add(new List<Vector>(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<Vector>(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<Vector>(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;
}
/// <summary>
/// Greedy nearest-neighbor ordering of polylines starting from
/// <paramref name="origin"/> (defaults to 0,0 = the work origin = the first
/// polyline's first point on the wire). When <paramref name="allowReverse"/>
/// is true a polyline may be reversed if its tail is closer than its head.
/// </summary>
public static List<List<Vector>> Reorder(
IEnumerable<IReadOnlyList<Vector>> polylines,
bool allowReverse = true,
Vector? origin = null)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var pool = new List<List<Vector>>();
foreach (var p in polylines)
{
if (p == null || p.Count < 2)
continue;
pool.Add(new List<Vector>(p));
}
var ordered = new List<List<Vector>>(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;
}
/// <summary>
/// Convenience: stitch then reorder.
/// </summary>
public static List<List<Vector>> Prepare(
IEnumerable<IReadOnlyList<Vector>> 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;
}
}
}