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 ADDED
@@ -0,0 +1,4 @@
1
+ """Web-based MuJoCo viewer powered by viser."""
2
+
3
+ from mjviser.scene import ViserMujocoScene as ViserMujocoScene
4
+ from mjviser.viewer import Viewer as Viewer
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