Fix math-related bugs in attachment code. (#5)

This fixes a bunch of bugs in our attachment-related code, and in so doing refactors things to make more sense, adds more tests, and also adds documentation.
pull/6/head
Atul Varma 2021-02-16 11:52:52 -05:00 zatwierdzone przez GitHub
rodzic 9ac1243249
commit 2b27ae02a4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
7 zmienionych plików z 163 dodań i 18 usunięć

Wyświetl plik

@ -0,0 +1,25 @@
import { normalToAttachmentSpaceDegrees } from "./attach";
describe("normalToAttachmentSpaceDegrees()", () => {
it("Treats 'up' in canvas space as 0 degrees", () => {
expect(normalToAttachmentSpaceDegrees({ x: 0, y: -1 })).toBe(0);
});
it("Treats 'right' in canvas space as 90 degrees", () => {
expect(normalToAttachmentSpaceDegrees({ x: 1, y: 0 })).toBe(90);
});
it("Treats 'left' in canvas space as 270 degrees", () => {
expect(normalToAttachmentSpaceDegrees({ x: -1, y: 0 })).toBe(270);
});
it("Treats 'almost left' in canvas space as ~270 degrees", () => {
expect(
normalToAttachmentSpaceDegrees({ x: -0.9999, y: 0.004 })
).toBeCloseTo(269.189);
});
it("Treats 'down' in canvas space as 180 degrees", () => {
expect(normalToAttachmentSpaceDegrees({ x: 0, y: 1 })).toBe(180);
});
});

47
lib/attach.tsx 100644
Wyświetl plik

@ -0,0 +1,47 @@
import { Point } from "../vendor/bezier-js";
import { normalizedPoint2rad, scalePointXY, subtractPoints } from "./point";
import { PointWithNormal } from "./specs";
import { rad2deg } from "./util";
function normalizeDeg(deg: number): number {
deg = deg % 360;
if (deg < 0) {
deg = 360 + deg;
}
return deg;
}
/**
* Convert the given normal in screen-space coordinates into
* degrees of rotation in attachment-space coordinates.
*/
export function normalToAttachmentSpaceDegrees(normal: Point): number {
// We need to flip our y because we're in screen space, yet our
// rotational math assumes we're not.
const yFlipped = scalePointXY(normal, 1, -1);
const rad = normalizedPoint2rad(yFlipped);
// The origin of our rotation space assumes that "up" is 0
// degrees, while our rotational math assumes 0 degrees is "right".
const reoriented = normalizeDeg(90 - rad2deg(rad));
return reoriented;
}
/**
* Given a child point that needs to be attached to a parent
* point, return the amount of translation and rotation we
* need to apply to the child point in order to align its
* position and normal with that of its parent.
*/
export function getAttachmentTransforms(
parent: PointWithNormal,
child: PointWithNormal
) {
const translation = subtractPoints(parent.point, child.point);
const parentRot = normalToAttachmentSpaceDegrees(parent.normal);
const childRot = normalToAttachmentSpaceDegrees(child.normal);
const rotation = parentRot - childRot;
return { translation, rotation };
}

Wyświetl plik

@ -7,7 +7,8 @@ import {
SvgSymbolData,
} from "../svg-symbol";
import { AttachmentPointType, PointWithNormal } from "../specs";
import { subtractPoints } from "../point";
import { getAttachmentTransforms } from "../attach";
import { scalePointXY } from "../point";
const SYMBOL_MAP = new Map(
SvgVocabulary.map((symbol) => [symbol.name, symbol])
@ -64,10 +65,6 @@ type CreatureSymbolProps = {
attachIndex?: number;
};
function rad2deg(radians: number): number {
return (radians * 180) / Math.PI;
}
const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
const ctx = useContext(CreatureContext);
const { data, attachTo, attachIndex } = props;
@ -92,28 +89,38 @@ const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
`Cannot attach ${props.data.name} because it has no parent!`
);
}
const parentAp = getAttachmentPoint(parent, attachTo, attachIndex);
const ourAp = getAttachmentPoint(data, "tail");
const dist = subtractPoints(parentAp.point, ourAp.point);
const ourTheta = rad2deg(Math.PI / 2 - Math.acos(Math.abs(ourAp.normal.x)));
const normX = parentAp.normal.x;
const theta = -ourTheta + rad2deg(Math.PI / 2 - Math.acos(Math.abs(normX)));
let xFlip = 1;
if (normX < 0) {
xFlip *= -1;
}
if (ourAp.normal.x < 0) {
// If we're being attached as a tail, we want to actually rotate
// the attachment an extra 180 degrees, as the tail attachment
// point is facing the opposite direction that we actually
// want to orient the tail in.
const extraRot = attachTo === "tail" ? 180 : 0;
// If we're attaching something oriented towards the left, horizontally flip
// the attachment image.
let xFlip = parentAp.normal.x < 0 ? -1 : 1;
// Er, things look weird if we don't inverse the flip logic for
// the downward-facing attachments, like legs...
if (parentAp.normal.y > 0) {
xFlip *= -1;
}
const t = getAttachmentTransforms(parentAp, {
point: ourAp.point,
normal: scalePointXY(ourAp.normal, xFlip, 1),
});
return (
<g transform={`translate(${dist.x} ${dist.y})`}>
<g transform={`translate(${t.translation.x} ${t.translation.y})`}>
<g
transform-origin={`${ourAp.point.x} ${ourAp.point.y}`}
transform={`scale(${xFlip * ctx.attachmentScale} ${
ctx.attachmentScale
}) rotate(${theta})`}
}) rotate(${xFlip * t.rotation + extraRot})`}
>
{ourSymbol}
</g>

Wyświetl plik

@ -1,4 +1,4 @@
import { normalizePoint } from "./point";
import { normalizedPoint2rad, normalizePoint } from "./point";
describe("normalizePoint()", () => {
it("Does nothing to points w/ length 1", () => {
@ -19,3 +19,39 @@ describe("normalizePoint()", () => {
});
});
});
describe("normalizedPoint2rad()", () => {
it("works for (1, 0)", () => {
expect(normalizedPoint2rad({ x: 1, y: 0 })).toBe(0);
});
it("works for (0, 1)", () => {
expect(normalizedPoint2rad({ x: 0, y: 1 })).toBe(Math.PI / 2);
});
it("works for (-1, 0)", () => {
expect(normalizedPoint2rad({ x: -1, y: 0 })).toBe(Math.PI);
});
it("works for (-0.9999, 0.0499)", () => {
expect(
normalizedPoint2rad({ x: -0.9999875634527172, y: 0.0049872778043753814 })
).toBeCloseTo(Math.PI);
});
it("works for (-0.9999, -0.0499)", () => {
expect(
normalizedPoint2rad({ x: -0.9999875634527172, y: -0.0049872778043753814 })
).toBeCloseTo(Math.PI);
});
it("works for (0.9999, -0.0499)", () => {
expect(
normalizedPoint2rad({ x: 0.9999875634527172, y: -0.0049872778043753814 })
).toBeCloseTo(2 * Math.PI);
});
it("works for (0, -1)", () => {
expect(normalizedPoint2rad({ x: 0, y: -1 })).toBe(Math.PI + Math.PI / 2);
});
});

Wyświetl plik

@ -1,5 +1,12 @@
import { Point } from "../vendor/bezier-js";
export function scalePointXY(p: Point, xScale: number, yScale: number): Point {
return {
x: p.x * xScale,
y: p.y * yScale,
};
}
export function subtractPoints(p1: Point, p2: Point): Point {
return {
x: p1.x - p2.x,
@ -17,3 +24,11 @@ export function normalizePoint(p: Point): Point {
y: p.y / len,
};
}
export function normalizedPoint2rad(p: Point): number {
let result = Math.acos(p.x);
if (p.y < 0) {
result += (Math.PI - result) * 2;
}
return result;
}

Wyświetl plik

@ -1,4 +1,4 @@
import { flatten, float } from "./util";
import { flatten, float, rad2deg } from "./util";
describe("float", () => {
it("converts strings", () => {
@ -17,3 +17,11 @@ describe("float", () => {
test("flatten() works", () => {
expect(flatten([[1], [2, 3], [4]])).toEqual([1, 2, 3, 4]);
});
test("rad2deg() works", () => {
expect(rad2deg(0)).toBe(0);
expect(rad2deg(-Math.PI)).toBe(-180);
expect(rad2deg(Math.PI)).toBe(180);
expect(rad2deg(Math.PI - 0.0000001)).toBeCloseTo(180);
expect(rad2deg(2 * Math.PI)).toBe(360);
});

Wyświetl plik

@ -20,3 +20,10 @@ export function flatten<T>(arr: T[][]): T[] {
return result;
}
/**
* Convert radians to degrees.
*/
export function rad2deg(radians: number): number {
return (radians * 180) / Math.PI;
}