Files
smart_wasm/public/fourbar_test.html
2026-06-16 11:58:10 +08:00

1795 lines
60 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>四连杆/曲柄滑块 WASM 验证台</title>
<style>
:root {
--bg: #f5f7f8;
--surface: #ffffff;
--surface-soft: #eef2f5;
--line: #cdd7df;
--text: #17212b;
--muted: #64717d;
--primary: #0f6f8f;
--primary-strong: #0a526b;
--accent: #946200;
--danger: #b42318;
--ok: #157347;
--code-bg: #111820;
--code-text: #edf6fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family:
"Segoe UI",
"Microsoft YaHei",
Arial,
sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
min-height: 44px;
border: 1px solid transparent;
border-radius: 8px;
padding: 10px 14px;
font-weight: 650;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(15, 111, 143, 0.25);
outline-offset: 2px;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.primary {
background: var(--primary);
color: #ffffff;
}
.primary:hover:not(:disabled) {
background: var(--primary-strong);
}
.secondary {
background: #ffffff;
color: var(--text);
border-color: var(--line);
}
.secondary:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary-strong);
}
.shell {
width: min(1460px, 100%);
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 28px;
line-height: 1.2;
}
.subtitle {
margin-top: 6px;
color: var(--muted);
font-size: 14px;
}
.status,
.badge {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
color: var(--muted);
font-size: 13px;
}
.status {
min-width: 156px;
padding: 8px 10px;
text-align: center;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 8px;
font-weight: 650;
}
.badge.ok {
border-color: rgba(21, 115, 71, 0.3);
background: rgba(21, 115, 71, 0.08);
color: var(--ok);
}
.badge.warn {
border-color: rgba(148, 98, 0, 0.35);
background: rgba(148, 98, 0, 0.08);
color: var(--accent);
}
.badge.error {
border-color: rgba(180, 35, 24, 0.35);
background: rgba(180, 35, 24, 0.08);
color: var(--danger);
}
.layout {
display: grid;
grid-template-columns: minmax(360px, 0.72fr) minmax(600px, 1.28fr);
gap: 16px;
align-items: start;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: var(--surface-soft);
}
.panel-header h2 {
font-size: 17px;
}
.panel-body {
padding: 16px;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
label {
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--text);
background: #ffffff;
}
input {
min-height: 44px;
padding: 10px 11px;
}
select {
min-height: 44px;
padding: 10px 11px;
}
textarea {
min-height: 360px;
resize: vertical;
padding: 12px;
font:
13px/1.5 Consolas,
"Courier New",
monospace;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.message {
margin-top: 12px;
min-height: 20px;
color: var(--muted);
font-size: 13px;
}
.message.is-ok {
color: var(--ok);
}
.message.is-error {
color: var(--danger);
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric {
min-height: 88px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #ffffff;
}
.metric-label {
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.metric-value {
margin-top: 8px;
color: var(--text);
font-size: 22px;
font-weight: 750;
overflow-wrap: anywhere;
}
.metric-value.ok {
color: var(--ok);
}
.metric-value.error {
color: var(--danger);
}
.chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.three-stage {
margin-bottom: 16px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: #0f1720;
}
.three-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: #17212b;
color: #edf6fb;
}
.three-toolbar h3 {
font-size: 15px;
}
.three-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.three-toolbar .secondary {
min-height: 38px;
background: #1f2d3a;
border-color: #3d4c5a;
color: #edf6fb;
}
.three-viewport {
position: relative;
width: 100%;
height: 460px;
}
.three-viewport canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
}
.three-viewport canvas:active {
cursor: grabbing;
}
.three-overlay {
position: absolute;
left: 14px;
bottom: 14px;
display: grid;
gap: 6px;
width: min(360px, calc(100% - 28px));
padding: 10px 12px;
border: 1px solid rgba(237, 246, 251, 0.16);
border-radius: 8px;
background: rgba(15, 23, 32, 0.78);
color: #edf6fb;
font-size: 12px;
line-height: 1.45;
pointer-events: none;
}
.three-overlay strong {
font-size: 13px;
}
.three-note {
padding: 12px 14px 14px;
border-top: 1px solid rgba(237, 246, 251, 0.12);
color: #c8d3dc;
font-size: 13px;
line-height: 1.55;
}
.chart-card {
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.chart-card.wide {
grid-column: 1 / -1;
}
.chart-title {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
color: var(--text);
font-size: 14px;
font-weight: 700;
}
.chart-title span:last-child {
color: var(--muted);
font-weight: 650;
}
.chart {
width: 100%;
height: 360px;
}
.chart.compact {
height: 300px;
}
.chart.empty {
display: grid;
place-items: center;
color: var(--muted);
font-size: 14px;
}
.chart-note {
min-height: 84px;
padding: 12px 14px 14px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
line-height: 1.55;
}
.chart-note strong {
color: var(--text);
}
.table-wrap {
margin-top: 16px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
font-size: 13px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 10px 8px;
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-weight: 700;
background: var(--surface-soft);
}
.tabs {
display: flex;
gap: 8px;
margin-top: 16px;
border-bottom: 1px solid var(--line);
}
.tab {
min-height: 40px;
border-color: var(--line);
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: #ffffff;
color: var(--muted);
}
.tab.active {
background: var(--primary);
color: #ffffff;
}
pre {
margin: 0;
max-height: 420px;
overflow: auto;
border-radius: 0 0 8px 8px;
padding: 14px;
background: var(--code-bg);
color: var(--code-text);
font:
12px/1.5 Consolas,
"Courier New",
monospace;
}
.hidden {
display: none;
}
@media (max-width: 1000px) {
.topbar {
align-items: stretch;
flex-direction: column;
}
.status {
width: 100%;
}
.layout {
grid-template-columns: 1fr;
}
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart-grid {
grid-template-columns: 1fr;
}
.three-toolbar {
align-items: flex-start;
flex-direction: column;
}
.three-viewport {
height: 380px;
}
}
@media (max-width: 560px) {
.shell {
padding: 16px;
}
.grid,
.summary {
grid-template-columns: 1fr;
}
h1 {
font-size: 24px;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>四连杆/曲柄滑块 WASM 验证台</h1>
<p class="subtitle">Cmd_FourBar_Simulate · PRRR / RPRR / RRRP / RRRR</p>
</div>
<div class="status" id="wasmStatus">WASM 未加载</div>
</header>
<section class="layout">
<div class="panel">
<div class="panel-header">
<h2>输入</h2>
<span class="badge" id="requestStatus">未执行</span>
</div>
<div class="panel-body">
<div class="grid">
<div class="field">
<label for="mechanismTypeInput">机构类型</label>
<select id="mechanismTypeInput">
<option value="RRRP">RRRP 曲柄滑块正解</option>
<option value="PRRR">PRRR 滑块曲柄逆解</option>
<option value="RPRR">RPRR 曲柄摇块正解</option>
<option value="RRRR">RRRR 四转副四杆</option>
</select>
</div>
<div class="field">
<label for="lAbInput" id="lAbLabel">L_AB 曲柄长度</label>
<input id="lAbInput" type="number" min="0" step="0.001" />
</div>
<div class="field">
<label for="lBsInput" id="lBsLabel">L_BS 连杆长度</label>
<input id="lBsInput" type="number" min="0" step="0.001" />
</div>
<div class="field">
<label for="sOfsInput" id="sOfsLabel">S_OFS 滑块偏移</label>
<input id="sOfsInput" type="number" step="0.001" />
</div>
<div class="field">
<label for="l3Input" id="l3Label">L3 摇杆长度</label>
<input id="l3Input" type="number" min="0" step="0.001" />
</div>
<div class="field">
<label for="l4Input" id="l4Label">L4 机架长度</label>
<input id="l4Input" type="number" min="0" step="0.001" />
</div>
<div class="field">
<label for="angleInput" id="startValueLabel">起始输入</label>
<input id="angleInput" type="number" step="1" />
</div>
<div class="field">
<label for="stepInput">stepTime 步长时间</label>
<input id="stepInput" type="number" min="0.01" max="1" step="0.01" />
</div>
<div class="field">
<label for="scanEndInput" id="endValueLabel">终止输入</label>
<input id="scanEndInput" type="number" step="1" />
</div>
<div class="field">
<label for="durationInput">duration 总时长</label>
<input id="durationInput" type="number" min="0.02" max="60" step="0.01" />
</div>
</div>
<div class="actions">
<button class="secondary" id="validButton" type="button">有效样例</button>
<button class="secondary" id="invalidButton" type="button">异常样例</button>
<button class="primary" id="runButton" type="button">调用接口</button>
<button class="primary" id="scanButton" type="button">仿真扫描</button>
</div>
<div class="message" id="inputMessage" aria-live="polite"></div>
<div class="field" style="margin-top: 16px">
<label for="requestOutput">请求 JSON</label>
<textarea id="requestOutput" spellcheck="false"></textarea>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>结果</h2>
<span class="badge" id="verifyStatus">未执行</span>
</div>
<div class="panel-body">
<div class="summary">
<div class="metric">
<div class="metric-label">顶层 success</div>
<div class="metric-value" id="topSuccess">-</div>
</div>
<div class="metric">
<div class="metric-label">业务 success</div>
<div class="metric-value" id="businessSuccess">-</div>
</div>
<div class="metric">
<div class="metric-label">输出 X</div>
<div class="metric-value" id="outputX">-</div>
</div>
<div class="metric">
<div class="metric-label">完整性</div>
<div class="metric-value" id="completeStatus">-</div>
</div>
</div>
<div class="three-stage" aria-label="曲柄滑块 3D 验证视图">
<div class="three-toolbar">
<h3>Three.js 机构空间视图</h3>
<div class="three-toolbar-actions">
<button class="secondary" id="playScanButton" type="button">播放扫描</button>
<button class="secondary" id="pauseScanButton" type="button">暂停</button>
<button class="secondary" id="resetCameraButton" type="button">复位视角</button>
</div>
</div>
<div class="three-viewport" id="threeViewport">
<div class="three-overlay" id="threeOverlay">
<strong>等待接口数据</strong>
<span>调用接口后显示当前机构姿态,仿真扫描后可播放 WASM 返回的连续点位。</span>
</div>
</div>
<div class="three-note">
<strong>3D 视图</strong>
使用接口返回点位直接驱动模型:蓝色为输入杆,金色为连接杆,绿色为输出滑块或输出杆,灰色为固定机架/导轨。播放扫描时不做前端几何重算,只复用 WASM 返回的逐帧点位。
</div>
</div>
<div class="chart-grid" aria-label="四连杆图形结果">
<div class="chart-card wide">
<div class="chart-title">
<span>机构当前姿态</span>
<span id="mechanismTopologyLabel">拓扑</span>
</div>
<div class="chart empty" id="mechanismChart">等待执行</div>
<div class="chart-note">
<strong>机构当前姿态</strong>
显示接口返回关键点的二维位置,并按当前机构类型连接输入杆、连接杆、输出杆或滑块。它用于核对单帧点位、杆长和装配方向是否符合预期。
</div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>B 点圆轨迹</span>
<span>输入端轨迹</span>
</div>
<div class="chart compact empty" id="crankChart">等待扫描</div>
<div class="chart-note">
<strong>B 点圆轨迹</strong>
展示仿真过程中主运动点的轨迹。对于角度驱动类型可用于检查曲柄半径、角度单位和旋转方向;对于滑块驱动类型可观察逆解得到的 B 点连续性。
</div>
</div>
<div class="chart-card">
<div class="chart-title">
<span>输出位移曲线</span>
<span id="outputCurveLabel">output / input</span>
</div>
<div class="chart compact empty" id="sliderChart">等待扫描</div>
<div class="chart-note">
<strong>输出位移曲线</strong>
横轴为接口输入值,纵轴为输出点 x 坐标。它适合检查输出链节或滑块在扫描范围内是否连续、是否存在极限位置跳变。
</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>检查项</th>
<th>结果</th>
<th>说明</th>
</tr>
</thead>
<tbody id="checkBody">
<tr>
<td colspan="3">等待执行</td>
</tr>
</tbody>
</table>
</div>
<div class="tabs">
<button class="tab active" id="responseTab" type="button">响应 JSON</button>
<button class="tab" id="scanTab" type="button">扫描 JSON</button>
</div>
<pre id="responseOutput">No output yet.</pre>
<pre class="hidden" id="scanOutput">No scan yet.</pre>
</div>
</div>
</section>
</main>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script src="./wasm/smart_math.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
const mechanismMeta = {
RRRP: {
title: "RRRP 曲柄滑块正解",
inputName: "angleDeg",
topology: "A-B-S",
firstLabel: "L_AB 曲柄长度",
secondLabel: "L_BS 连杆长度",
offsetLabel: "S_OFS 滑块偏移",
l3Label: "L3 未使用",
l4Label: "L4 未使用",
startLabel: "起始角度 angleDeg",
endLabel: "终止角度 endAngleDeg",
outputLabel: "S.x / angle",
},
PRRR: {
title: "PRRR 滑块曲柄逆解",
inputName: "sliderX",
topology: "A-B-S",
firstLabel: "L_AB 曲柄长度",
secondLabel: "L_BS 连杆长度",
offsetLabel: "S_OFS 滑块偏移",
l3Label: "L3 未使用",
l4Label: "L4 未使用",
startLabel: "起始滑块 sliderX",
endLabel: "终止滑块 endSliderX",
outputLabel: "B.x / sliderX",
},
RPRR: {
title: "RPRR 曲柄摇块正解",
inputName: "angleDeg",
topology: "O-A-B-C",
firstLabel: "OA 输入杆长度",
secondLabel: "AB 连接杆长度",
offsetLabel: "S_OFS 未使用",
l3Label: "L3 未使用",
l4Label: "OC 固定中心距",
startLabel: "起始角度 angleDeg",
endLabel: "终止角度 endAngleDeg",
outputLabel: "B.x / angle",
},
RRRR: {
title: "RRRR 四转副四杆",
inputName: "angleDeg",
topology: "A-B-C-D",
firstLabel: "L1 输入杆长度",
secondLabel: "L2 连接杆长度",
offsetLabel: "S_OFS 未使用",
l3Label: "L3 输出杆长度",
l4Label: "L4 固定杆长度",
startLabel: "起始角度 angleDeg",
endLabel: "终止角度 endAngleDeg",
outputLabel: "C.x / angle",
},
};
const validSamples = {
RRRP: { mechanismType: "RRRP", L_AB: 0.5, L_BS: 2.0, S_OFS: 0.0, L3: 2.5, L4: 3.5, startValue: 45, endValue: 360 },
PRRR: { mechanismType: "PRRR", L_AB: 0.5, L_BS: 2.0, S_OFS: 0.0, L3: 2.5, L4: 3.5, startValue: 1.5, endValue: 2.5 },
RPRR: { mechanismType: "RPRR", L_AB: 1.0, L_BS: 3.0, S_OFS: 0.0, L3: 2.5, L4: 3.0, startValue: 0, endValue: 180 },
RRRR: { mechanismType: "RRRR", L_AB: 1.0, L_BS: 3.0, S_OFS: 0.0, L3: 2.5, L4: 3.5, startValue: 0, endValue: 180 },
};
const invalidSamples = {
RRRP: { mechanismType: "RRRP", L_AB: 0, L_BS: 0.2, S_OFS: 0.5, L3: 2.5, L4: 3.5, startValue: 30, endValue: 180 },
PRRR: { mechanismType: "PRRR", L_AB: 0, L_BS: 0.2, S_OFS: 0.5, L3: 2.5, L4: 3.5, startValue: 0, endValue: 1 },
RPRR: { mechanismType: "RPRR", L_AB: 0, L_BS: 0.2, S_OFS: 0.0, L3: 2.5, L4: 8.0, startValue: 0, endValue: 180 },
RRRR: { mechanismType: "RRRR", L_AB: 0, L_BS: 0.2, S_OFS: 0.0, L3: 0.1, L4: 8.0, startValue: 0, endValue: 180 },
};
let wasmModule = null;
let charts = null;
let threeView = null;
let latestResponse = null;
let latestScan = [];
let scanAnimation = {
active: false,
frame: 0,
timer: 0,
intervalId: null,
};
const wasmStatus = document.getElementById("wasmStatus");
const requestStatus = document.getElementById("requestStatus");
const verifyStatus = document.getElementById("verifyStatus");
const inputMessage = document.getElementById("inputMessage");
const mechanismTypeInput = document.getElementById("mechanismTypeInput");
const lAbLabel = document.getElementById("lAbLabel");
const lBsLabel = document.getElementById("lBsLabel");
const sOfsLabel = document.getElementById("sOfsLabel");
const l3Label = document.getElementById("l3Label");
const l4Label = document.getElementById("l4Label");
const startValueLabel = document.getElementById("startValueLabel");
const endValueLabel = document.getElementById("endValueLabel");
const mechanismTopologyLabel = document.getElementById("mechanismTopologyLabel");
const outputCurveLabel = document.getElementById("outputCurveLabel");
const lAbInput = document.getElementById("lAbInput");
const lBsInput = document.getElementById("lBsInput");
const sOfsInput = document.getElementById("sOfsInput");
const l3Input = document.getElementById("l3Input");
const l4Input = document.getElementById("l4Input");
const angleInput = document.getElementById("angleInput");
const stepInput = document.getElementById("stepInput");
const scanEndInput = document.getElementById("scanEndInput");
const durationInput = document.getElementById("durationInput");
const requestOutput = document.getElementById("requestOutput");
const topSuccess = document.getElementById("topSuccess");
const businessSuccess = document.getElementById("businessSuccess");
const outputX = document.getElementById("outputX");
const completeStatus = document.getElementById("completeStatus");
const checkBody = document.getElementById("checkBody");
const responseOutput = document.getElementById("responseOutput");
const scanOutput = document.getElementById("scanOutput");
const responseTab = document.getElementById("responseTab");
const scanTab = document.getElementById("scanTab");
const threeViewport = document.getElementById("threeViewport");
const threeOverlay = document.getElementById("threeOverlay");
const chartElements = {
mechanism: document.getElementById("mechanismChart"),
crank: document.getElementById("crankChart"),
slider: document.getElementById("sliderChart"),
};
// 设置页面提示文案和语义状态。
function setMessage(message, className = "") {
inputMessage.textContent = message;
inputMessage.className = `message ${className}`.trim();
}
// 保留固定小数位,便于表格和图形标签核对。
function fmt(value, digits = 6) {
return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "-";
}
// 获取当前机构类型的展示和接口元信息。
function getCurrentMeta() {
return mechanismMeta[mechanismTypeInput.value] || mechanismMeta.RRRP;
}
// 根据当前机构类型刷新字段标签。
function updateMechanismLabels() {
const meta = getCurrentMeta();
lAbLabel.textContent = meta.firstLabel;
lBsLabel.textContent = meta.secondLabel;
sOfsLabel.textContent = meta.offsetLabel;
l3Label.textContent = meta.l3Label;
l4Label.textContent = meta.l4Label;
startValueLabel.textContent = meta.startLabel;
endValueLabel.textContent = meta.endLabel;
mechanismTopologyLabel.textContent = meta.topology;
outputCurveLabel.textContent = meta.outputLabel;
}
// 从页面读取并校验统一四连杆仿真接口参数。
function readInput() {
const input = {
mechanismType: mechanismTypeInput.value,
L_AB: Number(lAbInput.value),
L_BS: Number(lBsInput.value),
S_OFS: Number(sOfsInput.value),
L3: Number(l3Input.value),
L4: Number(l4Input.value),
startValue: Number(angleInput.value),
endValue: Number(scanEndInput.value),
};
const numericValues = [input.L_AB, input.L_BS, input.S_OFS, input.L3, input.L4, input.startValue, input.endValue];
if (!numericValues.every(Number.isFinite)) {
throw new Error("所有机构参数都必须是有效数字");
}
return input;
}
// 按 WASM 统一入口格式组装四类连杆仿真请求。
function buildSimulationRequest(input, sim, reqCode = `FOURBAR_SIM_${Date.now()}`) {
const param = {
mechanismType: input.mechanismType,
duration: sim.duration,
stepTime: sim.stepTime,
startValue: sim.startValue ?? input.startValue,
endValue: sim.endValue ?? input.endValue,
};
if (input.mechanismType === "RRRP" || input.mechanismType === "PRRR") {
param.L_AB = input.L_AB;
param.L_BS = input.L_BS;
param.S_OFS = input.S_OFS;
} else if (input.mechanismType === "RPRR") {
param.OA = input.L_AB;
param.AB = input.L_BS;
param.OC = input.L4;
} else {
param.L1 = input.L_AB;
param.L2 = input.L_BS;
param.L3 = input.L3;
param.L4 = input.L4;
}
return {
msg: "fourbar unified simulate",
req_code: reqCode,
req_from: "fourbar_test_page",
req_cmd: "Cmd_FourBar_Simulate",
req_param: param,
};
}
// 将当前表单同步到请求 JSON 文本框。
function refreshRequestPreview() {
try {
requestOutput.value = JSON.stringify(
buildSimulationRequest(readInput(), {
duration: Number(durationInput.value) || 2,
stepTime: Number(stepInput.value) || 0.02,
}),
null,
2,
);
} catch {
requestOutput.value = "";
}
}
// 填入内置样例并刷新请求预览。
function loadSample(sample) {
mechanismTypeInput.value = sample.mechanismType;
updateMechanismLabels();
lAbInput.value = sample.L_AB;
lBsInput.value = sample.L_BS;
sOfsInput.value = sample.S_OFS;
l3Input.value = sample.L3;
l4Input.value = sample.L4;
angleInput.value = sample.startValue;
scanEndInput.value = sample.endValue;
stepInput.value = stepInput.value || "0.02";
durationInput.value = durationInput.value || "2";
refreshRequestPreview();
}
// 懒初始化 ECharts 实例。
function ensureCharts() {
if (!window.echarts) {
throw new Error("ECharts 加载失败,请检查网络或 CDN 可用性");
}
if (charts) return charts;
Object.values(chartElements).forEach((element) => {
element.classList.remove("empty");
element.textContent = "";
});
charts = {
mechanism: echarts.init(chartElements.mechanism),
crank: echarts.init(chartElements.crank),
slider: echarts.init(chartElements.slider),
};
window.addEventListener("resize", () => {
Object.values(charts).forEach((chart) => chart.resize());
});
return charts;
}
// 初始化 Three.js 场景,负责更直观地展示接口返回的机构姿态。
function ensureThreeView() {
if (threeView) return threeView;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0f1720);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 100);
camera.up.set(0, 0, 1);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
threeViewport.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.enablePan = true;
controls.enableZoom = true;
controls.screenSpacePanning = false;
controls.minDistance = 0.35;
controls.maxDistance = 80;
const ambient = new THREE.AmbientLight(0xffffff, 0.58);
const keyLight = new THREE.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(3, 4, 5);
scene.add(ambient, keyLight);
const grid = new THREE.GridHelper(8, 32, 0x46606f, 0x263744);
grid.rotation.x = Math.PI / 2;
grid.position.z = -0.08;
scene.add(grid);
const group = new THREE.Group();
scene.add(group);
const materials = {
crank: new THREE.MeshStandardMaterial({ color: 0x0f6f8f, roughness: 0.42, metalness: 0.18 }),
rod: new THREE.MeshStandardMaterial({ color: 0xc18a18, roughness: 0.46, metalness: 0.12 }),
slider: new THREE.MeshStandardMaterial({ color: 0x24a267, roughness: 0.5, metalness: 0.08 }),
joint: new THREE.MeshStandardMaterial({ color: 0xf3f7fa, roughness: 0.35, metalness: 0.18 }),
rail: new THREE.MeshStandardMaterial({ color: 0x768898, roughness: 0.58, metalness: 0.08 }),
path: new THREE.LineBasicMaterial({ color: 0x62c7e8 }),
sliderPath: new THREE.LineBasicMaterial({ color: 0x8ddf9e }),
};
const crank = createBar(materials.crank);
const rod = createBar(materials.rod);
const outputLink = createBar(materials.slider);
const fixedLink = createBar(materials.rail, 0.035);
const rail = createBar(materials.rail, 0.035);
const slider = new THREE.Mesh(new THREE.BoxGeometry(0.32, 0.16, 0.18), materials.slider);
const pointA = createJoint(materials.joint, 0.075);
const pointB = createJoint(materials.joint, 0.065);
const pointC = createJoint(materials.joint, 0.07);
const pointD = createJoint(materials.joint, 0.07);
const pathLine = new THREE.Line(new THREE.BufferGeometry(), materials.path);
const sliderPathLine = new THREE.Line(new THREE.BufferGeometry(), materials.sliderPath);
group.add(crank, rod, outputLink, fixedLink, rail, slider, pointA, pointB, pointC, pointD, pathLine, sliderPathLine);
threeView = {
scene,
camera,
renderer,
controls,
group,
objects: { crank, rod, outputLink, fixedLink, rail, slider, pointA, pointB, pointC, pointD, pathLine, sliderPathLine },
};
resetThreeCamera();
resizeThreeView();
window.addEventListener("resize", resizeThreeView);
renderer.setAnimationLoop(renderThreeFrame);
return threeView;
}
// 创建一根沿 X 轴缩放的圆柱连杆。
function createBar(material, radius = 0.045) {
const geometry = new THREE.CylinderGeometry(radius, radius, 1, 24);
geometry.rotateZ(Math.PI / 2);
return new THREE.Mesh(geometry, material);
}
// 创建关键铰点的小球。
function createJoint(material, radius) {
return new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 16), material);
}
// 把二维接口点映射到 Three.js 的 XY 平面,保持 Z 轴为三维相机上方向。
function toThreePoint(point) {
return new THREE.Vector3(point.x, point.y, 0);
}
// 更新圆柱连杆的位置、长度和朝向。
function placeBar(mesh, start, end) {
if (!start || !end) {
mesh.visible = false;
return;
}
mesh.visible = true;
const from = toThreePoint(start);
const to = toThreePoint(end);
const delta = new THREE.Vector3().subVectors(to, from);
const length = Math.max(delta.length(), 0.000001);
mesh.position.copy(from).addScaledVector(delta, 0.5);
mesh.scale.set(length, 1, 1);
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(1, 0, 0), delta.normalize());
}
// 根据机构范围调整相机距离,避免 3D 模型出框。
function resetThreeCamera(input = readInput()) {
const view = ensureThreeView();
const span = Math.max(input.L_AB + input.L_BS + input.L3 + input.L4 * 0.35, Math.abs(input.S_OFS) + input.L_AB, 1);
const target = new THREE.Vector3(span * 0.35, input.S_OFS * 0.5, 0);
view.camera.up.set(0, 0, 1);
view.camera.position.set(span * 0.9, span * -1.45, span * 1.75);
view.camera.lookAt(target);
view.controls.target.copy(target);
view.controls.update();
}
// 依据机构参数固定导轨显示范围,避免播放时导轨随滑块位置伸缩。
function getFixedRailRange(input) {
const crank = Math.max(input.L_AB, 0);
const rod = Math.max(input.L_BS, 0);
const yDeltaMax = Math.abs(input.S_OFS) + crank;
const reachableXOffset = Math.sqrt(Math.max(0, rod ** 2 - yDeltaMax ** 2));
const minX = -crank - 0.35;
const maxX = crank + reachableXOffset + 0.7;
return {
minX: Math.min(minX, maxX - 0.8),
maxX: Math.max(maxX, minX + 0.8),
};
}
// 把不同拓扑的点位归一成图形和 3D 渲染可复用的主链。
function normalizeMechanismPoints(points = {}, input = readInput()) {
const type = input.mechanismType;
if (type === "RRRR") {
return {
type,
base: points.A,
input: points.B,
coupler: points.C,
output: points.D,
fixed: points.A && points.D ? [points.A, points.D] : null,
links: [
["输入杆", points.A, points.B],
["连接杆", points.B, points.C],
["输出杆", points.C, points.D],
["固定杆", points.D, points.A],
],
plotPoints: [
["A", points.A],
["B", points.B],
["C", points.C],
["D", points.D],
],
primaryPathPoint: points.B,
outputPoint: points.C,
};
}
if (type === "RPRR") {
return {
type,
base: points.O,
input: points.A,
coupler: points.B,
output: points.C,
fixed: points.O && points.C ? [points.O, points.C] : null,
links: [
["输入杆", points.O, points.A],
["连接杆", points.A, points.B],
["输出杆", points.B, points.C],
["固定杆", points.C, points.O],
],
plotPoints: [
["O", points.O],
["A", points.A],
["B", points.B],
["C", points.C],
],
primaryPathPoint: points.A,
outputPoint: points.B,
};
}
return {
type,
base: points.A,
input: points.B,
coupler: points.S,
output: points.S,
fixed: null,
links: [
["输入杆", points.A, points.B],
["连接杆", points.B, points.S],
],
plotPoints: [
["A", points.A],
["B", points.B],
["S", points.S],
],
primaryPathPoint: points.B,
outputPoint: type === "PRRR" ? points.B : points.S,
};
}
// 调整 Three.js 画布尺寸。
function resizeThreeView() {
if (!threeView) return;
const rect = threeViewport.getBoundingClientRect();
threeView.camera.aspect = Math.max(rect.width, 1) / Math.max(rect.height, 1);
threeView.camera.updateProjectionMatrix();
threeView.renderer.setSize(rect.width, rect.height, false);
}
// Three.js 持续渲染循环。
function renderThreeFrame() {
if (!threeView) return;
threeView.controls.update();
threeView.renderer.render(threeView.scene, threeView.camera);
}
// 使用接口点位刷新 3D 机构姿态。
function renderThreeMechanism(points, input, meta = {}) {
const view = ensureThreeView();
const normalized = normalizeMechanismPoints(points, input);
const railRange = getFixedRailRange(input);
placeBar(view.objects.crank, normalized.links[0]?.[1], normalized.links[0]?.[2]);
placeBar(view.objects.rod, normalized.links[1]?.[1], normalized.links[1]?.[2]);
placeBar(view.objects.outputLink, normalized.links[2]?.[1], normalized.links[2]?.[2]);
placeBar(view.objects.fixedLink, normalized.links[3]?.[1], normalized.links[3]?.[2]);
const showRail = input.mechanismType === "RRRP" || input.mechanismType === "PRRR";
view.objects.rail.visible = showRail;
view.objects.slider.visible = showRail;
if (showRail) {
placeBar(
view.objects.rail,
{ x: railRange.minX, y: input.S_OFS },
{ x: railRange.maxX, y: input.S_OFS },
);
view.objects.slider.position.copy(toThreePoint(normalized.output));
view.objects.slider.position.z = 0.02;
}
const joints = [view.objects.pointA, view.objects.pointB, view.objects.pointC, view.objects.pointD];
normalized.plotPoints.forEach(([, point], index) => {
if (!joints[index]) return;
joints[index].visible = Boolean(point);
if (point) joints[index].position.copy(toThreePoint(point));
});
for (let index = normalized.plotPoints.length; index < joints.length; index += 1) {
joints[index].visible = false;
}
if (meta.fitCamera) {
resetThreeCamera(input);
}
const inputValue = meta.value ?? input.startValue;
const outputPoint = normalized.outputPoint || {};
threeOverlay.innerHTML = `
<strong>${meta.title || "当前接口姿态"}</strong>
<span>${getCurrentMeta().inputName}=${fmt(inputValue, 3)},输出=(${fmt(outputPoint.x, 4)}, ${fmt(outputPoint.y, 4)})</span>
`;
}
// 使用扫描结果刷新 3D 轨迹线。
function renderThreePaths(scanRows) {
const view = ensureThreeView();
const validRows = scanRows.filter((row) => row.ok);
const bPoints = validRows.map((row) => toThreePoint(row.primary));
const sPoints = validRows.map((row) => toThreePoint(row.output));
view.objects.pathLine.geometry.dispose();
view.objects.pathLine.geometry = new THREE.BufferGeometry().setFromPoints(bPoints);
view.objects.sliderPathLine.geometry.dispose();
view.objects.sliderPathLine.geometry = new THREE.BufferGeometry().setFromPoints(sPoints);
}
// 播放扫描结果中的下一帧。
function animateScanFrame() {
const validRows = latestScan.filter((row) => row.ok);
if (!validRows.length) {
pauseScanAnimation();
return;
}
const row = validRows[scanAnimation.frame % validRows.length];
const input = { ...readInput(), startValue: row.inputValue };
renderThreeMechanism(
row.points,
input,
{ title: "扫描播放中", value: row.inputValue },
);
scanAnimation.frame += 1;
}
// 开始播放已扫描的接口点位。
function playScanAnimation() {
if (!latestScan.some((row) => row.ok)) {
setMessage("请先执行角度扫描,再播放 3D 动画", "is-error");
return;
}
renderThreePaths(latestScan);
if (scanAnimation.intervalId) {
clearInterval(scanAnimation.intervalId);
}
scanAnimation.active = true;
scanAnimation.timer = 0;
animateScanFrame();
scanAnimation.intervalId = window.setInterval(animateScanFrame, 120);
setMessage("正在播放扫描点位动画", "is-ok");
}
// 暂停 3D 扫描动画。
function pauseScanAnimation() {
scanAnimation.active = false;
if (scanAnimation.intervalId) {
clearInterval(scanAnimation.intervalId);
scanAnimation.intervalId = null;
}
setMessage("3D 动画已暂停", "is-ok");
}
// 加载并初始化 WASM 模块。
async function ensureWasm() {
if (wasmModule) return wasmModule;
wasmStatus.textContent = "WASM 加载中";
wasmModule = await createSmartMathModule();
const ptr = wasmModule._init_func();
try {
wasmModule.UTF8ToString(ptr);
} finally {
wasmModule._smart_free_string(ptr);
}
wasmStatus.textContent = "WASM 已初始化";
return wasmModule;
}
// 在 WASM 内存中写入字符串。
function allocString(module, value) {
const size = module.lengthBytesUTF8(value) + 1;
const ptr = module._malloc(size);
module.stringToUTF8(value, ptr, size);
return ptr;
}
// 调用四连杆统一仿真接口并解析 JSON 响应。
async function callFourBarSimulation(input, sim) {
const module = await ensureWasm();
const payload = JSON.stringify(buildSimulationRequest(input, sim));
let requestPtr = 0;
let responsePtr = 0;
try {
requestPtr = allocString(module, payload);
responsePtr = module._func(requestPtr);
return JSON.parse(module.UTF8ToString(responsePtr));
} finally {
if (responsePtr) module._smart_free_string(responsePtr);
if (requestPtr) module._free(requestPtr);
}
}
// 判断有效响应是否包含当前机构验证需要的核心字段。
function inspectResponse(response, input = readInput()) {
const resData = response && response.res_data ? response.res_data : {};
const firstFrame = (resData.frames || [])[0] || {};
const points = firstFrame.points || {};
const poses = firstFrame.poses || {};
const hasError = Boolean(resData.error);
const requiredPoints = input.mechanismType === "RRRR"
? ["A", "B", "C", "D"]
: input.mechanismType === "RPRR"
? ["O", "A", "B", "C"]
: ["A", "B", "S"];
const missingPoints = requiredPoints.filter((key) => !points[key]);
const hasTrajectory = Array.isArray(resData.trajectory) && resData.trajectory.length > 0;
const hasFrames = Array.isArray(resData.frames) && resData.frames.length > 0;
const hasBusinessType = resData.mechanismType === input.mechanismType;
return {
resData,
firstFrame,
points,
poses,
hasError,
missingPoints,
hasTrajectory,
hasFrames,
hasBusinessType,
complete:
response.success === true &&
resData.success === true &&
!hasError &&
missingPoints.length === 0 &&
hasFrames &&
hasTrajectory &&
hasBusinessType,
};
}
// 更新顶部摘要指标。
function renderSummary(response, input) {
const check = inspectResponse(response, input);
const normalized = normalizeMechanismPoints(check.points, input);
const outputPoint = normalized.outputPoint;
topSuccess.textContent = String(Boolean(response.success));
topSuccess.className = `metric-value ${response.success ? "ok" : "error"}`;
businessSuccess.textContent = String(Boolean(check.resData.success));
businessSuccess.className = `metric-value ${check.resData.success ? "ok" : "error"}`;
outputX.textContent = outputPoint ? fmt(outputPoint.x, 5) : "-";
completeStatus.textContent = check.complete ? "完整" : "异常";
completeStatus.className = `metric-value ${check.complete ? "ok" : "error"}`;
verifyStatus.textContent = check.complete ? "接口完整" : check.hasError ? "业务失败" : "需核对";
verifyStatus.className = `badge ${check.complete ? "ok" : check.hasError ? "error" : "warn"}`;
}
// 渲染字段完整性和几何误差检查表。
function renderChecks(response, input) {
const check = inspectResponse(response, input);
const rows = [];
rows.push({
name: "顶层 success",
ok: response.success === true,
desc: `实际值:${String(response.success)},业务失败时这里应为 false`,
});
rows.push({
name: "业务 error",
ok: !check.hasError,
desc: check.hasError ? check.resData.error : "res_data.error 不存在",
});
rows.push({
name: "机构类型",
ok: check.hasBusinessType,
desc: `期望 ${input.mechanismType},实际 ${check.resData.mechanismType || "-"}`,
});
rows.push({
name: "仿真帧",
ok: check.hasFrames,
desc: check.hasFrames ? `返回 ${check.resData.frames.length}` : "缺少 res_data.frames",
});
rows.push({
name: "点位完整性",
ok: check.missingPoints.length === 0,
desc: check.missingPoints.length
? `缺少:${check.missingPoints.join(", ")}`
: `${mechanismMeta[input.mechanismType].topology} 均已返回`,
});
rows.push({
name: "轨迹字段",
ok: check.hasTrajectory,
desc: check.hasTrajectory ? `trajectory 长度 ${check.resData.trajectory.length}` : "缺少 res_data.trajectory",
});
if (check.hasFrames) {
const inputName = check.resData.inputName || getCurrentMeta().inputName;
rows.push({
name: "输入字段",
ok: check.firstFrame[inputName] !== undefined || check.firstFrame.inputValue !== undefined,
desc: `${inputName}=${fmt(check.firstFrame[inputName] ?? check.firstFrame.inputValue, 5)}`,
});
}
checkBody.innerHTML = rows
.map(
(row) => `
<tr>
<td>${row.name}</td>
<td><span class="badge ${row.ok ? "ok" : "error"}">${row.ok ? "通过" : "失败"}</span></td>
<td>${row.desc}</td>
</tr>
`,
)
.join("");
}
// 渲染当前机构姿态图。
function renderMechanismChart(response, input) {
const chartMap = ensureCharts();
const data = response.res_data || {};
const frame = data.frames?.[0] || {};
const points = frame.points || {};
const normalized = normalizeMechanismPoints(points, input);
const visiblePoints = normalized.plotPoints.filter(([, point]) => point);
const xs = visiblePoints.map(([, point]) => point.x);
const ys = visiblePoints.map(([, point]) => point.y);
const margin = Math.max(input.L_AB, input.L_BS, input.L3, input.L4, Math.abs(input.S_OFS), 0.5) * 0.35;
const minX = Math.min(...xs) - margin;
const maxX = Math.max(...xs) + margin;
const minY = Math.min(...ys, input.S_OFS) - margin;
const maxY = Math.max(...ys, input.S_OFS) + margin;
const lineSeries = normalized.links
.filter(([, start, end]) => start && end)
.map(([name, start, end]) => ({
name,
type: "line",
data: [
[start.x, start.y],
[end.x, end.y],
],
symbol: "none",
lineStyle: { width: name === "固定杆" ? 3 : 4, type: name === "固定杆" ? "dashed" : "solid" },
}));
const railSeries = input.mechanismType === "RRRP" || input.mechanismType === "PRRR"
? [{
name: "滑块导轨",
type: "line",
data: [
[minX, input.S_OFS],
[maxX, input.S_OFS],
],
symbol: "none",
lineStyle: { type: "dashed", width: 2, color: "#64717d" },
}]
: [];
chartMap.mechanism.setOption(
{
color: ["#0f6f8f", "#946200", "#157347", "#64717d"],
tooltip: {
trigger: "item",
formatter: (params) => {
if (params.seriesType === "scatter") {
return `${params.name}<br/>x=${fmt(params.value[0], 5)}<br/>y=${fmt(params.value[1], 5)}`;
}
return params.seriesName;
},
},
grid: { left: 52, right: 28, top: 32, bottom: 42 },
xAxis: { type: "value", min: minX, max: maxX, scale: true, name: "x" },
yAxis: { type: "value", min: minY, max: maxY, scale: true, name: "y" },
series: [
...lineSeries,
...railSeries,
{
name: "关键点",
type: "scatter",
symbolSize: 12,
data: visiblePoints.map(([name, point]) => ({ name, value: [point.x, point.y] })),
label: {
show: true,
formatter: (params) => params.name,
position: "top",
},
},
],
},
true,
);
renderThreeMechanism(points, input, { fitCamera: true, value: frame.inputValue ?? input.startValue });
}
// 渲染仿真扫描汇总图。
function renderScanCharts(scanRows) {
const chartMap = ensureCharts();
const validRows = scanRows.filter((row) => row.ok);
const crankData = validRows.map((row) => [row.primary.x, row.primary.y, row.inputValue]);
const sliderData = validRows.map((row) => [row.inputValue, row.output.x]);
chartMap.crank.setOption(
{
color: ["#0f6f8f"],
tooltip: {
trigger: "item",
formatter: (params) =>
`输入=${fmt(params.value[2], 3)}<br/>x=${fmt(params.value[0], 5)}<br/>y=${fmt(params.value[1], 5)}`,
},
grid: { left: 52, right: 28, top: 30, bottom: 42 },
xAxis: { type: "value", scale: true, name: "B.x" },
yAxis: { type: "value", scale: true, name: "B.y" },
series: [
{
name: "主运动点轨迹",
type: "line",
data: crankData,
showSymbol: false,
lineStyle: { width: 3 },
},
],
},
true,
);
chartMap.slider.setOption(
{
color: ["#946200"],
tooltip: {
trigger: "axis",
formatter: (items) => {
const item = items[0];
return `输入=${fmt(item.value[0], 3)}<br/>输出.x=${fmt(item.value[1], 5)}`;
},
},
grid: { left: 52, right: 28, top: 30, bottom: 42 },
xAxis: { type: "value", name: getCurrentMeta().inputName },
yAxis: { type: "value", scale: true, name: "output.x" },
series: [
{
name: "输出位移",
type: "line",
data: sliderData,
showSymbol: false,
lineStyle: { width: 3 },
},
],
},
true,
);
renderThreePaths(scanRows);
}
// 执行当前角度的单次接口验证。
async function runOnce() {
try {
setMessage("");
requestStatus.textContent = "执行中";
requestStatus.className = "badge warn";
const input = readInput();
refreshRequestPreview();
const response = await callFourBarSimulation(input, {
startValue: input.startValue,
endValue: input.startValue,
duration: 0.02,
stepTime: 0.02,
});
latestResponse = response;
responseOutput.textContent = JSON.stringify(response, null, 2);
renderSummary(response, input);
renderChecks(response, input);
if (response.res_data && response.res_data.success) {
scanAnimation.active = false;
renderMechanismChart(response, input);
setMessage("接口调用完成,当前姿态已刷新", "is-ok");
} else {
scanAnimation.active = false;
setMessage(response.res_data?.error || response.msg || "接口返回业务失败", "is-error");
}
requestStatus.textContent = "已执行";
requestStatus.className = "badge ok";
} catch (error) {
requestStatus.textContent = "执行失败";
requestStatus.className = "badge error";
verifyStatus.textContent = "执行失败";
verifyStatus.className = "badge error";
setMessage(error.message, "is-error");
}
}
// 调用批量仿真接口,生成轨迹和位移曲线。
async function runScan() {
try {
setMessage("");
const baseInput = readInput();
const stepTime = Number(stepInput.value);
const scanEnd = Number(scanEndInput.value);
const duration = Number(durationInput.value);
if (!Number.isFinite(stepTime) || stepTime <= 0 || stepTime > 1) {
throw new Error("stepTime 必须是 0 到 1 秒之间的有效数字");
}
if (!Number.isFinite(scanEnd)) {
throw new Error("终止输入必须是有效数字");
}
if (!Number.isFinite(duration) || duration <= 0 || duration > 60) {
throw new Error("duration 必须在 0 到 60 秒之间");
}
requestStatus.textContent = "扫描中";
requestStatus.className = "badge warn";
const response = await callFourBarSimulation(baseInput, {
startValue: baseInput.startValue,
endValue: scanEnd,
duration,
stepTime,
});
const data = response.res_data || {};
if (response.success !== true || data.success !== true || data.error) {
throw new Error(data.error || response.msg || "批量仿真接口返回失败");
}
const rows = (data.frames || []).map((frame) => {
const normalized = normalizeMechanismPoints(frame.points || {}, baseInput);
return {
frame: frame.frame,
time: frame.time,
inputValue: frame.inputValue ?? frame[data.inputName] ?? frame.angleDeg ?? frame.sliderX,
ok: Boolean(normalized.primaryPathPoint && normalized.outputPoint),
points: frame.points || {},
primary: normalized.primaryPathPoint,
output: normalized.outputPoint,
error: "",
};
});
latestScan = rows;
scanOutput.textContent = JSON.stringify(response, null, 2);
renderScanCharts(rows);
scanAnimation.frame = 0;
requestStatus.textContent = "扫描完成";
requestStatus.className = "badge ok";
renderSummary(response, baseInput);
renderChecks(response, baseInput);
setMessage(`仿真完成:${rows.filter((row) => row.ok).length}/${rows.length} 帧有效`, "is-ok");
} catch (error) {
requestStatus.textContent = "扫描失败";
requestStatus.className = "badge error";
setMessage(error.message, "is-error");
}
}
// 切换响应与扫描 JSON 详情。
function setActiveTab(name) {
const isResponse = name === "response";
responseTab.classList.toggle("active", isResponse);
scanTab.classList.toggle("active", !isResponse);
responseOutput.classList.toggle("hidden", !isResponse);
scanOutput.classList.toggle("hidden", isResponse);
}
[mechanismTypeInput, lAbInput, lBsInput, sOfsInput, l3Input, l4Input, angleInput, stepInput, scanEndInput, durationInput].forEach((input) => {
input.addEventListener("input", refreshRequestPreview);
});
mechanismTypeInput.addEventListener("change", () => {
loadSample(validSamples[mechanismTypeInput.value]);
setMessage(`${getCurrentMeta().title} 样例已填入`, "is-ok");
});
document.getElementById("validButton").addEventListener("click", () => {
loadSample(validSamples[mechanismTypeInput.value] || validSamples.RRRP);
setMessage("有效样例已填入", "is-ok");
});
document.getElementById("invalidButton").addEventListener("click", () => {
loadSample(invalidSamples[mechanismTypeInput.value] || invalidSamples.RRRP);
setMessage("异常样例已填入", "is-ok");
});
document.getElementById("runButton").addEventListener("click", runOnce);
document.getElementById("scanButton").addEventListener("click", runScan);
document.getElementById("playScanButton").addEventListener("click", playScanAnimation);
document.getElementById("pauseScanButton").addEventListener("click", pauseScanAnimation);
document.getElementById("resetCameraButton").addEventListener("click", () => resetThreeCamera(readInput()));
responseTab.addEventListener("click", () => setActiveTab("response"));
scanTab.addEventListener("click", () => setActiveTab("scan"));
updateMechanismLabels();
loadSample(validSamples.RRRP);
stepInput.value = "0.02";
durationInput.value = "2";
refreshRequestPreview();
</script>
</body>
</html>