Author SHA1 Message Date
ajandClaude Sonnet 4.6 bcb85805d9 fix: remove Windows-specific TFM for Linux/Docker compatibility
Change net8.0-windows to net8.0 in Core, IO, and Engine projects so
the libraries can be consumed in Docker containers running on Linux.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:33:32 -04:00
18 changed files with 141 additions and 1087 deletions
-1
View File
@@ -120,4 +120,3 @@ 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).
+7 -17
View File
@@ -87,15 +87,14 @@ 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.Codes.Add(new LinearMove(endpt) { Layer = layer }); pgm.LineTo(endpt);
} }
else else
{ {
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer }); pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
} }
return lastpt; return lastpt;
@@ -108,7 +107,7 @@ namespace OpenNest.Converters
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance) if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
pgm.MoveTo(startpt); pgm.MoveTo(startpt);
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) }); pgm.ArcTo(startpt, circle.Center, circle.Rotation);
lastpt = startpt; lastpt = startpt;
return lastpt; return lastpt;
@@ -119,22 +118,13 @@ 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);
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) }); var move = new LinearMove(line.EndPoint);
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;
}
} }
} }
+37 -35
View File
@@ -57,14 +57,13 @@ namespace OpenNest.Geometry
} }
/// <summary> /// <summary>
/// Fits a circular arc that passes exactly through both the first and last points /// Fits a circular arc constrained to be tangent to the given directions at both
/// while matching the given endpoint tangents as closely as possible. For any /// the first and last points. The center lies at the intersection of the normals
/// circle through two points, the tangents at those points make equal mirrored /// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
/// angles with the chord, so the achievable inscribed angle is the average of the /// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
/// two requested ones — when the requested tangents are consistent with a single /// deviation includes any endpoint gap at Pn.
/// circular arc, both are matched exactly.
/// </summary> /// </summary>
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents( internal static (Vector center, double radius, double deviation) FitWithDualTangent(
List<Vector> points, Vector startTangent, Vector endTangent) List<Vector> points, Vector startTangent, Vector endTangent)
{ {
if (points.Count < 3) if (points.Count < 3)
@@ -73,39 +72,42 @@ 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 chordLen = System.Math.Sqrt(dx * dx + dy * dy); var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
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);
var ux = dx / chordLen; // Measure endpoint gap at Pn
var uy = dy / chordLen; var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
var endpointDev = System.Math.Abs(r2 - r1);
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn) var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
var theta1 = SignedAngle(ux, uy, startTangent); return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
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>
+26 -160
View File
@@ -374,8 +374,11 @@ 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 = EstimateStartTangent(entities, start, points, chainedTangent); var startTangent = chainedTangent.IsValid()
var endTangent = EstimateEndTangent(entities, k, points); ? chainedTangent
: 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;
@@ -383,10 +386,8 @@ 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);
if (extPoints.Count < 3) break; var extEndTangent = GetExitDirection(entities[k + 1]);
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++;
@@ -406,172 +407,37 @@ 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( private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
List<Vector> points, TangentEstimate start, TangentEstimate end)
{ {
foreach (var (center, radius, dev) in FitAttempts(points, start, end)) // Try dual-tangent fit first (matches direction at both endpoints)
if (endTangent.IsValid())
{ {
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
if (dc.IsValid() && dd <= Tolerance)
{
var isRev = SumSignedAngles(dc, points) < 0;
var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev);
if (aDev <= Tolerance)
return (dc, dr, System.Math.Max(dd, aDev));
}
}
// Fall back to start-tangent-only, then mirror axis
var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent);
if (!center.IsValid() || dev > Tolerance) if (!center.IsValid() || dev > Tolerance)
continue; (center, radius, dev) = FitMirrorAxis(points);
if (!center.IsValid() || dev > Tolerance)
return (Vector.Invalid, 0, 0);
// Check that the arc doesn't bulge away from the original line segments // Check that the arc doesn't bulge away from the original line segments
var isReversed = SumSignedAngles(center, points) < 0; var isReversed = SumSignedAngles(center, points) < 0;
var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed); var arcDev = MaxArcToSegmentDeviation(points, center, radius, isReversed);
if (arcDev > Tolerance) if (arcDev > Tolerance)
continue; return (Vector.Invalid, 0, 0);
return (center, radius, System.Math.Max(dev, arcDev)); return (center, radius, System.Math.Max(dev, arcDev));
} }
return (Vector.Invalid, 0, 0);
}
/// <summary>
/// Yields fit attempts in preference order. A trusted tangent (chained from the
/// previous arc, an adjacent original arc, or a long straight edge) is enforced
/// exactly on its side; otherwise the tangency error is balanced between both
/// endpoints. The unconstrained mirror-axis fit is the last resort. Every attempt
/// passes exactly through both endpoints, so no gaps are introduced.
/// </summary>
private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts(
List<Vector> points, TangentEstimate start, TangentEstimate end)
{
if (start.Trusted && !end.Trusted)
{
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
else if (end.Trusted && !start.Trusted)
{
yield return FitWithEndTangent(points, end.Direction);
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
}
else
{
yield return ArcFit.FitThroughEndpointsWithTangents(points, start.Direction, end.Direction);
yield return ArcFit.FitWithStartTangent(points, start.Direction);
yield return FitWithEndTangent(points, end.Direction);
}
yield return FitMirrorAxis(points);
}
/// <summary>
/// Fits an arc through both endpoints with an exact tangent at the last point,
/// by running the start-tangent fit on the reversed point sequence.
/// </summary>
private static (Vector center, double radius, double deviation) FitWithEndTangent(
List<Vector> points, Vector endTangent)
{
var reversed = new List<Vector>(points);
reversed.Reverse();
return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y));
}
/// <summary>
/// An estimated tangent direction at a fit endpoint. Trusted estimates come from
/// exact geometry (a chained arc, an adjacent original arc, or a long straight
/// edge) and are enforced exactly; untrusted ones are derived from the polyline
/// vertices and only guide the fit.
/// </summary>
private readonly record struct TangentEstimate(Vector Direction, bool Trusted);
/// <summary>Segment-length ratio above which a neighboring line counts as a true
/// straight edge (rather than another chord of the tessellated curve).</summary>
private const double NeighborEdgeFactor = 3.0;
private static TangentEstimate EstimateStartTangent(
List<Entity> entities, int start, List<Vector> points, Vector chainedTangent)
{
if (chainedTangent.IsValid())
return new TangentEstimate(chainedTangent, true);
if (entities[start] is Arc startArc)
return new TangentEstimate(GetEntryDirection(startArc), true);
var firstChordLen = points[0].DistanceTo(points[1]);
if (start > 0)
{
var prev = entities[start - 1];
var prevEnd = prev switch { Line l => l.EndPoint, Arc a => a.EndPoint(), _ => Vector.Invalid };
if (prevEnd.IsValid() && prevEnd.DistanceTo(points[0]) < 1e-6)
{
if (prev is Arc)
return new TangentEstimate(GetExitDirection(prev), true);
if (prev is Line prevLine && prevLine.StartPoint.DistanceTo(prevLine.EndPoint) >= NeighborEdgeFactor * firstChordLen)
return new TangentEstimate(GetExitDirection(prevLine), true);
}
}
var chord = new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
if (points.Count >= 3)
return new TangentEstimate(EstimateVertexTangent(points[0], points[1], points[2], chord), false);
return new TangentEstimate(chord, false);
}
private static TangentEstimate EstimateEndTangent(List<Entity> entities, int k, List<Vector> points)
{
if (entities[k] is Arc endArc)
return new TangentEstimate(GetExitDirection(endArc), true);
var lastChordLen = points[^1].DistanceTo(points[^2]);
if (k + 1 < entities.Count)
{
var next = entities[k + 1];
var nextStart = next switch { Line l => l.StartPoint, Arc a => a.StartPoint(), _ => Vector.Invalid };
if (nextStart.IsValid() && nextStart.DistanceTo(points[^1]) < 1e-6)
{
if (next is Arc nextArc)
return new TangentEstimate(GetEntryDirection(nextArc), true);
if (next is Line nextLine && nextLine.StartPoint.DistanceTo(nextLine.EndPoint) >= NeighborEdgeFactor * lastChordLen)
return new TangentEstimate(GetExitDirection(nextLine), true);
}
}
var chord = new Vector(points[^1].X - points[^2].X, points[^1].Y - points[^2].Y);
if (points.Count >= 3)
return new TangentEstimate(EstimateVertexTangent(points[^1], points[^2], points[^3], chord), false);
return new TangentEstimate(chord, false);
}
/// <summary>
/// Estimates the curve tangent at a polyline vertex from the circle through it and
/// its two nearest neighbors. A raw chord direction is off from the true tangent by
/// half the chord's subtended angle; the circumcircle estimate removes that bias.
/// Falls back to the travel direction when the three points are collinear.
/// </summary>
private static Vector EstimateVertexTangent(Vector at, Vector b, Vector c, Vector travel)
{
var d = 2 * (at.X * (b.Y - c.Y) + b.X * (c.Y - at.Y) + c.X * (at.Y - b.Y));
if (System.Math.Abs(d) < 1e-14)
return travel;
var sqA = at.X * at.X + at.Y * at.Y;
var sqB = b.X * b.X + b.Y * b.Y;
var sqC = c.X * c.X + c.Y * c.Y;
var cx = (sqA * (b.Y - c.Y) + sqB * (c.Y - at.Y) + sqC * (at.Y - b.Y)) / d;
var cy = (sqA * (c.X - b.X) + sqB * (at.X - c.X) + sqC * (b.X - at.X)) / d;
var tangent = new Vector(-(at.Y - cy), at.X - cx);
if (tangent.X * travel.X + tangent.Y * travel.Y < 0)
tangent = new Vector(-tangent.X, -tangent.Y);
return tangent;
}
/// <summary>
/// Returns the entry direction (tangent at start point) of an entity.
/// </summary>
private static Vector GetEntryDirection(Entity entity) => entity switch
{
Line line => new Vector(line.EndPoint.X - line.StartPoint.X, line.EndPoint.Y - line.StartPoint.Y),
Arc arc => arc.IsReversed
? new Vector(System.Math.Sin(arc.StartAngle), -System.Math.Cos(arc.StartAngle))
: new Vector(-System.Math.Sin(arc.StartAngle), System.Math.Cos(arc.StartAngle)),
_ => Vector.Invalid,
};
/// <summary> /// <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.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest</RootNamespace> <RootNamespace>OpenNest</RootNamespace>
<AssemblyName>OpenNest.Core</AssemblyName> <AssemblyName>OpenNest.Core</AssemblyName>
</PropertyGroup> </PropertyGroup>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest</RootNamespace> <RootNamespace>OpenNest</RootNamespace>
<AssemblyName>OpenNest.Engine</AssemblyName> <AssemblyName>OpenNest.Engine</AssemblyName>
</PropertyGroup> </PropertyGroup>
-1
View File
@@ -242,7 +242,6 @@ 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) };
+1 -1
View File
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.IO</RootNamespace> <RootNamespace>OpenNest.IO</RootNamespace>
<AssemblyName>OpenNest.IO</AssemblyName> <AssemblyName>OpenNest.IO</AssemblyName>
</PropertyGroup> </PropertyGroup>
@@ -1,81 +0,0 @@
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;
}
}
}
}
@@ -1,30 +1,16 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Ports; using System.IO.Ports;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading; using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS namespace OpenNest.Posts.GravographIS
{ {
/// <summary> /// <summary>
/// IPostProcessor implementation for the Gravograph IS8000. <see cref="Post(Nest, Stream)"/> /// 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)"/>. /// 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> /// </summary>
public sealed class GravographISPostProcessor : IConfigurablePostProcessor public sealed class GravographISPostProcessor : IPostProcessor
{ {
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
public string Name => "Gravograph IS8000"; public string Name => "Gravograph IS8000";
public string Author => "OpenNest"; public string Author => "OpenNest";
public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)"; public string Description => "Gravograph IS8000 mechanical engraver (binary HPGL over serial)";
@@ -37,53 +23,14 @@ namespace OpenNest.Posts.GravographIS
public bool AllowReverse { get; set; } = true; 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) public void Post(Nest nest, Stream outputStream)
{ {
if (nest == null) throw new ArgumentNullException(nameof(nest)); if (nest == null) throw new ArgumentNullException(nameof(nest));
if (outputStream == null) throw new ArgumentNullException(nameof(outputStream)); if (outputStream == null) throw new ArgumentNullException(nameof(outputStream));
var passes = BuildPasses(Extractor.ExtractLayered(nest)); var polylines = Extractor.Extract(nest);
new GravographISWriter(WriterOptions).Write(passes, outputStream); var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
} }
public void Post(Nest nest, string outputFile) public void Post(Nest nest, string outputFile)
@@ -92,52 +39,6 @@ namespace OpenNest.Posts.GravographIS
Post(nest, fs); 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> /// <summary>
/// Buffers the encoded job in memory, then streams it to the named COM port. /// Buffers the encoded job in memory, then streams it to the named COM port.
/// </summary> /// </summary>
+13 -150
View File
@@ -1,30 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS 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> /// <summary>
/// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL" /// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL"
/// wire format. The byte stream is byte-exact against captures from GravoStyle'98. /// wire format. The byte stream is byte-exact against captures from GravoStyle'98.
@@ -104,40 +84,12 @@ namespace OpenNest.Posts.GravographIS
public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output) public void Write(IEnumerable<IReadOnlyList<Vector>> polylines, Stream output)
{ {
if (polylines == null) throw new ArgumentNullException(nameof(polylines)); 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)); 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(); var preamble = (byte[])PreambleTemplate.Clone();
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed); PatchOperand(preamble, (byte)'V', (byte)'S', (short)Options.FeedMmPerSec);
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed); PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth)); PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
output.Write(preamble, 0, preamble.Length); output.Write(preamble, 0, preamble.Length);
// Cumulative head position from the operator-set upper-left origin, in // Cumulative head position from the operator-set upper-left origin, in
@@ -153,67 +105,13 @@ namespace OpenNest.Posts.GravographIS
var firstPolyline = true; var firstPolyline = true;
var polyIndex = 0; var polyIndex = 0;
var currentFeed = firstFeed;
var currentDepth = firstDepth;
for (var p = 0; p < passes.Count; p++) foreach (var poly in polylines)
{
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++; polyIndex++;
if (poly == null || poly.Count < 2) if (poly == null || poly.Count < 2)
continue; 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]); var (startX, startY) = ToWire(poly[0]);
WriteTravel(output, WriteTravel(output,
firstPolyline ? (byte)'D' : (byte)'P', firstPolyline ? (byte)'D' : (byte)'P',
@@ -248,49 +146,14 @@ namespace OpenNest.Posts.GravographIS
firstPolyline = false; firstPolyline = false;
} }
// The operator pause, minus the leading lift/park which the caller emits. WriteLiftOnly(output);
// Stops the spindle (MC off), turns off aux, writes the console message, then if (Options.ReturnToOriginAtEnd && !firstPolyline)
// 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 WriteTravel(output, (byte)'P', (byte)'U',
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off checked(-headX), checked(-headY),
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFA); // aux off ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
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
} }
output.Write(EndJobBytes, 0, EndJobBytes.Length);
// 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 const double StepsPerMm = 80.0;
@@ -318,11 +181,11 @@ namespace OpenNest.Posts.GravographIS
$"work envelope from upper-left origin. Refusing to emit the record."); $"work envelope from upper-left origin. Refusing to emit the record.");
} }
private static short DepthInStepsAsInt16(double depthInches) private short DepthInStepsAsInt16()
{ {
var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero); var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero);
if (steps < short.MinValue || steps > short.MaxValue) if (steps < short.MinValue || steps > short.MaxValue)
throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16."); throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16.");
return (short)steps; return (short)steps;
} }
@@ -1,35 +1,15 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Posts.GravographIS 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> /// <summary>
/// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph /// Lifts polylines out of an OpenNest <see cref="Nest"/> for the Gravograph
/// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks /// backend. Walks each <see cref="Part"/>'s <see cref="Program"/>, breaks
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and /// polylines at rapid moves, and tessellates arcs to a chord-deviation
/// tessellates arcs to a chord-deviation tolerance (the wire format takes /// tolerance (the wire format takes line segments only).
/// line segments only).
/// </summary> /// </summary>
public sealed class NestPolylineExtractor public sealed class NestPolylineExtractor
{ {
@@ -37,31 +17,13 @@ namespace OpenNest.Posts.GravographIS
/// <summary> /// <summary>
/// Extracts polylines from every non-cutoff part in every plate of the nest, /// Extracts polylines from every non-cutoff part in every plate of the nest,
/// returning them in plate coordinates (inches). Layer information is dropped; /// returning them in plate coordinates (inches).
/// use <see cref="ExtractLayered(Nest)"/> to keep it.
/// </summary> /// </summary>
public List<List<Vector>> Extract(Nest nest) 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)); if (nest == null) throw new ArgumentNullException(nameof(nest));
var result = new List<LayeredPolyline>(); var result = new List<List<Vector>>();
foreach (var plate in nest.Plates) foreach (var plate in nest.Plates)
{ {
@@ -78,17 +40,17 @@ namespace OpenNest.Posts.GravographIS
} }
/// <summary> /// <summary>
/// Extracts layer-tagged polylines for a single part. Public so callers /// Extracts polylines for a single part. Public so callers driving the
/// driving the writer directly (e.g. from a console one-off) can use it. /// writer directly (e.g. from a console one-off) can use it.
/// </summary> /// </summary>
public List<LayeredPolyline> ExtractPartLayered(Part part) public List<List<Vector>> ExtractPart(Part part)
{ {
var list = new List<LayeredPolyline>(); var list = new List<List<Vector>>();
ExtractPart(part, list); ExtractPart(part, list);
return list; return list;
} }
private void ExtractPart(Part part, List<LayeredPolyline> sink) private void ExtractPart(Part part, List<List<Vector>> sink)
{ {
var program = part.Program; var program = part.Program;
if (program == null) return; if (program == null) return;
@@ -105,7 +67,6 @@ namespace OpenNest.Posts.GravographIS
var offset = part.Location; var offset = part.Location;
var pos = new Vector(0, 0); var pos = new Vector(0, 0);
List<Vector> current = null; List<Vector> current = null;
var currentLayer = LayerType.Cut;
foreach (var code in program.Codes) foreach (var code in program.Codes)
{ {
@@ -116,14 +77,17 @@ namespace OpenNest.Posts.GravographIS
{ {
case RapidMove rapid: case RapidMove rapid:
{ {
FlushCurrent(sink, ref current, currentLayer); FlushCurrent(sink, ref current);
pos = rapid.EndPoint; pos = rapid.EndPoint;
break; break;
} }
case LinearMove linear: case LinearMove linear:
{ {
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset); if (current == null)
{
current = new List<Vector> { pos + offset };
}
var end = linear.EndPoint; var end = linear.EndPoint;
current.Add(end + offset); current.Add(end + offset);
pos = end; pos = end;
@@ -132,7 +96,10 @@ namespace OpenNest.Posts.GravographIS
case ArcMove arc: case ArcMove arc:
{ {
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset); if (current == null)
{
current = new List<Vector> { pos + offset };
}
TessellateArc(pos, arc, offset, ArcChordToleranceInches, current); TessellateArc(pos, arc, offset, ArcChordToleranceInches, current);
pos = arc.EndPoint; pos = arc.EndPoint;
break; break;
@@ -140,33 +107,13 @@ namespace OpenNest.Posts.GravographIS
} }
} }
FlushCurrent(sink, ref current, currentLayer); FlushCurrent(sink, ref current);
} }
// Ensures `current` is an open polyline whose layer matches `moveLayer`, private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> current)
// 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) if (current != null && current.Count >= 2)
sink.Add(new LayeredPolyline(current, layer)); sink.Add(current);
current = null; current = null;
} }
@@ -1,70 +0,0 @@
using System.Linq;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Converters;
public class ConvertGeometryLayerTests
{
private static Program ProgramFor(Entity entity)
{
var shape = new Shape();
shape.Entities.Add(entity);
return ConvertGeometry.ToProgram(shape);
}
[Fact]
public void AddLine_EngraveLayer_TagsScribe()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddLine_EtchLayer_TagsScribe()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddArc_EngraveLayer_TagsScribe()
{
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(arc);
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
Assert.NotEmpty(arcs);
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddCircle_EngraveLayer_TagsScribe()
{
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
var pgm = ProgramFor(circle);
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
Assert.NotEmpty(arcs);
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
}
[Fact]
public void AddLine_DefaultLayer_StaysCut()
{
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
var pgm = ProgramFor(line);
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
}
}
@@ -131,111 +131,6 @@ 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()
{ {
@@ -1,51 +0,0 @@
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));
}
}
@@ -1,70 +0,0 @@
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);
}
}
@@ -178,101 +178,6 @@ public class GravographISWriterTests
Assert.Equal(-GravographISWriter.StepsPerInch, dy); 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) private static void AssertOperand(byte[] bytes, byte c0, byte c1, byte hi, byte lo)
{ {
for (var i = 0; i < bytes.Length - 5; i++) for (var i = 0; i < bytes.Length - 5; i++)
@@ -34,45 +34,4 @@ public class NestPolylineExtractorTests
Assert.Equal(new Vector(0.25, 47.75), poly[3]); Assert.Equal(new Vector(0.25, 47.75), poly[3]);
Assert.Equal(new Vector(0.25, 46.75), poly[4]); 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);
}
} }