--- name: build-usd-scene description: > Assemble or edit LunCoSim's 3D world: load scenes, spawn objects, place, move, rotate, scale, tune, or clear them. USE THIS SKILL for requests such as "put a lander near that crater", "spawn rovers", "load the Moon scene", "add rocks", "move this", "set its mass or material", or "build a scene with X and Y". For the agent mid-code: `LoadScene`, `SpawnEntity`, `MoveEntity`, `TransformEntity`, `SetObjectProperty`, a catalog `entry_id`, a `.usda` file, or a placement/ lighting issue. Project-specific: USD is the source of truth projected to ECS; use the fixed Y-up, right-handed, -Z-forward metre frame, root-qualified `lunco://` or `twin://` paths, and catalogued spawnables. Do not use `SetDocumentSource` for live edits. Use author-scenario for behavior and authoring-vessel-controllers for GNC. Use edit-usd-assembly for editing the reusable rover, lander, payload, or sensor assembly itself in a visible Editor session. --- # Build & edit USD scenes The 3D world is **OpenUSD, projected to Bevy ECS** — USD is the source of truth, the ECS scene is its projection. You build the world by **authoring USD** (via commands that apply reversible ops), not by mutating ECS directly. Drive it over the API (`--api`, port **4101**; launch per [`test-via-api`](../test-via-api/SKILL.md)). Design background: [`21-domain-usd.md`](../../docs/architecture/21-domain-usd.md), [`usd-source-of-truth.md`](../../docs/architecture/usd-source-of-truth.md). This skill owns mounting and scene placement. For authoring the reusable assembly itself, switch to the [interactive Assembly Editor runbook](../edit-usd-assembly/SKILL.md), which keeps a headful preview visible and routes every edit through the document/journal command boundary. When placement, framing, contacts, terrain fit, or other scene appearance is under review, use the headful production window and leave it visible to the user. Headless/API-only runs can confirm typed state, but cannot establish that the scene looks correct; do not use them as a substitute for viewport review. ## Generic scene recipes for humans and AI For a repeatable scene assembled from authored USD components, use the namespaced Rhai `model_authoring` tool instead of scattering independent commands. Read the exact document/root and generation, then create a dry recipe: ```rhai let context = model_authoring::model_context(doc, root, "@root@"); let recipe = model_authoring::scene_recipe( doc, "@root@", scene_spec, context.generation); ``` `scene_spec` may contain `references`, `terrain`, `cameras`, `initial_state`, `routes`, and `programs`. Review and apply `recipe.ops` with `assembly_edit::batch` or the proposal flow. Route entries go to `waypoint_editor`; program entries go to `assembly_edit::attach_program`. For reusable or independently edited routes, author the route scope in a separate USD route-plan asset and compose it into the scene; bind all available programs from the subject with `rel programs` and select the active canonical program path explicitly when more than one is present. Missing parents/paths, invalid `lunco://` identities, and stale generations fail before a plan is returned. Use `readiness_report` and `port_graph`/`wiring_plan` before running the composed scene. The complete API is in [`scripting-guide.md`](../../docs/scripting-guide.md#model-and-assembly-authoring-human-and-ai). Before assembling a scene, choose its world/time contract. The complete option matrix is in [`assets/tutorials/README.md`](../../assets/tutorials/README.md); the short version is below. ## Choose the scene's lighting and time model | Scene contract | Author | Use it when | |---|---|---| | Fixed instructional world | A real `DistantLight` reference such as `lunco://lighting/sun.usda`, with an authored rotation; omit the celestial payload. | Teaching UI, spawning, or basic controls where changing sunlight is not the subject. | | Ephemeris world | Reference `lunco://celestial/solar_system.usda` under `SolarSystem`; author the site anchor on the scene root when needed. Add `LunCoEpochAPI` and a non-zero `double lunco:time:epochJd` on the scene root when a repeatable date is required. | Teaching a real lunar day, antenna pointing to a body-relative station, orbital motion, or another feature whose result depends on celestial time. | | Existing world | Reference or payload the authoritative scene that already owns gravity, lighting, time, and celestial content. | Adding a lesson or assembly whose subject is behaviour, not scenery. | | UI-only lesson | Omit the payload; the tutorial launcher clears an outgoing lesson scene before showing the UI-only lesson. | Teaching menus, commands, or workbench concepts. | After the USD stage and queued structural projection settle, `scene.time.select` runs once. It selects the authored non-zero root epoch when present; otherwise it selects current computer UTC converted to TDB. Physics, celestial placement, USD animation sampling, and DEM construction wait for that result. CPU-generated render meshes may continue streaming while this decision is applied. Missing time for a celestial source and an invalid `LunCoEpochAPI` value produce runtime warnings and `epoch-api-missing-time` lint findings. Author a root epoch when the scene must reproduce the same celestial date across launches. A fixed light is a complete scene contract without an orbital provider. Moving scene objects use ordinary USD animation. Author their translation as `double3 xformOp:translate` time samples in the scene's USD asset; each sample time is elapsed seconds from the scene's TDB epoch multiplied by the stage's `timeCodesPerSecond` (USD uses 24 when the metadata is omitted). The shared USD animation adapter samples these values from physical presentation time, with BigSpace splitting for a direct Grid child. Do not add a mission-specific trajectory component or downloader for authored motion. For tutorial payloads, use the fixed contract for onboarding scenes such as `first_drive.usda`, and the ephemeris contract for `driving_basics.usda` and `slope_test.usda`. `rover_variants.usda` reuses `driving_basics.usda`, while the lander mission reuses `scenes/luncosim/lander_ops.usda`; do not copy their environment or silently choose a second clock. ## The one coordinate frame (spec 009) The engine runs in **one fixed canonical frame: Y-up, right-handed, −Z-forward, SI metres, f64.** Any external asset (USD `upAxis`/`metersPerUnit`, glTF, Blender) is converted **once, at the importer** — never branch on convention in your own placement math. A `position` you pass to `SpawnEntity` is Y-up metres. ## The command surface | Command | Params | Does | |---|---|---| | `LoadScene` | `{path, root_prim}` | Load a USD scene. `path` is a root-qualified `lunco://…` or `twin://…` address. `root_prim` empty = the stage's `defaultPrim`. | | `ClearScene` | `{}` | Tear down the current scene. | | `RestartScene` | `{}` | Reload/reset the current scene. | | `SpawnEntity` | `{entry_id, position:[x,y,z], rotation?, producer_id?}` | Instance a catalogued prefab from the **spawn catalog** (`list_bundled` / `ListBundled`). Raw-file runtime spawns are admitted for the next fixed tick and return their stamp plus reserved root id. API, direct typed, and actorless Rhai callers supply a stable nonzero `producer_id`; Twin Rhai keeps its stable actor identity and omits it. Document-backed spawns remain USD journal operations. | | `MoveEntity` | `{…}` | Reposition an existing entity. | | `TransformEntity` | `{entity_id, translation, rotation}` | Set an existing entity's complete active-frame pose as one undoable USD edit. | | `SetObjectProperty` | `{entity_id:u64, property, value}` | Set a named property (both strings; value is coerced by property type). | | `SelectEntity` | `{…}` | Select (drives the gizmo/inspector). | | `SetPorts` | `{target, writes:[[name,val]], producer_id?}` | Set a persistent input intent (e.g. drive a spawned rover); external API/direct and actorless Rhai callers provide a stable nonzero `producer_id` and receive the next-tick admission stamp. Twin Rhai uses its actor identity. For live targets, the local Port Inspector uses the active `LocalUser` session identity and also commits at a fixed tick. Use `ReleasePort` or `ReleaseControl` to release it — see [`author-scenario`](../author-scenario/SKILL.md) for behavior. | | `ReleasePort` | `{target, name, producer_id?}` | Release a named hold at its ordered fixed-tick commit after earlier admitted writes. External API/direct and actorless Rhai callers provide a stable nonzero `producer_id` and receive the admission stamp. Twin Rhai uses its actor identity. | | `ReleaseControl` | `{target, producer_id?}` | Release all local input holds after earlier admitted writes. It does not change endpoint values; authored stop policy writes named values through `SetPorts`. External API/direct and actorless Rhai callers provide a stable nonzero `producer_id` and receive the admission stamp. Twin Rhai uses its actor identity. | Discover the live set with `DiscoverSchema`; discover spawnables with `list_bundled`. For scene selection readback, use `query("InspectSelection")`; it returns stable API ids in selection order, the current primary id, stable selected USD paths, and the current stale-entry count. The query is available in GUI and headless hosts. A tool that must retain selection across structural scene projection should use the returned USD path rather than authoring a transient runtime prim or caching an entity id. ## Recipe 1. **Base:** `LoadScene {path:"lunco://scenes/…/foo.usda", root_prim:""}` for an existing scene, or start from the loaded default and add to it. `ClearScene` first if replacing. 2. **What can I spawn?** `list_bundled` → pick an `entry_id`. 3. **Place it:** `SpawnEntity {entry_id, position:[x,y,z], rotation?, producer_id?}` (Y-up metres). For raw-file runtime scenes, the response `data` carries the fixed-tick admission stamp and reserved root id; the root receives that same id when it materializes. API and actorless Rhai calls include a stable producer id. 4. **Adjust:** `MoveEntity` or `TransformEntity` / `SetObjectProperty` (colour, mass, material, scale) / `SelectEntity` to inspect. 5. **Confirm:** `CaptureScreenshot` → `target/x.png` → Read it (see [`inspect-simulation`](../inspect-simulation/SKILL.md) for reading state back). 6. **Persist:** to make it permanent, author it into the `.usda` scene file under `assets/scenes/` (the runtime edits are USD ops; save them into the layer). 7. **Verify the contract:** run `$LUNCOSIM_BIN --validate `; for a composed world, run the authored Rhai scene gate and inspect its verdict. `--validate` catches parse/composition/lint failures but does not prove that the light remains stable during runtime. ## Gotchas - **Before typing `lunco:`, name the standard field this would be.** If USD owns the concept, use its schema. A vendor namespace is correct only for semantics USD does not define, and it must cover only that new concept. For a parametric surface of revolution, author only the LunCo-specific profile/shape fields; patch sampling and degree belong to the standard patch attributes. - **A Modelica program under a child `Scope` has no script-readable ports through the owner hierarchy.** Apply `LunCoProgramAPI` to the entity whose script reads the outputs, or connect the child program's outputs explicitly by USD prim path. The restriction is on script traversal, not on USD port connections. - **A moving part is a JOINT, not a script that rewrites a transform.** Author a `UsdPhysics` joint between two bodies and let the solver provide contact, limits, reaction forces, and joint ports. Do not duplicate dimensions or motion state in a script. - **A commandable sprung mechanism is a JOINT DRIVE — author it, then read it back.** Apply `UsdPhysicsDriveAPI:linear` and author `physics:type = "force"` with `drive:linear:physics:stiffness` (N/m) and `:damping` (N·s/m). The canonical USD-to-Avian reader preserves that SI force law and uses Avian's implicit `SpringDamper` realization when the driven body has a positive authored mass; it derives equivalent frequency and damping ratio for stable integration, without creating a second spring or changing the authored coefficients. Its stroke and its load then come off the joint's own `displacement` and `force` ports. **No Modelica model and no rhai script restates the spring** — a second spelling of one spring puts two writers on one fact, and which wins becomes a function of load order. Author `physics:type` explicitly: the coefficients mean newtons under `"force"`, while an `"acceleration"` drive is mass-normalized and has no honest newton readback at all, so its `force` port reads nothing. Missing mass, angular coefficient drives, and negative coefficients are not repaired by a fallback; the USD linter reports the invalid or conditionally unstable authoring. - **A physical landing member uses the standard joint drive.** Apply `PhysicsDriveAPI:linear` to a `PhysicsPrismaticJoint`; author `physics:type = "force"` with `stiffness`, `damping`, and `maxForce`. The native Avian prismatic joint is the sole axial mechanism, and its measured `displacement` and output-only `force` are the public state. Do not duplicate standard fields under `lunco:*`; missing or invalid fields fail projection and are never replaced by a target, force cap, or solver-resolution workaround. - **The joint's axis carries the SIGN, and it is the only place that does.** For a drive, `force = stiffness * (targetPosition - displacement)` under the authored force convention. A landing leg's compression must read NEGATIVE displacement, which fixes the axis to point the way the mechanism EXTENDS. `physics:axis` can only name `"X"|"Y"|"Z"`, so a raked or reversed axis is carried by `physics:localRot0` (quaternions are `(w, x, y, z)`), and the limits follow it: a landing leg is `lowerLimit = -stroke`, `upperLimit = 0`, rest at 0. Get this right in the joint and nothing downstream needs a sign fixup; get it wrong and every consumer grows one. - **Wire a physical part to PHYSICS, and flight software to SENSORS.** Contact, contact force, position and velocity are collider/body ports — they exist because the thing exists, with nothing to author. Sensors (`lunco:sensor:range` / `:imu` / `:contact`) are authored INSTRUMENTS that read those physics and add mount offset, range limits and out-of-range behaviour; they are what a GNC model should see, because a computer knows only what its instruments report. Getting it backwards is not a style question: gate a landing leg's behaviour on the ALTIMETER — whose datum sits above the pads — and a hand-copied constant has to restate that offset, lighting the legs before touchdown. **A constant in a `.mo` that exists only to translate between two prims' positions means the wire is wrong.** (USD has no standard sensor schema at all — core `UsdPhysics` stops at bodies/colliders/joints, and Omniverse invents its own too: `PhysxContactReportAPI`, `IsaacContactSensor`. `lunco:sensor:*` is the legitimate vendor-namespace case.) - **Publish the physical quantity, not the driving term.** A strut that reports the force *pressed onto* it reads fully loaded while it is still in the air; the honest number is the spring's own reaction, which is exactly zero until compression starts. Take it from the joint that integrates the spring — `PrismaticJoint`'s `force` port — rather than re-deriving it. When a visualization "happens too early", suspect something is publishing an input rather than a result. - **Bevy renders an axis-Y `Cone` with its APEX UP.** Verify the authored axis and orientation in a render before adding a corrective rotation. - **rhai has no float `pow`.** Exponentiation is registered under the OPERATOR name `**` only (`packages/arithmetic.rs`), so `pow(x, 0.7)` throws `Function not found: pow (f64, f64)` every tick — and because a scenario's error is per-tick and non-fatal, the rest of that function silently never runs. Use `x ** 0.7`. - **DUPLICATE NAMES ARE SILENT — check them before debugging unrelated rendering.** Two prims with the same name in one parent, or the same property authored twice on one prim, can be accepted without a diagnostic and change the composed result. Search the parent scope and property name before investigating shaders. - **A procedural camera background is not a sphere.** Author the existing `environment/starfield_sky.usda` pattern: an `Xform` with `LunCoProceduralSkyAPI` and `MaterialBindingAPI`. USD projection stamps `ProceduralSkybox` and the renderer uses its fullscreen background pass, so there is no radius, culling volume, collision surface, or mesh vertex shader to maintain. Use `UsdLuxDomeLight` for a textured environment light; it is a separate lighting contract. - **Custom-shader inputs are snake_case** — the ShaderMaterial reflection binds the WGSL struct's field names (`star_density`, `point_size`, `brightness`). A camelCase `inputs:starDensity` is a dead wire: no error, no effect, and hours of "why does tuning the sky do nothing". - **Exposure and illuminance only mean something together.** The frame's brightness is `illuminance / 2^EV100`, so a scene that copies a `DistantLight` intensity from one file and an `exposureEv100` from another lands stops away from either. Author both on purpose: the sun prim's `inputs:intensity` and the `LunCoEnvironment` prim's `lunco:env:exposureEv100`. - **Celestial time comes from one scene-time policy.** After the scene transaction settles, a `SolarSystem` reference makes body poses ephemeris-driven. The policy uses a valid non-zero `lunco:time:epochJd` on the selected scene root, or current computer UTC converted to TDB when no epoch is authored. Time-dependent consumers wait for the decision. Missing or invalid authored time warns and is linted; author a root epoch for repeatable scenes. - **`LoadScene` path must be root-qualified** — use `lunco://scenes/luncosim/lander_ops.usda` for a shipped asset or `twin:///…` for an opened Twin. Use the currently assigned Twin authority from asset discovery; never reconstruct it from a manifest name or retain an outgoing mount URI (see [mount identity](../../docs/architecture/55-scene-addressing-and-roots.md#3-mount-addresses-and-stable-source-identity)). Use `OpenFile` for a native filesystem path or standard `file:` URI. Encode URI filenames through `lunco-storage::file_path_to_uri`; the opener uses `file_uri_to_path` before I/O. Windows drive and UNC paths remain native paths unless explicitly URI-encoded; never construct a file URI by prefixing `file://` to a native path. Native composition encodes its root as a standard file URI and resolves sibling filenames through `lunco-usd-compose::canonicalize_at`; propagate its `Result` so invalid file addresses reject the operation. Native binary/texture/source projection must follow the [typed native admission contract](../../docs/architecture/55-scene-addressing-and-roots.md#native-payload-and-source-admission): use the originating live Twin authority and preserve `AssetPath` through the actual load, with glTF labels attached separately. Native references must use the current worker-prepared admission table; filesystem case aliases and containment are resolved off the UI thread, and absent/stale entries reject visibly. - **Scene command rejection is terminal** — a malformed `LoadScene` address or absent USD asset pipeline returns an error before admission. Check the command result; a warning alone must never count as an accepted load. - **Assets resolve beside their contributing layer** — composed default asset values carry the maintained OpenUSD canonical identifier even when the payload is not loaded. I/O readers use `UsdRead::asset_identifier` and shared typed admission; keep raw strings for authoring. References and sublayers must not reanchor texture, program, shader, albedo or DEM sources to the root scene. DEM source is a directory; its existing worker owns lookup and heightmap I/O. In a Twin, an asset value spelled as an OpenUSD search path (`@terrain/site@`, no `./`/`../`) resolves beside its layer first, then from the Twin root and its caches; `./`/`../` spellings and composition arcs are strictly layer-relative. Missing consumed provenance rejects visibly; unanchored asset time samples are not admitted. - **Logical scene filenames remain literal** — USD entries, previews and composition dependencies use the shared typed `load_asset_path` adapter. Keep `#`, `%`, spaces and Unicode in the filesystem path; attach Bevy labels separately. Validate OpenFile, Twin scene opening, LoadScene and RestartScene against the same existing document and exact current authority. - **Native USD references prepare before admission** — transitive file-URI layers use the loader's I/O worker. Incremental references and coarse rebuilds use the existing bounded reference lane to prepare confined typed addresses and canonical reanchored recipes. Each live instance retains the real source handle and shared canonical recipe/plan snapshot. Warm siblings reuse the canonical stage's weak snapshot cache only after exact-origin, loaded source-revision and live-mount checks; stale or failed preparation cannot release the current reference/document hold. See [native admission](../../docs/architecture/55-scene-addressing-and-roots.md#native-payload-and-source-admission). - **Browser imports have pathless identity** — picked bytes enter the existing asynchronous USD preparation pipeline, then install as a fresh Application document. The filename only names the document; duplicate filenames never reopen another pick. Browser Save As downloads the current source and reports saved state only after successful download admission. - **Portable Twin filenames** — generated save destinations and rename targets use the [shared file-workflow contract](../../docs/crates-index.md). Preflight invalid final names before any save/manifest command; keep rename source paths lossless and validate the actual parent through Storage. - **Document source ownership is admitted before publication** — `OpenFile` captures `FileDocumentAdmission` before worker I/O and validates its resolved runtime owner before installing source. New documents capture creation scope; forks retain source scope; private session restores belong to Application. Saving a path does not change runtime lifetime. See the [source lifetime contract](../../docs/architecture/55-scene-addressing-and-roots.md#document-source-admission-and-lifetime). - **Twin root ownership uses the canonical native path** — `Twin::open` stores the resolved root, including the Windows verbatim prefix. Opening a USD file inside that root remains a document-only open; it does not replace the running Twin. - **Display names are separate from Twin source components** — `TwinRoots::register_twin` URI-encodes the manifest/folder name at mount admission. Keep the displayed name unchanged and use the returned authority; spaces, Unicode, `%`, `#`, `/` and `?` in a name do not authorize reconstructed load addresses. - **Verify native paths on Windows** — use the nightly Windows native-path step for file URI, canonical Twin root, provider manifest, and typed native asset admission coverage. Record Windows execution separately from Unix results; the focused step does not exercise junction escapes. - **Spawn `entry_id` must be in the catalog** — an unknown id logs `unknown entry '…'` and no-ops. List first with `list_bundled`. - **Empty spawn path / root_prim → the `defaultPrim` sentinel**: an empty path means "the stage's default prim". A stage without `defaultPrim` is an invalid scene mount and fails visibly. - **Spawns land ON the terrain surface.** Placement samples the terrain **height oracle** (analytic, so it works even before a streamed/CDLOD collider tile bakes) — a spawn over un-baked terrain rests on the ground instead of free-falling. The GUI click path terrain-fits the asset's composed `UsdPhysics` collision footprint (slope-aligned, and it considers a physics obstacle under the chassis). An asset without a `defaultPrim` or collision footprint is rejected visibly; placement never invents dimensions or a lift. The API `SpawnEntity` path uses the explicit position supplied by the caller, so pass a real Y. On admission, the physics owner validates the authored pose against the live terrain using the collider's support geometry for convex shapes; a persisted pose that now overlaps after terrain changes remains held and must be repaired through `TransformEntity` or removed through `DeleteEntity`. - **One spawn = one entity.** In a single-player (`Standalone`) session a `SpawnEntity` instantiates exactly one rover; it is not also re-projected from the document (that path is suppressed to avoid a double-instantiation / vanish-on-reload). - **Gizmo / selection frame:** on a static-USD select, the selectable root is tagged `SelectableRoot` in the **world frame** — not `GridAnchor`. If the gizmo grabs the wrong thing or the wrong frame, that tag is why. - **Never `SetDocumentSource` for live scene building** — it replaces the whole source and cancels in-flight work. Submit typed scene commands (`SpawnEntity`/`MoveEntity`/`TransformEntity`/`SetObjectProperty`), and let their handlers lower the intent through the existing USD operation path. - **USD → ECS is a projection**, so authored changes flow one way — edit the USD (via ops), and the ECS scene reconciles. Don't hand-mutate ECS transforms expecting them to persist. - **Behaviour ≠ scene.** Making a spawned rover *do* something (drive, patrol) is a scenario — see [`author-scenario`](../author-scenario/SKILL.md); its self-driving GNC is [`authoring-vessel-controllers`](../authoring-vessel-controllers/SKILL.md). ## Anti-patterns - ❌ Writing or patching `.usd`, `.usda`, or `.usdc` source text directly, even for a temporary preview. Open the exact document, inspect its authored layer and edit target, submit schema-aware commands or typed `ApplyUsdOp(s)`, save, and inspect the result. If the authoring API is missing a needed operation, extend that API before changing the scene. - ❌ Passing a bare or absolute filesystem path to `LoadScene`. - ❌ Guessing an `entry_id` instead of `list_bundled`. - ❌ `SetDocumentSource` to build a scene incrementally — use the typed scene commands and their USD operation path. - ❌ Branching placement math on up-axis/units — the frame is fixed; convert only at the importer. - ❌ Mutating ECS `Transform` directly and expecting USD to remember it — author the USD.