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
ajandClaude Opus 4.6 f064368008 fix(io): remove zero-sweep arcs during DXF import
DXF files can contain degenerate arcs where start angle equals end angle
(zero sweep), often left as construction artifacts by CAD software.
These create spurious shapes in ShapeBuilder — e.g. SULLYS-033.dxf
showed 5 loops instead of 4 (3 cutouts + perimeter).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 21:45:44 -04:00
ajandClaude Opus 4.6 1c994718fb feat(io): add DWG file import support via ACadSharp DwgReader
ACadSharp already includes DwgReader, so this wires it up across the
entire import pipeline — Dxf.Import, CadConverter drag-drop, nest
import dialog, console CLI, BOM analyzer, and training data collector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
ajandClaude Opus 4.6 9d58e6fba8 fix(ui): stay on drawings tab after DXF import
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 23:53:29 -04:00
49 changed files with 4119 additions and 117 deletions
+3
View File
@@ -213,3 +213,6 @@ docs/superpowers/
# Launch settings # Launch settings
**/Properties/launchSettings.json **/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). - **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. - **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. - **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).
+4 -3
View File
@@ -151,7 +151,8 @@ static class NestConsole
f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase) f.EndsWith(NestFormat.FileExtension, StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)); || f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
var dxfFiles = options.InputFiles.Where(f => var dxfFiles = options.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList(); f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToList();
// If we have a nest file, load it and optionally add DXFs. // If we have a nest file, load it and optionally add DXFs.
if (nestFile != null) if (nestFile != null)
@@ -187,7 +188,7 @@ static class NestConsole
// DXF-only mode: create a fresh nest. // DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0) if (dxfFiles.Count == 0)
{ {
Console.Error.WriteLine("Error: no nest (.nest) or DXF (.dxf) files specified"); Console.Error.WriteLine("Error: no nest (.nest) or CAD (.dxf/.dwg) files specified");
return null; return null;
} }
@@ -461,7 +462,7 @@ static class NestConsole
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]"); Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:"); Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf drawing files"); Console.Error.WriteLine(" input-files One or more .nest nest files or .dxf/.dwg drawing files");
Console.Error.WriteLine(); Console.Error.WriteLine();
Console.Error.WriteLine("Modes:"); Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)"); Console.Error.WriteLine(" <nest.nest> Load nest and fill (existing behavior)");
+17 -7
View File
@@ -87,14 +87,15 @@ namespace OpenNest.Converters
lastpt = endpt; lastpt = endpt;
var layer = ClassifyLayer(arc);
var sweep = System.Math.Abs(arc.SweepAngle()); var sweep = System.Math.Abs(arc.SweepAngle());
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI)) if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
{ {
pgm.LineTo(endpt); pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
} }
else 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; return lastpt;
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt); 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; lastpt = startpt;
return lastpt; return lastpt;
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(line.StartPoint); pgm.MoveTo(line.StartPoint);
var move = new LinearMove(line.EndPoint); pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
move.Layer = LayerType.Scribe;
pgm.Codes.Add(move);
lastpt = line.EndPoint; lastpt = line.EndPoint;
return lastpt; 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;
}
} }
} }
+3
View File
@@ -93,6 +93,9 @@ namespace OpenNest.Geometry
} }
} }
public bool IsFullCircle() =>
SweepAngle() >= Angle.TwoPI - Tolerance.Epsilon;
/// <summary> /// <summary>
/// Angle in radians between start and end angles. /// Angle in radians between start and end angles.
/// </summary> /// </summary>
+35 -37
View File
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Fits a circular arc constrained to be tangent to the given directions at both /// Fits a circular arc that passes exactly through both the first and last points
/// the first and last points. The center lies at the intersection of the normals /// while matching the given endpoint tangents as closely as possible. For any
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives /// circle through two points, the tangents at those points make equal mirrored
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent); /// angles with the chord, so the achievable inscribed angle is the average of the
/// deviation includes any endpoint gap at Pn. /// two requested ones — when the requested tangents are consistent with a single
/// circular arc, both are matched exactly.
/// </summary> /// </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) List<Vector> points, Vector startTangent, Vector endTangent)
{ {
if (points.Count < 3) if (points.Count < 3)
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
var p1 = points[0]; var p1 = points[0];
var pn = points[^1]; 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 dx = pn.X - p1.X;
var dy = pn.Y - p1.Y; var dy = pn.Y - p1.Y;
var t1 = (dx * (-n2y) - (-n2x) * dy) / det; var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
if (chordLen < 1e-10)
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)
return (Vector.Invalid, 0, double.MaxValue); return (Vector.Invalid, 0, double.MaxValue);
// Measure endpoint gap at Pn var ux = dx / chordLen;
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y)); var uy = dy / chordLen;
var endpointDev = System.Math.Abs(r2 - r1);
var interiorDev = MaxRadialDeviation(points, cx, cy, r1); // Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev)); 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> /// <summary>
@@ -17,6 +17,38 @@ namespace OpenNest.Geometry
(list, item, i) => list.GetCollinearLines(item, i), (list, item, i) => list.GetCollinearLines(item, i),
(Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined)); (Line a, Line b, out Line joined) => TryJoinLines(a, b, out joined));
public static void Deduplicate(IList<Circle> circles)
{
for (var i = circles.Count - 1; i >= 1; i--)
{
for (var j = i - 1; j >= 0; j--)
{
if (circles[i].Center.DistanceTo(circles[j].Center) <= Tolerance.Epsilon
&& circles[i].Radius.IsEqualTo(circles[j].Radius))
{
circles.RemoveAt(i);
break;
}
}
}
}
public static void Deduplicate(IList<Circle> circles, IList<Arc> arcs)
{
for (var i = circles.Count - 1; i >= 0; i--)
{
for (var j = arcs.Count - 1; j >= 0; j--)
{
if (arcs[j].Center.DistanceTo(circles[i].Center) <= Tolerance.Epsilon
&& arcs[j].Radius.IsEqualTo(circles[i].Radius)
&& arcs[j].IsFullCircle())
{
arcs.RemoveAt(j);
}
}
}
}
private delegate bool TryJoin<T>(T a, T b, out T joined); private delegate bool TryJoin<T>(T a, T b, out T joined);
private static void MergePass<T>(IList<T> items, private static void MergePass<T>(IList<T> items,
+164 -30
View File
@@ -374,11 +374,8 @@ public class GeometrySimplifier
var points = CollectPoints(entities, start, k); var points = CollectPoints(entities, start, k);
if (points.Count < 3) return null; if (points.Count < 3) return null;
var startTangent = chainedTangent.IsValid() var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
? chainedTangent var endTangent = EstimateEndTangent(entities, k, points);
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var endTangent = GetExitDirection(entities[k]);
var (center, radius, dev) = TryFit(points, startTangent, endTangent); var (center, radius, dev) = TryFit(points, startTangent, endTangent);
if (!center.IsValid()) return null; if (!center.IsValid()) return null;
@@ -386,8 +383,10 @@ public class GeometrySimplifier
while (k + 1 <= runEnd) while (k + 1 <= runEnd)
{ {
var extPoints = CollectPoints(entities, start, k + 1); var extPoints = CollectPoints(entities, start, k + 1);
var extEndTangent = GetExitDirection(entities[k + 1]); if (extPoints.Count < 3) break;
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
if (!nc.IsValid()) break; if (!nc.IsValid()) break;
k++; k++;
@@ -407,37 +406,172 @@ public class GeometrySimplifier
return new ArcFitResult(center, radius, dev, points, k); 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) foreach (var (center, radius, dev) in FitAttempts(points, start, end))
if (endTangent.IsValid())
{ {
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent); if (!center.IsValid() || dev > Tolerance)
if (dc.IsValid() && dd <= Tolerance) 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)
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)
{ {
var isRev = SumSignedAngles(dc, points) < 0; if (prev is Arc)
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev); return new TangentEstimate(GetExitDirection(prev), true);
if (aDev <= Tolerance) if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
return (dc, dr, System.Math.Max(dd, aDev)); return new TangentEstimate(GetExitDirection(prevLine), true);
} }
} }
// Fall back to start-tangent-only, then mirror axis var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent); if (points.Count >= 3)
if (!center.IsValid() || dev > Tolerance) return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
(center, radius, dev) = FitMirrorAxis(points); return new TangentEstimate(chord, false);
if (!center.IsValid() || dev > Tolerance)
return (Vector.Invalid, 0, 0);
// 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);
return (center, radius, System.Math.Max(dev, arcDev));
} }
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> /// <summary>
/// Computes the tangent direction at the last point of a fitted arc, /// Computes the tangent direction at the last point of a fitted arc,
/// used to chain tangent continuity to the next arc. /// used to chain tangent continuity to the next arc.
+65
View File
@@ -1,6 +1,9 @@
using OpenNest.Engine;
using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
@@ -54,6 +57,68 @@ namespace OpenNest.Engine.BestFit
return new List<Part> { part1, part2 }; return new List<Part> { part1, part2 };
} }
public List<Part> BuildCanonicalParts()
{
return NormalizeToCutOrigin(BuildParts(Candidate.Drawing));
}
public List<Part> BuildSourceParts(Drawing drawing)
{
var parts = BuildCanonicalParts();
var sourceAngle = drawing?.Source?.Angle ?? 0.0;
for (var i = 0; i < parts.Count; i++)
{
var p = parts[i];
var rebound = Part.CreateAtOrigin(drawing, p.Rotation);
var delta = p.BoundingBox.Location - rebound.BoundingBox.Location;
rebound.Offset(delta);
rebound.UpdateBounds();
parts[i] = rebound;
}
return NormalizeToCutOrigin(CanonicalFrame.FromCanonical(parts, sourceAngle));
}
public Box GetCutBounds(List<Part> parts)
{
return GetCutBoundingBox(parts);
}
private static List<Part> NormalizeToCutOrigin(List<Part> parts)
{
if (parts == null || parts.Count == 0)
return parts;
var bounds = GetCutBoundingBox(parts);
var offset = new Vector(-bounds.Left, -bounds.Bottom);
foreach (var part in parts)
part.Offset(offset);
return parts;
}
private static Box GetCutBoundingBox(List<Part> parts)
{
var entities = new List<IBoundable>();
foreach (var part in parts)
{
var partEntities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
foreach (var entity in partEntities)
{
entity.Offset(part.Location);
entities.Add(entity);
}
}
return entities.GetBoundingBox();
}
} }
public enum BestFitSortField public enum BestFitSortField
+36 -3
View File
@@ -1,6 +1,7 @@
using OpenNest.Geometry; using OpenNest.Geometry;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using OpenNest.Math;
namespace OpenNest.Engine.Fill namespace OpenNest.Engine.Fill
{ {
@@ -14,7 +15,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction) public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return Push(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
@@ -26,7 +27,7 @@ namespace OpenNest.Engine.Fill
public static double Push(List<Part> movingParts, Plate plate, double angle) public static double Push(List<Part> movingParts, Plate plate, double angle)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle)); var direction = new Vector(System.Math.Cos(angle), System.Math.Sin(angle));
@@ -99,6 +100,13 @@ namespace OpenNest.Engine.Fill
: PartGeometry.GetPerimeterEntities(obstacleParts[i]); : PartGeometry.GetPerimeterEntities(obstacleParts[i]);
var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction); var d = SpatialQuery.DirectionalDistance(movingEntities, obstacleEntities[i], direction);
if (d <= Tolerance.Epsilon
&& partSpacing <= Tolerance.Epsilon
&& CanNudgeWithoutOverlap(moving, obstacleParts[i], direction))
{
continue;
}
if (d < distance) if (d < distance)
distance = d; distance = d;
} }
@@ -115,6 +123,31 @@ namespace OpenNest.Engine.Fill
return 0; return 0;
} }
private static bool IntersectsAny(Part candidate, List<Part> parts)
{
for (var i = 0; i < parts.Count; i++)
{
if (candidate.Intersects(parts[i], out _))
return true;
}
return false;
}
private static bool CanNudgeWithoutOverlap(Part moving, Part obstacle, Vector direction)
{
var nudge = direction * (Tolerance.Epsilon * 10);
moving.Offset(nudge);
try
{
return !moving.Intersects(obstacle, out _);
}
finally
{
moving.Offset(-nudge);
}
}
public static double Push(List<Part> movingParts, List<Part> obstacleParts, public static double Push(List<Part> movingParts, List<Part> obstacleParts,
Box workArea, double partSpacing, PushDirection direction) Box workArea, double partSpacing, PushDirection direction)
{ {
@@ -130,7 +163,7 @@ namespace OpenNest.Engine.Fill
public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction) public static double PushBoundingBox(List<Part> movingParts, Plate plate, PushDirection direction)
{ {
var obstacleParts = plate.Parts var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p)) .Where(p => !movingParts.Contains(p) && !IntersectsAny(p, movingParts))
.ToList(); .ToList();
return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction); return PushBoundingBox(movingParts, obstacleParts, plate.WorkArea(), plate.PartSpacing, direction);
+7 -2
View File
@@ -42,6 +42,11 @@ namespace OpenNest.IO.Bom
var nameWithoutExt = Path.GetFileNameWithoutExtension(file); var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles[nameWithoutExt] = file; dxfFiles[nameWithoutExt] = file;
} }
foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg"))
{
var nameWithoutExt = Path.GetFileNameWithoutExtension(file);
dxfFiles.TryAdd(nameWithoutExt, file);
}
} }
// Partition items into: skipped, unmatched, or matched (grouped) // Partition items into: skipped, unmatched, or matched (grouped)
@@ -57,8 +62,8 @@ namespace OpenNest.IO.Bom
var lookupName = item.FileName; var lookupName = item.FileName;
// Strip .dxf extension if the BOM includes it if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)) || lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName); lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (!folderExists) if (!folderExists)
+7
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using ACadSharp;
using OpenNest.Bending; using OpenNest.Bending;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -38,5 +39,11 @@ namespace OpenNest.IO
/// Default drawing name (filename without extension, unless overridden). /// Default drawing name (filename without extension, unless overridden).
/// </summary> /// </summary>
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// The raw CAD document from the source file. Available for callers
/// that need access to non-geometry entities (e.g., text annotations).
/// </summary>
public CadDocument Document { get; set; }
} }
} }
+8
View File
@@ -27,6 +27,7 @@ namespace OpenNest.IO
var dxf = Dxf.Import(path); var dxf = Dxf.Import(path);
RemoveDuplicateArcs(dxf.Entities); RemoveDuplicateArcs(dxf.Entities);
RemoveZeroSweepArcs(dxf.Entities);
var bends = new List<Bend>(); var bends = new List<Bend>();
if (options.DetectBends && dxf.Document != null) if (options.DetectBends && dxf.Document != null)
@@ -47,6 +48,7 @@ namespace OpenNest.IO
Bounds = dxf.Entities.GetBoundingBox(), Bounds = dxf.Entities.GetBoundingBox(),
SourcePath = path, SourcePath = path,
Name = options.Name ?? Path.GetFileNameWithoutExtension(path), Name = options.Name ?? Path.GetFileNameWithoutExtension(path),
Document = dxf.Document,
}; };
} }
@@ -140,6 +142,12 @@ namespace OpenNest.IO
return drawing; return drawing;
} }
internal static void RemoveZeroSweepArcs(List<Entity> entities)
{
entities.RemoveAll(e =>
e is Arc arc && arc.StartAngle.IsEqualTo(arc.EndAngle, Tolerance.ChainTolerance));
}
internal static void RemoveDuplicateArcs(List<Entity> entities) internal static void RemoveDuplicateArcs(List<Entity> entities)
{ {
var circles = entities.OfType<Circle>().ToList(); var circles = entities.OfType<Circle>().ToList();
+369
View File
@@ -0,0 +1,369 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.Geometry;
namespace OpenNest.IO
{
public class ChrFont
{
private readonly Dictionary<int, ChrGlyph> glyphs = new();
public string Name { get; internal set; }
public string Version { get; internal set; }
public double CapHeight { get; internal set; } = 5000;
internal void AddGlyph(int charCode, ChrGlyph glyph)
{
glyphs[charCode] = glyph;
}
public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode);
public ChrGlyph GetGlyph(int charCode) =>
glyphs.TryGetValue(charCode, out var g) ? g : null;
public double MeasureTextWidth(string text, double height)
{
var scale = height / CapHeight;
double width = 0;
foreach (var ch in text)
{
var glyph = GetGlyph(ch);
if (glyph == null)
{
var space = GetGlyph(' ');
width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
continue;
}
width += glyph.AdvanceWidth * scale;
}
return width;
}
public List<Entity> RenderText(string text, double height, Vector position, Layer layer = null)
{
var scale = height / CapHeight;
var entities = new List<Entity>();
var cursorX = position.X;
foreach (var ch in text)
{
var glyph = GetGlyph(ch);
if (glyph == null)
{
var space = GetGlyph(' ');
cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
continue;
}
var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer);
entities.AddRange(glyphEntities);
cursorX += glyph.AdvanceWidth * scale;
}
return entities;
}
public static ChrFont Read(string path, byte? xorKey = null)
{
var raw = File.ReadAllBytes(path);
// The whole file is obfuscated with a single-byte XOR. Different
// GravoStyle versions use different keys (0x2F in older releases,
// 0xCF in 7000-series). The font name at offset 0 is ASCII stored
// as UTF-16LE, so the high byte of its first character is 0x00 in
// plaintext — which means raw[1] is exactly the XOR key. Detect it
// from the file unless the caller forces a specific key.
var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F);
var data = new byte[raw.Length];
for (var i = 0; i < raw.Length; i++)
data[i] = (byte)(raw[i] ^ key);
return Parse(data);
}
private static ChrFont Parse(byte[] data)
{
var font = new ChrFont();
font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim();
font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim();
var charTable = new List<(int charCode, int offset)>();
var i = 0x40;
while (i + 5 < data.Length)
{
var charCode = data[i] | (data[i + 1] << 8);
var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24);
if (charCode < 0x20 || offset == 0 || offset >= data.Length)
break;
charTable.Add((charCode, offset));
i += 6;
}
for (var c = 0; c < charTable.Count; c++)
{
var (charCode, offset) = charTable[c];
var nextOffset = c + 1 < charTable.Count
? FindNextOffset(charTable, offset, data.Length)
: data.Length;
var glyph = ParseGlyph(data, offset, nextOffset);
if (glyph != null)
font.AddGlyph(charCode, glyph);
}
if (font.glyphs.Count > 0)
{
foreach (var g in font.glyphs.Values)
{
if (g.CapHeight > 0)
{
font.CapHeight = g.CapHeight;
break;
}
}
}
return font;
}
private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength)
{
var best = fileLength;
foreach (var (_, off) in table)
{
if (off > currentOffset && off < best)
best = off;
}
return best;
}
private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset)
{
if (offset + 92 > data.Length)
return null;
var glyph = new ChrGlyph();
glyph.CapHeight = ReadBE16(data, offset + 15 * 2);
var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2));
glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing;
var strokeStart = offset + 92;
var pos = strokeStart;
var currentStroke = new List<ChrStrokePoint>();
while (pos + 5 < endOffset)
{
var cmd = ReadBE16(data, pos);
var x = ReadBE16(data, pos + 2);
var y = ReadBE16(data, pos + 4);
pos += 6;
if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000)
break;
if (cmd < -1000)
break;
var type = cmd switch
{
1 => ChrPointType.Vertex,
4 => ChrPointType.Control,
5 => ChrPointType.EndPoint,
_ => ChrPointType.Vertex,
};
currentStroke.Add(new ChrStrokePoint(type, x, y));
if (type == ChrPointType.EndPoint)
{
if (currentStroke.Count > 0)
glyph.Strokes.Add(currentStroke);
currentStroke = new List<ChrStrokePoint>();
}
}
if (currentStroke.Count > 0)
glyph.Strokes.Add(currentStroke);
return glyph;
}
private static int ReadBE16(byte[] data, int offset)
{
var val = (data[offset] << 8) | data[offset + 1];
if (val > 32767) val -= 65536;
return val;
}
}
internal enum ChrPointType
{
Vertex,
Control,
EndPoint,
}
internal struct ChrStrokePoint
{
public ChrPointType Type;
public double X;
public double Y;
public ChrStrokePoint(ChrPointType type, double x, double y)
{
Type = type;
X = x;
Y = y;
}
}
public class ChrGlyph
{
internal readonly List<List<ChrStrokePoint>> Strokes = new();
public double AdvanceWidth { get; internal set; }
public double CapHeight { get; internal set; }
private const int ArcSamples = 16;
public List<Entity> ToEntities(double scale, double offsetX, double offsetY, Layer layer = null)
{
var entities = new List<Entity>();
layer ??= Layer.Default;
foreach (var stroke in Strokes)
{
if (stroke.Count < 2) continue;
var segments = BuildSegments(stroke);
foreach (var seg in segments)
{
if (seg.Points.Count < 2) continue;
var scaled = new List<Vector>(seg.Points.Count);
foreach (var pt in seg.Points)
scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY));
var converted = PointsToLines(scaled);
foreach (var e in converted)
{
e.Layer = layer;
entities.Add(e);
}
}
}
return entities;
}
private static List<Entity> PointsToLines(List<Vector> points)
{
var entities = new List<Entity>();
for (var i = 0; i < points.Count - 1; i++)
{
if (points[i].DistanceTo(points[i + 1]) < 0.001)
continue;
entities.Add(new Line(points[i], points[i + 1]));
}
return entities;
}
private static List<StrokeSegment> BuildSegments(List<ChrStrokePoint> stroke)
{
var segments = new List<StrokeSegment>();
var current = new StrokeSegment();
var i = 0;
while (i < stroke.Count)
{
var pt = stroke[i];
if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint)
{
if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control)
{
var p0 = new Vector(pt.X, pt.Y);
var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y);
var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1];
var p1 = new Vector(p2End.X, p2End.Y);
SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples);
current.HasCurves = true;
i += 2;
}
else
{
current.Points.Add(new Vector(pt.X, pt.Y));
i++;
}
}
else
{
i++;
}
}
if (current.Points.Count >= 2)
segments.Add(current);
return segments;
}
private class StrokeSegment
{
public readonly List<Vector> Points = new();
public bool HasCurves;
}
private static void SampleCircularArc(List<Vector> output, Vector p0, Vector pMid, Vector p1, int samples)
{
if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01)
output.Add(p0);
double ax = p0.X, ay = p0.Y;
double bx = pMid.X, by = pMid.Y;
double cx = p1.X, cy = p1.Y;
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (System.Math.Abs(d) < 1e-6)
{
output.Add(pMid);
output.Add(p1);
return;
}
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
var a0 = System.Math.Atan2(ay - uy, ax - ux);
var am = System.Math.Atan2(by - uy, bx - ux);
var a1 = System.Math.Atan2(cy - uy, cx - ux);
var ccwSweep = a1 - a0;
while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI;
var midRel = am - a0;
while (midRel < 0) midRel += 2 * System.Math.PI;
var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI;
for (var i = 1; i <= samples; i++)
{
var t = (double)i / samples;
var angle = a0 + sweep * t;
output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle)));
}
}
}
}
+58 -7
View File
@@ -27,8 +27,7 @@ namespace OpenNest.IO
/// </summary> /// </summary>
public static DxfImportResult Import(string path) public static DxfImportResult Import(string path)
{ {
using var reader = new DxfReader(path); var doc = ReadDocument(path);
var doc = reader.Read();
return new DxfImportResult return new DxfImportResult
{ {
@@ -41,8 +40,7 @@ namespace OpenNest.IO
{ {
try try
{ {
using var reader = new DxfReader(path); var doc = ReadDocument(path);
var doc = reader.Read();
return ConvertEntities(doc); return ConvertEntities(doc);
} }
catch (Exception ex) catch (Exception ex)
@@ -67,6 +65,36 @@ namespace OpenNest.IO
} }
} }
public static List<Entity> GetGeometry(string path, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(path);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
public static List<Entity> GetGeometry(Stream stream, Func<string, bool> layerFilter)
{
try
{
using var reader = new DxfReader(stream);
var doc = reader.Read();
return ConvertEntities(doc, layerFilter);
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
return new List<Entity>();
}
}
#endregion #endregion
#region Export #region Export
@@ -113,15 +141,34 @@ namespace OpenNest.IO
#region Private #region Private
private static List<Entity> ConvertEntities(CadDocument doc) private static bool IsDwg(string path) =>
Path.GetExtension(path).Equals(".dwg", StringComparison.OrdinalIgnoreCase);
private static CadDocument ReadDocument(string path)
{
if (IsDwg(path))
{
using var reader = new DwgReader(path);
return reader.Read();
}
else
{
using var reader = new DxfReader(path);
return reader.Read();
}
}
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
{ {
var entities = new List<Entity>(); var entities = new List<Entity>();
var lines = new List<Line>(); var lines = new List<Line>();
var arcs = new List<Arc>(); var arcs = new List<Arc>();
var circles = new List<Circle>();
var filter = layerFilter ?? IsNonCutLayer;
foreach (var entity in doc.Entities) foreach (var entity in doc.Entities)
{ {
if (IsNonCutLayer(entity.Layer?.Name)) if (filter(entity.Layer?.Name))
continue; continue;
switch (entity) switch (entity)
@@ -135,7 +182,7 @@ namespace OpenNest.IO
break; break;
case ACadSharp.Entities.Circle circle: case ACadSharp.Entities.Circle circle:
entities.Add(circle.ToOpenNest()); circles.Add(circle.ToOpenNest());
break; break;
case ACadSharp.Entities.Spline spline: case ACadSharp.Entities.Spline spline:
@@ -166,7 +213,10 @@ namespace OpenNest.IO
GeometryOptimizer.Optimize(lines); GeometryOptimizer.Optimize(lines);
GeometryOptimizer.Optimize(arcs); GeometryOptimizer.Optimize(arcs);
GeometryOptimizer.Deduplicate(circles);
GeometryOptimizer.Deduplicate(circles, arcs);
entities.AddRange(circles);
entities.AddRange(lines); entities.AddRange(lines);
entities.AddRange(arcs); entities.AddRange(arcs);
@@ -192,6 +242,7 @@ namespace OpenNest.IO
public ExportContext() public ExportContext()
{ {
Document = new CadDocument(); Document = new CadDocument();
Document.Header.Version = ACadVersion.AC1018;
CutLayer = new Layer("Cut") { Color = new Color(1) }; CutLayer = new Layer("Cut") { Color = new Color(1) };
RapidLayer = new Layer("Rapid") { Color = new Color(5) }; 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,72 @@
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
namespace OpenNest.Tests.BestFit;
public class BestFitResultFrameTests
{
[Fact]
public void BuildCanonicalParts_NonAxisAlignedPairNormalizesActualBounds()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
Assert.True(IsNonAxisAligned(result.OptimalRotation),
$"Expected a non-axis-aligned result, got {Angle.ToDegrees(result.OptimalRotation):F2} degrees.");
var parts = result.BuildCanonicalParts();
var bounds = result.GetCutBounds(parts);
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.Equal(result.BoundingWidth, bounds.Length, 2);
Assert.Equal(result.BoundingHeight, bounds.Width, 2);
}
[Fact]
public void BuildSourceParts_RebindsCanonicalResultToRotatedSourceDrawing()
{
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
drawing.RecomputeCanonicalAngle();
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
var result = EvaluateOffsetPair(canonical, new Vector(40, 30));
var parts = result.BuildSourceParts(drawing);
var bounds = result.GetCutBounds(parts);
Assert.All(parts, p => Assert.Same(drawing, p.BaseDrawing));
Assert.Equal(0, bounds.Left, 3);
Assert.Equal(0, bounds.Bottom, 3);
Assert.False(parts[0].Intersects(parts[1], out _));
}
private static BestFitResult EvaluateOffsetPair(Drawing drawing, Vector offset)
{
var candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = System.Math.PI,
Part2Offset = offset,
Spacing = 0.25
};
return new PairEvaluator().Evaluate(candidate);
}
private static bool IsNonAxisAligned(double angle)
{
var normalized = Angle.NormalizeRad(angle);
var nearestQuadrant = Angle.HalfPI * System.Math.Round(normalized / Angle.HalfPI);
var delta = System.Math.Abs(normalized - nearestQuadrant);
delta = System.Math.Min(delta, Angle.HalfPI - delta);
return delta > Angle.ToRadians(1);
}
}
@@ -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));
}
}
+107
View File
@@ -97,6 +97,33 @@ namespace OpenNest.Tests.Fill
return part; return part;
} }
private static Drawing MakeTriangleDrawing(params Vector[] points)
{
var pgm = new OpenNest.CNC.Program();
pgm.Codes.Add(new OpenNest.CNC.RapidMove(points[0]));
for (var i = 1; i < points.Length; i++)
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[i]));
pgm.Codes.Add(new OpenNest.CNC.LinearMove(points[0]));
return new Drawing("triangle", pgm);
}
private static Part MakeTrianglePart(params Vector[] points)
{
var part = new Part(MakeTriangleDrawing(points));
part.UpdateBounds();
return part;
}
private static Part MakeTrianglePart(double x, double y, params Vector[] points)
{
var part = MakeTrianglePart(points);
part.Location = new Vector(x, y);
part.UpdateBounds();
return part;
}
[Fact] [Fact]
public void Push_Left_MovesPartTowardEdge() public void Push_Left_MovesPartTowardEdge()
{ {
@@ -171,6 +198,86 @@ namespace OpenNest.Tests.Fill
Assert.NotEqual(distNoSpacing, distWithSpacing); Assert.NotEqual(distNoSpacing, distWithSpacing);
} }
[Fact]
public void Push_Up_AllowsSharedDiagonalEdgeToSeparate()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Up);
Assert.True(distance > 0);
Assert.True(movingPart.BoundingBox.Top > 19.9);
Assert.False(movingPart.Intersects(obstacle, out _));
}
[Fact]
public void Push_Up_MovesAfterRightTriangleIsPushedLeftIntoSharedEdge()
{
var workArea = new Box(0, 0, 24, 24);
var leftTriangle = MakeTrianglePart(
2, 2,
new Vector(0, 0),
new Vector(8, 0),
new Vector(4, 10));
var rightTriangle = MakeTrianglePart(
14, 4,
new Vector(0, 10),
new Vector(8, 10),
new Vector(4, 0));
var moving = new List<Part> { rightTriangle };
var obstacles = new List<Part> { leftTriangle };
var leftDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Left);
var yBeforePushUp = rightTriangle.Location.Y;
var bottomBeforePushUp = rightTriangle.BoundingBox.Bottom;
var upDistance = Compactor.Push(moving, obstacles, workArea, 0, PushDirection.Up);
Assert.True(leftDistance > 0);
Assert.True(upDistance > 0);
Assert.True(rightTriangle.Location.Y > yBeforePushUp);
Assert.True(rightTriangle.BoundingBox.Bottom > bottomBeforePushUp);
Assert.False(rightTriangle.Intersects(leftTriangle, out _));
}
[Fact]
public void Push_Left_BlocksWhenSharedDiagonalEdgeWouldOverlap()
{
var workArea = new Box(0, 0, 20, 20);
var obstacle = MakeTrianglePart(
new Vector(0, 0),
new Vector(10, 0),
new Vector(0, 10));
var movingPart = MakeTrianglePart(
new Vector(0, 10),
new Vector(10, 0),
new Vector(10, 10));
var distance = Compactor.Push(
new List<Part> { movingPart },
new List<Part> { obstacle },
workArea,
0,
PushDirection.Left);
Assert.Equal(0, distance);
Assert.Equal(0, movingPart.BoundingBox.Left);
}
[Fact] [Fact]
public void Push_AngleLeft_MovesPartTowardEdge() public void Push_AngleLeft_MovesPartTowardEdge()
{ {
@@ -131,6 +131,111 @@ public class GeometrySimplifierTests
Assert.IsType<Arc>(result.Entities[6]); 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] [Fact]
public void Apply_DynaPanDxf_NoGapsAfterSimplification() 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="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" /> <PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -27,6 +28,7 @@
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" /> <ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" /> <ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" /> <ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
<ProjectReference Include="..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
<ProjectReference Include="..\OpenNest\OpenNest.csproj" /> <ProjectReference Include="..\OpenNest\OpenNest.csproj" />
</ItemGroup> </ItemGroup>
@@ -37,6 +39,9 @@
<Content Include="Splitting\TestData\**\*"> <Content Include="Splitting\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="test-config.json" Condition="Exists('test-config.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
</Project> </Project>
+29
View File
@@ -1,8 +1,37 @@
using System.Text.Json;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Tests; 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 internal static class TestHelpers
{ {
public static Part MakePartAt(double x, double y, double size = 1) public static Part MakePartAt(double x, double y, double size = 1)
+4 -2
View File
@@ -89,8 +89,10 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
new Size(48, 24), new Size(120, 10) new Size(48, 24), new Size(120, 10)
}; };
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories)
Console.WriteLine($"Found {dxfFiles.Length} DXF files"); .Concat(Directory.GetFiles(dir, "*.dwg", SearchOption.AllDirectories))
.ToArray();
Console.WriteLine($"Found {dxfFiles.Length} CAD files");
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db"; var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}"); Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
+15
View File
@@ -30,6 +30,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PostProcessors", "PostProce
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", "OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj", "{FB1B2EB2-9D80-4499-BA93-B4E2F295A532}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}"
EndProject EndProject
Global Global
@@ -186,12 +188,25 @@ Global
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x64.Build.0 = Release|Any CPU {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.ActiveCfg = Release|Any CPU
{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Release|x86.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E} {FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C} SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C}
-18
View File
@@ -16,15 +16,11 @@ namespace OpenNest.Actions
private CutOffSettings settings; private CutOffSettings settings;
private CutOffAxis lockedAxis = CutOffAxis.Vertical; private CutOffAxis lockedAxis = CutOffAxis.Vertical;
private Dictionary<Part, Entity> perimeterCache; private Dictionary<Part, Entity> perimeterCache;
private readonly Timer debounceTimer;
private bool regeneratePending;
public ActionCutOff(PlateView plateView) public ActionCutOff(PlateView plateView)
: base(plateView) : base(plateView)
{ {
settings = plateView.CutOffSettings; settings = plateView.CutOffSettings;
debounceTimer = new Timer { Interval = 16 };
debounceTimer.Tick += OnDebounce;
ConnectEvents(); ConnectEvents();
} }
@@ -40,8 +36,6 @@ namespace OpenNest.Actions
public override void DisconnectEvents() public override void DisconnectEvents()
{ {
debounceTimer.Stop();
debounceTimer.Dispose();
plateView.MouseMove -= OnMouseMove; plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown; plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown; plateView.KeyDown -= OnKeyDown;
@@ -58,18 +52,6 @@ namespace OpenNest.Actions
private void OnMouseMove(object sender, MouseEventArgs e) private void OnMouseMove(object sender, MouseEventArgs e)
{ {
regeneratePending = true;
debounceTimer.Start();
}
private void OnDebounce(object sender, System.EventArgs e)
{
debounceTimer.Stop();
if (!regeneratePending)
return;
regeneratePending = false;
var pt = plateView.CurrentPoint; var pt = plateView.CurrentPoint;
previewCutOff = new CutOff(pt, lockedAxis); previewCutOff = new CutOff(pt, lockedAxis);
previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache); previewCutOff.Regenerate(plateView.Plate, settings, perimeterCache);
+16
View File
@@ -0,0 +1,16 @@
using System.Drawing;
using OpenNest.Geometry;
namespace OpenNest.Controls
{
public class CadText
{
public Vector Position { get; set; }
public string Value { get; set; }
public double Height { get; set; }
public double Rotation { get; set; }
public string LayerName { get; set; }
public StringAlignment HAlign { get; set; }
public StringAlignment VAlign { get; set; }
}
}
+76
View File
@@ -29,15 +29,18 @@ namespace OpenNest.Controls
public List<Entity> SimplifierToleranceRight { get; set; } public List<Entity> SimplifierToleranceRight { get; set; }
public List<Entity> OriginalEntities { get; set; } public List<Entity> OriginalEntities { get; set; }
public bool ShowEntityLabels { get; set; } public bool ShowEntityLabels { get; set; }
public List<CadText> Texts { get; set; } = new List<CadText>();
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70)); private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>(); private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
private readonly Font labelFont = new Font("Segoe UI", 7f); private readonly Font labelFont = new Font("Segoe UI", 7f);
private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200)); private readonly SolidBrush labelBrush = new SolidBrush(Color.FromArgb(220, 255, 255, 200));
private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48)); private readonly SolidBrush labelBackBrush = new SolidBrush(Color.FromArgb(33, 40, 48));
private readonly SolidBrush textBrush = new SolidBrush(Color.FromArgb(180, 200, 200, 200));
public event EventHandler<Line> LinePicked; public event EventHandler<Line> LinePicked;
public event EventHandler PickCancelled; public event EventHandler PickCancelled;
public event EventHandler<CadText> TextConvertRequested;
private bool isPickingBendLine; private bool isPickingBendLine;
public bool IsPickingBendLine public bool IsPickingBendLine
@@ -74,6 +77,13 @@ namespace OpenNest.Controls
if (line != null) if (line != null)
LinePicked?.Invoke(this, line); 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) protected override void OnPaint(PaintEventArgs e)
@@ -116,6 +126,8 @@ namespace OpenNest.Controls
DrawEntity(e.Graphics, entity, pen); DrawEntity(e.Graphics, entity, pen);
} }
DrawTexts(e.Graphics);
if (ShowEntityLabels) if (ShowEntityLabels)
DrawEntityLabels(e.Graphics); DrawEntityLabels(e.Graphics);
@@ -324,6 +336,41 @@ namespace OpenNest.Controls
return bestLine; 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) private void DrawEntityLabels(Graphics g)
{ {
for (var i = 0; i < Entities.Count; i++) for (var i = 0; i < Entities.Count; i++)
@@ -408,6 +455,7 @@ namespace OpenNest.Controls
labelFont.Dispose(); labelFont.Dispose();
labelBrush.Dispose(); labelBrush.Dispose();
labelBackBrush.Dispose(); labelBackBrush.Dispose();
textBrush.Dispose();
} }
base.Dispose(disposing); base.Dispose(disposing);
} }
@@ -474,6 +522,34 @@ namespace OpenNest.Controls
diameter); diameter);
} }
private void DrawTexts(Graphics g)
{
if (Texts == null || Texts.Count == 0)
return;
using var sf = new StringFormat();
foreach (var text in Texts)
{
var pos = PointWorldToGraph(text.Position);
var fontSize = LengthWorldToGui(text.Height);
if (fontSize < 2f) continue;
var state = g.Save();
g.TranslateTransform(pos.X, pos.Y);
if (text.Rotation != 0)
g.RotateTransform((float)OpenNest.Math.Angle.ToDegrees(text.Rotation));
sf.Alignment = text.HAlign;
sf.LineAlignment = text.VAlign;
using var font = new Font("Segoe UI", fontSize, GraphicsUnit.Pixel);
g.DrawString(text.Value, font, textBrush, 0, 0, sf);
g.Restore(state);
}
}
private void DrawPoint(Graphics g, Vector pt, Pen pen) private void DrawPoint(Graphics g, Vector pt, Pen pen)
{ {
var pt1 = PointWorldToGraph(pt); var pt1 = PointWorldToGraph(pt);
+1
View File
@@ -22,6 +22,7 @@ namespace OpenNest.Controls
public HashSet<Guid> SuppressedEntityIds { get; set; } public HashSet<Guid> SuppressedEntityIds { get; set; }
public Box Bounds { get; set; } public Box Bounds { get; set; }
public int EntityCount { get; set; } public int EntityCount { get; set; }
public List<CadText> Texts { get; set; } = new();
} }
public class FileListControl : Control public class FileListControl : Control
+4 -2
View File
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
public BestFitResult SelectedResult { get; private set; } public BestFitResult SelectedResult { get; private set; }
public Drawing SelectedDrawing => activeDrawing; public Drawing SelectedDrawing => activeDrawing;
public List<Part> SelectedParts { get; private set; }
public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches) public BestFitViewerForm(DrawingCollection drawings, Plate plate, Units units = Units.Inches)
{ {
@@ -318,12 +319,12 @@ namespace OpenNest.Forms
var cell = new BestFitCell(colorScheme); var cell = new BestFitCell(colorScheme);
cell.PartColor = partColor; cell.PartColor = partColor;
cell.Dock = DockStyle.Fill; cell.Dock = DockStyle.Fill;
var parts = result.BuildCanonicalParts();
cell.Plate.Size = new Geometry.Size( cell.Plate.Size = new Geometry.Size(
result.BoundingHeight, result.BoundingHeight,
result.BoundingWidth); result.BoundingWidth);
var parts = result.BuildParts(drawing);
foreach (var part in parts) foreach (var part in parts)
cell.Plate.Parts.Add(part); cell.Plate.Parts.Add(part);
@@ -332,6 +333,7 @@ namespace OpenNest.Forms
cell.DoubleClick += (sender, e) => cell.DoubleClick += (sender, e) =>
{ {
SelectedResult = result; SelectedResult = result;
SelectedParts = result.BuildSourceParts(drawing);
DialogResult = DialogResult.OK; DialogResult = DialogResult.OK;
Close(); Close();
}; };
+2 -1
View File
@@ -165,7 +165,8 @@ namespace OpenNest.Forms
else else
{ {
var lookupName = item.FileName; var lookupName = item.FileName;
if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)) if (lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase))
lookupName = Path.GetFileNameWithoutExtension(lookupName); lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (matchedPaths.TryGetValue(lookupName, out var dxfPath)) if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
+219 -2
View File
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
filterPanel.AddBendLineClicked += OnAddBendLineClicked; filterPanel.AddBendLineClicked += OnAddBendLineClicked;
entityView1.LinePicked += OnLinePicked; entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled; entityView1.PickCancelled += OnPickCancelled;
entityView1.TextConvertRequested += OnTextConvertRequested;
btnSplit.Click += OnSplitClicked; btnSplit.Click += OnSplitClicked;
numQuantity.ValueChanged += OnQuantityChanged; numQuantity.ValueChanged += OnQuantityChanged;
txtCustomer.TextChanged += OnCustomerChanged; txtCustomer.TextChanged += OnCustomerChanged;
@@ -92,7 +93,8 @@ namespace OpenNest.Forms
Customer = string.Empty, Customer = string.Empty,
Bends = result.Bends, Bends = result.Bends,
Bounds = result.Bounds, Bounds = result.Bounds,
EntityCount = result.Entities.Count EntityCount = result.Entities.Count,
Texts = ExtractTexts(result.Document),
}; };
if (InvokeRequired) if (InvokeRequired)
@@ -152,6 +154,7 @@ namespace OpenNest.Forms
entityView1.Entities.Clear(); entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities); entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends ?? new List<Bend>(); entityView1.Bends = item.Bends ?? new List<Bend>();
entityView1.Texts = item.Texts ?? new List<CadText>();
item.Entities.ForEach(e => e.IsVisible = true); item.Entities.ForEach(e => e.IsVisible = true);
if (item.Entities.Any(e => e.Layer != null)) if (item.Entities.Any(e => e.Layer != null))
@@ -461,6 +464,115 @@ namespace OpenNest.Forms
filterPanel.SetPickMode(false); 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) private void OnDragEnter(object sender, DragEventArgs e)
{ {
if (e.Data.GetDataPresent(DataFormats.FileDrop)) if (e.Data.GetDataPresent(DataFormats.FileDrop))
@@ -473,7 +585,8 @@ namespace OpenNest.Forms
{ {
var files = (string[])e.Data.GetData(DataFormats.FileDrop); var files = (string[])e.Data.GetData(DataFormats.FileDrop);
var dxfFiles = files.Where(f => var dxfFiles = files.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToArray(); f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) ||
f.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)).ToArray();
if (dxfFiles.Length > 0) if (dxfFiles.Length > 0)
AddFiles(dxfFiles); AddFiles(dxfFiles);
} }
@@ -803,6 +916,110 @@ namespace OpenNest.Forms
#endregion #endregion
private static List<CadText> ExtractTexts(ACadSharp.CadDocument doc)
{
var texts = new List<CadText>();
if (doc == null) return texts;
foreach (var entity in doc.Entities)
{
switch (entity)
{
case ACadSharp.Entities.MText mtext:
var (mh, mv) = MapAttachmentPoint(mtext.AttachmentPoint);
texts.Add(new CadText
{
Position = new Vector(mtext.InsertPoint.X, mtext.InsertPoint.Y),
Value = ReplaceControlCodes(StripMTextFormatting(mtext.Value)),
Height = mtext.Height,
Rotation = mtext.Rotation,
LayerName = mtext.Layer?.Name,
HAlign = mh,
VAlign = mv,
});
break;
case ACadSharp.Entities.TextEntity text:
var useAlignment = text.HorizontalAlignment != 0
|| text.VerticalAlignment != 0;
var pt = useAlignment ? text.AlignmentPoint : text.InsertPoint;
var ha = text.HorizontalAlignment switch
{
ACadSharp.Entities.TextHorizontalAlignment.Center => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.TextHorizontalAlignment.Right => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
var va = text.VerticalAlignment switch
{
ACadSharp.Entities.TextVerticalAlignmentType.Middle => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.TextVerticalAlignmentType.Top => System.Drawing.StringAlignment.Near,
ACadSharp.Entities.TextVerticalAlignmentType.Bottom => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Far,
};
texts.Add(new CadText
{
Position = new Vector(pt.X, pt.Y),
Value = ReplaceControlCodes(text.Value),
Height = text.Height,
Rotation = text.Rotation,
LayerName = text.Layer?.Name,
HAlign = ha,
VAlign = va,
});
break;
}
}
return texts;
}
private static (System.Drawing.StringAlignment h, System.Drawing.StringAlignment v) MapAttachmentPoint(
ACadSharp.Entities.AttachmentPointType apt)
{
var h = apt switch
{
ACadSharp.Entities.AttachmentPointType.TopCenter
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
or ACadSharp.Entities.AttachmentPointType.BottomCenter => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.AttachmentPointType.TopRight
or ACadSharp.Entities.AttachmentPointType.MiddleRight
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
var v = apt switch
{
ACadSharp.Entities.AttachmentPointType.MiddleLeft
or ACadSharp.Entities.AttachmentPointType.MiddleCenter
or ACadSharp.Entities.AttachmentPointType.MiddleRight => System.Drawing.StringAlignment.Center,
ACadSharp.Entities.AttachmentPointType.BottomLeft
or ACadSharp.Entities.AttachmentPointType.BottomCenter
or ACadSharp.Entities.AttachmentPointType.BottomRight => System.Drawing.StringAlignment.Far,
_ => System.Drawing.StringAlignment.Near,
};
return (h, v);
}
private static string StripMTextFormatting(string text)
{
if (string.IsNullOrEmpty(text)) return text;
var result = System.Text.RegularExpressions.Regex.Replace(text, @"\\[A-Za-z][^;]*;", "");
result = result.Replace("{", "").Replace("}", "");
return result.Trim();
}
private static string ReplaceControlCodes(string text)
{
if (string.IsNullOrEmpty(text)) return text;
return text
.Replace("%%p", "±")
.Replace("%%P", "±")
.Replace("%%d", "°")
.Replace("%%D", "°")
.Replace("%%c", "⌀")
.Replace("%%C", "⌀")
.Replace("%%%", "%");
}
private void filterPanel_Paint(object sender, PaintEventArgs e) private void filterPanel_Paint(object sender, PaintEventArgs e)
{ {
+1 -2
View File
@@ -329,7 +329,7 @@ namespace OpenNest.Forms
{ {
var dlg = new OpenFileDialog(); var dlg = new OpenFileDialog();
dlg.Multiselect = true; dlg.Multiselect = true;
dlg.Filter = "DXF Files (*.dxf) | *.dxf"; dlg.Filter = "CAD Files (*.dxf;*.dwg)|*.dxf;*.dwg|DXF Files (*.dxf)|*.dxf|DWG Files (*.dwg)|*.dwg";
if (dlg.ShowDialog() != DialogResult.OK) if (dlg.ShowDialog() != DialogResult.OK)
return; return;
@@ -346,7 +346,6 @@ namespace OpenNest.Forms
drawings.ForEach(d => Nest.Drawings.Add(d)); drawings.ForEach(d => Nest.Drawings.Add(d));
UpdateDrawingList(); UpdateDrawingList();
tabControl1.SelectedIndex = 1;
} }
public bool Export() public bool Export()
+2 -1
View File
@@ -686,7 +686,8 @@ namespace OpenNest.Forms
{ {
if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null) if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null)
{ {
var parts = form.SelectedResult.BuildParts(form.SelectedDrawing); var parts = form.SelectedParts
?? form.SelectedResult.BuildSourceParts(form.SelectedDrawing);
activeForm.PlateView.SetAction(typeof(ActionClone), parts); activeForm.PlateView.SetAction(typeof(ActionClone), parts);
} }
} }
+266
View File
@@ -0,0 +1,266 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.IO.Ports;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
if (args.Length < 2)
{
Console.Error.WriteLine("Usage:");
Console.Error.WriteLine(" StreamGravographJob <file.prn> <COMx> [chunk=256] [flow=rtscts|xonxoff|none]");
Console.Error.WriteLine(" StreamGravographJob --gen <name> <outfile.prn> # name: testA | testB | miniB | miniSquare");
Console.Error.WriteLine(" StreamGravographJob --inspect-nest <file.nest>");
Console.Error.WriteLine(" StreamGravographJob --from-nest <file.nest> <outfile.prn>");
return 2;
}
// Inspect a .nest file: extract polylines via the post-processor pipeline and
// report dimensions / bounding box / pen-up travel — no bytes written.
if (args[0] == "--inspect-nest")
{
if (args.Length < 2) { Console.Error.WriteLine("--inspect-nest requires <file.nest>"); return 2; }
var nestPath = args[1];
if (!File.Exists(nestPath)) { Console.Error.WriteLine($"Not found: {nestPath}"); return 3; }
using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
var reader = new OpenNest.IO.NestReader(fs);
var nest = reader.Read();
Console.WriteLine($"Nest: {nest.Name}");
Console.WriteLine($"Units: {nest.Units}");
Console.WriteLine($"Plates: {nest.Plates.Count}");
var plateIdx = 0;
foreach (var plate in nest.Plates)
{
plateIdx++;
Console.WriteLine($" Plate {plateIdx}: size={plate.Size.Length} x {plate.Size.Width}, quadrant={plate.Quadrant}, parts={plate.Parts.Count}");
}
var polylines = new OpenNest.Posts.GravographIS.NestPolylineExtractor().Extract(nest);
if (polylines.Count == 0)
{
Console.WriteLine("No polylines extracted.");
return 0;
}
double minX = double.PositiveInfinity, minY = double.PositiveInfinity;
double maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
int totalPts = 0;
foreach (var p in polylines)
{
foreach (var v in p)
{
if (v.X < minX) minX = v.X;
if (v.X > maxX) maxX = v.X;
if (v.Y < minY) minY = v.Y;
if (v.Y > maxY) maxY = v.Y;
}
totalPts += p.Count;
}
Console.WriteLine($"Polylines: {polylines.Count}, total points: {totalPts}");
Console.WriteLine($"Bounding box (inches): X ∈ [{minX:F3}, {maxX:F3}] Y ∈ [{minY:F3}, {maxY:F3}]");
Console.WriteLine($"Extents: {maxX - minX:F3}\" × {maxY - minY:F3}\"");
// After running the pre-pass (stitch + reorder from origin) — what the writer will actually consume.
var prepared = OpenNest.Posts.GravographIS.PolylinePrePass.Prepare(polylines);
Console.WriteLine($"After stitch+reorder: {prepared.Count} polylines");
Console.WriteLine();
Console.WriteLine("--- Vertex dump (prepared, upper-left origin, with segment deltas) ---");
var pi = 0;
foreach (var poly in prepared)
{
pi++;
Console.WriteLine($"Polyline {pi}: {poly.Count} points");
var cumX = 0.0; var cumY = 0.0;
for (var i = 0; i < poly.Count; i++)
{
var v = poly[i];
if (i == 0)
{
Console.WriteLine($" [{i}] ({v.X,7:F3}, {v.Y,7:F3}) first DR travel from upper-left origin=({v.X,+7:F3}, {v.Y,+7:F3})");
}
else
{
var dx = v.X - poly[i - 1].X;
var dy = v.Y - poly[i - 1].Y;
cumX += dx;
cumY += dy;
Console.WriteLine($" [{i}] ({v.X,7:F3}, {v.Y,7:F3}) Δ=({dx,+7:F3}, {dy,+7:F3}) cum from origin=({cumX,+7:F3}, {cumY,+7:F3})");
}
}
}
return 0;
}
// Convert a .nest file to a .prn job via the full post-processor pipeline.
if (args[0] == "--from-nest")
{
if (args.Length < 3) { Console.Error.WriteLine("--from-nest requires <file.nest> <outfile.prn>"); return 2; }
var nestPath = args[1];
var outFile = args[2];
if (!File.Exists(nestPath)) { Console.Error.WriteLine($"Not found: {nestPath}"); return 3; }
using var fs = new FileStream(nestPath, FileMode.Open, FileAccess.Read);
var nest = new OpenNest.IO.NestReader(fs).Read();
var post = new OpenNest.Posts.GravographIS.GravographISPostProcessor();
post.Post(nest, outFile);
var size = new FileInfo(outFile).Length;
Console.WriteLine($"Wrote {size} bytes → {outFile}");
return 0;
}
// Generator mode: run the live writer to produce a captured-test file on disk.
if (args[0] == "--gen")
{
if (args.Length < 3) { Console.Error.WriteLine("--gen requires <name> <outfile>"); return 2; }
var preset = args[1];
var outFile = args[2];
var polylines = preset.ToLowerInvariant() switch
{
"testa" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
},
"testb" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(1, 1), new OpenNest.Geometry.Vector(1, 3) },
new[] { new OpenNest.Geometry.Vector(4, 1), new OpenNest.Geometry.Vector(4, 3) },
new[] { new OpenNest.Geometry.Vector(4, 5), new OpenNest.Geometry.Vector(4, 7) },
new[] { new OpenNest.Geometry.Vector(1, 5), new OpenNest.Geometry.Vector(1, 7) },
},
// Same 4-polyline topology as testB (vertical lines + diagonal PU travels between them),
// shrunk to a 0.5" × 1.5" footprint so it stays right near the operator-set work origin.
"minib" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[] { new OpenNest.Geometry.Vector(0, 0), new OpenNest.Geometry.Vector(0, 0.5) },
new[] { new OpenNest.Geometry.Vector(0.5, 0), new OpenNest.Geometry.Vector(0.5, 0.5) },
new[] { new OpenNest.Geometry.Vector(0.5, 1), new OpenNest.Geometry.Vector(0.5, 1.5) },
new[] { new OpenNest.Geometry.Vector(0, 1), new OpenNest.Geometry.Vector(0, 1.5) },
},
// Closed 0.5" square as a SINGLE polyline of 5 points → 4-segment PD packet.
// Exercises multi-segment PD (one FF FD 50 44 00 00 followed by 4 records,
// no intermediate lifts) and bi-directional motion (X+, Y+, X, Y).
// Returns the head to its starting point so no manual jog needed after.
"minisquare" => new System.Collections.Generic.List<System.Collections.Generic.IReadOnlyList<OpenNest.Geometry.Vector>>
{
new[]
{
new OpenNest.Geometry.Vector(0, 0),
new OpenNest.Geometry.Vector(0.5, 0),
new OpenNest.Geometry.Vector(0.5, 0.5),
new OpenNest.Geometry.Vector(0, 0.5),
new OpenNest.Geometry.Vector(0, 0),
},
},
_ => throw new ArgumentException($"Unknown preset '{preset}' (try testA, testB, miniB, or miniSquare)."),
};
using var outFs = new FileStream(outFile, FileMode.Create, FileAccess.Write);
new OpenNest.Posts.GravographIS.GravographISWriter().Write(polylines, outFs);
Console.WriteLine($"Wrote {new FileInfo(outFile).Length} bytes via live writer → {outFile}");
return 0;
}
var file = args[0];
var portName = args[1];
var chunk = args.Length > 2 ? int.Parse(args[2]) : 256;
var flowArg = args.Length > 3 ? args[3] : "rtscts";
var handshake = flowArg.ToLowerInvariant() switch
{
"rtscts" or "rts" or "cts" => Handshake.RequestToSend,
"xonxoff" or "xon" or "xoff" => Handshake.XOnXOff,
"none" => Handshake.None,
_ => throw new ArgumentException($"Unknown flow control '{flowArg}'."),
};
if (!File.Exists(file))
{
Console.Error.WriteLine($"File not found: {file}");
return 3;
}
var bytes = File.ReadAllBytes(file);
Console.WriteLine($"File: {file}");
Console.WriteLine($"Size: {bytes.Length} bytes");
Console.WriteLine($"Header: {BitConverter.ToString(bytes, 0, Math.Min(7, bytes.Length)).Replace('-', ' ')}");
var ports = SerialPort.GetPortNames();
Array.Sort(ports);
Console.WriteLine($"Available COM ports: {string.Join(", ", ports)}");
if (Array.IndexOf(ports, portName) < 0)
{
Console.Error.WriteLine($"{portName} not in available ports.");
return 4;
}
using var port = new SerialPort(portName, 9600, Parity.None, 8, StopBits.One)
{
Handshake = handshake,
WriteTimeout = 30000,
ReadTimeout = 30000,
WriteBufferSize = 4096,
DtrEnable = true,
};
// Probe with the same CreateFile flags SerialStream uses, in this same process,
// so we can tell SerialStream-specific failures apart from process-level access denials.
{
const uint GENERIC_RW = 0x80000000u | 0x40000000u;
const uint OPEN_EXISTING = 3;
const uint FILE_FLAG_OVERLAPPED = 0x40000000u;
var devName = @"\\.\" + portName;
var handle = NativeMethods.CreateFileW(devName, GENERIC_RW, 0, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, IntPtr.Zero);
var err = Marshal.GetLastWin32Error();
if (handle.IsInvalid)
{
Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) FAILED: win32={err} ({new Win32Exception(err).Message})");
}
else
{
Console.WriteLine($"CreateFile(\"{devName}\", overlapped, exclusive) OK — closing.");
handle.Close();
}
}
Console.WriteLine($"Opening {portName} 9600 8N1 handshake={handshake}...");
port.Open();
Console.WriteLine("Opened.");
var sw = Stopwatch.StartNew();
try
{
for (var i = 0; i < bytes.Length; i += chunk)
{
var n = Math.Min(chunk, bytes.Length - i);
port.Write(bytes, i, n);
}
try { port.BaseStream.Flush(); } catch { /* advisory */ }
Thread.Sleep(500);
}
finally
{
sw.Stop();
port.Close();
}
Console.WriteLine($"Sent {bytes.Length} bytes in {sw.ElapsedMilliseconds} ms. Port closed.");
return 0;
internal static class NativeMethods
{
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CreateFileW")]
internal static extern SafeFileHandle CreateFileW(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>OpenNest.Tools.StreamGravographJob</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="8.0.0" />
<ProjectReference Include="..\..\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
<ProjectReference Include="..\..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>