"""This module provides implementations of NURBS for optical surfaces.
The geometric implementation of the surface is mostly based on the work of
Roberto Agromayor (https://github.com/turbo-sim/nurbspy), the surface
fitting is mostly based on the work of Onur R. Bingol
(https://github.com/orbingol/NURBS-Python), and the ray intersection with the
NURBS and the computation of the sag are based on my understanding of the
algorithms described in the paper 'Practical ray tracing of trimmed nurbs
surfaces'.
Matteo Taccola, 2025
"""
from __future__ import annotations
import numpy as np
from scipy.special import binom
import optiland.backend as be
from optiland.geometries.base import BaseGeometry
from .nurbs_basis_functions import (
compute_basis_polynomials,
compute_basis_polynomials_derivatives,
)
from .nurbs_fitting import approximate_surface
# Data points per control point along each axis when a surface is sampled to be
# fitted. One apiece makes the least squares square -- interpolation in disguise --
# and the net that comes back runs to 1e10 mm for a surface a few mm deep. Two
# settles it and is worth four orders of accuracy; more buys nothing measurable.
FIT_DATA_FACTOR = 2
[docs]
class NurbsGeometry(BaseGeometry):
"""Creates a NURBS (Non-Uniform Rational Basis Spline) geometry.
This class can be used to represent polynomial and rational Bézier,
B-Spline, and NURBS surfaces. The type of surface depends on the
initialization arguments.
Args:
coordinate_system: The coordinate system of the geometry.
radius: The radius of curvature of the base conic. (ignored if control
points are passed)
conic: The conic constant of the base conic. (ignored if control
points are passed)
nurbs_norm_x: Defines, along with nurbs_norm_y, x_center, and y_center,
the rectangular area for the fit of the surface with the NURBS.
If None, scales automatically during paraxial updates. Defaults to None.
nurbs_norm_y: Defines, along with nurbs_norm_x, x_center, and y_center,
the rectangular area for the fit of the surface with the NURBS.
If None, scales automatically during paraxial updates. Defaults to None.
x_center: Defines, along with nurbs_norm_x, nurbs_norm_y, and y_center,
the rectangular area for the fit of the surface with the NURBS.
y_center: Defines, along with nurbs_norm_x, nurbs_norm_y, and x_center,
the rectangular area for the fit of the surface with the NURBS.
control_points: An array with shape (ndim, n+1, m+1) containing the
coordinates of the control points. The first dimension of ´P´ spans
the coordinates of the control points (any number of dimensions),
the second dimension of ´P´ spans the u-direction control points
(0, 1, ..., n), and the third dimension of ´P´ spans the v-direction
control points (0, 1, ..., m).
weights: An array with shape (n+1, m+1) containing the weight of the
control points. The first dimension of ´W´ spans the u-direction
control points weights (0, 1, ..., n), and the second dimension of
´W´ spans the v-direction control points weights (0, 1, ..., m).
u_degree: The degree of the u-basis polynomials.
v_degree: The degree of the v-basis polynomials.
u_knots: A knot vector in the u-direction with shape (r+1=n+p+2,). Set
the multiplicity of the first and last entries equal to ´p+1´ to
obtain a clamped spline.
v_knots: A knot vector in the v-direction with shape (s+1=m+q+2,). Set
the multiplicity of the first and last entries equal to ´q+1´ to
obtain a clamped spline.
n_points_u: Defines the grid size of control points (n_points_u x
n_points_v). The default value is updated in case control points
are passed.
n_points_v: Defines the grid size of control points (n_points_u x
n_points_v). The default value is updated in case control points
are passed.
tol: The tolerance for Newton-Raphson iteration.
max_iter: The maximum number of iterations for Newton-Raphson.
max_stall: How many iterations a ray may go without improving before it
is taken as settled. A root off the patch is pinned by the clamp and
never reaches tol; without this it would iterate to max_iter.
Every net this module fits now lands at 2 -- measured over 24 of
them, spanning n_points 24 to 48 and radii 25 to 200. 12 is headroom
for control points supplied by a CALLER, which this module cannot
vouch for: a badly conditioned net does not converge steadily, its
residual falling in fits, and three quiet iterations are then not
evidence a ray is done. The headroom is close to free -- a ray ON the
patch lands in a single iteration whatever this says, and only a ray
off the patch pays, exactly max_stall + 1 against the 100 it used
to.
References:
- The NURBS Book. See references to equations and algorithms throughout
the code. L. Piegl and W. Tiller. Springer, second edition.
- Curves and Surfaces for CADGD. See references to equations in the
source code. G. Farin. Morgan Kaufmann Publishers, fifth edition.
- All references correspond to The NURBS book unless it is explicitly
stated that they come from Farin's book.
"""
def __init__(
self,
coordinate_system,
radius=be.inf,
conic=0.0,
nurbs_norm_x=None,
nurbs_norm_y=None,
x_center=0.0,
y_center=0.0,
control_points=None,
weights=None,
u_degree=None,
v_degree=None,
u_knots=None,
v_knots=None,
n_points_u=4,
n_points_v=4,
tol=1e-10,
max_iter=100,
max_stall=12,
):
super().__init__(coordinate_system)
self.P = be.asarray(control_points) if control_points is not None else None
self.W = be.asarray(weights) if weights is not None else None
self.p = u_degree
self.q = v_degree
self.U = be.asarray(u_knots) if u_knots is not None else None
self.V = be.asarray(v_knots) if v_knots is not None else None
self.normalization_mode = (
"manual"
if (nurbs_norm_x is not None or nurbs_norm_y is not None)
else "auto"
)
self.nurbs_norm_x = nurbs_norm_x if nurbs_norm_x is not None else 1.0
self.nurbs_norm_y = nurbs_norm_y if nurbs_norm_y is not None else 1.0
self.x_center = x_center
self.y_center = y_center
self.radius = radius
self.k = conic
self.tol = tol
self.max_iter = max_iter
self.max_stall = max_stall
self.is_symmetric = False
# If control points are not provided, the NURBS is obtained as a fit
# of a standard surface.
if control_points is None:
self.is_fitted = True
self.ndim = 3
# The CONTROL count, which is what n_points_u is documented to be.
# It used to be that plus one, being read as a data count by the
# standard-surface fit and as a control count by the plane one -- so
# the same n_points_u gave nets a size apart depending on the branch.
self.P_size_u = n_points_u
self.P_size_v = n_points_v
# Polynomial Bezier surface initialization
elif (
weights is None
and u_degree is None
and u_knots is None
and v_degree is None
and v_knots is None
):
self.is_fitted = False
self.surface_type = "Bezier"
self.ndim = be.shape(control_points)[0]
n = be.shape(control_points)[1] - 1
m = be.shape(control_points)[2] - 1
self.P_size_u = n + 1
self.P_size_v = m + 1
weights = be.ones((n + 1, m + 1), dtype=control_points.dtype)
u_degree = n
v_degree = m
u_knots = be.concatenate(
(
be.zeros(u_degree),
be.linspace(0, 1, n - u_degree + 2),
be.ones(u_degree),
)
)
v_knots = be.concatenate(
(
be.zeros(v_degree),
be.linspace(0, 1, m - v_degree + 2),
be.ones(v_degree),
)
)
# Rational Bezier surface initialization
elif (
u_degree is None
and u_knots is None
and v_degree is None
and v_knots is None
):
self.is_fitted = False
self.surface_type = "R-Bezier"
self.ndim = be.shape(control_points)[0]
n = be.shape(control_points)[1] - 1
m = be.shape(control_points)[2] - 1
self.P_size_u = n + 1
self.P_size_v = m + 1
u_degree = n
v_degree = m
u_knots = be.concatenate(
(
be.zeros(u_degree),
be.linspace(0, 1, n - u_degree + 2),
be.ones(u_degree),
)
)
v_knots = be.concatenate(
(
be.zeros(v_degree),
be.linspace(0, 1, m - v_degree + 2),
be.ones(v_degree),
)
)
# B-Spline surface initialization
elif weights is None and u_knots is not None and v_knots is not None:
self.is_fitted = False
self.surface_type = "B-Spline"
self.ndim = be.shape(control_points)[0]
n = be.shape(control_points)[1] - 1
m = be.shape(control_points)[2] - 1
self.P_size_u = n + 1
self.P_size_v = m + 1
weights = be.ones((n + 1, m + 1), dtype=control_points.dtype)
# B-Spline surface initialization
elif weights is None and u_knots is None and v_knots is None:
self.is_fitted = False
self.surface_type = "B-Spline"
self.ndim = be.shape(control_points)[0]
n = be.shape(control_points)[1] - 1
m = be.shape(control_points)[2] - 1
self.P_size_u = n + 1
self.P_size_v = m + 1
u_knots = be.concatenate(
(
be.zeros(u_degree),
be.linspace(0, 1, n - u_degree + 2),
be.ones(u_degree),
)
)
v_knots = be.concatenate(
(
be.zeros(v_degree),
be.linspace(0, 1, m - v_degree + 2),
be.ones(v_degree),
)
)
weights = be.ones((n + 1, m + 1), dtype=control_points.dtype)
# NURBS surface initialization
else:
self.is_fitted = False
self.surface_type = "NURBS"
if u_knots is None and v_knots is None:
n = be.shape(control_points)[1] - 1
m = be.shape(control_points)[2] - 1
self.P_size_u = n + 1
self.P_size_v = m + 1
u_knots = be.concatenate(
(
be.zeros(u_degree),
be.linspace(0, 1, n - u_degree + 2),
be.ones(u_degree),
)
)
v_knots = be.concatenate(
(
be.zeros(v_degree),
be.linspace(0, 1, m - v_degree + 2),
be.ones(v_degree),
)
)
self.ndim = be.shape(control_points)[0]
# The branches above fill in whatever the caller left out -- weights, a
# degree, a clamped knot vector -- as locals. Written back here, or the
# surface keeps the None it was constructed with and evaluates to nothing.
for name, value in (
("W", weights),
("p", u_degree),
("q", v_degree),
("U", u_knots),
("V", v_knots),
):
if getattr(self, name) is None and value is not None:
setattr(
self, name, be.asarray(value) if name not in ("p", "q") else value
)
[docs]
def set_radius(self, value: float) -> None:
"""Set the radius of curvature.
Args:
value (float): The new radius of curvature.
"""
self.radius = be.array(value)
[docs]
def flip(self):
"""Flip the geometry.
This method changes the sign of the radius and the z-coordinate of the
control points.
"""
self.radius = -self.radius
if self.P is not None:
self.P[2, :, :] = -self.P[2, :, :]
[docs]
def scale(self, scale_factor: float):
"""Scale the geometry parameters.
Args:
scale_factor (float): The factor by which to scale the geometry.
"""
self.radius = self.radius * scale_factor
if self.nurbs_norm_x is not None:
self.nurbs_norm_x = self.nurbs_norm_x * scale_factor
if self.nurbs_norm_y is not None:
self.nurbs_norm_y = self.nurbs_norm_y * scale_factor
self.x_center = self.x_center * scale_factor
self.y_center = self.y_center * scale_factor
if self.P is not None:
self.P = self.P * scale_factor
[docs]
def update_normalization(self, semi_aperture: float) -> None:
if self.normalization_mode == "auto":
self.nurbs_norm_x = semi_aperture * 1.25
self.nurbs_norm_y = semi_aperture * 1.25
if getattr(self, "is_fitted", False):
self.fit_surface()
[docs]
def get_value(self, u, v):
"""Evaluates the coordinates of the surface.
Args:
u: The u-parameter used to evaluate the surface.
v: The v-parameter used to evaluate the surface.
Returns:
An array containing the coordinates of the surface.
"""
if be.isscalar(u) and be.isscalar(v) or be.size(u) == be.size(v):
pass
else:
raise Exception("u and v must have the same size")
if u.ndim > 1:
a, b = u.shape
u = be.ravel(u)
v = be.ravel(v)
S = self.compute_nurbs_coordinates(
self.P, self.W, self.p, self.q, self.U, self.V, u, v
)
S = be.reshape(S, (self.ndim, a, b))
else:
S = self.compute_nurbs_coordinates(
self.P, self.W, self.p, self.q, self.U, self.V, u, v
)
return S
[docs]
@staticmethod
def compute_nurbs_coordinates(P, W, p, q, U, V, u, v):
"""Evaluates the coordinates of the NURBS surface.
This function computes the coordinates of the NURBS surface in
homogeneous space using equation 4.15 and then maps the coordinates
to ordinary space using the perspective map given by equation 1.16. See
algorithm A4.3 in The NURBS Book.
Args:
P: An array containing the coordinates of the control points.
W: An array containing the weight of the control points.
p: The degree of the u-basis polynomials.
q: The degree of the v-basis polynomials.
U: The knot vector in the u-direction.
V: The knot vector in the v-direction.
u: The u-parameter used to evaluate the surface.
v: The v-parameter used to evaluate the surface.
Returns:
An array containing the NURBS surface coordinates.
"""
if P.ndim > 3:
raise Exception("P must be an array of shape (ndim, n+1, m+1)")
if W.ndim > 2:
raise Exception("W must be an array of shape (n+1, m+1)")
if U.ndim > 1:
raise Exception("U must be an array of shape (r+1=n+p+2,)")
if V.ndim > 1:
raise Exception("V must be an array of shape (s+1=m+q+2,)")
if be.isscalar(u):
u = be.asarray(u)
elif u.ndim > 1:
raise Exception("u must be a scalar or an array of shape (N,)")
if be.isscalar(v):
v = be.asarray(v)
elif u.ndim > 1:
raise Exception("v must be a scalar or an array of shape (N,)")
n_dim, nn, mm = be.shape(P)
n = nn - 1
m = mm - 1
if be.get_backend() == "torch":
np_u = be.to_numpy(u).copy().astype(np.float64)
np_U = be.to_numpy(U).copy().astype(np.float64)
np_v = be.to_numpy(v).copy().astype(np.float64)
np_V = be.to_numpy(V).copy().astype(np.float64)
N_basis_u_np = compute_basis_polynomials(n, p, np_U, np_u)
N_basis_v_np = compute_basis_polynomials(m, q, np_V, np_v)
N_basis_u = be.asarray(N_basis_u_np)
N_basis_v = be.asarray(N_basis_v_np)
else:
N_basis_u = compute_basis_polynomials(n, p, U, u)
N_basis_v = compute_basis_polynomials(m, q, V, v)
P_w = be.concatenate((P * W[None, :], W[None, :]), axis=0)
A = be.matmul(P_w, N_basis_v)
B = be.stack([N_basis_u] * (n_dim + 1), axis=0)
S_w = be.sum(A * B, axis=1)
S = S_w[0:-1, :] / S_w[-1, :]
return S
[docs]
@staticmethod
def compute_bspline_coordinates(P, p, q, U, V, u, v):
"""Evaluates the coordinates of the B-Spline surface.
This function computes the coordinates of a B-Spline surface as given
by equation 3.11. See algorithm A3.5 in The NURBS Book.
Args:
P: An array containing the coordinates of the control points.
p: The degree of the u-basis polynomials.
q: The degree of the v-basis polynomials.
U: The knot vector in the u-direction.
V: The knot vector in the v-direction.
u: The u-parameter used to evaluate the surface.
v: The v-parameter used to evaluate the surface.
Returns:
An array containing the B-Spline surface coordinates.
"""
if P.ndim > 3:
raise Exception("P must be an array of shape (ndim, n+1, m+1)")
if not be.isscalar(p):
raise Exception("p must be an scalar")
if not be.isscalar(q):
raise Exception("q must be an scalar")
if U.ndim > 1:
raise Exception("U must be an array of shape (r+1=n+p+2,)")
if V.ndim > 1:
raise Exception("V must be an array of shape (s+1=m+q+2,)")
if be.isscalar(u):
u = be.asarray(u)
elif u.ndim > 1:
raise Exception("u must be a scalar or an array of shape (N,)")
if be.isscalar(v):
v = be.asarray(v)
elif u.ndim > 1:
raise Exception("v must be a scalar or an array of shape (N,)")
n_dim, nn, mm = be.shape(P)
n = nn - 1
m = mm - 1
N_basis_u = compute_basis_polynomials(n, p, U, u)
N_basis_v = compute_basis_polynomials(m, q, V, v)
A = be.dot(P, N_basis_v)
B = be.repeat(N_basis_u[be.newaxis], repeats=n_dim, axis=0)
S = be.sum(A * B, axis=1)
return S
[docs]
def get_derivative(self, u, v, order_u, order_v):
"""Evaluates the derivative of the surface.
Args:
u: The u-parameter used to evaluate the surface.
v: The v-parameter used to evaluate the surface.
order_u: The order of the partial derivative in the u-direction.
order_v: The order of the partial derivative in the v-direction.
Returns:
An array containing the derivative of the desired order.
"""
if u.ndim > 1:
a, b = u.shape
u = be.ravel(u)
v = be.ravel(v)
dS = self.compute_nurbs_derivatives(
self.P, self.W, self.p, self.q, self.U, self.V, u, v, order_u, order_v
)[order_u, order_v, ...]
dS = be.reshape(dS, (self.ndim, a, b))
else:
dS = self.compute_nurbs_derivatives(
self.P, self.W, self.p, self.q, self.U, self.V, u, v, order_u, order_v
)[order_u, order_v, ...]
return dS
[docs]
def compute_nurbs_derivatives(
self, P, W, p, q, U, V, u, v, up_to_order_u, up_to_order_v
):
"""Computes the derivatives of a NURBS surface.
This function computes the analytic derivatives of the NURBS surface
in ordinary space using equation 4.20 and the derivatives of the NURBS
surface in homogeneous space obtained from
compute_bspline_derivatives().
The derivatives are computed recursively in a fashion similar to
algorithm A4.4 in The NURBS Book.
Args:
P: An array containing the coordinates of the control points.
W: An array containing the weight of the control points.
p: The degree of the u-basis polynomials.
q: The degree of the v-basis polynomials.
U: The knot vector in the u-direction.
V: The knot vector in the v-direction.
u: The u-parameter used to evaluate the surface.
v: The v-parameter used to evaluate the surface.
up_to_order_u: The order of the highest derivative in the
u-direction.
up_to_order_v: The order of the highest derivative in the
v-direction.
Returns:
An array containing the NURBS surface derivatives.
"""
u, v = be.asarray(u), be.asarray(v)
P_w = be.concatenate((P * W[None, :], W[None, :]), axis=0)
bspline_derivatives = self.compute_bspline_derivatives(
P_w, p, q, U, V, u, v, up_to_order_u, up_to_order_v
)
A_ders = bspline_derivatives[:, :, 0:-1, :]
w_ders = bspline_derivatives[:, :, [-1], :]
_n_dim, _N = be.shape(P)[0], be.size(u)
rows = []
for k in range(up_to_order_u + 1):
cols = []
for L in range(up_to_order_v + 1):
temp_numerator = A_ders[k, L, ...]
for i in range(1, k + 1):
temp_numerator = temp_numerator - (
binom(k, i) * w_ders[i, 0, ...] * rows[k - i][L]
)
for j in range(1, L + 1):
temp_numerator = temp_numerator - (
binom(L, j) * w_ders[0, j, ...] * cols[L - j]
)
for i in range(1, k + 1):
for j in range(1, L + 1):
temp_numerator = temp_numerator - (
binom(k, i)
* binom(L, j)
* w_ders[i, j, ...]
* rows[k - i][L - j]
)
cols.append(temp_numerator / w_ders[0, 0, ...])
rows.append(be.stack(cols, axis=0))
nurbs_derivatives = be.stack(rows, axis=0)
return nurbs_derivatives
[docs]
@staticmethod
def compute_bspline_derivatives(P, p, q, U, V, u, v, up_to_order_u, up_to_order_v):
"""Computes the derivatives of a B-Spline surface.
This function computes the analytic derivatives of a B-Spline surface
using equation 3.17. See algorithm A3.6 in The NURBS Book.
Args:
P: An array containing the coordinates of the control points.
p: The degree of the u-basis polynomials.
q: The degree of the v-basis polynomials.
U: The knot vector in the u-direction.
V: The knot vector in the v-direction.
u: The u-parameter used to evaluate the surface.
v: The v-parameter used to evaluate the surface.
up_to_order_u: The order of the highest derivative in the
u-direction.
up_to_order_v: The order of the highest derivative in the
v-direction.
Returns:
An array containing the B-Spline surface derivatives.
"""
u = be.asarray(u)
n_dim, _N = be.shape(P)[0], be.size(u)
rows = []
for order_u in range(min(p, up_to_order_u) + 1):
cols = []
for order_v in range(min(q, up_to_order_v) + 1):
n = be.shape(P)[1] - 1
m = be.shape(P)[2] - 1
if be.get_backend() == "torch":
np_u = be.to_numpy(u).copy().astype(np.float64)
np_U = be.to_numpy(U).copy().astype(np.float64)
np_v = be.to_numpy(v).copy().astype(np.float64)
np_V = be.to_numpy(V).copy().astype(np.float64)
N_basis_u_np = compute_basis_polynomials_derivatives(
n, p, np_U, np_u, order_u
)
N_basis_v_np = compute_basis_polynomials_derivatives(
m, q, np_V, np_v, order_v
)
N_basis_u = be.asarray(N_basis_u_np)
N_basis_v = be.asarray(N_basis_v_np)
else:
N_basis_u = compute_basis_polynomials_derivatives(
n, p, U, u, order_u
)
N_basis_v = compute_basis_polynomials_derivatives(
m, q, V, v, order_v
)
A = be.matmul(P, N_basis_v)
B = be.stack([N_basis_u] * n_dim, axis=0)
cols.append(be.sum(A * B, axis=1))
rows.append(be.stack(cols, axis=0))
bspline_derivatives = be.stack(rows, axis=0)
return bspline_derivatives
[docs]
def get_normals(self, u, v):
"""Evaluates the unitary vectors normal to the surface.
The definition of the unitary normal vector is given in section 19.2
of Farin's textbook.
Args:
u: The u-parameter used to evaluate the normals.
v: The v-parameter used to evaluate the normals.
Returns:
An array containing the unitary vectors normal to the surface.
"""
S_u = self.get_derivative(u, v, order_u=1, order_v=0)
S_v = self.get_derivative(u, v, order_u=0, order_v=1)
normals = be.cross(S_u, S_v, axisa=0, axisb=0, axisc=0)
normals = normals / be.sum(normals**2, axis=0) ** (1 / 2)
return normals
def _corr_general(self, u, v, d1, d2, N1, N2):
"""Defines the correction step for the update of u,v coordinates.
See paper Practical ray tracing of trimmed NURBS surface - section 2.3.
Args:
u: The u-parameter.
v: The v-parameter.
d1: Contains minus dot product of N1 rows with ray (x,y,z).
d2: Contains minus dot product of N2 rows with ray (x,y,z).
N1: Each row of N1 is a normal vector of a plane.
N2: Each row of N2 is a normal vector of a plane. Intersection of
planes defined by N1[i,:] and N2[i,:] define a specific ray.
Returns:
A tuple containing the correction steps for u and v parameters,
and the distance between each ray and its surface intersection.
"""
S_uv = self.get_value(u, v).T
r = be.stack(
[be.sum(N1 * S_uv, axis=1) + d1, be.sum(N2 * S_uv, axis=1) + d2], axis=0
)
_, Np = r.shape
a = be.sum(N1 * self.get_derivative(u, v, order_u=1, order_v=0).T, axis=1)
b = be.sum(N1 * self.get_derivative(u, v, order_u=0, order_v=1).T, axis=1)
c = be.sum(N2 * self.get_derivative(u, v, order_u=1, order_v=0).T, axis=1)
d = be.sum(N2 * self.get_derivative(u, v, order_u=0, order_v=1).T, axis=1)
J = be.vstack((a, b, c, d)).T.reshape((Np, 2, 2))
adj = be.stack(
[
be.stack([J[:, 1, 1], -J[:, 0, 1]], axis=1),
be.stack([-J[:, 1, 0], J[:, 0, 0]], axis=1),
],
axis=1,
)
detJ = be.linalg.det(J)
detJ = detJ[:, None, None]
invj = adj / detJ
correction = be.einsum("ijk,ki->ji", invj, r)
# One residual PER RAY: _newton has to tell a ray still closing on its root
# from one the clamp has pinned, and a bundle-wide max hides that
residual = be.maximum(be.abs(r[0, :]), be.abs(r[1, :]))
return correction, residual
def _corr(self, u, v, d1, d2):
"""Computes the intersection between rays and NURBS surface.
This is a specific version of _corr_general that computes the
intersection between rays and the NURBS surface when the rays'
direction is along the Z-axis. This is used to compute the surface
sag.
Args:
u: The u-parameter.
v: The v-parameter.
d1: A parameter related to the ray's position.
d2: A parameter related to the ray's position.
Returns:
A tuple containing the correction steps and the per-ray residual.
"""
S_uv = self.get_value(u, v)
r = be.stack([S_uv[1, :] + d1, S_uv[0, :] + d2], axis=0)
_, Np = r.shape
a = self.get_derivative(u, v, order_u=1, order_v=0)[1, :]
b = self.get_derivative(u, v, order_u=0, order_v=1)[1, :]
c = self.get_derivative(u, v, order_u=1, order_v=0)[0, :]
d = self.get_derivative(u, v, order_u=0, order_v=1)[0, :]
J = be.vstack((a, b, c, d)).T.reshape((Np, 2, 2))
adj = be.stack(
[
be.stack([J[:, 1, 1], -J[:, 0, 1]], axis=1),
be.stack([-J[:, 1, 0], J[:, 0, 0]], axis=1),
],
axis=1,
)
detJ = be.linalg.det(J)
detJ = detJ[:, None, None]
invj = adj / detJ
correction = be.einsum("ijk,ki->ji", invj, r)
# One residual PER RAY: _newton has to tell a ray still closing on its root
# from one the clamp has pinned, and a bundle-wide max hides that
residual = be.maximum(be.abs(r[0, :]), be.abs(r[1, :]))
return correction, residual
def _seed(self, x, y):
"""Where in (u, v) to start the Newton for points at x, y: their place across
the patch, u running with x and v with y. Seeded at a corner instead, the
iteration walks off the patch or onto a neighbouring root, and no number of
restarts brings it back.
Measured off the FOUR CORNER control points, which a clamped spline
interpolates, not off the whole net -- a least squares run near
interpolation leaves interior control points decades away from the surface
they describe, and a box drawn round those seeds every point at one edge.
"""
corners = self.P[:, :: self.P.shape[1] - 1, :: self.P.shape[2] - 1]
def fraction(values, axis):
low, high = be.min(corners[axis]), be.max(corners[axis])
span = high - low
return be.clip((values - low) / be.where(span > 0.0, span, 1.0), 0.0, 1.0)
return fraction(be.ravel(x), 0), fraction(be.ravel(y), 1)
def _newton(self, corrector, u, v):
"""Drive one of the two correction steps to its root, clamped to the patch.
A root off the patch never reaches `tol` -- the clamp pins it and the same
correction returns for ever -- so a ray is done once it stops IMPROVING.
"""
best_u, best_v = u, v
best = be.full(be.shape(u), be.inf)
stalled = be.zeros(be.shape(u))
for _ in range(self.max_iter):
correction, residual = corrector(u, v)
# Kept by residual rather than by iteration: the pinned rays wander
# along the rim, so the last (u, v) need not be the closest one
improved = residual < best - self.tol
best_u = be.where(improved, u, best_u)
best_v = be.where(improved, v, best_v)
best = be.where(improved, residual, best)
stalled = be.where(improved, 0.0, stalled + 1.0)
if be.all(be.logical_or(best < self.tol, stalled >= self.max_stall)):
break
u = be.clip(u - correction[0, :], 0.0, 1.0)
v = be.clip(v - correction[1, :], 0.0, 1.0)
return best_u, best_v
[docs]
def sag(self, x=0, y=0):
"""Computes the surface sag.
The sag is computed for a specific x,y from the z-coordinate of the
intersection point between the ray with direction (0,0,1) and passing
from (x,y,0) and the NURBS surface.
Args:
x: The x-coordinate.
y: The y-coordinate.
Returns:
The surface sag.
"""
# Scalars are as legal an argument as arrays -- a caller sampling one
# height at a time is asking the same question of the same surface
x, y = be.asarray(x), be.asarray(y)
shape = be.shape(x)
u, v = self._newton(
lambda u, v: self._corr(u, v, -be.ravel(y), -be.ravel(x)), *self._seed(x, y)
)
return be.reshape(self.get_value(u, v)[2, :], shape)
[docs]
def distance(self, rays):
"""Finds the propagation distance to the geometry for the given rays.
The approach is described in the paper "Practical ray tracing of
trimmed NURBS surfaces" from William Martin etc.
Args:
rays: The rays for which to calculate the distance.
Returns:
An array of distances from each ray's current position to its
intersection point with the geometry.
"""
N1x = be.zeros_like(rays.x)
N1y = be.zeros_like(rays.x)
N1z = be.zeros_like(rays.x)
mask = be.logical_and(rays.L > rays.M, rays.L > rays.N)
# be.where evaluates both sides, so the branch a ray did NOT take still
# divides -- and an axial ray makes the unused one 0/0. Floored rather
# than warned about: the quotient is discarded either way.
def normalized(numerator, first, second):
return numerator / be.maximum(be.sqrt(first**2 + second**2), 1e-14)
N1x = be.where(mask, normalized(rays.M, rays.L, rays.M), N1x)
N1y = be.where(mask, normalized(-rays.L, rays.L, rays.M), N1y)
N1y = be.where(~mask, normalized(rays.N, rays.N, rays.M), N1y)
N1z = be.where(~mask, normalized(-rays.M, rays.N, rays.M), N1z)
N1 = be.column_stack((N1x, N1y, N1z))
d = be.column_stack((rays.L, rays.M, rays.N))
N2 = be.cross(N1, d)
P0 = be.column_stack((rays.x, rays.y, rays.z))
d1 = -be.sum(N1 * P0, axis=1)
d2 = -be.sum(N2 * P0, axis=1)
# Where the ray crosses the vertex plane, which is the seed the sag takes
# -- the surface departs from that plane by its sag and nothing more
along = be.where(be.abs(rays.N) > 1e-14, rays.N, 1e-14)
u, v = self._newton(
lambda u, v: self._corr_general(u, v, d1, d2, N1, N2),
*self._seed(
rays.x - rays.L * rays.z / along, rays.y - rays.M * rays.z / along
),
)
# Signed ALONG the ray rather than |S - P0|: an intersection behind the ray
# is a step backwards, and reporting it as an equal step forward puts the
# ray on the wrong side of itself. A plain trace rarely looks back, but an
# iterative aimer probes both ways, and no line search recovers from that.
t = be.sum((self.get_value(u, v).T - P0) * d, axis=1)
return t
[docs]
def surface_normal(self, rays):
"""Computes the surface normal.
Args:
rays: The rays for which to calculate the surface normal.
Returns:
A tuple containing the x, y, and z components of the surface
normal.
"""
x = rays.x
y = rays.y
# The rays are already ON the surface here, so their own x, y is the seed
u, v = self._newton(lambda u, v: self._corr(u, v, -y, -x), *self._seed(x, y))
n = self.get_normals(u, v)
nx = n[0, :]
ny = n[1, :]
nz = n[2, :]
return nx, ny, nz
def __str__(self) -> str:
return "NURBS"
[docs]
def fit_surface(self):
"""Handles the NURBS surface approximation.
This function calls specific functions depending on the nature of the
surface that we want to fit. For the time being, standard surface and
plane surface are implemented.
"""
if be.isinf(self.radius):
self._plane_surface()
else:
self._standard_surface()
def _standard_surface(self):
"""Generates NURBS parameters based on a standard surface."""
radius = self.radius
k = self.k
nurbs_norm_x = self.nurbs_norm_x
nurbs_norm_y = self.nurbs_norm_y
xc = self.x_center
yc = self.y_center
P_ndim = self.ndim
u_degree = 3
v_degree = 3
# The control net keeps the size it was ASKED for, and the data grid is
# its own and denser. Reading both off P_size_u made the least squares
# square, and made every RE-fit shrink the net by one -- which matters
# because update_normalization refits on each paraxial pass.
num_cpts_u = max(self.P_size_u, u_degree + 1)
num_cpts_v = max(self.P_size_v, v_degree + 1)
size_u = FIT_DATA_FACTOR * num_cpts_u
size_v = FIT_DATA_FACTOR * num_cpts_v
x = be.linspace(xc - nurbs_norm_x, xc + nurbs_norm_x, size_u)
y = be.linspace(yc - nurbs_norm_y, yc + nurbs_norm_y, size_v)
x, y = be.meshgrid(x, y)
r2 = x**2 + y**2
# A conic has no sag past its own edge, and the corners of a square box
# reach there on a fast surface. The root is floored so those corners
# carry the edge's own slope on instead of coming back NaN, which the
# fit does not accept -- it raises rather than fitting anything at all.
radicand = 1 - (1 + k) * r2 / radius**2
root = be.sqrt(be.where(radicand > 0.0, radicand, 0.0))
z = r2 / (radius * (1 + root))
points = be.stack((x.T, y.T, z.T), axis=0)
xp = (points.reshape(P_ndim, -1).T).tolist()
ctrlpts, u_degree, v_degree, num_cpts_u, num_cpts_v, kv_u, kv_v = (
approximate_surface(
xp,
size_u,
size_v,
u_degree,
v_degree,
ctrlpts_size_u=num_cpts_u,
ctrlpts_size_v=num_cpts_v,
)
)
self.P_size_u = num_cpts_u
self.P_size_v = num_cpts_v
ctrlpts = be.asarray(ctrlpts)
ctrlpts = (ctrlpts.T).reshape((P_ndim, num_cpts_u, num_cpts_v))
weights = be.ones((num_cpts_u, num_cpts_v))
u_knots = be.asarray(kv_u)
v_knots = be.asarray(kv_v)
self.surface_type = "NURBS"
self.P = ctrlpts
self.W = weights
self.p = u_degree
self.q = v_degree
self.U = u_knots
self.V = v_knots
def _plane_surface(self):
"""Generates a plane surface."""
x = be.linspace(
self.x_center - self.nurbs_norm_x,
self.x_center + self.nurbs_norm_x,
self.P_size_u,
)
y = be.linspace(
self.y_center - self.nurbs_norm_y,
self.y_center + self.nurbs_norm_y,
self.P_size_v,
)
x, y = be.meshgrid(x, y)
z = be.zeros_like(x)
control_points = be.stack((x.T, y.T, z.T), axis=0)
weights = be.ones((self.P_size_u, self.P_size_v))
n = be.shape(control_points)[1] - 1
m = be.shape(control_points)[2] - 1
u_degree = 3
v_degree = 3
u_knots = be.concatenate(
(
be.zeros(u_degree),
be.linspace(0, 1, n - u_degree + 2),
be.ones(u_degree),
)
)
v_knots = be.concatenate(
(
be.zeros(v_degree),
be.linspace(0, 1, m - v_degree + 2),
be.ones(v_degree),
)
)
self.surface_type = "NURBS"
self.P = control_points
self.W = weights
self.p = u_degree
self.q = v_degree
self.U = u_knots
self.V = v_knots