From df18b72881a0521ba555f462834c61484ae3bbd3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 24 Mar 2026 18:04:21 -0400 Subject: [PATCH] feat: add SplitLineIntersect helper for entity-splitline intersection Add ToLine() to SplitLine and create SplitLineIntersect static class with FindIntersection, CrossesSplitLine, and SideOf methods for testing entity intersections against split lines. These helpers support the upcoming Clipper2-free DrawingSplitter rewrite. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Core/Splitting/SplitLine.cs | 13 ++ OpenNest.Core/Splitting/SplitLineIntersect.cs | 81 +++++++ OpenNest.Tests/Splitting/EntitySplitTests.cs | 206 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 OpenNest.Core/Splitting/SplitLineIntersect.cs create mode 100644 OpenNest.Tests/Splitting/EntitySplitTests.cs diff --git a/OpenNest.Core/Splitting/SplitLine.cs b/OpenNest.Core/Splitting/SplitLine.cs index a4e55c3..54435aa 100644 --- a/OpenNest.Core/Splitting/SplitLine.cs +++ b/OpenNest.Core/Splitting/SplitLine.cs @@ -1,3 +1,4 @@ +using OpenNest.Geometry; using System.Collections.Generic; namespace OpenNest; @@ -23,4 +24,16 @@ public class SplitLine Position = position; Axis = axis; } + + /// + /// Returns a Line entity at the split position spanning the given extent range. + /// For Vertical: line from (Position, extentStart) to (Position, extentEnd). + /// For Horizontal: line from (extentStart, Position) to (extentEnd, Position). + /// + public Line ToLine(double extentStart, double extentEnd) + { + return Axis == CutOffAxis.Vertical + ? new Line(Position, extentStart, Position, extentEnd) + : new Line(extentStart, Position, extentEnd, Position); + } } diff --git a/OpenNest.Core/Splitting/SplitLineIntersect.cs b/OpenNest.Core/Splitting/SplitLineIntersect.cs new file mode 100644 index 0000000..222febd --- /dev/null +++ b/OpenNest.Core/Splitting/SplitLineIntersect.cs @@ -0,0 +1,81 @@ +using OpenNest.Geometry; +using OpenNest.Math; +using System.Collections.Generic; + +namespace OpenNest; + +/// +/// Static helpers for testing entity-splitline intersections. +/// +public static class SplitLineIntersect +{ + /// + /// Finds the intersection point between an entity and a split line. + /// Returns null if no intersection or the entity doesn't straddle the split line. + /// + public static Vector? FindIntersection(Entity entity, SplitLine sl) + { + if (!CrossesSplitLine(entity, sl)) + return null; + + var bbox = entity.BoundingBox; + var margin = 1.0; + + // Create a line at the split position spanning the entity's bbox extent (with margin) + Line splitLine; + + if (sl.Axis == CutOffAxis.Vertical) + splitLine = sl.ToLine(bbox.Bottom - margin, bbox.Top + margin); + else + splitLine = sl.ToLine(bbox.Left - margin, bbox.Right + margin); + + switch (entity.Type) + { + case EntityType.Line: + var line = (Line)entity; + if (Intersect.Intersects(line, splitLine, out var pt)) + return pt; + return null; + + case EntityType.Arc: + var arc = (Arc)entity; + if (Intersect.Intersects(arc, splitLine, out var pts)) + return pts.Count > 0 ? pts[0] : null; + return null; + + default: + return null; + } + } + + /// + /// Returns true if the entity's bounding box straddles the split line, + /// meaning it extends to both sides of the split position (not just touching). + /// + public static bool CrossesSplitLine(Entity entity, SplitLine sl) + { + var bbox = entity.BoundingBox; + + if (sl.Axis == CutOffAxis.Vertical) + return bbox.Left < sl.Position - Tolerance.Epsilon + && bbox.Right > sl.Position + Tolerance.Epsilon; + else + return bbox.Bottom < sl.Position - Tolerance.Epsilon + && bbox.Top > sl.Position + Tolerance.Epsilon; + } + + /// + /// Returns -1 if the point is below/left of the split line, + /// +1 if above/right, or 0 if on the line (within tolerance). + /// + public static int SideOf(Vector pt, SplitLine sl) + { + var value = sl.Axis == CutOffAxis.Vertical ? pt.X : pt.Y; + var diff = value - sl.Position; + + if (System.Math.Abs(diff) <= Tolerance.Epsilon) + return 0; + + return diff < 0 ? -1 : 1; + } +} diff --git a/OpenNest.Tests/Splitting/EntitySplitTests.cs b/OpenNest.Tests/Splitting/EntitySplitTests.cs new file mode 100644 index 0000000..b027873 --- /dev/null +++ b/OpenNest.Tests/Splitting/EntitySplitTests.cs @@ -0,0 +1,206 @@ +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Splitting; + +public class EntitySplitTests +{ + // --- SplitLine.ToLine --- + + [Fact] + public void ToLine_Vertical_ReturnsVerticalLine() + { + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + var line = sl.ToLine(0, 100); + + Assert.Equal(50.0, line.StartPoint.X, 5); + Assert.Equal(0.0, line.StartPoint.Y, 5); + Assert.Equal(50.0, line.EndPoint.X, 5); + Assert.Equal(100.0, line.EndPoint.Y, 5); + } + + [Fact] + public void ToLine_Horizontal_ReturnsHorizontalLine() + { + var sl = new SplitLine(30.0, CutOffAxis.Horizontal); + var line = sl.ToLine(10, 90); + + Assert.Equal(10.0, line.StartPoint.X, 5); + Assert.Equal(30.0, line.StartPoint.Y, 5); + Assert.Equal(90.0, line.EndPoint.X, 5); + Assert.Equal(30.0, line.EndPoint.Y, 5); + } + + // --- FindIntersection: Line crossing vertical split --- + + [Fact] + public void FindIntersection_LineCrossesVerticalSplit_ReturnsPoint() + { + // Diagonal line from (0,0) to (100,100), vertical split at x=50 + var line = new Line(0, 0, 100, 100); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + var result = SplitLineIntersect.FindIntersection(line, sl); + + Assert.NotNull(result); + Assert.Equal(50.0, result.Value.X, 5); + Assert.Equal(50.0, result.Value.Y, 5); + } + + // --- FindIntersection: Line NOT crossing --- + + [Fact] + public void FindIntersection_LineDoesNotCross_ReturnsNull() + { + // Line entirely to the left of split at x=50 + var line = new Line(0, 0, 40, 40); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + var result = SplitLineIntersect.FindIntersection(line, sl); + + Assert.Null(result); + } + + // --- FindIntersection: Line parallel to split --- + + [Fact] + public void FindIntersection_LineParallelToSplit_ReturnsNull() + { + // Vertical line at x=50 — parallel to vertical split at x=50 + var line = new Line(50, 0, 50, 100); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + var result = SplitLineIntersect.FindIntersection(line, sl); + + Assert.Null(result); + } + + // --- FindIntersection: Arc crossing vertical split --- + + [Fact] + public void FindIntersection_ArcCrossesVerticalSplit_ReturnsPoint() + { + // Arc centered at (60,50), radius 20, from PI to 0 (CCW). + // CCW from PI wraps through 3PI/2 (bottom) then 0 (right). + // At x=50: (50-60)^2 + (y-50)^2 = 400 => (y-50)^2 = 300 + // y = 50 - sqrt(300) ≈ 32.68 (bottom intersection, on the arc) + // y = 50 + sqrt(300) ≈ 67.32 (top intersection, also on the arc) + var arc = new Arc(60, 50, 20, System.Math.PI, 0, false); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + var result = SplitLineIntersect.FindIntersection(arc, sl); + + Assert.NotNull(result); + Assert.Equal(50.0, result.Value.X, 1); + // The first intersection found by the circle-line algorithm; either ~32.68 or ~67.32 + var y = result.Value.Y; + var expectedLow = 50.0 - System.Math.Sqrt(300); + var expectedHigh = 50.0 + System.Math.Sqrt(300); + Assert.True( + System.Math.Abs(y - expectedLow) < 0.1 || System.Math.Abs(y - expectedHigh) < 0.1, + $"Expected Y near {expectedLow:F2} or {expectedHigh:F2}, got {y:F2}"); + } + + // --- CrossesSplitLine --- + + [Fact] + public void CrossesSplitLine_LineStraddles_ReturnsTrue() + { + var line = new Line(40, 0, 60, 100); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + Assert.True(SplitLineIntersect.CrossesSplitLine(line, sl)); + } + + [Fact] + public void CrossesSplitLine_LineEntirelyOnOneSide_ReturnsFalse() + { + var line = new Line(10, 0, 40, 100); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + Assert.False(SplitLineIntersect.CrossesSplitLine(line, sl)); + } + + [Fact] + public void CrossesSplitLine_HorizontalSplit_Works() + { + var line = new Line(0, 10, 100, 60); + var sl = new SplitLine(30.0, CutOffAxis.Horizontal); + + Assert.True(SplitLineIntersect.CrossesSplitLine(line, sl)); + } + + [Fact] + public void CrossesSplitLine_LineTouchingButNotStraddling_ReturnsFalse() + { + // Line endpoint exactly at the split line — bbox right == 50, left < 50 + // But right must be > pos (strictly), so touching exactly returns false + var line = new Line(10, 0, 50, 100); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + Assert.False(SplitLineIntersect.CrossesSplitLine(line, sl)); + } + + // --- SideOf --- + + [Fact] + public void SideOf_PointLeftOfVerticalSplit_ReturnsNegative() + { + var pt = new Vector(30, 50); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + Assert.Equal(-1, SplitLineIntersect.SideOf(pt, sl)); + } + + [Fact] + public void SideOf_PointRightOfVerticalSplit_ReturnsPositive() + { + var pt = new Vector(70, 50); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + Assert.Equal(1, SplitLineIntersect.SideOf(pt, sl)); + } + + [Fact] + public void SideOf_PointOnVerticalSplit_ReturnsZero() + { + var pt = new Vector(50, 50); + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + + Assert.Equal(0, SplitLineIntersect.SideOf(pt, sl)); + } + + [Fact] + public void SideOf_PointBelowHorizontalSplit_ReturnsNegative() + { + var pt = new Vector(50, 10); + var sl = new SplitLine(30.0, CutOffAxis.Horizontal); + + Assert.Equal(-1, SplitLineIntersect.SideOf(pt, sl)); + } + + [Fact] + public void SideOf_PointAboveHorizontalSplit_ReturnsPositive() + { + var pt = new Vector(50, 60); + var sl = new SplitLine(30.0, CutOffAxis.Horizontal); + + Assert.Equal(1, SplitLineIntersect.SideOf(pt, sl)); + } + + // --- FindIntersection: horizontal split --- + + [Fact] + public void FindIntersection_LineCrossesHorizontalSplit_ReturnsPoint() + { + // Diagonal line from (0,0) to (100,100), horizontal split at y=50 + var line = new Line(0, 0, 100, 100); + var sl = new SplitLine(50.0, CutOffAxis.Horizontal); + + var result = SplitLineIntersect.FindIntersection(line, sl); + + Assert.NotNull(result); + Assert.Equal(50.0, result.Value.X, 5); + Assert.Equal(50.0, result.Value.Y, 5); + } +}