10 Commits
Author SHA1 Message Date
ajandClaude Opus 4.6 5bcad9667b fix: DetermineWinding used absolute area, always returned CCW
Shape.Area() returns Math.Abs(signedArea), so DetermineWinding always
detected CCW regardless of actual winding. Use ToPolygon().RotationDirection()
which uses the signed area correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:16:15 -04:00
ajandClaude Opus 4.6 64945220b9 fix: account for contour winding direction in lead-in normal computation
ComputeNormal assumed CW winding for all contours. For CCW-wound cutouts,
line normals pointed to the material side instead of scrap, placing lead-ins
on the wrong side. Now accepts a winding parameter: lines flip the normal
for CCW winding, and arcs flip when arc direction differs from contour
winding (concave feature detection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:06:08 -04:00
ajandClaude Opus 4.6 ec0baad585 feat: use Plate.Quantity as M98 L count for duplicate sheets in Cincinnati post
Instead of emitting separate M98 calls per identical sheet, use the L
(loop count) parameter so the operator can adjust quantity at the control.
M50 pallet exchange moves inside the sheet subprogram so each L iteration
gets its own exchange cycle. GOTO targets now correspond to layout groups.
Also fixes sheet name comment outputting dimensions in wrong order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:52:34 -04:00
ajandClaude Opus 4.6 f26edb824d fix: remove dangerous G0 X0 Y0 return-to-home rapids from Cincinnati post
Rapid traversing back to origin over a sheet of freshly cut parts risks
collisions with tipped or warped pieces. Removed from both the sheet
footer and part subprogram endings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:11:29 -04:00
ajandClaude Opus 4.6 aae593a73e feat: cutoff coordinates use sheet width/length variables in Cincinnati post
Cutoff features now substitute plate-edge coordinates with #SheetWidthVariable
and #SheetLengthVariable references. Vertical cutoffs at Y=plate_width emit
Y#110, horizontal cutoffs at X=plate_length emit X#111. Segmented cutoffs
only substitute the edge coordinate, interior segment endpoints stay literal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:08:40 -04:00
ajandClaude Opus 4.6 36d8f7fb11 docs: document G-code user variable feature in CLAUDE.md and README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:17:50 -04:00
ajandClaude Opus 4.6 52ad5b4575 feat: Cincinnati post emits user variables as numbered #variables
When programs have user-defined variables, the Cincinnati post now:
- Assigns numbered machine variables (#200, #201, etc.) to non-inline variables
- Emits declarations like #200=48.0 (SHEET WIDTH) in the variable declaration subprogram
- Emits X#200 instead of X48.0 in coordinates that have VariableRefs
- Handles global variables (shared number across drawings) vs local (per-drawing number)
- Inline variables emit the literal value as before

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:16:15 -04:00
ajandClaude Sonnet 4.6 7416f8ae3f feat: serialize variable definitions and \$references in NestWriter
Emit variable definitions before G-code in program text entries and use
\$varName syntax for coordinate fields that have VariableRefs, so programs
round-trip through NestWriter → NestReader without losing variable information.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:09:12 -04:00
ajandClaude Opus 4.6 46e3104dfc feat: add two-pass variable parsing to ProgramReader
ProgramReader now supports G-code user variables with a two-pass
approach: first pass collects variable definitions (name = expression
[inline] [global]) and evaluates them via topological sort and
ExpressionEvaluator; second pass parses G-code lines with $name
substitution and VariableRef tracking on motion and feedrate objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:04:59 -04:00
ajandClaude Sonnet 4.6 27afa04e4a feat: add Variables dictionary to Program with deep-copy in Clone
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:58:36 -04:00
20 changed files with 1091 additions and 122 deletions
+3 -2
View File
@@ -24,10 +24,10 @@ Eight projects form a layered architecture:
Domain model, geometry, and CNC primitives organized into namespaces: Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`. - **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. - **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction). - **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction).
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. - **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration. - **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`. - **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts. - **CutOffs** (`namespace OpenNest`): `CutOff` (axis-aligned cut line with position, axis, optional start/end limits), `CutOffAxis` enum (`Horizontal`, `Vertical`), `CutOffSettings` (clearance, overtravel, min segment length, direction), `CutDirection` enum (`TowardOrigin`, `AwayFromOrigin`). Cut-offs generate CNC `Program` objects with trimmed line segments that avoid parts.
@@ -116,3 +116,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.
- **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.
@@ -70,8 +70,8 @@ namespace OpenNest.CNC.CuttingStrategy
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null) private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
{ {
var contourType = forceType ?? DetectContourType(shape); var contourType = forceType ?? DetectContourType(shape);
var normal = ComputeNormal(point, entity, contourType);
var winding = DetermineWinding(shape); var winding = DetermineWinding(shape);
var normal = ComputeNormal(point, entity, contourType, winding);
var leadIn = SelectLeadIn(contourType); var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType); var leadOut = SelectLeadOut(contourType);
@@ -143,29 +143,33 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal; return ContourType.Internal;
} }
public static double ComputeNormal(Vector point, Entity entity, ContourType contourType) public static double ComputeNormal(Vector point, Entity entity, ContourType contourType,
RotationType winding = RotationType.CW)
{ {
double normal; double normal;
if (entity is Line line) if (entity is Line line)
{ {
// Perpendicular to line direction // Perpendicular to line direction: tangent + π/2 = left side.
// Left side = outward for CW winding; for CCW winding, outward
// is on the right side, so flip.
var tangent = line.EndPoint.AngleFrom(line.StartPoint); var tangent = line.EndPoint.AngleFrom(line.StartPoint);
normal = tangent + Math.Angle.HalfPI; normal = tangent + Math.Angle.HalfPI;
if (winding == RotationType.CCW)
normal += System.Math.PI;
} }
else if (entity is Arc arc) else if (entity is Arc arc)
{ {
// Radial direction from center to point // Radial direction from center to point.
// Flip when the arc direction differs from the contour winding —
// that indicates a concave feature where radial points inward.
normal = point.AngleFrom(arc.Center); normal = point.AngleFrom(arc.Center);
if (arc.Rotation != winding)
// For CCW arcs the radial points the wrong way — flip it.
// CW arcs are convex features (corners) where radial = outward.
// CCW arcs are concave features (slots) where radial = inward.
if (arc.Rotation == RotationType.CCW)
normal += System.Math.PI; normal += System.Math.PI;
} }
else if (entity is Circle circle) else if (entity is Circle circle)
{ {
// Radial outward — always correct regardless of winding
normal = point.AngleFrom(circle.Center); normal = point.AngleFrom(circle.Center);
} }
else else
@@ -182,9 +186,10 @@ namespace OpenNest.CNC.CuttingStrategy
public static RotationType DetermineWinding(Shape shape) public static RotationType DetermineWinding(Shape shape)
{ {
// Use signed area: positive = CCW, negative = CW if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
var area = shape.Area(); return circle.Rotation;
return area >= 0 ? RotationType.CCW : RotationType.CW;
return shape.ToPolygon().RotationDirection();
} }
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle) private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
+6
View File
@@ -1,6 +1,7 @@
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace OpenNest.CNC namespace OpenNest.CNC
@@ -9,6 +10,8 @@ namespace OpenNest.CNC
{ {
public List<ICode> Codes; public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
private Mode mode; private Mode mode;
public Program(Mode mode = Mode.Absolute) public Program(Mode mode = Mode.Absolute)
@@ -454,6 +457,9 @@ namespace OpenNest.CNC
pgm.Codes.AddRange(codes); pgm.Codes.AddRange(codes);
foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value;
return pgm; return pgm;
} }
+30 -14
View File
@@ -305,6 +305,15 @@ namespace OpenNest.IO
var writer = new StreamWriter(stream); var writer = new StreamWriter(stream);
writer.AutoFlush = true; writer.AutoFlush = true;
// Emit variable definitions before G-code
foreach (var v in program.Variables.Values)
{
var line = $"{v.Name} = {v.Expression}";
if (v.Inline) line += " inline";
if (v.Global) line += " global";
writer.WriteLine(line);
}
writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91"); writer.WriteLine(program.Mode == Mode.Absolute ? "G90" : "G91");
for (var i = 0; i < drawing.Program.Length; ++i) for (var i = 0; i < drawing.Program.Length; ++i)
@@ -316,6 +325,13 @@ namespace OpenNest.IO
stream.Position = 0; stream.Position = 0;
} }
private string FormatCoord(double value, string axis, Dictionary<string, string> variableRefs)
{
if (variableRefs != null && variableRefs.TryGetValue(axis, out var varName))
return $"${varName}";
return System.Math.Round(value, OutputPrecision).ToString(CoordinateFormat);
}
private string GetCodeString(ICode code) private string GetCodeString(ICode code)
{ {
switch (code.Type) switch (code.Type)
@@ -324,16 +340,16 @@ namespace OpenNest.IO
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var arcMove = (ArcMove)code; var arcMove = (ArcMove)code;
var refs = arcMove.VariableRefs;
var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat); var x = FormatCoord(arcMove.EndPoint.X, "X", refs);
var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat); var y = FormatCoord(arcMove.EndPoint.Y, "Y", refs);
var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat); var i = FormatCoord(arcMove.CenterPoint.X, "I", refs);
var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat); var j = FormatCoord(arcMove.CenterPoint.Y, "J", refs);
if (arcMove.Rotation == RotationType.CW) sb.Append(arcMove.Rotation == RotationType.CW
sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j)); ? $"G02X{x}Y{y}I{i}J{j}"
else : $"G03X{x}Y{y}I{i}J{j}");
sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j));
if (arcMove.Layer != LayerType.Cut) if (arcMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(arcMove.Layer)); sb.Append(GetLayerString(arcMove.Layer));
@@ -354,10 +370,9 @@ namespace OpenNest.IO
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
var linearMove = (LinearMove)code; var linearMove = (LinearMove)code;
var refs = linearMove.VariableRefs;
sb.Append(string.Format("G01X{0}Y{1}", sb.Append($"G01X{FormatCoord(linearMove.EndPoint.X, "X", refs)}Y{FormatCoord(linearMove.EndPoint.Y, "Y", refs)}");
System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)));
if (linearMove.Layer != LayerType.Cut) if (linearMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(linearMove.Layer)); sb.Append(GetLayerString(linearMove.Layer));
@@ -371,15 +386,16 @@ namespace OpenNest.IO
case CodeType.RapidMove: case CodeType.RapidMove:
{ {
var rapidMove = (RapidMove)code; var rapidMove = (RapidMove)code;
var refs = rapidMove.VariableRefs;
return string.Format("G00X{0}Y{1}", return $"G00X{FormatCoord(rapidMove.EndPoint.X, "X", refs)}Y{FormatCoord(rapidMove.EndPoint.Y, "Y", refs)}";
System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat));
} }
case CodeType.SetFeedrate: case CodeType.SetFeedrate:
{ {
var setFeedrate = (Feedrate)code; var setFeedrate = (Feedrate)code;
if (setFeedrate.VariableRef != null)
return $"F${setFeedrate.VariableRef}";
return "F" + setFeedrate.Value; return "F" + setFeedrate.Value;
} }
+262 -7
View File
@@ -1,7 +1,11 @@
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
namespace OpenNest.IO namespace OpenNest.IO
@@ -15,6 +19,7 @@ namespace OpenNest.IO
private CodeSection section; private CodeSection section;
private Program program; private Program program;
private StreamReader reader; private StreamReader reader;
private Dictionary<string, double> resolvedVariables;
public ProgramReader(Stream stream) public ProgramReader(Stream stream)
{ {
@@ -24,11 +29,38 @@ namespace OpenNest.IO
public Program Read() public Program Read()
{ {
// First pass: read all lines, collect variable definitions
var allLines = new List<string>();
var variableDefs = new Dictionary<string, (string expression, bool inline, bool global)>(
StringComparer.OrdinalIgnoreCase);
var codeLines = new List<string>();
string line; string line;
while ((line = reader.ReadLine()) != null) while ((line = reader.ReadLine()) != null)
{ {
block = ParseBlock(line); allLines.Add(line);
if (TryParseVariableDefinition(line, out var name, out var expression, out var isInline, out var isGlobal))
variableDefs[name] = (expression, isInline, isGlobal);
else
codeLines.Add(line);
}
// Evaluate variables with topological sort for dependency ordering
resolvedVariables = ResolveVariables(variableDefs);
// Store evaluated variables on the program
foreach (var kvp in variableDefs)
{
var name = kvp.Key;
var (expression, isInline, isGlobal) = kvp.Value;
var value = resolvedVariables[name];
program.Variables[name] = new VariableDefinition(name, expression, value, isInline, isGlobal);
}
// Second pass: parse G-code lines with variable substitution
foreach (var codeLine in codeLines)
{
block = ParseBlock(codeLine);
ProcessCurrentBlock(); ProcessCurrentBlock();
} }
@@ -39,10 +71,43 @@ namespace OpenNest.IO
{ {
var block = new CodeBlock(); var block = new CodeBlock();
Code code = null; Code code = null;
for (int i = 0; i < line.Length; ++i) for (var i = 0; i < line.Length; ++i)
{ {
var c = line[i]; var c = line[i];
if (char.IsLetter(c)) if (c == '$' && code != null && resolvedVariables != null)
{
// Read the maximal variable name (letters, digits, underscores)
var start = i + 1;
while (start < line.Length && (char.IsLetterOrDigit(line[start]) || line[start] == '_'))
start++;
var maxName = line.Substring(i + 1, start - i - 1);
// Try longest match first, then progressively shorter to handle
// cases like X$widthY0 where "widthY" isn't a variable but "width" is
string lookupKey = null;
var nameLen = maxName.Length;
while (nameLen > 0)
{
var candidate = maxName.Substring(0, nameLen);
lookupKey = resolvedVariables.Keys
.FirstOrDefault(k => string.Equals(k, candidate, StringComparison.OrdinalIgnoreCase));
if (lookupKey != null)
break;
nameLen--;
}
if (lookupKey != null)
{
code.Value = resolvedVariables[lookupKey].ToString(CultureInfo.InvariantCulture);
code.VariableRef = lookupKey;
i += nameLen; // advance past the matched variable name
}
else
{
i = start - 1; // no match, skip the whole thing
}
}
else if (char.IsLetter(c))
block.Add((code = new Code(c))); block.Add((code = new Code(c)));
else if (c == ':') else if (c == ':')
{ {
@@ -125,7 +190,10 @@ namespace OpenNest.IO
break; break;
case 'F': case 'F':
program.Codes.Add(new Feedrate() { Value = double.Parse(code.Value) }); var feedrate = new Feedrate() { Value = double.Parse(code.Value) };
if (code.VariableRef != null)
feedrate.VariableRef = code.VariableRef;
program.Codes.Add(feedrate);
code = GetNextCode(); code = GetNextCode();
break; break;
@@ -143,6 +211,7 @@ namespace OpenNest.IO
double y = 0; double y = 0;
var layer = LayerType.Cut; var layer = LayerType.Cut;
var suppressed = false; var suppressed = false;
string xRef = null, yRef = null;
while (section == CodeSection.Line) while (section == CodeSection.Line)
{ {
@@ -157,10 +226,12 @@ namespace OpenNest.IO
{ {
case 'X': case 'X':
x = double.Parse(code.Value); x = double.Parse(code.Value);
xRef = code.VariableRef;
break; break;
case 'Y': case 'Y':
y = double.Parse(code.Value); y = double.Parse(code.Value);
yRef = code.VariableRef;
break; break;
case ':': case ':':
@@ -200,10 +271,13 @@ namespace OpenNest.IO
break; break;
} }
} }
var refs = BuildVariableRefs(("X", xRef), ("Y", yRef));
if (isRapid) if (isRapid)
program.Codes.Add(new RapidMove(x, y)); program.Codes.Add(new RapidMove(x, y) { VariableRefs = refs });
else else
program.Codes.Add(new LinearMove(x, y) { Layer = layer, Suppressed = suppressed }); program.Codes.Add(new LinearMove(x, y) { Layer = layer, Suppressed = suppressed, VariableRefs = refs });
} }
private void ReadArc(RotationType rotation) private void ReadArc(RotationType rotation)
@@ -214,6 +288,7 @@ namespace OpenNest.IO
double j = 0; double j = 0;
var layer = LayerType.Cut; var layer = LayerType.Cut;
var suppressed = false; var suppressed = false;
string xRef = null, yRef = null, iRef = null, jRef = null;
while (section == CodeSection.Arc) while (section == CodeSection.Arc)
{ {
@@ -229,18 +304,22 @@ namespace OpenNest.IO
{ {
case 'X': case 'X':
x = double.Parse(code.Value); x = double.Parse(code.Value);
xRef = code.VariableRef;
break; break;
case 'Y': case 'Y':
y = double.Parse(code.Value); y = double.Parse(code.Value);
yRef = code.VariableRef;
break; break;
case 'I': case 'I':
i = double.Parse(code.Value); i = double.Parse(code.Value);
iRef = code.VariableRef;
break; break;
case 'J': case 'J':
j = double.Parse(code.Value); j = double.Parse(code.Value);
jRef = code.VariableRef;
break; break;
case ':': case ':':
@@ -286,7 +365,8 @@ namespace OpenNest.IO
CenterPoint = new Vector(i, j), CenterPoint = new Vector(i, j),
Rotation = rotation, Rotation = rotation,
Layer = layer, Layer = layer,
Suppressed = suppressed Suppressed = suppressed,
VariableRefs = BuildVariableRefs(("X", xRef), ("Y", yRef), ("I", iRef), ("J", jRef))
}); });
} }
@@ -351,6 +431,179 @@ namespace OpenNest.IO
return block[codeIndex]; return block[codeIndex];
} }
private static bool TryParseVariableDefinition(string line, out string name, out string expression,
out bool isInline, out bool isGlobal)
{
name = null;
expression = null;
isInline = false;
isGlobal = false;
var trimmed = line.Trim();
if (trimmed.Length == 0)
return false;
// Must start with a letter or underscore (not a G-code letter followed by a digit)
var firstChar = trimmed[0];
if (!char.IsLetter(firstChar) && firstChar != '_')
return false;
// If line starts with a known G-code letter followed by a digit, it's not a variable
if (trimmed.Length >= 2 && char.IsDigit(trimmed[1]))
{
var upper = char.ToUpper(firstChar);
if (upper is 'G' or 'M' or 'N' or 'F' or 'X' or 'Y' or 'I' or 'J' or 'T' or 'S' or 'O' or 'P' or 'R')
return false;
}
// Must contain '='
var eqIndex = trimmed.IndexOf('=');
if (eqIndex < 1)
return false;
// Extract name (everything before '=', trimmed)
var rawName = trimmed.Substring(0, eqIndex).Trim();
// Validate name: must be identifier (letter/underscore followed by alphanumeric/underscore)
if (rawName.Length == 0 || (!char.IsLetter(rawName[0]) && rawName[0] != '_'))
return false;
for (var i = 1; i < rawName.Length; i++)
{
if (!char.IsLetterOrDigit(rawName[i]) && rawName[i] != '_')
return false;
}
// Extract expression and flags from the remainder after '='
var remainder = trimmed.Substring(eqIndex + 1).Trim();
// Check for trailing flags: inline and/or global
// Parse from the end to separate expression from flags
var words = remainder.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var flagStart = words.Length;
for (var i = words.Length - 1; i >= 0; i--)
{
var word = words[i].ToLowerInvariant();
if (word == "inline" || word == "global")
flagStart = i;
else
break;
}
// Build expression from non-flag words
var expressionParts = words.Take(flagStart).ToArray();
if (expressionParts.Length == 0)
return false;
expression = string.Join(" ", expressionParts);
// Parse flags
for (var i = flagStart; i < words.Length; i++)
{
var word = words[i].ToLowerInvariant();
if (word == "inline") isInline = true;
else if (word == "global") isGlobal = true;
}
name = rawName;
return true;
}
private static Dictionary<string, double> ResolveVariables(
Dictionary<string, (string expression, bool inline, bool global)> variableDefs)
{
if (variableDefs.Count == 0)
return new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
// Build dependency graph
var dependencies = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in variableDefs)
{
var deps = new List<string>();
var expr = kvp.Value.expression;
for (var i = 0; i < expr.Length; i++)
{
if (expr[i] == '$')
{
var start = i + 1;
while (start < expr.Length && (char.IsLetterOrDigit(expr[start]) || expr[start] == '_'))
start++;
var refName = expr.Substring(i + 1, start - i - 1);
// Find the canonical name (case-insensitive match)
var canonical = variableDefs.Keys
.FirstOrDefault(k => string.Equals(k, refName, StringComparison.OrdinalIgnoreCase));
if (canonical != null)
deps.Add(canonical);
i = start - 1;
}
}
dependencies[kvp.Key] = deps;
}
// Topological sort (Kahn's algorithm)
var inDegree = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var name in variableDefs.Keys)
inDegree[name] = 0;
foreach (var kvp in dependencies)
{
foreach (var dep in kvp.Value)
{
if (inDegree.ContainsKey(dep))
inDegree[kvp.Key]++;
}
}
var queue = new Queue<string>();
foreach (var kvp in inDegree)
{
if (kvp.Value == 0)
queue.Enqueue(kvp.Key);
}
var resolved = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
var order = new List<string>();
while (queue.Count > 0)
{
var current = queue.Dequeue();
order.Add(current);
// Evaluate this variable
var expr = variableDefs[current].expression;
var value = ExpressionEvaluator.Evaluate(expr, resolved);
resolved[current] = value;
// Reduce in-degree of dependents
foreach (var kvp in dependencies)
{
if (kvp.Value.Contains(current, StringComparer.OrdinalIgnoreCase))
{
inDegree[kvp.Key]--;
if (inDegree[kvp.Key] == 0 && !order.Contains(kvp.Key))
queue.Enqueue(kvp.Key);
}
}
}
if (order.Count != variableDefs.Count)
throw new InvalidOperationException("Circular dependency detected among variables.");
return resolved;
}
private static Dictionary<string, string> BuildVariableRefs(params (string axis, string varRef)[] refs)
{
Dictionary<string, string> result = null;
foreach (var (axis, varRef) in refs)
{
if (varRef != null)
{
result ??= new Dictionary<string, string>();
result[axis] = varRef;
}
}
return result;
}
public void Close() public void Close()
{ {
reader.Close(); reader.Close();
@@ -374,6 +627,8 @@ namespace OpenNest.IO
public string Value { get; set; } public string Value { get; set; }
public string VariableRef { get; set; }
public override string ToString() public override string ToString()
{ {
return Id + Value; return Id + Value;
@@ -29,6 +29,29 @@ public sealed class FeatureContext
/// so part-relative programs become plate-absolute under G90. /// so part-relative programs become plate-absolute under G90.
/// </summary> /// </summary>
public Vector PartLocation { get; set; } = Vector.Zero; public Vector PartLocation { get; set; } = Vector.Zero;
/// <summary>
/// Maps (drawingId, variableName) to assigned machine variable numbers.
/// Used to emit #number references instead of literal values for user variables.
/// </summary>
public Dictionary<(int drawingId, string varName), int> UserVariableMapping { get; set; }
/// <summary>
/// The drawing ID for the current part, used to look up user variable mappings.
/// </summary>
public int DrawingId { get; set; }
/// <summary>
/// True if this feature is a cut-off line. Used to substitute plate-edge
/// coordinates with sheet width/length variables.
/// </summary>
public bool IsCutOff { get; set; }
/// <summary>Plate width (Y extent for vertical cutoffs).</summary>
public double PlateWidth { get; set; }
/// <summary>Plate length (X extent for horizontal cutoffs).</summary>
public double PlateLength { get; set; }
} }
/// <summary> /// <summary>
@@ -63,7 +86,7 @@ public sealed class CincinnatiFeatureWriter
var piercePoint = FindPiercePoint(ctx.Codes); var piercePoint = FindPiercePoint(ctx.Codes);
// 1. Rapid to pierce point (with line number if configured) // 1. Rapid to pierce point (with line number if configured)
WriteRapidToPierce(writer, ctx.FeatureNumber, piercePoint, offset); WriteRapidToPierce(writer, ctx, piercePoint, offset);
// 2. Part name comment on first feature of each part // 2. Part name comment on first feature of each part
if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName)) if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName))
@@ -112,7 +135,9 @@ public sealed class CincinnatiFeatureWriter
kerfEmitted = true; kerfEmitted = true;
} }
sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X + offset.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y + offset.Y)}"); var xCoord = FormatCoordWithVars(linear.EndPoint.X + offset.X, "X", linear.VariableRefs, ctx);
var yCoord = FormatCoordWithVars(linear.EndPoint.Y + offset.Y, "Y", linear.VariableRefs, ctx);
sb.Append($"G1 X{xCoord} Y{yCoord}");
// Feedrate — etch always uses process feedrate // Feedrate — etch always uses process feedrate
var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer); var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer);
@@ -138,7 +163,9 @@ public sealed class CincinnatiFeatureWriter
// G2 = CW, G3 = CCW // G2 = CW, G3 = CCW
var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3"; var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3";
sb.Append($"{gCode} X{_fmt.FormatCoord(arc.EndPoint.X + offset.X)} Y{_fmt.FormatCoord(arc.EndPoint.Y + offset.Y)}"); var xCoord = FormatCoordWithVars(arc.EndPoint.X + offset.X, "X", arc.VariableRefs, ctx);
var yCoord = FormatCoordWithVars(arc.EndPoint.Y + offset.Y, "Y", arc.VariableRefs, ctx);
sb.Append($"{gCode} X{xCoord} Y{yCoord}");
// Convert absolute center to incremental I/J // Convert absolute center to incremental I/J
var i = arc.CenterPoint.X - currentPos.X; var i = arc.CenterPoint.X - currentPos.X;
@@ -177,6 +204,52 @@ public sealed class CincinnatiFeatureWriter
WriteM47(writer, ctx); WriteM47(writer, ctx);
} }
/// <summary>
/// Formats a coordinate value, using a #number variable reference if the motion
/// has a VariableRef for this axis and the variable is mapped (non-inline).
/// For cut-off features, plate-edge coordinates are substituted with
/// the sheet width/length variables.
/// Inline variables fall through to literal formatting.
/// </summary>
private string FormatCoordWithVars(double value, string axis,
Dictionary<string, string> variableRefs, FeatureContext ctx)
{
// User-defined variable references take priority
if (variableRefs != null
&& variableRefs.TryGetValue(axis, out var varName)
&& ctx.UserVariableMapping != null
&& ctx.UserVariableMapping.TryGetValue((ctx.DrawingId, varName), out var varNum))
{
return $"#{varNum}";
}
// Cut-off plate-edge substitution
if (ctx.IsCutOff)
{
var sheetVar = MatchCutOffSheetVariable(value, axis, ctx);
if (sheetVar != null)
return sheetVar;
}
return _fmt.FormatCoord(value);
}
/// <summary>
/// For cut-off coordinates, checks if the value matches a plate edge dimension
/// and returns the sheet variable reference (e.g., "#110") if so.
/// </summary>
private string MatchCutOffSheetVariable(double value, string axis, FeatureContext ctx)
{
// Vertical cutoffs travel along Y — the Y endpoint at the plate edge = sheet width
// Horizontal cutoffs travel along X — the X endpoint at the plate edge = sheet length
if (axis == "Y" && Tolerance.IsEqualTo(value, ctx.PlateWidth))
return $"#{_config.SheetWidthVariable}";
if (axis == "X" && Tolerance.IsEqualTo(value, ctx.PlateLength))
return $"#{_config.SheetLengthVariable}";
return null;
}
private Vector FindPiercePoint(List<ICode> codes) private Vector FindPiercePoint(List<ICode> codes)
{ {
foreach (var code in codes) foreach (var code in codes)
@@ -195,14 +268,16 @@ public sealed class CincinnatiFeatureWriter
return Vector.Zero; return Vector.Zero;
} }
private void WriteRapidToPierce(TextWriter writer, int featureNumber, Vector piercePoint, Vector offset) private void WriteRapidToPierce(TextWriter writer, FeatureContext ctx, Vector piercePoint, Vector offset)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
if (_config.UseLineNumbers) if (_config.UseLineNumbers)
sb.Append($"N{featureNumber} "); sb.Append($"N{ctx.FeatureNumber} ");
sb.Append($"G0 X{_fmt.FormatCoord(piercePoint.X + offset.X)} Y{_fmt.FormatCoord(piercePoint.Y + offset.Y)}"); var xCoord = FormatCoordWithVars(piercePoint.X + offset.X, "X", null, ctx);
var yCoord = FormatCoordWithVars(piercePoint.Y + offset.Y, "Y", null, ctx);
sb.Append($"G0 X{xCoord} Y{yCoord}");
writer.WriteLine(sb.ToString()); writer.WriteLine(sb.ToString());
} }
@@ -66,7 +66,6 @@ public sealed class CincinnatiPartSubprogramWriter
_featureWriter.Write(w, ctx); _featureWriter.Write(w, ctx);
} }
w.WriteLine("G0 X0 Y0");
w.WriteLine($"M99 (END OF {drawingName})"); w.WriteLine($"M99 (END OF {drawingName})");
} }
@@ -253,6 +253,11 @@ namespace OpenNest.Posts.Cincinnati
new() { MaxRadius = 4.500, FeedratePercent = 0.80, VariableNumber = 125 } new() { MaxRadius = 4.500, FeedratePercent = 0.80, VariableNumber = 125 }
}; };
[Category("A. Variables")]
[DisplayName("User Variable Start")]
[Description("Starting variable number for user-defined variables (#200, #201, etc.).")]
public int UserVariableStart { get; set; } = 200;
[Category("A. Variables")] [Category("A. Variables")]
[DisplayName("Sheet Width Variable")] [DisplayName("Sheet Width Variable")]
[Description("Variable number for sheet width.")] [Description("Variable number for sheet width.")]
@@ -70,7 +70,10 @@ namespace OpenNest.Posts.Cincinnati
.Where(p => p.Parts.Count > 0) .Where(p => p.Parts.Count > 0)
.ToList(); .ToList();
// 3. Resolve gas and library files // 3. Register user variables from drawing programs
var userVarMapping = RegisterUserVariables(vars, plates);
// 4. Resolve gas and library files
var resolver = new MaterialLibraryResolver(Config); var resolver = new MaterialLibraryResolver(Config);
var gas = MaterialLibraryResolver.ResolveGas(nest, Config); var gas = MaterialLibraryResolver.ResolveGas(nest, Config);
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas); var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
@@ -79,42 +82,41 @@ namespace OpenNest.Posts.Cincinnati
var firstPlate = plates.FirstOrDefault(); var firstPlate = plates.FirstOrDefault();
var initialCutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas); var initialCutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas);
// 4. Build part sub-program registry (if enabled) // 5. Build part sub-program registry (if enabled)
Dictionary<(int, long), int> partSubprograms = null; Dictionary<(int, long), int> partSubprograms = null;
List<(int subNum, string name, Program program)> subprogramEntries = null; List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms) if (Config.UsePartSubprograms)
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart); (partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
// 5. Create writers // 6. Create writers
var preamble = new CincinnatiPreambleWriter(Config); var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars); var sheetWriter = new CincinnatiSheetWriter(Config, vars);
// 6. Build material description from nest // 7. Build material description from nest
var material = nest.Material; var material = nest.Material;
var materialDesc = material != null var materialDesc = material != null
? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}" ? $"{material.Name}{(string.IsNullOrEmpty(material.Grade) ? "" : $", {material.Grade}")}"
: ""; : "";
// 7. Write to stream // 8. Write to stream
using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true); using var writer = new StreamWriter(outputStream, Encoding.UTF8, 1024, leaveOpen: true);
// Main program // Main program
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates.Count, initialCutLibrary); preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates, initialCutLibrary);
// Variable declaration subprogram // Variable declaration subprogram
preamble.WriteVariableDeclaration(writer, vars); preamble.WriteVariableDeclaration(writer, vars);
// Sheet subprograms // Sheet subprograms (one per unique layout, quantity handled via L count in main)
for (var i = 0; i < plates.Count; i++) for (var i = 0; i < plates.Count; i++)
{ {
var plate = plates[i]; var plate = plates[i];
var sheetIndex = i + 1; var layoutIndex = i + 1;
var subNumber = Config.SheetSubprogramStart + i; var subNumber = Config.SheetSubprogramStart + i;
var cutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas); var cutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas);
var isLastSheet = i == plates.Count - 1; sheetWriter.Write(writer, plate, nest.Name ?? "NEST", layoutIndex, subNumber,
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber, cutLibrary, etchLibrary, partSubprograms, userVarMapping);
cutLibrary, etchLibrary, partSubprograms, isLastSheet);
} }
// Part sub-programs (if enabled) // Part sub-programs (if enabled)
@@ -142,6 +144,103 @@ namespace OpenNest.Posts.Cincinnati
Post(nest, fs); Post(nest, fs);
} }
private Dictionary<(int drawingId, string varName), int> RegisterUserVariables(
ProgramVariableManager vars, List<Plate> plates)
{
var mapping = new Dictionary<(int drawingId, string varName), int>();
var nextNumber = Config.UserVariableStart;
// Track global variables by name so they share a single number
var globalNumbers = new Dictionary<string, int>(System.StringComparer.OrdinalIgnoreCase);
// Collect unique drawings from all plates
var seenDrawings = new HashSet<int>();
foreach (var plate in plates)
{
foreach (var part in plate.Parts)
{
var drawing = part.BaseDrawing;
if (drawing.IsCutOff || !seenDrawings.Add(drawing.Id))
continue;
foreach (var kvp in drawing.Program.Variables)
{
var varDef = kvp.Value;
// Skip inline variables — they emit literal values
if (varDef.Inline)
continue;
if (varDef.Global)
{
if (!globalNumbers.TryGetValue(varDef.Name, out var globalNum))
{
globalNum = nextNumber++;
globalNumbers[varDef.Name] = globalNum;
// Register once in the variable manager
var commentName = ToPascalCase(varDef.Name);
var expression = FormatVariableValue(varDef.Value);
vars.GetOrCreate(commentName, globalNum, expression);
}
mapping[(drawing.Id, varDef.Name)] = globalNum;
}
else
{
var num = nextNumber++;
mapping[(drawing.Id, varDef.Name)] = num;
// Register with drawing name prefix in the comment
var drawingLabel = ToPascalCase(drawing.Name);
var varLabel = ToPascalCase(varDef.Name);
var commentName = $"{drawingLabel}{varLabel}";
var expression = FormatVariableValue(varDef.Value);
vars.GetOrCreate(commentName, num, expression);
}
}
}
}
return mapping;
}
/// <summary>
/// Converts a variable name from snake_case or camelCase to PascalCase.
/// Examples: "sheet_width" → "SheetWidth", "holeSpacing" → "HoleSpacing"
/// </summary>
private static string ToPascalCase(string name)
{
var sb = new StringBuilder(name.Length);
var capitalizeNext = true;
foreach (var c in name)
{
if (c == '_')
{
capitalizeNext = true;
continue;
}
if (capitalizeNext)
{
sb.Append(char.ToUpper(c));
capitalizeNext = false;
}
else
{
sb.Append(c);
}
}
return sb.ToString();
}
private static string FormatVariableValue(double value)
{
return value.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
}
private ProgramVariableManager CreateVariableManager() private ProgramVariableManager CreateVariableManager()
{ {
var vars = new ProgramVariableManager(); var vars = new ProgramVariableManager();
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using OpenNest; using OpenNest;
using OpenNest.CNC; using OpenNest.CNC;
@@ -23,7 +24,7 @@ public sealed class CincinnatiPreambleWriter
/// </summary> /// </summary>
/// <param name="initialLibrary">Resolved G89 library file for the initial process setup.</param> /// <param name="initialLibrary">Resolved G89 library file for the initial process setup.</param>
public void WriteMainProgram(TextWriter w, string nestName, string materialDescription, public void WriteMainProgram(TextWriter w, string nestName, string materialDescription,
int sheetCount, string initialLibrary) List<Plate> plates, string initialLibrary)
{ {
w.WriteLine(CoordinateFormatter.Comment($"NEST {nestName}")); w.WriteLine(CoordinateFormatter.Comment($"NEST {nestName}"));
w.WriteLine(CoordinateFormatter.Comment($"CONFIGURATION - {_config.ConfigurationName}")); w.WriteLine(CoordinateFormatter.Comment($"CONFIGURATION - {_config.ConfigurationName}"));
@@ -54,10 +55,16 @@ public sealed class CincinnatiPreambleWriter
w.WriteLine("GOTO1 (GOTO SHEET NUMBER)"); w.WriteLine("GOTO1 (GOTO SHEET NUMBER)");
for (var i = 1; i <= sheetCount; i++) for (var i = 0; i < plates.Count; i++)
{ {
var subNum = _config.SheetSubprogramStart + (i - 1); var layoutNumber = i + 1;
w.WriteLine($"N{i} M98 P{subNum} (SHEET {i})"); var subNum = _config.SheetSubprogramStart + i;
var qty = System.Math.Max(plates[i].Quantity, 1);
var lParam = qty > 1 ? $" L{qty}" : "";
var sheetLabel = qty > 1
? $"LAYOUT {layoutNumber} - {qty} SHEETS"
: $"LAYOUT {layoutNumber}";
w.WriteLine($"N{layoutNumber} M98 P{subNum}{lParam} ({sheetLabel})");
} }
w.WriteLine("M42"); w.WriteLine("M42");
@@ -35,10 +35,10 @@ public sealed class CincinnatiSheetWriter
/// Optional mapping of (drawingId, rotationKey) to sub-program number. /// Optional mapping of (drawingId, rotationKey) to sub-program number.
/// When provided, non-cutoff parts are emitted as M98 calls instead of inline features. /// When provided, non-cutoff parts are emitted as M98 calls instead of inline features.
/// </param> /// </param>
public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber, public void Write(TextWriter w, Plate plate, string nestName, int layoutIndex, int subNumber,
string cutLibrary, string etchLibrary, string cutLibrary, string etchLibrary,
Dictionary<(int, long), int> partSubprograms = null, Dictionary<(int, long), int> partSubprograms = null,
bool isLastSheet = false) Dictionary<(int drawingId, string varName), int> userVarMapping = null)
{ {
if (plate.Parts.Count == 0) if (plate.Parts.Count == 0)
return; return;
@@ -51,11 +51,10 @@ public sealed class CincinnatiSheetWriter
// 1. Sheet header // 1. Sheet header
w.WriteLine("(*****************************************************)"); w.WriteLine("(*****************************************************)");
w.WriteLine($"( START OF {nestName}.{sheetIndex:D3} )"); w.WriteLine($"( START OF {nestName}.{layoutIndex:D3} )");
w.WriteLine($":{subNumber}"); w.WriteLine($":{subNumber}");
w.WriteLine($"( Sheet {sheetIndex} )"); w.WriteLine($"( Layout {layoutIndex} )");
w.WriteLine($"( Layout {sheetIndex} )"); w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(width)} X {_fmt.FormatCoord(length)} )");
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(length)} X {_fmt.FormatCoord(width)} )");
w.WriteLine($"( Total parts on sheet = {partCount} )"); w.WriteLine($"( Total parts on sheet = {partCount} )");
w.WriteLine($"#{_config.SheetWidthVariable}={_fmt.FormatCoord(width)} (SHEET WIDTH FOR CUTOFFS)"); w.WriteLine($"#{_config.SheetWidthVariable}={_fmt.FormatCoord(width)} (SHEET WIDTH FOR CUTOFFS)");
w.WriteLine($"#{_config.SheetLengthVariable}={_fmt.FormatCoord(length)} (SHEET LENGTH FOR CUTOFFS)"); w.WriteLine($"#{_config.SheetLengthVariable}={_fmt.FormatCoord(length)} (SHEET LENGTH FOR CUTOFFS)");
@@ -88,23 +87,22 @@ public sealed class CincinnatiSheetWriter
// 4. Emit parts // 4. Emit parts
if (partSubprograms != null) if (partSubprograms != null)
WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms); WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, width, length, partSubprograms, userVarMapping);
else else
WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal); WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, width, length, userVarMapping);
// 5. Footer // 5. Footer
w.WriteLine("M42"); w.WriteLine("M42");
w.WriteLine("G0 X0 Y0"); if (_config.PalletExchange != PalletMode.None)
var emitM50 = _config.PalletExchange == PalletMode.EndOfSheet w.WriteLine("M50");
|| (_config.PalletExchange == PalletMode.StartAndEnd && isLastSheet); w.WriteLine($"M99 (END OF {nestName}.{layoutIndex:D3})");
if (emitM50)
w.WriteLine($"N{sheetIndex + 1} M50");
w.WriteLine($"M99 (END OF {nestName}.{sheetIndex:D3})");
} }
private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts, private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts,
string cutLibrary, string etchLibrary, double sheetDiagonal, string cutLibrary, string etchLibrary, double sheetDiagonal,
Dictionary<(int, long), int> partSubprograms) double plateWidth, double plateLength,
Dictionary<(int, long), int> partSubprograms,
Dictionary<(int drawingId, string varName), int> userVarMapping)
{ {
var lastPartName = ""; var lastPartName = "";
var featureIndex = 0; var featureIndex = 0;
@@ -154,7 +152,12 @@ public sealed class CincinnatiSheetWriter
LibraryFile = isEtch ? etchLibrary : cutLibrary, LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance, CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal, SheetDiagonal = sheetDiagonal,
PartLocation = part.Location PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id,
IsCutOff = part.BaseDrawing.IsCutOff,
PlateWidth = plateWidth,
PlateLength = plateLength
}; };
_featureWriter.Write(w, ctx); _featureWriter.Write(w, ctx);
@@ -202,7 +205,9 @@ public sealed class CincinnatiSheetWriter
} }
private void WritePartsInline(TextWriter w, List<Part> allParts, private void WritePartsInline(TextWriter w, List<Part> allParts,
string cutLibrary, string etchLibrary, double sheetDiagonal) string cutLibrary, string etchLibrary, double sheetDiagonal,
double plateWidth, double plateLength,
Dictionary<(int drawingId, string varName), int> userVarMapping)
{ {
// Split and classify features, ordering etch before cut per part // Split and classify features, ordering etch before cut per part
var features = new List<(Part part, List<ICode> codes, bool isEtch)>(); var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
@@ -242,7 +247,12 @@ public sealed class CincinnatiSheetWriter
LibraryFile = isEtch ? etchLibrary : cutLibrary, LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance, CutDistance = cutDistance,
SheetDiagonal = sheetDiagonal, SheetDiagonal = sheetDiagonal,
PartLocation = part.Location PartLocation = part.Location,
UserVariableMapping = userVarMapping,
DrawingId = part.BaseDrawing.Id,
IsCutOff = part.BaseDrawing.IsCutOff,
PlateWidth = plateWidth,
PlateLength = plateLength
}; };
_featureWriter.Write(w, ctx); _featureWriter.Write(w, ctx);
@@ -101,4 +101,35 @@ public class ProgramVariableTests
move.Offset(5.0, 0); move.Offset(5.0, 0);
Assert.Null(move.VariableRefs); Assert.Null(move.VariableRefs);
} }
[Fact]
public void Program_Variables_EmptyByDefault()
{
var pgm = new Program();
Assert.Empty(pgm.Variables);
}
[Fact]
public void Program_Variables_CaseInsensitive()
{
var pgm = new Program();
pgm.Variables["Diameter"] = new VariableDefinition("Diameter", "0.3", 0.3);
Assert.True(pgm.Variables.ContainsKey("diameter"));
}
[Fact]
public void Program_Clone_DeepCopiesVariables()
{
var pgm = new Program();
pgm.Variables["diameter"] = new VariableDefinition("diameter", "0.3", 0.3);
pgm.Codes.Add(new LinearMove(1.0, 0));
var clone = (Program)pgm.Clone();
Assert.Single(clone.Variables);
Assert.Equal(0.3, clone.Variables["diameter"].Value);
// Verify it's a separate dictionary
clone.Variables.Remove("diameter");
Assert.Single(pgm.Variables);
}
} }
@@ -38,7 +38,7 @@ public class CincinnatiPostProcessorTests
// Sheet subprogram // Sheet subprogram
Assert.Contains(":101", output); Assert.Contains(":101", output);
Assert.Contains("( Sheet 1 )", output); Assert.Contains("( Layout 1 )", output);
Assert.Contains("G84", output); Assert.Contains("G84", output);
Assert.Contains("M99", output); Assert.Contains("M99", output);
} }
@@ -150,8 +150,8 @@ public class CincinnatiPostProcessorTests
var output = Encoding.UTF8.GetString(ms.ToArray()); var output = Encoding.UTF8.GetString(ms.ToArray());
// Should only have one sheet subprogram call in main // Should only have one sheet subprogram call in main
Assert.Contains("N1 M98 P101 (SHEET 1)", output); Assert.Contains("N1 M98 P101 (LAYOUT 1)", output);
Assert.DoesNotContain("SHEET 2", output); Assert.DoesNotContain("LAYOUT 2", output);
} }
[Fact] [Fact]
@@ -258,8 +258,7 @@ public class CincinnatiPostProcessorTests
Assert.Contains(":200", output); Assert.Contains(":200", output);
Assert.Contains("G84", output); Assert.Contains("G84", output);
// Sub-program ends with G0 X0 Y0 and M99 // Sub-program ends with M99
Assert.Contains("G0 X0 Y0", output);
Assert.Contains("M99 (END OF Square)", output); Assert.Contains("M99 (END OF Square)", output);
// G92 restore after M98 call // G92 restore after M98 call
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
using OpenNest.CNC; using OpenNest.CNC;
@@ -19,7 +20,8 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "TestNest", "Mild Steel, 10GA", 2, "MS135N2PANEL.lib"); var plates = new List<Plate> { new(48, 96), new(48, 96) };
writer.WriteMainProgram(sw, "TestNest", "Mild Steel, 10GA", plates, "MS135N2PANEL.lib");
var output = sb.ToString(); var output = sb.ToString();
Assert.Contains("( NEST TestNest )", output); Assert.Contains("( NEST TestNest )", output);
@@ -29,8 +31,8 @@ public class CincinnatiPreambleWriterTests
Assert.Contains("G89 PMS135N2PANEL.lib", output); Assert.Contains("G89 PMS135N2PANEL.lib", output);
Assert.Contains("M98 P100 (Variable Declaration)", output); Assert.Contains("M98 P100 (Variable Declaration)", output);
Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output); Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output);
Assert.Contains("N1 M98 P101 (SHEET 1)", output); Assert.Contains("N1 M98 P101 (LAYOUT 1)", output);
Assert.Contains("N2 M98 P102 (SHEET 2)", output); Assert.Contains("N2 M98 P102 (LAYOUT 2)", output);
Assert.Contains("M30 (END OF MAIN)", output); Assert.Contains("M30 (END OF MAIN)", output);
} }
@@ -42,7 +44,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.Contains("G21 G90", sb.ToString()); Assert.Contains("G21 G90", sb.ToString());
} }
@@ -55,7 +57,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.Contains("G20 G90", sb.ToString()); Assert.Contains("G20 G90", sb.ToString());
} }
@@ -68,7 +70,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.Contains("G121 (SMART RAPIDS)", sb.ToString()); Assert.Contains("G121 (SMART RAPIDS)", sb.ToString());
} }
@@ -81,7 +83,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.DoesNotContain("G121", sb.ToString()); Assert.DoesNotContain("G121", sb.ToString());
} }
@@ -94,7 +96,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.Contains("M50", sb.ToString()); Assert.Contains("M50", sb.ToString());
} }
@@ -107,7 +109,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.DoesNotContain("M50", sb.ToString()); Assert.DoesNotContain("M50", sb.ToString());
} }
@@ -120,7 +122,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.Contains("G61", sb.ToString()); Assert.Contains("G61", sb.ToString());
} }
@@ -133,11 +135,33 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config); var writer = new CincinnatiPreambleWriter(config);
writer.WriteMainProgram(sw, "Test", "", 1, ""); writer.WriteMainProgram(sw, "Test", "", new List<Plate> { new(48, 96) }, "");
Assert.DoesNotContain("G61", sb.ToString()); Assert.DoesNotContain("G61", sb.ToString());
} }
[Fact]
public void WriteMainProgram_EmitsLCount_WhenQuantityGreaterThanOne()
{
var config = new CincinnatiPostConfig { PostedUnits = Units.Inches };
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var writer = new CincinnatiPreambleWriter(config);
var plates = new List<Plate>
{
new(48, 96) { Quantity = 5 },
new(72, 48) { Quantity = 2 },
new(36, 48) { Quantity = 1 }
};
writer.WriteMainProgram(sw, "Test", "", plates, "");
var output = sb.ToString();
Assert.Contains("N1 M98 P101 L5 (LAYOUT 1 - 5 SHEETS)", output);
Assert.Contains("N2 M98 P102 L2 (LAYOUT 2 - 2 SHEETS)", output);
Assert.Contains("N3 M98 P103 (LAYOUT 3)", output);
}
[Fact] [Fact]
public void WriteVariableDeclaration_EmitsSubprogram() public void WriteVariableDeclaration_EmitsSubprogram()
{ {
@@ -28,7 +28,7 @@ public class CincinnatiSheetWriterTests
var output = sb.ToString(); var output = sb.ToString();
Assert.Contains(":101", output); Assert.Contains(":101", output);
Assert.Contains("( Sheet 1 )", output); Assert.Contains("( Layout 1 )", output);
Assert.Contains("#110=", output); Assert.Contains("#110=", output);
Assert.Contains("#111=", output); Assert.Contains("#111=", output);
Assert.Contains("G92 X#5021 Y#5022", output); Assert.Contains("G92 X#5021 Y#5022", output);
@@ -55,7 +55,6 @@ public class CincinnatiSheetWriterTests
var output = sb.ToString(); var output = sb.ToString();
Assert.Contains("M42", output); Assert.Contains("M42", output);
Assert.Contains("G0 X0 Y0", output);
Assert.Contains("M50", output); Assert.Contains("M50", output);
} }
@@ -143,7 +142,7 @@ public class CincinnatiSheetWriterTests
} }
[Fact] [Fact]
public void WriteSheet_StartAndEnd_NoM50OnNonLastSheet() public void WriteSheet_StartAndEnd_EmitsM50()
{ {
var config = new CincinnatiPostConfig var config = new CincinnatiPostConfig
{ {
@@ -157,33 +156,33 @@ public class CincinnatiSheetWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager()); var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false); sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
var output = sb.ToString();
Assert.DoesNotContain("M50", output);
}
[Fact]
public void WriteSheet_StartAndEnd_M50OnLastSheet()
{
var config = new CincinnatiPostConfig
{
PalletExchange = PalletMode.StartAndEnd,
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: true);
var output = sb.ToString(); var output = sb.ToString();
Assert.Contains("M50", output); Assert.Contains("M50", output);
} }
[Fact]
public void WriteSheet_NoPalletExchange_OmitsM50()
{
var config = new CincinnatiPostConfig
{
PalletExchange = PalletMode.None,
PostedAccuracy = 4
};
var plate = new Plate(48.0, 96.0);
plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram())));
var sb = new StringBuilder();
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
var output = sb.ToString();
Assert.DoesNotContain("M50", output);
}
[Fact] [Fact]
public void WriteSheet_EndOfSheet_AlwaysEmitsM50() public void WriteSheet_EndOfSheet_AlwaysEmitsM50()
{ {
@@ -199,7 +198,7 @@ public class CincinnatiSheetWriterTests
using var sw = new StringWriter(sb); using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager()); var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false); sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
var output = sb.ToString(); var output = sb.ToString();
Assert.Contains("M50", output); Assert.Contains("M50", output);
@@ -0,0 +1,207 @@
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Posts.Cincinnati;
namespace OpenNest.Tests.Cincinnati;
public class UserVariablePostTests
{
[Fact]
public void UserVariables_EmittedInDeclarationSubprogram()
{
var output = PostNestWithVariables("width = 48.0\nG90\nG01X$widthY0");
Assert.Contains("#200=48", output);
Assert.Contains("WIDTH", output.ToUpper());
}
[Fact]
public void UserVariables_InlineVariable_NotEmittedAsNumbered()
{
var output = PostNestWithVariables("kerf = 0.06 inline\nG90\nG01X1Y0");
Assert.DoesNotContain("#200", output);
}
[Fact]
public void UserVariables_CoordinateUsesNumberedVariable()
{
var output = PostNestWithVariables("width = 48.0\nG90\nG01X$widthY0");
Assert.Contains("X#200", output);
}
[Fact]
public void UserVariables_InlineVariable_CoordinateUsesLiteral()
{
var output = PostNestWithVariables("kerf = 0.06 inline\nG90\nG01X$kerfY0");
Assert.Contains("X0.06", output);
// G1 coordinate lines should not use X#nnn variable references for inline vars
var g1Lines = output.Split('\n').Where(l => l.TrimStart().StartsWith("G1 ")).ToList();
Assert.All(g1Lines, line => Assert.DoesNotContain("X#", line));
}
[Fact]
public void UserVariables_GlobalVariables_SharedAcrossDrawings()
{
var pgm1 = ParseProgram("sheet_width = 48.0 global\nG90\nG01X$sheet_widthY0");
var pgm2 = ParseProgram("sheet_width = 48.0 global\nG90\nG01X$sheet_widthY0");
var drawing1 = new Drawing("Part1", pgm1);
var drawing2 = new Drawing("Part2", pgm2);
var nest = new Nest { Name = "Test" };
nest.Drawings.Add(drawing1);
nest.Drawings.Add(drawing2);
var plate = new Plate(new Size(100, 100));
plate.Parts.Add(new Part(drawing1, new Vector(0, 0)));
plate.Parts.Add(new Part(drawing2, new Vector(50, 0)));
nest.Plates.Add(plate);
var config = new CincinnatiPostConfig { UserVariableStart = 200 };
var post = new CincinnatiPostProcessor(config);
var output = PostToString(post, nest);
// Both should use the same #200 — only one declaration
var declarationCount = output.Split('\n')
.Count(l => l.Contains("#200=") && l.ToUpper().Contains("SHEET WIDTH"));
Assert.Equal(1, declarationCount);
}
[Fact]
public void UserVariables_LocalVariables_GetSeparateNumbers()
{
var pgm1 = ParseProgram("diameter = 0.3\nG90\nG01X$diameterY0");
var pgm2 = ParseProgram("diameter = 0.5\nG90\nG01X$diameterY0");
var drawing1 = new Drawing("TubeA", pgm1);
var drawing2 = new Drawing("TubeB", pgm2);
var nest = new Nest { Name = "Test" };
nest.Drawings.Add(drawing1);
nest.Drawings.Add(drawing2);
var plate = new Plate(new Size(100, 100));
plate.Parts.Add(new Part(drawing1, new Vector(0, 0)));
plate.Parts.Add(new Part(drawing2, new Vector(50, 0)));
nest.Plates.Add(plate);
var config = new CincinnatiPostConfig { UserVariableStart = 200 };
var post = new CincinnatiPostProcessor(config);
var output = PostToString(post, nest);
// Two separate declarations with different numbers
Assert.Contains("#200=0.3", output);
Assert.Contains("#201=0.5", output);
Assert.Contains("TUBE A", output.ToUpper());
Assert.Contains("TUBE B", output.ToUpper());
}
[Fact]
public void UserVariables_StartNumberConfigurable()
{
var config = new CincinnatiPostConfig { UserVariableStart = 300 };
var output = PostNestWithVariables("width = 48.0\nG90\nG01X$widthY0", config);
Assert.Contains("#300=48", output);
}
[Fact]
public void CutOff_VerticalCut_UsesSheetWidthVariable()
{
// Create a plate with a vertical cutoff
var config = new CincinnatiPostConfig { SheetWidthVariable = 110, SheetLengthVariable = 111 };
var nest = new Nest { Name = "Test" };
var plate = new Plate(new Size(48, 96));
// Add a simple part so the plate isn't empty
var partPgm = new Program();
partPgm.Codes.Add(new RapidMove(0, 0));
partPgm.Codes.Add(new LinearMove(10, 0));
partPgm.Codes.Add(new LinearMove(10, 10));
partPgm.Codes.Add(new LinearMove(0, 10));
partPgm.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("Part1", partPgm);
nest.Drawings.Add(drawing);
plate.Parts.Add(new Part(drawing, new Vector(0, 0)));
// Add a vertical cutoff that goes full width (Y=0 to Y=48)
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(new CutOffSettings());
nest.Plates.Add(plate);
var post = new CincinnatiPostProcessor(config);
var output = PostToString(post, nest);
// The cutoff line end at Y=48 (sheet width) should use #110
Assert.Contains("Y#110", output);
}
[Fact]
public void CutOff_SegmentedCut_OnlyEdgeUsesVariable()
{
// Create a plate with a part in the middle and a vertical cutoff
var config = new CincinnatiPostConfig { SheetWidthVariable = 110 };
var nest = new Nest { Name = "Test" };
var plate = new Plate(new Size(48, 96));
// Part in the middle — cutoff will be segmented around it
var partPgm = new Program();
partPgm.Codes.Add(new RapidMove(0, 0));
partPgm.Codes.Add(new LinearMove(10, 0));
partPgm.Codes.Add(new LinearMove(10, 10));
partPgm.Codes.Add(new LinearMove(0, 10));
partPgm.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("Part1", partPgm);
nest.Drawings.Add(drawing);
plate.Parts.Add(new Part(drawing, new Vector(15, 20))); // Part at Y=20-30, should create gap
var cutoff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(cutoff);
plate.RegenerateCutOffs(new CutOffSettings());
nest.Plates.Add(plate);
var post = new CincinnatiPostProcessor(config);
var output = PostToString(post, nest);
// The last segment endpoint at Y=48 should use #110
Assert.Contains("Y#110", output);
}
private static string PostNestWithVariables(string gcode, CincinnatiPostConfig config = null)
{
var program = ParseProgram(gcode);
var drawing = new Drawing("TestPart", program);
var nest = new Nest { Name = "Test" };
nest.Drawings.Add(drawing);
var plate = new Plate(new Size(100, 100));
plate.Parts.Add(new Part(drawing, new Vector(0, 0)));
nest.Plates.Add(plate);
config ??= new CincinnatiPostConfig { UserVariableStart = 200 };
var post = new CincinnatiPostProcessor(config);
return PostToString(post, nest);
}
private static string PostToString(CincinnatiPostProcessor post, Nest nest)
{
var ms = new MemoryStream();
post.Post(nest, ms);
ms.Position = 0;
return new StreamReader(ms).ReadToEnd();
}
private static Program ParseProgram(string gcode)
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode));
var reader = new ProgramReader(stream);
var program = reader.Read();
reader.Close();
return program;
}
}
@@ -0,0 +1,91 @@
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.IO;
public class NestWriterVariableTests
{
[Fact]
public void RoundTrip_VariableDefinitions_Preserved()
{
var nest = CreateNestWithVariableProgram(
"width = 48.0 global\ndiameter = 0.3\nG90\nG01X$widthY$diameter");
var loaded = RoundTrip(nest);
var pgm = loaded.Drawings.First().Program;
Assert.Equal(2, pgm.Variables.Count);
Assert.Equal(48.0, pgm.Variables["width"].Value);
Assert.True(pgm.Variables["width"].Global);
Assert.Equal(0.3, pgm.Variables["diameter"].Value);
Assert.False(pgm.Variables["diameter"].Global);
}
[Fact]
public void RoundTrip_VariableRefs_Preserved()
{
var nest = CreateNestWithVariableProgram(
"width = 48.0\nG90\nG01X$widthY0");
var loaded = RoundTrip(nest);
var pgm = loaded.Drawings.First().Program;
var linear = (LinearMove)pgm.Codes[0];
Assert.Equal(48.0, linear.EndPoint.X);
Assert.NotNull(linear.VariableRefs);
Assert.Equal("width", linear.VariableRefs["X"]);
}
[Fact]
public void RoundTrip_InlineFlag_Preserved()
{
var nest = CreateNestWithVariableProgram(
"kerf = 0.06 inline\nG90\nG01X1Y0");
var loaded = RoundTrip(nest);
var pgm = loaded.Drawings.First().Program;
Assert.True(pgm.Variables["kerf"].Inline);
}
[Fact]
public void RoundTrip_NoVariables_WorksAsNormal()
{
var nest = CreateNestWithVariableProgram("G90\nG01X1Y2");
var loaded = RoundTrip(nest);
var pgm = loaded.Drawings.First().Program;
Assert.Empty(pgm.Variables);
var linear = (LinearMove)pgm.Codes[0];
Assert.Equal(1.0, linear.EndPoint.X);
}
private static Nest CreateNestWithVariableProgram(string gcode)
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode));
var reader = new ProgramReader(stream);
var program = reader.Read();
reader.Close();
var drawing = new Drawing("TestPart", program);
var nest = new Nest { Name = "Test" };
nest.Drawings.Add(drawing);
var plate = new Plate(new Size(100, 100));
plate.Parts.Add(new Part(drawing, new Vector(0, 0)));
nest.Plates.Add(plate);
return nest;
}
private static Nest RoundTrip(Nest nest)
{
var ms = new MemoryStream();
new NestWriter(nest).Write(ms);
ms.Position = 0;
return new NestReader(ms).Read();
}
}
@@ -0,0 +1,136 @@
using System.IO;
using System.Text;
using OpenNest.CNC;
using OpenNest.IO;
namespace OpenNest.Tests.IO;
public class ProgramReaderVariableTests
{
private Program Parse(string gcode)
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode));
var reader = new ProgramReader(stream);
var program = reader.Read();
reader.Close();
return program;
}
[Fact]
public void Parse_SimpleVariable_StoredInVariables()
{
var pgm = Parse("diameter = 0.3\nG90\nG01X1Y0");
Assert.True(pgm.Variables.ContainsKey("diameter"));
Assert.Equal(0.3, pgm.Variables["diameter"].Value);
Assert.Equal("0.3", pgm.Variables["diameter"].Expression);
}
[Fact]
public void Parse_VariableWithInlineFlag()
{
var pgm = Parse("kerf = 0.06 inline\nG90\nG01X1Y0");
Assert.True(pgm.Variables["kerf"].Inline);
Assert.False(pgm.Variables["kerf"].Global);
}
[Fact]
public void Parse_VariableWithGlobalFlag()
{
var pgm = Parse("sheet_width = 48.0 global\nG90\nG01X1Y0");
Assert.True(pgm.Variables["sheet_width"].Global);
Assert.False(pgm.Variables["sheet_width"].Inline);
}
[Fact]
public void Parse_VariableWithBothFlags()
{
var pgm = Parse("speed = 200 global inline\nG90\nG01X1Y0");
Assert.True(pgm.Variables["speed"].Global);
Assert.True(pgm.Variables["speed"].Inline);
}
[Fact]
public void Parse_VariableReference_SubstitutedInCoordinate()
{
var pgm = Parse("width = 48.0\nG90\nG01X$widthY0");
var linear = (LinearMove)pgm.Codes[0];
Assert.Equal(48.0, linear.EndPoint.X);
Assert.Equal(0.0, linear.EndPoint.Y);
}
[Fact]
public void Parse_VariableReference_TrackedInVariableRefs()
{
var pgm = Parse("width = 48.0\nG90\nG01X$widthY0");
var linear = (LinearMove)pgm.Codes[0];
Assert.NotNull(linear.VariableRefs);
Assert.Equal("width", linear.VariableRefs["X"]);
Assert.False(linear.VariableRefs.ContainsKey("Y"));
}
[Fact]
public void Parse_VariableExpression_WithReference()
{
var pgm = Parse("diameter = 0.6\nradius = $diameter / 2\nG90\nG02X1Y0I$radiusJ0");
Assert.Equal(0.3, pgm.Variables["radius"].Value, 10);
var arc = (ArcMove)pgm.Codes[0];
Assert.Equal(0.3, arc.CenterPoint.X, 10);
Assert.Equal("radius", arc.VariableRefs["I"]);
}
[Fact]
public void Parse_FeedVariable_TrackedOnFeedrate()
{
var pgm = Parse("speed = 100\nG90\nF$speed\nG01X1Y0");
var feedrate = (Feedrate)pgm.Codes[0];
Assert.Equal(100.0, feedrate.Value);
Assert.Equal("speed", feedrate.VariableRef);
}
[Fact]
public void Parse_VariablesCollectedInPrepass_OrderIndependent()
{
var pgm = Parse("radius = $diameter / 2\ndiameter = 0.6\nG90\nG01X$radiusY0");
Assert.Equal(0.3, pgm.Variables["radius"].Value, 10);
var linear = (LinearMove)pgm.Codes[0];
Assert.Equal(0.3, linear.EndPoint.X, 10);
}
[Fact]
public void Parse_NoVariables_WorksAsNormal()
{
var pgm = Parse("G90\nG01X1.5Y2.5");
Assert.Empty(pgm.Variables);
var linear = (LinearMove)pgm.Codes[0];
Assert.Equal(1.5, linear.EndPoint.X);
Assert.Null(linear.VariableRefs);
}
[Fact]
public void Parse_RapidMove_WithVariableRef()
{
var pgm = Parse("start_x = 5.0\nG90\nG00X$start_xY0");
var rapid = (RapidMove)pgm.Codes[0];
Assert.Equal(5.0, rapid.EndPoint.X);
Assert.Equal("start_x", rapid.VariableRefs["X"]);
}
[Fact]
public void Parse_ArcMove_VariableOnMultipleAxes()
{
var pgm = Parse("r = 0.5\nG90\nG03X1Y0I$rJ$r");
var arc = (ArcMove)pgm.Codes[0];
Assert.Equal(0.5, arc.CenterPoint.X);
Assert.Equal(0.5, arc.CenterPoint.Y);
Assert.Equal("r", arc.VariableRefs["I"]);
Assert.Equal("r", arc.VariableRefs["J"]);
}
[Fact]
public void Parse_CaseInsensitive_VariableReference()
{
var pgm = Parse("Diameter = 0.3\nG90\nG01X$diameterY0");
var linear = (LinearMove)pgm.Codes[0];
Assert.Equal(0.3, linear.EndPoint.X);
}
}
+7 -4
View File
@@ -102,7 +102,7 @@ namespace OpenNest.Actions
snapPoint = closest; snapPoint = closest;
snapEntity = entity; snapEntity = entity;
snapContourType = info.ContourType; snapContourType = info.ContourType;
snapNormal = ContourCuttingStrategy.ComputeNormal(closest, entity, info.ContourType); snapNormal = ContourCuttingStrategy.ComputeNormal(closest, entity, info.ContourType, info.Winding);
hasSnap = true; hasSnap = true;
hoveredContour = info; hoveredContour = info;
} }
@@ -282,7 +282,7 @@ namespace OpenNest.Actions
{ {
snapPoint = bestPoint; snapPoint = bestPoint;
snapEntity = bestEntity; snapEntity = bestEntity;
snapNormal = ContourCuttingStrategy.ComputeNormal(bestPoint, bestEntity, snapContourType); snapNormal = ContourCuttingStrategy.ComputeNormal(bestPoint, bestEntity, snapContourType, hoveredContour.Winding);
activeSnapType = bestType; activeSnapType = bestType;
} }
@@ -356,7 +356,8 @@ namespace OpenNest.Actions
contours.Add(new ShapeInfo contours.Add(new ShapeInfo
{ {
Shape = profile.Perimeter, Shape = profile.Perimeter,
ContourType = ContourType.External ContourType = ContourType.External,
Winding = ContourCuttingStrategy.DetermineWinding(profile.Perimeter)
}); });
} }
@@ -366,7 +367,8 @@ namespace OpenNest.Actions
contours.Add(new ShapeInfo contours.Add(new ShapeInfo
{ {
Shape = cutout, Shape = cutout,
ContourType = ContourCuttingStrategy.DetectContourType(cutout) ContourType = ContourCuttingStrategy.DetectContourType(cutout),
Winding = ContourCuttingStrategy.DetermineWinding(cutout)
}); });
} }
} }
@@ -483,6 +485,7 @@ namespace OpenNest.Actions
{ {
public Shape Shape { get; set; } public Shape Shape { get; set; }
public ContourType ContourType { get; set; } public ContourType ContourType { get; set; }
public RotationType Winding { get; set; }
} }
} }
} }
+2 -1
View File
@@ -22,6 +22,7 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and
- **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting, with snap-to-endpoint/midpoint placement - **Lead-In/Lead-Out & Tabs** — Configurable approach paths, exit paths, and holding tabs for CNC cutting, with snap-to-endpoint/midpoint placement
- **Contour & Program Editing** — Inline G-code editor with contour reordering, direction arrows, and cut direction reversal - **Contour & Program Editing** — Inline G-code editor with contour reordering, direction arrows, and cut direction reversal
- **G-code Output** — Post-process nested layouts to G-code via plugin post-processors - **G-code Output** — Post-process nested layouts to G-code via plugin post-processors
- **User-Defined Variables** — Define named variables in G-code (`diameter = 0.3`) referenced with `$name` syntax; Cincinnati post emits numbered machine variables (`#200`) so operators can adjust values at the control
- **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts - **Built-in Shapes** — 12 parametric shapes (circles, rectangles, L-shapes, T-shapes, flanges, etc.) for quick testing or simple parts
- **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view - **Interactive Editing** — Zoom, pan, select, clone, push, and manually arrange parts on the plate view
- **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs - **Pluggable Engine Architecture** — Swap between built-in nesting engines or load custom engines from plugin DLLs
@@ -212,7 +213,7 @@ Custom post-processors implement the `IPostProcessor` interface and are auto-dis
Nest files (`.nest`) are ZIP archives containing: Nest files (`.nest`) are ZIP archives containing:
- `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs) - `nest.json` — JSON metadata: nest info, plate defaults, drawings (with bend data), and plates (with parts and cut-offs)
- `programs/program-N` — G-code text for each drawing's cut program - `programs/program-N` — G-code text for each drawing's cut program (may include variable definitions and `$name` references)
- `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional) - `bestfits/bestfit-N` — Cached best-fit pair evaluation results (optional)
## Roadmap ## Roadmap