diff --git a/src/App.tsx b/src/App.tsx index f7743f4..e617ce7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,9 @@ +import { lazy, Suspense } from 'react' import './App.css' -import Pendulum from './pendulum/mod' import Tabs, { Tab, TabPanel } from './components/Tabs' +const Pendulum = lazy(() => import('./pendulum/mod')); + function App() { return (
@@ -17,9 +19,11 @@ function App() { -
- -
+ Loading Pendulum...}> +
+ +
+
diff --git a/src/pendulum/drawing.ts b/src/pendulum/drawing.ts new file mode 100644 index 0000000..01407da --- /dev/null +++ b/src/pendulum/drawing.ts @@ -0,0 +1,97 @@ +// Helper to convert color names to RGB +function colorNameToRgb(name: string): [number, number, number] | null { + const colors: Record = { + 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); + } +} diff --git a/src/pendulum/mod.tsx b/src/pendulum/mod.tsx index 50908ce..af17265 100644 --- a/src/pendulum/mod.tsx +++ b/src/pendulum/mod.tsx @@ -1,161 +1,7 @@ import { useEffect, useRef, useState } from "react"; - -// x, y 2D point -type Point = [number, number]; - -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 }; -} +import { updateWorld } from "./physics"; +import { drawWorld } from "./drawing"; +import { getDefaultMyWorld, type WorldState } from "./worlds"; export default function Pendulum() { const canvasRef = useRef(null); @@ -167,51 +13,111 @@ export default function Pendulum() { const rafRef = useRef(null); const prevTimeRef = useRef(0); + // Drag state + const draggingRef = useRef<{ ballIdx: number | null, offset: [number, number] | null }>({ ballIdx: null, offset: null }); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext("2d"); if (!ctx) return; - const dpr = window.devicePixelRatio || 1; const resize = () => { const w = canvas.clientWidth || 600; const h = canvas.clientHeight || 400; canvas.width = Math.max(1, Math.round(w * 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); }; resize(); window.addEventListener("resize", resize); - if (!worldRef.current) { - // create a simple pendulum world worldRef.current = getDefaultMyWorld(); } - prevTimeRef.current = performance.now(); - const animate = (time: number) => { const deltaTime = time - prevTimeRef.current; prevTimeRef.current = time; - - const dt = Math.min(0.1, deltaTime / 1000) / subStep; // cap deltaTime to avoid large jumps + const dt = Math.min(0.1, deltaTime / 1000) / subStep; 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); - drawWorld(ctx, worldRef.current!); - + if (worldRef.current) { + drawWorld(ctx, worldRef.current, worldRef.current.ballDrawers); + } 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 () => { window.removeEventListener("resize", resize); if (rafRef.current) cancelAnimationFrame(rafRef.current); + canvas.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); }; }, [constraintIterations, subStep]); @@ -251,6 +157,7 @@ export default function Pendulum() {
diff --git a/src/pendulum/physics.ts b/src/pendulum/physics.ts new file mode 100644 index 0000000..0bba1fa --- /dev/null +++ b/src/pendulum/physics.ts @@ -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); + } + } +} diff --git a/src/pendulum/worlds.ts b/src/pendulum/worlds.ts new file mode 100644 index 0000000..149190f --- /dev/null +++ b/src/pendulum/worlds.ts @@ -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 }; +}