独立渲染引擎就绪引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>线缆驱动连续体原理可视化</title>
<style>
:root {
--bg-color: #05050a;
--grid-color: rgba(56, 189, 248, 0.1);
--text-main: #e2e8f0;
--text-muted: #64748b;
--accent-blue: #0ea5e9;
--accent-cyan: #22d3ee;
--accent-orange: #f97316;
--spine-color: #cbd5e1;
--rib-color: #334155;
--panel-bg: rgba(15, 23, 42, 0.75);
--panel-border: rgba(14, 165, 233, 0.3);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: var(--bg-color);
color: var(--text-main);
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
#app-container {
position: relative;
width: 100vw;
height: 100vh;
max-width: 1600px;
max-height: 1000px;
display: flex;
background:
linear-gradient(rgba(5, 5, 10, 0.9), rgba(5, 5, 10, 0.9)),
radial-gradient(circle at 50% 50%, #1e1b4b 0%, #05050a 80%);
box-shadow: inset 0 0 100px rgba(0,0,0,0.8);
}
/* SVG Canvas */
#viz-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* Grid Pattern */
.grid-bg {
background-image:
linear-gradient(to right, var(--grid-color) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid-color) 1px, transparent 1px);
background-size: 40px 40px;
width: 100%;
height: 100%;
position: absolute;
z-index: 0;
opacity: 0.5;
}
/* UI Panels */
.panel {
position: absolute;
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 20px;
backdrop-filter: blur(10px);
z-index: 10;
box-shadow: 0 10px 30px rgba(0,0,0,0.5), inset 0 0 20px rgba(14, 165, 233, 0.1);
}
.panel-title {
font-size: 1.2rem;
font-weight: 600;
color: var(--accent-cyan);
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
text-transform: uppercase;
letter-spacing: 2px;
}
.panel-title::before {
content: '';
display: block;
width: 12px;
height: 12px;
background: var(--accent-orange);
border-radius: 2px;
box-shadow: 0 0 10px var(--accent-orange);
}
#info-panel {
top: 40px;
left: 40px;
width: 320px;
}
#control-panel {
bottom: 40px;
left: 40px;
width: 320px;
}
#data-panel {
top: 40px;
right: 40px;
width: 280px;
}
.data-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
padding-bottom: 8px;
}
.data-label {
color: var(--text-muted);
}
.data-value {
color: var(--accent-cyan);
font-weight: bold;
text-shadow: 0 0 5px rgba(34, 211, 238, 0.5);
}
.data-value.high-tension {
color: var(--accent-orange);
text-shadow: 0 0 5px rgba(249, 115, 22, 0.5);
}
.tagline {
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 15px;
}
.highlight-text {
color: var(--accent-orange);
font-weight: bold;
}
/* Slider Controls */
.slider-container {
margin-top: 20px;
}
.slider-label {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 10px;
color: var(--accent-cyan);
}
input[type=range] {
-webkit-appearance: none;
width: 100%;
background: transparent;
}
input[type=range]:focus {
outline: none;
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 6px;
cursor: pointer;
background: rgba(255,255,255,0.1);
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.05);
}
input[type=range]::-webkit-slider-thumb {
height: 20px;
width: 20px;
border-radius: 50%;
background: var(--accent-cyan);
cursor: pointer;
-webkit-appearance: none;
margin-top: -8px;
box-shadow: 0 0 15px var(--accent-cyan);
transition: transform 0.1s;
}
input[type=range]::-webkit-slider-thumb:hover {
transform: scale(1.2);
background: var(--accent-orange);
box-shadow: 0 0 15px var(--accent-orange);
}
.status-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
margin-top: 10px;
background: rgba(249, 115, 22, 0.2);
color: var(--accent-orange);
border: 1px solid var(--accent-orange);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.4); }
70% { box-shadow: 0 0 0 10px rgba(249, 115, 22, 0); }
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0); }
}
/* SVG Styling Elements */
.glowing-path {
filter: drop-shadow(0 0 8px currentColor);
transition: stroke 0.3s ease;
}
.skin-layer {
stroke-dasharray: 10 5;
animation: dashMove 20s linear infinite;
}
@keyframes dashMove {
from { stroke-dashoffset: 200; }
to { stroke-dashoffset: 0; }
}
.indicator-line {
stroke: var(--text-muted);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.indicator-text {
fill: var(--text-main);
font-size: 14px;
font-family: sans-serif;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.8));
}
.indicator-text-highlight {
fill: var(--accent-orange);
font-weight: bold;
}
</style>
</head>
<body>
<div id="app-container">
<div class="grid-bg"></div>
<!-- UI Overlay Left -->
<div id="info-panel" class="panel">
<div class="panel-title">线缆驱动连续体</div>
<div class="tagline">
摒弃传统刚性关节。基于<span class="highlight-text">最终理想解 (IFR)</span>理念:通过集中化驱动与中心柔性高弹性脊椎,巧妙利用材料自身的被动弹性,实现彻底消除硬死角的绝对平滑曲线运动。
</div>
<div style="margin-top: 20px; border-left: 2px solid var(--accent-orange); padding-left: 10px;">
<div style="font-size: 0.8rem; color: var(--accent-orange); margin-bottom: 5px;">核心突破点</div>
<div style="font-size: 0.85rem; color: #cbd5e1;">资源利用:镍钛合金(Nitinol)脊椎提供结构支撑与复位势能,将复杂分布式电机转化为尾部集中张力控制。</div>
</div>
</div>
<div id="control-panel" class="panel">
<div class="panel-title">系统控制舱</div>
<div class="tagline">协同控制绞盘收放,动态分配线缆张力。</div>
<div class="slider-container">
<div class="slider-label">
<span>左侧拉紧 (150N)</span>
<span>右侧拉紧 (150N)</span>
</div>
<input type="range" id="bend-slider" min="-60" max="60" value="0" step="0.1">
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 15px;">
<div id="auto-mode-status" class="status-badge">自动寻迹模式运行中</div>
<button id="toggle-auto" style="background: transparent; border: 1px solid var(--accent-cyan); color: var(--accent-cyan); padding: 5px 10px; border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
手动覆盖
</button>
</div>
</div>
<!-- UI Overlay Right -->
<div id="data-panel" class="panel">
<div class="panel-title">实时遥测数据</div>
<div class="data-row">
<span class="data-label">脊椎弹性模量</span>
<span class="data-value">75.0 GPa</span>
</div>
<div class="data-row">
<span class="data-label">中心形变曲率 (κ)</span>
<span class="data-value" id="val-curvature">0.000 m⁻¹</span>
</div>
<div class="data-row">
<span class="data-label">末端偏转角 (θ)</span>
<span class="data-value" id="val-angle">0.0°</span>
</div>
<div style="margin: 15px 0; border-top: 1px dashed var(--text-muted);"></div>
<div class="data-row">
<span class="data-label">左侧牵引绳张力</span>
<span class="data-value" id="val-tension-left">10 N (预紧)</span>
</div>
<div class="data-row">
<span class="data-label">右侧牵引绳张力</span>
<span class="data-value" id="val-tension-right">10 N (预紧)</span>
</div>
<div class="data-row">
<span class="data-label">绞盘电机差速</span>
<span class="data-value" id="val-motor-diff">0 mm/s</span>
</div>
</div>
<!-- SVG Canvas for Animation -->
<svg id="viz-canvas" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<filter id="glow-orange" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-cyan" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- Pattern for Silicon Skin -->
<pattern id="corrugated" width="20" height="20" patternUnits="userSpaceOnUse" patternTransform="rotate(0)">
<line x1="0" y1="10" x2="20" y2="10" stroke="#1e293b" stroke-width="2"/>
</pattern>
<!-- Radial gradient for winch -->
<radialGradient id="winch-grad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#334155" />
<stop offset="100%" stop-color="#0f172a" />
</radialGradient>
</defs>
<!-- Dynamic Visualization Group -->
<g id="mech-system" transform="translate(800, 850)">
<!-- Base / Drive Mechanism (Winch visualization) -->
<g id="drive-base">
<rect x="-80" y="0" width="160" height="80" rx="10" fill="url(#winch-grad)" stroke="#475569" stroke-width="2"/>
<path d="M -80 20 L 80 20 M -80 60 L 80 60" stroke="#1e293b" stroke-width="2"/>
<!-- Spools -->
<circle id="spool-left" cx="-40" cy="40" r="25" fill="#0f172a" stroke="#64748b" stroke-width="3"/>
<line id="spool-left-mark" x1="-40" y1="40" x2="-40" y2="15" stroke="#f97316" stroke-width="3"/>
<text x="-40" y="80" fill="#64748b" font-size="12" text-anchor="middle" font-family="monospace">M1</text>
<circle id="spool-right" cx="40" cy="40" r="25" fill="#0f172a" stroke="#64748b" stroke-width="3"/>
<line id="spool-right-mark" x1="40" y1="40" x2="40" y2="15" stroke="#22d3ee" stroke-width="3"/>
<text x="40" y="80" fill="#64748b" font-size="12" text-anchor="middle" font-family="monospace">M2</text>
<rect x="-50" y="-10" width="100" height="10" fill="#475569"/>
</g>
<!-- Continuum Arm (Dynamically drawn by JS) -->
<g id="continuum-arm">
<!-- Silicon Skin (Background) -->
<path id="skin-bg" class="skin-layer" fill="url(#corrugated)" stroke="#1e293b" stroke-width="2" opacity="0.3"/>
<!-- Ribs (Disks) will be injected here -->
<g id="ribs-container"></g>
<!-- Left Cable -->
<path id="cable-left" fill="none" stroke="#64748b" stroke-width="4" class="glowing-path" />
<!-- Right Cable -->
<path id="cable-right" fill="none" stroke="#64748b" stroke-width="4" class="glowing-path" />
<!-- Center Flexible Spine -->
<path id="spine" fill="none" stroke="var(--spine-color)" stroke-width="6" stroke-linecap="round"/>
<!-- Silicon Skin (Foreground border to indicate section) -->
<path id="skin-fg-left" fill="none" stroke="#334155" stroke-width="3" stroke-dasharray="10 5" opacity="0.8"/>
<path id="skin-fg-right" fill="none" stroke="#334155" stroke-width="3" stroke-dasharray="10 5" opacity="0.8"/>
</g>
</g>
<!-- Callout / Explanatory Annotations -->
<g id="annotations">
<!-- Annotation: Central Spine -->
<path class="indicator-line" d="M 800 400 L 980 300 L 1050 300" id="anno-line-spine"/>
<circle cx="800" cy="400" r="4" fill="var(--spine-color)" id="anno-dot-spine"/>
<text x="1060" y="295" class="indicator-text"><tspan class="indicator-text-highlight">高弹性脊椎</tspan> (Nitinol)</text>
<text x="1060" y="315" class="indicator-text" fill="#64748b" font-size="12">储存被动复位势能,限制曲率极值</text>
<!-- Annotation: Tension Cable -->
<path class="indicator-line" d="M 750 500 L 620 400 L 550 400" id="anno-line-cable"/>
<circle cx="750" cy="500" r="4" fill="#f97316" id="anno-dot-cable"/>
<text x="540" y="295" class="indicator-text" text-anchor="end" transform="translate(0, 100)"><tspan class="indicator-text-highlight">牵引钢丝</tspan> (集中力传导)</text>
<text x="540" y="315" class="indicator-text" fill="#64748b" font-size="12" text-anchor="end" transform="translate(0, 100)">多级圆盘均布,取代分布式刚性关节</text>
<!-- Annotation: Outer Skin -->
<path class="indicator-line" d="M 850 600 L 980 650 L 1050 650" id="anno-line-skin"/>
<text x="1060" y="645" class="indicator-text">波纹硅胶皮</text>
<text x="1060" y="665" class="indicator-text" fill="#64748b" font-size="12">防水耐磨,适应大曲变 (截面透视化)</text>
</g>
</svg>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
// --- Core Mechanism Configuration ---
const config = {
length: 600, // Total length of the continuum arm in pixels
numRibs: 18, // Number of intermediate disks
ribWidth: 80, // Width of the rib disks
ribThickness: 8, // Thickness of the ribs
cableOffset: 30, // Distance from center spine to cable
skinOffset: 45, // Distance from center to skin edge
maxAngle: 75, // Maximum bending angle in degrees
baseX: 800, // Canvas base X (matched to svg transform in global coords for annotations)
baseY: 850, // Canvas base Y
};
// --- State ---
let state = {
targetAngle: 0,
currentAngle: 0,
autoPlay: true,
time: 0
};
// --- DOM Elements ---
const UI = {
ribsContainer: document.getElementById('ribs-container'),
spine: document.getElementById('spine'),
cableLeft: document.getElementById('cable-left'),
cableRight: document.getElementById('cable-right'),
skinBg: document.getElementById('skin-bg'),
skinFgLeft: document.getElementById('skin-fg-left'),
skinFgRight: document.getElementById('skin-fg-right'),
slider: document.getElementById('bend-slider'),
btnAuto: document.getElementById('toggle-auto'),
statusBadge: document.getElementById('auto-mode-status'),
valAngle: document.getElementById('val-angle'),
valCurvature: document.getElementById('val-curvature'),
valTensionLeft: document.getElementById('val-tension-left'),
valTensionRight: document.getElementById('val-tension-right'),
valMotorDiff: document.getElementById('val-motor-diff'),
spoolLeftMark: document.getElementById('spool-left-mark'),
spoolRightMark: document.getElementById('spool-right-mark'),
// Annotations tracking
annoLineSpine: document.getElementById('anno-line-spine'),
annoDotSpine: document.getElementById('anno-dot-spine'),
annoLineCable: document.getElementById('anno-line-cable'),
annoDotCable: document.getElementById('anno-dot-cable'),
annoLineSkin: document.getElementById('anno-line-skin'),
};
// Arrays to store rib elements for fast updates
const ribElements = [];
// --- Initialization ---
function init() {
// Generate Ribs
for (let i = 1; i <= config.numRibs; i++) {
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
// Draw a sleek pill-shape for the rib cross-section
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", -config.ribWidth/2);
rect.setAttribute("y", -config.ribThickness/2);
rect.setAttribute("width", config.ribWidth);
rect.setAttribute("height", config.ribThickness);
rect.setAttribute("rx", config.ribThickness/2);
rect.setAttribute("fill", "var(--rib-color)");
rect.setAttribute("stroke", "#475569");
rect.setAttribute("stroke-width", "1.5");
// Cable guide holes
const holeL = document.createElementNS("http://www.w3.org/2000/svg", "circle");
holeL.setAttribute("cx", -config.cableOffset);
holeL.setAttribute("cy", 0);
holeL.setAttribute("r", 3);
holeL.setAttribute("fill", "#0f172a");
const holeR = document.createElementNS("http://www.w3.org/2000/svg", "circle");
holeR.setAttribute("cx", config.cableOffset);
holeR.setAttribute("cy", 0);
holeR.setAttribute("r", 3);
holeR.setAttribute("fill", "#0f172a");
group.appendChild(rect);
group.appendChild(holeL);
group.appendChild(holeR);
UI.ribsContainer.appendChild(group);
ribElements.push(group);
}
// Event Listeners
UI.slider.addEventListener('input', (e) => {
state.autoPlay = false;
state.targetAngle = parseFloat(e.target.value);
updateUIMode();
});
UI.btnAuto.addEventListener('click', () => {
state.autoPlay = !state.autoPlay;
if(state.autoPlay) state.time = Math.asin(state.currentAngle / config.maxAngle); // Sync animation phase
updateUIMode();
});
updateUIMode();
requestAnimationFrame(animate);
}
function updateUIMode() {
if (state.autoPlay) {
UI.statusBadge.textContent = "自动寻迹模式运行中";
UI.statusBadge.style.color = "var(--accent-cyan)";
UI.statusBadge.style.borderColor = "var(--accent-cyan)";
UI.statusBadge.style.background = "rgba(34, 211, 238, 0.2)";
UI.btnAuto.textContent = "手动干预";
UI.slider.disabled = true;
UI.slider.style.opacity = 0.5;
} else {
UI.statusBadge.textContent = "手动张力分配覆盖";
UI.statusBadge.style.color = "var(--accent-orange)";
UI.statusBadge.style.borderColor = "var(--accent-orange)";
UI.statusBadge.style.background = "rgba(249, 115, 22, 0.2)";
UI.btnAuto.textContent = "恢复自动";
UI.slider.disabled = false;
UI.slider.style.opacity = 1;
}
}
// --- Math & Kinematics Calculation ---
// Calculate constant curvature arc parameters based on tip angle
function calculateContinuum(thetaDeg) {
const theta = thetaDeg * (Math.PI / 180); // Radian
const L = config.length;
let ptsCenter = [];
let ptsLeft = [];
let ptsRight = [];
let ptsSkinL = [];
let ptsSkinR = [];
let transforms = [];
const numSegments = config.numRibs + 1;
// Base coordinates (0,0) in local group space facing UP (-Y)
const X0 = 0;
const Y0 = -10; // Start slightly above base unit
for (let i = 0; i <= numSegments; i++) {
const s = (i / numSegments) * L; // Arc length at this point
let x, y, angleRad;
if (Math.abs(theta) < 0.001) {
// Straight line approximation to avoid division by zero
x = X0;
y = Y0 - s;
angleRad = 0;
} else {
const R = L / theta; // Radius of curvature. Positive R = bend right, negative R = bend left.
angleRad = s / R; // Angle at length s
// Center of curvature is at (X0 + R, Y0)
x = (X0 + R) - R * Math.cos(angleRad);
y = Y0 - R * Math.sin(angleRad);
}
// Normal vector direction (pointing right relative to curve)
const nx = Math.cos(angleRad);
const ny = Math.sin(angleRad);
ptsCenter.push({x, y});
// Left cable offset (-nx, -ny)
ptsLeft.push({
x: x - config.cableOffset * nx,
y: y - config.cableOffset * ny
});
// Right cable offset (+nx, +ny)
ptsRight.push({
x: x + config.cableOffset * nx,
y: y + config.cableOffset * ny
});
// Skin offset
ptsSkinL.push({ x: x - config.skinOffset * nx, y: y - config.skinOffset * ny });
ptsSkinR.push({ x: x + config.skinOffset * nx, y: y + config.skinOffset * ny });
if (i > 0) { // i=0 is base, ribs start at i=1
transforms.push(`translate(${x}, ${y}) rotate(${angleRad * (180/Math.PI)})`);
}
}
return { ptsCenter, ptsLeft, ptsRight, ptsSkinL, ptsSkinR, transforms, theta };
}
// Helper to convert array of points to SVG path string
function toPathStr(pts) {
if(pts.length === 0) return "";
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length; i++) {
d += ` L ${pts[i].x} ${pts[i].y}`;
}
return d;
}
// Generate full polygon path for the skin background
function toPolygonStr(ptsL, ptsR) {
if(ptsL.length === 0) return "";
let d = `M ${ptsL[0].x} ${ptsL[0].y}`;
for (let i = 1; i < ptsL.length; i++) d += ` L ${ptsL[i].x} ${ptsL[i].y}`;
// Connect to top of right side
d += ` L ${ptsR[ptsR.length-1].x} ${ptsR[ptsR.length-1].y}`;
// Go down right side
for (let i = ptsR.length - 2; i >= 0; i--) d += ` L ${ptsR[i].x} ${ptsR[i].y}`;
d += " Z";
return d;
}
// --- Render Loop ---
function animate() {
// Logic for auto-play sine wave
if (state.autoPlay) {
state.time += 0.015; // Animation speed
state.targetAngle = Math.sin(state.time) * config.maxAngle * 0.9;
UI.slider.value = state.targetAngle;
}
// Smooth interpolation (spring-like effect for realism)
state.currentAngle += (state.targetAngle - state.currentAngle) * 0.1;
// 1. Calculate Kinematics
const calc = calculateContinuum(state.currentAngle);
// 2. Update SVG Paths
UI.spine.setAttribute("d", toPathStr(calc.ptsCenter));
UI.cableLeft.setAttribute("d", toPathStr(calc.ptsLeft));
UI.cableRight.setAttribute("d", toPathStr(calc.ptsRight));
UI.skinBg.setAttribute("d", toPolygonStr(calc.ptsSkinL, calc.ptsSkinR));
UI.skinFgLeft.setAttribute("d", toPathStr(calc.ptsSkinL));
UI.skinFgRight.setAttribute("d", toPathStr(calc.ptsSkinR));
// 3. Update Rib Transforms
ribElements.forEach((el, index) => {
el.setAttribute("transform", calc.transforms[index]);
});
// 4. Update Tension Colors & Filters (Visualizing physical force)
// Negative angle = bending LEFT = Pulling LEFT cable
const tensionThreshold = 5; // degrees before color shift starts
// Left Cable styling
if (state.currentAngle < -tensionThreshold) {
const intensity = Math.min(1, Math.abs(state.currentAngle) / config.maxAngle);
UI.cableLeft.setAttribute("stroke", "var(--accent-orange)");
UI.cableLeft.style.filter = "url(#glow-orange)";
UI.cableLeft.setAttribute("stroke-width", 4 + intensity * 2);
} else {
UI.cableLeft.setAttribute("stroke", "#38bdf8"); // loose state
UI.cableLeft.style.filter = "none";
UI.cableLeft.setAttribute("stroke-width", "3");
}
// Right Cable styling
if (state.currentAngle > tensionThreshold) {
const intensity = Math.min(1, state.currentAngle / config.maxAngle);
UI.cableRight.setAttribute("stroke", "var(--accent-orange)");
UI.cableRight.style.filter = "url(#glow-orange)";
UI.cableRight.setAttribute("stroke-width", 4 + intensity * 2);
} else {
UI.cableRight.setAttribute("stroke", "#38bdf8"); // loose state
UI.cableRight.style.filter = "none";
UI.cableRight.setAttribute("stroke-width", "3");
}
// 5. Update Motor Spool visuals (Rotation based on cable length pulled)
// Differential arc length formula: delta_L = offset * theta
const deltaL = config.cableOffset * calc.theta;
const spoolRadius = 25;
const spoolAngle = (deltaL / spoolRadius) * (180 / Math.PI);
// If bending right (theta>0), right cable is pulled (gets shorter), spool rotates to take up slack.
// Left cable gets longer, spool releases.
UI.spoolLeftMark.setAttribute("transform", `rotate(${-spoolAngle} -40 40)`);
UI.spoolRightMark.setAttribute("transform", `rotate(${spoolAngle} 40 40)`);
// 6. Update HUD Data
UI.valAngle.textContent = `${state.currentAngle.toFixed(1)}°`;
let curvature = 0;
if(Math.abs(state.currentAngle) > 0.1) {
curvature = calc.theta / (config.length / 1000); // converting pixels to arbitrary 'meters' for display
}
UI.valCurvature.textContent = `${Math.abs(curvature).toFixed(3)} m⁻¹`;
// Calculate simulated tension (10N base, max 150N)
let tl = 10, tr = 10;
if (state.currentAngle < 0) {
tl = 10 + (Math.abs(state.currentAngle) / config.maxAngle) * 140;
tr = 10;
} else if (state.currentAngle > 0) {
tr = 10 + (state.currentAngle / config.maxAngle) * 140;
tl = 10;
}
UI.valTensionLeft.innerHTML = `${tl.toFixed(1)} N <span style="font-size:0.7em; color:#64748b">${tl>15?'(工作拉紧)':'(预紧)'}</span>`;
UI.valTensionRight.innerHTML = `${tr.toFixed(1)} N <span style="font-size:0.7em; color:#64748b">${tr>15?'(工作拉紧)':'(预紧)'}</span>`;
UI.valTensionLeft.className = tl > 20 ? 'data-value high-tension' : 'data-value';
UI.valTensionRight.className = tr > 20 ? 'data-value high-tension' : 'data-value';
// Motor diff velocity (derivative of angle)
const velocity = (state.targetAngle - state.currentAngle) * 5;
UI.valMotorDiff.textContent = `${Math.abs(velocity).toFixed(1)} mm/s`;
// 7. Dynamic Annotation Tracking
// Get point halfway up the spine for annotation
const midIndex = Math.floor(calc.ptsCenter.length / 2);
const spineMid = calc.ptsCenter[midIndex];
const leftCableMid = calc.ptsLeft[midIndex];
const skinMidR = calc.ptsSkinR[Math.floor(calc.ptsSkinR.length * 0.75)];
// Map local mech coords to global SVG coords for annotations
const toGlobal = (pt) => ({ x: pt.x + config.baseX, y: pt.y + config.baseY });
const gSpine = toGlobal(spineMid);
UI.annoDotSpine.setAttribute("cx", gSpine.x);
UI.annoDotSpine.setAttribute("cy", gSpine.y);
UI.annoLineSpine.setAttribute("d", `M ${gSpine.x} ${gSpine.y} L 980 300 L 1050 300`);
const gCable = toGlobal(leftCableMid);
UI.annoDotCable.setAttribute("cx", gCable.x);
UI.annoDotCable.setAttribute("cy", gCable.y);
UI.annoLineCable.setAttribute("d", `M ${gCable.x} ${gCable.y} L 620 400 L 550 400`);
const gSkin = toGlobal(skinMidR);
UI.annoLineSkin.setAttribute("d", `M ${gSkin.x} ${gSkin.y} L 980 650 L 1050 650`);
// Next frame
requestAnimationFrame(animate);
}
// Boot system
init();
});
</script>
</body>
</html>
积分规则:第一轮对话扣减8分,后续每轮扣6分
等待动画代码生成...
