The 2D Cross Product and Which Side
What You’ll Learn
Section titled “What You’ll Learn”The number the dot product could not give you: which side. One subtraction of two products, and it answers left or right, how far off a line something is, how big a polygon is, and which way round its corners were listed. In three dimensions this is the operation people find hardest. Here it is a single number.
One Number, Two Readings
Section titled “One Number, Two Readings”Note what it is not: it is not a vector. The 3D cross product returns one, which is where its right-hand rules and handedness conventions come from. In two dimensions there is no third axis for the result to point along, so what you get back is a plain number.
The meaning, as with the dot product, lives in the other way of writing it:
where is the angle from to , counter-clockwise. Put the two side by side and the division of labour is exact:
| Question | Operation | Contains | Symmetric? |
|---|---|---|---|
| do these agree in direction | yes, so no left/right | ||
| which way would I turn | no, so it knows |
Cosine is symmetric — — which is precisely why Section 1.4 ended with
acos unable to tell a target to your left from one to your right. Sine is not:
. The asymmetry is the information.
So swapping the arguments flips the sign, and gets checked at every angle rather than asserted here: for , for .
The Sign Is a Side
Section titled “The Sign Is a Side”Given a line running from to , and a point :
Positive means is to the left of the direction of travel, negative means right, zero means on the line.
src/lib/gamedev/demos/2d/side.scene.ts /** A point moving across a directed line, with the cross product's sign flipping as it crosses. */
import {
makeCanvas2D,
arrow,
dot as fillDot,
label,
line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import { perpLeft } from "../../../gamedev2d/cross2d.ts";
import { normalize } from "../../../gamedev2d/length2d.ts";
import { displacement, movedBy } from "../../../gamedev2d/vectors2d.ts";
import { footOnLine, parallelogramCorners, reading } from "./side-shared.ts";
import type { MountFn } from "../runner.ts";
const LEFT = "#7ee787";
const RIGHT = "#ff7b72";
const LINE = "#58a6ff";
const AREA = "#d2a8ff";
const GRID = "#252b33";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, width, height, clear } = makeCanvas2D(el, 310);
const show = addReadout(el);
const note = addReadout(el);
const py = addSlider(el, "point y", -4, 4, 2, draw, "", 0.1);
const px = addSlider(el, "point x", -7, 7, 2.5, draw, "", 0.1);
const angle = addSlider(el, "line angle", -90, 90, 20, draw);
const shade = addCheckbox(el, "shade the parallelogram", true, draw);
const reversed = addCheckbox(el, "read the line backwards", false, draw);
function draw() {
clear();
const unit = 30;
const ox = width / 2;
const oy = height / 2;
// World Y is up, so drawing negates it. Section 1.1's one conversion, in one place.
const at = (p: { x: number; y: number }) => ({
x: ox + p.x * unit,
y: oy - p.y * unit,
});
line(ctx, { x: 0, y: oy }, { x: width, y: oy }, GRID, { width: 1 });
line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, GRID, { width: 1 });
const r = reading(angle(), px(), py(), reversed());
const colour = r.side > 0 ? LEFT : r.side < 0 ? RIGHT : TEXT;
const direction = normalize(displacement(r.from, r.to)) ?? { x: 1, y: 0 };
// The line is infinite, so it is drawn running off both edges. Only the segment is solid.
const far = 40;
line(
ctx,
at(movedBy(r.from, { x: -direction.x * far, y: -direction.y * far })),
at(movedBy(r.to, { x: direction.x * far, y: direction.y * far })),
LINE,
{ dashed: true, width: 1 },
);
if (shade()) {
const corners = parallelogramCorners(r).map(at);
ctx.save();
ctx.fillStyle = "rgba(210, 168, 255, 0.14)";
ctx.strokeStyle = AREA;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(corners[0].x, corners[0].y);
for (const c of corners.slice(1)) ctx.lineTo(c.x, c.y);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
// The segment, drawn as an arrow because which side depends on which way it points.
arrow(ctx, at(r.from), at(r.to), LINE, 2.4);
label(ctx, "A", at(r.from).x - 12, at(r.from).y + 4, LINE);
label(ctx, "B", at(r.to).x + 8, at(r.to).y + 4, LINE);
// Which way "left" is, since it is a claim about the line's direction and not about the canvas.
const middle = movedBy(r.from, {
x: displacement(r.from, r.to).x / 2,
y: displacement(r.from, r.to).y / 2,
});
const normal = perpLeft(direction);
arrow(
ctx,
at(middle),
at(movedBy(middle, { x: normal.x * 1.1, y: normal.y * 1.1 })),
LEFT,
1.4,
);
label(
ctx,
"left",
at(movedBy(middle, { x: normal.x * 1.5, y: normal.y * 1.5 })).x,
at(movedBy(middle, { x: normal.x * 1.5, y: normal.y * 1.5 })).y,
LEFT,
"center",
);
// The perpendicular the signed distance measures.
const foot = footOnLine(r);
line(ctx, at(foot), at(r.p), colour, { dashed: true, width: 1 });
const point = at(r.p);
fillDot(ctx, point.x, point.y, 6, colour);
label(
ctx,
`P (${r.p.x.toFixed(1)}, ${r.p.y.toFixed(1)})`,
point.x + 10,
point.y - 8,
colour,
);
label(
ctx,
r.side > 0 ? "left" : r.side < 0 ? "right" : "on the line",
point.x + 10,
point.y + 8,
colour,
);
show(
`(B \u2212 A) \u00D7 (P \u2212 A) = ${r.raw.toFixed(2)} \u2192 ` +
`${r.side > 0 ? "positive, so P is left of A\u2192B" : r.side < 0 ? "negative, so P is right of A\u2192B" : "zero, so P is on the line"}` +
` \u00B7 ${Math.abs(r.distance).toFixed(2)} units from it`,
);
note(
reversed()
? "reading the line B\u2192A instead flips the sign of every point, and changes neither the distance nor the area"
: `\u007C cross \u007C ${r.parallelogram.toFixed(2)} is the shaded parallelogram's area, and half of it, ` +
`${r.triangle.toFixed(2)}, is triangle ABP's`,
);
}
draw();
return () => {};
};
export default mount; Sweep the point’s and watch the sign change exactly as it crosses. Nothing about the crossing is special-cased; the number simply passes through zero.
Two things this test is worth being careful about.
“Left” is relative to the line’s direction, not to the world. Tick the last box and every answer inverts, because the same two endpoints read backwards is a line pointing the other way. This is the first thing to check when a which-side test comes out consistently inverted — the geometry is probably fine and the edge is probably reversed.
Exactly zero is decided by rounding. The same caveat as a dot product of zero: a sweep of 58,564 positions across four line angles put exactly 124 of them on the line. Do not build anything that needs the zero case to appear.
The sign test is verified against a completely different piece of arithmetic — a signed angle from
atan2, which Section 2.2 covers — over that whole sweep, with zero disagreements and a symmetric
left against right. Two unrelated methods agreeing is worth more than one
method matching what I expected.
A Quarter Turn, For Free
Section titled “A Quarter Turn, For Free”Rotating by needs no trigonometry at all. Swap the components, negate one:
Which one is which follows from the cross product: is positive, so
perpLeft really is the counter-clockwise one. Section 2.3 derives the general rotation formula, and
this is that formula with and substituted in — which is checked, because
“obviously the same” is how sign errors survive.
A wall’s normal is this. So is the axis a separating-axis test needs in Section 5.3.
How Far Off the Line
Section titled “How Far Off the Line”The cross product carries a distance too, once you divide out the line’s length:
The cross product is twice the area of triangle ; dividing by the base leaves the height. One number that carries both the side and the distance, which is why it turns up in every line-of-sight, lane-following and “is the player past the finish line” check.
That formula is checked against a brute-force minimum over sixty thousand sampled points along the line rather than against itself rearranged, because a rearrangement of a wrong formula is still wrong.
The Magnitude Is an Area
Section titled “The Magnitude Is an Area”is the area of the parallelogram the two vectors span, and half of it is the triangle:
No square roots, no trigonometry, no separate base and height. Turn the shading on in the demo above and that parallelogram is the one being measured.
Zero area means the two vectors are parallel, which sounds like a curiosity and is not: it is how you detect two segments that will never meet, and Section 5.2 leans on it directly.
Winding Order
Section titled “Winding Order”Extend the area idea around a closed shape and you get the shoelace formula: sum the cross products of consecutive corners and halve it.
Keep the sign and it tells you which way round the corners were listed. Positive is counter-clockwise.
src/lib/gamedev/demos/2d/shoelace.ts /** The cross product's two readings: a sign that says which way round, and a size that is an area. */
import {
cross,
isConvex,
parallelogramArea,
signedPolygonArea,
} from "../../../gamedev2d/cross2d.ts";
import { fromNewOrigin, type Point } from "../../../gamedev2d/vectors2d.ts";
import type { Demo } from "../runner.ts";
const RECTANGLE: Point[] = [
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 2 },
{ x: 0, y: 2 },
];
/** An L, so there is something with a corner that turns back the other way. */
const L_SHAPE: Point[] = [
{ x: 0, y: 0 },
{ x: 3, y: 0 },
{ x: 3, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
{ x: 0, y: 2 },
];
const demo: Demo = (log) => {
log(
"cross({x: 1, y: 0}, {x: 0, y: 1})",
cross({ x: 1, y: 0 }, { x: 0, y: 1 }),
"positive: b is a quarter turn counter-clockwise from a",
);
log(
"cross({x: 0, y: 1}, {x: 1, y: 0})",
cross({ x: 0, y: 1 }, { x: 1, y: 0 }),
"the same two vectors the other way round, and only the sign changed",
);
log(
"parallelogramArea({x: 4, y: 0}, {x: 1, y: 2})",
parallelogramArea({ x: 4, y: 0 }, { x: 1, y: 2 }),
"so the triangle those two span has area 4, with no square root anywhere",
);
log(
"signedPolygonArea(rectangle)",
signedPolygonArea(RECTANGLE),
"positive, so the corners are listed counter-clockwise",
);
log(
"signedPolygonArea(the same corners, reversed)",
signedPolygonArea([...RECTANGLE].reverse()),
"clockwise now, and the rectangle has not moved",
);
// Every term is a triangle fanned out from the origin, and the surplus cancels - so where the
// origin sits cannot matter. Worth showing rather than claiming.
log(
"signedPolygonArea(L), then the same L re-measured from (100, 40)",
`${signedPolygonArea(L_SHAPE)}, then ${signedPolygonArea(
L_SHAPE.map((p) => fromNewOrigin(p, { x: 100, y: 40 })),
)}`,
"the origin can be anywhere, inside the shape or far outside it",
);
log(
"isConvex(L)",
isConvex(L_SHAPE),
"one of its corners turns the opposite way to the rest, which is what concave means",
);
};
export default demo; Every term is a triangle fanned out from the origin, with the parts outside the shape cancelling against the parts inside — so the origin can be anywhere at all, inside the polygon or a hundred units away, and the answer does not move. The panel shows that rather than claiming it.
Winding matters more than it looks like it should. It decides which way a polygon’s edge normals face, whether a separating-axis test finds the outside or the inside, and whether a shape ends up inside-out. A wrongly wound shape usually does not error. It just turns itself inside out, quietly, which is this module’s version of the mirrored-basis problem the 3D module has.
Three Sides Make an Inside
Section titled “Three Sides Make an Inside”Ask “which side” of all three edges of a triangle. If the answers agree, the point is inside.
That is three cross products, no division, and it works for either winding, because it only asks whether the three signs match rather than what they are. Checked against barycentric coordinates — different arithmetic, same question — over 32,361 grid cells with no disagreements. Counting the cells that came out inside gives an area of against the formula’s exact , which is a third, independent way of arriving at the same shape.
source The cross product, and the four questions it answers
/**
* The 2D cross product: one number, whose sign says which side and whose size is an area.
*
* In three dimensions the cross product returns a vector, which is where most of its difficulty
* comes from - a direction you have to reason about, a handedness convention, a right-hand rule to
* remember. In two dimensions it returns a **single number**, and that number does most of the same
* work with none of the bookkeeping.
*
* Read it two ways, exactly as with the dot product. The sign answers "which side of this line",
* which is the question the dot product cannot answer. The magnitude is an area, which is where
* polygon winding and every "is this shape wound the right way" test comes from.
*/
import { displacement, type Point, type Vector } from "./vectors2d.ts";
import { length } from "./length2d.ts";
/**
* The cross product of two 2D vectors. A number, not a vector.
*
* $$a \times b = a_x b_y - a_y b_x$$
*
* The meaning, which is the form worth carrying in your head:
*
* $$a \times b = |a|\,|b|\sin\theta$$
*
* where $\theta$ is the angle **from** `a` **to** `b`, measured counter-clockwise. Sine is positive
* for a counter-clockwise turn and negative for a clockwise one, so the sign tells you which way you
* would turn to get from `a` to `b`. That is the whole thing.
*
* Compare the dot product's $|a||b|\cos\theta$: cosine is symmetric, so the dot product cannot tell
* left from right. Sine is not, so this can. **Swapping the arguments flips the sign**, which is the
* property that makes it useful and the property that makes it easy to get backwards.
*/
export function cross(a: Vector, b: Vector): number {
return a.x * b.y - a.y * b.x;
}
/**
* The same vector turned a quarter turn counter-clockwise, which is free.
*
* No trigonometry, no multiplication: swap the components and negate one of them. This is the
* cheapest rotation there is, and it comes up constantly - a wall's normal, the perpendicular to a
* movement direction, the axis a separating-axis test wants in Section 5.3.
*/
export function perpLeft(v: Vector): Vector {
return { x: -v.y, y: v.x };
}
/** The other quarter turn. Negate the other component. */
export function perpRight(v: Vector): Vector {
return { x: v.y, y: -v.x };
}
/**
* The raw cross product of the line's direction with the direction to the point.
*
* Keep this rather than only its sign when you need the magnitude too, because it is twice the area
* of the triangle the three points make - so it is a measure of how far off the line the point is,
* scaled by the line's length.
*/
export function sideValue(from: Point, to: Point, p: Point): number {
return cross(displacement(from, to), displacement(from, p));
}
/**
* Which side of the line through `from` and `to` does `p` lie on?
*
* `1` is left of the direction of travel, `-1` is right, `0` is exactly on the line. **"Left" is
* relative to the line's direction**, not to anything absolute, so reading the same line backwards
* swaps every answer. That is not a flaw; it is what makes the test useful for a directed edge, and
* it is the first thing to check when a which-side test comes out inverted.
*
* As with a dot product of exactly zero, landing exactly on the line is decided by rounding rather
* than by geometry, so do not build anything that depends on the `0` case appearing.
*/
export function sideOf(from: Point, to: Point, p: Point): -1 | 0 | 1 {
const s = sideValue(from, to, p);
return s > 0 ? 1 : s < 0 ? -1 : 0;
}
/**
* How far `p` sits from the **infinite** line through `from` and `to`, with a sign for the side.
*
* $$d = \frac{(b - a) \times (p - a)}{|b - a|}$$
*
* Dividing the cross product by the line's length is all it takes: the cross product is twice the
* triangle's area, and area over base is height. Returns `0` for a degenerate line, which has no
* side to be on.
*/
export function signedDistanceToLine(from: Point, to: Point, p: Point): number {
const direction = displacement(from, to);
const len = length(direction);
return len < 1e-9 ? 0 : sideValue(from, to, p) / len;
}
/**
* The area of the parallelogram the two vectors span. The magnitude reading of the cross product.
*
* $$\text{area} = |a \times b|$$
*
* Zero means the two are parallel, which is a more useful test than it sounds: it is how you detect
* two segments that will never cross, and Section 5.2 uses exactly this.
*/
export function parallelogramArea(a: Vector, b: Vector): number {
return Math.abs(cross(a, b));
}
/** Half the parallelogram, which is the triangle. No square roots and no trigonometry. */
export function triangleArea(a: Point, b: Point, c: Point): number {
return Math.abs(sideValue(a, b, c)) / 2;
}
/**
* The **signed** area of a polygon, by the shoelace formula.
*
* $$2A = \sum_i \left(x_i y_{i+1} - y_i x_{i+1}\right)$$
*
* which is a sum of cross products of consecutive corners, taken as vectors from the origin. Every
* term is a triangle fanned out from the origin, and the ones outside the shape cancel against the
* ones inside - so the origin can be anywhere, including outside the polygon, and the answer is the
* same.
*
* The **sign is the winding**: positive for counter-clockwise, negative for clockwise. Keeping the
* sign rather than taking the absolute value immediately is what turns an area function into a
* winding test.
*/
export function signedPolygonArea(points: readonly Point[]): number {
if (points.length < 3) return 0;
let total = 0;
for (let i = 0; i < points.length; i += 1) {
const a = points[i];
const b = points[(i + 1) % points.length];
total += cross(a, b);
}
return total / 2;
}
/** The area, which is what you asked for when you did not care about the winding. */
export function polygonArea(points: readonly Point[]): number {
return Math.abs(signedPolygonArea(points));
}
export type Winding = "counter-clockwise" | "clockwise" | "degenerate";
/**
* Which way round the corners are listed.
*
* Worth naming because so much depends on it: which way a polygon's edge normals point, whether a
* separating-axis test finds the outside, whether a renderer culls the face. A shape wound the wrong
* way usually does not error - it just turns inside out, which is Section 2.1's version of the
* mirrored-basis problem the 3D module has.
*
* **Counter-clockwise is measured in world coordinates, with Y up.** A canvas has Y down, so the same
* list of corners appears to wind the other way once drawn. Nothing changed but the picture.
*/
export function windingOf(points: readonly Point[], epsilon = 1e-12): Winding {
const area = signedPolygonArea(points);
if (Math.abs(area) <= epsilon) return "degenerate";
return area > 0 ? "counter-clockwise" : "clockwise";
}
/**
* Is this polygon convex? Every turn goes the same way.
*
* Walk the corners, cross each edge with the next, and check the signs agree. A concave corner turns
* back the other way, so its cross product has the opposite sign. Zeros are skipped, because three
* corners in a straight line are neither turn.
*/
export function isConvex(points: readonly Point[]): boolean {
if (points.length < 3) return false;
let sign = 0;
for (let i = 0; i < points.length; i += 1) {
const a = points[i];
const b = points[(i + 1) % points.length];
const c = points[(i + 2) % points.length];
const turn = cross(displacement(a, b), displacement(b, c));
if (Math.abs(turn) < 1e-12) continue;
const here = turn > 0 ? 1 : -1;
if (sign === 0) sign = here;
else if (here !== sign) return false;
}
return sign !== 0;
}
/**
* Is `p` inside the triangle? The which-side test, three times.
*
* A point is inside when it is on the same side of all three edges, which is three cross products
* and no division. Works for either winding, because it only asks whether the three signs agree
* rather than what they are.
*/
export function pointInTriangle(
p: Point,
a: Point,
b: Point,
c: Point,
): boolean {
const ab = sideValue(a, b, p);
const bc = sideValue(b, c, p);
const ca = sideValue(c, a, p);
const noneNegative = ab >= 0 && bc >= 0 && ca >= 0;
const nonePositive = ab <= 0 && bc <= 0 && ca <= 0;
return noneNegative || nonePositive;
} Where This Shows Up
Section titled “Where This Shows Up”- Which side of a wall, a lane or a finish line something is, which is one cross product.
- Turning the right way: the sign says which direction to rotate, and Section 2.3 uses it.
- Polygon winding and convexity, and therefore every collision routine in Part 5.
- Segment intersection, which is four which-side tests. Section 5.2.
- The separating axis test, whose axes are the free perpendiculars above. Section 5.3.
- Triangle areas and point-in-triangle, used for hit testing and for navigation meshes.