Rays, Segments and Closest Points
What You’ll Learn
Section titled “What You’ll Learn”That is a line, a ray and a segment. The only difference between the three is how far is allowed to go:
| Shape | Range of | What it is |
|---|---|---|
| Line | anything | infinite in both directions |
| Ray | starts somewhere, never returns | |
| Segment | two ends |
Nearly every bug in this Section is a place where the wrong one of those three was used. A wall read as a line blocks a view from across the level. A ray read as a line hits things behind the shooter. A segment read as a line is nearest to a point that is nowhere near it.
Notice also that this is lerp from Section 4.2, applied to points. A segment is an interpolation
between its ends — and unlike Section 4.3’s Bezier parameter, this one is proportional to distance,
which the build checks.
The Closest Point: Project, Then Clamp
Section titled “The Closest Point: Project, Then Clamp”A dot product over a squared length — Section 1.4’s projection, divided so the answer comes out as a fraction of the way along. Then clamp into the shape’s range and evaluate.
You have done this before. Section 5.1 found the nearest point on a box by clamping a coordinate into a range. This clamps a parameter into a range. Same move, one dimension over, and that is worth noticing because it is the shape of almost every closest-point problem.
src/lib/gamedev/demos/2d/nearest.scene.ts /** The closest point on a wall, with and without the clamp that stops it running off the ends. */
import {
makeCanvas2D,
addDragTargets,
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 { pointOn } from "../../../gamedev2d/segment2d.ts";
import { normalize } from "../../../gamedev2d/length2d.ts";
import { displacement } from "../../../gamedev2d/vectors2d.ts";
import {
BOUNDS,
NEAREST_WALL,
START,
VIEW,
nearestReport,
screenOf,
worldOf,
} from "./sight-shared.ts";
import type { MountFn } from "../runner.ts";
const WALL = "#7d8590";
const LINE = "#3b4552";
const ON_SEGMENT = "#7ee787";
const ON_LINE = "#ff7b72";
const POINT = "#58a6ff";
const BEYOND = "rgba(255, 123, 114, 0.10)";
const DIM = "#636c76";
const mount: MountFn = (el) => {
const { ctx, canvas, clear } = makeCanvas2D(el, VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const px = addSlider(el, "x", -BOUNDS.x, BOUNDS.x, START.x, draw, "", 0.05);
const py = addSlider(el, "y", -BOUNDS.y, BOUNDS.y, START.y, draw, "", 0.05);
const unclamped = addCheckbox(
el,
"also show the answer without the clamp",
true,
draw,
);
// Dragging is the natural way in; the two sliders are the keyboard path to the same position.
const stopDragging = addDragTargets(
canvas,
() => [screenOf({ x: px(), y: py() })],
(_index, sx, sy) => {
const world = worldOf(sx, sy);
px.set(Math.min(Math.max(world.x, -BOUNDS.x), BOUNDS.x));
py.set(Math.min(Math.max(world.y, -BOUNDS.y), BOUNDS.y));
draw();
},
);
/* The two regions where `t` leaves [0, 1], shaded. Their boundaries are the perpendiculars at the
wall's ends, so drawing them is how "past the end" stops being a phrase and becomes a place. */
function shadeBeyond() {
const along = normalize(displacement(NEAREST_WALL.a, NEAREST_WALL.b));
if (along === null) return;
const across = { x: -along.y, y: along.x };
const far = 24;
ctx.save();
ctx.fillStyle = BEYOND;
for (const [end, sign] of [
[NEAREST_WALL.a, -1],
[NEAREST_WALL.b, 1],
] as const) {
const corners = [
{ x: end.x + across.x * far, y: end.y + across.y * far },
{ x: end.x - across.x * far, y: end.y - across.y * far },
{
x: end.x - across.x * far + along.x * sign * far,
y: end.y - across.y * far + along.y * sign * far,
},
{
x: end.x + across.x * far + along.x * sign * far,
y: end.y + across.y * far + along.y * sign * far,
},
];
ctx.beginPath();
corners.forEach((c, i) => {
const q = screenOf(c);
if (i === 0) ctx.moveTo(q.x, q.y);
else ctx.lineTo(q.x, q.y);
});
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function draw() {
clear();
const p = { x: px(), y: py() };
const r = nearestReport(p);
shadeBeyond();
// The infinite line the wall lies on, which is what dropping the clamp measures against.
line(
ctx,
screenOf(pointOn(NEAREST_WALL, -6)),
screenOf(pointOn(NEAREST_WALL, 7)),
LINE,
{
dashed: true,
width: 1,
},
);
// The wall itself, and its ends, which are the only difference between the two answers.
line(ctx, screenOf(NEAREST_WALL.a), screenOf(NEAREST_WALL.b), WALL, {
width: 3,
});
for (const [end, name] of [
[NEAREST_WALL.a, "t = 0"],
[NEAREST_WALL.b, "t = 1"],
] as const) {
const q = screenOf(end);
fillDot(ctx, q.x, q.y, 3.5, WALL);
label(ctx, name, q.x + 7, q.y + 14, DIM);
}
const here = screenOf(p);
// The unclamped answer, drawn first so the correct one sits on top of it.
if (unclamped() && r.clampMattered) {
const off = screenOf(r.onLine);
line(ctx, here, off, ON_LINE, { dashed: true, width: 1.5 });
fillDot(ctx, off.x, off.y, 4.5, ON_LINE);
label(ctx, "nearest on the line", off.x + 8, off.y - 8, ON_LINE);
}
// The answer, on the segment.
const on = screenOf(r.onSegment);
line(ctx, here, on, ON_SEGMENT, { width: 1.8 });
fillDot(ctx, on.x, on.y, 5, ON_SEGMENT);
label(ctx, "nearest on the wall", on.x + 8, on.y + 16, ON_SEGMENT);
fillDot(ctx, here.x, here.y, 5.5, POINT);
label(ctx, "drag me", here.x + 10, here.y - 8, POINT);
label(ctx, "shaded: past an end, where t leaves 0 to 1", 12, 18, DIM);
show(
`t = ${r.raw.toFixed(3)} \u2192 clamped to ${r.clamped.toFixed(3)} \u00b7 ` +
`${r.toSegment.toFixed(3)} from the wall, ${r.toLine.toFixed(3)} from its infinite line`,
);
note(
r.clampMattered
? `past an end, so the clamp is doing the work \u2014 without it the answer is ${(r.toSegment / Math.max(r.toLine, 1e-6)).toFixed(1)}\u00D7 too close`
: "between the ends, so the clamp changes nothing and both answers agree exactly",
);
}
draw();
return stopDragging;
};
export default mount; The shaded regions are where leaves . Drag into one and the two answers come apart: green stays on the wall, red runs off up the wall’s imaginary continuation.
Where Two Of Them Cross
Section titled “Where Two Of Them Cross”With and , both parameters come out of one division:
That is Section 2.1’s 2D cross product — one number, . Getting two answers from one denominator is why this is the standard form.
Then test both parameters against the ranges of the two shapes you actually have. Two segments cross when and . A ray hits a wall when and — the asymmetry is the whole point, and using the same test for both is how a ray ends up hitting things behind the shooter.
src/lib/gamedev/demos/2d/param.ts /** One equation, three shapes, and the two divisions that need a guard in front of them. */
import {
areCollinear,
clampT,
collinearOverlap,
distanceToSegment,
lineCrossing,
projectionT,
segmentCrossing,
type Segment,
} from "../../../gamedev2d/segment2d.ts";
import type { Demo } from "../runner.ts";
const CROSSING: Segment = { a: { x: -3, y: -1 }, b: { x: 3, y: 2 } };
const TALL: Segment = { a: { x: 1, y: -2 }, b: { x: 1, y: 3 } };
/** The same line as `TALL`, but only its top part - so the lines still cross and the segments do not. */
const SHORT: Segment = { a: { x: 1, y: 2 }, b: { x: 1, y: 3 } };
const FLAT: Segment = { a: { x: 0, y: 0 }, b: { x: 4, y: 0 } };
const SAME_LINE: Segment = { a: { x: 2, y: 0 }, b: { x: 6, y: 0 } };
const POINT: Segment = { a: { x: 1, y: 1 }, b: { x: 1, y: 1 } };
const demo: Demo = (log) => {
// The only difference between a line, a ray and a segment is how far t may go.
log(
"clampT(2.5, ...) for a line, a ray and a segment",
`${clampT(2.5, "line")}, ${clampT(2.5, "ray")}, ${clampT(2.5, "segment")}`,
"and at t = -0.5 they give -0.5, 0 and 0 - that is the whole distinction",
);
// Both parameters come out of one division, and both have to be checked.
const hit = lineCrossing(CROSSING, TALL)!;
log(
"lineCrossing(diagonal, tall wall)",
`t ${hit.t.toFixed(4)}, u ${hit.u.toFixed(4)}, at (${hit.point.x}, ${hit.point.y})`,
"both in 0 to 1, so the segments really cross",
);
const miss = lineCrossing(CROSSING, SHORT)!;
log(
"the same diagonal against a shorter wall on that same line",
`t ${miss.t.toFixed(4)}, u ${miss.u.toFixed(4)}`,
`t is still in range, so testing only t would report a hit - and segmentCrossing says ${segmentCrossing(CROSSING, SHORT) === null ? "no" : "yes"}`,
);
// Parallel means the division is impossible, and collinear means the answer is not a point at all.
log(
"lineCrossing on two collinear segments",
String(lineCrossing(FLAT, SAME_LINE)),
"the denominator is 0, so there is no single crossing to return",
);
log(
"collinearOverlap on the same pair",
`${areCollinear(FLAT, SAME_LINE)} and ${collinearOverlap(FLAT, SAME_LINE)}`,
"they do touch, along a shared stretch - a range check answers what the division cannot",
);
// And the other division: projecting onto something with no direction.
log(
"projectionT on a zero-length segment",
String(projectionT(POINT, { x: 4, y: 5 })),
"null rather than 0/0, because a point has no direction to project onto",
);
log(
"distanceToSegment to that same point",
distanceToSegment(POINT, { x: 4, y: 5 }),
"still a real answer: the nearest part of a one-point shape is that point",
);
};
export default demo; Testing only is the specific mistake worth naming, because it looks like it works. In the demo above a diagonal meets a short wall at — comfortably in range — while , meaning the crossing is a whole wall-length short of the wall itself. Check alone and that wall blocks everything along its line, for ever.
Line Of Sight, And What Dropping u Costs
Section titled “Line Of Sight, And What Dropping u Costs”src/lib/gamedev/demos/2d/sight.scene.ts /** A guard's line of sight past three walls, and what happens when a wall is read as an infinite line. */
import {
makeCanvas2D,
addDragTargets,
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 { pointOn } from "../../../gamedev2d/segment2d.ts";
import {
BOUNDS,
GUARD_AT,
RADIUS_RANGE,
SIGHT_RADIUS,
START,
UNIT,
VIEW,
WALLS,
clearInDirection,
screenOf,
sightReport,
sweepDisagreements,
worldOf,
} from "./sight-shared.ts";
import type { MountFn } from "../runner.ts";
const WALL = "#7d8590";
const EXTENSION = "#3b4552";
const CLEAR = "#7ee787";
const BLOCKED = "#ff7b72";
const GUARD = "#d2a8ff";
const TARGET = "#58a6ff";
const DIM = "#636c76";
/** Spokes in the compass around the guard. 120 is three degrees each, which reads as a fan. */
const SPOKES = 120;
const mount: MountFn = (el) => {
const { ctx, canvas, clear } = makeCanvas2D(el, VIEW.height);
const show = addReadout(el);
const note = addReadout(el);
const px = addSlider(
el,
"target x",
-BOUNDS.x,
BOUNDS.x,
START.x,
draw,
"",
0.05,
);
const py = addSlider(
el,
"target y",
-BOUNDS.y,
BOUNDS.y,
START.y,
draw,
"",
0.05,
);
/* A control rather than a constant, because the whole figure depends on it: at three units out the
two tests agree exactly, and by ten the third wall's line has come into range. */
const reach = addSlider(
el,
"how far out the spokes ask",
RADIUS_RANGE.min,
RADIUS_RANGE.max,
SIGHT_RADIUS,
draw,
" units",
RADIUS_RANGE.step,
);
const asLines = addCheckbox(
el,
"treat every wall as the infinite line it lies on",
false,
draw,
);
// Dragging is the natural way in; the two sliders are the keyboard path to the same position.
const stopDragging = addDragTargets(
canvas,
() => [screenOf({ x: px(), y: py() })],
(_index, sx, sy) => {
const world = worldOf(sx, sy);
px.set(Math.min(Math.max(world.x, -BOUNDS.x), BOUNDS.x));
py.set(Math.min(Math.max(world.y, -BOUNDS.y), BOUNDS.y));
draw();
},
);
function draw() {
clear();
const target = { x: px(), y: py() };
const wrong = asLines();
const radius = reach();
const r = sightReport(target);
const guard = screenOf(GUARD_AT);
// What the walls become if their ends are ignored. Drawn only when that is what is being asked.
if (wrong) {
for (const wall of WALLS) {
line(
ctx,
screenOf(pointOn(wall, -12)),
screenOf(pointOn(wall, 13)),
EXTENSION,
{
dashed: true,
width: 1,
},
);
}
}
/* The compass: one spoke per direction, coloured by whether a target SIGHT_RADIUS away that way is
visible. This is the Section's headline as a picture - tick the box and watch a third of the fan
turn red without a single wall having moved. */
for (let i = 0; i < SPOKES; i += 1) {
const angle = (i / SPOKES) * Math.PI * 2;
const visible = clearInDirection(angle, radius, wrong);
const from = {
x: GUARD_AT.x + Math.cos(angle) * 0.55,
y: GUARD_AT.y + Math.sin(angle) * 0.55,
};
const to = {
x: GUARD_AT.x + Math.cos(angle) * 1.35,
y: GUARD_AT.y + Math.sin(angle) * 1.35,
};
line(ctx, screenOf(from), screenOf(to), visible ? CLEAR : BLOCKED, {
width: 2,
});
}
// The walls, drawn over the compass so their ends are unmistakable.
for (const wall of WALLS) {
line(ctx, screenOf(wall.a), screenOf(wall.b), WALL, { width: 3.5 });
for (const end of [wall.a, wall.b]) {
const q = screenOf(end);
fillDot(ctx, q.x, q.y, 3, WALL);
}
}
/* The sight line, and where it stopped. **The marker follows whichever test is selected**, which is
the only way the interesting case reads: when the phantom wall blocks a view that is really clear,
the point worth pointing at is on the wall's extension, not on the wall. */
const to = screenOf(target);
const clearNow = wrong ? r.clearIfLines : r.clear;
const hitNow = wrong ? r.hitAtIfLines : r.hitAt;
line(ctx, guard, to, clearNow ? CLEAR : BLOCKED, {
width: 1.8,
dashed: !clearNow,
});
if (!clearNow && hitNow) {
const hit = screenOf(hitNow);
fillDot(ctx, hit.x, hit.y, 4.5, BLOCKED);
label(
ctx,
wrong && r.clear ? "blocked by nothing" : "blocked here",
hit.x + 8,
hit.y - 8,
BLOCKED,
);
}
fillDot(ctx, guard.x, guard.y, 6, GUARD);
/* Outside the compass, not inside it. This label sat at +20 px, which is within the ring of spokes,
so it was drawn over them and unreadable - reported by a reader looking at the rendered page,
which is the half of this a build with no GPU cannot check. */
label(ctx, "guard", guard.x - 1.35 * UNIT - 8, guard.y + 4, GUARD, "right");
fillDot(ctx, to.x, to.y, 5.5, TARGET);
label(ctx, "target", to.x + 10, to.y - 8, TARGET);
// Top left, which is empty. At the bottom it collided with the compass's lowest spokes.
label(
ctx,
`each spoke: can the guard see ${radius} units out that way?`,
12,
18,
DIM,
);
const sweep = sweepDisagreements(radius);
show(
`the target is ${clearNow ? "visible" : "hidden"} \u00b7 ` +
`of a full turn, ${sweep.clearDegrees.toFixed(2)}\u00B0 is really clear and ` +
`${sweep.wrongClearDegrees.toFixed(2)}\u00B0 would be if the walls were lines`,
);
note(
wrong
? sweep.degrees < 0.005
? `at ${radius} units out nothing is behind a wall's extension, so the two tests agree exactly \u2014 push the reach out`
: `the walls now reach for ever, so ${sweep.degrees.toFixed(2)}\u00B0 of the view is blocked by wall that is not there \u2014 ` +
"the fix is testing u as well as t"
: r.disagrees
? "at this exact spot the two tests already disagree, so tick the box and watch the sight line change"
: "every wall stops at its ends, which is what 0 \u2264 u \u2264 1 is for \u2014 tick the box to drop it",
);
}
draw();
return stopDragging;
};
export default mount; Each spoke around the guard asks: can I see a target six units out this way? Then tick the box, which reads every wall as the infinite line it lies on.
| Clear arc | |
|---|---|
| Walls as segments, correctly | |
| Walls as infinite lines | |
| Wrongly blocked |
Three short walls, and reading them as lines throws away 129.42 degrees of the guard’s view — 43% of everything it could actually see.
Those figures are derived in closed form and then checked against a sweep, which is worth doing rather than trusting either alone. At six units only the nearest wall is reachable as a segment, so the honestly blocked arc is simply the angle it subtends at the guard: . As lines, that wall’s line blocks exactly, the second’s blocks , and they overlap by — a union of , leaving clear.
Like Section 5.1’s grown box, the error is one-sided: reading a wall as a line can only ever block a view, never open one. The build sweeps 720 directions at four radii confirming it. So the wrong version is usable as a cheap first pass — if the line test says “clear”, it really is clear — and fatal as an answer.
The Two Divisions That Need Guarding
Section titled “The Two Divisions That Need Guarding”Both formulas above divide, and both denominators can be zero. Neither case throws.
A segment with no length. Its direction is the zero vector, so the projection is . projectionT
returns null rather than NaN, because a NaN position propagates into everything it touches and
surfaces as a sprite that has vanished, with nothing in the console.
Two parallel shapes. The cross product is zero, so lineCrossing returns null. Left
unguarded it produces NaN — and NaN compares false against every range test, so the hit is silently
dropped rather than reported. That matters most in the case that is not merely parallel but
collinear:
Two segments on the same line either share infinitely many points or none, and “where do they cross” has no single answer. So the general formula cannot help, and a sight line lying exactly along a wall gets reported as unblocked — a wall a character can walk through, but only when perfectly aligned with it. Rare, perfectly reproducible, and maddening to find.
The fix is to ask a different question: project both onto the shared direction and compare the two ranges. Which is Section 5.1’s range check, doing its third job in three Sections.
source Three domains for one parameter, the clamped projection, and the crossing with its guards
/**
* Rays, segments and lines: **one equation, three different answers about how far `t` may go.**
*
* $$P(t) = A + t\,(B - A)$$
*
* A line lets $t$ be anything. A ray starts somewhere and never comes back, so $t \ge 0$. A segment has
* two ends, so $0 \le t \le 1$. That is the whole difference between them, and nearly every bug in this
* Section is a place where the wrong one of those three was used.
*
* The other idea here is one you have already met. Section 5.1 found the closest point on a box by
* **clamping a coordinate** into a range. This Section finds the closest point on a segment by
* **clamping a parameter** into a range. Same move, one dimension over.
*/
import { cross } from "./cross2d.ts";
import { dot } from "./dot2d.ts";
import { distance, length, lengthSquared } from "./length2d.ts";
import { displacement, movedBy, scaled, type Point } from "./vectors2d.ts";
export type Segment = {
a: Point;
b: Point;
};
/** Which of the three shapes a parametric equation is being read as. The only thing that differs. */
export type Kind = "line" | "ray" | "segment";
/** The displacement from one end to the other. `t` is measured in whole multiples of this. */
export function direction(seg: Segment): Point {
return displacement(seg.a, seg.b);
}
/** How long the segment is. Also the scale `t` is measured against, which is worth being aware of. */
export function segmentLength(seg: Segment): number {
return distance(seg.a, seg.b);
}
/**
* The point at parameter `t`. Not clamped, on purpose: `t = 2` is a real place, past the far end.
*
* $$P(t) = A + t\,(B - A)$$
*
* Worth noticing that this is `lerp` from Section 4.2, applied to a point. A segment **is** an
* interpolation between its ends, and `t` is exactly the same unclamped fraction - which is why the same
* caution applies.
*/
export function pointOn(seg: Segment, t: number): Point {
return movedBy(seg.a, scaled(direction(seg), t));
}
/**
* The legal range of `t`, which is the only thing separating the three shapes.
*
* Stated as a function so the three cases live in one place and the Section can point at it. A ray's
* upper bound is genuinely infinite rather than "some big number", and using a big number instead is a
* real source of bugs at scale - a sight line across a large level quietly stops working.
*/
export function clampT(t: number, kind: Kind): number {
if (kind === "line") return t;
if (kind === "ray") return Math.max(t, 0);
return Math.min(Math.max(t, 0), 1);
}
/** Is this value of `t` on the shape at all? The same three cases, asked the other way. */
export function containsT(t: number, kind: Kind): boolean {
if (kind === "line") return true;
if (kind === "ray") return t >= 0;
return t >= 0 && t <= 1;
}
/**
* The parameter of the perpendicular foot: where along the infinite line `p` projects to.
*
* $$t = \frac{(P - A) \cdot (B - A)}{|B - A|^2}$$
*
* A dot product over a squared length, which is Section 1.4's projection with the division that turns
* it into a fraction of the way along. **Unclamped**, so the answer can be negative or greater than one,
* and that is information rather than a problem - it says which side of the segment you are past.
*
* Returns `null` for a segment with no length, where there is no direction to project onto and the
* division would be $0/0$. That is the guard this function exists for: without it the `NaN` flows into a
* position and a sprite disappears with nothing logged.
*/
export function projectionT(seg: Segment, p: Point): number | null {
const d = direction(seg);
const lengthSq = lengthSquared(d);
if (lengthSq < 1e-12) return null;
return dot(displacement(seg.a, p), d) / lengthSq;
}
/**
* The closest point on a line, ray or segment: **project, then clamp the parameter.**
*
* Two steps, and the second one is the one people leave out. Without the clamp you get the closest point
* on the infinite **line**, which is a different place as soon as you are past either end - so a guard
* measures its distance to a wall that is not there, and a character slides along the imaginary
* continuation of a platform.
*
* For a zero-length segment the answer is its single point, which is the sensible reading of "the
* nearest part of a shape that is one point".
*/
export function closestPoint(
seg: Segment,
p: Point,
kind: Kind = "segment",
): Point {
const t = projectionT(seg, p);
return t === null ? seg.a : pointOn(seg, clampT(t, kind));
}
/** How far `p` is from the nearest part of the segment. */
export function distanceToSegment(seg: Segment, p: Point): number {
return distance(p, closestPoint(seg, p, "segment"));
}
/** The same without the square root, for comparisons. Section 1.3's shortcut, still applicable. */
export function distanceSquaredToSegment(seg: Segment, p: Point): number {
const q = closestPoint(seg, p, "segment");
return lengthSquared(displacement(q, p));
}
/**
* The perpendicular distance to the **infinite line** through the segment. The unclamped answer.
*
* Here so the build can price the missing clamp rather than describe it. It is also the right function
* sometimes - "how far off the road's centreline am I" is a question about a line - so the point is not
* that it is wrong but that the two are different questions.
*/
export function distanceToLine(seg: Segment, p: Point): number {
const d = direction(seg);
const len = length(d);
if (len < 1e-12) return distance(seg.a, p);
// The cross product's magnitude is the parallelogram's area, and area over base is height.
return Math.abs(cross(d, displacement(seg.a, p))) / len;
}
// ---- Where two of them cross ------------------------------------------------------------------
export type Crossing = {
/** How far along the first shape, in its own parameter. */
t: number;
/** How far along the second. Both are needed: either can be out of range. */
u: number;
point: Point;
};
/**
* Where two lines cross, using the 2D cross product from Section 2.1.
*
* With $r = B - A$ and $s = D - C$:
*
* $$t = \frac{(C - A) \times s}{r \times s} \qquad u = \frac{(C - A) \times r}{r \times s}$$
*
* Both parameters come out of one division, which is why this is the standard form. **Returns `null`
* when the denominator is zero**, which means the two are parallel - and that case has to be handled
* rather than divided through, because the formula produces `Infinity` or `NaN` and both propagate.
*
* The caller decides what counts as a hit by testing `t` and `u` against the ranges of the two shapes
* it actually has. That is the whole reason the parameters are returned rather than a boolean.
*/
export function lineCrossing(first: Segment, second: Segment): Crossing | null {
const r = direction(first);
const s = direction(second);
const denominator = cross(r, s);
if (Math.abs(denominator) < 1e-12) return null;
const ac = displacement(first.a, second.a);
const t = cross(ac, s) / denominator;
const u = cross(ac, r) / denominator;
return { t, u, point: pointOn(first, t) };
}
/** Are the two parallel? Then `lineCrossing` cannot answer, whatever else is true of them. */
export function areParallel(first: Segment, second: Segment): boolean {
return Math.abs(cross(direction(first), direction(second))) < 1e-12;
}
/** Parallel **and** on the same line, which is the case that needs its own answer entirely. */
export function areCollinear(first: Segment, second: Segment): boolean {
if (!areParallel(first, second)) return false;
return (
Math.abs(cross(direction(first), displacement(first.a, second.a))) < 1e-12
);
}
/**
* Do two **collinear** segments overlap? The case the cross-product formula cannot reach.
*
* When two segments lie along the same line they either share infinitely many points or none, and
* "where do they cross" has no single answer - so the general formula divides by zero and returns
* `null`. Left there, two segments lying exactly on top of each other are reported as not touching,
* which is a wall a character can walk through only when perfectly aligned with it. Rare, reproducible,
* and maddening.
*
* Solved by projecting both onto the shared direction and comparing the two ranges - which is Section
* 5.1's range check, doing its third job.
*/
export function collinearOverlap(first: Segment, second: Segment): boolean {
if (!areCollinear(first, second)) return false;
const d = direction(first);
const lengthSq = lengthSquared(d);
if (lengthSq < 1e-12) {
// A degenerate first segment is a point: it overlaps if it lies on the second.
return distanceToSegment(second, first.a) < 1e-9;
}
const at = (p: Point) => dot(displacement(first.a, p), d) / lengthSq;
const lo = Math.min(at(second.a), at(second.b));
const hi = Math.max(at(second.a), at(second.b));
return hi >= 0 && lo <= 1;
}
/**
* Do two **segments** cross, and where? The parameters checked against $0 \le t,u \le 1$.
*
* Note both must be in range. Testing only `t` answers a different question - "does the first segment
* cross the infinite line of the second" - and that is the bug that makes a short wall block a sight
* line across the whole level.
*/
export function segmentCrossing(
first: Segment,
second: Segment,
): Crossing | null {
const crossing = lineCrossing(first, second);
if (crossing === null) return null;
return containsT(crossing.t, "segment") && containsT(crossing.u, "segment")
? crossing
: null;
}
/**
* Where a ray first meets a segment: $t \ge 0$ on the ray, $0 \le u \le 1$ on the segment.
*
* The asymmetry is the point. One shape is unbounded ahead and bounded behind; the other is bounded at
* both ends. Using the same test for both is how a ray ends up hitting things behind the shooter.
*/
export function rayHitsSegment(
origin: Point,
through: Point,
wall: Segment,
): Crossing | null {
const crossing = lineCrossing({ a: origin, b: through }, wall);
if (crossing === null) return null;
return containsT(crossing.t, "ray") && containsT(crossing.u, "segment")
? crossing
: null;
}
// ---- Line of sight ---------------------------------------------------------------------------
/**
* The first wall a sight line meets, or `null` for a clear view.
*
* "First" means smallest `t`, and taking the smallest is what makes this usable for anything beyond a
* yes-or-no answer - a laser that stops at the wall it hit, a bullet hole in the right surface. The
* collinear case is folded in: a sight line running exactly along a wall is blocked by it, which the
* general formula cannot report.
*/
export function firstBlocker(
from: Point,
to: Point,
walls: readonly Segment[],
): { wall: Segment; crossing: Crossing } | null {
const sight: Segment = { a: from, b: to };
let best: { wall: Segment; crossing: Crossing } | null = null;
for (const wall of walls) {
const crossing = segmentCrossing(sight, wall);
if (crossing === null) {
// Lying along a wall counts as blocked, and has no single crossing point to report.
if (collinearOverlap(sight, wall)) {
const grazing: Crossing = { t: 0, u: 0, point: from };
if (best === null) best = { wall, crossing: grazing };
}
continue;
}
if (best === null || crossing.t < best.crossing.t)
best = { wall, crossing };
}
return best;
}
/** Can one point see another, given some walls? The question a guard actually asks. */
export function hasLineOfSight(
from: Point,
to: Point,
walls: readonly Segment[],
): boolean {
return firstBlocker(from, to, walls) === null;
}
/**
* The first wall a sight line meets **if every wall is read as an infinite line**, or `null`.
*
* A wall is a segment. Read it as the line it lies on and it goes on for ever in both directions, so it
* blocks views that pass nowhere near it. Kept here beside the correct version so the build can count
* what that costs rather than the page asserting it matters.
*
* Returns the crossing rather than a boolean so a picture can mark **where** the phantom wall stopped
* the view - which is a more useful thing to see than the absence of a line.
*/
export function firstBlockerWrong(
from: Point,
to: Point,
walls: readonly Segment[],
): { wall: Segment; crossing: Crossing } | null {
const sight: Segment = { a: from, b: to };
let best: { wall: Segment; crossing: Crossing } | null = null;
for (const wall of walls) {
const crossing = lineCrossing(sight, wall);
// Only `t` is tested, so the wall's own ends are ignored entirely. That is the bug.
if (crossing === null || !containsT(crossing.t, "segment")) continue;
if (best === null || crossing.t < best.crossing.t)
best = { wall, crossing };
}
return best;
}
/** The same question as a boolean, for sweeping. */
export function hasLineOfSightWrong(
from: Point,
to: Point,
walls: readonly Segment[],
): boolean {
return firstBlockerWrong(from, to, walls) === null;
} Where This Shows Up
Section titled “Where This Shows Up”- Section 5.3, where the separating axis test projects polygons onto axes — the same projection, used to compare ranges rather than to find a point.
- Section 5.4, which needs the nearest point on a wall to work out which way to push a character off it, and needs it to be the nearest point on the segment.
- Section 5.1’s clamp, generalised from a coordinate to a parameter.
- Section 2.1’s cross product, doing the crossing arithmetic.
- The capstone, where a character’s swept motion is a segment against tile edges.