Spaces:
Sleeping
Sleeping
| 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 ( | |
| <canvas | |
| ref={canvasRef} | |
| width={dim} | |
| height={dim} | |
| className="canvas-3d" | |
| aria-label="3D fold preview" | |
| style={{ cursor: 'grab' }} | |
| /> | |
| ); | |
| } | |