---
name: author-usd-component
description: >
  Author a reusable LunCoSim USD asset from scratch: geometry, materials,
  physics, behavior, parameters, or spawn-catalog metadata. Use for new
  habitats, landers, rover parts, shaders, colliders, or parametric assets.
  `xformOpOrder`, standard USD schemas, array display colors, collider
  approximation, component requirements, and component tests are the key
  contracts. Use build-usd-scene
  for assembling existing assets, use-asset-library for placement/discovery, and
  validate-assets for pre-flight checks.
---

# Author a USD component

USD is the **source of truth**, projected to Bevy ECS. For a live Editor task,
you build a thing through a Rhai typed-operation plan and the document command
owner; the engine then projects the authored stage. Do not hand-edit USDA text,
patch a file behind the open document, or mutate ECS state to force a result.
The resulting component is still a normal `.usda` asset, but its persistent
file is written by the Editor's save operation. Nothing here is a Rust change
unless the typed owner is missing a generic USD capability.

Frame is fixed: **Y-up, right-handed, −Z-forward, SI metres** (`docs/architecture/41-axes-and-units.md`).
Author in that frame. `upAxis = "Z"` / `metersPerUnit != 1` are converted once at
the shared USD boundary (`crates/lunco-usd-document/src/units.rs`; the runtime
reader adapter is in `lunco-usd-bevy`) — never branch on them.

Background: [`21-domain-usd.md`](../../docs/architecture/21-domain-usd.md),
[`50-usd-driven-visuals.md`](../../docs/architecture/50-usd-driven-visuals.md).
Before adding a schema or property, read
[`clean-architecture-and-usd-standards.md`](../../docs/architecture/clean-architecture-and-usd-standards.md)
and run its standard-schema gate. Use `UsdGeom`, `UsdPhysics`, `UsdShade`, and
`UsdLux` where they own the concept; add a LunCo field only for semantics USD
does not define, then delete any overlapping superseded field and reader in the same
cutover.
Related skills: [`use-asset-library`](../use-asset-library/SKILL.md) (where the
file goes, how it is discovered, the `lunco://` scheme),
[`build-usd-scene`](../build-usd-scene/SKILL.md) (assemble),
[`edit-usd-assembly`](../edit-usd-assembly/SKILL.md) (interactive headful
assembly authoring with screenshot/user-feedback checkpoints),
[`validate-assets`](../validate-assets/SKILL.md) (pre-flight it),
[`test-via-api`](../test-via-api/SKILL.md) (verify), [`compose-multidomain-twin`](../compose-multidomain-twin/SKILL.md).
For creating or hot-registering reusable Rhai builders, lints, and component
tests, read [`author-rhai-tool`](../author-rhai-tool/SKILL.md).
For the agreed live feedback loop and the Blender/FreeCAD/Fusion/SOLIDWORKS/
COMSOL/OpenUSD practice mapping, read
[`interactive-component-authoring`](../interactive-component-authoring/SKILL.md).

## Component contract before geometry

