Author SHA1 Message Date
ajandClaude Opus 4.6 a085339ba9 fix: improve arc-tangency fitting and add layered engrave/cut passes for GravographIS
GeometrySimplifier/ArcFit now fit arcs that pass exactly through run
endpoints while balancing tangency error between trusted and estimated
directions, fixing arcs that previously bulged or broke tangent
continuity at fillet/compound-curve junctions.

GravographIS post processor gains per-layer (engrave/cut) tool passes
via a new GravographISPostConfig, so ENGRAVE/ETCH-tagged geometry runs
as a separate scribe pass with its own feed/depth and an operator
pause before the cut pass (spring-floated spindle needs a tool swap).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-06 23:15:18 -04:00
ajandClaude Opus 4.8 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
ajandClaude Opus 4.6 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
31 changed files with 3557 additions and 74 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
+1
View File
@@ -120,3 +120,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
+17 -7
View File
@@ -87,14 +87,15 @@ namespace OpenNest.Converters
lastpt = endpt;
var layer = ClassifyLayer(arc);
var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{
pgm.LineTo(endpt);
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
}
else
{
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
}
return lastpt;
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt);
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
lastpt = startpt;
return lastpt;
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint);
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
move.Layer = LayerType.Scribe;
pgm.Codes.Add(move);
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
lastpt = line.EndPoint;
return lastpt;
}
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
// separate tool pass; everything else keeps the move's default Cut layer.
private static LayerType ClassifyLayer(Entity geo)
{
var name = geo.Layer?.Name;
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
return LayerType.Scribe;
return LayerType.Cut;
}
}
}
+35 -37
View File
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
}
/// <summary>
/// Fits a circular arc constrained to be tangent to the given directions at both
/// the first and last points. The center lies at the intersection of the normals
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
/// deviation includes any endpoint gap at Pn.
/// Fits a circular arc that passes exactly through both the first and last points
/// while matching the given endpoint tangents as closely as possible. For any
/// circle through two points, the tangents at those points make equal mirrored
/// angles with the chord, so the achievable inscribed angle is the average of the
/// two requested ones — when the requested tangents are consistent with a single
/// circular arc, both are matched exactly.
/// </summary>
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
List<Vector> points, Vector startTangent, Vector endTangent)
{
if (points.Count < 3)
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
var p1 = points[0];
var pn = points[^1];
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
if (stLen < 1e-10 || etLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
// Normal to start tangent at P1 (perpendicular)
var n1x = -startTangent.Y / stLen;
var n1y = startTangent.X / stLen;
// Normal to end tangent at Pn
var n2x = -endTangent.Y / etLen;
var n2y = endTangent.X / etLen;
// Solve: P1 + t1*N1 = Pn + t2*N2
var det = n1x * (-n2y) - (-n2x) * n1y;
if (System.Math.Abs(det) < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
var dx = pn.X - p1.X;
var dy = pn.Y - p1.Y;
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
var cx = p1.X + t1 * n1x;
var cy = p1.Y + t1 * n1y;
// Use radius from P1 (guarantees exact start tangent and passes through P1)
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
if (r1 < 1e-10)
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
return (Vector.Invalid, 0, double.MaxValue);
// Measure endpoint gap at Pn
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
var endpointDev = System.Math.Abs(r2 - r1);
var ux = dx / chordLen;
var uy = dy / chordLen;
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
var theta1 = SignedAngle(ux, uy, startTangent);
var theta2 = -SignedAngle(ux, uy, endTangent);
var theta = (theta1 + theta2) / 2;
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
return (Vector.Invalid, 0, double.MaxValue);
var halfChord = chordLen / 2;
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
var d = -halfChord / System.Math.Tan(theta);
var cx = (p1.X + pn.X) / 2 + d * -uy;
var cy = (p1.Y + pn.Y) / 2 + d * ux;
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
}
private static double SignedAngle(double ux, double uy, Vector to)
{
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
if (len < 1e-10) return 0;
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
}
/// <summary>
+160 -26
View File
@@ -374,11 +374,8 @@ public class GeometrySimplifier
var points = CollectPoints(entities, start, k);
if (points.Count < 3) return null;
var startTangent = chainedTangent.IsValid()
? chainedTangent
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var endTangent = GetExitDirection(entities[k]);
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
var endTangent = EstimateEndTangent(entities, k, points);
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
if (!center.IsValid()) return null;
@@ -386,8 +383,10 @@ public class GeometrySimplifier
while (k + 1 <= runEnd)
{
var extPoints = CollectPoints(entities, start, k + 1);
var extEndTangent = GetExitDirection(entities[k + 1]);
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
if (extPoints.Count < 3) break;
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
if (!nc.IsValid()) break;
k++;
@@ -407,37 +406,172 @@ public class GeometrySimplifier
return new ArcFitResult(center, radius, dev, points, k);
}
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
private (Vector center, double radius, double deviation) TryFit(
List<Vector> points, TangentEstimate start, TangentEstimate end)
{
// Try dual-tangent fit first (matches direction at both endpoints)
if (endTangent.IsValid())
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
{
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
if (dc.IsValid() && dd <= Tolerance)
{
var isRev = SumSignedAngles(dc, points) < 0;
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
if (aDev <= Tolerance)
return (dc, dr, System.Math.Max(dd, aDev));
}
}
// Fall back to start-tangent-only, then mirror axis
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
if (!center.IsValid() || dev > Tolerance)
(center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance)
return (Vector.Invalid, 0, 0);
continue;
// Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance)
return (Vector.Invalid, 0, 0);
continue;
return (center, radius, System.Math.Max(dev, arcDev));
}
return (Vector.Invalid, 0, 0);
}
/// <summary>
/// Yields fit attempts in preference order. A trusted tangent (chained from the
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
/// exactly on its side; otherwise the tangency error is balanced between both
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
/// passes exactly through both endpoints, so no gaps are introduced.
/// </summary>
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
List<Vector> points, TangentEstimate start, TangentEstimate end)
{
if (start.Trusted && !end.Trusted)
{
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
else if (end.Trusted && !start.Trusted)
{
yield return FitWithEndTangent(points, end.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
}
else
{
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
yield return FitMirrorAxis(points);
}
/// <summary>
/// Fits an arc through both endpoints with an exact tangent at the last point,
/// by running the start-tangent fit on the reversed point sequence.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithEndTangent(
List<Vector> points, Vector endTangent)
{
var reversed = new List<Vector>(points);
reversed.Reverse();
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
}
/// <summary>
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
/// vertices and only guide the fit.
/// </summary>
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
/// <summary>Segment-length ratio above which a neighboring line counts as a true
/// straight edge (rather than another chord of the tessellated curve).</summary>
private const double NeighborEdgeFactor = 3.0;
private static TangentEstimate EstimateStartTangent(
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
{
if (chainedTangent.IsValid())
return new TangentEstimate(chainedTangent, true);
if (entities[start] is Arc startArc)
return new TangentEstimate(GetEntryDirection(startArc), true);
var firstChordLen = points[0].DistanceTo(points[1]);
if (start > 0)
{
var prev = entities[start - 1];
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
{
if (prev is Arc)
return new TangentEstimate(GetExitDirection(prev), true);
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
return new TangentEstimate(GetExitDirection(prevLine), true);
}
}
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
if (points.Count >= 3)
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
return new TangentEstimate(chord, false);
}
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
{
if (entities[k] is Arc endArc)
return new TangentEstimate(GetExitDirection(endArc), true);
var lastChordLen = points[^1].DistanceTo(points[^2]);
if (k + 1 < entities.Count)
{
var next = entities[k + 1];
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
{
if (next is Arc nextArc)
return new TangentEstimate(GetEntryDirection(nextArc), true);
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
return new TangentEstimate(GetExitDirection(nextLine), true);
}
}
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
if (points.Count >= 3)
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
return new TangentEstimate(chord, false);
}
/// <summary>
/// Estimates the curve tangent at a polyline vertex from the circle through it and
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
/// Falls back to the travel direction when the three points are collinear.
/// </summary>
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
{
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
if (System.Math.Abs(d) < 1e-14)
return travel;
var sqA = at.X * at.X + at.Y * at.Y;
var sqB = b.X * b.X + b.Y * b.Y;
var sqC = c.X * c.X + c.Y * c.Y;
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
var tangent = new Vector(-(at.Y - cy), at.X - cx);
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
tangent = new Vector(-tangent.X, -tangent.Y);
return tangent;
}
/// <summary>
/// Returns the entry direction (tangent at start point) of an entity.
/// </summary>
private static Vector GetEntryDirection(Entity entity) => entity switch
{
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
_ => Vector.Invalid,
};
/// <summary>
/// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc.
+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)));
}
}
}
}
+1
View File
@@ -242,6 +242,7 @@ namespace OpenNest.IO
public ExportContext()
{
Document = new CadDocument();
Document.Header.Version = ACadVersion.AC1018;
CutLayer = new Layer("Cut") { Color = new Color(1) };
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
@@ -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,81 @@
using System.ComponentModel;
using OpenNest.CNC;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
/// configuration PropertyGrid and persisted to JSON.
/// </summary>
[TypeConverter(typeof(ExpandableObjectConverter))]
public sealed class LayerCutConfig
{
[DisplayName("Feed (mm/sec)")]
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
public int FeedMmPerSec { get; set; } = 10;
[DisplayName("Depth (inches)")]
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
public double Depth { get; set; } = 0.25;
[DisplayName("Pause Before")]
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
public bool PauseBefore { get; set; }
[DisplayName("Pause Message")]
[Description("Message shown on the controller during the pause.")]
public string PauseMessage { get; set; } = "";
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
}
/// <summary>
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
/// The cut block carries the tool-change pause by default.
/// </summary>
public sealed class GravographISPostConfig
{
[Category("Engrave (Scribe)")]
[DisplayName("Engrave")]
[Description("Parameters for engrave/scribe geometry (text).")]
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
{
FeedMmPerSec = 10,
Depth = 0.25,
PauseBefore = false,
PauseMessage = "",
};
[Category("Cut")]
[DisplayName("Cut")]
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
{
FeedMmPerSec = 3,
Depth = 0.25,
PauseBefore = true,
PauseMessage = "Change tool",
};
/// <summary>
/// Returns the cut config a polyline of the given layer should use, or null
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
/// </summary>
public LayerCutConfig ConfigFor(LayerType layer)
{
switch (layer)
{
case LayerType.Scribe:
return Engrave;
case LayerType.Cut:
case LayerType.Leadin:
case LayerType.Leadout:
return Cut;
default:
return null;
}
}
}
}
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Ports;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using OpenNest.Geometry;
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)"/>.
///
/// Geometry is split by <see cref="OpenNest.CNC.LayerType"/> into an engrave pass
/// (Scribe) and a cut pass (Cut), each with its own feed/depth from <see cref="Config"/>.
/// The cut pass pauses by default so the operator can swap/adjust the tool.
/// </summary>
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
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 GravographISPostConfig Config { get; }
object IConfigurablePostProcessor.Config => Config;
public GravographISPostProcessor()
{
var configPath = GetConfigPath();
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
Config = JsonSerializer.Deserialize<GravographISPostConfig>(json, JsonOptions)
?? new GravographISPostConfig();
}
else
{
Config = new GravographISPostConfig();
SaveConfig();
}
}
public GravographISPostProcessor(GravographISPostConfig config)
{
Config = config ?? throw new ArgumentNullException(nameof(config));
}
public void SaveConfig()
{
var configPath = GetConfigPath();
var json = JsonSerializer.Serialize(Config, JsonOptions);
File.WriteAllText(configPath, json);
}
private static string GetConfigPath()
{
var assemblyPath = typeof(GravographISPostProcessor).Assembly.Location;
var dir = Path.GetDirectoryName(assemblyPath);
var name = Path.GetFileNameWithoutExtension(assemblyPath);
return Path.Combine(dir, name + ".json");
}
public void Post(Nest nest, Stream outputStream)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
var passes = BuildPasses(Extractor.ExtractLayered(nest));
new GravographISWriter(WriterOptions).Write(passes, outputStream);
}
public void Post(Nest nest, string outputFile)
{
using var fs = new FileStream(outputFile, FileMode.Create, FileAccess.Write);
Post(nest, fs);
}
/// <summary>
/// Groups layer-tagged polylines into ordered tool passes: engrave (Scribe)
/// first, then cut. Each group is stitch/reverse-optimized independently.
/// Geometry whose layer maps to no config (Display) is skipped. When only one
/// group is present, a single pass is returned (and so the writer emits no pause).
/// </summary>
public IReadOnlyList<GravographPass> BuildPasses(IEnumerable<LayeredPolyline> polylines)
{
if (polylines == null) throw new ArgumentNullException(nameof(polylines));
var engrave = new List<IReadOnlyList<Vector>>();
var cut = new List<IReadOnlyList<Vector>>();
foreach (var poly in polylines)
{
if (poly == null) continue;
var block = Config.ConfigFor(poly.Layer);
if (block == null)
continue; // non-cutting (Display) geometry
if (ReferenceEquals(block, Config.Engrave))
engrave.Add(poly.Points);
else
cut.Add(poly.Points);
}
var passes = new List<GravographPass>();
if (engrave.Count > 0)
passes.Add(MakePass(Config.Engrave, engrave));
if (cut.Count > 0)
passes.Add(MakePass(Config.Cut, cut));
return passes;
}
private GravographPass MakePass(LayerCutConfig block, List<IReadOnlyList<Vector>> polylines)
{
return new GravographPass
{
Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse),
FeedMmPerSec = block.FeedMmPerSec,
DepthInches = block.Depth,
PauseBefore = block.PauseBefore,
PauseMessage = block.PauseMessage ?? "",
};
}
/// <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,464 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// One tool pass: a run of polylines cut at a single feed/depth, optionally
/// preceded by an operator pause (to swap or adjust the tool). The Gravograph
/// post builds one pass for engrave and one for cut.
/// </summary>
public sealed class GravographPass
{
public IEnumerable<IReadOnlyList<Vector>> Polylines { get; set; }
public int FeedMmPerSec { get; set; }
public double DepthInches { get; set; }
/// <summary>When true, park to origin and prompt the operator before this pass.</summary>
public bool PauseBefore { get; set; }
public string PauseMessage { get; set; } = "";
}
/// <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));
// A single pass at the configured feed/depth — byte-identical to the
// original single-group output (no transitions, no pause).
Write(new[]
{
new GravographPass
{
Polylines = polylines,
FeedMmPerSec = Options.FeedMmPerSec,
DepthInches = Options.DepthInches,
PauseBefore = false,
PauseMessage = "",
},
}, output);
}
/// <summary>
/// Writes the full byte stream for an ordered list of tool passes. The preamble
/// carries the first pass's feed/depth; each later pass emits an inline feed
/// (and depth, if changed) and, when <see cref="GravographPass.PauseBefore"/> is
/// set, parks to the operator origin and emits an operator pause before cutting.
/// </summary>
public void Write(IReadOnlyList<GravographPass> passes, Stream output)
{
if (passes == null) throw new ArgumentNullException(nameof(passes));
if (output == null) throw new ArgumentNullException(nameof(output));
var firstFeed = passes.Count > 0 ? passes[0].FeedMmPerSec : Options.FeedMmPerSec;
var firstDepth = passes.Count > 0 ? passes[0].DepthInches : Options.DepthInches;
var preamble = (byte[])PreambleTemplate.Clone();
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
output.Write(preamble, 0, preamble.Length);
// Cumulative head position from the operator-set upper-left origin, in
// wire steps. The first polyline gets a leading DR travel from this
// origin before PD lowers for cutting. Used by the envelope guard to
// catch bad records before they ship to the engraver.
var headX = 0;
var headY = 0;
var envelopeXSteps = (int)System.Math.Round(Options.WorkEnvelopeXMm * StepsPerMm,
MidpointRounding.AwayFromZero);
var envelopeYSteps = (int)System.Math.Round(Options.WorkEnvelopeYMm * StepsPerMm,
MidpointRounding.AwayFromZero);
var firstPolyline = true;
var polyIndex = 0;
var currentFeed = firstFeed;
var currentDepth = firstDepth;
for (var p = 0; p < passes.Count; p++)
{
var pass = passes[p];
if (p > 0)
{
if (pass.PauseBefore)
{
// Park: lift Z, then rapid (pen-up) back to the operator origin so
// the head is clear of the work while the tool is swapped.
WriteLiftOnly(output);
WriteTravel(output, (byte)'P', (byte)'U',
checked(-headX), checked(-headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
WritePauseCore(output, pass.PauseMessage);
}
if (pass.FeedMmPerSec != currentFeed)
{
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
currentFeed = pass.FeedMmPerSec;
}
if (pass.DepthInches != currentDepth)
{
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
currentDepth = pass.DepthInches;
}
}
if (pass.Polylines == null) continue;
foreach (var poly in pass.Polylines)
{
polyIndex++;
if (poly == null || poly.Count < 2)
continue;
WritePolyline(output, poly, ref firstPolyline, ref headX, ref headY,
envelopeXSteps, envelopeYSteps, polyIndex);
}
}
WriteLiftOnly(output);
if (Options.ReturnToOriginAtEnd && !firstPolyline)
{
WriteTravel(output, (byte)'P', (byte)'U',
checked(-headX), checked(-headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
}
output.Write(EndJobBytes, 0, EndJobBytes.Length);
}
private void WritePolyline(Stream output, IReadOnlyList<Vector> poly,
ref bool firstPolyline, ref int headX, ref int headY,
int envelopeXSteps, int envelopeYSteps, int polyIndex)
{
var (startX, startY) = ToWire(poly[0]);
WriteTravel(output,
firstPolyline ? (byte)'D' : (byte)'P',
firstPolyline ? (byte)'R' : (byte)'U',
checked(startX - headX), checked(startY - headY),
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
// PD command + single records-follow flag, then one record per segment.
output.WriteByte(0xFF);
output.WriteByte(0xFD);
output.WriteByte((byte)'P');
output.WriteByte((byte)'D');
output.WriteByte(0x00);
output.WriteByte(0x00);
var prevX = startX;
var prevY = startY;
for (int i = 1; i < poly.Count; i++)
{
var (cx, cy) = ToWire(poly[i]);
var dx = checked(cx - prevX);
var dy = checked(cy - prevY);
EnsureEnvelope(headX + dx, headY + dy, envelopeXSteps, envelopeYSteps,
polyIndex, segment: i, isTravel: false);
WriteRecord(output, dx, dy);
prevX = cx;
prevY = cy;
headX += dx;
headY += dy;
}
firstPolyline = false;
}
// The operator pause, minus the leading lift/park which the caller emits.
// Stops the spindle (MC off), turns off aux, writes the console message, then
// restarts the spindle (MC on) so the job resumes when the operator presses start.
private static void WritePauseCore(Stream s, string message)
{
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x00); // begin message
WriteMessagePackets(s, message);
WriteCommandRaw(s, (byte)'N', (byte)'R', 0x00, 0x01); // line terminator
WriteCommandRaw(s, (byte)'L', (byte)'B', 0x00, 0x01); // end message
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x01); // motor on
}
// Console label packets carry two ASCII chars each; an odd-length message is
// space-padded to a whole number of packets so widths stay 2 bytes.
private static void WriteMessagePackets(Stream s, string message)
{
if (string.IsNullOrEmpty(message)) return;
var chars = Encoding.ASCII.GetBytes(message);
for (var i = 0; i < chars.Length; i += 2)
{
var c0 = chars[i];
var c1 = (i + 1 < chars.Length) ? chars[i + 1] : (byte)0x20;
WriteCommandRaw(s, (byte)'L', (byte)'B', c0, c1);
}
}
private static void WriteCommand(Stream s, byte c0, byte c1, short value)
{
WriteCommandRaw(s, c0, c1, (byte)((value >> 8) & 0xFF), (byte)(value & 0xFF));
}
private static void WriteCommandRaw(Stream s, byte c0, byte c1, byte hi, byte lo)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
s.WriteByte(hi);
s.WriteByte(lo);
}
private const double StepsPerMm = 80.0;
private void EnsureEnvelope(int wireX, int wireY,
int envXSteps, int envYSteps,
int polyIndex, int segment, bool isTravel)
{
if (!Options.EnvelopeGuardEnabled) return;
// Wire frame: X is identity to input; Y is negated. With the operator
// origin set at the upper-left of the work envelope and an OpenNest
// quadrant-4 plate, valid part coordinates are +X/right and -Y/down:
// wireX ∈ [0, +envXSteps]
// wireY ∈ [0, +envYSteps]
if (wireX >= 0 && wireX <= envXSteps && wireY >= 0 && wireY <= envYSteps)
return;
var inputX = wireX / (double)StepsPerInch;
var inputY = -wireY / (double)StepsPerInch;
var kind = isTravel ? "pen-up travel" : "cut segment";
throw new InvalidOperationException(
$"Polyline {polyIndex} {kind} (segment {segment}) would place the head at " +
$"({inputX:F3}\", {inputY:F3}\"), outside the {Options.WorkEnvelopeXMm}×{Options.WorkEnvelopeYMm} mm " +
$"work envelope from upper-left origin. Refusing to emit the record.");
}
private static short DepthInStepsAsInt16(double depthInches)
{
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero);
if (steps < short.MinValue || steps > short.MaxValue)
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16.");
return (short)steps;
}
private static (int x, int y) ToWire(Vector v)
{
// Inches -> steps. With upper-left origin in OpenNest quadrant 4,
// negative input Y is down; Y is negated on the wire.
var x = (int)System.Math.Round(v.X * StepsPerInch, MidpointRounding.AwayFromZero);
var y = (int)System.Math.Round(-v.Y * StepsPerInch, MidpointRounding.AwayFromZero);
return (x, y);
}
private void WriteTravel(Stream s, byte c0, byte c1, int dx, int dy,
ref int headX, ref int headY,
int envelopeXSteps, int envelopeYSteps,
int polyIndex)
{
if (dx == 0 && dy == 0)
return;
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
s.WriteByte(0x00);
s.WriteByte(0x00);
var chunks = System.Math.Max(
(int)System.Math.Ceiling(System.Math.Abs(dx) / (double)short.MaxValue),
(int)System.Math.Ceiling(System.Math.Abs(dy) / (double)short.MaxValue));
if (chunks < 1) chunks = 1;
var emittedX = 0;
var emittedY = 0;
for (var i = 1; i <= chunks; i++)
{
var targetX = (int)System.Math.Round(dx * (i / (double)chunks), MidpointRounding.AwayFromZero);
var targetY = (int)System.Math.Round(dy * (i / (double)chunks), MidpointRounding.AwayFromZero);
var chunkX = checked(targetX - emittedX);
var chunkY = checked(targetY - emittedY);
EnsureEnvelope(headX + chunkX, headY + chunkY, envelopeXSteps, envelopeYSteps,
polyIndex, segment: 0, isTravel: true);
WriteRecord(s, chunkX, chunkY);
emittedX = targetX;
emittedY = targetY;
headX += chunkX;
headY += chunkY;
}
}
private static void WriteLiftOnly(Stream s)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte((byte)'P');
s.WriteByte((byte)'U');
s.WriteByte(0x00);
s.WriteByte(0x01);
}
private static void WriteCommandWithRecord(Stream s, byte c0, byte c1, int dx, int dy)
{
s.WriteByte(0xFF);
s.WriteByte(0xFD);
s.WriteByte(c0);
s.WriteByte(c1);
// Records-follow flag (0x0000) emitted once per PU/PD packet.
s.WriteByte(0x00);
s.WriteByte(0x00);
WriteRecord(s, dx, dy);
}
private static void WriteRecord(Stream s, int dx, int dy)
{
if (dx < short.MinValue || dx > short.MaxValue ||
dy < short.MinValue || dy > short.MaxValue)
{
throw new InvalidOperationException(
$"Move delta ({dx}, {dy}) steps overflows signed int16 — split moves upstream.");
}
int word1;
int param;
var absDx = (double)System.Math.Abs(dx);
var absDy = (double)System.Math.Abs(dy);
var len = System.Math.Sqrt(absDx * absDx + absDy * absDy);
if (len < 1.0)
{
// Zero-length lift (PU 00 01) is the dedicated form; for a record-carrying
// packet a true zero-length move shouldn't occur, but stay numerically safe.
word1 = 16384;
param = 1;
}
else
{
var maxAbs = System.Math.Max(absDx, absDy);
word1 = (int)System.Math.Round(16384.0 * maxAbs / len, MidpointRounding.AwayFromZero);
param = (int)System.Math.Round(len / 22.4, MidpointRounding.AwayFromZero);
if (param < 1) param = 1;
if (param > 180) param = 180;
if (word1 > 16384) word1 = 16384;
}
WriteBigEndianInt16(s, (short)word1);
WriteBigEndianInt16(s, (short)param);
WriteBigEndianInt16(s, (short)dx);
WriteBigEndianInt16(s, (short)dy);
}
private static void WriteBigEndianInt16(Stream s, short value)
{
s.WriteByte((byte)((value >> 8) & 0xFF));
s.WriteByte((byte)(value & 0xFF));
}
// Locates the operand of a command (FF FD <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,232 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS
{
/// <summary>
/// A polyline together with the <see cref="LayerType"/> of the moves that
/// produced it. The Gravograph post groups by layer to emit separate engrave
/// and cut passes (with a tool-change pause between them).
/// </summary>
public sealed class LayeredPolyline
{
public LayeredPolyline(List<Vector> points, LayerType layer)
{
Points = points;
Layer = layer;
}
public List<Vector> Points { get; }
public LayerType Layer { get; }
}
/// <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 at <see cref="LayerType"/> changes, 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). Layer information is dropped;
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
/// </summary>
public List<List<Vector>> Extract(Nest nest)
{
return ExtractLayered(nest).Select(p => p.Points).ToList();
}
/// <summary>
/// Extracts polylines for a single part without layer information.
/// </summary>
public List<List<Vector>> ExtractPart(Part part)
{
return ExtractPartLayered(part).Select(p => p.Points).ToList();
}
/// <summary>
/// Extracts layer-tagged polylines from every non-cutoff part in every plate,
/// in plate coordinates (inches). Each polyline is layer-uniform.
/// </summary>
public List<LayeredPolyline> ExtractLayered(Nest nest)
{
if (nest == null) throw new ArgumentNullException(nameof(nest));
var result = new List<LayeredPolyline>();
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 layer-tagged 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<LayeredPolyline> ExtractPartLayered(Part part)
{
var list = new List<LayeredPolyline>();
ExtractPart(part, list);
return list;
}
private void ExtractPart(Part part, List<LayeredPolyline> 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;
var currentLayer = LayerType.Cut;
foreach (var code in program.Codes)
{
if (code is Motion m && m.Suppressed)
continue;
switch (code)
{
case RapidMove rapid:
{
FlushCurrent(sink, ref current, currentLayer);
pos = rapid.EndPoint;
break;
}
case LinearMove linear:
{
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
var end = linear.EndPoint;
current.Add(end + offset);
pos = end;
break;
}
case ArcMove arc:
{
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
pos = arc.EndPoint;
break;
}
}
}
FlushCurrent(sink, ref current, currentLayer);
}
// Ensures `current` is an open polyline whose layer matches `moveLayer`,
// seeded at `seed` (the current pen position). When the layer changes
// mid-chain the previous polyline is flushed and a new one begins at the
// shared seam vertex so engrave and cut passes stay geometrically continuous.
private static void StartOrSplit(List<LayeredPolyline> sink, ref List<Vector> current,
ref LayerType currentLayer, LayerType moveLayer, Vector seed)
{
if (current == null)
{
current = new List<Vector> { seed };
currentLayer = moveLayer;
}
else if (moveLayer != currentLayer)
{
FlushCurrent(sink, ref current, currentLayer);
current = new List<Vector> { seed };
currentLayer = moveLayer;
}
}
private static void FlushCurrent(List<LayeredPolyline> sink, ref List<Vector> current, LayerType layer)
{
if (current != null && current.Count >= 2)
sink.Add(new LayeredPolyline(current, layer));
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,70 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Converters;
public class ConvertGeometryLayerTests
{
private static Program ProgramFor(Entity entity)
{
var shape = new Shape();
shape.Entities.Add(entity);
return ConvertGeometry.ToProgram(shape);
}
[Fact]
public void AddLine_EngraveLayer_TagsScribe()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddLine_EtchLayer_TagsScribe()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddArc_EngraveLayer_TagsScribe()
{
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(arc);
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
Assert.NotEmpty(arcs);
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddCircle_EngraveLayer_TagsScribe()
{
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(circle);
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
Assert.NotEmpty(arcs);
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddLine_DefaultLayer_StaysCut()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
}
}
@@ -131,6 +131,111 @@ public class GeometrySimplifierTests
Assert.IsType<Arc>(result.Entities[6]);
}
[Fact]
public void Analyze_FilletBetweenTangentLines_ArcIsTangentToLines()
{
// A 90-degree fillet (r=0.3, center origin, 270deg..360deg CCW) between two
// long tangent lines, approximated by 8 chords whose interior vertices bulge
// radially outward within tolerance (simulates real DXF tessellation noise).
var r = 0.3;
var deltas = new[] { 0.0, 0.002, 0.003, 0.0035, 0.0035, 0.0035, 0.003, 0.002, 0.0 };
var pts = new List<Vector>();
for (var i = 0; i <= 8; i++)
{
var ang = OpenNest.Math.Angle.ToRadians(270 + 11.25 * i);
var radius = r + deltas[i];
pts.Add(new Vector(radius * System.Math.Cos(ang), radius * System.Math.Sin(ang)));
}
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(-2, -r), pts[0]));
for (var i = 0; i < pts.Count - 1; i++)
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
shape.Entities.Add(new Line(pts[^1], new Vector(r, 2)));
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
var candidates = simplifier.Analyze(shape);
Assert.Single(candidates);
var arc = candidates[0].FittedArc;
// Arc must pass exactly through the run's boundary vertices (no gaps)
Assert.True(arc.StartPoint().DistanceTo(pts[0]) < 1e-6);
Assert.True(arc.EndPoint().DistanceTo(pts[^1]) < 1e-6);
// Arc must be tangent to the adjacent straight edges at its endpoints
var startDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.StartPoint()), new Vector(1, 0));
var endDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.EndPoint()), new Vector(0, 1));
Assert.True(startDelta < 0.3, $"Arc start not tangent to incoming line: off by {startDelta:F3} deg");
Assert.True(endDelta < 0.3, $"Arc end not tangent to outgoing line: off by {endDelta:F3} deg");
}
[Fact]
public void Analyze_CompoundCurve_AdjacentArcsAreTangentAtJunction()
{
// Two tangent-continuous arcs of different radii (r=0.2 sweeping 60deg, then
// r=0.6 sweeping 40deg), tessellated into chords with slight radial noise.
// The fitted arcs must stay tangent-continuous at their junction.
var c1 = new Vector(0, 0);
var r1 = 0.2;
var deltas1 = new[] { 0.0, 0.001, 0.0005, -0.0005, -0.001, -0.0005, 0.0 };
var pts = new List<Vector>();
for (var i = 0; i <= 6; i++)
{
var ang = OpenNest.Math.Angle.ToRadians(10 * i);
var radius = r1 + deltas1[i];
pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang)));
}
// Second arc center along the junction radius so tangents match at the junction
var junctionAngle = OpenNest.Math.Angle.ToRadians(60);
var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle));
var r2 = 0.6;
var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2));
var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 };
for (var i = 1; i <= 5; i++)
{
var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i);
var radius = r2 + deltas2[i];
pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang)));
}
var shape = new Shape();
for (var i = 0; i < pts.Count - 1; i++)
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
var candidates = simplifier.Analyze(shape);
Assert.Equal(2, candidates.Count);
var arcA = candidates[0].FittedArc;
var arcB = candidates[1].FittedArc;
// Arcs must share the junction vertex exactly
Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6);
// Tangent continuity across the junction
var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint()));
Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction");
}
private static Vector ArcTangentAt(Arc arc, Vector pt)
{
var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X);
return arc.IsReversed
? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang))
: new Vector(-System.Math.Sin(ang), System.Math.Cos(ang));
}
private static double AngleBetweenDeg(Vector v1, Vector v2)
{
var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y);
var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y);
var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2);
dot = System.Math.Max(-1, System.Math.Min(1, dot));
return System.Math.Acos(dot) * 180.0 / System.Math.PI;
}
[Fact]
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
{
@@ -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,51 @@
using OpenNest.CNC;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class GravographISPostConfigTests
{
[Fact]
public void Defaults_EngraveBlock_NoPause_FasterFeed()
{
var config = new GravographISPostConfig();
Assert.Equal(10, config.Engrave.FeedMmPerSec);
Assert.False(config.Engrave.PauseBefore);
}
[Fact]
public void Defaults_CutBlock_PausesToChangeTool()
{
var config = new GravographISPostConfig();
Assert.Equal(3, config.Cut.FeedMmPerSec);
Assert.True(config.Cut.PauseBefore);
Assert.Equal("Change tool", config.Cut.PauseMessage);
}
[Theory]
[InlineData(LayerType.Scribe)]
public void ConfigFor_Scribe_ReturnsEngraveBlock(LayerType layer)
{
var config = new GravographISPostConfig();
Assert.Same(config.Engrave, config.ConfigFor(layer));
}
[Theory]
[InlineData(LayerType.Cut)]
[InlineData(LayerType.Leadin)]
[InlineData(LayerType.Leadout)]
public void ConfigFor_CutLayers_ReturnCutBlock(LayerType layer)
{
var config = new GravographISPostConfig();
Assert.Same(config.Cut, config.ConfigFor(layer));
}
[Fact]
public void ConfigFor_Display_ReturnsNull_SoItIsSkipped()
{
var config = new GravographISPostConfig();
Assert.Null(config.ConfigFor(LayerType.Display));
}
}
@@ -0,0 +1,70 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.GravographIS;
namespace OpenNest.Tests.GravographIS;
public class GravographISPostProcessorTests
{
private static LayeredPolyline Poly(LayerType layer, params Vector[] pts)
=> new LayeredPolyline(new List<Vector>(pts), layer);
[Fact]
public void BuildPasses_EngraveAndCut_OrdersEngraveFirstThenCutWithPause()
{
var post = new GravographISPostProcessor();
var passes = post.BuildPasses(new[]
{
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
Poly(LayerType.Scribe, new Vector(0, 0), new Vector(0, 1)),
});
Assert.Equal(2, passes.Count);
Assert.Equal(post.Config.Engrave.FeedMmPerSec, passes[0].FeedMmPerSec);
Assert.False(passes[0].PauseBefore);
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[1].FeedMmPerSec);
Assert.True(passes[1].PauseBefore);
Assert.Equal("Change tool", passes[1].PauseMessage);
}
[Fact]
public void BuildPasses_CutOnly_IsSinglePass()
{
var post = new GravographISPostProcessor();
var passes = post.BuildPasses(new[]
{
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
});
Assert.Single(passes);
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
}
[Fact]
public void BuildPasses_SkipsDisplayGeometry()
{
var post = new GravographISPostProcessor();
var passes = post.BuildPasses(new[]
{
Poly(LayerType.Display, new Vector(0, 0), new Vector(1, 0)),
Poly(LayerType.Cut, new Vector(0, 0), new Vector(0, 1)),
});
Assert.Single(passes);
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
}
[Fact]
public void Config_IsExposedThroughConfigurableInterface()
{
var post = new GravographISPostProcessor(new GravographISPostConfig());
OpenNest.IConfigurablePostProcessor configurable = post;
Assert.Same(post.Config, configurable.Config);
}
}
@@ -0,0 +1,318 @@
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);
}
[Fact]
public void Passes_PauseBeforeCut_EmitsPauseSequenceBetweenGroups()
{
var engrave = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } };
var cut = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } };
var passes = new List<GravographPass>
{
new GravographPass { Polylines = engrave, FeedMmPerSec = 10, DepthInches = 0.25 },
new GravographPass { Polylines = cut, FeedMmPerSec = 3, DepthInches = 0.25, PauseBefore = true, PauseMessage = "Hi" },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions
{
EnvelopeGuardEnabled = false,
ReturnToOriginAtEnd = false,
}).Write(passes, ms);
var bytes = ms.ToArray();
var mcOff = IndexOf(bytes, 0, (byte)'M', (byte)'C', 0x00, 0x00);
var ouFb = IndexOf(bytes, mcOff, (byte)'O', (byte)'U', 0xFF, 0xFB);
var ouFa = IndexOf(bytes, ouFb, (byte)'O', (byte)'U', 0xFF, 0xFA);
var lbBegin = IndexOf(bytes, ouFa, (byte)'L', (byte)'B', 0x00, 0x00);
var lbMsg = IndexOf(bytes, lbBegin, (byte)'L', (byte)'B', (byte)'H', (byte)'i');
var nr = IndexOf(bytes, lbMsg, (byte)'N', (byte)'R', 0x00, 0x01);
var lbEnd = IndexOf(bytes, nr, (byte)'L', (byte)'B', 0x00, 0x01);
var mcOn = IndexOf(bytes, lbEnd, (byte)'M', (byte)'C', 0x00, 0x01);
Assert.True(mcOff >= 0, "motor-off (MC 0000) not found");
Assert.True(mcOff < ouFb && ouFb < ouFa && ouFa < lbBegin && lbBegin < lbMsg
&& lbMsg < nr && nr < lbEnd && lbEnd < mcOn,
"pause commands out of order");
// Resume sets the cut feed inline (VS 0x0003) after the motor restarts.
var vsCut = IndexOf(bytes, mcOn, (byte)'V', (byte)'S', 0x00, 0x03);
Assert.True(vsCut > mcOn, "cut feed not set after resume");
}
[Fact]
public void Passes_PauseOddMessage_SpacePadsLastPacket()
{
var passes = new List<GravographPass>
{
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = true, PauseMessage = "abc" },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
var bytes = ms.ToArray();
var lbAb = IndexOf(bytes, 0, (byte)'L', (byte)'B', (byte)'a', (byte)'b');
var lbCPad = IndexOf(bytes, lbAb, (byte)'L', (byte)'B', (byte)'c', 0x20);
Assert.True(lbAb >= 0, "first message packet 'ab' not found");
Assert.True(lbCPad > lbAb, "odd packet not space-padded to 'c '");
}
[Fact]
public void Passes_DifferentFeeds_NoPause_EmitsInlineFeedChangeNoMessage()
{
var passes = new List<GravographPass>
{
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 },
new GravographPass { Polylines = new List<IReadOnlyList<Vector>> { new[] { new Vector(0, 0), new Vector(0, -1) } }, FeedMmPerSec = 3, PauseBefore = false },
};
using var ms = new MemoryStream();
new GravographISWriter(new GravographISWriterOptions { EnvelopeGuardEnabled = false, ReturnToOriginAtEnd = false }).Write(passes, ms);
var bytes = ms.ToArray();
Assert.True(IndexOf(bytes, 0, (byte)'V', (byte)'S', 0x00, 0x03) >= 0, "inline cut feed change missing");
Assert.True(IndexOfCmd(bytes, (byte)'L', (byte)'B') < 0, "no LB message expected without a pause");
}
private static int IndexOf(byte[] bytes, int from, byte c0, byte c1, byte hi, byte lo)
{
for (var i = System.Math.Max(0, from); i <= bytes.Length - 6; 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 int IndexOfCmd(byte[] bytes, byte c0, byte c1)
{
for (var i = 0; i <= bytes.Length - 4; i++)
{
if (bytes[i] == 0xFF && bytes[i + 1] == 0xFD && bytes[i + 2] == c0 && bytes[i + 3] == c1)
return i;
}
return -1;
}
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,78 @@
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]);
}
[Fact]
public void ExtractPartLayered_SplitsContinuousChainAtLayerChange()
{
// A single continuous chain (no rapid) that switches from Scribe to Cut
// must be split into two layer-uniform polylines sharing the seam vertex,
// so the post can emit a tool-change pause between engrave and cut.
var program = new Program(Mode.Absolute);
program.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Scribe });
program.Codes.Add(new LinearMove(2, 0) { Layer = LayerType.Scribe });
program.Codes.Add(new LinearMove(2, 1) { Layer = LayerType.Cut });
program.Codes.Add(new LinearMove(3, 1) { Layer = LayerType.Cut });
var drawing = new Drawing("Mixed", program);
var part = new Part(drawing, new Vector(0, 0));
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
Assert.Equal(2, polylines.Count);
Assert.Equal(LayerType.Scribe, polylines[0].Layer);
Assert.Equal(new[] { new Vector(0, 0), new Vector(1, 0), new Vector(2, 0) }, polylines[0].Points);
Assert.Equal(LayerType.Cut, polylines[1].Layer);
Assert.Equal(new[] { new Vector(2, 0), new Vector(2, 1), new Vector(3, 1) }, polylines[1].Points);
}
[Fact]
public void ExtractPartLayered_UniformChain_IsSinglePolyline()
{
var program = new Program(Mode.Absolute);
program.Codes.Add(new LinearMove(1, 0));
program.Codes.Add(new LinearMove(1, 1));
var part = new Part(new Drawing("Cut", program), new Vector(0, 0));
var polylines = new NestPolylineExtractor().ExtractPartLayered(part);
Assert.Single(polylines);
Assert.Equal(LayerType.Cut, polylines[0].Layer);
}
}
@@ -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}
+43
View File
@@ -40,6 +40,7 @@ namespace OpenNest.Controls
public event EventHandler<Line> LinePicked;
public event EventHandler PickCancelled;
public event EventHandler<CadText> TextConvertRequested;
private bool isPickingBendLine;
public bool IsPickingBendLine
@@ -76,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)
@@ -328,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++)
+118
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;
@@ -463,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))
@@ -839,6 +949,13 @@ namespace OpenNest.Forms
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),
@@ -847,6 +964,7 @@ namespace OpenNest.Forms
Rotation = text.Rotation,
LayerName = text.Layer?.Name,
HAlign = ha,
VAlign = va,
});
break;
}
+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>