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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- 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]
|