[software] 添加16DOF早期训练仿真与Sim2Real闭环
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
"""Minimal HTTP + SSE server for the sim2real web console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from web.session import RobotSession # noqa: E402
|
||||
|
||||
|
||||
SESSION: "RobotSession" = None # type: ignore
|
||||
|
||||
|
||||
def make_real_factory():
|
||||
def outer():
|
||||
def factory(can1_port, can2_port, debug):
|
||||
sim2real_root = Path(__file__).resolve().parents[1]
|
||||
for path in (
|
||||
sim2real_root / "vendored",
|
||||
"/home/rc2/work/rcwork/control",
|
||||
"/home/rc2/work/rcwork",
|
||||
):
|
||||
path_str = str(path)
|
||||
if path_str not in sys.path and Path(path).exists():
|
||||
sys.path.append(path_str)
|
||||
from drivers.motor_driver import RobStrideDriver # type: ignore
|
||||
|
||||
return RobStrideDriver(can1_port, debug), RobStrideDriver(can2_port, debug)
|
||||
|
||||
return factory
|
||||
|
||||
return outer
|
||||
|
||||
|
||||
def make_dry_factory():
|
||||
def outer():
|
||||
class MockMotor:
|
||||
def __init__(self):
|
||||
class State:
|
||||
position = 0.0
|
||||
velocity = 0.0
|
||||
torque = 0.0
|
||||
|
||||
self.state = State()
|
||||
|
||||
class MockDriver:
|
||||
def __init__(self, port, debug):
|
||||
self.port = port
|
||||
self.motors = {}
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def disconnect(self):
|
||||
pass
|
||||
|
||||
def add_motor(self, name, motor_id, model):
|
||||
self.motors[name] = MockMotor()
|
||||
|
||||
def enable(self, name):
|
||||
pass
|
||||
|
||||
def disable(self, name):
|
||||
pass
|
||||
|
||||
def clear_warnings(self, name):
|
||||
pass
|
||||
|
||||
def process_messages(self):
|
||||
pass
|
||||
|
||||
def control_mit(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def factory(can1_port, can2_port, debug):
|
||||
return MockDriver(can1_port, debug), MockDriver(can2_port, debug)
|
||||
|
||||
return factory
|
||||
|
||||
return outer
|
||||
|
||||
|
||||
def _send_json(handler: BaseHTTPRequestHandler, code: int, obj):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
handler.send_response(code)
|
||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def _send_static(handler: BaseHTTPRequestHandler, path: Path, content_type: str):
|
||||
if not path.exists():
|
||||
handler.send_error(404, str(path))
|
||||
return
|
||||
body = path.read_bytes()
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", content_type)
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "Sim2RealConsole/1.1"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
if "GET /events" in (fmt % args):
|
||||
return
|
||||
super().log_message(fmt, *args)
|
||||
|
||||
def do_GET(self):
|
||||
url = urlparse(self.path)
|
||||
if url.path in ("/", "/index.html"):
|
||||
return _send_static(self, Path(__file__).parent / "static" / "index.html", "text/html; charset=utf-8")
|
||||
if url.path == "/static/app.js":
|
||||
return _send_static(self, Path(__file__).parent / "static" / "app.js", "application/javascript; charset=utf-8")
|
||||
if url.path == "/static/style.css":
|
||||
return _send_static(self, Path(__file__).parent / "static" / "style.css", "text/css; charset=utf-8")
|
||||
if url.path.startswith("/static/viewer/"):
|
||||
viewer_file = url.path.split("/static/viewer/", 1)[1]
|
||||
viewer_path = Path(__file__).parent / "static" / "viewer" / viewer_file
|
||||
content_type = "text/javascript" if not viewer_file.endswith(".css") else "text/css"
|
||||
return _send_static(self, viewer_path, content_type)
|
||||
if url.path.startswith("/meshes/"):
|
||||
mesh_name = url.path.split("/meshes/", 1)[1]
|
||||
mesh_path = Path(__file__).resolve().parents[1] / "mjcf" / "meshes" / mesh_name
|
||||
if not mesh_path.exists():
|
||||
return self.send_error(404, f"mesh not found: {mesh_name}")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(mesh_path.stat().st_size))
|
||||
self.send_header("Cache-Control", "max-age=3600")
|
||||
self.end_headers()
|
||||
with open(mesh_path, "rb") as file_obj:
|
||||
while True:
|
||||
chunk = file_obj.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
return
|
||||
if url.path.startswith("/mjcf/"):
|
||||
mjcf_name = url.path.split("/mjcf/", 1)[1]
|
||||
mjcf_path = Path(__file__).resolve().parents[1] / "mjcf" / mjcf_name
|
||||
if not mjcf_path.exists():
|
||||
return self.send_error(404, f"mjcf not found: {mjcf_name}")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/xml; charset=utf-8")
|
||||
self.send_header("Content-Length", str(mjcf_path.stat().st_size))
|
||||
self.end_headers()
|
||||
self.wfile.write(mjcf_path.read_bytes())
|
||||
return
|
||||
if url.path == "/api/status":
|
||||
return _send_json(self, 200, SESSION.get_status())
|
||||
if url.path == "/api/debug":
|
||||
return _send_json(self, 200, SESSION.get_debug_snapshot())
|
||||
if url.path == "/api/logs":
|
||||
return _send_json(self, 200, {"sessions": SESSION.list_logs()})
|
||||
if url.path.startswith("/api/logs/"):
|
||||
parts = url.path.split("/")
|
||||
if len(parts) >= 5:
|
||||
session_id = parts[3]
|
||||
filename = parts[4]
|
||||
file_path = Path(SESSION.cfg.get("log_dir", "logs")) / session_id / filename
|
||||
if file_path.exists() and filename in ("state.csv", "events.jsonl"):
|
||||
self.send_response(200)
|
||||
self.send_header(
|
||||
"Content-Type",
|
||||
"text/csv" if filename.endswith("csv") else "application/json",
|
||||
)
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{session_id}_{filename}"')
|
||||
self.send_header("Content-Length", str(file_path.stat().st_size))
|
||||
self.end_headers()
|
||||
with open(file_path, "rb") as file_obj:
|
||||
while True:
|
||||
chunk = file_obj.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
return
|
||||
return self.send_error(404)
|
||||
if url.path == "/events":
|
||||
return self._handle_sse()
|
||||
return self.send_error(404, self.path)
|
||||
|
||||
def do_POST(self):
|
||||
url = urlparse(self.path)
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length) if length else b""
|
||||
data = json.loads(body) if body else {}
|
||||
except Exception as exc:
|
||||
SESSION.note_api_error()
|
||||
return _send_json(self, 400, {"error": f"bad body: {exc}"})
|
||||
|
||||
try:
|
||||
result = self._handle_post(url.path, data)
|
||||
except Exception as exc:
|
||||
SESSION.note_api_error()
|
||||
return _send_json(
|
||||
self,
|
||||
500,
|
||||
{
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"traceback": traceback.format_exc(),
|
||||
},
|
||||
)
|
||||
if result is None:
|
||||
return self.send_error(404)
|
||||
return _send_json(self, 200, {"ok": True, **(result if isinstance(result, dict) else {})})
|
||||
|
||||
def _handle_post(self, path: str, data: dict):
|
||||
if path == "/api/connect":
|
||||
return {"queued": SESSION.connect(dry_run=bool(data.get("dry_run", False)))}
|
||||
if path == "/api/disconnect":
|
||||
return {"queued": SESSION.disconnect()}
|
||||
if path == "/api/enable":
|
||||
return {"queued": SESSION.enable_motors()}
|
||||
if path == "/api/disable":
|
||||
return {"queued": SESSION.disable_motors()}
|
||||
if path == "/api/test_motor":
|
||||
return {
|
||||
"queued": SESSION.test_motor(
|
||||
leg=data["leg"],
|
||||
joint=data["joint"],
|
||||
delta_rad=float(data.get("delta_rad", 0.1)),
|
||||
kp=float(data.get("kp", 5.0)),
|
||||
kd=float(data.get("kd", 1.0)),
|
||||
duration_s=float(data.get("duration_s", 1.0)),
|
||||
)
|
||||
}
|
||||
if path == "/api/calibrate_offsets":
|
||||
return {
|
||||
"queued": SESSION.calibrate_offsets(
|
||||
target_pose_name=data.get("target_pose", "stand"),
|
||||
samples=int(data.get("samples", 100)),
|
||||
)
|
||||
}
|
||||
if path == "/api/startup":
|
||||
return {"queued": SESSION.startup()}
|
||||
if path == "/api/runtime/start":
|
||||
return {"queued": SESSION.runtime_start(policy_path=data.get("policy_path"))}
|
||||
if path == "/api/runtime/stop":
|
||||
return {"queued": SESSION.runtime_stop()}
|
||||
if path == "/api/cmd":
|
||||
SESSION.set_command(
|
||||
vx=float(data.get("vx", 0.0)),
|
||||
vy=float(data.get("vy", 0.0)),
|
||||
yaw=float(data.get("yaw", 0.0)),
|
||||
)
|
||||
return {}
|
||||
if path == "/api/estop":
|
||||
SESSION.estop()
|
||||
return {}
|
||||
if path == "/api/reset_estop":
|
||||
SESSION.reset_estop()
|
||||
return {}
|
||||
return None
|
||||
|
||||
def _handle_sse(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "keep-alive")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
|
||||
event_queue: "queue.Queue" = queue.Queue(maxsize=1024)
|
||||
SESSION.add_listener(event_queue)
|
||||
try:
|
||||
initial = {"kind": "STATUS_FULL", **SESSION.get_status()}
|
||||
self.wfile.write(f"data: {json.dumps(initial, ensure_ascii=False)}\n\n".encode())
|
||||
self.wfile.flush()
|
||||
last_keepalive = time.time()
|
||||
while True:
|
||||
try:
|
||||
event = event_queue.get(timeout=1.0)
|
||||
self.wfile.write(f"data: {json.dumps(event, ensure_ascii=False)}\n\n".encode())
|
||||
self.wfile.flush()
|
||||
except queue.Empty:
|
||||
if time.time() - last_keepalive > 15:
|
||||
self.wfile.write(b": keepalive\n\n")
|
||||
self.wfile.flush()
|
||||
last_keepalive = time.time()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
SESSION.remove_listener(event_queue)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--config", default=str(Path(__file__).resolve().parents[1] / "config.yaml"))
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg_path = Path(args.config)
|
||||
with open(cfg_path, "r", encoding="utf-8") as file_obj:
|
||||
cfg = yaml.safe_load(file_obj)
|
||||
|
||||
global SESSION
|
||||
SESSION = RobotSession(
|
||||
cfg=cfg,
|
||||
cfg_path=cfg_path,
|
||||
driver_factory_real=make_real_factory(),
|
||||
driver_factory_dry=make_dry_factory(),
|
||||
)
|
||||
|
||||
def _pulse():
|
||||
while True:
|
||||
try:
|
||||
SESSION._broadcast({"kind": "PULSE", **SESSION.get_status()})
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
|
||||
threading.Thread(target=_pulse, daemon=True).start()
|
||||
|
||||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
print(f"\n[Web] sim2real console -> http://{args.host}:{args.port}\n")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[Web] Ctrl+C received, shutting down...")
|
||||
finally:
|
||||
try:
|
||||
SESSION.estop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
SESSION._do_disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
const SIM_JOINT_ORDER = [
|
||||
["fl", "hip_abduction"], ["fl", "hip_pitch"], ["fl", "knee"],
|
||||
["fr", "hip_abduction"], ["fr", "hip_pitch"], ["fr", "knee"],
|
||||
["rl", "hip_abduction"], ["rl", "hip_pitch"], ["rl", "knee"],
|
||||
["rr", "hip_abduction"], ["rr", "hip_pitch"], ["rr", "knee"],
|
||||
["fl", "wheel"], ["fr", "wheel"], ["rl", "wheel"], ["rr", "wheel"],
|
||||
];
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const PLOTS = {};
|
||||
let CURRENT_STATUS = null;
|
||||
let SSE_CONN = null;
|
||||
let SSE_RECONNECT_TIMER = null;
|
||||
let LAST_RENDER_TS = 0;
|
||||
|
||||
async function api(path, body = null) {
|
||||
const options = { method: body ? "POST" : "GET" };
|
||||
if (body) {
|
||||
options.headers = { "Content-Type": "application/json" };
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(path, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function safeText(value, fallback = "--") {
|
||||
return value === undefined || value === null || Number.isNaN(value) ? fallback : value;
|
||||
}
|
||||
|
||||
function appendEvent(ev) {
|
||||
const el = $("events-log");
|
||||
if (!el) return;
|
||||
const item = document.createElement("div");
|
||||
let cls = "ev-name";
|
||||
if (/ERROR|STOP|NAN/.test(ev.kind || "")) cls = "ev-stop";
|
||||
else if (/FAULT|BRAKE/.test(ev.kind || "")) cls = "ev-fault";
|
||||
else if (/DONE|CONNECTED|ENABLED|PRIMED/.test(ev.kind || "")) cls = "ev-ok";
|
||||
const t = ev.t ? new Date(ev.t * 1000).toLocaleTimeString() : new Date().toLocaleTimeString();
|
||||
const detail = Object.entries(ev)
|
||||
.filter(([k]) => !["t", "kind"].includes(k))
|
||||
.slice(0, 6)
|
||||
.map(([k, v]) => `${k}=${typeof v === "number" ? v.toFixed(3) : JSON.stringify(v).slice(0, 80)}`)
|
||||
.join(" ");
|
||||
item.innerHTML = `<span class="ev-t">${t}</span> <span class="${cls}">${ev.kind}</span> <span style="color:#8e8e93">${detail}</span>`;
|
||||
el.appendChild(item);
|
||||
while (el.children.length > 300) el.removeChild(el.firstChild);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function setStage(stage, detail) {
|
||||
const el = $("stage");
|
||||
if (!el) return;
|
||||
el.textContent = stage + (detail ? ` · ${detail}` : "");
|
||||
el.className = "stage " + stage;
|
||||
}
|
||||
|
||||
function setButtonEnabled(id, enabled) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.disabled = !enabled;
|
||||
}
|
||||
|
||||
function updateButtons(status) {
|
||||
if (!status) return;
|
||||
const stage = status.stage || "DISCONNECTED";
|
||||
const busy = !!status.busy;
|
||||
const runtime = stage === "RUNTIME";
|
||||
const connected = stage !== "DISCONNECTED" && stage !== "CONNECTING";
|
||||
const enabled = ["ENABLED", "STARTING_UP", "STAND_HOLD", "RUNTIME"].includes(stage);
|
||||
const canStartup = stage === "ENABLED";
|
||||
const canRuntimeStart = stage === "STAND_HOLD";
|
||||
const canRuntimeStop = runtime;
|
||||
|
||||
setButtonEnabled("btn-connect", !busy && stage === "DISCONNECTED");
|
||||
setButtonEnabled("btn-disconnect", !busy && connected);
|
||||
setButtonEnabled("btn-enable", !busy && ["CONNECTED", "FAULTED"].includes(stage));
|
||||
setButtonEnabled("btn-disable", !busy && enabled);
|
||||
setButtonEnabled("btn-startup", !busy && canStartup);
|
||||
setButtonEnabled("btn-runtime-start", !busy && canRuntimeStart);
|
||||
setButtonEnabled("btn-runtime-stop", !busy && canRuntimeStop);
|
||||
setButtonEnabled("btn-reset-estop", !busy && stage === "ESTOPPED");
|
||||
setButtonEnabled("btn-estop", connected);
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
const el = $("state-summary");
|
||||
if (!el) return;
|
||||
if (!state) {
|
||||
el.innerHTML = '<div class="state-item"><span class="k">STATUS</span><span class="v">NO DATA</span></div>';
|
||||
return;
|
||||
}
|
||||
const metric = (k, v, cls = "") =>
|
||||
`<div class="state-item"><span class="k">${k}</span><span class="v ${cls}">${v}</span></div>`;
|
||||
const safetyText = ["NORMAL", "CLIP", "BRAKE", "ESTOP"][state.safety_level || 0];
|
||||
const guardText = ["NORMAL", "WARN", "STOP"][state.guard_level || 0] || "NORMAL";
|
||||
const imuCls = (state.imu_age_ms || 0) > 60 ? "bad" : (state.imu_age_ms || 0) > 30 ? "warn" : "";
|
||||
const dtCls = (state.loop_dt_ms || 0) > 25 ? "bad" : (state.loop_dt_ms || 0) > 22 ? "warn" : "";
|
||||
const gravityZ = state.proj_gravity?.[2] ?? -1;
|
||||
const gravityCls = gravityZ > -0.5 ? "warn" : "";
|
||||
const rawMax = Math.max(...(state.raw || [0]).map((x) => Math.abs(x || 0)));
|
||||
const trackingErr = Math.max(
|
||||
...(state.joint_pos || []).slice(0, 12).map((pos, i) => Math.abs(pos - ((state.target || [])[i] || 0))),
|
||||
0,
|
||||
);
|
||||
el.innerHTML = [
|
||||
metric("phase", safeText(state.phase, "?")),
|
||||
metric("imu_age", `${(state.imu_age_ms || 0).toFixed(1)} ms`, imuCls),
|
||||
metric("loop_dt", `${(state.loop_dt_ms || 0).toFixed(1)} ms`, dtCls),
|
||||
metric("safety", safetyText, state.safety_level >= 2 ? "bad" : state.safety_level === 1 ? "warn" : ""),
|
||||
metric("guard", guardText, state.guard_level >= 2 ? "bad" : state.guard_level === 1 ? "warn" : ""),
|
||||
metric("holdover", String(state.holdover_total || 0)),
|
||||
metric("raw max", rawMax.toFixed(2)),
|
||||
metric("grav_z", gravityZ.toFixed(3), gravityCls),
|
||||
metric("track_err", trackingErr.toFixed(3), trackingErr > 0.5 ? "bad" : trackingErr > 0.2 ? "warn" : ""),
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderDiagnostics(diag, state) {
|
||||
if (!diag) return;
|
||||
const setValue = (id, text, cls = "") => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.className = "diag-value " + cls;
|
||||
};
|
||||
setValue("diag-norm", "Aligned", "success");
|
||||
setValue("diag-latency", `${(state?.loop_dt_ms || 0).toFixed(1)} ms`, (state?.loop_dt_ms || 0) > 25 ? "danger" : (state?.loop_dt_ms || 0) > 22 ? "warning" : "success");
|
||||
const trackErr = Math.max(
|
||||
...(state?.joint_pos || []).slice(0, 12).map((pos, i) => Math.abs(pos - ((state?.target || [])[i] || 0))),
|
||||
0,
|
||||
);
|
||||
setValue("diag-track-err", `${trackErr.toFixed(3)} rad`, trackErr > 0.5 ? "danger" : trackErr > 0.2 ? "warning" : "success");
|
||||
setValue("diag-runtime", diag.runtime_active ? "ACTIVE" : "IDLE", diag.runtime_active ? "success" : "warning");
|
||||
setValue("diag-runtime-age", diag.last_runtime_age_s == null ? "--" : `${diag.last_runtime_age_s.toFixed(2)} s`, diag.last_runtime_age_s != null && diag.last_runtime_age_s > 1.0 ? "danger" : "success");
|
||||
setValue("diag-poll-age", diag.last_poll_age_s == null ? "--" : `${diag.last_poll_age_s.toFixed(2)} s`, diag.last_poll_age_s != null && diag.last_poll_age_s > 1.0 ? "warning" : "success");
|
||||
setValue("diag-cmd-age", diag.last_command_age_s == null ? "--" : `${diag.last_command_age_s.toFixed(2)} s`);
|
||||
setValue("diag-poll-errors", String(diag.poll_error_count || 0), (diag.poll_error_count || 0) > 0 ? "danger" : "success");
|
||||
setValue("diag-api-errors", String(diag.api_error_count || 0), (diag.api_error_count || 0) > 0 ? "warning" : "success");
|
||||
setValue("diag-suppression", String(diag.zero_cmd_suppression), diag.zero_cmd_suppression ? "warning" : "success");
|
||||
const pathEl = $("diag-policy");
|
||||
if (pathEl) pathEl.textContent = diag.policy_path || "--";
|
||||
}
|
||||
|
||||
function renderFault(status) {
|
||||
const faultBox = $("fault-box");
|
||||
const faultText = $("fault-text");
|
||||
const traceText = $("traceback-text");
|
||||
if (!faultBox || !faultText || !traceText) return;
|
||||
if (!status.fault_reason && !status.last_error) {
|
||||
faultBox.classList.add("hidden");
|
||||
faultText.textContent = "";
|
||||
traceText.textContent = "";
|
||||
return;
|
||||
}
|
||||
faultBox.classList.remove("hidden");
|
||||
faultText.textContent = status.fault_reason || status.last_error || "";
|
||||
traceText.textContent = status.last_traceback || "";
|
||||
}
|
||||
|
||||
function applyStatus(status) {
|
||||
if (!status) return;
|
||||
CURRENT_STATUS = { ...(CURRENT_STATUS || {}), ...status };
|
||||
const merged = CURRENT_STATUS;
|
||||
if (merged.stage) setStage(merged.stage, merged.detail || "");
|
||||
if (merged.busy !== undefined && $("busy")) $("busy").textContent = merged.busy ? " [BUSY]" : "";
|
||||
if (merged.log_dir && $("logdir")) $("logdir").textContent = merged.log_dir;
|
||||
updateButtons(merged);
|
||||
renderFault(merged);
|
||||
if (merged.last_state !== undefined) {
|
||||
const now = performance.now();
|
||||
if (now - LAST_RENDER_TS > 80) {
|
||||
renderState(merged.last_state);
|
||||
renderDiagnostics(merged.diagnostics || {}, merged.last_state);
|
||||
if (window.viewer3d && window.viewer3d._isLoaded && merged.last_state.joint_pos) {
|
||||
window.viewer3d.updateJoints(merged.last_state.joint_pos);
|
||||
}
|
||||
updateMotorsGrid(merged.last_state);
|
||||
addPlotData(merged.last_state);
|
||||
LAST_RENDER_TS = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDebug() {
|
||||
try {
|
||||
const debug = await api("/api/debug");
|
||||
if (debug.status) {
|
||||
applyStatus(debug.status);
|
||||
}
|
||||
renderDiagnostics(debug.status?.diagnostics || {}, debug.status?.last_state || null);
|
||||
renderFault(debug.status || {});
|
||||
const diagJson = $("debug-json");
|
||||
if (diagJson) diagJson.textContent = JSON.stringify(debug.status?.diagnostics || {}, null, 2);
|
||||
} catch (err) {
|
||||
appendEvent({ kind: "DEBUG_FETCH_ERROR", error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (SSE_CONN) {
|
||||
SSE_CONN.close();
|
||||
SSE_CONN = null;
|
||||
}
|
||||
if (SSE_RECONNECT_TIMER) {
|
||||
clearTimeout(SSE_RECONNECT_TIMER);
|
||||
SSE_RECONNECT_TIMER = null;
|
||||
}
|
||||
const es = new EventSource("/events");
|
||||
SSE_CONN = es;
|
||||
es.onmessage = (event) => {
|
||||
const ev = JSON.parse(event.data);
|
||||
if (ev.kind === "STATUS_FULL" || ev.kind === "PULSE" || ev.kind === "STATUS") {
|
||||
applyStatus(ev);
|
||||
if (ev.fault_reason) appendEvent({ t: ev.t, kind: "FAULT_REASON", reason: ev.fault_reason });
|
||||
} else {
|
||||
appendEvent(ev);
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (SSE_CONN) {
|
||||
SSE_CONN.close();
|
||||
SSE_CONN = null;
|
||||
}
|
||||
if (!SSE_RECONNECT_TIMER) {
|
||||
SSE_RECONNECT_TIMER = setTimeout(() => {
|
||||
SSE_RECONNECT_TIMER = null;
|
||||
connectSSE();
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
window.jog = async (leg, joint, dir) => {
|
||||
const delta = parseFloat($("jt-delta").value) * dir;
|
||||
const kp = parseFloat($("jt-kp").value);
|
||||
const kd = parseFloat($("jt-kd").value);
|
||||
const duration = parseFloat($("jt-dur").value);
|
||||
try {
|
||||
await api("/api/test_motor", { leg, joint, delta_rad: delta, kp, kd, duration_s: duration });
|
||||
appendEvent({ kind: "JOG_SENT", leg, joint, delta });
|
||||
} catch (err) {
|
||||
appendEvent({ kind: "JOG_ERROR", error: err.message, leg, joint });
|
||||
}
|
||||
};
|
||||
|
||||
function initMotorsGrid() {
|
||||
const grid = $("motors-grid");
|
||||
if (!grid) return;
|
||||
const abbr = { hip_abduction: "H_ABD", hip_pitch: "H_PIT", knee: "KNEE", wheel: "WHEEL" };
|
||||
grid.innerHTML = SIM_JOINT_ORDER.map(([leg, joint], i) => `
|
||||
<div class="motor-row" id="mi-${i}">
|
||||
<span class="m-status" id="ms-${i}" title="offline">●</span>
|
||||
<span class="name" title="${leg}_${joint}">${leg.toUpperCase()}_${abbr[joint]}</span>
|
||||
<span class="val pos">0.00</span>
|
||||
<span class="val vel">0.00</span>
|
||||
<span class="val tau">0.00</span>
|
||||
<div class="m-jog">
|
||||
<button class="btn-jog" onclick="window.jog('${leg}','${joint}',-1)">-</button>
|
||||
<button class="btn-jog" onclick="window.jog('${leg}','${joint}',1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function updateMotorsGrid(state) {
|
||||
if (!state || !state.joint_pos) return;
|
||||
const positions = state.joint_pos;
|
||||
const velocities = state.joint_vel || [];
|
||||
const torques = state.joint_torque || [];
|
||||
const stale = state.per_motor_stale || [];
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
const row = $("mi-" + i);
|
||||
if (!row) continue;
|
||||
const dot = $("ms-" + i);
|
||||
if (dot) {
|
||||
const count = stale[i] ?? 99;
|
||||
if (count <= 0) {
|
||||
dot.style.color = "#4ade80";
|
||||
dot.title = "online";
|
||||
} else if (count < 5) {
|
||||
dot.style.color = "#facc15";
|
||||
dot.title = `stale(${count})`;
|
||||
} else {
|
||||
dot.style.color = "#ef4444";
|
||||
dot.title = `offline(${count})`;
|
||||
}
|
||||
}
|
||||
row.children[2].textContent = (positions[i] || 0).toFixed(2);
|
||||
row.children[3].textContent = (velocities[i] || 0).toFixed(2);
|
||||
const tau = torques[i] || 0;
|
||||
row.children[4].textContent = tau.toFixed(2);
|
||||
row.children[4].style.color = Math.abs(tau) > 16.0 ? "var(--color-danger)" : "";
|
||||
row.children[4].style.fontWeight = Math.abs(tau) > 16.0 ? "bold" : "";
|
||||
}
|
||||
}
|
||||
|
||||
function initPlots() {
|
||||
const colors12 = ["#ff453a", "#ff9f0a", "#ffd60a", "#32ade6", "#0a84ff", "#5e5ce6", "#ff375f", "#bf5af2", "#30d158", "#66d4cf", "#8e8e93", "#c7c7cc"];
|
||||
const specs = [
|
||||
{ id: "plot-pos", title: "Leg Pos (12)", nCh: 12, colors: colors12 },
|
||||
{ id: "plot-vel", title: "Wheel Vel (4)", nCh: 4, colors: ["#ff453a", "#32ade6", "#30d158", "#ffd60a"] },
|
||||
{ id: "plot-imu", title: "IMU (gyro+gz)", nCh: 4, colors: ["#ff453a", "#30d158", "#0a84ff", "#ffd60a"] },
|
||||
{ id: "plot-diag", title: "Diag (dt+age)", nCh: 2, colors: ["#ff453a", "#30d158"] },
|
||||
];
|
||||
const maxPts = 150;
|
||||
specs.forEach((spec) => {
|
||||
const canvas = $(spec.id);
|
||||
if (!canvas) return;
|
||||
canvas.width = canvas.parentElement.clientWidth;
|
||||
canvas.height = 80;
|
||||
PLOTS[spec.id] = {
|
||||
ctx: canvas.getContext("2d"),
|
||||
title: spec.title,
|
||||
nCh: spec.nCh,
|
||||
colors: spec.colors,
|
||||
data: Array.from({ length: spec.nCh }, () => new Array(maxPts).fill(0)),
|
||||
yMin: Array(spec.nCh).fill(Infinity),
|
||||
yMax: Array(spec.nCh).fill(-Infinity),
|
||||
maxPts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function addPlotData(state) {
|
||||
if (!state) return;
|
||||
const channels = [
|
||||
["plot-pos", (state.joint_pos || []).slice(0, 12)],
|
||||
["plot-vel", (state.joint_vel || []).slice(12, 16)],
|
||||
["plot-imu", [...(state.gyro || [0, 0, 0]), (state.proj_gravity || [0, 0, -1])[2]]],
|
||||
["plot-diag", [state.loop_dt_ms || 0, state.imu_age_ms || 0]],
|
||||
];
|
||||
channels.forEach(([id, values]) => {
|
||||
const plot = PLOTS[id];
|
||||
if (!plot) return;
|
||||
for (let i = 0; i < plot.nCh && i < values.length; i += 1) {
|
||||
const data = plot.data[i];
|
||||
data.push(values[i]);
|
||||
if (data.length > plot.maxPts) data.shift();
|
||||
if (values[i] < plot.yMin[i]) plot.yMin[i] = values[i];
|
||||
if (values[i] > plot.yMax[i]) plot.yMax[i] = values[i];
|
||||
}
|
||||
drawPlot(plot);
|
||||
});
|
||||
}
|
||||
|
||||
function drawPlot(plot) {
|
||||
const { ctx, data, colors, yMin, yMax, title, maxPts } = plot;
|
||||
const canvas = ctx.canvas;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = "rgba(255,255,255,0.5)";
|
||||
ctx.font = "10px monospace";
|
||||
ctx.fillText(title, 4, 12);
|
||||
const margin = { l: 30, r: 4, t: 16, b: 4 };
|
||||
const plotW = width - margin.l - margin.r;
|
||||
const plotH = height - margin.t - margin.b;
|
||||
if (plotW <= 0 || plotH <= 0) return;
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
if (yMin[i] === Infinity) {
|
||||
yMin[i] = -1;
|
||||
yMax[i] = 1;
|
||||
}
|
||||
const curMin = Math.min(...data[i]);
|
||||
const curMax = Math.max(...data[i]);
|
||||
yMin[i] = yMin[i] * 0.99 + curMin * 0.01;
|
||||
yMax[i] = yMax[i] * 0.99 + curMax * 0.01;
|
||||
}
|
||||
const globalMin = Math.min(...yMin);
|
||||
const globalMax = Math.max(...yMax);
|
||||
const range = globalMax - globalMin || 1;
|
||||
data.forEach((series, i) => {
|
||||
if (series.length < 2) return;
|
||||
ctx.strokeStyle = colors[i] || "#8e8e93";
|
||||
ctx.lineWidth = 1.0;
|
||||
ctx.beginPath();
|
||||
series.forEach((value, j) => {
|
||||
const x = margin.l + (j / maxPts) * plotW;
|
||||
const y = margin.t + plotH - ((value - globalMin) / range) * plotH;
|
||||
if (j === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
});
|
||||
ctx.fillStyle = "rgba(255,255,255,0.4)";
|
||||
ctx.font = "9px monospace";
|
||||
ctx.fillText(globalMax.toFixed(1), 2, margin.t + 8);
|
||||
ctx.fillText(globalMin.toFixed(1), 2, margin.t + plotH - 2);
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const result = await api("/api/logs");
|
||||
const tbody = document.querySelector("#logs-table tbody");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = result.sessions.map((s) => `
|
||||
<tr>
|
||||
<td style="font-family:monospace">${s.id.slice(-8)}</td>
|
||||
<td>${s.state_csv ? `<a href="/api/logs/${s.id}/state.csv" download>CSV</a>` : "—"}</td>
|
||||
<td>${s.events_jsonl ? `<a href="/api/logs/${s.id}/events.jsonl" download>JSONL</a>` : "—"}</td>
|
||||
<td>${s.size_kb} KB</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch (err) {
|
||||
appendEvent({ kind: "LOG_REFRESH_ERROR", error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
let cmdTimer = null;
|
||||
function sendCmd() {
|
||||
if (cmdTimer) return;
|
||||
cmdTimer = setTimeout(() => {
|
||||
cmdTimer = null;
|
||||
api("/api/cmd", {
|
||||
vx: parseFloat($("cmd-vx").value),
|
||||
vy: parseFloat($("cmd-vy").value),
|
||||
yaw: parseFloat($("cmd-yaw").value),
|
||||
}).catch((err) => appendEvent({ kind: "CMD_ERROR", error: err.message }));
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function bind() {
|
||||
$("btn-connect").onclick = () => api("/api/connect", { dry_run: $("dry-run").checked }).catch((err) => appendEvent({ kind: "CONNECT_ERROR", error: err.message }));
|
||||
$("btn-disconnect").onclick = () => api("/api/disconnect", {}).catch((err) => appendEvent({ kind: "DISCONNECT_ERROR", error: err.message }));
|
||||
$("btn-enable").onclick = () => api("/api/enable", {}).catch((err) => appendEvent({ kind: "ENABLE_ERROR", error: err.message }));
|
||||
$("btn-disable").onclick = () => api("/api/disable", {}).catch((err) => appendEvent({ kind: "DISABLE_ERROR", error: err.message }));
|
||||
$("btn-startup").onclick = () => api("/api/startup", {}).catch((err) => appendEvent({ kind: "STARTUP_ERROR", error: err.message }));
|
||||
$("btn-runtime-start").onclick = () => api("/api/runtime/start", { policy_path: $("policy-path").value || null }).catch((err) => appendEvent({ kind: "RUNTIME_START_ERROR", error: err.message }));
|
||||
$("btn-runtime-stop").onclick = () => api("/api/runtime/stop", {}).catch((err) => appendEvent({ kind: "RUNTIME_STOP_ERROR", error: err.message }));
|
||||
$("btn-estop").onclick = () => api("/api/estop", {}).catch((err) => appendEvent({ kind: "ESTOP_ERROR", error: err.message }));
|
||||
$("btn-reset-estop").onclick = () => api("/api/reset_estop", {}).catch((err) => appendEvent({ kind: "RESET_ESTOP_ERROR", error: err.message }));
|
||||
$("btn-refresh-debug").onclick = () => refreshDebug();
|
||||
|
||||
["vx", "vy", "yaw"].forEach((key) => {
|
||||
const el = $("cmd-" + key);
|
||||
el.oninput = () => {
|
||||
$("cmd-" + key + "-v").textContent = parseFloat(el.value).toFixed(2);
|
||||
sendCmd();
|
||||
};
|
||||
});
|
||||
$("btn-cmd-zero").onclick = () => {
|
||||
["vx", "vy", "yaw"].forEach((key) => {
|
||||
const el = $("cmd-" + key);
|
||||
el.value = 0;
|
||||
$("cmd-" + key + "-v").textContent = "0.00";
|
||||
});
|
||||
sendCmd();
|
||||
};
|
||||
|
||||
const jtSlider = $("jt-delta");
|
||||
jtSlider.oninput = () => { $("jt-delta-v").textContent = parseFloat(jtSlider.value).toFixed(2); };
|
||||
|
||||
$("btn-show-logs").onclick = () => {
|
||||
refreshLogs();
|
||||
$("logs-modal").classList.remove("hidden");
|
||||
};
|
||||
$("btn-close-logs").onclick = () => $("logs-modal").classList.add("hidden");
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
initMotorsGrid();
|
||||
bind();
|
||||
initPlots();
|
||||
connectSSE();
|
||||
refreshLogs();
|
||||
refreshDebug();
|
||||
updateButtons({ stage: "DISCONNECTED", busy: false });
|
||||
setInterval(refreshLogs, 10000);
|
||||
setInterval(refreshDebug, 5000);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
Object.values(PLOTS).forEach((plot) => {
|
||||
plot.ctx.canvas.width = plot.ctx.canvas.parentElement.clientWidth;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>sim2real 控制台</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/examples/jsm/controls/OrbitControls.js": "https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js",
|
||||
"three/examples/jsm/loaders/STLLoader.js": "https://unpkg.com/three@0.160.0/examples/jsm/loaders/STLLoader.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="canvas-container">
|
||||
<canvas id="viewer-canvas"></canvas>
|
||||
<div id="viewer-status" class="viewer-overlay">加载中...</div>
|
||||
</div>
|
||||
|
||||
<header class="glass-panel top-bar">
|
||||
<div class="top-bar-left">
|
||||
<h1>sim2real</h1>
|
||||
<span class="stage" id="stage">DISCONNECTED</span>
|
||||
<span id="busy" class="busy-indicator"></span>
|
||||
<span id="logdir" class="logdir-indicator"></span>
|
||||
</div>
|
||||
<div class="top-bar-center">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="dry-run">
|
||||
<span class="slider"></span>
|
||||
<span class="label">Dry-run</span>
|
||||
</label>
|
||||
<button class="btn btn-primary" id="btn-connect">连接硬件</button>
|
||||
<button class="btn btn-secondary" id="btn-disconnect">断开连接</button>
|
||||
<div class="divider"></div>
|
||||
<button class="btn btn-success" id="btn-enable">使能电机</button>
|
||||
<button class="btn btn-warning" id="btn-disable">失能电机</button>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<button id="btn-reset-camera" class="btn btn-secondary btn-icon" title="重置视角">⟳</button>
|
||||
<button id="btn-estop" class="btn btn-danger">急停</button>
|
||||
<button id="btn-reset-estop" class="btn btn-secondary">解除急停</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="glass-panel side-panel left-panel">
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">控制流程</h2>
|
||||
<div class="btn-group-vertical">
|
||||
<button class="btn btn-action" id="btn-startup">一键起立</button>
|
||||
<div class="runtime-group">
|
||||
<input type="text" id="policy-path" class="glass-input" placeholder="策略路径,留空则使用默认 rough">
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-success flex-1" id="btn-runtime-start">启动策略</button>
|
||||
<button class="btn btn-danger flex-1" id="btn-runtime-stop">停止策略</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section state-section">
|
||||
<h2 class="panel-title">实时状态</h2>
|
||||
<div id="state-summary" class="state-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section flex-1">
|
||||
<div class="panel-title-row">
|
||||
<h2 class="panel-title">Motors / Jog Test</h2>
|
||||
<span class="hint" style="font-size:10px; color:var(--text-tertiary)">POS | VEL | TAU</span>
|
||||
</div>
|
||||
<div class="control-row mt-2 mb-2">
|
||||
<span class="label">Kp</span><input type="number" id="jt-kp" class="glass-input mini" value="5">
|
||||
<span class="label">Kd</span><input type="number" id="jt-kd" class="glass-input mini" value="1">
|
||||
<span class="label">Time</span><input type="number" id="jt-dur" class="glass-input mini" value="1.0">
|
||||
<span class="label">Δ(rad)</span><input type="number" id="jt-delta" class="glass-input mini" value="0.1" step="0.05">
|
||||
<span id="jt-delta-v" class="slider-val">0.10</span>
|
||||
</div>
|
||||
<div id="motors-grid" class="motors-grid-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel side-panel right-panel">
|
||||
<div class="panel-section">
|
||||
<div class="panel-title-row">
|
||||
<h2 class="panel-title">Diagnostics</h2>
|
||||
<button class="btn btn-secondary" id="btn-refresh-debug">刷新</button>
|
||||
</div>
|
||||
<div class="diag-row"><span class="diag-label">Obs Normalization</span><span class="diag-value success" id="diag-norm">Aligned</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Control Latency</span><span class="diag-value" id="diag-latency">-- ms</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Tracking Error</span><span class="diag-value" id="diag-track-err">-- rad</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Runtime</span><span class="diag-value" id="diag-runtime">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Runtime Age</span><span class="diag-value" id="diag-runtime-age">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Poll Age</span><span class="diag-value" id="diag-poll-age">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Cmd Age</span><span class="diag-value" id="diag-cmd-age">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Poll Errors</span><span class="diag-value" id="diag-poll-errors">0</span></div>
|
||||
<div class="diag-row"><span class="diag-label">API Errors</span><span class="diag-value" id="diag-api-errors">0</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Zero-Cmd Suppression</span><span class="diag-value" id="diag-suppression">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">Policy</span><span class="diag-value" id="diag-policy">--</span></div>
|
||||
</div>
|
||||
|
||||
<div id="fault-box" class="panel-section hidden">
|
||||
<h2 class="panel-title">Fault</h2>
|
||||
<div id="fault-text" class="diag-value danger"></div>
|
||||
<pre id="traceback-text" style="white-space:pre-wrap; font-size:11px; max-height:160px; overflow:auto;"></pre>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">Command</h2>
|
||||
<div class="slider-group">
|
||||
<div class="slider-row">
|
||||
<span class="slider-label">vx</span>
|
||||
<input type="range" id="cmd-vx" class="glass-slider" min="-1" max="1" step="0.05" value="0">
|
||||
<span class="slider-val" id="cmd-vx-v">0.00</span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="slider-label">vy</span>
|
||||
<input type="range" id="cmd-vy" class="glass-slider" min="-0.5" max="0.5" step="0.05" value="0">
|
||||
<span class="slider-val" id="cmd-vy-v">0.00</span>
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="slider-label">yaw</span>
|
||||
<input type="range" id="cmd-yaw" class="glass-slider" min="-1" max="1" step="0.05" value="0">
|
||||
<span class="slider-val" id="cmd-yaw-v">0.00</span>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width mt-2" id="btn-cmd-zero">速度归零</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section log-section flex-1">
|
||||
<h2 class="panel-title">事件流</h2>
|
||||
<div id="events-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">Debug JSON</h2>
|
||||
<pre id="debug-json" style="white-space:pre-wrap; font-size:11px; max-height:160px; overflow:auto;"></pre>
|
||||
</div>
|
||||
|
||||
<div class="panel-section plots-section">
|
||||
<h2 class="panel-title">实时曲线</h2>
|
||||
<div class="plots-container" style="max-height: 200px;">
|
||||
<canvas id="plot-pos"></canvas>
|
||||
<canvas id="plot-vel"></canvas>
|
||||
<canvas id="plot-imu"></canvas>
|
||||
<canvas id="plot-diag"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="logs-modal" class="glass-modal hidden">
|
||||
<div class="glass-panel modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 class="panel-title">日志下载</h2>
|
||||
<button class="btn-close" id="btn-close-logs">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table id="logs-table">
|
||||
<thead><tr><th>会话 ID</th><th>state.csv</th><th>events.jsonl</th><th>大小</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-show-logs" class="btn btn-secondary floating-btn" title="查看日志文件">🗂</button>
|
||||
<span id="viewer-joint-count" class="viewer-count-indicator"></span>
|
||||
|
||||
<script type="module">
|
||||
import { RobotViewer3D } from '/static/viewer/RobotViewer3D.js';
|
||||
window.RobotViewer3D = RobotViewer3D;
|
||||
const canvas = document.getElementById('viewer-canvas');
|
||||
window.viewer3d = new RobotViewer3D(canvas, { meshBaseUrl: '/meshes/' });
|
||||
try {
|
||||
await window.viewer3d.load();
|
||||
document.getElementById('viewer-status').textContent = '';
|
||||
document.getElementById('viewer-joint-count').textContent = window.viewer3d.jointMap.size + ' joints';
|
||||
} catch (error) {
|
||||
document.getElementById('viewer-status').textContent = '3D 加载失败: ' + error.message;
|
||||
console.error(error);
|
||||
}
|
||||
document.getElementById('btn-reset-camera').onclick = () => window.viewer3d.resetCamera();
|
||||
window.addEventListener('resize', () => window.viewer3d.resize());
|
||||
</script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,394 @@
|
||||
/* Apple Glass Design System for sim2real */
|
||||
:root {
|
||||
--bg-primary: #000000;
|
||||
--glass-bg: rgba(20, 20, 22, 0.65);
|
||||
--glass-border: rgba(255, 255, 255, 0.12);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #ebebf5;
|
||||
--text-tertiary: #8e8e93;
|
||||
--accent: #0a84ff;
|
||||
--accent-hover: #409cff;
|
||||
--success: #30d158;
|
||||
--warning: #ffd60a;
|
||||
--danger: #ff453a;
|
||||
--blur-amount: 24px;
|
||||
--saturation: 180%;
|
||||
--spring: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--panel-radius: 16px;
|
||||
--font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', sans-serif;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #f5f5f7;
|
||||
--glass-bg: rgba(245, 245, 245, 0.75);
|
||||
--glass-border: rgba(0, 0, 0, 0.15);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #424245;
|
||||
--text-tertiary: #86868b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
transition: background 0.3s var(--spring);
|
||||
}
|
||||
|
||||
/* 3D Canvas Background */
|
||||
#canvas-container {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 0;
|
||||
background: radial-gradient(circle at center, #1a1a24 0%, #000000 100%);
|
||||
}
|
||||
#viewer-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
#viewer-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.viewer-overlay {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-count-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
z-index: 10;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Glass Panels */
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--blur-amount)) saturate(var(--saturation));
|
||||
-webkit-backdrop-filter: blur(var(--blur-amount)) saturate(var(--saturation));
|
||||
border: 0.5px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Top Bar */
|
||||
.top-bar {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-radius: 24px;
|
||||
width: 96%;
|
||||
max-width: 1400px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.top-bar-left, .top-bar-center, .top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.top-bar-center {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.top-bar h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
background: -webkit-linear-gradient(45deg, #fff, #8e8e93);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--glass-border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Side Panels */
|
||||
.side-panel {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
bottom: 20px;
|
||||
width: 340px;
|
||||
border-radius: var(--panel-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel { left: 2%; }
|
||||
.right-panel { right: 2%; }
|
||||
|
||||
.panel-section {
|
||||
padding: 16px;
|
||||
border-bottom: 0.5px solid var(--glass-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.panel-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.flex-1 { flex: 1; min-height: 0; }
|
||||
|
||||
.panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.panel-title-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
|
||||
/* Typography & Badges */
|
||||
.stage {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.stage.DISCONNECTED { background: rgba(142,142,147,0.3); }
|
||||
.stage.CONNECTED { background: rgba(10,132,255,0.3); color: #82c4ff; }
|
||||
.stage.ENABLED { background: rgba(48,209,88,0.3); color: #8deda7; }
|
||||
.stage.FAULTED { background: rgba(255,69,58,0.3); color: #ff8b86; }
|
||||
.stage.ESTOPPED { background: rgba(255,69,58,0.5); color: #ff8b86; box-shadow: 0 0 8px rgba(255,69,58,0.4); }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--spring);
|
||||
font-family: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.btn-success { background: rgba(48,209,88,0.8); border-color: transparent; color: white; }
|
||||
.btn-warning { background: rgba(255,214,10,0.8); border-color: transparent; color: black; }
|
||||
.btn-danger { background: rgba(255,69,58,0.8); border-color: transparent; color: white; }
|
||||
.btn-icon { width: 28px; height: 28px; padding: 0; border-radius: 50%; }
|
||||
.full-width { width: 100%; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
|
||||
.btn-group-vertical {
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.btn-row {
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.glass-input, .glass-select {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.glass-input:focus, .glass-select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.glass-input.small { width: 60px; }
|
||||
.glass-input.mini { width: 45px; padding: 4px 6px; }
|
||||
|
||||
.control-row {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
|
||||
}
|
||||
.label { font-size: 11px; color: var(--text-tertiary); }
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
display: flex; align-items: center; gap: 8px; cursor: pointer;
|
||||
}
|
||||
.toggle-switch input { display: none; }
|
||||
.toggle-switch .slider {
|
||||
position: relative; width: 32px; height: 18px;
|
||||
background: rgba(255,255,255,0.2); border-radius: 18px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
.toggle-switch .slider::before {
|
||||
content: ""; position: absolute;
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
background: white; top: 2px; left: 2px; transition: 0.3s;
|
||||
}
|
||||
.toggle-switch input:checked + .slider { background: var(--accent); }
|
||||
.toggle-switch input:checked + .slider::before { transform: translateX(14px); }
|
||||
.toggle-switch .label { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
/* Range Sliders */
|
||||
.slider-row {
|
||||
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
|
||||
}
|
||||
.slider-label {
|
||||
font-size: 12px; width: 30px; color: var(--text-secondary); font-family: monospace;
|
||||
}
|
||||
.slider-val {
|
||||
font-size: 12px; width: 36px; text-align: right; color: var(--accent); font-family: monospace;
|
||||
}
|
||||
.glass-slider {
|
||||
flex: 1; -webkit-appearance: none; height: 4px; border-radius: 2px;
|
||||
background: rgba(255,255,255,0.2); outline: none;
|
||||
}
|
||||
.glass-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 14px; height: 14px;
|
||||
border-radius: 50%; background: white; cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
.glass-slider:active::-webkit-slider-thumb { transform: scale(1.2); }
|
||||
|
||||
/* Motors List (Jog & Status) */
|
||||
.motors-grid-list {
|
||||
display: flex; flex-direction: column; gap: 2px; overflow-y: auto; padding-right: 4px;
|
||||
}
|
||||
.motor-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 2px 6px; background: rgba(0,0,0,0.25); border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.03);
|
||||
}
|
||||
.motor-row .name { font-size: 11px; color: var(--text-secondary); width: 65px; font-weight: 500; font-family: monospace; }
|
||||
.motor-row .m-status { font-size: 8px; color: #ef4444; flex-shrink: 0; width: 12px; text-align: center; transition: color 0.3s; }
|
||||
.motor-row .val { font-size: 10px; font-family: monospace; text-align: right; width: 35px; }
|
||||
.motor-row .val.pos { color: #0a84ff; }
|
||||
.motor-row .val.vel { color: #30d158; }
|
||||
.motor-row .val.tau { color: #ff9f0a; }
|
||||
|
||||
.m-jog { display: flex; gap: 2px; }
|
||||
.btn-jog {
|
||||
background: rgba(255,255,255,0.1); border: none; border-radius: 4px;
|
||||
color: white; font-family: monospace; font-size: 11px; padding: 2px 6px;
|
||||
cursor: pointer; min-width: 24px; text-align: center;
|
||||
}
|
||||
.btn-jog:hover { background: rgba(255,255,255,0.25); }
|
||||
|
||||
/* State Grid */
|
||||
.state-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.state-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 4px;
|
||||
}
|
||||
.state-item .k { font-size: 10px; color: var(--text-tertiary); text-transform: uppercase; }
|
||||
.state-item .v { font-size: 11px; font-family: monospace; color: var(--text-primary); }
|
||||
.state-item .v.warn { color: var(--warning); }
|
||||
.state-item .v.bad { color: var(--danger); }
|
||||
|
||||
/* ==== Plots & Logs ==== */
|
||||
.log-section { flex: 1; display: flex; flex-direction: column; min-height: 150px; }
|
||||
.log {
|
||||
flex: 1; background: rgba(0,0,0,0.4); border-radius: 6px; padding: 8px;
|
||||
font-family: monospace; font-size: 11px; overflow-y: auto; color: var(--text-secondary);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.log div { margin-bottom: 2px; line-height: 1.3; }
|
||||
.plots-section { margin-top: auto; }
|
||||
.plots-container {
|
||||
display: flex; flex-direction: column; gap: 4px; overflow-y: auto; padding-right: 4px;
|
||||
}
|
||||
.plots-container canvas {
|
||||
width: 100% !important; height: 50px !important; background: rgba(0,0,0,0.2); border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==== Diagnostics ==== */
|
||||
.diag-row {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 4px 6px; background: rgba(0,0,0,0.2); border-radius: 4px;
|
||||
margin-bottom: 4px; font-family: monospace; font-size: 12px;
|
||||
}
|
||||
.diag-label { color: var(--text-secondary); }
|
||||
.diag-value { color: var(--text-primary); font-weight: bold; }
|
||||
.diag-value.success { color: var(--color-success); }
|
||||
.diag-value.warning { color: var(--color-warning); }
|
||||
.diag-value.danger { color: var(--color-danger); }
|
||||
.plots-container::-webkit-scrollbar { width: 4px; }
|
||||
.plots-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 2px; }
|
||||
|
||||
/* Modal & Floating BTN */
|
||||
.floating-btn {
|
||||
position: fixed; bottom: 20px; left: 20px; width: 40px; height: 40px;
|
||||
border-radius: 50%; font-size: 18px; z-index: 100;
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
.glass-modal {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000; transition: opacity 0.3s;
|
||||
}
|
||||
.glass-modal.hidden { opacity: 0; pointer-events: none; }
|
||||
.modal-content {
|
||||
width: 80%; max-width: 600px; max-height: 80vh;
|
||||
border-radius: var(--panel-radius); display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 16px; border-bottom: 0.5px solid var(--glass-border);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.btn-close {
|
||||
background: transparent; border: none; color: var(--text-tertiary);
|
||||
font-size: 20px; cursor: pointer;
|
||||
}
|
||||
.btn-close:hover { color: var(--text-primary); }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
table th { color: var(--text-tertiary); text-align: left; padding: 8px; border-bottom: 1px solid var(--glass-border); }
|
||||
table td { padding: 8px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
table a { color: var(--accent); text-decoration: none; }
|
||||
table a:hover { text-decoration: underline; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Adapted MeshLoader for sim2real web console.
|
||||
* Supports both fileMap-based loading (original robot_viewer API) and URL-based
|
||||
* fetching from the sim2real HTTP server at /meshes/<name>.STL.
|
||||
*
|
||||
* Uses importmap-resolved Three.js via CDN (no bundler).
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
|
||||
const _stlLoader = new STLLoader();
|
||||
|
||||
let loadersCache = null;
|
||||
async function getLoaders() {
|
||||
if (!loadersCache) {
|
||||
loadersCache = { STLLoader: _stlLoader };
|
||||
}
|
||||
return loadersCache;
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return '';
|
||||
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mesh from URL (sim2real server) or fileMap (robot_viewer compatibility).
|
||||
* @param {string} meshPath - e.g. "fl_hip_abduction_Link.STL"
|
||||
* @param {Map|null} fileMap - optional File map (compat with MJCFAdapter)
|
||||
* @param {string|null} meshBaseUrl - e.g. "/meshes/" for URL-based loading
|
||||
* @returns {Promise<THREE.BufferGeometry|THREE.Group|null>}
|
||||
*/
|
||||
export async function loadMeshFile(meshPath, fileMap = null, meshBaseUrl = null) {
|
||||
const fileName = normalizePath(meshPath).split('/').pop();
|
||||
|
||||
// Strategy 1: try fileMap (robot_viewer compatibility)
|
||||
if (fileMap) {
|
||||
for (const [key, file] of fileMap.entries()) {
|
||||
if (typeof key === 'string' && key.toLowerCase().endsWith(fileName.toLowerCase())) {
|
||||
try {
|
||||
const url = URL.createObjectURL(file);
|
||||
const geom = await new Promise((resolve, reject) => {
|
||||
_stlLoader.load(url, resolve, undefined, reject);
|
||||
});
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[MeshLoader] loaded from fileMap:', fileName);
|
||||
return geom;
|
||||
} catch (e) {
|
||||
URL.revokeObjectURL(url);
|
||||
console.warn('[MeshLoader] fileMap load failed:', fileName, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: try URL-based loading from sim2real server
|
||||
const baseUrl = meshBaseUrl || '/meshes/';
|
||||
const url = baseUrl + fileName;
|
||||
try {
|
||||
console.log('[MeshLoader] fetching:', url);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
console.warn('[MeshLoader] 404:', url);
|
||||
return null;
|
||||
}
|
||||
const arrayBuf = await resp.arrayBuffer();
|
||||
const blobUrl = URL.createObjectURL(new Blob([arrayBuf]));
|
||||
const geom = await new Promise((resolve, reject) => {
|
||||
_stlLoader.load(blobUrl, resolve, undefined, reject);
|
||||
});
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
console.log('[MeshLoader] loaded from URL:', fileName);
|
||||
return geom;
|
||||
} catch (e) {
|
||||
console.warn('[MeshLoader] URL load failed:', url, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ensureMeshHasPhongMaterial(meshObject) {
|
||||
meshObject.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.forEach((mat, i) => {
|
||||
if (!mat) return;
|
||||
if (mat.type === 'MeshBasicMaterial' || mat.type === 'MeshLambertMaterial') {
|
||||
const nm = new THREE.MeshPhongMaterial({
|
||||
color: mat.color, map: mat.map,
|
||||
transparent: mat.transparent, opacity: mat.opacity, side: mat.side,
|
||||
shininess: 50, specular: new THREE.Color(0.3, 0.3, 0.3),
|
||||
});
|
||||
if (nm.map) nm.map.colorSpace = THREE.SRGBColorSpace;
|
||||
materials[i] = nm;
|
||||
} else if (mat.isMeshPhongMaterial || mat.isMeshStandardMaterial) {
|
||||
if (mat.shininess === undefined || mat.shininess < 50) mat.shininess = 50;
|
||||
if (!mat.specular) mat.specular = new THREE.Color(0.3, 0.3, 0.3);
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
if (Array.isArray(child.material)) child.material = materials;
|
||||
else if (materials.length === 1) child.material = materials[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { getLoaders };
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* RobotViewer3D — sim2real 3D 可视化(基于 robot_viewer 的 MJCFAdapter + Three.js)
|
||||
*
|
||||
* 加载 wheelleg.xml → MJCFAdapter.parse → Three.js 场景树
|
||||
* 建立 jointName → THREE.Object3D 映射,通过 updateJoints(pos16) 实时更新。
|
||||
* 支持 OrbitControls 旋转/缩放/平移。
|
||||
*
|
||||
* 用法:
|
||||
* const viewer = new RobotViewer3D(canvasElement);
|
||||
* await viewer.load('/mjcf/wheelleg.xml');
|
||||
* viewer.updateJoints(jointPositions16);
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { MJCFAdapter } from './MJCFAdapter.js';
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
|
||||
|
||||
// 16 关节的标准顺序(与 motor_mapping.py:SIM_JOINT_ORDER 对齐)
|
||||
const JOINT_ORDER = [
|
||||
'fl_hip_abduction_joint', 'fl_hip_pitch_joint', 'fl_knee_joint',
|
||||
'fr_hip_abduction_joint', 'fr_hip_pitch_joint', 'fr_knee_joint',
|
||||
'rl_hip_abduction_joint', 'rl_hip_pitch_joint', 'rl_knee_joint',
|
||||
'rr_hip_abduction_joint', 'rr_hip_pitch_joint', 'rr_knee_joint',
|
||||
'fl_wheel_joint', 'fr_wheel_joint', 'rl_wheel_joint', 'rr_wheel_joint',
|
||||
];
|
||||
|
||||
// MJCF → Three.js 坐标轴转换:让 MJCF 的 Z 轴(向上) 映射到 Three.js 的 Y 轴(向上)
|
||||
const MJCF_TO_THREE = new THREE.Matrix4().makeRotationX(-Math.PI / 2);
|
||||
// 或直接用 euler: (0, PI, 0)
|
||||
|
||||
export class RobotViewer3D {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.meshBaseUrl='/meshes/'] STL mesh 文件的 HTTP 路径前缀
|
||||
* @param {string} [opts.mjcfUrl='/mjcf/wheelleg.xml']
|
||||
* @param {string} [opts.backgroundColor='#1a1d24']
|
||||
*/
|
||||
constructor(canvas, opts = {}) {
|
||||
this.canvas = canvas;
|
||||
this.meshBaseUrl = opts.meshBaseUrl || '/meshes/';
|
||||
this.mjcfUrl = opts.mjcfUrl || '/mjcf/wheelleg.xml';
|
||||
|
||||
// Three.js 核心
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
this.scene = new THREE.Scene();
|
||||
// 移除背景色,使用透明背景,由 CSS 控制
|
||||
// this.scene.background = new THREE.Color(opts.backgroundColor || '#1a1d24');
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(55, w / h, 0.05, 50);
|
||||
this.camera.position.set(0.5, 0.35, 0.65);
|
||||
this.camera.lookAt(0.2, 0, 0);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
this.renderer.setSize(w, h);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
|
||||
// OrbitControls
|
||||
this.controls = new OrbitControls(this.camera, canvas);
|
||||
this.controls.target.set(0.15, 0.08, 0.0);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.12;
|
||||
this.controls.update();
|
||||
|
||||
// 灯光
|
||||
this._setupLights();
|
||||
|
||||
// 地面
|
||||
const grid = new THREE.GridHelper(2, 20, 0x444444, 0x222222);
|
||||
grid.position.y = -0.35;
|
||||
this.scene.add(grid);
|
||||
|
||||
// 状态
|
||||
this.model = null;
|
||||
this.rootGroup = null;
|
||||
this.jointMap = new Map(); // jointName → { joint, group }
|
||||
this._isLoaded = false;
|
||||
this._rafId = null;
|
||||
this._stlCache = new Map(); // filename → BufferGeometry
|
||||
}
|
||||
|
||||
_setupLights() {
|
||||
const ambient = new THREE.AmbientLight(0x606060, 1.5);
|
||||
this.scene.add(ambient);
|
||||
|
||||
const dir1 = new THREE.DirectionalLight(0xffffff, 2.5);
|
||||
dir1.position.set(2, 3, 2);
|
||||
this.scene.add(dir1);
|
||||
|
||||
const dir2 = new THREE.DirectionalLight(0x8899cc, 1.0);
|
||||
dir2.position.set(-1, 1, -1);
|
||||
this.scene.add(dir2);
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0x8899cc, 0x334455, 1.2);
|
||||
this.scene.add(hemi);
|
||||
}
|
||||
|
||||
// ---- 加载模型 ----
|
||||
async load(mjcfUrlOverride) {
|
||||
const url = mjcfUrlOverride || this.mjcfUrl;
|
||||
console.log('[RobotViewer3D] loading MJCF:', url);
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`MJCF 404: ${url}`);
|
||||
const xmlText = await resp.text();
|
||||
|
||||
// 用 MJCFAdapter 解析 → UnifiedRobotModel
|
||||
// fileMap 为空时不传;MeshLoader 会自动 fallback 到 URL 加载
|
||||
const model = await MJCFAdapter.parse(xmlText, null);
|
||||
this.model = model;
|
||||
console.log('[RobotViewer3D] parsed:', model.links.size, 'links,', model.joints.size, 'joints');
|
||||
|
||||
// 取 rootGroup(MJCFAdapter.createThreeObject 已构建完整 hierarchy)
|
||||
this.rootGroup = model.threeObject;
|
||||
// 坐标轴转换:MJCF → Three.js
|
||||
this.rootGroup.applyMatrix4(MJCF_TO_THREE);
|
||||
this.scene.add(this.rootGroup);
|
||||
|
||||
// 遍历 joints,建立索引
|
||||
this.jointMap.clear();
|
||||
for (const [jointName, joint] of model.joints) {
|
||||
if (joint.threeObject) {
|
||||
this.jointMap.set(jointName, joint);
|
||||
}
|
||||
}
|
||||
// 已建立映射的关节列表
|
||||
const mapped = Array.from(this.jointMap.keys()).sort();
|
||||
console.log('[RobotViewer3D] joint map:', mapped.length, 'joints');
|
||||
|
||||
this._isLoaded = true;
|
||||
this._startRenderLoop();
|
||||
}
|
||||
|
||||
// ---- 渲染循环(按需 + 持续) ----
|
||||
_startRenderLoop() {
|
||||
if (this._rafId) return;
|
||||
const loop = () => {
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this._rafId = requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
}
|
||||
|
||||
// ---- 实时更新关节角度 ----
|
||||
/**
|
||||
* @param {Float64Array|number[]} pos16 — 16 关节角度 (rad),顺序同 SIM_JOINT_ORDER
|
||||
* 索引 0-11: 腿关节 (fl_abd,fl_pitch,fl_knee,fr...,rl...,rr...)
|
||||
* 索引 12-15: 轮子关节 (fl_wheel,fr_wheel,rl_wheel,rr_wheel)
|
||||
*/
|
||||
updateJoints(pos16) {
|
||||
if (!this._isLoaded) return;
|
||||
for (let i = 0; i < JOINT_ORDER.length && i < pos16.length; i++) {
|
||||
const name = JOINT_ORDER[i];
|
||||
const joint = this.jointMap.get(name);
|
||||
if (joint) {
|
||||
MJCFAdapter.setJointAngle(joint, pos16[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 重置相机 ----
|
||||
resetCamera() {
|
||||
this.camera.position.set(0.5, 0.35, 0.65);
|
||||
this.controls.target.set(0.15, 0.08, 0.0);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
// ---- 调整大小 ----
|
||||
resize() {
|
||||
const w = this.canvas.clientWidth, h = this.canvas.clientHeight;
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(w, h);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._rafId) cancelAnimationFrame(this._rafId);
|
||||
this.renderer.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Unified robot model data interface
|
||||
* All formats (URDF, MJCF, USD) are converted to this unified format
|
||||
*/
|
||||
export class UnifiedRobotModel {
|
||||
constructor() {
|
||||
this.name = '';
|
||||
this.links = new Map(); // Map<name, Link>
|
||||
this.joints = new Map(); // Map<name, Joint>
|
||||
this.materials = new Map(); // Map<name, Material>
|
||||
this.constraints = new Map(); // Map<name, Constraint> - for parallel mechanism constraints
|
||||
this.rootLink = null; // Root link name
|
||||
this.threeObject = null; // Three.js object (if available)
|
||||
}
|
||||
|
||||
addLink(link) {
|
||||
this.links.set(link.name, link);
|
||||
}
|
||||
|
||||
addJoint(joint) {
|
||||
this.joints.set(joint.name, joint);
|
||||
}
|
||||
|
||||
addConstraint(constraint) {
|
||||
this.constraints.set(constraint.name, constraint);
|
||||
}
|
||||
|
||||
getLink(name) {
|
||||
return this.links.get(name);
|
||||
}
|
||||
|
||||
getJoint(name) {
|
||||
return this.joints.get(name);
|
||||
}
|
||||
|
||||
getConstraint(name) {
|
||||
return this.constraints.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link interface
|
||||
*/
|
||||
export class Link {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.visuals = []; // VisualGeometry[]
|
||||
this.collisions = []; // CollisionGeometry[]
|
||||
this.inertial = null; // InertialProperties
|
||||
this.threeObject = null; // Three.js object
|
||||
this.userData = {}; // User-defined data (for adapters to store additional information)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VisualGeometry interface
|
||||
*/
|
||||
export class VisualGeometry {
|
||||
constructor() {
|
||||
this.name = '';
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.geometry = null; // GeometryType
|
||||
this.material = null; // Material
|
||||
this.threeObject = null; // Three.js Mesh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CollisionGeometry interface
|
||||
*/
|
||||
export class CollisionGeometry {
|
||||
constructor() {
|
||||
this.name = '';
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.geometry = null; // GeometryType
|
||||
this.threeObject = null; // Three.js Mesh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GeometryType interface
|
||||
*/
|
||||
export class GeometryType {
|
||||
constructor(type) {
|
||||
this.type = type; // 'box' | 'sphere' | 'cylinder' | 'mesh'
|
||||
this.size = null; // Size parameters (varies by type)
|
||||
this.filename = null; // Mesh file path (if mesh type)
|
||||
}
|
||||
|
||||
clone() {
|
||||
const cloned = new GeometryType(this.type);
|
||||
cloned.size = this.size ? { ...this.size } : null;
|
||||
cloned.filename = this.filename;
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* InertialProperties interface
|
||||
*/
|
||||
export class InertialProperties {
|
||||
constructor() {
|
||||
this.mass = 0;
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.ixx = 0;
|
||||
this.iyy = 0;
|
||||
this.izz = 0;
|
||||
this.ixy = 0;
|
||||
this.ixz = 0;
|
||||
this.iyz = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joint interface
|
||||
*/
|
||||
export class Joint {
|
||||
constructor(name, type) {
|
||||
this.name = name;
|
||||
this.type = type; // 'revolute' | 'prismatic' | 'fixed' | 'continuous'
|
||||
this.parent = null; // Parent link name
|
||||
this.child = null; // Child link name
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.axis = { xyz: [0, 0, 1] }; // Default z-axis
|
||||
this.limits = null; // JointLimits
|
||||
this.currentValue = 0; // Current joint value
|
||||
this.threeObject = null; // Three.js object (if available)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JointLimits interface
|
||||
*/
|
||||
export class JointLimits {
|
||||
constructor() {
|
||||
this.lower = -Math.PI;
|
||||
this.upper = Math.PI;
|
||||
this.effort = null;
|
||||
this.velocity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material interface
|
||||
*/
|
||||
export class Material {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.color = { r: 0.8, g: 0.8, b: 0.8 };
|
||||
this.texture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constraint interface - for describing closed-chain constraints of parallel mechanisms
|
||||
* Supports MuJoCo equality constraint types
|
||||
*/
|
||||
export class Constraint {
|
||||
constructor(name, type) {
|
||||
this.name = name;
|
||||
this.type = type; // 'connect' | 'weld' | 'joint' | 'tendon' | 'distance'
|
||||
|
||||
// Constraint objects (may be body, geom, joint, etc. depending on type)
|
||||
this.body1 = null;
|
||||
this.body2 = null;
|
||||
this.anchor = null; // Connection point coordinates
|
||||
this.torquescale = null; // Torque scale
|
||||
|
||||
// Joint constraint specific properties
|
||||
this.joint1 = null;
|
||||
this.joint2 = null;
|
||||
this.polycoef = null; // Polynomial coefficients [a0, a1, a2, a3, a4]
|
||||
|
||||
// Visualization object
|
||||
this.threeObject = null; // Three.js object for displaying constraint
|
||||
|
||||
// Original data (for debugging)
|
||||
this.userData = {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user