Class reference
Transform
Transform — the one built-in convention
A Prism Document carries no opinion about most properties. Transform is the exception. It is the one convention Core builds in: a small set of named properties — position, rotation, scale, pivot, visible — that any Element may author, and a pair of functions that read them back as a 2×3 affine matrix. The properties stack into a local matrix; the local matrices stack down the tree into a world matrix. That is the whole of 2-D placement: TRS+pivot at each Element, composed by parentage.
Nothing here is magic. The properties are ordinary Values at ordinary names, animatable on the same time axis as everything else. Transform.h is a handful of free functions over them. You can ignore the whole convention and place things by hand; or you can author the five names and let computeWorldMatrix do the composition.
#include "kinogaki/Transform.h"
using namespace kinogaki;
Document stage;
stage.append("/world", "group").set(xform::position, Vec2{100, 50});
stage.append("/world/sprite", "object")
.set(xform::position, Vec2{10, 0})
.set(xform::rotation, 0.7854f) // 45°, radians
.set(xform::scale, Vec2{2, 2})
.set(xform::pivot, Vec2{16, 16}); // rotate/scale about the sprite centre
Affine2 m = computeWorldMatrix(stage, Path("/world/sprite"), 0.0);
Vec2 p = affineApply(m, {0, 0}); // the sprite origin in world space
The matrix is {a, b, c, d, tx, ty} — six floats, the parent group's offset already folded in. The rest of this page walks the representation, then every function, then where the properties live.
Affine2 — the 2×3 representation
A transform is an Affine2: std::array<float, 6> laid out {a, b, c, d, tx, ty}, the bottom row of a 3×3 affine left implicit at (0, 0, 1). The six floats are the matrix
| a c tx |
| b d ty |
so a point (x, y) maps to (a·x + c·y + tx, b·x + d·y + ty). The a b c d block is the linear part (rotation, scale, shear); tx ty is the translation.
Affine2 is the same type Core uses for any 2×3 matrix Value: a float32 of shape (2, 3). So a transform matrix is storable as a property, serializable in .prisma, and readable back through the typed Value API like any other datum.
Value v = Value(makeIdentityAffine()); // a float32 (2,3) Value
bool ok = v.is<Affine2>(); // true: dtype F32, shape {2,3}
Affine2 m = v.as<Affine2>();
def object "sprite" {
matrix worldCache = ((1, 0, 0), (0, 1, 0)) # a (2,3) float32 — an Affine2
}
In Python the same matrix is a 6-length sequence of floats: the set/get family treats a 6-number tuple as kg.Codec's matrix Value (a float32 of shape (2,3)). More on the Python surface below.
affine functions
makeIdentityAffine
Affine2 makeIdentityAffine();
Returns {1, 0, 0, 1, 0, 0} — the identity: maps every point to itself. This is the seed computeWorldMatrix starts from, the value a missing or unparented Element contributes, and what invertAffine returns for a singular matrix rather than collapsing.
affineMul
Affine2 affineMul(const Affine2& A, const Affine2& B); // A∘B — apply B, then A
Composes two affines into one. The result is A ∘ B: the matrix that applies B first, then A. That ordering is the whole grammar of the convention — read a product right-to-left as "innermost transform first." Composition is associative, so a chain of affineMul folds in any grouping, but it is not commutative: affineMul(A, B) and affineMul(B, A) differ.
Affine2 move = {1, 0, 0, 1, 100, 0}; // translate +100 in x
Affine2 spin = {0, 1, -1, 0, 0, 0}; // rotate 90°
Affine2 a = affineMul(move, spin); // rotate, then move
Affine2 b = affineMul(spin, move); // move, then rotate — a different result
affineApply
Vec2 affineApply(const Affine2& M, Vec2 p);
Maps a point through a matrix: (a·x + c·y + tx, b·x + d·y + ty). This applies the full affine, translation included — it transforms a position. (To transform a direction or normal, drop tx/ty and apply only the a b c d block yourself.)
Affine2 m = computeWorldMatrix(stage, Path("/world/sprite"), 0.0);
Vec2 corner = affineApply(m, {32, 32}); // a local corner → world space
invertAffine
Affine2 invertAffine(const Affine2& M); // assumes non-singular
Returns the inverse: the matrix that undoes M, so affineMul(invertAffine(M), M) is the identity (within float error). Use it to go from world space back into an Element's local space — hit-testing a world-space click against a rotated, scaled sprite, for instance.
The determinant is a·d − b·c. If it is exactly zero — a zero scale on an axis, say — the matrix has no inverse, and invertAffine returns makeIdentityAffine() rather than producing NaNs or collapsing. A returned identity from a non-identity input is the signal that the matrix was singular.
Affine2 world = computeWorldMatrix(stage, Path("/world/sprite"), 0.0);
Vec2 local = affineApply(invertAffine(world), worldClick); // world → local
The transform properties — xform::
The five standard names live in namespace xform, as constexpr const char*, so authoring code spells them once and never typos them. Each is an ordinary property: independently animatable through timeSamples, absent unless authored, and resolved at a time t.
| xform:: name | string | type | default | meaning | |---|---|---|---|---| | position | "position" | Vec2 | {0, 0} | translation | | rotation | "rotation" | float | 0 | rotation about the pivot, radians | | scale | "scale" | Vec2 | {1, 1} | scale about the pivot, non-uniform allowed | | pivot | "pivot" | Vec2 | {0, 0} | the point rotation and scale turn around | | visible | "visible" | bool | true | hidden propagates down the tree |
Any property left unauthored uses its default, so an Element with only a position is a pure translation, and an Element with nothing at all is the identity. The properties are not a special schema — they are values at named addresses, exactly like radius or albedo. Core just knows how to read these five.
def object "sprite" {
float2 position = (10, 0)
float rotation = 0.7854 # radians
float2 scale = (2, 2)
float2 pivot = (16, 16)
bool visible = true
}
computeLocalMatrix
Affine2 computeLocalMatrix(const Element& element, double t);
Reads the five properties off one Element at time t and folds them into a single Affine2. The composition is fixed:
M = T(position) · T(pivot) · R(rotation) · S(scale) · T(-pivot)
Read right-to-left, that is: shift so the pivot sits at the origin, scale, rotate, shift the pivot back, then translate by position. So scale and rotation happen about the pivot, and position moves the Element afterward. rotation is in radians, positive turning from +x toward +y. A missing property contributes its identity default, so the function is total — it never fails, it just reads what is there.
Element& e = stage.edit("/world/sprite").element();
Affine2 local = computeLocalMatrix(e, 0.0); // this Element's own placement, no parent
Because the properties resolve at t, an animated position or rotation yields a different local matrix at each time — computeLocalMatrix(e, 0.0) and computeLocalMatrix(e, 1.0) differ when the Element is keyframed.
computeWorldMatrix
Affine2 computeWorldMatrix(const Document& stage, const Path& path, double t);
The Element's placement in the world: the product of every local matrix from the topmost ancestor down to path. It walks the ancestor chain top-to-self and affineMuls each computeLocalMatrix in order, so a child's transform is expressed inside its parent's frame — move the group, and every child moves with it.
world(path) = local(root_child) · … · local(parent) · local(self)
Non-uniform scale composes as a general affine, which means shear can appear in a child when a parent scales unevenly along a rotated axis. That is correct, not a bug — it is what a 2×3 affine is for, and the c/b off-diagonal terms carry it. Any ancestor that is missing, or is not an Element, contributes the identity, so a gap in the chain is a pass-through rather than an error. The walk uses path.elementPath(), so a property-slot path resolves to its owning Element first.
Affine2 world = computeWorldMatrix(stage, Path("/world/sprite"), 0.0);
Vec2 origin = affineApply(world, {0, 0}); // where the sprite origin lands on screen
This is the function a renderer or a hit-test calls. Core's Scene carries a per-item Affine2 world field that this fills.
isVisible
bool isVisible(const Document& stage, const Path& path, double t);
Visibility inherits down the tree. An Element is visible at t only when neither it nor any ancestor has authored visible = false. The function walks the same self-to-root chain; the first visible it resolves to false short-circuits to false. An unauthored visible, or a visible = true, lets the answer pass through to the next ancestor. So hiding a group hides everything beneath it, and an Element with no visible at all is visible unless an ancestor turned it off.
stage.edit("/world").set(xform::visible, false); // hide the group
bool v = isVisible(stage, Path("/world/sprite"), 0.0); // false — inherited
Where the properties live — authoring in C++
The transform properties sit on the Element like any others, set through the chainable Element/Handle set:
stage.append("/world", "group")
.set(xform::position, Vec2{100, 50})
.set(xform::visible, false);
stage.append("/world/sprite", "object")
.set(xform::position, Vec2{10, 0})
.set(xform::rotation, 0.7854f)
.set(xform::scale, Vec2{2, 2})
.set(xform::pivot, Vec2{16, 16});
To animate a transform, keyframe the same names on the time axis; computeLocalMatrix then reads the resolved value at each t:
auto& sprite = stage.edit("/world/sprite").element();
sprite.property(xform::rotation)->setSample(0.0, 0.0f);
sprite.property(xform::rotation)->setSample(1.0, 6.2832f); // one full turn over a second
Python — authoring works, the world query is not bound yet
The Python binding can author every transform property. Handle.set infers the Prism type from the native value, so a 2-tuple becomes a float2 and a 6-tuple becomes a matrix. Animate them with Handle.animate, exactly as you would any numeric property.
import kinogaki as kg
stage = kg.Document()
stage.append("/world", "group").set("position", (100.0, 50.0)).set("visible", False)
stage.append("/world/sprite", "object") \
.set("position", (10.0, 0.0)) \
.set("rotation", 0.7854) \
.set("scale", (2.0, 2.0)) \
.set("pivot", (16.0, 16.0))
# Keyframe a property on the time axis:
stage.edit("/world/sprite").animate("rotation", {0.0: 0.0, 1.0: 6.2832})
# Read a single property back at a time t:
pos = stage.edit("/world/sprite").get_float2("position") # (10.0, 0.0)
The matrix queries are not yet bound in Python. computeLocalMatrix, computeWorldMatrix, and isVisible have no binding in kinogaki/__init__.py today — the C ABI doesn't expose them, so there is no world_matrix or is_visible call. From Python you can author the transform and read the individual properties back (including a stored matrix, as a 6-tuple, via get_float2/the matrix getter), but the composition — local-to-world and inherited visibility — runs only in C++. When the binding lands it will join the Handle surface; until then, compose in C++ (or read the per-Element properties and multiply them yourself).
# A 6-tuple round-trips as a matrix Value, but composing the hierarchy is C++-only for now:
stage.edit("/world/sprite").set("worldCache", (2.0, 0.0, 0.0, 2.0, 110.0, 50.0)) # an Affine2
In one breath
Author position, rotation, scale, pivot, visible on an Element. computeLocalMatrix folds them into a 2×3 Affine2 about the pivot; computeWorldMatrix multiplies the chain of locals down from the root, so parents carry their children; isVisible ANDs visibility down the same chain. affineMul/affineApply/invertAffine are the arithmetic underneath, and makeIdentityAffine is the do-nothing transform. C++ has all of it; Python authors the properties today and gains the world query when its binding lands.