Files
wiki/交互式演示/tetrahedron-interactive.html
T
2026-05-14 16:56:48 +08:00

327 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>四面体旋转动画</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #1a1a2e;
min-height: 100vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
color: #ccc; overflow: hidden;
}
h2 { font-size: 16px; font-weight: 600; margin-bottom: 8px; color: #fff; }
.subtitle { font-size: 13px; color: #888; margin-bottom: 20px; }
canvas { cursor: grab; border-radius: 8px; }
canvas:active { cursor: grabbing; }
.controls { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
.btn {
padding: 10px 28px; border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px; background: rgba(255,255,255,0.08);
color: #fff; font-size: 14px; cursor: pointer; transition: all 0.2s;
}
.btn:hover { background: rgba(255,255,255,0.15); border-color: rgba(255,255,255,0.4); }
.btn-active { background: rgba(22,119,255,0.4); border-color: #1677FF; }
.info { position: fixed; bottom: 16px; font-size: 12px; color: #555; }
#debug {
position: fixed; top: 10px; right: 10px; font-size: 11px; color: #aaa;
font-family: monospace; background: rgba(0,0,0,0.6); padding: 10px 14px;
border-radius: 6px; line-height: 1.6; max-width: 300px;
}
</style>
</head>
<body>
<h2>正四面体旋转动画</h2>
<p class="subtitle">鼠标拖拽旋转 · 铰链展开</p>
<canvas id="canvas" width="500" height="500"></canvas>
<div class="controls">
<button class="btn" id="toggleBtn" onclick="toggleUnfold()">展开</button>
<button class="btn" id="autoBtn" onclick="toggleAutoRotate()">自动旋转</button>
</div>
<div id="debug">点击展开查看各面边长</div>
<div class="info">正四面体 — 铰链展开为平面展开图</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const cx = W/2, cy = H/2;
const debugEl = document.getElementById('debug');
const SC = 90;
const V = [
[ 0, 2/Math.sqrt(3), 0], // 0
[-1, -1/Math.sqrt(3), 0], // 1
[ 1, -1/Math.sqrt(3), 0], // 2
[ 0, 0, 2*Math.sqrt(2/3)], // 3
].map(v => v.map(c => c * SC));
const EDGE = 2 * SC;
const FACE_H = EDGE * Math.sqrt(3) / 2;
const FACES = [
[0, 2, 1], // 0: 底面
[0, 1, 3], // 1
[1, 2, 3], // 2
[2, 0, 3], // 3
];
// ===== 铰链展开预计算 =====
const hingeData = FACES.map((fv, fi) => {
if (fi === 0) return { base: true, unfoldedVerts: FACES[0].map(i => [...V[i]]) };
const f0v = FACES[0];
const shared = fv.filter(v => f0v.includes(v));
const apexIdx = fv.find(v => !f0v.includes(v));
const eA = V[shared[0]], eB = V[shared[1]];
const apex = V[apexIdx];
const hx = eB[0]-eA[0], hy = eB[1]-eA[1], hz = eB[2]-eA[2];
const hl = Math.sqrt(hx*hx+hy*hy+hz*hz);
const axis = [hx/hl, hy/hl, hz/hl];
const mx = (eA[0]+eB[0])/2, my = (eA[1]+eB[1])/2;
const dx = eB[0]-eA[0], dy = eB[1]-eA[1];
const dl = Math.sqrt(dx*dx+dy*dy);
const perp = [-dy/dl, dx/dl];
const uApex1 = [mx + perp[0]*FACE_H, my + perp[1]*FACE_H, 0];
const uApex2 = [mx - perp[0]*FACE_H, my - perp[1]*FACE_H, 0];
const d1 = uApex1[0]*uApex1[0]+uApex1[1]*uApex1[1];
const d2 = uApex2[0]*uApex2[0]+uApex2[1]*uApex2[1];
const unfoldedApex = d1 > d2 ? uApex1 : uApex2;
// 验证展开后三边长度
const ua = unfoldedApex;
const s0 = V[shared[0]], s1 = V[shared[1]];
const e0 = Math.sqrt((ua[0]-s0[0])**2+(ua[1]-s0[1])**2);
const e1 = Math.sqrt((ua[1]-s0[1])**2+(ua[1]-s1[1])**2);
const edgeLen = Math.sqrt((s1[0]-s0[0])**2+(s1[1]-s0[1])**2);
const vFrom = [apex[0]-eA[0], apex[1]-eA[1], apex[2]-eA[2]];
const vTo = [unfoldedApex[0]-eA[0], unfoldedApex[1]-eA[1], 0];
const crX = vFrom[1]*vTo[2]-vFrom[2]*vTo[1];
const crY = vFrom[2]*vTo[0]-vFrom[0]*vTo[2];
const crZ = vFrom[0]*vTo[1]-vFrom[1]*vTo[0];
const crL = Math.sqrt(crX*crX+crY*crY+crZ*crZ);
const dotP = vFrom[0]*vTo[0]+vFrom[1]*vTo[1]+vFrom[2]*vTo[2];
let angle = Math.atan2(crL, dotP);
if (crX*axis[0]+crY*axis[1]+crZ*axis[2] < 0) angle = -angle;
const unfoldedVerts = fv.map(vi => {
if (shared.includes(vi)) return [...V[vi]];
return [...unfoldedApex];
});
return {
base: false, shared, apexIdx, hingePt: eA, axis, unfoldedApex, angle, unfoldedVerts,
verify: `e0=${e0.toFixed(1)} e1=${e1.toFixed(1)} base=${edgeLen.toFixed(1)}`
};
});
function rodrigues(v, axis, angle) {
const [x,y,z] = v;
const [u,v2,w] = axis;
const c = Math.cos(angle), s = Math.sin(angle), t = 1-c;
return [
(t*u*u+c)*x+(t*u*v2-w*s)*y+(t*u*w+v2*s)*z,
(t*u*v2+w*s)*x+(t*v2*v2+c)*y+(t*v2*w-u*s)*z,
(t*u*w-v2*s)*x+(t*v2*w+u*s)*y+(t*w*w+c)*z
];
}
function faceVerts(fi, t) {
const hd = hingeData[fi];
const fv = FACES[fi];
if (hd.base) return fv.map(i => [...V[i]]);
if (t >= 0.999) return hd.unfoldedVerts.map(v => [...v]);
if (t === 0) return fv.map(i => [...V[i]]);
return fv.map(vi => {
if (hd.shared.includes(vi)) return [...V[vi]];
const v = V[vi];
const hp = hd.hingePt;
const translated = [v[0]-hp[0], v[1]-hp[1], v[2]-hp[2]];
const rotated = rodrigues(translated, hd.axis, t * hd.angle);
return [rotated[0]+hp[0], rotated[1]+hp[1], rotated[2]+hp[2]];
});
}
function proj(x, y, z) {
const cY = Math.cos(rY), sY = Math.sin(rY);
const cX = Math.cos(rX), sX = Math.sin(rX);
let x1 = x*cY+z*sY, y1 = y, z1 = -x*sY+z*cY;
let y2 = y1*cX-z1*sX, z2 = y1*sX+z1*cX;
const fov = 500;
const sc = fov/(fov+z2);
return [cx+x1*sc, cy+y2*sc, z2];
}
function dist(a, b) {
return Math.sqrt((a[0]-b[0])**2+(a[1]-b[1])**2);
}
let rX = -0.5, rY = 0.6;
let rot2d = 0;
let unfoldT = 0, unfoldTarget = 0;
let isUnfolded = false, autoRotate = true;
let dragging = false, lastMX, lastMY;
const COLORS = [
{ fill:'rgba(239,68,68,0.8)', stroke:'rgba(255,255,255,0.7)', label:'1' },
{ fill:'rgba(59,130,246,0.8)', stroke:'rgba(255,255,255,0.7)', label:'2' },
{ fill:'rgba(34,197,94,0.8)', stroke:'rgba(255,255,255,0.7)', label:'3' },
{ fill:'rgba(234,179,8,0.8)', stroke:'rgba(255,255,255,0.7)', label:'4' },
];
function drawFace(pv, col, showEdges) {
const [p0,p1,p2] = pv;
ctx.beginPath();
ctx.moveTo(p0[0],p0[1]); ctx.lineTo(p1[0],p1[1]); ctx.lineTo(p2[0],p2[1]);
ctx.closePath();
ctx.fillStyle = col.fill; ctx.fill();
ctx.strokeStyle = col.stroke; ctx.lineWidth = 2; ctx.stroke();
const mx = (p0[0]+p1[0]+p2[0])/3, my = (p0[1]+p1[1]+p2[1])/3;
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.font = 'bold 22px sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 4;
ctx.fillText(col.label, mx, my);
ctx.shadowBlur = 0;
if (showEdges) {
ctx.font = '11px monospace';
ctx.fillStyle = 'rgba(255,255,255,0.6)';
const e1 = dist(p0,p1).toFixed(0);
const e2 = dist(p1,p2).toFixed(0);
const e3 = dist(p2,p0).toFixed(0);
ctx.fillText(e1, (p0[0]+p1[0])/2, (p0[1]+p1[1])/2);
ctx.fillText(e2, (p1[0]+p2[0])/2, (p1[1]+p2[1])/2);
ctx.fillText(e3, (p2[0]+p0[0])/2, (p2[1]+p0[1])/2);
}
}
function draw() {
ctx.clearRect(0,0,W,H);
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 0.5;
for (let x = 0; x < W; x += 40) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke(); }
for (let y = 0; y < H; y += 40) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
let debugInfo = '';
if (unfoldT < 0.5) {
const faceData = FACES.map((fv, fi) => {
const v3d = faceVerts(fi, unfoldT * 2);
const pv = v3d.map(([x,y,z]) => proj(x,y,z));
return { fi, pv, avgZ: (pv[0][2]+pv[1][2]+pv[2][2])/3 };
});
faceData.sort((a,b) => b.avgZ - a.avgZ);
for (const fd of faceData) drawFace(fd.pv, COLORS[fd.fi], false);
if (unfoldT < 0.3) {
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.lineWidth = 1;
const ap = V.map(([x,y,z]) => proj(x,y,z));
for (const [a,b] of [[0,1],[0,2],[0,3],[1,2],[1,3],[2,3]]) {
ctx.beginPath(); ctx.moveTo(ap[a][0],ap[a][1]); ctx.lineTo(ap[b][0],ap[b][1]); ctx.stroke();
}
}
} else {
const c = Math.cos(rot2d), s = Math.sin(rot2d);
const faceData = FACES.map((fv, fi) => {
const pv = hingeData[fi].unfoldedVerts.map(([x,y,z]) => {
return [cx + x*c - y*s, cy + x*s + y*c, 0];
});
return { fi, pv };
});
for (const fd of faceData) drawFace(fd.pv, COLORS[fd.fi], true);
// 几何验证
hingeData.forEach((hd, fi) => {
if (fi === 0) {
const fv = FACES[0];
const e = dist(V[fv[0]], V[fv[1]]).toFixed(0);
debugInfo += `${fi+1}: ${e} ${e} ${e}\n`;
} else {
debugInfo += `${fi+1}: ${hd.verify}\n`;
}
});
}
debugEl.textContent = debugInfo || '点击展开后显示各面3D边长';
}
function animate() {
if (Math.abs(unfoldT - unfoldTarget) > 0.001) {
unfoldT += (unfoldTarget - unfoldT) * 0.075;
if (Math.abs(unfoldT - unfoldTarget) < 0.001) unfoldT = unfoldTarget;
}
if (autoRotate) {
if (!isUnfolded && !dragging && unfoldT < 0.5) {
rY += 0.008;
rX = -0.5 + Math.sin(Date.now()/4000)*0.2;
}
if (isUnfolded && !dragging && unfoldT > 0.5) {
rot2d += 0.005;
}
}
draw();
requestAnimationFrame(animate);
}
animate();
canvas.addEventListener('mousedown', e => { dragging=true; lastMX=e.clientX; lastMY=e.clientY; });
document.addEventListener('mousemove', e => {
if (!dragging) return;
if (unfoldT > 0.5) {
rot2d += (e.clientX-lastMX)*0.008;
} else {
rY += (e.clientX-lastMX)*0.008;
rX -= (e.clientY-lastMY)*0.008;
rX = Math.max(-1.5,Math.min(1.5,rX));
}
lastMX=e.clientX; lastMY=e.clientY;
});
document.addEventListener('mouseup', () => { dragging=false; });
canvas.addEventListener('touchstart', e => {
e.preventDefault(); dragging=true;
lastMX=e.touches[0].clientX; lastMY=e.touches[0].clientY;
}, {passive:false});
canvas.addEventListener('touchmove', e => {
e.preventDefault(); if(!dragging) return;
if (unfoldT > 0.5) {
rot2d += (e.touches[0].clientX-lastMX)*0.008;
} else {
rY += (e.touches[0].clientX-lastMX)*0.008;
rX -= (e.touches[0].clientY-lastMY)*0.008;
rX = Math.max(-1.5,Math.min(1.5,rX));
}
lastMX=e.touches[0].clientX; lastMY=e.touches[0].clientY;
}, {passive:false});
canvas.addEventListener('touchend', () => { dragging=false; });
function toggleUnfold() {
isUnfolded = !isUnfolded;
const btn = document.getElementById('toggleBtn');
if (isUnfolded) {
btn.textContent = '收起'; btn.classList.add('btn-active');
unfoldTarget = 1;
} else {
btn.textContent = '展开'; btn.classList.remove('btn-active');
unfoldTarget = 0;
}
}
function toggleAutoRotate() {
autoRotate = !autoRotate;
const btn = document.getElementById('autoBtn');
btn.textContent = autoRotate ? '停止旋转' : '自动旋转';
btn.classList.toggle('btn-active', autoRotate);
}
</script>
</body>
</html>