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