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