Building a world

A world is the saved 3D scene a workflow runs against. How to author one and what it stores.

A world is the saved 3D scene your workflow operates in: the table, the walls that bound the reachable space, and every object sitting on it with a pose. Worlds live at worlds/<id>/ in your project. Workflows do not embed a world. You pick one at run time, so one workflow can be exercised against several layouts.

Worlds are bigger than they look. A production world can carry dozens of objects: six workspace boundary walls, a handful of table and filter primitives, and the real lab objects, each with a pose, collision flags, and joint angles.

Objects themselves are not stored in the world. The world references object types that already live in your project, which you fill from the shared catalog. See Adding an object and The mesh database.

Creating a world

WhereHowResult
Main appBuild a scene in the World Builder and save itworlds/<sanitized-name>/
CLIzeon new world <name>worlds/<name>/world_state.json

zeon new world walks up from your current directory looking for a .zeon/ directory to find the enclosing project, then writes the template. It never overwrites an existing file: a collision aborts the command.

Building the scene

You assemble a world in the World Builder, which is a live scene rather than a document you are drafting. Every object you add appears in the simulator straight away, and the scene stays as you left it until you save it or clear it. For the panels, buttons, and dialogs, see The World Builder.

Choosing objects

The object picker offers the object types in the shared mesh database that are visible to you, not only the ones already in your project. Adding one pulls its model into the scene, and the saved world records it as objects/<type>/<type>.object_model.yaml.

Each added object gets an id of the form <object_type>_<uuid4>:

fixture_plate_9e3b532c-2541-42c5-a0b4-ba128bfb0219
tipbox_grey_917dd7d2-a4fb-445a-9626-ad70922b2bb3

That id is the key in world_state.json and the handle skills use at run time. It is generated, not chosen.

Naming and indexing

Every object in the scene carries a display name and, optionally, an index. Both are editable per object in the scene list.

The display name is metadata.name in the file, and it is de-duplicated on add, so a second base becomes base_1. metadata.type holds the base object type, which stays stable across instances.

Names matter, because the name is what your skill code reads. If you add three plates and leave the default names, your workflow has no reliable way to say which plate. Rename them to something your skills expect (source_plate, dest_plate), or set the index and let skills address them positionally.

Placing them

Objects land at a default pose, so every object you add needs moving. You set a pose either by typing the six numbers into the transform panel or by dragging the object's gizmo in the 3D view and reading the numbers back.

Positions are metres and rotations are radians, both in the world frame. The file stores the position as xyz and converts the rotation to a wxyz quaternion. See worlds/<id>/world_state.json.

Placement is the part worth spending time on. A pose that looks close enough in the viewport is still a real offset the arm has to absorb, and skills that assert poses rather than trusting physics (see How anchor snapping works) inherit whatever you saved. Work from the numbers, not the picture, whenever you know the intended figure.

🔒

Placing an object where it physically sits, by localizing it against real cameras, is available only on a local install attached to a rig. In the cloud there are no cameras, so you position objects by hand.

Saving

Saving asks you for a name and an optional description. See The World Builder for the dialog itself. Two things then happen, in order:

  1. The live scene is exported to disk under worlds/<sanitized-name>/.
  2. Every file in that directory is pushed to the cloud copy of your project, as a new commit on refs/heads/main.

Step 2 is what makes the world durable. Without it the save exists only in your current session, and a fresh session opening the same project would never see it. If step 2 does not complete you get an error rather than a success. The push uses an optimistic-concurrency check against the project's head commit, so if another writer moved the ref underneath you, the save fails with Project changed during save, refresh and retry. See How sync works.

Two details about the folder name:

  • Sanitising lowercases the name and replaces every non-alphanumeric character with an underscore, collapsing runs and trimming the ends, so Chemistry Lab Setup becomes chemistry_lab_setup. Your original text is kept as the world's display name.
  • If worlds/<sanitized-name>/ already exists, the export goes to worlds/<sanitized-name>_YYYYmmdd_HHMMSS/ instead of overwriting. Saving twice under the same name gives you two worlds, not one updated one.
📁

The save dialog says a timestamp is added automatically. It is added only on collision. Saving under a fresh name gives you a clean directory.

Saving is unavailable in read-only projects. If you cannot save, copy the project or start your own.

Iterating on a world

