Skip to content
PuniDocs
Open Puni

Commands


Every edit is a command on the document: the thirty-four kinds, atomic batches, command files, and one cloner for many copies.

The inspector, the timeline, connected runners, the command line and the MCP tools all change a scene the same way: with a command. A batch of commands applies atomically, and the result must validate or nothing changes.

#The kinds

GroupCommands
Modulesadd-module, update-module
Instancesadd-instance, set-instance, set-instance-value, reset-instance-bindings, remove-instances, duplicate-instance
Clonesset-clone-override, clear-clone-overrides, detach-clone
Actionsadd-action, set-action, remove-action, set-keys
Tracks and clipsadd-track, set-track, reorder-tracks, add-clip, set-clip, remove-clips, reorder-clips
Camerasadd-camera, set-camera, add-shot, set-shot, remove-shots
Audioadd-audio, set-audio, remove-audio
Sceneset-frame, retime, rename, batch

batch nests commands so one undo step holds a whole edit.

update-module restyles the objects that still use the module's old default: an object whose property equals the default it was made under follows the new default, and a value someone set is kept.

#A command file

text
[
  { "type": "set-instance", "id": "body", "transform": { "scale": [1.2, 1.2, 1.2] } },
  { "type": "set-instance-value", "id": "body", "path": "properties.color", "value": "#e4643f" },
  { "type": "add-track", "track": { "id": "t-body", "name": "Body", "targetId": "body", "muted": false } },
  {
    "type": "add-action",
    "action": {
      "id": "rise",
      "name": "Rise",
      "channels": [
        { "path": "position.y", "keys": [
          { "time": 0, "value": 40, "ease": "ease-out" },
          { "time": 1, "value": 80, "ease": "ease-in-out" }
        ] }
      ]
    }
  },
  { "type": "add-clip", "clip": { "id": "c1", "name": "Rise", "trackId": "t-body", "actionId": "rise", "start": 0, "duration": 1, "blend": "replace", "weight": 1, "fill": "forwards", "repeat": 1, "alternate": false, "enabled": true } }
]
bash
puni scene apply --file scene.json --commands edits.json --overwrite --json

Existing files need --overwrite; --output other.json keeps the input as it was.

#Motion without baking

Say the motion; never bake it into dense keys.

A spring: the first key's curve shapes the move to the second key, overshoots, and lands exactly on 80.

text
{ "type": "set-keys", "actionId": "rise", "path": "position.y", "keys": [
  { "time": 0, "value": 40, "ease": "linear", "curve": { "kind": "spring", "damping": 0.35, "frequency": 2.5 } },
  { "time": 1, "value": 80, "ease": "linear" }
] }

A hinge: the lid is 40 mm deep, so its back edge is z = -20 in its own millimetres. With that pivot, one rotation key opens it about the edge; no position keys hold the edge still.

text
[
  { "type": "set-instance", "id": "lid", "transform": { "pivot": [0, 0, -20] } },
  { "type": "set-keys", "actionId": "open", "path": "rotation.x", "keys": [
    { "time": 0, "value": 0, "ease": "ease-in-out" },
    { "time": 1, "value": -110, "ease": "linear" }
  ] }
]

A fade: the wordmark's group starts invisible and fades in, and every piece inside it fades with it. Replace keys are opacities from 0 to 1.

text
[
  { "type": "set-instance", "id": "wordmark", "opacity": 0 },
  { "type": "set-keys", "actionId": "reveal", "path": "opacity", "keys": [
    { "time": 0, "value": 0, "ease": "ease-out" },
    { "time": 1, "value": 1, "ease": "linear" }
  ] }
]

#Many copies: one cloner

