Compare commits

...

24 Commits

Author SHA1 Message Date
aj e50a7c82cf test: skip overlap tests gracefully when DXF fixture missing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:34:56 -04:00
aj 7a893ef50f refactor: replace floating tool window with docked side panel
- Add general-purpose ShowSidePanel/HideSidePanel to EditNestForm
- CuttingPanel uses Dock.Top layout so collapsible panels reflow
- Add loop selection step: click contour to lock before placing lead-in
- Stay on selected part after placing a lead-in
- Delete unused LeadInToolWindow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:34:20 -04:00
aj 925a1c7751 test: add tests for ApplySingleLeadIn on Part
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:45:02 -04:00
aj 036b48e273 refactor: replace CuttingParametersForm with settings-based parameter init
Remove CuttingParametersForm modal dialog. PlaceLeadIn_Click,
AssignLeadIns_Click, and AssignLeadInsAllPlates now initialize
cutting parameters from saved settings or defaults instead of
showing a dialog. The CuttingPanel tool window (in LeadInToolWindow)
replaces the form for interactive parameter editing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:43:16 -04:00
aj bd9b0369cf feat: ActionLeadIn uses tool window and single-contour placement
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:41:00 -04:00
aj 93391c4b8f feat: create LeadInToolWindow floating form
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:38:13 -04:00
aj ebab795f86 feat: create reusable CuttingPanel control
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:36:22 -04:00
aj 9f9111975d feat: add ApplySingle for exact-click single-contour lead-in placement
Adds ApplySingle to ContourCuttingStrategy that applies lead-in/out to
only the contour containing the clicked entity, emitting other contours
as raw geometry. Also adds ApplySingleLeadIn wrapper to Part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:32:56 -04:00
aj 25ee193ae6 feat: add auto-tab size range fields to CuttingParameters
Add AutoTabMinSize and AutoTabMaxSize properties to enable automatic tab
assignment based on part size. Update CuttingParametersSerializer for
round-trip serialization and add tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:25:06 -04:00
aj 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
aj 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
aj 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
aj 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
aj 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
aj 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
aj 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
aj 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
aj 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
aj 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
aj 95b9613e2d feat: add VariableRefs tracking on Motion and Feedrate
Adds Dictionary<string,string> VariableRefs to Motion (cleared on Rotate/Offset) and string VariableRef to Feedrate, with deep-copy Clone() support, so post processors can emit variable references instead of literal coordinate values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:56:28 -04:00
aj 3bc9301e22 feat: add ExpressionEvaluator for G-code variable expressions
Also set ContinueOnError=true on Cincinnati's post-build copy to prevent
the running WinForms app from blocking test builds via a file lock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:52:37 -04:00
aj 1040db414f feat: add VariableDefinition type for G-code user variables
Adds immutable VariableDefinition record to OpenNest.CNC with name,
expression, resolved value, inline, and global flags. Fixes namespace
collision in PatternTilerTests and PolygonHelperTests caused by the new
OpenNest.Tests.CNC namespace.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:46:37 -04:00
aj 287023d802 feat: add syntax highlighting to gcode editor
Switch gcodeEditor from TextBox to RichTextBox and colorize G-code
tokens: rapids (amber), linear cuts (green), arcs (blue), comments
(dim gray), and mode codes (purple).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:38:14 -04:00
aj 3a24e76dbd refactor: make ProgramEditorControl gcode editor read-only with contour comments
Remove the Apply button and OnApplyClicked handler since the gcode
editor is now read-only. Add contour label comments (e.g. "; Hole 1
(CCW)") to the formatted gcode output so users can see which feature
each group of codes belongs to.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:34:25 -04:00
46 changed files with 2603 additions and 686 deletions
+3 -2
View File
@@ -24,10 +24,10 @@ Eight projects form a layered architecture:
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`.
- **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).
- **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.
- **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.
@@ -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.
- `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).
- **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.
+4 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -66,7 +67,8 @@ namespace OpenNest.CNC
return new ArcMove(EndPoint, CenterPoint, Rotation)
{
Layer = Layer,
Suppressed = Suppressed
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
}
@@ -50,6 +50,126 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
{
var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
var profile = new ShapeProfile(entities);
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities);
// Find the target shape that contains the clicked entity
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
// Emit cutouts — only the target gets lead-in/out
foreach (var cutout in profile.Cutouts)
{
if (cutout == targetShape)
{
var ct = DetectContourType(cutout);
EmitContour(result, cutout, point, matchedEntity, ct);
}
else
{
EmitRawContour(result, cutout);
}
}
// Emit perimeter
if (profile.Perimeter == targetShape)
{
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
}
else
{
EmitRawContour(result, profile.Perimeter);
}
result.Mode = Mode.Incremental;
return new CuttingResult
{
Program = result,
LastCutPoint = point
};
}
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
{
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
if (matched != null)
return (profile.Perimeter, matched);
foreach (var cutout in profile.Cutouts)
{
matched = FindMatchingEntity(cutout, clickedEntity);
if (matched != null)
return (cutout, matched);
}
// Fallback: closest shape, use closest point to find entity
var best = profile.Perimeter;
var bestPt = profile.Perimeter.ClosestPointTo(point, out var bestEntity);
var bestDist = bestPt.DistanceTo(point);
foreach (var cutout in profile.Cutouts)
{
var pt = cutout.ClosestPointTo(point, out var cutoutEntity);
var dist = pt.DistanceTo(point);
if (dist < bestDist)
{
best = cutout;
bestEntity = cutoutEntity;
bestDist = dist;
}
}
return (best, bestEntity);
}
private static Entity FindMatchingEntity(Shape shape, Entity clickedEntity)
{
foreach (var shapeEntity in shape.Entities)
{
if (shapeEntity.GetType() != clickedEntity.GetType())
continue;
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
{
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
{
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
{
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
}
return null;
}
private void EmitRawContour(Program program, Shape shape)
{
var startPoint = GetShapeStartPoint(shape);
program.Codes.Add(new RapidMove(startPoint));
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
}
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
{
var entries = new ContourEntry[cutouts.Count];
@@ -70,8 +190,8 @@ namespace OpenNest.CNC.CuttingStrategy
private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null)
{
var contourType = forceType ?? DetectContourType(shape);
var normal = ComputeNormal(point, entity, contourType);
var winding = DetermineWinding(shape);
var normal = ComputeNormal(point, entity, contourType, winding);
var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType);
@@ -143,29 +263,33 @@ namespace OpenNest.CNC.CuttingStrategy
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;
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);
normal = tangent + Math.Angle.HalfPI;
if (winding == RotationType.CCW)
normal += System.Math.PI;
}
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);
// 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)
if (arc.Rotation != winding)
normal += System.Math.PI;
}
else if (entity is Circle circle)
{
// Radial outward — always correct regardless of winding
normal = point.AngleFrom(circle.Center);
}
else
@@ -182,9 +306,10 @@ namespace OpenNest.CNC.CuttingStrategy
public static RotationType DetermineWinding(Shape shape)
{
// Use signed area: positive = CCW, negative = CW
var area = shape.Area();
return area >= 0 ? RotationType.CCW : RotationType.CW;
if (shape.Entities.Count == 1 && shape.Entities[0] is Circle circle)
return circle.Rotation;
return shape.ToPolygon().RotationDirection();
}
private LeadIn ClampLeadInForCircle(LeadIn leadIn, Circle circle, Vector contourPoint, double normalAngle)
@@ -23,6 +23,9 @@ namespace OpenNest.CNC.CuttingStrategy
public double PierceClearance { get; set; } = 0.0625;
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
public Tab TabConfig { get; set; }
public bool TabsEnabled { get; set; }
+3 -1
View File
@@ -17,6 +17,8 @@
public double Value { get; set; }
public string VariableRef { get; set; }
public CodeType Type
{
get { return CodeType.SetFeedrate; }
@@ -24,7 +26,7 @@
public ICode Clone()
{
return new Feedrate(Value);
return new Feedrate(Value) { VariableRef = VariableRef };
}
public override string ToString()
+4 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -32,7 +33,8 @@ namespace OpenNest.CNC
return new LinearMove(EndPoint)
{
Layer = Layer,
Suppressed = Suppressed
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
}
+8 -1
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -14,6 +15,8 @@ namespace OpenNest.CNC
public bool Suppressed { get; set; }
public Dictionary<string, string> VariableRefs { get; set; }
protected Motion()
{
Feedrate = CNC.Feedrate.UseDefault;
@@ -22,21 +25,25 @@ namespace OpenNest.CNC
public virtual void Rotate(double angle)
{
EndPoint = EndPoint.Rotate(angle);
VariableRefs = null;
}
public virtual void Rotate(double angle, Vector origin)
{
EndPoint = EndPoint.Rotate(angle, origin);
VariableRefs = null;
}
public virtual void Offset(double x, double y)
{
EndPoint = new Vector(EndPoint.X + x, EndPoint.Y + y);
VariableRefs = null;
}
public virtual void Offset(Vector voffset)
{
EndPoint += voffset;
VariableRefs = null;
}
public abstract CodeType Type { get; }
+6
View File
@@ -1,6 +1,7 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
namespace OpenNest.CNC
@@ -9,6 +10,8 @@ namespace OpenNest.CNC
{
public List<ICode> Codes;
public Dictionary<string, VariableDefinition> Variables { get; } = new(StringComparer.OrdinalIgnoreCase);
private Mode mode;
public Program(Mode mode = Mode.Absolute)
@@ -454,6 +457,9 @@ namespace OpenNest.CNC
pgm.Codes.AddRange(codes);
foreach (var kvp in Variables)
pgm.Variables[kvp.Key] = kvp.Value;
return pgm;
}
+4 -2
View File
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.CNC
{
@@ -28,7 +29,8 @@ namespace OpenNest.CNC
{
return new RapidMove(EndPoint)
{
Suppressed = Suppressed
Suppressed = Suppressed,
VariableRefs = VariableRefs != null ? new Dictionary<string, string>(VariableRefs) : null
};
}
+21
View File
@@ -0,0 +1,21 @@
namespace OpenNest.CNC
{
public sealed class VariableDefinition
{
public string Name { get; }
public string Expression { get; }
public double Value { get; }
public bool Inline { get; }
public bool Global { get; }
public VariableDefinition(string name, string expression, double value,
bool inline = false, bool global = false)
{
Name = name;
Expression = expression;
Value = value;
Inline = inline;
Global = global;
}
}
}
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Globalization;
namespace OpenNest.Math
{
/// <summary>
/// Recursive descent parser for simple arithmetic expressions supporting
/// +, -, *, /, parentheses, unary minus/plus, and $variable references.
/// </summary>
public static class ExpressionEvaluator
{
public static double Evaluate(string expression, IReadOnlyDictionary<string, double> variables)
{
var parser = new Parser(expression, variables);
var result = parser.ParseExpression();
parser.SkipWhitespace();
if (!parser.IsEnd)
throw new FormatException($"Unexpected character at position {parser.Position}: '{parser.Current}'");
return result;
}
private ref struct Parser
{
private readonly ReadOnlySpan<char> _input;
private readonly IReadOnlyDictionary<string, double> _variables;
private int _pos;
public Parser(string input, IReadOnlyDictionary<string, double> variables)
{
_input = input.AsSpan();
_variables = variables;
_pos = 0;
}
public int Position => _pos;
public bool IsEnd => _pos >= _input.Length;
public char Current => _input[_pos];
public void SkipWhitespace()
{
while (_pos < _input.Length && _input[_pos] == ' ')
_pos++;
}
// Expression = Term (('+' | '-') Term)*
public double ParseExpression()
{
SkipWhitespace();
var left = ParseTerm();
while (true)
{
SkipWhitespace();
if (IsEnd) break;
var op = Current;
if (op != '+' && op != '-') break;
_pos++;
SkipWhitespace();
var right = ParseTerm();
left = op == '+' ? left + right : left - right;
}
return left;
}
// Term = Unary (('*' | '/') Unary)*
private double ParseTerm()
{
var left = ParseUnary();
while (true)
{
SkipWhitespace();
if (IsEnd) break;
var op = Current;
if (op != '*' && op != '/') break;
_pos++;
SkipWhitespace();
var right = ParseUnary();
left = op == '*' ? left * right : left / right;
}
return left;
}
// Unary = ('-' | '+')? Primary
private double ParseUnary()
{
SkipWhitespace();
if (!IsEnd && Current == '-')
{
_pos++;
return -ParsePrimary();
}
if (!IsEnd && Current == '+')
{
_pos++;
}
return ParsePrimary();
}
// Primary = '(' Expression ')' | '$' Identifier | Number
private double ParsePrimary()
{
SkipWhitespace();
if (IsEnd)
throw new FormatException("Unexpected end of expression.");
if (Current == '(')
{
_pos++; // consume '('
var value = ParseExpression();
SkipWhitespace();
if (IsEnd || Current != ')')
throw new FormatException("Expected closing parenthesis.");
_pos++; // consume ')'
return value;
}
if (Current == '$')
{
_pos++; // consume '$'
var start = _pos;
while (_pos < _input.Length && (char.IsLetterOrDigit(_input[_pos]) || _input[_pos] == '_'))
_pos++;
if (_pos == start)
throw new FormatException("Expected variable name after '$'.");
var name = _input.Slice(start, _pos - start).ToString();
if (!_variables.TryGetValue(name, out var varValue))
throw new KeyNotFoundException($"Undefined variable: ${name}");
return varValue;
}
// Number
var numStart = _pos;
while (_pos < _input.Length && (char.IsDigit(_input[_pos]) || _input[_pos] == '.'))
_pos++;
if (_pos == numStart)
throw new FormatException($"Unexpected character '{Current}' at position {_pos}.");
var numSpan = _input.Slice(numStart, _pos - numStart).ToString();
if (!double.TryParse(numSpan, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
throw new FormatException($"Invalid number: '{numSpan}'");
return number;
}
}
}
}
+12
View File
@@ -72,6 +72,18 @@ namespace OpenNest
UpdateBounds();
}
public void ApplySingleLeadIn(CNC.CuttingStrategy.CuttingParameters parameters,
Geometry.Vector point, Geometry.Entity entity, CNC.CuttingStrategy.ContourType contourType)
{
preLeadInRotation = Rotation;
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
var result = strategy.ApplySingle(Program, point, entity, contourType);
Program = result.Program;
CuttingParameters = parameters;
HasManualLeadIns = true;
UpdateBounds();
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;
+30 -14
View File
@@ -305,6 +305,15 @@ namespace OpenNest.IO
var writer = new StreamWriter(stream);
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");
for (var i = 0; i < drawing.Program.Length; ++i)
@@ -316,6 +325,13 @@ namespace OpenNest.IO
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)
{
switch (code.Type)
@@ -324,16 +340,16 @@ namespace OpenNest.IO
{
var sb = new StringBuilder();
var arcMove = (ArcMove)code;
var refs = arcMove.VariableRefs;
var x = System.Math.Round(arcMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat);
var y = System.Math.Round(arcMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat);
var i = System.Math.Round(arcMove.CenterPoint.X, OutputPrecision).ToString(CoordinateFormat);
var j = System.Math.Round(arcMove.CenterPoint.Y, OutputPrecision).ToString(CoordinateFormat);
var x = FormatCoord(arcMove.EndPoint.X, "X", refs);
var y = FormatCoord(arcMove.EndPoint.Y, "Y", refs);
var i = FormatCoord(arcMove.CenterPoint.X, "I", refs);
var j = FormatCoord(arcMove.CenterPoint.Y, "J", refs);
if (arcMove.Rotation == RotationType.CW)
sb.Append(string.Format("G02X{0}Y{1}I{2}J{3}", x, y, i, j));
else
sb.Append(string.Format("G03X{0}Y{1}I{2}J{3}", x, y, i, j));
sb.Append(arcMove.Rotation == RotationType.CW
? $"G02X{x}Y{y}I{i}J{j}"
: $"G03X{x}Y{y}I{i}J{j}");
if (arcMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(arcMove.Layer));
@@ -354,10 +370,9 @@ namespace OpenNest.IO
{
var sb = new StringBuilder();
var linearMove = (LinearMove)code;
var refs = linearMove.VariableRefs;
sb.Append(string.Format("G01X{0}Y{1}",
System.Math.Round(linearMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(linearMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat)));
sb.Append($"G01X{FormatCoord(linearMove.EndPoint.X, "X", refs)}Y{FormatCoord(linearMove.EndPoint.Y, "Y", refs)}");
if (linearMove.Layer != LayerType.Cut)
sb.Append(GetLayerString(linearMove.Layer));
@@ -371,15 +386,16 @@ namespace OpenNest.IO
case CodeType.RapidMove:
{
var rapidMove = (RapidMove)code;
var refs = rapidMove.VariableRefs;
return string.Format("G00X{0}Y{1}",
System.Math.Round(rapidMove.EndPoint.X, OutputPrecision).ToString(CoordinateFormat),
System.Math.Round(rapidMove.EndPoint.Y, OutputPrecision).ToString(CoordinateFormat));
return $"G00X{FormatCoord(rapidMove.EndPoint.X, "X", refs)}Y{FormatCoord(rapidMove.EndPoint.Y, "Y", refs)}";
}
case CodeType.SetFeedrate:
{
var setFeedrate = (Feedrate)code;
if (setFeedrate.VariableRef != null)
return $"F${setFeedrate.VariableRef}";
return "F" + setFeedrate.Value;
}
+262 -7
View File
@@ -1,7 +1,11 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenNest.IO
@@ -15,6 +19,7 @@ namespace OpenNest.IO
private CodeSection section;
private Program program;
private StreamReader reader;
private Dictionary<string, double> resolvedVariables;
public ProgramReader(Stream stream)
{
@@ -24,11 +29,38 @@ namespace OpenNest.IO
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;
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();
}
@@ -39,10 +71,43 @@ namespace OpenNest.IO
{
var block = new CodeBlock();
Code code = null;
for (int i = 0; i < line.Length; ++i)
for (var i = 0; i < line.Length; ++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)));
else if (c == ':')
{
@@ -125,7 +190,10 @@ namespace OpenNest.IO
break;
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();
break;
@@ -143,6 +211,7 @@ namespace OpenNest.IO
double y = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null, yRef = null;
while (section == CodeSection.Line)
{
@@ -157,10 +226,12 @@ namespace OpenNest.IO
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case ':':
@@ -200,10 +271,13 @@ namespace OpenNest.IO
break;
}
}
var refs = BuildVariableRefs(("X", xRef), ("Y", yRef));
if (isRapid)
program.Codes.Add(new RapidMove(x, y));
program.Codes.Add(new RapidMove(x, y) { VariableRefs = refs });
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)
@@ -214,6 +288,7 @@ namespace OpenNest.IO
double j = 0;
var layer = LayerType.Cut;
var suppressed = false;
string xRef = null, yRef = null, iRef = null, jRef = null;
while (section == CodeSection.Arc)
{
@@ -229,18 +304,22 @@ namespace OpenNest.IO
{
case 'X':
x = double.Parse(code.Value);
xRef = code.VariableRef;
break;
case 'Y':
y = double.Parse(code.Value);
yRef = code.VariableRef;
break;
case 'I':
i = double.Parse(code.Value);
iRef = code.VariableRef;
break;
case 'J':
j = double.Parse(code.Value);
jRef = code.VariableRef;
break;
case ':':
@@ -286,7 +365,8 @@ namespace OpenNest.IO
CenterPoint = new Vector(i, j),
Rotation = rotation,
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];
}
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()
{
reader.Close();
@@ -374,6 +627,8 @@ namespace OpenNest.IO
public string Value { get; set; }
public string VariableRef { get; set; }
public override string ToString()
{
return Id + Value;
@@ -29,6 +29,29 @@ public sealed class FeatureContext
/// so part-relative programs become plate-absolute under G90.
/// </summary>
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>
@@ -63,7 +86,7 @@ public sealed class CincinnatiFeatureWriter
var piercePoint = FindPiercePoint(ctx.Codes);
// 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
if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName))
@@ -112,7 +135,9 @@ public sealed class CincinnatiFeatureWriter
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
var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer);
@@ -138,7 +163,9 @@ public sealed class CincinnatiFeatureWriter
// G2 = CW, G3 = CCW
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
var i = arc.CenterPoint.X - currentPos.X;
@@ -177,6 +204,52 @@ public sealed class CincinnatiFeatureWriter
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)
{
foreach (var code in codes)
@@ -195,14 +268,16 @@ public sealed class CincinnatiFeatureWriter
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();
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());
}
@@ -66,7 +66,6 @@ public sealed class CincinnatiPartSubprogramWriter
_featureWriter.Write(w, ctx);
}
w.WriteLine("G0 X0 Y0");
w.WriteLine($"M99 (END OF {drawingName})");
}
@@ -253,6 +253,11 @@ namespace OpenNest.Posts.Cincinnati
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")]
[DisplayName("Sheet Width Variable")]
[Description("Variable number for sheet width.")]
@@ -70,7 +70,10 @@ namespace OpenNest.Posts.Cincinnati
.Where(p => p.Parts.Count > 0)
.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 gas = MaterialLibraryResolver.ResolveGas(nest, Config);
var etchLibrary = resolver.ResolveEtchLibrary(Config.DefaultEtchGas);
@@ -79,42 +82,41 @@ namespace OpenNest.Posts.Cincinnati
var firstPlate = plates.FirstOrDefault();
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;
List<(int subNum, string name, Program program)> subprogramEntries = null;
if (Config.UsePartSubprograms)
(partSubprograms, subprogramEntries) = CincinnatiPartSubprogramWriter.BuildRegistry(plates, Config.PartSubprogramStart);
// 5. Create writers
// 6. Create writers
var preamble = new CincinnatiPreambleWriter(Config);
var sheetWriter = new CincinnatiSheetWriter(Config, vars);
// 6. Build material description from nest
// 7. Build material description from nest
var material = nest.Material;
var materialDesc = material != null
? $"{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);
// Main program
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates.Count, initialCutLibrary);
preamble.WriteMainProgram(writer, nest.Name ?? "NEST", materialDesc, plates, initialCutLibrary);
// Variable declaration subprogram
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++)
{
var plate = plates[i];
var sheetIndex = i + 1;
var layoutIndex = i + 1;
var subNumber = Config.SheetSubprogramStart + i;
var cutLibrary = resolver.ResolveCutLibrary(nest.Material?.Name ?? "", nest.Thickness, gas);
var isLastSheet = i == plates.Count - 1;
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber,
cutLibrary, etchLibrary, partSubprograms, isLastSheet);
sheetWriter.Write(writer, plate, nest.Name ?? "NEST", layoutIndex, subNumber,
cutLibrary, etchLibrary, partSubprograms, userVarMapping);
}
// Part sub-programs (if enabled)
@@ -142,6 +144,103 @@ namespace OpenNest.Posts.Cincinnati
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()
{
var vars = new ProgramVariableManager();
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using OpenNest;
using OpenNest.CNC;
@@ -23,7 +24,7 @@ public sealed class CincinnatiPreambleWriter
/// </summary>
/// <param name="initialLibrary">Resolved G89 library file for the initial process setup.</param>
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($"CONFIGURATION - {_config.ConfigurationName}"));
@@ -54,10 +55,16 @@ public sealed class CincinnatiPreambleWriter
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);
w.WriteLine($"N{i} M98 P{subNum} (SHEET {i})");
var layoutNumber = i + 1;
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");
@@ -35,10 +35,10 @@ public sealed class CincinnatiSheetWriter
/// Optional mapping of (drawingId, rotationKey) to sub-program number.
/// When provided, non-cutoff parts are emitted as M98 calls instead of inline features.
/// </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,
Dictionary<(int, long), int> partSubprograms = null,
bool isLastSheet = false)
Dictionary<(int drawingId, string varName), int> userVarMapping = null)
{
if (plate.Parts.Count == 0)
return;
@@ -51,11 +51,10 @@ public sealed class CincinnatiSheetWriter
// 1. Sheet header
w.WriteLine("(*****************************************************)");
w.WriteLine($"( START OF {nestName}.{sheetIndex:D3} )");
w.WriteLine($"( START OF {nestName}.{layoutIndex:D3} )");
w.WriteLine($":{subNumber}");
w.WriteLine($"( Sheet {sheetIndex} )");
w.WriteLine($"( Layout {sheetIndex} )");
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(length)} X {_fmt.FormatCoord(width)} )");
w.WriteLine($"( Layout {layoutIndex} )");
w.WriteLine($"( SHEET NAME = {_fmt.FormatCoord(width)} X {_fmt.FormatCoord(length)} )");
w.WriteLine($"( Total parts on sheet = {partCount} )");
w.WriteLine($"#{_config.SheetWidthVariable}={_fmt.FormatCoord(width)} (SHEET WIDTH FOR CUTOFFS)");
w.WriteLine($"#{_config.SheetLengthVariable}={_fmt.FormatCoord(length)} (SHEET LENGTH FOR CUTOFFS)");
@@ -88,23 +87,22 @@ public sealed class CincinnatiSheetWriter
// 4. Emit parts
if (partSubprograms != null)
WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, partSubprograms);
WritePartsWithSubprograms(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, width, length, partSubprograms, userVarMapping);
else
WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal);
WritePartsInline(w, allParts, cutLibrary, etchLibrary, sheetDiagonal, width, length, userVarMapping);
// 5. Footer
w.WriteLine("M42");
w.WriteLine("G0 X0 Y0");
var emitM50 = _config.PalletExchange == PalletMode.EndOfSheet
|| (_config.PalletExchange == PalletMode.StartAndEnd && isLastSheet);
if (emitM50)
w.WriteLine($"N{sheetIndex + 1} M50");
w.WriteLine($"M99 (END OF {nestName}.{sheetIndex:D3})");
if (_config.PalletExchange != PalletMode.None)
w.WriteLine("M50");
w.WriteLine($"M99 (END OF {nestName}.{layoutIndex:D3})");
}
private void WritePartsWithSubprograms(TextWriter w, List<Part> allParts,
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 featureIndex = 0;
@@ -154,7 +152,12 @@ public sealed class CincinnatiSheetWriter
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
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);
@@ -202,7 +205,9 @@ public sealed class CincinnatiSheetWriter
}
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
var features = new List<(Part part, List<ICode> codes, bool isEtch)>();
@@ -242,7 +247,12 @@ public sealed class CincinnatiSheetWriter
LibraryFile = isEtch ? etchLibrary : cutLibrary,
CutDistance = cutDistance,
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);
@@ -11,6 +11,6 @@
<PostsDir>..\OpenNest\bin\$(Configuration)\$(TargetFramework)\Posts\</PostsDir>
</PropertyGroup>
<MakeDir Directories="$(PostsDir)" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" />
<Copy SourceFiles="$(TargetPath)" DestinationFolder="$(PostsDir)" SkipUnchangedFiles="true" ContinueOnError="true" />
</Target>
</Project>
+135
View File
@@ -0,0 +1,135 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests.CNC;
public class ProgramVariableTests
{
[Fact]
public void LinearMove_VariableRefs_NullByDefault()
{
var move = new LinearMove(1.0, 2.0);
Assert.Null(move.VariableRefs);
}
[Fact]
public void LinearMove_Clone_CopiesVariableRefs()
{
var move = new LinearMove(1.0, 2.0);
move.VariableRefs = new Dictionary<string, string> { { "X", "width" } };
var clone = (LinearMove)move.Clone();
Assert.NotSame(move.VariableRefs, clone.VariableRefs);
Assert.Equal("width", clone.VariableRefs["X"]);
}
[Fact]
public void LinearMove_Clone_NullRefs_StaysNull()
{
var move = new LinearMove(1.0, 2.0);
var clone = (LinearMove)move.Clone();
Assert.Null(clone.VariableRefs);
}
[Fact]
public void ArcMove_Clone_CopiesVariableRefs()
{
var move = new ArcMove(1, 0, 0.5, 0);
move.VariableRefs = new Dictionary<string, string> { { "I", "radius" } };
var clone = (ArcMove)move.Clone();
Assert.NotSame(move.VariableRefs, clone.VariableRefs);
Assert.Equal("radius", clone.VariableRefs["I"]);
}
[Fact]
public void RapidMove_Clone_CopiesVariableRefs()
{
var move = new RapidMove(5.0, 0);
move.VariableRefs = new Dictionary<string, string> { { "X", "start_x" } };
var clone = (RapidMove)move.Clone();
Assert.NotSame(move.VariableRefs, clone.VariableRefs);
Assert.Equal("start_x", clone.VariableRefs["X"]);
}
[Fact]
public void Feedrate_VariableRef_NullByDefault()
{
var f = new Feedrate(100.0);
Assert.Null(f.VariableRef);
}
[Fact]
public void Feedrate_Clone_CopiesVariableRef()
{
var f = new Feedrate(100.0) { VariableRef = "cut_speed" };
var clone = (Feedrate)f.Clone();
Assert.Equal("cut_speed", clone.VariableRef);
}
[Fact]
public void Motion_Rotate_ClearsVariableRefs()
{
var move = new LinearMove(1.0, 0);
move.VariableRefs = new Dictionary<string, string> { { "X", "width" } };
move.Rotate(System.Math.PI / 2);
Assert.Null(move.VariableRefs);
}
[Fact]
public void Motion_Offset_ClearsVariableRefs()
{
var move = new LinearMove(1.0, 0);
move.VariableRefs = new Dictionary<string, string> { { "X", "width" } };
move.Offset(5.0, 0);
Assert.Null(move.VariableRefs);
}
[Fact]
public void ArcMove_Rotate_ClearsVariableRefs()
{
var move = new ArcMove(1, 0, 0.5, 0);
move.VariableRefs = new Dictionary<string, string> { { "I", "radius" } };
move.Rotate(System.Math.PI / 2);
Assert.Null(move.VariableRefs);
}
[Fact]
public void ArcMove_Offset_ClearsVariableRefs()
{
var move = new ArcMove(1, 0, 0.5, 0);
move.VariableRefs = new Dictionary<string, string> { { "I", "radius" } };
move.Offset(5.0, 0);
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);
}
}
@@ -0,0 +1,33 @@
using OpenNest.CNC;
namespace OpenNest.Tests.CNC;
public class VariableDefinitionTests
{
[Fact]
public void Constructor_SetsAllProperties()
{
var v = new VariableDefinition("diameter", "0.3", 0.3);
Assert.Equal("diameter", v.Name);
Assert.Equal("0.3", v.Expression);
Assert.Equal(0.3, v.Value);
Assert.False(v.Inline);
Assert.False(v.Global);
}
[Fact]
public void Constructor_WithFlags_SetsFlags()
{
var v = new VariableDefinition("width", "48.0", 48.0, inline: true, global: true);
Assert.True(v.Inline);
Assert.True(v.Global);
}
[Fact]
public void DefaultFlags_AreFalse()
{
var v = new VariableDefinition("x", "1", 1.0);
Assert.False(v.Inline);
Assert.False(v.Global);
}
}
@@ -38,7 +38,7 @@ public class CincinnatiPostProcessorTests
// Sheet subprogram
Assert.Contains(":101", output);
Assert.Contains("( Sheet 1 )", output);
Assert.Contains("( Layout 1 )", output);
Assert.Contains("G84", output);
Assert.Contains("M99", output);
}
@@ -150,8 +150,8 @@ public class CincinnatiPostProcessorTests
var output = Encoding.UTF8.GetString(ms.ToArray());
// Should only have one sheet subprogram call in main
Assert.Contains("N1 M98 P101 (SHEET 1)", output);
Assert.DoesNotContain("SHEET 2", output);
Assert.Contains("N1 M98 P101 (LAYOUT 1)", output);
Assert.DoesNotContain("LAYOUT 2", output);
}
[Fact]
@@ -258,8 +258,7 @@ public class CincinnatiPostProcessorTests
Assert.Contains(":200", output);
Assert.Contains("G84", output);
// Sub-program ends with G0 X0 Y0 and M99
Assert.Contains("G0 X0 Y0", output);
// Sub-program ends with M99
Assert.Contains("M99 (END OF Square)", output);
// G92 restore after M98 call
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using OpenNest.CNC;
@@ -19,7 +20,8 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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();
Assert.Contains("( NEST TestNest )", output);
@@ -29,8 +31,8 @@ public class CincinnatiPreambleWriterTests
Assert.Contains("G89 PMS135N2PANEL.lib", output);
Assert.Contains("M98 P100 (Variable Declaration)", output);
Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output);
Assert.Contains("N1 M98 P101 (SHEET 1)", output);
Assert.Contains("N2 M98 P102 (SHEET 2)", output);
Assert.Contains("N1 M98 P101 (LAYOUT 1)", output);
Assert.Contains("N2 M98 P102 (LAYOUT 2)", output);
Assert.Contains("M30 (END OF MAIN)", output);
}
@@ -42,7 +44,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -55,7 +57,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -68,7 +70,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -81,7 +83,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -94,7 +96,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -107,7 +109,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -120,7 +122,7 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
@@ -133,11 +135,33 @@ public class CincinnatiPreambleWriterTests
using var sw = new StringWriter(sb);
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());
}
[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]
public void WriteVariableDeclaration_EmitsSubprogram()
{
@@ -28,7 +28,7 @@ public class CincinnatiSheetWriterTests
var output = sb.ToString();
Assert.Contains(":101", output);
Assert.Contains("( Sheet 1 )", output);
Assert.Contains("( Layout 1 )", output);
Assert.Contains("#110=", output);
Assert.Contains("#111=", output);
Assert.Contains("G92 X#5021 Y#5022", output);
@@ -55,7 +55,6 @@ public class CincinnatiSheetWriterTests
var output = sb.ToString();
Assert.Contains("M42", output);
Assert.Contains("G0 X0 Y0", output);
Assert.Contains("M50", output);
}
@@ -143,7 +142,7 @@ public class CincinnatiSheetWriterTests
}
[Fact]
public void WriteSheet_StartAndEnd_NoM50OnNonLastSheet()
public void WriteSheet_StartAndEnd_EmitsM50()
{
var config = new CincinnatiPostConfig
{
@@ -157,33 +156,33 @@ public class CincinnatiSheetWriterTests
using var sw = new StringWriter(sb);
var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager());
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false);
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);
sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "");
var output = sb.ToString();
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]
public void WriteSheet_EndOfSheet_AlwaysEmitsM50()
{
@@ -199,7 +198,7 @@ public class CincinnatiSheetWriterTests
using var sw = new StringWriter(sb);
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.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,130 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
public class ApplySingleTests
{
private static Program MakeSquareProgram()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return pgm;
}
private static Program MakeSquareWithHoleProgram()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 20)));
pgm.Codes.Add(new LinearMove(new Vector(20, 20)));
pgm.Codes.Add(new LinearMove(new Vector(20, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
pgm.Codes.Add(new RapidMove(new Vector(12, 10)));
pgm.Codes.Add(new ArcMove(new Vector(12, 10), new Vector(10, 10), RotationType.CW));
return pgm;
}
private static List<ICode> GetLeadInCodes(Program program)
{
var result = new List<ICode>();
foreach (var code in program.Codes)
{
if (code is LinearMove lm && lm.Layer == LayerType.Leadin)
result.Add(lm);
else if (code is ArcMove am && am.Layer == LayerType.Leadin)
result.Add(am);
}
return result;
}
[Fact]
public void ApplySingle_ExternalContour_PlacesLeadInAtExactPoint()
{
var pgm = MakeSquareProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(5, 0);
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
var hasLeadin = result.Program.Codes.OfType<LinearMove>().Any(m => m.Layer == LayerType.Leadin);
Assert.True(hasLeadin);
}
[Fact]
public void ApplySingle_ContourStartsAtClickPoint()
{
var pgm = MakeSquareProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(5, 0);
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
// Convert back to absolute to check positions
result.Program.Mode = Mode.Absolute;
var firstLinear = result.Program.Codes.OfType<LinearMove>()
.First(m => m.Layer == LayerType.Leadin);
Assert.Equal(clickPoint.X, firstLinear.EndPoint.X, 4);
Assert.Equal(clickPoint.Y, firstLinear.EndPoint.Y, 4);
}
[Fact]
public void ApplySingle_ProgramModeIsIncremental()
{
var pgm = MakeSquareProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(5, 0);
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
Assert.Equal(Mode.Incremental, result.Program.Mode);
}
[Fact]
public void ApplySingle_OnlyTargetContourGetsLeadIn()
{
var pgm = MakeSquareWithHoleProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
InternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(10, 0);
var entity = new Line(new Vector(20, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
var leadinMoves = GetLeadInCodes(result.Program);
Assert.Single(leadinMoves);
}
}
@@ -0,0 +1,35 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Forms;
namespace OpenNest.Tests.CuttingStrategy;
public class CuttingParametersSerializerTests
{
[Fact]
public void RoundTrip_PreservesAutoTabFields()
{
var original = new CuttingParameters
{
AutoTabMinSize = 0.5,
AutoTabMaxSize = 3.0,
ExternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 }
};
var json = CuttingParametersSerializer.Serialize(original);
var restored = CuttingParametersSerializer.Deserialize(json);
Assert.Equal(0.5, restored.AutoTabMinSize);
Assert.Equal(3.0, restored.AutoTabMaxSize);
}
[Fact]
public void Deserialize_MissingAutoTabFields_DefaultsToZero()
{
var json = "{\"externalLeadIn\":{\"type\":\"None\"},\"externalLeadOut\":{\"type\":\"None\"},\"internalLeadIn\":{\"type\":\"None\"},\"internalLeadOut\":{\"type\":\"None\"},\"arcCircleLeadIn\":{\"type\":\"None\"},\"arcCircleLeadOut\":{\"type\":\"None\"},\"tabsEnabled\":false,\"tabWidth\":0.25,\"pierceClearance\":0.0625}";
var restored = CuttingParametersSerializer.Deserialize(json);
Assert.Equal(0.0, restored.AutoTabMinSize);
Assert.Equal(0.0, restored.AutoTabMaxSize);
}
}
@@ -124,4 +124,74 @@ public class PartLeadInTests
var part = MakeSquarePart();
Assert.False(part.LeadInsLocked);
}
[Fact]
public void ApplySingleLeadIn_SetsHasManualLeadIns()
{
var part = MakeSquarePart();
var parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
};
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
part.ApplySingleLeadIn(parameters, new Vector(5, 0), entity, ContourType.External);
Assert.True(part.HasManualLeadIns);
}
[Fact]
public void ApplySingleLeadIn_ProgramContainsLeadinCodes()
{
var part = MakeSquarePart();
var parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
};
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
part.ApplySingleLeadIn(parameters, new Vector(5, 0), entity, ContourType.External);
var hasLeadin = part.Program.Codes.OfType<LinearMove>().Any(m => m.Layer == LayerType.Leadin);
Assert.True(hasLeadin);
}
[Fact]
public void ApplySingleLeadIn_ThenRemove_RestoresOriginal()
{
var part = MakeSquarePart();
var originalCodeCount = part.Program.Codes.Count;
var parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
};
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
part.ApplySingleLeadIn(parameters, new Vector(5, 0), entity, ContourType.External);
part.RemoveLeadIns();
Assert.False(part.HasManualLeadIns);
Assert.Equal(originalCodeCount, part.Program.Codes.Count);
}
[Fact]
public void ApplySingleLeadIn_PreservesRotation()
{
var part = MakeSquarePart();
part.Rotate(System.Math.PI / 4);
var rotation = part.Rotation;
var parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
};
// After rotation, the edges change. Use a point on the rotated bottom edge.
// The rotated square has vertices roughly at rotated positions.
// We'll use a generic entity that will be matched via fallback.
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
part.ApplySingleLeadIn(parameters, new Vector(5, 0), entity, ContourType.External);
Assert.Equal(rotation, part.Rotation, 6);
}
}
+7 -1
View File
@@ -15,8 +15,11 @@ public class EngineOverlapTests
_output = output;
}
private static Drawing ImportDxf()
private static Drawing? ImportDxf()
{
if (!System.IO.File.Exists(DxfPath))
return null;
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var pgm = ConvertGeometry.ToProgram(geometry);
@@ -31,6 +34,9 @@ public class EngineOverlapTests
public void FillPlate_NoOverlaps(string engineName)
{
var drawing = ImportDxf();
if (drawing is null)
return; // Skip if test DXF not available
var plate = new Plate(60, 120);
NestEngineRegistry.ActiveEngineName = engineName;
+6 -5
View File
@@ -1,3 +1,4 @@
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
@@ -7,11 +8,11 @@ public class PatternTilerTests
{
private static Drawing MakeSquareDrawing(double size)
{
var pgm = new CNC.Program();
pgm.Codes.Add(new CNC.LinearMove(size, 0));
pgm.Codes.Add(new CNC.LinearMove(size, size));
pgm.Codes.Add(new CNC.LinearMove(0, size));
pgm.Codes.Add(new CNC.LinearMove(0, 0));
var pgm = new Program();
pgm.Codes.Add(new LinearMove(size, 0));
pgm.Codes.Add(new LinearMove(size, size));
pgm.Codes.Add(new LinearMove(0, size));
pgm.Codes.Add(new LinearMove(0, 0));
return new Drawing("square", pgm);
}
@@ -57,12 +57,12 @@ public class PolygonHelperTests
public void ExtractPerimeterPolygon_InflatedPolygonIsLarger_ForCCWWinding()
{
// CCW winding: (0,0)→(10,0)→(10,10)→(0,10)→(0,0)
var pgm = new CNC.Program();
pgm.Codes.Add(new CNC.RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new CNC.LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new CNC.LinearMove(new Vector(0, 0)));
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("ccw-square", pgm);
var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0);
@@ -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);
}
}
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Tests.Math;
public class ExpressionEvaluatorTests
{
private static readonly Dictionary<string, double> Empty = new();
[Fact]
public void Evaluate_NumericLiteral()
{
Assert.Equal(42.0, ExpressionEvaluator.Evaluate("42", Empty));
}
[Fact]
public void Evaluate_DecimalLiteral()
{
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("0.3", Empty));
}
[Fact]
public void Evaluate_NegativeLiteral()
{
Assert.Equal(-5.0, ExpressionEvaluator.Evaluate("-5", Empty));
}
[Fact]
public void Evaluate_Addition()
{
Assert.Equal(5.0, ExpressionEvaluator.Evaluate("2 + 3", Empty));
}
[Fact]
public void Evaluate_Subtraction()
{
Assert.Equal(1.0, ExpressionEvaluator.Evaluate("3 - 2", Empty));
}
[Fact]
public void Evaluate_Multiplication()
{
Assert.Equal(6.0, ExpressionEvaluator.Evaluate("2 * 3", Empty));
}
[Fact]
public void Evaluate_Division()
{
Assert.Equal(2.5, ExpressionEvaluator.Evaluate("5 / 2", Empty));
}
[Fact]
public void Evaluate_OperatorPrecedence()
{
Assert.Equal(7.0, ExpressionEvaluator.Evaluate("1 + 2 * 3", Empty));
}
[Fact]
public void Evaluate_Parentheses()
{
Assert.Equal(9.0, ExpressionEvaluator.Evaluate("(1 + 2) * 3", Empty));
}
[Fact]
public void Evaluate_VariableReference()
{
var vars = new Dictionary<string, double> { { "diameter", 0.3 } };
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter", vars));
}
[Fact]
public void Evaluate_VariableInExpression()
{
var vars = new Dictionary<string, double> { { "diameter", 0.6 } };
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter / 2", vars));
}
[Fact]
public void Evaluate_MultipleVariables()
{
var vars = new Dictionary<string, double> { { "x", 2.0 }, { "y", 3.0 } };
Assert.Equal(5.0, ExpressionEvaluator.Evaluate("$x + $y", vars));
}
[Fact]
public void Evaluate_CaseInsensitiveVariables()
{
var vars = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
{
{ "Diameter", 0.3 }
};
Assert.Equal(0.3, ExpressionEvaluator.Evaluate("$diameter", vars));
}
[Fact]
public void Evaluate_UndefinedVariable_Throws()
{
Assert.Throws<KeyNotFoundException>(() =>
ExpressionEvaluator.Evaluate("$missing", Empty));
}
[Fact]
public void Evaluate_NestedParentheses()
{
Assert.Equal(20.0, ExpressionEvaluator.Evaluate("((2 + 3) * (1 + 1)) * 2", Empty));
}
[Fact]
public void Evaluate_UnaryMinusBeforeParens()
{
Assert.Equal(-6.0, ExpressionEvaluator.Evaluate("-(2 + 4)", Empty));
}
[Fact]
public void Evaluate_WhitespaceHandling()
{
Assert.Equal(5.0, ExpressionEvaluator.Evaluate(" 2 + 3 ", Empty));
}
}
+1
View File
@@ -27,6 +27,7 @@
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
<ProjectReference Include="..\OpenNest\OpenNest.csproj" />
</ItemGroup>
<ItemGroup>
@@ -18,8 +18,11 @@ public class StrategyOverlapTests
_output = output;
}
private static Drawing ImportDxf()
private static Drawing? ImportDxf()
{
if (!System.IO.File.Exists(DxfPath))
return null;
var importer = new DxfImporter();
importer.GetGeometry(DxfPath, out var geometry);
var pgm = ConvertGeometry.ToProgram(geometry);
@@ -30,6 +33,9 @@ public class StrategyOverlapTests
public void EachStrategy_CheckOverlaps()
{
var drawing = ImportDxf();
if (drawing is null)
return; // Skip if test DXF not available
_output.WriteLine($"Drawing bbox: {drawing.Program.BoundingBox().Width:F2} x {drawing.Program.BoundingBox().Length:F2}");
var strategies = FillStrategyRegistry.Strategies.ToList();
+183 -28
View File
@@ -1,6 +1,7 @@
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Converters;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
@@ -30,9 +31,12 @@ namespace OpenNest.Actions
private bool hasSnap;
private SnapType activeSnapType;
private ShapeInfo hoveredContour;
private ShapeInfo lockedContour;
private ContextMenuStrip contextMenu;
private CuttingPanel cuttingPanel;
private static readonly Brush grayOverlay = new SolidBrush(Color.FromArgb(160, 180, 180, 180));
private static readonly Pen highlightPen = new Pen(Color.Cyan, 2.5f);
private static readonly Pen lockedPen = new Pen(Color.Yellow, 3.0f);
public ActionLeadIn(PlateView plateView)
: base(plateView)
@@ -46,6 +50,7 @@ namespace OpenNest.Actions
plateView.MouseDown += OnMouseDown;
plateView.KeyDown += OnKeyDown;
plateView.Paint += OnPaint;
ShowSidePanel();
}
public override void DisconnectEvents()
@@ -55,6 +60,8 @@ namespace OpenNest.Actions
plateView.KeyDown -= OnKeyDown;
plateView.Paint -= OnPaint;
HideSidePanel();
contextMenu?.Dispose();
contextMenu = null;
@@ -72,6 +79,77 @@ namespace OpenNest.Actions
public override bool IsBusy() => selectedPart != null;
private void ShowSidePanel()
{
var form = plateView.FindForm() as EditNestForm;
if (form == null)
return;
cuttingPanel = new CuttingPanel { ShowAutoAssign = true };
cuttingPanel.AutoAssignClicked += OnAutoAssignClicked;
cuttingPanel.ParametersChanged += OnToolParametersChanged;
// Load current parameters or defaults
var plate = plateView.Plate;
if (plate?.CuttingParameters != null)
cuttingPanel.LoadFromParameters(plate.CuttingParameters);
else
{
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try
{
var saved = CuttingParametersSerializer.Deserialize(json);
cuttingPanel.LoadFromParameters(saved);
}
catch { /* use defaults */ }
}
}
form.ShowSidePanel(cuttingPanel);
}
private void HideSidePanel()
{
if (cuttingPanel == null)
return;
SaveParameters();
cuttingPanel.ParametersChanged -= OnToolParametersChanged;
cuttingPanel.AutoAssignClicked -= OnAutoAssignClicked;
var form = plateView.FindForm() as EditNestForm;
form?.HideSidePanel();
cuttingPanel = null;
}
private CuttingParameters GetCurrentParameters()
{
return cuttingPanel?.BuildParameters() ?? plateView.Plate?.CuttingParameters ?? new CuttingParameters();
}
private void SaveParameters()
{
if (cuttingPanel == null)
return;
var parameters = cuttingPanel.BuildParameters();
var json = CuttingParametersSerializer.Serialize(parameters);
Properties.Settings.Default.CuttingParametersJson = json;
Properties.Settings.Default.Save();
if (plateView.Plate != null)
plateView.Plate.CuttingParameters = parameters;
}
private void OnToolParametersChanged(object sender, System.EventArgs e)
{
plateView.Invalidate();
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (selectedPart == null || contours == null)
@@ -91,7 +169,12 @@ namespace OpenNest.Actions
activeSnapType = SnapType.None;
hoveredContour = null;
foreach (var info in contours)
// When a contour is locked, only snap within that contour
var searchContours = lockedContour != null
? new List<ShapeInfo> { lockedContour }
: contours;
foreach (var info in searchContours)
{
var closest = info.Shape.ClosestPointTo(localPt, out var entity);
var dist = closest.DistanceTo(localPt);
@@ -102,7 +185,7 @@ namespace OpenNest.Actions
snapPoint = closest;
snapEntity = entity;
snapContourType = info.ContourType;
snapNormal = ContourCuttingStrategy.ComputeNormal(closest, entity, info.ContourType);
snapNormal = ContourCuttingStrategy.ComputeNormal(closest, entity, info.ContourType, info.Winding);
hasSnap = true;
hoveredContour = info;
}
@@ -110,8 +193,14 @@ namespace OpenNest.Actions
// Check endpoint/midpoint snaps on the hovered contour
if (hoveredContour != null)
{
TrySnapToEntityPoints(localPt);
// Auto-switch tool window tab only when no contour is locked
if (cuttingPanel != null && lockedContour == null)
cuttingPanel.ActiveContourType = snapContourType;
}
plateView.Invalidate();
}
@@ -124,15 +213,22 @@ namespace OpenNest.Actions
// First click: select a part
SelectPartAtCursor();
}
else if (hasSnap)
else if (lockedContour == null && hasSnap)
{
// Second click: commit lead-in at snap point
// Second click: lock the hovered contour
LockContour(hoveredContour);
}
else if (lockedContour != null && hasSnap)
{
// Third click: commit lead-in at snap point on locked contour
CommitLeadIn();
}
}
else if (e.Button == MouseButtons.Right)
{
if (selectedPart != null && selectedPart.HasManualLeadIns)
if (lockedContour != null)
UnlockContour();
else if (selectedPart != null && selectedPart.HasManualLeadIns)
ShowContextMenu(e.Location);
else
DeselectPart();
@@ -143,7 +239,9 @@ namespace OpenNest.Actions
{
if (e.KeyCode == Keys.Escape)
{
if (selectedPart != null)
if (lockedContour != null)
UnlockContour();
else if (selectedPart != null)
DeselectPart();
else
plateView.SetAction(typeof(ActionSelect));
@@ -159,6 +257,23 @@ namespace OpenNest.Actions
DrawLeadInPreview(g);
}
private void LockContour(ShapeInfo contour)
{
lockedContour = contour;
// Lock the tab to this contour type
if (cuttingPanel != null)
cuttingPanel.ActiveContourType = contour.ContourType;
plateView.Invalidate();
}
private void UnlockContour()
{
lockedContour = null;
plateView.Invalidate();
}
private void DrawOverlay(Graphics g)
{
foreach (var lp in plateView.LayoutParts)
@@ -170,10 +285,24 @@ namespace OpenNest.Actions
private void DrawHoveredContour(Graphics g)
{
if (hoveredContour == null || selectedPart == null)
if (selectedPart == null)
return;
using var contourPath = hoveredContour.Shape.GetGraphicsPath();
// Draw locked contour with distinct pen
if (lockedContour != null)
{
DrawContourHighlight(g, lockedContour.Shape, lockedPen);
return;
}
// Draw hovered contour
if (hoveredContour != null)
DrawContourHighlight(g, hoveredContour.Shape, highlightPen);
}
private void DrawContourHighlight(Graphics g, Shape shape, Pen pen)
{
using var contourPath = shape.GetGraphicsPath();
using var contourMatrix = new Matrix();
contourMatrix.Translate((float)selectedPart.Location.X, (float)selectedPart.Location.Y);
contourMatrix.Multiply(plateView.Matrix, MatrixOrder.Append);
@@ -181,7 +310,7 @@ namespace OpenNest.Actions
var prevSmooth = g.SmoothingMode;
g.SmoothingMode = SmoothingMode.AntiAlias;
g.DrawPath(highlightPen, contourPath);
g.DrawPath(pen, contourPath);
g.SmoothingMode = prevSmooth;
}
@@ -190,9 +319,7 @@ namespace OpenNest.Actions
if (!hasSnap || selectedPart == null)
return;
var parameters = plateView.Plate?.CuttingParameters;
if (parameters == null)
return;
var parameters = GetCurrentParameters();
var leadIn = SelectLeadIn(parameters, snapContourType);
if (leadIn == null)
@@ -282,7 +409,7 @@ namespace OpenNest.Actions
{
snapPoint = bestPoint;
snapEntity = bestEntity;
snapNormal = ContourCuttingStrategy.ComputeNormal(bestPoint, bestEntity, snapContourType);
snapNormal = ContourCuttingStrategy.ComputeNormal(bestPoint, bestEntity, snapContourType, hoveredContour.Winding);
activeSnapType = bestType;
}
@@ -356,7 +483,8 @@ namespace OpenNest.Actions
contours.Add(new ShapeInfo
{
Shape = profile.Perimeter,
ContourType = ContourType.External
ContourType = ContourType.External,
Winding = ContourCuttingStrategy.DetermineWinding(profile.Perimeter)
});
}
@@ -366,36 +494,61 @@ namespace OpenNest.Actions
contours.Add(new ShapeInfo
{
Shape = cutout,
ContourType = ContourCuttingStrategy.DetectContourType(cutout)
ContourType = ContourCuttingStrategy.DetectContourType(cutout),
Winding = ContourCuttingStrategy.DetermineWinding(cutout)
});
}
}
private void CommitLeadIn()
{
var parameters = plateView.Plate?.CuttingParameters;
if (parameters == null)
return;
var parameters = GetCurrentParameters();
// Remove any existing lead-ins first
if (selectedPart.HasManualLeadIns)
selectedPart.RemoveLeadIns();
// Apply lead-ins using the snap point as the approach point.
// snapPoint is in the program's local coordinate space (rotated, not offset),
// which is what Part.ApplyLeadIns expects.
selectedPart.ApplyLeadIns(parameters, snapPoint);
ApplyAutoTab(parameters);
selectedPart.ApplySingleLeadIn(parameters, snapPoint, snapEntity, snapContourType);
selectedPart.LeadInsLocked = true;
// Rebuild the layout part's graphics
selectedLayoutPart.IsDirty = true;
selectedLayoutPart.Update();
// Deselect and reset
DeselectPart();
UnlockContour();
plateView.Invalidate();
}
private void OnAutoAssignClicked(object sender, System.EventArgs e)
{
if (selectedPart == null)
return;
var parameters = GetCurrentParameters();
if (selectedPart.HasManualLeadIns)
selectedPart.RemoveLeadIns();
ApplyAutoTab(parameters);
selectedPart.ApplyLeadIns(parameters, Vector.Zero);
selectedPart.LeadInsLocked = true;
selectedLayoutPart.IsDirty = true;
UnlockContour();
plateView.Invalidate();
}
private void ApplyAutoTab(CuttingParameters parameters)
{
if (parameters.AutoTabMinSize <= 0 && parameters.AutoTabMaxSize <= 0)
return;
var bbox = selectedPart.Program.BoundingBox();
var minDim = System.Math.Min(bbox.Width, bbox.Length);
if (minDim >= parameters.AutoTabMinSize && minDim <= parameters.AutoTabMaxSize)
parameters.TabsEnabled = true;
}
private void DeselectPart()
{
if (selectedLayoutPart != null)
@@ -407,6 +560,7 @@ namespace OpenNest.Actions
selectedPart = null;
profile = null;
contours = null;
lockedContour = null;
hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null;
@@ -483,6 +637,7 @@ namespace OpenNest.Actions
{
public Shape Shape { get; set; }
public ContourType ContourType { get; set; }
public RotationType Winding { get; set; }
}
}
}
@@ -1,10 +1,11 @@
using OpenNest.CNC.CuttingStrategy;
using System;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms
namespace OpenNest.Controls
{
public partial class CuttingParametersForm : Form
public class CuttingPanel : Panel
{
private static readonly string[] LeadInTypes =
{ "None", "Line", "Arc", "Line + Arc", "Clean Hole", "Line + Line" };
@@ -12,85 +13,241 @@ namespace OpenNest.Forms
private static readonly string[] LeadOutTypes =
{ "None", "Line", "Arc", "Microtab" };
private ComboBox cboExternalLeadIn, cboExternalLeadOut;
private ComboBox cboInternalLeadIn, cboInternalLeadOut;
private ComboBox cboArcCircleLeadIn, cboArcCircleLeadOut;
private readonly TabControl tabControl;
private readonly ComboBox cboExternalLeadIn, cboExternalLeadOut;
private readonly ComboBox cboInternalLeadIn, cboInternalLeadOut;
private readonly ComboBox cboArcCircleLeadIn, cboArcCircleLeadOut;
private Panel pnlExternalLeadIn, pnlExternalLeadOut;
private Panel pnlInternalLeadIn, pnlInternalLeadOut;
private Panel pnlArcCircleLeadIn, pnlArcCircleLeadOut;
private readonly Panel pnlExternalLeadIn, pnlExternalLeadOut;
private readonly Panel pnlInternalLeadIn, pnlInternalLeadOut;
private readonly Panel pnlArcCircleLeadIn, pnlArcCircleLeadOut;
private CheckBox chkTabsEnabled;
private NumericUpDown nudTabWidth;
private NumericUpDown nudPierceClearance;
private readonly CheckBox chkTabsEnabled;
private readonly NumericUpDown nudTabWidth;
private readonly NumericUpDown nudAutoTabMin;
private readonly NumericUpDown nudAutoTabMax;
private readonly NumericUpDown nudPierceClearance;
private bool hasCustomParameters;
private CuttingParameters parameters = new CuttingParameters();
private readonly Button btnAutoAssign;
public CuttingParameters Parameters
private bool suppressEvents;
public event EventHandler ParametersChanged;
public event EventHandler AutoAssignClicked;
public bool ShowAutoAssign
{
get => parameters;
get => btnAutoAssign.Visible;
set => btnAutoAssign.Visible = value;
}
public ContourType? ActiveContourType
{
get
{
return tabControl.SelectedIndex switch
{
0 => ContourType.External,
1 => ContourType.Internal,
2 => ContourType.ArcCircle,
_ => null
};
}
set
{
parameters = value;
hasCustomParameters = true;
if (value == null)
return;
var index = value.Value switch
{
ContourType.External => 0,
ContourType.Internal => 1,
ContourType.ArcCircle => 2,
_ => -1
};
if (index >= 0 && tabControl.SelectedIndex != index)
tabControl.SelectedIndex = index;
}
}
public CuttingParametersForm()
public CuttingPanel()
{
InitializeComponent();
AutoScroll = true;
BackColor = Color.White;
SetupTab(tabExternal,
out cboExternalLeadIn, out pnlExternalLeadIn,
// Tab control for contour types — wrapped in a fixed-height panel for Dock.Top
tabControl = new TabControl
{
Dock = DockStyle.Fill
};
var tabExternal = new TabPage("External") { Padding = new Padding(4) };
var tabInternal = new TabPage("Internal") { Padding = new Padding(4) };
var tabArcCircle = new TabPage("Arc / Circle") { Padding = new Padding(4) };
SetupTab(tabExternal, out cboExternalLeadIn, out pnlExternalLeadIn,
out cboExternalLeadOut, out pnlExternalLeadOut);
SetupTab(tabInternal,
out cboInternalLeadIn, out pnlInternalLeadIn,
SetupTab(tabInternal, out cboInternalLeadIn, out pnlInternalLeadIn,
out cboInternalLeadOut, out pnlInternalLeadOut);
SetupTab(tabArcCircle,
out cboArcCircleLeadIn, out pnlArcCircleLeadIn,
SetupTab(tabArcCircle, out cboArcCircleLeadIn, out pnlArcCircleLeadIn,
out cboArcCircleLeadOut, out pnlArcCircleLeadOut);
SetupTabsSection();
tabControl.Controls.Add(tabExternal);
tabControl.Controls.Add(tabInternal);
tabControl.Controls.Add(tabArcCircle);
var tabWrapper = new Panel
{
Dock = DockStyle.Top,
Height = 340
};
tabWrapper.Controls.Add(tabControl);
// Tabs section
var tabsPanel = new CollapsiblePanel
{
HeaderText = "Tabs",
Dock = DockStyle.Top,
ExpandedHeight = 120,
IsExpanded = false
};
chkTabsEnabled = new CheckBox
{
Text = "Enable Tabs",
Location = new Point(12, 4),
AutoSize = true
};
chkTabsEnabled.CheckedChanged += (s, e) =>
{
nudTabWidth.Enabled = chkTabsEnabled.Checked;
OnParametersChanged();
};
tabsPanel.ContentPanel.Controls.Add(chkTabsEnabled);
tabsPanel.ContentPanel.Controls.Add(new Label
{
Text = "Width:",
Location = new Point(160, 6),
AutoSize = true
});
nudTabWidth = CreateNumeric(215, 3, 0.25, 0.0625);
nudTabWidth.Enabled = false;
tabsPanel.ContentPanel.Controls.Add(nudTabWidth);
tabsPanel.ContentPanel.Controls.Add(new Label
{
Text = "Auto-Tab Min Size:",
Location = new Point(12, 32),
AutoSize = true
});
nudAutoTabMin = CreateNumeric(140, 29, 0, 0.0625);
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMin);
tabsPanel.ContentPanel.Controls.Add(new Label
{
Text = "Auto-Tab Max Size:",
Location = new Point(12, 58),
AutoSize = true
});
nudAutoTabMax = CreateNumeric(140, 55, 0, 0.0625);
tabsPanel.ContentPanel.Controls.Add(nudAutoTabMax);
// Pierce section
var piercePanel = new CollapsiblePanel
{
HeaderText = "Pierce",
Dock = DockStyle.Top,
ExpandedHeight = 60,
IsExpanded = true
};
piercePanel.ContentPanel.Controls.Add(new Label
{
Text = "Pierce Clearance:",
Location = new Point(12, 6),
AutoSize = true
});
nudPierceClearance = CreateNumeric(130, 3, 0.0625, 0.0625);
piercePanel.ContentPanel.Controls.Add(nudPierceClearance);
// Auto-Assign button — wrapped in a panel for Dock.Top with padding
btnAutoAssign = new Button
{
Text = "Auto-Assign Lead-ins",
Dock = DockStyle.Top,
Height = 32,
Visible = false
};
btnAutoAssign.Click += (s, e) => AutoAssignClicked?.Invoke(this, EventArgs.Empty);
var btnWrapper = new Panel
{
Dock = DockStyle.Top,
Height = 36,
Padding = new Padding(4, 2, 4, 2)
};
btnWrapper.Controls.Add(btnAutoAssign);
// Add in reverse order — Dock.Top stacks top-down
Controls.Add(btnWrapper);
Controls.Add(piercePanel);
Controls.Add(tabsPanel);
Controls.Add(tabWrapper);
// Wire up change events
PopulateDropdowns();
cboExternalLeadIn.SelectedIndexChanged += OnLeadInTypeChanged;
cboInternalLeadIn.SelectedIndexChanged += OnLeadInTypeChanged;
cboArcCircleLeadIn.SelectedIndexChanged += OnLeadInTypeChanged;
cboExternalLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged;
cboInternalLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged;
cboArcCircleLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged;
WireChangeEvents();
}
protected override void OnLoad(EventArgs e)
public CuttingParameters BuildParameters()
{
base.OnLoad(e);
// If caller didn't provide custom parameters, try loading saved ones
if (!hasCustomParameters)
return new CuttingParameters
{
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { Parameters = CuttingParametersSerializer.Deserialize(json); }
catch { /* use defaults on corrupt data */ }
}
}
LoadFromParameters(Parameters);
ExternalLeadIn = BuildLeadIn(cboExternalLeadIn, pnlExternalLeadIn),
ExternalLeadOut = BuildLeadOut(cboExternalLeadOut, pnlExternalLeadOut),
InternalLeadIn = BuildLeadIn(cboInternalLeadIn, pnlInternalLeadIn),
InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut),
ArcCircleLeadIn = BuildLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn),
ArcCircleLeadOut = BuildLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut),
TabsEnabled = chkTabsEnabled.Checked,
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
PierceClearance = (double)nudPierceClearance.Value,
AutoTabMinSize = (double)nudAutoTabMin.Value,
AutoTabMaxSize = (double)nudAutoTabMax.Value
};
}
protected override void OnFormClosing(FormClosingEventArgs e)
public void LoadFromParameters(CuttingParameters p)
{
base.OnFormClosing(e);
suppressEvents = true;
if (DialogResult == System.Windows.Forms.DialogResult.OK)
{
var json = CuttingParametersSerializer.Serialize(BuildParameters());
Properties.Settings.Default.CuttingParametersJson = json;
Properties.Settings.Default.Save();
}
LoadLeadIn(cboExternalLeadIn, pnlExternalLeadIn, p.ExternalLeadIn);
LoadLeadOut(cboExternalLeadOut, pnlExternalLeadOut, p.ExternalLeadOut);
LoadLeadIn(cboInternalLeadIn, pnlInternalLeadIn, p.InternalLeadIn);
LoadLeadOut(cboInternalLeadOut, pnlInternalLeadOut, p.InternalLeadOut);
LoadLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn, p.ArcCircleLeadIn);
LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut);
chkTabsEnabled.Checked = p.TabsEnabled;
if (p.TabConfig != null)
nudTabWidth.Value = (decimal)p.TabConfig.Size;
nudPierceClearance.Value = (decimal)p.PierceClearance;
nudAutoTabMin.Value = (decimal)p.AutoTabMinSize;
nudAutoTabMax.Value = (decimal)p.AutoTabMaxSize;
suppressEvents = false;
}
private void OnParametersChanged()
{
if (!suppressEvents)
ParametersChanged?.Invoke(this, EventArgs.Empty);
}
private static void SetupTab(TabPage tab,
@@ -100,30 +257,30 @@ namespace OpenNest.Forms
var grpLeadIn = new GroupBox
{
Text = "Lead-In",
Location = new System.Drawing.Point(4, 4),
Size = new System.Drawing.Size(364, 168)
Location = new Point(4, 4),
Size = new Size(340, 148)
};
tab.Controls.Add(grpLeadIn);
grpLeadIn.Controls.Add(new Label
{
Text = "Type:",
Location = new System.Drawing.Point(8, 22),
Location = new Point(8, 22),
AutoSize = true
});
leadInCombo = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
Location = new System.Drawing.Point(90, 19),
Size = new System.Drawing.Size(250, 24)
Location = new Point(90, 19),
Size = new Size(230, 24)
};
grpLeadIn.Controls.Add(leadInCombo);
leadInPanel = new Panel
{
Location = new System.Drawing.Point(8, 48),
Size = new System.Drawing.Size(340, 112),
Location = new Point(8, 48),
Size = new Size(320, 92),
AutoScroll = true
};
grpLeadIn.Controls.Add(leadInPanel);
@@ -131,106 +288,35 @@ namespace OpenNest.Forms
var grpLeadOut = new GroupBox
{
Text = "Lead-Out",
Location = new System.Drawing.Point(4, 176),
Size = new System.Drawing.Size(364, 132)
Location = new Point(4, 156),
Size = new Size(340, 132)
};
tab.Controls.Add(grpLeadOut);
grpLeadOut.Controls.Add(new Label
{
Text = "Type:",
Location = new System.Drawing.Point(8, 22),
Location = new Point(8, 22),
AutoSize = true
});
leadOutCombo = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
Location = new System.Drawing.Point(90, 19),
Size = new System.Drawing.Size(250, 24)
Location = new Point(90, 19),
Size = new Size(230, 24)
};
grpLeadOut.Controls.Add(leadOutCombo);
leadOutPanel = new Panel
{
Location = new System.Drawing.Point(8, 48),
Size = new System.Drawing.Size(340, 76),
Location = new Point(8, 48),
Size = new Size(320, 76),
AutoScroll = true
};
grpLeadOut.Controls.Add(leadOutPanel);
}
private void SetupTabsSection()
{
var grpTabs = new GroupBox
{
Text = "Tabs",
Location = new System.Drawing.Point(4, 350),
Size = new System.Drawing.Size(372, 55),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
chkTabsEnabled = new CheckBox
{
Text = "Enable Tabs",
Location = new System.Drawing.Point(12, 22),
AutoSize = true
};
chkTabsEnabled.CheckedChanged += (s, e) => nudTabWidth.Enabled = chkTabsEnabled.Checked;
grpTabs.Controls.Add(chkTabsEnabled);
grpTabs.Controls.Add(new Label
{
Text = "Width:",
Location = new System.Drawing.Point(160, 23),
AutoSize = true
});
nudTabWidth = new NumericUpDown
{
Location = new System.Drawing.Point(215, 20),
Size = new System.Drawing.Size(100, 22),
DecimalPlaces = 4,
Increment = 0.0625m,
Minimum = 0,
Maximum = 9999,
Value = 0.25m,
Enabled = false
};
grpTabs.Controls.Add(nudTabWidth);
Controls.Add(grpTabs);
var grpPierce = new GroupBox
{
Text = "Pierce",
Location = new System.Drawing.Point(4, 410),
Size = new System.Drawing.Size(372, 55),
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right
};
grpPierce.Controls.Add(new Label
{
Text = "Pierce Clearance:",
Location = new System.Drawing.Point(12, 23),
AutoSize = true
});
nudPierceClearance = new NumericUpDown
{
Location = new System.Drawing.Point(130, 20),
Size = new System.Drawing.Size(100, 22),
DecimalPlaces = 4,
Increment = 0.0625m,
Minimum = 0,
Maximum = 9999,
Value = 0.0625m
};
grpPierce.Controls.Add(nudPierceClearance);
Controls.Add(grpPierce);
}
private void PopulateDropdowns()
{
foreach (var combo in new[] { cboExternalLeadIn, cboInternalLeadIn, cboArcCircleLeadIn })
@@ -246,12 +332,24 @@ namespace OpenNest.Forms
}
}
private void WireChangeEvents()
{
cboExternalLeadIn.SelectedIndexChanged += OnLeadInTypeChanged;
cboInternalLeadIn.SelectedIndexChanged += OnLeadInTypeChanged;
cboArcCircleLeadIn.SelectedIndexChanged += OnLeadInTypeChanged;
cboExternalLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged;
cboInternalLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged;
cboArcCircleLeadOut.SelectedIndexChanged += OnLeadOutTypeChanged;
}
private void OnLeadInTypeChanged(object sender, EventArgs e)
{
var combo = (ComboBox)sender;
var panel = GetLeadInPanel(combo);
if (panel != null)
BuildLeadInParamControls(panel, combo.SelectedIndex);
OnParametersChanged();
}
private void OnLeadOutTypeChanged(object sender, EventArgs e)
@@ -260,6 +358,7 @@ namespace OpenNest.Forms
var panel = GetLeadOutPanel(combo);
if (panel != null)
BuildLeadOutParamControls(panel, combo.SelectedIndex);
OnParametersChanged();
}
private Panel GetLeadInPanel(ComboBox combo)
@@ -278,31 +377,31 @@ namespace OpenNest.Forms
return null;
}
private static void BuildLeadInParamControls(Panel panel, int typeIndex)
private void BuildLeadInParamControls(Panel panel, int typeIndex)
{
panel.Controls.Clear();
var y = 0;
switch (typeIndex)
{
case 1: // Line
case 1:
AddNumericField(panel, "Length:", 0.25, ref y, "Length");
AddNumericField(panel, "Approach Angle:", 90, ref y, "ApproachAngle");
break;
case 2: // Arc
case 2:
AddNumericField(panel, "Radius:", 0.25, ref y, "Radius");
break;
case 3: // Line + Arc
case 3:
AddNumericField(panel, "Line Length:", 0.25, ref y, "LineLength");
AddNumericField(panel, "Arc Radius:", 0.125, ref y, "ArcRadius");
AddNumericField(panel, "Approach Angle:", 135, ref y, "ApproachAngle");
break;
case 4: // Clean Hole
case 4:
AddNumericField(panel, "Line Length:", 0.25, ref y, "LineLength");
AddNumericField(panel, "Arc Radius:", 0.125, ref y, "ArcRadius");
AddNumericField(panel, "Kerf:", 0.06, ref y, "Kerf");
break;
case 5: // Line + Line
case 5:
AddNumericField(panel, "Length 1:", 0.25, ref y, "Length1");
AddNumericField(panel, "Angle 1:", 90, ref y, "Angle1");
AddNumericField(panel, "Length 2:", 0.25, ref y, "Length2");
@@ -311,67 +410,56 @@ namespace OpenNest.Forms
}
}
private static void BuildLeadOutParamControls(Panel panel, int typeIndex)
private void BuildLeadOutParamControls(Panel panel, int typeIndex)
{
panel.Controls.Clear();
var y = 0;
switch (typeIndex)
{
case 1: // Line
case 1:
AddNumericField(panel, "Length:", 0.25, ref y, "Length");
AddNumericField(panel, "Approach Angle:", 90, ref y, "ApproachAngle");
break;
case 2: // Arc
case 2:
AddNumericField(panel, "Radius:", 0.25, ref y, "Radius");
break;
case 3: // Microtab
case 3:
AddNumericField(panel, "Gap Size:", 0.06, ref y, "GapSize");
break;
}
}
private static void AddNumericField(Panel panel, string label, double defaultValue,
private void AddNumericField(Panel panel, string label, double defaultValue,
ref int y, string tag)
{
panel.Controls.Add(new Label
{
Text = label,
Location = new System.Drawing.Point(0, y + 3),
Location = new Point(0, y + 3),
AutoSize = true
});
panel.Controls.Add(new NumericUpDown
{
Location = new System.Drawing.Point(130, y),
Size = new System.Drawing.Size(120, 22),
DecimalPlaces = 4,
Increment = 0.0625m,
Minimum = 0,
Maximum = 9999,
Value = (decimal)defaultValue,
Tag = tag
});
var nud = CreateNumeric(130, y, defaultValue, 0.0625);
nud.Tag = tag;
nud.ValueChanged += (s, e) => OnParametersChanged();
panel.Controls.Add(nud);
y += 30;
}
private void LoadFromParameters(CuttingParameters p)
private static NumericUpDown CreateNumeric(int x, int y, double defaultValue, double increment)
{
LoadLeadIn(cboExternalLeadIn, pnlExternalLeadIn, p.ExternalLeadIn);
LoadLeadOut(cboExternalLeadOut, pnlExternalLeadOut, p.ExternalLeadOut);
LoadLeadIn(cboInternalLeadIn, pnlInternalLeadIn, p.InternalLeadIn);
LoadLeadOut(cboInternalLeadOut, pnlInternalLeadOut, p.InternalLeadOut);
LoadLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn, p.ArcCircleLeadIn);
LoadLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut, p.ArcCircleLeadOut);
chkTabsEnabled.Checked = p.TabsEnabled;
if (p.TabConfig != null)
nudTabWidth.Value = (decimal)p.TabConfig.Size;
nudPierceClearance.Value = (decimal)p.PierceClearance;
return new NumericUpDown
{
Location = new Point(x, y),
Size = new Size(120, 22),
DecimalPlaces = 4,
Increment = (decimal)increment,
Minimum = 0,
Maximum = 9999,
Value = (decimal)defaultValue
};
}
private static void LoadLeadIn(ComboBox combo, Panel panel, LeadIn leadIn)
@@ -435,88 +523,61 @@ namespace OpenNest.Forms
}
}
public CuttingParameters BuildParameters()
{
var p = new CuttingParameters
{
ExternalLeadIn = BuildLeadIn(cboExternalLeadIn, pnlExternalLeadIn),
ExternalLeadOut = BuildLeadOut(cboExternalLeadOut, pnlExternalLeadOut),
InternalLeadIn = BuildLeadIn(cboInternalLeadIn, pnlInternalLeadIn),
InternalLeadOut = BuildLeadOut(cboInternalLeadOut, pnlInternalLeadOut),
ArcCircleLeadIn = BuildLeadIn(cboArcCircleLeadIn, pnlArcCircleLeadIn),
ArcCircleLeadOut = BuildLeadOut(cboArcCircleLeadOut, pnlArcCircleLeadOut),
TabsEnabled = chkTabsEnabled.Checked,
TabConfig = new NormalTab { Size = (double)nudTabWidth.Value },
PierceClearance = (double)nudPierceClearance.Value
};
return p;
}
private static LeadIn BuildLeadIn(ComboBox combo, Panel panel)
{
switch (combo.SelectedIndex)
return combo.SelectedIndex switch
{
case 1:
return new LineLeadIn
{
Length = GetParam(panel, "Length", 0.25),
ApproachAngle = GetParam(panel, "ApproachAngle", 90)
};
case 2:
return new ArcLeadIn
{
Radius = GetParam(panel, "Radius", 0.25)
};
case 3:
return new LineArcLeadIn
{
LineLength = GetParam(panel, "LineLength", 0.25),
ArcRadius = GetParam(panel, "ArcRadius", 0.125),
ApproachAngle = GetParam(panel, "ApproachAngle", 135)
};
case 4:
return new CleanHoleLeadIn
{
LineLength = GetParam(panel, "LineLength", 0.25),
ArcRadius = GetParam(panel, "ArcRadius", 0.125),
Kerf = GetParam(panel, "Kerf", 0.06)
};
case 5:
return new LineLineLeadIn
{
Length1 = GetParam(panel, "Length1", 0.25),
ApproachAngle1 = GetParam(panel, "Angle1", 90),
Length2 = GetParam(panel, "Length2", 0.25),
ApproachAngle2 = GetParam(panel, "Angle2", 90)
};
default:
return new NoLeadIn();
}
1 => new LineLeadIn
{
Length = GetParam(panel, "Length", 0.25),
ApproachAngle = GetParam(panel, "ApproachAngle", 90)
},
2 => new ArcLeadIn
{
Radius = GetParam(panel, "Radius", 0.25)
},
3 => new LineArcLeadIn
{
LineLength = GetParam(panel, "LineLength", 0.25),
ArcRadius = GetParam(panel, "ArcRadius", 0.125),
ApproachAngle = GetParam(panel, "ApproachAngle", 135)
},
4 => new CleanHoleLeadIn
{
LineLength = GetParam(panel, "LineLength", 0.25),
ArcRadius = GetParam(panel, "ArcRadius", 0.125),
Kerf = GetParam(panel, "Kerf", 0.06)
},
5 => new LineLineLeadIn
{
Length1 = GetParam(panel, "Length1", 0.25),
ApproachAngle1 = GetParam(panel, "Angle1", 90),
Length2 = GetParam(panel, "Length2", 0.25),
ApproachAngle2 = GetParam(panel, "Angle2", 90)
},
_ => new NoLeadIn()
};
}
private static LeadOut BuildLeadOut(ComboBox combo, Panel panel)
{
switch (combo.SelectedIndex)
return combo.SelectedIndex switch
{
case 1:
return new LineLeadOut
{
Length = GetParam(panel, "Length", 0.25),
ApproachAngle = GetParam(panel, "ApproachAngle", 90)
};
case 2:
return new ArcLeadOut
{
Radius = GetParam(panel, "Radius", 0.25)
};
case 3:
return new MicrotabLeadOut
{
GapSize = GetParam(panel, "GapSize", 0.06)
};
default:
return new NoLeadOut();
}
1 => new LineLeadOut
{
Length = GetParam(panel, "Length", 0.25),
ApproachAngle = GetParam(panel, "ApproachAngle", 90)
},
2 => new ArcLeadOut
{
Radius = GetParam(panel, "Radius", 0.25)
},
3 => new MicrotabLeadOut
{
GapSize = GetParam(panel, "GapSize", 0.06)
},
_ => new NoLeadOut()
};
}
private static void SetParam(Panel panel, string tag, double value)
+5 -19
View File
@@ -28,9 +28,8 @@ namespace OpenNest.Controls
rightSplit = new System.Windows.Forms.SplitContainer();
preview = new EntityView();
editorPanel = new System.Windows.Forms.Panel();
gcodeEditor = new System.Windows.Forms.TextBox();
gcodeEditor = new System.Windows.Forms.RichTextBox();
editorToolbar = new System.Windows.Forms.Panel();
applyButton = new System.Windows.Forms.Button();
lblGcode = new System.Windows.Forms.Label();
((System.ComponentModel.ISupportInitialize)mainSplit).BeginInit();
mainSplit.Panel1.SuspendLayout();
@@ -186,9 +185,9 @@ namespace OpenNest.Controls
gcodeEditor.Font = new System.Drawing.Font("Consolas", 10F);
gcodeEditor.ForeColor = System.Drawing.Color.FromArgb(180, 200, 180);
gcodeEditor.Location = new System.Drawing.Point(0, 30);
gcodeEditor.Multiline = true;
gcodeEditor.Name = "gcodeEditor";
gcodeEditor.ScrollBars = System.Windows.Forms.ScrollBars.Both;
gcodeEditor.ReadOnly = true;
gcodeEditor.ScrollBars = System.Windows.Forms.RichTextBoxScrollBars.Both;
gcodeEditor.Size = new System.Drawing.Size(260, 470);
gcodeEditor.TabIndex = 1;
gcodeEditor.WordWrap = false;
@@ -196,7 +195,6 @@ namespace OpenNest.Controls
// editorToolbar
//
editorToolbar.BackColor = System.Drawing.Color.FromArgb(245, 245, 245);
editorToolbar.Controls.Add(applyButton);
editorToolbar.Controls.Add(lblGcode);
editorToolbar.Dock = System.Windows.Forms.DockStyle.Top;
editorToolbar.Location = new System.Drawing.Point(0, 0);
@@ -204,18 +202,7 @@ namespace OpenNest.Controls
editorToolbar.Padding = new System.Windows.Forms.Padding(6, 4, 6, 4);
editorToolbar.Size = new System.Drawing.Size(260, 30);
editorToolbar.TabIndex = 0;
//
// applyButton
//
applyButton.Dock = System.Windows.Forms.DockStyle.Right;
applyButton.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
applyButton.Font = new System.Drawing.Font("Segoe UI", 9F);
applyButton.Location = new System.Drawing.Point(184, 4);
applyButton.Name = "applyButton";
applyButton.Size = new System.Drawing.Size(70, 22);
applyButton.TabIndex = 1;
applyButton.Text = "Apply";
//
//
// lblGcode
//
lblGcode.AutoSize = true;
@@ -263,8 +250,7 @@ namespace OpenNest.Controls
private System.Windows.Forms.Panel editorPanel;
private System.Windows.Forms.Panel editorToolbar;
private System.Windows.Forms.Label lblGcode;
private System.Windows.Forms.Button applyButton;
private System.Windows.Forms.TextBox gcodeEditor;
private System.Windows.Forms.RichTextBox gcodeEditor;
private System.Windows.Forms.ContextMenuStrip contourMenu;
private System.Windows.Forms.ToolStripMenuItem menuReverse;
private System.Windows.Forms.ToolStripMenuItem menuMoveUp;
+75 -67
View File
@@ -1,11 +1,11 @@
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Windows.Forms;
namespace OpenNest.Controls
@@ -29,7 +29,6 @@ namespace OpenNest.Controls
menuMoveDown.Click += OnMoveDownClicked;
menuSequence.Click += OnSequenceClicked;
contourMenu.Opening += OnContourMenuOpening;
applyButton.Click += OnApplyClicked;
preview.PaintOverlay = OnPreviewPaintOverlay;
}
@@ -92,34 +91,49 @@ namespace OpenNest.Controls
private void UpdateGcodeText()
{
gcodeEditor.Text = Program != null ? FormatProgram(Program) : string.Empty;
var text = Program != null ? FormatProgram(Program, contours) : string.Empty;
gcodeEditor.Text = text;
ApplyHighlighting();
}
private static string FormatProgram(Program pgm)
private static string FormatProgram(Program pgm, List<ContourInfo> contours)
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(pgm.Mode == Mode.Absolute ? "G90" : "G91");
var lastWasRapid = false;
foreach (var code in pgm.Codes)
var codeIndex = 0;
var codes = pgm.Codes;
foreach (var contour in contours)
{
if (code is RapidMove rapid)
var sub = ConvertGeometry.ToProgram(contour.Shape);
if (sub == null) continue;
sb.AppendLine();
sb.AppendLine($"; {contour.Label} ({contour.DirectionLabel})");
var lastWasRapid = false;
for (var i = 0; i < sub.Length && codeIndex < codes.Count; i++, codeIndex++)
{
if (!lastWasRapid && sb.Length > 0)
sb.AppendLine();
sb.AppendLine($"G00 X{FormatCoord(rapid.EndPoint.X)} Y{FormatCoord(rapid.EndPoint.Y)}");
lastWasRapid = true;
}
else if (code is ArcMove arc)
{
var g = arc.Rotation == RotationType.CW ? "G02" : "G03";
sb.AppendLine($"{g} X{FormatCoord(arc.EndPoint.X)} Y{FormatCoord(arc.EndPoint.Y)} I{FormatCoord(arc.CenterPoint.X)} J{FormatCoord(arc.CenterPoint.Y)}");
lastWasRapid = false;
}
else if (code is LinearMove linear)
{
sb.AppendLine($"G01 X{FormatCoord(linear.EndPoint.X)} Y{FormatCoord(linear.EndPoint.Y)}");
lastWasRapid = false;
var code = codes[codeIndex];
if (code is RapidMove rapid)
{
if (!lastWasRapid)
sb.AppendLine();
sb.AppendLine($"G00 X{FormatCoord(rapid.EndPoint.X)} Y{FormatCoord(rapid.EndPoint.Y)}");
lastWasRapid = true;
}
else if (code is ArcMove arc)
{
var g = arc.Rotation == RotationType.CW ? "G02" : "G03";
sb.AppendLine($"{g} X{FormatCoord(arc.EndPoint.X)} Y{FormatCoord(arc.EndPoint.Y)} I{FormatCoord(arc.CenterPoint.X)} J{FormatCoord(arc.CenterPoint.Y)}");
lastWasRapid = false;
}
else if (code is LinearMove linear)
{
sb.AppendLine($"G01 X{FormatCoord(linear.EndPoint.X)} Y{FormatCoord(linear.EndPoint.Y)}");
lastWasRapid = false;
}
}
}
@@ -131,6 +145,45 @@ namespace OpenNest.Controls
return System.Math.Round(value, 4).ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
}
private void ApplyHighlighting()
{
var text = gcodeEditor.Text;
if (string.IsNullOrEmpty(text)) return;
gcodeEditor.SuspendLayout();
var rapidColor = Color.FromArgb(230, 180, 80);
var linearColor = Color.FromArgb(130, 200, 140);
var arcColor = Color.FromArgb(120, 160, 255);
var commentColor = Color.FromArgb(120, 120, 140);
var modeColor = Color.FromArgb(200, 140, 220);
var coordColor = Color.FromArgb(180, 200, 180);
gcodeEditor.SelectAll();
gcodeEditor.SelectionColor = coordColor;
var rules = new (Regex pattern, Color color)[]
{
(new Regex(@"^;.*$", RegexOptions.Multiline), commentColor),
(new Regex(@"^G9[01]\b", RegexOptions.Multiline), modeColor),
(new Regex(@"^G00\b", RegexOptions.Multiline), rapidColor),
(new Regex(@"^G01\b", RegexOptions.Multiline), linearColor),
(new Regex(@"^G0[23]\b", RegexOptions.Multiline), arcColor),
};
foreach (var (pattern, color) in rules)
{
foreach (Match match in pattern.Matches(text))
{
gcodeEditor.Select(match.Index, match.Length);
gcodeEditor.SelectionColor = color;
}
}
gcodeEditor.Select(0, 0);
gcodeEditor.ResumeLayout();
}
private void RefreshPreview()
{
preview.ClearPenCache();
@@ -418,50 +471,5 @@ namespace OpenNest.Controls
}
}
private void OnApplyClicked(object sender, EventArgs e)
{
var text = gcodeEditor.Text;
if (string.IsNullOrWhiteSpace(text))
{
MessageBox.Show("G-code is empty.", "Apply", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
try
{
using var stream = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(text));
var reader = new ProgramReader(stream);
var parsed = reader.Read();
if (parsed == null || parsed.Length == 0)
{
MessageBox.Show("No valid G-code found.", "Apply", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
// Rebuild shapes from the parsed program
var entities = ConvertProgram.ToGeometry(parsed);
var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0)
{
MessageBox.Show("No contours found in parsed G-code.", "Apply", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
contours = ContourInfo.Classify(shapes);
Program = parsed;
isDirty = true;
PopulateContourList();
RefreshPreview();
ProgramChanged?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
MessageBox.Show($"Error parsing G-code: {ex.Message}", "Apply",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
}
-156
View File
@@ -1,156 +0,0 @@
namespace OpenNest.Forms
{
partial class CuttingParametersForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.tabControl = new System.Windows.Forms.TabControl();
this.tabExternal = new System.Windows.Forms.TabPage();
this.tabInternal = new System.Windows.Forms.TabPage();
this.tabArcCircle = new System.Windows.Forms.TabPage();
this.acceptButton = new System.Windows.Forms.Button();
this.cancelButton = new System.Windows.Forms.Button();
this.bottomPanel = new OpenNest.Controls.BottomPanel();
this.tabControl.SuspendLayout();
this.bottomPanel.SuspendLayout();
this.SuspendLayout();
//
// tabControl
//
this.tabControl.Controls.Add(this.tabExternal);
this.tabControl.Controls.Add(this.tabInternal);
this.tabControl.Controls.Add(this.tabArcCircle);
this.tabControl.Dock = System.Windows.Forms.DockStyle.Top;
this.tabControl.Location = new System.Drawing.Point(0, 0);
this.tabControl.Margin = new System.Windows.Forms.Padding(4);
this.tabControl.Name = "tabControl";
this.tabControl.SelectedIndex = 0;
this.tabControl.Size = new System.Drawing.Size(380, 348);
this.tabControl.TabIndex = 0;
//
// tabExternal
//
this.tabExternal.Location = new System.Drawing.Point(4, 25);
this.tabExternal.Margin = new System.Windows.Forms.Padding(4);
this.tabExternal.Name = "tabExternal";
this.tabExternal.Padding = new System.Windows.Forms.Padding(8);
this.tabExternal.Size = new System.Drawing.Size(372, 319);
this.tabExternal.TabIndex = 0;
this.tabExternal.Text = "External";
this.tabExternal.UseVisualStyleBackColor = true;
//
// tabInternal
//
this.tabInternal.Location = new System.Drawing.Point(4, 25);
this.tabInternal.Margin = new System.Windows.Forms.Padding(4);
this.tabInternal.Name = "tabInternal";
this.tabInternal.Padding = new System.Windows.Forms.Padding(8);
this.tabInternal.Size = new System.Drawing.Size(372, 319);
this.tabInternal.TabIndex = 1;
this.tabInternal.Text = "Internal";
this.tabInternal.UseVisualStyleBackColor = true;
//
// tabArcCircle
//
this.tabArcCircle.Location = new System.Drawing.Point(4, 25);
this.tabArcCircle.Margin = new System.Windows.Forms.Padding(4);
this.tabArcCircle.Name = "tabArcCircle";
this.tabArcCircle.Padding = new System.Windows.Forms.Padding(8);
this.tabArcCircle.Size = new System.Drawing.Size(372, 319);
this.tabArcCircle.TabIndex = 2;
this.tabArcCircle.Text = "Arc / Circle";
this.tabArcCircle.UseVisualStyleBackColor = true;
//
// acceptButton
//
this.acceptButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.acceptButton.DialogResult = System.Windows.Forms.DialogResult.OK;
this.acceptButton.Location = new System.Drawing.Point(165, 11);
this.acceptButton.Margin = new System.Windows.Forms.Padding(4);
this.acceptButton.Name = "acceptButton";
this.acceptButton.Size = new System.Drawing.Size(90, 28);
this.acceptButton.TabIndex = 8;
this.acceptButton.Text = "OK";
this.acceptButton.UseVisualStyleBackColor = true;
//
// cancelButton
//
this.cancelButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.cancelButton.Location = new System.Drawing.Point(263, 11);
this.cancelButton.Margin = new System.Windows.Forms.Padding(4);
this.cancelButton.Name = "cancelButton";
this.cancelButton.Size = new System.Drawing.Size(90, 28);
this.cancelButton.TabIndex = 9;
this.cancelButton.Text = "Cancel";
this.cancelButton.UseVisualStyleBackColor = true;
//
// bottomPanel
//
this.bottomPanel.Controls.Add(this.acceptButton);
this.bottomPanel.Controls.Add(this.cancelButton);
this.bottomPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
this.bottomPanel.Location = new System.Drawing.Point(0, 466);
this.bottomPanel.Name = "bottomPanel";
this.bottomPanel.Size = new System.Drawing.Size(380, 50);
this.bottomPanel.TabIndex = 1;
//
// CuttingParametersForm
//
this.AcceptButton = this.acceptButton;
this.AutoScaleDimensions = new System.Drawing.SizeF(8F, 16F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(380, 516);
this.Controls.Add(this.tabControl);
this.Controls.Add(this.bottomPanel);
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.Margin = new System.Windows.Forms.Padding(4);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "CuttingParametersForm";
this.ShowIcon = false;
this.ShowInTaskbar = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Cutting Parameters";
this.tabControl.ResumeLayout(false);
this.bottomPanel.ResumeLayout(false);
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.TabControl tabControl;
private System.Windows.Forms.TabPage tabExternal;
private System.Windows.Forms.TabPage tabInternal;
private System.Windows.Forms.TabPage tabArcCircle;
private Controls.BottomPanel bottomPanel;
private System.Windows.Forms.Button acceptButton;
private System.Windows.Forms.Button cancelButton;
}
}
@@ -3,7 +3,7 @@ using System.Text.Json;
namespace OpenNest.Forms
{
internal static class CuttingParametersSerializer
public static class CuttingParametersSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -23,7 +23,9 @@ namespace OpenNest.Forms
ArcCircleLeadOut = ToLeadOutDto(p.ArcCircleLeadOut),
TabsEnabled = p.TabsEnabled,
TabWidth = p.TabConfig?.Size ?? 0.25,
PierceClearance = p.PierceClearance
PierceClearance = p.PierceClearance,
AutoTabMinSize = p.AutoTabMinSize,
AutoTabMaxSize = p.AutoTabMaxSize
};
return JsonSerializer.Serialize(dto, JsonOptions);
}
@@ -44,7 +46,9 @@ namespace OpenNest.Forms
ArcCircleLeadOut = FromLeadOutDto(dto.ArcCircleLeadOut),
TabsEnabled = dto.TabsEnabled,
TabConfig = new NormalTab { Size = dto.TabWidth },
PierceClearance = dto.PierceClearance
PierceClearance = dto.PierceClearance,
AutoTabMinSize = dto.AutoTabMinSize,
AutoTabMaxSize = dto.AutoTabMaxSize
};
}
@@ -109,6 +113,8 @@ namespace OpenNest.Forms
public bool TabsEnabled { get; set; }
public double TabWidth { get; set; }
public double PierceClearance { get; set; }
public double AutoTabMinSize { get; set; }
public double AutoTabMaxSize { get; set; }
}
private class LeadInDto
+77 -20
View File
@@ -37,6 +37,9 @@ namespace OpenNest.Forms
private Button btnNextPlate;
private Button btnLastPlate;
private SplitContainer viewSplitContainer;
private Panel sidePanel;
/// <summary>
/// Used to distinguish between single/double click on drawing within drawinglistbox.
/// If double click, this is set to false so the single click action won't be triggered.
@@ -53,8 +56,9 @@ namespace OpenNest.Forms
InitializeComponent();
CreatePlateHeader();
CreateSidePanel();
splitContainer.Panel2.Controls.Add(PlateView);
splitContainer.Panel2.Controls.Add(viewSplitContainer);
splitContainer.Panel2.Controls.Add(plateHeaderPanel);
var renderer = new ToolStripRenderer(ToolbarTheme.Toolbar);
@@ -146,6 +150,43 @@ namespace OpenNest.Forms
navPanel.Top = (plateHeaderPanel.Height - navPanel.Height) / 2;
}
private void CreateSidePanel()
{
sidePanel = new Panel
{
Dock = DockStyle.Fill,
AutoScroll = true,
BackColor = Color.White
};
viewSplitContainer = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical,
FixedPanel = FixedPanel.Panel2,
Panel2MinSize = 0
};
viewSplitContainer.Panel1.Controls.Add(PlateView);
viewSplitContainer.Panel2.Controls.Add(sidePanel);
viewSplitContainer.Panel2Collapsed = true;
}
public void ShowSidePanel(Control content, int width = 390)
{
sidePanel.Controls.Clear();
content.Dock = DockStyle.Fill;
sidePanel.Controls.Add(content);
viewSplitContainer.SplitterDistance = viewSplitContainer.Width - width;
viewSplitContainer.Panel2Collapsed = false;
}
public void HideSidePanel()
{
viewSplitContainer.Panel2Collapsed = true;
sidePanel.Controls.Clear();
}
private static Button CreateNavButton(System.Drawing.Image image)
{
return new Button
@@ -725,15 +766,19 @@ namespace OpenNest.Forms
var plate = PlateView.Plate;
using var form = new CuttingParametersForm();
if (plate.CuttingParameters != null)
form.Parameters = plate.CuttingParameters;
if (form.ShowDialog(this) != DialogResult.OK)
return;
var parameters = form.BuildParameters();
plate.CuttingParameters = parameters;
if (plate.CuttingParameters == null)
{
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { plate.CuttingParameters = CuttingParametersSerializer.Deserialize(json); }
catch { plate.CuttingParameters = new CuttingParameters(); }
}
else
{
plate.CuttingParameters = new CuttingParameters();
}
}
var assigner = new LeadInAssigner
{
@@ -784,11 +829,18 @@ namespace OpenNest.Forms
if (Nest == null)
return;
using var form = new CuttingParametersForm();
if (form.ShowDialog(this) != DialogResult.OK)
return;
CuttingParameters parameters;
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { parameters = CuttingParametersSerializer.Deserialize(json); }
catch { parameters = new CuttingParameters(); }
}
else
{
parameters = new CuttingParameters();
}
var parameters = form.BuildParameters();
var assigner = new LeadInAssigner
{
Sequencer = new LeftSideSequencer()
@@ -835,14 +887,19 @@ namespace OpenNest.Forms
var plate = PlateView.Plate;
// Ensure cutting parameters are configured
// If no cutting parameters exist, initialize from saved settings or defaults
if (plate.CuttingParameters == null)
{
using var form = new CuttingParametersForm();
if (form.ShowDialog(this) != DialogResult.OK)
return;
plate.CuttingParameters = form.BuildParameters();
var json = Properties.Settings.Default.CuttingParametersJson;
if (!string.IsNullOrEmpty(json))
{
try { plate.CuttingParameters = CuttingParametersSerializer.Deserialize(json); }
catch { plate.CuttingParameters = new CuttingParameters(); }
}
else
{
plate.CuttingParameters = new CuttingParameters();
}
}
PlateView.SetAction(typeof(Actions.ActionLeadIn));
+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
- **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
- **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
- **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
@@ -212,7 +213,7 @@ Custom post-processors implement the `IPostProcessor` interface and are auto-dis
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)
- `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)
## Roadmap