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
SIMENGINE_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
- Record the baseline. Run it even if an earlier
runalready passed: step 5 compares againstruns/before.json, which only this command writes (acheckresult has no digest or captures and is not a baseline).
mkdir -p "$PROJECT/runs"
python3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" run --project "$PROJECT" --build "$SIMENGINE_BUILD" > "$PROJECT/runs/before.json"
-
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 toentities:{"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):
python3 - "$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
- Check (no GPU). A mistake shows up here as
scenario_invalid(23) with a JSON pointer per error, orscene_invalid(24) with file and line. Fix and repeat.
python3 "$SIMENGINE_ROOT/examples/first_scene/first_scene.py" check --project "$PROJECT" --build "$SIMENGINE_BUILD"
- Run, capture and verify the changed project.
python3 "$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"
- Prove the change: the state digest changed, the new expectation is evaluated and
passed, and the images exist. Then open the new
final-frontPNG and look at it.
python3 - "$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):
cmake --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
python3 "$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.