增强曲柄滑块三维验证视图

This commit is contained in:
zhangshun
2026-06-16 10:59:04 +08:00
parent 5e6bdc3ecf
commit 58d4c6660e

View File

@@ -299,6 +299,82 @@
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;
@@ -441,6 +517,15 @@
.chart-grid {
grid-template-columns: 1fr;
}
.three-toolbar {
align-items: flex-start;
flex-direction: column;
}
.three-viewport {
height: 380px;
}
}
@media (max-width: 560px) {
@@ -543,6 +628,27 @@
</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">
@@ -610,6 +716,7 @@
</main>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.min.js"></script>
<script src="./wasm/smart_math.js"></script>
<script>
const validSample = {
@@ -628,8 +735,15 @@
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");
@@ -651,6 +765,8 @@
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"),
@@ -742,6 +858,214 @@
return charts;
}
// 初始化 Three.js 场景,负责更直观地展示接口返回的机构姿态。
function ensureThreeView() {
if (!window.THREE) {
throw new Error("Three.js 加载失败,请检查网络或 CDN 可用性");
}
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);
}
// 调整 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 railHalfLength = Math.max(input.L_AB + input.L_BS + Math.abs(input.S_OFS), Math.abs(s.x) + 0.8, 1);
placeBar(view.objects.crank, a, b);
placeBar(view.objects.rod, b, s);
placeBar(
view.objects.rail,
{ x: -railHalfLength * 0.25, y: input.S_OFS },
{ x: railHalfLength, 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;
@@ -987,6 +1311,8 @@
},
true,
);
renderThreeMechanism({ A: a, B: b, S: s }, input, { fitCamera: true });
}
// 渲染角度扫描汇总图。
@@ -1045,6 +1371,8 @@
},
true,
);
renderThreePaths(scanRows);
}
// 执行当前角度的单次接口验证。
@@ -1061,9 +1389,11 @@
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");
}
@@ -1111,6 +1441,7 @@
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");
@@ -1146,6 +1477,9 @@
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"));
@@ -1153,6 +1487,10 @@
stepInput.value = "10";
scanEndInput.value = "360";
refreshRequestPreview();
renderThreeMechanism(calculateLocalGeometry(validSample), validSample, {
title: "有效样例预览",
fitCamera: true,
});
</script>
</body>
</html>