optigami / src /components /Fold3DCanvas.js
sissississi's picture
go-back (#6)
e9b7141
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' }}
/>
);
}