Files
aibis-dream/Assets/Prototype/cable-tangent-lock-prototype.html
T

1555 lines
51 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cable Tangent Lock Prototype</title>
<style>
:root {
--ink: #1d2430;
--muted: #667085;
--line: #d7dde8;
--panel: #f8fafc;
--paper: #ffffff;
--blue: #5d8cff;
--blue-deep: #3d68de;
--green: #1f9d6a;
--red: #e45757;
--amber: #c98b24;
--shadow: 0 20px 54px rgba(37, 47, 72, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
background:
linear-gradient(180deg, #f4f7fb 0%, #eef3f8 100%);
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
letter-spacing: 0;
overflow: hidden;
}
.app {
display: grid;
grid-template-rows: auto 1fr;
height: 100vh;
}
.toolbar {
display: grid;
grid-template-columns: minmax(214px, auto) minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid rgba(130, 145, 170, 0.25);
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(16px);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mark {
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid rgba(61, 104, 222, 0.28);
background:
radial-gradient(circle at 70% 70%, rgba(93, 140, 255, 0.28), transparent 34%),
linear-gradient(135deg, #ffffff, #e7edf9);
position: relative;
flex: 0 0 auto;
}
.mark::before {
content: "";
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
height: 5px;
border-radius: 999px;
background: var(--blue);
transform: rotate(-28deg);
transform-origin: left center;
}
h1 {
margin: 0;
font-size: 17px;
line-height: 1.2;
font-weight: 650;
}
.sub {
color: var(--muted);
font-size: 12px;
margin-top: 2px;
white-space: nowrap;
}
.controls {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
}
.control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 11px;
border: 1px solid rgba(130, 145, 170, 0.32);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.7) inset;
}
.control label {
color: #48566b;
font-size: 12px;
white-space: nowrap;
}
input[type="range"] {
width: 88px;
accent-color: var(--blue-deep);
}
.value {
width: 38px;
color: #263247;
font-size: 12px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.segmented {
display: inline-flex;
padding: 3px;
border: 1px solid rgba(130, 145, 170, 0.35);
border-radius: 8px;
background: #eef3f9;
}
.segmented button,
.icon-button {
border: 0;
cursor: pointer;
color: #3d4a5f;
background: transparent;
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
font: inherit;
}
.segmented button {
min-width: 56px;
height: 30px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
}
.segmented button[aria-pressed="true"] {
color: #15223b;
background: #ffffff;
box-shadow: 0 1px 6px rgba(55, 68, 92, 0.12);
}
.segmented button:focus-visible,
.icon-button:focus-visible,
input:focus-visible {
outline: 2px solid rgba(61, 104, 222, 0.42);
outline-offset: 2px;
}
.icon-button {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid rgba(130, 145, 170, 0.32);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.icon-button:hover {
background: #ffffff;
}
.icon-button:active {
transform: translateY(1px);
}
.stage-wrap {
padding: 18px;
min-height: 0;
}
.stage {
width: 100%;
height: 100%;
border: 1px solid rgba(130, 145, 170, 0.28);
border-radius: 8px;
background: var(--paper);
box-shadow: var(--shadow);
overflow: hidden;
position: relative;
}
canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
touch-action: none;
}
canvas.dragging {
cursor: grabbing;
}
.hint {
position: absolute;
left: 18px;
bottom: 14px;
color: #667085;
font-size: 12px;
pointer-events: none;
}
@media (max-width: 980px) {
body {
overflow: auto;
}
.app {
min-height: 100vh;
height: auto;
}
.toolbar {
grid-template-columns: 1fr auto;
}
.controls {
grid-column: 1 / -1;
justify-content: start;
overflow-x: auto;
padding-bottom: 2px;
}
.stage-wrap {
height: 680px;
}
.sub {
white-space: normal;
}
}
</style>
</head>
<body>
<main class="app">
<header class="toolbar">
<div class="brand">
<div class="mark" aria-hidden="true"></div>
<div>
<h1>Cable 出线口约束原型</h1>
<div class="sub">固定口沿、锁定出线方向、后段再弯曲</div>
</div>
</div>
<div class="controls" aria-label="prototype controls">
<div class="segmented" aria-label="view mode">
<button id="viewCompare" type="button" aria-pressed="true">对比</button>
<button id="viewLocked" type="button" aria-pressed="false">锁定</button>
<button id="viewMesh" type="button" aria-pressed="false">截面</button>
</div>
<div class="segmented" aria-label="physics mode">
<button id="physicsOn" type="button" aria-pressed="true">物理</button>
<button id="physicsOff" type="button" aria-pressed="false">静态</button>
</div>
<div class="control">
<label for="angle">出口角</label>
<input id="angle" type="range" min="-80" max="80" value="0">
<span class="value" id="angleValue"></span>
</div>
<div class="control">
<label for="guard">保护段</label>
<input id="guard" type="range" min="12" max="120" value="58">
<span class="value" id="guardValue">58</span>
</div>
<div class="control">
<label for="width">线宽</label>
<input id="width" type="range" min="22" max="54" value="36">
<span class="value" id="widthValue">36</span>
</div>
<div class="control">
<label for="sag">重力</label>
<input id="sag" type="range" min="0" max="95" value="18">
<span class="value" id="sagValue">18</span>
</div>
<div class="control">
<label for="damping">阻尼</label>
<input id="damping" type="range" min="18" max="92" value="66">
<span class="value" id="dampingValue">66</span>
</div>
<div class="control">
<label for="spring">收线</label>
<input id="spring" type="range" min="0" max="100" value="58">
<span class="value" id="springValue">58</span>
</div>
<div class="control">
<label for="resolution">采样</label>
<input id="resolution" type="range" min="4" max="20" value="8" title="点间距,越大越快但越糙">
<span class="value" id="resolutionValue">8</span>
</div>
<div class="control">
<label for="solver">迭代</label>
<input id="solver" type="range" min="6" max="64" value="28" title="约束求解次数,越高越硬">
<span class="value" id="solverValue">28</span>
</div>
</div>
<button class="icon-button" id="reset" type="button" aria-label="Reset" title="Reset">
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12a8 8 0 1 0 2.34-5.66L4 8.68" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 4v4.68h4.68" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</header>
<section class="stage-wrap">
<div class="stage">
<canvas id="stage" width="1600" height="900"></canvas>
<div class="hint">拖动任意插头观察线缆根部</div>
</div>
</section>
</main>
<script>
const canvas = document.getElementById("stage");
const ctx = canvas.getContext("2d");
const ui = {
viewCompare: document.getElementById("viewCompare"),
viewLocked: document.getElementById("viewLocked"),
viewMesh: document.getElementById("viewMesh"),
physicsOn: document.getElementById("physicsOn"),
physicsOff: document.getElementById("physicsOff"),
angle: document.getElementById("angle"),
guard: document.getElementById("guard"),
width: document.getElementById("width"),
sag: document.getElementById("sag"),
damping: document.getElementById("damping"),
spring: document.getElementById("spring"),
resolution: document.getElementById("resolution"),
solver: document.getElementById("solver"),
angleValue: document.getElementById("angleValue"),
guardValue: document.getElementById("guardValue"),
widthValue: document.getElementById("widthValue"),
sagValue: document.getElementById("sagValue"),
dampingValue: document.getElementById("dampingValue"),
springValue: document.getElementById("springValue"),
resolutionValue: document.getElementById("resolutionValue"),
solverValue: document.getElementById("solverValue"),
reset: document.getElementById("reset")
};
const state = {
mode: "compare",
outletAngle: 0,
guardLength: 58,
cableWidth: 36,
sag: 18,
damping: 66,
spring: 58,
resolution: 8,
solverIterations: 28,
physicsEnabled: true,
targetOffset: { x: 238, y: 196 },
dragging: false,
activePanel: null,
lastPanels: [],
physics: {
wave: 0,
waveVelocity: 0,
sagOffset: 0,
sagVelocity: 0,
phase: 0,
lastFrameTime: 0,
frameDt: 0,
prevFrameDt: 0,
dragVelocity: { x: 0, y: 0 },
lastPointer: null,
lastPointerTime: 0,
verlet: null,
reelLength: 0,
targetReelLength: 0,
renderPoints: null,
plugAngle: null
}
};
const colors = {
module: "#eef0f3",
moduleEdge: "#e3e7ee",
outlet: "#e9edf5",
outletMouth: "#dbe6ff",
cable: "#5d8cff",
cableDark: "#3d68de",
cableSoft: "rgba(93, 140, 255, 0.22)",
red: "#e45757",
green: "#1f9d6a",
amber: "#c98b24",
ink: "#1d2430",
muted: "#667085"
};
function vec(x, y) {
return { x, y };
}
function add(a, b) {
return vec(a.x + b.x, a.y + b.y);
}
function sub(a, b) {
return vec(a.x - b.x, a.y - b.y);
}
function mul(a, s) {
return vec(a.x * s, a.y * s);
}
function len(a) {
return Math.hypot(a.x, a.y);
}
function norm(a, fallback = vec(1, 0)) {
const l = len(a);
return l < 0.0001 ? fallback : vec(a.x / l, a.y / l);
}
function perp(a) {
return vec(-a.y, a.x);
}
function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function lerp(a, b, t) {
return vec(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
function angleOf(v) {
return Math.atan2(v.y, v.x);
}
function shortestAngleDelta(from, to) {
let delta = (to - from + Math.PI) % (Math.PI * 2) - Math.PI;
if (delta < -Math.PI) delta += Math.PI * 2;
return delta;
}
function directionFromAngle(angle) {
return vec(Math.cos(angle), Math.sin(angle));
}
function cubic(p0, p1, p2, p3, t) {
const u = 1 - t;
return add(
add(mul(p0, u * u * u), mul(p1, 3 * u * u * t)),
add(mul(p2, 3 * u * t * t), mul(p3, t * t * t))
);
}
function catmullRom(p0, p1, p2, p3, t) {
const t2 = t * t;
const t3 = t2 * t;
return vec(
0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3)
);
}
function smoothCablePath(points, samplesPerSpan = 4) {
if (points.length < 4) return points.slice();
const smoothed = [points[0]];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
for (let sample = 1; sample <= samplesPerSpan; sample++) {
smoothed.push(catmullRom(p0, p1, p2, p3, sample / samplesPerSpan));
}
}
smoothed[smoothed.length - 1] = points[points.length - 1];
return smoothed;
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
state.physics.verlet = null;
draw();
}
function getLayout() {
const rect = canvas.getBoundingClientRect();
const pad = 24;
const gap = 18;
const h = rect.height - pad * 2;
if (state.mode === "locked" || state.mode === "mesh" || rect.width < 860) {
return [
{ x: pad, y: pad, w: rect.width - pad * 2, h, kind: "correct" }
];
}
const w = (rect.width - pad * 2 - gap) / 2;
return [
{ x: pad, y: pad, w, h, kind: "wrong" },
{ x: pad + w + gap, y: pad, w, h, kind: "correct" }
];
}
function panelMetrics(panel) {
const scale = Math.min(panel.w / 760, panel.h / 820);
const center = vec(panel.x + panel.w * 0.5, panel.y + panel.h * 0.29);
const radius = Math.max(86, 126 * scale);
const outletLength = 82 * scale;
const outletWidth = Math.max(34, state.cableWidth * 1.12 * scale);
const angle = (90 + state.outletAngle) * Math.PI / 180;
const dir = vec(Math.cos(angle), Math.sin(angle));
const normal = perp(dir);
const mouth = add(center, mul(dir, radius + outletLength * 0.72));
const outletInner = add(center, mul(dir, radius - outletLength * 0.18));
const target = add(center, mul(state.targetOffset, scale));
const bounds = {
minX: panel.x + 58 * scale,
maxX: panel.x + panel.w - 58 * scale,
minY: panel.y + 86 * scale,
maxY: panel.y + panel.h - 38 * scale
};
return { scale, center, radius, outletLength, outletWidth, angle, dir, normal, mouth, outletInner, target, bounds };
}
function getCableStart(metrics) {
return add(metrics.mouth, mul(metrics.dir, state.guardLength * metrics.scale));
}
function getNeutralTail(metrics) {
const start = getCableStart(metrics);
return add(add(start, mul(metrics.dir, 28 * metrics.scale)), mul(metrics.normal, -82 * metrics.scale));
}
function getFlexibleSampleCount(metrics, start, target) {
const distance = Math.max(1, len(sub(target, start)));
const spacing = Math.max(4, state.resolution) * metrics.scale;
return clamp(Math.round(distance / spacing), 14, 88);
}
function buildRestCablePoints(metrics) {
const guard = state.guardLength * metrics.scale;
const mouth = metrics.mouth;
const start = add(mouth, mul(metrics.dir, guard));
const target = state.dragging ? metrics.target : getNeutralTail(metrics);
const distance = Math.max(1, len(sub(target, start)));
const p1 = add(start, mul(metrics.dir, Math.min(distance * 0.34, guard + 54 * metrics.scale)));
const endApproach = norm(sub(start, target), mul(metrics.dir, -1));
const p2 = add(target, mul(endApproach, Math.min(distance * 0.36, 96 * metrics.scale)));
const points = [mouth];
for (let i = 1; i <= 4; i++) {
points.push(lerp(mouth, start, i / 4));
}
const flexibleSamples = getFlexibleSampleCount(metrics, start, target);
for (let i = 1; i <= flexibleSamples; i++) {
const t = i / flexibleSamples;
points.push(cubic(start, p1, p2, target, t));
}
return points;
}
function resetVerletCable(metrics) {
const points = buildRestCablePoints(metrics);
const lastIndex = points.length - 1;
const particles = points.map((point, index) => {
const isPlug = index === lastIndex;
return {
x: point.x,
y: point.y,
px: point.x,
py: point.y,
pinned: index <= 4,
invMass: index <= 4 ? 0 : (isPlug ? 1.35 : 1)
};
});
const segments = [];
let fixedLength = 0;
let flexBaseLength = 0;
for (let i = 0; i < points.length - 1; i++) {
const rest = len(sub(points[i + 1], points[i]));
segments.push({ a: i, b: i + 1, rest, baseRest: rest });
if (i < 4) fixedLength += rest;
else flexBaseLength += rest;
}
const defaultReelLength = fixedLength + 118 * metrics.scale;
const maxReelLength = fixedLength + 610 * metrics.scale;
state.physics.verlet = {
particles,
segments,
pinnedCount: 5,
pointCount: points.length,
fixedLength,
flexBaseLength,
defaultReelLength,
maxReelLength
};
const tail = points[points.length - 1];
const tailBase = points[Math.max(0, points.length - 6)];
state.physics.plugAngle = angleOf(norm(sub(tail, tailBase), metrics.dir));
state.physics.reelLength = defaultReelLength;
state.physics.targetReelLength = defaultReelLength;
}
function getMinReelLength(metrics, tailPos, verlet) {
const start = getCableStart(metrics);
const chord = len(sub(tailPos, start));
const flexMin = Math.max(52 * metrics.scale, chord * 1.06 + state.cableWidth * metrics.scale * 0.55);
return verlet.fixedLength + flexMin;
}
function updateReelLength(metrics) {
const verlet = state.physics.verlet;
if (!verlet) return;
const dt = clamp(state.physics.frameDt || 1 / 60, 1 / 120, 1 / 30);
const spring01 = state.spring / 100;
const tail = verlet.particles[verlet.particles.length - 1];
const tailPos = vec(tail.x, tail.y);
const start = getCableStart(metrics);
const minReelLength = getMinReelLength(metrics, tailPos, verlet);
if (state.dragging) {
const payoutRequired = len(sub(metrics.target, start)) * 1.14 + state.cableWidth * metrics.scale * 1.35;
const retractRadius = 190 * metrics.scale;
const fullRetractRadius = 64 * metrics.scale;
const retract01 = clamp((retractRadius - len(sub(metrics.target, start))) / Math.max(1, retractRadius - fullRetractRadius), 0, 1);
const retractEase = retract01 * retract01 * (3 - 2 * retract01);
const payoutTarget = Math.max(state.physics.reelLength, payoutRequired);
const retractTarget = Math.max(verlet.defaultReelLength, minReelLength);
const targetLength = payoutTarget + (retractTarget - payoutTarget) * retractEase;
state.physics.targetReelLength = clamp(targetLength, verlet.defaultReelLength, verlet.maxReelLength);
const lengthBlend = 1 - Math.exp(-(state.physics.targetReelLength < state.physics.reelLength ? 16 + spring01 * 20 : 24 + spring01 * 16) * dt);
state.physics.reelLength += (state.physics.targetReelLength - state.physics.reelLength) * lengthBlend;
} else {
const retractSpeed = (62 + spring01 * 148) * metrics.scale;
state.physics.reelLength -= retractSpeed * dt;
state.physics.reelLength = clamp(
state.physics.reelLength,
Math.max(verlet.defaultReelLength, minReelLength),
verlet.maxReelLength
);
}
const targetFlexLength = Math.max(52 * metrics.scale, state.physics.reelLength - verlet.fixedLength);
const ratio = clamp(targetFlexLength / Math.max(1, verlet.flexBaseLength), 0.32, 2.35);
for (let i = 0; i < verlet.segments.length; i++) {
const segment = verlet.segments[i];
segment.rest = i < 4 ? segment.baseRest : segment.baseRest * ratio;
}
}
function syncTargetOffsetFromPlug(metrics) {
const verlet = state.physics.verlet;
if (!verlet || state.dragging) return;
const tail = verlet.particles[verlet.particles.length - 1];
state.targetOffset = mul(sub(vec(tail.x, tail.y), metrics.center), 1 / metrics.scale);
}
function pinDragTail(metrics) {
const verlet = state.physics.verlet;
if (!verlet || !state.dragging) return;
const dt = clamp(state.physics.frameDt || 1 / 60, 1 / 120, 1 / 30);
const follow = 1 - Math.exp(-34 * dt);
const tailIndex = verlet.particles.length - 1;
const tail = verlet.particles[tailIndex];
const target = metrics.target;
tail.x += (target.x - tail.x) * follow;
tail.y += (target.y - tail.y) * follow;
tail.px = tail.x;
tail.py = tail.y;
for (let i = Math.max(verlet.pinnedCount, tailIndex - 4); i < tailIndex; i++) {
const particle = verlet.particles[i];
particle.px = particle.x + (particle.px - particle.x) * 0.35;
particle.py = particle.y + (particle.py - particle.y) * 0.35;
}
}
function pinVerletEnds(metrics) {
const verlet = state.physics.verlet;
if (!verlet) return;
const guard = state.guardLength * metrics.scale;
const mouth = metrics.mouth;
const start = add(mouth, mul(metrics.dir, guard));
for (let i = 0; i < verlet.pinnedCount; i++) {
const pinned = lerp(mouth, start, i / (verlet.pinnedCount - 1));
const particle = verlet.particles[i];
particle.x = pinned.x;
particle.y = pinned.y;
particle.px = pinned.x;
particle.py = pinned.y;
}
pinDragTail(metrics);
}
function applyPlugGuard(metrics, updateAngle) {
const verlet = state.physics.verlet;
if (!verlet || !updateAngle) return;
const tailIndex = verlet.particles.length - 1;
const guardCount = 7;
const baseIndex = Math.max(verlet.pinnedCount, tailIndex - guardCount);
const tail = verlet.particles[tailIndex];
const base = verlet.particles[baseIndex];
const desiredDir = norm(sub(tail, base), metrics.dir);
const desiredAngle = angleOf(desiredDir);
const dt = clamp(state.physics.frameDt || 1 / 60, 1 / 120, 1 / 30);
if (state.physics.plugAngle == null) {
state.physics.plugAngle = desiredAngle;
return;
}
const response = state.dragging ? 18 : 8.5;
const maxStep = (state.dragging ? 6.5 : 3.4) * dt;
const delta = shortestAngleDelta(state.physics.plugAngle, desiredAngle);
const blend = 1 - Math.exp(-response * dt);
state.physics.plugAngle += clamp(delta * blend, -maxStep, maxStep);
}
function stepVerletCable(metrics) {
if (!state.physics.verlet) {
resetVerletCable(metrics);
}
const verlet = state.physics.verlet;
const dt = clamp(state.physics.frameDt || 1 / 60, 1 / 120, 1 / 30);
const prevDt = state.physics.prevFrameDt > 0 ? state.physics.prevFrameDt : dt;
const timeCorrection = prevDt > 0 ? dt / prevDt : 1;
const spring01 = state.spring / 100;
const dragDamping = 0.992 - state.damping * 0.00085;
const freeDamping = 0.988 - state.damping * 0.00145 - spring01 * 0.008;
const damping = state.dragging ? dragDamping : freeDamping;
const gravity = (980 + state.sag * 34) * metrics.scale;
const accelCoef = dt * dt;
const tailIndex = verlet.particles.length - 1;
pinVerletEnds(metrics);
updateReelLength(metrics);
for (let i = verlet.pinnedCount; i < verlet.particles.length; i++) {
const particle = verlet.particles[i];
if (state.dragging && i === tailIndex) continue;
const vx = (particle.x - particle.px) * damping * timeCorrection;
const vy = (particle.y - particle.py) * damping * timeCorrection;
const isPlug = i === tailIndex;
const tailT = clamp((i - (tailIndex - 8)) / 8, 0, 1);
const localGravity = state.dragging
? gravity * (0.22 + tailT * 0.45)
: gravity * (isPlug ? 2.35 : 0.85 + tailT * 1.05);
particle.px = particle.x;
particle.py = particle.y;
particle.x += vx;
particle.y += vy + localGravity * accelCoef;
if (particle.x < metrics.bounds.minX || particle.x > metrics.bounds.maxX) {
particle.x = clamp(particle.x, metrics.bounds.minX, metrics.bounds.maxX);
particle.px = particle.x + vx * -0.18;
}
if (particle.y > metrics.bounds.maxY) {
particle.y = metrics.bounds.maxY;
particle.py = particle.y + vy * -0.12;
} else if (particle.y < metrics.bounds.minY) {
particle.y = metrics.bounds.minY;
particle.py = particle.y;
}
}
applyPlugGuard(metrics, true);
const solverPasses = state.solverIterations;
const stiffness = 0.72 + spring01 * 0.28;
for (let pass = 0; pass < solverPasses; pass++) {
pinVerletEnds(metrics);
for (const segment of verlet.segments) {
const a = verlet.particles[segment.a];
const b = verlet.particles[segment.b];
const dx = b.x - a.x;
const dy = b.y - a.y;
const distance = Math.hypot(dx, dy) || 0.0001;
const difference = (distance - segment.rest) / distance;
const correctionX = dx * difference * stiffness;
const correctionY = dy * difference * stiffness;
const invA = a.pinned ? 0 : a.invMass;
const invB = b.pinned ? 0 : b.invMass;
const invTotal = invA + invB || 1;
if (!a.pinned) {
a.x += correctionX * (invA / invTotal);
a.y += correctionY * (invA / invTotal);
}
if (!b.pinned) {
b.x -= correctionX * (invB / invTotal);
b.y -= correctionY * (invB / invTotal);
}
}
applyPlugGuard(metrics, false);
}
pinVerletEnds(metrics);
applyPlugGuard(metrics, false);
for (const particle of verlet.particles) {
if (particle.pinned) continue;
particle.x = clamp(particle.x, metrics.bounds.minX, metrics.bounds.maxX);
particle.y = clamp(particle.y, metrics.bounds.minY, metrics.bounds.maxY);
}
state.physics.prevFrameDt = dt;
syncTargetOffsetFromPlug(metrics);
return verlet.particles.map((particle) => vec(particle.x, particle.y));
}
function ensureSimulationPanels() {
if (!state.lastPanels.length) {
state.lastPanels = getLayout().map((panel) => ({ panel, metrics: panelMetrics(panel) }));
}
}
function advanceVerletSimulation() {
if (!state.physicsEnabled) {
state.physics.renderPoints = null;
return;
}
ensureSimulationPanels();
const correctItem = state.lastPanels.find((item) => item.panel.kind === "correct") || state.lastPanels[0];
if (!correctItem) {
state.physics.renderPoints = null;
return;
}
state.physics.renderPoints = stepVerletCable(correctItem.metrics);
}
function sampleCable(mode, metrics) {
const width = state.cableWidth * metrics.scale;
const guard = state.guardLength * metrics.scale;
const target = metrics.target;
const mouth = metrics.mouth;
const startDir = metrics.dir;
const toTarget = norm(sub(target, mouth), startDir);
if (mode === "correct" && state.physicsEnabled) {
const rawPoints = state.physics.renderPoints || buildRestCablePoints(metrics);
const smoothSamples = clamp(Math.round(24 / state.resolution), 2, 5);
const points = smoothCablePath(rawPoints, smoothSamples);
return {
points,
rawPoints,
width,
mismatch: 0,
startDir,
plugAngle: state.physics.plugAngle
};
}
const actualStartDir = mode === "wrong" ? toTarget : startDir;
const start = mode === "wrong" ? mouth : add(mouth, mul(startDir, guard));
const distance = Math.max(1, len(sub(target, start)));
const p1 = add(start, mul(actualStartDir, Math.min(distance * 0.42, guard + 50 * metrics.scale)));
const endApproach = norm(sub(start, target), mul(actualStartDir, -1));
const p2 = add(target, mul(endApproach, Math.min(distance * 0.36, 92 * metrics.scale)));
const dynamicSag = mode === "correct" && state.physicsEnabled ? state.physics.sagOffset : 0;
const sag = state.sag * metrics.scale * Math.min(1, distance / (360 * metrics.scale)) + dynamicSag;
const chordDir = norm(sub(target, start), startDir);
const lateralDir = perp(chordDir);
const physicsWave = mode === "correct" && state.physicsEnabled ? state.physics.wave : 0;
const points = [];
if (mode === "correct") {
points.push(mouth);
points.push(start);
} else {
points.push(mouth);
}
for (let i = 1; i <= 42; i++) {
const t = i / 42;
const p = cubic(start, p1, p2, target, t);
p.y += Math.sin(t * Math.PI) * sag;
if (mode === "correct" && state.physicsEnabled) {
const flexT = clamp((t - 0.08) / 0.92, 0, 1);
const anchorEnvelope = Math.sin(flexT * Math.PI);
const travelingWave = Math.sin(flexT * Math.PI * 1.55 + state.physics.phase);
const lateralOffset = physicsWave * anchorEnvelope * travelingWave;
const verticalOffset = state.physics.sagOffset * 0.18 * anchorEnvelope * Math.sin(state.physics.phase * 0.72 + flexT * Math.PI);
p.x += lateralDir.x * lateralOffset;
p.y += lateralDir.y * lateralOffset + verticalOffset;
}
points.push(p);
}
return {
points,
width,
mismatch: Math.acos(Math.max(-1, Math.min(1, dot(toTarget, startDir)))) || 0,
startDir: actualStartDir
};
}
function drawRoundedRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function drawRotatedOutlet(metrics) {
const { outletInner, mouth, normal, dir, outletWidth } = metrics;
const a = add(outletInner, mul(normal, outletWidth * 0.5));
const b = add(mouth, mul(normal, outletWidth * 0.5));
const c = add(mouth, mul(normal, -outletWidth * 0.5));
const d = add(outletInner, mul(normal, -outletWidth * 0.5));
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.quadraticCurveTo(add(mouth, mul(dir, 7 * metrics.scale)).x, add(mouth, mul(dir, 7 * metrics.scale)).y, c.x, c.y);
ctx.lineTo(d.x, d.y);
ctx.closePath();
ctx.fillStyle = colors.outlet;
ctx.fill();
ctx.strokeStyle = "rgba(207, 216, 230, 0.75)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(b.x, b.y);
ctx.lineTo(c.x, c.y);
ctx.strokeStyle = colors.outletMouth;
ctx.lineWidth = Math.max(7, metrics.scale * 12);
ctx.lineCap = "round";
ctx.stroke();
}
function drawCableStrip(points, width, fill, stroke, mesh) {
if (points.length < 2) return;
ctx.save();
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const mid = lerp(points[i], points[i + 1], 0.5);
ctx.quadraticCurveTo(points[i].x, points[i].y, mid.x, mid.y);
}
const last = points[points.length - 1];
ctx.lineTo(last.x, last.y);
ctx.strokeStyle = stroke;
ctx.lineWidth = width + 2;
ctx.lineCap = "butt";
ctx.lineJoin = "round";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const mid = lerp(points[i], points[i + 1], 0.5);
ctx.quadraticCurveTo(points[i].x, points[i].y, mid.x, mid.y);
}
ctx.lineTo(last.x, last.y);
ctx.strokeStyle = fill;
ctx.lineWidth = width;
ctx.lineCap = "butt";
ctx.lineJoin = "round";
ctx.stroke();
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const mid = lerp(points[i], points[i + 1], 0.5);
ctx.quadraticCurveTo(points[i].x, points[i].y, mid.x, mid.y);
}
ctx.lineTo(last.x, last.y);
ctx.strokeStyle = "rgba(255, 255, 255, 0.23)";
ctx.lineWidth = Math.max(2, width * 0.11);
ctx.lineCap = "butt";
ctx.lineJoin = "round";
ctx.stroke();
if (mesh) {
ctx.strokeStyle = "rgba(29, 36, 48, 0.24)";
ctx.lineWidth = 1;
for (let i = 0; i < points.length; i += 7) {
const prev = points[Math.max(0, i - 1)];
const next = points[Math.min(points.length - 1, i + 1)];
const n = perp(norm(sub(next, prev)));
const left = add(points[i], mul(n, width * 0.5));
const right = add(points[i], mul(n, -width * 0.5));
ctx.beginPath();
ctx.moveTo(left.x, left.y);
ctx.lineTo(right.x, right.y);
ctx.stroke();
}
}
ctx.restore();
}
function drawPlug(root, tangent, scale) {
const axis = norm(tangent, vec(1, 0));
const angle = Math.atan2(axis.y, axis.x);
ctx.save();
ctx.translate(root.x, root.y);
ctx.rotate(angle);
const bodyW = 95 * scale;
const bodyH = 31 * scale;
ctx.translate(8 * scale, 0);
ctx.fillStyle = "#1e2632";
drawRoundedRect(ctx, 0, -bodyH * 0.54, bodyW * 0.2, bodyH * 1.08, 6 * scale);
ctx.fill();
ctx.fillStyle = "#f12ba6";
ctx.fillRect(bodyW * 0.16, -bodyH * 0.52, bodyW * 0.08, bodyH * 1.04);
ctx.fillStyle = "#31d1c4";
ctx.fillRect(bodyW * 0.24, -bodyH * 0.52, bodyW * 0.08, bodyH * 1.04);
const grad = ctx.createLinearGradient(bodyW * 0.32, -bodyH, bodyW * 0.82, bodyH);
grad.addColorStop(0, "#f4efe0");
grad.addColorStop(0.25, "#5d6367");
grad.addColorStop(0.55, "#c9c3ae");
grad.addColorStop(0.82, "#2f3538");
grad.addColorStop(1, "#f8f3dc");
ctx.fillStyle = grad;
drawRoundedRect(ctx, bodyW * 0.32, -bodyH * 0.5, bodyW * 0.55, bodyH, 8 * scale);
ctx.fill();
ctx.strokeStyle = "rgba(29, 36, 48, 0.34)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = "#dca51e";
drawRoundedRect(ctx, bodyW * 0.84, -bodyH * 0.31, bodyW * 0.22, bodyH * 0.62, 4 * scale);
ctx.fill();
ctx.strokeStyle = "#8d6712";
ctx.stroke();
ctx.fillStyle = "#e0a91f";
ctx.beginPath();
ctx.moveTo(bodyW * 1.05, -bodyH * 0.18);
ctx.lineTo(bodyW * 1.22, -bodyH * 0.1);
ctx.lineTo(bodyW * 1.22, bodyH * 0.1);
ctx.lineTo(bodyW * 1.05, bodyH * 0.18);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawPanel(panel) {
const metrics = panelMetrics(panel);
const locked = panel.kind === "correct";
const mesh = state.mode === "mesh";
const cable = sampleCable(panel.kind, metrics);
ctx.save();
drawRoundedRect(ctx, panel.x, panel.y, panel.w, panel.h, 8);
ctx.fillStyle = "#fbfcff";
ctx.fill();
ctx.strokeStyle = "rgba(130, 145, 170, 0.22)";
ctx.stroke();
ctx.clip();
ctx.fillStyle = "#f4f7fb";
ctx.fillRect(panel.x, panel.y, panel.w, panel.h);
ctx.fillStyle = locked ? colors.green : colors.red;
ctx.beginPath();
ctx.arc(panel.x + 27, panel.y + 27, 11, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2.4;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
if (locked) {
ctx.moveTo(panel.x + 21, panel.y + 27);
ctx.lineTo(panel.x + 25.5, panel.y + 31);
ctx.lineTo(panel.x + 34, panel.y + 22.5);
} else {
ctx.moveTo(panel.x + 22.5, panel.y + 22.5);
ctx.lineTo(panel.x + 31.5, panel.y + 31.5);
ctx.moveTo(panel.x + 31.5, panel.y + 22.5);
ctx.lineTo(panel.x + 22.5, panel.y + 31.5);
}
ctx.stroke();
ctx.fillStyle = colors.ink;
ctx.font = "650 16px Segoe UI, Microsoft YaHei, sans-serif";
ctx.fillText(locked ? "切线锁定" : "直接转向", panel.x + 48, panel.y + 32);
ctx.fillStyle = colors.muted;
ctx.font = "12px Segoe UI, Microsoft YaHei, sans-serif";
ctx.fillText(locked ? "口沿固定,后段弯曲" : "线头被拖拽方向带走", panel.x + 48, panel.y + 53);
ctx.save();
ctx.beginPath();
ctx.arc(metrics.center.x, metrics.center.y, metrics.radius, 0, Math.PI * 2);
ctx.fillStyle = colors.module;
ctx.fill();
ctx.strokeStyle = colors.moduleEdge;
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
drawRotatedOutlet(metrics);
if (!locked && cable.mismatch > 0.22) {
ctx.save();
ctx.globalAlpha = Math.min(0.42, cable.mismatch / 2.1);
ctx.beginPath();
ctx.arc(metrics.mouth.x, metrics.mouth.y, Math.max(34, cable.width * 1.16), 0, Math.PI * 2);
ctx.fillStyle = colors.red;
ctx.fill();
ctx.restore();
}
const mouthA = add(metrics.mouth, mul(metrics.normal, cable.width * 0.5));
const mouthB = add(metrics.mouth, mul(metrics.normal, -cable.width * 0.5));
if (locked) {
ctx.beginPath();
ctx.moveTo(mouthA.x, mouthA.y);
ctx.lineTo(mouthB.x, mouthB.y);
ctx.strokeStyle = colors.green;
ctx.lineWidth = 4;
ctx.lineCap = "round";
ctx.stroke();
const arrowBase = add(metrics.mouth, mul(metrics.dir, 18 * metrics.scale));
const arrowTip = add(metrics.mouth, mul(metrics.dir, Math.max(54, state.guardLength) * metrics.scale));
drawArrow(arrowBase, arrowTip, colors.green, metrics.scale);
} else {
const wrongDir = norm(sub(metrics.target, metrics.mouth), metrics.dir);
drawArrow(add(metrics.mouth, mul(wrongDir, 14 * metrics.scale)), add(metrics.mouth, mul(wrongDir, 72 * metrics.scale)), colors.red, metrics.scale);
}
drawCableStrip(
cable.points,
cable.width,
locked ? colors.cable : "rgba(93, 140, 255, 0.78)",
locked ? "rgba(61, 104, 222, 0.35)" : "rgba(93, 76, 186, 0.24)",
mesh
);
const last = cable.points[cable.points.length - 1];
const prev = cable.points[cable.points.length - 4] || cable.points[cable.points.length - 2];
const plugTangent = cable.plugAngle == null ? norm(sub(last, prev), vec(1, 0)) : directionFromAngle(cable.plugAngle);
panel.plugPoint = last;
drawPlug(last, plugTangent, metrics.scale);
ctx.beginPath();
ctx.arc(last.x, last.y, Math.max(9, 11 * metrics.scale), 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.strokeStyle = colors.cableDark;
ctx.lineWidth = 2;
ctx.stroke();
if (mesh || locked) {
drawAnchorDot(mouthA, "A", metrics.scale);
drawAnchorDot(mouthB, "B", metrics.scale);
}
ctx.restore();
}
function drawArrow(from, to, color, scale) {
const d = norm(sub(to, from));
const n = perp(d);
const size = 8 * scale;
ctx.save();
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = Math.max(2, 2.4 * scale);
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(to.x, to.y);
ctx.lineTo(to.x - d.x * size - n.x * size * 0.6, to.y - d.y * size - n.y * size * 0.6);
ctx.lineTo(to.x - d.x * size + n.x * size * 0.6, to.y - d.y * size + n.y * size * 0.6);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawAnchorDot(point, label, scale) {
ctx.save();
ctx.beginPath();
ctx.arc(point.x, point.y, Math.max(5, 6 * scale), 0, Math.PI * 2);
ctx.fillStyle = "#ffffff";
ctx.fill();
ctx.strokeStyle = colors.green;
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = colors.green;
ctx.font = `${Math.max(10, 11 * scale)}px Segoe UI, Microsoft YaHei, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, point.x, point.y - 18 * scale);
ctx.restore();
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
ctx.fillStyle = "#eef3f8";
ctx.fillRect(0, 0, rect.width, rect.height);
const panels = getLayout();
state.lastPanels = panels.map((panel) => ({ panel, metrics: panelMetrics(panel) }));
if (state.physicsEnabled && !state.physics.renderPoints) {
advanceVerletSimulation();
}
for (const panel of panels) drawPanel(panel);
}
function resetPhysics() {
state.physics.wave = 0;
state.physics.waveVelocity = 0;
state.physics.sagOffset = 0;
state.physics.sagVelocity = 0;
state.physics.phase = 0;
state.physics.dragVelocity = { x: 0, y: 0 };
state.physics.lastPointer = null;
state.physics.lastPointerTime = 0;
state.physics.verlet = null;
state.physics.reelLength = 0;
state.physics.targetReelLength = 0;
state.physics.renderPoints = null;
state.physics.plugAngle = null;
state.physics.prevFrameDt = 0;
}
function applyReleaseImpulse(item) {
if (!state.physicsEnabled || !item || !state.physics.verlet) return;
const tail = state.physics.verlet.particles[state.physics.verlet.particles.length - 1];
const velocity = state.physics.dragVelocity;
const speed = len(velocity);
if (speed < 4) return;
const dt = clamp(state.physics.frameDt || 1 / 60, 1 / 120, 1 / 30);
tail.px = tail.x - velocity.x * dt * 0.9;
tail.py = tail.y - velocity.y * dt * 0.9;
}
function tickPhysics(time) {
if (!state.physics.lastFrameTime) state.physics.lastFrameTime = time;
const dt = Math.min(0.033, Math.max(0.001, (time - state.physics.lastFrameTime) / 1000));
state.physics.lastFrameTime = time;
state.physics.frameDt = dt;
if (state.physicsEnabled) {
const damping = 2.3 + state.damping * 0.075;
const waveStiffness = 34;
const sagStiffness = 26;
if (!state.dragging) {
state.physics.waveVelocity += -state.physics.wave * waveStiffness * dt;
state.physics.waveVelocity *= Math.exp(-damping * dt);
state.physics.wave += state.physics.waveVelocity * dt;
state.physics.sagVelocity += -state.physics.sagOffset * sagStiffness * dt;
state.physics.sagVelocity *= Math.exp(-damping * 0.72 * dt);
state.physics.sagOffset += state.physics.sagVelocity * dt;
} else {
state.physics.wave *= Math.exp(-damping * 1.4 * dt);
state.physics.sagOffset *= Math.exp(-damping * 1.15 * dt);
}
state.physics.phase += dt * (7.2 + clamp(Math.abs(state.physics.waveVelocity) * 0.006, 0, 3.6));
if (Math.abs(state.physics.wave) < 0.02 && Math.abs(state.physics.waveVelocity) < 0.02) {
state.physics.wave = 0;
state.physics.waveVelocity = 0;
}
if (Math.abs(state.physics.sagOffset) < 0.02 && Math.abs(state.physics.sagVelocity) < 0.02) {
state.physics.sagOffset = 0;
state.physics.sagVelocity = 0;
}
} else if (state.physics.wave !== 0 || state.physics.sagOffset !== 0) {
resetPhysics();
}
state.lastPanels = getLayout().map((panel) => ({ panel, metrics: panelMetrics(panel) }));
advanceVerletSimulation();
draw();
requestAnimationFrame(tickPhysics);
}
function canvasPoint(event) {
const rect = canvas.getBoundingClientRect();
return vec(event.clientX - rect.left, event.clientY - rect.top);
}
function findPanelAt(point) {
let best = null;
let bestDistance = Infinity;
for (const item of state.lastPanels) {
const target = item.panel.plugPoint || item.metrics.target;
const distance = len(sub(point, target));
if (distance < bestDistance) {
bestDistance = distance;
best = item;
}
}
return bestDistance < 86 ? best : null;
}
function updateTargetFromPoint(point, item, time) {
const panel = item.panel;
const metrics = panelMetrics(panel);
const minX = panel.x + 72 * metrics.scale;
const maxX = panel.x + panel.w - 72 * metrics.scale;
const minY = panel.y + 96 * metrics.scale;
const maxY = panel.y + panel.h - 54 * metrics.scale;
const clamped = vec(
Math.max(minX, Math.min(maxX, point.x)),
Math.max(minY, Math.min(maxY, point.y))
);
const now = time || performance.now();
if (state.physics.lastPointer) {
const dt = Math.max(0.001, (now - state.physics.lastPointerTime) / 1000);
const rawVelocity = mul(sub(clamped, state.physics.lastPointer), 1 / dt);
state.physics.dragVelocity = lerp(state.physics.dragVelocity, rawVelocity, 0.42);
}
state.physics.lastPointer = clamped;
state.physics.lastPointerTime = now;
state.targetOffset = mul(sub(clamped, metrics.center), 1 / metrics.scale);
}
canvas.addEventListener("pointerdown", (event) => {
const point = canvasPoint(event);
const hit = findPanelAt(point);
if (!hit) return;
state.dragging = true;
state.activePanel = hit;
state.physics.dragVelocity = { x: 0, y: 0 };
state.physics.lastPointer = null;
state.physics.lastPointerTime = event.timeStamp || performance.now();
canvas.classList.add("dragging");
canvas.setPointerCapture(event.pointerId);
updateTargetFromPoint(point, hit, event.timeStamp);
});
canvas.addEventListener("pointermove", (event) => {
if (!state.dragging || !state.activePanel) return;
updateTargetFromPoint(canvasPoint(event), state.activePanel, event.timeStamp);
});
canvas.addEventListener("pointerup", (event) => {
applyReleaseImpulse(state.activePanel);
state.dragging = false;
state.activePanel = null;
state.physics.lastPointer = null;
canvas.classList.remove("dragging");
canvas.releasePointerCapture(event.pointerId);
});
canvas.addEventListener("pointercancel", () => {
applyReleaseImpulse(state.activePanel);
state.dragging = false;
state.activePanel = null;
state.physics.lastPointer = null;
canvas.classList.remove("dragging");
});
function setMode(mode) {
state.mode = mode;
state.physics.verlet = null;
ui.viewCompare.setAttribute("aria-pressed", String(mode === "compare"));
ui.viewLocked.setAttribute("aria-pressed", String(mode === "locked"));
ui.viewMesh.setAttribute("aria-pressed", String(mode === "mesh"));
draw();
}
function setPhysicsMode(enabled) {
state.physicsEnabled = enabled;
ui.physicsOn.setAttribute("aria-pressed", String(enabled));
ui.physicsOff.setAttribute("aria-pressed", String(!enabled));
resetPhysics();
draw();
}
ui.viewCompare.addEventListener("click", () => setMode("compare"));
ui.viewLocked.addEventListener("click", () => setMode("locked"));
ui.viewMesh.addEventListener("click", () => setMode("mesh"));
ui.physicsOn.addEventListener("click", () => setPhysicsMode(true));
ui.physicsOff.addEventListener("click", () => setPhysicsMode(false));
ui.angle.addEventListener("input", () => {
state.outletAngle = Number(ui.angle.value);
ui.angleValue.textContent = `${state.outletAngle}°`;
state.physics.verlet = null;
draw();
});
ui.guard.addEventListener("input", () => {
state.guardLength = Number(ui.guard.value);
ui.guardValue.textContent = String(state.guardLength);
state.physics.verlet = null;
draw();
});
ui.width.addEventListener("input", () => {
state.cableWidth = Number(ui.width.value);
ui.widthValue.textContent = String(state.cableWidth);
draw();
});
ui.sag.addEventListener("input", () => {
state.sag = Number(ui.sag.value);
ui.sagValue.textContent = String(state.sag);
draw();
});
ui.damping.addEventListener("input", () => {
state.damping = Number(ui.damping.value);
ui.dampingValue.textContent = String(state.damping);
draw();
});
ui.spring.addEventListener("input", () => {
state.spring = Number(ui.spring.value);
ui.springValue.textContent = String(state.spring);
draw();
});
ui.resolution.addEventListener("input", () => {
state.resolution = Number(ui.resolution.value);
ui.resolutionValue.textContent = String(state.resolution);
state.physics.verlet = null;
state.physics.prevFrameDt = 0;
draw();
});
ui.solver.addEventListener("input", () => {
state.solverIterations = Number(ui.solver.value);
ui.solverValue.textContent = String(state.solverIterations);
draw();
});
ui.reset.addEventListener("click", () => {
state.outletAngle = 0;
state.guardLength = 58;
state.cableWidth = 36;
state.sag = 18;
state.damping = 66;
state.spring = 58;
state.resolution = 8;
state.solverIterations = 28;
state.physicsEnabled = true;
state.targetOffset = { x: 238, y: 196 };
resetPhysics();
ui.angle.value = state.outletAngle;
ui.guard.value = state.guardLength;
ui.width.value = state.cableWidth;
ui.sag.value = state.sag;
ui.damping.value = state.damping;
ui.spring.value = state.spring;
ui.resolution.value = state.resolution;
ui.solver.value = state.solverIterations;
ui.angleValue.textContent = "0°";
ui.guardValue.textContent = "58";
ui.widthValue.textContent = "36";
ui.sagValue.textContent = "18";
ui.dampingValue.textContent = "66";
ui.springValue.textContent = "58";
ui.resolutionValue.textContent = "8";
ui.solverValue.textContent = "28";
setPhysicsMode(true);
setMode("compare");
});
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
requestAnimationFrame(tickPhysics);
</script>
</body>
</html>