simrig 0.2.2__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.
- simrig/__init__.py +82 -0
- simrig/_version.py +3 -0
- simrig/browser_render.py +145 -0
- simrig/browser_shell.py +130 -0
- simrig/cli.py +412 -0
- simrig/core.py +144 -0
- simrig/custom_env.py +109 -0
- simrig/huggingface.py +78 -0
- simrig/io.py +49 -0
- simrig/live_view.py +553 -0
- simrig/model_view.py +944 -0
- simrig/mujoco_backend.py +197 -0
- simrig/paths.py +54 -0
- simrig/playground_backend.py +603 -0
- simrig/presets.py +93 -0
- simrig/preview.py +956 -0
- simrig/rendering.py +127 -0
- simrig/scaffold.py +127 -0
- simrig/three_scene.py +107 -0
- simrig/validate_env.py +211 -0
- simrig-0.2.2.dist-info/METADATA +238 -0
- simrig-0.2.2.dist-info/RECORD +26 -0
- simrig-0.2.2.dist-info/WHEEL +5 -0
- simrig-0.2.2.dist-info/entry_points.txt +2 -0
- simrig-0.2.2.dist-info/licenses/LICENSE +21 -0
- simrig-0.2.2.dist-info/top_level.txt +1 -0
simrig/model_view.py
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
"""Browser viewer for MuJoCo models with per-joint controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import gzip
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from simrig.browser_render import MujocoFramePump
|
|
18
|
+
from simrig.browser_shell import camera_interaction_script, frame_poll_script, viewer_styles
|
|
19
|
+
from simrig.mujoco_backend import _import_mujoco, resolve_model_path
|
|
20
|
+
from simrig.rendering import make_tracking_camera, tracking_body_id
|
|
21
|
+
from simrig.three_scene import geom_transforms, scene_payload
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class JointControl:
|
|
26
|
+
joint_id: int
|
|
27
|
+
joint_name: str
|
|
28
|
+
joint_type: str
|
|
29
|
+
component: int
|
|
30
|
+
label: str
|
|
31
|
+
value: float
|
|
32
|
+
min: float
|
|
33
|
+
max: float
|
|
34
|
+
limited: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def joint_control_specs(mujoco: Any, model: Any, data: Any) -> list[JointControl]:
|
|
38
|
+
"""Build browser controls for every joint, using joint names as labels."""
|
|
39
|
+
|
|
40
|
+
specs: list[JointControl] = []
|
|
41
|
+
for joint_id in range(model.njnt):
|
|
42
|
+
joint_name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, joint_id) or f"joint_{joint_id}"
|
|
43
|
+
joint_type = int(model.jnt_type[joint_id])
|
|
44
|
+
qpos_adr = int(model.jnt_qposadr[joint_id])
|
|
45
|
+
limited = bool(model.jnt_limited[joint_id])
|
|
46
|
+
joint_range = model.jnt_range[joint_id]
|
|
47
|
+
|
|
48
|
+
if joint_type == mujoco.mjtJoint.mjJNT_HINGE:
|
|
49
|
+
specs.append(
|
|
50
|
+
_scalar_control(
|
|
51
|
+
mujoco,
|
|
52
|
+
model,
|
|
53
|
+
data,
|
|
54
|
+
joint_id=joint_id,
|
|
55
|
+
joint_name=joint_name,
|
|
56
|
+
joint_type="hinge",
|
|
57
|
+
component=0,
|
|
58
|
+
qpos_index=qpos_adr,
|
|
59
|
+
label=joint_name,
|
|
60
|
+
limited=limited,
|
|
61
|
+
joint_range=joint_range,
|
|
62
|
+
default_min=-3.14159,
|
|
63
|
+
default_max=3.14159,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
elif joint_type == mujoco.mjtJoint.mjJNT_SLIDE:
|
|
67
|
+
specs.append(
|
|
68
|
+
_scalar_control(
|
|
69
|
+
mujoco,
|
|
70
|
+
model,
|
|
71
|
+
data,
|
|
72
|
+
joint_id=joint_id,
|
|
73
|
+
joint_name=joint_name,
|
|
74
|
+
joint_type="slide",
|
|
75
|
+
component=0,
|
|
76
|
+
qpos_index=qpos_adr,
|
|
77
|
+
label=joint_name,
|
|
78
|
+
limited=limited,
|
|
79
|
+
joint_range=joint_range,
|
|
80
|
+
default_min=-1.0,
|
|
81
|
+
default_max=1.0,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
elif joint_type == mujoco.mjtJoint.mjJNT_FREE:
|
|
85
|
+
axis_labels = ("x", "y", "z", "qw", "qx", "qy", "qz")
|
|
86
|
+
defaults = (
|
|
87
|
+
(-2.0, 2.0),
|
|
88
|
+
(-2.0, 2.0),
|
|
89
|
+
(-0.5, 2.0),
|
|
90
|
+
(-1.0, 1.0),
|
|
91
|
+
(-1.0, 1.0),
|
|
92
|
+
(-1.0, 1.0),
|
|
93
|
+
(-1.0, 1.0),
|
|
94
|
+
)
|
|
95
|
+
for component, axis in enumerate(axis_labels):
|
|
96
|
+
min_value, max_value = defaults[component]
|
|
97
|
+
specs.append(
|
|
98
|
+
JointControl(
|
|
99
|
+
joint_id=joint_id,
|
|
100
|
+
joint_name=joint_name,
|
|
101
|
+
joint_type="free",
|
|
102
|
+
component=component,
|
|
103
|
+
label=f"{joint_name}_{axis}",
|
|
104
|
+
value=float(data.qpos[qpos_adr + component]),
|
|
105
|
+
min=min_value,
|
|
106
|
+
max=max_value,
|
|
107
|
+
limited=False,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
elif joint_type == mujoco.mjtJoint.mjJNT_BALL:
|
|
111
|
+
axis_labels = ("qw", "qx", "qy", "qz")
|
|
112
|
+
for component, axis in enumerate(axis_labels):
|
|
113
|
+
specs.append(
|
|
114
|
+
JointControl(
|
|
115
|
+
joint_id=joint_id,
|
|
116
|
+
joint_name=joint_name,
|
|
117
|
+
joint_type="ball",
|
|
118
|
+
component=component,
|
|
119
|
+
label=f"{joint_name}_{axis}",
|
|
120
|
+
value=float(data.qpos[qpos_adr + component]),
|
|
121
|
+
min=-1.0,
|
|
122
|
+
max=1.0,
|
|
123
|
+
limited=False,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
return specs
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _scalar_control(
|
|
130
|
+
mujoco: Any,
|
|
131
|
+
model: Any,
|
|
132
|
+
data: Any,
|
|
133
|
+
*,
|
|
134
|
+
joint_id: int,
|
|
135
|
+
joint_name: str,
|
|
136
|
+
joint_type: str,
|
|
137
|
+
component: int,
|
|
138
|
+
qpos_index: int,
|
|
139
|
+
label: str,
|
|
140
|
+
limited: bool,
|
|
141
|
+
joint_range: Any,
|
|
142
|
+
default_min: float,
|
|
143
|
+
default_max: float,
|
|
144
|
+
) -> JointControl:
|
|
145
|
+
del model, mujoco
|
|
146
|
+
min_value = float(joint_range[0]) if limited else default_min
|
|
147
|
+
max_value = float(joint_range[1]) if limited else default_max
|
|
148
|
+
return JointControl(
|
|
149
|
+
joint_id=joint_id,
|
|
150
|
+
joint_name=joint_name,
|
|
151
|
+
joint_type=joint_type,
|
|
152
|
+
component=component,
|
|
153
|
+
label=label,
|
|
154
|
+
value=float(data.qpos[qpos_index]),
|
|
155
|
+
min=min_value,
|
|
156
|
+
max=max_value,
|
|
157
|
+
limited=limited,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ModelViewSession:
|
|
162
|
+
"""Owns a MuJoCo model, joint state, and browser renderer."""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
model_or_xml: str | Path,
|
|
167
|
+
*,
|
|
168
|
+
menagerie: Path | str | None = None,
|
|
169
|
+
width: int = 960,
|
|
170
|
+
height: int = 540,
|
|
171
|
+
render_mode: str = "threejs",
|
|
172
|
+
camera: str | int | None = None,
|
|
173
|
+
fps: int = 24,
|
|
174
|
+
) -> None:
|
|
175
|
+
self.model_path = resolve_model_path(model_or_xml, menagerie)
|
|
176
|
+
self.mujoco = _import_mujoco()
|
|
177
|
+
try:
|
|
178
|
+
from PIL import Image # type: ignore
|
|
179
|
+
from PIL import ImageDraw # type: ignore
|
|
180
|
+
except ImportError as exc:
|
|
181
|
+
raise RuntimeError("Browser model view requires Pillow.") from exc
|
|
182
|
+
|
|
183
|
+
self.Image = Image
|
|
184
|
+
self.ImageDraw = ImageDraw
|
|
185
|
+
self.model = self.mujoco.MjModel.from_xml_path(str(self.model_path))
|
|
186
|
+
self.data = self.mujoco.MjData(self.model)
|
|
187
|
+
self.initial_keyframe: str | None = None
|
|
188
|
+
if self.model.nkey:
|
|
189
|
+
self.mujoco.mj_resetDataKeyframe(self.model, self.data, 0)
|
|
190
|
+
self.initial_keyframe = self.mujoco.mj_id2name(
|
|
191
|
+
self.model,
|
|
192
|
+
self.mujoco.mjtObj.mjOBJ_KEY,
|
|
193
|
+
0,
|
|
194
|
+
) or "keyframe_0"
|
|
195
|
+
self.mujoco.mj_forward(self.model, self.data)
|
|
196
|
+
self._initial_qpos = np.array(self.data.qpos, copy=True)
|
|
197
|
+
|
|
198
|
+
self.width = width
|
|
199
|
+
self.height = height
|
|
200
|
+
self.render_mode = render_mode.lower()
|
|
201
|
+
self.renderer_error: str | None = None
|
|
202
|
+
self._lock = threading.Lock()
|
|
203
|
+
self._last_frame_jpeg: bytes | None = None
|
|
204
|
+
self._scene_payload: dict[str, Any] | None = None
|
|
205
|
+
|
|
206
|
+
self.renderer = None
|
|
207
|
+
if self.render_mode not in ("threejs", "topdown", "mujoco"):
|
|
208
|
+
raise ValueError("render_mode must be 'threejs', 'mujoco', or 'topdown'.")
|
|
209
|
+
self.camera, self.camera_state = make_tracking_camera(
|
|
210
|
+
self.mujoco,
|
|
211
|
+
self.model,
|
|
212
|
+
self.data,
|
|
213
|
+
camera,
|
|
214
|
+
)
|
|
215
|
+
self._frame_pump: MujocoFramePump | None = None
|
|
216
|
+
if self.render_mode != "threejs":
|
|
217
|
+
self._frame_pump = MujocoFramePump(
|
|
218
|
+
self.mujoco,
|
|
219
|
+
self.model,
|
|
220
|
+
self.data,
|
|
221
|
+
width=width,
|
|
222
|
+
height=height,
|
|
223
|
+
camera=self.camera,
|
|
224
|
+
camera_state=self.camera_state,
|
|
225
|
+
image_module=self.Image,
|
|
226
|
+
fps=fps,
|
|
227
|
+
render_mode=self.render_mode,
|
|
228
|
+
scene_lock=self._lock,
|
|
229
|
+
fallback_frame=self._fallback_frame,
|
|
230
|
+
error_frame=self._error_frame,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def reset(self) -> None:
|
|
234
|
+
with self._lock:
|
|
235
|
+
self.data.qpos[:] = self._initial_qpos
|
|
236
|
+
self.mujoco.mj_forward(self.model, self.data)
|
|
237
|
+
|
|
238
|
+
def set_joint_value(self, joint_id: int, component: int, value: float) -> None:
|
|
239
|
+
with self._lock:
|
|
240
|
+
if joint_id < 0 or joint_id >= self.model.njnt:
|
|
241
|
+
raise ValueError(f"Invalid joint id: {joint_id}")
|
|
242
|
+
qpos_adr = int(self.model.jnt_qposadr[joint_id])
|
|
243
|
+
joint_type = int(self.model.jnt_type[joint_id])
|
|
244
|
+
if joint_type in (
|
|
245
|
+
self.mujoco.mjtJoint.mjJNT_HINGE,
|
|
246
|
+
self.mujoco.mjtJoint.mjJNT_SLIDE,
|
|
247
|
+
):
|
|
248
|
+
if component != 0:
|
|
249
|
+
raise ValueError(f"Joint {joint_id} only has one position coordinate.")
|
|
250
|
+
if bool(self.model.jnt_limited[joint_id]):
|
|
251
|
+
low, high = self.model.jnt_range[joint_id]
|
|
252
|
+
value = float(np.clip(value, low, high))
|
|
253
|
+
self.data.qpos[qpos_adr] = value
|
|
254
|
+
elif joint_type == self.mujoco.mjtJoint.mjJNT_FREE:
|
|
255
|
+
if component < 0 or component > 6:
|
|
256
|
+
raise ValueError(f"Invalid free-joint component: {component}")
|
|
257
|
+
self.data.qpos[qpos_adr + component] = value
|
|
258
|
+
quat = self.data.qpos[qpos_adr + 3 : qpos_adr + 7]
|
|
259
|
+
norm = float(np.linalg.norm(quat))
|
|
260
|
+
if norm > 1e-8:
|
|
261
|
+
self.data.qpos[qpos_adr + 3 : qpos_adr + 7] = quat / norm
|
|
262
|
+
elif joint_type == self.mujoco.mjtJoint.mjJNT_BALL:
|
|
263
|
+
if component < 0 or component > 3:
|
|
264
|
+
raise ValueError(f"Invalid ball-joint component: {component}")
|
|
265
|
+
self.data.qpos[qpos_adr + component] = value
|
|
266
|
+
quat = self.data.qpos[qpos_adr : qpos_adr + 4]
|
|
267
|
+
norm = float(np.linalg.norm(quat))
|
|
268
|
+
if norm > 1e-8:
|
|
269
|
+
self.data.qpos[qpos_adr : qpos_adr + 4] = quat / norm
|
|
270
|
+
else:
|
|
271
|
+
raise ValueError(f"Unsupported joint type for joint {joint_id}.")
|
|
272
|
+
self.mujoco.mj_forward(self.model, self.data)
|
|
273
|
+
|
|
274
|
+
def set_camera_from_query(self, query: dict[str, list[str]]) -> None:
|
|
275
|
+
if self._frame_pump is not None:
|
|
276
|
+
self._frame_pump.set_camera_from_query(query)
|
|
277
|
+
|
|
278
|
+
def joints_payload(self) -> dict[str, Any]:
|
|
279
|
+
acquired = self._lock.acquire(blocking=False)
|
|
280
|
+
try:
|
|
281
|
+
controls = joint_control_specs(self.mujoco, self.model, self.data)
|
|
282
|
+
pump_stats = (
|
|
283
|
+
self._frame_pump.stats()
|
|
284
|
+
if self._frame_pump is not None
|
|
285
|
+
else {
|
|
286
|
+
"renderer_error": None,
|
|
287
|
+
"fps_target": 60,
|
|
288
|
+
"camera": {
|
|
289
|
+
"interactive": True,
|
|
290
|
+
"renderer": "threejs-orbit-controls",
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
return {
|
|
295
|
+
"model_name": self.model_path.parent.name or self.model_path.stem,
|
|
296
|
+
"model_path": str(self.model_path),
|
|
297
|
+
"joint_count": self.model.njnt,
|
|
298
|
+
"control_count": len(controls),
|
|
299
|
+
"render_mode": self.render_mode,
|
|
300
|
+
"initial_keyframe": self.initial_keyframe,
|
|
301
|
+
"renderer_error": pump_stats["renderer_error"],
|
|
302
|
+
"fps_target": pump_stats["fps_target"],
|
|
303
|
+
"camera": pump_stats["camera"],
|
|
304
|
+
"transforms": self.geom_transforms(),
|
|
305
|
+
"controls": [
|
|
306
|
+
{
|
|
307
|
+
"joint_id": control.joint_id,
|
|
308
|
+
"joint_name": control.joint_name,
|
|
309
|
+
"joint_type": control.joint_type,
|
|
310
|
+
"component": control.component,
|
|
311
|
+
"label": control.label,
|
|
312
|
+
"value": control.value,
|
|
313
|
+
"min": control.min,
|
|
314
|
+
"max": control.max,
|
|
315
|
+
"limited": control.limited,
|
|
316
|
+
}
|
|
317
|
+
for control in controls
|
|
318
|
+
],
|
|
319
|
+
}
|
|
320
|
+
finally:
|
|
321
|
+
if acquired:
|
|
322
|
+
self._lock.release()
|
|
323
|
+
|
|
324
|
+
def scene_payload(self) -> dict[str, Any]:
|
|
325
|
+
"""Return static render geometry plus the current world transforms."""
|
|
326
|
+
|
|
327
|
+
if self._scene_payload is None:
|
|
328
|
+
self._scene_payload = scene_payload(
|
|
329
|
+
self.mujoco,
|
|
330
|
+
self.model,
|
|
331
|
+
self.data,
|
|
332
|
+
model_name=self.model_path.parent.name or self.model_path.stem,
|
|
333
|
+
)
|
|
334
|
+
self._scene_payload.pop("transforms", None)
|
|
335
|
+
return {
|
|
336
|
+
**self._scene_payload,
|
|
337
|
+
"transforms": self.geom_transforms(),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
def geom_transforms(self) -> list[dict[str, Any]]:
|
|
341
|
+
return geom_transforms(self.model, self.data)
|
|
342
|
+
|
|
343
|
+
def frame_jpeg(self) -> bytes:
|
|
344
|
+
if self._frame_pump is None:
|
|
345
|
+
raise RuntimeError("Frame streaming is disabled in threejs mode.")
|
|
346
|
+
return self._frame_pump.get_jpeg()
|
|
347
|
+
|
|
348
|
+
def close(self) -> None:
|
|
349
|
+
if self._frame_pump is not None:
|
|
350
|
+
self._frame_pump.close()
|
|
351
|
+
|
|
352
|
+
def _tracking_body_id(self) -> int:
|
|
353
|
+
return tracking_body_id(self.mujoco, self.model, self.data)
|
|
354
|
+
|
|
355
|
+
def _fallback_frame(self) -> np.ndarray:
|
|
356
|
+
"""Explicit schematic mode only; not used for default MuJoCo rendering."""
|
|
357
|
+
image = self.Image.new("RGB", (self.width, self.height), (8, 10, 12))
|
|
358
|
+
draw = self.ImageDraw.Draw(image)
|
|
359
|
+
model = self.model
|
|
360
|
+
center = np.asarray(self.data.xpos[self._tracking_body_id()])[:2]
|
|
361
|
+
scale = min(self.width, self.height) / 5.0
|
|
362
|
+
|
|
363
|
+
def project(pos: np.ndarray) -> tuple[int, int]:
|
|
364
|
+
xy = (np.asarray(pos)[:2] - center) * scale
|
|
365
|
+
return int(self.width / 2 + xy[0]), int(self.height / 2 - xy[1])
|
|
366
|
+
|
|
367
|
+
grid_color = (28, 34, 38)
|
|
368
|
+
for offset in np.linspace(-2.0, 2.0, 9):
|
|
369
|
+
x1, y1 = project(center + np.array([offset, -2.0]))
|
|
370
|
+
x2, y2 = project(center + np.array([offset, 2.0]))
|
|
371
|
+
draw.line((x1, y1, x2, y2), fill=grid_color)
|
|
372
|
+
x1, y1 = project(center + np.array([-2.0, offset]))
|
|
373
|
+
x2, y2 = project(center + np.array([2.0, offset]))
|
|
374
|
+
draw.line((x1, y1, x2, y2), fill=grid_color)
|
|
375
|
+
|
|
376
|
+
for body_id in range(1, model.nbody):
|
|
377
|
+
parent = int(model.body_parentid[body_id])
|
|
378
|
+
if parent <= 0:
|
|
379
|
+
continue
|
|
380
|
+
x1, y1 = project(self.data.xpos[parent])
|
|
381
|
+
x2, y2 = project(self.data.xpos[body_id])
|
|
382
|
+
draw.line((x1, y1, x2, y2), fill=(96, 150, 255), width=3)
|
|
383
|
+
|
|
384
|
+
for body_id in range(1, model.nbody):
|
|
385
|
+
x, y = project(self.data.xpos[body_id])
|
|
386
|
+
radius = 7 if body_id == self._tracking_body_id() else 4
|
|
387
|
+
fill = (255, 210, 94) if body_id == self._tracking_body_id() else (205, 224, 255)
|
|
388
|
+
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=fill)
|
|
389
|
+
|
|
390
|
+
overlay = [
|
|
391
|
+
"SimRig Model View",
|
|
392
|
+
f"mode: {self.render_mode}",
|
|
393
|
+
f"model: {self.model_path.name}",
|
|
394
|
+
f"joints: {self.model.njnt}",
|
|
395
|
+
]
|
|
396
|
+
if self.renderer_error:
|
|
397
|
+
overlay.append(f"render error: {self.renderer_error[:80]}")
|
|
398
|
+
draw.rectangle((16, 16, min(self.width - 16, 520), 140), fill=(0, 0, 0))
|
|
399
|
+
y = 28
|
|
400
|
+
for line in overlay:
|
|
401
|
+
draw.text((28, y), line, fill=(236, 240, 245))
|
|
402
|
+
y += 22
|
|
403
|
+
return np.asarray(image)
|
|
404
|
+
|
|
405
|
+
def _error_frame(self, message: str) -> np.ndarray:
|
|
406
|
+
image = self.Image.new("RGB", (self.width, self.height), (8, 10, 12))
|
|
407
|
+
draw = self.ImageDraw.Draw(image)
|
|
408
|
+
lines = [
|
|
409
|
+
"SimRig Model View",
|
|
410
|
+
"MuJoCo rendering failed",
|
|
411
|
+
message[:240],
|
|
412
|
+
"Try: export MUJOCO_GL=glfw",
|
|
413
|
+
]
|
|
414
|
+
y = 28
|
|
415
|
+
for line in lines:
|
|
416
|
+
draw.text((28, y), line, fill=(236, 240, 245))
|
|
417
|
+
y += 24
|
|
418
|
+
return np.asarray(image)
|
|
419
|
+
|
|
420
|
+
def _plain_frame(self, message: str) -> np.ndarray:
|
|
421
|
+
image = self.Image.new("RGB", (self.width, self.height), (8, 10, 12))
|
|
422
|
+
draw = self.ImageDraw.Draw(image)
|
|
423
|
+
draw.text((28, 28), "SimRig Model View", fill=(236, 240, 245))
|
|
424
|
+
draw.text((28, 56), message, fill=(180, 190, 200))
|
|
425
|
+
return np.asarray(image)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def serve_model_view(
|
|
429
|
+
model_or_xml: str | Path,
|
|
430
|
+
*,
|
|
431
|
+
menagerie: Path | str | None = None,
|
|
432
|
+
host: str = "127.0.0.1",
|
|
433
|
+
port: int = 8766,
|
|
434
|
+
width: int = 960,
|
|
435
|
+
height: int = 540,
|
|
436
|
+
render_mode: str = "threejs",
|
|
437
|
+
camera: str | int | None = None,
|
|
438
|
+
fps: int = 24,
|
|
439
|
+
) -> None:
|
|
440
|
+
session = ModelViewSession(
|
|
441
|
+
model_or_xml,
|
|
442
|
+
menagerie=menagerie,
|
|
443
|
+
width=width,
|
|
444
|
+
height=height,
|
|
445
|
+
render_mode=render_mode,
|
|
446
|
+
camera=camera,
|
|
447
|
+
fps=fps,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
class Handler(BaseHTTPRequestHandler):
|
|
451
|
+
def do_GET(self) -> None: # noqa: N802
|
|
452
|
+
parsed = urlparse(self.path)
|
|
453
|
+
if parsed.path == "/":
|
|
454
|
+
self._send_html(_html(session.render_mode))
|
|
455
|
+
elif parsed.path == "/frame.jpg":
|
|
456
|
+
if session.render_mode == "threejs":
|
|
457
|
+
self.send_error(HTTPStatus.NOT_FOUND, "Frame streaming disabled")
|
|
458
|
+
else:
|
|
459
|
+
self._send_bytes(session.frame_jpeg(), "image/jpeg")
|
|
460
|
+
elif parsed.path == "/scene.json":
|
|
461
|
+
self._send_json(session.scene_payload(), compress=True)
|
|
462
|
+
elif parsed.path == "/joints.json":
|
|
463
|
+
self._send_json(session.joints_payload())
|
|
464
|
+
elif parsed.path == "/camera":
|
|
465
|
+
query = parse_qs(parsed.query)
|
|
466
|
+
session.set_camera_from_query(query)
|
|
467
|
+
self._send_json(session.joints_payload())
|
|
468
|
+
elif parsed.path == "/set":
|
|
469
|
+
query = parse_qs(parsed.query)
|
|
470
|
+
joint_id = int(query.get("joint_id", query.get("joint", ["-1"]))[0])
|
|
471
|
+
component = int(query.get("component", ["0"])[0])
|
|
472
|
+
value = float(query.get("value", ["0"])[0])
|
|
473
|
+
session.set_joint_value(joint_id, component, value)
|
|
474
|
+
self._send_json(session.joints_payload())
|
|
475
|
+
elif parsed.path == "/reset":
|
|
476
|
+
session.reset()
|
|
477
|
+
self._send_json(session.joints_payload())
|
|
478
|
+
else:
|
|
479
|
+
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
|
|
480
|
+
|
|
481
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
def _send_html(self, body: str) -> None:
|
|
485
|
+
self._send_bytes(body.encode("utf-8"), "text/html; charset=utf-8")
|
|
486
|
+
|
|
487
|
+
def _send_json(self, value: dict[str, Any], *, compress: bool = False) -> None:
|
|
488
|
+
body = json.dumps(value, separators=(",", ":")).encode("utf-8")
|
|
489
|
+
if compress and "gzip" in self.headers.get("Accept-Encoding", ""):
|
|
490
|
+
body = gzip.compress(body, compresslevel=5)
|
|
491
|
+
self._send_bytes(
|
|
492
|
+
body,
|
|
493
|
+
"application/json; charset=utf-8",
|
|
494
|
+
content_encoding="gzip",
|
|
495
|
+
)
|
|
496
|
+
return
|
|
497
|
+
self._send_bytes(body, "application/json; charset=utf-8")
|
|
498
|
+
|
|
499
|
+
def _send_bytes(
|
|
500
|
+
self,
|
|
501
|
+
body: bytes,
|
|
502
|
+
content_type: str,
|
|
503
|
+
*,
|
|
504
|
+
content_encoding: str | None = None,
|
|
505
|
+
) -> None:
|
|
506
|
+
self.send_response(HTTPStatus.OK)
|
|
507
|
+
self.send_header("Content-Type", content_type)
|
|
508
|
+
self.send_header("Cache-Control", "no-store")
|
|
509
|
+
if content_encoding is not None:
|
|
510
|
+
self.send_header("Content-Encoding", content_encoding)
|
|
511
|
+
self.send_header("Content-Length", str(len(body)))
|
|
512
|
+
self.end_headers()
|
|
513
|
+
self.wfile.write(body)
|
|
514
|
+
|
|
515
|
+
server = ThreadingHTTPServer((host, port), Handler)
|
|
516
|
+
print(f"SimRig model view: http://{host}:{port}")
|
|
517
|
+
try:
|
|
518
|
+
server.serve_forever()
|
|
519
|
+
except KeyboardInterrupt:
|
|
520
|
+
pass
|
|
521
|
+
finally:
|
|
522
|
+
session.close()
|
|
523
|
+
server.server_close()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _html(render_mode: str = "threejs") -> str:
|
|
527
|
+
if render_mode == "threejs":
|
|
528
|
+
return _threejs_html()
|
|
529
|
+
return _frame_html()
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _threejs_html() -> str:
|
|
533
|
+
return (
|
|
534
|
+
"""<!doctype html>
|
|
535
|
+
<html>
|
|
536
|
+
<head>
|
|
537
|
+
<meta charset="utf-8">
|
|
538
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
539
|
+
<title>SimRig Model View</title>
|
|
540
|
+
<style>"""
|
|
541
|
+
+ viewer_styles(sidebar_width=360)
|
|
542
|
+
+ """
|
|
543
|
+
#three-view { width: 100%; height: 100%; display: block; outline: none; }
|
|
544
|
+
#loading { position: absolute; inset: 0; display: grid; place-items: center; color: #cbd5e1; background: #070b12; z-index: 2; }
|
|
545
|
+
#loading.error { color: #fca5a5; padding: 28px; text-align: center; white-space: pre-wrap; }
|
|
546
|
+
</style>
|
|
547
|
+
<script type="importmap">
|
|
548
|
+
{"imports": {
|
|
549
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
|
|
550
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/"
|
|
551
|
+
}}
|
|
552
|
+
</script>
|
|
553
|
+
</head>
|
|
554
|
+
<body>
|
|
555
|
+
<main>
|
|
556
|
+
<div id="viewport">
|
|
557
|
+
<canvas id="three-view" aria-label="Interactive SimRig model"></canvas>
|
|
558
|
+
<div id="loading">Loading WebGL scene…</div>
|
|
559
|
+
<div id="hint">Drag to orbit · scroll to zoom · right-drag to pan</div>
|
|
560
|
+
</div>
|
|
561
|
+
</main>
|
|
562
|
+
<aside>
|
|
563
|
+
<h1>SimRig Model View</h1>
|
|
564
|
+
<div id="meta" class="meta">loading joints...</div>
|
|
565
|
+
<button class="secondary" id="reset-joints">Reset Joints</button>
|
|
566
|
+
<button class="secondary" id="reset-camera">Reset Camera</button>
|
|
567
|
+
<div id="controls"></div>
|
|
568
|
+
<h1 style="margin-top:18px">Status</h1>
|
|
569
|
+
<pre id="status">loading</pre>
|
|
570
|
+
</aside>
|
|
571
|
+
<script type="module">
|
|
572
|
+
import * as THREE from 'three';
|
|
573
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
574
|
+
|
|
575
|
+
const canvas = document.getElementById('three-view');
|
|
576
|
+
const viewport = document.getElementById('viewport');
|
|
577
|
+
const loadingEl = document.getElementById('loading');
|
|
578
|
+
const statusEl = document.getElementById('status');
|
|
579
|
+
const metaEl = document.getElementById('meta');
|
|
580
|
+
const controlsEl = document.getElementById('controls');
|
|
581
|
+
const objects = new Map();
|
|
582
|
+
const meshGeometries = new Map();
|
|
583
|
+
let controlsRendered = false;
|
|
584
|
+
|
|
585
|
+
const renderer = new THREE.WebGLRenderer({canvas, antialias: true, alpha: false});
|
|
586
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
587
|
+
renderer.shadowMap.enabled = true;
|
|
588
|
+
renderer.shadowMap.type = THREE.PCFShadowMap;
|
|
589
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
590
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
591
|
+
renderer.toneMappingExposure = 1.0;
|
|
592
|
+
renderer.setClearColor(0x0b1220, 1);
|
|
593
|
+
|
|
594
|
+
const scene = new THREE.Scene();
|
|
595
|
+
scene.background = new THREE.Color(0x0b1220);
|
|
596
|
+
scene.fog = new THREE.Fog(0x0b1220, 7, 22);
|
|
597
|
+
|
|
598
|
+
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1000);
|
|
599
|
+
camera.up.set(0, 0, 1);
|
|
600
|
+
const orbit = new OrbitControls(camera, canvas);
|
|
601
|
+
orbit.enableDamping = true;
|
|
602
|
+
orbit.dampingFactor = 0.075;
|
|
603
|
+
orbit.screenSpacePanning = false;
|
|
604
|
+
orbit.minDistance = 0.08;
|
|
605
|
+
orbit.maxDistance = 100;
|
|
606
|
+
orbit.minPolarAngle = 0.08;
|
|
607
|
+
orbit.maxPolarAngle = Math.PI / 2 - 0.04;
|
|
608
|
+
|
|
609
|
+
scene.add(new THREE.HemisphereLight(0xbfdcff, 0x172033, 1.15));
|
|
610
|
+
const keyLight = new THREE.DirectionalLight(0xffffff, 2.35);
|
|
611
|
+
keyLight.position.set(4, -5, 8);
|
|
612
|
+
keyLight.castShadow = true;
|
|
613
|
+
keyLight.shadow.mapSize.set(2048, 2048);
|
|
614
|
+
keyLight.shadow.camera.near = 0.1;
|
|
615
|
+
keyLight.shadow.camera.far = 30;
|
|
616
|
+
keyLight.shadow.camera.left = -5;
|
|
617
|
+
keyLight.shadow.camera.right = 5;
|
|
618
|
+
keyLight.shadow.camera.top = 5;
|
|
619
|
+
keyLight.shadow.camera.bottom = -5;
|
|
620
|
+
keyLight.shadow.bias = -0.0002;
|
|
621
|
+
scene.add(keyLight);
|
|
622
|
+
const rimLight = new THREE.DirectionalLight(0x7aa8ff, 0.85);
|
|
623
|
+
rimLight.position.set(-5, 3, 5);
|
|
624
|
+
scene.add(rimLight);
|
|
625
|
+
|
|
626
|
+
const modelRoot = new THREE.Group();
|
|
627
|
+
scene.add(modelRoot);
|
|
628
|
+
|
|
629
|
+
function materialFor(geom) {
|
|
630
|
+
const [r, g, b, a] = geom.rgba;
|
|
631
|
+
const props = geom.material || {};
|
|
632
|
+
if (geom.type === 0) {
|
|
633
|
+
return new THREE.MeshStandardMaterial({color: 0x182231, roughness: 0.92});
|
|
634
|
+
}
|
|
635
|
+
const material = new THREE.MeshPhysicalMaterial({
|
|
636
|
+
color: new THREE.Color(r, g, b),
|
|
637
|
+
opacity: a,
|
|
638
|
+
transparent: a < 0.999,
|
|
639
|
+
roughness: THREE.MathUtils.clamp(0.68 - (props.shininess || 0) * 0.32, 0.22, 0.82),
|
|
640
|
+
metalness: THREE.MathUtils.clamp((props.reflectance || 0) * 0.45, 0, 0.35),
|
|
641
|
+
clearcoat: THREE.MathUtils.clamp((props.specular || 0) * 0.35, 0, 0.4),
|
|
642
|
+
clearcoatRoughness: 0.35,
|
|
643
|
+
});
|
|
644
|
+
if ((props.emission || 0) > 0) {
|
|
645
|
+
material.emissive.setRGB(r, g, b);
|
|
646
|
+
material.emissiveIntensity = props.emission;
|
|
647
|
+
}
|
|
648
|
+
return material;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function primitiveGeometry(geom) {
|
|
652
|
+
const [x, y, z] = geom.size;
|
|
653
|
+
switch (geom.type) {
|
|
654
|
+
case 0: // plane
|
|
655
|
+
return new THREE.PlaneGeometry(200, 200);
|
|
656
|
+
case 2: // sphere
|
|
657
|
+
return new THREE.SphereGeometry(x, 32, 20);
|
|
658
|
+
case 3: { // capsule, MuJoCo axis is local Z
|
|
659
|
+
const geometry = new THREE.CapsuleGeometry(x, 2 * y, 10, 24);
|
|
660
|
+
geometry.rotateX(Math.PI / 2);
|
|
661
|
+
return geometry;
|
|
662
|
+
}
|
|
663
|
+
case 4: { // ellipsoid
|
|
664
|
+
const geometry = new THREE.SphereGeometry(1, 32, 20);
|
|
665
|
+
geometry.scale(x, y, z);
|
|
666
|
+
return geometry;
|
|
667
|
+
}
|
|
668
|
+
case 5: { // cylinder, MuJoCo axis is local Z
|
|
669
|
+
const geometry = new THREE.CylinderGeometry(x, x, 2 * y, 32);
|
|
670
|
+
geometry.rotateX(Math.PI / 2);
|
|
671
|
+
return geometry;
|
|
672
|
+
}
|
|
673
|
+
case 6:
|
|
674
|
+
return new THREE.BoxGeometry(2 * x, 2 * y, 2 * z);
|
|
675
|
+
case 7:
|
|
676
|
+
return meshGeometries.get(geom.mesh_id) || null;
|
|
677
|
+
default:
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function applyTransform(object, transform) {
|
|
683
|
+
if (!object || !transform) return;
|
|
684
|
+
object.position.fromArray(transform.position);
|
|
685
|
+
const m = transform.matrix;
|
|
686
|
+
const rotation = new THREE.Matrix4();
|
|
687
|
+
rotation.set(
|
|
688
|
+
m[0], m[1], m[2], 0,
|
|
689
|
+
m[3], m[4], m[5], 0,
|
|
690
|
+
m[6], m[7], m[8], 0,
|
|
691
|
+
0, 0, 0, 1,
|
|
692
|
+
);
|
|
693
|
+
object.quaternion.setFromRotationMatrix(rotation);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function updateTransforms(transforms) {
|
|
697
|
+
for (const transform of transforms || []) {
|
|
698
|
+
applyTransform(objects.get(transform.id), transform);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function fitCamera() {
|
|
703
|
+
const bounds = new THREE.Box3().setFromObject(modelRoot);
|
|
704
|
+
const center = bounds.getCenter(new THREE.Vector3());
|
|
705
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
706
|
+
const radius = Math.max(size.x, size.y, size.z, 0.25);
|
|
707
|
+
orbit.target.copy(center);
|
|
708
|
+
camera.position.set(
|
|
709
|
+
center.x + radius * 1.35,
|
|
710
|
+
center.y - radius * 1.75,
|
|
711
|
+
center.z + radius * 0.95,
|
|
712
|
+
);
|
|
713
|
+
camera.near = Math.max(radius / 200, 0.002);
|
|
714
|
+
camera.far = Math.max(radius * 80, 100);
|
|
715
|
+
camera.updateProjectionMatrix();
|
|
716
|
+
orbit.update();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function resize() {
|
|
720
|
+
const width = Math.max(1, viewport.clientWidth);
|
|
721
|
+
const height = Math.max(1, viewport.clientHeight);
|
|
722
|
+
renderer.setSize(width, height, false);
|
|
723
|
+
camera.aspect = width / height;
|
|
724
|
+
camera.updateProjectionMatrix();
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function animate() {
|
|
728
|
+
orbit.update();
|
|
729
|
+
renderer.render(scene, camera);
|
|
730
|
+
requestAnimationFrame(animate);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function loadScene() {
|
|
734
|
+
const res = await fetch('/scene.json', {cache: 'no-store'});
|
|
735
|
+
if (!res.ok) throw new Error(`scene request failed (${res.status})`);
|
|
736
|
+
const payload = await res.json();
|
|
737
|
+
|
|
738
|
+
for (const mesh of payload.meshes) {
|
|
739
|
+
const geometry = new THREE.BufferGeometry();
|
|
740
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(mesh.vertices, 3));
|
|
741
|
+
geometry.setIndex(mesh.indices);
|
|
742
|
+
geometry.computeVertexNormals();
|
|
743
|
+
geometry.computeBoundingSphere();
|
|
744
|
+
meshGeometries.set(mesh.id, geometry);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const transformById = new Map(payload.transforms.map(item => [item.id, item]));
|
|
748
|
+
for (const geom of payload.geoms) {
|
|
749
|
+
const geometry = primitiveGeometry(geom);
|
|
750
|
+
if (!geometry || geom.rgba[3] <= 0.001) continue;
|
|
751
|
+
const object = new THREE.Mesh(geometry, materialFor(geom));
|
|
752
|
+
object.name = geom.name;
|
|
753
|
+
object.castShadow = geom.type !== 0;
|
|
754
|
+
object.receiveShadow = true;
|
|
755
|
+
applyTransform(object, transformById.get(geom.id));
|
|
756
|
+
if (geom.type === 0) {
|
|
757
|
+
scene.add(object);
|
|
758
|
+
} else {
|
|
759
|
+
modelRoot.add(object);
|
|
760
|
+
}
|
|
761
|
+
objects.set(geom.id, object);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const grid = new THREE.GridHelper(30, 60, 0x52647a, 0x263346);
|
|
765
|
+
grid.rotation.x = Math.PI / 2;
|
|
766
|
+
grid.position.z = 0.001;
|
|
767
|
+
grid.material.opacity = 0.42;
|
|
768
|
+
grid.material.transparent = true;
|
|
769
|
+
scene.add(grid);
|
|
770
|
+
|
|
771
|
+
fitCamera();
|
|
772
|
+
loadingEl.remove();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function statusPayload(payload) {
|
|
776
|
+
const copy = {...payload};
|
|
777
|
+
delete copy.transforms;
|
|
778
|
+
return copy;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function renderControls(payload) {
|
|
782
|
+
metaEl.textContent = `${payload.model_name} | ${payload.joint_count} joints | ${payload.control_count} controls | mode: ${payload.render_mode} | fps: display`;
|
|
783
|
+
updateTransforms(payload.transforms);
|
|
784
|
+
if (!controlsRendered) {
|
|
785
|
+
controlsEl.innerHTML = '';
|
|
786
|
+
for (const control of payload.controls) {
|
|
787
|
+
const wrapper = document.createElement('div');
|
|
788
|
+
wrapper.className = 'control';
|
|
789
|
+
const label = document.createElement('label');
|
|
790
|
+
label.textContent = control.label;
|
|
791
|
+
const slider = document.createElement('input');
|
|
792
|
+
slider.type = 'range';
|
|
793
|
+
slider.min = control.min;
|
|
794
|
+
slider.max = control.max;
|
|
795
|
+
slider.step = Math.max((control.max - control.min) / 300, 0.000001);
|
|
796
|
+
slider.value = control.value;
|
|
797
|
+
slider.dataset.jointId = control.joint_id;
|
|
798
|
+
slider.dataset.component = control.component;
|
|
799
|
+
const valueEl = document.createElement('div');
|
|
800
|
+
valueEl.className = 'value';
|
|
801
|
+
valueEl.textContent = control.value.toFixed(4);
|
|
802
|
+
slider.addEventListener('input', () => {
|
|
803
|
+
valueEl.textContent = Number(slider.value).toFixed(4);
|
|
804
|
+
});
|
|
805
|
+
slider.addEventListener('change', async () => {
|
|
806
|
+
await setJoint(control.joint_id, control.component, slider.value);
|
|
807
|
+
});
|
|
808
|
+
wrapper.append(label, slider, valueEl);
|
|
809
|
+
controlsEl.appendChild(wrapper);
|
|
810
|
+
}
|
|
811
|
+
controlsRendered = true;
|
|
812
|
+
}
|
|
813
|
+
statusEl.textContent = JSON.stringify(statusPayload(payload), null, 2);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async function loadJoints() {
|
|
817
|
+
const res = await fetch('/joints.json', {cache: 'no-store'});
|
|
818
|
+
renderControls(await res.json());
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function setJoint(jointId, component, value) {
|
|
822
|
+
const res = await fetch(`/set?joint_id=${jointId}&component=${component}&value=${value}`, {cache: 'no-store'});
|
|
823
|
+
renderControls(await res.json());
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function resetJoints() {
|
|
827
|
+
const res = await fetch('/reset', {cache: 'no-store'});
|
|
828
|
+
const payload = await res.json();
|
|
829
|
+
controlsRendered = false;
|
|
830
|
+
renderControls(payload);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
document.getElementById('reset-joints').addEventListener('click', resetJoints);
|
|
834
|
+
document.getElementById('reset-camera').addEventListener('click', fitCamera);
|
|
835
|
+
window.addEventListener('resize', resize);
|
|
836
|
+
resize();
|
|
837
|
+
animate();
|
|
838
|
+
try {
|
|
839
|
+
await Promise.all([loadScene(), loadJoints()]);
|
|
840
|
+
setInterval(loadJoints, 2000);
|
|
841
|
+
} catch (err) {
|
|
842
|
+
loadingEl.className = 'error';
|
|
843
|
+
loadingEl.textContent = `WebGL viewer failed to load.\n${err}\n\nThree.js is loaded from jsDelivr, so an internet connection is required.`;
|
|
844
|
+
statusEl.textContent = String(err);
|
|
845
|
+
console.error(err);
|
|
846
|
+
}
|
|
847
|
+
</script>
|
|
848
|
+
</body>
|
|
849
|
+
</html>
|
|
850
|
+
"""
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _frame_html() -> str:
|
|
855
|
+
return f"""<!doctype html>
|
|
856
|
+
<html>
|
|
857
|
+
<head>
|
|
858
|
+
<meta charset="utf-8">
|
|
859
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
860
|
+
<title>SimRig Model View</title>
|
|
861
|
+
<style>{viewer_styles(sidebar_width=360)}</style>
|
|
862
|
+
</head>
|
|
863
|
+
<body>
|
|
864
|
+
<main>
|
|
865
|
+
<div id="viewport">
|
|
866
|
+
<img id="frame" alt="SimRig model frame">
|
|
867
|
+
<div id="hint">Drag to orbit · scroll to zoom</div>
|
|
868
|
+
</div>
|
|
869
|
+
</main>
|
|
870
|
+
<aside>
|
|
871
|
+
<h1>SimRig Model View</h1>
|
|
872
|
+
<div id="meta" class="meta">loading joints...</div>
|
|
873
|
+
<button class="secondary" onclick="resetJoints()">Reset Joints</button>
|
|
874
|
+
<div id="controls"></div>
|
|
875
|
+
<h1 style="margin-top:18px">Status</h1>
|
|
876
|
+
<pre id="status">loading</pre>
|
|
877
|
+
</aside>
|
|
878
|
+
<script>
|
|
879
|
+
const frame = document.getElementById('frame');
|
|
880
|
+
const statusEl = document.getElementById('status');
|
|
881
|
+
const metaEl = document.getElementById('meta');
|
|
882
|
+
const controlsEl = document.getElementById('controls');
|
|
883
|
+
let refreshControlsTimer = null;
|
|
884
|
+
{camera_interaction_script()}
|
|
885
|
+
{frame_poll_script(poll_ms=33)}
|
|
886
|
+
bindCameraControls(frame);
|
|
887
|
+
|
|
888
|
+
function renderControls(payload) {{
|
|
889
|
+
controlsEl.innerHTML = '';
|
|
890
|
+
metaEl.textContent = `${{payload.model_name}} | ${{payload.joint_count}} joints | ${{payload.control_count}} controls | mode: ${{payload.render_mode}} | fps: ${{payload.fps_target}}`;
|
|
891
|
+
applyCameraFromStatus(payload);
|
|
892
|
+
for (const control of payload.controls) {{
|
|
893
|
+
const wrapper = document.createElement('div');
|
|
894
|
+
wrapper.className = 'control';
|
|
895
|
+
const label = document.createElement('label');
|
|
896
|
+
label.textContent = control.label;
|
|
897
|
+
const slider = document.createElement('input');
|
|
898
|
+
slider.type = 'range';
|
|
899
|
+
slider.min = control.min;
|
|
900
|
+
slider.max = control.max;
|
|
901
|
+
slider.step = (control.max - control.min) / 200;
|
|
902
|
+
slider.value = control.value;
|
|
903
|
+
const valueEl = document.createElement('div');
|
|
904
|
+
valueEl.className = 'value';
|
|
905
|
+
valueEl.textContent = control.value.toFixed(4);
|
|
906
|
+
slider.addEventListener('input', () => {{
|
|
907
|
+
valueEl.textContent = Number(slider.value).toFixed(4);
|
|
908
|
+
}});
|
|
909
|
+
slider.addEventListener('change', async () => {{
|
|
910
|
+
await setJoint(control.joint_id, control.component, slider.value);
|
|
911
|
+
}});
|
|
912
|
+
wrapper.appendChild(label);
|
|
913
|
+
wrapper.appendChild(slider);
|
|
914
|
+
wrapper.appendChild(valueEl);
|
|
915
|
+
controlsEl.appendChild(wrapper);
|
|
916
|
+
}}
|
|
917
|
+
statusEl.textContent = JSON.stringify(payload, null, 2);
|
|
918
|
+
}}
|
|
919
|
+
|
|
920
|
+
async function loadJoints() {{
|
|
921
|
+
const res = await fetch('/joints.json', {{cache: 'no-store'}});
|
|
922
|
+
const payload = await res.json();
|
|
923
|
+
renderControls(payload);
|
|
924
|
+
}}
|
|
925
|
+
|
|
926
|
+
async function setJoint(jointId, component, value) {{
|
|
927
|
+
const res = await fetch(`/set?joint_id=${{jointId}}&component=${{component}}&value=${{value}}`);
|
|
928
|
+
const payload = await res.json();
|
|
929
|
+
statusEl.textContent = JSON.stringify(payload, null, 2);
|
|
930
|
+
}}
|
|
931
|
+
|
|
932
|
+
async function resetJoints() {{
|
|
933
|
+
const res = await fetch('/reset');
|
|
934
|
+
const payload = await res.json();
|
|
935
|
+
renderControls(payload);
|
|
936
|
+
}}
|
|
937
|
+
|
|
938
|
+
refreshFrame();
|
|
939
|
+
loadJoints();
|
|
940
|
+
refreshControlsTimer = setInterval(loadJoints, 2000);
|
|
941
|
+
</script>
|
|
942
|
+
</body>
|
|
943
|
+
</html>
|
|
944
|
+
"""
|