分享图
动画工坊
引擎就绪
<!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分