Files
smart_wasm/public/robot_kinematics_three_test.html
2026-06-16 12:56:26 +08:00

1274 lines
38 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>URDF KDL Three.js Test</title>
<style>
:root {
--bg: #eef2f5;
--surface: #ffffff;
--surface-2: #f6f8fa;
--line: #cfd8df;
--text: #17212b;
--muted: #5d6975;
--primary: #006d77;
--primary-strong: #005760;
--accent: #b84a36;
--ok: #1f7a4d;
--danger: #a7352a;
--shadow: 0 14px 36px rgba(23, 33, 43, 0.1);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
}
body {
margin: 0;
font-family:
"Segoe UI",
"Microsoft YaHei",
Arial,
sans-serif;
color: var(--text);
background: var(--bg);
overflow: hidden;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
min-height: 46px;
border: 1px solid transparent;
border-radius: 8px;
padding: 0 14px;
color: #fff;
background: var(--primary);
font-weight: 600;
cursor: pointer;
transition:
background 180ms ease,
border-color 180ms ease,
color 180ms ease,
opacity 180ms ease;
}
button:hover {
background: var(--primary-strong);
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(0, 109, 119, 0.28);
outline-offset: 2px;
}
button.secondary {
color: var(--text);
border-color: var(--line);
background: #fff;
}
button.secondary:hover {
border-color: var(--primary);
color: var(--primary);
background: #f7fbfb;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
label {
display: block;
margin: 0 0 6px;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
input,
textarea {
width: 100%;
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px 10px;
color: var(--text);
background: #fff;
}
input[type="range"] {
min-height: 28px;
padding: 0;
accent-color: var(--primary);
}
textarea {
min-height: 184px;
resize: vertical;
font:
12px/1.5 Consolas,
"Courier New",
monospace;
}
.app {
display: grid;
grid-template-columns: minmax(336px, 420px) minmax(0, 1fr);
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.side {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
height: 100dvh;
overflow-x: hidden;
overflow-y: auto;
padding: 18px;
border-right: 1px solid var(--line);
background: var(--surface);
box-shadow: var(--shadow);
overscroll-behavior: contain;
z-index: 2;
}
.title-block {
display: grid;
gap: 8px;
}
.title-block h1 {
margin: 0;
font-size: 22px;
line-height: 1.2;
}
.status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.pill {
min-height: 28px;
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 10px;
color: var(--muted);
background: var(--surface-2);
font-size: 12px;
font-weight: 700;
}
.pill.ok {
border-color: rgba(31, 122, 77, 0.24);
color: var(--ok);
background: rgba(31, 122, 77, 0.08);
}
.pill.error {
border-color: rgba(167, 53, 42, 0.24);
color: var(--danger);
background: rgba(167, 53, 42, 0.08);
}
.panel {
display: grid;
gap: 12px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
background: var(--surface-2);
}
.panel h2 {
margin: 0;
font-size: 15px;
line-height: 1.25;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.field.full {
grid-column: 1 / -1;
}
.joint-row {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 84px;
gap: 8px;
align-items: center;
}
.joint-row span {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.scene-wrap {
position: relative;
min-width: 0;
min-height: 0;
height: 100dvh;
overflow: hidden;
background: #dfe7ec;
}
#scene {
display: block;
width: 100%;
height: 100%;
}
.scene-toolbar {
position: absolute;
top: 16px;
right: 16px;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: min(520px, calc(100% - 32px));
}
.metric {
min-width: 112px;
border: 1px solid rgba(207, 216, 223, 0.9);
border-radius: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(8px);
}
.metric-label {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.metric-value {
display: block;
margin-top: 3px;
color: var(--text);
font-size: 13px;
font-variant-numeric: tabular-nums;
font-weight: 700;
word-break: break-word;
}
.request-output {
display: grid;
gap: 10px;
}
.split-output {
display: grid;
gap: 10px;
}
.toast {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
max-width: min(560px, calc(100vw - 32px));
border: 1px solid rgba(23, 33, 43, 0.12);
border-radius: 8px;
padding: 10px 12px;
color: #fff;
background: rgba(23, 33, 43, 0.92);
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition:
opacity 180ms ease,
transform 180ms ease;
z-index: 10;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(-4px);
}
@media (max-width: 940px) {
body {
overflow: auto;
}
.app {
grid-template-columns: 1fr;
height: auto;
min-height: 100dvh;
overflow: visible;
}
.side {
height: auto;
max-height: none;
overflow: visible;
border-right: 0;
border-bottom: 1px solid var(--line);
box-shadow: none;
}
.scene-wrap,
#scene {
height: 58dvh;
min-height: 360px;
}
}
@media (max-width: 560px) {
.side {
padding: 14px;
}
.form-grid,
.actions {
grid-template-columns: 1fr;
}
.joint-row {
grid-template-columns: 30px minmax(0, 1fr);
}
.joint-row input[type="number"] {
grid-column: 2;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
</style>
</head>
<body>
<main class="app">
<aside class="side" aria-label="运动学控制">
<section class="title-block">
<h1>URDF / Orocos KDL 运动学测试</h1>
<div class="status-row">
<span class="pill" id="wasm-status">WASM 未初始化</span>
<span class="pill" id="solver-status">等待请求</span>
</div>
</section>
<section class="panel">
<h2>机器人</h2>
<div class="form-grid">
<div class="field full">
<label for="robot-uuid">robot_uuid</label>
<input id="robot-uuid" value="abb_irb120_3_58" />
</div>
<div class="field full">
<label for="urdf-file">URDF 文件</label>
<input id="urdf-file" type="file" accept=".xml,.urdf" />
</div>
</div>
<div class="actions">
<button id="init-btn" type="button">初始化</button>
<button id="register-urdf-btn" class="secondary" type="button">
注册 URDF
</button>
<button id="reset-btn" class="secondary" type="button">零位</button>
</div>
</section>
<section class="panel">
<h2>关节角</h2>
<div id="joint-controls"></div>
<div class="actions">
<button id="fk-btn" type="button">正解</button>
<button id="singularity-btn" class="secondary" type="button">
奇异点
</button>
<button id="target-current-btn" class="secondary" type="button">
取当前位姿
</button>
</div>
</section>
<section class="panel">
<h2>逆解目标</h2>
<div class="form-grid">
<div class="field">
<label for="target-x">X</label>
<input id="target-x" type="number" step="0.001" value="0" />
</div>
<div class="field">
<label for="target-y">Y</label>
<input id="target-y" type="number" step="0.001" value="0" />
</div>
<div class="field">
<label for="target-z">Z</label>
<input id="target-z" type="number" step="0.001" value="0" />
</div>
<div class="field">
<label for="target-qx">QX</label>
<input id="target-qx" type="number" step="0.001" value="0" />
</div>
<div class="field">
<label for="target-qy">QY</label>
<input id="target-qy" type="number" step="0.001" value="0" />
</div>
<div class="field">
<label for="target-qz">QZ</label>
<input id="target-qz" type="number" step="0.001" value="0" />
</div>
<div class="field full">
<label for="target-qw">QW</label>
<input id="target-qw" type="number" step="0.001" value="1" />
</div>
</div>
<div class="actions">
<button id="ik-btn" type="button">逆解</button>
<button id="apply-ik-btn" class="secondary" type="button" disabled>
应用解
</button>
</div>
</section>
<section class="panel request-output">
<h2>JSON</h2>
<div class="split-output">
<div>
<label for="request-json">请求</label>
<textarea id="request-json" readonly></textarea>
</div>
<div>
<label for="response-json">响应</label>
<textarea id="response-json" readonly></textarea>
</div>
</div>
</section>
</aside>
<section class="scene-wrap" aria-label="三维视图">
<canvas id="scene"></canvas>
<div class="scene-toolbar">
<div class="metric">
<span class="metric-label">TCP</span>
<span class="metric-value" id="tcp-value">-</span>
</div>
<div class="metric">
<span class="metric-label">关节数</span>
<span class="metric-value" id="joint-count-value">0</span>
</div>
<div class="metric">
<span class="metric-label">奇异风险</span>
<span class="metric-value" id="singularity-value">-</span>
</div>
<div class="metric">
<span class="metric-label">three.js</span>
<span class="metric-value">0.184.0</span>
</div>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="./wasm/smart_math.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.184.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.184.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import SmartWasmAPI from "./wasm/smart_api_wrapper.js";
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const api = new SmartWasmAPI();
const jointCount = 6;
const state = {
initialized: false,
joints: Array(jointCount).fill(0),
latestTcp: null,
latestIkJoints: null,
lastResponse: null,
};
const elements = {
canvas: document.getElementById("scene"),
wasmStatus: document.getElementById("wasm-status"),
solverStatus: document.getElementById("solver-status"),
robotUuid: document.getElementById("robot-uuid"),
urdfFile: document.getElementById("urdf-file"),
jointControls: document.getElementById("joint-controls"),
requestJson: document.getElementById("request-json"),
responseJson: document.getElementById("response-json"),
targetX: document.getElementById("target-x"),
targetY: document.getElementById("target-y"),
targetZ: document.getElementById("target-z"),
targetQx: document.getElementById("target-qx"),
targetQy: document.getElementById("target-qy"),
targetQz: document.getElementById("target-qz"),
targetQw: document.getElementById("target-qw"),
tcpValue: document.getElementById("tcp-value"),
jointCountValue: document.getElementById("joint-count-value"),
singularityValue: document.getElementById("singularity-value"),
toast: document.getElementById("toast"),
initBtn: document.getElementById("init-btn"),
registerUrdfBtn: document.getElementById("register-urdf-btn"),
resetBtn: document.getElementById("reset-btn"),
fkBtn: document.getElementById("fk-btn"),
singularityBtn: document.getElementById("singularity-btn"),
ikBtn: document.getElementById("ik-btn"),
applyIkBtn: document.getElementById("apply-ik-btn"),
targetCurrentBtn: document.getElementById("target-current-btn"),
};
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdfe7ec);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 80);
camera.up.set(0, 0, 1);
camera.position.set(1.18, -1.68, 0.92);
const renderer = new THREE.WebGLRenderer({
canvas: elements.canvas,
antialias: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.screenSpacePanning = false;
controls.target.set(0.18, 0, 0.34);
controls.update();
window.__robotKinematicsDebug = {
// 获取当前相机状态,用于验证按钮操作不会改变视角。
getCameraState() {
return {
position: camera.position.toArray(),
up: camera.up.toArray(),
target: controls.target.toArray(),
};
},
// 获取 three.js 默认上方向,用于验证场景为 Z 轴向上。
getDefaultUp() {
return THREE.Object3D.DEFAULT_UP.toArray();
},
};
const robotGroup = new THREE.Group();
const targetGroup = new THREE.Group();
scene.add(robotGroup, targetGroup);
const linkMaterial = new THREE.MeshStandardMaterial({
color: 0x006d77,
metalness: 0.18,
roughness: 0.42,
});
const jointMaterial = new THREE.MeshStandardMaterial({
color: 0xb84a36,
metalness: 0.08,
roughness: 0.5,
});
const tcpMaterial = new THREE.MeshStandardMaterial({
color: 0x1f7a4d,
emissive: 0x0c3221,
emissiveIntensity: 0.14,
});
const targetMaterial = new THREE.MeshStandardMaterial({
color: 0x6f3cc3,
metalness: 0.08,
roughness: 0.46,
});
scene.add(new THREE.HemisphereLight(0xffffff, 0x8ca2ad, 2.1));
const sun = new THREE.DirectionalLight(0xffffff, 2.2);
sun.position.set(3, -4, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
scene.add(sun);
const grid = new THREE.GridHelper(2.6, 26, 0x60727e, 0xb9c4ca);
grid.rotation.x = Math.PI / 2;
scene.add(grid);
scene.add(new THREE.AxesHelper(0.36));
// 创建关节滑块和数值输入。
function createJointControls() {
elements.jointControls.innerHTML = "";
for (let index = 0; index < jointCount; index += 1) {
const row = document.createElement("div");
row.className = "joint-row";
const label = document.createElement("span");
label.textContent = `J${index + 1}`;
const range = document.createElement("input");
range.type = "range";
range.min = "-3.142";
range.max = "3.142";
range.step = "0.001";
range.value = "0";
range.dataset.index = String(index);
range.setAttribute("aria-label", `J${index + 1} 弧度滑块`);
const number = document.createElement("input");
number.type = "number";
number.min = "-3.142";
number.max = "3.142";
number.step = "0.001";
number.value = "0.000";
number.dataset.index = String(index);
number.setAttribute("aria-label", `J${index + 1} 弧度数值`);
row.append(label, range, number);
elements.jointControls.append(row);
range.addEventListener("input", handleJointInput);
number.addEventListener("change", handleJointInput);
}
}
// 响应关节输入变化,并保持滑块和数值框同步。
function handleJointInput(event) {
const index = Number(event.target.dataset.index);
const nextValue = clampJoint(Number(event.target.value));
state.joints[index] = nextValue;
syncJointControls();
}
// 限制关节输入范围。
function clampJoint(value) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(-3.142, Math.min(3.142, value));
}
// 将当前关节状态写回所有控件。
function syncJointControls() {
const ranges = elements.jointControls.querySelectorAll(
'input[type="range"]',
);
const numbers = elements.jointControls.querySelectorAll(
'input[type="number"]',
);
ranges.forEach((input, index) => {
input.value = state.joints[index].toFixed(3);
});
numbers.forEach((input, index) => {
input.value = state.joints[index].toFixed(3);
});
}
// 生成接口需要的关节字符串。
function buildJointString(joints = state.joints) {
return joints.map((value) => Number(value).toFixed(6)).join(",");
}
// 生成正解请求。
function buildForwardAllJointsRequest() {
return {
msg: "threejs fk all joints",
req_code: `THREE_FK_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_Kinematics_forward_all_joints",
req_param: {
robot_uuid: elements.robotUuid.value.trim(),
joints_str: buildJointString(),
},
};
}
// 生成 TCP 正解请求。
function buildForwardPoseRequest() {
return {
msg: "threejs fk tcp",
req_code: `THREE_TCP_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_Kinematics_forward_pose_str",
req_param: {
robot_uuid: elements.robotUuid.value.trim(),
q_init_str: buildJointString(),
},
};
}
// 生成奇异点检测请求。
function buildSingularityRequest() {
return {
msg: "threejs singularity check",
req_code: `THREE_SINGULARITY_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_Kinematics_check_singularity",
req_param: {
robot_uuid: elements.robotUuid.value.trim(),
joints_str: buildJointString(),
},
};
}
// 生成逆解请求。
function buildInverseRequest() {
const pose = [
elements.targetX.value,
elements.targetY.value,
elements.targetZ.value,
elements.targetQx.value,
elements.targetQy.value,
elements.targetQz.value,
elements.targetQw.value,
]
.map((value) => Number(value || 0).toFixed(6))
.join(",");
return {
msg: "threejs ik",
req_code: `THREE_IK_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_Kinematics_inverse_pose_str_NoDifference",
req_param: {
robot_uuid: elements.robotUuid.value.trim(),
pose_str: pose,
q_init_str: buildJointString(),
},
};
}
// 初始化 WASM 业务接口。
async function initializeWasm() {
if (state.initialized) {
return;
}
setBusy(true, "正在初始化");
try {
await api.initialize();
const response = await api.initializeBusinessApi();
state.initialized = response.success === true;
elements.wasmStatus.textContent = state.initialized
? "WASM 已初始化"
: "WASM 初始化失败";
elements.wasmStatus.className = `pill ${
state.initialized ? "ok" : "error"
}`;
renderJson(null, response);
await runForwardKinematics();
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 读取本地 URDF 文件并注册为当前 robot_uuid。
async function registerUrdfFromFile() {
await ensureInitialized();
const file = elements.urdfFile.files?.[0];
const robotUuid = elements.robotUuid.value.trim();
if (!file) {
showToast("请选择 URDF 文件");
return;
}
if (!robotUuid) {
showToast("请先填写 robot_uuid");
return;
}
setBusy(true, "正在注册 URDF");
try {
const urdfText = await file.text();
const request = {
msg: "threejs init robot from urdf",
req_code: `THREE_INIT_URDF_${Date.now()}`,
req_from: "threejs_test",
req_cmd: "Cmd_InitRobot",
req_param: {
robot_uuid: robotUuid,
force_update: true,
urdf_base64: encodeUtf8Base64(urdfText),
},
};
renderJson(request, null);
const response = await api.processBusinessRequest(request);
renderJson(request, response);
if (response.success && response.res_data?.success) {
state.latestIkJoints = null;
elements.applyIkBtn.disabled = true;
setStatus("URDF 注册完成", true);
await runForwardKinematics();
} else {
setStatus("URDF 注册失败", false);
showToast(response.res_data?.message || response.msg || "URDF 注册失败");
}
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 将 URDF 文本编码成接口需要的 UTF-8 Base64 字符串。
function encodeUtf8Base64(value) {
const bytes = new TextEncoder().encode(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
// 确保调用算法前已经完成初始化。
async function ensureInitialized() {
if (!state.initialized) {
await initializeWasm();
}
}
// 执行正解并刷新三维场景。
async function runForwardKinematics() {
await ensureInitialized();
setBusy(true, "正解计算中");
const skeletonRequest = buildForwardAllJointsRequest();
const tcpRequest = buildForwardPoseRequest();
try {
renderJson(skeletonRequest, null);
const [skeletonResponse, tcpResponse] = await Promise.all([
api.processBusinessRequest(skeletonRequest),
api.processBusinessRequest(tcpRequest),
]);
state.lastResponse = skeletonResponse;
state.latestTcp = extractTcp(tcpResponse);
renderRobot(extractObjStates(skeletonResponse));
updateTcpTargetFromCurrent();
renderJson(skeletonRequest, {
skeleton: skeletonResponse,
tcp: tcpResponse,
});
setStatus("正解完成", true);
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 执行奇异点检测并显示风险等级。
async function runSingularityCheck() {
await ensureInitialized();
setBusy(true, "奇异点检测中");
const request = buildSingularityRequest();
try {
renderJson(request, null);
const response = await api.processBusinessRequest(request);
renderJson(request, response);
updateSingularityMetric(response?.res_data);
const ok = response.success && response.res_data?.success;
setStatus(ok ? "奇异点检测完成" : "奇异点检测失败", Boolean(ok));
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 执行逆解并保存待应用的关节解。
async function runInverseKinematics() {
await ensureInitialized();
setBusy(true, "逆解计算中");
drawTargetMarker(readTargetPose());
const request = buildInverseRequest();
try {
renderJson(request, null);
const response = await api.processBusinessRequest(request);
const solution = extractIkSolution(response);
state.latestIkJoints = solution;
elements.applyIkBtn.disabled = !solution;
renderJson(request, response);
setStatus(solution ? "逆解完成" : "逆解无解", Boolean(solution));
if (!solution) {
showToast("未取得可应用的关节解");
}
} catch (error) {
showError(error);
} finally {
setBusy(false);
}
}
// 应用最近一次逆解结果。
async function applyInverseSolution() {
if (!state.latestIkJoints) {
return;
}
state.joints = state.latestIkJoints.slice(0, jointCount).map(clampJoint);
syncJointControls();
await runForwardKinematics();
}
// 从正解响应中抽取关节对象状态。
function extractObjStates(response) {
const frames =
response?.res_data?.OPERATION?.OPERATION?.frames ??
response?.res_data?.OPERATION?.frames ??
[];
const frame = frames[0] || {};
return Array.isArray(frame.objStates) ? frame.objStates : [];
}
// 从 TCP 正解响应中抽取末端位姿。
function extractTcp(response) {
const position = response?.res_data?.position;
const orientation = response?.res_data?.orientation;
if (!Array.isArray(position) || !Array.isArray(orientation)) {
return null;
}
return {
position: position.map(Number),
orientation: orientation.map(Number),
};
}
// 从逆解响应中抽取最后一组关节解。
function extractIkSolution(response) {
const joints = response?.res_data?.joints;
if (!Array.isArray(joints) || joints.length === 0) {
return null;
}
const last = joints[joints.length - 1];
if (!Array.isArray(last) || last.length < jointCount) {
return null;
}
return last.map(Number);
}
// 将对象状态转换为累积世界位姿。
function toWorldPoints(objStates) {
const matrix = new THREE.Matrix4();
const world = new THREE.Matrix4();
const position = new THREE.Vector3();
const quaternion = new THREE.Quaternion();
const scale = new THREE.Vector3(1, 1, 1);
const points = [new THREE.Vector3(0, 0, 0)];
for (const item of objStates) {
position.set(Number(item.tx) || 0, Number(item.ty) || 0, Number(item.tz) || 0);
quaternion.set(
Number(item.qx) || 0,
Number(item.qy) || 0,
Number(item.qz) || 0,
Number(item.qw) || 1,
);
matrix.compose(position, quaternion.normalize(), scale);
world.multiply(matrix);
points.push(new THREE.Vector3().setFromMatrixPosition(world));
}
return points;
}
// 渲染机器人骨架、关节和 TCP。
function renderRobot(objStates) {
clearGroup(robotGroup);
const points = toWorldPoints(objStates);
elements.jointCountValue.textContent = String(Math.max(0, points.length - 1));
points.forEach((point, index) => {
const material = index === points.length - 1 ? tcpMaterial : jointMaterial;
const radius = index === points.length - 1 ? 0.032 : 0.026;
const joint = new THREE.Mesh(new THREE.SphereGeometry(radius, 32, 16), material);
joint.position.copy(point);
joint.castShadow = true;
robotGroup.add(joint);
});
for (let index = 0; index < points.length - 1; index += 1) {
const link = createLink(points[index], points[index + 1]);
if (link) {
robotGroup.add(link);
}
}
if (state.latestTcp) {
const tcp = new THREE.AxesHelper(0.16);
tcp.position.fromArray(state.latestTcp.position);
const q = state.latestTcp.orientation;
tcp.quaternion.set(q[0], q[1], q[2], q[3]).normalize();
robotGroup.add(tcp);
elements.tcpValue.textContent = state.latestTcp.position
.map((value) => Number(value).toFixed(3))
.join(", ");
}
}
// 创建两个关节点之间的连杆圆柱。
function createLink(start, end) {
const direction = new THREE.Vector3().subVectors(end, start);
const length = direction.length();
if (length < 0.001) {
return null;
}
const geometry = new THREE.CylinderGeometry(0.014, 0.014, length, 20);
const mesh = new THREE.Mesh(geometry, linkMaterial);
const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5);
mesh.position.copy(midpoint);
mesh.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.clone().normalize(),
);
mesh.castShadow = true;
mesh.receiveShadow = true;
return mesh;
}
// 清空三维分组。
function clearGroup(group) {
while (group.children.length > 0) {
const child = group.children.pop();
child.traverse((object) => {
if (object.geometry) {
object.geometry.dispose();
}
});
}
}
// 将当前 TCP 位姿写入逆解目标输入框。
function updateTcpTargetFromCurrent() {
if (!state.latestTcp) {
return;
}
const [x, y, z] = state.latestTcp.position;
const [qx, qy, qz, qw] = state.latestTcp.orientation;
elements.targetX.value = Number(x).toFixed(6);
elements.targetY.value = Number(y).toFixed(6);
elements.targetZ.value = Number(z).toFixed(6);
elements.targetQx.value = Number(qx).toFixed(6);
elements.targetQy.value = Number(qy).toFixed(6);
elements.targetQz.value = Number(qz).toFixed(6);
elements.targetQw.value = Number(qw).toFixed(6);
drawTargetMarker(readTargetPose());
}
// 读取逆解目标输入框。
function readTargetPose() {
return {
position: [
Number(elements.targetX.value) || 0,
Number(elements.targetY.value) || 0,
Number(elements.targetZ.value) || 0,
],
orientation: [
Number(elements.targetQx.value) || 0,
Number(elements.targetQy.value) || 0,
Number(elements.targetQz.value) || 0,
Number(elements.targetQw.value) || 1,
],
};
}
// 绘制逆解目标点。
function drawTargetMarker(targetPose) {
clearGroup(targetGroup);
const marker = new THREE.Mesh(
new THREE.TorusGeometry(0.056, 0.004, 12, 48),
targetMaterial,
);
marker.position.fromArray(targetPose.position);
targetGroup.add(marker);
const axes = new THREE.AxesHelper(0.18);
axes.position.fromArray(targetPose.position);
const q = targetPose.orientation;
axes.quaternion.set(q[0], q[1], q[2], q[3]).normalize();
targetGroup.add(axes);
}
// 渲染请求与响应 JSON。
function renderJson(request, response) {
if (request) {
elements.requestJson.value = JSON.stringify(request, null, 2);
}
if (response !== null) {
elements.responseJson.value = response
? JSON.stringify(response, null, 2)
: "";
}
}
// 更新奇异点风险指标。
function updateSingularityMetric(result) {
if (!result?.success) {
elements.singularityValue.textContent = "-";
return;
}
const riskText = {
normal: "正常",
warning: "接近",
singular: "奇异",
}[result.risk_level] ?? result.risk_level;
const minSv = Number(result.min_singular_value);
elements.singularityValue.textContent = `${riskText} / ${Number.isFinite(minSv) ? minSv.toExponential(2) : "-"}`;
}
// 设置按钮忙碌状态。
function setBusy(isBusy, label) {
[
elements.initBtn,
elements.registerUrdfBtn,
elements.resetBtn,
elements.fkBtn,
elements.singularityBtn,
elements.ikBtn,
elements.targetCurrentBtn,
].forEach((button) => {
button.disabled = isBusy;
});
elements.applyIkBtn.disabled = isBusy || !state.latestIkJoints;
if (label) {
setStatus(label, null);
}
}
// 设置求解状态。
function setStatus(message, ok) {
elements.solverStatus.textContent = message;
elements.solverStatus.className =
ok === null ? "pill" : `pill ${ok ? "ok" : "error"}`;
}
// 显示错误状态。
function showError(error) {
const message = error?.message || String(error);
setStatus("请求失败", false);
elements.responseJson.value = message;
showToast(message);
}
// 显示短提示。
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.add("show");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => {
elements.toast.classList.remove("show");
}, 3600);
}
// 重置关节到零位。
async function resetJoints() {
state.joints = Array(jointCount).fill(0);
state.latestIkJoints = null;
elements.applyIkBtn.disabled = true;
syncJointControls();
await runForwardKinematics();
}
// 处理窗口尺寸变化。
function resizeRenderer() {
const rect = elements.canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width));
const height = Math.max(1, Math.floor(rect.height));
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
// 启动三维渲染循环。
function animate() {
resizeRenderer();
controls.update();
renderer.render(scene, camera);
window.requestAnimationFrame(animate);
}
elements.initBtn.addEventListener("click", initializeWasm);
elements.registerUrdfBtn.addEventListener("click", registerUrdfFromFile);
elements.resetBtn.addEventListener("click", resetJoints);
elements.fkBtn.addEventListener("click", runForwardKinematics);
elements.singularityBtn.addEventListener("click", runSingularityCheck);
elements.ikBtn.addEventListener("click", runInverseKinematics);
elements.applyIkBtn.addEventListener("click", applyInverseSolution);
elements.targetCurrentBtn.addEventListener("click", updateTcpTargetFromCurrent);
[
elements.targetX,
elements.targetY,
elements.targetZ,
elements.targetQx,
elements.targetQy,
elements.targetQz,
elements.targetQw,
].forEach((input) => input.addEventListener("change", () => drawTargetMarker(readTargetPose())));
createJointControls();
syncJointControls();
drawTargetMarker(readTargetPose());
animate();
initializeWasm();
</script>
</body>
</html>