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

View File

@@ -15,20 +15,12 @@ public static class DrawingSplitter
if (splitLines.Count == 0)
return new List<Drawing> { drawing };
// 1. Convert program to geometry -> ShapeProfile separates perimeter from cutouts
// 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()
var profile = BuildProfile(drawing);
DecomposeCircles(profile);
var perimeter = profile.Perimeter;
var bounds = perimeter.BoundingBox;
// 2. Sort split lines by position, discard any outside the part
var sortedLines = splitLines
.Where(l => IsLineInsideBounds(l, bounds))
.OrderBy(l => l.Position)
@@ -37,64 +29,25 @@ public static class DrawingSplitter
if (sortedLines.Count == 0)
return new List<Drawing> { drawing };
// 3. Build clip regions (grid cells between split lines)
var regions = BuildClipRegions(sortedLines, bounds);
// 4. Get the split feature strategy
var feature = GetFeature(parameters.Type);
// 5. For each region, clip the perimeter and build a new drawing
var results = new List<Drawing>();
var pieceIndex = 1;
foreach (var region in regions)
{
var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
if (pieceEntities.Count == 0)
continue;
// Assign cutouts fully inside this region
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);
}
}
var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines);
// Normalize origin: translate so bounding box starts at (0,0)
var allEntities = new List<Entity>();
allEntities.AddRange(pieceEntities);
allEntities.AddRange(cutoutEntities);
var pieceBounds = allEntities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
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;
var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex);
results.Add(piece);
pieceIndex++;
}
@@ -102,6 +55,52 @@ public static class DrawingSplitter
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)
{
DecomposeCirclesInShape(profile.Perimeter);
@@ -155,138 +154,248 @@ public static class DrawingSplitter
}
/// <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>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
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();
regionPoly.Vertices.Add(new Vector(region.Left, region.Bottom));
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();
foreach (var entity in perimeter.Entities)
{
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
// Reuse existing Clipper2 helpers from NoFitPolygon
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)
if (entities.Count == 0)
return new List<Entity>();
var clippedPoly = NoFitPolygon.FromClipperPath(result[0]);
var clippedEntities = new List<Entity>();
InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters);
EnsurePerimeterWinding(entities);
return entities;
}
var verts = clippedPoly.Vertices;
for (var i = 0; i < verts.Count - 1; i++)
private static void ProcessEntity(Entity entity, Box region,
List<SplitLine> boundarySplitLines, List<Entity> entities,
List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints)
{
// Find the first boundary split line this entity crosses
SplitLine crossedLine = null;
Vector? intersectionPt = null;
foreach (var sl in boundarySplitLines)
{
var start = verts[i];
var end = verts[i + 1];
// Check if this edge lies on a split line -- replace with feature geometry
var splitLine = FindSplitLineForEdge(start, end, splitLines);
if (splitLine != null)
if (SplitLineIntersect.CrossesSplitLine(entity, sl))
{
var extentStart = splitLine.Axis == CutOffAxis.Vertical
? System.Math.Min(start.Y, end.Y)
: System.Math.Min(start.X, end.X);
var extentEnd = splitLine.Axis == CutOffAxis.Vertical
? System.Math.Max(start.Y, end.Y)
: System.Math.Max(start.X, end.X);
var featureResult = feature.GenerateFeatures(splitLine, extentStart, extentEnd, parameters);
var regionCenter = splitLine.Axis == CutOffAxis.Vertical
? (region.Left + region.Right) / 2
: (region.Bottom + region.Top) / 2;
var isNegativeSide = regionCenter < splitLine.Position;
var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
// Ensure feature edge direction matches the polygon winding
if (featureEdge.Count > 0)
var pt = SplitLineIntersect.FindIntersection(entity, sl);
if (pt != null)
{
var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]);
var edgeGoesForward = splitLine.Axis == CutOffAxis.Vertical
? start.Y < end.Y : start.X < end.X;
var featureGoesForward = splitLine.Axis == CutOffAxis.Vertical
? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward)
{
featureEdge = new List<Entity>(featureEdge);
featureEdge.Reverse();
foreach (var e in featureEdge)
e.Reverse();
}
crossedLine = sl;
intersectionPt = pt;
break;
}
clippedEntities.AddRange(featureEdge);
}
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();
shape.Entities.AddRange(clippedEntities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CW)
shape.Reverse();
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;
return shape.Entities;
SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints);
}
else
{
// 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 SplitLine FindSplitLineForEdge(Vector start, Vector end, List<SplitLine> splitLines)
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(start.X - sl.Position) < 0.1 && System.Math.Abs(end.X - sl.Position) < 0.1)
return sl;
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(start.Y - sl.Position) < 0.1 && System.Math.Abs(end.Y - sl.Position) < 0.1)
return sl;
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 null;
return result;
}
/// <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.
/// Returns -1 or +1 indicating which side of the split line the region center is on.
/// </summary>
private static Arc FindMatchingArc(Vector start, Vector end, Shape perimeter)
private static int RegionSideOf(Box region, SplitLine sl)
{
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;
return SplitLineIntersect.SideOf(region.Center, sl);
}
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);
}
/// <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;
if (featureEdge.Count > 0)
featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis);
entities.AddRange(featureEdge);
}
}
return null;
}
private static List<Entity> AlignFeatureDirection(List<Entity> featureEdge, Vector start, Vector end, CutOffAxis axis)
{
var featureStart = GetStartPoint(featureEdge[0]);
var featureEnd = GetEndPoint(featureEdge[^1]);
var isVertical = axis == CutOffAxis.Vertical;
var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X;
var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
if (edgeGoesForward != featureGoesForward)
{
featureEdge = new List<Entity>(featureEdge);
featureEdge.Reverse();
foreach (var e in featureEdge)
e.Reverse();
}
return featureEdge;
}
private static void EnsurePerimeterWinding(List<Entity> entities)
{
var shape = new Shape();
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CW)
shape.Reverse();
entities.Clear();
entities.AddRange(shape.Entities);
}
private static bool IsCutoutInRegion(Shape cutout, Box region)
@@ -309,32 +418,53 @@ public static class DrawingSplitter
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 regionPoly = new Polygon();
regionPoly.Vertices.Add(new Vector(region.Left, region.Bottom));
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 boundarySplitLines = GetBoundarySplitLines(region, splitLines);
var entities = new List<Entity>();
var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>();
var subj = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(cutoutPoly) };
var clip = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(regionPoly) };
var result = Clipper2Lib.Clipper.Intersect(subj, clip, Clipper2Lib.FillRule.NonZero);
foreach (var entity in cutout.Entities)
{
ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints);
}
if (result.Count == 0)
if (entities.Count == 0)
return new List<Entity>();
var clippedPoly = NoFitPolygon.FromClipperPath(result[0]);
var lineEntities = new List<Entity>();
var verts = clippedPoly.Vertices;
for (var i = 0; i < verts.Count - 1; i++)
lineEntities.Add(new Line(verts[i], verts[i + 1]));
// Close gaps with straight lines (connect exit→entry pairs)
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;
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
var shape = new Shape();
shape.Entities.AddRange(lineEntities);
shape.Entities.AddRange(entities);
var poly = shape.ToPolygon();
if (poly != null && poly.RotationDirection() != RotationType.CCW)
shape.Reverse();
@@ -347,7 +477,7 @@ public static class DrawingSplitter
return entity switch
{
Line l => l.StartPoint,
Arc a => a.StartPoint(), // Arc.StartPoint() is a method
Arc a => a.StartPoint(),
_ => new Vector(0, 0)
};
}
@@ -357,7 +487,7 @@ public static class DrawingSplitter
return entity switch
{
Line l => l.EndPoint,
Arc a => a.EndPoint(), // Arc.EndPoint() is a method
Arc a => a.EndPoint(),
_ => new Vector(0, 0)
};
}

