Compare commits

...

13 Commits

Author SHA1 Message Date
aj f46bcd4e4b feat: add filter toggle to remnant viewer for showing all remnants
The remnant viewer previously always filtered by smallest part dimension,
hiding large remnants that were narrower than the smallest part. Added a
"Filter by part size" checkbox (on by default) so users can toggle this
off to see all remnants regardless of size.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:39:03 -04:00
aj f29f086080 feat: add pierce point visualization and rename shape dimensions to Length/Width
Add toggleable pierce point drawing to PlateView that shows small red
filled circles at each rapid move endpoint (where cutting begins). Wire
through View menu, EditNestForm toggle, and MainForm handler.

Also rename RectangleShape/RoundedRectangleShape Width/Height to
Length/Width for consistency with CNC conventions, update MCP tools and
tests accordingly. Fix SplitDrawingForm designer layout ordering and
EntityView bend line selection styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:26:49 -04:00
aj 19001ea5be fix: prevent GeometryOptimizer from merging semicircular arcs into invalid arc
After splitting a drawing with a circular hole, CadConverterForm writes
the split piece to DXF and re-imports it. The circle (decomposed into
two semicircular arcs by DrawingSplitter) was being incorrectly merged
back into a single zero-sweep arc by GeometryOptimizer.TryJoinArcs
during reimport.

Root cause: TryJoinArcs mutated input arc angles in-place and didn't
guard against merging two arcs that together form a full circle. When
arc2 had startAngle=π, endAngle=0 (DXF wrap-around from 360°→0°), the
mutation produced startAngle=-π, and the merge created an arc with
startAngle=π, endAngle=π (zero sweep), losing half the hole.

Fix: use local variables instead of mutating inputs, require arcs to be
adjacent (endpoints touching) rather than just overlapping, and refuse
to merge when the combined sweep would be a full circle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:34:38 -04:00
aj 269746b8a4 feat: fit-to-plate splits use full plate work area with preview line
FitToPlate now places split lines at usable-width intervals so each
piece (except the last) fills the entire plate work area. Also adds a
live yellow preview line that follows the cursor during manual split
line placement, and piece dimension labels in the preview regions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:27:15 -04:00
aj 35218a7435 feat: wire manual bend line pick → dialog → promote flow in CadConverterForm
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:38:12 -04:00
aj bd973c5f79 feat: add 'Add Bend Line' toggle and pick mode UI to FilterPanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:36:40 -04:00
aj d042bd1844 feat: add bend line pick mode with hit-testing to EntityView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:35:30 -04:00
aj ebdd489fdc feat: add BendLineDialog for manual bend line property entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:34:14 -04:00
aj 885dec5f0e feat: add SourceEntity property to Bend for manual pick tracking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:33:24 -04:00
aj 6106df929e feat: add F key shortcut for zoom-to-fit on EntityView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:53:38 -04:00
aj 965b9c8c1a feat: change nest name format to N{YY}-{base30} for brevity and readability
Uses 2-digit year + 3-char base-30 sequence (ambiguous chars 0OI1l8B excluded),
supporting ~27k nests/year. E.g. N26-4E2 instead of N0325-126.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:14:46 -04:00
aj 98e90cc176 fix: preserve bend lines through drawing split — clip, offset, and carry metadata
DrawingSplitter now clips bend lines to each piece's region using
Liang-Barsky line clipping and offsets them to the new origin. Bend
properties (direction, angle, radius, note text) are preserved through
the entire split pipeline instead of being lost during re-import.

CadConverterForm applies the same origin offset to bends before passing
them to the splitter, and creates FileListItems directly from split
results to avoid re-detection overwriting the bend metadata.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:24:41 -04:00
aj d9005cccc3 fix: improve split drawing UX — shorter suffix, piece numbers, axis fix
- Change split file suffix from _split# to -# (e.g., PartName-1.dxf)
- Add numbered labels at the center of each split region in the preview
- Fix fit-to-plate axis calculation to use correct plate dimension
  instead of min(width, height) for single-axis splits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:49:02 -04:00
