Compare commits

10 Commits

Author SHA1 Message Date
aj e493d83899 feat(io): add Gravograph .CHR font reader with text-to-geometry
Add ChrFont, a reader for Gravograph .CHR engraving fonts, plus UI to
convert placed text into engraved geometry in the CAD converter.

The .CHR files are obfuscated with a single-byte XOR. Different
GravoStyle releases use different keys (0x2F in older versions, 0xCF in
the 7000 series, and others across the font library), so the key is
auto-detected from byte 1 of the file: the font name is ASCII stored as
UTF-16LE, so the high byte of its first character is 0x00 in plaintext
and the raw byte equals the key. This reads every font in a GravoStyle
install regardless of version, not just one hardcoded key.

UI: right-clicking a text item in EntityView raises TextConvertRequested;
CadConverterForm renders it via ChrFont with H/V alignment and adds the
result on an ENGRAVE layer.

Tests use Xunit.SkippableFact and a gitignored test-config.json so the
suite points at a local .CHR file without committing proprietary assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 14:37:48 -04:00
aj 987a5e25bc Add Gravograph IS post processor 2026-05-23 12:40:53 -04:00
aj 86582d28c3 fix(io): map DXF text vertical alignment for correct rendering
TextEntity import was only mapping HorizontalAlignment to CadText,
leaving VAlign at its default (Near/top). Middle-center text rendered
shifted to the bottom instead of vertically centered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 07:17:47 -04:00
aj f064368008 fix(io): remove zero-sweep arcs during DXF import
DXF files can contain degenerate arcs where start angle equals end angle
(zero sweep), often left as construction artifacts by CAD software.
These create spurious shapes in ShapeBuilder — e.g. SULLYS-033.dxf
showed 5 loops instead of 4 (3 cutouts + perimeter).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 06:50:20 -04:00
aj 9148797897 fix(ui): remove cut-off preview debounce for immediate cursor tracking
The 16ms timer delay made the preview feel laggy. Regenerate directly
on mouse move instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-23 06:50:17 -04:00
aj da77cc9270 Fix best-fit viewer bounds for angled pairs 2026-05-18 22:17:47 -04:00
aj 27f0685058 fix(engine): skip intersecting parts as obstacles during compactor push
Parts that already overlap the moving group are now excluded from the
obstacle list so they don't block the push direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 19:07:42 -04:00
aj 53988acefc fix(io): deduplicate circles and full-circle arcs during DXF import
Duplicate circle entities at the same location inflated pierce counts
and cut pricing (e.g. SULLYS-035 showed 9 pierces instead of 8).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:03 -04:00
aj a8d90be2ea feat: add layer filter overloads to Dxf.GetGeometry()
Add optional Func<string, bool> layerFilter parameter to ConvertEntities
and two new GetGeometry overloads (path and stream) that accept a layer
filter. This lets callers control which layers to exclude instead of
being limited to the hardcoded IsNonCutLayer check. Existing overloads
without the filter continue to use the default IsNonCutLayer behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:02 -04:00
aj c25b6bc23a feat(ui): render DXF text annotations in CAD converter preview
Extract MText and TextEntity from the CadDocument during DXF import
and render them in the EntityView. Handles text alignment (left/center/
right via InsertPoint vs AlignmentPoint) and replaces AutoCAD control
codes (%%p → ±, %%d → °, %%c → ⌀). MText formatting codes are
stripped before display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:45:44 -04:00
35 changed files with 3060 additions and 28 deletions
+3
View File
@@ -213,3 +213,6 @@ docs/superpowers/
# Launch settings
**/Properties/launchSettings.json
# Local test config (contains user-specific paths to proprietary test assets)
OpenNest.Tests/test-config.json
+3
View File
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
}
}
public bool IsFullCircle() =>
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary>
/// Angle in radians between start and end angles.
/// </summary>
@@ -17,6 +17,38 @@ namespace OpenNest.Geometry
(list, item, i) => list.GetCollinearLines(item, i),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
public static void Deduplicate(IList<Circle> circles)
{
for (var i = circles.Count - 1; i >= 1; i--)
{
for (var j = i - 1; j >= 0; j--)
{
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
{
circles.RemoveAt(i);
break;
}
}
}
}
public static void Deduplicate(IList<Circle> circles, IList<Arc> arcs)
{
for (var i = circles.Count - 1; i >= 0; i--)
{
for (var j = arcs.Count - 1; j >= 0; j--)
{
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
&& arcs[j].IsFullCircle())
{
arcs.RemoveAt(j);
}
}
}
}
private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>(IList<T> items,
+65
View File
@@ -1,6 +1,9 @@
using OpenNest.Engine;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit
{
@@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit
return new List<Part> { part1, part2 };
}
public List<Part> BuildCanonicalParts()
{
return NormalizeToCutOrigin(BuildParts(Candidate.Drawing));
}
public List<Part> BuildSourceParts(Drawing drawing)
{
var parts = BuildCanonicalParts();
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
}
public Box GetCutBounds(List<Part> parts)
{
return GetCutBoundingBox(parts);
}
private static List<Part> NormalizeToCutOrigin(List<Part> parts)
{
if (parts == null || parts.Count == 0)
return parts;
var bounds = GetCutBoundingBox(parts);
var offset = new Vector(-bounds.Left, -bounds.Bottom);
foreach (var part in parts)
part.Offset(offset);
return parts;
}
private static Box GetCutBoundingBox(List<Part> parts)
{
var entities = new List<IBoundable>();
foreach (var part in parts)
{
var partEntities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
foreach (var entity in partEntities)
{
entity.Offset(part.Location);
entities.Add(entity);
}
}
return entities.GetBoundingBox();
}
}
public enum BestFitSortField
+36 -3
View File
@@ -1,6 +1,7 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Engine.Fill
{
@@ -14,7 +15,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList();
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -26,7 +27,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, double angle)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList();
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
@@ -99,6 +100,13 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
{
continue;
}
if (d < distance)
distance = d;
}
@@ -115,6 +123,31 @@ namespace OpenNest.Engine.Fill
return 0;
}
private static bool IntersectsAny(Part candidate, List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
if (candidate.Intersects(parts[i], out _))
return true;
}
return false;
}
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
{
var nudge = direction * (Tolerance.Epsilon * 10);
moving.Offset(nudge);
try
{
return !moving.Intersects(obstacle, out _);
}
finally
{
moving.Offset(-nudge);
}
}
public static double Push(List<Part> movingParts, List<Part> obstacleParts,
Box workArea, double partSpacing, PushDirection direction)
{
@@ -130,7 +163,7 @@ namespace OpenNest.Engine.Fill
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList();
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
+7
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending;
using OpenNest.Geometry;
@@ -38,5 +39,11 @@ namespace OpenNest.IO
/// Default drawing name (filename without extension, unless overridden).
/// </summary>
public string Name { get; set; }
/// <summary>
/// The raw CAD document from the source file. Available for callers
/// that need access to non-geometry entities (e.g., text annotations).
/// </summary>
public CadDocument Document { get; set; }
}
}
+8
View File
@@ -27,6 +27,7 @@ namespace OpenNest.IO
var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities);
RemoveZeroSweepArcs(dxf.Entities);
var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null)
@@ -47,6 +48,7 @@ namespace OpenNest.IO
Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
};
}
@@ -140,6 +142,12 @@ namespace OpenNest.IO
return drawing;
}
internal static void RemoveZeroSweepArcs(List<Entity> entities)
{
entities.RemoveAll(e =>
e is Arc arc && arc.StartAngle.IsEqualTo(arc.EndAngle, Tolerance.ChainTolerance));
}
internal static void RemoveDuplicateArcs(List<Entity> entities)
{
var circles = entities.OfType<Circle>().ToList();
+369
View File
@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.IO
{
public class ChrFont
{
private readonly Dictionary<int, ChrGlyph> glyphs = new();
public string Name { get; internal set; }
public string Version { get; internal set; }
public double CapHeight { get; internal set; } = 5000;
internal void AddGlyph(int charCode, ChrGlyph glyph)
{
glyphs[charCode] = glyph;
}
public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode);
public ChrGlyph GetGlyph(int charCode) =>
glyphs.TryGetValue(charCode, out var g) ? g : null;
public double MeasureTextWidth(string text, double height)
{
var scale = height / CapHeight;
double width = 0;
foreach (var ch in text)
{
var glyph = GetGlyph(ch);
if (glyph == null)
{
var space = GetGlyph(' ');
width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
continue;
}
width += glyph.AdvanceWidth * scale;
}
return width;
}
public List<Entity> RenderText(string text, double height, Vector position, Layer layer = null)
{
var scale = height / CapHeight;
var entities = new List<Entity>();
var cursorX = position.X;
foreach (var ch in text)
{
var glyph = GetGlyph(ch);
if (glyph == null)
{
var space = GetGlyph(' ');
cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
continue;
}
var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer);
entities.AddRange(glyphEntities);
cursorX += glyph.AdvanceWidth * scale;
}
return entities;
}
public static ChrFont Read(string path, byte? xorKey = null)
{
var raw = File.ReadAllBytes(path);
// The whole file is obfuscated with a single-byte XOR. Different
// GravoStyle versions use different keys (0x2F in older releases,
// 0xCF in 7000-series). The font name at offset 0 is ASCII stored
// as UTF-16LE, so the high byte of its first character is 0x00 in
// plaintext — which means raw[1] is exactly the XOR key. Detect it
// from the file unless the caller forces a specific key.
var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F);
var data = new byte[raw.Length];
for (var i = 0; i < raw.Length; i++)
data[i] = (byte)(raw[i] ^ key);
return Parse(data);
}
private static ChrFont Parse(byte[] data)
{
var font = new ChrFont();
font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim();
font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim();
var charTable = new List<(int charCode, int offset)>();
var i = 0x40;
while (i + 5 < data.Length)
{
var charCode = data[i] | (data[i + 1] << 8);
var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24);
if (charCode < 0x20 || offset == 0 || offset >= data.Length)
break;
charTable.Add((charCode, offset));
i += 6;
}
for (var c = 0; c < charTable.Count; c++)
{
var (charCode, offset) = charTable[c];
var nextOffset = c + 1 < charTable.Count
? FindNextOffset(charTable, offset, data.Length)
: data.Length;
var glyph = ParseGlyph(data, offset, nextOffset);
if (glyph != null)
font.AddGlyph(charCode, glyph);
}
if (font.glyphs.Count > 0)
{
foreach (var g in font.glyphs.Values)
{
if (g.CapHeight > 0)
{
font.CapHeight = g.CapHeight;
break;
}
}
}
return font;
}
private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength)
{
var best = fileLength;
foreach (var (_, off) in table)
{
if (off > currentOffset && off < best)
best = off;
}
return best;
}
private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset)
{
if (offset + 92 > data.Length)
return null;
var glyph = new ChrGlyph();
glyph.CapHeight = ReadBE16(data, offset + 15 * 2);
var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2));
glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing;
var strokeStart = offset + 92;
var pos = strokeStart;
var currentStroke = new List<ChrStrokePoint>();
while (pos + 5 < endOffset)
{
var cmd = ReadBE16(data, pos);
var x = ReadBE16(data, pos + 2);
var y = ReadBE16(data, pos + 4);
pos += 6;
if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000)
break;
if (cmd < -1000)
break;
var type = cmd switch
{
1 => ChrPointType.Vertex,
4 => ChrPointType.Control,
5 => ChrPointType.EndPoint,
_ => ChrPointType.Vertex,
};
currentStroke.Add(new ChrStrokePoint(type, x, y));
if (type == ChrPointType.EndPoint)
{
if (currentStroke.Count > 0)
glyph.Strokes.Add(currentStroke);
currentStroke = new List<ChrStrokePoint>();
}
}
if (currentStroke.Count > 0)
glyph.Strokes.Add(currentStroke);
return glyph;
}
private static int ReadBE16(byte[] data, int offset)
{
var val = (data[offset] << 8) | data[offset + 1];
if (val > 32767) val -= 65536;
return val;
}
}
internal enum ChrPointType
{
Vertex,
Control,
EndPoint,
}
internal struct ChrStrokePoint
{
public ChrPointType Type;
public double X;
public double Y;
public ChrStrokePoint(ChrPointType type, double x, double y)
{
Type = type;
X = x;
Y = y;
}
}
public class ChrGlyph
{
internal readonly List<List<ChrStrokePoint>> Strokes = new();
public double AdvanceWidth { get; internal set; }
public double CapHeight { get; internal set; }
private const int ArcSamples = 16;
public List<Entity> ToEntities(double scale, double offsetX, double offsetY, Layer layer = null)
{
var entities = new List<Entity>();
layer ??= Layer.Default;
foreach (var stroke in Strokes)
{
if (stroke.Count < 2) continue;
var segments = BuildSegments(stroke);
foreach (var seg in segments)
{
if (seg.Points.Count < 2) continue;
var scaled = new List<Vector>(seg.Points.Count);
foreach (var pt in seg.Points)
scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY));
var converted = PointsToLines(scaled);
foreach (var e in converted)
{
e.Layer = layer;
entities.Add(e);
}
}
}
return entities;
}
private static List<Entity> PointsToLines(List<Vector> points)
{
var entities = new List<Entity>();
for (var i = 0; i < points.Count - 1; i++)
{
if (points[i].DistanceTo(points[i + 1]) < 0.001)
continue;
entities.Add(new Line(points[i], points[i + 1]));
}
return entities;
}
private static List<StrokeSegment> BuildSegments(List<ChrStrokePoint> stroke)
{
var segments = new List<StrokeSegment>();
var current = new StrokeSegment();
var i = 0;
while (i < stroke.Count)
{
var pt = stroke[i];
if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint)
{
if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control)
{
var p0 = new Vector(pt.X, pt.Y);
var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y);
var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1];
var p1 = new Vector(p2End.X, p2End.Y);
SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples);
current.HasCurves = true;
i += 2;
}
else
{
current.Points.Add(new Vector(pt.X, pt.Y));
i++;
}
}
else
{
i++;
}
}
if (current.Points.Count >= 2)
segments.Add(current);
return segments;
}
private class StrokeSegment
{
public readonly List<Vector> Points = new();
public bool HasCurves;
}
private static void SampleCircularArc(List<Vector> output, Vector p0, Vector pMid, Vector p1, int samples)
{
if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01)
output.Add(p0);
double ax = p0.X, ay = p0.Y;
double bx = pMid.X, by = pMid.Y;
double cx = p1.X, cy = p1.Y;
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (System.Math.Abs(d) < 1e-6)
{
output.Add(pMid);
output.Add(p1);
return;
}
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
var a0 = System.Math.Atan2(ay - uy, ax - ux);
var am = System.Math.Atan2(by - uy, bx - ux);
var a1 = System.Math.Atan2(cy - uy, cx - ux);
var ccwSweep = a1 - a0;
while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI;
var midRel = am - a0;
while (midRel < 0) midRel += 2 * System.Math.PI;
var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI;
for (var i = 1; i <= samples; i++)
{
var t = (double)i / samples;
var angle = a0 + sweep * t;
output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle)));
}
}
}
}
+38 -3
View File
@@ -65,6 +65,36 @@ namespace OpenNest.IO
}
}
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
#endregion
#region Export
@@ -128,15 +158,17 @@ namespace OpenNest.IO
}
}
private static List<Entity> ConvertEntities(CadDocument doc)
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
{
var entities = new List<Entity>();
var lines = new List<Line>();
var arcs = new List<Arc>();
var circles = new List<Circle>();
var filter = layerFilter ?? IsNonCutLayer;
foreach (var entity in doc.Entities)
{
if (IsNonCutLayer(entity.Layer?.Name))
if (filter(entity.Layer?.Name))
continue;
switch (entity)
@@ -150,7 +182,7 @@ namespace OpenNest.IO
break;
case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest());
circles.Add(circle.ToOpenNest());
break;
case ACadSharp.Entities.Spline spline:
@@ -181,7 +213,10 @@ namespace OpenNest.IO
GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
entities.AddRange(circles);
entities.AddRange(lines);
entities.AddRange(arcs);
@@ -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;
}
}
}
@@ -0,0 +1,72 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
namespace OpenNest.Tests.BestFit;
public class BestFitResultFrameTests
{
[Fact]
public void BuildCanonicalParts_NonAxisAlignedPairNormalizesActualBounds()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
Assert.True(IsNonAxisAligned(result.OptimalRotation),
$"Expected a non-axis-aligned result, got {Angle.ToDegrees(result.OptimalRotation):F2} degrees.");
var parts = result.BuildCanonicalParts();
var bounds = result.GetCutBounds(parts);
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.Equal(result.BoundingWidth, bounds.Length, 2);
Assert.Equal(result.BoundingHeight, bounds.Width, 2);
}
[Fact]
public void BuildSourceParts_RebindsCanonicalResultToRotatedSourceDrawing()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
drawing.RecomputeCanonicalAngle();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
var parts = result.BuildSourceParts(drawing);
var bounds = result.GetCutBounds(parts);
Assert.All(parts, p => Assert.Same(drawing, p.BaseDrawing));
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.False(parts[0].Intersects(parts[1], out _));
}
private static BestFitResult EvaluateOffsetPair(Drawing drawing, Vector offset)
{
var candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = System.Math.PI,
Part2Offset = offset,
Spacing = 0.25
};
return new PairEvaluator().Evaluate(candidate);
}
private static bool IsNonAxisAligned(double angle)
{
var normalized = Angle.NormalizeRad(angle);
var nearestQuadrant = Angle.HalfPI * System.Math.Round(normalized / Angle.HalfPI);
var delta = System.Math.Abs(normalized - nearestQuadrant);
delta = System.Math.Min(delta, Angle.HalfPI - delta);
return delta > Angle.ToRadians(1);
}
}
+107
View File
@@ -97,6 +97,33 @@ namespace OpenNest.Tests.Fill
return part;
}
private static Drawing MakeTriangleDrawing(params Vector[] points)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(points[0]));
for (var i = 1; i < points.Length; i++)
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[i]));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[0]));
return new Drawing("triangle", pgm);
}
private static Part MakeTrianglePart(params Vector[] points)
{
var part = new Part(MakeTriangleDrawing(points));
part.UpdateBounds();
return part;
}
private static Part MakeTrianglePart(double x, double y, params Vector[] points)
{
var part = MakeTrianglePart(points);
part.Location = new Vector(x, y);
part.UpdateBounds();
return part;
}
[Fact]
public void Push_Left_MovesPartTowardEdge()
{
@@ -171,6 +198,86 @@ namespace OpenNest.Tests.Fill
Assert.NotEqual(distNoSpacing, distWithSpacing);
}
[Fact]
public void Push_Up_AllowsSharedDiagonalEdgeToSeparate()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Up);
Assert.True(distance > 0);
Assert.True(movingPart.BoundingBox.Top > 19.9);
Assert.False(movingPart.Intersects(obstacle, out _));
}
[Fact]
public void Push_Up_MovesAfterRightTriangleIsPushedLeftIntoSharedEdge()
{
var workArea = new Box(0, 0, 24, 24);
var leftTriangle = MakeTrianglePart(
2, 2,
new Vector(0, 0),
new Vector(8, 0),
new Vector(4, 10));
var rightTriangle = MakeTrianglePart(
14, 4,
new Vector(0, 10),
new Vector(8, 10),
new Vector(4, 0));
var moving = new List<Part> { rightTriangle };
var obstacles = new List<Part> { leftTriangle };
var leftDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
var yBeforePushUp = rightTriangle.Location.Y;
var bottomBeforePushUp = rightTriangle.BoundingBox.Bottom;
var upDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Up);
Assert.True(leftDistance > 0);
Assert.True(upDistance > 0);
Assert.True(rightTriangle.Location.Y > yBeforePushUp);
Assert.True(rightTriangle.BoundingBox.Bottom > bottomBeforePushUp);
Assert.False(rightTriangle.Intersects(leftTriangle, out _));
}
[Fact]
public void Push_Left_BlocksWhenSharedDiagonalEdgeWouldOverlap()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Left);
Assert.Equal(0, distance);
Assert.Equal(0, movingPart.BoundingBox.Left);
}
[Fact]
public void Push_AngleLeft_MovesPartTowardEdge()
{
@@ -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<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(-1, 0) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
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<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0, 1) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
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<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(25, 0) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
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<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0, -49) },
};
Assert.Throws<System.InvalidOperationException>(() =>
{
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<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(1, 0) },
new[] { new Vector(30, 0), new Vector(30, -1) },
};
var ex = Assert.Throws<System.InvalidOperationException>(() =>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
new[] { new Vector(0, 0), new Vector(0, -2) },
};
var opts = new GravographISWriterOptions
{
WorkEnvelopeXMm = 25.4,
WorkEnvelopeYMm = 25.4,
};
Assert.Throws<System.InvalidOperationException>(() =>
{
using var ms = new MemoryStream();
new GravographISWriter(opts).Write(polylines, ms);
});
}
}
@@ -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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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;
}
}
@@ -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]);
}
}
@@ -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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>>
{
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<IReadOnlyList<Vector>> 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;
}
}
+180
View File
@@ -0,0 +1,180 @@
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.IO;
public class ChrFontTests
{
private ChrFont LoadFont()
{
var path = TestConfig.GetExistingPath("ChrFontPath");
Skip.If(path == null, "ChrFontPath not configured in test-config.json or file not found");
return ChrFont.Read(path);
}
[SkippableFact]
public void Read_ParsesFontName()
{
var font = LoadFont();
Assert.Equal("US BLOCK 1L", font.Name);
}
[SkippableFact]
public void Read_ParsesVersion()
{
var font = LoadFont();
Assert.StartsWith("C1.", font.Version);
}
[SkippableFact]
public void Read_HasAsciiGlyphs()
{
var font = LoadFont();
Assert.True(font.HasGlyph('A'));
Assert.True(font.HasGlyph('Z'));
Assert.True(font.HasGlyph('0'));
Assert.True(font.HasGlyph(' '));
}
[SkippableFact]
public void Read_HasExtendedGlyphs()
{
var font = LoadFont();
Assert.True(font.HasGlyph(0xC7)); // C-cedilla
}
[SkippableFact]
public void Glyph_L_ProducesLines()
{
var font = LoadFont();
var glyph = font.GetGlyph('L');
Assert.NotNull(glyph);
var entities = glyph.ToEntities(1.0, 0, 0);
Assert.True(entities.Count >= 2, $"Expected at least 2 entities for 'L', got {entities.Count}");
Assert.All(entities, e => Assert.Equal(EntityType.Line, e.Type));
}
[SkippableFact]
public void Glyph_O_ProducesEntities()
{
var font = LoadFont();
var glyph = font.GetGlyph('O');
Assert.NotNull(glyph);
var entities = glyph.ToEntities(1.0, 0, 0);
Assert.True(entities.Count > 0);
}
[SkippableFact]
public void RenderText_ProducesEntities()
{
var font = LoadFont();
var entities = font.RenderText("HELLO", 1.0, new Vector(0, 0));
Assert.True(entities.Count > 0, "RenderText should produce entities");
}
[SkippableFact]
public void RenderText_ScalesCorrectly()
{
var font = LoadFont();
var small = font.RenderText("A", 0.5, Vector.Zero);
var large = font.RenderText("A", 2.0, Vector.Zero);
var smallBox = small.GetBoundingBox();
var largeBox = large.GetBoundingBox();
Assert.True(largeBox.Width > smallBox.Width);
Assert.True(largeBox.Length > smallBox.Length);
}
[SkippableFact]
public void RenderText_AdvancesCursor()
{
var font = LoadFont();
var abEntities = font.RenderText("AB", 1.0, Vector.Zero);
var aEntities = font.RenderText("A", 1.0, Vector.Zero);
var abBox = abEntities.GetBoundingBox();
var aBox = aEntities.GetBoundingBox();
Assert.True(abBox.Length > aBox.Length * 1.5,
$"AB width ({abBox.Length:F1}) should be significantly wider than A width ({aBox.Length:F1})");
}
[SkippableFact]
public void RenderText_MatchesGravographReference()
{
var font = LoadFont();
var height = 5.08;
var centerX = 50.8;
var centerY = 34.925;
var entities = font.RenderText("Text", height, Vector.Zero);
var rawBox = entities.GetBoundingBox();
var shiftX = centerX - (rawBox.Left + rawBox.Right) / 2;
var shiftY = centerY - (rawBox.Top + rawBox.Bottom) / 2;
foreach (var e in entities)
e.Offset(new Vector(shiftX, shiftY));
Assert.True(entities.Count > 0, "Should produce entities for 'Text'");
var box = entities.GetBoundingBox();
var refLeft = 43.53;
var refRight = 58.07;
var refBottom = 32.39;
var refTop = 37.47;
var tolerance = 0.5;
Assert.True(System.Math.Abs(box.Left - refLeft) < tolerance,
$"Left: ours={box.Left:F2}, ref={refLeft:F2}, diff={System.Math.Abs(box.Left - refLeft):F2}");
Assert.True(System.Math.Abs(box.Right - refRight) < tolerance,
$"Right: ours={box.Right:F2}, ref={refRight:F2}, diff={System.Math.Abs(box.Right - refRight):F2}");
Assert.True(System.Math.Abs(box.Bottom - refBottom) < tolerance,
$"Bottom: ours={box.Bottom:F2}, ref={refBottom:F2}, diff={System.Math.Abs(box.Bottom - refBottom):F2}");
Assert.True(System.Math.Abs(box.Top - refTop) < tolerance,
$"Top: ours={box.Top:F2}, ref={refTop:F2}, diff={System.Math.Abs(box.Top - refTop):F2}");
var actualCapHeight = box.Top - box.Bottom;
Assert.True(System.Math.Abs(actualCapHeight - height) < 0.5,
$"Cap height: ours={actualCapHeight:F2}, expected={height:F2}");
}
[SkippableFact]
public void MeasureTextWidth_IsConsistent()
{
var font = LoadFont();
var height = 5.08;
var measuredWidth = font.MeasureTextWidth("Text", height);
var entities = font.RenderText("Text", height, Vector.Zero);
var box = entities.GetBoundingBox();
Assert.True(measuredWidth >= box.Length,
$"Measured={measuredWidth:F2} should be >= rendered={box.Length:F2}");
Assert.True(measuredWidth - box.Length < 2.0,
$"Measured={measuredWidth:F2}, rendered={box.Length:F2}, diff={measuredWidth - box.Length:F2}");
}
[SkippableFact]
public void Glyph_t_HasCurveAtBottom()
{
var font = LoadFont();
var glyph = font.GetGlyph('t');
Assert.NotNull(glyph);
var entities = glyph.ToEntities(1.0, 0, 0);
var lines = entities.Cast<Line>().ToList();
Assert.True(lines.Count >= 10, $"Expected at least 10 entities for 't', got {lines.Count}");
var curveLines = lines.Skip(1).Take(lines.Count - 3).ToList();
Assert.True(curveLines.Count >= 14, $"Expected at least 14 curve segments, got {curveLines.Count}");
var lastCurve = curveLines[^1];
Assert.True(lastCurve.EndPoint.X > curveLines[0].StartPoint.X,
$"Curve should end to the right of where it starts: start X={curveLines[0].StartPoint.X:F1}, end X={lastCurve.EndPoint.X:F1}");
}
}
+5
View File
@@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup>
<ItemGroup>
@@ -27,6 +28,7 @@
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
<ProjectReference Include="..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
</ItemGroup>
@@ -37,6 +39,9 @@
<Content Include="Splitting\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="test-config.json" Condition="Exists('test-config.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+29
View File
@@ -1,8 +1,37 @@
using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
internal static class TestConfig
{
private static readonly Lazy<Dictionary<string, string>> Config = new(() =>
{
var dir = AppContext.BaseDirectory;
for (var i = 0; i < 6; i++)
{
var path = Path.Combine(dir, "test-config.json");
if (File.Exists(path))
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
}
dir = Path.GetDirectoryName(dir)!;
}
return new();
});
public static string? Get(string key) =>
Config.Value.TryGetValue(key, out var val) ? val : null;
public static string? GetExistingPath(string key)
{
var path = Get(key);
return path != null && File.Exists(path) ? path : null;
}
}
internal static class TestHelpers
{
public static Part MakePartAt(double x, double y, double size = 1)
+15
View File
@@ -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}
-18
View File
@@ -16,15 +16,11 @@ namespace OpenNest.Actions
private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView)
: base(plateView)
{
settings = plateView.CutOffSettings;
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
ConnectEvents();
}
@@ -40,8 +36,6 @@ namespace OpenNest.Actions
public override void DisconnectEvents()
{
debounceTimer.Stop();
debounceTimer.Dispose();
plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown;
@@ -58,18 +52,6 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e)
{
regeneratePending = true;
debounceTimer.Start();
}
private void OnDebounce(object sender, System.EventArgs e)
{
debounceTimer.Stop();
if (!regeneratePending)
return;
regeneratePending = false;
var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
+16
View File
@@ -0,0 +1,16 @@
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
public class CadText
{
public Vector Position { get; set; }
public string Value { get; set; }
public double Height { get; set; }
public double Rotation { get; set; }
public string LayerName { get; set; }
public StringAlignment HAlign { get; set; }
public StringAlignment VAlign { get; set; }
}
}
+76
View File
@@ -29,15 +29,18 @@ namespace OpenNest.Controls
public List<Entity> SimplifierToleranceRight { get; set; }
public List<Entity> OriginalEntities { get; set; }
public bool ShowEntityLabels { get; set; }
public List<CadText> Texts { get; set; } = new List<CadText>();
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
private readonly Font labelFont = new Font("Segoe UI", 7f);
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
public event EventHandler<Line> LinePicked;
public event EventHandler PickCancelled;
public event EventHandler<CadText> TextConvertRequested;
private bool isPickingBendLine;
public bool IsPickingBendLine
@@ -74,6 +77,13 @@ namespace OpenNest.Controls
if (line != null)
LinePicked?.Invoke(this, line);
}
if (e.Button == MouseButtons.Right)
{
var text = HitTestText(e.Location);
if (text != null)
ShowTextContextMenu(text, e.Location);
}
}
protected override void OnPaint(PaintEventArgs e)
@@ -116,6 +126,8 @@ namespace OpenNest.Controls
DrawEntity(e.Graphics, entity, pen);
}
DrawTexts(e.Graphics);
if (ShowEntityLabels)
DrawEntityLabels(e.Graphics);
@@ -324,6 +336,41 @@ namespace OpenNest.Controls
return bestLine;
}
private CadText HitTestText(Point controlPoint)
{
if (Texts == null || Texts.Count == 0)
return null;
var worldPoint = PointControlToWorld(controlPoint);
var tolerance = LengthGuiToWorld(8);
foreach (var text in Texts)
{
if (string.IsNullOrEmpty(text.Value))
continue;
var estimatedWidth = text.Height * text.Value.Length * 0.6;
var minX = text.Position.X - tolerance;
var maxX = text.Position.X + estimatedWidth + tolerance;
var minY = text.Position.Y - tolerance;
var maxY = text.Position.Y + text.Height + tolerance;
if (worldPoint.X >= minX && worldPoint.X <= maxX &&
worldPoint.Y >= minY && worldPoint.Y <= maxY)
return text;
}
return null;
}
private void ShowTextContextMenu(CadText text, Point location)
{
var menu = new ContextMenuStrip();
var item = menu.Items.Add($"Convert \"{text.Value}\" to Geometry");
item.Click += (s, e) => TextConvertRequested?.Invoke(this, text);
menu.Show(this, location);
}
private void DrawEntityLabels(Graphics g)
{
for (var i = 0; i < Entities.Count; i++)
@@ -408,6 +455,7 @@ namespace OpenNest.Controls
labelFont.Dispose();
labelBrush.Dispose();
labelBackBrush.Dispose();
textBrush.Dispose();
}
base.Dispose(disposing);
}
@@ -474,6 +522,34 @@ namespace OpenNest.Controls
diameter);
}
private void DrawTexts(Graphics g)
{
if (Texts == null || Texts.Count == 0)
return;
using var sf = new StringFormat();
foreach (var text in Texts)
{
var pos = PointWorldToGraph(text.Position);
var fontSize = LengthWorldToGui(text.Height);
if (fontSize < 2f) continue;
var state = g.Save();
g.TranslateTransform(pos.X, pos.Y);
if (text.Rotation != 0)
g.RotateTransform((float)OpenNest.Math.Angle.ToDegrees(text.Rotation));
sf.Alignment = text.HAlign;
sf.LineAlignment = text.VAlign;
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
g.DrawString(text.Value, font, textBrush, 0, 0, sf);
g.Restore(state);
}
}
private void DrawPoint(Graphics g, Vector pt, Pen pen)
{
var pt1 = PointWorldToGraph(pt);
+1
View File
@@ -22,6 +22,7 @@ namespace OpenNest.Controls
public HashSet<Guid> SuppressedEntityIds { get; set; }
public Box Bounds { get; set; }
public int EntityCount { get; set; }
public List<CadText> Texts { get; set; } = new();
}
public class FileListControl : Control
+4 -2
View File
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
public BestFitResult SelectedResult { get; private set; }
public Drawing SelectedDrawing => activeDrawing;
public List<Part> SelectedParts { get; private set; }
public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches)
{
@@ -318,12 +319,12 @@ namespace OpenNest.Forms
var cell = new BestFitCell(colorScheme);
cell.PartColor = partColor;
cell.Dock = DockStyle.Fill;
var parts = result.BuildCanonicalParts();
cell.Plate.Size = new Geometry.Size(
result.BoundingHeight,
result.BoundingWidth);
var parts = result.BuildParts(drawing);
foreach (var part in parts)
cell.Plate.Parts.Add(part);
@@ -332,6 +333,7 @@ namespace OpenNest.Forms
cell.DoubleClick += (sender, e) =>
{
SelectedResult = result;
SelectedParts = result.BuildSourceParts(drawing);
DialogResult = DialogResult.OK;
Close();
};
+217 -1
View File
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled;
entityView1.TextConvertRequested += OnTextConvertRequested;
btnSplit.Click += OnSplitClicked;
numQuantity.ValueChanged += OnQuantityChanged;
txtCustomer.TextChanged += OnCustomerChanged;
@@ -92,7 +93,8 @@ namespace OpenNest.Forms
Customer = string.Empty,
Bends = result.Bends,
Bounds = result.Bounds,
EntityCount = result.Entities.Count
EntityCount = result.Entities.Count,
Texts = ExtractTexts(result.Document),
};
if (InvokeRequired)
@@ -152,6 +154,7 @@ namespace OpenNest.Forms
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends ?? new List<Bend>();
entityView1.Texts = item.Texts ?? new List<CadText>();
item.Entities.ForEach(e => e.IsVisible = true);
if (item.Entities.Any(e => e.Layer != null))
@@ -461,6 +464,115 @@ namespace OpenNest.Forms
filterPanel.SetPickMode(false);
}
private void OnTextConvertRequested(object sender, Controls.CadText text)
{
var item = CurrentItem;
if (item == null) return;
var font = LoadChrFont();
if (font == null) return;
var layer = new Geometry.Layer("ENGRAVE")
{
Color = System.Drawing.Color.Cyan,
IsVisible = true,
};
var entities = font.RenderText(text.Value, text.Height, Geometry.Vector.Zero, layer);
if (entities.Count > 0)
{
var box = entities.GetBoundingBox();
var shiftX = text.HAlign switch
{
System.Drawing.StringAlignment.Center => text.Position.X - (box.Left + box.Right) / 2,
System.Drawing.StringAlignment.Far => text.Position.X - box.Right,
_ => text.Position.X - box.Left,
};
var shiftY = text.VAlign switch
{
System.Drawing.StringAlignment.Center => text.Position.Y - (box.Top + box.Bottom) / 2,
System.Drawing.StringAlignment.Near => text.Position.Y - box.Top,
_ => text.Position.Y - box.Bottom,
};
var shift = new Geometry.Vector(shiftX, shiftY);
foreach (var e in entities)
e.Offset(shift);
}
if (entities.Count == 0)
{
MessageBox.Show($"No geometry produced for \"{text.Value}\".", "Convert Text",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
item.Entities.AddRange(entities);
item.Texts.Remove(text);
item.EntityCount = item.Entities.Count;
item.Bounds = item.Entities.GetBoundingBox();
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Texts = item.Texts;
filterPanel.LoadItem(item.Entities, item.Bends);
entityView1.Invalidate();
staleProgram = true;
lblEntityCount.Text = $"{item.EntityCount} entities";
}
private ChrFont cachedChrFont;
private string cachedChrFontPath;
private ChrFont LoadChrFont()
{
if (cachedChrFont != null)
return cachedChrFont;
// Look for .CHR files next to the app, then prompt
var appDir = System.IO.Path.GetDirectoryName(Application.ExecutablePath);
var candidates = Directory.GetFiles(appDir, "*.CHR", SearchOption.TopDirectoryOnly);
string fontPath;
if (candidates.Length == 1)
{
fontPath = candidates[0];
}
else if (candidates.Length > 1)
{
fontPath = PromptForChrFile(appDir);
}
else
{
fontPath = PromptForChrFile(null);
}
if (fontPath == null)
return null;
try
{
cachedChrFont = ChrFont.Read(fontPath);
cachedChrFontPath = fontPath;
return cachedChrFont;
}
catch (Exception ex)
{
MessageBox.Show($"Error loading font: {ex.Message}", "Font Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return null;
}
}
private static string PromptForChrFile(string initialDir)
{
using var dlg = new OpenFileDialog
{
Title = "Select Engraving Font (.CHR)",
Filter = "Gravograph Font (*.CHR)|*.CHR",
InitialDirectory = initialDir ?? "",
};
return dlg.ShowDialog() == DialogResult.OK ? dlg.FileName : null;
}
private void OnDragEnter(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
@@ -804,6 +916,110 @@ namespace OpenNest.Forms
#endregion
private static List<CadText> ExtractTexts(ACadSharp.CadDocument doc)
{
var texts = new List<CadText>();
if (doc == null) return texts;
foreach (var entity in doc.Entities)
{
switch (entity)
{
case ACadSharp.Entities.MText mtext:
var (mh, mv) = MapAttachmentPoint(mtext.AttachmentPoint);
texts.Add(new CadText
{
Position = new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y),
Value = ReplaceControlCodes(StripMTextFormatting(mtext.Value)),
Height = mtext.Height,
Rotation = mtext.Rotation,
LayerName = mtext.Layer?.Name,
HAlign = mh,
VAlign = mv,
});
break;
case ACadSharp.Entities.TextEntity text:
var useAlignment = text.HorizontalAlignment != 0
|| text.VerticalAlignment != 0;
var pt = useAlignment ? text.AlignmentPoint : text.InsertPoint;
var ha = text.HorizontalAlignment switch
{
ACadSharp.Entities.TextHorizontalAlignment.Center => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
var va = text.VerticalAlignment switch
{
ACadSharp.Entities.TextVerticalAlignmentType.Middle => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.TextVerticalAlignmentType.Top => System.Drawing.StringAlignment.Near,
ACadSharp.Entities.TextVerticalAlignmentType.Bottom => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Far,
};
texts.Add(new CadText
{
Position = new Vector(pt.X, pt.Y),
Value = ReplaceControlCodes(text.Value),
Height = text.Height,
Rotation = text.Rotation,
LayerName = text.Layer?.Name,
HAlign = ha,
VAlign = va,
});
break;
}
}
return texts;
}
private static (System.Drawing.StringAlignment h, System.Drawing.StringAlignment v) MapAttachmentPoint(
ACadSharp.Entities.AttachmentPointType apt)
{
var h = apt switch
{
ACadSharp.Entities.AttachmentPointType.TopCenter
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
or ACadSharp.Entities.AttachmentPointType.BottomCenter => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.AttachmentPointType.TopRight
or ACadSharp.Entities.AttachmentPointType.MiddleRight
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
var v = apt switch
{
ACadSharp.Entities.AttachmentPointType.MiddleLeft
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
or ACadSharp.Entities.AttachmentPointType.MiddleRight => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.AttachmentPointType.BottomLeft
or ACadSharp.Entities.AttachmentPointType.BottomCenter
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
return (h, v);
}
private static string StripMTextFormatting(string text)
{
if (string.IsNullOrEmpty(text)) return text;
var result = System.Text.RegularExpressions.Regex.Replace(text, @"\\[A-Za-z][^;]*;", "");
result = result.Replace("{", "").Replace("}", "");
return result.Trim();
}
private static string ReplaceControlCodes(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text
.Replace("%%p", "±")
.Replace("%%P", "±")
.Replace("%%d", "°")
.Replace("%%D", "°")
.Replace("%%c", "⌀")
.Replace("%%C", "⌀")
.Replace("%%%", "%");
}
private void filterPanel_Paint(object sender, PaintEventArgs e)
{
+2 -1
View File
@@ -686,7 +686,8 @@ namespace OpenNest.Forms
{
if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null)
{
var parts = form.SelectedResult.BuildParts(form.SelectedDrawing);
var parts = form.SelectedParts
?? form.SelectedResult.BuildSourceParts(form.SelectedDrawing);
activeForm.PlateView.SetAction(typeof(ActionClone), parts);
}
}
+266
View File
@@ -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 <file.prn> <COMx> [chunk=256] [flow=rtscts|xonxoff|none]");
Console.Error.WriteLine(" StreamGravographJob --gen <name> <outfile.prn> # name: testA | testB | miniB | miniSquare");
Console.Error.WriteLine(" StreamGravographJob --inspect-nest <file.nest>");
Console.Error.WriteLine(" StreamGravographJob --from-nest <file.nest> <outfile.prn>");
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 <file.nest>"); 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 <file.nest> <outfile.prn>"); 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 <name> <outfile>"); return 2; }
var preset = args[1];
var outFile = args[2];
var polylines = preset.ToLowerInvariant() switch
{
"testa" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
},
"testb" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
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<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
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<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
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);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpenNest.Tools.StreamGravographJob</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<ProjectReference Include="..\..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
<ProjectReference Include="..\..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>