Last active
April 18, 2022 08:13
-
-
Save EladBezalel/d6ae9b2b72d4b02f1f4fecca38ddbc34 to your computer and use it in GitHub Desktop.
A double pendulum react hook
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Renderer, renderHook, RenderHookResult } from '@testing-library/react-hooks'; | |
import { act } from 'react-dom/test-utils'; | |
import { | |
calcDoublePendulum, | |
calcRadAngleFromLine, | |
Environment, | |
NonNullRef, | |
PendulumItem, | |
useDoublePendulum, | |
UseDoublePendulum, | |
} from './useDoublePendulum'; | |
function render( | |
...props: Parameters<UseDoublePendulum> | |
): RenderHookResult< | |
Parameters<UseDoublePendulum>, | |
ReturnType<UseDoublePendulum>, | |
Renderer<Parameters<UseDoublePendulum>> | |
> { | |
return renderHook<Parameters<UseDoublePendulum>, ReturnType<UseDoublePendulum>>( | |
(renderProps: Parameters<UseDoublePendulum>) => useDoublePendulum(...renderProps), | |
{ | |
initialProps: props, | |
}, | |
); | |
} | |
describe('useDoublePendulum', () => { | |
jest.useFakeTimers('legacy'); | |
const env: NonNullRef<Environment> = { current: {} } as unknown as NonNullRef<Environment>; | |
const item1: NonNullRef<PendulumItem> = { current: {} } as unknown as NonNullRef<PendulumItem>; | |
const item2: NonNullRef<PendulumItem> = { current: {} } as unknown as NonNullRef<PendulumItem>; | |
beforeEach(() => { | |
env.current = { | |
position: { | |
x: 0, | |
y: 0, | |
}, | |
gravity: 9.81, | |
time: 0.05, | |
momentum: 1, | |
damping: 0.000_005, | |
}; | |
item1.current = { | |
v0: env.current.position, | |
v1: { | |
x: 0, | |
y: 0, | |
}, | |
mass: 15, | |
height: 150, | |
dRad: 0, | |
rad: -Math.PI / 12, | |
}; | |
item2.current = { | |
v0: { | |
x: 0, | |
y: 0, | |
}, | |
v1: { | |
x: 0, | |
y: 0, | |
}, | |
mass: 30, | |
height: 150, | |
dRad: 0, | |
rad: -Math.PI / 6, | |
}; | |
}); | |
describe('initial render', () => { | |
it('should return an array with 2 transformations', () => { | |
act(() => { | |
const { result } = render(item1, item2, env); | |
jest.advanceTimersByTime(10); | |
expect(result.current).toHaveLength(2); | |
// eslint-disable-next-line jest/prefer-called-with | |
expect(setInterval).toHaveBeenCalled(); | |
}); | |
}); | |
}); | |
describe('damping', () => { | |
it('should damp the pendulum to halt', () => { | |
env.current.damping = 1; | |
const { result } = render(item1, item2, env); | |
setTimeout(() => { | |
expect(result.current[0].rotation).toBe(0); | |
expect(result.current[1].rotation).toBe(0); | |
}, 0); | |
}); | |
}); | |
}); | |
describe('calcDoublePendulum', () => { | |
let env: Environment = {} as unknown as Environment; | |
let item1: PendulumItem = {} as unknown as PendulumItem; | |
let item2: PendulumItem = {} as unknown as PendulumItem; | |
beforeEach(() => { | |
env = { | |
position: { | |
x: 0, | |
y: 0, | |
}, | |
gravity: 9.81, | |
time: 0.05, | |
momentum: 1, | |
damping: 0.000_005, | |
}; | |
item1 = { | |
v0: env.position, | |
v1: { | |
x: 0, | |
y: 0, | |
}, | |
mass: 15, | |
height: 150, | |
dRad: 0, | |
rad: -Math.PI / 12, | |
}; | |
item2 = { | |
v0: env.position, | |
v1: { | |
x: 0, | |
y: 0, | |
}, | |
mass: 30, | |
height: 150, | |
dRad: 0, | |
rad: -Math.PI / 6, | |
}; | |
}); | |
it('should get 2 items & env and return different 2 items and env after a pendulum calc', () => { | |
expect(calcDoublePendulum(item1, item2, env)).not.toBe([item1, item2, env]); | |
}); | |
describe('clampAngle', () => { | |
it('should not allow the angle to cross the min values of clampAngle', () => { | |
const [updated1, updated2] = calcDoublePendulum( | |
{ | |
...item1, | |
rad: -Math.PI / 6, | |
clampAngle: { | |
minRad: -Math.PI / 12, | |
maxRad: Math.PI / 12, | |
}, | |
}, | |
{ | |
...item2, | |
rad: -Math.PI / 6, | |
clampAngle: { | |
minRad: -Math.PI / 12, | |
maxRad: Math.PI / 12, | |
}, | |
}, | |
env, | |
); | |
expect(updated1.rad).toBeCloseTo(-Math.PI / 12); | |
expect(updated2.rad).toBeCloseTo(-Math.PI / 12); | |
}); | |
it('should not allow the angle to cross the max values of clampAngle', () => { | |
const [updated1, updated2] = calcDoublePendulum( | |
{ | |
...item1, | |
rad: Math.PI / 6, | |
clampAngle: { | |
minRad: -Math.PI / 12, | |
maxRad: Math.PI / 12, | |
}, | |
}, | |
{ | |
...item2, | |
rad: Math.PI / 6, | |
clampAngle: { | |
minRad: -Math.PI / 12, | |
maxRad: Math.PI / 12, | |
}, | |
}, | |
env, | |
); | |
expect(updated1.rad).toBeCloseTo(Math.PI / 12); | |
expect(updated2.rad).toBeCloseTo(Math.PI / 12); | |
}); | |
}); | |
describe('momentum', () => { | |
it('should lower the momentum by the damping if momentum is higher than 0', () => { | |
const updatedEnv = calcDoublePendulum(item1, item2, { | |
...env, | |
damping: 0.5, | |
})[2]; | |
expect(updatedEnv.momentum).toBe(env.momentum - 0.5); | |
}); | |
it('should not lower the momentum below 0', () => { | |
const updatedEnv = calcDoublePendulum(item1, item2, { | |
...env, | |
momentum: 0, | |
damping: 0.5, | |
})[2]; | |
expect(updatedEnv.momentum).toBe(0); | |
}); | |
}); | |
}); | |
describe('calcRadAngleFromLine', () => { | |
it('should return the radian angle from a line', () => { | |
expect( | |
calcRadAngleFromLine({ | |
v0: { x: 0, y: 0 }, | |
v1: { x: 0, y: 1 }, | |
}), | |
).toBe(Math.PI / 2); | |
}); | |
describe('rotateDeg', () => { | |
it('should return the radian angle from a line + the rotation degree for a positive rad', () => { | |
expect( | |
calcRadAngleFromLine( | |
{ | |
v0: { x: 0, y: 0 }, | |
v1: { x: 1, y: 1 }, | |
}, | |
Math.PI / 4, | |
), | |
).toBe(Math.PI / 2); | |
}); | |
it('should return the radian angle from a line reducing the rotation degree for a negative rad', () => { | |
expect( | |
calcRadAngleFromLine( | |
{ | |
v0: { x: 0, y: 0 }, | |
v1: { x: 1, y: 1 }, | |
}, | |
-Math.PI / 4, | |
), | |
).toBe(0); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useCallback, useEffect, useState } from 'react'; | |
type Milliseconds = Milliseconds; | |
type Radians = number; | |
export interface Vector { | |
readonly x: number; | |
readonly y: number; | |
} | |
export function subtract(vectorA: Vector, vectorB: Vector): Vector { | |
return { x: vectorA.x - vectorB.x, y: vectorA.y - vectorB.y }; | |
} | |
interface Line { | |
v0: Vector; | |
v1: Vector; | |
} | |
export interface NonNullRef<T> { | |
current: T; | |
} | |
export interface PendulumItem extends Line { | |
mass: number; | |
height: number; | |
dRad: Radians; | |
rad: Radians; | |
clampAngle?: { | |
minRad: Radians; | |
maxRad: Radians; | |
}; | |
} | |
export interface Environment { | |
position: Vector; | |
gravity: number; | |
time: number; | |
momentum: number; | |
damping: number; | |
} | |
export interface PendulumTransformation { | |
position: Vector; | |
rotation: Radians; | |
} | |
export const calcRadAngleFromLine = ({ v0, v1 }: Line, rotateRad: Radians = 0): Radians => { | |
const { x, y } = subtract(v1, v0); | |
return Math.atan2(y, x) + rotateRad; | |
}; | |
const getTransformation = (item: PendulumItem): PendulumTransformation => ({ | |
position: item.v0, | |
rotation: calcRadAngleFromLine(item, -Math.PI / 2), | |
}); | |
const DECIMAL_POINTS_OPTIMIZATION = 4; | |
const RENDER_INTERVAL: Milliseconds = 5; | |
export const calcDoublePendulum = ( | |
item1: PendulumItem, | |
item2: PendulumItem, | |
env: Environment, | |
): [PendulumItem, PendulumItem, Environment] => { | |
const massRatio = 1 + item1.mass / item2.mass; | |
const item1d2Rad = | |
(env.gravity * (Math.sin(item2.rad) * Math.cos(item1.rad - item2.rad) - massRatio * Math.sin(item1.rad)) - | |
(item2.height * item2.dRad * item2.dRad + | |
item1.height * item1.dRad * item1.dRad * Math.cos(item1.rad - item2.rad)) * | |
Math.sin(item1.rad - item2.rad)) / | |
(item1.height * (massRatio - Math.cos(item1.rad - item2.rad) * Math.cos(item1.rad - item2.rad))); | |
const item2d2Rad = | |
(massRatio * env.gravity * (Math.sin(item1.rad) * Math.cos(item1.rad - item2.rad) - Math.sin(item2.rad)) + | |
(massRatio * item1.height * item1.dRad * item1.dRad + | |
item2.height * item2.dRad * item2.dRad * Math.cos(item1.rad - item2.rad)) * | |
Math.sin(item1.rad - item2.rad)) / | |
(item2.height * (massRatio - Math.cos(item1.rad - item2.rad) * Math.cos(item1.rad - item2.rad))); | |
item1.dRad += item1d2Rad * env.time; | |
item2.dRad += item2d2Rad * env.time; | |
item1.rad += item1.dRad * env.time; | |
item2.rad += item2.dRad * env.time; | |
if (item1.clampAngle) item1.rad = Math.min(Math.max(item1.rad, item1.clampAngle.minRad), item1.clampAngle.maxRad); | |
if (item2.clampAngle) item2.rad = Math.min(Math.max(item2.rad, item2.clampAngle.minRad), item2.clampAngle.maxRad); | |
if (env.momentum > 0) env.momentum -= env.damping; | |
item1.rad *= env.momentum; | |
item2.rad *= env.momentum; | |
// Round | |
item1.rad = Number.parseFloat(item1.rad.toFixed(DECIMAL_POINTS_OPTIMIZATION)); | |
item2.rad = Number.parseFloat(item2.rad.toFixed(DECIMAL_POINTS_OPTIMIZATION)); | |
item1.v1 = { | |
x: env.position.x + item1.height * Math.sin(item1.rad), | |
y: env.position.y + item1.height * Math.cos(item1.rad), | |
}; | |
item2.v0 = item1.v1; | |
item2.v1 = { | |
x: env.position.x + item1.height * Math.sin(item1.rad) + item2.height * Math.sin(item2.rad), | |
y: env.position.y + item1.height * Math.cos(item1.rad) + item2.height * Math.cos(item2.rad), | |
}; | |
return [item1, item2, env]; | |
}; | |
export type UseDoublePendulum = ( | |
item1: NonNullRef<PendulumItem>, | |
item2: NonNullRef<PendulumItem>, | |
env: NonNullRef<Environment>, | |
) => [PendulumTransformation, PendulumTransformation]; | |
export const useDoublePendulum: UseDoublePendulum = (item1, item2, env) => { | |
const [transformations, setTransformations] = useState<ReturnType<UseDoublePendulum>>([ | |
getTransformation(item1.current), | |
getTransformation(item2.current), | |
]); | |
const update = useCallback((): void => { | |
const [updatedItem1, updatedItem2, updatedEnv] = calcDoublePendulum(item1.current, item2.current, env.current); | |
item1.current = updatedItem1; | |
item2.current = updatedItem2; | |
env.current = updatedEnv; | |
setTransformations([getTransformation(item1.current), getTransformation(item2.current)]); | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, [item1.current, item2.current, env.current, getTransformation]); | |
useEffect(() => { | |
const init = setInterval(update, RENDER_INTERVAL); | |
return () => { | |
clearInterval(init); | |
}; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []); | |
return transformations; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Screen.Recording.2022-01-26.at.16.05.31.mov