Built-in Relations
PinePaper includes several pre-defined relation types for common behaviors.
orbits
Circular motion around a target.
app.addRelation(moonId, earthId, 'orbits', {
radius: 100,
speed: 0.5,
direction: 'counterclockwise',
phase: 0
});
| Parameter | Type | Default | Description |
|---|---|---|---|
radius |
number | 100 | Orbit radius |
speed |
number | 1 | Rotation speed |
direction |
string | 'counterclockwise' |
'clockwise' or 'counterclockwise' |
phase |
number | 0 | Starting angle offset |
follows
Move toward target with smoothing.
app.addRelation(labelId, playerId, 'follows', {
offset: [0, -50],
smoothing: 0.1,
delay: 0
});
| Parameter | Type | Default | Description |
|---|---|---|---|
offset |
array | [0, 0] |
Position offset from target |
smoothing |
number | 0.1 | Movement smoothing (0-1) |
delay |
number | 0 | Follow delay in seconds |
attached_to
Move with target (parent-child relationship).
app.addRelation(hatId, characterId, 'attached_to', {
offset: [0, -30],
inherit_rotation: true
});
| Parameter | Type | Default | Description |
|---|---|---|---|
offset |
array | [0, 0] |
Fixed offset from target |
inherit_rotation |
boolean | false | Also rotate with target |
maintains_distance
Stay at fixed distance from target.
app.addRelation(satelliteId, stationId, 'maintains_distance', {
distance: 200,
strength: 0.8
});
| Parameter | Type | Default | Description |
|---|---|---|---|
distance |
number | 100 | Target distance |
strength |
number | 1 | How strongly to maintain distance |
points_at
Rotate to face target.
app.addRelation(arrowId, targetId, 'points_at', {
offset_angle: -90,
smoothing: 0.2
});
| Parameter | Type | Default | Description |
|---|---|---|---|
offset_angle |
number | 0 | Angle offset in degrees |
smoothing |
number | 0 | Rotation smoothing |
mirrors
Mirror position across axis.
app.addRelation(reflectionId, originalId, 'mirrors', {
axis: 'vertical',
center: [400, 300]
});
| Parameter | Type | Default | Description |
|---|---|---|---|
axis |
string | 'vertical' |
'vertical', 'horizontal', or 'both' |
center |
array | canvas center | Mirror center point |
parallax
Move relative to target by depth factor.
app.addRelation(backgroundId, cameraId, 'parallax', {
depth: 0.5,
origin: [400, 300]
});
| Parameter | Type | Default | Description |
|---|---|---|---|
depth |
number | 0.5 | Depth factor (0-1) |
origin |
array | [0, 0] |
Parallax origin point |
bounds_to
Stay within target’s bounds.
app.addRelation(playerId, arenaId, 'bounds_to', {
padding: 20,
bounce: true
});
| Parameter | Type | Default | Description |
|---|---|---|---|
padding |
number | 0 | Inner padding |
bounce |
boolean | false | Bounce off edges |
Manim-Inspired Animation Relations
These relations are inspired by the Manim Community animation library, providing similar animation capabilities in PinePaper’s declarative relation system.
grows_from
Item scales from zero to full size, emanating from a point. Similar to Manim’s GrowFromPoint and GrowFromCenter.
app.addRelation(itemId, null, 'grows_from', {
origin: 'center',
duration: 0.5,
delay: 0,
easing: 'easeOut'
});
| Parameter | Type | Default | Description |
|---|---|---|---|
origin |
string | 'center' |
Origin point: 'center', 'top', 'bottom', 'left', 'right', 'topLeft', 'topRight', 'bottomLeft', 'bottomRight' |
duration |
number | 0.5 | Growth duration in seconds |
delay |
number | 0 | Delay before growth starts |
easing |
string | 'easeOut' |
Easing function |
staggered_with
Staggered animation timing for groups. Similar to Manim’s LaggedStart.
// Apply to each item in a group with increasing index
app.addRelation(item1Id, leaderId, 'staggered_with', { index: 0, stagger: 0.1, effect: 'popIn' });
app.addRelation(item2Id, leaderId, 'staggered_with', { index: 1, stagger: 0.1, effect: 'popIn' });
app.addRelation(item3Id, leaderId, 'staggered_with', { index: 2, stagger: 0.1, effect: 'popIn' });
| Parameter | Type | Default | Description |
|---|---|---|---|
index |
number | 0 | Position in stagger sequence (0-based) |
stagger |
number | 0.1 | Delay between each item in seconds |
effect |
string | 'fadeIn' |
Effect type: 'fadeIn', 'fadeOut', 'growIn', 'slideIn', 'popIn' |
indicates
Temporary highlight effect with scale and color pulse. Similar to Manim’s Indicate.
app.addRelation(itemId, null, 'indicates', {
scale: 1.2,
color: '#fbbf24',
duration: 0.5,
repeat: 2
});
| Parameter | Type | Default | Description |
|---|---|---|---|
scale |
number | 1.2 | Maximum scale during indication |
color |
string | '#fbbf24' |
Highlight color (null to skip color) |
duration |
number | 0.5 | Duration of one indication cycle |
delay |
number | 0 | Delay before indication starts |
repeat |
number | 1 | Number of times to repeat |
circumscribes
Draw a temporary shape around the target item. Similar to Manim’s Circumscribe.
app.addRelation(sourceId, targetId, 'circumscribes', {
shape: 'rectangle',
color: '#fbbf24',
strokeWidth: 3,
padding: 10,
duration: 1.0,
fadeOut: true
});
| Parameter | Type | Default | Description |
|---|---|---|---|
shape |
string | 'rectangle' |
Shape type: 'rectangle', 'circle', 'ellipse' |
color |
string | '#fbbf24' |
Stroke color |
strokeWidth |
number | 3 | Stroke width in pixels |
padding |
number | 10 | Padding around target item |
duration |
number | 1.0 | Animation duration |
fadeOut |
boolean | true | Fade out after drawing |
wave_through
Send a wave distortion through the item. Similar to Manim’s ApplyWave.
app.addRelation(itemId, null, 'wave_through', {
amplitude: 20,
frequency: 2,
direction: 'horizontal',
duration: 1.0
});
| Parameter | Type | Default | Description |
|---|---|---|---|
amplitude |
number | 20 | Wave amplitude in pixels |
frequency |
number | 2 | Number of wave cycles |
direction |
string | 'horizontal' |
Wave direction: 'horizontal' or 'vertical' |
duration |
number | 1.0 | Wave duration in seconds |
delay |
number | 0 | Delay before wave starts |
camera_follows
View pans to follow a target item. Similar to Manim’s MovingCameraScene.
app.addRelation(null, targetId, 'camera_follows', {
smoothing: 0.1,
offset: [0, 0],
zoom: 1,
deadzone: 50
});
| Parameter | Type | Default | Description |
|---|---|---|---|
smoothing |
number | 0.1 | Follow smoothing (0 = instant, 1 = very slow) |
offset |
array | [0, 0] |
Offset from target center |
zoom |
number | 1 | Zoom level (1 = normal, >1 = zoom in) |
deadzone |
number | 50 | Pixels target can move before camera follows |
bounds |
object | null | Camera bounds: {minX, maxX, minY, maxY} |
camera_animates
Keyframe-based camera zoom and pan animation. Uses 'camera' as a virtual item ID.
Each keyframe’s target is given by a polymorphic focus field resolved
per-frame against the live scene graph — so the camera chases items as
they move, rig, or get nudged. The legacy center: [x, y] field is still
honored when focus is absent or unresolvable, for backward compat.
// Cinematic walkthrough — focus on items instead of pixel coordinates.
app.addRelation('camera', null, 'camera_animates', {
duration: 6,
loop: true,
keyframes: [
{ time: 0, zoom: 1, focus: [400, 300] }, // literal
{ time: 2, zoom: 2.2, focus: 'hero_shape', easing: 'easeInOut' }, // item id
{ time: 4, zoom: 2, focus: { item: 'intro_text', offset: [0, -40] }, easing: 'easeOut' }, // item + offset
{ time: 6, zoom: 1, focus: [400, 300], easing: 'easeInOut' }
]
});
// Or use the camera helper API
app.camera.zoomIn(2, 0.5);
app.camera.panTo(200, 200, 0.5);
app.camera.moveTo(200, 200, 2, 0.5);
app.camera.reset(0.5);
| Parameter | Type | Default | Description |
|---|---|---|---|
keyframes |
array | [] | Array of {time, zoom, focus, pitch, yaw, easing} objects |
duration |
number | 2 | Total animation duration in seconds |
loop |
boolean | false | Loop the animation |
delay |
number | 0 | Delay before animation starts |
pitch |
number | 0 | 3D tilt in degrees (global default; per-keyframe overrides) |
yaw |
number | 0 | 3D rotation in degrees (global default; per-keyframe overrides) |
fov |
number | 60 | Perspective field of view in degrees |
Keyframe Properties:
| Property | Type | Description |
|---|---|---|
time |
number | Time in seconds |
zoom |
number | Zoom level (1=normal, 2=2x zoom in, 0.5=zoom out) |
focus |
[x, y] | 'itemId' | { item, offset } |
What the camera frames this frame. Resolved live — see below. |
center |
[x, y] |
Legacy alias for focus: [x, y]. Honored when focus is absent. |
pitch |
number | 3D tilt in degrees (0 = flat, positive = tilt forward) |
yaw |
number | 3D rotation in degrees |
easing |
string | Easing function for transition to this keyframe |
pathOut |
[dx, dy] |
Tangent handle when leaving this keyframe (promotes segment to cubic bezier) |
pathIn |
[dx, dy] |
Tangent handle when arriving at this keyframe |
pathMode |
'arc' | 'custom' |
Shorthand path types (see below) |
pivot |
[x, y] |
Arc center when pathMode: 'arc' |
arcDirection |
'ccw' | 'cw' |
Arc sweep direction (default 'ccw') |
path |
SVG path string | Path data when pathMode: 'custom' |
Focus resolution:
| Shape | Resolves to | When to use |
|---|---|---|
[x, y] |
The literal point | Free-floating waypoints with no associated item |
'itemId' |
itemRegistry.get(id).item.bounds.center |
Track a shape as it moves |
{ item: 'id', offset: [x, y] } |
item.bounds.center + offset |
Frame part of an item (e.g., a head via offset from a body’s center) |
Because items can themselves be bound by other relations (part_of,
follows, attached_to), focus lookups transitively inherit those
behaviors — rig a character’s head via skeletal animation and a keyframe
with focus: 'head' tracks it automatically. The camera becomes a
derivation over the scene graph rather than an independent pixel tour.
Path shapes between keyframes:
The spatial path from one keyframe to the next defaults to a straight line. Four escape hatches let you shape the in-between path:
-
Bezier handles —
pathOuton the previous keyframe,pathInon the next keyframe. Both are 2D offsets relative to the resolved focus point. Either handle alone is enough to promote the segment to a cubic bezier.keyframes: [ { time: 0, focus: 'st_idle', pathOut: [200, -100] }, // leave going right-and-up { time: 3, focus: 'st_error', pathIn: [-200, 100] }, // arrive from upper-right ] -
Arc (shorthand) —
pathMode: 'arc'on the previous keyframe plus apivot: [x, y]center.arcDirection: 'ccw'(default) takes the counter-clockwise sweep;'cw'reverses. Compiles to cubic-bezier handles internally via the standardh = (4/3) tan(Δ/4) rformula.keyframes: [ { time: 0, focus: [100, 0], pathMode: 'arc', pivot: [0, 0], arcDirection: 'ccw' }, { time: 2, focus: [0, 100] }, // quarter-circle around the origin ] -
Custom SVG path —
pathMode: 'custom'with an SVG-pathdstring. The camera traces the path at eased progress. Requires Paper.js (i.e. the main-thread renderer); falls back to a straight line in headless environments.keyframes: [ { time: 0, focus: [0, 0], pathMode: 'custom', path: 'M 0 0 C 50 -100, 150 -100, 200 0' }, { time: 2, focus: [200, 0] }, ] -
Linear — the default when none of the above are present. Identical to the pre-0.5 behavior; existing templates keep working unchanged.
Endpoints always match the keyframe’s focus position regardless of which
path mode you pick — pathOut doesn’t shift where the camera leaves
the previous keyframe, only how it curves on the way out.
Camera Helper Methods:
| Method | Description |
|---|---|
app.camera.zoomIn(level, duration) |
Animate zoom in |
app.camera.zoomOut(level, duration) |
Animate zoom out |
app.camera.panTo(x, y, duration) |
Pan to coordinates |
app.camera.panBy(dx, dy, duration) |
Pan by relative offset |
app.camera.panLeft/Right/Up/Down(amount, duration) |
Directional pan |
app.camera.moveTo(x, y, zoom, duration) |
Combined zoom and pan |
app.camera.reset(duration) |
Reset to default state |
app.camera.stop() |
Stop current animation |
app.camera.getState() |
Get {zoom, center} |
app.camera.isAnimating() |
Check if animating |
morphs_to
Shape morphing animation. Similar to Manim’s Transform. Supports cross-type morphing (e.g., Path to PointText) using particle-based denoising effects.
app.addRelation(sourceId, targetId, 'morphs_to', {
duration: 1.0,
delay: 0,
easing: 'easeInOut',
morphColor: true,
morphSize: true,
hideTarget: true,
removeTargetOnComplete: false
});
| Parameter | Type | Default | Description |
|---|---|---|---|
duration |
number | 1.0 | Morph duration in seconds |
delay |
number | 0 | Delay before morph starts |
easing |
string | 'easeInOut' |
Easing function |
morphColor |
boolean | true | Also morph colors |
morphSize |
boolean | true | Also morph size/scale |
hideTarget |
boolean | true | Hide target item during morph (serves as shape reference) |
removeTargetOnComplete |
boolean | false | Remove target item when morph completes |
Discovery
getAvailableRelations()
Get a list of all registered relation type names.
Returns: Array<string>
const types = app.getAvailableRelations();
// ['orbits', 'follows', 'attached_to', 'maintains_distance', 'points_at',
// 'mirrors', 'parallax', 'bounds_to', 'grows_from', 'staggered_with',
// 'indicates', 'circumscribes', 'wave_through', 'camera_follows',
// 'camera_animates', 'morphs_to', 'driven_by', 'wiggle', 'time_expression',
// 'bone_attached', 'ik_target', 'blend_reacts_to', 'blend_transition']
Morph Strategy by Type:
| Source → Target | Strategy | Description |
|---|---|---|
| Path → Path | Direct | Point-by-point path interpolation |
| Circle → Circle | Direct | Radius and position interpolation |
| Text → Text | Direct | Font size, position, opacity blend |
| Path → Text | Denoising | Particle effect transition |
| Text → Path | Denoising | Particle effect transition |
| Group → Any | Denoising | Complex shape dissolution |
Note: Path segment interpolation works best when source and target have the same number of segments. Cross-type morphs use a particle-based denoising effect for smooth transitions.