1508 lines
48 KiB
HTML
1508 lines
48 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,
|
||
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,
|
||
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,
|
||
textarea {
|
||
width: 100%;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
color: var(--text);
|
||
background: #ffffff;
|
||
}
|
||
|
||
input {
|
||
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%;
|
||
}
|
||
|
||
.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_CrankSlider · 点位 / 姿态 / 轨迹</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="lAbInput">L_AB 曲柄长度</label>
|
||
<input id="lAbInput" type="number" min="0" step="0.001" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="lBsInput">L_BS 连杆长度</label>
|
||
<input id="lBsInput" type="number" min="0" step="0.001" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="sOfsInput">S_OFS 滑块偏移</label>
|
||
<input id="sOfsInput" type="number" step="0.001" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="angleInput">angleDeg 曲柄角度</label>
|
||
<input id="angleInput" type="number" step="1" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="stepInput">扫描步长</label>
|
||
<input id="stepInput" type="number" min="1" max="90" step="1" />
|
||
</div>
|
||
<div class="field">
|
||
<label for="scanEndInput">扫描终止角</label>
|
||
<input id="scanEndInput" type="number" min="1" max="720" step="1" />
|
||
</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="sliderX">-</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>调用接口后显示当前 A-B-S 姿态,角度扫描后可播放 WASM 返回的连续点位。</span>
|
||
</div>
|
||
</div>
|
||
<div class="three-note">
|
||
<strong>3D 视图</strong>
|
||
使用接口返回的 A、B、S 点直接驱动模型:蓝色为曲柄 AB,金色为连杆 BS,绿色为滑块,灰色为导轨。播放扫描时不会重新计算几何,只复用多次接口调用得到的点位序列。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-grid" aria-label="曲柄滑块图形结果">
|
||
<div class="chart-card wide">
|
||
<div class="chart-title">
|
||
<span>机构当前姿态</span>
|
||
<span>A-B-S</span>
|
||
</div>
|
||
<div class="chart empty" id="mechanismChart">等待执行</div>
|
||
<div class="chart-note">
|
||
<strong>机构当前姿态</strong>
|
||
显示固定铰点 A、曲柄端点 B 和滑块 S 的二维位置。A-B 是曲柄,B-S 是连杆,S 点落在 y=S_OFS 的滑块导轨上,用于核对单角度计算结果是否符合几何关系。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-card">
|
||
<div class="chart-title">
|
||
<span>B 点圆轨迹</span>
|
||
<span>Crank path</span>
|
||
</div>
|
||
<div class="chart compact empty" id="crankChart">等待扫描</div>
|
||
<div class="chart-note">
|
||
<strong>B 点圆轨迹</strong>
|
||
由测试页按角度多次调用接口后汇总生成,理论上应接近以 A 为圆心、L_AB 为半径的圆。它适合验证曲柄长度、角度单位和旋转方向。
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chart-card">
|
||
<div class="chart-title">
|
||
<span>滑块位移曲线</span>
|
||
<span>S.x / angle</span>
|
||
</div>
|
||
<div class="chart compact empty" id="sliderChart">等待扫描</div>
|
||
<div class="chart-note">
|
||
<strong>滑块位移曲线</strong>
|
||
横轴为输入角度,纵轴为接口返回的 S 点 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="module">
|
||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js";
|
||
|
||
const validSample = {
|
||
L_AB: 0.5,
|
||
L_BS: 2.0,
|
||
S_OFS: 0.0,
|
||
angleDeg: 45,
|
||
};
|
||
|
||
const invalidSample = {
|
||
L_AB: 0,
|
||
L_BS: 0.2,
|
||
S_OFS: 0.5,
|
||
angleDeg: 30,
|
||
};
|
||
|
||
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 lAbInput = document.getElementById("lAbInput");
|
||
const lBsInput = document.getElementById("lBsInput");
|
||
const sOfsInput = document.getElementById("sOfsInput");
|
||
const angleInput = document.getElementById("angleInput");
|
||
const stepInput = document.getElementById("stepInput");
|
||
const scanEndInput = document.getElementById("scanEndInput");
|
||
const requestOutput = document.getElementById("requestOutput");
|
||
const topSuccess = document.getElementById("topSuccess");
|
||
const businessSuccess = document.getElementById("businessSuccess");
|
||
const sliderX = document.getElementById("sliderX");
|
||
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 readInput() {
|
||
const input = {
|
||
L_AB: Number(lAbInput.value),
|
||
L_BS: Number(lBsInput.value),
|
||
S_OFS: Number(sOfsInput.value),
|
||
angleDeg: Number(angleInput.value),
|
||
};
|
||
|
||
if (!Object.values(input).every(Number.isFinite)) {
|
||
throw new Error("所有机构参数都必须是有效数字");
|
||
}
|
||
|
||
return input;
|
||
}
|
||
|
||
// 按 WASM 统一入口格式组装四连杆请求。
|
||
function buildRequest(input, reqCode = `FOURBAR_VERIFY_${Date.now()}`) {
|
||
return {
|
||
msg: "fourbar crank slider verify",
|
||
req_code: reqCode,
|
||
req_from: "fourbar_test_page",
|
||
req_cmd: "Cmd_FourBar_CrankSlider",
|
||
req_param: input,
|
||
};
|
||
}
|
||
|
||
// 将当前表单同步到请求 JSON 文本框。
|
||
function refreshRequestPreview() {
|
||
try {
|
||
requestOutput.value = JSON.stringify(buildRequest(readInput()), null, 2);
|
||
} catch {
|
||
requestOutput.value = "";
|
||
}
|
||
}
|
||
|
||
// 填入内置样例并刷新请求预览。
|
||
function loadSample(sample) {
|
||
lAbInput.value = sample.L_AB;
|
||
lBsInput.value = sample.L_BS;
|
||
sOfsInput.value = sample.S_OFS;
|
||
angleInput.value = sample.angleDeg;
|
||
stepInput.value = stepInput.value || "10";
|
||
scanEndInput.value = scanEndInput.value || "360";
|
||
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);
|
||
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 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.position.y = -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 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 pointS = 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, rail, slider, pointA, pointB, pointS, pathLine, sliderPathLine);
|
||
|
||
threeView = {
|
||
scene,
|
||
camera,
|
||
renderer,
|
||
group,
|
||
objects: { crank, rod, rail, slider, pointA, pointB, pointS, 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 的 XZ 平面。
|
||
function toThreePoint(point) {
|
||
return new THREE.Vector3(point.x, 0, point.y);
|
||
}
|
||
|
||
// 更新圆柱连杆的位置、长度和朝向。
|
||
function placeBar(mesh, start, end) {
|
||
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, Math.abs(input.S_OFS) + input.L_AB, 1);
|
||
view.camera.position.set(span * 0.9, span * 1.45, span * 1.75);
|
||
view.camera.lookAt(span * 0.35, 0, input.S_OFS * 0.5);
|
||
}
|
||
|
||
// 依据机构参数固定导轨显示范围,避免播放时导轨随滑块位置伸缩。
|
||
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),
|
||
};
|
||
}
|
||
|
||
// 调整 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.renderer.render(threeView.scene, threeView.camera);
|
||
}
|
||
|
||
// 使用接口点位刷新 3D 机构姿态。
|
||
function renderThreeMechanism(points, input, meta = {}) {
|
||
const view = ensureThreeView();
|
||
const a = points.A || points.pointA;
|
||
const b = points.B || points.pointB;
|
||
const s = points.S || points.pointS;
|
||
const railRange = getFixedRailRange(input);
|
||
|
||
placeBar(view.objects.crank, a, b);
|
||
placeBar(view.objects.rod, b, s);
|
||
placeBar(
|
||
view.objects.rail,
|
||
{ x: railRange.minX, y: input.S_OFS },
|
||
{ x: railRange.maxX, y: input.S_OFS },
|
||
);
|
||
|
||
view.objects.pointA.position.copy(toThreePoint(a));
|
||
view.objects.pointB.position.copy(toThreePoint(b));
|
||
view.objects.pointS.position.copy(toThreePoint(s));
|
||
view.objects.slider.position.copy(toThreePoint(s));
|
||
view.objects.slider.position.y = 0.02;
|
||
|
||
if (meta.fitCamera) {
|
||
resetThreeCamera(input);
|
||
}
|
||
|
||
threeOverlay.innerHTML = `
|
||
<strong>${meta.title || "当前接口姿态"}</strong>
|
||
<span>angle=${fmt(meta.angle ?? input.angleDeg, 2)}°,B=(${fmt(b.x, 4)}, ${fmt(b.y, 4)}),S=(${fmt(s.x, 4)}, ${fmt(s.y, 4)})</span>
|
||
`;
|
||
}
|
||
|
||
// 使用扫描结果刷新 3D 轨迹线。
|
||
function renderThreePaths(scanRows) {
|
||
const view = ensureThreeView();
|
||
const validRows = scanRows.filter((row) => row.ok);
|
||
const bPoints = validRows.map((row) => toThreePoint(row.B));
|
||
const sPoints = validRows.map((row) => toThreePoint(row.S));
|
||
|
||
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(), angleDeg: row.angle };
|
||
renderThreeMechanism(
|
||
{ A: { x: 0, y: 0 }, B: row.B, S: row.S },
|
||
input,
|
||
{ title: "扫描播放中", angle: row.angle },
|
||
);
|
||
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 callFourBar(input, reqCode) {
|
||
const module = await ensureWasm();
|
||
const payload = JSON.stringify(buildRequest(input, reqCode));
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 计算前端几何校验值,用于和 WASM 返回点位对照。
|
||
function calculateLocalGeometry(input) {
|
||
const angle = (input.angleDeg * Math.PI) / 180;
|
||
const pointA = { x: 0, y: 0 };
|
||
const pointB = {
|
||
x: input.L_AB * Math.cos(angle),
|
||
y: input.L_AB * Math.sin(angle),
|
||
};
|
||
const dx = Math.sqrt(
|
||
Math.max(0, input.L_BS ** 2 - (input.S_OFS - pointB.y) ** 2),
|
||
);
|
||
const pointS = { x: pointB.x + dx, y: input.S_OFS };
|
||
return { pointA, pointB, pointS };
|
||
}
|
||
|
||
// 判断有效响应是否包含曲柄滑块验证需要的核心字段。
|
||
function inspectResponse(response) {
|
||
const resData = response && response.res_data ? response.res_data : {};
|
||
const points = resData.points || {};
|
||
const poses = resData.poses || {};
|
||
const hasError = Boolean(resData.error);
|
||
const requiredPoints = ["A", "B", "S"];
|
||
const requiredPoses = ["AB", "BS", "TCP"];
|
||
const missingPoints = requiredPoints.filter((key) => !points[key]);
|
||
const missingPoses = requiredPoses.filter((key) => !poses[key]);
|
||
const hasAngles = Boolean(resData.angles && resData.angles["曲柄角度"] !== undefined);
|
||
|
||
return {
|
||
resData,
|
||
hasError,
|
||
missingPoints,
|
||
missingPoses,
|
||
hasAngles,
|
||
complete:
|
||
response.success === true &&
|
||
resData.success === true &&
|
||
!hasError &&
|
||
missingPoints.length === 0 &&
|
||
missingPoses.length === 0 &&
|
||
hasAngles,
|
||
};
|
||
}
|
||
|
||
// 更新顶部摘要指标。
|
||
function renderSummary(response) {
|
||
const check = inspectResponse(response);
|
||
const sPoint = check.resData.points && check.resData.points.S;
|
||
|
||
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"}`;
|
||
sliderX.textContent = sPoint ? fmt(sPoint.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);
|
||
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.missingPoints.length === 0,
|
||
desc: check.missingPoints.length
|
||
? `缺少:${check.missingPoints.join(", ")}`
|
||
: "A / B / S 均已返回",
|
||
});
|
||
rows.push({
|
||
name: "姿态完整性",
|
||
ok: check.missingPoses.length === 0,
|
||
desc: check.missingPoses.length
|
||
? `缺少:${check.missingPoses.join(", ")}`
|
||
: "AB / BS / TCP 均已返回",
|
||
});
|
||
rows.push({
|
||
name: "角度字段",
|
||
ok: check.hasAngles,
|
||
desc: check.hasAngles
|
||
? `曲柄角度=${fmt(check.resData.angles["曲柄角度"], 3)}`
|
||
: "缺少 res_data.angles.曲柄角度",
|
||
});
|
||
|
||
if (check.resData.success && check.resData.points) {
|
||
const local = calculateLocalGeometry(input);
|
||
const b = check.resData.points.B;
|
||
const s = check.resData.points.S;
|
||
const bDiff = Math.hypot(b.x - local.pointB.x, b.y - local.pointB.y);
|
||
const sDiff = Math.hypot(s.x - local.pointS.x, s.y - local.pointS.y);
|
||
rows.push({
|
||
name: "B 点几何误差",
|
||
ok: bDiff <= 0.000001,
|
||
desc: `接口 B=(${fmt(b.x)}, ${fmt(b.y)}),前端复算误差=${fmt(bDiff)}`,
|
||
});
|
||
rows.push({
|
||
name: "S 点几何误差",
|
||
ok: sDiff <= 0.000001,
|
||
desc: `接口 S=(${fmt(s.x)}, ${fmt(s.y)}),前端复算误差=${fmt(sDiff)}`,
|
||
});
|
||
}
|
||
|
||
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 points = data.points || calculateLocalGeometry(input);
|
||
const a = points.A || points.pointA;
|
||
const b = points.B || points.pointB;
|
||
const s = points.S || points.pointS;
|
||
const margin = Math.max(input.L_AB, input.L_BS, Math.abs(input.S_OFS), 0.5) * 0.4;
|
||
const minX = Math.min(a.x, b.x, s.x) - margin;
|
||
const maxX = Math.max(a.x, b.x, s.x) + margin;
|
||
const minY = Math.min(a.y, b.y, s.y, input.S_OFS) - margin;
|
||
const maxY = Math.max(a.y, b.y, s.y, input.S_OFS) + margin;
|
||
|
||
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: [
|
||
{
|
||
name: "曲柄 AB",
|
||
type: "line",
|
||
data: [
|
||
[a.x, a.y],
|
||
[b.x, b.y],
|
||
],
|
||
symbol: "none",
|
||
lineStyle: { width: 4 },
|
||
},
|
||
{
|
||
name: "连杆 BS",
|
||
type: "line",
|
||
data: [
|
||
[b.x, b.y],
|
||
[s.x, s.y],
|
||
],
|
||
symbol: "none",
|
||
lineStyle: { width: 4 },
|
||
},
|
||
{
|
||
name: "关键点",
|
||
type: "scatter",
|
||
symbolSize: 12,
|
||
data: [
|
||
{ name: "A 固定铰点", value: [a.x, a.y] },
|
||
{ name: "B 曲柄端点", value: [b.x, b.y] },
|
||
{ name: "S 滑块", value: [s.x, s.y] },
|
||
],
|
||
label: {
|
||
show: true,
|
||
formatter: (params) => params.name.slice(0, 1),
|
||
position: "top",
|
||
},
|
||
},
|
||
{
|
||
name: "滑块导轨",
|
||
type: "line",
|
||
data: [
|
||
[minX, input.S_OFS],
|
||
[maxX, input.S_OFS],
|
||
],
|
||
symbol: "none",
|
||
lineStyle: { type: "dashed", width: 2, color: "#64717d" },
|
||
},
|
||
],
|
||
},
|
||
true,
|
||
);
|
||
|
||
renderThreeMechanism({ A: a, B: b, S: s }, input, { fitCamera: true });
|
||
}
|
||
|
||
// 渲染角度扫描汇总图。
|
||
function renderScanCharts(scanRows) {
|
||
const chartMap = ensureCharts();
|
||
const validRows = scanRows.filter((row) => row.ok);
|
||
const crankData = validRows.map((row) => [row.B.x, row.B.y, row.angle]);
|
||
const sliderData = validRows.map((row) => [row.angle, row.S.x]);
|
||
|
||
chartMap.crank.setOption(
|
||
{
|
||
color: ["#0f6f8f"],
|
||
tooltip: {
|
||
trigger: "item",
|
||
formatter: (params) =>
|
||
`角度=${fmt(params.value[2], 2)}<br/>B.x=${fmt(params.value[0], 5)}<br/>B.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: "B 点轨迹",
|
||
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], 2)}<br/>S.x=${fmt(item.value[1], 5)}`;
|
||
},
|
||
},
|
||
grid: { left: 52, right: 28, top: 30, bottom: 42 },
|
||
xAxis: { type: "value", name: "angleDeg" },
|
||
yAxis: { type: "value", scale: true, name: "S.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 callFourBar(input);
|
||
latestResponse = response;
|
||
responseOutput.textContent = JSON.stringify(response, null, 2);
|
||
renderSummary(response);
|
||
renderChecks(response, input);
|
||
if (response.res_data && response.res_data.success) {
|
||
scanAnimation.active = false;
|
||
renderMechanismChart(response, input);
|
||
setMessage("接口调用完成,当前姿态已刷新", "is-ok");
|
||
} else {
|
||
scanAnimation.active = false;
|
||
renderMechanismChart({ res_data: { points: calculateLocalGeometry(input) } }, input);
|
||
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 step = Number(stepInput.value);
|
||
const scanEnd = Number(scanEndInput.value);
|
||
if (!Number.isInteger(step) || step < 1 || step > 90) {
|
||
throw new Error("扫描步长必须是 1 到 90 之间的整数");
|
||
}
|
||
if (!Number.isFinite(scanEnd) || scanEnd <= 0 || scanEnd > 720) {
|
||
throw new Error("扫描终止角必须在 1 到 720 之间");
|
||
}
|
||
|
||
requestStatus.textContent = "扫描中";
|
||
requestStatus.className = "badge warn";
|
||
const rows = [];
|
||
for (let angle = 0; angle <= scanEnd + 0.000001; angle += step) {
|
||
const input = { ...baseInput, angleDeg: angle };
|
||
const response = await callFourBar(input, `FOURBAR_SCAN_${angle}`);
|
||
const data = response.res_data || {};
|
||
rows.push({
|
||
angle,
|
||
ok: response.success === true && data.success === true && !data.error,
|
||
B: data.points?.B,
|
||
S: data.points?.S,
|
||
error: data.error || response.msg || "",
|
||
});
|
||
}
|
||
|
||
latestScan = rows;
|
||
scanOutput.textContent = JSON.stringify(rows, null, 2);
|
||
renderScanCharts(rows);
|
||
scanAnimation.frame = 0;
|
||
requestStatus.textContent = "扫描完成";
|
||
requestStatus.className = "badge ok";
|
||
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);
|
||
}
|
||
|
||
[lAbInput, lBsInput, sOfsInput, angleInput].forEach((input) => {
|
||
input.addEventListener("input", refreshRequestPreview);
|
||
});
|
||
|
||
document.getElementById("validButton").addEventListener("click", () => {
|
||
loadSample(validSample);
|
||
setMessage("有效样例已填入", "is-ok");
|
||
});
|
||
|
||
document.getElementById("invalidButton").addEventListener("click", () => {
|
||
loadSample(invalidSample);
|
||
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"));
|
||
|
||
loadSample(validSample);
|
||
stepInput.value = "10";
|
||
scanEndInput.value = "360";
|
||
refreshRequestPreview();
|
||
renderThreeMechanism(calculateLocalGeometry(validSample), validSample, {
|
||
title: "有效样例预览",
|
||
fitCamera: true,
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|