There is no in-place edit of a saved world. The loop is: load a saved world back into the editor, change what you need, and save it under a new name. Because a repeated name gets a timestamped folder rather than an overwrite, version the name yourself (bench_layout_v2) and point your workflow at the folder you meant. The list of worlds you can load is on the Saved Worlds tab.

Worlds carry a provenance marker: worlds you build in the editor are recorded as manually created, worlds captured from a run are recorded as coming from an execution. It is a record of where the scene came from, not a difference in how the file behaves.

Worked situations

Reposition one object in an existing world

Load the world back into the editor, read the object's current pose, change the one number you care about, apply it, and save. Remember the save creates a new folder unless you pick an unused name, so give it something like bench_layout_v2 and point your workflow at that.

A workflow cannot find an object

Load the world your workflow uses and check the object names in the scene. Those names are exactly the names your skill code must use. If they differ, rename the objects and save the world again.

A teammate cannot see a world you saved

They need to pull. Saving pushes a commit from your side; their instance has to fetch it. Run zeon sync locally, or reopen the project in the app. See How sync works.

What a world stores on disk

worlds/bench_layout/
├── world_state.json    # the scene: config + every object
├── live_state.yaml     # mutable runtime state (optional)
├── blox_voxels.npz     # ESDF voxel grid, when the world has a blox layer
└── nvblox_map.bin      # binary map sidecar on scene-scanned worlds

world_state.json is the only required file. The .npz and .bin sidecars are written by the platform; you do not edit them by hand. All of them ride along in the sync commit, which is why a scene-scanned world is a much heavier push than a hand-built one.

Inside world_state.json

A fresh world from zeon new world my_world is deliberately empty:

{
  "version": "2.0",
  "config": {
    "tensor_device": "cuda",
    "enable_blox": false,
    "create_workspace_boundaries": false
  },
  "objects": {},
  "metadata": {
    "name": "my_world",
    "description": "",
    "source": "manual"
  }
}

A world saved out of World Builder is not that shape. The exporter writes the full config block and the full object set. metadata is filled in from the name and description you gave, and gains a created_at timestamp and "source": "manual", which is the provenance marker.

Full field-by-field reference: worlds/<id>/world_state.json. The parts worth understanding while you are authoring are below.

An object entry

Every object in objects has the same shape:

"fixture_plate_9e3b532c-2541-42c5-a0b4-ba128bfb0219": {
  "geometry": {
    "type": "articulated",
    "yaml_path": "objects/fixture_plate/fixture_plate.object_model.yaml"
  },
  "mount": {
    "type": "FixedMount",
    "world_P_body_fixed": {
      "xyz": [0.6052980087088213, 0.40324841237384174, 0.0],
      "wxyz": [1.0, 0.0, 0.0, 0.0],
      "stamp_s": null
    }
  },
  "enabled": true,
  "filter_from_depth": true,
  "collide_in_planner": true,
  "attachment_spec": {
    "fit_type": "voxel_volume_sample_surface",
    "surface_sphere_radius": 0.001,
    "voxelize_method": "ray"
  },
  "metadata": {
    "name": "fixture_plate",
    "location": "fixture_plate",
    "type": "fixture_plate"
  },
  "joint_config": {}
}
FieldWhat it is
geometryEither {"type": "articulated", "yaml_path": ...} pointing at an object model, or a primitive {"class": "Cuboid", "kwargs": {"dims": [...], "color": [...]}}
mountHow the object is anchored in the world. See below
enabledOff means the object is skipped when the scene is built
filter_from_depthWhether this object's geometry is used to mask it out of camera depth
collide_in_plannerWhether the motion planner treats it as an obstacle
attachment_specHow the object is spherized when a gripper picks it up: fit_type, surface_sphere_radius in metres, voxelize_method
metadataname (the display label you set on the object), type (base object type), and optionally index
joint_config{joint_name: radians}, present only on articulated objects

enabled, filter_from_depth, and collide_in_planner are three independent filters. Scene assembly, depth masking, and planner collision each consult a different combination, so an object can be visible but not planned against, or planned against but not masked from depth.

mount is the placement mechanism

There is no separate position or rotation field. Placement lives entirely in mount, and objects you place by hand use FixedMount:

"mount": {
  "type": "FixedMount",
  "world_P_body_fixed": {
    "xyz":  [0.26, -0.3575, -0.025],
    "wxyz": [1.0, 0.0, 0.0, 0.0],
    "stamp_s": null
  }
}

