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,
|
SvgSymbolData,
|
||||||
} from "../svg-symbol";
|
} from "../svg-symbol";
|
||||||
import { AttachmentPointType, PointWithNormal } from "../specs";
|
import { AttachmentPointType, PointWithNormal } from "../specs";
|
||||||
import { subtractPoints } from "../point";
|
import { getAttachmentTransforms } from "../attach";
|
||||||
|
import { scalePointXY } from "../point";
|
||||||
|
|
||||||
const SYMBOL_MAP = new Map(
|
const SYMBOL_MAP = new Map(
|
||||||
SvgVocabulary.map((symbol) => [symbol.name, symbol])
|
SvgVocabulary.map((symbol) => [symbol.name, symbol])
|
||||||
|
@ -64,10 +65,6 @@ type CreatureSymbolProps = {
|
||||||
attachIndex?: number;
|
attachIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function rad2deg(radians: number): number {
|
|
||||||
return (radians * 180) / Math.PI;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
|
const CreatureSymbol: React.FC<CreatureSymbolProps> = (props) => {
|
||||||
const ctx = useContext(CreatureContext);
|
const ctx = useContext(CreatureContext);
|
||||||
const { data, attachTo, attachIndex } = props;
|
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!`
|
`Cannot attach ${props.data.name} because it has no parent!`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentAp = getAttachmentPoint(parent, attachTo, attachIndex);
|
const parentAp = getAttachmentPoint(parent, attachTo, attachIndex);
|
||||||
const ourAp = getAttachmentPoint(data, "tail");
|
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) {
|
// If we're being attached as a tail, we want to actually rotate
|
||||||
xFlip *= -1;
|
// the attachment an extra 180 degrees, as the tail attachment
|
||||||
}
|
// point is facing the opposite direction that we actually
|
||||||
if (ourAp.normal.x < 0) {
|
// 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;
|
xFlip *= -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = getAttachmentTransforms(parentAp, {
|
||||||
|
point: ourAp.point,
|
||||||
|
normal: scalePointXY(ourAp.normal, xFlip, 1),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g transform={`translate(${dist.x} ${dist.y})`}>
|
<g transform={`translate(${t.translation.x} ${t.translation.y})`}>
|
||||||
<g
|
<g
|
||||||
transform-origin={`${ourAp.point.x} ${ourAp.point.y}`}
|
transform-origin={`${ourAp.point.x} ${ourAp.point.y}`}
|
||||||
transform={`scale(${xFlip * ctx.attachmentScale} ${
|
transform={`scale(${xFlip * ctx.attachmentScale} ${
|
||||||
ctx.attachmentScale
|
ctx.attachmentScale
|
||||||
}) rotate(${theta})`}
|
}) rotate(${xFlip * t.rotation + extraRot})`}
|
||||||
>
|
>
|
||||||
{ourSymbol}
|
{ourSymbol}
|
||||||
</g>
|
</g>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { normalizePoint } from "./point";
|
import { normalizedPoint2rad, normalizePoint } from "./point";
|
||||||
|
|
||||||
describe("normalizePoint()", () => {
|
describe("normalizePoint()", () => {
|
||||||
it("Does nothing to points w/ length 1", () => {
|
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";
|
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 {
|
export function subtractPoints(p1: Point, p2: Point): Point {
|
||||||
return {
|
return {
|
||||||
x: p1.x - p2.x,
|
x: p1.x - p2.x,
|
||||||
|
@ -17,3 +24,11 @@ export function normalizePoint(p: Point): Point {
|
||||||
y: p.y / len,
|
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", () => {
|
describe("float", () => {
|
||||||
it("converts strings", () => {
|
it("converts strings", () => {
|
||||||
|
@ -17,3 +17,11 @@ describe("float", () => {
|
||||||
test("flatten() works", () => {
|
test("flatten() works", () => {
|
||||||
expect(flatten([[1], [2, 3], [4]])).toEqual([1, 2, 3, 4]);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert radians to degrees.
|
||||||
|
*/
|
||||||
|
export function rad2deg(radians: number): number {
|
||||||
|
return (radians * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue