core design
Design goal
core is the part of geometry3d that every other package agrees on: what a mesh is, how a point moves under a transform, which side of a face is the outside, and how bright a face is under a light. It must be small enough to read in one sitting, exact about its conventions, and free of anything that belongs to a camera, a screen or an output device. It is also a demonstration that the dense types of Luna-Flow/linear-algebra are enough to carry a 3D pipeline.
Mathematical background
Homogeneous coordinates
An affine map of is not linear, so it has no 3×3 matrix. Embedding in fixes this. A point is written and a direction is written , and the affine map becomes the block matrix
The last coordinate records what kind of object a vector is. A direction is the difference of two points, , and the computation above shows that it is moved by alone: translating both points does not change their difference. This is exactly the split between Transform3::apply_point (which appends ) and Transform3::apply_direction (which appends ).
A general 4×4 matrix also has a last row . It then sends to with , and the point it represents is found by the homogeneous division . Because and give the same point for every , these matrices act on projective space; they include the perspective projection that view uses. apply_point always divides, so it handles both cases, and skips the division when = DEPTH_EPSILON, that is, for points sent to (or near) the plane at infinity.
Composition
For two transforms (applied first) and (applied second) the composite acts on a column vector as by associativity of the matrix product. Transform3::compose stores :
The method reads in application order while the matrix product reads right to left. Two consequences follow from the algebra. Composition is associative, so a chain can be grouped freely. It is not commutative: with a translation by and a rotation,
unless . The usual model transform therefore scales, then rotates, then translates: s.compose(r).compose(t), matrix .
Elementary rotations
A linear map is determined by the images of the basis vectors, which form the columns of its matrix. Rotating the -plane by about the axis sends
which gives the matrix of rotation_z. The rotations about and follow by cycling the axes : about the pair plays the role of , and about the pair does. The second substitution is why the minus sign of sits below the diagonal:
Each is orthogonal with determinant : its columns are orthonormal, and . Hence , and rotations preserve lengths, angles and the orientation of a frame (they map a right-handed triple to a right-handed one).
Euler angles
rotation_matrix(α, β, γ) composes the elementary rotations as . Writing and of the corresponding angle and multiplying out,
Applied to a column vector, the rotation about acts first, then , then , each about the fixed world axes (extrinsic --, equivalently intrinsic --). A product of rotations is a rotation, so by Euler’s rotation theorem is a single rotation by some angle about some axis, with .11 The trace is invariant under change of basis, and in a basis whose third vector is the axis the matrix is , whose trace is . geometry3d does not extract the axis and angle; the identity is useful for checking a composed rotation in a test.
Euler angles have a singularity. At the matrix becomes
which depends on only: changing and by the same amount gives the same rotation. One degree of freedom is lost (gimbal lock), and near small changes of orientation need large changes of the angles. The demos animate the three angles with independent constant rates, where this does not matter.
Faces, normals and orientation
A face lies in a plane when its four vertices do. Its normal is computed from the first three:
The cross product is antisymmetric, so swapping two vertices reverses ; the vertex order is the orientation. The generators list every face so that points out of the solid. For the cube face at :
which points to , away from the cube. For the torus the face at uses the vertices , , . To first order the two edges are and , so
and at , and give : the normal points away from the axis, out of the tube. A test in the repository checks this so that back-face culling cannot regress to showing the inner wall.
Every quad the generators emit is planar, so its normal is well defined. For the sphere and the torus, the face between the parameters is symmetric under the reflection in the plane through the axis at angle , which swaps and . The segments and are both perpendicular to that mirror plane, hence parallel, and two parallel segments span a plane. Cube, cylinder sides and degenerate quads are planar by construction.
Back-face visibility
A point sees the front side of a planar face exactly when it lies in the open half-space the normal points into:
The sign does not depend on the choice of : for two points of the plane, , so . face_is_visible takes = face_center. For a closed solid, a face whose back is turned to the eye is hidden by the front faces of the same solid, so culling it never removes visible surface; it roughly halves the work of the rasterizers. It does not resolve occlusion between different front faces; that is the job of the depth buffer.
Lambert shading
A small surface patch of area with unit normal , lit by parallel rays travelling against the unit direction , intercepts the flux crossing an area perpendicular to the rays, where . A perfectly diffuse (Lambertian) surface reflects light equally in all directions, so its apparent brightness is proportional to , and to zero when the light is behind the surface:
face_intensity returns exactly this, which is why light must be a unit vector pointing towards the light. One value per face gives flat (faceted) shading.
Design decisions
Quads as the canonical topology
The problem: meshes need one face type that every generator, the culling test and the rasterizers understand. The options were triangles only, quads only, or general polygons. Quads were chosen because the parametric surfaces of the generators (sphere, cylinder, torus) are grids in , whose cells are quads; one quad has one normal and one shading value, which halves the face count of flat shading. Triangles are embedded as degenerate quads with , so a pyramid, a cone side or a sphere cap fits the same type. triangulate_quad turns a quad into two triangles only where a rasterizer needs them, and the zero-area second triangle of a degenerate quad is dropped by the area test.
Points and directions as separate operations
Transform3 stores one 4×4 matrix and exposes apply_point and apply_direction instead of asking callers to build themselves. This keeps the homogeneous convention inside the package and makes the rule “directions ignore translations” impossible to get wrong at a call site.
Normals recomputed, not transformed
Normals do not transform like directions under a non-uniform scale: the correct matrix for normals is , because the tangent of a surface satisfies , and after the map the vector keeps . Instead of storing normals and transforming them with this matrix, core recomputes from transformed vertices every time (face_normal after apply_mesh). The cost is one cross product per face; the benefit is that normals are always correct for any affine map with , including non-uniform scales. A map with (a reflection) reverses the winding and turns every face inside out.
Euler angles instead of axis-angle or quaternions
The demos only need to spin objects with independent rates about three axes, which is exactly what Euler angles express, and the matrices are three lines each. Axis-angle construction (Rodrigues’ formula) and quaternion composition are not implemented; Luna-Flow/quaternion is the place for them. Any rotation matrix produced elsewhere can be passed in with Transform3::from_matrix.
A shared tolerance
DEPTH_EPSILON = is used for every “is this zero?” decision in the pipeline: normalization, homogeneous division, triangle area, and the strict depth tests depth + ε < stored. One constant makes the behaviour at the degenerate boundary consistent across packages. It is an absolute tolerance, so it assumes scene coordinates of order to ; the demos use units of about one.
Correctness and invariants
apply_pointon an affine transform is exact up to floating-point rounding: , so no division error is introduced.composesatisfies for affine transforms, and up to the homogeneous scale for projective ones. A test checks the order with a translation followed by a scale.- Rotation matrices are orthogonal with determinant ; products of them are again rotations.
- Generated meshes are closed, centred on the origin, with planar faces and outward normals;
face_is_visibleandface_intensityrely on that. normalize_vecandface_normalnever divide by a number smaller thanDEPTH_EPSILON; degenerate inputs yield the zero vector, which makes the face invisible and unlit.- Every operation allocates a new vector or mesh; no function mutates its arguments.
apply_meshshares the face array of its input.
Each function is constant time except the generators and apply_mesh, which are linear in the number of vertices and faces.
Alternatives rejected
- A dedicated small-vector type (
Vec3struct withx,y,zfields) would be faster and type-safe about dimensions, but would duplicatelinear-algebra. The point of the repository is to build on the Luna-Flow base, so vectors are@la.Vector[Double]and dimensions are a convention. - Generic scalars (
Mesh[T]over anyField) were rejected: trigonometry, square roots and the tolerance all assumeDouble, and no backend could use another type. - Storing normals in the mesh was rejected for the reason given above: recomputing them is cheap and always correct after a transform.
- Triangle meshes as the primary type would make every flat-shaded grid face two faces with two identical normals.
Boundaries
core does not:
- know about cameras, projections, viewports, terminals, colours or the DOM;
- let code outside the package build arbitrary meshes: the fields of
MeshandQuadFaceare read-only outside it, so the generators are the only source of meshes; - load or save meshes, or provide scene graphs, materials, textures, physics or spatial indices;
- compute smooth (per-vertex) normals, clip geometry, or test intersections;
- provide inverse transforms, axis-angle or quaternion rotations.
Footnotes
-
The trace is invariant under change of basis, and in a basis whose third vector is the axis the matrix is , whose trace is .
geometry3ddoes not extract the axis and angle; the identity is useful for checking a composed rotation in a test. ↩