Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a085339ba9 | ||
|
|
e493d83899 |
@@ -213,3 +213,6 @@ docs/superpowers/
|
|||||||
|
|
||||||
# Launch settings
|
# Launch settings
|
||||||
**/Properties/launchSettings.json
|
**/Properties/launchSettings.json
|
||||||
|
|
||||||
|
# Local test config (contains user-specific paths to proprietary test assets)
|
||||||
|
OpenNest.Tests/test-config.json
|
||||||
|
|||||||
@@ -120,3 +120,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
|
|||||||
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
- **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load).
|
||||||
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
- **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity.
|
||||||
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata.
|
||||||
|
- **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList<GravographPass>, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth).
|
||||||
|
|||||||
@@ -87,14 +87,15 @@ namespace OpenNest.Converters
|
|||||||
|
|
||||||
lastpt = endpt;
|
lastpt = endpt;
|
||||||
|
|
||||||
|
var layer = ClassifyLayer(arc);
|
||||||
var sweep = System.Math.Abs(arc.SweepAngle());
|
var sweep = System.Math.Abs(arc.SweepAngle());
|
||||||
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
if (sweep < Tolerance.Epsilon || sweep.IsEqualTo(Angle.TwoPI))
|
||||||
{
|
{
|
||||||
pgm.LineTo(endpt);
|
pgm.Codes.Add(new LinearMove(endpt) { Layer = layer });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
pgm.ArcTo(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW);
|
pgm.Codes.Add(new ArcMove(endpt, arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW) { Layer = layer });
|
||||||
}
|
}
|
||||||
|
|
||||||
return lastpt;
|
return lastpt;
|
||||||
@@ -107,7 +108,7 @@ namespace OpenNest.Converters
|
|||||||
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
if (startpt.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(startpt);
|
pgm.MoveTo(startpt);
|
||||||
|
|
||||||
pgm.ArcTo(startpt, circle.Center, circle.Rotation);
|
pgm.Codes.Add(new ArcMove(startpt, circle.Center, circle.Rotation) { Layer = ClassifyLayer(circle) });
|
||||||
|
|
||||||
lastpt = startpt;
|
lastpt = startpt;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
@@ -118,13 +119,22 @@ namespace OpenNest.Converters
|
|||||||
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
if (line.StartPoint.DistanceTo(lastpt) > Tolerance.ChainTolerance)
|
||||||
pgm.MoveTo(line.StartPoint);
|
pgm.MoveTo(line.StartPoint);
|
||||||
|
|
||||||
var move = new LinearMove(line.EndPoint);
|
pgm.Codes.Add(new LinearMove(line.EndPoint) { Layer = ClassifyLayer(line) });
|
||||||
if (string.Equals(line.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
|
||||||
move.Layer = LayerType.Scribe;
|
|
||||||
pgm.Codes.Add(move);
|
|
||||||
|
|
||||||
lastpt = line.EndPoint;
|
lastpt = line.EndPoint;
|
||||||
return lastpt;
|
return lastpt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Engrave/etch geometry maps to Scribe so the post processor can treat it as a
|
||||||
|
// separate tool pass; everything else keeps the move's default Cut layer.
|
||||||
|
private static LayerType ClassifyLayer(Entity geo)
|
||||||
|
{
|
||||||
|
var name = geo.Layer?.Name;
|
||||||
|
if (string.Equals(name, "ENGRAVE", System.StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(name, "ETCH", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
return LayerType.Scribe;
|
||||||
|
|
||||||
|
return LayerType.Cut;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,13 +57,14 @@ namespace OpenNest.Geometry
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fits a circular arc constrained to be tangent to the given directions at both
|
/// Fits a circular arc that passes exactly through both the first and last points
|
||||||
/// the first and last points. The center lies at the intersection of the normals
|
/// while matching the given endpoint tangents as closely as possible. For any
|
||||||
/// at P1 and Pn, guaranteeing the arc departs P1 in the start direction and arrives
|
/// circle through two points, the tangents at those points make equal mirrored
|
||||||
/// at Pn in the end direction. Uses the radius from P1 (exact start tangent);
|
/// angles with the chord, so the achievable inscribed angle is the average of the
|
||||||
/// deviation includes any endpoint gap at Pn.
|
/// two requested ones — when the requested tangents are consistent with a single
|
||||||
|
/// circular arc, both are matched exactly.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static (Vector center, double radius, double deviation) FitWithDualTangent(
|
internal static (Vector center, double radius, double deviation) FitThroughEndpointsWithTangents(
|
||||||
List<Vector> points, Vector startTangent, Vector endTangent)
|
List<Vector> points, Vector startTangent, Vector endTangent)
|
||||||
{
|
{
|
||||||
if (points.Count < 3)
|
if (points.Count < 3)
|
||||||
@@ -72,42 +73,39 @@ namespace OpenNest.Geometry
|
|||||||
var p1 = points[0];
|
var p1 = points[0];
|
||||||
var pn = points[^1];
|
var pn = points[^1];
|
||||||
|
|
||||||
var stLen = System.Math.Sqrt(startTangent.X * startTangent.X + startTangent.Y * startTangent.Y);
|
|
||||||
var etLen = System.Math.Sqrt(endTangent.X * endTangent.X + endTangent.Y * endTangent.Y);
|
|
||||||
if (stLen < 1e-10 || etLen < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
// Normal to start tangent at P1 (perpendicular)
|
|
||||||
var n1x = -startTangent.Y / stLen;
|
|
||||||
var n1y = startTangent.X / stLen;
|
|
||||||
|
|
||||||
// Normal to end tangent at Pn
|
|
||||||
var n2x = -endTangent.Y / etLen;
|
|
||||||
var n2y = endTangent.X / etLen;
|
|
||||||
|
|
||||||
// Solve: P1 + t1*N1 = Pn + t2*N2
|
|
||||||
var det = n1x * (-n2y) - (-n2x) * n1y;
|
|
||||||
if (System.Math.Abs(det) < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
|
||||||
|
|
||||||
var dx = pn.X - p1.X;
|
var dx = pn.X - p1.X;
|
||||||
var dy = pn.Y - p1.Y;
|
var dy = pn.Y - p1.Y;
|
||||||
var t1 = (dx * (-n2y) - (-n2x) * dy) / det;
|
var chordLen = System.Math.Sqrt(dx * dx + dy * dy);
|
||||||
|
if (chordLen < 1e-10)
|
||||||
var cx = p1.X + t1 * n1x;
|
|
||||||
var cy = p1.Y + t1 * n1y;
|
|
||||||
|
|
||||||
// Use radius from P1 (guarantees exact start tangent and passes through P1)
|
|
||||||
var r1 = System.Math.Sqrt((cx - p1.X) * (cx - p1.X) + (cy - p1.Y) * (cy - p1.Y));
|
|
||||||
if (r1 < 1e-10)
|
|
||||||
return (Vector.Invalid, 0, double.MaxValue);
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
// Measure endpoint gap at Pn
|
var ux = dx / chordLen;
|
||||||
var r2 = System.Math.Sqrt((cx - pn.X) * (cx - pn.X) + (cy - pn.Y) * (cy - pn.Y));
|
var uy = dy / chordLen;
|
||||||
var endpointDev = System.Math.Abs(r2 - r1);
|
|
||||||
|
|
||||||
var interiorDev = MaxRadialDeviation(points, cx, cy, r1);
|
// Inscribed angle between chord and tangent at each endpoint (mirrored at Pn)
|
||||||
return (new Vector(cx, cy), r1, System.Math.Max(endpointDev, interiorDev));
|
var theta1 = SignedAngle(ux, uy, startTangent);
|
||||||
|
var theta2 = -SignedAngle(ux, uy, endTangent);
|
||||||
|
var theta = (theta1 + theta2) / 2;
|
||||||
|
|
||||||
|
// Nearly straight or degenerate (sweep would exceed ~356 degrees)
|
||||||
|
if (System.Math.Abs(theta) < 1e-3 || System.Math.Abs(theta) > System.Math.PI * 0.99)
|
||||||
|
return (Vector.Invalid, 0, double.MaxValue);
|
||||||
|
|
||||||
|
var halfChord = chordLen / 2;
|
||||||
|
var radius = halfChord / System.Math.Abs(System.Math.Sin(theta));
|
||||||
|
var d = -halfChord / System.Math.Tan(theta);
|
||||||
|
|
||||||
|
var cx = (p1.X + pn.X) / 2 + d * -uy;
|
||||||
|
var cy = (p1.Y + pn.Y) / 2 + d * ux;
|
||||||
|
|
||||||
|
return (new Vector(cx, cy), radius, MaxRadialDeviation(points, cx, cy, radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SignedAngle(double ux, double uy, Vector to)
|
||||||
|
{
|
||||||
|
var len = System.Math.Sqrt(to.X * to.X + to.Y * to.Y);
|
||||||
|
if (len < 1e-10) return 0;
|
||||||
|
return System.Math.Atan2(ux * to.Y - uy * to.X, ux * to.X + uy * to.Y);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -374,11 +374,8 @@ public class GeometrySimplifier
|
|||||||
var points = CollectPoints(entities, start, k);
|
var points = CollectPoints(entities, start, k);
|
||||||
if (points.Count < 3) return null;
|
if (points.Count < 3) return null;
|
||||||
|
|
||||||
var startTangent = chainedTangent.IsValid()
|
var startTangent = EstimateStartTangent(entities, start, points, chainedTangent);
|
||||||
? chainedTangent
|
var endTangent = EstimateEndTangent(entities, k, points);
|
||||||
: new Vector(points[1].X - points[0].X, points[1].Y - points[0].Y);
|
|
||||||
|
|
||||||
var endTangent = GetExitDirection(entities[k]);
|
|
||||||
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
var (center, radius, dev) = TryFit(points, startTangent, endTangent);
|
||||||
if (!center.IsValid()) return null;
|
if (!center.IsValid()) return null;
|
||||||
|
|
||||||
@@ -386,8 +383,10 @@ public class GeometrySimplifier
|
|||||||
while (k + 1 <= runEnd)
|
while (k + 1 <= runEnd)
|
||||||
{
|
{
|
||||||
var extPoints = CollectPoints(entities, start, k + 1);
|
var extPoints = CollectPoints(entities, start, k + 1);
|
||||||
var extEndTangent = GetExitDirection(entities[k + 1]);
|
if (extPoints.Count < 3) break;
|
||||||
var (nc, nr, nd) = extPoints.Count >= 3 ? TryFit(extPoints, startTangent, extEndTangent) : (Vector.Invalid, 0, 0d);
|
|
||||||
|
var extEndTangent = EstimateEndTangent(entities, k + 1, extPoints);
|
||||||
|
var (nc, nr, nd) = TryFit(extPoints, startTangent, extEndTangent);
|
||||||
if (!nc.IsValid()) break;
|
if (!nc.IsValid()) break;
|
||||||
|
|
||||||
k++;
|
k++;
|
||||||
@@ -407,37 +406,172 @@ public class GeometrySimplifier
|
|||||||
return new ArcFitResult(center, radius, dev, points, k);
|
return new ArcFitResult(center, radius, dev, points, k);
|
||||||
}
|
}
|
||||||
|
|
||||||
private (Vector center, double radius, double deviation) TryFit(List<Vector> points, Vector startTangent, Vector endTangent)
|
private (Vector center, double radius, double deviation) TryFit(
|
||||||
|
List<Vector> points, TangentEstimate start, TangentEstimate end)
|
||||||
{
|
{
|
||||||
// Try dual-tangent fit first (matches direction at both endpoints)
|
foreach (var (center, radius, dev) in FitAttempts(points, start, end))
|
||||||
if (endTangent.IsValid())
|
|
||||||
{
|
{
|
||||||
var (dc, dr, dd) = ArcFit.FitWithDualTangent(points, startTangent, endTangent);
|
|
||||||
if (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)
|
||||||
(center, radius, dev) = FitMirrorAxis(points);
|
continue;
|
||||||
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)
|
||||||
return (Vector.Invalid, 0, 0);
|
continue;
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.IO
|
||||||
|
{
|
||||||
|
public class ChrFont
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, ChrGlyph> glyphs = new();
|
||||||
|
|
||||||
|
public string Name { get; internal set; }
|
||||||
|
public string Version { get; internal set; }
|
||||||
|
public double CapHeight { get; internal set; } = 5000;
|
||||||
|
|
||||||
|
internal void AddGlyph(int charCode, ChrGlyph glyph)
|
||||||
|
{
|
||||||
|
glyphs[charCode] = glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasGlyph(int charCode) => glyphs.ContainsKey(charCode);
|
||||||
|
|
||||||
|
public ChrGlyph GetGlyph(int charCode) =>
|
||||||
|
glyphs.TryGetValue(charCode, out var g) ? g : null;
|
||||||
|
|
||||||
|
public double MeasureTextWidth(string text, double height)
|
||||||
|
{
|
||||||
|
var scale = height / CapHeight;
|
||||||
|
double width = 0;
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
var glyph = GetGlyph(ch);
|
||||||
|
if (glyph == null)
|
||||||
|
{
|
||||||
|
var space = GetGlyph(' ');
|
||||||
|
width += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
width += glyph.AdvanceWidth * scale;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Entity> RenderText(string text, double height, Vector position, Layer layer = null)
|
||||||
|
{
|
||||||
|
var scale = height / CapHeight;
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
var cursorX = position.X;
|
||||||
|
|
||||||
|
foreach (var ch in text)
|
||||||
|
{
|
||||||
|
var glyph = GetGlyph(ch);
|
||||||
|
if (glyph == null)
|
||||||
|
{
|
||||||
|
var space = GetGlyph(' ');
|
||||||
|
cursorX += (space?.AdvanceWidth ?? CapHeight * 0.6) * scale;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var glyphEntities = glyph.ToEntities(scale, cursorX, position.Y, layer);
|
||||||
|
entities.AddRange(glyphEntities);
|
||||||
|
cursorX += glyph.AdvanceWidth * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChrFont Read(string path, byte? xorKey = null)
|
||||||
|
{
|
||||||
|
var raw = File.ReadAllBytes(path);
|
||||||
|
|
||||||
|
// The whole file is obfuscated with a single-byte XOR. Different
|
||||||
|
// GravoStyle versions use different keys (0x2F in older releases,
|
||||||
|
// 0xCF in 7000-series). The font name at offset 0 is ASCII stored
|
||||||
|
// as UTF-16LE, so the high byte of its first character is 0x00 in
|
||||||
|
// plaintext — which means raw[1] is exactly the XOR key. Detect it
|
||||||
|
// from the file unless the caller forces a specific key.
|
||||||
|
var key = xorKey ?? (raw.Length > 1 ? raw[1] : (byte)0x2F);
|
||||||
|
|
||||||
|
var data = new byte[raw.Length];
|
||||||
|
for (var i = 0; i < raw.Length; i++)
|
||||||
|
data[i] = (byte)(raw[i] ^ key);
|
||||||
|
|
||||||
|
return Parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChrFont Parse(byte[] data)
|
||||||
|
{
|
||||||
|
var font = new ChrFont();
|
||||||
|
|
||||||
|
font.Name = Encoding.Unicode.GetString(data, 0, 26).TrimEnd('\0').Trim();
|
||||||
|
font.Version = Encoding.ASCII.GetString(data, 26, 12).TrimEnd('\0').Trim();
|
||||||
|
|
||||||
|
var charTable = new List<(int charCode, int offset)>();
|
||||||
|
var i = 0x40;
|
||||||
|
while (i + 5 < data.Length)
|
||||||
|
{
|
||||||
|
var charCode = data[i] | (data[i + 1] << 8);
|
||||||
|
var offset = data[i + 2] | (data[i + 3] << 8) | (data[i + 4] << 16) | (data[i + 5] << 24);
|
||||||
|
|
||||||
|
if (charCode < 0x20 || offset == 0 || offset >= data.Length)
|
||||||
|
break;
|
||||||
|
|
||||||
|
charTable.Add((charCode, offset));
|
||||||
|
i += 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var c = 0; c < charTable.Count; c++)
|
||||||
|
{
|
||||||
|
var (charCode, offset) = charTable[c];
|
||||||
|
|
||||||
|
var nextOffset = c + 1 < charTable.Count
|
||||||
|
? FindNextOffset(charTable, offset, data.Length)
|
||||||
|
: data.Length;
|
||||||
|
|
||||||
|
var glyph = ParseGlyph(data, offset, nextOffset);
|
||||||
|
if (glyph != null)
|
||||||
|
font.AddGlyph(charCode, glyph);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (font.glyphs.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var g in font.glyphs.Values)
|
||||||
|
{
|
||||||
|
if (g.CapHeight > 0)
|
||||||
|
{
|
||||||
|
font.CapHeight = g.CapHeight;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindNextOffset(List<(int charCode, int offset)> table, int currentOffset, int fileLength)
|
||||||
|
{
|
||||||
|
var best = fileLength;
|
||||||
|
foreach (var (_, off) in table)
|
||||||
|
{
|
||||||
|
if (off > currentOffset && off < best)
|
||||||
|
best = off;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChrGlyph ParseGlyph(byte[] data, int offset, int endOffset)
|
||||||
|
{
|
||||||
|
if (offset + 92 > data.Length)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var glyph = new ChrGlyph();
|
||||||
|
glyph.CapHeight = ReadBE16(data, offset + 15 * 2);
|
||||||
|
var bearing = System.Math.Abs(ReadBE16(data, offset + 18 * 2));
|
||||||
|
glyph.AdvanceWidth = ReadBE16(data, offset + 22 * 2) + bearing;
|
||||||
|
|
||||||
|
var strokeStart = offset + 92;
|
||||||
|
var pos = strokeStart;
|
||||||
|
var currentStroke = new List<ChrStrokePoint>();
|
||||||
|
|
||||||
|
while (pos + 5 < endOffset)
|
||||||
|
{
|
||||||
|
var cmd = ReadBE16(data, pos);
|
||||||
|
var x = ReadBE16(data, pos + 2);
|
||||||
|
var y = ReadBE16(data, pos + 4);
|
||||||
|
pos += 6;
|
||||||
|
|
||||||
|
if (System.Math.Abs(x) > 15000 || System.Math.Abs(y) > 15000)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (cmd < -1000)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var type = cmd switch
|
||||||
|
{
|
||||||
|
1 => ChrPointType.Vertex,
|
||||||
|
4 => ChrPointType.Control,
|
||||||
|
5 => ChrPointType.EndPoint,
|
||||||
|
_ => ChrPointType.Vertex,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentStroke.Add(new ChrStrokePoint(type, x, y));
|
||||||
|
|
||||||
|
if (type == ChrPointType.EndPoint)
|
||||||
|
{
|
||||||
|
if (currentStroke.Count > 0)
|
||||||
|
glyph.Strokes.Add(currentStroke);
|
||||||
|
currentStroke = new List<ChrStrokePoint>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStroke.Count > 0)
|
||||||
|
glyph.Strokes.Add(currentStroke);
|
||||||
|
|
||||||
|
return glyph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadBE16(byte[] data, int offset)
|
||||||
|
{
|
||||||
|
var val = (data[offset] << 8) | data[offset + 1];
|
||||||
|
if (val > 32767) val -= 65536;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal enum ChrPointType
|
||||||
|
{
|
||||||
|
Vertex,
|
||||||
|
Control,
|
||||||
|
EndPoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal struct ChrStrokePoint
|
||||||
|
{
|
||||||
|
public ChrPointType Type;
|
||||||
|
public double X;
|
||||||
|
public double Y;
|
||||||
|
|
||||||
|
public ChrStrokePoint(ChrPointType type, double x, double y)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
X = x;
|
||||||
|
Y = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChrGlyph
|
||||||
|
{
|
||||||
|
internal readonly List<List<ChrStrokePoint>> Strokes = new();
|
||||||
|
|
||||||
|
public double AdvanceWidth { get; internal set; }
|
||||||
|
public double CapHeight { get; internal set; }
|
||||||
|
|
||||||
|
private const int ArcSamples = 16;
|
||||||
|
|
||||||
|
public List<Entity> ToEntities(double scale, double offsetX, double offsetY, Layer layer = null)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
layer ??= Layer.Default;
|
||||||
|
|
||||||
|
foreach (var stroke in Strokes)
|
||||||
|
{
|
||||||
|
if (stroke.Count < 2) continue;
|
||||||
|
|
||||||
|
var segments = BuildSegments(stroke);
|
||||||
|
foreach (var seg in segments)
|
||||||
|
{
|
||||||
|
if (seg.Points.Count < 2) continue;
|
||||||
|
|
||||||
|
var scaled = new List<Vector>(seg.Points.Count);
|
||||||
|
foreach (var pt in seg.Points)
|
||||||
|
scaled.Add(new Vector(pt.X * scale + offsetX, pt.Y * scale + offsetY));
|
||||||
|
|
||||||
|
var converted = PointsToLines(scaled);
|
||||||
|
|
||||||
|
foreach (var e in converted)
|
||||||
|
{
|
||||||
|
e.Layer = layer;
|
||||||
|
entities.Add(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Entity> PointsToLines(List<Vector> points)
|
||||||
|
{
|
||||||
|
var entities = new List<Entity>();
|
||||||
|
for (var i = 0; i < points.Count - 1; i++)
|
||||||
|
{
|
||||||
|
if (points[i].DistanceTo(points[i + 1]) < 0.001)
|
||||||
|
continue;
|
||||||
|
entities.Add(new Line(points[i], points[i + 1]));
|
||||||
|
}
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StrokeSegment> BuildSegments(List<ChrStrokePoint> stroke)
|
||||||
|
{
|
||||||
|
var segments = new List<StrokeSegment>();
|
||||||
|
var current = new StrokeSegment();
|
||||||
|
|
||||||
|
var i = 0;
|
||||||
|
while (i < stroke.Count)
|
||||||
|
{
|
||||||
|
var pt = stroke[i];
|
||||||
|
|
||||||
|
if (pt.Type == ChrPointType.Vertex || pt.Type == ChrPointType.EndPoint)
|
||||||
|
{
|
||||||
|
if (i + 1 < stroke.Count && stroke[i + 1].Type == ChrPointType.Control)
|
||||||
|
{
|
||||||
|
var p0 = new Vector(pt.X, pt.Y);
|
||||||
|
var pMid = new Vector(stroke[i + 1].X, stroke[i + 1].Y);
|
||||||
|
var p2End = i + 2 < stroke.Count ? stroke[i + 2] : stroke[i + 1];
|
||||||
|
var p1 = new Vector(p2End.X, p2End.Y);
|
||||||
|
|
||||||
|
SampleCircularArc(current.Points, p0, pMid, p1, ArcSamples);
|
||||||
|
current.HasCurves = true;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current.Points.Add(new Vector(pt.X, pt.Y));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Points.Count >= 2)
|
||||||
|
segments.Add(current);
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class StrokeSegment
|
||||||
|
{
|
||||||
|
public readonly List<Vector> Points = new();
|
||||||
|
public bool HasCurves;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SampleCircularArc(List<Vector> output, Vector p0, Vector pMid, Vector p1, int samples)
|
||||||
|
{
|
||||||
|
if (output.Count == 0 || output[^1].DistanceTo(p0) > 0.01)
|
||||||
|
output.Add(p0);
|
||||||
|
|
||||||
|
double ax = p0.X, ay = p0.Y;
|
||||||
|
double bx = pMid.X, by = pMid.Y;
|
||||||
|
double cx = p1.X, cy = p1.Y;
|
||||||
|
|
||||||
|
var d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
|
||||||
|
|
||||||
|
if (System.Math.Abs(d) < 1e-6)
|
||||||
|
{
|
||||||
|
output.Add(pMid);
|
||||||
|
output.Add(p1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
|
||||||
|
var uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
|
||||||
|
var radius = System.Math.Sqrt((ax - ux) * (ax - ux) + (ay - uy) * (ay - uy));
|
||||||
|
|
||||||
|
var a0 = System.Math.Atan2(ay - uy, ax - ux);
|
||||||
|
var am = System.Math.Atan2(by - uy, bx - ux);
|
||||||
|
var a1 = System.Math.Atan2(cy - uy, cx - ux);
|
||||||
|
|
||||||
|
var ccwSweep = a1 - a0;
|
||||||
|
while (ccwSweep <= 0) ccwSweep += 2 * System.Math.PI;
|
||||||
|
|
||||||
|
var midRel = am - a0;
|
||||||
|
while (midRel < 0) midRel += 2 * System.Math.PI;
|
||||||
|
|
||||||
|
var sweep = midRel < ccwSweep ? ccwSweep : ccwSweep - 2 * System.Math.PI;
|
||||||
|
|
||||||
|
for (var i = 1; i <= samples; i++)
|
||||||
|
{
|
||||||
|
var t = (double)i / samples;
|
||||||
|
var angle = a0 + sweep * t;
|
||||||
|
output.Add(new Vector(ux + radius * System.Math.Cos(angle), uy + radius * System.Math.Sin(angle)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,6 +242,7 @@ namespace OpenNest.IO
|
|||||||
public ExportContext()
|
public ExportContext()
|
||||||
{
|
{
|
||||||
Document = new CadDocument();
|
Document = new CadDocument();
|
||||||
|
Document.Header.Version = ACadVersion.AC1018;
|
||||||
|
|
||||||
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
CutLayer = new Layer("Cut") { Color = new Color(1) };
|
||||||
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
RapidLayer = new Layer("Rapid") { Color = new Color(5) };
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
|
||||||
|
namespace OpenNest.Posts.GravographIS
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cut parameters for one kind of pass (engrave or cut). Edited in the post
|
||||||
|
/// configuration PropertyGrid and persisted to JSON.
|
||||||
|
/// </summary>
|
||||||
|
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||||
|
public sealed class LayerCutConfig
|
||||||
|
{
|
||||||
|
[DisplayName("Feed (mm/sec)")]
|
||||||
|
[Description("XY and Z feed for this pass. Patches the VS and VZ wire commands.")]
|
||||||
|
public int FeedMmPerSec { get; set; } = 10;
|
||||||
|
|
||||||
|
[DisplayName("Depth (inches)")]
|
||||||
|
[Description("Programmed Z plunge (DZ). Note: the spring-floated spindle means this does not set actual cut depth — tool protrusion does.")]
|
||||||
|
public double Depth { get; set; } = 0.25;
|
||||||
|
|
||||||
|
[DisplayName("Pause Before")]
|
||||||
|
[Description("Stop the spindle and prompt the operator before this pass begins, so the tool can be swapped/adjusted.")]
|
||||||
|
public bool PauseBefore { get; set; }
|
||||||
|
|
||||||
|
[DisplayName("Pause Message")]
|
||||||
|
[Description("Message shown on the controller during the pause.")]
|
||||||
|
public string PauseMessage { get; set; } = "";
|
||||||
|
|
||||||
|
public override string ToString() => $"{FeedMmPerSec} mm/s, {Depth:0.###}\"" + (PauseBefore ? ", pause" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the Gravograph IS post processor: one <see cref="LayerCutConfig"/>
|
||||||
|
/// per cut kind. The engrave block applies to <see cref="LayerType.Scribe"/> paths,
|
||||||
|
/// the cut block to <see cref="LayerType.Cut"/>/<see cref="LayerType.Leadin"/>/<see cref="LayerType.Leadout"/>.
|
||||||
|
/// The cut block carries the tool-change pause by default.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GravographISPostConfig
|
||||||
|
{
|
||||||
|
[Category("Engrave (Scribe)")]
|
||||||
|
[DisplayName("Engrave")]
|
||||||
|
[Description("Parameters for engrave/scribe geometry (text).")]
|
||||||
|
public LayerCutConfig Engrave { get; set; } = new LayerCutConfig
|
||||||
|
{
|
||||||
|
FeedMmPerSec = 10,
|
||||||
|
Depth = 0.25,
|
||||||
|
PauseBefore = false,
|
||||||
|
PauseMessage = "",
|
||||||
|
};
|
||||||
|
|
||||||
|
[Category("Cut")]
|
||||||
|
[DisplayName("Cut")]
|
||||||
|
[Description("Parameters for cut geometry (outlines). Pauses for a tool change by default.")]
|
||||||
|
public LayerCutConfig Cut { get; set; } = new LayerCutConfig
|
||||||
|
{
|
||||||
|
FeedMmPerSec = 3,
|
||||||
|
Depth = 0.25,
|
||||||
|
PauseBefore = true,
|
||||||
|
PauseMessage = "Change tool",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the cut config a polyline of the given layer should use, or null
|
||||||
|
/// if the layer is non-cutting (<see cref="LayerType.Display"/>) and should be skipped.
|
||||||
|
/// </summary>
|
||||||
|
public LayerCutConfig ConfigFor(LayerType layer)
|
||||||
|
{
|
||||||
|
switch (layer)
|
||||||
|
{
|
||||||
|
case LayerType.Scribe:
|
||||||
|
return Engrave;
|
||||||
|
case LayerType.Cut:
|
||||||
|
case LayerType.Leadin:
|
||||||
|
case LayerType.Leadout:
|
||||||
|
return Cut;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
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 : IPostProcessor
|
public sealed class GravographISPostProcessor : IConfigurablePostProcessor
|
||||||
{
|
{
|
||||||
|
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)";
|
||||||
@@ -23,14 +37,53 @@ 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 polylines = Extractor.Extract(nest);
|
var passes = BuildPasses(Extractor.ExtractLayered(nest));
|
||||||
var prepared = PolylinePrePass.Prepare(polylines, StitchTolerance, AllowReverse);
|
new GravographISWriter(WriterOptions).Write(passes, outputStream);
|
||||||
new GravographISWriter(WriterOptions).Write(prepared, outputStream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Post(Nest nest, string outputFile)
|
public void Post(Nest nest, string outputFile)
|
||||||
@@ -39,6 +92,52 @@ 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>
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
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.
|
||||||
@@ -84,12 +104,40 @@ 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)Options.FeedMmPerSec);
|
PatchOperand(preamble, (byte)'V', (byte)'S', (short)firstFeed);
|
||||||
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)Options.FeedMmPerSec);
|
PatchOperand(preamble, (byte)'V', (byte)'Z', (short)firstFeed);
|
||||||
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16());
|
PatchOperand(preamble, (byte)'D', (byte)'Z', DepthInStepsAsInt16(firstDepth));
|
||||||
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
|
||||||
@@ -105,13 +153,67 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
|
|
||||||
var firstPolyline = true;
|
var firstPolyline = true;
|
||||||
var polyIndex = 0;
|
var polyIndex = 0;
|
||||||
|
var currentFeed = firstFeed;
|
||||||
|
var currentDepth = firstDepth;
|
||||||
|
|
||||||
foreach (var poly in polylines)
|
for (var p = 0; p < passes.Count; p++)
|
||||||
|
{
|
||||||
|
var pass = passes[p];
|
||||||
|
|
||||||
|
if (p > 0)
|
||||||
|
{
|
||||||
|
if (pass.PauseBefore)
|
||||||
|
{
|
||||||
|
// Park: lift Z, then rapid (pen-up) back to the operator origin so
|
||||||
|
// the head is clear of the work while the tool is swapped.
|
||||||
|
WriteLiftOnly(output);
|
||||||
|
WriteTravel(output, (byte)'P', (byte)'U',
|
||||||
|
checked(-headX), checked(-headY),
|
||||||
|
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
||||||
|
WritePauseCore(output, pass.PauseMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pass.FeedMmPerSec != currentFeed)
|
||||||
|
{
|
||||||
|
WriteCommand(output, (byte)'V', (byte)'S', (short)pass.FeedMmPerSec);
|
||||||
|
WriteCommand(output, (byte)'V', (byte)'Z', (short)pass.FeedMmPerSec);
|
||||||
|
currentFeed = pass.FeedMmPerSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pass.DepthInches != currentDepth)
|
||||||
|
{
|
||||||
|
WriteCommand(output, (byte)'D', (byte)'Z', DepthInStepsAsInt16(pass.DepthInches));
|
||||||
|
currentDepth = pass.DepthInches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pass.Polylines == null) continue;
|
||||||
|
|
||||||
|
foreach (var poly in pass.Polylines)
|
||||||
{
|
{
|
||||||
polyIndex++;
|
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',
|
||||||
@@ -146,14 +248,49 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
firstPolyline = false;
|
firstPolyline = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteLiftOnly(output);
|
// The operator pause, minus the leading lift/park which the caller emits.
|
||||||
if (Options.ReturnToOriginAtEnd && !firstPolyline)
|
// 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)
|
||||||
{
|
{
|
||||||
WriteTravel(output, (byte)'P', (byte)'U',
|
WriteCommandRaw(s, (byte)'M', (byte)'C', 0x00, 0x00); // motor off
|
||||||
checked(-headX), checked(-headY),
|
WriteCommandRaw(s, (byte)'O', (byte)'U', 0xFF, 0xFB); // aux off
|
||||||
ref headX, ref headY, envelopeXSteps, envelopeYSteps, polyIndex);
|
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
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -181,11 +318,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 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)
|
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;
|
return (short)steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
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 tessellates arcs to a chord-deviation
|
/// polylines at rapid moves and at <see cref="LayerType"/> changes, and
|
||||||
/// tolerance (the wire format takes line segments only).
|
/// tessellates arcs to a chord-deviation tolerance (the wire format takes
|
||||||
|
/// line segments only).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NestPolylineExtractor
|
public sealed class NestPolylineExtractor
|
||||||
{
|
{
|
||||||
@@ -17,13 +37,31 @@ 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).
|
/// returning them in plate coordinates (inches). Layer information is dropped;
|
||||||
|
/// 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<List<Vector>>();
|
var result = new List<LayeredPolyline>();
|
||||||
|
|
||||||
foreach (var plate in nest.Plates)
|
foreach (var plate in nest.Plates)
|
||||||
{
|
{
|
||||||
@@ -40,17 +78,17 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts polylines for a single part. Public so callers driving the
|
/// Extracts layer-tagged polylines for a single part. Public so callers
|
||||||
/// writer directly (e.g. from a console one-off) can use it.
|
/// driving the writer directly (e.g. from a console one-off) can use it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<List<Vector>> ExtractPart(Part part)
|
public List<LayeredPolyline> ExtractPartLayered(Part part)
|
||||||
{
|
{
|
||||||
var list = new List<List<Vector>>();
|
var list = new List<LayeredPolyline>();
|
||||||
ExtractPart(part, list);
|
ExtractPart(part, list);
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ExtractPart(Part part, List<List<Vector>> sink)
|
private void ExtractPart(Part part, List<LayeredPolyline> sink)
|
||||||
{
|
{
|
||||||
var program = part.Program;
|
var program = part.Program;
|
||||||
if (program == null) return;
|
if (program == null) return;
|
||||||
@@ -67,6 +105,7 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -77,17 +116,14 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
{
|
{
|
||||||
case RapidMove rapid:
|
case RapidMove rapid:
|
||||||
{
|
{
|
||||||
FlushCurrent(sink, ref current);
|
FlushCurrent(sink, ref current, currentLayer);
|
||||||
pos = rapid.EndPoint;
|
pos = rapid.EndPoint;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case LinearMove linear:
|
case LinearMove linear:
|
||||||
{
|
{
|
||||||
if (current == null)
|
StartOrSplit(sink, ref current, ref currentLayer, linear.Layer, pos + offset);
|
||||||
{
|
|
||||||
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;
|
||||||
@@ -96,10 +132,7 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
|
|
||||||
case ArcMove arc:
|
case ArcMove arc:
|
||||||
{
|
{
|
||||||
if (current == null)
|
StartOrSplit(sink, ref current, ref currentLayer, arc.Layer, pos + offset);
|
||||||
{
|
|
||||||
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;
|
||||||
@@ -107,13 +140,33 @@ namespace OpenNest.Posts.GravographIS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FlushCurrent(sink, ref current);
|
FlushCurrent(sink, ref current, currentLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void FlushCurrent(List<List<Vector>> sink, ref List<Vector> 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<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(current);
|
sink.Add(new LayeredPolyline(current, layer));
|
||||||
current = null;
|
current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Converters;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.Converters;
|
||||||
|
|
||||||
|
public class ConvertGeometryLayerTests
|
||||||
|
{
|
||||||
|
private static Program ProgramFor(Entity entity)
|
||||||
|
{
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(entity);
|
||||||
|
return ConvertGeometry.ToProgram(shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddLine_EngraveLayer_TagsScribe()
|
||||||
|
{
|
||||||
|
var line = new Line(0, 0, 1, 0) { Layer = new Layer("ENGRAVE") };
|
||||||
|
|
||||||
|
var pgm = ProgramFor(line);
|
||||||
|
|
||||||
|
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddLine_EtchLayer_TagsScribe()
|
||||||
|
{
|
||||||
|
var line = new Line(0, 0, 1, 0) { Layer = new Layer("etch") };
|
||||||
|
|
||||||
|
var pgm = ProgramFor(line);
|
||||||
|
|
||||||
|
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddArc_EngraveLayer_TagsScribe()
|
||||||
|
{
|
||||||
|
var arc = new Arc(new Vector(0, 0), 1, 0, System.Math.PI / 2) { Layer = new Layer("ENGRAVE") };
|
||||||
|
|
||||||
|
var pgm = ProgramFor(arc);
|
||||||
|
|
||||||
|
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||||
|
Assert.NotEmpty(arcs);
|
||||||
|
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddCircle_EngraveLayer_TagsScribe()
|
||||||
|
{
|
||||||
|
var circle = new Circle(0, 0, 1) { Layer = new Layer("ENGRAVE") };
|
||||||
|
|
||||||
|
var pgm = ProgramFor(circle);
|
||||||
|
|
||||||
|
var arcs = pgm.Codes.OfType<ArcMove>().ToList();
|
||||||
|
Assert.NotEmpty(arcs);
|
||||||
|
Assert.All(arcs, m => Assert.Equal(LayerType.Scribe, m.Layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddLine_DefaultLayer_StaysCut()
|
||||||
|
{
|
||||||
|
var line = new Line(0, 0, 1, 0) { Layer = new Layer("0") };
|
||||||
|
|
||||||
|
var pgm = ProgramFor(line);
|
||||||
|
|
||||||
|
Assert.All(pgm.Codes.OfType<LinearMove>(), m => Assert.Equal(LayerType.Cut, m.Layer));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,111 @@ public class GeometrySimplifierTests
|
|||||||
Assert.IsType<Arc>(result.Entities[6]);
|
Assert.IsType<Arc>(result.Entities[6]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_FilletBetweenTangentLines_ArcIsTangentToLines()
|
||||||
|
{
|
||||||
|
// A 90-degree fillet (r=0.3, center origin, 270deg..360deg CCW) between two
|
||||||
|
// long tangent lines, approximated by 8 chords whose interior vertices bulge
|
||||||
|
// radially outward within tolerance (simulates real DXF tessellation noise).
|
||||||
|
var r = 0.3;
|
||||||
|
var deltas = new[] { 0.0, 0.002, 0.003, 0.0035, 0.0035, 0.0035, 0.003, 0.002, 0.0 };
|
||||||
|
var pts = new List<Vector>();
|
||||||
|
for (var i = 0; i <= 8; i++)
|
||||||
|
{
|
||||||
|
var ang = OpenNest.Math.Angle.ToRadians(270 + 11.25 * i);
|
||||||
|
var radius = r + deltas[i];
|
||||||
|
pts.Add(new Vector(radius * System.Math.Cos(ang), radius * System.Math.Sin(ang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var shape = new Shape();
|
||||||
|
shape.Entities.Add(new Line(new Vector(-2, -r), pts[0]));
|
||||||
|
for (var i = 0; i < pts.Count - 1; i++)
|
||||||
|
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||||
|
shape.Entities.Add(new Line(pts[^1], new Vector(r, 2)));
|
||||||
|
|
||||||
|
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||||
|
var candidates = simplifier.Analyze(shape);
|
||||||
|
|
||||||
|
Assert.Single(candidates);
|
||||||
|
var arc = candidates[0].FittedArc;
|
||||||
|
|
||||||
|
// Arc must pass exactly through the run's boundary vertices (no gaps)
|
||||||
|
Assert.True(arc.StartPoint().DistanceTo(pts[0]) < 1e-6);
|
||||||
|
Assert.True(arc.EndPoint().DistanceTo(pts[^1]) < 1e-6);
|
||||||
|
|
||||||
|
// Arc must be tangent to the adjacent straight edges at its endpoints
|
||||||
|
var startDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.StartPoint()), new Vector(1, 0));
|
||||||
|
var endDelta = AngleBetweenDeg(ArcTangentAt(arc, arc.EndPoint()), new Vector(0, 1));
|
||||||
|
Assert.True(startDelta < 0.3, $"Arc start not tangent to incoming line: off by {startDelta:F3} deg");
|
||||||
|
Assert.True(endDelta < 0.3, $"Arc end not tangent to outgoing line: off by {endDelta:F3} deg");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Analyze_CompoundCurve_AdjacentArcsAreTangentAtJunction()
|
||||||
|
{
|
||||||
|
// Two tangent-continuous arcs of different radii (r=0.2 sweeping 60deg, then
|
||||||
|
// r=0.6 sweeping 40deg), tessellated into chords with slight radial noise.
|
||||||
|
// The fitted arcs must stay tangent-continuous at their junction.
|
||||||
|
var c1 = new Vector(0, 0);
|
||||||
|
var r1 = 0.2;
|
||||||
|
var deltas1 = new[] { 0.0, 0.001, 0.0005, -0.0005, -0.001, -0.0005, 0.0 };
|
||||||
|
var pts = new List<Vector>();
|
||||||
|
for (var i = 0; i <= 6; i++)
|
||||||
|
{
|
||||||
|
var ang = OpenNest.Math.Angle.ToRadians(10 * i);
|
||||||
|
var radius = r1 + deltas1[i];
|
||||||
|
pts.Add(new Vector(c1.X + radius * System.Math.Cos(ang), c1.Y + radius * System.Math.Sin(ang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second arc center along the junction radius so tangents match at the junction
|
||||||
|
var junctionAngle = OpenNest.Math.Angle.ToRadians(60);
|
||||||
|
var u = new Vector(System.Math.Cos(junctionAngle), System.Math.Sin(junctionAngle));
|
||||||
|
var r2 = 0.6;
|
||||||
|
var c2 = new Vector(c1.X + u.X * (r1 - r2), c1.Y + u.Y * (r1 - r2));
|
||||||
|
var deltas2 = new[] { 0.0, 0.001, -0.001, 0.0005, -0.0005, 0.0 };
|
||||||
|
for (var i = 1; i <= 5; i++)
|
||||||
|
{
|
||||||
|
var ang = OpenNest.Math.Angle.ToRadians(60 + 8 * i);
|
||||||
|
var radius = r2 + deltas2[i];
|
||||||
|
pts.Add(new Vector(c2.X + radius * System.Math.Cos(ang), c2.Y + radius * System.Math.Sin(ang)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var shape = new Shape();
|
||||||
|
for (var i = 0; i < pts.Count - 1; i++)
|
||||||
|
shape.Entities.Add(new Line(pts[i], pts[i + 1]));
|
||||||
|
|
||||||
|
var simplifier = new GeometrySimplifier { Tolerance = 0.004 };
|
||||||
|
var candidates = simplifier.Analyze(shape);
|
||||||
|
|
||||||
|
Assert.Equal(2, candidates.Count);
|
||||||
|
var arcA = candidates[0].FittedArc;
|
||||||
|
var arcB = candidates[1].FittedArc;
|
||||||
|
|
||||||
|
// Arcs must share the junction vertex exactly
|
||||||
|
Assert.True(arcA.EndPoint().DistanceTo(arcB.StartPoint()) < 1e-6);
|
||||||
|
|
||||||
|
// Tangent continuity across the junction
|
||||||
|
var junctionDelta = AngleBetweenDeg(ArcTangentAt(arcA, arcA.EndPoint()), ArcTangentAt(arcB, arcB.StartPoint()));
|
||||||
|
Assert.True(junctionDelta < 0.3, $"Tangent break of {junctionDelta:F3} deg at arc-arc junction");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector ArcTangentAt(Arc arc, Vector pt)
|
||||||
|
{
|
||||||
|
var ang = System.Math.Atan2(pt.Y - arc.Center.Y, pt.X - arc.Center.X);
|
||||||
|
return arc.IsReversed
|
||||||
|
? new Vector(System.Math.Sin(ang), -System.Math.Cos(ang))
|
||||||
|
: new Vector(-System.Math.Sin(ang), System.Math.Cos(ang));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double AngleBetweenDeg(Vector v1, Vector v2)
|
||||||
|
{
|
||||||
|
var l1 = System.Math.Sqrt(v1.X * v1.X + v1.Y * v1.Y);
|
||||||
|
var l2 = System.Math.Sqrt(v2.X * v2.X + v2.Y * v2.Y);
|
||||||
|
var dot = (v1.X * v2.X + v1.Y * v2.Y) / (l1 * l2);
|
||||||
|
dot = System.Math.Max(-1, System.Math.Min(1, dot));
|
||||||
|
return System.Math.Acos(dot) * 180.0 / System.Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
public void Apply_DynaPanDxf_NoGapsAfterSimplification()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.GravographIS;
|
||||||
|
|
||||||
|
public class GravographISPostConfigTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_EngraveBlock_NoPause_FasterFeed()
|
||||||
|
{
|
||||||
|
var config = new GravographISPostConfig();
|
||||||
|
|
||||||
|
Assert.Equal(10, config.Engrave.FeedMmPerSec);
|
||||||
|
Assert.False(config.Engrave.PauseBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defaults_CutBlock_PausesToChangeTool()
|
||||||
|
{
|
||||||
|
var config = new GravographISPostConfig();
|
||||||
|
|
||||||
|
Assert.Equal(3, config.Cut.FeedMmPerSec);
|
||||||
|
Assert.True(config.Cut.PauseBefore);
|
||||||
|
Assert.Equal("Change tool", config.Cut.PauseMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LayerType.Scribe)]
|
||||||
|
public void ConfigFor_Scribe_ReturnsEngraveBlock(LayerType layer)
|
||||||
|
{
|
||||||
|
var config = new GravographISPostConfig();
|
||||||
|
Assert.Same(config.Engrave, config.ConfigFor(layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(LayerType.Cut)]
|
||||||
|
[InlineData(LayerType.Leadin)]
|
||||||
|
[InlineData(LayerType.Leadout)]
|
||||||
|
public void ConfigFor_CutLayers_ReturnCutBlock(LayerType layer)
|
||||||
|
{
|
||||||
|
var config = new GravographISPostConfig();
|
||||||
|
Assert.Same(config.Cut, config.ConfigFor(layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigFor_Display_ReturnsNull_SoItIsSkipped()
|
||||||
|
{
|
||||||
|
var config = new GravographISPostConfig();
|
||||||
|
Assert.Null(config.ConfigFor(LayerType.Display));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using OpenNest.CNC;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.Posts.GravographIS;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.GravographIS;
|
||||||
|
|
||||||
|
public class GravographISPostProcessorTests
|
||||||
|
{
|
||||||
|
private static LayeredPolyline Poly(LayerType layer, params Vector[] pts)
|
||||||
|
=> new LayeredPolyline(new List<Vector>(pts), layer);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPasses_EngraveAndCut_OrdersEngraveFirstThenCutWithPause()
|
||||||
|
{
|
||||||
|
var post = new GravographISPostProcessor();
|
||||||
|
|
||||||
|
var passes = post.BuildPasses(new[]
|
||||||
|
{
|
||||||
|
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||||
|
Poly(LayerType.Scribe, new Vector(0, 0), new Vector(0, 1)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, passes.Count);
|
||||||
|
|
||||||
|
Assert.Equal(post.Config.Engrave.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||||
|
Assert.False(passes[0].PauseBefore);
|
||||||
|
|
||||||
|
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[1].FeedMmPerSec);
|
||||||
|
Assert.True(passes[1].PauseBefore);
|
||||||
|
Assert.Equal("Change tool", passes[1].PauseMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPasses_CutOnly_IsSinglePass()
|
||||||
|
{
|
||||||
|
var post = new GravographISPostProcessor();
|
||||||
|
|
||||||
|
var passes = post.BuildPasses(new[]
|
||||||
|
{
|
||||||
|
Poly(LayerType.Cut, new Vector(0, 0), new Vector(1, 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Single(passes);
|
||||||
|
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildPasses_SkipsDisplayGeometry()
|
||||||
|
{
|
||||||
|
var post = new GravographISPostProcessor();
|
||||||
|
|
||||||
|
var passes = post.BuildPasses(new[]
|
||||||
|
{
|
||||||
|
Poly(LayerType.Display, new Vector(0, 0), new Vector(1, 0)),
|
||||||
|
Poly(LayerType.Cut, new Vector(0, 0), new Vector(0, 1)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Single(passes);
|
||||||
|
Assert.Equal(post.Config.Cut.FeedMmPerSec, passes[0].FeedMmPerSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Config_IsExposedThroughConfigurableInterface()
|
||||||
|
{
|
||||||
|
var post = new GravographISPostProcessor(new GravographISPostConfig());
|
||||||
|
OpenNest.IConfigurablePostProcessor configurable = post;
|
||||||
|
Assert.Same(post.Config, configurable.Config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,6 +178,101 @@ 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,4 +34,45 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using OpenNest.Geometry;
|
||||||
|
using OpenNest.IO;
|
||||||
|
|
||||||
|
namespace OpenNest.Tests.IO;
|
||||||
|
|
||||||
|
public class ChrFontTests
|
||||||
|
{
|
||||||
|
private ChrFont LoadFont()
|
||||||
|
{
|
||||||
|
var path = TestConfig.GetExistingPath("ChrFontPath");
|
||||||
|
Skip.If(path == null, "ChrFontPath not configured in test-config.json or file not found");
|
||||||
|
return ChrFont.Read(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_ParsesFontName()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.Equal("US BLOCK 1L", font.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_ParsesVersion()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.StartsWith("C1.", font.Version);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_HasAsciiGlyphs()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.True(font.HasGlyph('A'));
|
||||||
|
Assert.True(font.HasGlyph('Z'));
|
||||||
|
Assert.True(font.HasGlyph('0'));
|
||||||
|
Assert.True(font.HasGlyph(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Read_HasExtendedGlyphs()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
Assert.True(font.HasGlyph(0xC7)); // C-cedilla
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_L_ProducesLines()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('L');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
Assert.True(entities.Count >= 2, $"Expected at least 2 entities for 'L', got {entities.Count}");
|
||||||
|
Assert.All(entities, e => Assert.Equal(EntityType.Line, e.Type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_O_ProducesEntities()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('O');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
Assert.True(entities.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_ProducesEntities()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var entities = font.RenderText("HELLO", 1.0, new Vector(0, 0));
|
||||||
|
Assert.True(entities.Count > 0, "RenderText should produce entities");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_ScalesCorrectly()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
|
||||||
|
var small = font.RenderText("A", 0.5, Vector.Zero);
|
||||||
|
var large = font.RenderText("A", 2.0, Vector.Zero);
|
||||||
|
|
||||||
|
var smallBox = small.GetBoundingBox();
|
||||||
|
var largeBox = large.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(largeBox.Width > smallBox.Width);
|
||||||
|
Assert.True(largeBox.Length > smallBox.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_AdvancesCursor()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var abEntities = font.RenderText("AB", 1.0, Vector.Zero);
|
||||||
|
var aEntities = font.RenderText("A", 1.0, Vector.Zero);
|
||||||
|
|
||||||
|
var abBox = abEntities.GetBoundingBox();
|
||||||
|
var aBox = aEntities.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(abBox.Length > aBox.Length * 1.5,
|
||||||
|
$"AB width ({abBox.Length:F1}) should be significantly wider than A width ({aBox.Length:F1})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void RenderText_MatchesGravographReference()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
|
||||||
|
var height = 5.08;
|
||||||
|
var centerX = 50.8;
|
||||||
|
var centerY = 34.925;
|
||||||
|
|
||||||
|
var entities = font.RenderText("Text", height, Vector.Zero);
|
||||||
|
var rawBox = entities.GetBoundingBox();
|
||||||
|
var shiftX = centerX - (rawBox.Left + rawBox.Right) / 2;
|
||||||
|
var shiftY = centerY - (rawBox.Top + rawBox.Bottom) / 2;
|
||||||
|
foreach (var e in entities)
|
||||||
|
e.Offset(new Vector(shiftX, shiftY));
|
||||||
|
Assert.True(entities.Count > 0, "Should produce entities for 'Text'");
|
||||||
|
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
|
||||||
|
var refLeft = 43.53;
|
||||||
|
var refRight = 58.07;
|
||||||
|
var refBottom = 32.39;
|
||||||
|
var refTop = 37.47;
|
||||||
|
|
||||||
|
var tolerance = 0.5;
|
||||||
|
|
||||||
|
Assert.True(System.Math.Abs(box.Left - refLeft) < tolerance,
|
||||||
|
$"Left: ours={box.Left:F2}, ref={refLeft:F2}, diff={System.Math.Abs(box.Left - refLeft):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Right - refRight) < tolerance,
|
||||||
|
$"Right: ours={box.Right:F2}, ref={refRight:F2}, diff={System.Math.Abs(box.Right - refRight):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Bottom - refBottom) < tolerance,
|
||||||
|
$"Bottom: ours={box.Bottom:F2}, ref={refBottom:F2}, diff={System.Math.Abs(box.Bottom - refBottom):F2}");
|
||||||
|
Assert.True(System.Math.Abs(box.Top - refTop) < tolerance,
|
||||||
|
$"Top: ours={box.Top:F2}, ref={refTop:F2}, diff={System.Math.Abs(box.Top - refTop):F2}");
|
||||||
|
|
||||||
|
var actualCapHeight = box.Top - box.Bottom;
|
||||||
|
Assert.True(System.Math.Abs(actualCapHeight - height) < 0.5,
|
||||||
|
$"Cap height: ours={actualCapHeight:F2}, expected={height:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void MeasureTextWidth_IsConsistent()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var height = 5.08;
|
||||||
|
var measuredWidth = font.MeasureTextWidth("Text", height);
|
||||||
|
var entities = font.RenderText("Text", height, Vector.Zero);
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
|
||||||
|
Assert.True(measuredWidth >= box.Length,
|
||||||
|
$"Measured={measuredWidth:F2} should be >= rendered={box.Length:F2}");
|
||||||
|
Assert.True(measuredWidth - box.Length < 2.0,
|
||||||
|
$"Measured={measuredWidth:F2}, rendered={box.Length:F2}, diff={measuredWidth - box.Length:F2}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public void Glyph_t_HasCurveAtBottom()
|
||||||
|
{
|
||||||
|
var font = LoadFont();
|
||||||
|
var glyph = font.GetGlyph('t');
|
||||||
|
Assert.NotNull(glyph);
|
||||||
|
|
||||||
|
var entities = glyph.ToEntities(1.0, 0, 0);
|
||||||
|
var lines = entities.Cast<Line>().ToList();
|
||||||
|
|
||||||
|
Assert.True(lines.Count >= 10, $"Expected at least 10 entities for 't', got {lines.Count}");
|
||||||
|
|
||||||
|
var curveLines = lines.Skip(1).Take(lines.Count - 3).ToList();
|
||||||
|
Assert.True(curveLines.Count >= 14, $"Expected at least 14 curve segments, got {curveLines.Count}");
|
||||||
|
|
||||||
|
var lastCurve = curveLines[^1];
|
||||||
|
Assert.True(lastCurve.EndPoint.X > curveLines[0].StartPoint.X,
|
||||||
|
$"Curve should end to the right of where it starts: start X={curveLines[0].StartPoint.X:F1}, end X={lastCurve.EndPoint.X:F1}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
|
<PackageReference Include="Xunit.SkippableFact" Version="1.4.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -38,6 +39,9 @@
|
|||||||
<Content Include="Splitting\TestData\**\*">
|
<Content Include="Splitting\TestData\**\*">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="test-config.json" Condition="Exists('test-config.json')">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,37 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using OpenNest.CNC;
|
using OpenNest.CNC;
|
||||||
using OpenNest.Geometry;
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
namespace OpenNest.Tests;
|
namespace OpenNest.Tests;
|
||||||
|
|
||||||
|
internal static class TestConfig
|
||||||
|
{
|
||||||
|
private static readonly Lazy<Dictionary<string, string>> Config = new(() =>
|
||||||
|
{
|
||||||
|
var dir = AppContext.BaseDirectory;
|
||||||
|
for (var i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(dir, "test-config.json");
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
return JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
|
||||||
|
}
|
||||||
|
dir = Path.GetDirectoryName(dir)!;
|
||||||
|
}
|
||||||
|
return new();
|
||||||
|
});
|
||||||
|
|
||||||
|
public static string? Get(string key) =>
|
||||||
|
Config.Value.TryGetValue(key, out var val) ? val : null;
|
||||||
|
|
||||||
|
public static string? GetExistingPath(string key)
|
||||||
|
{
|
||||||
|
var path = Get(key);
|
||||||
|
return path != null && File.Exists(path) ? path : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal static class TestHelpers
|
internal static class TestHelpers
|
||||||
{
|
{
|
||||||
public static Part MakePartAt(double x, double y, double size = 1)
|
public static Part MakePartAt(double x, double y, double size = 1)
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ namespace OpenNest.Controls
|
|||||||
|
|
||||||
public event EventHandler<Line> LinePicked;
|
public event EventHandler<Line> LinePicked;
|
||||||
public event EventHandler PickCancelled;
|
public event EventHandler PickCancelled;
|
||||||
|
public event EventHandler<CadText> TextConvertRequested;
|
||||||
|
|
||||||
private bool isPickingBendLine;
|
private bool isPickingBendLine;
|
||||||
public bool IsPickingBendLine
|
public bool IsPickingBendLine
|
||||||
@@ -76,6 +77,13 @@ namespace OpenNest.Controls
|
|||||||
if (line != null)
|
if (line != null)
|
||||||
LinePicked?.Invoke(this, line);
|
LinePicked?.Invoke(this, line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.Button == MouseButtons.Right)
|
||||||
|
{
|
||||||
|
var text = HitTestText(e.Location);
|
||||||
|
if (text != null)
|
||||||
|
ShowTextContextMenu(text, e.Location);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnPaint(PaintEventArgs e)
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
@@ -328,6 +336,41 @@ namespace OpenNest.Controls
|
|||||||
return bestLine;
|
return bestLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CadText HitTestText(Point controlPoint)
|
||||||
|
{
|
||||||
|
if (Texts == null || Texts.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var worldPoint = PointControlToWorld(controlPoint);
|
||||||
|
var tolerance = LengthGuiToWorld(8);
|
||||||
|
|
||||||
|
foreach (var text in Texts)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text.Value))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var estimatedWidth = text.Height * text.Value.Length * 0.6;
|
||||||
|
var minX = text.Position.X - tolerance;
|
||||||
|
var maxX = text.Position.X + estimatedWidth + tolerance;
|
||||||
|
var minY = text.Position.Y - tolerance;
|
||||||
|
var maxY = text.Position.Y + text.Height + tolerance;
|
||||||
|
|
||||||
|
if (worldPoint.X >= minX && worldPoint.X <= maxX &&
|
||||||
|
worldPoint.Y >= minY && worldPoint.Y <= maxY)
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowTextContextMenu(CadText text, Point location)
|
||||||
|
{
|
||||||
|
var menu = new ContextMenuStrip();
|
||||||
|
var item = menu.Items.Add($"Convert \"{text.Value}\" to Geometry");
|
||||||
|
item.Click += (s, e) => TextConvertRequested?.Invoke(this, text);
|
||||||
|
menu.Show(this, location);
|
||||||
|
}
|
||||||
|
|
||||||
private void DrawEntityLabels(Graphics g)
|
private void DrawEntityLabels(Graphics g)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < Entities.Count; i++)
|
for (var i = 0; i < Entities.Count; i++)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
|
||||||
entityView1.LinePicked += OnLinePicked;
|
entityView1.LinePicked += OnLinePicked;
|
||||||
entityView1.PickCancelled += OnPickCancelled;
|
entityView1.PickCancelled += OnPickCancelled;
|
||||||
|
entityView1.TextConvertRequested += OnTextConvertRequested;
|
||||||
btnSplit.Click += OnSplitClicked;
|
btnSplit.Click += OnSplitClicked;
|
||||||
numQuantity.ValueChanged += OnQuantityChanged;
|
numQuantity.ValueChanged += OnQuantityChanged;
|
||||||
txtCustomer.TextChanged += OnCustomerChanged;
|
txtCustomer.TextChanged += OnCustomerChanged;
|
||||||
@@ -463,6 +464,115 @@ namespace OpenNest.Forms
|
|||||||
filterPanel.SetPickMode(false);
|
filterPanel.SetPickMode(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnTextConvertRequested(object sender, Controls.CadText text)
|
||||||
|
{
|
||||||
|
var item = CurrentItem;
|
||||||
|
if (item == null) return;
|
||||||
|
|
||||||
|
var font = LoadChrFont();
|
||||||
|
if (font == null) return;
|
||||||
|
|
||||||
|
var layer = new Geometry.Layer("ENGRAVE")
|
||||||
|
{
|
||||||
|
Color = System.Drawing.Color.Cyan,
|
||||||
|
IsVisible = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var entities = font.RenderText(text.Value, text.Height, Geometry.Vector.Zero, layer);
|
||||||
|
if (entities.Count > 0)
|
||||||
|
{
|
||||||
|
var box = entities.GetBoundingBox();
|
||||||
|
var shiftX = text.HAlign switch
|
||||||
|
{
|
||||||
|
System.Drawing.StringAlignment.Center => text.Position.X - (box.Left + box.Right) / 2,
|
||||||
|
System.Drawing.StringAlignment.Far => text.Position.X - box.Right,
|
||||||
|
_ => text.Position.X - box.Left,
|
||||||
|
};
|
||||||
|
var shiftY = text.VAlign switch
|
||||||
|
{
|
||||||
|
System.Drawing.StringAlignment.Center => text.Position.Y - (box.Top + box.Bottom) / 2,
|
||||||
|
System.Drawing.StringAlignment.Near => text.Position.Y - box.Top,
|
||||||
|
_ => text.Position.Y - box.Bottom,
|
||||||
|
};
|
||||||
|
var shift = new Geometry.Vector(shiftX, shiftY);
|
||||||
|
foreach (var e in entities)
|
||||||
|
e.Offset(shift);
|
||||||
|
}
|
||||||
|
if (entities.Count == 0)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"No geometry produced for \"{text.Value}\".", "Convert Text",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.Entities.AddRange(entities);
|
||||||
|
item.Texts.Remove(text);
|
||||||
|
item.EntityCount = item.Entities.Count;
|
||||||
|
item.Bounds = item.Entities.GetBoundingBox();
|
||||||
|
|
||||||
|
entityView1.Entities.Clear();
|
||||||
|
entityView1.Entities.AddRange(item.Entities);
|
||||||
|
entityView1.Texts = item.Texts;
|
||||||
|
filterPanel.LoadItem(item.Entities, item.Bends);
|
||||||
|
entityView1.Invalidate();
|
||||||
|
staleProgram = true;
|
||||||
|
lblEntityCount.Text = $"{item.EntityCount} entities";
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChrFont cachedChrFont;
|
||||||
|
private string cachedChrFontPath;
|
||||||
|
|
||||||
|
private ChrFont LoadChrFont()
|
||||||
|
{
|
||||||
|
if (cachedChrFont != null)
|
||||||
|
return cachedChrFont;
|
||||||
|
|
||||||
|
// Look for .CHR files next to the app, then prompt
|
||||||
|
var appDir = System.IO.Path.GetDirectoryName(Application.ExecutablePath);
|
||||||
|
var candidates = Directory.GetFiles(appDir, "*.CHR", SearchOption.TopDirectoryOnly);
|
||||||
|
|
||||||
|
string fontPath;
|
||||||
|
if (candidates.Length == 1)
|
||||||
|
{
|
||||||
|
fontPath = candidates[0];
|
||||||
|
}
|
||||||
|
else if (candidates.Length > 1)
|
||||||
|
{
|
||||||
|
fontPath = PromptForChrFile(appDir);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fontPath = PromptForChrFile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontPath == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cachedChrFont = ChrFont.Read(fontPath);
|
||||||
|
cachedChrFontPath = fontPath;
|
||||||
|
return cachedChrFont;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Error loading font: {ex.Message}", "Font Error",
|
||||||
|
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PromptForChrFile(string initialDir)
|
||||||
|
{
|
||||||
|
using var dlg = new OpenFileDialog
|
||||||
|
{
|
||||||
|
Title = "Select Engraving Font (.CHR)",
|
||||||
|
Filter = "Gravograph Font (*.CHR)|*.CHR",
|
||||||
|
InitialDirectory = initialDir ?? "",
|
||||||
|
};
|
||||||
|
return dlg.ShowDialog() == DialogResult.OK ? dlg.FileName : null;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnDragEnter(object sender, DragEventArgs e)
|
private void OnDragEnter(object sender, DragEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
if (e.Data.GetDataPresent(DataFormats.FileDrop))
|
||||||
|
|||||||
Reference in New Issue
Block a user