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