1159 lines
35 KiB
HTML
1159 lines
35 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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
@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="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>
|
||
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 latestResponse = null;
|
||
let latestScan = [];
|
||
|
||
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 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;
|
||
}
|
||
|
||
// 加载并初始化 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,
|
||
);
|
||
}
|
||
|
||
// 渲染角度扫描汇总图。
|
||
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,
|
||
);
|
||
}
|
||
|
||
// 执行当前角度的单次接口验证。
|
||
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) {
|
||
renderMechanismChart(response, input);
|
||
setMessage("接口调用完成,当前姿态已刷新", "is-ok");
|
||
} else {
|
||
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);
|
||
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);
|
||
responseTab.addEventListener("click", () => setActiveTab("response"));
|
||
scanTab.addEventListener("click", () => setActiveTab("scan"));
|
||
|
||
loadSample(validSample);
|
||
stepInput.value = "10";
|
||
scanEndInput.value = "360";
|
||
refreshRequestPreview();
|
||
</script>
|
||
</body>
|
||
</html>
|