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

1508 lines
48 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>