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/huggingface.py ADDED
@@ -0,0 +1,78 @@
1
+ """Helpers for resolving policy checkpoints from Hugging Face Hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ HF_PREFIX = "hf://"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class HuggingFacePolicyRef:
15
+ """A policy file stored in a Hugging Face Hub repository."""
16
+
17
+ repo_id: str
18
+ filename: str
19
+ revision: str | None = None
20
+
21
+
22
+ def is_huggingface_ref(value: str) -> bool:
23
+ """Return whether a checkpoint string uses SimRig's HF URI form."""
24
+
25
+ return value.startswith(HF_PREFIX)
26
+
27
+
28
+ def parse_huggingface_ref(value: str, *, revision: str | None = None) -> HuggingFacePolicyRef:
29
+ """Parse ``hf://owner/repo/path/to/policy.params`` into Hub download parts."""
30
+
31
+ if not is_huggingface_ref(value):
32
+ raise ValueError(f"Expected Hugging Face policy ref to start with {HF_PREFIX!r}.")
33
+
34
+ body = value[len(HF_PREFIX) :].strip("/")
35
+ parts = [part for part in body.split("/") if part]
36
+ if len(parts) < 3:
37
+ raise ValueError(
38
+ "Hugging Face policy refs must look like "
39
+ "hf://owner/repo/path/to/policy.params."
40
+ )
41
+ return HuggingFacePolicyRef(
42
+ repo_id="/".join(parts[:2]),
43
+ filename="/".join(parts[2:]),
44
+ revision=revision,
45
+ )
46
+
47
+
48
+ def resolve_policy_checkpoint(
49
+ checkpoint: Path | str,
50
+ *,
51
+ hf_revision: str | None = None,
52
+ hf_token: str | None = None,
53
+ ) -> Path:
54
+ """Return a local policy checkpoint path, downloading HF refs when needed."""
55
+
56
+ value = str(checkpoint)
57
+ if not is_huggingface_ref(value):
58
+ return Path(checkpoint)
59
+
60
+ ref = parse_huggingface_ref(value, revision=hf_revision)
61
+ token = hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
62
+
63
+ try:
64
+ from huggingface_hub import hf_hub_download # type: ignore
65
+ except ImportError as exc:
66
+ raise RuntimeError(
67
+ "Hugging Face policy refs require the `huggingface_hub` package. "
68
+ "Install SimRig with `python -m pip install -e \".[hf]\"`, or install "
69
+ "`huggingface_hub` directly."
70
+ ) from exc
71
+
72
+ path = hf_hub_download(
73
+ repo_id=ref.repo_id,
74
+ filename=ref.filename,
75
+ revision=ref.revision,
76
+ token=token,
77
+ )
78
+ return Path(path)
simrig/io.py ADDED
@@ -0,0 +1,49 @@
1
+ """Input/output helpers for reports and run directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from simrig.core import report_markdown, to_dict
12
+
13
+
14
+ def slugify(value: str) -> str:
15
+ slug = re.sub(r"[^a-zA-Z0-9_.-]+", "-", value.strip()).strip("-")
16
+ return slug or "simrig"
17
+
18
+
19
+ def timestamp() -> str:
20
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
21
+
22
+
23
+ def default_run_dir(env_name: str, preset: str, root: Path | str = "runs") -> Path:
24
+ return Path(root) / f"{timestamp()}-{slugify(env_name)}-{slugify(preset)}"
25
+
26
+
27
+ def save_json(path: Path | str, value: Any) -> Path:
28
+ output = Path(path)
29
+ output.parent.mkdir(parents=True, exist_ok=True)
30
+ output.write_text(json.dumps(to_dict(value), indent=2, sort_keys=True) + "\n")
31
+ return output
32
+
33
+
34
+ def save_report_pair(
35
+ report: Any,
36
+ *,
37
+ name: str,
38
+ title: str,
39
+ root: Path | str = "reports",
40
+ ) -> tuple[Path, Path]:
41
+ base = Path(root)
42
+ stem = f"{slugify(name)}_inspection"
43
+ json_path = base / f"{stem}.json"
44
+ md_path = base / f"{stem}.md"
45
+ save_json(json_path, report)
46
+ md_path.parent.mkdir(parents=True, exist_ok=True)
47
+ md_path.write_text(report_markdown(title, report), encoding="utf-8")
48
+ return md_path, json_path
49
+
simrig/live_view.py ADDED
@@ -0,0 +1,553 @@
1
+ """Three.js viewer for MuJoCo simulations owned by ordinary Python scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ from http import HTTPStatus
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ import json
9
+ import threading
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ import numpy as np
14
+
15
+ from simrig.browser_shell import viewer_styles
16
+ from simrig.three_scene import geom_transforms, scene_payload
17
+
18
+
19
+ class LiveWebViewer:
20
+ """Serve an existing ``MjModel``/``MjData`` pair without owning its loop.
21
+
22
+ The calling script remains responsible for control and ``mj_step``. Use
23
+ :attr:`lock` around mutations of ``data`` so HTTP snapshots are consistent.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ model: Any,
29
+ data: Any,
30
+ *,
31
+ name: str = "MuJoCo script",
32
+ host: str = "127.0.0.1",
33
+ port: int = 8767,
34
+ fps: int = 30,
35
+ tracking_body: str | int | None = None,
36
+ mujoco_module: Any | None = None,
37
+ ) -> None:
38
+ if fps < 1:
39
+ raise ValueError("fps must be at least 1")
40
+ if port < 0 or port > 65535:
41
+ raise ValueError("port must be between 0 and 65535")
42
+
43
+ if mujoco_module is None:
44
+ try:
45
+ import mujoco as mujoco_module # type: ignore
46
+ except ImportError as exc:
47
+ raise RuntimeError("LiveWebViewer requires MuJoCo.") from exc
48
+
49
+ self.mujoco = mujoco_module
50
+ self.model = model
51
+ self.data = data
52
+ self.name = name
53
+ self.host = host
54
+ self.port = port
55
+ self.fps = int(fps)
56
+ self.lock = threading.RLock()
57
+ self._tracking_body_id, self._tracking_body_name = self._resolve_tracking_body(
58
+ tracking_body
59
+ )
60
+ self._scene: dict[str, Any] | None = None
61
+ self._status: dict[str, Any] = {}
62
+ self._frame = 0
63
+ self._state = "starting"
64
+ self._client_event = threading.Event()
65
+ self._server: ThreadingHTTPServer | None = None
66
+ self._server_thread: threading.Thread | None = None
67
+
68
+ @property
69
+ def url(self) -> str:
70
+ """Return the browser URL, including an OS-assigned port after start."""
71
+
72
+ port = self.port
73
+ if self._server is not None:
74
+ port = int(self._server.server_address[1])
75
+ display_host = "127.0.0.1" if self.host in ("0.0.0.0", "::") else self.host
76
+ return f"http://{display_host}:{port}/"
77
+
78
+ @property
79
+ def is_running(self) -> bool:
80
+ return self._server is not None
81
+
82
+ def start(self) -> "LiveWebViewer":
83
+ """Start the local HTTP server in a background thread."""
84
+
85
+ if self._server is not None:
86
+ return self
87
+
88
+ viewer = self
89
+
90
+ class Handler(BaseHTTPRequestHandler):
91
+ def do_GET(self) -> None: # noqa: N802
92
+ parsed = urlparse(self.path)
93
+ if parsed.path == "/":
94
+ self._send_bytes(_live_html().encode("utf-8"), "text/html; charset=utf-8")
95
+ elif parsed.path == "/scene.json":
96
+ viewer._note_client()
97
+ self._send_json(viewer.scene_payload(), compress=True)
98
+ elif parsed.path == "/state.json":
99
+ viewer._note_client()
100
+ self._send_json(viewer.state_payload())
101
+ else:
102
+ self.send_error(HTTPStatus.NOT_FOUND, "Not found")
103
+
104
+ def log_message(self, format: str, *args: Any) -> None:
105
+ return
106
+
107
+ def _send_json(self, value: dict[str, Any], *, compress: bool = False) -> None:
108
+ body = json.dumps(value, separators=(",", ":")).encode("utf-8")
109
+ if compress and "gzip" in self.headers.get("Accept-Encoding", ""):
110
+ self._send_bytes(
111
+ gzip.compress(body, compresslevel=5),
112
+ "application/json; charset=utf-8",
113
+ content_encoding="gzip",
114
+ )
115
+ return
116
+ self._send_bytes(body, "application/json; charset=utf-8")
117
+
118
+ def _send_bytes(
119
+ self,
120
+ body: bytes,
121
+ content_type: str,
122
+ *,
123
+ content_encoding: str | None = None,
124
+ ) -> None:
125
+ self.send_response(HTTPStatus.OK)
126
+ self.send_header("Content-Type", content_type)
127
+ self.send_header("Cache-Control", "no-store")
128
+ if content_encoding is not None:
129
+ self.send_header("Content-Encoding", content_encoding)
130
+ self.send_header("Content-Length", str(len(body)))
131
+ self.end_headers()
132
+ self.wfile.write(body)
133
+
134
+ self._server = ThreadingHTTPServer((self.host, self.port), Handler)
135
+ self._server.daemon_threads = True
136
+ self.port = int(self._server.server_address[1])
137
+ self._state = "running"
138
+ self._server_thread = threading.Thread(
139
+ target=self._server.serve_forever,
140
+ name="simrig-live-web-viewer",
141
+ daemon=True,
142
+ )
143
+ self._server_thread.start()
144
+ print(f"SimRig live viewer: {self.url}")
145
+ return self
146
+
147
+ def close(self) -> None:
148
+ """Stop the HTTP server. Safe to call more than once."""
149
+
150
+ server = self._server
151
+ thread = self._server_thread
152
+ if server is None:
153
+ return
154
+ with self.lock:
155
+ self._state = "closed"
156
+ server.shutdown()
157
+ server.server_close()
158
+ if thread is not None and thread is not threading.current_thread():
159
+ thread.join(timeout=2.0)
160
+ self._server = None
161
+ self._server_thread = None
162
+
163
+ def sync(self, **status: Any) -> None:
164
+ """Record a completed simulation step and optional status fields."""
165
+
166
+ with self.lock:
167
+ self._frame += 1
168
+ self._status.update(status)
169
+
170
+ def update_status(self, **status: Any) -> None:
171
+ """Publish script-specific scalar or JSON-compatible metadata."""
172
+
173
+ with self.lock:
174
+ self._status.update(status)
175
+
176
+ def mark_complete(self, **status: Any) -> None:
177
+ """Mark the script complete while keeping its last pose available."""
178
+
179
+ with self.lock:
180
+ self._state = "complete"
181
+ self._status.update(status)
182
+
183
+ def wait_for_client(self, timeout: float | None = None) -> bool:
184
+ """Wait until the browser requests the Three.js scene or live state."""
185
+
186
+ return self._client_event.wait(timeout)
187
+
188
+ def scene_payload(self) -> dict[str, Any]:
189
+ """Return static geometry and the current initial transforms."""
190
+
191
+ with self.lock:
192
+ if self._scene is None:
193
+ self._scene = scene_payload(
194
+ self.mujoco,
195
+ self.model,
196
+ self.data,
197
+ model_name=self.name,
198
+ )
199
+ self._scene.pop("transforms", None)
200
+ return {
201
+ **self._scene,
202
+ "transforms": geom_transforms(self.model, self.data),
203
+ "tracking_position": self._tracking_position(),
204
+ "tracking_body": self._tracking_body_name,
205
+ "fps_target": self.fps,
206
+ }
207
+
208
+ def state_payload(self) -> dict[str, Any]:
209
+ """Return the latest live transforms and script metadata."""
210
+
211
+ with self.lock:
212
+ return {
213
+ "name": self.name,
214
+ "state": self._state,
215
+ "time": float(self.data.time),
216
+ "frame": self._frame,
217
+ "viewer_connected": self._client_event.is_set(),
218
+ "fps_target": self.fps,
219
+ "tracking_body": self._tracking_body_name,
220
+ "tracking_position": self._tracking_position(),
221
+ "transforms": geom_transforms(self.model, self.data),
222
+ **self._status,
223
+ }
224
+
225
+ def __enter__(self) -> "LiveWebViewer":
226
+ return self.start()
227
+
228
+ def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
229
+ self.close()
230
+
231
+ def _resolve_tracking_body(self, body: str | int | None) -> tuple[int | None, str | None]:
232
+ if body is None:
233
+ return None, None
234
+ if isinstance(body, int):
235
+ body_id = body
236
+ if body_id < 0 or body_id >= self.model.nbody:
237
+ raise ValueError(f"Invalid tracking body id: {body_id}")
238
+ name = self.mujoco.mj_id2name(
239
+ self.model, self.mujoco.mjtObj.mjOBJ_BODY, body_id
240
+ )
241
+ return body_id, name or f"body_{body_id}"
242
+ body_id = self.mujoco.mj_name2id(
243
+ self.model, self.mujoco.mjtObj.mjOBJ_BODY, body
244
+ )
245
+ if body_id < 0:
246
+ raise ValueError(f"Model has no body named {body!r}")
247
+ return int(body_id), body
248
+
249
+ def _tracking_position(self) -> list[float] | None:
250
+ if self._tracking_body_id is None:
251
+ return None
252
+ return np.asarray(
253
+ self.data.xpos[self._tracking_body_id], dtype=float
254
+ ).tolist()
255
+
256
+ def _note_client(self) -> None:
257
+ self._client_event.set()
258
+
259
+
260
+ def _live_html() -> str:
261
+ return (
262
+ """<!doctype html>
263
+ <html>
264
+ <head>
265
+ <meta charset="utf-8">
266
+ <meta name="viewport" content="width=device-width, initial-scale=1">
267
+ <title>SimRig Live</title>
268
+ <style>"""
269
+ + viewer_styles(sidebar_width=320)
270
+ + """
271
+ #three-view { width: 100%; height: 100%; display: block; outline: none; }
272
+ #loading { position: absolute; inset: 0; display: grid; place-items: center; color: #cbd5e1; background: #070b12; z-index: 2; }
273
+ #loading.error { color: #fca5a5; padding: 28px; text-align: center; white-space: pre-wrap; }
274
+ #render-meta { color: #94a3b8; font-size: 12px; margin: -4px 0 12px; }
275
+ </style>
276
+ <script type="importmap">
277
+ {"imports": {
278
+ "three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
279
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/"
280
+ }}
281
+ </script>
282
+ </head>
283
+ <body>
284
+ <main>
285
+ <div id="viewport">
286
+ <canvas id="three-view" aria-label="Interactive live MuJoCo script"></canvas>
287
+ <div id="loading">Loading live scene…</div>
288
+ <div id="hint">Drag to orbit · scroll to zoom · right-drag to pan</div>
289
+ </div>
290
+ </main>
291
+ <aside>
292
+ <h1>SimRig Live</h1>
293
+ <div id="render-meta">Three.js · connecting to script…</div>
294
+ <button class="secondary" id="reset-camera">Reset Camera</button>
295
+ <button class="secondary" id="clear-trail">Clear Trail</button>
296
+ <h1 style="margin-top:18px">Script State</h1>
297
+ <pre id="status">loading</pre>
298
+ </aside>
299
+ <script type="module">
300
+ import * as THREE from 'three';
301
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
302
+
303
+ const canvas = document.getElementById('three-view');
304
+ const viewport = document.getElementById('viewport');
305
+ const loadingEl = document.getElementById('loading');
306
+ const statusEl = document.getElementById('status');
307
+ const renderMetaEl = document.getElementById('render-meta');
308
+ const objects = new Map();
309
+ const meshGeometries = new Map();
310
+ const trailPoints = [];
311
+ let trail = null;
312
+ let stateTimer = null;
313
+ let targetPollMs = 33;
314
+
315
+ const renderer = new THREE.WebGLRenderer({canvas, antialias: true, alpha: false});
316
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
317
+ renderer.shadowMap.enabled = true;
318
+ renderer.shadowMap.type = THREE.PCFShadowMap;
319
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
320
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
321
+ renderer.toneMappingExposure = 1.0;
322
+ renderer.setClearColor(0x0b1220, 1);
323
+
324
+ const scene = new THREE.Scene();
325
+ scene.background = new THREE.Color(0x0b1220);
326
+ scene.fog = new THREE.Fog(0x0b1220, 8, 26);
327
+
328
+ const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 1000);
329
+ camera.up.set(0, 0, 1);
330
+ const orbit = new OrbitControls(camera, canvas);
331
+ orbit.enableDamping = true;
332
+ orbit.dampingFactor = 0.075;
333
+ orbit.screenSpacePanning = false;
334
+ orbit.minDistance = 0.08;
335
+ orbit.maxDistance = 100;
336
+ orbit.minPolarAngle = 0.08;
337
+ orbit.maxPolarAngle = Math.PI / 2 - 0.04;
338
+
339
+ scene.add(new THREE.HemisphereLight(0xbfdcff, 0x172033, 1.15));
340
+ const keyLight = new THREE.DirectionalLight(0xffffff, 2.35);
341
+ keyLight.position.set(4, -5, 8);
342
+ keyLight.castShadow = true;
343
+ keyLight.shadow.mapSize.set(2048, 2048);
344
+ keyLight.shadow.camera.near = 0.1;
345
+ keyLight.shadow.camera.far = 30;
346
+ keyLight.shadow.camera.left = -5;
347
+ keyLight.shadow.camera.right = 5;
348
+ keyLight.shadow.camera.top = 5;
349
+ keyLight.shadow.camera.bottom = -5;
350
+ keyLight.shadow.bias = -0.0002;
351
+ scene.add(keyLight, keyLight.target);
352
+ const rimLight = new THREE.DirectionalLight(0x7aa8ff, 0.85);
353
+ rimLight.position.set(-5, 3, 5);
354
+ scene.add(rimLight);
355
+
356
+ const modelRoot = new THREE.Group();
357
+ scene.add(modelRoot);
358
+
359
+ function materialFor(geom) {
360
+ const [r, g, b, a] = geom.rgba;
361
+ const props = geom.material || {};
362
+ if (geom.type === 0) {
363
+ return new THREE.MeshStandardMaterial({color: 0x182231, roughness: 0.92});
364
+ }
365
+ const material = new THREE.MeshPhysicalMaterial({
366
+ color: new THREE.Color(r, g, b), opacity: a, transparent: a < 0.999,
367
+ roughness: THREE.MathUtils.clamp(0.68 - (props.shininess || 0) * 0.32, 0.22, 0.82),
368
+ metalness: THREE.MathUtils.clamp((props.reflectance || 0) * 0.45, 0, 0.35),
369
+ clearcoat: THREE.MathUtils.clamp((props.specular || 0) * 0.35, 0, 0.4),
370
+ clearcoatRoughness: 0.35,
371
+ });
372
+ if ((props.emission || 0) > 0) {
373
+ material.emissive.setRGB(r, g, b);
374
+ material.emissiveIntensity = props.emission;
375
+ }
376
+ return material;
377
+ }
378
+
379
+ function primitiveGeometry(geom) {
380
+ const [x, y, z] = geom.size;
381
+ switch (geom.type) {
382
+ case 0: return new THREE.PlaneGeometry(200, 200);
383
+ case 2: return new THREE.SphereGeometry(x, 32, 20);
384
+ case 3: {
385
+ const geometry = new THREE.CapsuleGeometry(x, 2 * y, 10, 24);
386
+ geometry.rotateX(Math.PI / 2);
387
+ return geometry;
388
+ }
389
+ case 4: {
390
+ const geometry = new THREE.SphereGeometry(1, 32, 20);
391
+ geometry.scale(x, y, z);
392
+ return geometry;
393
+ }
394
+ case 5: {
395
+ const geometry = new THREE.CylinderGeometry(x, x, 2 * y, 32);
396
+ geometry.rotateX(Math.PI / 2);
397
+ return geometry;
398
+ }
399
+ case 6: return new THREE.BoxGeometry(2 * x, 2 * y, 2 * z);
400
+ case 7: return meshGeometries.get(geom.mesh_id) || null;
401
+ default: return null;
402
+ }
403
+ }
404
+
405
+ function applyTransform(object, transform) {
406
+ if (!object || !transform) return;
407
+ object.position.fromArray(transform.position);
408
+ const m = transform.matrix;
409
+ const rotation = new THREE.Matrix4();
410
+ rotation.set(
411
+ m[0], m[1], m[2], 0, m[3], m[4], m[5], 0,
412
+ m[6], m[7], m[8], 0, 0, 0, 0, 1,
413
+ );
414
+ object.quaternion.setFromRotationMatrix(rotation);
415
+ }
416
+
417
+ function updateTransforms(transforms) {
418
+ for (const transform of transforms || []) applyTransform(objects.get(transform.id), transform);
419
+ }
420
+
421
+ function updateTrail(rawPosition) {
422
+ if (!Array.isArray(rawPosition)) return;
423
+ const next = new THREE.Vector3().fromArray(rawPosition);
424
+ if (trailPoints.length === 0 || trailPoints[trailPoints.length - 1].distanceTo(next) > 0.001) {
425
+ trailPoints.push(next.clone());
426
+ if (trailPoints.length > 4000) trailPoints.shift();
427
+ if (trail === null) {
428
+ trail = new THREE.Line(
429
+ new THREE.BufferGeometry(),
430
+ new THREE.LineBasicMaterial({color: 0x38bdf8}),
431
+ );
432
+ scene.add(trail);
433
+ }
434
+ trail.geometry.dispose();
435
+ trail.geometry = new THREE.BufferGeometry().setFromPoints(trailPoints);
436
+ }
437
+ }
438
+
439
+ function clearTrail() {
440
+ trailPoints.length = 0;
441
+ if (trail !== null) {
442
+ trail.geometry.dispose();
443
+ scene.remove(trail);
444
+ trail = null;
445
+ }
446
+ }
447
+
448
+ function fitCamera() {
449
+ const bounds = new THREE.Box3().setFromObject(modelRoot);
450
+ const center = bounds.getCenter(new THREE.Vector3());
451
+ const size = bounds.getSize(new THREE.Vector3());
452
+ const radius = Math.max(size.x, size.y, size.z, 0.25);
453
+ orbit.target.copy(center);
454
+ camera.position.set(center.x + radius * 1.35, center.y - radius * 1.75, center.z + radius * 0.95);
455
+ camera.near = Math.max(radius / 200, 0.002);
456
+ camera.far = Math.max(radius * 80, 100);
457
+ camera.updateProjectionMatrix();
458
+ orbit.update();
459
+ }
460
+
461
+ function resize() {
462
+ const width = Math.max(1, viewport.clientWidth);
463
+ const height = Math.max(1, viewport.clientHeight);
464
+ renderer.setSize(width, height, false);
465
+ camera.aspect = width / height;
466
+ camera.updateProjectionMatrix();
467
+ }
468
+
469
+ function animate() {
470
+ orbit.update();
471
+ renderer.render(scene, camera);
472
+ requestAnimationFrame(animate);
473
+ }
474
+
475
+ async function loadScene() {
476
+ const res = await fetch('/scene.json', {cache: 'no-store'});
477
+ if (!res.ok) throw new Error(`scene request failed (${res.status})`);
478
+ const payload = await res.json();
479
+ targetPollMs = Math.max(16, Math.round(1000 / (payload.fps_target || 30)));
480
+ for (const mesh of payload.meshes) {
481
+ const geometry = new THREE.BufferGeometry();
482
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(mesh.vertices, 3));
483
+ geometry.setIndex(mesh.indices);
484
+ geometry.computeVertexNormals();
485
+ geometry.computeBoundingSphere();
486
+ meshGeometries.set(mesh.id, geometry);
487
+ }
488
+ const transformById = new Map(payload.transforms.map(item => [item.id, item]));
489
+ for (const geom of payload.geoms) {
490
+ const geometry = primitiveGeometry(geom);
491
+ if (!geometry || geom.rgba[3] <= 0.001) continue;
492
+ const object = new THREE.Mesh(geometry, materialFor(geom));
493
+ object.name = geom.name;
494
+ object.castShadow = geom.type !== 0;
495
+ object.receiveShadow = true;
496
+ applyTransform(object, transformById.get(geom.id));
497
+ if (geom.type === 0) scene.add(object); else modelRoot.add(object);
498
+ objects.set(geom.id, object);
499
+ }
500
+ const grid = new THREE.GridHelper(40, 80, 0x52647a, 0x263346);
501
+ grid.rotation.x = Math.PI / 2;
502
+ grid.position.z = 0.001;
503
+ grid.material.opacity = 0.42;
504
+ grid.material.transparent = true;
505
+ scene.add(grid);
506
+ fitCamera();
507
+ updateTrail(payload.tracking_position);
508
+ loadingEl.remove();
509
+ }
510
+
511
+ function displayStatus(status) {
512
+ const copy = {...status};
513
+ delete copy.transforms;
514
+ delete copy.tracking_position;
515
+ statusEl.textContent = JSON.stringify(copy, null, 2);
516
+ renderMetaEl.textContent = `${status.name} · ${status.time.toFixed(2)} s · frame ${status.frame}`;
517
+ }
518
+
519
+ async function refreshState() {
520
+ clearTimeout(stateTimer);
521
+ try {
522
+ const res = await fetch('/state.json', {cache: 'no-store'});
523
+ if (!res.ok) throw new Error(`state request failed (${res.status})`);
524
+ const status = await res.json();
525
+ updateTransforms(status.transforms);
526
+ updateTrail(status.tracking_position);
527
+ displayStatus(status);
528
+ } catch (err) {
529
+ statusEl.textContent = String(err);
530
+ } finally {
531
+ stateTimer = setTimeout(refreshState, targetPollMs);
532
+ }
533
+ }
534
+
535
+ document.getElementById('reset-camera').addEventListener('click', fitCamera);
536
+ document.getElementById('clear-trail').addEventListener('click', clearTrail);
537
+ window.addEventListener('resize', resize);
538
+ resize();
539
+ animate();
540
+ try {
541
+ await loadScene();
542
+ await refreshState();
543
+ } catch (err) {
544
+ loadingEl.className = 'error';
545
+ loadingEl.textContent = `WebGL viewer failed to load.\n${err}\n\nThree.js is loaded from jsDelivr, so an internet connection is required.`;
546
+ statusEl.textContent = String(err);
547
+ console.error(err);
548
+ }
549
+ </script>
550
+ </body>
551
+ </html>
552
+ """
553
+ )