simscope 0.1.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.
Files changed (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
simscope/mujoco.py ADDED
@@ -0,0 +1,578 @@
1
+ """Adapter for MuJoCo, MJX, and MuJoCo Warp.
2
+
3
+ ``scene_from_model`` turns a compiled ``MjModel`` into a
4
+ ``simscope.core.Scene``. ``poses`` turns simulator state into the
5
+ ``[E, B, 7]`` pose array the recorder logs, converting MuJoCo's wxyz
6
+ quaternions to simscope's xyzw. Nothing here imports JAX or Warp: MJX and
7
+ Warp arrays only need to convert through ``np.asarray`` (or ``.numpy()``).
8
+
9
+ ``contacts`` turns ``data.contact`` into the ``[1, K, 6]`` arrows frame of
10
+ the ``contacts`` stream (point and force on the robot, both in the world
11
+ frame), and ``add_contacts_stream`` declares that stream on a recorder with
12
+ the ``1 / (m g)`` drawing scale.
13
+
14
+ Importing this module imports ``mujoco``; ``import simscope`` does not.
15
+
16
+ Example:
17
+ >>> from simscope import mujoco as smj
18
+ >>> scene = smj.scene_from_model(model)
19
+ >>> frame = smj.poses(data) # [1, n_bodies, 7]
20
+ >>> smj.add_contacts_stream(rec, model, max_contacts=16)
21
+ >>> rec.log(frame, contacts=smj.contacts(model, data, max_contacts=16))
22
+ """
23
+
24
+ import collections
25
+ import logging
26
+ import struct
27
+ import zlib
28
+ from typing import Any
29
+
30
+ import numpy as np
31
+ import numpy.typing as npt
32
+
33
+ try:
34
+ import mujoco
35
+ except ImportError as e: # pragma: no cover
36
+ raise ImportError(
37
+ "simscope.mujoco needs the mujoco package: "
38
+ "pip install 'simscope[mujoco]'"
39
+ ) from e
40
+
41
+ from simscope import _mjviser, core
42
+
43
+ _LOG = logging.getLogger(__name__)
44
+
45
+ _GEOM = mujoco.mjtGeom
46
+ _KINDS: dict[int, core.GeomKind] = {
47
+ int(_GEOM.mjGEOM_BOX): "box",
48
+ int(_GEOM.mjGEOM_SPHERE): "sphere",
49
+ int(_GEOM.mjGEOM_CAPSULE): "capsule",
50
+ int(_GEOM.mjGEOM_CYLINDER): "cylinder",
51
+ int(_GEOM.mjGEOM_ELLIPSOID): "ellipsoid",
52
+ int(_GEOM.mjGEOM_PLANE): "plane",
53
+ int(_GEOM.mjGEOM_MESH): "mesh",
54
+ int(_GEOM.mjGEOM_HFIELD): "mesh",
55
+ }
56
+ _FIRST_COLLISION_GROUP = 3
57
+ """Geom groups at or above this are hidden by default in MuJoCo and mjviser."""
58
+
59
+ _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
60
+ _PNG_COLOR_TYPE = {3: 2, 4: 6} # channels -> RGB, RGBA
61
+
62
+
63
+ def source_info() -> dict[str, str]:
64
+ """Describes the simulator for a rollout manifest.
65
+
66
+ Returns:
67
+ ``{"simulator": "mujoco", "version": <mujoco version>}``. MJX and
68
+ MuJoCo Warp are reported as ``mujoco`` too.
69
+ """
70
+ return {"simulator": "mujoco", "version": mujoco.__version__}
71
+
72
+
73
+ def scene_from_model(
74
+ model: mujoco.MjModel, *, collision: bool = True
75
+ ) -> core.Scene:
76
+ """Builds a scene from a compiled MuJoCo model.
77
+
78
+ Bodies keep MuJoCo's ids, so the pose stream from ``poses`` lines up
79
+ with ``Scene.bodies``. Geoms are attached to ``geom_bodyid`` with
80
+ ``geom_pos``/``geom_quat`` as their local pose. Mesh vertices are stored
81
+ exactly as ``mjModel`` holds them (already recentered and scaled by the
82
+ compiler), so the geom's local pose alone places them in the world and
83
+ ``Geom.scale`` is always 1.
84
+
85
+ Geom kinds: box, sphere, capsule, cylinder, ellipsoid, plane, and mesh
86
+ map one to one with MuJoCo's ``geom_size`` (unused entries zeroed).
87
+ Height fields are tessellated into meshes in the geom frame. Other
88
+ geom types (SDF) are skipped with a single warning.
89
+
90
+ Role rule (group only, as in mjviser, whose viewer shows groups 0-2 by
91
+ default): a geom is ``"collision"`` if its ``geom_group`` is 3 or
92
+ higher, or if its displayed alpha is 0; otherwise it is ``"visual"``.
93
+ ``contype``/``conaffinity`` are deliberately ignored, because in plain
94
+ MJCF a default (group 0) geom collides and is also the only thing to
95
+ look at.
96
+
97
+ Color rule (verified against ``mjv_updateScene``): a geom with a
98
+ material takes ``mat_rgba`` unless its own ``rgba`` was set explicitly.
99
+ Materials are deduplicated. Only 2D textures are exported, as PNG;
100
+ cube maps and skyboxes are dropped. A mesh geom keeps its texture only
101
+ if the mesh has texture coordinates. Texture coordinates follow OpenGL
102
+ (``v = 0`` is the bottom of the image) and PNG rows are top-down.
103
+
104
+ MuJoCo's stored mesh normals are not exported; viewers compute them.
105
+
106
+ Args:
107
+ model: A compiled ``mujoco.MjModel``.
108
+ collision: If false, keep only ``"visual"`` geoms.
109
+
110
+ Returns:
111
+ The scene. Meshes, textures, and materials are deduplicated and
112
+ include only what the kept geoms use.
113
+ """
114
+ body_names = [
115
+ mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, i) or f"body{i}"
116
+ for i in range(model.nbody)
117
+ ]
118
+ parents = model.body_parentid.tolist()
119
+ if parents:
120
+ parents[0] = -1
121
+ masses = model.body_mass.tolist()
122
+ if masses:
123
+ masses[0] = 0.0 # the world body has no mass
124
+ bodies = tuple(
125
+ core.Body(name=name, parent=parent, mass=mass)
126
+ for name, parent, mass in zip(body_names, parents, masses, strict=True)
127
+ )
128
+
129
+ types = model.geom_type
130
+ supported = np.isin(types, list(_KINDS))
131
+ supported &= ~((types == _GEOM.mjGEOM_MESH) & (model.geom_dataid < 0))
132
+ _warn_skipped(model, types[~supported])
133
+
134
+ rgba = _mjviser.effective_geom_rgba(model)
135
+ hidden = (model.geom_group >= _FIRST_COLLISION_GROUP) | (rgba[:, 3] == 0)
136
+ keep = supported if collision else supported & ~hidden
137
+ ids = np.flatnonzero(keep)
138
+
139
+ sizes = model.geom_size[ids].astype(np.float64)
140
+ kept_types = types[ids]
141
+ sizes[kept_types == _GEOM.mjGEOM_SPHERE, 1:] = 0.0
142
+ sizes[
143
+ (kept_types == _GEOM.mjGEOM_CAPSULE)
144
+ | (kept_types == _GEOM.mjGEOM_CYLINDER),
145
+ 2,
146
+ ] = 0.0
147
+ sizes[
148
+ (kept_types == _GEOM.mjGEOM_MESH) | (kept_types == _GEOM.mjGEOM_HFIELD)
149
+ ] = 0.0
150
+ positions = model.geom_pos[ids].tolist()
151
+ quats = model.geom_quat[ids][:, [1, 2, 3, 0]].tolist()
152
+ size_rows = sizes.tolist()
153
+
154
+ builder = _SceneBuilder(model, rgba)
155
+ geoms = []
156
+ for k, gid in enumerate(ids.tolist()):
157
+ material, mesh = builder.material_and_mesh(gid)
158
+ geoms.append(
159
+ core.Geom(
160
+ body=int(model.geom_bodyid[gid]),
161
+ kind=_KINDS[int(types[gid])],
162
+ size=tuple(size_rows[k]),
163
+ pos=tuple(positions[k]),
164
+ quat=tuple(quats[k]),
165
+ material=material,
166
+ mesh=mesh,
167
+ role="collision" if hidden[gid] else "visual",
168
+ name=mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, gid)
169
+ or "",
170
+ )
171
+ )
172
+ return core.Scene(
173
+ bodies=bodies,
174
+ geoms=tuple(geoms),
175
+ materials=tuple(builder.materials) or (core.Material(),),
176
+ meshes=tuple(builder.meshes),
177
+ textures=tuple(builder.textures),
178
+ )
179
+
180
+
181
+ def poses(
182
+ data_or_xpos: Any,
183
+ xquat: Any | None = None,
184
+ *,
185
+ out: npt.NDArray[np.float32] | None = None,
186
+ ) -> npt.NDArray[np.float32]:
187
+ """Packs body poses into simscope's ``[E, B, 7]`` layout.
188
+
189
+ Args:
190
+ data_or_xpos: Either an object with ``xpos`` and ``xquat``
191
+ attributes (``mujoco.MjData``, or an MJX data object), or the
192
+ body positions ``xpos`` with shape ``[..., B, 3]``. Arrays may be
193
+ numpy, JAX, or Warp; anything with a ``numpy()`` method uses it,
194
+ everything else goes through ``np.asarray``.
195
+ xquat: Body quaternions in wxyz order, shape ``[..., B, 4]``. Must be
196
+ ``None`` when ``data_or_xpos`` is a data object.
197
+ out: Optional preallocated float32 C-contiguous ``[E, B, 7]`` array
198
+ to fill and return.
199
+
200
+ Returns:
201
+ A float32 C-contiguous array ``[E, B, 7]`` of ``xyz`` followed by
202
+ quaternion ``xyzw``. A single ``MjData`` gives ``E = 1``; extra
203
+ leading axes of batched input are flattened into ``E``. The result
204
+ never aliases the input.
205
+
206
+ Raises:
207
+ ValueError: If shapes are inconsistent, or ``out`` is unsuitable.
208
+ TypeError: If ``xquat`` is missing and the first argument has no
209
+ ``xpos``/``xquat``.
210
+ """
211
+ if xquat is None:
212
+ if not (
213
+ hasattr(data_or_xpos, "xpos") and hasattr(data_or_xpos, "xquat")
214
+ ):
215
+ raise TypeError(
216
+ "pass MjData (or an object with xpos and xquat), "
217
+ "or both xpos and xquat arrays"
218
+ )
219
+ xpos_in, xquat_in = data_or_xpos.xpos, data_or_xpos.xquat
220
+ else:
221
+ xpos_in, xquat_in = data_or_xpos, xquat
222
+ xpos = _to_numpy(xpos_in)
223
+ quat = _to_numpy(xquat_in)
224
+ if xpos.ndim < 2 or xpos.shape[-1] != 3:
225
+ raise ValueError(f"xpos must have shape [..., B, 3], got {xpos.shape}")
226
+ if quat.shape[:-1] != xpos.shape[:-1] or quat.shape[-1] != 4:
227
+ raise ValueError(
228
+ f"xquat must have shape {(*xpos.shape[:-1], 4)}, got {quat.shape}"
229
+ )
230
+ n_bodies = xpos.shape[-2]
231
+ xpos = xpos.reshape(-1, n_bodies, 3)
232
+ quat = quat.reshape(-1, n_bodies, 4)
233
+ shape = (xpos.shape[0], n_bodies, core.POSE_DIM)
234
+ if out is None:
235
+ out = np.empty(shape, dtype=np.float32)
236
+ elif (
237
+ out.shape != shape
238
+ or out.dtype != np.float32
239
+ or not out.flags.c_contiguous
240
+ ):
241
+ raise ValueError(
242
+ f"out must be a C-contiguous float32 array of shape {shape}"
243
+ )
244
+ out[..., :3] = xpos
245
+ out[..., 3:6] = quat[..., 1:]
246
+ out[..., 6] = quat[..., 0]
247
+ return out
248
+
249
+
250
+ DEFAULT_MAX_CONTACTS = 16
251
+ """Contact slots per frame that ``add_contacts_stream`` reserves."""
252
+
253
+ CONTACTS_STREAM = "contacts"
254
+ """Name of the contacts stream (viewer contracts 7)."""
255
+
256
+ _GRAVITY = 9.81
257
+
258
+
259
+ def contacts(
260
+ model: mujoco.MjModel,
261
+ data: mujoco.MjData,
262
+ out: npt.NDArray[np.float32] | None = None,
263
+ *,
264
+ max_contacts: int | None = None,
265
+ ) -> npt.NDArray[np.float32]:
266
+ """Packs the active contacts of one ``MjData`` for the contacts stream.
267
+
268
+ Each row is ``[px, py, pz, fx, fy, fz]``: the contact point (m) and the
269
+ force (N) that the world exerts on the robot, both in the world frame.
270
+ MuJoCo's contact force (``mj_contactForce``, rotated out of the contact
271
+ frame) is the force on geom 2, so the sign is flipped when the robot is
272
+ geom 1. The fixed side is the one whose geom belongs to the world body
273
+ (the ground, a wall); if neither geom does, the force on geom 2 is
274
+ used. Unused rows are zero, and so are contacts that carry no force. If
275
+ there are more than ``K`` force-carrying contacts, the ``K`` strongest
276
+ are kept.
277
+
278
+ For batched MJX or MuJoCo Warp data, convert each env to an ``MjData``
279
+ (``mjx.get_data(model, batch)`` returns one per env) and call this once
280
+ per env with ``out=frame[e]``, where ``frame`` is an ``[E, K, 6]``
281
+ array. A fully batched path would need ``mj_contactForce`` on device
282
+ and is left out on purpose.
283
+
284
+ Args:
285
+ model: The compiled model.
286
+ data: Simulator state; contacts are read as of the last step.
287
+ out: Optional float32 C-contiguous buffer to fill (and return),
288
+ shape ``[K, 6]`` or ``[1, K, 6]``. It is zeroed first.
289
+ max_contacts: ``K`` when ``out`` is not given.
290
+
291
+ Returns:
292
+ A float32 array ``[1, K, 6]`` (``out`` itself when it has that
293
+ shape, else a view of it), ready for ``Recorder.log``.
294
+
295
+ Raises:
296
+ ValueError: If neither ``out`` nor ``max_contacts`` is given, they
297
+ disagree, or ``out`` is unsuitable.
298
+ """
299
+ if out is None:
300
+ if max_contacts is None or max_contacts < 1:
301
+ raise ValueError("pass out, or max_contacts >= 1")
302
+ out = np.zeros((1, max_contacts, 6), dtype=np.float32)
303
+ else:
304
+ if (
305
+ out.dtype != np.float32
306
+ or not out.flags.c_contiguous
307
+ or out.ndim not in (2, 3)
308
+ or out.shape[-1] != 6
309
+ or (out.ndim == 3 and out.shape[0] != 1)
310
+ ):
311
+ raise ValueError(
312
+ "out must be a C-contiguous float32 [K, 6] or [1, K, 6] "
313
+ f"array, got {out.dtype} {out.shape}"
314
+ )
315
+ if max_contacts is not None and max_contacts != out.shape[-2]:
316
+ raise ValueError(
317
+ f"max_contacts={max_contacts} but out has {out.shape[-2]} rows"
318
+ )
319
+ out[...] = 0.0
320
+ rows = out.reshape(-1, 6)
321
+ n = int(data.ncon)
322
+ if n == 0:
323
+ return out.reshape(1, *rows.shape)
324
+ contact = data.contact
325
+ local = np.zeros(6)
326
+ force = np.zeros((n, 3))
327
+ for i in range(n):
328
+ mujoco.mj_contactForce(model, data, i, local)
329
+ force[i] = local[:3]
330
+ frame = np.asarray(contact.frame[:n]).reshape(n, 3, 3)
331
+ world = np.einsum("nji,nj->ni", frame, force) # force on geom 2
332
+ geoms = np.asarray(contact.geom[:n])
333
+ fixed = model.geom_bodyid[geoms] == 0
334
+ world[fixed[:, 1] & ~fixed[:, 0]] *= -1.0 # robot is geom 1
335
+ magnitude = np.einsum("ni,ni->n", world, world)
336
+ live = np.flatnonzero(magnitude > 0.0)
337
+ if len(live) > len(rows):
338
+ live = live[np.argsort(-magnitude[live], kind="stable")[: len(rows)]]
339
+ live.sort()
340
+ rows[: len(live), :3] = np.asarray(contact.pos[:n])[live]
341
+ rows[: len(live), 3:] = world[live]
342
+ return out.reshape(1, *rows.shape)
343
+
344
+
345
+ def contacts_scale(model: mujoco.MjModel) -> float:
346
+ """Metres of drawn arrow per newton: one body weight draws as 1 m.
347
+
348
+ Args:
349
+ model: The compiled model.
350
+
351
+ Returns:
352
+ ``1 / (m g)`` with ``m`` the total mass and ``g`` gravity's
353
+ magnitude, or ``1 / (m * 9.81)`` when the model has no gravity, or
354
+ ``1.0`` when it has no mass.
355
+ """
356
+ mass = float(model.body_subtreemass[0])
357
+ g = float(np.linalg.norm(model.opt.gravity)) or _GRAVITY
358
+ return 1.0 / (mass * g) if mass > 0.0 else 1.0
359
+
360
+
361
+ def add_contacts_stream(
362
+ recorder: Any,
363
+ model: mujoco.MjModel,
364
+ *,
365
+ max_contacts: int = DEFAULT_MAX_CONTACTS,
366
+ name: str = CONTACTS_STREAM,
367
+ ) -> None:
368
+ """Declares the contacts stream on a recorder.
369
+
370
+ Call it before the first ``log``, then pass ``contacts(...)`` as the
371
+ ``contacts`` keyword of ``log``.
372
+
373
+ Args:
374
+ recorder: A ``simscope.recorder.Recorder``.
375
+ model: The compiled model (its mass sets the drawing scale).
376
+ max_contacts: ``K``, the contact slots per frame.
377
+ name: Stream name.
378
+ """
379
+ recorder.add_stream(
380
+ name,
381
+ "arrows",
382
+ (max_contacts, 6),
383
+ units="N",
384
+ scale=contacts_scale(model),
385
+ )
386
+
387
+
388
+ def _to_numpy(array: Any) -> np.ndarray:
389
+ """Converts a numpy, JAX, or Warp array to numpy without importing them.
390
+
391
+ Args:
392
+ array: An array-like object.
393
+
394
+ Returns:
395
+ A numpy array (a view where possible).
396
+ """
397
+ if isinstance(array, np.ndarray):
398
+ return array
399
+ to_numpy = getattr(array, "numpy", None)
400
+ if callable(to_numpy):
401
+ return np.asarray(to_numpy())
402
+ return np.asarray(array)
403
+
404
+
405
+ def _warn_skipped(model: mujoco.MjModel, skipped: np.ndarray) -> None:
406
+ """Logs one warning for everything the adapter cannot represent.
407
+
408
+ Args:
409
+ model: The compiled model.
410
+ skipped: The ``geom_type`` of each skipped geom.
411
+ """
412
+ counts: collections.Counter[str] = collections.Counter()
413
+ for geom_type in skipped.tolist():
414
+ try:
415
+ counts[_GEOM(geom_type).name.removeprefix("mjGEOM_")] += 1
416
+ except ValueError:
417
+ counts[f"type{geom_type}"] += 1
418
+ if model.nflex:
419
+ counts["flex"] = int(model.nflex)
420
+ if counts:
421
+ summary = ", ".join(f"{n} {name}" for name, n in sorted(counts.items()))
422
+ _LOG.warning(
423
+ "simscope.mujoco skipped unsupported geometry: %s", summary
424
+ )
425
+
426
+
427
+ class _SceneBuilder:
428
+ """Accumulates deduplicated materials, textures, and meshes for a scene."""
429
+
430
+ def __init__(
431
+ self, model: mujoco.MjModel, rgba: npt.NDArray[np.float32]
432
+ ) -> None:
433
+ """Initializes empty tables.
434
+
435
+ Args:
436
+ model: The compiled model.
437
+ rgba: Displayed color of every geom, ``[ngeom, 4]``.
438
+ """
439
+ self._model = model
440
+ self._rgba = rgba
441
+ self.materials: dict[core.Material, int] = {}
442
+ self.textures: list[core.Texture] = []
443
+ self.meshes: list[core.Mesh] = []
444
+ self._texture_by_id: dict[int, int | None] = {}
445
+ self._texture_by_png: dict[bytes, int] = {}
446
+ self._mesh_by_key: dict[tuple[str, int, bool], int] = {}
447
+
448
+ def material_and_mesh(self, gid: int) -> tuple[int, int | None]:
449
+ """Resolves the material and mesh indices of one geom.
450
+
451
+ Args:
452
+ gid: Geom id, of a supported type.
453
+
454
+ Returns:
455
+ ``(material index, mesh index or None)``.
456
+ """
457
+ model = self._model
458
+ geom_type = int(model.geom_type[gid])
459
+ matid = int(model.geom_matid[gid])
460
+ texid = _mjviser.texture_id(model, matid)
461
+ has_texture = self._is_supported_texture(texid)
462
+
463
+ mesh = None
464
+ if geom_type == _GEOM.mjGEOM_MESH:
465
+ mesh_id = int(model.geom_dataid[gid])
466
+ has_texture &= int(model.mesh_texcoordnum[mesh_id]) > 0
467
+ mesh = self._mesh(("mesh", mesh_id, has_texture))
468
+ elif geom_type == _GEOM.mjGEOM_HFIELD:
469
+ mesh = self._mesh(
470
+ ("hfield", int(model.geom_dataid[gid]), has_texture)
471
+ )
472
+ return self._material(gid, matid, texid if has_texture else -1), mesh
473
+
474
+ def _is_supported_texture(self, texid: int) -> bool:
475
+ """Tells whether a texture id refers to an exportable 2D texture."""
476
+ if texid < 0:
477
+ return False
478
+ model = self._model
479
+ return bool(
480
+ int(model.tex_type[texid]) == int(mujoco.mjtTexture.mjTEXTURE_2D)
481
+ and int(model.tex_nchannel[texid]) in (1, 3, 4)
482
+ )
483
+
484
+ def _material(self, gid: int, matid: int, texid: int) -> int:
485
+ """Returns the deduplicated material index of a geom."""
486
+ model = self._model
487
+ metallic, roughness = 0.0, 1.0
488
+ repeat = (1.0, 1.0)
489
+ texture = None
490
+ if matid >= 0:
491
+ metallic = _unless_negative(model.mat_metallic[matid], metallic)
492
+ roughness = _unless_negative(model.mat_roughness[matid], roughness)
493
+ if texid >= 0:
494
+ texture = self._texture(texid)
495
+ u, v = model.mat_texrepeat[matid].tolist()
496
+ repeat = (u, v)
497
+ r, g, b, a = self._rgba[gid].tolist()
498
+ material = core.Material(
499
+ rgba=(r, g, b, a),
500
+ metallic=metallic,
501
+ roughness=roughness,
502
+ texture=texture,
503
+ texrepeat=repeat,
504
+ )
505
+ return self.materials.setdefault(material, len(self.materials))
506
+
507
+ def _texture(self, texid: int) -> int | None:
508
+ """Encodes a texture once and returns its deduplicated index."""
509
+ if texid in self._texture_by_id:
510
+ return self._texture_by_id[texid]
511
+ pixels = _mjviser.texture_pixels(self._model, texid)
512
+ index = None
513
+ if pixels is not None:
514
+ png = _encode_png(pixels)
515
+ if png not in self._texture_by_png:
516
+ self._texture_by_png[png] = len(self.textures)
517
+ self.textures.append(
518
+ core.Texture(
519
+ data=png,
520
+ media_type="image/png",
521
+ width=pixels.shape[1],
522
+ height=pixels.shape[0],
523
+ )
524
+ )
525
+ index = self._texture_by_png[png]
526
+ self._texture_by_id[texid] = index
527
+ return index
528
+
529
+ def _mesh(self, key: tuple[str, int, bool]) -> int:
530
+ """Builds a mesh or height field once and returns its index."""
531
+ if key in self._mesh_by_key:
532
+ return self._mesh_by_key[key]
533
+ source, asset_id, with_uvs = key
534
+ extract = (
535
+ _mjviser.mesh_arrays if source == "mesh" else _mjviser.hfield_arrays
536
+ )
537
+ vertices, faces, uvs = extract(self._model, asset_id, with_uvs=with_uvs)
538
+ self.meshes.append(core.Mesh(vertices, faces, uvs=uvs))
539
+ self._mesh_by_key[key] = len(self.meshes) - 1
540
+ return self._mesh_by_key[key]
541
+
542
+
543
+ def _unless_negative(value: float, default: float) -> float:
544
+ """Returns ``value`` as a float, or ``default`` if MuJoCo left it unset."""
545
+ return float(value) if value >= 0 else default
546
+
547
+
548
+ def _encode_png(pixels: npt.NDArray[np.uint8]) -> bytes:
549
+ """Encodes 8-bit RGB or RGBA pixels as a PNG file.
550
+
551
+ Args:
552
+ pixels: A ``[H, W, 3]`` or ``[H, W, 4]`` uint8 array, top row first.
553
+
554
+ Returns:
555
+ The bytes of the PNG file, using filter type 0 and one IDAT chunk.
556
+ """
557
+ height, width, channels = pixels.shape
558
+ raw = np.zeros((height, 1 + width * channels), dtype=np.uint8)
559
+ raw[:, 1:] = pixels.reshape(height, width * channels)
560
+ header = struct.pack(
561
+ ">IIBBBBB", width, height, 8, _PNG_COLOR_TYPE[channels], 0, 0, 0
562
+ )
563
+ return (
564
+ _PNG_SIGNATURE
565
+ + _png_chunk(b"IHDR", header)
566
+ + _png_chunk(b"IDAT", zlib.compress(raw.tobytes(), 6))
567
+ + _png_chunk(b"IEND", b"")
568
+ )
569
+
570
+
571
+ def _png_chunk(tag: bytes, payload: bytes) -> bytes:
572
+ """Frames one PNG chunk with its length and CRC."""
573
+ return (
574
+ struct.pack(">I", len(payload))
575
+ + tag
576
+ + payload
577
+ + struct.pack(">I", zlib.crc32(tag + payload))
578
+ )
simscope/py.typed ADDED
File without changes