mjviser 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mjviser/__init__.py +4 -0
- mjviser/__main__.py +61 -0
- mjviser/conversions.py +474 -0
- mjviser/py.typed +0 -0
- mjviser/scene.py +1158 -0
- mjviser/viewer.py +490 -0
- mjviser-0.0.1.dist-info/METADATA +108 -0
- mjviser-0.0.1.dist-info/RECORD +11 -0
- mjviser-0.0.1.dist-info/WHEEL +4 -0
- mjviser-0.0.1.dist-info/entry_points.txt +3 -0
- mjviser-0.0.1.dist-info/licenses/LICENSE +191 -0
mjviser/__init__.py
ADDED
mjviser/__main__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Allow ``python -m mjviser model.xml`` to launch the active viewer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import mujoco
|
|
9
|
+
|
|
10
|
+
from mjviser import Viewer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_path(arg: str) -> Path:
|
|
14
|
+
"""Resolve a model path, falling back to recursive glob search in CWD."""
|
|
15
|
+
path = Path(arg)
|
|
16
|
+
if path.is_file():
|
|
17
|
+
return path
|
|
18
|
+
|
|
19
|
+
# If pointing at a directory, search for XMLs inside it.
|
|
20
|
+
if path.is_dir():
|
|
21
|
+
matches = sorted(path.glob("**/*.xml"))
|
|
22
|
+
elif "*" in arg:
|
|
23
|
+
matches = sorted(Path.cwd().glob(arg))
|
|
24
|
+
else:
|
|
25
|
+
matches = sorted(Path.cwd().glob(f"**/*{arg}*.xml"))
|
|
26
|
+
if not matches:
|
|
27
|
+
print(f"No XML files matching '{arg}' found in {Path.cwd()}")
|
|
28
|
+
sys.exit(1)
|
|
29
|
+
if len(matches) == 1:
|
|
30
|
+
print(f"Found: {matches[0]}")
|
|
31
|
+
return matches[0]
|
|
32
|
+
|
|
33
|
+
# Multiple matches — let the user pick.
|
|
34
|
+
print(f"Multiple matches for '{arg}':")
|
|
35
|
+
for i, m in enumerate(matches, 1):
|
|
36
|
+
try:
|
|
37
|
+
label = m.relative_to(Path.cwd())
|
|
38
|
+
except ValueError:
|
|
39
|
+
label = m
|
|
40
|
+
print(f" [{i}] {label}")
|
|
41
|
+
try:
|
|
42
|
+
choice = input("Select [1]: ").strip()
|
|
43
|
+
idx = int(choice) - 1 if choice else 0
|
|
44
|
+
return matches[idx]
|
|
45
|
+
except (ValueError, IndexError, KeyboardInterrupt):
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
if len(sys.argv) < 2:
|
|
51
|
+
print("Usage: python -m mjviser <model.xml>")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
path = _resolve_path(sys.argv[1])
|
|
55
|
+
model = mujoco.MjModel.from_xml_path(str(path))
|
|
56
|
+
data = mujoco.MjData(model)
|
|
57
|
+
Viewer(model, data).run()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
mjviser/conversions.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""Convert MuJoCo mesh data to trimesh format with texture support."""
|
|
2
|
+
|
|
3
|
+
import mujoco
|
|
4
|
+
import numpy as np
|
|
5
|
+
import trimesh
|
|
6
|
+
import trimesh.visual
|
|
7
|
+
import trimesh.visual.material
|
|
8
|
+
import viser.transforms as vtf
|
|
9
|
+
from mujoco import mj_id2name, mjtGeom, mjtObj
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
# ------------------------------------------------------------------
|
|
13
|
+
# Color / texture helpers
|
|
14
|
+
# ------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_texture_id(mj_model: mujoco.MjModel, matid: int) -> int:
|
|
18
|
+
"""Return the RGB or RGBA texture ID for a material, or -1."""
|
|
19
|
+
texid = int(mj_model.mat_texid[matid, int(mujoco.mjtTextureRole.mjTEXROLE_RGB)])
|
|
20
|
+
if texid < 0:
|
|
21
|
+
texid = int(mj_model.mat_texid[matid, int(mujoco.mjtTextureRole.mjTEXROLE_RGBA)])
|
|
22
|
+
return texid
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _extract_texture_image(mj_model: mujoco.MjModel, texid: int) -> Image.Image | None:
|
|
26
|
+
"""Extract a 2D texture as a PIL Image, or None for unsupported types."""
|
|
27
|
+
w = mj_model.tex_width[texid]
|
|
28
|
+
h = mj_model.tex_height[texid]
|
|
29
|
+
nc = mj_model.tex_nchannel[texid]
|
|
30
|
+
adr = mj_model.tex_adr[texid]
|
|
31
|
+
data = mj_model.tex_data[adr : adr + w * h * nc]
|
|
32
|
+
|
|
33
|
+
# MuJoCo uses bottom-left origin; GLTF expects top-left.
|
|
34
|
+
if nc == 1:
|
|
35
|
+
arr = np.flipud(data.reshape(h, w))
|
|
36
|
+
return Image.fromarray(arr.astype(np.uint8), mode="L")
|
|
37
|
+
elif nc in (3, 4):
|
|
38
|
+
arr = np.flipud(data.reshape(h, w, nc))
|
|
39
|
+
return Image.fromarray(arr.astype(np.uint8))
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_flat_rgba(mj_model: mujoco.MjModel, geom_idx: int) -> np.ndarray:
|
|
44
|
+
"""Resolve the flat RGBA color for a geom as uint8.
|
|
45
|
+
|
|
46
|
+
Priority: material rgba > geom rgba.
|
|
47
|
+
"""
|
|
48
|
+
matid = mj_model.geom_matid[geom_idx]
|
|
49
|
+
if matid >= 0 and matid < mj_model.nmat:
|
|
50
|
+
rgba = mj_model.mat_rgba[matid]
|
|
51
|
+
else:
|
|
52
|
+
rgba = mj_model.geom_rgba[geom_idx]
|
|
53
|
+
return (np.clip(rgba, 0, 1) * 255).astype(np.uint8)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _apply_flat_color(mesh: trimesh.Trimesh, rgba_uint8: np.ndarray) -> None:
|
|
57
|
+
"""Apply a uniform RGBA color to all vertices."""
|
|
58
|
+
mesh.visual = trimesh.visual.ColorVisuals(
|
|
59
|
+
vertex_colors=np.tile(rgba_uint8, (len(mesh.vertices), 1))
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _cubemap_vertex_colors(
|
|
64
|
+
mj_model: mujoco.MjModel,
|
|
65
|
+
matid: int,
|
|
66
|
+
vertices: np.ndarray,
|
|
67
|
+
faces: np.ndarray,
|
|
68
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
|
|
69
|
+
"""Project a cube map texture onto mesh triangles by normal direction.
|
|
70
|
+
|
|
71
|
+
For each triangle, finds the cube map face most aligned with its
|
|
72
|
+
normal and assigns that face's average color. If the best face is
|
|
73
|
+
empty, falls back to the nearest non-empty face (handles chamfered
|
|
74
|
+
edges).
|
|
75
|
+
|
|
76
|
+
Returns (new_vertices, new_faces, vertex_colors) with duplicated
|
|
77
|
+
vertices so each triangle can have its own color, or None if the
|
|
78
|
+
material has no cube map texture.
|
|
79
|
+
"""
|
|
80
|
+
texid = _get_texture_id(mj_model, matid)
|
|
81
|
+
if texid < 0:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
w = mj_model.tex_width[texid]
|
|
85
|
+
h = mj_model.tex_height[texid]
|
|
86
|
+
nc = mj_model.tex_nchannel[texid]
|
|
87
|
+
if int(mj_model.tex_type[texid]) != 1 or h != w * 6:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
adr = mj_model.tex_adr[texid]
|
|
91
|
+
data = mj_model.tex_data[adr : adr + w * h * nc].reshape(6, w, w, nc)
|
|
92
|
+
|
|
93
|
+
# Average color per cube face. Empty faces stay at [0,0,0,0].
|
|
94
|
+
face_colors = np.zeros((6, 4), dtype=np.uint8)
|
|
95
|
+
has_color = np.zeros(6, dtype=bool)
|
|
96
|
+
for i in range(6):
|
|
97
|
+
mask = data[i, :, :, : min(nc, 3)].sum(axis=2) > 0
|
|
98
|
+
if mask.any():
|
|
99
|
+
avg = data[i][mask].mean(axis=0).astype(np.uint8)
|
|
100
|
+
face_colors[i, :nc] = avg[:nc]
|
|
101
|
+
if nc < 4:
|
|
102
|
+
face_colors[i, 3] = 255
|
|
103
|
+
has_color[i] = True
|
|
104
|
+
|
|
105
|
+
if not has_color.any():
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
# Cube map axes: +X, -X, +Y, -Y, +Z, -Z.
|
|
109
|
+
axes = np.array(
|
|
110
|
+
[[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]],
|
|
111
|
+
dtype=np.float64,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Per-triangle normals.
|
|
115
|
+
v0, v1, v2 = vertices[faces[:, 0]], vertices[faces[:, 1]], vertices[faces[:, 2]]
|
|
116
|
+
normals = np.cross(v1 - v0, v2 - v0)
|
|
117
|
+
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-10)
|
|
118
|
+
|
|
119
|
+
# For each triangle, pick the best aligned face that has color.
|
|
120
|
+
dots = normals @ axes.T
|
|
121
|
+
ranked = np.argsort(-dots, axis=1)
|
|
122
|
+
nf = len(faces)
|
|
123
|
+
valid = has_color[ranked] # (nf, 6) bool
|
|
124
|
+
first_valid = valid.argmax(axis=1) # First True per row.
|
|
125
|
+
best_face = ranked[np.arange(nf), first_valid]
|
|
126
|
+
tri_colors = face_colors[best_face]
|
|
127
|
+
# Zero out rows where no face has color.
|
|
128
|
+
tri_colors[~valid.any(axis=1)] = 0
|
|
129
|
+
|
|
130
|
+
# Duplicate vertices so each triangle gets its own color.
|
|
131
|
+
new_verts = vertices[faces.ravel()]
|
|
132
|
+
new_faces = np.arange(nf * 3).reshape(-1, 3)
|
|
133
|
+
vert_colors = np.repeat(tri_colors, 3, axis=0)
|
|
134
|
+
|
|
135
|
+
return new_verts, new_faces, vert_colors
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Mesh extraction
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _extract_mesh_data(
|
|
144
|
+
mj_model: mujoco.MjModel, geom_idx: int
|
|
145
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
|
146
|
+
"""Extract vertices, faces, and optional UVs for a mesh geom.
|
|
147
|
+
|
|
148
|
+
Returns (vertices, faces, uvs). UVs are None when the mesh has no
|
|
149
|
+
texture coordinates. When UVs are present, vertices and faces are
|
|
150
|
+
duplicated so each face-vertex gets its own UV.
|
|
151
|
+
"""
|
|
152
|
+
mesh_id = mj_model.geom_dataid[geom_idx]
|
|
153
|
+
vert_start = int(mj_model.mesh_vertadr[mesh_id])
|
|
154
|
+
vert_count = int(mj_model.mesh_vertnum[mesh_id])
|
|
155
|
+
face_start = int(mj_model.mesh_faceadr[mesh_id])
|
|
156
|
+
face_count = int(mj_model.mesh_facenum[mesh_id])
|
|
157
|
+
|
|
158
|
+
vertices = mj_model.mesh_vert[vert_start : vert_start + vert_count]
|
|
159
|
+
faces = mj_model.mesh_face[face_start : face_start + face_count]
|
|
160
|
+
|
|
161
|
+
texcoord_num = mj_model.mesh_texcoordnum[mesh_id]
|
|
162
|
+
if texcoord_num == 0:
|
|
163
|
+
return vertices, faces, None
|
|
164
|
+
|
|
165
|
+
texcoord_adr = mj_model.mesh_texcoordadr[mesh_id]
|
|
166
|
+
texcoords = mj_model.mesh_texcoord[texcoord_adr : texcoord_adr + texcoord_num]
|
|
167
|
+
face_tc_idx = mj_model.mesh_facetexcoord[face_start : face_start + face_count]
|
|
168
|
+
|
|
169
|
+
# Duplicate vertices so each face-vertex gets its own UV.
|
|
170
|
+
new_verts = vertices[faces.flatten()]
|
|
171
|
+
new_uvs = texcoords[face_tc_idx.flatten()]
|
|
172
|
+
new_faces = np.arange(face_count * 3).reshape(-1, 3)
|
|
173
|
+
|
|
174
|
+
return new_verts, new_faces, new_uvs
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def mujoco_mesh_to_trimesh(mj_model: mujoco.MjModel, geom_idx: int) -> trimesh.Trimesh:
|
|
178
|
+
"""Convert a MuJoCo mesh geometry to a trimesh with visual appearance.
|
|
179
|
+
|
|
180
|
+
Color resolution order:
|
|
181
|
+
1. 2D texture with UVs (PBR material with baseColorTexture)
|
|
182
|
+
2. Cube map texture projected by triangle normals
|
|
183
|
+
3. Flat material color (mat_rgba)
|
|
184
|
+
4. Flat geom color (geom_rgba)
|
|
185
|
+
"""
|
|
186
|
+
vertices, faces, uvs = _extract_mesh_data(mj_model, geom_idx)
|
|
187
|
+
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
|
|
188
|
+
|
|
189
|
+
matid = mj_model.geom_matid[geom_idx]
|
|
190
|
+
|
|
191
|
+
# Path 1: mesh has UVs and material has a 2D texture.
|
|
192
|
+
if uvs is not None and matid >= 0:
|
|
193
|
+
texid = _get_texture_id(mj_model, matid)
|
|
194
|
+
if texid >= 0:
|
|
195
|
+
image = _extract_texture_image(mj_model, texid)
|
|
196
|
+
if image is not None:
|
|
197
|
+
rgba = mj_model.mat_rgba[matid]
|
|
198
|
+
material = trimesh.visual.material.PBRMaterial(
|
|
199
|
+
baseColorFactor=rgba,
|
|
200
|
+
baseColorTexture=image,
|
|
201
|
+
metallicFactor=0.0,
|
|
202
|
+
roughnessFactor=1.0,
|
|
203
|
+
)
|
|
204
|
+
mesh.visual = trimesh.visual.TextureVisuals(uv=uvs, material=material)
|
|
205
|
+
return mesh
|
|
206
|
+
|
|
207
|
+
# Path 2: no UVs, try cube map projection.
|
|
208
|
+
if uvs is None and matid >= 0:
|
|
209
|
+
result = _cubemap_vertex_colors(mj_model, matid, vertices, faces)
|
|
210
|
+
if result is not None:
|
|
211
|
+
new_verts, new_faces, vert_colors = result
|
|
212
|
+
mesh = trimesh.Trimesh(vertices=new_verts, faces=new_faces, process=False)
|
|
213
|
+
mesh.visual = trimesh.visual.ColorVisuals(vertex_colors=vert_colors)
|
|
214
|
+
return mesh
|
|
215
|
+
|
|
216
|
+
# Path 3/4: flat color from material or geom.
|
|
217
|
+
_apply_flat_color(mesh, _resolve_flat_rgba(mj_model, geom_idx))
|
|
218
|
+
return mesh
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _create_shape_mesh(geom_type: int, size: np.ndarray) -> trimesh.Trimesh:
|
|
222
|
+
"""Create an uncolored mesh for a standard shape type."""
|
|
223
|
+
if geom_type == mjtGeom.mjGEOM_SPHERE:
|
|
224
|
+
return trimesh.creation.icosphere(radius=size[0], subdivisions=2)
|
|
225
|
+
elif geom_type == mjtGeom.mjGEOM_BOX:
|
|
226
|
+
return trimesh.creation.box(extents=2.0 * size)
|
|
227
|
+
elif geom_type == mjtGeom.mjGEOM_CAPSULE:
|
|
228
|
+
return trimesh.creation.capsule(radius=size[0], height=2.0 * size[1])
|
|
229
|
+
elif geom_type == mjtGeom.mjGEOM_CYLINDER:
|
|
230
|
+
return trimesh.creation.cylinder(radius=size[0], height=2.0 * size[1])
|
|
231
|
+
elif geom_type == mjtGeom.mjGEOM_ELLIPSOID:
|
|
232
|
+
mesh = trimesh.creation.icosphere(subdivisions=3, radius=1.0)
|
|
233
|
+
mesh.apply_scale(size)
|
|
234
|
+
return mesh
|
|
235
|
+
raise ValueError(f"Unsupported shape type: {geom_type}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def create_primitive_mesh(mj_model: mujoco.MjModel, geom_id: int) -> trimesh.Trimesh:
|
|
239
|
+
"""Create a mesh for primitive geom types.
|
|
240
|
+
|
|
241
|
+
Supports sphere, box, capsule, cylinder, plane, ellipsoid, and
|
|
242
|
+
heightfield.
|
|
243
|
+
"""
|
|
244
|
+
geom_type = mj_model.geom_type[geom_id]
|
|
245
|
+
|
|
246
|
+
if geom_type == mjtGeom.mjGEOM_HFIELD:
|
|
247
|
+
return _create_heightfield_mesh(mj_model, geom_id)
|
|
248
|
+
|
|
249
|
+
if geom_type == mjtGeom.mjGEOM_PLANE:
|
|
250
|
+
size = mj_model.geom_size[geom_id]
|
|
251
|
+
plane_x = 2.0 * size[0] if size[0] > 0 else 20.0
|
|
252
|
+
plane_y = 2.0 * size[1] if size[1] > 0 else 20.0
|
|
253
|
+
mesh = trimesh.creation.box((plane_x, plane_y, 0.001))
|
|
254
|
+
else:
|
|
255
|
+
mesh = _create_shape_mesh(geom_type, mj_model.geom_size[geom_id])
|
|
256
|
+
|
|
257
|
+
_apply_flat_color(mesh, _resolve_flat_rgba(mj_model, geom_id))
|
|
258
|
+
return mesh
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _create_heightfield_mesh(mj_model: mujoco.MjModel, geom_id: int) -> trimesh.Trimesh:
|
|
262
|
+
"""Create a colored heightfield mesh."""
|
|
263
|
+
hfield_id = mj_model.geom_dataid[geom_id]
|
|
264
|
+
nrow = mj_model.hfield_nrow[hfield_id]
|
|
265
|
+
ncol = mj_model.hfield_ncol[hfield_id]
|
|
266
|
+
sx, sy, sz, _base = mj_model.hfield_size[hfield_id]
|
|
267
|
+
|
|
268
|
+
offset = 0
|
|
269
|
+
for k in range(hfield_id):
|
|
270
|
+
offset += mj_model.hfield_nrow[k] * mj_model.hfield_ncol[k]
|
|
271
|
+
hfield = mj_model.hfield_data[offset : offset + nrow * ncol].reshape(nrow, ncol)
|
|
272
|
+
|
|
273
|
+
x = np.linspace(-sx, sx, ncol)
|
|
274
|
+
y = np.linspace(-sy, sy, nrow)
|
|
275
|
+
xx, yy = np.meshgrid(x, y)
|
|
276
|
+
zz = hfield * sz
|
|
277
|
+
|
|
278
|
+
vertices = np.column_stack((xx.ravel(), yy.ravel(), zz.ravel()))
|
|
279
|
+
|
|
280
|
+
ri, ci = np.mgrid[: nrow - 1, : ncol - 1]
|
|
281
|
+
i0 = (ri * ncol + ci).ravel()
|
|
282
|
+
faces = np.column_stack(
|
|
283
|
+
[i0, i0 + 1, i0 + ncol + 1, i0, i0 + ncol + 1, i0 + ncol]
|
|
284
|
+
).reshape(-1, 3)
|
|
285
|
+
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
|
|
286
|
+
|
|
287
|
+
# Color by height using HSV.
|
|
288
|
+
zz_min, zz_max = zz.min(), zz.max()
|
|
289
|
+
normalized = (
|
|
290
|
+
(zz - zz_min) / (zz_max - zz_min) if zz_max > zz_min else np.full_like(zz, 0.5)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
hue = 0.5 - normalized * 0.45
|
|
294
|
+
saturation = 0.6 - normalized * 0.2
|
|
295
|
+
value = 0.4 + normalized * 0.3
|
|
296
|
+
|
|
297
|
+
c = value * saturation
|
|
298
|
+
x = c * (1 - np.abs((hue * 6) % 2 - 1))
|
|
299
|
+
m = value - c
|
|
300
|
+
|
|
301
|
+
hue_sector = (hue * 6).astype(int) % 6
|
|
302
|
+
r = np.zeros_like(hue)
|
|
303
|
+
g = np.zeros_like(hue)
|
|
304
|
+
b = np.zeros_like(hue)
|
|
305
|
+
|
|
306
|
+
for sector, rc, gc, bc in [
|
|
307
|
+
(0, c, x, 0),
|
|
308
|
+
(1, x, c, 0),
|
|
309
|
+
(2, 0, c, x),
|
|
310
|
+
(3, 0, x, c),
|
|
311
|
+
(4, x, 0, c),
|
|
312
|
+
(5, c, 0, x),
|
|
313
|
+
]:
|
|
314
|
+
mask = hue_sector == sector
|
|
315
|
+
r[mask] = rc[mask] if isinstance(rc, np.ndarray) else rc
|
|
316
|
+
g[mask] = gc[mask] if isinstance(gc, np.ndarray) else gc
|
|
317
|
+
b[mask] = bc[mask] if isinstance(bc, np.ndarray) else bc
|
|
318
|
+
|
|
319
|
+
r += m
|
|
320
|
+
g += m
|
|
321
|
+
b += m
|
|
322
|
+
|
|
323
|
+
vertex_colors = np.column_stack(
|
|
324
|
+
[
|
|
325
|
+
(np.clip(r, 0, 1) * 255).astype(np.uint8).ravel(),
|
|
326
|
+
(np.clip(g, 0, 1) * 255).astype(np.uint8).ravel(),
|
|
327
|
+
(np.clip(b, 0, 1) * 255).astype(np.uint8).ravel(),
|
|
328
|
+
np.full(vertices.shape[0], 255, dtype=np.uint8),
|
|
329
|
+
]
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
mesh.visual = trimesh.visual.ColorVisuals(mesh=mesh, vertex_colors=vertex_colors)
|
|
333
|
+
return mesh
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# Texture / visual utilities
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_geom_texture_id(mj_model: mujoco.MjModel, geom_idx: int) -> int:
|
|
342
|
+
"""Return the texture ID the trimesh conversion will use, or -1.
|
|
343
|
+
|
|
344
|
+
Returns -1 when the geom is a primitive, the material has no
|
|
345
|
+
texture, or the mesh has no UV coordinates.
|
|
346
|
+
"""
|
|
347
|
+
if mj_model.geom_type[geom_idx] != mjtGeom.mjGEOM_MESH:
|
|
348
|
+
return -1
|
|
349
|
+
|
|
350
|
+
matid = mj_model.geom_matid[geom_idx]
|
|
351
|
+
if matid < 0 or matid >= mj_model.nmat:
|
|
352
|
+
return -1
|
|
353
|
+
|
|
354
|
+
texid = _get_texture_id(mj_model, matid)
|
|
355
|
+
if texid < 0:
|
|
356
|
+
return -1
|
|
357
|
+
|
|
358
|
+
mesh_id = mj_model.geom_dataid[geom_idx]
|
|
359
|
+
if mj_model.mesh_texcoordnum[mesh_id] <= 0:
|
|
360
|
+
return -1
|
|
361
|
+
|
|
362
|
+
return texid
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def group_geoms_by_visual_compat(
|
|
366
|
+
mj_model: mujoco.MjModel, geom_ids: list[int]
|
|
367
|
+
) -> list[list[int]]:
|
|
368
|
+
"""Partition geom IDs into groups that can be safely merged.
|
|
369
|
+
|
|
370
|
+
Geoms sharing the same texture ID are grouped together. All
|
|
371
|
+
untextured geoms form a single group.
|
|
372
|
+
"""
|
|
373
|
+
groups: dict[int, list[int]] = {}
|
|
374
|
+
for gid in geom_ids:
|
|
375
|
+
tex_id = get_geom_texture_id(mj_model, gid)
|
|
376
|
+
groups.setdefault(tex_id, []).append(gid)
|
|
377
|
+
return list(groups.values())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ------------------------------------------------------------------
|
|
381
|
+
# Merge / transform utilities
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _merge_meshes(
|
|
386
|
+
meshes: list[trimesh.Trimesh],
|
|
387
|
+
positions: list[np.ndarray],
|
|
388
|
+
quats: list[np.ndarray],
|
|
389
|
+
) -> trimesh.Trimesh:
|
|
390
|
+
"""Transform and merge meshes given positions and wxyz quaternions."""
|
|
391
|
+
for mesh, pos, quat in zip(meshes, positions, quats, strict=True):
|
|
392
|
+
transform = np.eye(4)
|
|
393
|
+
transform[:3, :3] = vtf.SO3(quat).as_matrix()
|
|
394
|
+
transform[:3, 3] = pos
|
|
395
|
+
mesh.apply_transform(transform)
|
|
396
|
+
|
|
397
|
+
result = meshes[0] if len(meshes) == 1 else trimesh.util.concatenate(meshes)
|
|
398
|
+
result.merge_vertices()
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def merge_geoms(mj_model: mujoco.MjModel, geom_ids: list[int]) -> trimesh.Trimesh:
|
|
403
|
+
"""Merge multiple geoms into a single trimesh in local body space."""
|
|
404
|
+
meshes = []
|
|
405
|
+
for geom_id in geom_ids:
|
|
406
|
+
if mj_model.geom_type[geom_id] == mjtGeom.mjGEOM_MESH:
|
|
407
|
+
meshes.append(mujoco_mesh_to_trimesh(mj_model, geom_id))
|
|
408
|
+
else:
|
|
409
|
+
meshes.append(create_primitive_mesh(mj_model, geom_id))
|
|
410
|
+
|
|
411
|
+
return _merge_meshes(
|
|
412
|
+
meshes,
|
|
413
|
+
[mj_model.geom_pos[gid] for gid in geom_ids],
|
|
414
|
+
[mj_model.geom_quat[gid] for gid in geom_ids],
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def rotation_matrix_from_vectors(
|
|
419
|
+
from_vec: np.ndarray, to_vec: np.ndarray
|
|
420
|
+
) -> np.ndarray:
|
|
421
|
+
"""3x3 rotation matrix that rotates from_vec to to_vec (Rodrigues)."""
|
|
422
|
+
from_vec = from_vec / np.linalg.norm(from_vec)
|
|
423
|
+
to_vec = to_vec / np.linalg.norm(to_vec)
|
|
424
|
+
|
|
425
|
+
if np.allclose(from_vec, to_vec):
|
|
426
|
+
return np.eye(3)
|
|
427
|
+
if np.allclose(from_vec, -to_vec):
|
|
428
|
+
return np.array([[-1, 0, 0], [0, 1, 0], [0, 0, -1]])
|
|
429
|
+
|
|
430
|
+
v = np.cross(from_vec, to_vec)
|
|
431
|
+
s = np.linalg.norm(v)
|
|
432
|
+
c = np.dot(from_vec, to_vec)
|
|
433
|
+
vx = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]])
|
|
434
|
+
return np.eye(3) + vx + vx @ vx * ((1 - c) / (s * s))
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def is_fixed_body(mj_model: mujoco.MjModel, body_id: int) -> bool:
|
|
438
|
+
"""Check if a body is fixed (welded to world, not attached to mocap)."""
|
|
439
|
+
is_weld = mj_model.body_weldid[body_id] == 0
|
|
440
|
+
root_id = mj_model.body_rootid[body_id]
|
|
441
|
+
return bool(is_weld and mj_model.body_mocapid[root_id] < 0)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def get_body_name(mj_model: mujoco.MjModel, body_id: int) -> str:
|
|
445
|
+
"""Body name with fallback to ``body_{id}``."""
|
|
446
|
+
name = mj_id2name(mj_model, mjtObj.mjOBJ_BODY, body_id)
|
|
447
|
+
return name if name else f"body_{body_id}"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def create_site_mesh(mj_model: mujoco.MjModel, site_id: int) -> trimesh.Trimesh:
|
|
451
|
+
"""Create a mesh for a single site."""
|
|
452
|
+
rgba = mj_model.site_rgba[site_id].copy()
|
|
453
|
+
if np.all(rgba == 0):
|
|
454
|
+
rgba = np.array([0.5, 0.5, 0.5, 1.0])
|
|
455
|
+
rgba_uint8 = (np.clip(rgba, 0, 1) * 255).astype(np.uint8)
|
|
456
|
+
|
|
457
|
+
mesh = _create_shape_mesh(mj_model.site_type[site_id], mj_model.site_size[site_id])
|
|
458
|
+
_apply_flat_color(mesh, rgba_uint8)
|
|
459
|
+
return mesh
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def merge_sites(mj_model: mujoco.MjModel, site_ids: list[int]) -> trimesh.Trimesh:
|
|
463
|
+
"""Merge multiple sites into a single trimesh in local body space."""
|
|
464
|
+
return _merge_meshes(
|
|
465
|
+
[create_site_mesh(mj_model, sid) for sid in site_ids],
|
|
466
|
+
[mj_model.site_pos[sid] for sid in site_ids],
|
|
467
|
+
[mj_model.site_quat[sid] for sid in site_ids],
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def get_site_name(mj_model: mujoco.MjModel, site_id: int) -> str:
|
|
472
|
+
"""Site name with fallback to ``site_{id}``."""
|
|
473
|
+
name = mj_id2name(mj_model, mjtObj.mjOBJ_SITE, site_id)
|
|
474
|
+
return name if name else f"site_{site_id}"
|
mjviser/py.typed
ADDED
|
File without changes
|