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/isaaclab.py ADDED
@@ -0,0 +1,1460 @@
1
+ """Adapter for Isaac Lab 3.0+ (Isaac Sim 6.x).
2
+
3
+ ``scene_from_env`` reads the static scene from the live USD stage. ``poses``
4
+ and ``PoseBuffer`` read ``asset.data.body_link_pose_w`` every step and give
5
+ the ``[E, B, 7]`` layout the recorder logs. Isaac Lab 3.0 already uses xyzw
6
+ quaternions and world-frame poses that include the env origins, so no
7
+ conversion is needed.
8
+
9
+ Nothing here imports torch, warp, isaaclab, omni, or isaacsim at import time.
10
+ Arrays are duck-typed (numpy, torch, Warp, or Isaac Lab's ``ProxyArray``) and
11
+ torch or Warp are imported lazily inside the branch that needs them.
12
+ Importing this module does import ``pxr``, which only exists after Isaac Sim
13
+ has started (for example after ``AppLauncher``); ``import simscope`` does not.
14
+
15
+ ``contacts`` turns a ``ContactSensor``'s net body forces into the
16
+ ``[E, B, 6]`` arrows frame of the ``contacts`` stream, and
17
+ ``add_contacts_stream`` declares that stream with the ``1 / (m g)`` scale.
18
+
19
+ Example:
20
+ >>> from simscope import isaaclab as sil
21
+ >>> scene = sil.scene_from_env(env, ["robot", "cube"])
22
+ >>> frame = sil.poses(env, ["robot", "cube"]) # [E, B, 7]
23
+ >>> sil.add_contacts_stream(rec, n_bodies=len(sensor.body_names), mass=m)
24
+ >>> rec.log(frame, contacts=sil.contacts(sensor, robot))
25
+ """
26
+
27
+ import collections
28
+ import dataclasses
29
+ import hashlib
30
+ import importlib
31
+ import importlib.metadata
32
+ import logging
33
+ import re
34
+ from collections.abc import Sequence
35
+ from typing import Any
36
+
37
+ import numpy as np
38
+ import numpy.typing as npt
39
+
40
+ try:
41
+ # Loaded dynamically: pxr ships no type stubs, so every pxr name is Any.
42
+ Usd: Any = importlib.import_module("pxr.Usd")
43
+ UsdGeom: Any = importlib.import_module("pxr.UsdGeom")
44
+ UsdPhysics: Any = importlib.import_module("pxr.UsdPhysics")
45
+ UsdShade: Any = importlib.import_module("pxr.UsdShade")
46
+ except ImportError as e: # pragma: no cover
47
+ raise ImportError(
48
+ "simscope.isaaclab needs pxr, which ships with Isaac Sim "
49
+ "(import it after the app has launched), or pip install usd-core"
50
+ ) from e
51
+
52
+ from simscope import core
53
+
54
+ _LOG = logging.getLogger(__name__)
55
+
56
+ _ENV_REGEX_MACRO = "{ENV_REGEX_NS}"
57
+ _ENV_REGEX_FALLBACKS = (r"env_\[\^/\]\+", r"env_\.\*", r"env_\[\^/\]\*")
58
+ _GLOB_CHARS = frozenset("[]*?^()|+")
59
+
60
+
61
+ def source_info() -> dict[str, str]:
62
+ """Describes the simulator for a rollout manifest.
63
+
64
+ Returns:
65
+ ``{"simulator": "isaaclab", "version": <isaaclab version>}``. The
66
+ version is ``"unknown"`` if the ``isaaclab`` package metadata cannot
67
+ be found.
68
+ """
69
+ try:
70
+ version = importlib.metadata.version("isaaclab")
71
+ except importlib.metadata.PackageNotFoundError:
72
+ version = "unknown"
73
+ return {"simulator": "isaaclab", "version": version}
74
+
75
+
76
+ def poses(env: Any, assets: Sequence[str]) -> npt.NDArray[np.float32]:
77
+ """Reads body poses for the named assets as a host array.
78
+
79
+ Args:
80
+ env: An Isaac Lab environment (or a gym wrapper around one).
81
+ assets: Scene entity names, such as ``["robot", "cube"]``. Each must
82
+ be an articulation, rigid object, or rigid object collection.
83
+
84
+ Returns:
85
+ A new float32 C-contiguous array ``[E, B, 7]`` of ``xyz`` followed by
86
+ quaternion ``xyzw`` in the world frame (env origins included).
87
+ Bodies are ordered by asset, then by each asset's ``body_names``.
88
+
89
+ Raises:
90
+ ValueError: If an asset's pose array has the wrong shape, or the
91
+ assets disagree on the number of envs.
92
+ """
93
+ arrays = [_to_host(a) for a in _pose_sources(env, assets)]
94
+ for array in arrays:
95
+ _check_pose_shape(array.shape)
96
+ n_envs = arrays[0].shape[0]
97
+ n_bodies = sum(a.shape[1] for a in arrays)
98
+ out = np.empty((n_envs, n_bodies, core.POSE_DIM), dtype=np.float32)
99
+ start = 0
100
+ for array in arrays:
101
+ if array.shape[0] != n_envs:
102
+ raise ValueError("assets have different numbers of envs")
103
+ out[:, start : start + array.shape[1]] = array
104
+ start += array.shape[1]
105
+ return out
106
+
107
+
108
+ def env_origins(env: Any) -> npt.NDArray[np.float32]:
109
+ """Reads the world origin of every env.
110
+
111
+ Args:
112
+ env: An Isaac Lab environment (or a gym wrapper around one).
113
+
114
+ Returns:
115
+ A float32 array ``[E, 3]``.
116
+
117
+ Raises:
118
+ ValueError: If ``env.scene.env_origins`` is not ``[E, 3]``.
119
+ """
120
+ origins = _to_host(_scene(env).env_origins)
121
+ if origins.ndim != 2 or origins.shape[1] != 3:
122
+ raise ValueError(f"env_origins must be [E, 3], got {origins.shape}")
123
+ return origins
124
+
125
+
126
+ def step_dt(env: Any) -> float:
127
+ """Returns the env step interval, which is the recorded frame ``dt``.
128
+
129
+ Args:
130
+ env: An Isaac Lab environment (or a gym wrapper around one).
131
+
132
+ Returns:
133
+ ``env.step_dt`` in seconds (physics dt times decimation).
134
+ """
135
+ return float(_unwrap(env).step_dt)
136
+
137
+
138
+ CONTACTS_STREAM = "contacts"
139
+ """Name of the contacts stream (viewer contracts 7)."""
140
+
141
+ _GRAVITY = 9.81
142
+
143
+
144
+ def contacts(
145
+ sensor: Any,
146
+ bodies: Any,
147
+ *,
148
+ out: npt.NDArray[np.float32] | None = None,
149
+ ) -> npt.NDArray[np.float32]:
150
+ """Packs a ``ContactSensor``'s net body forces for the contacts stream.
151
+
152
+ Row ``b`` of env ``e`` is ``[px, py, pz, fx, fy, fz]``: the position of
153
+ sensor body ``b`` (where the arrow is drawn) and the net contact force
154
+ on it, both in the world frame (``sensor.data.net_forces_w``). Bodies
155
+ without contact give zero rows, which deflate to nothing. ``K`` is the
156
+ number of sensor bodies.
157
+
158
+ Args:
159
+ sensor: An Isaac Lab ``ContactSensor`` (anything with
160
+ ``data.net_forces_w`` of shape ``[E, B, 3]``).
161
+ bodies: Where the sensor bodies are. Either the asset that owns
162
+ them (an articulation: its ``body_names`` are matched against
163
+ ``sensor.body_names`` and ``data.body_link_pose_w`` is read), or
164
+ an array of their positions or poses in sensor-body order,
165
+ ``[E, B, 3]`` or ``[E, B, 7]``.
166
+ out: Optional float32 C-contiguous ``[E, B, 6]`` array to fill.
167
+
168
+ Returns:
169
+ A float32 array ``[E, B, 6]`` on the host (``out`` if given).
170
+
171
+ Raises:
172
+ ValueError: If the shapes disagree, a sensor body is not found in
173
+ the asset, or ``out`` is unsuitable.
174
+ """
175
+ forces = _to_host(sensor.data.net_forces_w)
176
+ if forces.ndim != 3 or forces.shape[-1] != 3:
177
+ raise ValueError(f"net_forces_w must be [E, B, 3], got {forces.shape}")
178
+ if hasattr(bodies, "body_names") and hasattr(bodies, "data"):
179
+ names = list(bodies.body_names)
180
+ try:
181
+ ids = [names.index(n) for n in sensor.body_names]
182
+ except ValueError as exc:
183
+ raise ValueError(
184
+ f"sensor bodies {list(sensor.body_names)} are not all in "
185
+ f"the asset's bodies {names}"
186
+ ) from exc
187
+ positions = _to_host(bodies.data.body_link_pose_w)[:, ids, :3]
188
+ else:
189
+ positions = _to_host(bodies)[..., :3]
190
+ if positions.shape != forces.shape:
191
+ raise ValueError(
192
+ f"positions {positions.shape} do not match forces {forces.shape}"
193
+ )
194
+ shape = (*forces.shape[:2], 6)
195
+ if out is None:
196
+ out = np.empty(shape, dtype=np.float32)
197
+ elif (
198
+ out.shape != shape
199
+ or out.dtype != np.float32
200
+ or not out.flags.c_contiguous
201
+ ):
202
+ raise ValueError(
203
+ f"out must be a C-contiguous float32 array of shape {shape}"
204
+ )
205
+ out[..., :3] = positions
206
+ out[..., 3:] = forces
207
+ return out
208
+
209
+
210
+ def add_contacts_stream(
211
+ recorder: Any,
212
+ n_bodies: int,
213
+ *,
214
+ mass: float,
215
+ name: str = CONTACTS_STREAM,
216
+ ) -> None:
217
+ """Declares the contacts stream on a recorder.
218
+
219
+ Call it before the first ``log``, then pass ``contacts(...)`` as the
220
+ ``contacts`` keyword of ``log``.
221
+
222
+ Args:
223
+ recorder: A ``simscope.recorder.Recorder``.
224
+ n_bodies: ``K``, the number of sensor bodies.
225
+ mass: Robot mass in kg; one body weight (``mass * 9.81`` N) is drawn
226
+ as a 1 m arrow.
227
+ name: Stream name.
228
+
229
+ Raises:
230
+ ValueError: If ``mass`` is not positive.
231
+ """
232
+ if mass <= 0:
233
+ raise ValueError(f"mass must be positive, got {mass}")
234
+ recorder.add_stream(
235
+ name,
236
+ "arrows",
237
+ (n_bodies, 6),
238
+ units="N",
239
+ scale=1.0 / (mass * _GRAVITY),
240
+ )
241
+
242
+
243
+ class PoseBuffer:
244
+ """Stages poses in a device-side ring buffer, one host copy per block.
245
+
246
+ ``push`` copies this step's poses into a preallocated buffer on the same
247
+ device as the source arrays, so it never synchronizes with the host.
248
+ ``drain`` then moves everything to the host in a single transfer. For
249
+ numpy sources (CPU) it uses a preallocated numpy buffer instead.
250
+
251
+ Attributes:
252
+ capacity: Maximum number of frames held between drains.
253
+ """
254
+
255
+ def __init__(self, env: Any, assets: Sequence[str], capacity: int) -> None:
256
+ """Binds the buffer to an env and a list of assets.
257
+
258
+ The device buffer is allocated lazily at the first ``push``, with the
259
+ same framework and device as the first pose array.
260
+
261
+ Args:
262
+ env: An Isaac Lab environment (or a gym wrapper around one).
263
+ assets: Scene entity names, in pose order.
264
+ capacity: Frames to hold between drains.
265
+
266
+ Raises:
267
+ ValueError: If ``capacity`` is not positive or ``assets`` is
268
+ empty.
269
+ """
270
+ if capacity <= 0:
271
+ raise ValueError(f"capacity must be positive, got {capacity}")
272
+ self.capacity = capacity
273
+ self._sources = _asset_data(env, assets)
274
+ self._buffer: Any = None
275
+ self._is_torch = False
276
+ self._slices: list[tuple[int, int]] = []
277
+ self._count = 0
278
+
279
+ def __len__(self) -> int:
280
+ """Returns the number of frames waiting to be drained."""
281
+ return self._count
282
+
283
+ @property
284
+ def full(self) -> bool:
285
+ """Whether the next ``push`` would overflow the buffer."""
286
+ return self._count >= self.capacity
287
+
288
+ def push(self) -> None:
289
+ """Copies the current poses into the ring buffer on the device.
290
+
291
+ Raises:
292
+ BufferError: If the buffer is full; call ``drain`` first.
293
+ ValueError: If an asset's pose array changed shape.
294
+ TypeError: If the assets mix torch and numpy arrays.
295
+ """
296
+ if self._count >= self.capacity:
297
+ raise BufferError("PoseBuffer is full; call drain() first")
298
+ arrays = [_as_array(d.body_link_pose_w) for d in self._sources]
299
+ if self._buffer is None:
300
+ self._allocate(arrays)
301
+ frame = self._buffer[self._count]
302
+ for array, (lo, hi) in zip(arrays, self._slices, strict=True):
303
+ if _is_torch(array) != self._is_torch:
304
+ raise TypeError("assets mix torch and numpy pose arrays")
305
+ if tuple(array.shape) != (frame.shape[0], hi - lo, core.POSE_DIM):
306
+ raise ValueError(f"pose array changed shape: {array.shape}")
307
+ if self._is_torch:
308
+ frame[:, lo:hi].copy_(array)
309
+ else:
310
+ frame[:, lo:hi] = array
311
+ self._count += 1
312
+
313
+ def drain(self) -> npt.NDArray[np.float32]:
314
+ """Moves the buffered frames to the host and empties the buffer.
315
+
316
+ Returns:
317
+ A new float32 array ``[n, E, B, 7]`` with ``n = len(self)``
318
+ before the call. Uses exactly one device-to-host copy.
319
+ """
320
+ n, self._count = self._count, 0
321
+ if self._buffer is None:
322
+ return np.empty((0, 0, 0, core.POSE_DIM), dtype=np.float32)
323
+ block = self._buffer[:n]
324
+ if not self._is_torch:
325
+ return block.copy()
326
+ if block.device.type == "cpu":
327
+ block = block.clone()
328
+ return block.cpu().numpy()
329
+
330
+ def _allocate(self, arrays: list[Any]) -> None:
331
+ """Allocates the buffer to match the first pose arrays.
332
+
333
+ Args:
334
+ arrays: Pose arrays of every asset, each ``[E, B_i, 7]``.
335
+
336
+ Raises:
337
+ ValueError: If an array is not ``[E, B_i, 7]`` or the assets
338
+ disagree on ``E``.
339
+ """
340
+ first = arrays[0]
341
+ n_envs = first.shape[0]
342
+ self._slices = []
343
+ start = 0
344
+ for array in arrays:
345
+ _check_pose_shape(array.shape)
346
+ if array.shape[0] != n_envs:
347
+ raise ValueError("assets have different numbers of envs")
348
+ self._slices.append((start, start + array.shape[1]))
349
+ start += array.shape[1]
350
+ shape = (self.capacity, n_envs, start, core.POSE_DIM)
351
+ self._is_torch = _is_torch(first)
352
+ if self._is_torch:
353
+ torch = importlib.import_module("torch") # Only inside Isaac.
354
+
355
+ self._buffer = torch.empty(
356
+ shape, dtype=torch.float32, device=first.device
357
+ )
358
+ else:
359
+ self._buffer = np.empty(shape, dtype=np.float32)
360
+
361
+
362
+ def _unwrap(env: Any) -> Any:
363
+ """Returns the base env behind gym wrappers."""
364
+ return getattr(env, "unwrapped", env)
365
+
366
+
367
+ def _scene(env: Any) -> Any:
368
+ """Returns the ``InteractiveScene`` of an env."""
369
+ return _unwrap(env).scene
370
+
371
+
372
+ def _asset_data(env: Any, assets: Sequence[str]) -> list[Any]:
373
+ """Looks up the ``data`` object of each named asset.
374
+
375
+ Args:
376
+ env: An Isaac Lab environment.
377
+ assets: Scene entity names.
378
+
379
+ Returns:
380
+ The ``asset.data`` objects, in order.
381
+
382
+ Raises:
383
+ ValueError: If ``assets`` is empty.
384
+ """
385
+ if not assets:
386
+ raise ValueError("assets must not be empty")
387
+ scene = _scene(env)
388
+ return [scene[name].data for name in assets]
389
+
390
+
391
+ def _pose_sources(env: Any, assets: Sequence[str]) -> list[Any]:
392
+ """Reads ``body_link_pose_w`` of each asset.
393
+
394
+ Isaac Lab 3.0 documents this array as ``(num_instances, num_bodies)`` of
395
+ ``wp.transformf``, which is ``(N, B, 7)`` in torch, with the orientation
396
+ in ``(x, y, z, w)`` order, in the simulation world frame
397
+ (``source/isaaclab/isaaclab/assets/articulation/base_articulation_data.py``
398
+ lines 777-785; the PhysX articulation, rigid object and collection data
399
+ classes and the OVPhysX one follow the same contract).
400
+
401
+ Args:
402
+ env: An Isaac Lab environment.
403
+ assets: Scene entity names.
404
+
405
+ Returns:
406
+ The raw pose arrays, one per asset.
407
+ """
408
+ return [d.body_link_pose_w for d in _asset_data(env, assets)]
409
+
410
+
411
+ def _check_pose_shape(shape: Sequence[int]) -> None:
412
+ """Checks that a pose array shape is ``[E, B, 7]``.
413
+
414
+ Args:
415
+ shape: The array shape.
416
+
417
+ Raises:
418
+ ValueError: If the shape is not three-dimensional with a last axis
419
+ of size 7.
420
+ """
421
+ if len(shape) != 3 or shape[-1] != core.POSE_DIM:
422
+ raise ValueError(f"body_link_pose_w must be [E, B, 7], got {shape}")
423
+
424
+
425
+ def _is_torch(array: Any) -> bool:
426
+ """Tells whether an object is a torch tensor without importing torch."""
427
+ return type(array).__module__.partition(".")[0] == "torch"
428
+
429
+
430
+ def _as_array(array: Any) -> Any:
431
+ """Turns any supported pose container into a numpy or torch array.
432
+
433
+ Args:
434
+ array: A numpy array, torch tensor, Warp array, Isaac Lab
435
+ ``ProxyArray``, or anything with a ``numpy()`` method.
436
+
437
+ Returns:
438
+ A numpy array or torch tensor sharing memory with the input where
439
+ possible. Nothing is copied to the host.
440
+ """
441
+ if isinstance(array, np.ndarray):
442
+ return array
443
+ cls = type(array)
444
+ # ProxyArray forwards unknown attributes to torch (with a deprecation
445
+ # warning), so identify it on the class and use its explicit accessor.
446
+ if hasattr(cls, "torch") and hasattr(cls, "warp"):
447
+ return array.torch
448
+ root = cls.__module__.partition(".")[0]
449
+ if root == "torch":
450
+ return array
451
+ if root == "warp":
452
+ try:
453
+ warp = importlib.import_module("warp") # Only inside Isaac.
454
+
455
+ return warp.to_torch(array)
456
+ except ImportError:
457
+ return np.asarray(array.numpy())
458
+ to_numpy = getattr(array, "numpy", None)
459
+ if callable(to_numpy):
460
+ return np.asarray(to_numpy())
461
+ return np.asarray(array)
462
+
463
+
464
+ def _to_host(array: Any) -> npt.NDArray[np.float32]:
465
+ """Copies a pose container to a float32 numpy array on the host.
466
+
467
+ Args:
468
+ array: Anything ``_as_array`` accepts.
469
+
470
+ Returns:
471
+ A float32 numpy array (a view of the input when it is already host
472
+ float32 numpy).
473
+ """
474
+ array = _as_array(array)
475
+ if _is_torch(array):
476
+ array = array.detach().cpu().numpy()
477
+ return np.asarray(array, dtype=np.float32)
478
+
479
+
480
+ def scene_from_env(
481
+ env: Any, assets: Sequence[str], *, collision: bool = True
482
+ ) -> core.Scene:
483
+ """Builds a scene from the live USD stage of an Isaac Lab env.
484
+
485
+ For each asset, the body prims are those of env 0. An articulation's
486
+ bodies are its links, named by ``asset.body_names``; a rigid object is its
487
+ root prim; a rigid object collection is one prim per object. Body paths
488
+ come from the physics view's ``link_paths`` when it offers them, and
489
+ otherwise from a search of the asset's root prim subtree. ``poses`` and
490
+ the returned scene use the same body order (asset by asset, then each
491
+ asset's own ``body_names`` order), so frame ``[e, b]`` is
492
+ ``Scene.bodies[b]`` in env ``e``. Each body is named by its prim path.
493
+
494
+ Args:
495
+ env: An Isaac Lab environment (or a gym wrapper around one).
496
+ assets: Scene entity names, in pose order.
497
+ collision: If false, keep only ``"visual"`` geoms.
498
+
499
+ Returns:
500
+ The scene, extracted with ``scene_from_stage``.
501
+
502
+ Raises:
503
+ ValueError: If an asset's body prims cannot be located, or ``assets``
504
+ is empty.
505
+ """
506
+ if not assets:
507
+ raise ValueError("assets must not be empty")
508
+ scene = _scene(env)
509
+ stage = _stage(env)
510
+ body_paths: list[str] = []
511
+ for name in assets:
512
+ body_paths.extend(_asset_body_paths(scene, stage, scene[name]))
513
+ return scene_from_stage(stage, body_paths, collision=collision)
514
+
515
+
516
+ def _stage(env: Any) -> Any:
517
+ """Returns the USD stage of an env.
518
+
519
+ Args:
520
+ env: An Isaac Lab environment.
521
+
522
+ Returns:
523
+ ``scene.stage``, or ``env.sim.stage`` if the scene has none.
524
+
525
+ Raises:
526
+ ValueError: If no stage can be found.
527
+ """
528
+ unwrapped = _unwrap(env)
529
+ for owner in (unwrapped.scene, getattr(unwrapped, "sim", None)):
530
+ stage = getattr(owner, "stage", None)
531
+ if stage is not None:
532
+ return stage
533
+ raise ValueError("cannot find the USD stage on env.scene or env.sim")
534
+
535
+
536
+ def _env0_path(scene: Any, expr: str) -> str:
537
+ """Resolves a scene prim path expression to the path in env 0.
538
+
539
+ Args:
540
+ scene: The ``InteractiveScene``.
541
+ expr: A prim path that may contain ``{ENV_REGEX_NS}`` or the
542
+ scene's env regex (``/World/envs/env_[^/]+``).
543
+
544
+ Returns:
545
+ A literal prim path.
546
+
547
+ Raises:
548
+ ValueError: If wildcard characters remain after the substitution.
549
+ """
550
+ env0 = str(scene.env_prim_paths[0])
551
+ path = expr.replace(_ENV_REGEX_MACRO, env0)
552
+ regex_ns = getattr(scene, "env_regex_ns", None)
553
+ if isinstance(regex_ns, str) and regex_ns in path:
554
+ path = path.replace(regex_ns, env0)
555
+ else:
556
+ leaf = env0.rsplit("/", 1)[-1]
557
+ for pattern in _ENV_REGEX_FALLBACKS:
558
+ path = re.sub(pattern, leaf, path)
559
+ if _GLOB_CHARS & set(path):
560
+ raise ValueError(f"cannot resolve {expr!r} to one prim path: {path!r}")
561
+ return path
562
+
563
+
564
+ def _asset_body_paths(scene: Any, stage: Any, asset: Any) -> list[str]:
565
+ """Finds the env-0 prim path of every body of an asset.
566
+
567
+ Args:
568
+ scene: The ``InteractiveScene``.
569
+ stage: The USD stage.
570
+ asset: An articulation, rigid object, or rigid object collection.
571
+
572
+ Returns:
573
+ One prim path per entry of ``asset.body_names``, in that order.
574
+
575
+ Raises:
576
+ ValueError: If a body has no matching prim.
577
+ """
578
+ names = list(asset.body_names)
579
+ objects = getattr(asset.cfg, "rigid_objects", None)
580
+ if isinstance(objects, dict): # rigid object collection
581
+ return [_env0_path(scene, objects[n].prim_path) for n in names]
582
+ root = _env0_path(scene, asset.cfg.prim_path)
583
+ if len(names) == 1 and root.rsplit("/", 1)[-1] == names[0]:
584
+ return [root] # rigid object
585
+ by_name = _link_paths_by_name(asset, root)
586
+ if not by_name or any(n not in by_name for n in names):
587
+ by_name = _prims_by_name(stage, root)
588
+ missing = [n for n in names if n not in by_name]
589
+ if missing:
590
+ raise ValueError(f"no prim under {root} for bodies {missing}")
591
+ return [by_name[n] for n in names]
592
+
593
+
594
+ def _link_paths_by_name(asset: Any, root: str) -> dict[str, str]:
595
+ """Maps link names to env-0 prim paths using the physics view.
596
+
597
+ Args:
598
+ asset: An articulation.
599
+ root: The env-0 root prim path of the asset.
600
+
601
+ Returns:
602
+ A dict from link name to path, empty if the view exposes no
603
+ ``link_paths`` or they do not belong to ``root`` (which means they
604
+ are not env 0's).
605
+ """
606
+ view = getattr(asset, "root_view", None)
607
+ link_paths = getattr(view, "link_paths", None)
608
+ if not link_paths:
609
+ return {}
610
+ paths = [str(p) for p in link_paths[0]]
611
+ if not all(p == root or p.startswith(root + "/") for p in paths):
612
+ return {}
613
+ return {p.rsplit("/", 1)[-1]: p for p in paths}
614
+
615
+
616
+ def _prims_by_name(stage: Any, root: str) -> dict[str, str]:
617
+ """Indexes the prims under ``root`` by name, preferring rigid bodies.
618
+
619
+ Args:
620
+ stage: The USD stage.
621
+ root: Path of the subtree to search (included in the search).
622
+
623
+ Returns:
624
+ A dict from prim name to path. If several prims share a name, the
625
+ first one in traversal order wins unless a later one has
626
+ ``UsdPhysics.RigidBodyAPI`` and the earlier one does not.
627
+ """
628
+ prim = stage.GetPrimAtPath(root)
629
+ if not prim.IsValid():
630
+ return {}
631
+ found: dict[str, tuple[str, bool]] = {}
632
+ for child in Usd.PrimRange(prim, Usd.TraverseInstanceProxies()):
633
+ name = child.GetName()
634
+ rigid = child.HasAPI(UsdPhysics.RigidBodyAPI)
635
+ if name not in found or (rigid and not found[name][1]):
636
+ found[name] = (str(child.GetPath()), rigid)
637
+ return {name: path for name, (path, _) in found.items()}
638
+
639
+
640
+ # --------------------------------------------------------------------------
641
+ # USD stage extraction
642
+ #
643
+ # Matrices are numpy float64 4x4 in USD's row-vector convention: a point maps
644
+ # as ``p_out = p_in @ M`` and the translation is the last row.
645
+ # --------------------------------------------------------------------------
646
+
647
+ _INFINITE_PLANE_METRES = 100.0
648
+ """A UsdGeom.Plane at least this wide (in metres) becomes an infinite plane."""
649
+ _SHEAR_TOL = 1e-4
650
+ _UNIFORM_RTOL = 1e-4
651
+ _ARC_SEGMENTS = 32
652
+ _CAP_RINGS = 8
653
+ _AXES = {"X": 0, "Y": 1, "Z": 2}
654
+
655
+ # Rows are the images of the shape-frame axes (x, y, z) in USD-local axes.
656
+ # Cylinder, capsule and cone run along local +z in the shape frame.
657
+ # fmt: off
658
+ _AXIS_ROT = {
659
+ 0: np.array([[0.0, 0, -1], [0, 1, 0], [1, 0, 0]]),
660
+ 1: np.array([[1.0, 0, 0], [0, 0, -1], [0, 1, 0]]),
661
+ 2: np.eye(3),
662
+ }
663
+ # UsdGeom.Plane: the normal is the axis; width and length run along the next
664
+ # two axes cyclically (Z: x,y; X: y,z; Y: z,x).
665
+ _PLANE_ROT = {
666
+ 0: np.array([[0.0, 1, 0], [0, 0, 1], [1, 0, 0]]),
667
+ 1: np.array([[0.0, 0, 1], [1, 0, 0], [0, 1, 0]]),
668
+ 2: np.eye(3),
669
+ }
670
+ # fmt: on
671
+
672
+ _COLOR_INPUTS = (
673
+ "diffuseColor",
674
+ "diffuse_color_constant",
675
+ "diffuse_reflection_color",
676
+ "base_color",
677
+ "diffuse_tint",
678
+ )
679
+ _OPACITY_INPUTS = ("opacity", "opacity_constant", "geometry_opacity")
680
+ _METALLIC_INPUTS = ("metallic", "metallic_constant", "metalness")
681
+ _ROUGHNESS_INPUTS = (
682
+ "roughness",
683
+ "reflection_roughness_constant",
684
+ "specular_reflection_roughness",
685
+ "specular_roughness",
686
+ )
687
+
688
+
689
+ def _matrix(gf_matrix: Any) -> npt.NDArray[np.float64]:
690
+ """Converts a ``Gf.Matrix4d`` to a numpy 4x4."""
691
+ return np.array(gf_matrix, dtype=np.float64).reshape(4, 4)
692
+
693
+
694
+ def _rigid_part(m: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
695
+ """Removes scale and shear from a transform, keeping rotation and shift.
696
+
697
+ Args:
698
+ m: A 4x4 row-vector transform.
699
+
700
+ Returns:
701
+ The 4x4 transform with the nearest proper rotation (polar factor) in
702
+ place of the linear part.
703
+ """
704
+ u, _, vt = np.linalg.svd(m[:3, :3])
705
+ if np.linalg.det(u @ vt) < 0:
706
+ u[:, -1] *= -1
707
+ out = np.eye(4)
708
+ out[:3, :3] = u @ vt
709
+ out[3, :3] = m[3, :3]
710
+ return out
711
+
712
+
713
+ def _rotation_to_quat(m: npt.NDArray[np.float64]) -> core.Quat:
714
+ """Converts a row-vector rotation matrix to an xyzw quaternion.
715
+
716
+ Args:
717
+ m: A proper 3x3 rotation acting on row vectors (``p @ m``).
718
+
719
+ Returns:
720
+ A unit quaternion ``(x, y, z, w)`` with ``w >= 0``.
721
+ """
722
+ r = m.T # the equivalent column-vector matrix
723
+ trace = r[0, 0] + r[1, 1] + r[2, 2]
724
+ if trace > 0:
725
+ s = 2.0 * np.sqrt(trace + 1.0)
726
+ q = [
727
+ (r[2, 1] - r[1, 2]) / s,
728
+ (r[0, 2] - r[2, 0]) / s,
729
+ (r[1, 0] - r[0, 1]) / s,
730
+ 0.25 * s,
731
+ ]
732
+ else:
733
+ i = int(np.argmax(np.diag(r)))
734
+ j, k = (i + 1) % 3, (i + 2) % 3
735
+ s = 2.0 * np.sqrt(1.0 + r[i, i] - r[j, j] - r[k, k])
736
+ q = [0.0] * 4
737
+ q[i] = 0.25 * s
738
+ q[j] = (r[j, i] + r[i, j]) / s
739
+ q[k] = (r[k, i] + r[i, k]) / s
740
+ q[3] = (r[k, j] - r[j, k]) / s
741
+ quat = np.array(q)
742
+ quat /= np.linalg.norm(quat)
743
+ if quat[3] < 0:
744
+ quat = -quat
745
+ x, y, z, w = quat.tolist()
746
+ return (x, y, z, w)
747
+
748
+
749
+ def _vec3(values: Sequence[float] | npt.NDArray[np.float64]) -> core.Vec3:
750
+ """Converts three numbers to a tuple of Python floats."""
751
+ x, y, z = (float(v) for v in values)
752
+ return (x, y, z)
753
+
754
+
755
+ def _is_uniform(values: npt.NDArray[np.float64]) -> bool:
756
+ """Tells whether all entries are equal within a relative tolerance."""
757
+ return bool(np.ptp(values) <= _UNIFORM_RTOL * np.max(np.abs(values)))
758
+
759
+
760
+ def _triangulate(
761
+ counts: npt.NDArray[np.int64], indices: npt.NDArray[np.int64]
762
+ ) -> npt.NDArray[np.int64]:
763
+ """Fan-triangulates polygons without a Python loop.
764
+
765
+ Args:
766
+ counts: Vertex count of every face, ``[F]``.
767
+ indices: Concatenated face vertex indices.
768
+
769
+ Returns:
770
+ Triangle indices ``[T, 3]``. Faces with fewer than 3 vertices give
771
+ no triangles.
772
+ """
773
+ starts = np.cumsum(counts) - counts
774
+ n_tri = np.maximum(counts - 2, 0)
775
+ face = np.repeat(np.arange(len(counts)), n_tri)
776
+ within = np.arange(int(n_tri.sum())) - np.repeat(
777
+ np.cumsum(n_tri) - n_tri, n_tri
778
+ )
779
+ first = starts[face]
780
+ return np.stack(
781
+ (
782
+ indices[first],
783
+ indices[first + within + 1],
784
+ indices[first + within + 2],
785
+ ),
786
+ axis=1,
787
+ )
788
+
789
+
790
+ def _revolve(
791
+ profile: Sequence[tuple[float, float]], segments: int = _ARC_SEGMENTS
792
+ ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.int64]]:
793
+ """Revolves an ``(r, z)`` profile about +z into a closed mesh.
794
+
795
+ Args:
796
+ profile: Points from the top pole to the bottom pole. Walking the
797
+ profile must keep the solid on its right, so triangles face
798
+ outward.
799
+ segments: Number of samples around the axis.
800
+
801
+ Returns:
802
+ ``(vertices [V, 3], faces [F, 3])`` with degenerate triangles at the
803
+ poles removed.
804
+ """
805
+ rings = len(profile)
806
+ r, z = np.array(profile, dtype=np.float64).T
807
+ theta = np.linspace(0.0, 2.0 * np.pi, segments, endpoint=False)
808
+ verts = np.stack(
809
+ (
810
+ np.outer(r, np.cos(theta)),
811
+ np.outer(r, np.sin(theta)),
812
+ np.repeat(z[:, None], segments, axis=1),
813
+ ),
814
+ axis=-1,
815
+ ).reshape(-1, 3)
816
+ i, j = np.meshgrid(np.arange(rings - 1), np.arange(segments), indexing="ij")
817
+ a = (i * segments + j).ravel()
818
+ b = (i * segments + (j + 1) % segments).ravel()
819
+ c = ((i + 1) * segments + (j + 1) % segments).ravel()
820
+ d = ((i + 1) * segments + j).ravel()
821
+ faces = np.concatenate(
822
+ (np.stack((a, d, c), axis=1), np.stack((a, c, b), axis=1))
823
+ )
824
+ tri = verts[faces]
825
+ area = np.linalg.norm(
826
+ np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]), axis=1
827
+ )
828
+ return verts, faces[area > 1e-9 * area.max()]
829
+
830
+
831
+ def _capsule_profile(radius: float, half: float) -> list[tuple[float, float]]:
832
+ """Returns the ``(r, z)`` outline of a capsule, top pole first."""
833
+ phi = np.linspace(0.0, np.pi / 2, _CAP_RINGS + 1)
834
+ top = [(radius * np.sin(p), half + radius * np.cos(p)) for p in phi]
835
+ bottom = [(r, -z) for r, z in reversed(top)]
836
+ return [(float(r), float(z)) for r, z in top + bottom]
837
+
838
+
839
+ @dataclasses.dataclass(frozen=True)
840
+ class _Shape:
841
+ """A USD geometry prim reduced to what the exporter needs.
842
+
843
+ Attributes:
844
+ kind: ``box``, ``sphere``, ``capsule``, ``cylinder``, ``cone``,
845
+ ``frustum``, ``plane`` or ``mesh``.
846
+ dims: Kind-specific sizes in stage units: box half-extents; sphere
847
+ ``(radius,)``; capsule, cylinder and cone ``(radius, height)``;
848
+ frustum ``(radius_top, radius_bottom, height)``; plane
849
+ ``(width, length)``.
850
+ axis: Index of the USD axis a capsule, cylinder, cone, frustum or
851
+ plane is aligned with.
852
+ points: Mesh vertices in stage units, ``[V, 3]``.
853
+ faces: Mesh triangles, ``[T, 3]``.
854
+ """
855
+
856
+ kind: str
857
+ dims: tuple[float, ...] = ()
858
+ axis: int = 2
859
+ points: npt.NDArray[np.float64] | None = None
860
+ faces: npt.NDArray[np.int64] | None = None
861
+
862
+
863
+ def _read_mesh(prim: Any) -> _Shape | None:
864
+ """Reads a ``UsdGeom.Mesh`` as a triangle mesh.
865
+
866
+ Args:
867
+ prim: A prim of type ``UsdGeom.Mesh``.
868
+
869
+ Returns:
870
+ The shape, or ``None`` if the mesh is empty or malformed.
871
+ """
872
+ mesh = UsdGeom.Mesh(prim)
873
+ points = mesh.GetPointsAttr().Get()
874
+ counts = mesh.GetFaceVertexCountsAttr().Get()
875
+ indices = mesh.GetFaceVertexIndicesAttr().Get()
876
+ if not points or counts is None or indices is None:
877
+ return None
878
+ verts = np.asarray(points, dtype=np.float64).reshape(-1, 3)
879
+ faces = _triangulate(
880
+ np.asarray(counts, dtype=np.int64), np.asarray(indices, dtype=np.int64)
881
+ )
882
+ if not len(faces) or faces.min() < 0 or faces.max() >= len(verts):
883
+ return None
884
+ if mesh.GetOrientationAttr().Get() == UsdGeom.Tokens.leftHanded:
885
+ faces = faces[:, ::-1]
886
+ return _Shape("mesh", points=verts, faces=np.ascontiguousarray(faces))
887
+
888
+
889
+ def _read_shape(prim: Any) -> _Shape | None:
890
+ """Reads the geometry of a supported ``UsdGeom`` prim.
891
+
892
+ Args:
893
+ prim: A prim, possibly an instance proxy.
894
+
895
+ Returns:
896
+ The shape, or ``None`` if the prim type is unsupported or the
897
+ geometry is empty.
898
+ """
899
+ if prim.IsA(UsdGeom.Mesh):
900
+ return _read_mesh(prim)
901
+ if prim.IsA(UsdGeom.Cube):
902
+ half = UsdGeom.Cube(prim).GetSizeAttr().Get() / 2.0
903
+ return _Shape("box", (half, half, half))
904
+ if prim.IsA(UsdGeom.Sphere):
905
+ return _Shape("sphere", (UsdGeom.Sphere(prim).GetRadiusAttr().Get(),))
906
+ for schema, kind in (
907
+ (UsdGeom.Capsule, "capsule"),
908
+ (UsdGeom.Cylinder, "cylinder"),
909
+ (UsdGeom.Cone, "cone"),
910
+ ):
911
+ if prim.IsA(schema):
912
+ api = schema(prim)
913
+ axis = _AXES[str(api.GetAxisAttr().Get())]
914
+ dims = (api.GetRadiusAttr().Get(), api.GetHeightAttr().Get())
915
+ return _Shape(kind, dims, axis)
916
+ if prim.IsA(UsdGeom.Plane):
917
+ plane = UsdGeom.Plane(prim)
918
+ axis = _AXES[str(plane.GetAxisAttr().Get())]
919
+ dims = (plane.GetWidthAttr().Get(), plane.GetLengthAttr().Get())
920
+ return _Shape("plane", dims, axis)
921
+ return _read_tapered(prim)
922
+
923
+
924
+ def _read_tapered(prim: Any) -> _Shape | None:
925
+ """Reads the newer ``Capsule_1`` and ``Cylinder_1`` schemas.
926
+
927
+ Args:
928
+ prim: A prim.
929
+
930
+ Returns:
931
+ A capsule or cylinder when both radii match, a frustum when a
932
+ cylinder tapers, and ``None`` for anything else (including tapered
933
+ capsules).
934
+ """
935
+ for name, kind in (("Capsule_1", "capsule"), ("Cylinder_1", "cylinder")):
936
+ schema = getattr(UsdGeom, name, None)
937
+ if schema is None or not prim.IsA(schema):
938
+ continue
939
+ api = schema(prim)
940
+ top = api.GetRadiusTopAttr().Get()
941
+ bottom = api.GetRadiusBottomAttr().Get()
942
+ height = api.GetHeightAttr().Get()
943
+ axis = _AXES[str(api.GetAxisAttr().Get())]
944
+ if abs(top - bottom) <= 1e-9 * max(top, bottom, 1e-30):
945
+ return _Shape(kind, (top, height), axis)
946
+ if kind == "cylinder":
947
+ return _Shape("frustum", (top, bottom, height), axis)
948
+ return None
949
+
950
+
951
+ def _box_mesh(
952
+ half: Sequence[float],
953
+ ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.int64]]:
954
+ """Builds a box with outward-facing triangles.
955
+
956
+ Args:
957
+ half: Half-extents along x, y, z.
958
+
959
+ Returns:
960
+ ``(vertices [8, 3], faces [12, 3])``.
961
+ """
962
+ corners = np.array(
963
+ [[x, y, z] for z in (-1, 1) for y in (-1, 1) for x in (-1, 1)],
964
+ dtype=np.float64,
965
+ )
966
+ faces = np.array(
967
+ [
968
+ [0, 2, 3], [0, 3, 1], # -z
969
+ [4, 5, 7], [4, 7, 6], # +z
970
+ [0, 1, 5], [0, 5, 4], # -y
971
+ [2, 6, 7], [2, 7, 3], # +y
972
+ [0, 4, 6], [0, 6, 2], # -x
973
+ [1, 3, 7], [1, 7, 5], # +x
974
+ ]
975
+ ) # fmt: skip
976
+ return corners * np.asarray(half), faces
977
+
978
+
979
+ def _tessellate(
980
+ shape: _Shape,
981
+ ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.int64]]:
982
+ """Builds triangles for a shape in USD-local axes and stage units.
983
+
984
+ Args:
985
+ shape: Any shape. Meshes pass through; the rest are generated
986
+ with outward-facing triangles.
987
+
988
+ Returns:
989
+ ``(vertices [V, 3], faces [T, 3])``.
990
+ """
991
+ kind, dims = shape.kind, shape.dims
992
+ if kind == "mesh":
993
+ assert shape.points is not None and shape.faces is not None
994
+ return shape.points, shape.faces
995
+ rotation = _AXIS_ROT[shape.axis]
996
+ if kind == "capsule":
997
+ radius, height = dims
998
+ verts, faces = _revolve(_capsule_profile(radius, height / 2))
999
+ elif kind == "sphere":
1000
+ verts, faces = _revolve(_capsule_profile(dims[0], 0.0))
1001
+ elif kind == "box":
1002
+ verts, faces = _box_mesh(dims)
1003
+ elif kind == "cylinder":
1004
+ radius, height = dims
1005
+ half = height / 2
1006
+ verts, faces = _revolve(
1007
+ [(0.0, half), (radius, half), (radius, -half), (0.0, -half)]
1008
+ )
1009
+ elif kind in ("cone", "frustum"):
1010
+ top, bottom = (0.0, dims[0]) if kind == "cone" else dims[:2]
1011
+ half = dims[-1] / 2
1012
+ verts, faces = _revolve(
1013
+ [(0.0, half), (top, half), (bottom, -half), (0.0, -half)]
1014
+ )
1015
+ elif kind == "plane":
1016
+ rotation = _PLANE_ROT[shape.axis]
1017
+ w, length = dims[0] / 2, dims[1] / 2
1018
+ verts = np.array(
1019
+ [[-w, -length, 0], [w, -length, 0], [w, length, 0], [-w, length, 0]]
1020
+ )
1021
+ faces = np.array([[0, 1, 2], [0, 2, 3]])
1022
+ else:
1023
+ raise ValueError(f"cannot tessellate a {kind}")
1024
+ return verts @ rotation, faces
1025
+
1026
+
1027
+ def _shader_value(shader: Any, names: Sequence[str]) -> Any:
1028
+ """Returns the first authored value among the named shader inputs."""
1029
+ for name in names:
1030
+ shader_input = shader.GetInput(name)
1031
+ if shader_input:
1032
+ value = shader_input.Get()
1033
+ if value is not None:
1034
+ return value
1035
+ return None
1036
+
1037
+
1038
+ def _surface_shader(prim: Any) -> Any:
1039
+ """Finds the surface shader of a prim's bound material.
1040
+
1041
+ Args:
1042
+ prim: A geometry prim, possibly an instance proxy.
1043
+
1044
+ Returns:
1045
+ A ``UsdShade.Shader``, or ``None`` if there is no bound material or
1046
+ no surface shader (all-purpose, then ``mdl`` render context).
1047
+ """
1048
+ material = UsdShade.MaterialBindingAPI(prim).ComputeBoundMaterial()[0]
1049
+ if not material:
1050
+ return None
1051
+ for context in ("", "mdl"):
1052
+ shader = material.ComputeSurfaceSource(context)[0]
1053
+ if shader:
1054
+ return shader
1055
+ return None
1056
+
1057
+
1058
+ def _display_mean(values: Any, width: int) -> list[float] | None:
1059
+ """Averages a ``displayColor`` or ``displayOpacity`` primvar."""
1060
+ if values is None or len(values) == 0:
1061
+ return None
1062
+ array = np.asarray(values, dtype=np.float64).reshape(-1, width)
1063
+ return array.mean(axis=0).tolist()
1064
+
1065
+
1066
+ def _read_material(prim: Any) -> core.Material:
1067
+ """Collapses a prim's appearance into one constant PBR material.
1068
+
1069
+ Order of precedence, applied per property: the bound material's surface
1070
+ shader, then the prim's ``displayColor``/``displayOpacity`` primvars, then
1071
+ the ``core.Material`` defaults. ``UsdPreviewSurface`` inputs are read
1072
+ directly. MDL shaders (OmniPBR, OmniSurface) are read best-effort through
1073
+ their constant inputs (``diffuse_color_constant`` and the like); this is
1074
+ lossy by design. Textures and texture-driven inputs are not exported.
1075
+
1076
+ Args:
1077
+ prim: A geometry prim, possibly an instance proxy.
1078
+
1079
+ Returns:
1080
+ The material.
1081
+ """
1082
+ default = core.Material()
1083
+ rgb: Sequence[float] | None = None
1084
+ opacity = metallic = roughness = None
1085
+ shader = _surface_shader(prim)
1086
+ if shader is not None:
1087
+ rgb = _shader_value(shader, _COLOR_INPUTS)
1088
+ opacity = _shader_value(shader, _OPACITY_INPUTS)
1089
+ metallic = _shader_value(shader, _METALLIC_INPUTS)
1090
+ roughness = _shader_value(shader, _ROUGHNESS_INPUTS)
1091
+ gprim = UsdGeom.Gprim(prim)
1092
+ if rgb is None:
1093
+ rgb = _display_mean(gprim.GetDisplayColorAttr().Get(), 3)
1094
+ if opacity is None:
1095
+ found = _display_mean(gprim.GetDisplayOpacityAttr().Get(), 1)
1096
+ opacity = found[0] if found else None
1097
+ if (
1098
+ rgb is None
1099
+ and opacity is None
1100
+ and metallic is None
1101
+ and roughness is None
1102
+ ):
1103
+ return default
1104
+ rgb = default.rgba[:3] if rgb is None else tuple(rgb)[:3]
1105
+ values = np.clip(
1106
+ [
1107
+ *rgb,
1108
+ 1.0 if opacity is None else opacity,
1109
+ default.metallic if metallic is None else metallic,
1110
+ default.roughness if roughness is None else roughness,
1111
+ ],
1112
+ 0.0,
1113
+ 1.0,
1114
+ )
1115
+ r, g, b, a, m, rough = (float(np.float32(v)) for v in values)
1116
+ return core.Material(rgba=(r, g, b, a), metallic=m, roughness=rough)
1117
+
1118
+
1119
+ def scene_from_stage(
1120
+ stage: Any, body_paths: Sequence[str], *, collision: bool = True
1121
+ ) -> core.Scene:
1122
+ """Builds a scene from bodies and geometry on a USD stage.
1123
+
1124
+ Bodies are the prims at ``body_paths``, in that order, all with parent
1125
+ ``-1``: the runtime supplies a world pose per body per frame, so the tree
1126
+ is not needed. Each body owns the geometry prims in its subtree, stopping
1127
+ at any nested prim that is itself in ``body_paths``. Instance proxies are
1128
+ traversed.
1129
+
1130
+ Output is in metres: ``metersPerUnit`` is applied to sizes, vertices,
1131
+ and offsets. A geom's pose is stored relative to its body prim (the USD
1132
+ world transform with scale removed, which is what PhysX reports as the
1133
+ link pose), so the stage's up axis cancels out and needs no rotation:
1134
+ the pose stream fixes the world frame, and Isaac Lab stages are Z-up. A
1135
+ Y-up stage logs a warning, because its world poses would have to be
1136
+ rotated by the caller (+90 degrees about x) to be Z-up.
1137
+
1138
+ Geometry mapping:
1139
+
1140
+ * ``Cube`` gives ``box``; ``Sphere`` gives ``sphere`` (``ellipsoid``
1141
+ under non-uniform scale); ``Capsule`` gives ``capsule`` (uniform scale
1142
+ only); ``Cylinder`` gives ``cylinder`` (uniform radial scale). ``axis``
1143
+ is honoured by rotating the shape onto local +z. Anything that a
1144
+ primitive cannot express (non-uniform capsule, ``Cone``, tapered
1145
+ ``Cylinder_1``, sheared transforms) is tessellated into a ``mesh``.
1146
+ * ``Mesh`` gives ``mesh`` with polygons fan-triangulated. Non-uniform
1147
+ scale goes into ``Geom.scale``. Per-face materials (``GeomSubset``),
1148
+ normals, and UVs are not exported.
1149
+ * ``Plane`` at least 100 m wide is an infinite ``plane``; smaller ones
1150
+ become a two-triangle mesh.
1151
+ * Other types (points, curves, point instancers) are skipped with one
1152
+ warning.
1153
+
1154
+ Role rule: a geom is ``"collision"`` if its computed purpose is
1155
+ ``guide`` or ``proxy``, or if it (or an ancestor up to the body) has
1156
+ ``UsdPhysics.CollisionAPI`` and its purpose is not ``render``. Everything
1157
+ else is ``"visual"``. Refinement: if that would leave a body with no
1158
+ visual geom, its default-purpose collision geoms are promoted to
1159
+ ``"visual"``, because in Isaac Lab spawned primitives are usually both
1160
+ the only shape and the collider. Invisible prims are skipped.
1161
+
1162
+ Material rule: see ``_read_material``. Bound ``UsdPreviewSurface`` values
1163
+ win, then MDL constants, then ``displayColor``/``displayOpacity``. No
1164
+ textures are exported.
1165
+
1166
+ Meshes and materials are deduplicated by content.
1167
+
1168
+ Args:
1169
+ stage: A ``Usd.Stage``.
1170
+ body_paths: Prim paths of the bodies, in pose order.
1171
+ collision: If false, keep only ``"visual"`` geoms.
1172
+
1173
+ Returns:
1174
+ The scene. Body names are the prim paths, and geom names are paths
1175
+ relative to their body.
1176
+
1177
+ Raises:
1178
+ ValueError: If ``body_paths`` is empty, has duplicates, or names a
1179
+ prim that does not exist.
1180
+ """
1181
+ paths = [str(p) for p in body_paths]
1182
+ if not paths:
1183
+ raise ValueError("body_paths must not be empty")
1184
+ if len(set(paths)) != len(paths):
1185
+ raise ValueError("body_paths must not contain duplicates")
1186
+ prims = [stage.GetPrimAtPath(p) for p in paths]
1187
+ missing = [p for p, prim in zip(paths, prims, strict=True) if not prim]
1188
+ if missing:
1189
+ raise ValueError(f"no such prims on the stage: {missing}")
1190
+ builder = _SceneBuilder(stage, set(paths), collision)
1191
+ for index, prim in enumerate(prims):
1192
+ builder.add_body(index, prim)
1193
+ return builder.finish(tuple(core.Body(name=p) for p in paths))
1194
+
1195
+
1196
+ class _SceneBuilder:
1197
+ """Walks body subtrees and accumulates geoms, meshes, and materials."""
1198
+
1199
+ def __init__(self, stage: Any, body_set: set[str], collision: bool) -> None:
1200
+ """Reads the stage units and prepares the transform cache.
1201
+
1202
+ Args:
1203
+ stage: A ``Usd.Stage``.
1204
+ body_set: Paths of all bodies; traversal stops at these.
1205
+ collision: Whether to keep collision geoms.
1206
+ """
1207
+ self._body_set = body_set
1208
+ self._collision = collision
1209
+ self._mpu = float(UsdGeom.GetStageMetersPerUnit(stage) or 1.0)
1210
+ self._unit = np.diag([self._mpu] * 3 + [1.0])
1211
+ self._unit_inv = np.diag([1.0 / self._mpu] * 3 + [1.0])
1212
+ if str(UsdGeom.GetStageUpAxis(stage)) == "Y":
1213
+ _LOG.warning(
1214
+ "stage is Y-up: geoms are exported relative to their bodies, "
1215
+ "so recorded world poses must already be Z-up (Isaac Lab "
1216
+ "stages always are)"
1217
+ )
1218
+ self._xforms = UsdGeom.XformCache(Usd.TimeCode.Default())
1219
+ self._geoms: list[core.Geom] = []
1220
+ self._materials: dict[core.Material, int] = {}
1221
+ self._meshes: list[core.Mesh] = []
1222
+ self._mesh_index: dict[bytes, int] = {}
1223
+ self._skipped: collections.Counter[str] = collections.Counter()
1224
+
1225
+ def finish(self, bodies: tuple[core.Body, ...]) -> core.Scene:
1226
+ """Logs skipped prims and assembles the scene.
1227
+
1228
+ Args:
1229
+ bodies: The scene bodies.
1230
+
1231
+ Returns:
1232
+ The finished scene.
1233
+ """
1234
+ if self._skipped:
1235
+ summary = ", ".join(
1236
+ f"{n} {name}" for name, n in sorted(self._skipped.items())
1237
+ )
1238
+ _LOG.warning("simscope.isaaclab skipped geometry: %s", summary)
1239
+ return core.Scene(
1240
+ bodies=bodies,
1241
+ geoms=tuple(self._geoms),
1242
+ materials=tuple(self._materials) or (core.Material(),),
1243
+ meshes=tuple(self._meshes),
1244
+ )
1245
+
1246
+ def add_body(self, index: int, body: Any) -> None:
1247
+ """Adds the geoms of one body.
1248
+
1249
+ Args:
1250
+ index: Index of the body in the scene.
1251
+ body: The body prim.
1252
+ """
1253
+ body_path = str(body.GetPath())
1254
+ body_metres = self._to_metres(_rigid_part(self._world(body)))
1255
+ to_body = np.linalg.inv(body_metres)
1256
+ candidates = self._candidates(body, body_path)
1257
+ if not any(role == "visual" for _, role, _ in candidates):
1258
+ candidates = [
1259
+ (prim, "visual" if promotable else role, promotable)
1260
+ for prim, role, promotable in candidates
1261
+ ]
1262
+ for prim, role, _ in candidates:
1263
+ if role == "collision" and not self._collision:
1264
+ continue
1265
+ local = self._to_metres(self._world(prim)) @ to_body
1266
+ geom = self._make_geom(index, prim, role, body_path, local)
1267
+ if geom is not None:
1268
+ self._geoms.append(geom)
1269
+
1270
+ def _world(self, prim: Any) -> npt.NDArray[np.float64]:
1271
+ """Returns the local-to-world matrix of a prim in stage units."""
1272
+ return _matrix(self._xforms.GetLocalToWorldTransform(prim))
1273
+
1274
+ def _to_metres(self, m: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
1275
+ """Re-expresses a stage-unit transform for geometry measured in metres.
1276
+
1277
+ Local geometry is scaled by ``mpu`` first, so the transform is
1278
+ conjugated by the unit change; only translations pick up ``mpu``.
1279
+ """
1280
+ return self._unit_inv @ m @ self._unit
1281
+
1282
+ def _candidates(
1283
+ self, body: Any, body_path: str
1284
+ ) -> list[tuple[Any, core.GeomRole, bool]]:
1285
+ """Lists the visible geometry prims of a body with their roles.
1286
+
1287
+ Args:
1288
+ body: The body prim.
1289
+ body_path: Its path.
1290
+
1291
+ Returns:
1292
+ ``(prim, role, promotable)`` tuples, where ``promotable`` marks
1293
+ default-purpose collision geoms that may become visual.
1294
+ """
1295
+ found: list[tuple[Any, core.GeomRole, bool]] = []
1296
+ iterator = iter(Usd.PrimRange(body, Usd.TraverseInstanceProxies()))
1297
+ for prim in iterator:
1298
+ path = str(prim.GetPath())
1299
+ if path != body_path and path in self._body_set:
1300
+ iterator.PruneChildren()
1301
+ continue
1302
+ if prim.IsA(UsdShade.Material):
1303
+ iterator.PruneChildren()
1304
+ continue
1305
+ if not prim.IsA(UsdGeom.Gprim):
1306
+ continue
1307
+ imageable = UsdGeom.Imageable(prim)
1308
+ if imageable.ComputeVisibility() == UsdGeom.Tokens.invisible:
1309
+ continue
1310
+ purpose = imageable.ComputePurpose()
1311
+ has_collider = self._has_collider(prim, body_path)
1312
+ if purpose in (UsdGeom.Tokens.guide, UsdGeom.Tokens.proxy):
1313
+ found.append((prim, "collision", False))
1314
+ elif has_collider and purpose != UsdGeom.Tokens.render:
1315
+ found.append((prim, "collision", True))
1316
+ else:
1317
+ found.append((prim, "visual", False))
1318
+ return found
1319
+
1320
+ @staticmethod
1321
+ def _has_collider(prim: Any, body_path: str) -> bool:
1322
+ """Tells whether a prim or an ancestor up to the body collides."""
1323
+ while prim:
1324
+ if prim.HasAPI(UsdPhysics.CollisionAPI):
1325
+ return True
1326
+ if str(prim.GetPath()) == body_path:
1327
+ break
1328
+ prim = prim.GetParent()
1329
+ return False
1330
+
1331
+ def _make_geom(
1332
+ self,
1333
+ body: int,
1334
+ prim: Any,
1335
+ role: core.GeomRole,
1336
+ body_path: str,
1337
+ local: npt.NDArray[np.float64],
1338
+ ) -> core.Geom | None:
1339
+ """Converts one geometry prim into a geom.
1340
+
1341
+ Args:
1342
+ body: Index of the owning body.
1343
+ prim: The geometry prim.
1344
+ role: The geom role.
1345
+ body_path: Path of the owning body prim.
1346
+ local: Geom-to-body transform in metres, row-vector convention.
1347
+
1348
+ Returns:
1349
+ The geom, or ``None`` if it was skipped.
1350
+ """
1351
+ shape = _read_shape(prim)
1352
+ linear, pos = local[:3, :3], local[3, :3]
1353
+ scale = np.linalg.norm(linear, axis=1)
1354
+ if shape is None or scale.min() <= 1e-12 * scale.max():
1355
+ self._skipped[str(prim.GetTypeName())] += 1
1356
+ return None
1357
+ rotation = linear / scale[:, None]
1358
+ sheared = bool(
1359
+ np.abs(rotation @ rotation.T - np.eye(3)).max() > _SHEAR_TOL
1360
+ )
1361
+ if not sheared and np.linalg.det(rotation) < 0:
1362
+ rotation[0] *= -1 # a mirror: keep it as a negative scale
1363
+ scale[0] *= -1
1364
+ path = str(prim.GetPath())
1365
+ name = path[len(body_path) + 1 :] or str(prim.GetName())
1366
+ material = self._material(_read_material(prim))
1367
+ spec = None if sheared else self._primitive(shape, np.abs(scale))
1368
+ if spec is not None:
1369
+ kind, size, axis_rot = spec
1370
+ return core.Geom(
1371
+ body=body,
1372
+ kind=kind,
1373
+ size=size,
1374
+ pos=_vec3(pos),
1375
+ quat=_rotation_to_quat(axis_rot @ rotation),
1376
+ material=material,
1377
+ role=role,
1378
+ name=name,
1379
+ )
1380
+ points, faces = _tessellate(shape)
1381
+ points = points * self._mpu
1382
+ if sheared:
1383
+ points = points @ linear
1384
+ rotation, scale = np.eye(3), np.ones(3)
1385
+ mesh = self._mesh(points, faces)
1386
+ return core.Geom(
1387
+ body=body,
1388
+ kind="mesh",
1389
+ pos=_vec3(pos),
1390
+ quat=_rotation_to_quat(rotation),
1391
+ scale=_vec3(scale),
1392
+ material=material,
1393
+ mesh=mesh,
1394
+ role=role,
1395
+ name=name,
1396
+ )
1397
+
1398
+ def _primitive(
1399
+ self, shape: _Shape, scale: npt.NDArray[np.float64]
1400
+ ) -> tuple[core.GeomKind, core.Vec3, npt.NDArray[np.float64]] | None:
1401
+ """Maps a shape to a simscope primitive under a local scale.
1402
+
1403
+ Args:
1404
+ shape: The USD shape.
1405
+ scale: Absolute per-axis scale in the prim's local axes.
1406
+
1407
+ Returns:
1408
+ ``(kind, size, axis_rotation)`` where ``axis_rotation`` takes the
1409
+ shape frame (extruded along +z) to the prim's local axes, or
1410
+ ``None`` if the shape needs a mesh under this scale.
1411
+ """
1412
+ m = self._mpu
1413
+ kind, dims, axis = shape.kind, shape.dims, shape.axis
1414
+ if kind == "box":
1415
+ half = m * scale * np.asarray(dims)
1416
+ return "box", _vec3(half), np.eye(3)
1417
+ if kind == "sphere":
1418
+ if _is_uniform(scale):
1419
+ radius = m * dims[0] * scale[0]
1420
+ return "sphere", _vec3((radius, 0.0, 0.0)), np.eye(3)
1421
+ return "ellipsoid", _vec3(m * dims[0] * scale), np.eye(3)
1422
+ if kind == "plane":
1423
+ extent = m * max(dims) * scale[[i for i in range(3) if i != axis]]
1424
+ if extent.max() >= _INFINITE_PLANE_METRES:
1425
+ return "plane", (0.0, 0.0, 0.0), _PLANE_ROT[axis]
1426
+ return None
1427
+ radial = scale[[i for i in range(3) if i != axis]]
1428
+ if kind == "capsule" and _is_uniform(scale):
1429
+ size = (m * dims[0] * scale[0], m * dims[1] / 2 * scale[0], 0.0)
1430
+ return "capsule", _vec3(size), _AXIS_ROT[axis]
1431
+ if kind == "cylinder" and _is_uniform(radial):
1432
+ size = (m * dims[0] * radial[0], m * dims[1] / 2 * scale[axis], 0.0)
1433
+ return "cylinder", _vec3(size), _AXIS_ROT[axis]
1434
+ return None
1435
+
1436
+ def _material(self, material: core.Material) -> int:
1437
+ """Returns the deduplicated index of a material."""
1438
+ return self._materials.setdefault(material, len(self._materials))
1439
+
1440
+ def _mesh(
1441
+ self, points: npt.NDArray[np.float64], faces: npt.NDArray[np.int64]
1442
+ ) -> int:
1443
+ """Adds a mesh once and returns its deduplicated index.
1444
+
1445
+ Args:
1446
+ points: Vertices in metres, ``[V, 3]``.
1447
+ faces: Triangles, ``[T, 3]``.
1448
+
1449
+ Returns:
1450
+ The index into ``Scene.meshes``.
1451
+ """
1452
+ vertices = np.ascontiguousarray(points, dtype=np.float32)
1453
+ triangles = np.ascontiguousarray(faces, dtype=np.uint32)
1454
+ digest = hashlib.blake2b(vertices.tobytes(), digest_size=16)
1455
+ digest.update(triangles.tobytes())
1456
+ key = digest.digest()
1457
+ if key not in self._mesh_index:
1458
+ self._mesh_index[key] = len(self._meshes)
1459
+ self._meshes.append(core.Mesh(vertices, triangles))
1460
+ return self._mesh_index[key]