Class reference
Property, TimeSamples & interpolation
Property — a value plus a time axis
A Value is a bare datum. A Property is that value as it sits on an element: a default plus, optionally, a set of TimeSamples — keyframes that animate it. One mechanism carries every numeric value in the stage (transforms, material params, node params, slot inputs), and it is the only thing animation touches.
Reading a Property means asking for its value at a time. With no samples you get the default. With samples you get the interpolated curve. The pieces are small: a Property wraps a Value and an optional TimeSamples; a TimeSamples is a sorted list of Keyframes; each Keyframe carries a value, a time, and an Interp mode that governs the segment leaving it.
def object "ball" {
float radius = 1.5 # a static Property: just the default
float3 position = { # an animated Property: keyframed samples
0: (0, 0, 0)
24: (0, 5, 0)
48: (0, 0, 0)
}
}
Property radius{Float(1.5f)}; // static
Property pos;
pos.setSample(0, Float3(0, 0, 0));
pos.setSample(24, Float3(0, 5, 0));
pos.setSample(48, Float3(0, 0, 0));
Value at0 = pos.resolve(0); // (0, 0, 0)
Value at12 = pos.resolve(12); // (0, 2.5, 0): linearly interpolateddoc.edit("/world/ball") \
.set("radius", 1.5) \
.animate("position", {0: (0, 0, 0), 24: (0, 5, 0), 48: (0, 0, 0)})
at0 = doc.eval("/world/ball.position", time=0) # (0, 0, 0)
at12 = doc.eval("/world/ball.position", time=12) # (0, 2.5, 0)In C++ you hold Property directly; in Python you set the default with Handle.set, author samples with Handle.animate, and read the resolved value with eval(..., time=...) or a get_*(..., time=...). Same machinery, two altitudes.
Property
A Property owns one Value (its default) and an optional TimeSamples. It is a plain value type — copyable, comparable, equal when both default and samples match.
Construction
Property p; // empty: default Value, no samples
Property q{Float3(0.9f, 0.2f, 0.2f)}; // a static Property from a Value
Property() makes an empty Property (a default-constructed Value, no animation). Property(Value) wraps a value as the default. Nothing is animated until you author a sample.
The default value
Type t = p.type(); // the dtype of the default Value
const Value& v = p.defaultValue(); // read the default
p.setDefault(Float(2.0f)); // replace it (does not touch samples)
type() reports the default's dtype. defaultValue() returns it by const reference. setDefault(Value) replaces the default without disturbing any keyframes.
Animation state
bool a = p.animated(); // true iff there are samples
TimeSamples& ts = p.timeSamples(); // mutable handle (creates the set on first use)
const std::optional<TimeSamples>& maybe = std::as_const(p).timeSamples();
animated() is true only when a TimeSamples exists and holds at least one key. The mutable timeSamples() lazily creates the set, so calling it is enough to start animating; the const overload returns the underlying optional so a reader can tell "never animated" from "animated".
Authoring a sample
p.setSample(0, Float(0.0f)); // linear by default
p.setSample(24, Float(5.0f), Interp::Bezier); // bezier leaving this key
p.clearAnimation(); // drop all samples → back to static
setSample(double t, Value v, Interp interp = Interp::Linear) creates the TimeSamples on first use, then inserts or replaces the key at t. clearAnimation() discards the whole set, leaving the default.
resolve(t)
Value at(double t) const;
Value v = p.resolve(12.0);
resolve(t) is the whole point. Animated, it interpolates the samples at t; static, it returns the default — t is ignored. This is what evaluation calls under the hood; in Python the equivalent is eval(slot, time=t).
TimeSamples
A TimeSamples is an ordered set of Keyframes — the animation of one Property. It stays sorted by time at all times, so reads bracket in a binary search.
TimeSamples ts;
ts.set(0, Float(0.0f));
ts.set(24, Float(5.0f));
ts.set(48, Float(0.0f));
bool empty = ts.empty(); // false
const std::vector<Keyframe>& ks = ts.keys(); // the sorted keyframes
Value v = ts.resolve(12); // 2.5f
empty / keys
empty() is true when there are no keyframes. keys() returns the sorted vector — time-ascending, no duplicate times.
set
void set(double t, Value value, Interp interp = Interp::Linear);
Inserts the key at t, or replaces the value and interp of the key already there. The list stays sorted. A non-finite t (NaN, infinity) is ignored — it would break the sorted invariant. Authoring a sample never touches the handles of neighbouring keys.
remove
void remove(double t);
Drops the key at exactly t, if one exists. No effect otherwise.
setHandles
void setHandles(double t, HandleType type,
std::vector<std::array<double, 2>> left,
std::vector<std::array<double, 2>> right);
Attaches explicit bezier tangent handles to the key at t. It is a no-op if there is no key at t — handles have nothing to hang on. type records the authoring mode (HandleType). left and right are (Δframe, Δvalue) offsets from the key, one entry per scalar component (so a float3 key takes three entries each). Passing empty vectors clears the handles, and the solver falls back to its auto tangent. The key's own value and interp are left untouched.
resolve
Value resolve(double t) const;
The value at t:
- Empty → a default
Value. - Before the first key or after the last → clamp (hold the endpoint value). The curve never extrapolates.
- Inside the range → bracket the surrounding keys
a(the last key with time ≤t) andb(the next), and interpolate pera's interp — the left key governs the segment leaving it.
Keyframe
A single sample. value matches the owning Property's dtype.
struct Keyframe {
double time = 0.0;
Value value;
Interp interp = Interp::Linear; // governs the segment leaving this key
HandleType handle = HandleType::Auto;
std::vector<std::array<double, 2>> leftHandle; // incoming tangent, per component
std::vector<std::array<double, 2>> rightHandle; // outgoing tangent, per component
};
time— the keyframe's position on the time axis.value— the keyed value; same dtype as the Property.interp— theInterpfor the segment after this key (Blender f-curve convention). Held, Linear, or Bezier.handle— the bezier authoring mode (HandleType).leftHandle/rightHandle—(Δframe, Δvalue)tangent offsets from the key, one per scalar component. Empty unless bezier handles are authored; empty means "use the auto tangent".
Two keyframes are equal when every field matches.
Interp
enum class Interp { Held, Linear, Bezier };
The interp on key a decides how the segment from a to the next key b is filled.
Held
The value steps. The segment holds a's value until t reaches b, then jumps. Works for every dtype.
float gate = {
0: 0 (interp = held)
10: 1 (interp = held)
}
# 0 for t in [0, 10), then 1
Linear
A straight lerp between a and b — but only for float dtypes. float, float2/float3, matrix, spectrum, and float arrays interpolate component-wise. Anything non-float (bool, int, str, int arrays) cannot lerp meaningfully and holds at the left key, stepping like Held. (Int beziers are the one exception — see below — but linear ints hold.)
float3 position = {
0: (0, 0, 0) # interp defaults to linear
24: (0, 5, 0)
}
# t=12 → (0, 2.5, 0)
Bezier
A smooth cubic eased through the segment's tangent handles, solved per scalar component. Bezier applies only when a and b share a dtype; mismatched types degrade to linear. Within bezier:
float,float2,float3,matrix,spectrum— each component solves its own cubic.int— solves the cubic, then rounds to the nearest integer.bool— holds the left value (no curve to ease).- arrays (
float[],float2[],int[]) and generic values — fall back to element-wise linear (float shapes deform, non-float holds); they carry no per-element handles.
float ease = {
0: 0 (interp = bezier)
24: 1 (interp = bezier)
}
How the cubic is solved
Each component runs a standard 1-D cubic bezier B(u) whose four control points are: the left key, the left key plus its right handle, the right key plus its left handle, and the right key. Given a target time, the solver finds the parameter u where B_x(u) equals that time — Newton's method (8 iterations, seeded with the linear estimate), falling back to 32-step bisection if Newton drifts off [0, 1]. Then it evaluates B_y(u) for the value. The inner time controls are clamped into the segment window (and a Blender-style joint clamp keeps them from overrunning the segment) so time stays monotonic and the curve never folds back.
Auto tangents
When a key has no explicit handle for a component (the usual case — setHandles was never called), the solver synthesizes a Catmull-Rom-flavoured auto tangent: its direction follows (next − prev) across the neighbouring keys, its length is one third of the local segment in time. At the ends of the curve the neighbour collapses to the segment endpoint, so an edge key degrades to a segment-aligned slope — bezier with default handles behaves like a smooth linear, never wild. This means you get pleasant easing for free by setting interp = bezier and authoring no handles at all.
HandleType
enum class HandleType { Auto, Vector, Aligned, Free };
Records how a key's bezier handles were authored — Auto (synthesized), Vector (point straight at the neighbour, i.e. linear-like), Aligned (the two handles stay collinear), Free (independent). It is metadata for editors; the solver reads the stored handle offsets, and when they are empty it uses the auto tangent regardless of handle.
Which dtypes interpolate
| Interp | float / float2 / float3 / matrix / spectrum | float arrays | int | int arrays | bool | str | |--------|---------------------------------------------|--------------|-----|-----------|------|-----| | Held | hold | hold | hold | hold | hold | hold | | Linear | lerp | lerp (per element) | hold | hold | hold | hold | | Bezier | cubic (per component) | lerp (per element) | cubic, rounded | hold | hold | hold |
The rule in one line: only float dtypes interpolate; everything else holds at the left key. Linear and bezier on a non-float value step exactly like Held.
The .prisma text form
An animated Property writes its samples as a brace block of time: value lines, sorted by time. The default interp is linear; add a per-key (interp = ...) to override it.
def object "ball" {
float3 position = {
0: (0, 0, 0)
24: (0, 5, 0) # linear by default
48: (0, 0, 0) (interp = held) # holds from 48 onward
}
}
# mixed modes on one curve
float spin = {
0: 0 (interp = bezier)
30: 180 (interp = linear)
60: 180 (interp = held)
}
Each line is one Keyframe; the optional (interp = held | linear | bezier) sets that key's interp (the mode for the segment leaving it). Bezier tangent handles round-trip through both the ASCII and binary encodings.
Python: animate and time= reads
Python authors samples with Handle.animate and reads them back with the time= argument on eval and the get_* family.
import kinogaki as kg
doc = kg.Document()
doc.append("/world", "group")
doc.append("/world/ball", "object")
# author keyframes: a dict {time: value} …
doc.edit("/world/ball").animate("position", {0: (0, 0, 0), 24: (0, 5, 0), 48: (0, 0, 0)})
# … or an iterable of (time, value) pairs, with a named interp
doc.edit("/world/ball").animate("alpha", [(0, 0.0), (12, 1.0)], "bezier")
animate(key, samples, interp="linear"):
samplesis a{time: value}mapping or an iterable of(time, value)pairs.- each value is a
float(scalar) or a 3-tuple (float3); these are the time-sampleable shapes the binding exposes. interpis"linear"(default),"held", or"bezier"— anything else raisesValueError. The chosen mode applies to every sample in the call.- returns the same
Handle, so it chains.
Reading at a time:
p0 = doc.eval("/world/ball.position", time=0) # (0.0, 0.0, 0.0)
p12 = doc.eval("/world/ball.position", time=12) # (0.0, 2.5, 0.0)
a = doc.edit("/world/ball").get_float3("position", time=12) # same, via the handle
eval(slot, time=0.0) resolves a slot through connections and time samples, returning a native value. Every get_*/require_* on a Handle takes the same time= argument (defaulting to 0.0), so a single read can ask "what is this property at frame 12?" without touching the animation machinery directly.
See also
- Values — the dtype-and-shape atom a Property wraps.
- Properties — the orienting overview of named, time-aware values.
- Connections — letting one Property's value flow from another.