refactor: pendulum simulation

Refactor pendulum simulation: implement lazy loading for Pendulum component, add drawing utilities, and enhance world generation logic
This commit is contained in:
monoid 2025-08-24 21:22:24 +09:00
parent 8df4f7578f
commit bf44ea8ae7
5 changed files with 279 additions and 174 deletions

View file

@ -1,7 +1,9 @@
import { lazy, Suspense } from 'react'
import './App.css' import './App.css'
import Pendulum from './pendulum/mod'
import Tabs, { Tab, TabPanel } from './components/Tabs' import Tabs, { Tab, TabPanel } from './components/Tabs'
const Pendulum = lazy(() => import('./pendulum/mod'));
function App() { function App() {
return ( return (
<main className='container mx-auto p-4'> <main className='container mx-auto p-4'>
@ -17,9 +19,11 @@ function App() {
</div> </div>
<TabPanel id="pendulum"> <TabPanel id="pendulum">
<Suspense fallback={<div className='h-96'>Loading Pendulum...</div>}>
<div className="h-96"> <div className="h-96">
<Pendulum /> <Pendulum />
</div> </div>
</Suspense>
</TabPanel> </TabPanel>
<TabPanel id="placeholder"> <TabPanel id="placeholder">

97
src/pendulum/drawing.ts Normal file
View file

@ -0,0 +1,97 @@
// Helper to convert color names to RGB
function colorNameToRgb(name: string): [number, number, number] | null {
const colors: Record<string, [number, number, number]> = {
blue: [0, 0, 255],
red: [255, 0, 0],
green: [0, 128, 0],
yellow: [255, 255, 0],
black: [0, 0, 0],
white: [255, 255, 255],
// Add more as needed
};
return colors[name.toLowerCase()] || null;
}
// Helper to convert hex color to RGB
function hexToRgb(hex: string): [number, number, number] | null {
let c = hex.replace('#', '');
if (c.length === 3) {
c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
}
if (c.length !== 6) return null;
const num = parseInt(c, 16);
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
}
// Canvas 렌더링 관련 클래스와 함수 정의
import type { BallState, PhysicalWorldState } from "./physics";
export class BallDrawer {
radius: number;
previousPositions: [number, number][] = [];
maxPositions: number;
color: string = "blue";
trailColor: string = "blue";
constructor({ radius = 10, maxPositions = 60, color = "blue", trailColor }: { radius?: number; maxPositions?: number; color?: string; trailColor?: string } = {}) {
this.maxPositions = maxPositions;
this.radius = radius;
this.color = color;
this.trailColor = trailColor ?? color;
}
draw(ctx: CanvasRenderingContext2D, ball: BallState) {
this.previousPositions.push([ball.pos[0], ball.pos[1]]);
if (this.previousPositions.length > this.maxPositions) {
this.previousPositions.shift();
}
ctx.save();
if (this.previousPositions.length > 1) {
ctx.lineWidth = 2;
for (let i = 1; i < this.previousPositions.length; i++) {
const alpha = (i / this.previousPositions.length);
// Use trailColor for consistency
const rgb = this.trailColor.startsWith('#') ? hexToRgb(this.trailColor) : colorNameToRgb(this.trailColor);
if (rgb) {
ctx.strokeStyle = `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha * 0.5})`;
} else {
ctx.strokeStyle = `rgba(0,0,255,${alpha * 0.5})`;
}
ctx.beginPath();
ctx.moveTo(this.previousPositions[i - 1][0], this.previousPositions[i - 1][1]);
ctx.lineTo(this.previousPositions[i][0], this.previousPositions[i][1]);
ctx.stroke();
}
}
ctx.beginPath();
ctx.arc(ball.pos[0], ball.pos[1], this.radius, 0, Math.PI * 2);
ctx.fillStyle = this.color;
ctx.fill();
ctx.restore();
}
}
export function drawConstraint(ctx: CanvasRenderingContext2D, ball1: BallState, ball2: BallState) {
ctx.beginPath();
ctx.moveTo(ball1.pos[0], ball1.pos[1]);
ctx.lineTo(ball2.pos[0], ball2.pos[1]);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
}
export function drawWorld(ctx: CanvasRenderingContext2D,
world: PhysicalWorldState,
ballDrawers: BallDrawer[]
) {
for (const constraint of world.constraints) {
const b1 = world.balls[constraint.p1Idx];
const b2 = world.balls[constraint.p2Idx];
drawConstraint(ctx, b1, b2);
}
for (let i = 0; i < world.balls.length; i++) {
const ball = world.balls[i];
if (!ball) continue;
const drawer = ballDrawers[i];
if (!drawer) continue;
drawer.draw(ctx, ball);
}
}

View file

@ -1,161 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { updateWorld } from "./physics";
// x, y 2D point import { drawWorld } from "./drawing";
type Point = [number, number]; import { getDefaultMyWorld, type WorldState } from "./worlds";
type BallState = {
pos: Point;
prevPos: Point; // 이전 위치
mass: number;
prevDt: number; // 이전 프레임의 dt
isFixed?: boolean;
}
type ConstraintState = {
p1Idx: number; // index of first ball
p2Idx: number; // index of second ball
restLength: number; // rest length of the constraint
stiffness: number; // stiffness of the constraint (0 to 1)
}
type WorldState = {
balls: BallState[];
constraints: ConstraintState[];
ballDrawers: BallDrawer[];
}
const SCENE_GRAVITY: Point = [0, 9.81 * 100]; // gravitational acceleration
function updateUnconstrainedBall(ball: BallState, dt: number = 0.016) {
if (ball.isFixed) return;
// Verlet integration with variable dt
const acc: Point = [SCENE_GRAVITY[0], SCENE_GRAVITY[1]];
const nextPos: Point = [
ball.pos[0] + (ball.pos[0] - ball.prevPos[0]) * (dt / ball.prevDt) + acc[0]
* (dt + ball.prevDt) / 2
* dt,
ball.pos[1] + (ball.pos[1] - ball.prevPos[1]) * (dt / ball.prevDt) + acc[1]
* (dt + ball.prevDt) / 2
* dt,
];
ball.prevPos = [...ball.pos];
ball.pos = nextPos;
ball.prevDt = dt;
}
class BallDrawer {
radius: number;
previousPositions: Point[] = []; // 이동 경로 저장
maxPositions: number;
constructor({ radius = 10, maxPositions = 60 }: { radius?: number; maxPositions?: number } = {}) {
this.maxPositions = maxPositions;
this.radius = radius;
}
draw(ctx: CanvasRenderingContext2D, ball: BallState) {
// 이동 경로 저장
this.previousPositions.push([ball.pos[0], ball.pos[1]]);
if (this.previousPositions.length > this.maxPositions) { // 최대 maxPositions개만 저장
this.previousPositions.shift();
}
// 이동 경로 그리기 (알파값 점점 낮추기)
ctx.save();
if (this.previousPositions.length > 1) {
ctx.lineWidth = 2;
for (let i = 1; i < this.previousPositions.length; i++) {
const alpha = (i / this.previousPositions.length); // 오래된 점일수록 더 투명
ctx.strokeStyle = `rgba(0,0,255,${alpha * 0.5})`;
ctx.beginPath();
ctx.moveTo(this.previousPositions[i - 1][0], this.previousPositions[i - 1][1]);
ctx.lineTo(this.previousPositions[i][0], this.previousPositions[i][1]);
ctx.stroke();
}
}
ctx.beginPath();
ctx.arc(ball.pos[0], ball.pos[1], this.radius, 0, Math.PI * 2);
ctx.fillStyle = "blue";
ctx.fill();
ctx.restore();
}
}
function satisfyConstraint(ball1: BallState, ball2: BallState, constraint: ConstraintState) {
const dx = ball2.pos[0] - ball1.pos[0];
const dy = ball2.pos[1] - ball1.pos[1];
const length = Math.sqrt(dx * dx + dy * dy);
if (length == 0) return; // avoid division by zero
const diff = length - constraint.restLength;
const invMass1 = 1 / ball1.mass;
const invMass2 = 1 / ball2.mass;
const sumInvMass = invMass1 + invMass2;
if (sumInvMass == 0) return; // both infinite mass. we can't move them
const p1Prop = invMass1 / sumInvMass;
const p2Prop = invMass2 / sumInvMass;
// positional correction
const correction = diff * constraint.stiffness / length;
ball1.pos[0] += dx * correction * p1Prop;
ball1.pos[1] += dy * correction * p1Prop;
ball2.pos[0] -= dx * correction * p2Prop;
ball2.pos[1] -= dy * correction * p2Prop;
}
function drawConstraint(ctx: CanvasRenderingContext2D, ball1: BallState, ball2: BallState) {
ctx.beginPath();
ctx.moveTo(ball1.pos[0], ball1.pos[1]);
ctx.lineTo(ball2.pos[0], ball2.pos[1]);
ctx.strokeStyle = "black";
ctx.lineWidth = 2;
ctx.stroke();
}
function updateWorld(world: WorldState, dt: number = 0.016, constraintIterations: number = 50) {
// unconstrained motion
for (const ball of world.balls) {
updateUnconstrainedBall(ball, dt);
}
// constraints
for (let i = 0; i < constraintIterations; i++) {
for (const constraint of world.constraints) {
const b1 = world.balls[constraint.p1Idx];
const b2 = world.balls[constraint.p2Idx];
satisfyConstraint(b1, b2, constraint);
}
}
}
function drawWorld(ctx: CanvasRenderingContext2D, world: WorldState,) {
// draw constraints
for (const constraint of world.constraints) {
const b1 = world.balls[constraint.p1Idx];
const b2 = world.balls[constraint.p2Idx];
drawConstraint(ctx, b1, b2);
}
// draw balls
for (let i = 0; i < world.balls.length; i++) {
const ball = world.balls[i];
const drawer = world.ballDrawers[i];
drawer.draw(ctx, ball);
}
}
function getDefaultMyWorld() {
const balls: BallState[] = [
{ pos: [200, 50], prevPos: [200, 50], mass: Infinity, prevDt: 0.016, isFixed: true },
{ pos: [300, 50], prevPos: [300, 50], mass: 1, prevDt: 0.016 },
{ pos: [300, 0], prevPos: [300, 0], mass: 1, prevDt: 0.016 },
];
const constraints: ConstraintState[] = [
{ p1Idx: 0, p2Idx: 1, restLength: 100, stiffness: 1 },
{ p1Idx: 1, p2Idx: 2, restLength: 50, stiffness: 1},
];
const ballDrawers = [
new BallDrawer({ radius: 10 }),
new BallDrawer({ radius: 10 }),
new BallDrawer({ radius: 10 }),
];
return { balls, constraints, ballDrawers };
}
export default function Pendulum() { export default function Pendulum() {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@ -167,51 +13,111 @@ export default function Pendulum() {
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
const prevTimeRef = useRef<number>(0); const prevTimeRef = useRef<number>(0);
// Drag state
const draggingRef = useRef<{ ballIdx: number | null, offset: [number, number] | null }>({ ballIdx: null, offset: null });
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const resize = () => { const resize = () => {
const w = canvas.clientWidth || 600; const w = canvas.clientWidth || 600;
const h = canvas.clientHeight || 400; const h = canvas.clientHeight || 400;
canvas.width = Math.max(1, Math.round(w * dpr)); canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr)); canvas.height = Math.max(1, Math.round(h * dpr));
// scale drawing so 1 unit == 1 CSS pixel
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}; };
resize(); resize();
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
if (!worldRef.current) { if (!worldRef.current) {
// create a simple pendulum world
worldRef.current = getDefaultMyWorld(); worldRef.current = getDefaultMyWorld();
} }
prevTimeRef.current = performance.now(); prevTimeRef.current = performance.now();
const animate = (time: number) => { const animate = (time: number) => {
const deltaTime = time - prevTimeRef.current; const deltaTime = time - prevTimeRef.current;
prevTimeRef.current = time; prevTimeRef.current = time;
const dt = Math.min(0.1, deltaTime / 1000) / subStep;
const dt = Math.min(0.1, deltaTime / 1000) / subStep; // cap deltaTime to avoid large jumps
for (let i = 0; i < subStep; i++) { for (let i = 0; i < subStep; i++) {
updateWorld(worldRef.current!, dt, constraintIterations); if (worldRef.current) {
updateWorld(worldRef.current, dt, constraintIterations);
}
} }
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawWorld(ctx, worldRef.current!); if (worldRef.current) {
drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers);
}
rafRef.current = requestAnimationFrame(animate); rafRef.current = requestAnimationFrame(animate);
}; };
rafRef.current = requestAnimationFrame(animate); rafRef.current = requestAnimationFrame(animate);
// --- Drag interaction handlers ---
function getPointerPos(evt: PointerEvent) {
if (!canvas) return [0, 0];
const rect = canvas.getBoundingClientRect();
return [evt.clientX - rect.left, evt.clientY - rect.top] as [number, number];
}
function onPointerDown(e: PointerEvent) {
if (!worldRef.current) return;
const pos = getPointerPos(e);
let minDist = Infinity;
let closestIdx: number | null = null;
worldRef.current.balls.forEach((ball, idx) => {
const dx = ball.pos[0] - pos[0];
const dy = ball.pos[1] - pos[1];
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 25 && dist < minDist && !ball.isFixed) { // Only allow dragging non-fixed balls
minDist = dist;
closestIdx = idx;
}
});
if (closestIdx !== null) {
draggingRef.current.ballIdx = closestIdx;
draggingRef.current.offset = [
worldRef.current.balls[closestIdx].pos[0] - pos[0],
worldRef.current.balls[closestIdx].pos[1] - pos[1],
];
// Pause animation while dragging
if (rafRef.current) cancelAnimationFrame(rafRef.current);
}
}
function onPointerMove(e: PointerEvent) {
if (!worldRef.current) return;
if (draggingRef.current.ballIdx === null) return;
const pos = getPointerPos(e);
const idx = draggingRef.current.ballIdx;
const offset = draggingRef.current.offset || [0, 0];
// Update ball position
worldRef.current.balls[idx].pos = [pos[0] + offset[0], pos[1] + offset[1]];
// Optionally update prevPos for stability
worldRef.current.balls[idx].prevPos = [pos[0] + offset[0], pos[1] + offset[1]];
// Redraw
if (!ctx) return;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers);
}
function onPointerUp() {
if (draggingRef.current.ballIdx !== null) {
draggingRef.current.ballIdx = null;
draggingRef.current.offset = null;
// Resume animation
prevTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(animate);
}
}
canvas.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => { return () => {
window.removeEventListener("resize", resize); window.removeEventListener("resize", resize);
if (rafRef.current) cancelAnimationFrame(rafRef.current); if (rafRef.current) cancelAnimationFrame(rafRef.current);
canvas.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
}; };
}, [constraintIterations, subStep]); }, [constraintIterations, subStep]);
@ -251,6 +157,7 @@ export default function Pendulum() {
</div> </div>
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<canvas <canvas
style={{ touchAction: "none" }}
ref={canvasRef} ref={canvasRef}
className="w-full h-full" className="w-full h-full"
/> />

68
src/pendulum/physics.ts Normal file
View file

@ -0,0 +1,68 @@
// 물리 관련 타입과 함수 정의
export type Point = [number, number];
export type BallState = {
pos: Point;
prevPos: Point;
mass: number;
prevDt: number;
isFixed?: boolean;
};
export type ConstraintState = {
p1Idx: number;
p2Idx: number;
restLength: number;
stiffness: number;
};
export type PhysicalWorldState = {
balls: BallState[];
constraints: ConstraintState[];
};
export const SCENE_GRAVITY: Point = [0, 9.81 * 100];
export function updateUnconstrainedBall(ball: BallState, dt: number = 0.016) {
if (ball.isFixed) return;
const acc: Point = [SCENE_GRAVITY[0], SCENE_GRAVITY[1]];
const nextPos: Point = [
ball.pos[0] + (ball.pos[0] - ball.prevPos[0]) * (dt / ball.prevDt) + acc[0] * (dt + ball.prevDt) / 2 * dt,
ball.pos[1] + (ball.pos[1] - ball.prevPos[1]) * (dt / ball.prevDt) + acc[1] * (dt + ball.prevDt) / 2 * dt,
];
ball.prevPos = [...ball.pos];
ball.pos = nextPos;
ball.prevDt = dt;
}
export function satisfyConstraint(ball1: BallState, ball2: BallState, constraint: ConstraintState) {
const dx = ball2.pos[0] - ball1.pos[0];
const dy = ball2.pos[1] - ball1.pos[1];
const length = Math.sqrt(dx * dx + dy * dy);
if (length == 0) return;
const diff = length - constraint.restLength;
const invMass1 = 1 / ball1.mass;
const invMass2 = 1 / ball2.mass;
const sumInvMass = invMass1 + invMass2;
if (sumInvMass == 0) return;
const p1Prop = invMass1 / sumInvMass;
const p2Prop = invMass2 / sumInvMass;
const correction = diff * constraint.stiffness / length;
ball1.pos[0] += dx * correction * p1Prop;
ball1.pos[1] += dy * correction * p1Prop;
ball2.pos[0] -= dx * correction * p2Prop;
ball2.pos[1] -= dy * correction * p2Prop;
}
export function updateWorld(world: PhysicalWorldState, dt: number = 0.016, constraintIterations: number = 50) {
for (const ball of world.balls) {
updateUnconstrainedBall(ball, dt);
}
for (let i = 0; i < constraintIterations; i++) {
for (const constraint of world.constraints) {
const b1 = world.balls[constraint.p1Idx];
const b2 = world.balls[constraint.p2Idx];
satisfyConstraint(b1, b2, constraint);
}
}
}

29
src/pendulum/worlds.ts Normal file
View file

@ -0,0 +1,29 @@
// 시뮬레이션 월드 생성 함수 정의
import type { BallState, ConstraintState, PhysicalWorldState } from "./physics";
import { BallDrawer } from "./drawing";
export type WorldState = PhysicalWorldState & { ballDrawers: BallDrawer[] };
export function getDefaultMyWorld(): WorldState {
const balls: BallState[] = [
{ pos: [200, 50], prevPos: [200, 50], mass: Infinity, prevDt: 0.016, isFixed: true },
{ pos: [300, 50], prevPos: [300, 50], mass: 1, prevDt: 0.016 },
{ pos: [300, 0], prevPos: [300, 0], mass: 1, prevDt: 0.016 },
{ pos: [400, 50], prevPos: [400, 50], mass: 1, prevDt: 0.016 },
{ pos: [400, 0], prevPos: [400, 0], mass: 1, prevDt: 0.016 },
];
const constraints: ConstraintState[] = [
{ p1Idx: 0, p2Idx: 1, restLength: 100, stiffness: 1 },
{ p1Idx: 1, p2Idx: 2, restLength: 50, stiffness: 1 },
{ p1Idx: 0, p2Idx: 3, restLength: 200, stiffness: 1 },
{ p1Idx: 3, p2Idx: 4, restLength: 50, stiffness: 1 },
];
const ballDrawers = [
new BallDrawer({ radius: 10 }),
new BallDrawer({ radius: 10 }),
new BallDrawer({ radius: 10 }),
new BallDrawer({ radius: 10, color: "red", trailColor: "red" }),
new BallDrawer({ radius: 10, color: "red", trailColor: "red" }),
];
return { balls, constraints, ballDrawers };
}