The Surface
The simple Definition
The 2D World in 3D Space
A Surface is a 2D topological space embedded in a 3D environment. Think of it as a sheet of paper that can be bent, twisted, and warped in any direction. Like curves, surfaces are defined by NURBS mathematics.
Every point on a surface is defined by a unique coordinate pair: U and V.
Data Structure
Surfaces use UV parametrization which parallels Curve's T parameter.
1class Surface:2 """3 Represents a NURBS surface.4 """5 def __init__(self, control_grid: list[list]):6 # A 2D list of Point3d objects7 self.ControlGrid = control_grid8 self.DegreeU = 39 self.DegreeV = 3
The Control Point Grid
Just as a curve is defined by a 1D sequence of control points, a Surface is defined by a 2D Grid of Control Points.
- U direction: The "rows" of the grid.
- V direction: The "columns" of the grid.
By moving a single control point in the grid, you warp the entire surface locally.
Area():
1import math234# Surface Area is the double integral of the magnitude5# of the cross product |du x dv| over the UV domain.67u_min, u_max = surface.domain_u()8v_min, v_max = surface.domain_v()910steps_u = 5011steps_v = 501213du_step = (u_max - u_min) / steps_u14dv_step = (v_max - v_min) / steps_v1516approx_area = 0.01718# Numerical integration (Riemann sum) over the UV domain19for i in range(steps_u):20 for j in range(steps_v):21 # Sample at the center of the patch22 u = u_min + (i + 0.5) * du_step23 v = v_min + (j + 0.5) * dv_step2425 # Evaluate 1st derivatives (tangent vectors) at this UV coordinate26 pt, du, dv = surface.evaluate_derivatives(u, v)2728 # Area of this tiny patch is |du x dv| * du_step * dv_step29 # |du x dv| is the Jacobian determinant of the surface30 patch_area = cross_product_length(du, dv) * du_step * dv_step3132 approx_area += patch_area3334# approx_area approaches the exact mathematical area as steps increase
Flip():
1def Flip(self) -> 'Surface':2 """3 Reverses the direction of the surface normal.4 A naive implementation reverses the U direction by reversing the order5 of control points in every row of the control grid.6 """7 new_srf = self.copy()89 # By reversing the control points along one direction (U),10 # the Cross Product of U x V will point in the opposite direction.11 for row in new_srf.ControlGrid:12 row.reverse()1314 return new_srf
Every surface has a "Front" and a "Back". The front is defined by the Normal Vector. But unlike a Mesh where you can just flip the vertex winding order, a Surface's normal is mathematically derived from its U and V parameter directions using the Right-Hand Rule (U × V = Normal).
To flip a surface inside-out, you must reverse the direction of either its U or its V domain. This is frequently required when projecting curves onto surfaces, or extruding thicknesses, as you need to ensure all surfaces point "outward".
Evaluate():
1def Evaluate(self, u: float, v: float) -> tuple['Point3d', 'Vector3d', 'Vector3d', 'Vector3d']:2 """3 Evaluates the surface to return the point and its partial derivatives.4 A naive implementation uses finite differences to estimate the tangent vectors.5 """6 # 1. Evaluate the central point7 pt = self.PointAt(u, v)89 # 2. Evaluate points slightly ahead in U and V directions10 du = 0.00111 dv = 0.00112 pt_u = self.PointAt(u + du, v)13 pt_v = self.PointAt(u, v + dv)1415 # 3. Calculate Tangent vectors (Derivatives)16 u_tangent = (pt_u - pt) / du17 v_tangent = (pt_v - pt) / dv1819 # 4. Calculate the Normal vector using the Cross Product20 normal = Vector3d.CrossProduct(u_tangent, v_tangent)21 normal.Unitize()2223 return pt, u_tangent, v_tangent, normal
While `PointAt(u, v)` only gives you the physical `(x, y, z)` location, `Evaluate(u, v, numDerivatives)` gives you the entire mathematical context of that point.
By requesting 1 derivative, you get the tangent vectors travelling along the U and V directions. This is exactly how a 3D engine calculates the surface Normal—by taking the cross product of these two tangents. Requesting 2 derivatives gives you information about the sharpness of the bend (curvature) at that exact spot.
NormalAt(u, v):
1import math23# --- HOW DERIVATIVES ARE CALCULATED ---4# We use the finite difference method to approximate tangent vectors.5# eps (epsilon) is a very small number representing a tiny step.6eps = 0.00178# 1. Get the 3D point exactly at (u, v)9pt = surface.evaluate(u, v)1011# 2. Get points slightly further along the U and V directions12pt_u_stepped = surface.evaluate(u + eps, v)13pt_v_stepped = surface.evaluate(u, v + eps)1415# 3. Calculate tangent vectors (rate of change = rise / run)16du = (17 (pt_u_stepped.x - pt.x) / eps,18 (pt_u_stepped.y - pt.y) / eps,19 (pt_u_stepped.z - pt.z) / eps20)2122dv = (23 (pt_v_stepped.x - pt.x) / eps,24 (pt_v_stepped.y - pt.y) / eps,25 (pt_v_stepped.z - pt.z) / eps26)2728# 4. Cross product yields a vector perpendicular to both tangents29nx = du[1]*dv[2] - du[2]*dv[1]30ny = du[2]*dv[0] - du[0]*dv[2]31nz = du[0]*dv[1] - du[1]*dv[0]3233# 5. Unitize makes its length exactly 1.0 (a unit vector)34length = math.sqrt(nx**2 + ny**2 + nz**2)35unit_normal = (nx/length, ny/length, nz/length)3637# unit_normal now points directly "away" from the surface!
PointAt(u, v):
u and v, both usually ranging from 0.0 to 1.0.1def PointAt(self, u: float, v: float) -> Point3d:2 """Evaluates the surface at UV coordinates using bilinear interpolation."""3 p00, p10, p01, p11 = self.corners45 # Interpolate along U6 p_u0 = p00 * (1.0 - u) + p10 * u7 p_u1 = p01 * (1.0 - u) + p11 * u89 # Interpolate along V10 return p_u0 * (1.0 - v) + p_u1 * v
DistanceTo(test_point):
1def DistanceTo(self, test_point: 'Point3d') -> float:2 """Computes the shortest distance from the surface to the point."""3 success, u, v = self.ClosestPoint(test_point)4 if success:5 closest_pt = self.PointAt(u, v)6 return closest_pt.DistanceTo(test_point)7 return -1.0
Distance checks are fundamental for collision detection and proximity analysis. Under the hood, this usually performs a ClosestPoint search to find the nearest UV coordinate, then calculates the Euclidean distance.
Translate(vector):
1def Translate(self, vector: 'Vector3d') -> 'Surface':2 """3 Shifts the entire Control Grid by a given translation vector.4 Since NURBS surfaces are defined by their control points,5 moving the points moves the entire surface identically.6 """7 # 1. Create a copy to avoid mutating the original surface8 new_srf = self.copy()910 # 2. Iterate through the 2D grid of control points (U and V directions)11 for row in new_srf.ControlGrid:12 for cp in row:13 # 3. Add the vector components to each control point's coordinates14 cp.x += vector.x15 cp.y += vector.y16 cp.z += vector.z1718 return new_srf
Scale(factor):
1def scale_surface(srf, factor):2 new_srf = srf.copy()34 for row in new_srf.control_points:5 for pt in row:6 # Multiply each coordinate by the scale factor7 pt.x *= factor8 pt.y *= factor9 pt.z *= factor1011 return new_srf
Rotate(angle):
1import math23def rotate_surface_z(srf, angle_deg):4 new_srf = srf.copy()56 rad = math.radians(angle_deg)7 cos_a = math.cos(rad)8 sin_a = math.sin(rad)910 for row in new_srf.control_points:11 for pt in row:12 # Apply 2D rotation matrix for Z-axis rotation13 new_x = pt.x * cos_a - pt.y * sin_a14 new_y = pt.x * sin_a + pt.y * cos_a1516 pt.x = new_x17 pt.y = new_y1819 return new_srf
ClosestPoint(Point3d):
(u, v) that results in the minimum distance to the target point.1def ClosestPoint(surface: Surface, test_pt: Point3d) -> Point3d:2 """Iteratively finds the closest point on a NURBS surface."""3 u, v = 0.5, 0.545 # Run Newton-Raphson iterations to minimize distance6 for _ in range(5):7 pt = surface.PointAt(u, v)8 r = pt - test_pt910 # Tangent vectors (first derivatives)11 su = surface.DerivativeAtU(u, v)12 sv = surface.DerivativeAtV(u, v)1314 # Second derivatives15 suu = surface.SecondDerivativeAtU(u, v)16 svv = surface.SecondDerivativeAtV(u, v)1718 # Update steps using Hessian approximations19 u -= r.DotProduct(su) / (su.SquareLength() + r.DotProduct(suu))20 v -= r.DotProduct(sv) / (sv.SquareLength() + r.DotProduct(svv))2122 u = max(0.0, min(1.0, u))23 v = max(0.0, min(1.0, v))2425 return surface.PointAt(u, v)
IsoCurve:
1def ExtractIsocurves(surface: 'Surface', u_param: float, v_param: float) -> tuple['Curve', 'Curve']:2 """3 Extracts the continuous U and V lines running across the surface at specific parameters.4 A naive implementation evaluates the surface formula at a fixed parameter while stepping5 through the other parameter to generate points, then interpolates them into curves.6 """7 u_curve_points = []8 v_curve_points = []910 steps = 501112 # 1. Generate the U-Curve (keep V fixed, vary U from 0 to 1)13 for i in range(steps + 1):14 u_step = i / steps15 # Evaluate 3D point on surface at (u_step, fixed_v)16 pt = surface.Evaluate(u_step, v_param)17 u_curve_points.append(pt)1819 # 2. Generate the V-Curve (keep U fixed, vary V from 0 to 1)20 for j in range(steps + 1):21 v_step = j / steps22 # Evaluate 3D point on surface at (fixed_u, v_step)23 pt = surface.Evaluate(u_param, v_step)24 v_curve_points.append(pt)2526 # 3. Create continuous curves from the sampled points27 u_curve = CreateCurveFromPoints(u_curve_points)28 v_curve = CreateCurveFromPoints(v_curve_points)2930 return u_curve, v_curve
An "Isocurve" (Isoparametric Curve) is a line along which one parameter (U or V) remains constant while the other changes. Imagine the latitude and longitude lines on a globe—those are isocurves.
Extracting isocurves is a fundamental way to deconstruct a surface. You can use them to generate structural ribs for a building facade, extract toolpaths for a CNC mill, or visualize the "flow" of the geometry.
Isotrim():
1dom_u = S.Domain(0)2dom_v = S.Domain(1)34# Map normalized 0-1 sliders to the actual surface domains5u0 = dom_u.Min + U_Start * dom_u.Length6u1 = dom_u.Min + U_End * dom_u.Length7v0 = dom_v.Min + V_Start * dom_v.Length8v1 = dom_v.Min + V_End * dom_v.Length910u_domain = rg.Interval(u0, u1)11v_domain = rg.Interval(v0, v1)1213sub_surface = S.Trim(u_domain, v_domain)
Panelization:
1u_domain = surface.Domain(0)2v_domain = surface.Domain(1)34u_step = u_domain.Length / U_Count5v_step = v_domain.Length / V_Count67panels = []8for i in range(U_Count):9 for j in range(V_Count):10 # Create sub-domains for U and V11 u_interval = rg.Interval(u_domain.Min + i * u_step, u_domain.Min + (i + 1) * u_step)12 v_interval = rg.Interval(v_domain.Min + j * v_step, v_domain.Min + (j + 1) * v_step)1314 # Extract a sub-surface using the U and V parameter intervals15 panel = surface.Trim(u_interval, v_interval)16 panels.append(panel)
CurvatureAt(u, v):
1import math23# Evaluate surface curvature at the given U, V parameter4curvature = surface.CurvatureAt(u, v)56if curvature:7 # Built-in way to extract principal curvatures:8 # k1 is the max curvature, k2 is the min curvature9 k1 = curvature.Kappa(0)10 k2 = curvature.Kappa(1)1112 # --- HOW IT WORKS UNDER THE HOOD ---13 # To compute this manually, we evaluate up to the 2nd derivatives14 success, pt, du, dv, duu, duv, dvv = surface.Evaluate(u, v, 2)1516 if success:17 # First Fundamental Form (E, F, G) - relates to lengths/angles18 E = du * du19 F = du * dv20 G = dv * dv2122 # Surface normal vector23 normal = rg.Vector3d.CrossProduct(du, dv)24 normal.Unitize()2526 # Second Fundamental Form (L, M, N) - relates to shape/bending27 L = normal * duu28 M = normal * duv29 N = normal * dvv3031 # Gaussian Curvature K = (LN - M^2) / (EG - F^2)32 det_1st = (E*G - F*F)33 K = (L*N - M*M) / det_1st3435 # Mean Curvature H = (EN - 2FM + GL) / (2 * (EG - F^2))36 H = (E*N - 2*F*M + G*L) / (2 * det_1st)3738 # Principal Curvatures (k1, k2) are roots of: k^2 - 2H*k + K = 03940 discriminant = max(0, H*H - K)4142 computed_k1 = H + math.sqrt(discriminant)43 computed_k2 = H - math.sqrt(discriminant)44 # computed_k1 and computed_k2 match curvature.Kappa(0) and Kappa(1)!4546 # Naive shape classification47 if K > 0.001: shape_type = "Synclastic (Dome)"48 elif K < -0.001: shape_type = "Anticlastic (Saddle)"49 else: shape_type = "Developable (Flat/Cyl)"
Offset(distance, tolerance):
1def Offset(self, distance: float) -> 'Surface':2 """3 Creates a new surface parallel to the original at the specified distance.4 A naive implementation translates each control point along the5 surface normal by the offset distance.6 """7 new_srf = self.copy()89 for u_idx in range(new_srf.GridSizeU):10 for v_idx in range(new_srf.GridSizeV):11 # 1. Get the UV parameter for this control point12 u, v = new_srf.GetUVParams(u_idx, v_idx)1314 # 2. Evaluate the normal at this location15 pt, u_tangent, v_tangent, normal = self.Evaluate(u, v)1617 # 3. Move the control point along the normal18 cv = new_srf.ControlGrid[u_idx][v_idx]19 offset_cv = cv + (normal * distance)2021 new_srf.ControlGrid[u_idx][v_idx] = offset_cv2223 return new_srf
Offsetting a surface is crucial for adding thickness to sheet-like geometries or for calculating clearance zones. Note that offsetting complex doubly-curved surfaces can sometimes result in self-intersections depending on the curvature.
IsPlanar(tolerance):
1import numpy as np23def is_planar(points: list[tuple[float, float, float]], tolerance: float = 1e-6) -> bool:4 """5 Checks if a set of 3D points is planar within a given tolerance6 using eigenvalues of the covariance matrix.7 """8 if len(points) <= 3:9 return True1011 pts = np.array(points)12 centroid = np.mean(pts, axis=0)1314 # Calculate the covariance matrix15 cov_matrix = np.cov((pts - centroid).T)1617 # Eigen decomposition18 eigenvalues, _ = np.linalg.eigh(cov_matrix)1920 # If the smallest eigenvalue is near 0, the points are coplanar21 return eigenvalues[0] < tolerance
Boolean checks like IsPlanar are vital for fabrication and topology analysis. If a surface is planar, it can be easily cut from flat sheets (like plywood or glass).