diff --git a/src/UnitTests/Evaluation/BasisEvaluationTest.cs b/src/UnitTests/Evaluation/BasisEvaluationTest.cs index dbf5e62..31d3330 100644 --- a/src/UnitTests/Evaluation/BasisEvaluationTest.cs +++ b/src/UnitTests/Evaluation/BasisEvaluationTest.cs @@ -35,8 +35,197 @@ public void BasisFunctionTest() degree = 3; span = evaluator.ExposeFindSpan(degree, knots, 0.1); Assert.That(span, Is.EqualTo(3)); + } + + /// + /// B-spline basis functions sum to 1 (partition of unity) at any parameter. + /// + [Test] + public void BasisFunction_PartitionOfUnity() + { + DummyEvaluator evaluator = new DummyEvaluator(); + double[] knots = new double[] { 0, 0, 0, 0, 1, 2, 3, 3, 3, 3 }; + int degree = 3; + int n = knots.Length - degree - 2; // last control point index + + double[] sampleParams = { 0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0 }; + foreach (double u in sampleParams) + { + double sum = 0.0; + for (int i = 0; i <= n; i++) + sum += evaluator.ExposeBasisFunction(i, degree, knots, u); + Assert.That(sum, Is.EqualTo(1.0).Within(1e-12), + $"Partition of unity violated at u={u}: sum={sum}"); + } + } + + /// + /// B-spline basis functions are non-negative everywhere. + /// + [Test] + public void BasisFunction_NonNegativity() + { + DummyEvaluator evaluator = new DummyEvaluator(); + double[] knots = new double[] { 0, 0, 0, 0, 1, 2, 3, 3, 3, 3 }; + int degree = 3; + int n = knots.Length - degree - 2; + + for (int sample = 0; sample <= 30; sample++) + { + double u = 3.0 * sample / 30.0; + for (int i = 0; i <= n; i++) + { + double val = evaluator.ExposeBasisFunction(i, degree, knots, u); + Assert.That(val, Is.GreaterThanOrEqualTo(-1e-14), + $"Negative basis function N_{i},{degree} at u={u}: {val}"); + } + } + } + + /// + /// Linear basis functions (degree 1) on uniform knots produce hat functions. + /// + [Test] + public void BasisFunction_Degree1_HatFunction() + { + DummyEvaluator evaluator = new DummyEvaluator(); + // Degree 1, 3 control points -> knots [0,0,0.5,1,1] + double[] knots = new double[] { 0, 0, 0.5, 1, 1 }; + int degree = 1; + + // At u=0: N_0=1, N_1=0, N_2=0 + Assert.That(evaluator.ExposeBasisFunction(0, degree, knots, 0.0), Is.EqualTo(1.0).Within(1e-12)); + Assert.That(evaluator.ExposeBasisFunction(1, degree, knots, 0.0), Is.EqualTo(0.0).Within(1e-12)); + + // At u=0.5: N_0=0, N_1=1, N_2=0 (N_1 peaks at the interior knot) + double n0 = evaluator.ExposeBasisFunction(0, degree, knots, 0.5); + double n1 = evaluator.ExposeBasisFunction(1, degree, knots, 0.5); + Assert.That(n0, Is.EqualTo(0.0).Within(1e-12)); + Assert.That(n1, Is.EqualTo(1.0).Within(1e-12)); // at knot, N_1=1 + + // At u=1: N_last=1 + Assert.That(evaluator.ExposeBasisFunction(2, degree, knots, 1.0), Is.EqualTo(1.0).Within(1e-12)); + + // Intermediate values verify piecewise-linear (hat) shape: + // N_0 linearly ramps from 1 at u=0 to 0 at u=0.5 -> at u=0.25, N_0=0.5 + Assert.That(evaluator.ExposeBasisFunction(0, degree, knots, 0.25), Is.EqualTo(0.5).Within(1e-12)); + // N_1 ramps up from 0 at u=0 to 1 at u=0.5, then back to 0 at u=1.0 + // at u=0.25 (half way on [0,0.5]), N_1=0.5 + Assert.That(evaluator.ExposeBasisFunction(1, degree, knots, 0.25), Is.EqualTo(0.5).Within(1e-12)); + // at u=0.75 (half way on [0.5,1.0]), N_1=0.5 + Assert.That(evaluator.ExposeBasisFunction(1, degree, knots, 0.75), Is.EqualTo(0.5).Within(1e-12)); + // N_2 ramps from 0 at u=0.5 to 1 at u=1.0 -> at u=0.75, N_2=0.5 + Assert.That(evaluator.ExposeBasisFunction(2, degree, knots, 0.75), Is.EqualTo(0.5).Within(1e-12)); + } + + /// + /// First derivative of B-spline basis sums to zero (derivative of partition of unity = 0). + /// + [Test] + public void BasisFunctionDerivative_SumsToZero() + { + DummyEvaluator evaluator = new DummyEvaluator(); + double[] knots = new double[] { 0, 0, 0, 0, 1, 2, 3, 3, 3, 3 }; + int degree = 3; + int n = knots.Length - degree - 2; + + // Sample inside the domain, avoiding knot values for derivative continuity + double[] sampleParams = { 0.5, 1.5, 2.5 }; + + foreach (double u in sampleParams) + { + double sum = 0.0; + for (int i = 0; i <= n; i++) + sum += evaluator.ExposeBasisFunctionDerivative(i, degree, knots, u, 1); + Assert.That(sum, Is.EqualTo(0.0).Within(1e-10), + $"Sum of first derivatives should be 0 at u={u}: got {sum}"); + } + } + + /// + /// DeBoor algorithm on a degree-1 curve with two control points reproduces linear interpolation. + /// + [Test] + public void DeBoor_Degree1_LinearInterpolation() + { + DummyEvaluator evaluator = new DummyEvaluator(); + double[] knots = new double[] { 0, 0, 1, 1 }; + int degree = 1; + + Vector4Double[] cps = new Vector4Double[] + { + new Vector4Double(0, 0, 0, 1), + new Vector4Double(10, 5, 0, 1) + }; + + int span0 = evaluator.ExposeFindSpan(degree, knots, 0.0); + int span1 = evaluator.ExposeFindSpan(degree, knots, 1.0); + int spanMid = evaluator.ExposeFindSpan(degree, knots, 0.5); + + var p0 = evaluator.ExposeDeBoor(degree, knots, span0, cps, 0.0); + var p1 = evaluator.ExposeDeBoor(degree, knots, span1, cps, 1.0); + var pm = evaluator.ExposeDeBoor(degree, knots, spanMid, cps, 0.5); + + using (Assert.EnterMultipleScope()) + { + Assert.That(p0.X / p0.W, Is.EqualTo(0.0).Within(1e-12)); + Assert.That(p0.Y / p0.W, Is.EqualTo(0.0).Within(1e-12)); + Assert.That(p1.X / p1.W, Is.EqualTo(10.0).Within(1e-12)); + Assert.That(p1.Y / p1.W, Is.EqualTo(5.0).Within(1e-12)); + Assert.That(pm.X / pm.W, Is.EqualTo(5.0).Within(1e-12)); + Assert.That(pm.Y / pm.W, Is.EqualTo(2.5).Within(1e-12)); + } + } + + /// + /// DeBoor on a degree-2 (quadratic) curve passes through end control points. + /// + [Test] + public void DeBoor_Degree2_Quadratic_EndPointInterpolation() + { + DummyEvaluator evaluator = new DummyEvaluator(); + double[] knots = new double[] { 0, 0, 0, 1, 1, 1 }; + int degree = 2; + + Vector4Double[] cps = new Vector4Double[] + { + new Vector4Double(0, 0, 0, 1), + new Vector4Double(1, 2, 0, 1), + new Vector4Double(2, 0, 0, 1) + }; + + int span0 = evaluator.ExposeFindSpan(degree, knots, 0.0); + int span1 = evaluator.ExposeFindSpan(degree, knots, 1.0); + + var p0 = evaluator.ExposeDeBoor(degree, knots, span0, cps, 0.0); + var p1 = evaluator.ExposeDeBoor(degree, knots, span1, cps, 1.0); + + using (Assert.EnterMultipleScope()) + { + // Clamped B-spline interpolates first and last control points + Assert.That(p0.X / p0.W, Is.EqualTo(0.0).Within(1e-12)); + Assert.That(p0.Y / p0.W, Is.EqualTo(0.0).Within(1e-12)); + Assert.That(p1.X / p1.W, Is.EqualTo(2.0).Within(1e-12)); + Assert.That(p1.Y / p1.W, Is.EqualTo(0.0).Within(1e-12)); + } + } + + /// + /// FindSpan returns consistent results for out-of-range parameters (clamping behavior). + /// + [Test] + public void FindSpan_ClampingBehavior() + { + DummyEvaluator evaluator = new DummyEvaluator(); + double[] knots = new double[] { 0, 0, 0, 1, 1, 1 }; + int degree = 2; + // Below minimum: should return degree + Assert.That(evaluator.ExposeFindSpan(degree, knots, -5.0), Is.EqualTo(degree)); + // Above maximum: should return n = knots.Length - degree - 2 + int expected = knots.Length - degree - 2; + Assert.That(evaluator.ExposeFindSpan(degree, knots, 5.0), Is.EqualTo(expected)); } } diff --git a/src/UnitTests/Evaluation/VolumeEvaluateTest.cs b/src/UnitTests/Evaluation/VolumeEvaluateTest.cs index 86159e6..9e8e642 100644 --- a/src/UnitTests/Evaluation/VolumeEvaluateTest.cs +++ b/src/UnitTests/Evaluation/VolumeEvaluateTest.cs @@ -14,19 +14,10 @@ namespace UnitTests.Evaluation [TestFixture] public class VolumeEvaluateTest { - - - [Test] - public void NurbsVolumeTestA() + private static NurbsVolume BuildLinearUnitCube() { - // Bilinear NURBS volue (degree 1 in both directions) - int degreeU = 1; - int degreeV = 1; - int degreeW = 1; - - double[] knotsU = [0, 0, 1, 1]; - double[] knotsV = [0, 0, 1, 1]; - double[] knotsW = [0, 0, 1, 1]; + int degree = 1; + double[] knots = [0, 0, 1, 1]; ControlPoint[][][] controlPoints = new ControlPoint[2][][]; controlPoints[0] = new ControlPoint[2][]; controlPoints[0][0] = [ @@ -46,7 +37,19 @@ public void NurbsVolumeTestA() new ControlPoint(0.0, 1.0, 1.5, 1), new ControlPoint(1.0, 1.0, 2.5, 1) ]; - var volume = new NurbsVolume(degreeU, degreeV, degreeW, new KnotVector(knotsU,degreeU), new KnotVector(knotsV,degreeV), new KnotVector(knotsW,degreeW), controlPoints); + return new NurbsVolume( + degree, degree, degree, + new KnotVector(knots, degree), + new KnotVector(knots, degree), + new KnotVector(knots, degree), + controlPoints); + } + + [Test] + public void NurbsVolumeTestA() + { + // Bilinear NURBS volume (degree 1 in all directions) + var volume = BuildLinearUnitCube(); var samples = new (double u, double v, double w, Vector3Double expected)[] { (0.000, 0.000, 0.000, new Vector3Double(0.000000, 0.000000, 0.000000)), @@ -72,7 +75,124 @@ public void NurbsVolumeTestA() } } - + /// + /// A uniform grid axis-aligned NURBS volume (degree 1) maps parameters linearly to positions. + /// + [Test] + public void NurbsVolume_LinearAxisAligned_MapsParametersToPositions() + { + // Simple axis-aligned unit cube: P(u,v,w) = (w, v, u) for u,v,w in [0,1] + int degree = 1; + double[] knots = [0, 0, 1, 1]; + + ControlPoint[][][] cps = new ControlPoint[2][][]; + for (int i = 0; i < 2; i++) + { + cps[i] = new ControlPoint[2][]; + for (int j = 0; j < 2; j++) + { + cps[i][j] = new ControlPoint[2]; + for (int k = 0; k < 2; k++) + { + cps[i][j][k] = new ControlPoint(k, j, i, 1.0); + } + } + } + + var volume = new NurbsVolume( + degree, degree, degree, + new KnotVector(knots, degree), + new KnotVector(knots, degree), + new KnotVector(knots, degree), + cps); + + // Evaluate at corners and center + var corners = new (double u, double v, double w)[] + { + (0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), + (1, 1, 1), (0.5, 0.5, 0.5) + }; + + foreach (var (u, v, w) in corners) + { + var pt = VolumeEvaluator.Evaluate(volume, u, v, w); + using (Assert.EnterMultipleScope()) + { + Assert.That(pt.x, Is.EqualTo(w).Within(1e-10)); + Assert.That(pt.y, Is.EqualTo(v).Within(1e-10)); + Assert.That(pt.z, Is.EqualTo(u).Within(1e-10)); + } + } + } + + /// + /// NURBS volume bounding box encompasses all control points. + /// + [Test] + public void NurbsVolume_BoundingBox_ContainsAllControlPoints() + { + var volume = BuildLinearUnitCube(); + var bbox = volume.BoundingBox; + + // All evaluated points must lie within the bounding box (with tolerance) + for (int si = 0; si <= 4; si++) + for (int sj = 0; sj <= 4; sj++) + for (int sk = 0; sk <= 4; sk++) + { + double u = si / 4.0; + double v = sj / 4.0; + double w = sk / 4.0; + var pt = VolumeEvaluator.Evaluate(volume, u, v, w); + + Assert.That(pt.x, Is.GreaterThanOrEqualTo(bbox.Min.X - 1e-9)); + Assert.That(pt.x, Is.LessThanOrEqualTo(bbox.Max.X + 1e-9)); + Assert.That(pt.y, Is.GreaterThanOrEqualTo(bbox.Min.Y - 1e-9)); + Assert.That(pt.y, Is.LessThanOrEqualTo(bbox.Max.Y + 1e-9)); + Assert.That(pt.z, Is.GreaterThanOrEqualTo(bbox.Min.Z - 1e-9)); + Assert.That(pt.z, Is.LessThanOrEqualTo(bbox.Max.Z + 1e-9)); + } + } + + /// + /// NurbsVolume throws on null argument to Evaluate. + /// + [Test] + public void NurbsVolume_Evaluate_NullThrows() + { + Assert.Throws(() => + { + VolumeEvaluator.Evaluate(null!, 0.5, 0.5, 0.5); + }); + } + + /// + /// NurbsVolume constructor validates knot-vector vs. control-point dimensions. + /// + [Test] + public void NurbsVolume_Constructor_InvalidKnotLengthThrows() + { + int degree = 1; + double[] validKnots = [0, 0, 1, 1]; + double[] badKnots = [0, 0, 0.5, 1, 1]; // length 5 instead of 4 + + ControlPoint[][][] cps = new ControlPoint[2][][]; + for (int i = 0; i < 2; i++) + { + cps[i] = new ControlPoint[2][]; + for (int j = 0; j < 2; j++) + cps[i][j] = [new ControlPoint(0, 0, 0, 1), new ControlPoint(1, 0, 0, 1)]; + } + + Assert.Throws(() => + { + _ = new NurbsVolume( + degree, degree, degree, + new KnotVector(badKnots, degree), + new KnotVector(validKnots, degree), + new KnotVector(validKnots, degree), + cps); + }); + } } diff --git a/src/UnitTests/Generation/PrimitiveTest.cs b/src/UnitTests/Generation/PrimitiveTest.cs index 94de377..f2b5df4 100644 --- a/src/UnitTests/Generation/PrimitiveTest.cs +++ b/src/UnitTests/Generation/PrimitiveTest.cs @@ -170,6 +170,46 @@ public void PrimitiveTestSphere() } + [Test] + public void PrimitiveTestLine() + { + var p0 = new Vector3Double(0, 0, 0); + var p1 = new Vector3Double(3, 4, 0); + var line = PrimitiveFactory.CreateLine(p0, p1); + + // A line is degree-1 with 2 control points + Assert.That(line.Degree, Is.EqualTo(1)); + Assert.That(line.ControlPoints, Has.Length.EqualTo(2)); + Assert.That(line.KnotVector.Knots, Has.Length.EqualTo(4)); + + // Start and end interpolation + var start = line.GetPos(0.0); + var end = line.GetPos(1.0); + using (Assert.EnterMultipleScope()) + { + Assert.That(start.X, Is.EqualTo(p0.X).Within(1e-10)); + Assert.That(start.Y, Is.EqualTo(p0.Y).Within(1e-10)); + Assert.That(start.Z, Is.EqualTo(p0.Z).Within(1e-10)); + Assert.That(end.X, Is.EqualTo(p1.X).Within(1e-10)); + Assert.That(end.Y, Is.EqualTo(p1.Y).Within(1e-10)); + Assert.That(end.Z, Is.EqualTo(p1.Z).Within(1e-10)); + } + + // Midpoint should be the average + var mid = line.GetPos(0.5); + Assert.That(mid.X, Is.EqualTo(1.5).Within(1e-10)); + Assert.That(mid.Y, Is.EqualTo(2.0).Within(1e-10)); + + // All points lie on the segment (linear interpolation) + for (int i = 0; i <= 10; i++) + { + double t = i / 10.0; + var pt = line.GetPos(t); + Assert.That(pt.X, Is.EqualTo(p0.X + t * (p1.X - p0.X)).Within(1e-10)); + Assert.That(pt.Y, Is.EqualTo(p0.Y + t * (p1.Y - p0.Y)).Within(1e-10)); + } + } + private static void TestOutputIGES(List surface, string filePath= "PrimitiveTestFace.igs") { using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write); diff --git a/src/UnitTests/Operation/SurfaceOperatorTest.cs b/src/UnitTests/Operation/SurfaceOperatorTest.cs index d495a3d..074749f 100644 --- a/src/UnitTests/Operation/SurfaceOperatorTest.cs +++ b/src/UnitTests/Operation/SurfaceOperatorTest.cs @@ -175,5 +175,108 @@ public void ExtractIsoCurve_ConvenienceMethods() Assert.That(isoV.Degree, Is.EqualTo(face.DegreeU)); } } + + /// + /// FindClosestPoint on a flat planar surface returns the projected point. + /// + [Test] + public void FindClosestPoint_PlanarSurface_ProjectsCorrectly() + { + // Flat XY-plane surface from (0,0,0) to (10,10,0) + var face = PrimitiveFactory.CreateFace( + new Vector3Double(0, 0, 0), + new Vector3Double(10, 0, 0), + new Vector3Double(0, 10, 0), + new Vector3Double(10, 10, 0)); + + // Target point above the center of the surface + var target = new Vector3Double(5, 5, 3); + var result = SurfaceOperator.FindClosestPoint(face, target); + + // The closest point should be the projection onto the plane (z=0) + using (Assert.EnterMultipleScope()) + { + Assert.That(result.point.X, Is.EqualTo(5.0).Within(0.01)); + Assert.That(result.point.Y, Is.EqualTo(5.0).Within(0.01)); + Assert.That(result.point.Z, Is.EqualTo(0.0).Within(0.01)); + Assert.That(result.distance, Is.EqualTo(3.0).Within(0.01)); + } + } + + /// + /// FindClosestPoint with initial guess on a planar surface. + /// + [Test] + public void FindClosestPoint_PlanarSurface_WithInitialGuess() + { + var face = PrimitiveFactory.CreateFace( + new Vector3Double(0, 0, 0), + new Vector3Double(10, 0, 0), + new Vector3Double(0, 10, 0), + new Vector3Double(10, 10, 0)); + + var target = new Vector3Double(2, 8, 1); + var result = SurfaceOperator.FindClosestPoint(face, target, 0.2, 0.8); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.point.X, Is.EqualTo(2.0).Within(0.05)); + Assert.That(result.point.Y, Is.EqualTo(8.0).Within(0.05)); + Assert.That(result.point.Z, Is.EqualTo(0.0).Within(0.05)); + Assert.That(result.distance, Is.EqualTo(1.0).Within(0.05)); + } + } + + /// + /// FindClosestPoint on a sphere surface: the closest point should lie on the sphere. + /// Also verifies that explicit tolerance/gridDivisions produce the same result as defaults. + /// + [Test] + public void FindClosestPoint_Sphere_ClosestPointLiesOnSphere() + { + double radius = 5.0; + var sphere = PrimitiveFactory.CreateSphere(radius); + + // Target point outside the sphere along the X axis + var target = new Vector3Double(8, 0, 0); + + // With explicit parameters + var resultExplicit = SurfaceOperator.FindClosestPoint(sphere, target, tolerance: 1e-5, gridDivisions: 6); + + // With default parameters (tolerance=1e-6, gridDivisions=5) + var resultDefault = SurfaceOperator.FindClosestPoint(sphere, target); + + // Both results should be consistent (both should find a point on the sphere) + double distExplicit = Math.Sqrt( + resultExplicit.point.X * resultExplicit.point.X + + resultExplicit.point.Y * resultExplicit.point.Y + + resultExplicit.point.Z * resultExplicit.point.Z); + double distDefault = Math.Sqrt( + resultDefault.point.X * resultDefault.point.X + + resultDefault.point.Y * resultDefault.point.Y + + resultDefault.point.Z * resultDefault.point.Z); + + using (Assert.EnterMultipleScope()) + { + Assert.That(distExplicit, Is.EqualTo(radius).Within(0.1)); + Assert.That(distDefault, Is.EqualTo(radius).Within(0.1)); + Assert.That(resultExplicit.distance, Is.EqualTo(3.0).Within(0.2)); // 8 - 5 = 3 + Assert.That(resultDefault.distance, Is.EqualTo(3.0).Within(0.2)); + // Explicit and default converge to same result within reasonable tolerance + Assert.That(resultExplicit.point.DistanceTo(resultDefault.point), Is.LessThan(0.5)); + } + } + + /// + /// FindClosestPoint with null surface throws ArgumentNullException. + /// + [Test] + public void FindClosestPoint_NullSurface_Throws() + { + Assert.Throws(() => + { + SurfaceOperator.FindClosestPoint(null!, new Vector3Double(1, 1, 1)); + }); + } } }