1795 lines
60 KiB
HTML
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>
|