--- name: build-vehicle description: > How to BUILD A VEHICLE (rover, hauler, wheeled anything) for LunCoSim out of the mobility component library — assembly root, wheels, tires, suspensions, chassis, lights, variant axes, drive laws, and live parameter tuning. USE THIS SKILL when the user asks to "make/build a rover", "add a vehicle", "give it different wheels/tires", "swap the drivetrain", "tune wheel physics", "make the drivetrain a Modelica model", or asks why a wheel refuses to spawn. For a single reusable part use author-usd-component; for scene assembly use build-usd-scene; for GNC use authoring-vessel-controllers. Project-specific and non-obvious: wheel params are STRICT (a missing attr refuses the wheel and names everything missing), defaults live in components/mobility/wheel.usda (never in Rust), the raycast/physical split is decided per wheel by an authored PhysicsRevoluteJoint, and live edits flow ApplyUsdOp → in-place wheel resync (never a respawn). --- # Build a vehicle A vehicle is a **thin assembly**: a `kind = "assembly"` Xform root that references library components and authors only its own decisions — poses, indices, scale, paint. Components own their defaults; **variants choose components, they never restate them**. For script-authored assembly edits, the dynamically reloadable `assets/scripting/tools/assembly_builder.rhai` library provides semantic frame/shape construction, placement, Cube and composed collision alignment/clearance, referenced-part mounting, existing-mount frame realignment, and body/joint plans. A referenced assembly uses `referenced_instance_plan` (or its targeted form), then applies `select_variants_plan` only after the reference children are queryable. Parameter edits use `parameter_plan` and the same typed `ApplyUsdOps` boundary as the Editor; the planner is generic and does not know vehicle names or paths. When iterating a referenced component in Editor, use `component_editor::selected_update_context` to preserve the exact preview, document, path, and generation, then use `component_editor::update_plan` with the component recipe from the owning Twin. Review and commit its one typed change set before checking the composed rover; do not put vehicle-specific recipes or a component registry in Rust. Mission-specific recipes and study assets belong in the Twin that owns them, rather than in the core asset library. A Twin tool may compose these generic helpers and supply its own explicit paths and study values, but it must remain Rhai-owned and use the proposal/journal boundary. Keep component defaults in reusable USD components and author vehicle-specific choices in the Twin assembly. When a vehicle part is already attached and a socket or plug frame moves, use `assembly_builder::mount_frame_realignment_plan` with the exact authored paths. The tool composes nested `translate`/`rotateXYZ` unit-scale frames, updates the part and its recorded joint anchor atomically through the review boundary, and rejects invalid topology or non-rigid frame data before any USD edit. For an AI- or human-led vehicle build, use `model_authoring::model_context` before choosing parts and `readiness_report` after the component contracts are composed. Use `port_graph` to inspect the standard USD endpoint surface and `wiring_plan` to produce one typed, generation-checked connection plan for Modelica/Rhai/physics endpoints. Use `scene_recipe` for the surrounding test scene and `publish_component` when a validated part is ready for explicit Save-As. These are dry Rhai facades; keep wheel/rover policy and dimensions in the owning Twin and send all USD operations through the existing journal path. See [`scripting-guide.md`](../../docs/scripting-guide.md#model-and-assembly-authoring-human-and-ai) for the call sequence. Working exemplars, simplest first: `assets/vessels/rovers/skid_rover.usda` (4-wheel skid), `ackermann_rover.usda` (steering), `six_wheel_rover.usda` (per-wheel port wiring + `driveLaw` variant), `six_wheel_independent.usda` (fully authored per-wheel mix), `rocker_bogie.usda` (linkage + gear-joint differential), `rucheyok/` (Z-forward, Modelica electrical). ## Reuse-first vehicle audit Before authoring a new vehicle, identify the closest shipped assembly and read its component references, variant axes, composing scene, and runtime test. Use the existing assembly as the structural template and change only the authored vehicle facts: poses, dimensions, mass properties, topology, bindings, and parameters. Classify the request before editing: ```text existing generic mechanism -> compose/configure it missing vehicle asset -> author USD assembly/components missing equation -> extend/reuse Modelica package missing engine behavior -> prove with a focused fixture before Rust missing evidence -> add an authored test and production run ``` Do not infer an engine gap from the absence of a mission-specific vehicle file. Do not use a tutorial or presentation-only vehicle as the production physics contract without checking its consumer and test. ## The component library (`assets/components/`) | Part | File | Owns | |---|---|---| | Wheel hub | `mobility/wheel.usda` | dimensions, mass, brake, contact, and solved shaft boundary — THE default set every wheel composes | | Tire | `mobility/tires/*.usda` | grip (`physics:dynamicFriction`, `physxVehicleTire:longitudinalStiffness`, `physxVehicleTire:lateralStiffnessGraph`, `physxVehicleTire:restLoad`) + look (wheel.wgsl inputs: tread lugs, relief, wear) — chosen via the wheel's `tire` variantSet | | Suspension | `mobility/suspensions/*.usda` | compliance (`lunco:suspension:restLength`, `physxVehicleSuspension:*`) + strut visuals — ALL suspensions carry them: standard/rocker have the animated Casing/Piston/Spring trio (`lunco:suspensionVisual:role`), rigid a static casing only (zero travel ⇒ no roles) | | Battery | `power/battery.usda` | reusable physical/nameplate/electrical contribution; the rover-root collection composes it with loads and synthesizes one acausal network DAE | | Ideal rail | `power/ideal_voltage_source.usda` | authored unlimited-power source for the `infinite` power variant; it is still compiled into the same electrical network | | Motor / reduction / shaft | `mobility/motor.usda`, `mobility/gearbox.usda`, `mobility/avian_shaft.usda` | Modelica electrical and rotational equations plus the generic Avian mechanical boundary | | Motor thermal | `thermal/motor_thermal.usda` | rover-agnostic thermal PARTS (`MotorHeatLoad`/`MotorThermalMass`/`MotorRadiator`); each rover authors its own `Scope "Thermal"` with one heat load per driven motor, compiled to its own DAE separate from the rover-root network — chosen via the rover's `thermal` variantSet | | Chassis | `mobility/chassis/box_chassis.usda` | collider + panelised hull material (`rover_hull.wgsl`) | | Headlight | `lights/headlight.usda` | spotlight + casing + glowing lens, self-contained | | Drive law | `mobility/drive_laws/modelica_{skid,ackermann,six_independent}.usda` | Authored Modelica controller and generic drive/heading outputs | | Drivetrain realization | `mobility/physical_drivetrain.usda` | the `physical` variant: articulation root + per-wheel revolute joints. The `raycast` variant is EMPTY — a raycast wheel is the absence of a joint. Wheel MOUNTS are never authored here: the wheel prim is the axle in both realizations, so it belongs to the rover, outside the variantSet. | ## Minimal rover ```usda def Xform "MyRover" ( kind = "assembly" prepend apiSchemas = ["PhysicsRigidBodyAPI", "PhysxRigidBodyAPI", "PhysicsMassAPI", "PhysxVehicleContextAPI", "LunCoCatalogAPI"] ) { uniform bool lunco:spawnable = true float physics:mass = 1000.0 float3 physics:diagonalInertia = (1028, 1354, 341) # author it — see skid_rover def "Controls" ( prepend references = @lunco://vessels/control_profiles.usda@ ) {} def Cube "Chassis" ( prepend references = @lunco://components/mobility/chassis/box_chassis.usda@ ) {} def Cylinder "Wheel_FL" ( prepend apiSchemas = ["PhysxVehicleWheelAttachmentAPI", "PhysxVehicleWheelAPI"] prepend references = [ @lunco://components/mobility/wheel.usda@, @lunco://components/mobility/suspensions/standard.usda@, ] variants = { string tire = "regolith" } ) { double3 xformOp:translate = (-1.0, -0.15, -1.225) uniform token[] xformOpOrder = ["xformOp:translate"] int physxVehicleWheelAttachment:index = 0 } # …Wheel_FR/RL/RR: index 1/2/3, mirrored translates… } ``` - `PhysxVehicleContextAPI` on the root ⇒ `MobilityRoot` + `OutputPorts` + authored ports; `MobilityRoot` identifies the vehicle and `OutputPorts` indexes the generic command/output surface. - Vehicle motion policy is authored in the composed Modelica/Rhai controller. Skid, Ackermann, crab, and independent-wheel modes are programs that publish different named outputs; they are not Rust type dispatch. - Wheel→port wiring is a USD connection: each wheel connects `float inputs:drive.connect` and, where applicable, a heading-joint input to an authored `float outputs:` on the controller/network boundary. ## Wheel physics: one parameter set, two realizations Both wheel kinds read the SAME attributes through ONE strict reader (`lunco-usd-sim-authoring/src/wheel_params.rs`). Only force generation differs: - **raycast** (default): analytical spring + traction force at the hub. Requires a composed suspension. - **physical**: authored `PhysicsRevoluteJoint` targeting the wheel via `physics:body1` ⇒ rigid body + solved shaft torque boundary. That joint IS the switch — the `drivetrain` variantSet on the 4-wheel rovers just references the component that authors (or omits) the joints. **One authored mechanical network, so both realizations consume the same solve.** `DCMotor.mo` solves winding current, back-EMF, electromagnetic torque, terminal voltage/current, and heat from its authored electrical pin and measured speed. Its solved torque crosses the authored generic `Torque` boundary into `GearRatio.mo` and `AvianShaft.mo`. The physical wheel applies that torque across its revolute joint; the raycast wheel uses the same solved torque in its contact-plane spin equation: ``` F_long = clamp(k_slip · (ω · radius − v_forward), −μN, +μN) ω̇ = (τ_motor + τ_brake − F_long · radius − c_bearing · ω) / I ``` Speed is therefore determined by the authored motor, gearbox, complete wheel assembly inertia, and tire load, not by a second Rust motor curve or a copied no-load-speed clamp. The motor speed input is connected to the engine's solved wheel speed, and its torque output is connected through the generic authored boundary. Both realizations consume that same solved network; no Rust motor or second shaft state mirrors it. **Strictness:** every drivetrain/tire attr is required; a wheel missing any refuses to spawn and the error names ALL missing attrs. That now includes `physxVehicleWheel:dampingRate` (bearing + rolling drag): it is a physical property of the hub, so it is authored, never inferred from the drive torque — drive torque is not derived from unrelated values. `physxVehicleWheel:moi` is the complete authored tire-and-drivetrain assembly inertia; when omitted, the documented solid-cylinder derivation ½·m·r² applies. You never author them per vehicle — composing `wheel.usda` + a tire + (for raycast) a suspension is the complete set. If your wheel refuses to spawn, you dropped one of those three arcs. **Tuning:** all wheel params carry schema-level slider hints, so every wheel gets Inspector sliders with zero per-asset authoring (`SchemaRegistry::ui_hint` → `produce_usd_param_view`; a per-asset authored `customData` still overrides — see [`author-usd-component`](../author-usd-component/SKILL.md#adding-a-new-lunco-property--source--regenerate)). To reach one wheel: select the rover, then **Alt+Shift+click** the wheel — that drills the Inspector to that subpart's own PRIM (`crates/lunco-luncosim-edit-ui/src/selection.rs`). Plain **Shift+click is the multi-select extend** and retains the existing selection; it does not drill. The drill also requires the rover to already be the primary selection. Wheel, suspension, tire, and authored vehicle-control edits go `ApplyUsdOp SetAttribute` → document → **in-place resync**. `UsdSimPlugin` registers the vehicle wheel owner with `lunco_usd_bevy_core::live_edit::UsdLiveEditRegistry`; that owner claims the wheel, suspension, tire, and authored vehicle-control attributes and refreshes the live components from the composed stage, preserving the current entities and joints. Other edits that touch a rigid body, collider, or joint promote to a stage-wide reset so external joint endpoints are retired and admitted together. The physics owner removes graph edges before joint components and collider markers; a failed reset holds the active simulation and reports the owner error. Never poke `WheelRaycast`/`RevoluteJoint` components directly; the next document change would overwrite you. ## Delivery order Author the vehicle and its test in the owning Twin. Keep generic components and engine regression scenes in the shared LunCo asset library only when they are reusable beyond one mission or presentation. A scene-specific vehicle should reference the shared components rather than make the shared library depend on a one-off scene. Build the smallest authored-controller, raycast rover path before adding power, thermal, autonomy, or a physical drivetrain. For a lunar presentation, start with the existing `skid_rover` on the shared `lunar_surface` base: it has the complete command surface and the least moving runtime parts. The first acceptance gate is `scenes/tests/drivetrain_parity.usda`: its Rhai scenario settles, drives both wheel realizations, and emits the real verdict `DRIVETRAIN PARITY: PASS|FAIL`. A rover that merely composes, or two rovers that are both stationary, do not pass. Only after that gate passes, add one concern at a time: 1. terrain/course and presentation cameras; 2. the vehicle-specific assembly or rocker-bogie morphology; 3. the authored Modelica drive law, proved by `scenes/tests/modelica_drive_law.usda` (`MODELICA DRIVE LAW: PASS|FAIL` proves the composed output surface and motion); 4. battery, generation, thermal, then autonomy/story behaviour. Do not combine these stages. A failed rover with a new terrain, Modelica model, and scenario has too many owners to diagnose; restore the last passing stage before adding the next one. ### Coordinate contract for mounted mechanisms For an antenna, camera gimbal, solar head, or other rover-mounted tracker, declare one coordinate contract before tuning: world axes, rover/mount local axes, joint positive axes and order, and the physical boresight. Derive the Modelica setpoint from that contract; never copy a `-Z forward` formula into a component whose geometry points along another axis. Validate the target vector, setpoint, measured joint angles, and rendered boresight together after a full scene reload. For a fixed photovoltaic deck, there is no tracker controller to tune. Reference `components/power/solar_panel.usda` once, author its `inputs:area` and placement on the rover, use the component's +Y normal unless a different face is explicit, connect its `connectors:p` to the battery, and include both in the rover-root `CollectionAPI:components` collection with the driven loads. Keep the panel's visual frame and cell surface under that same mounted component; do not add a second rigid body or a disconnected visual proxy. A horizontal deck uses the component's +Y collecting face. Verify `power_out`, `cos_incidence`, battery current and `soc_out`, not only that a panel prim appears in the hierarchy. ### Stability before tuning If a lunar rover tips at launch, inspect the assembled load path before changing solver settings or adding visual smoothing. High-grip contact forces applied at the wheel plane create a real pitch moment when the authored `physics:centerOfMass` is high. The vehicle-level acceptance test should report travel, maximum tilt, detached descendants, and fixed-step sample count. A repeatable tilt failure is a geometry/mass/traction defect; a visual jitter with the body and its labels moving together is a coordinate/transform defect and needs composed transform inspection. ## Variant axes (orthogonal, each choosing a component) Axes are **opt-in per vehicle** — a rover only has the axes its file declares. What is actually authored today: | Rover | `drivetrain` | `driveLaw` | `power` | `thermal` | |---|---|---|---|---| | `skid_rover` | ✅ | ✅ | ✅ | — | | `ackermann_rover` | ✅ | ✅ | — | — | | `six_wheel_rover` | — | ✅ | ✅ | ✅ | | `six_wheel_independent` | — | ✅ | — | — | | `rocker_bogie` | — | ✅ | — | — | (`tire` is per-wheel, not per-vehicle — it is declared once on `components/mobility/wheel.usda` and every composed wheel has it. `differential_rig.usda` and `rucheyok/` are not driveable vehicles and have no axes.) Adding a missing axis to a rover is a few lines of `variantSet` copied from an exemplar — that is the intended way to extend, not a Rust change. - `drivetrain` = **raycast | physical** — how wheels are realized physically. Authored on `skid_rover` and `ackermann_rover`. Switching it changes fidelity and cost, NOT how fast the rover goes: both realizations self-limit at the motor/gearbox axle no-load speed · radius (see *Wheel physics* above). - `tire` (per wheel) = **regolith | hard | cleated | worn | bald** — grip+look. - `driveLaw` = **modelica | rhai** — how throttle/steer become final drive and heading port values. Exists on ALL driveable rovers; one authored controller program owns the mapping for its vehicle: * `drive_laws/modelica_skid.usda` (skid_rover, six_wheel_rover, rocker_bogie): `RoverDrivetrain.mo` integrates a per-side motor lag on the solver clock; native USD connections publish `drive_left`/`drive_right`. * `drive_laws/modelica_ackermann.usda` (ackermann_rover): `RoverAckermannDrivetrain.mo` publishes the front wheel heading outputs and the drive outputs consumed by the raycast or physical realization. * `drive_laws/modelica_six_independent.usda` (six_wheel_independent): the SAME `RoverDrivetrain.mo` (the law is per-side; fan-out is wiring, not physics) with a bridge writing `drive_w0..w2` = left, `drive_w3..w5` = right. Allocation ownership is the composed controller's complete authored output wiring. A partial or missing controller contract is an authoring error; it is not replaced by a second controller. The whole law is USD + `.mo` + `.rhai` — no sentinel hook or type-specific Rust path. Wheels stay port-name-agnostic throughout: each listens to its `lunco:drivePort` (or the index-parity default, even ⇒ drive_left / odd ⇒ drive_right); a drive law is a VEHICLE-level component that writes those ports by name. - `power` = **infinite | battery** — does driving cost anything. `infinite` is an EMPTY variant (absence of a battery = today's drive-forever default); `battery` references reusable battery and motor parts, authors their connector topology in the rover file, and lists the actual part paths in a standard `CollectionAPI:components` on the rover root. Runtime projects that collection as one acausal electrical DAE, with drive commands entering as scalar domain-boundary inputs. Brownout and current limiting are equations and therefore belong in the projected Modelica island. Production Rhai must never scale drive ports per tick. - `thermal` = **none | basic** — do the motors have temperatures. `none` is EMPTY; `basic` authors a `Scope "Thermal"` with its own `CollectionAPI:components`, compiled to a SEPARATE generated DAE from rover-root network. Each driven motor gets one `MotorHeatLoad` (from `thermal/motor_thermal.usda`); the motor's solved `outputs:heat` crosses into the thermal island as a causal `inputs:motor_heat_*` boundary wire (a runtime `SimConnection`). The acausal `connectors:port` edges stay inside the thermal collection (heat balance per bank). This compiles and publishes `motor_temp_left`/`motor_temp_right` (K) REGARDLESS of the `power` variant — thermal is decoupled from electrical. See `docs/architecture/34-scenario-and-multidomain.md`. Exemplar: rocker_bogie (6 motors), skid_rover (4), six_wheel_rover (6). ## Looks **Colour is `primvars:displayColor`, always — the shader CONSUMES it.** One authored attribute, in the standard USD place, whether the part renders through plain PBR or through a shader. `rover_hull.wgsl` declares `//!@engine display_color` and the engine fills it from the prim's composed `primvars:displayColor` (element 0 — it is a `color3f[]` ARRAY by schema). Restyle a rover, or a difficulty tier, by overriding that one attribute: ```usda over "Chassis" { color3f[] primvars:displayColor = [(0.30, 0.72, 0.35)] } ``` Shader `inputs:` are for what displayColor cannot say — `accent_color`, `panel_scale`, `wear`, and `dust_amount` where the owning shader defines it. Authoring `inputs:display_color` explicitly still wins over the engine fill, but you rarely want that; it hides the colour from every other tool that reads USD. Tire look lives on the tire component (`wheel.wgsl` inputs `tread_lugs`, `lug_depth`, `wear`) — a tire that grips differently should LOOK different in the same file. Tires author their colours as shader `inputs:` deliberately; that is unchanged. ## Verify For iterative modeling, keep one luncosim process running with an explicit `--api PORT` and use that API. Edit a component through the focused Editor preview with `assembly_edit`/`assembly_builder`; the document owner journals the change and the dependency-scoped projection refreshes mounted references in place, preserving the current camera and selection. Wait for the matching projected generation, query the affected composed paths, capture the focused view, and re-run the Rhai observer with `RunScenario` before saving. `RestartScene` is reserved for an explicit scene-lifecycle test or a Rust binary rebuild. Never use it as the normal response to a component edit, and never emulate refresh by manually respawning a visual subtree. If a referenced component fails to refresh, inspect the document/preview generations and the projection diagnostics; report the dependency or capability error instead of adding a second writer or a restart fallback. **1. Pre-flight, before launching anything** — composes the whole reference closure and runs the same strict wheel reader the spawner uses, so a missing attribute is named in seconds rather than at spawn time ([`validate-assets`](../validate-assets/SKILL.md)): ```bash "$LUNCOSIM_BIN" --validate assets/vessels/rovers/my_rover.usda ``` **2. Drivetrain parity regression** — the guard that the two realizations stay matched. `assets/scenes/tests/drivetrain_parity.usda` instantiates `skid_rover` twice side by side (`drivetrain = "raycast"` at x = −25, `"physical"` at x = +25) and auto-runs `assets/scenarios/tests/drivetrain_parity.rhai`: settle 3 s → full throttle straight 12 s → throttle + steer 6 s. ```bash "$LUNCOSIM_BIN" --api 4101 --scene scenes/tests/drivetrain_parity.usda 2>&1 | tee target/parity.log grep -E 'DRIVETRAIN PARITY|PARITY FAIL' target/parity.log ``` It asserts terminal speed ±15 %, peak speed ±15 %, distance ±20 %, yaw magnitude ±35 % with a strict sign check, and that **both** land in `[2.4, 6.0] m/s` — the absolute band around the authored `ω_max · r ≈ 4.8`. Both-near-zero is a FAIL, not a pass. **It emits no exit code** — the verdict is the last stdout line `DRIVETRAIN PARITY: PASS|FAIL`, so grep for it; a green-looking run that never printed the line means the scenario never reached its verdict. Run this after ANY change to wheel params, the authored motor network, or `wheel.usda` defaults — it is the only thing that catches the two realizations drifting apart. **3. Interactive** — spawn from the palette (folder = category; needs `lunco:spawnable` on the `defaultPrim`, see [`use-asset-library`](../use-asset-library/SKILL.md)), possess, drive ([`test-via-api`](../test-via-api/SKILL.md)): throttle ⇒ position delta; steer ⇒ heading change; both `drivetrain` variants. `QueryEntity` a wheel prim ⇒ canonical attrs resolved. Watch the log: wheel refusals and resyncs are loud by design. For a raycast wheel that rests on the chassis, inspect `QueryPhysicsState` on its `MobilityRoot`: the `wheel_contacts` entries report the actual hit, owner, ray origin, and distance. Carrier resolution uses the same stage path and instance root while excluding `UsdPreviewOnly` descendants; same-path preview entities must never own a mounted wheel's support ray. ## Anti-patterns - ❌ Duplicating motor/gearbox torque or speed on a wheel — tune the composed motor and gearbox parts; wheel-local `lunco:` fields are only for wheel mechanics such as steering axis and drive damping. - ❌ Restating component defaults in the assembly (radius 0.4, axis "X", displayColor) — delete; composition provides them. - ❌ A variant that inlines prims instead of referencing a component. - ❌ Editing wheel components in ECS/Rust for "live tuning" — the document is the only writer; use the Inspector sliders or `ApplyUsdOp`. - ❌ Hand-writing a **wheel** `PhysicsRevoluteJoint` outside a drivetrain component — that joint is the raycast/physical discriminator; keep wheel hinges in the variant arc. Generic revolute mechanisms (antenna, solar tracker, arm) are allowed: their `body1` is not a `PhysxVehicleWheelAPI`, so they must not alter drivetrain admission or articulation classification. A generic mechanism owns its own hinges and is composed as a root overlay onto the vehicle body; do not re-author its hinge in the rover file. - ❌ Expecting plain Shift+click to drill into a wheel — it extends the multi-selection. Alt+Shift+click drills. - ❌ Adding a second name for a quantity that already exists — one authoritative motor/gearbox reduction, one reader, one place to change it. - ❌ Overriding a shader `inputs:` to repaint a rover — author `primvars:displayColor`; `rover_hull.wgsl` consumes it via `//!@engine display_color` ([`use-asset-library`](../use-asset-library/SKILL.md#add-a-shader-wgsl)). - ❌ Changing wheel physics without re-running the drivetrain parity scene — the two realizations drift silently otherwise. - ❌ Assuming every rover has every variant axis — check the table above; most have only `driveLaw`. - ❌ Creating a new rover assembly before reading the nearest shipped exemplar, its composing scene, and its behavior test.