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:

  1. Bezier handlespathOut on the previous keyframe, pathIn on 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
    ]
    
  2. Arc (shorthand)pathMode: 'arc' on the previous keyframe plus a pivot: [x, y] center. arcDirection: 'ccw' (default) takes the counter-clockwise sweep; 'cw' reverses. Compiles to cubic-bezier handles internally via the standard h = (4/3) tan(Δ/4) r formula.

    keyframes: [
      { time: 0, focus: [100, 0],
        pathMode: 'arc', pivot: [0, 0], arcDirection: 'ccw' },
      { time: 2, focus: [0, 100] },   // quarter-circle around the origin
    ]
    
  3. Custom SVG pathpathMode: 'custom' with an SVG-path d string. 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] },
    ]
    
  4. 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_to per source item. Setup is cached on the source after first activation; a second morphs_to with the same source will reuse the cached source/target sample points. Use separate source items if you need a chain.
  • In denoising mode 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 — no item.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.
  • onRemove restores 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:

  1. Select the item → + Add Relations
  2. Pick moves_along_path from the dropdown
  3. Continue button reads “Draw path →”
  4. Cursor becomes a crosshair, banner reads “Drag on canvas to draw the path”
  5. 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 + params schema 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.