Treat a component as a lightweight CAD deliverable rather than an isolated
mesh. Before authoring, state its local frame, mount datum, required
topology/types, dimensional envelope, mass/inertia owner, collision policy,
parameters/units, public provenance, and any deployment or operating limits.
Keep public/reference-backed facts separate from Twin study assumptions.
For material-dependent parts, record a typed SysML material reference and the
condition-specific properties required by the analysis; the USD material
assignment/runtime facts remain standard authored USD and must be an explicit
projection of that same selection, not a second manual source. Keep this
distinct from `UsdShade` appearance and `UsdPhysicsMaterialAPI` contact
coefficients.
Use the [SysML material-property gate](../interactive-component-authoring/references/mission-engineering-quality.md#physical-materials-and-engineering-properties)
for the single-source catalogue rule. Do not author `physics:density` expecting
it to affect runtime mass: this component path consumes explicit `physics:mass`.

Before authoring any component, use the repository-wide five-phase workflow in
[`interactive-component-authoring`](../interactive-component-authoring/SKILL.md):
analyse the USD/Rhai/Modelica/Rust seam, split by ownership, record sources and
rationale in SysML, build with typed Editor tools, and run component → assembly
→ whole-system gates. This applies to every mission and model. A missing datum
or unsupported generic operation is an explicit failing report; this skill does
not permit invented dimensions, duplicated requirement literals, or a direct
USDA workaround.

One independently reusable or articulated part gets one explicit component
root and its own USD file under the Twin's `components/` tree. The component's
Rhai tool creates typed USD operations and its requirement/test Rhai reads the
composed stage and checks both normal and boundary cases. The parent assembly
must separately test reference identity, placement, symmetry/clearance, joint
endpoints, variant selection, and cross-component wiring. Do not rely on the
assembly test to prove the component's internal contract, or on a component
test to prove it is correctly mounted.

For live Editor work, create/open the component document, apply the typed
Rhai plan, wait for projection, inspect the exact prims, capture a screenshot,
and only then save. Do not hand-edit or flatten USDA text to accelerate a
component change. Use standard USD variants for genuine configurations; if
the typed editor cannot create the needed variant set/blocks or reference-list
opinion, report the Rust capability gap instead of using hidden duplicate
geometry as a substitute.

Keep the component's Rhai source reviewable: pure plan functions,
read-only requirement reports, and runtime test observers are separate
responsibilities. Register Twin-scoped libraries through `RegisterToolLibrary`
and verify a real namespaced call in the same process; `ListToolLibraries` alone
is not invocation proof. See [`author-rhai-tool`](../author-rhai-tool/SKILL.md).

For recurring parametric parts, use the generic
`assembly_builder::component_bundle_facts` and
`assembly_builder::component_bundle_plan` contract instead of adding a
vehicle-specific schema or Rust type. Supply SI-metre dimensions, standard
Cube/Cylinder/Cone geometry, explicit visual/collision roles, existing
material targets, mass/inertia, named attachment or actuator frames, and any
limits. The facts call is the validation boundary; the plan returns typed USD
operations for the normal proposal/review/commit flow. It does not invent a
body, joint, socket occupancy, or material asset. Add those contracts through
their existing explicit builders and test the component's normalized facts
and composed result in Rhai.

When editing an existing component rather than creating it, use the generic
`component_editor::update_context` or
`component_editor::selected_update_context` facade. It combines the exact
document/edit-target/generation checkpoint with the standard property catalog;
`component_editor::update_plan` delegates to the same bundle planner and
returns dry typed ops. Keep the bundle recipe in the owning Twin/model package,
preserve existing topology and material ownership, and commit one reviewed
proposal. Do not infer the recipe from child names or add a Rust registry.

When the component is ready to become a reusable asset, call
`model_authoring::publish_component(doc, root, edit_target, output,
provenance)`. It checks the top-level component root, standard `kind` and
`defaultPrim`, applied schemas, reference identities, and caller-supplied
provenance, then returns ordinary metadata ops and an explicit `save_as`
command. Review and apply the ops through `assembly_edit`; call
`assembly_edit::save_as_document` explicitly after projection/lint. The helper
does not invent a provenance schema, write USDA directly, or autosave.
See [`scripting-guide.md`](../../docs/scripting-guide.md#model-and-assembly-authoring-human-and-ai)
for the complete facade sequence.

## Skeleton

**One file = one spawnable thing.** The catalog keys off the file, and
`lunco:spawnable` must sit on the stage's `defaultPrim`.

For live authoring, the skeleton below describes the authored result; it is
not permission to paste or rewrite USDA source. Use `assembly_edit::new_document`
or open the exact component document, call the component's Rhai builder, apply
its reviewed typed ops, inspect the projected result, and save through the
document lifecycle.

```usda
#usda 1.0
(
    defaultPrim = "Widget"
    upAxis = "Y"
    metersPerUnit = 1.0
    doc = """What this is, and where its numbers came from."""
)

def Xform "Widget" (
    kind = "component"
    prepend apiSchemas = ["LunCoCatalogAPI"]
)
{
    uniform bool lunco:spawnable = true

    def Scope "Looks" { def Material "Shell" { ... } }

    def Mesh "Body" (prepend apiSchemas = ["MaterialBindingAPI"]) { ... }
}
```

`kind` is **authored but read by nothing** — standard-USD hygiene for DCC
interop, not an engine signal. Use `doc = "..."` prim metadata for descriptions;
`doc` is the standard USD description metadata; do not add a `lunco:description`
attribute (`crates/lunco-scene-catalog/src/spawn_meta.rs`).

## Transforms — the mandatory bit

```usda
double3 xformOp:translate = (0, 1.5, 0)
double3 xformOp:scale = (1, 0.5, 1)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
```

**Without `xformOpOrder` the prim is at identity and every `xformOp:*` is
ignored, silently.** Treat a missing or incomplete transform order as an authoring
error. This is the single most common way to author a correct-looking file that
does nothing.

Supported: `translate`, `scale`, `orient` (quat, USD `(w,x,y,z)`), `transform`
(matrix4d), `rotateX/Y/Z` (degrees), all six Euler orders, and the `!invert!`
prefix. Ops compose in listed order, so the **last listed applies first** to the
geometry. An op that is listed but unreadable is skipped as identity — silently.

Also: a translation of `(0,0,0)`, an identity rotation, or an all-zero scale will
**not overwrite** an existing spawned transform (`crates/lunco-usd-bevy/src/lib.rs`). Authoring
zero is a no-op, not a reset.

## Geometry

| Type | Attributes (defaults) |
|---|---|
| `Cube` | `size` (**2.0**) — always uniform; use `xformOp:scale` for a box |
| `Sphere` | `radius` (1.0) |
| `Cylinder` / `Cone` | `radius` (1.0), `height` (2.0), `axis` (**"Z"**) |
| `Capsule` | `radius` (0.5), `height` (1.0) — height is the cylindrical section only |
| `Plane` | `width` (2.0), `length` (2.0) |
| `Mesh` | `points`, `faceVertexCounts`, `faceVertexIndices` — all three required |
| `NurbsPatch` | see below |
| `BasisCurves` / `NurbsCurves` | `points`, **`widths` required** |

`axis` defaults to `"Z"`, not Y — a `Cylinder` with no `axis` lies along Z
(`crates/lunco-usd-bevy/src/lib.rs`). `Cube.width/height/depth` do **not** exist.
`extent` is never read.

### Procedural camera backgrounds

Use the reusable `environment/starfield_sky.usda` pattern for a procedural
camera background: author an `Xform` with `MaterialBindingAPI`, bind its
`UsdShade` material, and apply `LunCoProceduralSkyAPI`. This registered API
intent is needed because USD has no standard field for a renderer-specific
procedural camera background. The projection stamps the existing
render-free `ProceduralSkybox` intent and creates no mesh. Use
`UsdLuxDomeLight` when the scene needs a textured environment light; it is a
different USD concept and must not be represented by a skybox marker.

If a referenced component supplies a generic visual proxy but the enclosing
vehicle needs a different authored shape, keep the reference for its ports and
domain facets, set the referencing visual prim's standard `visibility` to
`"invisible"`, and add the replacement as a visual-only child of the same
rigid body. Do not duplicate the electrical/environment component or give the
replacement its own body. This keeps USD topology and runtime ownership intact.

`UsdGeomPointInstancer` is supported for static direct renderable Gprim prototypes:
author the standard `prototypes`, `protoIndices`, `positions`, and optional
`orientations`/`orientationsf`, `scales`, `ids`, and `invisibleIds` properties.
The visual projection shares the prototype mesh/material handles so Bevy can
automatically batch equal instances. Time-sampled instance arrays and animated
prototypes are rejected visibly until runtime sampling is implemented. Arbitrary
prototype subtrees are rejected visibly until the multi-mesh render batch is implemented. Native
`instanceable = true` scenegraph instancing, `Points`, `GeomSubset`, and
`subdivisionScheme` remain unsupported (`catmullClark` renders as its raw
control cage).

### Mesh rules

For Rhai-authored mesh updates, keep `points`, `faceVertexCounts`, and
`faceVertexIndices` as numeric arrays and pass them to
`assembly_builder::mesh_points_update_plan` (points only) or
`assembly_builder::mesh_geometry_update_plan` (points plus topology). Review
and apply the returned operations through `assembly_edit`; do not hand-build
USD array literals in model-specific scripts. These helpers serialize only at
the standard USD attribute boundary and leave material, purpose, and physics
metadata untouched.

- Output is unindexed triangles; n-gons are **fan-triangulated**, so author
  convex faces or triangulate yourself.
- `orientation = "leftHanded"` flips winding; default is right-handed/CCW.
- Any malformed topology → **no mesh at all**, no fallback primitive.
- **Interpolation is inferred from array length only** — `interpolation`
  metadata is never read. An array matching `points.len()` is
  per-vertex; one matching `faceVertexIndices.len()` is faceVarying; **any other
  length is silently ignored**. So `uniform`/`constant` normals or UVs vanish.
- UVs: **`primvars:st` only**, UV_0 only. Bare `st` and `primvars:st0` are not read.
- Normals: authored `normals` used, else flat-computed. No smoothing.

### NurbsPatch

```usda
def NurbsPatch "Wall" {
    int uVertexCount = 9
    int vVertexCount = 2
    int uOrder = 3                      # default is 4 if unauthored
    int vOrder = 2
    double[] uKnots = [0,0,0,1,1,2,2,3,3,4,4,4]
    double[] vKnots = [0,0,1,1]
    point3f[] points = [ ... ]          # v-major: v rows of u points
    double[] pointWeights = [ ... ]     # omit ⇒ all 1 ⇒ POLYNOMIAL, not rational
}
```

- **Point order is `index = iv * uVertexCount + iu`** — v-major rows of u-points.
  `pointWeights` uses the same index.
- **`uRange` / `vRange` are NEVER READ.** The parametric span comes from the
  knots: `[uKnots[uOrder-1], uKnots[uVertexCount]]`. Authoring a range that
  disagrees with the knots does nothing at all.
- **Omitting `pointWeights` silently gives you the wrong shape.** A circle needs
  rational weights `1, cos45, 1, cos45, …`; without them the "circle" is a
  quadratic B-spline through a square control polygon and bulges ~6% at the
  diagonals. Pinned by `nurbs::tests::dropping_weights_visibly_breaks_the_circle`.
- Tessellation is fixed, not adaptive: `clamp(count * 6, 8, 128)` per direction.
- Normals are analytic; a degenerate row (a dome apex) yields `+Y` rather than NaN.

Every rejection path warns with a reason (`crates/lunco-usd-geometry/src/nurbs.rs`).
If a patch is missing from the render, **read the log first** — it will say which
guard fired, and untrimmed patches log their vert count.

### Curves

`widths` is **required** — no widths, no mesh. That gate is what stops a camera
rail becoming a pipe. Note `basis = "bspline"` is approximated as CatmullRom
(interpolating, not hull-approximating).

## Real holes — trim curves

`trimCurve:*` is the standard USD way to put a genuine hole in a surface, and it
is implemented by the importer.

```usda
int[] trimCurve:counts = [1]           # curves per loop
int[] trimCurve:orders = [2]           # 2 = linear = a polyline
int[] trimCurve:vertexCounts = [16]
double[] trimCurve:knots = [0,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,15]
point3f[] trimCurve:points = [ (u, v, w), ... ]   # HOMOGENEOUS 2D
```

- Points are **homogeneous**: the position is `(x/w, y/w)`. Skipping the divide
  gives a subtly wrong, plausible-looking loop.
- Coordinates are in the patch's **raw parameter space** (from the knots), not
  normalised, and are deliberately not unit/axis converted.
- **Winding does not matter.** Classification is even-odd with the domain
  rectangle as an implicit outer loop, so USD's unstated keep/discard rule never
  has to be guessed (`crates/lunco-usd-geometry/src/trim.rs`).
- Parameter space is **anisotropic and non-linear**. On a cylinder, u spans
  circumference while v spans height, and a rational arc parameterises
  non-uniformly — at the quarter point of a 90° span the true angle is 21.598°,
  not 22.5°. A circle authored naively renders as a squashed, mis-sized shape.
- **A trim failure renders UNTRIMMED with a warning** — bigger than authored,
  never smaller. A hole that doesn't appear is a log line, not a silent nothing.
- Trim gives you no **reveal**: a trimmed surface has no side walls, so the wall
  thickness at the opening is open. Closing it needs a ruled surface lofted
  between the two loops, authored separately.

## Materials

Bind a `UsdPreviewSurface`; the `Looks` scope is convention only, enforced nowhere.

```usda
def Scope "Looks" {
    def Material "Shell" {
        token outputs:surface.connect = </Widget/Looks/Shell/Shader.outputs:surface>
        def Shader "Shader" {
            uniform token info:id = "UsdPreviewSurface"
            color3f inputs:diffuseColor = (0.42, 0.40, 0.38)
            float inputs:roughness = 0.9
            float inputs:metallic = 0.0
        }
    }
}
```

Read: `diffuseColor`, `emissiveColor`, `metallic`, `roughness`, `normal`,
`occlusion`, `opacity`, `opacityThreshold`, `ior`, `clearcoat`,
`clearcoatRoughness`, `useSpecularWorkflow`, `specularColor`.

- **`MaterialBindingAPI` does NOT need applying.** Resolution uses
  `compute_bound_material` via `::on`, so bindings **inherit down namespace** and
  collection-based bindings work (`crates/lunco-usd-bevy/src/lib.rs`). Applying it is harmless.
- **`primvars:displayColor` must be an ARRAY.** `color3f[] primvars:displayColor
  = [(r,g,b)]`. A scalar `color3f` is silently ignored, and the bare
  `displayColor` alias is not read at all. Same for `float[] primvars:displayOpacity`.
  Values are **linear**, not sRGB.
- **`displayColor` is the ONE place a colour is authored, shader or not.** A WGSL
  shader opts in with `//!@engine display_color` and the engine fills that uniform
  from the prim's composed `primvars:displayColor` — so a shader-bound part is
  still painted the ordinary USD way. Don't author a parallel colour input on the
  Shader prim; use `inputs:*` only for what displayColor cannot express (accents,
  panel scale, wear). An explicit `inputs:display_color` overrides the fill.
- **`inputs:*` authored directly on a gprim is not read** — put it on a bound
  Shader.
- `doubleSided` (on the **gprim**, default false) is required for anything you can
  see through — a trimmed surface reads as a hole from one side and nothing from
  the other without it.
- Alpha: `opacity < 1` or a connected `inputs:opacity` → Blend;
  `opacityThreshold > 0` → Mask. For a symbol that must bypass lighting,
  normals, and shadows, apply `LunCoSurfaceAPI` to the gprim and author
  `bool lunco:surface:unlit = true`. This maps to `PbrLook.unlit`; it is not
  emissive radiance and must not be simulated with an emissive-only surface.
- Navigational annotations use the authored opacity appropriate to their visual
  job. The reusable route-point dome is translucent, unlit, and shadowless,
  with a bright green unvisited colour. The generic route policy applies a gray
  transient material-colour override when its sensor event reaches the point.
  Its invisible Trigger remains a separate overlap-only prim.
- For an annotation that is genuinely meant to emit visible radiance, use
  `inputs:emissiveColor`; for one that must not emit light, keep that input black.
  `lunco:surface:additive` is reserved for authored additive glow surfaces and
  is not a substitute for unlit presentation.
- Textures: `UsdUVTexture` via `inputs:file`, `wrapS`/`wrapT`,
  `inputs:sourceColorSpace`. **There is no UV primvar reader** —
  `UsdPrimvarReader_float2`/`inputs:st` is inert; UVs come from the mesh's own
  `primvars:st`. If distinct metallic and roughness textures are both connected,
  the **metallic one is silently dropped** (one Bevy slot).

Custom WGSL is bindable through a standard composed `UsdShade.Material` and
`UsdShade.Shader`, using `uniform asset info:wgsl:sourceAsset =
@lunco://shaders/x.wgsl@` and typed `inputs:*` parameters on the Shader. The
same material-binding resolution handles inherited and collection bindings.
Binding a library shader with no `@fragment` entry (e.g. `pbr_lit.wgsl`) is an
authored render error; bind a complete material shader instead.

## Physics

```usda
def Xform "Body" (prepend apiSchemas = ["PhysicsRigidBodyAPI"]) {
    float physics:mass = 4.5
    def Mesh "Hull" (prepend apiSchemas = ["PhysicsCollisionAPI"]) {
        uniform token physics:approximation = "convexHull"
    }
}
```

Backend is **Avian3D**. One prim with `PhysicsRigidBodyAPI` becomes **one**
rigid body aggregating all descendant colliders into a compound; descendants
carry `PhysicsCollisionAPI` only and get **no** independent body.

> **A mounted component must not apply `PhysicsRigidBodyAPI` as a static child.**
> The loader honors the schema wherever it appears: a nested body is separate
> from its parent and requires a joint. An internal part is **mass + geometry**
> (`PhysicsMassAPI`, `PhysicsCollisionAPI` on its gprims). A part that must move
> relative to its host gets a body and a joint authored together. A reusable
> moving assembly owns every joint in its mechanism, including its host-facing
> hinge. `luncosim --validate` reports an unattached nested body as
> `[usd/nested-body-no-joint]`; see
> [`author-usd-physics`](../author-usd-physics/SKILL.md#6-a-part-is-not-a-body).

- **`physics:approximation` defaults to `trimesh`, and a trimesh cannot be a
  moving rigid body in parry.** A dynamic mesh body must author `"convexHull"` or
  `"convexDecomposition"` or it will not behave.
- **There is no `physics:friction`.** Use `physics:dynamicFriction` /
  `physics:staticFriction` / `physics:restitution` on a material bound through
  `material:binding:physics`.
- `physics:density` is not read anywhere. Author `physics:mass`.
- `PhysicsScene` gravity attributes are vendored but not consumed.
- Non-cuboid colliders lose exactness under non-uniform scale (tessellated to a
  convex hull); cuboids stay exact.
- Joints: `PhysicsFixedJoint`, `PhysicsRevoluteJoint`, `PhysicsPrismaticJoint`.
  Generic D6 is unsupported and warns.

Render and collision are allowed to differ, and only a cutaway view can tell.
That is a legitimate technique, not a bug — but write down that you did it.

For a fixed component mounted on a rover (battery, solar panel, lamp or
instrument), keep one root component with its visual, mass and collision facets;
do not apply `PhysicsRigidBodyAPI` to the component unless a joint in the same
assembly attaches that body to its host. The host body is the physical owner of
fixed descendants. For a photovoltaic component specifically, expose the
electrical pin and environment inputs on the reusable root, while the enclosing
vehicle owns area, placement and battery wiring (the fixed panel's semantic
normal is +Y unless explicitly overridden). This lets the
same component be visibly and electrically real without creating a free body or
duplicating the panel geometry.

## Behaviour — one binding for every language

There is **one program contract, not a per-language schema**. `LunCoProgramAPI` is
modelled on `UsdShade.Shader`: its implementation is selected with the standard
`info:implementationSource` / `info:id` / `info:sourceAsset` / `info:sourceCode`
vocabulary. `info:implementationSource` selects exactly one implementation arm;
populating another arm is an authoring error. The LunCo runtime dispatches
file-backed programs by their source extension.

```usda
def Xform "Balloon" (prepend apiSchemas = ["LunCoProgramAPI"]) {
    uniform token info:implementationSource = "sourceAsset"
    uniform asset info:sourceAsset = @lunco://models/Balloon.mo@
    uniform bool lunco:program:realtimeSafe = true

    float inputs:force_y.connect = </Balloon.outputs:netForce>
    float inputs:height.connect  = </Balloon.outputs:position_y>
}
```

`.mo` → Modelica, `.py` → Python, `.rhai` → Rhai.
**Nothing else about the prim changes.**

- **Role is derived, never declared.** A program with `inputs:`/`outputs:` ports
  is a node in the port graph and is stepped; one without them runs for effects
  only. **Parameters are ports** — a gain is `float inputs:kv = 1.2`.
- **A Modelica class output is not an external USD port by itself.** Declare
  each value consumed outside the program as a typed `outputs:<name>` property
  on its `LunCoProgramAPI` prim. Connections must target that declared endpoint;
  check the generated network's public interface and the composed connection
  source before relying on it in a material, light, or another program.
- For a precise change to an existing authored `.usda` layer, use the explicit
  Twin Source Editor workflow (`OpenTwinSource`, then `SaveSourceText`). It
  saves the selected layer's authored text. Keep routine stage edits on the
  typed USD operation and document-journal path.
- Apply `LunCoProgramAPI` directly when the program *is* intrinsic to the thing
  (a vessel's flight control); apply it to a child `Scope` for a separable guidance
  law or patrol tree, so deleting that prim deletes the behaviour. Both placements
  have `info:sourceAsset:subIdentifier` for multi-model `.mo` files.
- **`realtimeSafe` defaults to `false`, and the wiring pass will then refuse it a
  force/torque port on a client-predicted body.** A correctly-wired program can do
  nothing until this is authored `true`.
- **`sourceAsset` must be typed `asset`, never `string`** — only an `asset` is
  visible to the resolver, the reference closure, and packaging.
- Programs use standard `info:id` for a registered driver, `info:sourceAsset` for
  authored source, or `info:sourceCode` for live, journalled editing, matching the
  selected `info:implementationSource` arm. Production programs normally use the
  asset form. An unknown or unsupported implementation id is an error with a
  diagnostic; fix the authored source selector instead of adding a fallback.
- Wiring is native USD `connectionPaths`; `SimConnection` is a derived cache, so
  hand-authoring it is pointless.

Task programs follow the ordinary `LunCoProgramAPI` rule — a child `Scope`
(conventionally `Mission` or `Program`) whose `info:sourceAsset` names a
`.rhai` source, exactly as `.mo` selects Modelica. Compose reusable task
constructors and policy in Rhai; do not introduce a second behavior format.

Vehicles are a special case with **no fallbacks**: a wheel missing any required
`LunCoWheelAPI`, `PhysxVehicleTireAPI`, or `PhysicsMaterialAPI` attribute logs an
error and **refuses to spawn**.
Compose `components/mobility/wheel.usda` rather than authoring one.

## Tunable parameters → Inspector sliders

```usda
double radius = 7.345 (
    customData = {
        double min = 3.0
        double max = 12.0
        string unit = "m"
        string type = "double"
    }
)
```

- Keys are exactly **`min`, `max`, `unit`, `type`**. There is **no `doc` key** —
  documentation goes in USD's own `doc = "..."` metadata.
- **Both `min` and `max` are required, and `max > min`**, or the parameter is
  skipped silently.
- `type` drives write-back and **defaults to `"float"`** — set it for a `double`.
- Only scalars readable as `f64`.

The Inspector keeps these values as a local draft while the user edits. Apply
commits all changed properties on the selected prim as one generation-checked
`ApplyUsdOps` journal/undo unit; Cancel does not touch USD. AI/Rhai edits use
`assembly_builder::parameter_plan` plus `assembly_edit::batch` or proposal
review, which reaches the same typed document boundary. Parameter hints describe
the control surface only; they do not replace USD or Modelica validation.

For a live model component, author instance-specific constants as standard
`inputs:*` overrides and leave the source asset unchanged. A live edit advances
the generic `lunco_core::ModelStateRevision`; it is intentionally not a
Modelica-specific compile command. The backend owning the model interprets the
revision and reports its own rebuild/reset/readiness result, so the same
authoring pattern remains usable for Rhai, physics, or later tool backends.

USD has **no expressions**. A measured quantity and the transform encoding it are
two authored numbers you must keep consistent by hand. Author both, and write the
invariant in a comment — the measurement is the durable record, the transform is
an encoding of it, and losing the measurement to a scale factor is how a fitted
number quietly becomes a magic constant.

## Spawnable, variants, persistence

**Catalog** is fully derived, nothing hardcoded: `lunco:spawnable = true` on the
`defaultPrim`, `id` = file stem, **`category` = the immediate parent folder,
Title-cased** (`vessels/rovers/x.usda` → "Rovers"). An unreadable file is not
spawnable. `RescanSpawnCatalog` re-reads.

**Variants** — a variant should *choose* a component, not restate one:

```usda
prepend variantSets = "tire"
variants = { string tire = "regolith" }
variantSet "tire" = {
    "regolith" (prepend references = @lunco://components/mobility/tires/regolith.usda@</Tire>) { }
}
```

Switch at runtime with `SetVariantSelection`. **Every variant must author every
property the others do** — a variant that only sets what it needs leaves the
  another variant's opinions standing, so it accumulates rather than switches.

Keep a referenced component with internal relative relationships on the stable
assembly prim, outside the variant. A variant that disables that realization
removes the reference with an authored `delete references` opinion; the variant
that uses it keeps the stable reference. A reference placed only inside a
variant can lose composed relative relationship targets, so verify the target
through `StageView::rel_targets` on the composed stage.

**Persistence:** only **doc-backed twin scenes** keep runtime edits. A scene
opened as a raw file path reloads base bytes and discards every edit on restart.
A twin is a folder with `twin.toml` (`name`, `[usd] default_scene`), addressed as
`twin://<name>/<rel>`; runtime state lands in `.lunco/runtime/`, journal in
`history/`.

## Adding a new `lunco:*` property — source + regenerate

A new property is **inert** until it reaches the registered layer:

1. Edit `crates/lunco-usd-document/schema/schema.usda` — the source, **never read at runtime**
2. Run `python3 scripts/gen_schema.py` — regenerates
   `crates/lunco-usd-document/schema/generatedSchema.usda`, the file actually compiled in
   (never hand-edit it)
3. A new CLASS additionally needs a `crates/lunco-usd-document/schema/plugInfo.json`
   Types entry (`every_schema_class_is_registered_in_pluginfo` pins this)

Registry tests pin source↔generated class parity and (for the wheel domain)
schema UI hints, so a forgotten regenerate fails loudly.

**Schema-level sliders.** `customData = { double min; double max; string unit }`
on a SCHEMA attribute gives every asset composing that schema a derived
Inspector slider with zero per-asset authoring (`SchemaRegistry::ui_hint`,
consumed by `produce_usd_param_view`). Per-asset authored `customData` still
overrides. Hints are UI metadata only — value defaults stay in the component
`.usda` (no-fallback doctrine), e.g. `components/mobility/wheel.usda` for
wheels.

## Verify

**Pre-flight first — it costs seconds and needs no app:**

```bash
"$LUNCOSIM_BIN" --validate assets/<your file>.usda
```

It parses the layer, **composes the whole reference closure** (so a dangling
`@lunco://…@` fails loudly here instead of silently at load), and runs the strict
wheel reader on any `PhysxVehicleWheelAPI` prim. See
[`validate-assets`](../validate-assets/SKILL.md).

Then author → load → look. Per [`test-via-api`](../test-via-api/SKILL.md): drive the
**already-running** workbench, never `pkill`, and always use the tagged command
envelope `{"type":"ExecuteCommand","command":"…","params":{…}}`. Arguments
outside `"params"` are rejected at the API boundary rather than silently
dropped.

Check the log before concluding anything about geometry. The reader warns on
every skip path, and "no warning + no geometry" means the prim was never
traversed — a different bug from "patch rejected".

For a placed mechanism, inspect the composed transform as well as the source
file: every authored `xformOp:*` must appear in `xformOpOrder`, and the layer that
owns the final placement must be the one that supplies the effective heading.
An asset-local forward axis and a scene-specific route heading are separate
facts; keep them in separate layers and verify the composed result.

## Anti-patterns

- ❌ `xformOp:*` without `xformOpOrder` — identity, silently.
- ❌ A `NurbsPatch` circle without `pointWeights` — a bulged rounded square.
- ❌ Trusting `uRange`/`vRange` — unread; the knots define the span.
- ❌ Scalar `primvars:displayColor` — must be an array.
- ❌ `inputs:roughness` on the gprim — must be on a bound Shader.
- ❌ A dynamic mesh body without `physics:approximation` — trimesh can't move.
- ❌ `physics:friction` — does not exist.
- ❌ `string doc` inside `customData` — not a key; use prim `doc` metadata.
- ❌ `info:sourceAsset` typed as `string` — must be `asset`.
- ❌ Editing `schema.usda` without running `scripts/gen_schema.py` — the
  runtime reads only the generated layer.
- ❌ Hand-editing `generatedSchema.usda` — the next regenerate erases it.
- ❌ Assuming `kind` does something.
- ❌ Inferring geometry from a screenshot when a number would settle it. Trim
  loops, control nets and joints are arithmetic — check the arithmetic. A view
  chosen on a symmetry axis of the hypotheses you are deciding between cannot
  discriminate them, and will confidently confirm whichever you already believe.

### Revolved profile shading

`RevolveProfileMesh` accepts `normal_crease_angle_deg`: zero preserves flat
shading; a positive angle smooths shared face corners within that crease.
Choose the angle in the owning component recipe/requirements. Geometry,
collision and topology remain unchanged, and profile rims above the threshold
keep sharp normals. The command expands to ordinary USD mesh normals; do not
substitute extra geometry to hide flat shading.