A terrain, a scatter or a crowd is one cloner, never hundreds of objects. A cloner draws up to 4096 copies of its template on a line (linear), a grid, a ring (radial) or a path. Its fields property gives each copy its own value on any of seven channels: height, scale, color, keep, delay, offset and rotation. Each field reads one sample per clone from a source:

  • noise: seeded value noise over the lattice, with seed, scale (feature size in clones), octaves and an optional falloff of edge (an island) or center (a crater). It is integer arithmetic only, so the studio, shares, the CLI and the native app draw the same field.
  • asset: an uploaded PNG's luma, r, g, b or a, averaged over each clone's cell. Only PNG: JPEG and other formats decode differently across platforms, so convert first and use the PNG's /api/assets/<sha256>.
  • values: one number per clone, in clone order.

range places the sample on the channel, step snaps it to whole steps and invert reads it backwards. height stretches each clone along its own z from the template's lowest point, so give the template a little thickness and no rounding along z.

strength scales every field except keep and delay, from the template as authored at 0 to the full field at 1, and it animates. A stagger plays the cloner's own property clips once per clone, each clone that many seconds behind: by row, column, index, distance from an origin (or along a direction), or the delay field. So a field rising and sinking is two clips on properties.strength, not one per row.

Rising ground, 434 columns on twelve levels that spring up row by row from the far side and sink back. The cloner is rotated so its z is up:

text
{ "type": "add-instance", "instance": {
  "id": "ground", "moduleId": "cloner", "name": "Rising ground", "parentId": null,
  "visible": true, "locked": false,
  "transform": { "position": [0, 0, 0], "rotation": [-90, 0, 0], "scale": [1, 1, 1] },
  "properties": {
    "template": "tile", "distribution": "grid", "count": 900, "columns": 30, "rows": 30,
    "spacing": [9, 9, 0], "strength": 0,
    "fields": {
      "height": { "source": { "kind": "noise", "seed": 2, "scale": 6.9, "octaves": 3, "falloff": "edge" }, "range": [10, 160], "step": 10 },
      "color": { "source": { "kind": "noise", "seed": 2, "scale": 6.9, "octaves": 3, "falloff": "edge" }, "range": ["#b9b4aa", "#ffffff"] },
      "keep": { "source": { "kind": "noise", "seed": 9, "scale": 2.3, "octaves": 1, "falloff": "edge" }, "threshold": 0.1 }
    },
    "stagger": { "by": "row", "step": 0.07, "reverse": true }
  }
} }

Two actions on properties.strength, a spring from 0 to 1 and a bezier from 1 to 0, and two clips on the ground's one track play the whole rise and sink. A diagonal wave is one more command:

json
{ "type": "set-instance", "id": "ground", "properties": { "stagger": {
  "by": "distance", "step": 0.0055, "origin": [-130.5, 130.5, 0], "direction": [1, -1, 0]
} } }

To hand-tune one clone, set an override by its address: "col,row" on a grid ("col,row,layer" past the first layer), its index otherwise. An override lays its values over the fields. An address outside the current lattice is kept and does nothing until the lattice grows back to it. detach-clone turns one clone into a real object, a copy of the template made a child of the cloner where the clone stood, and the cloner stops drawing it. A clone placed by a height or image field needs its transform sent with the command, because only a renderer reads the template's size and the image.

json
[
  { "type": "set-clone-override", "id": "ground", "address": "14,20", "override": { "height": 160, "color": "#e4643f" } },
  { "type": "clear-clone-overrides", "id": "ground", "addresses": ["14,20"] }
]

A clone keeps its identity, the cloner plus its address, through a reseed or a field edit. Fields, stagger and overrides are set as values: they are never animated or driven by a parameter.

#Rules

  • IDs are yours to choose and must be unique in the document. Replaying an add command with an existing ID is rejected.
  • A property value must match the module's property type and range.
  • A command that names a missing ID fails the whole batch.
  • set-frame can change size and rate; retime changes the duration and keeps the motion.

After an interrupted write, read the scene before sending again: a batch that landed moved the revision, and the same batch sent from the old one is refused rather than doubled. Scene IDs identify objects; they do not make arbitrary command batches safe to replay.

#Fittings and behaviors

scene fit and scene behavior are conveniences that expand into ordinary commands, so the document never learns a new kind of thing. Read Motion for what a behavior is.

Read Evaluate and simulate for checking the result without a screen.

Was this useful?