--- 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 = 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 = float inputs:height.connect = } ``` `.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:` 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@) { } } ``` 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:///`; 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/.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.