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