Source code for optable.surfaces

from .base import *
from .solver import *


[docs] class Surface(Base):
[docs] def __init__(self): """ local coordinate system """ super().__init__() self.planar = True # True if the surface is planar
def _normalize_vector(self, vector) -> np.ndarray: vec = np.array(vector, dtype=float) return vec / np.linalg.norm(vec)
[docs] def f(self, P: np.ndarray) -> float: """f(x,y,z) = 0""" raise NotImplementedError( "Method 'f' must be implemented in the derived class." )
[docs] def normal(self, P: np.ndarray) -> np.ndarray: """Return the normal vector at the point.""" raise NotImplementedError( "Method 'normal' must be implemented in the derived class." )
[docs] def within_boundary(self, P: np.ndarray) -> bool: """Check if the point is inside the boundary.""" raise NotImplementedError( "Method 'within_boundary' must be implemented in the derived class." )
[docs] def parametric_boundary_3d(self, t: float) -> np.ndarray: """Return the point on the boundary given the parameter t=[0,1].""" raise NotImplementedError( "Method 'parametric_boundary' must be implemented in the derived class" )
[docs] def merge_bbox(self, bbox1, bbox2): return ( min(bbox1[0], bbox2[0]), max(bbox1[1], bbox2[1]), min(bbox1[2], bbox2[2]), max(bbox1[3], bbox2[3]), min(bbox1[4], bbox2[4]), max(bbox1[5], bbox2[5]), )
[docs] def merge_bboxs(self, bboxs): return ( np.min([b[0] for b in bboxs]), np.max([b[1] for b in bboxs]), np.min([b[2] for b in bboxs]), np.max([b[3] for b in bboxs]), np.min([b[4] for b in bboxs]), np.max([b[5] for b in bboxs]), )
[docs] def solve_crosssection_ray_bbox_local( self, ray_origin, ray_direction ) -> Tuple[float]: bbox_local = self.get_bbox_local() return solve_ray_bboxes_intersections(ray_origin, ray_direction, bbox_local)
[docs] class Point(Surface):
[docs] def __init__(self): super().__init__() self.planar = False
[docs] def f(self, P: np.ndarray) -> float: return np.linalg.norm(P)
[docs] def normal(self, P: np.ndarray) -> np.ndarray: return P / np.linalg.norm(P)
[docs] def within_boundary(self, P: np.ndarray) -> bool: return np.linalg.norm(P) < 1e-12 # only the origin
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: return np.zeros((3, len(t)))
[docs] def get_bbox_local(self): return (0, 0, 0, 0, 0, 0)
[docs] class Plane(Surface):
[docs] def __init__(self): super().__init__() self._normal = np.array([1, 0, 0])
[docs] def f(self, P: np.ndarray) -> float: return np.dot(self._normal, P)
[docs] def normal(self, P: np.ndarray) -> np.ndarray: return self._normal
[docs] def union(self, other): obj = Plane() def within_boundary(P): return self.within_boundary(P) or other.within_boundary(P) def parametric_boundary(t, type): return np.hstack( [self.parametric_boundary(t, type), other.parametric_boundary(t, type)] ) def get_bbox_local(): return self.merge_bbox(self.get_bbox_local(), other.get_bbox_local()) obj.within_boundary = within_boundary obj.parametric_boundary = parametric_boundary obj.get_bbox_local = get_bbox_local return obj
[docs] def subtract(self, other): obj = Plane() def within_boundary(P): return self.within_boundary(P) and not other.within_boundary(P) def parametric_boundary(t, type): return np.hstack( [self.parametric_boundary(t, type), other.parametric_boundary(t, type)] ) def get_bbox_local(): return self.merge_bbox(self.get_bbox_local(), other.get_bbox_local()) obj.within_boundary = within_boundary obj.parametric_boundary = parametric_boundary obj.get_bbox_local = get_bbox_local return obj
[docs] class Circle(Plane):
[docs] def __init__(self, radius): super().__init__() self.radius = radius
[docs] def within_boundary(self, P: np.ndarray) -> bool: return np.linalg.norm(P) <= self.radius
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: if type == "Z": x = np.array([0, 0]) y = np.array([-self.radius, self.radius]) z = np.array([0, 0]) elif type == "3D": theta = 2 * np.pi * t x = np.zeros_like(theta) y = self.radius * np.cos(theta) z = self.radius * np.sin(theta) points = np.vstack([x, y, z]) return points
[docs] def get_bbox_local(self): return (0, 0, -self.radius, self.radius, -self.radius, self.radius)
[docs] class Rectangle(Plane):
[docs] def __init__(self, width, height): super().__init__() self.width = width # y self.height = height # z
[docs] def within_boundary(self, P: np.ndarray) -> bool: return np.abs(P[1]) <= self.width / 2 and np.abs(P[2]) <= self.height / 2
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: if type == "Z": x = np.array([0, 0]) y = np.array([-self.width / 2, self.width / 2]) z = np.array([0, 0]) elif type == "3D" or type in ["X", "Y"]: x = np.zeros(5) y = np.array( [ -self.width / 2, self.width / 2, self.width / 2, -self.width / 2, -self.width / 2, ] ) z = np.array( [ -self.height / 2, -self.height / 2, self.height / 2, self.height / 2, -self.height / 2, ] ) points = np.vstack([x, y, z]) return points
[docs] def get_bbox_local(self): return ( 0, 0, -self.width / 2, self.width / 2, -self.height / 2, self.height / 2, )
[docs] class Cylinder(Surface):
[docs] def __init__(self, radius, height, theta_range=(-np.pi, np.pi)): super().__init__() self.radius = radius self.height = height self.theta_range = theta_range self.planar = False
def _theta(self, t): return self.theta_range[0] + (self.theta_range[1] - self.theta_range[0]) * t
[docs] def f(self, P: np.ndarray) -> float: return np.linalg.norm(P[:2]) - self.radius
[docs] def normal(self, P: np.ndarray) -> np.ndarray: return np.array([P[0], P[1], 0]) / self.radius
[docs] def within_boundary(self, P: np.ndarray) -> bool: z = P[2] theta = np.arctan2(P[1], P[0]) # range: [-pi, pi] return (self.theta_range[0] <= theta <= self.theta_range[1]) and ( -self.height / 2 <= z <= self.height / 2 )
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: if type == "Z": theta = self._theta(t) x = self.radius * np.cos(theta) y = self.radius * np.sin(theta) z = np.zeros_like(t) elif type == "3D": x = np.zeros_like(t) y = np.zeros_like(t) z = np.zeros_like(t) # 0<=t<=1/4, top circle, map t to theta_range t0_mask = (0 <= t) & (t < 1 / 4) theta = self._theta((t[t0_mask] - 1 / 4) * 4) x[t0_mask] = self.radius * np.cos(theta) y[t0_mask] = self.radius * np.sin(theta) z[t0_mask] = self.height / 2 # 1/4<=t<=1/2, right side t1_mask = (1 / 4 <= t) & (t < 1 / 2) theta_max = self.theta_range[1] x[t1_mask] = self.radius * np.cos(theta_max) y[t1_mask] = self.radius * np.sin(theta_max) z[t1_mask] = self.height / 2 - (t[t1_mask] - 1 / 4) * self.height * 2 # 1/2<=t<=3/4, bottom circle t2_mask = (1 / 2 <= t) & (t < 3 / 4) theta = self._theta((t[t2_mask] - 1 / 2) * 4) x[t2_mask] = self.radius * np.cos(theta) y[t2_mask] = self.radius * np.sin(theta) z[t2_mask] = -self.height / 2 # 3/4<=t<=1, left side t3_mask = (3 / 4 <= t) & (t <= 1) theta_min = self.theta_range[0] x[t3_mask] = self.radius * np.cos(theta_min) y[t3_mask] = self.radius * np.sin(theta_min) z[t3_mask] = -self.height / 2 + (t[t3_mask] - 3 / 4) * self.height * 2 points = np.vstack([x, y, z]) return points
[docs] def get_bbox_local(self): return ( -self.radius, self.radius, -self.radius, self.radius, -self.height / 2, self.height / 2, )
[docs] class Sphere(Surface):
[docs] def __init__(self, radius, height=None): super().__init__() self.radius = radius self.height = ( height if height is not None else 2 * radius ) # from z=+radius to z=+radius-height self.planar = False self.diameter = np.sqrt(radius**2 - (radius - height) ** 2) * 2
[docs] def f(self, P: np.ndarray) -> float: return np.linalg.norm(P) - self.radius
[docs] def normal(self, P: np.ndarray) -> np.ndarray: return P / self.radius
[docs] def within_boundary(self, P: np.ndarray) -> bool: EPS = 1e-12 x, y, z = P return self.radius - self.height - EPS <= x and x <= self.radius + EPS
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: theta = 2 * np.pi * t # bottom circle r = np.sqrt(self.radius**2 - (self.radius - self.height) ** 2) y = r * np.cos(theta) z = r * np.sin(theta) x = np.full_like(t, self.radius - self.height) points = np.vstack([x, y, z]) # curved top 1 theta_r = np.arccos((self.radius - self.height) / self.radius) theta = (2 * theta_r) * (t - 0.5) x = self.radius * np.cos(theta) z = self.radius * np.sin(theta) y = np.full_like(t, 0.0) points = np.hstack([points, np.vstack([x, y, z])]) # curved top 2 x = self.radius * np.cos(theta) y = self.radius * np.sin(theta) z = np.full_like(t, 0.0) points = np.hstack([points, np.vstack([x, y, z])]) return points
[docs] def get_bbox_local(self): return ( self.radius - self.height, self.radius, -self.diameter / 2, self.diameter / 2, -self.diameter / 2, self.diameter / 2, )
[docs] class ASphere(Surface):
[docs] def __init__(self, radius, f_asphere: callable): """x = -f_asphere(r)""" super().__init__() self.radius = radius self.f_asphere = f_asphere self.planar = False x0 = -self.f_asphere(0) xR = -self.f_asphere(self.radius) self.xmin = min(x0, xR) self.xmax = max(x0, xR)
def _df_asphere_dr(self, r: float) -> float: h = 1e-4 * self.radius return (self.f_asphere(r + h) - self.f_asphere(r - h)) / (2 * h) def _df_asphere_dr2(self, r: float) -> float: # use central difference to compute second derivative h = 1e-4 * self.radius return ( self.f_asphere(r + h) - 2 * self.f_asphere(r) + self.f_asphere(r - h) ) / (h**2)
[docs] def roc_r(self, r: float) -> float: """Return the radius of curvature at radius r.""" df_dr = self._df_asphere_dr(r) df_dr2 = self._df_asphere_dr2(r) roc = ( 1 + df_dr**2 ) ** 1.5 / df_dr2 # roc>0 means center of curvature toward -x side return roc
[docs] def roc(self, P: np.ndarray) -> float: r = np.linalg.norm(P[1:3]) return self.roc_r(r)
[docs] def f(self, P: np.ndarray) -> float: r = np.linalg.norm(P[1:3]) z_asphere = -self.f_asphere(r) return P[0] - z_asphere
[docs] def normal(self, P: np.ndarray) -> np.ndarray: r = np.linalg.norm(P[1:3]) if r < 1e-12: n = np.array([1.0, 0.0, 0.0]) else: df_dr = self._df_asphere_dr(r) n = np.array([1, (df_dr) * (P[1] / r), (df_dr) * (P[2] / r)]) n = n / np.linalg.norm(n) return n
[docs] def within_boundary(self, P: np.ndarray) -> bool: EPS = 1e-12 r = np.linalg.norm(P[1:3]) return r <= self.radius + EPS
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: theta = 2 * np.pi * t # outer circle r = self.radius y = r * np.cos(theta) z = r * np.sin(theta) x = -self.f_asphere(r) * np.ones_like(t) points = np.vstack([x, y, z]) # curved top 1 r = np.linspace(-self.radius, self.radius, len(t)) x = -self.f_asphere(np.abs(r)) z = np.zeros_like(t) y = r * np.full_like(t, 1.0) points = np.hstack([points, np.vstack([x, y, z])]) # curved top 2 y = r * np.zeros_like(t) z = r * np.full_like(t, 1.0) points = np.hstack([points, np.vstack([x, y, z])]) return points
[docs] def get_bbox_local(self): return ( self.xmin, self.xmax, -self.radius, self.radius, -self.radius, self.radius, )
[docs] class Polygon(Plane): """ Planar polygon surface. *If* vertices are supplied as (N, 2) or as (N, 3) with every x = 0, the polygon is taken to lie in the x = 0 plane with normal (1, 0, 0). Parameters ---------- vertices : Sequence[Sequence[float]] Coordinates of the polygon vertices (N ≥ 3, 2- or 3-D). Ordering **must** be counter-clockwise when viewed *along* the supplied/implicit normal. normal : Sequence[float] | None Optional outward normal. Required if the vertices are not in a single x = const plane. """
[docs] def __init__( self, vertices: Sequence[Sequence[float]], normal: Sequence[float] | None = None, ): super().__init__() # Plane sets self._normal self._tol = 1e-9 verts = np.asarray(vertices, dtype=float) if verts.ndim != 2 or verts.shape[0] < 3: raise ValueError("Need at least three vertices (shape (N,2) or (N,3)).") self.planar = False # planar polygon in x = 0 plane # --------------------------------------------------------------- # 1. Promote 2-D vertices → 3-D in the x = 0 plane # --------------------------------------------------------------- if verts.shape[1] == 2: # (y, z) given verts = np.column_stack((np.zeros(len(verts)), verts)) if normal is None: normal = (1.0, 0.0, 0.0) self.planar = True # planar polygon in x = 0 plane # If 3-D vertices are all in x = 0 and no normal was given, # assume the default plane too. if verts.shape[1] == 3 and normal is None and np.allclose(verts[:, 0], 0.0): normal = (1.0, 0.0, 0.0) self.planar = True # planar polygon in x = 0 plane self.vertices = verts # --------------------------------------------------------------- # 2. Establish or verify the plane normal # --------------------------------------------------------------- if normal is None: # derive from first non-colinear triplet found = False for i in range(2, len(verts)): n = np.cross(verts[i] - verts[0], verts[1] - verts[0]) if np.linalg.norm(n) > self._tol: normal = n found = True break if not found: raise ValueError("Vertices are colinear - cannot define a plane.") self._normal = self._normalize_vector(normal) # Coplanarity check if np.any(np.abs((verts - verts[0]) @ self._normal) > self._tol): raise ValueError("Vertices are not coplanar with the supplied normal.") # --------------------------------------------------------------- # 3. Build an orthonormal (u,v) basis in the plane # --------------------------------------------------------------- a = np.array([1.0, 0.0, 0.0]) if np.abs(np.dot(a, self._normal)) > 0.99: # almost parallel a = np.array([0.0, 1.0, 0.0]) u = self._normalize_vector(np.cross(self._normal, a)) v = np.cross(self._normal, u) self._basis = (u, v) # Pre-project all vertices once self._verts2d = self._project_to_2d(verts) # Axis-aligned 3-D bounding box (fast rejection) self._bbox = ( verts[:, 0].min(), verts[:, 0].max(), verts[:, 1].min(), verts[:, 1].max(), verts[:, 2].min(), verts[:, 2].max(), )
# ------------------------------------------------------------------ # # Internal helpers # # ------------------------------------------------------------------ # def _project_to_2d(self, pts: np.ndarray) -> np.ndarray: """Project 3-D points into the polygon's local (u,v) coordinates.""" u, v = self._basis d = pts - self.vertices[0] return np.column_stack((d @ u, d @ v)) # ------------------------------------------------------------------ # # Plane/Surface interface overrides # # ------------------------------------------------------------------ #
[docs] def f(self, P: np.ndarray) -> float: """Signed distance from point to the polygon's plane.""" return np.dot(self._normal, P - self.vertices[0])
[docs] def within_boundary(self, P: np.ndarray) -> bool: # The within boundary criterion is determined by the 2-D even–odd ray-casting in (u,v) plane px, py = self._project_to_2d(P[None, :])[0] verts = self._verts2d inside = False for i in range(len(verts)): x1, y1 = verts[i] x2, y2 = verts[(i + 1) % len(verts)] # (a) exactly on edge? treat as inside if ( np.abs(np.cross([x2 - x1, y2 - y1], [px - x1, py - y1])) <= self._tol and min(x1, x2) - self._tol <= px <= max(x1, x2) + self._tol and min(y1, y2) - self._tol <= py <= max(y1, y2) + self._tol ): return True # (b) even-odd rule if (y1 > py) != (y2 > py): x_at_y = x1 + (py - y1) * (x2 - x1) / (y2 - y1) if x_at_y >= px: inside = not inside return inside
# Optional: provide the boundary path (only type == "3D" is implemented)
[docs] def parametric_boundary(self, t: Sequence[float], type: str) -> np.ndarray: # if type != "3D": # raise NotImplementedError("Only '3D' parametric boundary is supported.") verts_closed = np.vstack([self.vertices, self.vertices[0]]) # close the loop return verts_closed.T
[docs] def get_bbox_local(self) -> Tuple[float, float, float, float, float, float]: return self._bbox