diff --git a/OpenNest.Core/Splitting/SpikeGrooveSplit.cs b/OpenNest.Core/Splitting/SpikeGrooveSplit.cs
new file mode 100644
index 0000000..fa91f63
--- /dev/null
+++ b/OpenNest.Core/Splitting/SpikeGrooveSplit.cs
@@ -0,0 +1,105 @@
+using System.Collections.Generic;
+using OpenNest.Geometry;
+
+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.
+///
+public class SpikeGrooveSplit : ISplitFeature
+{
+ public string Name => "Spike / V-Groove";
+
+ public SplitFeatureResult GenerateFeatures(SplitLine line, double extentStart, double extentEnd, SplitParameters parameters)
+ {
+ var extent = extentEnd - extentStart;
+ var pairCount = parameters.SpikePairCount;
+ var depth = parameters.SpikeDepth;
+ var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2);
+ var halfWidth = depth * System.Math.Tan(angleRad);
+
+ var isVertical = line.Axis == CutOffAxis.Vertical;
+ var pos = line.Position;
+
+ // Place pairs evenly: one near each end, with margin
+ var margin = extent * 0.15;
+ var pairPositions = new List();
+ if (pairCount == 1)
+ {
+ pairPositions.Add(extentStart + extent / 2);
+ }
+ else
+ {
+ var usable = extent - 2 * margin;
+ for (var i = 0; i < pairCount; i++)
+ pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1));
+ }
+
+ var negEntities = BuildGrooveSide(pairPositions, halfWidth, depth, extentStart, extentEnd, pos, isVertical);
+ var posEntities = BuildSpikeSide(pairPositions, halfWidth, depth, extentStart, extentEnd, pos, isVertical);
+
+ return new SplitFeatureResult(negEntities, posEntities);
+ }
+
+ private static List BuildGrooveSide(List pairPositions, double halfWidth, double depth,
+ double extentStart, double extentEnd, double pos, bool isVertical)
+ {
+ var entities = new List();
+ var cursor = extentStart;
+
+ foreach (var center in pairPositions)
+ {
+ var grooveStart = center - halfWidth;
+ var grooveEnd = center + halfWidth;
+
+ if (grooveStart > cursor + OpenNest.Math.Tolerance.Epsilon)
+ entities.Add(MakeLine(pos, cursor, pos, grooveStart, isVertical));
+
+ entities.Add(MakeLine(pos, grooveStart, pos - depth, center, isVertical));
+ entities.Add(MakeLine(pos - depth, center, pos, grooveEnd, isVertical));
+
+ cursor = grooveEnd;
+ }
+
+ if (extentEnd > cursor + OpenNest.Math.Tolerance.Epsilon)
+ entities.Add(MakeLine(pos, cursor, pos, extentEnd, isVertical));
+
+ return entities;
+ }
+
+ private static List BuildSpikeSide(List pairPositions, double halfWidth, double depth,
+ double extentStart, double extentEnd, double pos, bool isVertical)
+ {
+ var entities = new List();
+ var cursor = extentEnd;
+
+ for (var i = pairPositions.Count - 1; i >= 0; i--)
+ {
+ var center = pairPositions[i];
+ var spikeEnd = center + halfWidth;
+ var spikeStart = center - halfWidth;
+
+ if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon)
+ entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical));
+
+ entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical));
+ entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical));
+
+ cursor = spikeStart;
+ }
+
+ if (cursor > extentStart + OpenNest.Math.Tolerance.Epsilon)
+ entities.Add(MakeLine(pos, cursor, pos, extentStart, isVertical));
+
+ return entities;
+ }
+
+ private static Line MakeLine(double splitAxis1, double along1, double splitAxis2, double along2, bool isVertical)
+ {
+ return isVertical
+ ? new Line(new Vector(splitAxis1, along1), new Vector(splitAxis2, along2))
+ : new Line(new Vector(along1, splitAxis1), new Vector(along2, splitAxis2));
+ }
+}
diff --git a/OpenNest.Tests/Splitting/SplitFeatureTests.cs b/OpenNest.Tests/Splitting/SplitFeatureTests.cs
index eef2c5c..9fcd8f0 100644
--- a/OpenNest.Tests/Splitting/SplitFeatureTests.cs
+++ b/OpenNest.Tests/Splitting/SplitFeatureTests.cs
@@ -93,4 +93,44 @@ public class SplitFeatureTests
{
Assert.Equal("Straight", new StraightSplit().Name);
}
+
+ [Fact]
+ public void SpikeGrooveSplit_Vertical_TwoPairs_SpikesOnPositiveSide()
+ {
+ var feature = new SpikeGrooveSplit();
+ var line = new SplitLine(50.0, CutOffAxis.Vertical);
+ var parameters = new SplitParameters
+ {
+ Type = SplitType.SpikeGroove,
+ SpikeDepth = 1.0,
+ SpikeAngle = 60.0,
+ SpikePairCount = 2
+ };
+
+ var result = feature.GenerateFeatures(line, 0.0, 100.0, parameters);
+
+ // Both sides should have multiple entities (straight segments + spike/groove geometry)
+ Assert.True(result.NegativeSideEdge.Count > 1, "Negative side should have groove geometry");
+ Assert.True(result.PositiveSideEdge.Count > 1, "Positive side should have spike geometry");
+
+ // All entities should be lines
+ Assert.All(result.NegativeSideEdge, e => Assert.IsType(e));
+ Assert.All(result.PositiveSideEdge, e => Assert.IsType(e));
+
+ // Spikes protrude in negative-X direction (into the negative side's territory)
+ var posLines = result.PositiveSideEdge.Cast().ToList();
+ var minX = posLines.Min(l => System.Math.Min(l.StartPoint.X, l.EndPoint.X));
+ Assert.True(minX < 50.0, "Spikes should protrude past the split line");
+
+ // Grooves indent in the positive-X direction (into positive side's territory)
+ var negLines = result.NegativeSideEdge.Cast().ToList();
+ var maxX = negLines.Max(l => System.Math.Max(l.StartPoint.X, l.EndPoint.X));
+ Assert.True(maxX <= 50.0, "Grooves should not protrude past the split line");
+ }
+
+ [Fact]
+ public void SpikeGrooveSplit_Name()
+ {
+ Assert.Equal("Spike / V-Groove", new SpikeGrooveSplit().Name);
+ }
}