Skip to content

JSON Format Specification

If you prefer to generate data files manually or from another language, SimView uses a single JSON document with two top-level keys: model (static data, sent once) and states (an array of time-ordered snapshots). This is exactly what SimulationScene.save() produces.

{ "model": { ... }, "states": [ { ... }, { ... } ] }

Breaking change in 4.0.0

Terrain's frictionData/stiffnessData fields and their bounds.minFriction/ maxFriction/minStiffness/maxStiffness entries were replaced by the generic terrain.properties object described below. Scene JSON files saved by simview < 4.0.0 will not load correctly — there is no automatic migration. Re-save the file with the current version (e.g. SimulationScene.load(old_path).save(new_path)) to upgrade it. See the changelog for the full list of breaking changes in this release.

Model (Static Data)

  • simBatches (integer) — number of parallel simulation instances (batches).
  • batchNames (array[string], optional) — display name for each batch, length must equal simBatches. Shown in the Batch Legend; falls back to "Batch <index>" per entry if omitted, empty, or the wrong length. Renames made from the Batch Legend are persisted server-side (see below) and take precedence over this field on subsequent loads.
  • scalarNames (array[string]) — names of per-batch scalar time-series (e.g. "energy").
  • dt (float) — simulation timestep in seconds. Used for playback timing; if omitted or invalid the viewer infers it from consecutive state times.
  • collapse (boolean) — UI hint to start with the body-state window collapsed.
  • metadata (object, optional) — free-form, JSON-serializable run provenance (e.g. engine name, checkpoint path, git commit, CLI args). Opaque to the viewer itself; shown read-only in simview info and the browser's "Scene Info" GUI folder so a scene saved months ago stays self-describing.
  • episodes (array, optional) — episode boundaries for an episodic (e.g. RL) recording. Each entry:
  • startIndex (integer) — index into states of the episode's first frame. Must be >= 0 and strictly increasing across entries; each episode implicitly ends where the next begins (the last runs to the end of states).
  • label (string, optional) — display name; falls back to "Episode <n>".

    Purely descriptive: playback itself is unchanged, but the viewer draws boundaries on the playback bar, offers episode navigation, and aggregates scalars per episode (see Episodes). Omit the key entirely for an ordinary continuous timeline. - bodies (array) — dynamic bodies. Each entry: - name (string) — unique identifier, referenced from each state. - shape (object) — geometry, keyed by a string type: - "box" — requires hx, hy, hz (half-extents). - "sphere" — requires radius. - "cylinder" — requires radius, height. - "pointcloud" — requires points (array[array[3]]) in the body's local frame. Optional color (array[array[3]], values in [0, 1]) — a static per-point RGB color for vertex-colored rendering. Optional embedding (array[array[K]]) — a per-point K-wide feature vector (e.g. a reduced-dim PCA projection of a learned backbone's features); enables the viewer's click-to-similarity "similarity" Point Color Mode, computed client-side as cosine similarity to a clicked point (see Controls). - "mesh" — requires vertices (array[array[3]]) and faces (array[array[3]]). - availableAttributes (array[string], optional) — which optional per-state fields this body provides. Any of "contacts", "velocity", "angularVelocity", "force", "torque". - parent (string, optional) — name of another model.bodies[] entry this body is attached to. When set, this body's pose is no longer absolute world space; see localTransform below and the bodyTransform note under States. - localTransform (array[7], optional) — [x, y, z, w, qx, qy, qz] constant offset from parent, for bodies rigidly attached (e.g. a wheel bolted to a chassis). Set only together with parent. A body with localTransform never appears in any state's bodies[] — its world pose is derived every frame from its parent's current pose plus this fixed offset, saving the cost of repeating an unchanging transform every frame. For an articulated attachment (e.g. an arm joint) instead, set only parent and keep providing a per-frame bodyTransform in states[].bodies[] as usual — it's then interpreted as local to the parent's current-frame pose rather than world space. - staticObjects (array, optional) — non-moving geometry. Each entry has name, isSingleton (boolean), and either shape (when singleton) or shapes (array, one per batch) using the same shape objects as bodies. - terrain (object) — heightfield shared or per-batch: - dimensions: sizeX, sizeY (float) and resolutionX, resolutionY (int). - bounds: minX, maxX, minY, maxY, minZ, maxZ — purely spatial; a named property's own value range lives on the property itself (see properties below), not here. - isSingleton (boolean) — true when one terrain is shared by all batches; false when each batch has its own. A singleton terrain ships exactly one copy of heightData/normals/each property's data/embeddingData (the shared row is detected by its resolution-sized length); readers also still accept the legacy layout where singleton data was broadcast to simBatches identical copies. - heightData (array[array[float]]) — one flattened resolutionX * resolutionY grid per batch (a single flat array is also accepted and treated as one batch). - normals (array[array[array[3]]]) — per-batch surface normals, one [x, y, z] per grid point. - properties (object, optional) — arbitrary named per-cell scalar fields over the grid (e.g. friction, stiffness, or any custom name), each selectable as a terrain color mode with no viewer code changes. Keyed by property name, each entry is {"data": array[array[float]] | null, "min": float | null, "max": float | null} — data follows the same per-batch flattened-grid shape as heightData; min/max are the range the viewer normalizes its color map against, defaulting to the property's own data range but overridable per property with create_terrain(property_bounds={"friction": (0.0, 1.0)}) to keep one scale comparable across scenes. Cells outside [min, max] saturate at the end colors; if either bound is missing or the range is degenerate the viewer falls back to clamping the raw value into [0, 1]. Example: {"friction": {"data": [...], "min": 0.2, "max": 0.9}}. - embeddingData (array[array[float]] | null, optional) — per-batch, per-cell K-wide feature vectors (flattened resolutionX * resolutionY * K per batch), enabling the viewer's click-to-similarity "features" terrain color mode, mirroring color/embedding on point-cloud bodies above. Not a named property (no min/max, similarity-colored), so it stays a separate top-level field.

