Skip to content

Xformable::local_to_parent_transform generate incorrect transformation #123

Description

@cedricpinson

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
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions