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/preview.py
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
"""Browser preview server for policy rollouts."""
|
|
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 functools
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import parse_qs, urlparse
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from simrig.browser_render import MujocoFramePump
|
|
20
|
+
from simrig.browser_shell import camera_interaction_script, frame_poll_script, viewer_styles
|
|
21
|
+
from simrig.playground_backend import (
|
|
22
|
+
_apply_command,
|
|
23
|
+
_import_training_deps,
|
|
24
|
+
_validate_backend,
|
|
25
|
+
load_env,
|
|
26
|
+
)
|
|
27
|
+
from simrig.presets import hidden_sizes, resolve_small_network
|
|
28
|
+
from simrig.rendering import make_tracking_camera, tracking_body_id
|
|
29
|
+
from simrig.three_scene import geom_transforms, scene_payload
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PreviewStatus:
|
|
34
|
+
env_name: str
|
|
35
|
+
checkpoint: str
|
|
36
|
+
step: int
|
|
37
|
+
reward: float
|
|
38
|
+
total_reward: float
|
|
39
|
+
done: bool
|
|
40
|
+
command: list[float] | None
|
|
41
|
+
command_applied: bool
|
|
42
|
+
paused: bool
|
|
43
|
+
render_mode: str
|
|
44
|
+
frame_busy: bool
|
|
45
|
+
renderer_error: str | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PolicyPreviewSession:
|
|
49
|
+
"""Owns env, policy, renderer, and mutable rollout state."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
checkpoint: Path | str,
|
|
54
|
+
*,
|
|
55
|
+
env_name: str,
|
|
56
|
+
backend: str = "mujoco-playground",
|
|
57
|
+
small_network: bool | None = None,
|
|
58
|
+
seed: int = 0,
|
|
59
|
+
command: tuple[float, ...] | None = None,
|
|
60
|
+
width: int = 960,
|
|
61
|
+
height: int = 540,
|
|
62
|
+
frame_skip: int = 1,
|
|
63
|
+
camera: str | int | None = None,
|
|
64
|
+
render_mode: str = "threejs",
|
|
65
|
+
paused: bool = False,
|
|
66
|
+
fps: int = 24,
|
|
67
|
+
) -> None:
|
|
68
|
+
_validate_backend(backend)
|
|
69
|
+
self.env_name = env_name
|
|
70
|
+
self.checkpoint = str(checkpoint)
|
|
71
|
+
self.backend = backend
|
|
72
|
+
self.frame_skip = max(1, int(frame_skip))
|
|
73
|
+
self.command = list(command) if command is not None else None
|
|
74
|
+
self.command_applied = False
|
|
75
|
+
self.paused = paused
|
|
76
|
+
self.step_count = 0
|
|
77
|
+
self.total_reward = 0.0
|
|
78
|
+
self.last_reward = 0.0
|
|
79
|
+
self.done = False
|
|
80
|
+
self.render_mode = render_mode.lower()
|
|
81
|
+
self.fps = max(1, int(fps))
|
|
82
|
+
self.renderer_error: str | None = None
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
self._last_frame_jpeg: bytes | None = None
|
|
85
|
+
self._scene_payload: dict[str, Any] | None = None
|
|
86
|
+
|
|
87
|
+
(
|
|
88
|
+
self.jax,
|
|
89
|
+
self.jp,
|
|
90
|
+
self.brax_model,
|
|
91
|
+
self.running_statistics,
|
|
92
|
+
self.ppo_networks,
|
|
93
|
+
*_,
|
|
94
|
+
) = _import_training_deps()
|
|
95
|
+
try:
|
|
96
|
+
import mujoco # type: ignore
|
|
97
|
+
from PIL import Image # type: ignore
|
|
98
|
+
from PIL import ImageDraw # type: ignore
|
|
99
|
+
except ImportError as exc:
|
|
100
|
+
raise RuntimeError("Browser preview requires MuJoCo and Pillow.") from exc
|
|
101
|
+
|
|
102
|
+
self.mujoco = mujoco
|
|
103
|
+
self.Image = Image
|
|
104
|
+
self.ImageDraw = ImageDraw
|
|
105
|
+
self.env = load_env(env_name)
|
|
106
|
+
sizes = hidden_sizes(resolve_small_network(checkpoint, small_network=small_network))
|
|
107
|
+
network_factory = functools.partial(
|
|
108
|
+
self.ppo_networks.make_ppo_networks,
|
|
109
|
+
policy_hidden_layer_sizes=sizes,
|
|
110
|
+
value_hidden_layer_sizes=sizes,
|
|
111
|
+
policy_obs_key="state",
|
|
112
|
+
value_obs_key="privileged_state",
|
|
113
|
+
)
|
|
114
|
+
networks = network_factory(
|
|
115
|
+
self.env.observation_size,
|
|
116
|
+
self.env.action_size,
|
|
117
|
+
preprocess_observations_fn=self.running_statistics.normalize,
|
|
118
|
+
)
|
|
119
|
+
params = self.brax_model.load_params(str(checkpoint))
|
|
120
|
+
self.policy = self.jax.jit(
|
|
121
|
+
self.ppo_networks.make_inference_fn(networks)(params, deterministic=True)
|
|
122
|
+
)
|
|
123
|
+
self.reset_fn = self.jax.jit(self.env.reset)
|
|
124
|
+
self.step_fn = self.jax.jit(self.env.step)
|
|
125
|
+
self.rng = self.jax.random.PRNGKey(seed)
|
|
126
|
+
self.state = self.reset_fn(self.rng)
|
|
127
|
+
self._apply_current_command()
|
|
128
|
+
|
|
129
|
+
self.mj_data = self.mujoco.MjData(self.env.mj_model)
|
|
130
|
+
self._copy_state_to_mujoco_unlocked()
|
|
131
|
+
self.width = width
|
|
132
|
+
self.height = height
|
|
133
|
+
self.renderer = None
|
|
134
|
+
if self.render_mode not in ("threejs", "topdown", "mujoco"):
|
|
135
|
+
raise ValueError("render_mode must be 'threejs', 'mujoco', or 'topdown'.")
|
|
136
|
+
self.camera, self.camera_state = make_tracking_camera(
|
|
137
|
+
self.mujoco,
|
|
138
|
+
self.env.mj_model,
|
|
139
|
+
self.mj_data,
|
|
140
|
+
camera,
|
|
141
|
+
)
|
|
142
|
+
self._frame_pump: MujocoFramePump | None = None
|
|
143
|
+
self._rollout_thread: threading.Thread | None = None
|
|
144
|
+
self._running = True
|
|
145
|
+
if self.render_mode == "threejs":
|
|
146
|
+
self._rollout_thread = threading.Thread(
|
|
147
|
+
target=self._run_rollout,
|
|
148
|
+
name="simrig-preview-rollout",
|
|
149
|
+
daemon=True,
|
|
150
|
+
)
|
|
151
|
+
self._rollout_thread.start()
|
|
152
|
+
else:
|
|
153
|
+
self._frame_pump = MujocoFramePump(
|
|
154
|
+
self.mujoco,
|
|
155
|
+
self.env.mj_model,
|
|
156
|
+
self.mj_data,
|
|
157
|
+
width=width,
|
|
158
|
+
height=height,
|
|
159
|
+
camera=self.camera,
|
|
160
|
+
camera_state=self.camera_state,
|
|
161
|
+
image_module=self.Image,
|
|
162
|
+
fps=self.fps,
|
|
163
|
+
render_mode=self.render_mode,
|
|
164
|
+
scene_lock=self._lock,
|
|
165
|
+
before_render=self._advance_rollout,
|
|
166
|
+
fallback_frame=self._fallback_frame,
|
|
167
|
+
error_frame=self._error_frame,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def reset(self) -> None:
|
|
171
|
+
with self._lock:
|
|
172
|
+
self.rng = self.jax.random.PRNGKey(0)
|
|
173
|
+
self.state = self.reset_fn(self.rng)
|
|
174
|
+
self.step_count = 0
|
|
175
|
+
self.total_reward = 0.0
|
|
176
|
+
self.last_reward = 0.0
|
|
177
|
+
self.done = False
|
|
178
|
+
self._apply_current_command()
|
|
179
|
+
|
|
180
|
+
def set_paused(self, paused: bool) -> None:
|
|
181
|
+
with self._lock:
|
|
182
|
+
self.paused = paused
|
|
183
|
+
|
|
184
|
+
def set_command(self, command: list[float] | None) -> None:
|
|
185
|
+
with self._lock:
|
|
186
|
+
self.command = command
|
|
187
|
+
self._apply_current_command()
|
|
188
|
+
|
|
189
|
+
def status(self) -> PreviewStatus:
|
|
190
|
+
with self._lock:
|
|
191
|
+
return self._status_unlocked()
|
|
192
|
+
|
|
193
|
+
def status_payload(self) -> dict[str, Any]:
|
|
194
|
+
with self._lock:
|
|
195
|
+
payload = self._status_unlocked().__dict__
|
|
196
|
+
payload.update(self._renderer_stats())
|
|
197
|
+
return payload
|
|
198
|
+
|
|
199
|
+
def state_payload(self) -> dict[str, Any]:
|
|
200
|
+
"""Return current rollout metadata and world transforms for Three.js."""
|
|
201
|
+
|
|
202
|
+
with self._lock:
|
|
203
|
+
payload = self._status_unlocked().__dict__
|
|
204
|
+
payload.update(self._renderer_stats())
|
|
205
|
+
payload["transforms"] = geom_transforms(self.env.mj_model, self.mj_data)
|
|
206
|
+
payload["tracking_position"] = np.asarray(
|
|
207
|
+
self.mj_data.xpos[self._tracking_body_id()],
|
|
208
|
+
dtype=float,
|
|
209
|
+
).tolist()
|
|
210
|
+
return payload
|
|
211
|
+
|
|
212
|
+
def scene_payload(self) -> dict[str, Any]:
|
|
213
|
+
with self._lock:
|
|
214
|
+
if self._scene_payload is None:
|
|
215
|
+
self._scene_payload = scene_payload(
|
|
216
|
+
self.mujoco,
|
|
217
|
+
self.env.mj_model,
|
|
218
|
+
self.mj_data,
|
|
219
|
+
model_name=self.env_name,
|
|
220
|
+
)
|
|
221
|
+
self._scene_payload.pop("transforms", None)
|
|
222
|
+
return {
|
|
223
|
+
**self._scene_payload,
|
|
224
|
+
"transforms": geom_transforms(self.env.mj_model, self.mj_data),
|
|
225
|
+
"tracking_position": np.asarray(
|
|
226
|
+
self.mj_data.xpos[self._tracking_body_id()],
|
|
227
|
+
dtype=float,
|
|
228
|
+
).tolist(),
|
|
229
|
+
"fps_target": self.fps,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
def _status_unlocked(self) -> PreviewStatus:
|
|
233
|
+
return PreviewStatus(
|
|
234
|
+
env_name=self.env_name,
|
|
235
|
+
checkpoint=self.checkpoint,
|
|
236
|
+
step=self.step_count,
|
|
237
|
+
reward=self.last_reward,
|
|
238
|
+
total_reward=self.total_reward,
|
|
239
|
+
done=self.done,
|
|
240
|
+
command=list(self.command) if self.command is not None else None,
|
|
241
|
+
command_applied=self.command_applied,
|
|
242
|
+
paused=self.paused,
|
|
243
|
+
render_mode=self.render_mode,
|
|
244
|
+
frame_busy=False,
|
|
245
|
+
renderer_error=self._renderer_stats()["renderer_error"],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _renderer_stats(self) -> dict[str, Any]:
|
|
249
|
+
if self._frame_pump is not None:
|
|
250
|
+
return self._frame_pump.stats()
|
|
251
|
+
return {
|
|
252
|
+
"fps_target": self.fps,
|
|
253
|
+
"renderer_error": self.renderer_error,
|
|
254
|
+
"camera": {
|
|
255
|
+
"interactive": True,
|
|
256
|
+
"renderer": "threejs-orbit-controls",
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def set_camera_from_query(self, query: dict[str, list[str]]) -> None:
|
|
261
|
+
if self._frame_pump is not None:
|
|
262
|
+
self._frame_pump.set_camera_from_query(query)
|
|
263
|
+
|
|
264
|
+
def frame_jpeg(self) -> bytes:
|
|
265
|
+
if self._frame_pump is None:
|
|
266
|
+
raise RuntimeError("Frame streaming is disabled in threejs mode.")
|
|
267
|
+
return self._frame_pump.get_jpeg()
|
|
268
|
+
|
|
269
|
+
def close(self) -> None:
|
|
270
|
+
self._running = False
|
|
271
|
+
if self._frame_pump is not None:
|
|
272
|
+
self._frame_pump.close()
|
|
273
|
+
if self._rollout_thread is not None:
|
|
274
|
+
self._rollout_thread.join(timeout=2.0)
|
|
275
|
+
|
|
276
|
+
def _run_rollout(self) -> None:
|
|
277
|
+
interval = 1.0 / self.fps
|
|
278
|
+
while self._running:
|
|
279
|
+
started = time.monotonic()
|
|
280
|
+
try:
|
|
281
|
+
with self._lock:
|
|
282
|
+
self._advance_rollout()
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
self.renderer_error = str(exc)
|
|
285
|
+
elapsed = time.monotonic() - started
|
|
286
|
+
time.sleep(max(0.0, interval - elapsed))
|
|
287
|
+
|
|
288
|
+
def _advance_rollout(self) -> None:
|
|
289
|
+
if not self.paused and not self.done:
|
|
290
|
+
for _ in range(self.frame_skip):
|
|
291
|
+
self._step_once_unlocked()
|
|
292
|
+
if self.done:
|
|
293
|
+
break
|
|
294
|
+
self._copy_state_to_mujoco_unlocked()
|
|
295
|
+
|
|
296
|
+
def _step_once_unlocked(self) -> None:
|
|
297
|
+
self._apply_current_command()
|
|
298
|
+
self.rng, action_rng = self.jax.random.split(self.rng)
|
|
299
|
+
action, _ = self.policy(self.state.obs, action_rng)
|
|
300
|
+
self.state = self.step_fn(self.state, action)
|
|
301
|
+
self.last_reward = float(self.state.reward)
|
|
302
|
+
self.total_reward += self.last_reward
|
|
303
|
+
self.step_count += 1
|
|
304
|
+
self.done = bool(self.state.done)
|
|
305
|
+
|
|
306
|
+
def _apply_current_command(self) -> None:
|
|
307
|
+
if self.command is None:
|
|
308
|
+
self.command_applied = False
|
|
309
|
+
return
|
|
310
|
+
self.state, self.command_applied = _apply_command(
|
|
311
|
+
self.env,
|
|
312
|
+
self.state,
|
|
313
|
+
self.jp.asarray(self.command),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _copy_state_to_mujoco_unlocked(self) -> None:
|
|
317
|
+
self.mj_data.qpos[:] = np.asarray(self.state.data.qpos)
|
|
318
|
+
self.mj_data.qvel[:] = np.asarray(self.state.data.qvel)
|
|
319
|
+
for name in ("mocap_pos", "mocap_quat"):
|
|
320
|
+
source = getattr(self.state.data, name, None)
|
|
321
|
+
target = getattr(self.mj_data, name, None)
|
|
322
|
+
if source is not None and target is not None:
|
|
323
|
+
target[:] = np.asarray(source)
|
|
324
|
+
self.mujoco.mj_forward(self.env.mj_model, self.mj_data)
|
|
325
|
+
|
|
326
|
+
def _tracking_body_id(self) -> int:
|
|
327
|
+
from simrig.rendering import tracking_body_id
|
|
328
|
+
|
|
329
|
+
return tracking_body_id(self.mujoco, self.env.mj_model, self.mj_data)
|
|
330
|
+
|
|
331
|
+
def _fallback_frame(self) -> np.ndarray:
|
|
332
|
+
"""Explicit schematic mode only; not used for default MuJoCo rendering."""
|
|
333
|
+
image = self.Image.new("RGB", (self.width, self.height), (8, 10, 12))
|
|
334
|
+
draw = self.ImageDraw.Draw(image)
|
|
335
|
+
model = self.env.mj_model
|
|
336
|
+
center = np.asarray(self.mj_data.xpos[self._tracking_body_id()])[:2]
|
|
337
|
+
scale = min(self.width, self.height) / 5.0
|
|
338
|
+
|
|
339
|
+
def project(pos: np.ndarray) -> tuple[int, int]:
|
|
340
|
+
xy = (np.asarray(pos)[:2] - center) * scale
|
|
341
|
+
return int(self.width / 2 + xy[0]), int(self.height / 2 - xy[1])
|
|
342
|
+
|
|
343
|
+
# Ground grid.
|
|
344
|
+
grid_color = (28, 34, 38)
|
|
345
|
+
for offset in np.linspace(-2.0, 2.0, 9):
|
|
346
|
+
x1, y1 = project(center + np.array([offset, -2.0]))
|
|
347
|
+
x2, y2 = project(center + np.array([offset, 2.0]))
|
|
348
|
+
draw.line((x1, y1, x2, y2), fill=grid_color)
|
|
349
|
+
x1, y1 = project(center + np.array([-2.0, offset]))
|
|
350
|
+
x2, y2 = project(center + np.array([2.0, offset]))
|
|
351
|
+
draw.line((x1, y1, x2, y2), fill=grid_color)
|
|
352
|
+
|
|
353
|
+
# Parent-child body graph.
|
|
354
|
+
for body_id in range(1, model.nbody):
|
|
355
|
+
parent = int(model.body_parentid[body_id])
|
|
356
|
+
if parent <= 0:
|
|
357
|
+
continue
|
|
358
|
+
x1, y1 = project(self.mj_data.xpos[parent])
|
|
359
|
+
x2, y2 = project(self.mj_data.xpos[body_id])
|
|
360
|
+
draw.line((x1, y1, x2, y2), fill=(96, 150, 255), width=3)
|
|
361
|
+
|
|
362
|
+
for body_id in range(1, model.nbody):
|
|
363
|
+
x, y = project(self.mj_data.xpos[body_id])
|
|
364
|
+
radius = 7 if body_id == self._tracking_body_id() else 4
|
|
365
|
+
fill = (255, 210, 94) if body_id == self._tracking_body_id() else (205, 224, 255)
|
|
366
|
+
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=fill)
|
|
367
|
+
|
|
368
|
+
command = self.command if self.command is not None else ["-", "-", "-"]
|
|
369
|
+
overlay = [
|
|
370
|
+
"SimRig Preview",
|
|
371
|
+
f"mode: {self.render_mode}",
|
|
372
|
+
f"env: {self.env_name}",
|
|
373
|
+
f"step: {self.step_count}",
|
|
374
|
+
f"reward: {self.last_reward:.4f}",
|
|
375
|
+
f"command: {command}",
|
|
376
|
+
]
|
|
377
|
+
if self.renderer_error:
|
|
378
|
+
overlay.append(f"render error: {self.renderer_error[:80]}")
|
|
379
|
+
draw.rectangle((16, 16, min(self.width - 16, 720), 168), fill=(0, 0, 0))
|
|
380
|
+
y = 28
|
|
381
|
+
for line in overlay:
|
|
382
|
+
draw.text((28, y), line, fill=(236, 240, 245))
|
|
383
|
+
y += 22
|
|
384
|
+
return np.asarray(image)
|
|
385
|
+
|
|
386
|
+
def _error_frame(self, message: str) -> np.ndarray:
|
|
387
|
+
image = self.Image.new("RGB", (self.width, self.height), (8, 10, 12))
|
|
388
|
+
draw = self.ImageDraw.Draw(image)
|
|
389
|
+
lines = [
|
|
390
|
+
"SimRig Preview",
|
|
391
|
+
"MuJoCo rendering failed",
|
|
392
|
+
message[:240],
|
|
393
|
+
"Try: export MUJOCO_GL=glfw",
|
|
394
|
+
"Or use: simrig demo ... for native 3D viewer",
|
|
395
|
+
]
|
|
396
|
+
y = 28
|
|
397
|
+
for line in lines:
|
|
398
|
+
draw.text((28, y), line, fill=(236, 240, 245))
|
|
399
|
+
y += 24
|
|
400
|
+
return np.asarray(image)
|
|
401
|
+
|
|
402
|
+
def _plain_frame(self, message: str) -> np.ndarray:
|
|
403
|
+
image = self.Image.new("RGB", (self.width, self.height), (8, 10, 12))
|
|
404
|
+
draw = self.ImageDraw.Draw(image)
|
|
405
|
+
draw.text((28, 28), "SimRig Preview", fill=(236, 240, 245))
|
|
406
|
+
draw.text((28, 56), message, fill=(180, 190, 200))
|
|
407
|
+
return np.asarray(image)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def serve_policy_preview(
|
|
411
|
+
checkpoint: Path | str,
|
|
412
|
+
*,
|
|
413
|
+
env_name: str,
|
|
414
|
+
host: str = "127.0.0.1",
|
|
415
|
+
port: int = 8765,
|
|
416
|
+
backend: str = "mujoco-playground",
|
|
417
|
+
small_network: bool | None = None,
|
|
418
|
+
seed: int = 0,
|
|
419
|
+
command: tuple[float, ...] | None = None,
|
|
420
|
+
width: int = 960,
|
|
421
|
+
height: int = 540,
|
|
422
|
+
frame_skip: int = 1,
|
|
423
|
+
camera: str | int | None = None,
|
|
424
|
+
render_mode: str = "threejs",
|
|
425
|
+
paused: bool = False,
|
|
426
|
+
fps: int = 24,
|
|
427
|
+
) -> None:
|
|
428
|
+
session = PolicyPreviewSession(
|
|
429
|
+
checkpoint,
|
|
430
|
+
env_name=env_name,
|
|
431
|
+
backend=backend,
|
|
432
|
+
small_network=small_network,
|
|
433
|
+
seed=seed,
|
|
434
|
+
command=command,
|
|
435
|
+
width=width,
|
|
436
|
+
height=height,
|
|
437
|
+
frame_skip=frame_skip,
|
|
438
|
+
camera=camera,
|
|
439
|
+
render_mode=render_mode,
|
|
440
|
+
paused=paused,
|
|
441
|
+
fps=fps,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
class Handler(BaseHTTPRequestHandler):
|
|
445
|
+
def do_GET(self) -> None: # noqa: N802
|
|
446
|
+
parsed = urlparse(self.path)
|
|
447
|
+
if parsed.path == "/":
|
|
448
|
+
self._send_html(_html(session.render_mode))
|
|
449
|
+
elif parsed.path == "/frame.jpg":
|
|
450
|
+
if session.render_mode == "threejs":
|
|
451
|
+
self.send_error(HTTPStatus.NOT_FOUND, "Frame streaming disabled")
|
|
452
|
+
else:
|
|
453
|
+
self._send_bytes(session.frame_jpeg(), "image/jpeg")
|
|
454
|
+
elif parsed.path == "/scene.json":
|
|
455
|
+
self._send_json(session.scene_payload(), compress=True)
|
|
456
|
+
elif parsed.path == "/state.json":
|
|
457
|
+
self._send_json(session.state_payload())
|
|
458
|
+
elif parsed.path == "/status.json":
|
|
459
|
+
self._send_json(session.status_payload())
|
|
460
|
+
elif parsed.path == "/camera":
|
|
461
|
+
query = parse_qs(parsed.query)
|
|
462
|
+
session.set_camera_from_query(query)
|
|
463
|
+
self._send_json(session.status_payload())
|
|
464
|
+
elif parsed.path == "/command":
|
|
465
|
+
query = parse_qs(parsed.query)
|
|
466
|
+
command_values = _command_from_query(query)
|
|
467
|
+
session.set_command(command_values)
|
|
468
|
+
self._send_json(session.status_payload())
|
|
469
|
+
elif parsed.path == "/pause":
|
|
470
|
+
session.set_paused(True)
|
|
471
|
+
self._send_json(session.status_payload())
|
|
472
|
+
elif parsed.path == "/resume":
|
|
473
|
+
session.set_paused(False)
|
|
474
|
+
self._send_json(session.status_payload())
|
|
475
|
+
elif parsed.path == "/reset":
|
|
476
|
+
session.reset()
|
|
477
|
+
self._send_json(session.status_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 preview: 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 _command_from_query(query: dict[str, list[str]]) -> list[float] | None:
|
|
527
|
+
if "clear" in query:
|
|
528
|
+
return None
|
|
529
|
+
values = []
|
|
530
|
+
for key in ("x", "y", "yaw"):
|
|
531
|
+
raw = query.get(key, ["0"])[0]
|
|
532
|
+
values.append(float(raw))
|
|
533
|
+
return values
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _html(render_mode: str = "threejs") -> str:
|
|
537
|
+
if render_mode == "threejs":
|
|
538
|
+
return _threejs_html()
|
|
539
|
+
return _frame_html()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _threejs_html() -> str:
|
|
543
|
+
return (
|
|
544
|
+
"""<!doctype html>
|
|
545
|
+
<html>
|
|
546
|
+
<head>
|
|
547
|
+
<meta charset="utf-8">
|
|
548
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
549
|
+
<title>SimRig Preview</title>
|
|
550
|
+
<style>"""
|
|
551
|
+
+ viewer_styles(sidebar_width=320)
|
|
552
|
+
+ """
|
|
553
|
+
#three-view { width: 100%; height: 100%; display: block; outline: none; }
|
|
554
|
+
#loading { position: absolute; inset: 0; display: grid; place-items: center; color: #cbd5e1; background: #070b12; z-index: 2; }
|
|
555
|
+
#loading.error { color: #fca5a5; padding: 28px; text-align: center; white-space: pre-wrap; }
|
|
556
|
+
#render-meta { color: #94a3b8; font-size: 12px; margin: -4px 0 12px; }
|
|
557
|
+
</style>
|
|
558
|
+
<script type="importmap">
|
|
559
|
+
{"imports": {
|
|
560
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
|
|
561
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/"
|
|
562
|
+
}}
|
|
563
|
+
</script>
|
|
564
|
+
</head>
|
|
565
|
+
<body>
|
|
566
|
+
<main>
|
|
567
|
+
<div id="viewport">
|
|
568
|
+
<canvas id="three-view" aria-label="Interactive SimRig policy preview"></canvas>
|
|
569
|
+
<div id="loading">Loading rollout scene…</div>
|
|
570
|
+
<div id="hint">Drag to orbit · scroll to zoom · right-drag to pan</div>
|
|
571
|
+
</div>
|
|
572
|
+
</main>
|
|
573
|
+
<aside>
|
|
574
|
+
<h1>SimRig Preview</h1>
|
|
575
|
+
<div id="render-meta">Three.js · loading rollout…</div>
|
|
576
|
+
<label>Forward X</label><input id="x" type="number" step="0.1" value="0">
|
|
577
|
+
<label>Lateral Y</label><input id="y" type="number" step="0.1" value="0">
|
|
578
|
+
<label>Yaw</label><input id="yaw" type="number" step="0.1" value="0">
|
|
579
|
+
<button id="set-command">Set Command</button>
|
|
580
|
+
<button class="secondary" id="clear-command">Clear</button>
|
|
581
|
+
<button class="secondary" id="pause">Pause</button>
|
|
582
|
+
<button class="secondary" id="resume">Resume</button>
|
|
583
|
+
<button class="secondary" id="reset">Reset</button>
|
|
584
|
+
<button class="secondary" id="reset-camera">Reset Camera</button>
|
|
585
|
+
<h1 style="margin-top:18px">Status</h1>
|
|
586
|
+
<pre id="status">loading</pre>
|
|
587
|
+
</aside>
|
|
588
|
+
<script type="module">
|
|
589
|
+
import * as THREE from 'three';
|
|
590
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
591
|
+
|
|
592
|
+
const canvas = document.getElementById('three-view');
|
|
593
|
+
const viewport = document.getElementById('viewport');
|
|
594
|
+
const loadingEl = document.getElementById('loading');
|
|
595
|
+
const statusEl = document.getElementById('status');
|
|
596
|
+
const renderMetaEl = document.getElementById('render-meta');
|
|
597
|
+
const xInput = document.getElementById('x');
|
|
598
|
+
const yInput = document.getElementById('y');
|
|
599
|
+
const yawInput = document.getElementById('yaw');
|
|
600
|
+
const objects = new Map();
|
|
601
|
+
const meshGeometries = new Map();
|
|
602
|
+
let controlsInitialized = false;
|
|
603
|
+
let trackingPosition = null;
|
|
604
|
+
let stateTimer = null;
|
|
605
|
+
let targetPollMs = 33;
|
|
606
|
+
|
|
607
|
+
const renderer = new THREE.WebGLRenderer({canvas, antialias: true, alpha: false});
|
|
608
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
609
|
+
renderer.shadowMap.enabled = true;
|
|
610
|
+
renderer.shadowMap.type = THREE.PCFShadowMap;
|
|
611
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
612
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
613
|
+
renderer.toneMappingExposure = 1.0;
|
|
614
|
+
renderer.setClearColor(0x0b1220, 1);
|
|
615
|
+
|
|
616
|
+
const scene = new THREE.Scene();
|
|
617
|
+
scene.background = new THREE.Color(0x0b1220);
|
|
618
|
+
scene.fog = new THREE.Fog(0x0b1220, 8, 26);
|
|
619
|
+
|
|
620
|
+
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1000);
|
|
621
|
+
camera.up.set(0, 0, 1);
|
|
622
|
+
const orbit = new OrbitControls(camera, canvas);
|
|
623
|
+
orbit.enableDamping = true;
|
|
624
|
+
orbit.dampingFactor = 0.075;
|
|
625
|
+
orbit.screenSpacePanning = false;
|
|
626
|
+
orbit.minDistance = 0.08;
|
|
627
|
+
orbit.maxDistance = 100;
|
|
628
|
+
orbit.minPolarAngle = 0.08;
|
|
629
|
+
orbit.maxPolarAngle = Math.PI / 2 - 0.04;
|
|
630
|
+
|
|
631
|
+
scene.add(new THREE.HemisphereLight(0xbfdcff, 0x172033, 1.15));
|
|
632
|
+
const keyLight = new THREE.DirectionalLight(0xffffff, 2.35);
|
|
633
|
+
keyLight.position.set(4, -5, 8);
|
|
634
|
+
keyLight.castShadow = true;
|
|
635
|
+
keyLight.shadow.mapSize.set(2048, 2048);
|
|
636
|
+
keyLight.shadow.camera.near = 0.1;
|
|
637
|
+
keyLight.shadow.camera.far = 30;
|
|
638
|
+
keyLight.shadow.camera.left = -5;
|
|
639
|
+
keyLight.shadow.camera.right = 5;
|
|
640
|
+
keyLight.shadow.camera.top = 5;
|
|
641
|
+
keyLight.shadow.camera.bottom = -5;
|
|
642
|
+
keyLight.shadow.bias = -0.0002;
|
|
643
|
+
scene.add(keyLight, keyLight.target);
|
|
644
|
+
const rimLight = new THREE.DirectionalLight(0x7aa8ff, 0.85);
|
|
645
|
+
rimLight.position.set(-5, 3, 5);
|
|
646
|
+
scene.add(rimLight);
|
|
647
|
+
|
|
648
|
+
const modelRoot = new THREE.Group();
|
|
649
|
+
scene.add(modelRoot);
|
|
650
|
+
|
|
651
|
+
function materialFor(geom) {
|
|
652
|
+
const [r, g, b, a] = geom.rgba;
|
|
653
|
+
const props = geom.material || {};
|
|
654
|
+
if (geom.type === 0) {
|
|
655
|
+
return new THREE.MeshStandardMaterial({color: 0x182231, roughness: 0.92});
|
|
656
|
+
}
|
|
657
|
+
const material = new THREE.MeshPhysicalMaterial({
|
|
658
|
+
color: new THREE.Color(r, g, b),
|
|
659
|
+
opacity: a,
|
|
660
|
+
transparent: a < 0.999,
|
|
661
|
+
roughness: THREE.MathUtils.clamp(0.68 - (props.shininess || 0) * 0.32, 0.22, 0.82),
|
|
662
|
+
metalness: THREE.MathUtils.clamp((props.reflectance || 0) * 0.45, 0, 0.35),
|
|
663
|
+
clearcoat: THREE.MathUtils.clamp((props.specular || 0) * 0.35, 0, 0.4),
|
|
664
|
+
clearcoatRoughness: 0.35,
|
|
665
|
+
});
|
|
666
|
+
if ((props.emission || 0) > 0) {
|
|
667
|
+
material.emissive.setRGB(r, g, b);
|
|
668
|
+
material.emissiveIntensity = props.emission;
|
|
669
|
+
}
|
|
670
|
+
return material;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function primitiveGeometry(geom) {
|
|
674
|
+
const [x, y, z] = geom.size;
|
|
675
|
+
switch (geom.type) {
|
|
676
|
+
case 0:
|
|
677
|
+
return new THREE.PlaneGeometry(200, 200);
|
|
678
|
+
case 2:
|
|
679
|
+
return new THREE.SphereGeometry(x, 32, 20);
|
|
680
|
+
case 3: {
|
|
681
|
+
const geometry = new THREE.CapsuleGeometry(x, 2 * y, 10, 24);
|
|
682
|
+
geometry.rotateX(Math.PI / 2);
|
|
683
|
+
return geometry;
|
|
684
|
+
}
|
|
685
|
+
case 4: {
|
|
686
|
+
const geometry = new THREE.SphereGeometry(1, 32, 20);
|
|
687
|
+
geometry.scale(x, y, z);
|
|
688
|
+
return geometry;
|
|
689
|
+
}
|
|
690
|
+
case 5: {
|
|
691
|
+
const geometry = new THREE.CylinderGeometry(x, x, 2 * y, 32);
|
|
692
|
+
geometry.rotateX(Math.PI / 2);
|
|
693
|
+
return geometry;
|
|
694
|
+
}
|
|
695
|
+
case 6:
|
|
696
|
+
return new THREE.BoxGeometry(2 * x, 2 * y, 2 * z);
|
|
697
|
+
case 7:
|
|
698
|
+
return meshGeometries.get(geom.mesh_id) || null;
|
|
699
|
+
default:
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function applyTransform(object, transform) {
|
|
705
|
+
if (!object || !transform) return;
|
|
706
|
+
object.position.fromArray(transform.position);
|
|
707
|
+
const m = transform.matrix;
|
|
708
|
+
const rotation = new THREE.Matrix4();
|
|
709
|
+
rotation.set(
|
|
710
|
+
m[0], m[1], m[2], 0,
|
|
711
|
+
m[3], m[4], m[5], 0,
|
|
712
|
+
m[6], m[7], m[8], 0,
|
|
713
|
+
0, 0, 0, 1,
|
|
714
|
+
);
|
|
715
|
+
object.quaternion.setFromRotationMatrix(rotation);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function updateTransforms(transforms) {
|
|
719
|
+
for (const transform of transforms || []) {
|
|
720
|
+
applyTransform(objects.get(transform.id), transform);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function followRobot(rawPosition) {
|
|
725
|
+
if (!Array.isArray(rawPosition)) return;
|
|
726
|
+
const next = new THREE.Vector3().fromArray(rawPosition);
|
|
727
|
+
if (trackingPosition !== null) {
|
|
728
|
+
const delta = next.clone().sub(trackingPosition);
|
|
729
|
+
camera.position.add(delta);
|
|
730
|
+
orbit.target.add(delta);
|
|
731
|
+
keyLight.position.add(delta);
|
|
732
|
+
keyLight.target.position.add(delta);
|
|
733
|
+
}
|
|
734
|
+
trackingPosition = next;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function fitCamera() {
|
|
738
|
+
const bounds = new THREE.Box3().setFromObject(modelRoot);
|
|
739
|
+
const center = bounds.getCenter(new THREE.Vector3());
|
|
740
|
+
const size = bounds.getSize(new THREE.Vector3());
|
|
741
|
+
const radius = Math.max(size.x, size.y, size.z, 0.25);
|
|
742
|
+
orbit.target.copy(center);
|
|
743
|
+
camera.position.set(
|
|
744
|
+
center.x + radius * 1.35,
|
|
745
|
+
center.y - radius * 1.75,
|
|
746
|
+
center.z + radius * 0.95,
|
|
747
|
+
);
|
|
748
|
+
camera.near = Math.max(radius / 200, 0.002);
|
|
749
|
+
camera.far = Math.max(radius * 80, 100);
|
|
750
|
+
camera.updateProjectionMatrix();
|
|
751
|
+
orbit.update();
|
|
752
|
+
trackingPosition = null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function resize() {
|
|
756
|
+
const width = Math.max(1, viewport.clientWidth);
|
|
757
|
+
const height = Math.max(1, viewport.clientHeight);
|
|
758
|
+
renderer.setSize(width, height, false);
|
|
759
|
+
camera.aspect = width / height;
|
|
760
|
+
camera.updateProjectionMatrix();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function animate() {
|
|
764
|
+
orbit.update();
|
|
765
|
+
renderer.render(scene, camera);
|
|
766
|
+
requestAnimationFrame(animate);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function loadScene() {
|
|
770
|
+
const res = await fetch('/scene.json', {cache: 'no-store'});
|
|
771
|
+
if (!res.ok) throw new Error(`scene request failed (${res.status})`);
|
|
772
|
+
const payload = await res.json();
|
|
773
|
+
targetPollMs = Math.max(16, Math.round(1000 / (payload.fps_target || 24)));
|
|
774
|
+
|
|
775
|
+
for (const mesh of payload.meshes) {
|
|
776
|
+
const geometry = new THREE.BufferGeometry();
|
|
777
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(mesh.vertices, 3));
|
|
778
|
+
geometry.setIndex(mesh.indices);
|
|
779
|
+
geometry.computeVertexNormals();
|
|
780
|
+
geometry.computeBoundingSphere();
|
|
781
|
+
meshGeometries.set(mesh.id, geometry);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const transformById = new Map(payload.transforms.map(item => [item.id, item]));
|
|
785
|
+
for (const geom of payload.geoms) {
|
|
786
|
+
const geometry = primitiveGeometry(geom);
|
|
787
|
+
if (!geometry || geom.rgba[3] <= 0.001) continue;
|
|
788
|
+
const object = new THREE.Mesh(geometry, materialFor(geom));
|
|
789
|
+
object.name = geom.name;
|
|
790
|
+
object.castShadow = geom.type !== 0;
|
|
791
|
+
object.receiveShadow = true;
|
|
792
|
+
applyTransform(object, transformById.get(geom.id));
|
|
793
|
+
if (geom.type === 0) {
|
|
794
|
+
scene.add(object);
|
|
795
|
+
} else {
|
|
796
|
+
modelRoot.add(object);
|
|
797
|
+
}
|
|
798
|
+
objects.set(geom.id, object);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const grid = new THREE.GridHelper(40, 80, 0x52647a, 0x263346);
|
|
802
|
+
grid.rotation.x = Math.PI / 2;
|
|
803
|
+
grid.position.z = 0.001;
|
|
804
|
+
grid.material.opacity = 0.42;
|
|
805
|
+
grid.material.transparent = true;
|
|
806
|
+
scene.add(grid);
|
|
807
|
+
|
|
808
|
+
fitCamera();
|
|
809
|
+
followRobot(payload.tracking_position);
|
|
810
|
+
loadingEl.remove();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function displayStatus(status) {
|
|
814
|
+
const copy = {...status};
|
|
815
|
+
delete copy.transforms;
|
|
816
|
+
delete copy.tracking_position;
|
|
817
|
+
statusEl.textContent = JSON.stringify(copy, null, 2);
|
|
818
|
+
renderMetaEl.textContent = `${status.env_name} · step ${status.step} · ${status.fps_target || 24} Hz physics · display-rate WebGL`;
|
|
819
|
+
if (!controlsInitialized && Array.isArray(status.command)) {
|
|
820
|
+
xInput.value = status.command[0] ?? 0;
|
|
821
|
+
yInput.value = status.command[1] ?? 0;
|
|
822
|
+
yawInput.value = status.command[2] ?? 0;
|
|
823
|
+
controlsInitialized = true;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async function refreshState() {
|
|
828
|
+
clearTimeout(stateTimer);
|
|
829
|
+
try {
|
|
830
|
+
const res = await fetch('/state.json', {cache: 'no-store'});
|
|
831
|
+
if (!res.ok) throw new Error(`state request failed (${res.status})`);
|
|
832
|
+
const status = await res.json();
|
|
833
|
+
updateTransforms(status.transforms);
|
|
834
|
+
followRobot(status.tracking_position);
|
|
835
|
+
displayStatus(status);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
statusEl.textContent = String(err);
|
|
838
|
+
} finally {
|
|
839
|
+
stateTimer = setTimeout(refreshState, targetPollMs);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
async function call(path) {
|
|
844
|
+
const res = await fetch(path, {cache: 'no-store'});
|
|
845
|
+
displayStatus(await res.json());
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async function setCommand() {
|
|
849
|
+
const params = new URLSearchParams({x: xInput.value, y: yInput.value, yaw: yawInput.value});
|
|
850
|
+
await call('/command?' + params.toString());
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
async function clearCommand() {
|
|
854
|
+
controlsInitialized = false;
|
|
855
|
+
await call('/command?clear=1');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
document.getElementById('set-command').addEventListener('click', setCommand);
|
|
859
|
+
document.getElementById('clear-command').addEventListener('click', clearCommand);
|
|
860
|
+
document.getElementById('pause').addEventListener('click', () => call('/pause'));
|
|
861
|
+
document.getElementById('resume').addEventListener('click', () => call('/resume'));
|
|
862
|
+
document.getElementById('reset').addEventListener('click', () => call('/reset'));
|
|
863
|
+
document.getElementById('reset-camera').addEventListener('click', fitCamera);
|
|
864
|
+
window.addEventListener('resize', resize);
|
|
865
|
+
resize();
|
|
866
|
+
animate();
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
await loadScene();
|
|
870
|
+
await refreshState();
|
|
871
|
+
} catch (err) {
|
|
872
|
+
loadingEl.className = 'error';
|
|
873
|
+
loadingEl.textContent = `WebGL preview failed to load.\n${err}\n\nThree.js is loaded from jsDelivr, so an internet connection is required.`;
|
|
874
|
+
statusEl.textContent = String(err);
|
|
875
|
+
console.error(err);
|
|
876
|
+
}
|
|
877
|
+
</script>
|
|
878
|
+
</body>
|
|
879
|
+
</html>
|
|
880
|
+
"""
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _frame_html() -> str:
|
|
885
|
+
return f"""<!doctype html>
|
|
886
|
+
<html>
|
|
887
|
+
<head>
|
|
888
|
+
<meta charset="utf-8">
|
|
889
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
890
|
+
<title>SimRig Preview</title>
|
|
891
|
+
<style>{viewer_styles(sidebar_width=320)}</style>
|
|
892
|
+
</head>
|
|
893
|
+
<body>
|
|
894
|
+
<main>
|
|
895
|
+
<div id="viewport">
|
|
896
|
+
<img id="frame" alt="SimRig rendered frame">
|
|
897
|
+
<div id="hint">Drag to orbit · scroll to zoom</div>
|
|
898
|
+
</div>
|
|
899
|
+
</main>
|
|
900
|
+
<aside>
|
|
901
|
+
<h1>SimRig Preview</h1>
|
|
902
|
+
<label>Forward X</label><input id="x" type="number" step="0.1" value="0">
|
|
903
|
+
<label>Lateral Y</label><input id="y" type="number" step="0.1" value="0">
|
|
904
|
+
<label>Yaw</label><input id="yaw" type="number" step="0.1" value="0">
|
|
905
|
+
<button onclick="setCommand()">Set Command</button>
|
|
906
|
+
<button class="secondary" onclick="clearCommand()">Clear</button>
|
|
907
|
+
<button class="secondary" onclick="call('/pause')">Pause</button>
|
|
908
|
+
<button class="secondary" onclick="call('/resume')">Resume</button>
|
|
909
|
+
<button class="secondary" onclick="call('/reset')">Reset</button>
|
|
910
|
+
<h1 style="margin-top:18px">Status</h1>
|
|
911
|
+
<pre id="status">loading</pre>
|
|
912
|
+
</aside>
|
|
913
|
+
<script>
|
|
914
|
+
const frame = document.getElementById('frame');
|
|
915
|
+
const statusEl = document.getElementById('status');
|
|
916
|
+
const xInput = document.getElementById('x');
|
|
917
|
+
const yInput = document.getElementById('y');
|
|
918
|
+
const yawInput = document.getElementById('yaw');
|
|
919
|
+
let controlsInitialized = false;
|
|
920
|
+
{camera_interaction_script()}
|
|
921
|
+
{frame_poll_script(poll_ms=33)}
|
|
922
|
+
bindCameraControls(frame);
|
|
923
|
+
async function refreshStatus() {{
|
|
924
|
+
try {{
|
|
925
|
+
const res = await fetch('/status.json', {{cache: 'no-store'}});
|
|
926
|
+
const status = await res.json();
|
|
927
|
+
statusEl.textContent = JSON.stringify(status, null, 2);
|
|
928
|
+
applyCameraFromStatus(status);
|
|
929
|
+
if (!controlsInitialized && Array.isArray(status.command)) {{
|
|
930
|
+
xInput.value = status.command[0] ?? 0;
|
|
931
|
+
yInput.value = status.command[1] ?? 0;
|
|
932
|
+
yawInput.value = status.command[2] ?? 0;
|
|
933
|
+
controlsInitialized = true;
|
|
934
|
+
}}
|
|
935
|
+
}} catch (err) {{
|
|
936
|
+
statusEl.textContent = String(err);
|
|
937
|
+
}}
|
|
938
|
+
}}
|
|
939
|
+
async function call(path) {{ await fetch(path); await refreshStatus(); }}
|
|
940
|
+
async function setCommand() {{
|
|
941
|
+
const x = xInput.value;
|
|
942
|
+
const y = yInput.value;
|
|
943
|
+
const yaw = yawInput.value;
|
|
944
|
+
await call(`/command?x=${{x}}&y=${{y}}&yaw=${{yaw}}`);
|
|
945
|
+
}}
|
|
946
|
+
async function clearCommand() {{
|
|
947
|
+
controlsInitialized = false;
|
|
948
|
+
await call('/command?clear=1');
|
|
949
|
+
}}
|
|
950
|
+
setInterval(refreshStatus, 500);
|
|
951
|
+
refreshFrame();
|
|
952
|
+
refreshStatus();
|
|
953
|
+
</script>
|
|
954
|
+
</body>
|
|
955
|
+
</html>
|
|
956
|
+
"""
|