Skip to content

Commit 7e73a35

Browse files
authored
Support inline MJCF mesh data (#3686)
1 parent fcb7ed1 commit 7e73a35

3 files changed

Lines changed: 321 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Import MJCF mesh assets authored with inline vertex, face, normal, and texture-coordinate data.
78
- Break the viewer's shape count down into visual and collision shapes. The two are listed under `Shapes` in the stats overlay and need not sum to the total, since a shape can be both.
89
- Add selection of the shapes included in model shape BVHs through `Model.bvh_build_shapes(shape_flags=...)` and `ModelBuilder.default_bvh_cfg.shape_flags`, e.g. `ShapeFlags.VISIBLE | ShapeFlags.COLLIDE_SHAPES` to also include collision shapes.
910
- Add a `damping` parameter to `ModelBuilder.add_joint_ball()` that applies passive angular damping to all three ball-joint DOFs; when omitted, `ModelBuilder.default_joint_cfg.damping` applies.

newton/_src/utils/import_mjcf.py

Lines changed: 165 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from ..core.types import Axis, AxisType, Sequence, Transform, vec10
1818
from ..geometry import GeoType, Mesh, ShapeFlags, compute_inertia_shape
1919
from ..geometry.types import Heightfield
20-
from ..geometry.utils import compute_aabb, compute_inertia_box_mesh
20+
from ..geometry.utils import compute_aabb, compute_inertia_box_mesh, remesh_convex_hull
2121
from ..sim import JointTargetMode, JointType, ModelBuilder
2222
from ..sim.model import Model
2323
from ..solvers.mujoco import SolverMuJoCo
@@ -508,19 +508,106 @@ def resolve_element_attrib(element, tag: str, ambient_defaults: dict | None = No
508508
hfield_assets = {}
509509
for asset in root.findall("asset"):
510510
for mesh in asset.findall("mesh"):
511-
if "file" in mesh.attrib:
512-
fname = os.path.join(mesh_dir, mesh.attrib["file"])
511+
mesh_attrib = resolve_element_attrib(mesh, "mesh")
512+
mesh_file = mesh_attrib.get("file")
513+
mesh_name = mesh_attrib.get("name")
514+
mesh_scale = np.array(mesh_attrib.get("scale", "1.0 1.0 1.0").split(), dtype=np.float32)
515+
maxhullvert = int(mesh_attrib.get("maxhullvert", str(mesh_maxhullvert)))
516+
517+
if mesh_file:
518+
fname = os.path.join(mesh_dir, mesh_file)
513519
# handle stl relative paths
514520
if not os.path.isabs(fname):
515521
fname = os.path.abspath(os.path.join(mjcf_dirname, fname))
516-
# resolve mesh element's class defaults
517-
mesh_attrib = resolve_element_attrib(mesh, "mesh")
518-
name = mesh.attrib.get("name", ".".join(os.path.basename(fname).split(".")[:-1]))
519-
s = mesh_attrib.get("scale", "1.0 1.0 1.0")
520-
s = np.array(s.split(), dtype=np.float32)
521-
# parse maxhullvert attribute, default to mesh_maxhullvert if not specified
522-
maxhullvert = int(mesh_attrib.get("maxhullvert", str(mesh_maxhullvert)))
523-
mesh_assets[name] = {"file": fname, "scale": s, "maxhullvert": maxhullvert}
522+
name = mesh_name or ".".join(os.path.basename(fname).split(".")[:-1])
523+
mesh_assets[name] = {"file": fname, "scale": mesh_scale, "maxhullvert": maxhullvert}
524+
elif "vertex" in mesh_attrib:
525+
name = mesh_name
526+
if not name:
527+
raise ValueError("Inline MJCF mesh assets require a name.")
528+
try:
529+
vertices = np.array(mesh_attrib["vertex"].split(), dtype=np.float32)
530+
except ValueError as exc:
531+
raise ValueError(f"Inline MJCF mesh {name!r} has invalid vertex data.") from exc
532+
if len(vertices) % 3 != 0:
533+
raise ValueError(
534+
f"Inline MJCF mesh {name!r} vertex data must contain a multiple of 3 values; "
535+
f"got {len(vertices)}."
536+
)
537+
if len(vertices) < 9:
538+
raise ValueError(f"Inline MJCF mesh {name!r} must contain at least 3 vertices.")
539+
vertices = vertices.reshape(-1, 3)
540+
541+
faces = None
542+
face_data = mesh_attrib.get("face", "").strip()
543+
if face_data:
544+
try:
545+
faces = np.array(face_data.split(), dtype=np.int32)
546+
except ValueError as exc:
547+
raise ValueError(f"Inline MJCF mesh {name!r} has invalid face data.") from exc
548+
if len(faces) % 3 != 0:
549+
raise ValueError(
550+
f"Inline MJCF mesh {name!r} face data must contain a multiple of 3 values; got {len(faces)}."
551+
)
552+
if np.any(faces < 0) or np.any(faces >= len(vertices)):
553+
raise ValueError(f"Inline MJCF mesh {name!r} face data contains an invalid vertex index.")
554+
faces = faces.reshape(-1, 3)
555+
556+
normals = None
557+
if "normal" in mesh_attrib:
558+
try:
559+
normals = np.array(mesh_attrib["normal"].split(), dtype=np.float32)
560+
except ValueError as exc:
561+
raise ValueError(f"Inline MJCF mesh {name!r} has invalid normal data.") from exc
562+
if len(normals) != 3 * len(vertices):
563+
raise ValueError(
564+
f"Inline MJCF mesh {name!r} normal data must contain 3 values per vertex; "
565+
f"got {len(normals)} values for {len(vertices)} vertices."
566+
)
567+
normals = normals.reshape(-1, 3)
568+
569+
texcoords = None
570+
if "texcoord" in mesh_attrib:
571+
try:
572+
texcoords = np.array(mesh_attrib["texcoord"].split(), dtype=np.float32)
573+
except ValueError as exc:
574+
raise ValueError(f"Inline MJCF mesh {name!r} has invalid texcoord data.") from exc
575+
if len(texcoords) != 2 * len(vertices):
576+
raise ValueError(
577+
f"Inline MJCF mesh {name!r} texcoord data must contain 2 values per vertex; "
578+
f"got {len(texcoords)} values for {len(vertices)} vertices."
579+
)
580+
texcoords = texcoords.reshape(-1, 2)
581+
582+
try:
583+
refpos = np.array(mesh_attrib.get("refpos", "0 0 0").split(), dtype=np.float32)
584+
except ValueError as exc:
585+
raise ValueError(f"Inline MJCF mesh {name!r} has invalid refpos data.") from exc
586+
try:
587+
refquat = np.array(mesh_attrib.get("refquat", "1 0 0 0").split(), dtype=np.float32)
588+
except ValueError as exc:
589+
raise ValueError(f"Inline MJCF mesh {name!r} has invalid refquat data.") from exc
590+
if refpos.shape != (3,):
591+
raise ValueError(f"Inline MJCF mesh {name!r} refpos must have 3 values.")
592+
if refquat.shape != (4,):
593+
raise ValueError(f"Inline MJCF mesh {name!r} refquat must have 4 values.")
594+
refquat_norm = np.linalg.norm(refquat)
595+
if not np.isfinite(refquat_norm) or refquat_norm == 0.0:
596+
raise ValueError(f"Inline MJCF mesh {name!r} refquat must be finite and nonzero.")
597+
if not np.all(np.isfinite(refpos)):
598+
raise ValueError(f"Inline MJCF mesh {name!r} refpos must contain only finite values.")
599+
refquat /= refquat_norm
600+
601+
mesh_assets[name] = {
602+
"vertices": vertices,
603+
"faces": faces,
604+
"normals": normals,
605+
"texcoords": texcoords,
606+
"refpos": refpos,
607+
"refquat": refquat,
608+
"scale": mesh_scale,
609+
"maxhullvert": maxhullvert,
610+
}
524611
for texture in asset.findall("texture"):
525612
tex_name = texture.attrib.get("name")
526613
tex_file = texture.attrib.get("file")
@@ -575,6 +662,64 @@ def resolve_element_attrib(element, tag: str, ambient_defaults: dict | None = No
575662
"elevation": elevation_data,
576663
}
577664

665+
def load_mesh_asset(
666+
mesh_name: str,
667+
scaling: np.ndarray,
668+
maxhullvert: int,
669+
override_color: tuple[float, float, float] | None = None,
670+
override_texture: str | None = None,
671+
) -> list[Mesh]:
672+
mesh_asset = mesh_assets[mesh_name]
673+
if "file" in mesh_asset:
674+
return load_meshes_from_file(
675+
mesh_asset["file"],
676+
scale=scaling,
677+
maxhullvert=maxhullvert,
678+
override_color=override_color,
679+
override_texture=override_texture,
680+
)
681+
682+
refquat = mesh_asset["refquat"]
683+
rotation = np.asarray(
684+
wp.quat_to_matrix(wp.quat(refquat[1], refquat[2], refquat[3], refquat[0])),
685+
dtype=np.float32,
686+
).reshape(3, 3)
687+
vertices = ((mesh_asset["vertices"] - mesh_asset["refpos"]) @ rotation) * scaling
688+
faces = mesh_asset["faces"]
689+
normals = mesh_asset["normals"]
690+
texcoords = mesh_asset["texcoords"]
691+
if normals is not None:
692+
normals = (normals @ rotation) / scaling
693+
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
694+
normals = np.divide(normals, lengths, out=np.zeros_like(normals), where=lengths > 0.0)
695+
696+
if faces is None:
697+
hull_vertices, faces = remesh_convex_hull(vertices, maxhullvert=maxhullvert)
698+
source_index_by_vertex = {}
699+
for index, vertex in enumerate(vertices):
700+
source_index_by_vertex.setdefault(tuple(vertex), index)
701+
source_indices = np.array(
702+
[source_index_by_vertex[tuple(vertex)] for vertex in hull_vertices],
703+
dtype=np.int32,
704+
)
705+
vertices = hull_vertices
706+
if normals is not None:
707+
normals = normals[source_indices]
708+
if texcoords is not None:
709+
texcoords = texcoords[source_indices]
710+
711+
return [
712+
Mesh(
713+
vertices,
714+
faces,
715+
normals=normals,
716+
uvs=texcoords,
717+
maxhullvert=maxhullvert,
718+
color=override_color,
719+
texture=override_texture,
720+
)
721+
]
722+
578723
axis_xform = wp.transform(wp.vec3(0.0), quat_between_axes(up_axis, builder.up_axis))
579724
xform = xform * axis_xform
580725

@@ -873,19 +1018,14 @@ def parse_shapes(
8731018
print(f"Warning: mesh asset for fitting not found for {geom_name}, skipping geom")
8741019
continue
8751020
else:
876-
stl_file = mesh_assets[mesh_name]["file"]
8771021
if "mesh" in geom_defaults:
8781022
mesh_scale = parse_vec(geom_defaults["mesh"], "scale", mesh_assets[mesh_name]["scale"])
8791023
else:
8801024
mesh_scale = mesh_assets[mesh_name]["scale"]
8811025
scaling = np.array(mesh_scale) * scale
8821026
maxhullvert = mesh_assets[mesh_name].get("maxhullvert", mesh_maxhullvert)
8831027

884-
m_meshes = load_meshes_from_file(
885-
stl_file,
886-
scale=scaling,
887-
maxhullvert=maxhullvert,
888-
)
1028+
m_meshes = load_mesh_asset(mesh_name, scaling, maxhullvert)
8891029
# Combine all sub-meshes into one vertex array for fitting.
8901030
all_vertices = np.concatenate([m.vertices for m in m_meshes], axis=0)
8911031

@@ -1001,18 +1141,19 @@ def parse_shapes(
10011141
if verbose:
10021142
print(f"Warning: mesh asset {geom_attrib['mesh']} not found, skipping")
10031143
continue
1004-
stl_file = mesh_assets[geom_attrib["mesh"]]["file"]
1005-
mesh_scale = mesh_assets[geom_attrib["mesh"]]["scale"]
1144+
mesh_asset = mesh_assets[geom_attrib["mesh"]]
1145+
mesh_label = mesh_asset.get("file", geom_attrib["mesh"])
1146+
mesh_scale = mesh_asset["scale"]
10061147
scaling = np.array(mesh_scale) * scale
10071148
# as per the Mujoco XML reference, ignore geom size attribute
10081149

10091150
# get maxhullvert value from mesh assets
10101151
maxhullvert = mesh_assets[geom_attrib["mesh"]].get("maxhullvert", mesh_maxhullvert)
10111152

1012-
m_meshes = load_meshes_from_file(
1013-
stl_file,
1014-
scale=scaling,
1015-
maxhullvert=maxhullvert,
1153+
m_meshes = load_mesh_asset(
1154+
geom_attrib["mesh"],
1155+
scaling,
1156+
maxhullvert,
10161157
override_color=material_color,
10171158
override_texture=texture,
10181159
)
@@ -1035,7 +1176,7 @@ def parse_shapes(
10351176
for m_mesh in m_meshes:
10361177
if m_mesh.texture is not None and m_mesh.uvs is None:
10371178
if verbose:
1038-
print(f"Warning: mesh {stl_file} has a texture but no UVs; texture will be ignored.")
1179+
print(f"Warning: mesh {mesh_label} has a texture but no UVs; texture will be ignored.")
10391180
m_mesh.texture = None
10401181
# Mesh shapes must not use cfg.sdf_*; SDFs are built on the mesh itself.
10411182
mesh_shape_kwargs = dict(shape_kwargs)

0 commit comments

Comments
 (0)