From cc721772b06e8c9334603f0951e342eaf58292e4 Mon Sep 17 00:00:00 2001 From: Francois Date: Thu, 9 Oct 2025 14:16:44 +0900 Subject: [PATCH] feat(spatial): add raycasting utilities (raycastSegment, raycastAabb); docs + tests + example (rebase) --- docs/index.d.ts | 45 ++++++++++++++++++++++++++++ examples/raycast.ts | 15 ++++++++++ src/index.ts | 16 ++++++++++ src/spatial/raycast.ts | 68 ++++++++++++++++++++++++++++++++++++++++++ tests/raycast.test.ts | 42 ++++++++++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 examples/raycast.ts create mode 100644 src/spatial/raycast.ts create mode 100644 tests/raycast.test.ts diff --git a/docs/index.d.ts b/docs/index.d.ts index b1e0edd..7dc08f3 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -5,6 +5,7 @@ // - 🎮 Pathfinding & Navigation → astar, dijkstra (examples/astar.ts) // - 🌍 Procedural Generation → perlin, simplex2D/3D, worley (examples/simplex.ts, examples/worley.ts) // - 🎯 Spatial & Collision → quadtree, aabb, sat, circleRayIntersection, sweptAABB (examples/sat.ts) +// plus circleCollision, circleAabbCollision, circleSegmentIntersection (examples/circle.ts), raycastSegment/Aabb (examples/raycast.ts) // - 🤖 AI & Behaviour → seek/flee/arrive/pursue/wander, updateBoids, BehaviorTree, rvoStep (examples/steering.ts, examples/boids.ts, examples/rvo.ts) // - ⚡ Web Performance → debounce, throttle, LRUCache, memoize, deduplicateRequest, virtual scroll (examples/requestDedup.ts, examples/virtualScroll.ts) // - 🔍 Search & Text → fuzzySearch, fuzzyScore, Trie, binarySearch, levenshteinDistance @@ -797,6 +798,50 @@ export function satCollision(polygonA: Point[], polygonB: Point[]): CollisionMan */ export function circleRayIntersection(ray: Ray, circle: Circle): Point[]; +/** + * Circle vs circle overlap test. + * Use for: simple collision checks, proximity triggers. + * Performance: O(1). + * Import: spatial/circleCollision.ts + */ +export function circleCollision(a: Circle, b: Circle): boolean; + +/** + * Circle vs axis-aligned rectangle (AABB) intersection. + * Use for: tile collisions, UI hit-tests, broad-phase pruning. + * Performance: O(1). + * Import: spatial/circleCollision.ts + */ +export function circleAabbCollision(circle: Circle, rect: Rect): boolean; + +/** + * Circle vs line segment intersection test. + * Use for: ray/segment hits, visibility checks, bullet tests. + * Performance: O(1). + * Import: spatial/circleCollision.ts + */ +export function circleSegmentIntersection(circle: Circle, a: Point, b: Point): boolean; + +/** + * Ray vs segment intersection returning closest hit. + * Use for: visibility checks, line-of-sight, editor picking. + * Performance: O(1). + * Import: spatial/raycast.ts + */ +export interface RaycastHit { + point: Point; + distance: number; +} +export function raycastSegment(ray: Ray, a: Point, b: Point): RaycastHit | null; + +/** + * Ray vs AABB intersection using slabs method. + * Use for: fast occlusion tests, spatial queries, physics sweeps. + * Performance: O(1). + * Import: spatial/raycast.ts + */ +export function raycastAabb(ray: Ray, rect: Rect): RaycastHit | null; + /** * Swept AABB collision detection for moving rectangles. * Use for: continuous collisions, fast projectiles, platformer physics. diff --git a/examples/raycast.ts b/examples/raycast.ts new file mode 100644 index 0000000..c81d44e --- /dev/null +++ b/examples/raycast.ts @@ -0,0 +1,15 @@ +import { raycastSegment, raycastAabb } from '../src/index.js'; + +const hit1 = raycastSegment( + { origin: { x: 0, y: 0 }, direction: { x: 1, y: 0 } }, + { x: 5, y: -1 }, + { x: 5, y: 1 } +); +console.log('raycastSegment hit:', hit1); + +const hit2 = raycastAabb( + { origin: { x: -5, y: 0 }, direction: { x: 1, y: 0 } }, + { x: 0, y: -1, width: 2, height: 2 } +); +console.log('raycastAabb hit:', hit2); + diff --git a/src/index.ts b/src/index.ts index 9d142bb..2d44e46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,8 @@ export const examples = { circleCollision: 'examples/circle.ts', circleAabbCollision: 'examples/circle.ts', circleSegmentIntersection: 'examples/circle.ts', + raycastSegment: 'examples/raycast.ts', + raycastAabb: 'examples/raycast.ts', sweptAABB: 'examples/sweptAabb.ts', }, search: { @@ -410,6 +412,7 @@ export { satCollision } from './spatial/sat.js'; */ export { circleRayIntersection } from './spatial/circleRay.js'; /** +<<<<<<< HEAD * Fast circle-circle overlap test. * * Example file: examples/circle.ts @@ -427,6 +430,19 @@ export { circleAabbCollision } from './spatial/circleCollision.js'; * Example file: examples/circle.ts */ export { circleSegmentIntersection } from './spatial/circleCollision.js'; +======= + * Ray vs. segment intersection test returning closest hit. + * + * Example file: examples/raycast.ts + */ +export { raycastSegment } from './spatial/raycast.js'; +/** + * Ray vs. AABB intersection using slabs method. + * + * Example file: examples/raycast.ts + */ +export { raycastAabb } from './spatial/raycast.js'; +>>>>>>> 83f962b (feat(spatial): add raycasting utilities (raycastSegment, raycastAabb); docs + tests + example) /** * Continuous swept AABB collision detection for moving boxes. diff --git a/src/spatial/raycast.ts b/src/spatial/raycast.ts new file mode 100644 index 0000000..652f2cd --- /dev/null +++ b/src/spatial/raycast.ts @@ -0,0 +1,68 @@ +import type { Point, Ray, Rect } from '../types.js'; + +export interface RaycastHit { + point: Point; + distance: number; // distance from ray.origin to hit point +} + +/** + * Ray vs. segment intersection using 2D cross products. + * Returns the closest hit on the segment if it exists. + */ +export function raycastSegment(ray: Ray, a: Point, b: Point): RaycastHit | null { + const r = ray.direction; + const s = { x: b.x - a.x, y: b.y - a.y }; + const rxs = cross(r, s); + const qp = { x: a.x - ray.origin.x, y: a.y - ray.origin.y }; + + if (Math.abs(rxs) < 1e-12) { + // Parallel or collinear: treat as no hit for simplicity + return null; + } + + const t = cross(qp, s) / rxs; // along ray r + const u = cross(qp, r) / rxs; // along segment s + + if (t >= 0 && u >= 0 && u <= 1) { + const hitPoint: Point = { x: ray.origin.x + r.x * t, y: ray.origin.y + r.y * t }; + return { point: hitPoint, distance: Math.hypot(hitPoint.x - ray.origin.x, hitPoint.y - ray.origin.y) }; + } + + return null; +} + +/** + * Ray vs. AABB intersection using the slabs method. + * Returns the nearest hit point if any exists in front of origin. + */ +export function raycastAabb(ray: Ray, rect: Rect): RaycastHit | null { + const dir = ray.direction; + const invDirX = 1 / (dir.x || 1e-12); + const invDirY = 1 / (dir.y || 1e-12); + + const t1 = (rect.x - ray.origin.x) * invDirX; + const t2 = (rect.x + rect.width - ray.origin.x) * invDirX; + const t3 = (rect.y - ray.origin.y) * invDirY; + const t4 = (rect.y + rect.height - ray.origin.y) * invDirY; + + const tmin = Math.max(Math.min(t1, t2), Math.min(t3, t4)); + const tmax = Math.min(Math.max(t1, t2), Math.max(t3, t4)); + + if (tmax < 0 || tmin > tmax) { + return null; // box behind ray or no intersection + } + + const tHit = tmin >= 0 ? tmin : tmax >= 0 ? tmax : null; + if (tHit === null) { + return null; + } + const hitPoint: Point = { x: ray.origin.x + dir.x * tHit, y: ray.origin.y + dir.y * tHit }; + return { point: hitPoint, distance: Math.hypot(hitPoint.x - ray.origin.x, hitPoint.y - ray.origin.y) }; +} + +function cross(a: { x: number; y: number }, b: { x: number; y: number }): number { + return a.x * b.y + 0 - a.y * b.x; +} + +export const __internals = { cross }; + diff --git a/tests/raycast.test.ts b/tests/raycast.test.ts new file mode 100644 index 0000000..18aaa6f --- /dev/null +++ b/tests/raycast.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { raycastSegment, raycastAabb } from '../src/index.js'; + +describe('raycasting utilities', () => { + it('raycastSegment hits segment in front of origin', () => { + const hit = raycastSegment( + { origin: { x: 0, y: 0 }, direction: { x: 1, y: 0 } }, + { x: 5, y: -1 }, + { x: 5, y: 1 } + ); + expect(hit).toBeTruthy(); + expect(hit && Math.round(hit.distance)).toBe(5); + }); + + it('raycastSegment misses parallel/behind segments', () => { + // Parallel segment + const miss1 = raycastSegment( + { origin: { x: 0, y: 0 }, direction: { x: 1, y: 0 } }, + { x: 0, y: 5 }, + { x: 5, y: 5 } + ); + expect(miss1).toBeNull(); + + // Segment behind the origin + const miss2 = raycastSegment( + { origin: { x: 0, y: 0 }, direction: { x: 1, y: 0 } }, + { x: -5, y: -1 }, + { x: -5, y: 1 } + ); + expect(miss2).toBeNull(); + }); + + it('raycastAabb detects nearest hit', () => { + const hit = raycastAabb( + { origin: { x: -5, y: 0 }, direction: { x: 1, y: 0 } }, + { x: 0, y: -1, width: 2, height: 2 } + ); + expect(hit).toBeTruthy(); + expect(hit && Math.round(hit.distance)).toBe(5); + }); +}); +