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/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
+ """