Mathematical and ownership conventions¶
Current reference: master 3e714fe949b7a6b7724d5c0da3395ee92483265f, including
the final SLERP repair (PR #59). These describe implemented contracts checked against source and
regressions, not a claim that all numeric inputs are validated. Historical
audit conventions contain baseline defects followed
by corrections; read their later decisions when comparing old behavior. This
page summarizes current behavior. API spellings/signatures remain unchanged.
Vectors, dimensions and exact comparison¶
Vector(size, data=None) stores ordered components in .vector, with declared
dimension .size; 2/3/4 mean XY/XYZ/XYZW. Generic component arithmetic also works
for other well-formed sizes. Cross products and directional conveniences are 3D;
perpendicular helpers are 2D. toAngle, lperp, rperp take raw indexable
coordinate sequences, not a subscriptable Vector wrapper.
Equal dimensions compare every component exactly, with no tolerance. Different
dimensions compare unequal; two empty Vectors compare equal. != complements
supported Vector equality. Other operand types receive NotImplemented from
the special methods. Arithmetic/dot expect matching well-formed storage; mixed
dimension error behavior is not standardized merely because equality is defined.
isInSameDirection/isInOppositeDirection test the sign of dot, not collinearity.
lerp and scalarLerp implement a+t*(b-a) without clamping. Reflection requires
a unit normal. Refraction uses eta=n1/n2 (incident/transmitted refractive index),
unit incident/normal vectors of matching dimension and a normal toward the
incident medium, opposing incidence. It does not normalize or flip inputs.
Total internal reflection returns a fresh zero Vector, not a reflected ray.
Matrices and row-vector application¶
.matrix[row][column] is nested row-major storage. Products are the ordinary
matrix product C[i][j]=sum(A[i][k]*B[k][j]). Despite syntax M*v, the Vector
kernel computes the row-vector product v M. Mathematically v(A B) applies A
then B; in wrapper syntax (A*B)*v == B*(A*v) for matching dimensions.
from gem.matrix import Matrix
from gem.vector import Vector
m = Matrix(2, [[1, 2], [3, 4]])
assert (m * Vector(2, [5, 6])).vector == [23, 34]
assert Matrix(2).matrix == [[1.0, 0.0], [0.0, 1.0]]
General MatrixVector requires matching dimensions; it does not promote Vector3 to Vector4. The implementation does not uniformly validate mismatches before indexing. MatrixMatrix explicitly rejects different declared sizes. Determinants and inverses dispatch for sizes 2/3/4, not arbitrary-size linear algebra.
Homogeneous translation occupies the final row. Position [x,y,z,1] receives
translation; direction [x,y,z,0] does not. Vector4 offsets to Matrix4 translation
use their first three components. Matrix3/Vector2 translation is affine 2D;
legacy translate3 overwrites the last row of a 3x3 matrix and is not general
3D translation. Matrix2 translation is unsupported.
vector.transform(size, position, matrix) takes raw position and nested matrix
lists. Same-size inputs use the ordinary row product. With an (N+1)x(N+1)
matrix it locally supplies w=1 and returns N components; this is intended for
affine positions. Explicit homogeneous inputs compute output w normally. There
is no perspective divide: implicit projective results are homogeneous numerators,
not perspective-correct Cartesian coordinates. Wrapper transform returns a new
Vector; i_transform changes only the receiver.
rotate2(point, theta) is a 3x3 homogeneous pivot rotation in degrees, positive
counterclockwise: p'=pivot+(p-pivot)R. Matrix2's wrapper rotation uses its linear
2x2 block (origin-only), while Matrix3/4 wrappers dispatch to 3D axis-angle
rotation. Shear XY means Z'=Z+xX+yY; YZ means X'=X+yY+zZ; XZ means
Y'=Y+xX+zZ. Other coordinates remain unchanged. Size-3 forms act on XYZ;
size-4 forms preserve homogeneous w. Wrapper transforms postmultiply the receiver.
Orientation and angles¶
Cross products use right-handed X cross Y = Z. Positive +Z rotations move +X
toward +Y. lookAt and projection cameras view toward negative Z with a
right-handed frame. These conventions do not enforce a universal application
world frame. Vector front() is -Z, but identity Quaternion getForward() is
+Z. Neither is silently redefined.
| Existing API | Unit |
|---|---|
rotate2, rotate3, rotate4, Matrix.rotate/i_rotate |
Degrees |
rotate_origin2 |
Radians |
perspective, perspectiveX FOV |
Degrees; vertical/horizontal respectively, aspect=width/height |
quat_from_axis_angle, quat_rotate, quat_rotate_from_axis_angle |
Degrees |
quat_rotate_x_from_angle, quat_rotate_y_from_angle, quat_rotate_z_from_angle |
Radians |
vector.toAngle; SH theta/phi |
Radians |
radiansToDegrees(degrees) multiplies by 180/pi;
degreesToRadians(radians) multiplies by pi/180. Their misleading historical
parameter names are retained; function names describe actual conversion.
Quaternion algebra, domains and interpolation¶
Components are [w,x,y,z], identity [1,0,0,0], with Hamilton multiplication.
Unit q and -q represent the same rotation. q1q2 applies q2 first; equivalent
row-vector rotation matrices compose in reversed order. Vector rotation is
q*(0,v)*conjugate(q), returning Vector3. qVector alone returns a Quaternion
Hamilton product, not a rotated Vector. Nonunit sandwich inputs scale the result
by the squared norm; rotation APIs do not implicitly normalize q.
Use quat_from_axis_angle with a nonzero Vector3/list axis; it normalizes temporary
storage. Legacy quat_rotate_from_axis_angle rotates the normalized axis about
itself and returns an approximately pure Quaternion [0,axis], not an orientation
constructor. quat_rotate instead takes raw unit axis coordinates without
normalizing them. X/Y/Z constructors return lists, not Quaternion wrappers.
from gem.quaternion import quat_from_axis_angle, quat_rotate_vector
from gem.vector import Vector
axis = [0, 0, 2]
q = quat_from_axis_angle(axis, 90)
v = quat_rotate_vector(q, Vector(3, [1, 0, 0]))
assert axis == [0, 0, 2]
assert abs(v.vector[0]) < 1e-14 and abs(v.vector[1]-1) < 1e-14
Conversions assume unit quaternions/proper rotation matrices. toMatrix returns
Matrix4; quat_from_matrix reads a Matrix wrapper's upper-left 3x3 block and
returns Quaternion. There is no automatic orthogonalization, sign canonicalization
or general matrix validation. Quaternion inverse is conjugate/norm² for nonzero
ordinary inputs; its direct squared sum is not an extreme-scale stable inverse.
Powers/logarithms support unit inputs without implicit normalization or a new
unit tolerance check; general nonunit behavior is unsupported, not uniformly
rejected. Powers use a principal angle atan2(|imaginary|,w) in [0,pi] with finite
real exponents and fresh Quaternion output. q^0=identity and q^1=q on that domain.
Log returns a fresh list [0,axis*angle]. Exact zero inputs raise ValueError.
Negative identity supports integer powers by parity; fractional powers/log reject
its unspecified axis. Near-zero imaginary direction is retained without a cutoff.
LERP is component-linear, not normalized. Accurate quat_slerp/Quaternion.slerp
uses shortest-path sign correction for unit inputs, preserving input storage.
It neither normalizes inputs nor clamps t. Legacy slerp_no_invert remains
sign-sensitive, with linear approximation for dot outside (-.95,.95); unit norm
is not guaranteed, and an antipodal midpoint can be zero.
Legacy three-control SQUAD is nested no-invert interpolation: start=quat0,
end=quat2, control=quat1. Conventional squad4(q0,q1,s0,s1,t) separately uses
accurate SLERP between endpoints and between SQUAD controls, then blends with
2t(1-t). Controls are not simply neighbouring keyframes. Unit-output regression
tolerance is 1e-12 for supported unit inputs in [0,1], not a new validation rule.
No quaternion exponential, cross product or automatic control generation exists.
Normalization, inversion and floating-point limits¶
Stable hypot-style norms and scaled normalization avoid avoidable intermediate overflow/underflow for finite supported inputs. Direct zero Vector normalization returns zero; zero Quaternion normalization returns identity. Returning results are fresh; i-prefixed variants return the same receiver. These fallbacks do not extend to geometric degeneracy: zero axes, degenerate lookAt frames, ray directions and plane normals retain exact-zero ZeroDivisionError guards, without epsilons.
Matrix3/4 inverses retain power-of-two row scaling and an exact represented-binary64 coefficient singularity check before floating cofactor evaluation. Singular matrices raise ZeroDivisionError; no arbitrary near-singular cutoff exists. Extreme uniform scales around 1e-300 to 1e300 and selected mixed exponents have independent tests. This does not guarantee accuracy for severely ill-conditioned matrices, floating cofactor cancellation or all mixed-scale cases. Matrix2 inversion and public determinants lack the same stabilization.
Ordinary Python float arithmetic is binary64. A true finite-input norm or inverse coefficient outside its range may become signed infinity. Dot products, cross products and all other helpers do not inherit blanket extreme-value guarantees. No universal NaN/Infinity input policy is established: legacy paths retain their arithmetic, while newer SH APIs explicitly reject nonfinite data. c_matrix is binary32 and can overflow/round values that remain representable in Python.
Ownership and mutation¶
Vector/Quaternion constructors retain supplied component storage; Matrix retains
supplied rows. Shape/type validation is limited. Returning arithmetic, normalize,
clamp, quaternion conversion and supported transform results allocate independent
output storage. i-prefixed methods and augmented assignment change receivers,
usually replacing component lists rather than updating external references to old
lists. Vector zero()/one() also mutate despite lacking the i prefix.
Returning clamp preserves value/bound lists. In-place clamp replaces receiver storage without changing a separate caller list or another wrapper sharing old storage. Direct field edits remain caller-managed; Matrix ctypes buffers and Plane normal/coefficient snapshots do not track arbitrary edits automatically.
Plane coefficients satisfy a*x+b*y+c*z+d=0, with .normal=[a,b,c] at the
same scale. Three-point construction uses a unit normal and d=-dot(n,point).
Normalization scales all four coefficients together. dot takes Vector4,
including the supplied w; signed distance applies to a position with w=1 and a
unit normal. Newell polygon normals wrap vertices; bestFitD is signed D=mean(n·p),
converted to coefficient d=-D. Nonplanar polygons yield an approximation.
Ray construction retains start/direction Vectors, records the original direction
length as distance and normalizes the caller direction in place. Duplication deep
copies all Vector fields without rerunning construction. Rigid transforms mutate
the ray, replace start/direction, preserve distance and leave .end unchanged.
.end is intersection placeholder/state, not an automatically derived geometric
endpoint. It has no validity flag; even a zero value could be a hit at the origin.
Matrix4 translation promotes local positions with w=1 and directions with w=0.
Projection and legacy viewport¶
project takes explicit Vector4 and Matrix4 wrappers/raw nested lists, including
mixed forms. Compose modelview*projection, divide by clip w, then map NDC to a
lower-left viewport [x,y,width,height], Y upward. OpenGL NDC z in [-1,1]
becomes window z=(z+1)/2 in [0,1] for visible points; no clamping occurs. Near/far
camera depths -near/-far map to 0/1. The result is fresh Vector3.
unproject reverses viewport/depth mapping, multiplies by the inverse combined
matrix and divides by output w. Singular inversion raises ZeroDivisionError.
Project zero clip w raises ZeroDivisionError; unproject zero output w retains a
zero Vector3 sentinel. Invalid viewports/frusta and near-zero thresholds remain
separate policies, not a universal input-validation contract.
from gem.matrix import Matrix, project, unproject
from gem.vector import Vector
viewport = [10, 20, 100, 200]
window = project(Vector(4, [0, 0, 0, 1]), Matrix(4), Matrix(4), viewport)
assert window.vector == [60.0, 120.0, 0.5]
assert unproject(*window.vector, Matrix(4), Matrix(4), viewport).vector == [0.0, 0.0, 0.0]
common.getViewPort is separate: normalize the whole Vector, scale XY by
width/height, then add original XY as offsets. It returns a four-element list,
preserves inputs and rejects zero with ZeroDivisionError. It is neither glViewport
nor conventional project/NDC mapping; Z/W affect its normalization.
Curves, polynomials and sampling¶
Bezier evaluation uses Bernstein polynomials for scalars or Vector controls;
t is not clamped. Cubic paths use 3k+1 controls and retain explicit supplied
control lists. Sampling accepts finite scalars or uniform control representations
of Vector2/3 controls. minimum_sqr_distance is a positive finite squared
coordinate-distance tolerance; sampling uses its square root. Midpoint de
Casteljau subdivision checks maximum interior-control distance to the endpoint
segment, with clamped chord projection to retain collinear overshoot. Depth
is capped at 16; best-available output may exceed tolerance. No unconditional
approximation-error guarantee is claimed.
Standalone samples include both endpoints in increasing t order. getDrawingPoints
returns per-segment lists with shared boundary points omitted after the first.
interpolate appends controls intentionally; samplePoints rebuilds generated
controls using ordered source vertices and min/max squared-distance thinning
heuristics, not spacing guarantees. Both return None and no-op below two sources.
Legendre is unnormalized P_l^m with Condon–Shortley phase. Associated inputs use
integer 0<=m<=l and x in [-1,1]; m=0 ordinary polynomials also evaluate outside
that interval. run() preserves scratch state; explicit historical helpers update
it. Invalid/extreme-order behavior is not newly standardized.
Spherical harmonics and radiometry¶
Real orthonormal SH includes Condon–Shortley phase, cosine for positive m, sine
for negative m. Theta is polar angle from +Z; phi is azimuth +X toward +Y, in
radians. Index=l(l+1)+m, complete coefficient count=bands². Canonical L2 order
has polynomial signs [1,-Y,Z,-X,XY,-YZ,3Z²-1,-XZ,X²-Y²] with orthonormal scales.
RGB projection/reconstruction returns fresh RGB lists; analytical rotation also
accepts scalar arrays of length 1/4/9. Scalar reconstruction is not a current API.
GenerateSamples uses global-RNG jittered strata and N=(sqrtNumSamples)² directions; reset the seed for repeatability. Omitted projection weights mean 4pi/N, valid for uniform sphere samples. Explicit weights are solid angles in steradians, finite/nonnegative and not forced to sum to 4pi. Reconstruction requires supplied unit XYZ/Vector3 directions, without implicit normalization or convolution.
Angular-disk RGB pixels use centers u=2(col+.5)/width-1, v=1-2(row+.5)/height, r=hypot(u,v), theta=pir and phi=atan2(v,u); ignore r>1. Right/up/center correspond to +X/+Y/+Z. Rectangular images stretch the disk. Weights are 4pi²/(widthheight)*sinc(theta), without renormalization. This is approximate quadrature, not a mirrored-ball photograph mapping. Latitude-longitude/RGBE adapters exist only in the HDR example, with separate mapping and solid-angle rules.
The legacy probe class stores nine radiance coefficients in a rounded,
positive-X/Y basis. Explicit legacy_to_canonical changes odd-order signs and
scale factors; an import-path change alone does not convert coefficients.
calculateCoefficients rebuilds, while updateCoefficients accumulates.
Active analytical rotation means f_rotated(d)=f_original(R^-1 d): +90 degrees about Z moves a +X light feature toward +Y. No directional resampling occurs. Orientation must be finite Quaternion with norm deviation <=1e-12; a temporary copy is normalized, larger deviations/zero/nonfinite inputs raise ValueError. This local validation does not redefine all Quaternion APIs.
Diffuse convolution returns separate irradiance coefficients with degree factors pi, 2pi/3, pi/4, supporting at most three bands. Apply it once. Reconstruct these directly; Lambertian reflected radiance additionally equals albedo*irradiance/pi. Lighting stays linear until display conversion; low-order SH truncation can ring and yield negative reconstructions without changing coefficients.
import math
from gem.spherical_harmonics import convolve_diffuse, reconstruct
# Only L0: orthonormal Y00=1/sqrt(4*pi); constant RGB radiance [1,2,3].
radiance = [[math.sqrt(4*math.pi)*c for c in [1, 2, 3]]]
irradiance = reconstruct(convolve_diffuse(radiance), [0, 0, 1])
assert all(abs(value-math.pi*c) < 1e-12 for value, c in zip(irradiance, [1, 2, 3]))
Evidence and unresolved policies¶
Representative regressions: vector/viewport, transforms, pivot/shear, projection, quaternions, numerical limits, planes, rays, Bezier sampling, Legendre, SH rotation and HDR reference. Their full-suite success does not settle wider shape/scalar/error policies or Ray hit-state design. See open decisions.