Skip to content

Instantly share code, notes, and snippets.

@EladBezalel
Last active April 18, 2022 08:13
Show Gist options
  • Save EladBezalel/d6ae9b2b72d4b02f1f4fecca38ddbc34 to your computer and use it in GitHub Desktop.
Save EladBezalel/d6ae9b2b72d4b02f1f4fecca38ddbc34 to your computer and use it in GitHub Desktop.
A double pendulum react hook
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);
});
});
});
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;
};
@EladBezalel
Copy link
Author

Screen.Recording.2022-01-26.at.16.05.31.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment