[software] 添加16DOF早期训练仿真与Sim2Real闭环

This commit is contained in:
2026-07-21 16:15:14 +08:00
parent 9bd22225f9
commit e9e2c946b3
681 changed files with 137221 additions and 8 deletions
+353
View File
@@ -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
+480
View File
@@ -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');
// 取 rootGroupMJCFAdapter.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 = {};
}
}