world_P_body_fixed is the object's pose in the world frame: xyz in metres, wxyz a quaternion in w-first order. The roll, pitch, and yaw radians you set when placing the object are converted into that quaternion on write.

This is also the hook the snapping convention hangs off. When a skill snaps a plate into the gripper or onto a destination anchor, it is asserting a pose rather than trusting where physics left the object. See How anchor snapping works. When an object is attached to an arm its mount becomes an ArmLinkMount instead; on export, attached objects are temporarily detached so the file always records a world-frame pose, then re-attached afterwards.

joint_config

Articulated objects hold their pose and their joint state in the world. Most joint_config blocks are {}, a rigid articulated object with no movable joints, but a thermocycler stores its lid:

"joint_config": {
  "lid_joint": 0.0,
  "lid_inner_joint": 0.0
}

Saving a world therefore freezes lids open or shut, drawers in or out. If a workflow expects a closed lid, the world it runs against has to have one.

Workspace boundaries

When create_workspace_boundaries is true, the exporter generates six Cuboid objects named x_min, x_max, y_min, y_max, z_min, z_max. They are thin slabs sitting immediately outside the faces of the workspace_aabb_min and workspace_aabb_max box, with collide_in_planner: true and filter_from_depth: false. The planner treats them as obstacles, so the arm never plans a path outside the working volume.

They are ordinary entries in objects and count toward the world's object total. World loading can skip them explicitly, which is how a new scene can be dropped into an existing workspace without disturbing its bounds.

live_state.yaml

world_state.json is a frozen snapshot. Things that genuinely change during a run do not belong in it, so they live in a live_state.yaml sibling, keyed by the same object ids:

version: 1
objects:
  tipbox_grey_917dd7d2-a4fb-445a-9626-ad70922b2bb3:
    name: tipbox_10ul_1
    type: tipbox_grey
    active: true
    tip_index: 7
    calibration:
      '1': [0.015, 0.102]
      '2': [0.015, 0.093]

tip_index is the next-tip pointer on a tip box; calibration holds per-well or per-tip [dx, dy] offsets. Skills read and write it through get_world_state(object_id) and set_world_state(object_id, updates). The file path is resolved from whichever world was most recently loaded, so calling these before a world is loaded fails with live_state: no world loaded.

It is safe to hand-edit. The loader validates the YAML and tells you where the mistake is rather than failing silently.

How a workflow picks a world

A world is chosen at run time, in the Workflow Editor rather than here. See The Workflow Editor.

The world offered to you by default follows a precedence order: whatever the navigation context specified, then active_world from project.json, then first in the list. active_world is read-only in the app: it seeds the dropdown and is never written back. To change the default world for a project, edit project.json.

How a workflow refers to objects

Object ids are <type>_<uuid>, so a workflow cannot name them directly. Binding is indirect, and there are two mechanisms.

Object-typed inputs. The current mechanism. The workflow declares an input of type object; at run time you pick a concrete world object for it, and the id is passed to the skills that consume it.

The objects block. The older mechanism, kept for workflows that already use it. The workflow declares aliases up front:

"objects": [
  { "alias": "source_plate",   "mesh_type": "wellplate_pcr",  "display_name": "PCR Wellplate" },
  { "alias": "cold_block",     "mesh_type": "coldblock_small", "display_name": "Cold Block" }
]

and node parameters reference them by alias:

"parameters": {
  "cold_block": { "alias": "cold_block" },
  "well_anchor":   { "$input": "well_anchor" }
}

Each alias is bound to a concrete world object before the run. mesh_type is shown alongside the alias, but it does not restrict what you can pick. Every object in the world is offered, so matching the type is your job.

🔗

No name matching happens at run time. An alias called source_plate does not find an object called source_plate. The binding is an explicit id, chosen per run. This is why the same workflow runs against different worlds: the aliases stay put and the ids behind them change.

A world is compatible with a workflow when it contains at least one object of the right type for every alias or object input. Nothing enforces that. An unbound alias does not block the run. The editor tells you how many inputs are still unset, but that is advisory. At run time an unresolved alias only logs a warning and the parameter is passed through unbound, so the skill receives a reference that points at nothing. Check that everything is bound before you run.

Related


Did this page help you?