| 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, and schema generation 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. You build a thing by
writing a .usda file; the engine reads it. Nothing here is a Rust change.
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 importer (crates/lunco-usd-bevy/src/units.rs) — never branch on them.
Background: 21-domain-usd.md,
50-usd-driven-visuals.md.
Before adding a schema or property, read
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 (where the
file goes, how it is discovered, the lunco:// scheme),
build-usd-scene (assemble),
edit-usd-assembly (interactive headful
assembly authoring with screenshot/user-feedback checkpoints),
validate-assets (pre-flight it),
test-via-api (verify), compose-multidomain-twin.
Skeleton
One file = one spawnable thing. The catalog keys off the file, and
lunco:spawnable must sit on the stage's defaultPrim.
#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-commands/src/spawn_meta.rs).
Transforms — the mandatory bit
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 set bool lunco:surface:skybox = true. This vendor
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.
Not supported at all: Points, GeomSubset, PointInstancer,
instanceable, subdivisionScheme (a catmullClark mesh renders as its raw
control cage).
Mesh rules
- 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
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-bevy/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.
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-bevy/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.
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. No blend-mode control, no unlit from USD (use an
emissive-only surface: diffuseColor 0, emissiveColor C).
- Navigational annotations use the authored opacity appropriate to their visual
job. The reusable waypoint dome uses
float[] primvars:displayOpacity = [0.2] so a rover inside remains readable; landing locations and predicted
landing annotations remain opaque unless their own review contract changes.
All three use the emissive-only surface with
primvars:doNotCastShadows = true, so appearance stays independent of scene
lighting and the waypoint's invisible Trigger remains separate from its
translucent visual dome.
- For an emissive annotation that must remain visible without occluding geometry
behind it, author
bool lunco:surface:additive = true on the gprim (as the
waypoint dome does). This uses the existing additive,
depth-tested/non-depth-writing render intent; do not hide the problem with a
camera offset or a second overlay mechanism.
Custom WGSL is bindable via uniform asset info:wgsl:sourceAsset = @lunco://shaders/x.wgsl@
with inputs:* as parameters — but that path uses a weaker resolver (no
inheritance, no collections), so bind it directly on the gprim. Binding a
library shader with no @fragment entry (e.g. pbr_lit.wgsl) is refused with a
warning, deliberately — an invalid pipeline poisons the cache until restart.
Physics
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.
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.
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, .btxml → behaviour tree
(.xml is accepted only for upstream interoperability).
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.
- 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.
Behaviour trees follow the ordinary LunCoProgramAPI rule — a child prim
(conventionally Mission) whose info:sourceAsset ends in canonical .btxml,
exactly as .rhai and .mo select their engines. Imported BehaviorTree.CPP/Groot/ROS
.xml is also accepted.
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
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.
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:
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:
- Edit
crates/lunco-usd/schema/schema.usda — the source, never read at runtime
- Run
python3 scripts/gen_schema.py — regenerates
crates/lunco-usd/schema/generatedSchema.usda, the file actually compiled in
(never hand-edit it)
- A new CLASS additionally needs a
crates/lunco-usd/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:
target/debug/luncosim --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.
Then author → load → look. Per test-via-api: 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.