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:
		
							parent
							
								
									8df4f7578f
								
							
						
					
					
						commit
						bf44ea8ae7
					
				
					 5 changed files with 279 additions and 174 deletions
				
			
		| 
						 | 
					@ -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
									
								
							
							
						
						
									
										97
									
								
								src/pendulum/drawing.ts
									
										
									
									
									
										Normal 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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -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
									
								
							
							
						
						
									
										68
									
								
								src/pendulum/physics.ts
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										29
									
								
								src/pendulum/worlds.ts
									
										
									
									
									
										Normal 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 };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue