import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; const PAPER_RGB = [250, 250, 245]; const SENSITIVITY = 0.005; const PITCH_MIN = -Math.PI / 2 + 0.1; const PITCH_MAX = Math.PI / 2 - 0.1; const ZOOM_MIN = 0.3; const ZOOM_MAX = 5.0; const LIGHT_DIR = normalize3([0.4, -0.45, 1.0]); const MOUNTAIN_COLOR = 'rgba(245, 158, 11, 0.9)'; const VALLEY_COLOR = 'rgba(56, 189, 248, 0.9)'; 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})`; } const SIDE_EPS = 1e-10; const MAX_FOLD_RAD = Math.PI; 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 dotVal = px * kx + py * ky + pz * kz; const oneMinus = 1.0 - cosA; return [ axisPoint[0] + px * cosA + crossX * sinA + kx * dotVal * oneMinus, axisPoint[1] + py * cosA + crossY * sinA + ky * dotVal * oneMinus, axisPoint[2] + pz * cosA + crossZ * sinA + kz * dotVal * oneMinus, ]; } /** * Pre-compute which side of each fold line every vertex falls on, * using the ORIGINAL flat 2D positions. This avoids the bug where * post-fold 3D coordinates corrupt the side classification for * subsequent folds (the root cause of broken accordion rendering). * * Inspired by OrigamiSimulator's approach where crease assignments * are determined from the flat state, not the deformed state. */ function buildFoldMasks(meshVertices, folds, resolution) { const stride = resolution + 1; const pointToIndex = (pt) => { const ix = clamp(Math.round(pt[0] * resolution), 0, resolution); const iy = clamp(Math.round(pt[1] * resolution), 0, resolution); return iy * stride + ix; }; return folds.map((fold) => { const [x1, y1] = fold.from; const [x2, y2] = fold.to; const dx = x2 - x1; const dy = y2 - y1; const len = Math.hypot(dx, dy); const shouldRotate = new Uint8Array(meshVertices.length); for (let i = 0; i < meshVertices.length; i += 1) { const v = meshVertices[i]; const side = dx * (v[1] - y1) - dy * (v[0] - x1); shouldRotate[i] = side > SIDE_EPS ? 1 : 0; } return { shouldRotate, fromIdx: pointToIndex(fold.from), toIdx: pointToIndex(fold.to), sign: fold.assignment === 'V' ? 1 : -1, assignment: fold.assignment, len, }; }); } /** * Apply all folds sequentially using pre-computed masks and * tracking fold axes in current 3D space. Each fold's rotation * axis is found by looking up where the fold-line endpoints * have moved to (via previous folds), rather than using the * original 2D coordinates. */ function applyAllFolds(vertices, foldMasks, progresses) { for (let i = 0; i < foldMasks.length; i += 1) { const p = progresses[i]; if (p <= 0) continue; const mask = foldMasks[i]; if (mask.len < 1e-8) continue; const axisFrom = vertices[mask.fromIdx]; const axisTo = vertices[mask.toIdx]; const axisDir = normalize3(sub3(axisTo, axisFrom)); const angle = mask.sign * MAX_FOLD_RAD * p; for (let j = 0; j < vertices.length; j += 1) { if (!mask.shouldRotate[j]) continue; vertices[j] = rotateAroundAxis(vertices[j], axisFrom, axisDir, angle); } } } function projectVertex(vertex, dim, pitch, yaw, zoom) { let x = vertex[0] - 0.5; let y = vertex[1] - 0.5; let z = vertex[2] || 0; 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); const scale = 0.82 * zoom; return { x: dim * 0.5 + x2 * perspective * dim * scale, y: dim * 0.52 - y1 * perspective * dim * scale, z: z2, }; } function stepEasing(t) { return t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2; } export default function Fold3DCanvas({ steps, currentStep, dim = 280, }) { const canvasRef = useRef(null); const rafRef = useRef(null); const animatedStepRef = useRef(currentStep); const [pitch, setPitch] = useState(1.04); const [yaw, setYaw] = useState(-0.78); const [zoom, setZoom] = useState(1.0); const isDraggingRef = useRef(false); const lastMouseRef = useRef({ x: 0, y: 0 }); const pitchRef = useRef(1.04); const yawRef = useRef(-0.78); const zoomRef = useRef(1.0); pitchRef.current = pitch; yawRef.current = yaw; zoomRef.current = zoom; const folds = useMemo( () => (steps || []) .map((s) => s.fold) .filter(Boolean) .map((fold) => ({ from: [Number(fold.from[0]), Number(fold.from[1])], to: [Number(fold.to[0]), Number(fold.to[1])], assignment: fold.assignment === 'M' ? 'M' : 'V', })), [steps], ); const mesh = useMemo(() => buildGridMesh(18), []); const foldMasks = useMemo( () => buildFoldMasks(mesh.vertices, folds, mesh.resolution), [mesh, folds], ); 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 progresses = new Array(folds.length).fill(0); for (let i = 0; i < folds.length; i += 1) { if (stepValue >= i + 1) progresses[i] = 1; else if (stepValue > i) progresses[i] = clamp(stepValue - i, 0, 1); } applyAllFolds(vertices, foldMasks, progresses); const proj = (v) => projectVertex(v, dim, pitchRef.current, yawRef.current, zoomRef.current); const projected = vertices.map(proj); 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(); } for (let i = 0; i < foldMasks.length; i += 1) { if (progresses[i] <= 0.02) continue; const mask = foldMasks[i]; const pa = projected[mask.fromIdx]; const pb = projected[mask.toIdx]; ctx.beginPath(); ctx.moveTo(pa.x, pa.y); ctx.lineTo(pb.x, pb.y); ctx.strokeStyle = mask.assignment === 'M' ? MOUNTAIN_COLOR : VALLEY_COLOR; ctx.globalAlpha = clamp(0.35 + 0.65 * progresses[i], 0, 1); ctx.lineWidth = 2.15; ctx.stroke(); ctx.globalAlpha = 1; } }, [dim, folds, mesh, foldMasks]); useEffect(() => { draw(animatedStepRef.current); }, [draw, pitch, yaw, zoom]); 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]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const handleWheel = (e) => { e.preventDefault(); setZoom((z) => clamp(z * (e.deltaY > 0 ? 0.9 : 1.1), ZOOM_MIN, ZOOM_MAX)); }; const handleMouseDown = (e) => { isDraggingRef.current = true; lastMouseRef.current = { x: e.clientX, y: e.clientY }; canvas.style.cursor = 'grabbing'; }; const handleMouseMove = (e) => { if (!isDraggingRef.current) return; const dx = e.clientX - lastMouseRef.current.x; const dy = e.clientY - lastMouseRef.current.y; lastMouseRef.current = { x: e.clientX, y: e.clientY }; setYaw((y) => y + dx * SENSITIVITY); setPitch((p) => clamp(p - dy * SENSITIVITY, PITCH_MIN, PITCH_MAX)); }; const handleMouseUp = () => { isDraggingRef.current = false; canvas.style.cursor = 'grab'; }; canvas.addEventListener('wheel', handleWheel, { passive: false }); canvas.addEventListener('mousedown', handleMouseDown); window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { canvas.removeEventListener('wheel', handleWheel); canvas.removeEventListener('mousedown', handleMouseDown); window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); canvas.style.cursor = ''; }; }, []); return ( ); }