29 changed files with 997 additions and 173 deletions
+3
View File
@@ -12,6 +12,9 @@ namespace OpenNest.Bending
public double? Radius { get; set; }
public string NoteText { get; set; }
[System.Text.Json.Serialization.JsonIgnore]
public Entity SourceEntity { get; set; }
public double Length => StartPoint.DistanceTo(EndPoint);
public double AngleRadians => Angle.HasValue
+20 -7
View File
@@ -133,17 +133,30 @@ namespace OpenNest.Geometry
if (!arc1.Radius.IsEqualTo(arc2.Radius))
return false;
if (arc1.StartAngle > arc1.EndAngle)
arc1.StartAngle -= Angle.TwoPI;
var start1 = arc1.StartAngle;
var end1 = arc1.EndAngle;
var start2 = arc2.StartAngle;
var end2 = arc2.EndAngle;
if (arc2.StartAngle > arc2.EndAngle)
arc2.StartAngle -= Angle.TwoPI;
if (start1 > end1)
start1 -= Angle.TwoPI;
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle)
if (start2 > end2)
start2 -= Angle.TwoPI;
// Check that arcs are adjacent (endpoints touch), not overlapping
var touch1 = end1.IsEqualTo(start2) || (end1 + Angle.TwoPI).IsEqualTo(start2);
var touch2 = end2.IsEqualTo(start1) || (end2 + Angle.TwoPI).IsEqualTo(start1);
if (!touch1 && !touch2)
return false;
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle;
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle;
var startAngle = start1 < start2 ? start1 : start2;
var endAngle = end1 > end2 ? end1 : end2;
// Don't merge if the result would be a full circle (start == end)
var sweep = endAngle - startAngle;
if (sweep >= Angle.TwoPI - Tolerance.Epsilon)
return false;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
+5 -5
View File
@@ -5,17 +5,17 @@ namespace OpenNest.Shapes
{
public class RectangleShape : ShapeDefinition
{
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public override Drawing GetDrawing()
{
var entities = new List<Entity>
{
new Line(0, 0, Width, 0),
new Line(Width, 0, Width, Height),
new Line(Width, Height, 0, Height),
new Line(0, Height, 0, 0)
new Line(0, 0, Length, 0),
new Line(Length, 0, Length, Width),
new Line(Length, Width, 0, Width),
new Line(0, Width, 0, 0)
};
return CreateDrawing(entities);
+15 -15
View File
@@ -6,8 +6,8 @@ namespace OpenNest.Shapes
{
public class RoundedRectangleShape : ShapeDefinition
{
public double Length { get; set; }
public double Width { get; set; }
public double Height { get; set; }
public double Radius { get; set; }
public override Drawing GetDrawing()
@@ -17,36 +17,36 @@ namespace OpenNest.Shapes
if (r <= 0)
{
entities.Add(new Line(0, 0, Width, 0));
entities.Add(new Line(Width, 0, Width, Height));
entities.Add(new Line(Width, Height, 0, Height));
entities.Add(new Line(0, Height, 0, 0));
entities.Add(new Line(0, 0, Length, 0));
entities.Add(new Line(Length, 0, Length, Width));
entities.Add(new Line(Length, Width, 0, Width));
entities.Add(new Line(0, Width, 0, 0));
}
else
{
// Bottom edge (left to right, above bottom-left arc to bottom-right arc)
entities.Add(new Line(r, 0, Width - r, 0));
entities.Add(new Line(r, 0, Length - r, 0));
// Bottom-right corner arc: center at (Width-r, r), from 270deg to 360deg
entities.Add(new Arc(Width - r, r, r,
// Bottom-right corner arc: center at (Length-r, r), from 270deg to 360deg
entities.Add(new Arc(Length - r, r, r,
Angle.ToRadians(270), Angle.ToRadians(360)));
// Right edge
entities.Add(new Line(Width, r, Width, Height - r));
entities.Add(new Line(Length, r, Length, Width - r));
// Top-right corner arc: center at (Width-r, Height-r), from 0deg to 90deg
entities.Add(new Arc(Width - r, Height - r, r,
// Top-right corner arc: center at (Length-r, Width-r), from 0deg to 90deg
entities.Add(new Arc(Length - r, Width - r, r,
Angle.ToRadians(0), Angle.ToRadians(90)));
// Top edge (right to left)
entities.Add(new Line(Width - r, Height, r, Height));
entities.Add(new Line(Length - r, Width, r, Width));
// Top-left corner arc: center at (r, Height-r), from 90deg to 180deg
entities.Add(new Arc(r, Height - r, r,
// Top-left corner arc: center at (r, Width-r), from 90deg to 180deg
entities.Add(new Arc(r, Width - r, r,
Angle.ToRadians(90), Angle.ToRadians(180)));
// Left edge
entities.Add(new Line(0, Height - r, 0, r));
entities.Add(new Line(0, Width - r, 0, r));
// Bottom-left corner arc: center at (r, r), from 180deg to 270deg
entities.Add(new Arc(r, r, r,
+4 -12
View File
@@ -19,19 +19,11 @@ public static class AutoSplitCalculator
if (verticalSplits < 0) verticalSplits = 0;
if (horizontalSplits < 0) horizontalSplits = 0;
if (verticalSplits > 0)
{
var spacing = partBounds.Width / (verticalSplits + 1);
for (var i = 1; i <= verticalSplits; i++)
lines.Add(new SplitLine(partBounds.X + spacing * i, CutOffAxis.Vertical));
}
for (var i = 1; i <= verticalSplits; i++)
lines.Add(new SplitLine(partBounds.X + usableWidth * i, CutOffAxis.Vertical));
if (horizontalSplits > 0)
{
var spacing = partBounds.Length / (horizontalSplits + 1);
for (var i = 1; i <= horizontalSplits; i++)
lines.Add(new SplitLine(partBounds.Y + spacing * i, CutOffAxis.Horizontal));
}
for (var i = 1; i <= horizontalSplits; i++)
lines.Add(new SplitLine(partBounds.Y + usableHeight * i, CutOffAxis.Horizontal));
return lines;
}
+62 -2
View File
@@ -47,7 +47,7 @@ public static class DrawingSplitter
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex);
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex, region);
results.Add(piece);
pieceIndex++;
}
@@ -80,7 +80,7 @@ public static class DrawingSplitter
return entities;
}
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex)
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex, Box region)
{
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
var offsetX = -pieceBounds.X;
@@ -98,9 +98,69 @@ public static class DrawingSplitter
piece.Customer = source.Customer;
piece.Source = source.Source;
piece.Quantity.Required = source.Quantity.Required;
if (source.Bends != null && source.Bends.Count > 0)
{
piece.Bends = new List<Bending.Bend>();
foreach (var bend in source.Bends)
{
var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region);
if (clipped == null)
continue;
piece.Bends.Add(new Bending.Bend
{
StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY),
EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY),
Direction = bend.Direction,
Angle = bend.Angle,
Radius = bend.Radius,
NoteText = bend.NoteText,
});
}
}
return piece;
}
/// <summary>
/// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm.
/// Returns the clipped start/end or null if the line is entirely outside.
/// </summary>
private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box)
{
var dx = end.X - start.X;
var dy = end.Y - start.Y;
double t0 = 0, t1 = 1;
double[] p = { -dx, dx, -dy, dy };
double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y };
for (var i = 0; i < 4; i++)
{
if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon)
{
if (q[i] < -Math.Tolerance.Epsilon)
return null;
}
else
{
var t = q[i] / p[i];
if (p[i] < 0)
t0 = System.Math.Max(t0, t);
else
t1 = System.Math.Min(t1, t);
if (t0 > t1)
return null;
}
}
var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy);
var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy);
return (clippedStart, clippedEnd);
}
private static void DecomposeCircles(ShapeProfile profile)
{
DecomposeCirclesInShape(profile.Perimeter);
+12 -1
View File
@@ -24,6 +24,10 @@ namespace OpenNest.IO.Bending
@"\\[fHCTQWASpOoLlKk][^;]*;|\\P|[{}]|%%[dDpPcC]",
RegexOptions.Compiled);
private static readonly Regex UnicodeEscapeRegex = new Regex(
@"\\U\+([0-9A-Fa-f]{4})",
RegexOptions.Compiled);
public List<Bend> DetectBends(CadDocument document)
{
var bendLines = FindBendLines(document);
@@ -116,8 +120,15 @@ namespace OpenNest.IO.Bending
if (string.IsNullOrEmpty(text))
return text;
// Convert \U+XXXX DXF unicode escapes to actual characters
var result = UnicodeEscapeRegex.Replace(text, m =>
{
var codePoint = int.Parse(m.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
return char.ConvertFromUtf32(codePoint);
});
// Replace known DXF special characters
var result = text
result = result
.Replace("%%d", "°").Replace("%%D", "°")
.Replace("%%p", "±").Replace("%%P", "±")
.Replace("%%c", "⌀").Replace("%%C", "⌀");
+4 -4
View File
@@ -122,7 +122,7 @@ namespace OpenNest.Mcp.Tools
[Description("Name for the drawing")] string name,
[Description("Shape type: rectangle, circle, l_shape, t_shape, gcode")] string shape,
[Description("Width of the shape (not used for circle or gcode)")] double width = 10,
[Description("Height of the shape (not used for circle or gcode)")] double height = 10,
[Description("Length of the shape (not used for circle or gcode)")] double length = 10,
[Description("Radius for circle shape")] double radius = 5,
[Description("G-code string (only used when shape is 'gcode')")] string gcode = null)
{
@@ -131,7 +131,7 @@ namespace OpenNest.Mcp.Tools
switch (shape.ToLower())
{
case "rectangle":
shapeDef = new RectangleShape { Name = name, Width = width, Height = height };
shapeDef = new RectangleShape { Name = name, Width = width, Length = length };
break;
case "circle":
@@ -139,11 +139,11 @@ namespace OpenNest.Mcp.Tools
break;
case "l_shape":
shapeDef = new LShape { Name = name, Width = width, Height = height };
shapeDef = new LShape { Name = name, Width = width, Height = length };
break;
case "t_shape":
shapeDef = new TShape { Name = name, Width = width, Height = height };
shapeDef = new TShape { Name = name, Width = width, Height = length };
break;
case "gcode":
+3 -3
View File
@@ -59,7 +59,7 @@ namespace OpenNest.Mcp.Tools
[Description("X origin of the area")] double x,
[Description("Y origin of the area")] double y,
[Description("Width of the area")] double width,
[Description("Height of the area")] double height,
[Description("Length of the area")] double length,
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0)
{
var plate = _session.GetPlate(plateIndex);
@@ -73,14 +73,14 @@ namespace OpenNest.Mcp.Tools
var countBefore = plate.Parts.Count;
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity };
var area = new Box(x, y, width, height);
var area = new Box(x, y, width, length);
var success = engine.Fill(item, area);
var countAfter = plate.Parts.Count;
var added = countAfter - countBefore;
var sb = new StringBuilder();
sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{height:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}");
sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{length:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}");
sb.AppendLine($" Parts added: {added}");
sb.AppendLine($" Total parts: {countAfter}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
+2 -2
View File
@@ -19,13 +19,13 @@ namespace OpenNest.Mcp.Tools
[Description("Create a new plate with the given dimensions and spacing. Returns plate index and work area.")]
public string CreatePlate(
[Description("Plate width")] double width,
[Description("Plate height")] double height,
[Description("Plate length")] double length,
[Description("Spacing between parts (default 0)")] double partSpacing = 0,
[Description("Edge spacing on all sides (default 0)")] double edgeSpacing = 0,
[Description("Quadrant 1-4 (default 1). 1=TopRight, 2=TopLeft, 3=BottomLeft, 4=BottomRight")] int quadrant = 1,
[Description("Material name (optional)")] string material = null)
{
var plate = new Plate(width, height);
var plate = new Plate(width, length);
plate.PartSpacing = partSpacing;
plate.EdgeSpacing = new Spacing(edgeSpacing, edgeSpacing);
plate.Quadrant = quadrant;
+26
View File
@@ -87,4 +87,30 @@ public class BendModelTests
Assert.Contains("90", str);
Assert.Contains("0.06", str);
}
[Fact]
public void SourceEntity_DefaultsToNull()
{
var bend = new Bend
{
StartPoint = new Vector(0, 0),
EndPoint = new Vector(10, 0),
Direction = BendDirection.Down,
Angle = 90
};
Assert.Null(bend.SourceEntity);
}
[Fact]
public void SourceEntity_StoresReference()
{
var line = new Line(new Vector(0, 0), new Vector(10, 0));
var bend = new Bend
{
StartPoint = line.StartPoint,
EndPoint = line.EndPoint,
SourceEntity = line
};
Assert.Same(line, bend.SourceEntity);
}
}
@@ -1,3 +1,4 @@
using ACadSharp.IO;
using OpenNest.Bending;
using OpenNest.IO.Bending;
@@ -27,4 +28,27 @@ public class SolidWorksBendDetectorTests
var bends = BendDetectorRegistry.AutoDetect(doc);
Assert.Empty(bends);
}
[Fact]
public void DetectBends_RealDxf_ParsesNotesCorrectly()
{
var path = Path.Combine(AppContext.BaseDirectory, "Bending", "TestData", "4526 A14 PT45.dxf");
Assert.True(File.Exists(path), $"Test DXF not found: {path}");
using var reader = new DxfReader(path);
var doc = reader.Read();
var detector = new SolidWorksBendDetector();
var bends = detector.DetectBends(doc);
Assert.Equal(2, bends.Count);
foreach (var bend in bends)
{
Assert.NotNull(bend.NoteText);
Assert.Equal(BendDirection.Up, bend.Direction);
Assert.Equal(90.0, bend.Angle);
Assert.Equal(0.313, bend.Radius);
}
}
}
+6
View File
@@ -28,4 +28,10 @@
<ProjectReference Include="..\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Bending\TestData\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+3 -3
View File
@@ -7,7 +7,7 @@ public class RectangleShapeTests
[Fact]
public void GetDrawing_ReturnsDrawingWithCorrectBoundingBox()
{
var shape = new RectangleShape { Width = 10, Height = 5 };
var shape = new RectangleShape { Length = 10, Width = 5 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
@@ -18,7 +18,7 @@ public class RectangleShapeTests
[Fact]
public void GetDrawing_DefaultName_IsRectangle()
{
var shape = new RectangleShape { Width = 10, Height = 5 };
var shape = new RectangleShape { Length = 10, Width = 5 };
var drawing = shape.GetDrawing();
Assert.Equal("Rectangle", drawing.Name);
@@ -27,7 +27,7 @@ public class RectangleShapeTests
[Fact]
public void GetDrawing_CustomName_IsUsed()
{
var shape = new RectangleShape { Name = "Plate1", Width = 10, Height = 5 };
var shape = new RectangleShape { Name = "Plate1", Length = 10, Width = 5 };
var drawing = shape.GetDrawing();
Assert.Equal("Plate1", drawing.Name);
@@ -7,7 +7,7 @@ public class RoundedRectangleShapeTests
[Fact]
public void GetDrawing_BoundingBoxMatchesDimensions()
{
var shape = new RoundedRectangleShape { Width = 20, Height = 10, Radius = 2 };
var shape = new RoundedRectangleShape { Length = 20, Width = 10, Radius = 2 };
var drawing = shape.GetDrawing();
var bbox = drawing.Program.BoundingBox();
@@ -18,7 +18,7 @@ public class RoundedRectangleShapeTests
[Fact]
public void GetDrawing_AreaIsLessThanFullRectangle()
{
var shape = new RoundedRectangleShape { Width = 20, Height = 10, Radius = 2 };
var shape = new RoundedRectangleShape { Length = 20, Width = 10, Radius = 2 };
var drawing = shape.GetDrawing();
// Area should be less than 20*10=200 because corners are rounded
@@ -30,7 +30,7 @@ public class RoundedRectangleShapeTests
[Fact]
public void GetDrawing_ZeroRadius_MatchesRectangleArea()
{
var shape = new RoundedRectangleShape { Width = 20, Height = 10, Radius = 0 };
var shape = new RoundedRectangleShape { Length = 20, Width = 10, Radius = 0 };
var drawing = shape.GetDrawing();
Assert.Equal(200, drawing.Area, 0.5);
+266 -22
View File
@@ -1,30 +1,15 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Splitting;
public class DrawingSplitterTests
{
/// <summary>
/// Helper: creates a Drawing from a rectangular perimeter.
/// </summary>
private static Drawing MakeRectangleDrawing(string name, double width, double height)
{
var entities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(width, 0)),
new Line(new Vector(width, 0), new Vector(width, height)),
new Line(new Vector(width, height), new Vector(0, height)),
new Line(new Vector(0, height), new Vector(0, 0))
};
var pgm = ConvertGeometry.ToProgram(entities);
return new Drawing(name, pgm);
}
[Fact]
public void Split_Rectangle_Vertical_ProducesTwoPieces()
{
var drawing = MakeRectangleDrawing("RECT", 100, 50);
var drawing = new RectangleShape { Name = "RECT", Length = 100, Width = 50 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
@@ -42,7 +27,7 @@ public class DrawingSplitterTests
[Fact]
public void Split_Rectangle_Horizontal_ProducesTwoPieces()
{
var drawing = MakeRectangleDrawing("RECT", 100, 60);
var drawing = new RectangleShape { Name = "RECT", Length = 100, Width = 60 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(30.0, CutOffAxis.Horizontal) };
var parameters = new SplitParameters { Type = SplitType.Straight };
@@ -56,7 +41,7 @@ public class DrawingSplitterTests
[Fact]
public void Split_ThreePieces_NamesSequentially()
{
var drawing = MakeRectangleDrawing("PART", 150, 50);
var drawing = new RectangleShape { Name = "PART", Length = 150, Width = 50 }.GetDrawing();
var splitLines = new List<SplitLine>
{
new SplitLine(50.0, CutOffAxis.Vertical),
@@ -75,7 +60,7 @@ public class DrawingSplitterTests
[Fact]
public void Split_CopiesDrawingProperties()
{
var drawing = MakeRectangleDrawing("PART", 100, 50);
var drawing = new RectangleShape { Name = "PART", Length = 100, Width = 50 }.GetDrawing();
drawing.Color = System.Drawing.Color.Red;
drawing.Priority = 5;
@@ -93,7 +78,7 @@ public class DrawingSplitterTests
[Fact]
public void Split_PiecesNormalizedToOrigin()
{
var drawing = MakeRectangleDrawing("PART", 100, 50);
var drawing = new RectangleShape { Name = "PART", Length = 100, Width = 50 }.GetDrawing();
var results = DrawingSplitter.Split(drawing,
new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) },
new SplitParameters());
@@ -146,7 +131,7 @@ public class DrawingSplitterTests
[Fact]
public void Split_GridSplit_ProducesFourPieces()
{
var drawing = MakeRectangleDrawing("GRID", 100, 100);
var drawing = new RectangleShape { Name = "GRID", Length = 100, Width = 100 }.GetDrawing();
var splitLines = new List<SplitLine>
{
new SplitLine(50.0, CutOffAxis.Vertical),
@@ -160,4 +145,263 @@ public class DrawingSplitterTests
Assert.Equal("GRID-3", results[2].Name);
Assert.Equal("GRID-4", results[3].Name);
}
[Fact]
public void Split_Square_Vertical_PieceWidthsSumToOriginal()
{
var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(40.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
Assert.Equal(2, results.Count);
var bb1 = results[0].Program.BoundingBox();
var bb2 = results[1].Program.BoundingBox();
// Piece lengths should sum to original length
Assert.Equal(100.0, bb1.Width + bb2.Width, 1);
// Both pieces should have the same width as the original
Assert.Equal(100.0, bb1.Length, 1);
Assert.Equal(100.0, bb2.Length, 1);
}
[Fact]
public void Split_Square_Horizontal_PieceHeightsSumToOriginal()
{
var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(60.0, CutOffAxis.Horizontal) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
Assert.Equal(2, results.Count);
var bb1 = results[0].Program.BoundingBox();
var bb2 = results[1].Program.BoundingBox();
// Piece widths should sum to original width
Assert.Equal(100.0, bb1.Length + bb2.Length, 1);
// Both pieces should have the same length as the original
Assert.Equal(100.0, bb1.Width, 1);
Assert.Equal(100.0, bb2.Width, 1);
}
[Fact]
public void Split_Square_Vertical_AreaPreserved()
{
var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing();
var originalArea = drawing.Area;
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
var totalArea = results.Sum(d => d.Area);
Assert.Equal(originalArea, totalArea, 1);
}
[Fact]
public void Split_Square_Vertical_PiecesAreClosedPerimeters()
{
var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
foreach (var piece in results)
{
var entities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
Assert.True(entities.Count >= 4, $"{piece.Name} should have at least 4 entities for a rectangle");
// First entity start should connect to last entity end (closed shape)
var firstStart = GetStartPoint(entities[0]);
var lastEnd = GetEndPoint(entities[^1]);
var closingGap = firstStart.DistanceTo(lastEnd);
Assert.True(closingGap < 0.01,
$"{piece.Name} is not closed: gap of {closingGap:F6} between last end and first start");
// Consecutive entities should connect
for (var i = 0; i < entities.Count - 1; i++)
{
var end = GetEndPoint(entities[i]);
var start = GetStartPoint(entities[i + 1]);
var gap = end.DistanceTo(start);
Assert.True(gap < 0.01,
$"Gap of {gap:F6} between entities {i} and {i + 1} in {piece.Name}");
}
}
}
[Fact]
public void Split_Square_Horizontal_PiecesAreClosedPerimeters()
{
var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Horizontal) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
foreach (var piece in results)
{
var entities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
Assert.True(entities.Count >= 4, $"{piece.Name} should have at least 4 entities for a rectangle");
var firstStart = GetStartPoint(entities[0]);
var lastEnd = GetEndPoint(entities[^1]);
var closingGap = firstStart.DistanceTo(lastEnd);
Assert.True(closingGap < 0.01,
$"{piece.Name} is not closed: gap of {closingGap:F6} between last end and first start");
for (var i = 0; i < entities.Count - 1; i++)
{
var end = GetEndPoint(entities[i]);
var start = GetStartPoint(entities[i + 1]);
var gap = end.DistanceTo(start);
Assert.True(gap < 0.01,
$"Gap of {gap:F6} between entities {i} and {i + 1} in {piece.Name}");
}
}
}
[Fact]
public void Split_Square_AsymmetricSplit_PieceDimensionsMatchSplitPosition()
{
var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing();
var splitLines = new List<SplitLine> { new SplitLine(30.0, CutOffAxis.Vertical) };
var parameters = new SplitParameters { Type = SplitType.Straight };
var results = DrawingSplitter.Split(drawing, splitLines, parameters);
Assert.Equal(2, results.Count);
var bb1 = results[0].Program.BoundingBox();
var bb2 = results[1].Program.BoundingBox();
// Left piece should be 30 long, right piece should be 70 long
Assert.Equal(30.0, bb1.Width, 1);
Assert.Equal(70.0, bb2.Width, 1);
}
[Fact]
public void Split_CircleHole_NotOnSplitLine_PreservedInCorrectPiece()
{
// Rectangle 100x50 with a circle hole at (20, 25) radius 3
// Split vertically at x=50 — hole is entirely in the left piece
var perimeterEntities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(100, 0)),
new Line(new Vector(100, 0), new Vector(100, 50)),
new Line(new Vector(100, 50), new Vector(0, 50)),
new Line(new Vector(0, 50), new Vector(0, 0))
};
var hole = new Circle(new Vector(20, 25), 3);
var allEntities = new List<Entity>();
allEntities.AddRange(perimeterEntities);
allEntities.Add(hole);
var pgm = ConvertGeometry.ToProgram(allEntities);
var drawing = new Drawing("CIRC", pgm);
var results = DrawingSplitter.Split(drawing,
new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) },
new SplitParameters());
Assert.Equal(2, results.Count);
// Left piece should have the hole — verify by checking it has arc entities
var leftEntities = ConvertProgram.ToGeometry(results[0].Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var leftArcs = leftEntities.OfType<Arc>().ToList();
// Decomposed circle = 2 arcs. Both should be present.
Assert.True(leftArcs.Count >= 2,
$"Left piece should have at least 2 arcs (full circle), but has {leftArcs.Count}");
// Right piece should have no arcs (hole is on the left)
var rightEntities = ConvertProgram.ToGeometry(results[1].Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
var rightArcs = rightEntities.OfType<Arc>().ToList();
Assert.Equal(0, rightArcs.Count);
}
[Fact]
public void Split_DxfRoundTrip_CircleHolePreserved()
{
// Two semicircular arcs (decomposed circle) must survive DXF write→reimport.
// Regression: GeometryOptimizer.TryJoinArcs merged them into a single arc
// because it incorrectly handled the wrap-around case (π→2π written as π→0°).
var perimeterEntities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(100, 0)),
new Line(new Vector(100, 0), new Vector(100, 50)),
new Line(new Vector(100, 50), new Vector(0, 50)),
new Line(new Vector(0, 50), new Vector(0, 0))
};
var hole = new Circle(new Vector(20, 25), 3);
var allEntities = new List<Entity>();
allEntities.AddRange(perimeterEntities);
allEntities.Add(hole);
var pgm = ConvertGeometry.ToProgram(allEntities);
var drawing = new Drawing("CIRC", pgm);
drawing.Bends = new List<OpenNest.Bending.Bend>();
// Split — the circle gets decomposed into two arcs
var results = DrawingSplitter.Split(drawing,
new List<SplitLine> { new SplitLine(50.0, CutOffAxis.Vertical) },
new SplitParameters());
Assert.Equal(2, results.Count);
// Write left piece to DXF and re-import
var tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "split_roundtrip_test.dxf");
try
{
var writer = new OpenNest.IO.SplitDxfWriter();
writer.Write(tempPath, results[0]);
var reimporter = new OpenNest.IO.DxfImporter();
var reimportResult = reimporter.Import(tempPath);
var afterArcs = reimportResult.Entities.OfType<Arc>().Count();
var afterCircles = reimportResult.Entities.OfType<Circle>().Count();
Assert.True(afterArcs + afterCircles * 2 >= 2,
$"After DXF round-trip: {afterArcs} arcs, {afterCircles} circles (expected 2+ for full hole)");
}
finally
{
if (System.IO.File.Exists(tempPath))
System.IO.File.Delete(tempPath);
}
}
private static Vector GetStartPoint(Entity entity)
{
return entity switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(),
_ => new Vector(0, 0)
};
}
private static Vector GetEndPoint(Entity entity)
{
return entity switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(),
_ => new Vector(0, 0)
};
}
}
@@ -1,5 +1,6 @@
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Shapes;
namespace OpenNest.Tests.Splitting;
@@ -8,16 +9,7 @@ public class SplitIntegrationTest
[Fact]
public void Split_SpikeGroove_NoContinuityGaps()
{
// Create a rectangle
var entities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(100, 0)),
new Line(new Vector(100, 0), new Vector(100, 50)),
new Line(new Vector(100, 50), new Vector(0, 50)),
new Line(new Vector(0, 50), new Vector(0, 0))
};
var pgm = ConvertGeometry.ToProgram(entities);
var drawing = new Drawing("TEST", pgm);
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
var sl = new SplitLine(50.0, CutOffAxis.Vertical);
sl.FeaturePositions.Add(12.5);
@@ -60,15 +52,7 @@ public class SplitIntegrationTest
[Fact]
public void Split_SpikeGroove_Horizontal_NoContinuityGaps()
{
var entities = new List<Entity>
{
new Line(new Vector(0, 0), new Vector(100, 0)),
new Line(new Vector(100, 0), new Vector(100, 50)),
new Line(new Vector(100, 50), new Vector(0, 50)),
new Line(new Vector(0, 50), new Vector(0, 0))
};
var pgm = ConvertGeometry.ToProgram(entities);
var drawing = new Drawing("TEST", pgm);
var drawing = new RectangleShape { Name = "TEST", Length = 100, Width = 50 }.GetDrawing();
var sl = new SplitLine(25.0, CutOffAxis.Horizontal);
sl.FeaturePositions.Add(25.0);
+1 -1
View File
@@ -42,7 +42,7 @@ public class AutoSplitCalculatorTests
Assert.Single(lines);
Assert.Equal(CutOffAxis.Vertical, lines[0].Axis);
Assert.Equal(50.0, lines[0].Position, 1);
Assert.Equal(58.0, lines[0].Position, 1);
}
[Fact]
+73 -3
View File
@@ -1,6 +1,7 @@
using OpenNest.Bending;
using OpenNest.Geometry;
using OpenNest.Math;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
@@ -17,6 +18,20 @@ namespace OpenNest.Controls
private readonly Pen gridPen = new Pen(Color.FromArgb(70, 70, 70));
private readonly Dictionary<int, Pen> penCache = new Dictionary<int, Pen>();
public event EventHandler<Line> LinePicked;
public event EventHandler PickCancelled;
private bool isPickingBendLine;
public bool IsPickingBendLine
{
get => isPickingBendLine;
set
{
isPickingBendLine = value;
Cursor = value ? Cursors.Hand : Cursors.Cross;
}
}
public EntityView()
{
Entities = new List<Entity>();
@@ -34,6 +49,13 @@ namespace OpenNest.Controls
{
base.OnMouseClick(e);
if (!Focused) Focus();
if (IsPickingBendLine && e.Button == MouseButtons.Left)
{
var line = HitTestLine(e.Location);
if (line != null)
LinePicked?.Invoke(this, line);
}
}
protected override void OnPaint(PaintEventArgs e)
@@ -117,6 +139,20 @@ namespace OpenNest.Controls
ZoomToFit();
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
if (e.KeyCode == Keys.F)
ZoomToFit();
if (IsPickingBendLine && e.KeyCode == Keys.Escape)
{
IsPickingBendLine = false;
PickCancelled?.Invoke(this, EventArgs.Empty);
}
}
private Pen GetEntityPen(Color color)
{
if (color.IsEmpty || color.A == 0)
@@ -158,9 +194,9 @@ namespace OpenNest.Controls
{
DashPattern = new float[] { 8, 6 }
};
using var selectedPen = new Pen(Color.Cyan, 2.5f)
using var glowPen = new Pen(Color.OrangeRed, 2.0f)
{
DashPattern = new float[] { 8, 6 }
DashPattern = new float[] { 6, 4 }
};
for (var i = 0; i < Bends.Count; i++)
@@ -168,10 +204,44 @@ namespace OpenNest.Controls
var bend = Bends[i];
var pt1 = PointWorldToGraph(bend.StartPoint);
var pt2 = PointWorldToGraph(bend.EndPoint);
g.DrawLine(i == SelectedBendIndex ? selectedPen : bendPen, pt1, pt2);
if (i == SelectedBendIndex)
{
g.DrawLine(glowPen, pt1, pt2);
}
else
{
g.DrawLine(bendPen, pt1, pt2);
}
}
}
private Line HitTestLine(Point controlPoint)
{
var worldPoint = PointControlToWorld(controlPoint);
var tolerance = LengthGuiToWorld(6);
Line bestLine = null;
var bestDistance = double.MaxValue;
foreach (var entity in Entities)
{
if (entity.Type != EntityType.Line || !entity.IsVisible)
continue;
var line = (Line)entity;
var closest = line.ClosestPointTo(worldPoint);
var distance = worldPoint.DistanceTo(closest);
if (distance < tolerance && distance < bestDistance)
{
bestLine = line;
bestDistance = distance;
}
}
return bestLine;
}
protected override void Dispose(bool disposing)
{
if (disposing)
+30 -4
View File
@@ -19,6 +19,7 @@ namespace OpenNest.Controls
private readonly CheckedListBox colorsList;
private readonly CheckedListBox lineTypesList;
private readonly ListBox bendLinesList;
private readonly LinkLabel bendAddLink;
private List<Entity> currentEntities;
private List<Bend> currentBends;
@@ -26,6 +27,7 @@ namespace OpenNest.Controls
public event EventHandler FilterChanged;
public event EventHandler<int> BendLineSelected;
public event EventHandler<int> BendLineRemoved;
public event EventHandler AddBendLineClicked;
public FilterPanel()
{
@@ -51,9 +53,8 @@ namespace OpenNest.Controls
var bendDeleteLink = new LinkLabel
{
Text = "Remove Selected",
Dock = DockStyle.Bottom,
Height = 20,
Text = "Remove",
AutoSize = true,
Font = new Font("Segoe UI", 8f)
};
bendDeleteLink.LinkClicked += (s, e) =>
@@ -62,8 +63,27 @@ namespace OpenNest.Controls
BendLineRemoved?.Invoke(this, bendLinesList.SelectedIndex);
};
bendAddLink = new LinkLabel
{
Text = "Add Bend Line",
AutoSize = true,
Font = new Font("Segoe UI", 8f)
};
bendAddLink.LinkClicked += (s, e) =>
AddBendLineClicked?.Invoke(this, EventArgs.Empty);
var bendLinksPanel = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
Height = 20,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false
};
bendLinksPanel.Controls.Add(bendAddLink);
bendLinksPanel.Controls.Add(bendDeleteLink);
bendLinesPanel.ContentPanel.Controls.Add(bendLinesList);
bendLinesPanel.ContentPanel.Controls.Add(bendDeleteLink);
bendLinesPanel.ContentPanel.Controls.Add(bendLinksPanel);
// Line Types
lineTypesPanel = new CollapsiblePanel
@@ -237,6 +257,12 @@ namespace OpenNest.Controls
e.DrawFocusRectangle();
}
public void SetPickMode(bool active)
{
bendAddLink.Text = active ? "Cancel (Esc)" : "Add Bend Line";
bendAddLink.LinkColor = active ? Color.OrangeRed : Color.Empty;
}
}
public class ColorItem
+53
View File
@@ -131,6 +131,8 @@ namespace OpenNest.Controls
public bool DrawRapid { get; set; }
public bool DrawPiercePoints { get; set; }
public bool DrawBounds { get; set; }
public bool DrawOffset { get; set; }
@@ -617,6 +619,9 @@ namespace OpenNest.Controls
if (DrawRapid)
DrawRapids(g);
if (DrawPiercePoints)
DrawAllPiercePoints(g);
}
private void DrawBendLines(Graphics g, Part part)
@@ -813,6 +818,54 @@ namespace OpenNest.Controls
}
}
private void DrawAllPiercePoints(Graphics g)
{
using var brush = new SolidBrush(Color.Red);
using var pen = new Pen(Color.DarkRed, 1f);
for (var i = 0; i < Plate.Parts.Count; ++i)
{
var part = Plate.Parts[i];
var pgm = part.Program;
var pos = part.Location;
DrawProgramPiercePoints(g, pgm, ref pos, brush, pen);
}
}
private void DrawProgramPiercePoints(Graphics g, Program pgm, ref Vector pos, Brush brush, Pen pen)
{
for (var i = 0; i < pgm.Length; ++i)
{
var code = pgm[i];
if (code.Type == CodeType.SubProgramCall)
{
var subpgm = (SubProgramCall)code;
if (subpgm.Program != null)
DrawProgramPiercePoints(g, subpgm.Program, ref pos, brush, pen);
}
else
{
var motion = code as Motion;
if (motion == null) continue;
var endpt = pgm.Mode == Mode.Incremental
? motion.EndPoint + pos
: motion.EndPoint;
if (code.Type == CodeType.RapidMove)
{
var pt = PointWorldToGraph(endpt);
var radius = 2f;
g.FillEllipse(brush, pt.X - radius, pt.Y - radius, radius * 2, radius * 2);
g.DrawEllipse(pen, pt.X - radius, pt.Y - radius, radius * 2, radius * 2);
}
pos = endpt;
}
}
}
private void DrawLine(Graphics g, Vector pt1, Vector pt2, Pen pen)
{
var point1 = PointWorldToGraph(pt1);
+103
View File
@@ -0,0 +1,103 @@
using OpenNest.Bending;
using System;
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public class BendLineDialog : Form
{
private readonly ComboBox cboDirection;
private readonly NumericUpDown numAngle;
private readonly NumericUpDown numRadius;
private readonly CheckBox chkRadius;
public BendLineDialog()
{
Text = "Bend Line Properties";
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Size = new Size(260, 200);
var font = new Font("Segoe UI", 9f);
// Direction
var lblDir = new Label { Text = "Direction:", Location = new Point(12, 15), AutoSize = true, Font = font };
cboDirection = new ComboBox
{
DropDownStyle = ComboBoxStyle.DropDownList,
Location = new Point(100, 12),
Width = 130,
Font = font
};
cboDirection.Items.AddRange(new object[] { "Down", "Up" });
cboDirection.SelectedIndex = 0;
// Angle
var lblAngle = new Label { Text = "Angle:", Location = new Point(12, 47), AutoSize = true, Font = font };
numAngle = new NumericUpDown
{
Location = new Point(100, 44),
Width = 130,
Font = font,
Minimum = 0,
Maximum = 180,
DecimalPlaces = 1,
Value = 90
};
// Radius (with checkbox to enable)
chkRadius = new CheckBox { Text = "Radius:", Location = new Point(12, 79), AutoSize = true, Font = font };
numRadius = new NumericUpDown
{
Location = new Point(100, 76),
Width = 130,
Font = font,
Minimum = 0,
Maximum = 25,
DecimalPlaces = 3,
Increment = 0.0625m,
Enabled = false
};
chkRadius.CheckedChanged += (s, e) => numRadius.Enabled = chkRadius.Checked;
// Buttons
var btnOk = new Button
{
Text = "OK",
DialogResult = DialogResult.OK,
Location = new Point(62, 120),
Size = new Size(80, 28),
Font = font
};
var btnCancel = new Button
{
Text = "Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(150, 120),
Size = new Size(80, 28),
Font = font
};
AcceptButton = btnOk;
CancelButton = btnCancel;
Controls.AddRange(new Control[] {
lblDir, cboDirection,
lblAngle, numAngle,
chkRadius, numRadius,
btnOk, btnCancel
});
}
public BendDirection Direction => cboDirection.SelectedIndex == 0
? BendDirection.Down
: BendDirection.Up;
public double BendAngle => (double)numAngle.Value;
public double? BendRadius => chkRadius.Checked ? (double)numRadius.Value : null;
}
}
+98 -8
View File
@@ -29,6 +29,9 @@ namespace OpenNest.Forms
filterPanel.FilterChanged += OnFilterChanged;
filterPanel.BendLineSelected += OnBendLineSelected;
filterPanel.BendLineRemoved += OnBendLineRemoved;
filterPanel.AddBendLineClicked += OnAddBendLineClicked;
entityView1.LinePicked += OnLinePicked;
entityView1.PickCancelled += OnPickCancelled;
btnSplit.Click += OnSplitClicked;
numQuantity.ValueChanged += OnQuantityChanged;
txtCustomer.TextChanged += OnCustomerChanged;
@@ -132,6 +135,11 @@ namespace OpenNest.Forms
private void LoadItem(FileListItem item)
{
entityView1.ClearPenCache();
if (entityView1.IsPickingBendLine)
{
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
}
entityView1.Entities.Clear();
entityView1.Entities.AddRange(item.Entities);
entityView1.Bends = item.Bends ?? new List<Bend>();
@@ -139,6 +147,7 @@ namespace OpenNest.Forms
item.Entities.ForEach(e => e.IsVisible = true);
if (item.Entities.Any(e => e.Layer != null))
item.Entities.ForEach(e => e.Layer.IsVisible = true);
ReHidePromotedEntities(item.Bends);
filterPanel.LoadItem(item.Entities, item.Bends);
@@ -170,6 +179,7 @@ namespace OpenNest.Forms
if (item == null) return;
filterPanel.ApplyFilters(item.Entities);
ReHidePromotedEntities(item.Bends);
entityView1.Invalidate();
}
@@ -184,6 +194,10 @@ namespace OpenNest.Forms
var item = CurrentItem;
if (item == null || index < 0 || index >= item.Bends.Count) return;
var bend = item.Bends[index];
if (bend.SourceEntity != null)
bend.SourceEntity.IsVisible = true;
item.Bends.RemoveAt(index);
entityView1.Bends = item.Bends;
entityView1.SelectedBendIndex = -1;
@@ -231,14 +245,25 @@ namespace OpenNest.Forms
shape.Cutouts.ForEach(c => drawEntities.AddRange(c.Entities));
var pgm = ConvertGeometry.ToProgram(drawEntities);
var originOffset = Vector.Zero;
if (pgm.Codes.Count > 0 && pgm[0].Type == CodeType.RapidMove)
{
var rapid = (RapidMove)pgm[0];
pgm.Offset(-rapid.EndPoint);
originOffset = rapid.EndPoint;
pgm.Offset(-originOffset);
pgm.Codes.RemoveAt(0);
}
var drawing = new Drawing(item.Name, pgm);
drawing.Bends = item.Bends.Select(b => new Bend
{
StartPoint = new Vector(b.StartPoint.X - originOffset.X, b.StartPoint.Y - originOffset.Y),
EndPoint = new Vector(b.EndPoint.X - originOffset.X, b.EndPoint.Y - originOffset.Y),
Direction = b.Direction,
Angle = b.Angle,
Radius = b.Radius,
NoteText = b.NoteText,
}).ToList();
using var form = new SplitDrawingForm(drawing);
if (form.ShowDialog(this) != DialogResult.OK || form.ResultDrawings?.Count <= 1)
@@ -255,31 +280,86 @@ namespace OpenNest.Forms
var newItems = new List<string>();
var splitWriter = new SplitDxfWriter();
var splitItems = new List<FileListItem>();
for (var i = 0; i < form.ResultDrawings.Count; i++)
{
var splitDrawing = form.ResultDrawings[i];
// Assign bends from the source item — spatial filtering is a future enhancement
splitDrawing.Bends.AddRange(item.Bends);
var splitName = $"{baseName}_split{i + 1}.dxf";
var splitName = $"{baseName}-{i + 1}.dxf";
var splitPath = GetUniquePath(Path.Combine(writableDir, splitName));
splitWriter.Write(splitPath, splitDrawing);
newItems.Add(splitPath);
// Re-import geometry but keep bends from the split drawing
var importer = new DxfImporter();
importer.SplinePrecision = Settings.Default.ImportSplinePrecision;
var result = importer.Import(splitPath);
var splitItem = new FileListItem
{
Name = Path.GetFileNameWithoutExtension(splitPath),
Entities = result.Entities,
Path = splitPath,
Quantity = item.Quantity,
Customer = item.Customer,
Bends = splitDrawing.Bends ?? new List<Bend>(),
Bounds = result.Entities.GetBoundingBox(),
EntityCount = result.Entities.Count
};
splitItems.Add(splitItem);
}
// Remove original and add split files
// Remove original and add split items directly (preserving bend info)
fileList.RemoveAt(index);
foreach (var path in newItems)
AddFile(path);
foreach (var splitItem in splitItems)
fileList.AddItem(splitItem);
if (writableDir != sourceDir)
MessageBox.Show($"Split files written to: {writableDir}", "Split Output",
MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void OnAddBendLineClicked(object sender, EventArgs e)
{
var active = !entityView1.IsPickingBendLine;
entityView1.IsPickingBendLine = active;
filterPanel.SetPickMode(active);
}
private void OnLinePicked(object sender, Line line)
{
using var dialog = new BendLineDialog();
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
var item = CurrentItem;
if (item == null) return;
var bend = new Bend
{
StartPoint = line.StartPoint,
EndPoint = line.EndPoint,
Direction = dialog.Direction,
Angle = dialog.BendAngle,
Radius = dialog.BendRadius,
SourceEntity = line
};
line.IsVisible = false;
item.Bends.Add(bend);
entityView1.Bends = item.Bends;
filterPanel.LoadItem(item.Entities, item.Bends);
entityView1.Invalidate();
}
private void OnPickCancelled(object sender, EventArgs e)
{
entityView1.IsPickingBendLine = false;
filterPanel.SetPickMode(false);
}
private void OnDragEnter(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
@@ -358,6 +438,16 @@ namespace OpenNest.Forms
#region Helpers
private static void ReHidePromotedEntities(List<Bend> bends)
{
if (bends == null) return;
foreach (var bend in bends)
{
if (bend.SourceEntity != null)
bend.SourceEntity.IsVisible = false;
}
}
private static void SetRotation(Shape shape, RotationType rotation)
{
try
+6
View File
@@ -473,6 +473,12 @@ namespace OpenNest.Forms
PlateView.Invalidate();
}
public void TogglePiercePoints()
{
PlateView.DrawPiercePoints = !PlateView.DrawPiercePoints;
PlateView.Invalidate();
}
public void ToggleDrawBounds()
{
PlateView.DrawBounds = !PlateView.DrawBounds;
+12 -2
View File
@@ -48,6 +48,7 @@
mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem();
mnuView = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawOffset = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem5 = new System.Windows.Forms.ToolStripSeparator();
@@ -297,7 +298,7 @@
//
// mnuView
//
mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawBounds, mnuViewDrawOffset, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut });
mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut });
mnuView.Name = "mnuView";
mnuView.Size = new System.Drawing.Size(44, 20);
mnuView.Text = "&View";
@@ -308,7 +309,15 @@
mnuViewDrawRapids.Size = new System.Drawing.Size(222, 22);
mnuViewDrawRapids.Text = "Draw Rapids";
mnuViewDrawRapids.Click += ToggleDrawRapids_Click;
//
//
// mnuViewDrawPiercePoints
//
mnuViewDrawPiercePoints.CheckOnClick = true;
mnuViewDrawPiercePoints.Name = "mnuViewDrawPiercePoints";
mnuViewDrawPiercePoints.Size = new System.Drawing.Size(222, 22);
mnuViewDrawPiercePoints.Text = "Draw Pierce Points";
mnuViewDrawPiercePoints.Click += ToggleDrawPiercePoints_Click;
//
// mnuViewDrawBounds
//
mnuViewDrawBounds.CheckOnClick = true;
@@ -1131,6 +1140,7 @@
private System.Windows.Forms.ToolStripMenuItem mnuEditSelectAll;
private System.Windows.Forms.ToolStripMenuItem mnuView;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawRapids;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawOffset;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem5;
+26 -5
View File
@@ -78,10 +78,24 @@ namespace OpenNest.Forms
private string GetNestName(DateTime date, int id)
{
var month = date.Month.ToString().PadLeft(2, '0');
var day = date.Day.ToString().PadLeft(2, '0');
var year = (date.Year % 100).ToString("D2");
var seq = ToBase36(id).PadLeft(3, '0');
return string.Format("N{0}{1}-{2}", month, day, id.ToString().PadLeft(3, '0'));
return $"N{year}-{seq}";
}
private static string ToBase36(int value)
{
const string chars = "2345679ACDEFGHJKLMNPQRSTUVWXYZ";
if (value == 0) return "0";
var result = "";
while (value > 0)
{
result = chars[value % 36] + result;
value /= 36;
}
return result;
}
private void LoadNest(Nest nest, FormWindowState windowState = FormWindowState.Maximized)
@@ -377,6 +391,7 @@ namespace OpenNest.Forms
activeForm.PlateView.PartAdded += PlateView_PartAdded;
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
mnuViewDrawPiercePoints.Checked = activeForm.PlateView.DrawPiercePoints;
mnuViewDrawBounds.Checked = activeForm.PlateView.DrawBounds;
statusLabel1.Text = activeForm.PlateView.Status;
}
@@ -539,6 +554,13 @@ namespace OpenNest.Forms
mnuViewDrawRapids.Checked = activeForm.PlateView.DrawRapid;
}
private void ToggleDrawPiercePoints_Click(object sender, EventArgs e)
{
if (activeForm == null) return;
activeForm.TogglePiercePoints();
mnuViewDrawPiercePoints.Checked = activeForm.PlateView.DrawPiercePoints;
}
private void ToggleDrawBounds_Click(object sender, EventArgs e)
{
if (activeForm == null) return;
@@ -826,7 +848,6 @@ namespace OpenNest.Forms
}
var finder = RemnantFinder.FromPlate(plate);
var tiered = finder.FindTieredRemnants(minDim);
if (remnantViewer == null || remnantViewer.IsDisposed)
{
@@ -840,7 +861,7 @@ namespace OpenNest.Forms
Top);
}
remnantViewer.LoadRemnants(tiered, activeForm.PlateView);
remnantViewer.LoadRemnants(finder, minDim, activeForm.PlateView);
remnantViewer.Show();
remnantViewer.BringToFront();
}
+30 -2
View File
@@ -11,7 +11,10 @@ namespace OpenNest.Forms
public class RemnantViewerForm : Form
{
private ListView listView;
private CheckBox filterCheckBox;
private PlateView plateView;
private RemnantFinder finder;
private double minDim;
private List<TieredRemnant> remnants = new();
private int selectedIndex = -1;
@@ -24,6 +27,15 @@ namespace OpenNest.Forms
ShowInTaskbar = false;
TopMost = true;
filterCheckBox = new CheckBox
{
Text = "Filter by part size",
Checked = true,
Dock = DockStyle.Top,
Padding = new Padding(4, 2, 0, 2),
};
filterCheckBox.CheckedChanged += FilterCheckBox_CheckedChanged;
listView = new ListView
{
Dock = DockStyle.Fill,
@@ -42,6 +54,7 @@ namespace OpenNest.Forms
listView.SelectedIndexChanged += ListView_SelectedIndexChanged;
Controls.Add(listView);
Controls.Add(filterCheckBox);
}
protected override bool ProcessDialogKey(Keys keyData)
@@ -54,10 +67,25 @@ namespace OpenNest.Forms
return base.ProcessDialogKey(keyData);
}
public void LoadRemnants(List<TieredRemnant> tieredRemnants, PlateView view)
public void LoadRemnants(RemnantFinder finder, double minDim, PlateView view)
{
plateView = view;
remnants = tieredRemnants;
this.finder = finder;
this.minDim = minDim;
Refresh();
}
private void FilterCheckBox_CheckedChanged(object sender, EventArgs e)
{
if (finder != null)
Refresh();
}
private new void Refresh()
{
var dim = filterCheckBox.Checked ? minDim : 0;
remnants = finder.FindTieredRemnants(dim);
selectedIndex = -1;
listView.BeginUpdate();
+39 -36
View File
@@ -29,9 +29,6 @@ namespace OpenNest.Forms
private void InitializeComponent()
{
pnlSettings = new System.Windows.Forms.Panel();
pnlButtons = new System.Windows.Forms.Panel();
btnCancel = new System.Windows.Forms.Button();
btnOK = new System.Windows.Forms.Button();
grpSpikeParams = new System.Windows.Forms.GroupBox();
nudSpikePairCount = new System.Windows.Forms.NumericUpDown();
lblSpikePairCount = new System.Windows.Forms.Label();
@@ -70,6 +67,9 @@ namespace OpenNest.Forms
radByCount = new System.Windows.Forms.RadioButton();
radFitToPlate = new System.Windows.Forms.RadioButton();
radManual = new System.Windows.Forms.RadioButton();
pnlButtons = new System.Windows.Forms.Panel();
btnOK = new System.Windows.Forms.Button();
btnCancel = new System.Windows.Forms.Button();
pnlPreview = new SplitPreview();
toolStrip = new System.Windows.Forms.ToolStrip();
btnAddLine = new System.Windows.Forms.ToolStripButton();
@@ -96,6 +96,7 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)nudPlateHeight).BeginInit();
((System.ComponentModel.ISupportInitialize)nudPlateWidth).BeginInit();
grpMethod.SuspendLayout();
pnlButtons.SuspendLayout();
toolStrip.SuspendLayout();
statusStrip.SuspendLayout();
SuspendLayout();
@@ -116,38 +117,6 @@ namespace OpenNest.Forms
pnlSettings.Padding = new System.Windows.Forms.Padding(6);
pnlSettings.Size = new System.Drawing.Size(220, 611);
pnlSettings.TabIndex = 2;
//
// pnlButtons
//
pnlButtons.Controls.Add(btnOK);
pnlButtons.Controls.Add(btnCancel);
pnlButtons.Dock = System.Windows.Forms.DockStyle.Bottom;
pnlButtons.Name = "pnlButtons";
pnlButtons.Size = new System.Drawing.Size(208, 40);
pnlButtons.TabIndex = 8;
//
// btnCancel
//
btnCancel.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
btnCancel.Location = new System.Drawing.Point(110, 6);
btnCancel.Name = "btnCancel";
btnCancel.Size = new System.Drawing.Size(80, 28);
btnCancel.TabIndex = 7;
btnCancel.Text = "Cancel";
btnCancel.UseVisualStyleBackColor = true;
btnCancel.Click += OnCancel;
//
// btnOK
//
btnOK.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
btnOK.Location = new System.Drawing.Point(20, 6);
btnOK.Name = "btnOK";
btnOK.Size = new System.Drawing.Size(80, 28);
btnOK.TabIndex = 6;
btnOK.Text = "OK";
btnOK.UseVisualStyleBackColor = true;
btnOK.Click += OnOK;
//
// grpSpikeParams
//
@@ -216,7 +185,7 @@ namespace OpenNest.Forms
nudGrooveDepth.Name = "nudGrooveDepth";
nudGrooveDepth.Size = new System.Drawing.Size(88, 23);
nudGrooveDepth.TabIndex = 1;
nudGrooveDepth.Value = new decimal(new int[] { 625, 0, 0, 196608 });
nudGrooveDepth.Value = new decimal(new int[] { 125, 0, 0, 196608 });
nudGrooveDepth.ValueChanged += OnSpikeParamChanged;
//
// lblGrooveDepth
@@ -568,6 +537,39 @@ namespace OpenNest.Forms
radManual.Text = "Manual";
radManual.CheckedChanged += OnMethodChanged;
//
// pnlButtons
//
pnlButtons.Controls.Add(btnOK);
pnlButtons.Controls.Add(btnCancel);
pnlButtons.Dock = System.Windows.Forms.DockStyle.Bottom;
pnlButtons.Location = new System.Drawing.Point(6, 637);
pnlButtons.Name = "pnlButtons";
pnlButtons.Size = new System.Drawing.Size(191, 40);
pnlButtons.TabIndex = 8;
//
// btnOK
//
btnOK.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
btnOK.Location = new System.Drawing.Point(11, 6);
btnOK.Name = "btnOK";
btnOK.Size = new System.Drawing.Size(80, 28);
btnOK.TabIndex = 6;
btnOK.Text = "OK";
btnOK.UseVisualStyleBackColor = true;
btnOK.Click += OnOK;
//
// btnCancel
//
btnCancel.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
btnCancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
btnCancel.Location = new System.Drawing.Point(101, 6);
btnCancel.Name = "btnCancel";
btnCancel.Size = new System.Drawing.Size(80, 28);
btnCancel.TabIndex = 7;
btnCancel.Text = "Cancel";
btnCancel.UseVisualStyleBackColor = true;
btnCancel.Click += OnCancel;
//
// pnlPreview
//
pnlPreview.BackColor = System.Drawing.Color.FromArgb(33, 40, 48);
@@ -667,6 +669,7 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)nudPlateWidth).EndInit();
grpMethod.ResumeLayout(false);
grpMethod.PerformLayout();
pnlButtons.ResumeLayout(false);
toolStrip.ResumeLayout(false);
toolStrip.PerformLayout();
statusStrip.ResumeLayout(false);
+65 -14
View File
@@ -18,6 +18,7 @@ public partial class SplitDrawingForm : Form
private readonly List<SplitLine> _splitLines = new();
private CutOffAxis _currentAxis = CutOffAxis.Vertical;
private bool _placingLine;
private Vector _placingCursor;
// Feature handle drag state
private int _dragLineIndex = -1;
@@ -74,30 +75,22 @@ public partial class SplitDrawingForm : Form
if (axisIndex == 1)
{
var usable = System.Math.Min(plateW, plateH) - 2 * spacing - overhang;
var usable = plateW - 2 * spacing - overhang;
if (usable > 0)
{
var splits = (int)System.Math.Ceiling(_drawingBounds.Width / usable) - 1;
if (splits > 0)
{
var step = _drawingBounds.Width / (splits + 1);
for (var i = 1; i <= splits; i++)
_splitLines.Add(new SplitLine(_drawingBounds.X + step * i, CutOffAxis.Vertical));
}
for (var i = 1; i <= splits; i++)
_splitLines.Add(new SplitLine(_drawingBounds.X + usable * i, CutOffAxis.Vertical));
}
}
else if (axisIndex == 2)
{
var usable = System.Math.Min(plateW, plateH) - 2 * spacing - overhang;
var usable = plateH - 2 * spacing - overhang;
if (usable > 0)
{
var splits = (int)System.Math.Ceiling(_drawingBounds.Length / usable) - 1;
if (splits > 0)
{
var step = _drawingBounds.Length / (splits + 1);
for (var i = 1; i <= splits; i++)
_splitLines.Add(new SplitLine(_drawingBounds.Y + step * i, CutOffAxis.Horizontal));
}
for (var i = 1; i <= splits; i++)
_splitLines.Add(new SplitLine(_drawingBounds.Y + usable * i, CutOffAxis.Horizontal));
}
}
else
@@ -293,6 +286,12 @@ public partial class SplitDrawingForm : Form
}
}
if (_placingLine)
{
_placingCursor = SnapToMidpoint(worldPt);
pnlPreview.Invalidate();
}
lblCursor.Text = $"Cursor: {worldPt.X:F2}, {worldPt.Y:F2}";
}
@@ -339,6 +338,7 @@ public partial class SplitDrawingForm : Form
if (keyData == Keys.Space)
{
_currentAxis = _currentAxis == CutOffAxis.Vertical ? CutOffAxis.Horizontal : CutOffAxis.Vertical;
pnlPreview.Invalidate();
return true;
}
if (keyData == Keys.Escape)
@@ -381,6 +381,30 @@ public partial class SplitDrawingForm : Form
System.Math.Abs(br.X - tl.X), System.Math.Abs(br.Y - tl.Y));
}
// Piece number and dimension labels at center of each region
if (regions.Count > 1)
{
using var labelFont = new Font("Segoe UI", 14f, FontStyle.Bold, GraphicsUnit.Pixel);
using var dimFont = new Font("Segoe UI", 11f, FontStyle.Regular, GraphicsUnit.Pixel);
using var labelBrush = new SolidBrush(Color.FromArgb(200, 255, 255, 255));
using var shadowBrush = new SolidBrush(Color.FromArgb(160, 0, 0, 0));
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
for (var i = 0; i < regions.Count; i++)
{
var r = regions[i];
var center = pnlPreview.PointWorldToGraph(r.Center.X, r.Center.Y);
var label = (i + 1).ToString();
var dim = $"{r.Width:F2} x {r.Length:F2}";
// Shadow offset for readability
g.DrawString(label, labelFont, shadowBrush, center.X + 1, center.Y - 7, sf);
g.DrawString(label, labelFont, labelBrush, center.X, center.Y - 8, sf);
g.DrawString(dim, dimFont, shadowBrush, center.X + 1, center.Y + 9, sf);
g.DrawString(dim, dimFont, labelBrush, center.X, center.Y + 8, sf);
}
}
// Split lines — trimmed at feature positions with feature contours
var parameters = GetCurrentParameters();
var feature = GetSplitFeature(parameters.Type);
@@ -449,6 +473,33 @@ public partial class SplitDrawingForm : Form
}
}
// Placement preview line
if (_placingLine && _placingCursor != null)
{
var isVert = _currentAxis == CutOffAxis.Vertical;
var snapped = _placingCursor;
var pos = isVert ? snapped.X : snapped.Y;
var margin = 10.0;
PointF pp1, pp2;
if (isVert)
{
pp1 = pnlPreview.PointWorldToGraph(pos, _drawingBounds.Bottom - margin);
pp2 = pnlPreview.PointWorldToGraph(pos, _drawingBounds.Top + margin);
}
else
{
pp1 = pnlPreview.PointWorldToGraph(_drawingBounds.Left - margin, pos);
pp2 = pnlPreview.PointWorldToGraph(_drawingBounds.Right + margin, pos);
}
using var previewPen = new Pen(Color.FromArgb(180, 255, 213, 79), 1.5f);
previewPen.DashStyle = DashStyle.DashDot;
g.DrawLine(previewPen, pp1, pp2);
}
// Feature position handles
if (!radStraight.Checked)
{