Crate version: openusd 0.5.0 (same code on main)
Note that I had problems to read usd files with openusd 0.5.0 about usd files from Nvidia... Transformation were not correctly set and I used blender to debug to understand what was going on.
This report and fix has been done with help of Claude so keep it in mind if quality is not good enough and you dont want to waste your time on it. The code is used just to show the problem and fix, but it's probably better to write it in your way.
Summary
For a prim authored with the conventional UsdGeomXformCommonAPI op order
(xformOpOrder = ["xformOp:translate", "xformOp:rotate*", "xformOp:scale"]),
local_to_parent_transform returns a matrix whose translation is the authored
xformOp:translate pre-rotated by the prim's own rotation.
Consequence: any prim with a non-identity rotation is placed in the wrong spot (it gets orbited
around the parent origin by its own orientation). Prims with identity rotation are unaffected, so
it's easy to miss until a rotated prim shows up. This is the order written by Blender, Houdini,
Omniverse and UsdGeomXformCommonAPI, so it hits real scenes hard — on NVIDIA SimReady assets it
flung ~40% of the props across the world while the (un-rotated) walls/floor stayed put.
Minimal reproduction (compiled & run against 0.5.0)
Cargo.toml:
[dependencies]
openusd = { version = "0.5", features = ["geom"] }
anyhow = "1"
src/main.rs:
use openusd::schemas::geom::{Imageable, Xformable};
use openusd::sdf;
use openusd::usd::{Prim, SchemaBase, SchemaKind, Stage};
// openusd only implements Xformable on concrete schema structs; wrap any Prim.
struct AnyPrim(Prim);
impl SchemaBase for AnyPrim {
const KIND: SchemaKind = SchemaKind::ConcreteTyped;
fn prim(&self) -> &Prim { &self.0 }
}
impl Imageable for AnyPrim {}
impl Xformable for AnyPrim {}
const USDA: &str = r#"#usda 1.0
( upAxis = "Y" )
def Xform "Obj"
{
double3 xformOp:translate = (3, 5, 7)
float xformOp:rotateZ = 90
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:rotateZ"]
}
"#;
fn main() -> anyhow::Result<()> {
std::fs::write("/tmp/min.usda", USDA)?;
let stage = Stage::open("/tmp/min.usda")?;
let prim = stage.prim_at(sdf::Path::new("/Obj")?);
let m = AnyPrim(prim).local_to_parent_transform(0.0)?;
let f = m.0;
println!("translation column = ({:.3}, {:.3}, {:.3})", f[12], f[13], f[14]);
Ok(())
}
Output:
translation column = (-5.000, 3.000, 7.000)
- Expected:
(3.000, 5.000, 7.000) — the prim's origin sits at the authored translate.
(Confirmed in usdview and Blender's USD importer, both pxr-based.)
- Actual:
(-5.000, 3.000, 7.000) — (3, 5, 7) rotated 90° about Z, i.e. the translation was
transformed by the op's own rotation.
Real-asset data point
NVIDIA SimReady scene, xformOpOrder = [translate, rotateXYZ, scale], scale = 1, no instancing,
no resetXformStack, determinant +1:
| prim |
authored translate (pxr / Blender matrix_world) |
local_to_parent_transform |
| identity rotation |
(12.78, -13.49, 0.90) |
(12.78, -13.49, 0.90) ✅ |
| rotated |
(13.98, -13.43, 1.02) |
(-1.02, -8.55, -17.40) ❌ |
‖·‖ = 19.41 for both rotated vectors — the magnitude is preserved, i.e. it's exactly the authored
translation rotated. With R = the prim's rotation, Rᵀ · t_returned == t_authored to 4 s.f.
Likely cause
local_to_parent_transform folds the ops in list order as m = m * build_op_matrix(op)
("row-vector, first op most local, cumulative matrix grows on the right"). For [translate, rotate]
that is T * R, whose translation row is t transformed by R. pxr's
UsdGeomXformable::ComputeLocalToParentTransform yields a matrix whose origin maps to the authored
translate for this same op order, so the two disagree. (The exact correction — fold order / matrix
convention — is yours to judge; happy to send more controlled cases or a PR.)
Relevant code: src/schemas/geom/xformable.rs, local_to_parent_transform + build_op_matrix.
Workaround we shipped
We stopped calling local_to_parent_transform and compose the xformOpOrder stack ourselves in a
column-vector convention (build per-op matrices, multiply in list order so op0 is leftmost,
then decompose to TRS). After that, both position and orientation match pxr/Blender exactly
across the scene, rotated prims included.
Core of the fix (engine-specific helpers — reading op values, and compose_trs/multiply/
decompose_trs column-vector matrix math — elided; m_translate/m_scale/m_rot build a
single-op column-vector matrix):
let mut m = Matrix::identity();
for op in &order { // order = xform_op_order()
let kind = op.strip_prefix("xformOp:").unwrap_or(op)
.split(':').next().unwrap_or(op);
let op_mat = match kind {
"translate" => m_translate(read_vec3(op, 0.0)),
"scale" => m_scale(read_vec3(op, 1.0)),
"rotateX" => m_rot(axis_quat(0, read_scalar(op).to_radians())),
"rotateY" => m_rot(axis_quat(1, read_scalar(op).to_radians())),
"rotateZ" => m_rot(axis_quat(2, read_scalar(op).to_radians())),
"rotateXYZ" | "rotateYXZ" | "rotateZXY"
| "rotateXZY" | "rotateYZX" | "rotateZYX" => {
let e = read_vec3(op, 0.0).map(f32::to_radians);
m_rot(euler_quat(e, &kind["rotate".len()..])) // axis order from the name
}
"orient" => m_rot(read_quat_xyzw(op)),
"transform" => transpose_to_column_vector(read_matrix(op)),
_ => continue, // (+ !invert! / !resetXformStack! handling)
};
// List order, op0 leftmost: [translate, rotate, scale] -> T·R·S, so origin -> t.
m = multiply(&m, &op_mat);
}
let (translation, rotation, scale) = decompose_trs(&m);
/// Hamilton product, `a ∘ b` applies `b` first.
fn quat_mul(a: [f32;4], b: [f32;4]) -> [f32;4] {
let ([ax,ay,az,aw],[bx,by,bz,bw]) = (a,b);
[ aw*bx + ax*bw + ay*bz - az*by,
aw*by - ax*bz + ay*bw + az*bx,
aw*bz + ax*by - ay*bx + az*bw,
aw*bw - ax*bx - ay*by - az*bz ]
}
/// `rad` about a single axis (0=X,1=Y,2=Z) as an `[x,y,z,w]` quat.
fn axis_quat(axis: usize, rad: f32) -> [f32;4] {
let (s,c) = (rad*0.5).sin_cos();
let mut q = [0.0,0.0,0.0,c]; q[axis] = s; q
}
/// Euler -> quat. The FIRST-named axis is applied first, so it sits on the RIGHT of
/// the product (for "XYZ": qZ ∘ qY ∘ qX). Reversing this keeps positions correct but
/// silently flips orientation — the subtle half of the fix.
fn euler_quat(angles: [f32;3], order: &str) -> [f32;4] {
let mut q = [0.0,0.0,0.0,1.0];
for c in order.chars() {
let axis = match c { 'X'=>0, 'Y'=>1, 'Z'=>2, _=>continue };
q = quat_mul(axis_quat(axis, angles[axis]), q); // prepend
}
q
}
Crate version:
openusd 0.5.0(same code onmain)Note that I had problems to read usd files with openusd 0.5.0 about usd files from Nvidia... Transformation were not correctly set and I used blender to debug to understand what was going on.
This report and fix has been done with help of Claude so keep it in mind if quality is not good enough and you dont want to waste your time on it. The code is used just to show the problem and fix, but it's probably better to write it in your way.
Summary
For a prim authored with the conventional
UsdGeomXformCommonAPIop order(
xformOpOrder = ["xformOp:translate", "xformOp:rotate*", "xformOp:scale"]),local_to_parent_transformreturns a matrix whose translation is the authoredxformOp:translatepre-rotated by the prim's own rotation.Consequence: any prim with a non-identity rotation is placed in the wrong spot (it gets orbited
around the parent origin by its own orientation). Prims with identity rotation are unaffected, so
it's easy to miss until a rotated prim shows up. This is the order written by Blender, Houdini,
Omniverse and
UsdGeomXformCommonAPI, so it hits real scenes hard — on NVIDIA SimReady assets itflung ~40% of the props across the world while the (un-rotated) walls/floor stayed put.
Minimal reproduction (compiled & run against 0.5.0)
Cargo.toml:src/main.rs:Output:
(3.000, 5.000, 7.000)— the prim's origin sits at the authored translate.(Confirmed in usdview and Blender's USD importer, both pxr-based.)
(-5.000, 3.000, 7.000)—(3, 5, 7)rotated 90° about Z, i.e. the translation wastransformed by the op's own rotation.
Real-asset data point
NVIDIA SimReady scene,
xformOpOrder = [translate, rotateXYZ, scale], scale = 1, no instancing,no
resetXformStack, determinant +1:matrix_world)local_to_parent_transform(12.78, -13.49, 0.90)(12.78, -13.49, 0.90)✅(13.98, -13.43, 1.02)(-1.02, -8.55, -17.40)❌‖·‖ = 19.41for both rotated vectors — the magnitude is preserved, i.e. it's exactly the authoredtranslation rotated. With
R= the prim's rotation,Rᵀ · t_returned == t_authoredto 4 s.f.Likely cause
local_to_parent_transformfolds the ops in list order asm = m * build_op_matrix(op)("row-vector, first op most local, cumulative matrix grows on the right"). For
[translate, rotate]that is
T * R, whose translation row isttransformed byR. pxr'sUsdGeomXformable::ComputeLocalToParentTransformyields a matrix whose origin maps to the authoredtranslate for this same op order, so the two disagree. (The exact correction — fold order / matrix
convention — is yours to judge; happy to send more controlled cases or a PR.)
Relevant code:
src/schemas/geom/xformable.rs,local_to_parent_transform+build_op_matrix.Workaround we shipped
We stopped calling
local_to_parent_transformand compose thexformOpOrderstack ourselves in acolumn-vector convention (build per-op matrices, multiply in list order so
op0is leftmost,then decompose to TRS). After that, both position and orientation match pxr/Blender exactly
across the scene, rotated prims included.
Core of the fix (engine-specific helpers — reading op values, and
compose_trs/multiply/decompose_trscolumn-vector matrix math — elided;m_translate/m_scale/m_rotbuild asingle-op column-vector matrix):