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 |
loop |
boolean | false | Replay the morph each time the timeline loops back to the start. Without this, the morph plays once and stops. |
Modes
morphs_to picks one of two interpolation modes from the source/target item type pair:
| Source → Target | Mode | What happens |
|---|---|---|
| Path → Path, Circle → Circle, Text → Text | segments |
Direct point-by-point interpolation between segment arrays (also interpolates radius / font-size / colour). |
| CompoundPath, Group, or any cross-type pair (e.g. Path → Text) | denoising |
Samples 48 points along each item’s outline, animates a proxy polyline between them, and emits a transient particle group for the visual transition. |
Working pattern
The relation drives opacity, visible, and position on both items directly during the morph window. Place each endpoint at the position you want it to occupy (the morph interpolates source position toward target position) and do not stack opacity/visibility keyframes on either endpoint — keyframes on those properties will conflict with what the relation is writing each frame.
// Both items at different canvas positions, both visible.
const cp1 = new paper.CompoundPath({ children: [...], fillColor: '#a78bfa' });
const cp2 = new paper.CompoundPath({ children: [...], fillColor: '#fbbf24' });
const aId = app.itemRegistry.register(cp1, 'compoundPath', { name: 'source' }, 'template');
const bId = app.itemRegistry.register(cp2, 'compoundPath', { name: 'target' }, 'template');
app.addRelation(aId, bId, 'morphs_to', {
duration: 4.0,
delay: 0.5,
hideTarget: false, // keep both visible; relation crossfades during the window
loop: true, // replay each timeline iteration
});
The Morph Cross-fade Demo template (js/templates/creative.js:1848) is the canonical working example.
Notes
- One
morphs_toper source item. Setup is cached on the source after first activation; a secondmorphs_towith the same source will reuse the cached source/target sample points. Use separate source items if you need a chain. - In
denoisingmode the relation creates transient canvas items (a particle group, and where needed a target clone or proxy path) that are not part of the declared scene and are not exported by the SVG / Lottie writers.
group_morphs_to
Pair-by-index group morph. Source paper.Group’s children migrate into target paper.Group’s children’s positions. The relation is generic — same call shape works for any two groups, not just graphs (letter collages, dashboard icons, custom user groups all qualify).
app.addRelation(srcGroupId, tgtGroupId, 'group_morphs_to', {
duration: 1.5, // morph duration each direction
hold: 1.0, // hold on each end before reversing
loop: true, // cycle source ↔ target indefinitely
easing: 'easeInOut',
deformLines: true, // Path.Line children deform via segment endpoints
});
Working pattern
Two paper.Group items, each containing the visual children that constitute one “configuration”. The relation pairs src.children[i] with tgt.children[i]:
// Build two groups: one configuration each.
const groupA = new paper.Group(); // children: graph A's vertices + edges
const groupB = new paper.Group(); // children: graph B's vertices + edges
const idA = app.itemRegistry.register(groupA, 'group', { name: 'config-a' });
const idB = app.itemRegistry.register(groupB, 'group', { name: 'config-b' });
app.addRelation(idA, idB, 'group_morphs_to', { duration: 1.5, hold: 1.0 });
Behavior per child kind
| Child class | What apply does each frame |
|---|---|
Path.Line (deformLines: true) |
Lerps each of the two segment endpoints separately. A line whose endpoints map to two different target positions visibly bends during the morph. |
| Anything else | Lerps item.position between source and target home positions (rigid translation). |
| Excess (source or target) | Pinned at home position. Opacity fades — source side fades out as progress: 0 → 1, target side fades in. |
Notes
- State lives in a module-local
WeakMap— noitem.data._*cache fields. Passes the relation-genericity ratchet (0 hidden / 0 logs). - Snapshot is captured ONCE on first apply. If you edit child positions and want the relation to use the new positions: remove the relation (×) and re-add it.
onRemoverestores source children to their home snapshot — removing the relation doesn’t leave items mid-morph.
moves_along_path
Self-relation. Item moves along a custom-drawn path stored in params.path. No target item needed.
app.addRelation(itemId, null, 'moves_along_path', {
path: [{x: 200, y: 100}, {x: 400, y: 300}, {x: 300, y: 500}],
speed: 1, // 1 ≈ 150 px/s
closed: true, // loop back to start
phase: 0, // starting position along path (0–1)
easing: 'linear', // linear / easeIn / easeOut / easeInOut / sine / bounce / pingpong
});
UI flow
The Item-panel Relations picker offers a one-click draw-to-capture mode for path-shaped relations:
- Select the item → + Add Relations
- Pick
moves_along_pathfrom the dropdown - Continue button reads “Draw path →”
- Cursor becomes a crosshair, banner reads “Drag on canvas to draw the path”
- Drag and release — the simplified (RDP) point list lands in
params.path
The per-param editor renders the easing dropdown (7 named curves), speed/phase number inputs, and a closed checkbox. Save-on-change writes back via addAssociation’s idempotency.
Easing curves
| Name | Effect over one cycle |
|---|---|
linear |
Constant speed (default) |
easeIn |
Slow start, fast end |
easeOut |
Fast start, slow end |
easeInOut |
Slow at both ends |
sine |
Sinusoidal acceleration |
bounce |
Robert Penner bounce-out at end of each cycle |
pingpong |
Non-monotonic — 0→1→0 per cycle. Lets the item travel back and forth along the drawn path without redrawing it. |
Declaring custom relations
The relation graph is the canvas’s single canonical behavior surface — every built-in relation is registered through the same app.relationRegistry.registerRule(name, definition) API any user / agent can call. If you need a behavior that isn’t covered, declare it as a custom relation rather than reaching for an addOnFrameCallback or a side-channel system. Three immediate benefits:
- Picker discoverability. The Item-panel Relations picker reads
app.relationRegistry.getAllRules()live — any rule you register appears in the dropdown automatically. No editor reload, no UI plumbing. - Agent learnability. An LLM agent gets your rule’s
description+paramsschema for free. It can apply your behavior to any compatible pair of items without ever seeing the source. - State machine + timeline integration. The Inspector pills and TimelineUI segment bars both ingest the registry, so your custom relation gets a Relations pill row, a per-param editor, and an amber timeline lane with no extra work.
Descriptive relations (no compute)
A relation is allowed to have no compute and no apply. The engine skips it in the per-frame loop (if (!rule.compute) continue; in RelationRegistry.updateSync), so it costs nothing at runtime. The point is graph completeness — the rule exists in the registry, surfaces in the picker, and the data lives in item.data.associations like any other relation. Use this when the behavior is handled by an existing engine that reads item.data.* directly.
Worked example: keyframes as a custom relation
Keyframes are stored in item.data.keyframes and animated by KeyframeEngine — a legacy data path that’s invisible to the relation graph. You can surface them as a declarative relation without changing the engine:
// Register once, at app boot or template load time.
app.relationRegistry.registerRule('keyframe_animates', {
description: 'Item is animated by a keyframe timeline. Each keyframe specifies (time, properties, easing). Actual interpolation is handled by KeyframeEngine — this rule exposes keyframes as a graph entity so they appear in the relation graph alongside other behavioral relations.',
multiInstance: false,
params: {
keyframes: {
type: 'array',
default: [],
description: 'Array of { time, properties, easing } objects'
},
loop: {
type: 'boolean',
default: false,
description: 'Loop the keyframe sequence'
},
},
// No `compute`, no `apply` — KeyframeEngine reads item.data.keyframes
// every frame regardless of whether this rule exists. The mirror in
// onInit + cleanup in onRemove keeps the two stores in sync.
onInit: (item, _target, params) => {
if (!item || !item.data || !Array.isArray(params.keyframes)) return;
if (params.keyframes.length === 0) return;
item.data.keyframes = params.keyframes;
item.data.animationType = 'keyframe';
},
onRemove: (item) => {
if (!item || !item.data) return;
delete item.data.keyframes;
if (item.data.animationType === 'keyframe') {
item.data.animationType = 'none';
}
},
templates: [
'{item} animates via keyframes',
'add keyframe timeline to {item}',
],
});
Apply it like any other relation:
app.addRelation(itemId, null, 'keyframe_animates', {
keyframes: [
{ time: 0, properties: { opacity: 0 } },
{ time: 0.5, properties: { opacity: 1 } },
{ time: 2.0, properties: { opacity: 1 } },
{ time: 2.5, properties: { opacity: 0 } },
],
loop: true,
});
The Item-panel Relations pill now shows keyframe_animates alongside any other relation on the item; the per-param editor lets you edit the params inline; the timeline segment lane renders an amber bar; the registry surfaces it via getAllRules() so agents can discover and apply the same behavior to other items.
Same pattern works for any “data on item, behavior handled by an external engine” scenario — masks, generators, custom physics. The relation IS the contract; whatever engine reads item.data.* is an implementation detail.
Compute + apply for self-driven behaviors
When the behavior IS the relation (no external engine reads the data), provide compute and apply. Pattern:
app.relationRegistry.registerRule('attracts_to', {
description: 'Source item accelerates toward target with a spring force.',
multiInstance: false,
params: {
strength: { type: 'number', default: 0.1, description: 'Spring constant' },
damping: { type: 'number', default: 0.9, description: 'Velocity damping' },
},
compute: ({ fromPosition, toPosition, params, time }) => {
return {
ax: (toPosition.x - fromPosition.x) * params.strength,
ay: (toPosition.y - fromPosition.y) * params.strength,
};
},
apply: (item, _target, computed, params) => {
if (!item || !computed) return;
// Module-local WeakMap state — see the genericity contract for why.
// (Skipping the WeakMap helper here for brevity; in real code use
// `getMyRuleState(item)` pattern from RelationRegistry built-ins.)
item._vx = (item._vx || 0) * params.damping + computed.ax;
item._vy = (item._vy || 0) * params.damping + computed.ay;
item.position.x += item._vx;
item.position.y += item._vy;
},
});
The genericity contract (see memory/backlog_relation_genericity_audit.md and __tests__/relations-contract.test.js) applies — keep state in a module-local WeakMap, not item.data._*; no production console.log in compute / apply; declare your params; declare side-effect items if you create any.
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', 'group_morphs_to', 'moves_along_path',
// '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.