diff --git a/CLAUDE.md b/CLAUDE.md index a039b1d..d130134 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,3 +120,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). - **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity. - **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata. +- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList, …)` 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). diff --git a/OpenNest.Core/Converters/ConvertGeometry.cs b/OpenNest.Core/Converters/ConvertGeometry.cs index ed709ce..69643ea 100644 --- a/OpenNest.Core/Converters/ConvertGeometry.cs +++ b/OpenNest.Core/Converters/ConvertGeometry.cs @@ -87,14 +87,15 @@ namespace OpenNest.Converters lastpt = endpt; + var layer = ClassifyLayer(arc); var sweep = System.Math.Abs(arc.SweepAngle()); if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI)) { - pgm.LineTo(endpt); + pgm.Codes.Add(new LinearMove(endpt) { Layer = layer }); } else { - pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW); + pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer }); } return lastpt; @@ -107,7 +108,7 @@ namespace OpenNest.Converters if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance) pgm.MoveTo(startpt); - pgm.ArcTo(startpt, circle.Center, circle.Rotation); + pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) }); lastpt = startpt; return lastpt; @@ -118,13 +119,22 @@ namespace OpenNest.Converters if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance) pgm.MoveTo(line.StartPoint); - var move = new LinearMove(line.EndPoint); - if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase)) - move.Layer = LayerType.Scribe; - pgm.Codes.Add(move); + pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) }); lastpt = line.EndPoint; return lastpt; } + + // Engrave/etch geometry maps to Scribe so the post processor can treat it as a + // separate tool pass; everything else keeps the move's default Cut layer. + private static LayerType ClassifyLayer(Entity geo) + { + var name = geo.Layer?.Name; + if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase)) + return LayerType.Scribe; + + return LayerType.Cut; + } } } diff --git a/OpenNest.Core/Geometry/ArcFit.cs b/OpenNest.Core/Geometry/ArcFit.cs index 758af4d..6240d5c 100644 --- a/OpenNest.Core/Geometry/ArcFit.cs +++ b/OpenNest.Core/Geometry/ArcFit.cs @@ -57,13 +57,14 @@ namespace OpenNest.Geometry } /// - /// Fits a circular arc constrained to be tangent to the given directions at both - /// the first and last points. The center lies at the intersection of the normals - /// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives - /// at Pn in the end direction. Uses the radius from P1 (exact start tangent); - /// deviation includes any endpoint gap at Pn. + /// Fits a circular arc that passes exactly through both the first and last points + /// while matching the given endpoint tangents as closely as possible. For any + /// circle through two points, the tangents at those points make equal mirrored + /// angles with the chord, so the achievable inscribed angle is the average of the + /// two requested ones — when the requested tangents are consistent with a single + /// circular arc, both are matched exactly. /// - internal static (Vector center, double radius, double deviation) FitWithDualTangent( + internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents( List points, Vector startTangent, Vector endTangent) { if (points.Count < 3) @@ -72,42 +73,39 @@ namespace OpenNest.Geometry var p1 = points[0]; var pn = points[^1]; - var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y); - var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y); - if (stLen < 1e-10 || etLen < 1e-10) - return (Vector.Invalid, 0, double.MaxValue); - - // Normal to start tangent at P1 (perpendicular) - var n1x = -startTangent.Y / stLen; - var n1y = startTangent.X / stLen; - - // Normal to end tangent at Pn - var n2x = -endTangent.Y / etLen; - var n2y = endTangent.X / etLen; - - // Solve: P1 + t1*N1 = Pn + t2*N2 - var det = n1x * (-n2y) - (-n2x) * n1y; - if (System.Math.Abs(det) < 1e-10) - return (Vector.Invalid, 0, double.MaxValue); - var dx = pn.X - p1.X; var dy = pn.Y - p1.Y; - var t1 = (dx * (-n2y) - (-n2x) * dy) / det; - - var cx = p1.X + t1 * n1x; - var cy = p1.Y + t1 * n1y; - - // Use radius from P1 (guarantees exact start tangent and passes through P1) - var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y)); - if (r1 < 1e-10) + var chordLen = System.Math.Sqrt(dx * dx + dy * dy); + if (chordLen < 1e-10) return (Vector.Invalid, 0, double.MaxValue); - // Measure endpoint gap at Pn - var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y)); - var endpointDev = System.Math.Abs(r2 - r1); + var ux = dx / chordLen; + var uy = dy / chordLen; - var interiorDev = MaxRadialDeviation(points, cx, cy, r1); - return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev)); + // Inscribed angle between chord and tangent at each endpoint (mirrored at Pn) + var theta1 = SignedAngle(ux, uy, startTangent); + var theta2 = -SignedAngle(ux, uy, endTangent); + var theta = (theta1 + theta2) / 2; + + // Nearly straight or degenerate (sweep would exceed ~356 degrees) + if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99) + return (Vector.Invalid, 0, double.MaxValue); + + var halfChord = chordLen / 2; + var radius = halfChord / System.Math.Abs(System.Math.Sin(theta)); + var d = -halfChord / System.Math.Tan(theta); + + var cx = (p1.X + pn.X) / 2 + d * -uy; + var cy = (p1.Y + pn.Y) / 2 + d * ux; + + return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius)); + } + + private static double SignedAngle(double ux, double uy, Vector to) + { + var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y); + if (len < 1e-10) return 0; + return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y); } /// diff --git a/OpenNest.Core/Geometry/GeometrySimplifier.cs b/OpenNest.Core/Geometry/GeometrySimplifier.cs index 7c8ebe1..11f11b3 100644 --- a/OpenNest.Core/Geometry/GeometrySimplifier.cs +++ b/OpenNest.Core/Geometry/GeometrySimplifier.cs @@ -374,11 +374,8 @@ public class GeometrySimplifier var points = CollectPoints(entities, start, k); if (points.Count < 3) return null; - var startTangent = chainedTangent.IsValid() - ? chainedTangent - : new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y); - - var endTangent = GetExitDirection(entities[k]); + var startTangent = EstimateStartTangent(entities, start, points, chainedTangent); + var endTangent = EstimateEndTangent(entities, k, points); var (center, radius, dev) = TryFit(points, startTangent, endTangent); if (!center.IsValid()) return null; @@ -386,8 +383,10 @@ public class GeometrySimplifier while (k + 1 <= runEnd) { var extPoints = CollectPoints(entities, start, k + 1); - var extEndTangent = GetExitDirection(entities[k + 1]); - var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d); + if (extPoints.Count < 3) break; + + var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints); + var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent); if (!nc.IsValid()) break; k++; @@ -407,37 +406,172 @@ public class GeometrySimplifier return new ArcFitResult(center, radius, dev, points, k); } - private (Vector center, double radius, double deviation) TryFit(List points, Vector startTangent, Vector endTangent) + private (Vector center, double radius, double deviation) TryFit( + List points, TangentEstimate start, TangentEstimate end) { - // Try dual-tangent fit first (matches direction at both endpoints) - if (endTangent.IsValid()) + foreach (var (center, radius, dev) in FitAttempts(points, start, end)) { - var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent); - if (dc.IsValid() && dd <= Tolerance) + if (!center.IsValid() || dev > 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); + } + + /// + /// 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. + /// + private IEnumerable<(Vector center, double radius, double deviation)> FitAttempts( + List 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); + } + + /// + /// 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. + /// + private static (Vector center, double radius, double deviation) FitWithEndTangent( + List points, Vector endTangent) + { + var reversed = new List(points); + reversed.Reverse(); + return ArcFit.FitWithStartTangent(reversed, new Vector(-endTangent.X, -endTangent.Y)); + } + + /// + /// 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. + /// + private readonly record struct TangentEstimate(Vector Direction, bool Trusted); + + /// Segment-length ratio above which a neighboring line counts as a true + /// straight edge (rather than another chord of the tessellated curve). + private const double NeighborEdgeFactor = 3.0; + + private static TangentEstimate EstimateStartTangent( + List entities, int start, List 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; - var aDev = MaxArcToSegmentDeviation(points, dc, dr, isRev); - if (aDev <= Tolerance) - return (dc, dr, System.Math.Max(dd, aDev)); + 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); } } - // Fall back to start-tangent-only, then mirror axis - var (center, radius, dev) = ArcFit.FitWithStartTangent(points, startTangent); - if (!center.IsValid() || dev > Tolerance) - (center, radius, dev) = FitMirrorAxis(points); - if (!center.IsValid() || dev > Tolerance) - return (Vector.Invalid, 0, 0); - - // 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)); + 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 entities, int k, List 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); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Returns the entry direction (tangent at start point) of an entity. + /// + 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, + }; + /// /// Computes the tangent direction at the last point of a fitted arc, /// used to chain tangent continuity to the next arc. diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index cdde347..a4fe48d 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -242,6 +242,7 @@ namespace OpenNest.IO public ExportContext() { Document = new CadDocument(); + Document.Header.Version = ACadVersion.AC1018; CutLayer = new Layer("Cut") { Color = new Color(1) }; RapidLayer = new Layer("Rapid") { Color = new Color(5) }; diff --git a/OpenNest.Posts.GravographIS/GravographISPostConfig.cs b/OpenNest.Posts.GravographIS/GravographISPostConfig.cs new file mode 100644 index 0000000..15f7bce --- /dev/null +++ b/OpenNest.Posts.GravographIS/GravographISPostConfig.cs @@ -0,0 +1,81 @@ +using System.ComponentModel; +using OpenNest.CNC; + +namespace OpenNest.Posts.GravographIS +{ + /// + /// Cut parameters for one kind of pass (engrave or cut). Edited in the post + /// configuration PropertyGrid and persisted to JSON. + /// + [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" : ""); + } + + /// + /// Configuration for the Gravograph IS post processor: one + /// per cut kind. The engrave block applies to paths, + /// the cut block to //. + /// The cut block carries the tool-change pause by default. + /// + 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", + }; + + /// + /// Returns the cut config a polyline of the given layer should use, or null + /// if the layer is non-cutting () and should be skipped. + /// + 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; + } + } + } +} diff --git a/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs b/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs index 1e7c67e..c2b1a2a 100644 --- a/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs +++ b/OpenNest.Posts.GravographIS/GravographISPostProcessor.cs @@ -1,16 +1,30 @@ 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 { /// /// IPostProcessor implementation for the Gravograph IS8000. /// writes the binary HPGL bytes. For serial streaming, use . + /// + /// Geometry is split by into an engrave pass + /// (Scribe) and a cut pass (Cut), each with its own feed/depth from . + /// The cut pass pauses by default so the operator can swap/adjust the tool. /// - public sealed class GravographISPostProcessor : IPostProcessor + 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)"; @@ -23,14 +37,53 @@ namespace OpenNest.Posts.GravographIS 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(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 polylines = Extractor.Extract(nest); - var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse); - new GravographISWriter(WriterOptions).Write(prepared, outputStream); + var passes = BuildPasses(Extractor.ExtractLayered(nest)); + new GravographISWriter(WriterOptions).Write(passes, outputStream); } public void Post(Nest nest, string outputFile) @@ -39,6 +92,52 @@ namespace OpenNest.Posts.GravographIS Post(nest, fs); } + /// + /// 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). + /// + public IReadOnlyList BuildPasses(IEnumerable polylines) + { + if (polylines == null) throw new ArgumentNullException(nameof(polylines)); + + var engrave = new List>(); + var cut = new List>(); + + 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(); + 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> polylines) + { + return new GravographPass + { + Polylines = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse), + FeedMmPerSec = block.FeedMmPerSec, + DepthInches = block.Depth, + PauseBefore = block.PauseBefore, + PauseMessage = block.PauseMessage ?? "", + }; + } + /// /// Buffers the encoded job in memory, then streams it to the named COM port. /// diff --git a/OpenNest.Posts.GravographIS/GravographISWriter.cs b/OpenNest.Posts.GravographIS/GravographISWriter.cs index 285eb38..24fffdf 100644 --- a/OpenNest.Posts.GravographIS/GravographISWriter.cs +++ b/OpenNest.Posts.GravographIS/GravographISWriter.cs @@ -1,10 +1,30 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; using OpenNest.Geometry; namespace OpenNest.Posts.GravographIS { + /// + /// 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. + /// + public sealed class GravographPass + { + public IEnumerable> Polylines { get; set; } + + public int FeedMmPerSec { get; set; } + + public double DepthInches { get; set; } + + /// When true, park to origin and prompt the operator before this pass. + public bool PauseBefore { get; set; } + + public string PauseMessage { get; set; } = ""; + } + /// /// Encodes polylines (in inches) into the Gravograph IS8000 native "binary HPGL" /// wire format. The byte stream is byte-exact against captures from GravoStyle'98. @@ -84,12 +104,40 @@ namespace OpenNest.Posts.GravographIS public void Write(IEnumerable> 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); + } + + /// + /// 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 is + /// set, parks to the operator origin and emits an operator pause before cutting. + /// + public void Write(IReadOnlyList 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)Options.FeedMmPerSec); - PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec); - PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16()); + 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 @@ -105,45 +153,51 @@ namespace OpenNest.Posts.GravographIS var firstPolyline = true; var polyIndex = 0; + var currentFeed = firstFeed; + var currentDepth = firstDepth; - foreach (var poly in polylines) + for (var p = 0; p < passes.Count; p++) { - polyIndex++; - if (poly == null || poly.Count < 2) - continue; + var pass = passes[p]; - 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++) + if (p > 0) { - 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; + 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; + } } - firstPolyline = false; + 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); @@ -156,6 +210,89 @@ namespace OpenNest.Posts.GravographIS output.Write(EndJobBytes, 0, EndJobBytes.Length); } + private void WritePolyline(Stream output, IReadOnlyList 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, @@ -181,11 +318,11 @@ namespace OpenNest.Posts.GravographIS $"work envelope from upper-left origin. Refusing to emit the record."); } - private short DepthInStepsAsInt16() + private static short DepthInStepsAsInt16(double depthInches) { - var steps = (long)System.Math.Round(Options.DepthInches * StepsPerInch, MidpointRounding.AwayFromZero); + var steps = (long)System.Math.Round(depthInches * StepsPerInch, MidpointRounding.AwayFromZero); if (steps < short.MinValue || steps > short.MaxValue) - throw new ArgumentOutOfRangeException(nameof(Options.DepthInches), $"Depth {Options.DepthInches} in. → {steps} steps overflows int16."); + throw new ArgumentOutOfRangeException(nameof(depthInches), $"Depth {depthInches} in. → {steps} steps overflows int16."); return (short)steps; } diff --git a/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs b/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs index b4e9055..3434f17 100644 --- a/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs +++ b/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs @@ -1,15 +1,35 @@ using System; using System.Collections.Generic; +using System.Linq; using OpenNest.CNC; using OpenNest.Geometry; namespace OpenNest.Posts.GravographIS { + /// + /// A polyline together with the 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). + /// + public sealed class LayeredPolyline + { + public LayeredPolyline(List points, LayerType layer) + { + Points = points; + Layer = layer; + } + + public List Points { get; } + + public LayerType Layer { get; } + } + /// /// Lifts polylines out of an OpenNest for the Gravograph /// backend. Walks each 's , breaks - /// polylines at rapid moves, and tessellates arcs to a chord-deviation - /// tolerance (the wire format takes line segments only). + /// polylines at rapid moves and at changes, and + /// tessellates arcs to a chord-deviation tolerance (the wire format takes + /// line segments only). /// public sealed class NestPolylineExtractor { @@ -17,13 +37,31 @@ namespace OpenNest.Posts.GravographIS /// /// Extracts polylines from every non-cutoff part in every plate of the nest, - /// returning them in plate coordinates (inches). + /// returning them in plate coordinates (inches). Layer information is dropped; + /// use to keep it. /// public List> Extract(Nest nest) + { + return ExtractLayered(nest).Select(p => p.Points).ToList(); + } + + /// + /// Extracts polylines for a single part without layer information. + /// + public List> ExtractPart(Part part) + { + return ExtractPartLayered(part).Select(p => p.Points).ToList(); + } + + /// + /// Extracts layer-tagged polylines from every non-cutoff part in every plate, + /// in plate coordinates (inches). Each polyline is layer-uniform. + /// + public List ExtractLayered(Nest nest) { if (nest == null) throw new ArgumentNullException(nameof(nest)); - var result = new List>(); + var result = new List(); foreach (var plate in nest.Plates) { @@ -40,17 +78,17 @@ namespace OpenNest.Posts.GravographIS } /// - /// Extracts polylines for a single part. Public so callers driving the - /// writer directly (e.g. from a console one-off) can use it. + /// 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. /// - public List> ExtractPart(Part part) + public List ExtractPartLayered(Part part) { - var list = new List>(); + var list = new List(); ExtractPart(part, list); return list; } - private void ExtractPart(Part part, List> sink) + private void ExtractPart(Part part, List sink) { var program = part.Program; if (program == null) return; @@ -67,6 +105,7 @@ namespace OpenNest.Posts.GravographIS var offset = part.Location; var pos = new Vector(0, 0); List current = null; + var currentLayer = LayerType.Cut; foreach (var code in program.Codes) { @@ -77,17 +116,14 @@ namespace OpenNest.Posts.GravographIS { case RapidMove rapid: { - FlushCurrent(sink, ref current); + FlushCurrent(sink, ref current, currentLayer); pos = rapid.EndPoint; break; } case LinearMove linear: { - if (current == null) - { - current = new List { pos + offset }; - } + StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset); var end = linear.EndPoint; current.Add(end + offset); pos = end; @@ -96,10 +132,7 @@ namespace OpenNest.Posts.GravographIS case ArcMove arc: { - if (current == null) - { - current = new List { pos + offset }; - } + StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset); TessellateArc(pos, arc, offset, ArcChordToleranceInches, current); pos = arc.EndPoint; break; @@ -107,13 +140,33 @@ namespace OpenNest.Posts.GravographIS } } - FlushCurrent(sink, ref current); + FlushCurrent(sink, ref current, currentLayer); } - private static void FlushCurrent(List> sink, ref List current) + // 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 sink, ref List current, + ref LayerType currentLayer, LayerType moveLayer, Vector seed) + { + if (current == null) + { + current = new List { seed }; + currentLayer = moveLayer; + } + else if (moveLayer != currentLayer) + { + FlushCurrent(sink, ref current, currentLayer); + current = new List { seed }; + currentLayer = moveLayer; + } + } + + private static void FlushCurrent(List sink, ref List current, LayerType layer) { if (current != null && current.Count >= 2) - sink.Add(current); + sink.Add(new LayeredPolyline(current, layer)); current = null; } diff --git a/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs b/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs new file mode 100644 index 0000000..2243855 --- /dev/null +++ b/OpenNest.Tests/Converters/ConvertGeometryLayerTests.cs @@ -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(), 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(), 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().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().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(), m => Assert.Equal(LayerType.Cut, m.Layer)); + } +} diff --git a/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs b/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs index d13e02f..b8f268b 100644 --- a/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs +++ b/OpenNest.Tests/Geometry/GeometrySimplifierTests.cs @@ -131,6 +131,111 @@ public class GeometrySimplifierTests Assert.IsType(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(); + 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(); + for (var i = 0; i <= 6; i++) + { + var ang = OpenNest.Math.Angle.ToRadians(10 * i); + var radius = r1 + deltas1[i]; + pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang))); + } + + // Second arc center along the junction radius so tangents match at the junction + var junctionAngle = OpenNest.Math.Angle.ToRadians(60); + var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle)); + var r2 = 0.6; + var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2)); + var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 }; + for (var i = 1; i <= 5; i++) + { + var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i); + var radius = r2 + deltas2[i]; + pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang))); + } + + var shape = new Shape(); + for (var i = 0; i < pts.Count - 1; i++) + shape.Entities.Add(new Line(pts[i], pts[i + 1])); + + var simplifier = new GeometrySimplifier { Tolerance = 0.004 }; + var candidates = simplifier.Analyze(shape); + + Assert.Equal(2, candidates.Count); + var arcA = candidates[0].FittedArc; + var arcB = candidates[1].FittedArc; + + // Arcs must share the junction vertex exactly + Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6); + + // Tangent continuity across the junction + var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint())); + Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction"); + } + + private static Vector ArcTangentAt(Arc arc, Vector pt) + { + var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X); + return arc.IsReversed + ? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang)) + : new Vector(-System.Math.Sin(ang), System.Math.Cos(ang)); + } + + private static double AngleBetweenDeg(Vector v1, Vector v2) + { + var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y); + var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y); + var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2); + dot = System.Math.Max(-1, System.Math.Min(1, dot)); + return System.Math.Acos(dot) * 180.0 / System.Math.PI; + } + [Fact] public void Apply_DynaPanDxf_NoGapsAfterSimplification() { diff --git a/OpenNest.Tests/GravographIS/GravographISPostConfigTests.cs b/OpenNest.Tests/GravographIS/GravographISPostConfigTests.cs new file mode 100644 index 0000000..821f9eb --- /dev/null +++ b/OpenNest.Tests/GravographIS/GravographISPostConfigTests.cs @@ -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)); + } +} diff --git a/OpenNest.Tests/GravographIS/GravographISPostProcessorTests.cs b/OpenNest.Tests/GravographIS/GravographISPostProcessorTests.cs new file mode 100644 index 0000000..89399eb --- /dev/null +++ b/OpenNest.Tests/GravographIS/GravographISPostProcessorTests.cs @@ -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(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); + } +} diff --git a/OpenNest.Tests/GravographIS/GravographISWriterTests.cs b/OpenNest.Tests/GravographIS/GravographISWriterTests.cs index 6528b53..4db5947 100644 --- a/OpenNest.Tests/GravographIS/GravographISWriterTests.cs +++ b/OpenNest.Tests/GravographIS/GravographISWriterTests.cs @@ -178,6 +178,101 @@ public class GravographISWriterTests Assert.Equal(-GravographISWriter.StepsPerInch, dy); } + [Fact] + public void Passes_PauseBeforeCut_EmitsPauseSequenceBetweenGroups() + { + var engrave = new List> { new[] { new Vector(0, 0), new Vector(1, 0) } }; + var cut = new List> { new[] { new Vector(0, 0), new Vector(0, -1) } }; + var passes = new List + { + 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 + { + new GravographPass { Polylines = new List> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 }, + new GravographPass { Polylines = new List> { 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 + { + new GravographPass { Polylines = new List> { new[] { new Vector(0, 0), new Vector(1, 0) } }, FeedMmPerSec = 10 }, + new GravographPass { Polylines = new List> { 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++) diff --git a/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs b/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs index bc6cd7f..7c99643 100644 --- a/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs +++ b/OpenNest.Tests/GravographIS/NestPolylineExtractorTests.cs @@ -34,4 +34,45 @@ public class NestPolylineExtractorTests 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); + } }