Docs /Agent skills

Modify a scene

Agent skill: add an object, move it with a scheduled command, add an expectation and prove the change.

Modify a scene or behavior

This skill ships with the SimEngine first_scene example and drives only public tools: simengine_host (scenario CLI), simengine_capture (offscreen render) and examples/first_scene/first_scene.py (the example driver). It assumes a project created with simengine-create-project.

Compatible versions

Scenario schema simengine.scenario schema_version 1; capability document 1; scene state 1; simulation contract 1; module contract 1; scene/asset text formats simengine_scene 1 / simengine_asset 1; capture manifest 1; example driver 1 and simengine.example_project 1. The driver checks all of them (incompatible_engine, exit 21, on a mismatch). The authoritative field list is simengine_host --capabilities → capabilities.scenario_schema; the scene and asset formats are documented in fixtures/README.md of the SimEngine checkout.

How a project fits together

File Owns Notes
scenario.json simulation: entities, seed, ticks, scheduled commands, expectations validated all-or-nothing by simengine_host --load
scene.sescene presentation: which asset each object uses, material, scale, camera, sun validated by simengine_capture --check-scene
project.json which scene and scenarios, viewpoints, capture size read by the driver only
assets/*.seasset meshes and materials (text, meters, +Y up) each file records its license and tool

The rule that binds them: every scenario entity needs a scene instance with exactly the same name. When the driver poses the scene, each such instance takes the entity’s position and yaw from the simulation state; instances without an entity (the ground) stay where they are authored. Generated entities are named id_prefix + an index zero-padded to the width of count - 1 (barrel_0 … barrel_2 for count 3, crate_00 … for count 11 or more).

Units and frame: meters, one Cartesian frame, right-handed, up_axis "+y" (the scene convention; the driver refuses any other). Orientations are quaternions [x, y, z, w] that must be pure yaw (rotation about +Y): [0, sin(a/2), 0, cos(a/2)].

Placement reference (asset origins): crate is a 0.5 m cube with its origin at the center of its base (on the ground: y = 0; on another crate: y = 0.5); spheres (mesh sphere_r0p25) has its origin at the center (on the ground: y = 0.25; on the table: y = 1.0); barrel (r 0.3 m, h 0.8 m) and table (top at y = 0.75 m, 1.2 m × 0.8 m) have their origin on the floor. Sphere materials: dielectric_rough, dielectric_smooth, metal_rough, metal_smooth.

Motion in a core-only build is simengine.entity.set_position at a tick (a teleport, applied at the start of that tick, before systems, in document order). There is no velocity, interpolation, collision or rotation command. Expectations compare the state at the end of the run, component-wise, within tolerance.

Variables

textSIMENGINE_ROOT=/absolute/path/to/SimEngine
SIMENGINE_BUILD=$SIMENGINE_ROOT/build/linux-release
PROJECT=/absolute/path/to/my_first_scene

Prefix each command with them if your shell tool does not keep variables between calls.

Steps

  1. Record the baseline. Run it even if an earlier run already passed: step 5 compares against runs/before.json, which only this command writes (a check result has no digest or captures and is not a baseline).
shellmkdir -p "$PROJECT/runs"
python3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" run --project "$PROJECT" --build "$SIMENGINE_BUILD" > "$PROJECT/runs/before.json"
  1. Make the change in both files. Worked example — add a third crate that starts on the ground and is stacked on top of the other two at tick 100:

    • scenario.json, append to entities: {"id": "crate_c", "position": {"value": [-1.2, 0, 0.9], "unit": "m", "frame": "world"}}
    • append to commands: {"at_tick": 100, "command": "simengine.entity.set_position", "version": 1, "args": {"entity": "crate_c", "position": {"value": [-2, 1, -0.5], "unit": "m", "frame": "world"}}}
    • append to expect: {"name": "crate_c_on_top", "entity": "crate_c", "quantity": "position", "value": {"value": [-2, 1, -0.5], "unit": "m", "frame": "world"}, "tolerance": {"value": 0, "unit": "m"}}
    • scene.sescene, after the other crates: instance crate_c crate * position -1.2 0 0.9

    Make these edits with your editor. The block below applies exactly the same edit (the example’s automated test runs it):

shellpython3 - "$PROJECT" <<'EOF'
import json, pathlib, sys
project = pathlib.Path(sys.argv[1])
scenario_path = project / "scenario.json"
scenario = json.loads(scenario_path.read_text(encoding="utf-8"))
pos = lambda v: {"value": v, "unit": "m", "frame": "world"}
scenario["entities"].append({"id": "crate_c", "position": pos([-1.2, 0, 0.9])})
scenario["commands"].append({"at_tick": 100, "command": "simengine.entity.set_position", "version": 1,
                             "args": {"entity": "crate_c", "position": pos([-2, 1, -0.5])}})
scenario["expect"].append({"name": "crate_c_on_top", "entity": "crate_c", "quantity": "position",
                           "value": pos([-2, 1, -0.5]), "tolerance": {"value": 0, "unit": "m"}})
scenario_path.write_text(json.dumps(scenario, indent=2) + "\n", encoding="utf-8")
scene_path = project / "scene.sescene"
lines = scene_path.read_text(encoding="utf-8").splitlines()
at = max(i for i, line in enumerate(lines) if line.startswith("instance crate_"))
lines.insert(at + 1, "instance crate_c crate * position -1.2 0 0.9")
scene_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
EOF
  1. Check (no GPU). A mistake shows up here as scenario_invalid (23) with a JSON pointer per error, or scene_invalid (24) with file and line. Fix and repeat.
shellpython3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" check --project "$PROJECT" --build "$SIMENGINE_BUILD"
  1. Run, capture and verify the changed project.
shellpython3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" run --project "$PROJECT" --build "$SIMENGINE_BUILD" > "$PROJECT/runs/after.json"
python3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" verify --project "$PROJECT"
  1. Prove the change: the state digest changed, the new expectation is evaluated and passed, and the images exist. Then open the new final-front PNG and look at it.
shellpython3 - "$PROJECT/runs/before.json" "$PROJECT/runs/after.json" <<'EOF'
import json, sys
before, after = (json.load(open(p, encoding="utf-8")) for p in sys.argv[1:3])
assert before["outcome"] == "PASS" and after["outcome"] == "PASS", (before["failure"], after["failure"])
assert after["final_state_sha256"] != before["final_state_sha256"], "the simulation state did not change"
assert after["expectations"]["failed"] == 0 and after["expectations"]["passed"] == before["expectations"]["passed"] + 1
print("changed:", before["final_state_sha256"][:12], "->", after["final_state_sha256"][:12],
      "| expectations", after["expectations"], "| images", [c["png"] for c in after["captures"]])
EOF

A presentation-only change (a material, the camera, project.json viewpoints) leaves final_state_sha256 unchanged and changes the captured pixels (pixels_sha256); check that instead.

Behavior with velocity and gravity: the point_mass variant

toss.scenario.json tosses the ball under gravity with an impulse, using the optional point_mass test module (point kinematics, semi-implicit Euler; no collisions, so a body falls through the ground if you run long enough). It needs a build that compiles the module in; a core-only build stops with module_missing (22):

textcmake --preset linux-release -B "$SIMENGINE_ROOT/build/linux-release-point_mass" -DSIMENGINE_MODULES=point_mass
cmake --build "$SIMENGINE_ROOT/build/linux-release-point_mass"
SIMENGINE_BUILD_POINT_MASS=$SIMENGINE_ROOT/build/linux-release-point_mass
shellpython3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" run --project "$PROJECT" --build "$SIMENGINE_BUILD_POINT_MASS" --scenario toss
python3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" verify --project "$PROJECT" --scenario toss

Its components and commands (point_mass.body, point_mass.world, point_mass.apply_impulse, point_mass.set_velocity) are listed by --capabilities of that build only. Expected values for its expectations follow the closed form of semi-implicit Euler: after n ticks of duration dt, v = v0 + n·g·dt and x = x0 + n·v0·dt + g·dt²·n(n+1)/2 (plus impulse/mass from the tick it is applied).

On failure

Exit failure.code Do this
23 scenario_invalid For each failure.detail.errors[]: open the scenario at line/column, fix the field at path (code says what: unit_mismatch, unknown_entity, unknown_command, unknown_component, non_normalized_orientation, …; expected/found give the values). Nothing ran.
24 scene_invalid Fix the line in failure.detail (code, file, line): e.g. unknown_mesh, syntax_error, invalid_scene.
25 pose_mapping_failed Add the missing scene instances listed in failure.detail.entities_without_instance (same names), or fix a non-yaw orientation / non-+y frame.
27 expectation_failed Compare failure.detail.failed[].expected and .actual; fix whichever is wrong (the command or the expectation), never widen a tolerance just to pass.
22 module_missing Use the point_mass build or a core-only scenario.
other see simengine-run-capture-diagnose

Change only the project’s files. Never edit SimEngine sources, the example under $SIMENGINE_ROOT/examples/, or a tolerance to make a run pass.