import { useCallback, useEffect, useMemo, useRef } from 'react'; const PAPER_RGB = [250, 250, 245]; const LIGHT_DIR = normalize3([0.4, -0.45, 1.0]); const MAX_FOLD_RAD = Math.PI * 0.92; const SIDE_EPS = 1e-7; const MOUNTAIN_COLOR = 'rgba(245, 158, 11, 0.95)'; const VALLEY_COLOR = 'rgba(56, 189, 248, 0.95)'; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function normalize3(v) { const mag = Math.hypot(v[0], v[1], v[2]); if (mag < 1e-12) return [0, 0, 0]; return [v[0] / mag, v[1] / mag, v[2] / mag]; } function cross3(a, b) { return [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ]; } function sub3(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } function shadePaper(intensity) { const lit = clamp(0.3 + 0.7 * Math.abs(intensity), 0.0, 1.0); const r = Math.round(PAPER_RGB[0] * lit); const g = Math.round(PAPER_RGB[1] * lit); const b = Math.round(PAPER_RGB[2] * lit); return `rgb(${r}, ${g}, ${b})`; } function buildGridMesh(resolution = 18) { const vertices = []; for (let y = 0; y <= resolution; y += 1) { for (let x = 0; x <= resolution; x += 1) { vertices.push([x / resolution, y / resolution, 0]); } } const triangles = []; const stride = resolution + 1; for (let y = 0; y < resolution; y += 1) { for (let x = 0; x < resolution; x += 1) { const a = y * stride + x; const b = a + 1; const c = a + stride; const d = c + 1; triangles.push([a, b, d]); triangles.push([a, d, c]); } } return { vertices, triangles, resolution }; } function rotateAroundAxis(point, axisPoint, axisDir, angleRad) { const px = point[0] - axisPoint[0]; const py = point[1] - axisPoint[1]; const pz = point[2] - axisPoint[2]; const kx = axisDir[0]; const ky = axisDir[1]; const kz = axisDir[2]; const cosA = Math.cos(angleRad); const sinA = Math.sin(angleRad); const crossX = ky * pz - kz * py; const crossY = kz * px - kx * pz; const crossZ = kx * py - ky * px; const dot = px * kx + py * ky + pz * kz; const oneMinus = 1.0 - cosA; return [ axisPoint[0] + px * cosA + crossX * sinA + kx * dot * oneMinus, axisPoint[1] + py * cosA + crossY * sinA + ky * dot * oneMinus, axisPoint[2] + pz * cosA + crossZ * sinA + kz * dot * oneMinus, ]; } function applyFoldToVertices(vertices, fold, progress) { if (!fold || progress <= 0) return; const [x1, y1] = fold.from; const [x2, y2] = fold.to; const dx = x2 - x1; const dy = y2 - y1; const len = Math.hypot(dx, dy); if (len < 1e-8) return; const sideValues = []; let posCount = 0; let negCount = 0; for (let i = 0; i < vertices.length; i += 1) { const v = vertices[i]; const side = dx * (v[1] - y1) - dy * (v[0] - x1); sideValues.push(side); if (side > SIDE_EPS) posCount += 1; else if (side < -SIDE_EPS) negCount += 1; } let rotatePositive = posCount <= negCount; if (posCount === 0 && negCount > 0) rotatePositive = false; if (negCount === 0 && posCount > 0) rotatePositive = true; if (posCount === 0 && negCount === 0) return; const sign = fold.assignment === 'V' ? 1 : -1; const angle = sign * MAX_FOLD_RAD * progress; const axisPoint = [x1, y1, 0]; const axisDir = [dx / len, dy / len, 0]; for (let i = 0; i < vertices.length; i += 1) { const side = sideValues[i]; const shouldRotate = rotatePositive ? side > SIDE_EPS : side < -SIDE_EPS; if (!shouldRotate) continue; vertices[i] = rotateAroundAxis(vertices[i], axisPoint, axisDir, angle); } } function projectVertex(vertex, dim) { let x = vertex[0] - 0.5; let y = vertex[1] - 0.5; let z = vertex[2]; const pitch = 1.04; const yaw = -0.78; const cp = Math.cos(pitch); const sp = Math.sin(pitch); const y1 = y * cp - z * sp; const z1 = y * sp + z * cp; const cy = Math.cos(yaw); const sy = Math.sin(yaw); const x2 = x * cy + z1 * sy; const z2 = -x * sy + z1 * cy; const camDist = 2.8; const perspective = camDist / (camDist - z2); return { x: dim * 0.5 + x2 * perspective * dim * 0.82, y: dim * 0.52 - y1 * perspective * dim * 0.82, z: z2, }; } function foldProgresses(stepValue, foldCount, mode, totalSteps) { const values = new Array(foldCount).fill(0); if (foldCount === 0) return values; if (mode === 'final') { const startCollapse = Math.max(totalSteps - 1, 0); const collapse = clamp(stepValue - startCollapse, 0, 1); for (let i = 0; i < foldCount; i += 1) values[i] = collapse; return values; } for (let i = 0; i < foldCount; i += 1) { if (stepValue >= i + 1) values[i] = 1; else if (stepValue > i) values[i] = clamp(stepValue - i, 0, 1); } return values; } function stepEasing(t) { return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2; } export default function Fold3DCanvas({ steps, currentStep, totalSteps, mode = 'progressive', dim = 280, }) { const canvasRef = useRef(null); const rafRef = useRef(null); const animatedStepRef = useRef(currentStep); const folds = useMemo( () => (steps || []) .map((s) => s.fold) .filter(Boolean) .map((fold) => ({ from: [Number(fold.from_point[0]), Number(fold.from_point[1])], to: [Number(fold.to_point[0]), Number(fold.to_point[1])], assignment: fold.assignment === 'M' ? 'M' : 'V', })), [steps], ); const mesh = useMemo(() => buildGridMesh(18), []); const draw = useCallback((stepValue) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, dim, dim); ctx.fillStyle = '#121220'; ctx.fillRect(0, 0, dim, dim); const vertices = mesh.vertices.map((v) => [v[0], v[1], v[2]]); const progress = foldProgresses(stepValue, folds.length, mode, totalSteps); for (let i = 0; i < folds.length; i += 1) { if (progress[i] <= 0) continue; applyFoldToVertices(vertices, folds[i], progress[i]); } const projected = vertices.map((v) => projectVertex(v, dim)); const tris = mesh.triangles.map((tri) => { const p0 = projected[tri[0]]; const p1 = projected[tri[1]]; const p2 = projected[tri[2]]; const avgZ = (p0.z + p1.z + p2.z) / 3; const v0 = vertices[tri[0]]; const v1 = vertices[tri[1]]; const v2 = vertices[tri[2]]; const normal = normalize3(cross3(sub3(v1, v0), sub3(v2, v0))); const intensity = dot3(normal, LIGHT_DIR); return { tri, avgZ, shade: shadePaper(intensity), }; }); tris.sort((a, b) => a.avgZ - b.avgZ); for (const triInfo of tris) { const [a, b, c] = triInfo.tri; const p0 = projected[a]; const p1 = projected[b]; const p2 = projected[c]; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.closePath(); ctx.fillStyle = triInfo.shade; ctx.fill(); ctx.strokeStyle = 'rgba(42, 42, 58, 0.22)'; ctx.lineWidth = 0.55; ctx.stroke(); } const res = mesh.resolution; const stride = res + 1; const pointToIndex = (pt) => { const ix = clamp(Math.round(pt[0] * res), 0, res); const iy = clamp(Math.round(pt[1] * res), 0, res); return iy * stride + ix; }; for (let i = 0; i < folds.length; i += 1) { if (progress[i] <= 0.02) continue; const fold = folds[i]; const aIdx = pointToIndex(fold.from); const bIdx = pointToIndex(fold.to); const pa = projected[aIdx]; const pb = projected[bIdx]; ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.strokeStyle = fold.assignment === 'M' ? MOUNTAIN_COLOR : VALLEY_COLOR; ctx.globalAlpha = clamp(0.35 + 0.65 * progress[i], 0, 1); ctx.lineWidth = 2.15; ctx.stroke(); ctx.globalAlpha = 1; } }, [dim, folds, mesh, mode, totalSteps]); useEffect(() => { draw(animatedStepRef.current); }, [draw]); useEffect(() => { cancelAnimationFrame(rafRef.current); const startValue = animatedStepRef.current; const endValue = currentStep; const durationMs = 420; const startAt = performance.now(); const tick = (now) => { const t = clamp((now - startAt) / durationMs, 0, 1); const eased = stepEasing(t); const value = startValue + (endValue - startValue) * eased; animatedStepRef.current = value; draw(value); if (t < 1) rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); }, [currentStep, draw]); return ( ); }