Skip to content

Transform a group of object points

Intermediate. Prerequisites: vectors and basic matrix products. Learn to compose scale, rotation and translation, distinguish points from directions and recover object-local coordinates. These calculations are useful for object placement, attachments and camera-relative tools.

gem's row-vector convention

Rows are stored in .matrix[row][column]. The matrix product C=AB is ordinary C_ij=sum_k A_ik B_kj, but the wrapper expression M * v evaluates the mathematical row product vM*. Therefore (S*R*T)*p applies scale, then rotation, then translation. Translation is in the final row. Do not import column-vector composition order from another engine.

A homogeneous position [x,y,z,1] receives translation; a direction [x,y,z,0] does not. Scale changes direction length; rotation alone preserves it. For XYZ scale [2,1,1], +90° about Z and translation [10,-2,3], (x,y,z) → (2x,y,z) → (-y,2x,z) → (10-y,2x-2,z+3). This coordinate derivation gives independent answers for the point group.

Worked local-to-world conversion

# With row vectors, the first matrix in the product acts first.
from gem.matrix import Matrix
from gem.vector import Vector

local = [Vector(4, [0, 0, 0, 1]), Vector(4, [1, 0, 0, 1]), Vector(4, [0, 1, 0, 1])]
scale = Matrix(4).scale(Vector(3, [2, 1, 1]))
rotation = Matrix(4).rotate(Vector(3, [0, 0, 1]), 90)  # degrees
translation = Matrix(4).translate(Vector(3, [10, -2, 3]))
model = scale * rotation * translation
world = [model * point for point in local]
expected = [[10, -2, 3, 1], [10, 0, 3, 1], [9, -2, 3, 1]]
assert all(abs(a - b) < 1e-14 for p, e in zip(world, expected) for a, b in zip(p.vector, e))
world_direction = model * Vector(4, [1, 0, 0, 0])
assert all(abs(a - b) < 1e-14 for a, b in zip(world_direction.vector, [0, 2, 0, 0]))
recovered = [model.inverse() * point for point in world]
assert all(abs(a - b) < 1e-13 for p, e in zip(recovered, local) for a, b in zip(p.vector, e.vector))
other_order = (translation * rotation) * Vector(4, [0, 0, 0, 1])
assert all(abs(a - b) < 1e-14 for a, b in zip(other_order.vector, [2, 10, 3, 1]))
assert local[1].vector == [1, 0, 0, 1]
print([[round(x, 6) for x in point.vector[:3]] for point in world])

Output: [[10.0, -2.0, 3.0], [10.0, 0.0, 3.0], [9.0, -2.0, 3.0]]. The local X axis becomes twice-length +Y; moving the translation before rotation instead rotates its offset to [2,10,3]. Thus changing order changes the frame in which a transformation acts. Inverse(model) converts the world coordinates back to the original local frame, without mutating the points or model.

Homogeneous and ownership boundaries

General Matrix*Vector requires matching dimensions. Use explicit Vector4 here; it does not promote Vector3 automatically. Vector.transform has separate local affine promotion rules and performs no perspective divide. Those rules are not an interchangeable camera projection API.

Returning matrix transforms allocate fresh rows; in-place variants postmultiply the receiver and synchronize ctypes. Nonuniform scale requires a separate inverse-transpose treatment for surface normals: applying the position/direction matrix indiscriminately does not preserve their perpendicularity.

Zero scale can make the model singular; inverse then raises ZeroDivisionError. Very ill-conditioned transforms and float32 exports have numerical limits even when Python rows are representable. See matrix API, transform conventions and accuracy. Continue with camera coordinates, interoperability or the tutorial index.

See the visual example and its reproducible assets.