States (Dynamic Data)

states is an array; each element is one snapshot:

  • time (float) — snapshot time in seconds.
  • bodies (array) — per body:
  • name (string | array[string]) — matches a model.bodies[].name. May instead be a list of names when several bodies move rigidly together (e.g. links welded to the same parent): the single entry's bodyTransform and other fields below then apply identically to every named body, instead of repeating identical data once per body. All named bodies must exist in model.bodies.
  • bodyTransform — pose. Batched: array[array[7]], one [x, y, z, w, qx, qy, qz] per batch; single: a flat [x, y, z, w, qx, qy, qz]. Absolute world-space, unless the referenced body has a parent in model.bodies (see above), in which case this is local to that parent's current-frame pose instead. A body with a constant localTransform on the model never has a bodyTransform entry here at all.
  • contacts (array[array[int]], optional) — per batch, indices of contacting points (into the body's pointcloud points). Empty array means no contacts.
  • velocity, angularVelocity, force, torque (array[array[3]], optional) — per-batch 3-vectors.
  • <scalarName> (array[float]) — for each name in model.scalarNames, one value per batch.

Binary state fields

The numeric per-body fields (bodyTransform, velocity, angularVelocity, force, torque) may alternatively be a string of the form "__b64__<base64>", where the base64 payload is the little-endian float32 bytes of the batched array in row-major order (bodyTransform is width 7, the vectors width 3). Both SimViewBodyState (used by add_state) and SimulationScene.add_trajectory emit this by default (typically ~3-4× smaller than the equivalent plain JSON floats); pass binary=False to either to emit plain JSON lists instead. The viewer and the file-merge decode binary fields transparently. contacts, scalars, and time are always plain JSON.

Columnar states (states as an object)

states may instead be an object — the columnar layout — rather than the per-frame array described above. Where the per-frame layout stores thousands of small values, this stores one binary blob per body per numeric field (and one per scalar), covering all T frames at once:

{
  "version": 4,
  "times": [0.0, 0.001, 0.002],
  "bodies": [
    {
      "name": "box",
      "fields": {"bodyTransform": "__b64__<...>", "velocity": "__b64__<...>"},
      "contacts": [[[0, 3]], null, [[7]]]
    }
  ],
  "scalars": {"energy": "__b64__<...>"}
}
  • version (int) — 4. Identifies the layout; a plain array means the legacy per-frame layout.
  • times (array[float]) — length T, one per frame.
  • bodies[].fields — each blob is little-endian float32, row-major, shape (T, B, k), where k is the field's width (7 for bodyTransform, 3 for the vectors).
  • bodies[].contacts (optional) — length T, one per-frame contacts value (or null for a frame with none), still plain JSON since it's ragged.
  • scalars — each blob is little-endian float32, row-major, shape (T, B).

SimulationScene.save writes this layout by default; pass columnar=False for the legacy array, or columnar=True to fail loudly rather than fall back. Both layouts are read transparently by SimulationScene.load, merge_simulation_files, simview info/diff/terrain, and the viewer.

Range requests

Blobs are served with Accept-Ranges: bytes, so the viewer can fetch a slice of a field rather than the whole thing. It uses this for long trajectories: the per-body vector fields (velocity, angularVelocity, force, torque) are only ever read for the frame on screen, so above 8 MB they're streamed in windows around the playhead. bodyTransform and the scalars are always fetched whole — trails, the error metrics, the terrain profile and the scalar plots all walk every frame of them.

Over the wire vs. on disk

The two are the same document; only how a blob is referenced differs. On disk it's an inline "__b64__<base64>" string, exactly like the per-frame binary fields; over HTTP the server rewrites each into a /blob/... URL the browser fetches in parallel and decodes into a Float32Array. A columnar file therefore needs no repacking at all to be served.

A legacy file still gets repacked into this shape at load time, so the viewer sees one lightweight index plus raw binary either way. That repack requires the body set, per-body field set, and field widths to be identical across every frame (contacts is exempt and may come and go per frame); if a scene doesn't meet that, the server serves the per-frame array unchanged, which the viewer also still supports. simview info reports which layout a file uses and, for a legacy file, whether it's repackable.

Authoring whole trajectories

Building states one frame at a time (add_state) is fine for short scenes, but for long, dense trajectories prefer SimulationScene.add_trajectory, which appends an entire time-series in one call, converting each body's tensors once instead of per frame — noticeably faster save/load than the same data built frame-by-frame. Both paths pack the numeric fields as the binary blobs described above by default, so file size is comparable either way:

from simview import SimulationScene, BodyShapeType, BodyTrajectory

scene = SimulationScene(batch_size=B, scalar_names=[], dt=0.001)
scene.create_terrain(...)
scene.create_body(body_name="box", shape_type=BodyShapeType.BOX, hx=0.5, hy=0.3, hz=0.15)

# positions: (T, B, 3), orientations: (T, B, 4) as [w, x, y, z]
# (2-D (T, 3) / (T, 4) is accepted when batch_size == 1)
scene.add_trajectory(
    times=times,                                  # length-T sequence or tensor
    trajectories=[BodyTrajectory("box", positions, orientations)],
)
scene.save("scene.json")

Pass binary=False to emit plain JSON lists instead.

Both BodyTrajectory.name and SimViewBodyState's body_name accept a list of body names instead of a single string, for bodies that move rigidly together (e.g. BodyTrajectory(["link_a", "link_b"], positions, orientations)) — the same transform (and any optional attributes) is applied to every named body, so it only needs to be written once per frame instead of once per body.

For large simulations, pass compress=True to save() (or use a .gz filepath) to gzip the output — SimulationScene.load(), the CLI, and the server all detect and decompress it transparently regardless of extension.

Parent-relative bodies (rigid and articulated attachments)

create_body accepts parent/local_transform to attach a body to another body already in the model, instead of it moving in world space:

scene.create_body(body_name="chassis", shape_type=BodyShapeType.BOX, hx=0.6, hy=0.4, hz=0.2)

# Rigid attachment (e.g. a wheel bolted to the chassis): a constant offset, defined
# once, never repeated per frame. Never call add_state/add_trajectory for "left_wheel".
scene.create_body(
    body_name="left_wheel", shape_type=BodyShapeType.CYLINDER, radius=0.15, height=0.1,
    parent="chassis", local_transform=[0.4, 0.52, 0.0, 1.0, 0.0, 0.0, 0.0],
)

# Articulated attachment (e.g. an arm joint): only `parent` is set, so this body's
# pose is still supplied every frame via add_state/add_trajectory as usual -- it's
# just interpreted as local to the chassis's current-frame pose instead of world.
scene.create_body(body_name="arm_joint", shape_type=BodyShapeType.BOX, hx=0.05, hy=0.05, hz=0.2, parent="chassis")

A body's parent must already exist in the model (added before its children), which also rules out cycles. Merging files containing rigid (constant-offset) bodies works the same way — merge_simulation_files carries the parent/localTransform through in model.bodies and doesn't require or emit per-frame data for them.

Example (2 batches, one box, flat terrain)

{
  "model": {
    "simBatches": 2,
    "scalarNames": ["energy"],
    "dt": 0.1,
    "collapse": false,
    "bodies": [
      {
        "name": "Box",
        "shape": { "type": "box", "hx": 0.5, "hy": 0.5, "hz": 0.5 },
        "availableAttributes": ["velocity"]
      }
    ],
    "staticObjects": [],
    "terrain": {
      "dimensions": { "sizeX": 10.0, "sizeY": 10.0, "resolutionX": 2, "resolutionY": 2 },
      "bounds": { "minX": -5.0, "maxX": 5.0, "minY": -5.0, "maxY": 5.0, "minZ": 0.0, "maxZ": 0.0 },
      "isSingleton": true,
      "heightData": [[0.0, 0.0, 0.0, 0.0]],
      "normals": [[[0, 0, 1], [0, 0, 1], [0, 0, 1], [0, 0, 1]]]
    }
  },
  "states": [
    {
      "time": 0.0,
      "bodies": [
        {
          "name": "Box",
          "bodyTransform": [
            [0, 0, 1, 1, 0, 0, 0],
            [2, 0, 1, 1, 0, 0, 0]
          ],
          "velocity": [
            [0, 0, -0.1],
            [0, 0, 0]
          ]
        }
      ],
      "energy": [1.2, 0.1]
    }
  ]
}

Notes

  • Quaternion Convention Quaternions use [w, x, y, z] (scalar-first format), packed into bodyTransform after the position.

  • Terrain Consistency Each per-batch heightData grid and normals list must contain exactly resolutionX * resolutionY elements.

  • Batch Synchronization Per-batch arrays (bodyTransform, velocity, scalar values, …) must have length simBatches. When terrain.isSingleton is true, heightData/normals hold a single shared copy that is reused for all instances (legacy files with simBatches identical broadcast copies are still accepted).

  • Contact Points The contacts field lists point indices into a body's pointcloud points for each batch. An empty array means no contacts.