Files
feuerwerk/src/core/properties/ray.ts
T
adminandClaude Opus 5 ae3aa7d3bd
game-maker / gate (push) Successful in 2m0s
game-maker / suite (push) Successful in 7m17s
Refactoring round 5 covers the code since round 4 without changing behaviour: four identical copies of dot() and three of "nearest point on a segment" stood in the collision math, in the hit area of the collider outlines and in the polygon handles, so they moved into the new module src/core/util/vec.ts; the warning line, the busy state, info() and the caret reset were written out line by line in both the sign-in screen and the password dialog and are now FormStatus; the closing row "Cancel plus a confirming button, optionally one on the left" was built by hand in FormDialog, FilePicker, OpenProjectDialog and PasswordDialog and is now dialogButtons(); the header bar was drawn by the toolbar, the TabHeader and the Breadcrumb each on its own and is now drawHeaderBar(); AddColliderCommand and AddRigidbodyCommand share a base, each subclass only builds its own asset; Editor.ts derives the panes from assetEditors instead of repeating eight entries and clamps its splitters with clamp(); jscpd over src falls from 0.19 % to 0.14 %; dead code is gone - listScenes() has been orphaned since the Scene picker fell away (#183), DEFAULT_FILTER (#152) was never used, the two test hooks projectPickerRowAt/rowPosition and particleCompiles are read by no tool, and four functions lost an export they did not need; seven comments that told history ("used to", "no longer", "since #...") describe the state of things now, and that this changed nothing was proved by a rebuild - the bundle is md5 identical, b4ad0dfe... before and after; the README said Logout sits at the far right of the toolbar, which has been the Account menu since #182, and wiki/Architecture.md was missing src/core/util/ altogether; 4 new unit tests cover vec.ts, and switching the clamp on the segment off makes exactly one of them fail while none of the 76 neighbouring tests notice, so that clamping was covered nowhere before (#85)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 17:24:42 +02:00

136 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Where a ray meets a collider shape (#153), in f64 like the rest of the collision, so the editor run
* and the played game answer alike and always about where things are now. Every shape is taken as
* its rounded convex pieces (see `convex.ts`), and each piece as convex parts a ray is easy to meet:
* the core polygon, a circle at each corner and a band along each edge as wide as the radius.
*/
import type { Vec2 } from '../model/Properties';
import { dot } from '../util/vec';
import { signedArea } from '../model/polygon';
import { type ColliderShape, piecesOf } from './collider';
import { type Rounded, roundedContact } from './convex';
export interface RayHit {
/** How far along the ray it met the shape (world units). */
distance: number;
/** The face it met, pointing out of the shape. */
normal: Vec2;
}
/** A start this deep inside a shape is inside it; on its outline it is not. */
const INSIDE = 1e-9;
type Part = { kind: 'circle'; c: Vec2; r: number } | { kind: 'polygon'; points: readonly Vec2[] };
/**
* Where the ray from `from` along the unit direction `dir` first meets `shape` within `length`, or
* null. A shape the ray starts inside is not met – so a ray cast from the middle of the thing casting
* it finds what is around, not itself; starting on its outline and running in does meet it, at 0.
* Grazing a corner counts as meeting it.
*/
export function rayCast(shape: ColliderShape, from: Vec2, dir: Vec2, length: number): RayHit | null {
const pieces = piecesOf(shape);
const start: Rounded = { points: [from], r: 0 };
if (pieces.some((piece) => roundedContact(start, piece, dir).separation < -INSIDE)) return null;
let best: RayHit | null = null;
for (const piece of pieces)
for (const part of partsOf(piece)) {
const hit =
part.kind === 'circle'
? rayCircle(part, from, dir, length)
: rayPolygon(part.points, from, dir, length);
if (hit && (!best || hit.distance < best.distance)) best = hit;
}
return best;
}
/** The parts whose union is the rounded piece. */
function partsOf(piece: Rounded): Part[] {
const { points, r } = piece;
if (r <= 0) return points.length >= 3 ? [{ kind: 'polygon', points }] : [];
const parts: Part[] = points.map((c) => ({ kind: 'circle', c, r }));
if (points.length === 2) {
const [a, b] = points as [Vec2, Vec2];
const n = normalOf(a, b);
if (n) {
const off = { x: n.x * r, y: n.y * r };
parts.push({
kind: 'polygon',
points: [
{ x: a.x + off.x, y: a.y + off.y },
{ x: b.x + off.x, y: b.y + off.y },
{ x: b.x - off.x, y: b.y - off.y },
{ x: a.x - off.x, y: a.y - off.y },
],
});
}
}
if (points.length >= 3) {
parts.push({ kind: 'polygon', points });
const outward = signedArea(points) > 0 ? 1 : -1;
points.forEach((a, i) => {
const b = points[(i + 1) % points.length]!;
const n = normalOf(a, b);
if (!n) return;
const off = { x: outward * n.x * r, y: outward * n.y * r };
parts.push({
kind: 'polygon',
points: [a, b, { x: b.x + off.x, y: b.y + off.y }, { x: a.x + off.x, y: a.y + off.y }],
});
});
}
return parts;
}
/** The unit normal of ab turned the way a positive signed area turns outward; null for no length. */
function normalOf(a: Vec2, b: Vec2): Vec2 | null {
const d = { x: b.x - a.x, y: b.y - a.y };
const length = Math.hypot(d.x, d.y);
return length > 0 ? { x: d.y / length, y: -d.x / length } : null;
}
function rayCircle({ c, r }: { c: Vec2; r: number }, from: Vec2, dir: Vec2, length: number): RayHit | null {
const m = { x: from.x - c.x, y: from.y - c.y };
const b = dot(m, dir);
const k = dot(m, m) - r * r;
const disc = b * b - k;
if (disc < 0) return null;
const t = -b - Math.sqrt(disc);
if (t < -INSIDE || t > length) return null;
const at = Math.max(0, t);
return { distance: at, normal: { x: (m.x + dir.x * at) / r + 0, y: (m.y + dir.y * at) / r + 0 } };
}
/**
* A convex polygon, whichever way round its corners run: the ray is clipped by every edge's half
* plane, and the last edge it comes in through is the one it meets.
*/
function rayPolygon(points: readonly Vec2[], from: Vec2, dir: Vec2, length: number): RayHit | null {
const outward = signedArea(points) > 0 ? 1 : -1;
let enter = -Infinity;
let leave = Infinity;
let normal: Vec2 | null = null;
for (let i = 0; i < points.length; i++) {
const a = points[i]!;
const n0 = normalOf(a, points[(i + 1) % points.length]!);
if (!n0) continue;
const n = { x: outward * n0.x, y: outward * n0.y };
// Inside this edge's half plane: dot(n, from + t·dir − a) ≤ 0.
const gap = dot(n, { x: a.x - from.x, y: a.y - from.y });
const speed = dot(n, dir);
if (Math.abs(speed) < 1e-12) {
if (gap < 0) return null;
continue;
}
const t = gap / speed;
if (speed < 0) {
if (t > enter) {
enter = t;
normal = n;
}
} else leave = Math.min(leave, t);
}
if (!normal || enter > leave || enter < -INSIDE || enter > length) return null;
return { distance: Math.max(0, enter), normal: { x: normal.x + 0, y: normal.y + 0 } };
}