1555 lines
51 KiB
HTML
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">0°</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>
|