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
rodzic
9ac1243249
commit
2b27ae02a4
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
15
lib/point.ts
15
lib/point.ts
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue