diff --git a/newton/_src/usd/utils.py b/newton/_src/usd/utils.py index cf3b35fd21..d14954cf57 100644 --- a/newton/_src/usd/utils.py +++ b/newton/_src/usd/utils.py @@ -1612,6 +1612,7 @@ def _extract_shader_properties(shader: UsdShade.Shader | None, prim: Usd.Prim) - shader, ( "diffuse_color_constant", + "diffuse_tint", "diffuse_color", "diffuseColor", "base_color", @@ -1835,6 +1836,18 @@ def resolve_material_properties_for_prim(prim: Usd.Prim) -> dict[str, Any]: if fallback_props is not None: return fallback_props + # Last resort: check displayColor primvar even when no material is bound. + # This covers collision-only prims whose colour is set via displayColor + # rather than through a material binding (e.g. ground planes). + if UsdGeom is not None: + display_color = UsdGeom.PrimvarsAPI(prim).GetPrimvar("displayColor") + if display_color: + color = _coerce_color(display_color.Get()) + if color is not None: + props = _empty_material_properties() + props["color"] = color + return props + return _empty_material_properties() diff --git a/newton/_src/utils/import_usd.py b/newton/_src/utils/import_usd.py index 2e0eae9355..92c2777d2e 100644 --- a/newton/_src/utils/import_usd.py +++ b/newton/_src/utils/import_usd.py @@ -431,11 +431,14 @@ def _get_mesh_with_visual_material(prim: Usd.Prim, *, path_name: str) -> Mesh: mesh.texture = texture if mesh.texture is not None and mesh.uvs is None: warnings.warn( - f"Warning: mesh {path_name} has a texture but no UVs; texture will be ignored.", + f"Mesh {path_name} has a texture but no UVs; texture will use projected UVs.", stacklevel=2, ) - mesh.texture = None - if material_props.get("color") is not None and mesh.texture is None: + if mesh.texture is not None: + # Texture provides albedo; use white so it renders at full brightness. + # (diffuse_color_constant is the untextured fallback, not a multiplier.) + mesh.color = (1.0, 1.0, 1.0) + elif material_props.get("color") is not None: mesh.color = material_props["color"] if material_props.get("roughness") is not None: mesh.roughness = material_props["roughness"] @@ -461,6 +464,42 @@ def _get_prim_world_mat(prim, articulation_root_xform, incoming_world_xform): prim_world_mat = incoming_mat @ prim_world_mat return prim_world_mat + def _cube_to_textured_mesh(hx: float, hy: float, hz: float, material_props: dict) -> Mesh: + """Convert a cube primitive to a Mesh with box-mapped UVs for texture rendering. + + Creates 24 vertices (4 per face) with independent UVs so each face + maps the full texture via simple box projection. + """ + # 6 faces, 4 verts each, 2 tris each — winding is CCW when viewed from outside + verts = np.array([ + # +X face + [hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz], + # -X face + [-hx, hy, -hz], [-hx, -hy, -hz], [-hx, -hy, hz], [-hx, hy, hz], + # +Y face + [hx, hy, -hz], [-hx, hy, -hz], [-hx, hy, hz], [hx, hy, hz], + # -Y face + [-hx, -hy, -hz], [hx, -hy, -hz], [hx, -hy, hz], [-hx, -hy, hz], + # +Z face + [-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz], + # -Z face + [-hx, hy, -hz], [hx, hy, -hz], [hx, -hy, -hz], [-hx, -hy, -hz], + ], dtype=np.float32) + uvs = np.tile(np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32), (6, 1)) + tris = np.array([[i * 4 + j for j in t] for i in range(6) for t in [[0, 1, 2], [0, 2, 3]]], dtype=np.int32) + mesh = Mesh(verts, tris, uvs=uvs, compute_inertia=False) + mesh.texture = material_props.get("texture") + # When a texture is present, the texture provides the albedo and the + # shape color acts as a multiplier. Use white so the texture renders + # at full brightness (OmniPBR's diffuse_color_constant is the + # untextured fallback and would darken the image if used here). + mesh.color = (1.0, 1.0, 1.0) + if material_props.get("roughness") is not None: + mesh.roughness = material_props["roughness"] + if material_props.get("metallic") is not None: + mesh.metallic = material_props["metallic"] + return mesh + def _load_visual_shapes_impl( parent_body_id: int, prim: Usd.Prim, @@ -524,19 +563,47 @@ def _load_visual_shapes_impl( return if path_name not in path_shape_map: + # Resolve material properties for primitive (non-mesh) visual shapes. + # Walk up to the parent if the prim itself has no material bound, + # since USD materials are often authored on a parent Xform. + _prim_mat_props = {} + if type_name != "mesh": + _prim_mat_props = _get_material_props_cached(prim) + if _prim_mat_props.get("color") is None and _prim_mat_props.get("texture") is None: + parent = prim.GetParent() + if parent and parent.IsValid(): + _par_props = _get_material_props_cached(parent) + if _par_props.get("color") is not None or _par_props.get("texture") is not None: + _prim_mat_props = _par_props + _prim_color = _prim_mat_props.get("color") + if type_name == "cube": size = usd.get_float(prim, "size", 2.0) side_lengths = scale * size - shape_id = builder.add_shape_box( - parent_body_id, - xform, - hx=side_lengths[0] / 2, - hy=side_lengths[1] / 2, - hz=side_lengths[2] / 2, - cfg=visual_shape_cfg, - as_site=is_site, - label=path_name, - ) + hx, hy, hz = side_lengths[0] / 2, side_lengths[1] / 2, side_lengths[2] / 2 + # If the material has a texture, convert the cube to a mesh + # with UVs so Newton's texture pipeline can render it. + if _prim_mat_props.get("texture") is not None: + mesh = _cube_to_textured_mesh(hx, hy, hz, _prim_mat_props) + shape_id = builder.add_shape_mesh( + parent_body_id, + xform, + mesh=mesh, + cfg=visual_shape_cfg, + label=path_name, + ) + else: + shape_id = builder.add_shape_box( + parent_body_id, + xform, + hx=hx, + hy=hy, + hz=hz, + cfg=visual_shape_cfg, + as_site=is_site, + color=_prim_color, + label=path_name, + ) elif type_name == "sphere": if not (scale[0] == scale[1] == scale[2]): print("Warning: Non-uniform scaling of spheres is not supported.") @@ -547,6 +614,7 @@ def _load_visual_shapes_impl( radius, cfg=visual_shape_cfg, as_site=is_site, + color=_prim_color, label=path_name, ) elif type_name == "plane": @@ -562,6 +630,7 @@ def _load_visual_shapes_impl( width=width, length=length, cfg=visual_shape_cfg, + color=_prim_color, label=path_name, ) elif type_name == "capsule": @@ -577,6 +646,7 @@ def _load_visual_shapes_impl( half_height, cfg=visual_shape_cfg, as_site=is_site, + color=_prim_color, label=path_name, ) elif type_name == "cylinder": @@ -592,6 +662,7 @@ def _load_visual_shapes_impl( half_height, cfg=visual_shape_cfg, as_site=is_site, + color=_prim_color, label=path_name, ) elif type_name == "cone": @@ -2151,9 +2222,22 @@ def _build_mass_info_from_shape_geometry( if shape_kd is None: shape_kd = builder.default_shape_cfg.kd + # Resolve material color for visible collision shapes so they + # render with the correct colour instead of the palette fallback. + # Walk up to the parent if the prim itself has no material. + _collider_color = None + if collider_is_visible: + _coll_mat = _get_material_props_cached(prim) + _collider_color = _coll_mat.get("color") + if _collider_color is None: + _coll_parent = prim.GetParent() + if _coll_parent and _coll_parent.IsValid(): + _collider_color = _get_material_props_cached(_coll_parent).get("color") + shape_params = { "body": body_id, "xform": shape_xform, + "color": _collider_color, "cfg": ModelBuilder.ShapeConfig( ke=shape_ke, kd=shape_kd,