View File

@@ -7,6 +7,7 @@ namespace OpenNest;
/// Generates interlocking spike/V-groove pairs along the split edge.
/// Spikes protrude from the positive side into the negative side.
/// 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>
public class SpikeGrooveSplit : ISplitFeature
{
@@ -18,7 +19,6 @@ public class SpikeGrooveSplit : ISplitFeature
var pairCount = parameters.SpikePairCount;
var spikeDepth = parameters.SpikeDepth;
var grooveDepth = parameters.GrooveDepth;
var weldGap = parameters.SpikeWeldGap;
var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2);
var spikeHalfWidth = spikeDepth * 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));
}
// Groove side: V-groove cut deeper than the spike so the spike fits inside
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, weldGap, extentStart, extentEnd, pos, isVertical);
var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, extentStart, extentEnd, pos, isVertical);
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,
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 cursor = extentEnd;
@@ -95,8 +91,8 @@ public class SpikeGrooveSplit : ISplitFeature
if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon)
entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical));
entities.Add(MakeLine(pos, spikeEnd, pos - tipDepth, center, isVertical));
entities.Add(MakeLine(pos - tipDepth, center, pos, spikeStart, isVertical));
entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical));
entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical));
cursor = spikeStart;
}

View File

@@ -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)
};
}
}

View File

@@ -41,8 +41,6 @@ namespace OpenNest.Forms
lblGrooveDepth = new System.Windows.Forms.Label();
nudSpikeAngle = new System.Windows.Forms.NumericUpDown();
lblSpikeAngle = new System.Windows.Forms.Label();
nudSpikeDepth = new System.Windows.Forms.NumericUpDown();
lblSpikeDepth = new System.Windows.Forms.Label();
grpTabParams = new System.Windows.Forms.GroupBox();
nudTabCount = new System.Windows.Forms.NumericUpDown();
lblTabCount = new System.Windows.Forms.Label();
@@ -85,7 +83,6 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).BeginInit();
((System.ComponentModel.ISupportInitialize)nudGrooveDepth).BeginInit();
((System.ComponentModel.ISupportInitialize)nudSpikeAngle).BeginInit();
((System.ComponentModel.ISupportInitialize)nudSpikeDepth).BeginInit();
grpTabParams.SuspendLayout();
((System.ComponentModel.ISupportInitialize)nudTabCount).BeginInit();
((System.ComponentModel.ISupportInitialize)nudTabHeight).BeginInit();
@@ -162,12 +159,10 @@ namespace OpenNest.Forms
grpSpikeParams.Controls.Add(lblGrooveDepth);
grpSpikeParams.Controls.Add(nudSpikeAngle);
grpSpikeParams.Controls.Add(lblSpikeAngle);
grpSpikeParams.Controls.Add(nudSpikeDepth);
grpSpikeParams.Controls.Add(lblSpikeDepth);
grpSpikeParams.Dock = System.Windows.Forms.DockStyle.Top;
grpSpikeParams.Location = new System.Drawing.Point(6, 511);
grpSpikeParams.Name = "grpSpikeParams";
grpSpikeParams.Size = new System.Drawing.Size(191, 159);
grpSpikeParams.Size = new System.Drawing.Size(191, 132);
grpSpikeParams.TabIndex = 5;
grpSpikeParams.TabStop = false;
grpSpikeParams.Text = "Spike Parameters";
@@ -175,7 +170,7 @@ namespace OpenNest.Forms
//
// 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.Minimum = new decimal(new int[] { 1, 0, 0, 0 });
nudSpikePairCount.Name = "nudSpikePairCount";
@@ -187,7 +182,7 @@ namespace OpenNest.Forms
// lblSpikePairCount
//
lblSpikePairCount.AutoSize = true;
lblSpikePairCount.Location = new System.Drawing.Point(10, 130);
lblSpikePairCount.Location = new System.Drawing.Point(10, 103);
lblSpikePairCount.Name = "lblSpikePairCount";
lblSpikePairCount.Size = new System.Drawing.Size(66, 15);
lblSpikePairCount.TabIndex = 5;
@@ -196,7 +191,7 @@ namespace OpenNest.Forms
// nudSpikeWeldGap
//
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.Name = "nudSpikeWeldGap";
nudSpikeWeldGap.Size = new System.Drawing.Size(88, 23);
@@ -207,7 +202,7 @@ namespace OpenNest.Forms
// lblSpikeWeldGap
//
lblSpikeWeldGap.AutoSize = true;
lblSpikeWeldGap.Location = new System.Drawing.Point(10, 76);
lblSpikeWeldGap.Location = new System.Drawing.Point(10, 49);
lblSpikeWeldGap.Name = "lblSpikeWeldGap";
lblSpikeWeldGap.Size = new System.Drawing.Size(61, 15);
lblSpikeWeldGap.TabIndex = 6;
@@ -216,18 +211,18 @@ namespace OpenNest.Forms
// nudGrooveDepth
//
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.Name = "nudGrooveDepth";
nudGrooveDepth.Size = new System.Drawing.Size(88, 23);
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;
//
// lblGrooveDepth
//
lblGrooveDepth.AutoSize = true;
lblGrooveDepth.Location = new System.Drawing.Point(10, 49);
lblGrooveDepth.Location = new System.Drawing.Point(10, 22);
lblGrooveDepth.Name = "lblGrooveDepth";
lblGrooveDepth.Size = new System.Drawing.Size(83, 15);
lblGrooveDepth.TabIndex = 7;
@@ -236,7 +231,7 @@ namespace OpenNest.Forms
// nudSpikeAngle
//
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.Minimum = new decimal(new int[] { 10, 0, 0, 0 });
nudSpikeAngle.Name = "nudSpikeAngle";
@@ -247,32 +242,12 @@ namespace OpenNest.Forms
// lblSpikeAngle
//
lblSpikeAngle.AutoSize = true;
lblSpikeAngle.Location = new System.Drawing.Point(10, 103);
lblSpikeAngle.Location = new System.Drawing.Point(10, 76);
lblSpikeAngle.Name = "lblSpikeAngle";
lblSpikeAngle.Size = new System.Drawing.Size(72, 15);
lblSpikeAngle.TabIndex = 8;
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.Controls.Add(nudTabCount);
@@ -674,7 +649,6 @@ namespace OpenNest.Forms
((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).EndInit();
((System.ComponentModel.ISupportInitialize)nudGrooveDepth).EndInit();
((System.ComponentModel.ISupportInitialize)nudSpikeAngle).EndInit();
((System.ComponentModel.ISupportInitialize)nudSpikeDepth).EndInit();
grpTabParams.ResumeLayout(false);
grpTabParams.PerformLayout();
((System.ComponentModel.ISupportInitialize)nudTabCount).EndInit();
@@ -747,8 +721,6 @@ namespace OpenNest.Forms
private System.Windows.Forms.NumericUpDown nudTabCount;
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.NumericUpDown nudSpikeAngle;
private System.Windows.Forms.Label lblSpikePairCount;

View File

@@ -141,16 +141,8 @@ public partial class SplitDrawingForm : Form
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)
{
UpdateSpikeDepth();
if (radFitToPlate.Checked)
RecalculateAutoSplitLines();
pnlPreview.Invalidate();
@@ -175,9 +167,9 @@ public partial class SplitDrawingForm : Form
else if (radSpike.Checked)
{
p.Type = SplitType.SpikeGroove;
p.SpikeDepth = (double)nudSpikeDepth.Value;
p.GrooveDepth = p.SpikeDepth + (double)nudGrooveDepth.Value;
p.GrooveDepth = (double)nudGrooveDepth.Value;
p.SpikeWeldGap = (double)nudSpikeWeldGap.Value;
p.SpikeDepth = p.GrooveDepth + p.SpikeWeldGap;
p.SpikeAngle = (double)nudSpikeAngle.Value;
p.SpikePairCount = (int)nudSpikePairCount.Value;
}
@@ -389,23 +381,72 @@ public partial class SplitDrawingForm : Form
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));
splitPen.DashStyle = DashStyle.Dash;
using var featurePen = new Pen(Color.FromArgb(200, 255, 82, 82), 1.5f);
foreach (var sl in _splitLines)
{
PointF p1, p2;
if (sl.Axis == CutOffAxis.Vertical)
GetExtent(sl, out var extStart, out var extEnd);
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);
p2 = pnlPreview.PointWorldToGraph(sl.Position, _drawingBounds.Top + 10);
// No features — draw one continuous line
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
{
p1 = pnlPreview.PointWorldToGraph(_drawingBounds.Left - 10, sl.Position);
p2 = pnlPreview.PointWorldToGraph(_drawingBounds.Right + 10, sl.Position);
// Generate feature geometry and draw contours
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);
}
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);
}
}
g.DrawLine(splitPen, p1, p2);
}
// Feature position handles
@@ -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";
}
// --- 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 ---
private class SplitPreview : EntityView