feat: replace Clipper2 with direct entity splitting in DrawingSplitter

Replace polygon boolean clipping with direct entity splitting using
bounding box filtering and exact intersection math. Eliminates Clipper2
precision drift that caused contour gaps (0.0035") breaking area
calculation and ShapeBuilder chaining.

Also fixes SpikeGrooveSplit: spike depth is now grooveDepth + weldGap
(spike protrudes past groove), both V-shapes use same angle formula,
and weldGap no longer double-subtracted from tip depth.

SplitDrawingForm: fix parameter mapping (GrooveDepth direct from nud,
not inflated), remove redundant Spike Depth display, add feature
contour preview and trimmed split lines at feature positions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 18:19:47 -04:00
parent df18b72881
commit 39f8a79cfd
5 changed files with 531 additions and 230 deletions
+296 -166
View File
@@ -15,20 +15,12 @@ public static class DrawingSplitter
if (splitLines.Count == 0) if (splitLines.Count == 0)
return new List<Drawing> { drawing }; return new List<Drawing> { drawing };
// 1. Convert program to geometry -> ShapeProfile separates perimeter from cutouts var profile = BuildProfile(drawing);
// Filter out rapid-layer entities so ShapeBuilder doesn't chain cutouts to the perimeter
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var profile = new ShapeProfile(entities);
// Decompose circles to arcs so all entities support SplitAt()
DecomposeCircles(profile); DecomposeCircles(profile);
var perimeter = profile.Perimeter; var perimeter = profile.Perimeter;
var bounds = perimeter.BoundingBox; var bounds = perimeter.BoundingBox;
// 2. Sort split lines by position, discard any outside the part
var sortedLines = splitLines var sortedLines = splitLines
.Where(l => IsLineInsideBounds(l, bounds)) .Where(l => IsLineInsideBounds(l, bounds))
.OrderBy(l => l.Position) .OrderBy(l => l.Position)
@@ -37,64 +29,25 @@ public static class DrawingSplitter
if (sortedLines.Count == 0) if (sortedLines.Count == 0)
return new List<Drawing> { drawing }; return new List<Drawing> { drawing };
// 3. Build clip regions (grid cells between split lines)
var regions = BuildClipRegions(sortedLines, bounds); var regions = BuildClipRegions(sortedLines, bounds);
// 4. Get the split feature strategy
var feature = GetFeature(parameters.Type); var feature = GetFeature(parameters.Type);
// 5. For each region, clip the perimeter and build a new drawing
var results = new List<Drawing>(); var results = new List<Drawing>();
var pieceIndex = 1; var pieceIndex = 1;
foreach (var region in regions) foreach (var region in regions)
{ {
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters); var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
if (pieceEntities.Count == 0) if (pieceEntities.Count == 0)
continue; continue;
// Assign cutouts fully inside this region var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
var cutoutEntities = new List<Entity>();
foreach (var cutout in profile.Cutouts)
{
if (IsCutoutInRegion(cutout, region))
cutoutEntities.AddRange(cutout.Entities);
else if (DoesCutoutCrossSplitLine(cutout, sortedLines))
{
// Cutout crosses a split line -- clip it to this region too
var clippedCutout = ClipCutoutToRegion(cutout, region);
if (clippedCutout.Count > 0)
cutoutEntities.AddRange(clippedCutout);
}
}
// Normalize origin: translate so bounding box starts at (0,0)
var allEntities = new List<Entity>(); var allEntities = new List<Entity>();
allEntities.AddRange(pieceEntities); allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities); allEntities.AddRange(cutoutEntities);
var pieceBounds = allEntities.Select(e => e.BoundingBox).ToList().GetBoundingBox(); var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex);
var offsetX = -pieceBounds.X;
var offsetY = -pieceBounds.Y;
foreach (var e in allEntities)
e.Offset(offsetX, offsetY);
// Build program (ConvertGeometry.ToProgram internally identifies perimeter vs cutouts)
var pgm = ConvertGeometry.ToProgram(allEntities);
// Create drawing with copied properties
var piece = new Drawing($"{drawing.Name}-{pieceIndex}", pgm);
piece.Color = drawing.Color;
piece.Priority = drawing.Priority;
piece.Material = drawing.Material;
piece.Constraints = drawing.Constraints;
piece.Customer = drawing.Customer;
piece.Source = drawing.Source;
piece.Quantity.Required = drawing.Quantity.Required;
results.Add(piece); results.Add(piece);
pieceIndex++; pieceIndex++;
} }
@@ -102,6 +55,52 @@ public static class DrawingSplitter
return results; return results;
} }
private static ShapeProfile BuildProfile(Drawing drawing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
return new ShapeProfile(entities);
}
private static List<Entity> CollectCutouts(List<Shape> cutouts, Box region, List<SplitLine> splitLines)
{
var entities = new List<Entity>();
foreach (var cutout in cutouts)
{
if (IsCutoutInRegion(cutout, region))
entities.AddRange(cutout.Entities);
else if (DoesCutoutCrossSplitLine(cutout, splitLines))
{
var clipped = ClipCutoutToRegion(cutout, region, splitLines);
if (clipped.Count > 0)
entities.AddRange(clipped);
}
}
return entities;
}
private static Drawing BuildPieceDrawing(Drawing source, List<Entity> entities, int pieceIndex)
{
var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
var offsetX = -pieceBounds.X;
var offsetY = -pieceBounds.Y;
foreach (var e in entities)
e.Offset(offsetX, offsetY);
var pgm = ConvertGeometry.ToProgram(entities);
var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm);
piece.Color = source.Color;
piece.Priority = source.Priority;
piece.Material = source.Material;
piece.Constraints = source.Constraints;
piece.Customer = source.Customer;
piece.Source = source.Source;
piece.Quantity.Required = source.Quantity.Required;
return piece;
}
private static void DecomposeCircles(ShapeProfile profile) private static void DecomposeCircles(ShapeProfile profile)
{ {
DecomposeCirclesInShape(profile.Perimeter); DecomposeCirclesInShape(profile.Perimeter);
@@ -155,65 +154,226 @@ public static class DrawingSplitter
} }
/// <summary> /// <summary>
/// Clip perimeter to a region using Clipper2, then recover original arcs and stitch in feature edges. /// Clip perimeter to a region by walking entities, splitting at split line crossings,
/// and stitching in feature edges. No polygon clipping library needed.
/// </summary> /// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region, private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters) List<SplitLine> splitLines, ISplitFeature feature, SplitParameters parameters)
{ {
var perimPoly = perimeter.ToPolygonWithTolerance(0.01); var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
var regionPoly = new Polygon(); foreach (var entity in perimeter.Entities)
regionPoly.Vertices.Add(new Vector(region.Left, region.Bottom)); {
regionPoly.Vertices.Add(new Vector(region.Right, region.Bottom)); ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
regionPoly.Vertices.Add(new Vector(region.Right, region.Top)); }
regionPoly.Vertices.Add(new Vector(region.Left, region.Top));
regionPoly.Close();
// Reuse existing Clipper2 helpers from NoFitPolygon if (entities.Count == 0)
var subj = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(perimPoly) };
var clip = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(regionPoly) };
var result = Clipper2Lib.Clipper.Intersect(subj, clip, Clipper2Lib.FillRule.NonZero);
if (result.Count == 0)
return new List<Entity>(); return new List<Entity>();
var clippedPoly = NoFitPolygon.FromClipperPath(result[0]); InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
var clippedEntities = new List<Entity>(); EnsurePerimeterWinding(entities);
return entities;
}
var verts = clippedPoly.Vertices; private static void ProcessEntity(Entity entity, Box region,
for (var i = 0; i < verts.Count - 1; i++) List<SplitLine> boundarySplitLines, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{ {
var start = verts[i]; // Find the first boundary split line this entity crosses
var end = verts[i + 1]; SplitLine crossedLine = null;
Vector? intersectionPt = null;
// Check if this edge lies on a split line -- replace with feature geometry foreach (var sl in boundarySplitLines)
var splitLine = FindSplitLineForEdge(start, end, splitLines);
if (splitLine != null)
{ {
var extentStart = splitLine.Axis == CutOffAxis.Vertical if (SplitLineIntersect.CrossesSplitLine(entity, sl))
? System.Math.Min(start.Y, end.Y) {
: System.Math.Min(start.X, end.X); var pt = SplitLineIntersect.FindIntersection(entity, sl);
var extentEnd = splitLine.Axis == CutOffAxis.Vertical if (pt != null)
? System.Math.Max(start.Y, end.Y) {
: System.Math.Max(start.X, end.X); crossedLine = sl;
intersectionPt = pt;
break;
}
}
}
var featureResult = feature.GenerateFeatures(splitLine, extentStart, extentEnd, parameters); if (crossedLine != null)
{
// Entity crosses a split line — split it and keep the half inside the region
var regionSide = RegionSideOf(region, crossedLine);
var startPt = GetStartPoint(entity);
var startSide = SplitLineIntersect.SideOf(startPt, crossedLine);
var startInRegion = startSide == regionSide || startSide == 0;
var regionCenter = splitLine.Axis == CutOffAxis.Vertical SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
? (region.Left + region.Right) / 2 }
: (region.Bottom + region.Top) / 2; else
var isNegativeSide = regionCenter < splitLine.Position; {
// Entity doesn't cross any boundary split line — check if it's inside the region
var mid = MidPoint(entity);
if (region.Contains(mid))
entities.Add(entity);
}
}
private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion,
SplitLine crossedLine, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{
if (entity is Line line)
{
var (first, second) = line.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
}
else if (entity is Arc arc)
{
var (first, second) = arc.SplitAt(point);
if (startInRegion)
{
if (first != null) entities.Add(first);
splitPoints.Add((point, crossedLine, true));
}
else
{
splitPoints.Add((point, crossedLine, false));
if (second != null) entities.Add(second);
}
}
}
/// <summary>
/// Returns split lines whose position matches a boundary edge of the region.
/// </summary>
private static List<SplitLine> GetBoundarySplitLines(Box region, List<SplitLine> splitLines)
{
var result = new List<SplitLine>();
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical)
{
if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
else
{
if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon
|| System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon)
result.Add(sl);
}
}
return result;
}
/// <summary>
/// Returns -1 or +1 indicating which side of the split line the region center is on.
/// </summary>
private static int RegionSideOf(Box region, SplitLine sl)
{
return SplitLineIntersect.SideOf(region.Center, sl);
}
/// <summary>
/// Returns the midpoint of an entity. For lines: average of endpoints.
/// For arcs: point at the mid-angle.
/// </summary>
private static Vector MidPoint(Entity entity)
{
if (entity is Line line)
return line.MidPoint;
if (entity is Arc arc)
{
var midAngle = (arc.StartAngle + arc.EndAngle) / 2;
return new Vector(
arc.Center.X + arc.Radius * System.Math.Cos(midAngle),
arc.Center.Y + arc.Radius * System.Math.Sin(midAngle));
}
return new Vector(0, 0);
}
/// <summary>
/// Groups split points by split line, pairs exits with entries, and generates feature edges.
/// </summary>
private static void InsertFeatureEdges(List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints,
Box region, List<SplitLine> boundarySplitLines,
ISplitFeature feature, SplitParameters parameters)
{
// Group split points by their split line
var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
foreach (var sp in splitPoints)
{
if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
}
foreach (var kvp in groups)
{
var sl = kvp.Key;
var points = kvp.Value;
// Pair each exit with the next entry
var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList();
if (exits.Count == 0 || entries.Count == 0)
continue;
// For each exit, find the matching entry to form the feature edge span
// Sort exits and entries by their position along the split line
var isVertical = sl.Axis == CutOffAxis.Vertical;
exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList();
entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList();
// Pair them up: each exit with the next entry (or vice versa)
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
{
var exitPt = exits[i];
var entryPt = entries[i];
var extentStart = isVertical
? System.Math.Min(exitPt.Y, entryPt.Y)
: System.Math.Min(exitPt.X, entryPt.X);
var extentEnd = isVertical
? System.Math.Max(exitPt.Y, entryPt.Y)
: System.Math.Max(exitPt.X, entryPt.X);
var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters);
var isNegativeSide = RegionSideOf(region, sl) < 0;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
// Ensure feature edge direction matches the polygon winding
if (featureEdge.Count > 0) if (featureEdge.Count > 0)
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
entities.AddRange(featureEdge);
}
}
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
{ {
var featureStart = GetStartPoint(featureEdge[0]); var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]); var featureEnd = GetEndPoint(featureEdge[^1]);
var edgeGoesForward = splitLine.Axis == CutOffAxis.Vertical var isVertical = axis == CutOffAxis.Vertical;
? start.Y < end.Y : start.X < end.X;
var featureGoesForward = splitLine.Axis == CutOffAxis.Vertical var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X; var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward) if (edgeGoesForward != featureGoesForward)
{ {
@@ -222,71 +382,20 @@ public static class DrawingSplitter
foreach (var e in featureEdge) foreach (var e in featureEdge)
e.Reverse(); e.Reverse();
} }
return featureEdge;
} }
clippedEntities.AddRange(featureEdge); private static void EnsurePerimeterWinding(List<Entity> entities)
}
else
{ {
// Try to recover original arc for this chord edge
var originalArc = FindMatchingArc(start, end, perimeter);
if (originalArc != null)
clippedEntities.Add(originalArc);
else
clippedEntities.Add(new Line(start, end));
}
}
// Ensure CW winding for perimeter (positive area = CCW in Polygon, so CW = negative)
var shape = new Shape(); var shape = new Shape();
shape.Entities.AddRange(clippedEntities); shape.Entities.AddRange(entities);
var poly = shape.ToPolygon(); var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CW) if (poly != null && poly.RotationDirection() != RotationType.CW)
shape.Reverse(); shape.Reverse();
return shape.Entities; entities.Clear();
} entities.AddRange(shape.Entities);
private static SplitLine FindSplitLineForEdge(Vector start, Vector end, List<SplitLine> splitLines)
{
foreach (var sl in splitLines)
{
if (sl.Axis == CutOffAxis.Vertical)
{
if (System.Math.Abs(start.X - sl.Position) < 0.1 && System.Math.Abs(end.X - sl.Position) < 0.1)
return sl;
}
else
{
if (System.Math.Abs(start.Y - sl.Position) < 0.1 && System.Math.Abs(end.Y - sl.Position) < 0.1)
return sl;
}
}
return null;
}
/// <summary>
/// Search original perimeter for an arc whose circle matches this polygon chord edge.
/// Returns a new arc segment between the chord endpoints if found.
/// </summary>
private static Arc FindMatchingArc(Vector start, Vector end, Shape perimeter)
{
foreach (var entity in perimeter.Entities)
{
if (entity is Arc arc)
{
var distStart = start.DistanceTo(arc.Center) - arc.Radius;
var distEnd = end.DistanceTo(arc.Center) - arc.Radius;
if (System.Math.Abs(distStart) < 0.1 && System.Math.Abs(distEnd) < 0.1)
{
var startAngle = OpenNest.Math.Angle.NormalizeRad(arc.Center.AngleTo(start));
var endAngle = OpenNest.Math.Angle.NormalizeRad(arc.Center.AngleTo(end));
return new Arc(arc.Center, arc.Radius, startAngle, endAngle, arc.IsReversed);
}
}
}
return null;
} }
private static bool IsCutoutInRegion(Shape cutout, Box region) private static bool IsCutoutInRegion(Shape cutout, Box region)
@@ -309,32 +418,53 @@ public static class DrawingSplitter
return false; return false;
} }
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region) /// <summary>
/// Clip a cutout shape to a region by walking entities, splitting at split line
/// intersections, keeping portions inside the region, and closing gaps with
/// straight lines. No polygon clipping library needed.
/// </summary>
private static List<Entity> ClipCutoutToRegion(Shape cutout, Box region, List<SplitLine> splitLines)
{ {
var cutoutPoly = cutout.ToPolygonWithTolerance(0.01); var boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var regionPoly = new Polygon(); var entities = new List<Entity>();
regionPoly.Vertices.Add(new Vector(region.Left, region.Bottom)); var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
regionPoly.Vertices.Add(new Vector(region.Right, region.Bottom));
regionPoly.Vertices.Add(new Vector(region.Right, region.Top));
regionPoly.Vertices.Add(new Vector(region.Left, region.Top));
regionPoly.Close();
var subj = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(cutoutPoly) }; foreach (var entity in cutout.Entities)
var clip = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(regionPoly) }; {
var result = Clipper2Lib.Clipper.Intersect(subj, clip, Clipper2Lib.FillRule.NonZero); ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
if (result.Count == 0) if (entities.Count == 0)
return new List<Entity>(); return new List<Entity>();
var clippedPoly = NoFitPolygon.FromClipperPath(result[0]); // Close gaps with straight lines (connect exit→entry pairs)
var lineEntities = new List<Entity>(); var groups = new Dictionary<SplitLine, List<(Vector Point, bool IsExit)>>();
var verts = clippedPoly.Vertices; foreach (var sp in splitPoints)
for (var i = 0; i < verts.Count - 1; i++) {
lineEntities.Add(new Line(verts[i], verts[i + 1])); if (!groups.ContainsKey(sp.Line))
groups[sp.Line] = new List<(Vector, bool)>();
groups[sp.Line].Add((sp.Point, sp.IsExit));
}
foreach (var kvp in groups)
{
var sl = kvp.Key;
var points = kvp.Value;
var isVertical = sl.Axis == CutOffAxis.Vertical;
var exits = points.Where(p => p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var entries = points.Where(p => !p.IsExit).Select(p => p.Point)
.OrderBy(p => isVertical ? p.Y : p.X).ToList();
var pairCount = System.Math.Min(exits.Count, entries.Count);
for (var i = 0; i < pairCount; i++)
entities.Add(new Line(exits[i], entries[i]));
}
// Ensure CCW winding for cutouts // Ensure CCW winding for cutouts
var shape = new Shape(); var shape = new Shape();
shape.Entities.AddRange(lineEntities); shape.Entities.AddRange(entities);
var poly = shape.ToPolygon(); var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CCW) if (poly != null && poly.RotationDirection() != RotationType.CCW)
shape.Reverse(); shape.Reverse();
@@ -347,7 +477,7 @@ public static class DrawingSplitter
return entity switch return entity switch
{ {
Line l => l.StartPoint, Line l => l.StartPoint,
Arc a => a.StartPoint(), // Arc.StartPoint() is a method Arc a => a.StartPoint(),
_ => new Vector(0, 0) _ => new Vector(0, 0)
}; };
} }
@@ -357,7 +487,7 @@ public static class DrawingSplitter
return entity switch return entity switch
{ {
Line l => l.EndPoint, Line l => l.EndPoint,
Arc a => a.EndPoint(), // Arc.EndPoint() is a method Arc a => a.EndPoint(),
_ => new Vector(0, 0) _ => new Vector(0, 0)
}; };
} }
+5 -9
View File
@@ -7,6 +7,7 @@ namespace OpenNest;
/// Generates interlocking spike/V-groove pairs along the split edge. /// Generates interlocking spike/V-groove pairs along the split edge.
/// Spikes protrude from the positive side into the negative side. /// Spikes protrude from the positive side into the negative side.
/// V-grooves on the negative side receive the spikes for self-alignment during welding. /// V-grooves on the negative side receive the spikes for self-alignment during welding.
/// The weld gap (grooveDepth - spikeDepth) is the clearance at the tip when assembled.
/// </summary> /// </summary>
public class SpikeGrooveSplit : ISplitFeature public class SpikeGrooveSplit : ISplitFeature
{ {
@@ -18,7 +19,6 @@ public class SpikeGrooveSplit : ISplitFeature
var pairCount = parameters.SpikePairCount; var pairCount = parameters.SpikePairCount;
var spikeDepth = parameters.SpikeDepth; var spikeDepth = parameters.SpikeDepth;
var grooveDepth = parameters.GrooveDepth; var grooveDepth = parameters.GrooveDepth;
var weldGap = parameters.SpikeWeldGap;
var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2); var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2);
var spikeHalfWidth = spikeDepth * System.Math.Tan(angleRad); var spikeHalfWidth = spikeDepth * System.Math.Tan(angleRad);
var grooveHalfWidth = grooveDepth * System.Math.Tan(angleRad); var grooveHalfWidth = grooveDepth * System.Math.Tan(angleRad);
@@ -44,10 +44,8 @@ public class SpikeGrooveSplit : ISplitFeature
pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1)); pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1));
} }
// Groove side: V-groove cut deeper than the spike so the spike fits inside
var negEntities = BuildGrooveSide(pairPositions, grooveHalfWidth, grooveDepth, extentStart, extentEnd, pos, isVertical); var negEntities = BuildGrooveSide(pairPositions, grooveHalfWidth, grooveDepth, extentStart, extentEnd, pos, isVertical);
// Spike side: spike protrudes but stops short of the split line by weldGap var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, extentStart, extentEnd, pos, isVertical);
var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, weldGap, extentStart, extentEnd, pos, isVertical);
return new SplitFeatureResult(negEntities, posEntities); return new SplitFeatureResult(negEntities, posEntities);
} }
@@ -79,10 +77,8 @@ public class SpikeGrooveSplit : ISplitFeature
} }
private static List<Entity> BuildSpikeSide(List<double> pairPositions, double halfWidth, double depth, private static List<Entity> BuildSpikeSide(List<double> pairPositions, double halfWidth, double depth,
double weldGap, double extentStart, double extentEnd, double pos, bool isVertical) double extentStart, double extentEnd, double pos, bool isVertical)
{ {
// Spike tip stops short of the split line by weldGap
var tipDepth = depth - weldGap;
var entities = new List<Entity>(); var entities = new List<Entity>();
var cursor = extentEnd; var cursor = extentEnd;
@@ -95,8 +91,8 @@ public class SpikeGrooveSplit : ISplitFeature
if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon) if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon)
entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical)); entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical));
entities.Add(MakeLine(pos, spikeEnd, pos - tipDepth, center, isVertical)); entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical));
entities.Add(MakeLine(pos - tipDepth, center, pos, spikeStart, isVertical)); entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical));
cursor = spikeStart; cursor = spikeStart;
} }
@@ -0,0 +1,127 @@
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Splitting;
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 sl = new SplitLine(50.0, CutOffAxis.Vertical);
sl.FeaturePositions.Add(12.5);
sl.FeaturePositions.Add(37.5);
var parameters = new SplitParameters
{
Type = SplitType.SpikeGroove,
GrooveDepth = 0.625,
SpikeDepth = 0.75,
SpikeWeldGap = 0.125,
SpikeAngle = 45,
SpikePairCount = 2
};
var results = DrawingSplitter.Split(drawing, new List<SplitLine> { sl }, parameters);
Assert.Equal(2, results.Count);
foreach (var piece in results)
{
// Get cut entities only (no rapids)
var pieceEntities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
// Check that consecutive entity endpoints connect (no gaps)
for (var i = 0; i < pieceEntities.Count - 1; i++)
{
var end = GetEndPoint(pieceEntities[i]);
var start = GetStartPoint(pieceEntities[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}");
}
// Area should be non-zero
Assert.True(piece.Area > 0, $"{piece.Name} has zero area");
}
}
[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 sl = new SplitLine(25.0, CutOffAxis.Horizontal);
sl.FeaturePositions.Add(25.0);
sl.FeaturePositions.Add(75.0);
var parameters = new SplitParameters
{
Type = SplitType.SpikeGroove,
GrooveDepth = 0.625,
SpikeDepth = 0.75,
SpikeWeldGap = 0.125,
SpikeAngle = 45,
SpikePairCount = 2
};
var results = DrawingSplitter.Split(drawing, new List<SplitLine> { sl }, parameters);
Assert.Equal(2, results.Count);
foreach (var piece in results)
{
var pieceEntities = ConvertProgram.ToGeometry(piece.Program)
.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
for (var i = 0; i < pieceEntities.Count - 1; i++)
{
var end = GetEndPoint(pieceEntities[i]);
var start = GetStartPoint(pieceEntities[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}");
}
Assert.True(piece.Area > 0, $"{piece.Name} has zero area");
}
}
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)
};
}
}
+10 -38
View File
@@ -41,8 +41,6 @@ namespace OpenNest.Forms
lblGrooveDepth = new System.Windows.Forms.Label(); lblGrooveDepth = new System.Windows.Forms.Label();
nudSpikeAngle = new System.Windows.Forms.NumericUpDown(); nudSpikeAngle = new System.Windows.Forms.NumericUpDown();
lblSpikeAngle = new System.Windows.Forms.Label(); lblSpikeAngle = new System.Windows.Forms.Label();
nudSpikeDepth = new System.Windows.Forms.NumericUpDown();
lblSpikeDepth = new System.Windows.Forms.Label();
grpTabParams = new System.Windows.Forms.GroupBox(); grpTabParams = new System.Windows.Forms.GroupBox();
nudTabCount = new System.Windows.Forms.NumericUpDown(); nudTabCount = new System.Windows.Forms.NumericUpDown();
lblTabCount = new System.Windows.Forms.Label(); lblTabCount = new System.Windows.Forms.Label();
@@ -85,7 +83,6 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).BeginInit();
((System.ComponentModel.ISupportInitialize)nudGrooveDepth).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudGrooveDepth).BeginInit();
((System.ComponentModel.ISupportInitialize)nudSpikeAngle).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudSpikeAngle).BeginInit();
((System.ComponentModel.ISupportInitialize)nudSpikeDepth).BeginInit();
grpTabParams.SuspendLayout(); grpTabParams.SuspendLayout();
((System.ComponentModel.ISupportInitialize)nudTabCount).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudTabCount).BeginInit();
((System.ComponentModel.ISupportInitialize)nudTabHeight).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudTabHeight).BeginInit();
@@ -162,12 +159,10 @@ namespace OpenNest.Forms
grpSpikeParams.Controls.Add(lblGrooveDepth); grpSpikeParams.Controls.Add(lblGrooveDepth);
grpSpikeParams.Controls.Add(nudSpikeAngle); grpSpikeParams.Controls.Add(nudSpikeAngle);
grpSpikeParams.Controls.Add(lblSpikeAngle); grpSpikeParams.Controls.Add(lblSpikeAngle);
grpSpikeParams.Controls.Add(nudSpikeDepth);
grpSpikeParams.Controls.Add(lblSpikeDepth);
grpSpikeParams.Dock = System.Windows.Forms.DockStyle.Top; grpSpikeParams.Dock = System.Windows.Forms.DockStyle.Top;
grpSpikeParams.Location = new System.Drawing.Point(6, 511); grpSpikeParams.Location = new System.Drawing.Point(6, 511);
grpSpikeParams.Name = "grpSpikeParams"; grpSpikeParams.Name = "grpSpikeParams";
grpSpikeParams.Size = new System.Drawing.Size(191, 159); grpSpikeParams.Size = new System.Drawing.Size(191, 132);
grpSpikeParams.TabIndex = 5; grpSpikeParams.TabIndex = 5;
grpSpikeParams.TabStop = false; grpSpikeParams.TabStop = false;
grpSpikeParams.Text = "Spike Parameters"; grpSpikeParams.Text = "Spike Parameters";
@@ -175,7 +170,7 @@ namespace OpenNest.Forms
// //
// nudSpikePairCount // nudSpikePairCount
// //
nudSpikePairCount.Location = new System.Drawing.Point(110, 128); nudSpikePairCount.Location = new System.Drawing.Point(110, 101);
nudSpikePairCount.Maximum = new decimal(new int[] { 50, 0, 0, 0 }); nudSpikePairCount.Maximum = new decimal(new int[] { 50, 0, 0, 0 });
nudSpikePairCount.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); nudSpikePairCount.Minimum = new decimal(new int[] { 1, 0, 0, 0 });
nudSpikePairCount.Name = "nudSpikePairCount"; nudSpikePairCount.Name = "nudSpikePairCount";
@@ -187,7 +182,7 @@ namespace OpenNest.Forms
// lblSpikePairCount // lblSpikePairCount
// //
lblSpikePairCount.AutoSize = true; lblSpikePairCount.AutoSize = true;
lblSpikePairCount.Location = new System.Drawing.Point(10, 130); lblSpikePairCount.Location = new System.Drawing.Point(10, 103);
lblSpikePairCount.Name = "lblSpikePairCount"; lblSpikePairCount.Name = "lblSpikePairCount";
lblSpikePairCount.Size = new System.Drawing.Size(66, 15); lblSpikePairCount.Size = new System.Drawing.Size(66, 15);
lblSpikePairCount.TabIndex = 5; lblSpikePairCount.TabIndex = 5;
@@ -196,7 +191,7 @@ namespace OpenNest.Forms
// nudSpikeWeldGap // nudSpikeWeldGap
// //
nudSpikeWeldGap.DecimalPlaces = 3; nudSpikeWeldGap.DecimalPlaces = 3;
nudSpikeWeldGap.Location = new System.Drawing.Point(110, 74); nudSpikeWeldGap.Location = new System.Drawing.Point(110, 47);
nudSpikeWeldGap.Maximum = new decimal(new int[] { 10, 0, 0, 0 }); nudSpikeWeldGap.Maximum = new decimal(new int[] { 10, 0, 0, 0 });
nudSpikeWeldGap.Name = "nudSpikeWeldGap"; nudSpikeWeldGap.Name = "nudSpikeWeldGap";
nudSpikeWeldGap.Size = new System.Drawing.Size(88, 23); nudSpikeWeldGap.Size = new System.Drawing.Size(88, 23);
@@ -207,7 +202,7 @@ namespace OpenNest.Forms
// lblSpikeWeldGap // lblSpikeWeldGap
// //
lblSpikeWeldGap.AutoSize = true; lblSpikeWeldGap.AutoSize = true;
lblSpikeWeldGap.Location = new System.Drawing.Point(10, 76); lblSpikeWeldGap.Location = new System.Drawing.Point(10, 49);
lblSpikeWeldGap.Name = "lblSpikeWeldGap"; lblSpikeWeldGap.Name = "lblSpikeWeldGap";
lblSpikeWeldGap.Size = new System.Drawing.Size(61, 15); lblSpikeWeldGap.Size = new System.Drawing.Size(61, 15);
lblSpikeWeldGap.TabIndex = 6; lblSpikeWeldGap.TabIndex = 6;
@@ -216,18 +211,18 @@ namespace OpenNest.Forms
// nudGrooveDepth // nudGrooveDepth
// //
nudGrooveDepth.DecimalPlaces = 3; nudGrooveDepth.DecimalPlaces = 3;
nudGrooveDepth.Location = new System.Drawing.Point(110, 47); nudGrooveDepth.Location = new System.Drawing.Point(110, 20);
nudGrooveDepth.Minimum = new decimal(new int[] { 1, 0, 0, 131072 }); nudGrooveDepth.Minimum = new decimal(new int[] { 1, 0, 0, 131072 });
nudGrooveDepth.Name = "nudGrooveDepth"; nudGrooveDepth.Name = "nudGrooveDepth";
nudGrooveDepth.Size = new System.Drawing.Size(88, 23); nudGrooveDepth.Size = new System.Drawing.Size(88, 23);
nudGrooveDepth.TabIndex = 1; nudGrooveDepth.TabIndex = 1;
nudGrooveDepth.Value = new decimal(new int[] { 125, 0, 0, 196608 }); nudGrooveDepth.Value = new decimal(new int[] { 625, 0, 0, 196608 });
nudGrooveDepth.ValueChanged += OnSpikeParamChanged; nudGrooveDepth.ValueChanged += OnSpikeParamChanged;
// //
// lblGrooveDepth // lblGrooveDepth
// //
lblGrooveDepth.AutoSize = true; lblGrooveDepth.AutoSize = true;
lblGrooveDepth.Location = new System.Drawing.Point(10, 49); lblGrooveDepth.Location = new System.Drawing.Point(10, 22);
lblGrooveDepth.Name = "lblGrooveDepth"; lblGrooveDepth.Name = "lblGrooveDepth";
lblGrooveDepth.Size = new System.Drawing.Size(83, 15); lblGrooveDepth.Size = new System.Drawing.Size(83, 15);
lblGrooveDepth.TabIndex = 7; lblGrooveDepth.TabIndex = 7;
@@ -236,7 +231,7 @@ namespace OpenNest.Forms
// nudSpikeAngle // nudSpikeAngle
// //
nudSpikeAngle.DecimalPlaces = 1; nudSpikeAngle.DecimalPlaces = 1;
nudSpikeAngle.Location = new System.Drawing.Point(110, 101); nudSpikeAngle.Location = new System.Drawing.Point(110, 74);
nudSpikeAngle.Maximum = new decimal(new int[] { 89, 0, 0, 0 }); nudSpikeAngle.Maximum = new decimal(new int[] { 89, 0, 0, 0 });
nudSpikeAngle.Minimum = new decimal(new int[] { 10, 0, 0, 0 }); nudSpikeAngle.Minimum = new decimal(new int[] { 10, 0, 0, 0 });
nudSpikeAngle.Name = "nudSpikeAngle"; nudSpikeAngle.Name = "nudSpikeAngle";
@@ -247,32 +242,12 @@ namespace OpenNest.Forms
// lblSpikeAngle // lblSpikeAngle
// //
lblSpikeAngle.AutoSize = true; lblSpikeAngle.AutoSize = true;
lblSpikeAngle.Location = new System.Drawing.Point(10, 103); lblSpikeAngle.Location = new System.Drawing.Point(10, 76);
lblSpikeAngle.Name = "lblSpikeAngle"; lblSpikeAngle.Name = "lblSpikeAngle";
lblSpikeAngle.Size = new System.Drawing.Size(72, 15); lblSpikeAngle.Size = new System.Drawing.Size(72, 15);
lblSpikeAngle.TabIndex = 8; lblSpikeAngle.TabIndex = 8;
lblSpikeAngle.Text = "Spike Angle:"; lblSpikeAngle.Text = "Spike Angle:";
// //
// nudSpikeDepth
//
nudSpikeDepth.DecimalPlaces = 2;
nudSpikeDepth.Enabled = false;
nudSpikeDepth.Location = new System.Drawing.Point(110, 20);
nudSpikeDepth.Minimum = new decimal(new int[] { 1, 0, 0, 131072 });
nudSpikeDepth.Name = "nudSpikeDepth";
nudSpikeDepth.Size = new System.Drawing.Size(88, 23);
nudSpikeDepth.TabIndex = 0;
nudSpikeDepth.Value = new decimal(new int[] { 25, 0, 0, 131072 });
//
// lblSpikeDepth
//
lblSpikeDepth.AutoSize = true;
lblSpikeDepth.Location = new System.Drawing.Point(10, 22);
lblSpikeDepth.Name = "lblSpikeDepth";
lblSpikeDepth.Size = new System.Drawing.Size(73, 15);
lblSpikeDepth.TabIndex = 9;
lblSpikeDepth.Text = "Spike Depth:";
//
// grpTabParams // grpTabParams
// //
grpTabParams.Controls.Add(nudTabCount); grpTabParams.Controls.Add(nudTabCount);
@@ -674,7 +649,6 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).EndInit(); ((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).EndInit();
((System.ComponentModel.ISupportInitialize)nudGrooveDepth).EndInit(); ((System.ComponentModel.ISupportInitialize)nudGrooveDepth).EndInit();
((System.ComponentModel.ISupportInitialize)nudSpikeAngle).EndInit(); ((System.ComponentModel.ISupportInitialize)nudSpikeAngle).EndInit();
((System.ComponentModel.ISupportInitialize)nudSpikeDepth).EndInit();
grpTabParams.ResumeLayout(false); grpTabParams.ResumeLayout(false);
grpTabParams.PerformLayout(); grpTabParams.PerformLayout();
((System.ComponentModel.ISupportInitialize)nudTabCount).EndInit(); ((System.ComponentModel.ISupportInitialize)nudTabCount).EndInit();
@@ -747,8 +721,6 @@ namespace OpenNest.Forms
private System.Windows.Forms.NumericUpDown nudTabCount; private System.Windows.Forms.NumericUpDown nudTabCount;
private System.Windows.Forms.GroupBox grpSpikeParams; private System.Windows.Forms.GroupBox grpSpikeParams;
private System.Windows.Forms.Label lblSpikeDepth;
private System.Windows.Forms.NumericUpDown nudSpikeDepth;
private System.Windows.Forms.Label lblSpikeAngle; private System.Windows.Forms.Label lblSpikeAngle;
private System.Windows.Forms.NumericUpDown nudSpikeAngle; private System.Windows.Forms.NumericUpDown nudSpikeAngle;
private System.Windows.Forms.Label lblSpikePairCount; private System.Windows.Forms.Label lblSpikePairCount;
+94 -18
View File
@@ -141,16 +141,8 @@ public partial class SplitDrawingForm : Form
pnlPreview.Invalidate(); pnlPreview.Invalidate();
} }
private void UpdateSpikeDepth()
{
var grooveDepth = (double)nudGrooveDepth.Value;
var weldGap = (double)nudSpikeWeldGap.Value;
nudSpikeDepth.Value = (decimal)(grooveDepth + weldGap);
}
private void OnSpikeParamChanged(object sender, EventArgs e) private void OnSpikeParamChanged(object sender, EventArgs e)
{ {
UpdateSpikeDepth();
if (radFitToPlate.Checked) if (radFitToPlate.Checked)
RecalculateAutoSplitLines(); RecalculateAutoSplitLines();
pnlPreview.Invalidate(); pnlPreview.Invalidate();
@@ -175,9 +167,9 @@ public partial class SplitDrawingForm : Form
else if (radSpike.Checked) else if (radSpike.Checked)
{ {
p.Type = SplitType.SpikeGroove; p.Type = SplitType.SpikeGroove;
p.SpikeDepth = (double)nudSpikeDepth.Value; p.GrooveDepth = (double)nudGrooveDepth.Value;
p.GrooveDepth = p.SpikeDepth + (double)nudGrooveDepth.Value;
p.SpikeWeldGap = (double)nudSpikeWeldGap.Value; p.SpikeWeldGap = (double)nudSpikeWeldGap.Value;
p.SpikeDepth = p.GrooveDepth + p.SpikeWeldGap;
p.SpikeAngle = (double)nudSpikeAngle.Value; p.SpikeAngle = (double)nudSpikeAngle.Value;
p.SpikePairCount = (int)nudSpikePairCount.Value; p.SpikePairCount = (int)nudSpikePairCount.Value;
} }
@@ -389,24 +381,73 @@ public partial class SplitDrawingForm : Form
System.Math.Abs(br.X - tl.X), System.Math.Abs(br.Y - tl.Y)); System.Math.Abs(br.X - tl.X), System.Math.Abs(br.Y - tl.Y));
} }
// Split lines // Split lines — trimmed at feature positions with feature contours
var parameters = GetCurrentParameters();
var feature = GetSplitFeature(parameters.Type);
using var splitPen = new Pen(Color.FromArgb(255, 82, 82)); using var splitPen = new Pen(Color.FromArgb(255, 82, 82));
splitPen.DashStyle = DashStyle.Dash; splitPen.DashStyle = DashStyle.Dash;
using var featurePen = new Pen(Color.FromArgb(200, 255, 82, 82), 1.5f);
foreach (var sl in _splitLines) foreach (var sl in _splitLines)
{ {
PointF p1, p2; GetExtent(sl, out var extStart, out var extEnd);
if (sl.Axis == CutOffAxis.Vertical) var isVert = sl.Axis == CutOffAxis.Vertical;
var margin = 10.0;
if (sl.FeaturePositions.Count == 0 || radStraight.Checked)
{ {
p1 = pnlPreview.PointWorldToGraph(sl.Position, _drawingBounds.Bottom - 10); // No features — draw one continuous line
p2 = pnlPreview.PointWorldToGraph(sl.Position, _drawingBounds.Top + 10); var p1 = isVert
? pnlPreview.PointWorldToGraph(sl.Position, extStart - margin)
: pnlPreview.PointWorldToGraph(extStart - margin, sl.Position);
var p2 = isVert
? pnlPreview.PointWorldToGraph(sl.Position, extEnd + margin)
: pnlPreview.PointWorldToGraph(extEnd + margin, sl.Position);
g.DrawLine(splitPen, p1, p2);
} }
else else
{ {
p1 = pnlPreview.PointWorldToGraph(_drawingBounds.Left - 10, sl.Position); // Generate feature geometry and draw contours
p2 = pnlPreview.PointWorldToGraph(_drawingBounds.Right + 10, sl.Position); var featureResult = feature.GenerateFeatures(sl, extStart, extEnd, parameters);
} DrawFeatureEdge(g, featurePen, featureResult.NegativeSideEdge, isVert);
DrawFeatureEdge(g, featurePen, featureResult.PositiveSideEdge, isVert);
// Draw split line in segments between features
var halfExt = GetFeatureHalfExtent(parameters);
var sorted = new List<double>(sl.FeaturePositions);
sorted.Sort();
var cursor = extStart - margin;
foreach (var fc in sorted)
{
var gapStart = fc - halfExt;
if (gapStart > cursor)
{
var p1 = isVert
? pnlPreview.PointWorldToGraph(sl.Position, cursor)
: pnlPreview.PointWorldToGraph(cursor, sl.Position);
var p2 = isVert
? pnlPreview.PointWorldToGraph(sl.Position, gapStart)
: pnlPreview.PointWorldToGraph(gapStart, sl.Position);
g.DrawLine(splitPen, p1, p2); g.DrawLine(splitPen, p1, p2);
} }
cursor = fc + halfExt;
}
// Final segment after last feature
var end = extEnd + margin;
if (end > cursor)
{
var p1 = isVert
? pnlPreview.PointWorldToGraph(sl.Position, cursor)
: pnlPreview.PointWorldToGraph(cursor, sl.Position);
var p2 = isVert
? pnlPreview.PointWorldToGraph(sl.Position, end)
: pnlPreview.PointWorldToGraph(end, sl.Position);
g.DrawLine(splitPen, p1, p2);
}
}
}
// Feature position handles // Feature position handles
if (!radStraight.Checked) if (!radStraight.Checked)
@@ -509,6 +550,41 @@ public partial class SplitDrawingForm : Form
lblStatus.Text = $"Part: {_drawingBounds.Width:F2} x {_drawingBounds.Length:F2} | {_splitLines.Count} split lines | {pieceCount} pieces"; lblStatus.Text = $"Part: {_drawingBounds.Width:F2} x {_drawingBounds.Length:F2} | {_splitLines.Count} split lines | {pieceCount} pieces";
} }
// --- Feature rendering helpers ---
private static ISplitFeature GetSplitFeature(SplitType type)
{
return type switch
{
SplitType.WeldGapTabs => new WeldGapTabSplit(),
SplitType.SpikeGroove => new SpikeGrooveSplit(),
_ => new StraightSplit()
};
}
private static double GetFeatureHalfExtent(SplitParameters p)
{
return p.Type switch
{
SplitType.WeldGapTabs => p.TabWidth / 2,
SplitType.SpikeGroove => p.GrooveDepth * System.Math.Tan(OpenNest.Math.Angle.ToRadians(p.SpikeAngle / 2)),
_ => 0
};
}
private void DrawFeatureEdge(Graphics g, Pen pen, List<Geometry.Entity> entities, bool isVertical)
{
foreach (var entity in entities)
{
if (entity is Geometry.Line line)
{
var p1 = pnlPreview.PointWorldToGraph(line.StartPoint.X, line.StartPoint.Y);
var p2 = pnlPreview.PointWorldToGraph(line.EndPoint.X, line.EndPoint.Y);
g.DrawLine(pen, p1, p2);
}
}
}
// --- SplitPreview control --- // --- SplitPreview control ---
private class SplitPreview : EntityView private class SplitPreview : EntityView