Skip to content

Commit 81b86af

Browse files
committed
feat: Implement Ray class for 2D raycasting and intersection tests with various shapes
1 parent 872295f commit 81b86af

File tree

1 file changed

+391
-0
lines changed

1 file changed

+391
-0
lines changed

src/core/math/Ray.ts

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
import { Vector } from "./Vector";
2+
import { Line } from "./Line";
3+
import { Circle } from "./Circle";
4+
import { Rectangle } from "./Rectangle";
5+
import { Ellipse } from "./Ellipse";
6+
import { Polygon } from "./Polygon";
7+
import { Shape } from "./Shape";
8+
9+
/**
10+
* Represents the result of a ray intersection test
11+
*/
12+
export interface RaycastHit {
13+
/** The point where the ray hit */
14+
point: Vector;
15+
/** Distance from ray origin to hit point */
16+
distance: number;
17+
/** Normal vector at the hit point (perpendicular to surface) */
18+
normal: Vector;
19+
/** The shape that was hit */
20+
shape?: Shape;
21+
/** Parameter t where ray hit (distance along ray direction) */
22+
t: number;
23+
}
24+
25+
/**
26+
* Represents a 2D ray with an origin and direction.
27+
* Useful for raycasting, line-of-sight checks, click detection, projectiles, etc.
28+
*/
29+
export class Ray {
30+
private readonly origin: Vector;
31+
private readonly direction: Vector;
32+
33+
constructor(origin: Vector, direction: Vector) {
34+
this.origin = origin;
35+
this.direction = direction.normalize(); // Always normalized
36+
}
37+
38+
/**
39+
* Creates a ray from origin pointing towards a target point
40+
*/
41+
public static fromPoints(origin: Vector, target: Vector): Ray {
42+
const direction = target.subtract(origin);
43+
return new Ray(origin, direction);
44+
}
45+
46+
/**
47+
* Creates a ray from origin with an angle in degrees
48+
*/
49+
public static fromAngle(origin: Vector, angle: number): Ray {
50+
const direction = Vector.ofAngle(angle);
51+
return new Ray(origin, direction);
52+
}
53+
54+
/**
55+
* Gets a point along the ray at distance t
56+
*/
57+
public getPoint(t: number): Vector {
58+
return this.origin.add(this.direction.multiply(t));
59+
}
60+
61+
/**
62+
* Casts the ray against a line segment
63+
*/
64+
public castLine(line: Line): RaycastHit | null {
65+
const start = line.getStart();
66+
const end = line.getEnd();
67+
68+
const lineDir = end.subtract(start);
69+
const v1 = this.origin.subtract(start);
70+
const v2 = new Vector(-this.direction.y, this.direction.x);
71+
const v3 = new Vector(-lineDir.y, lineDir.x);
72+
73+
const dot = lineDir.dot(v2);
74+
if (Math.abs(dot) < 0.000001) {
75+
return null; // Parallel
76+
}
77+
78+
const t1 = lineDir.perpDot(v1) / dot;
79+
const t2 = v1.dot(v2) / dot;
80+
81+
if (t1 >= 0 && t2 >= 0 && t2 <= 1) {
82+
const point = this.getPoint(t1);
83+
const distance = this.origin.distanceBetween(point);
84+
85+
// Normal is perpendicular to line direction
86+
const normal = new Vector(-lineDir.y, lineDir.x).normalize();
87+
88+
return {
89+
point,
90+
distance,
91+
normal,
92+
t: t1
93+
};
94+
}
95+
96+
return null;
97+
}
98+
99+
/**
100+
* Casts the ray against a circle
101+
*/
102+
public castCircle(circle: Circle): RaycastHit | null {
103+
const center = circle.getPosition();
104+
const radius = circle.getRadius();
105+
106+
const oc = this.origin.subtract(center);
107+
const a = this.direction.dot(this.direction);
108+
const b = 2 * oc.dot(this.direction);
109+
const c = oc.dot(oc) - radius * radius;
110+
111+
const discriminant = b * b - 4 * a * c;
112+
113+
if (discriminant < 0) {
114+
return null; // No intersection
115+
}
116+
117+
const sqrt = Math.sqrt(discriminant);
118+
let t = (-b - sqrt) / (2 * a);
119+
120+
// Use the closer intersection point
121+
if (t < 0) {
122+
t = (-b + sqrt) / (2 * a);
123+
}
124+
125+
if (t < 0) {
126+
return null; // Circle is behind ray
127+
}
128+
129+
const point = this.getPoint(t);
130+
const distance = this.origin.distanceBetween(point);
131+
const normal = point.subtract(center).normalize();
132+
133+
return {
134+
point,
135+
distance,
136+
normal,
137+
shape: circle,
138+
t
139+
};
140+
}
141+
142+
/**
143+
* Casts the ray against a rectangle
144+
*/
145+
public castRectangle(rectangle: Rectangle): RaycastHit | null {
146+
const sides = rectangle.getSides();
147+
const hits: RaycastHit[] = [];
148+
149+
// Check all four sides
150+
for (const side of [sides.top, sides.right, sides.bottom, sides.left]) {
151+
const hit = this.castLine(side);
152+
if (hit) {
153+
hits.push(hit);
154+
}
155+
}
156+
157+
if (hits.length === 0) {
158+
return null;
159+
}
160+
161+
// Return the closest hit
162+
hits.sort((a, b) => a.distance - b.distance);
163+
return { ...hits[0], shape: rectangle };
164+
}
165+
166+
/**
167+
* Casts the ray against an ellipse
168+
*/
169+
public castEllipse(ellipse: Ellipse): RaycastHit | null {
170+
// Transform ray to ellipse's local coordinate system
171+
const center = ellipse.getCenter();
172+
const radiusX = ellipse.getRadiusX();
173+
const radiusY = ellipse.getRadiusY();
174+
const rotation = ellipse.getRotation();
175+
176+
// Simplified approach: sample the ellipse border
177+
const samples = 64;
178+
let closestHit: RaycastHit | null = null;
179+
let closestDistance = Infinity;
180+
181+
for (let i = 0; i < samples; i++) {
182+
const angle1 = (360 / samples) * i;
183+
const angle2 = (360 / samples) * ((i + 1) % samples);
184+
185+
const p1 = ellipse.getBorderPoint(angle1);
186+
const p2 = ellipse.getBorderPoint(angle2);
187+
188+
const segment = Line.ofPoints(p1, p2);
189+
const hit = this.castLine(segment);
190+
191+
if (hit && hit.distance < closestDistance) {
192+
closestDistance = hit.distance;
193+
closestHit = hit;
194+
}
195+
}
196+
197+
return closestHit ? { ...closestHit, shape: ellipse } : null;
198+
}
199+
200+
/**
201+
* Casts the ray against a polygon
202+
*/
203+
public castPolygon(polygon: Polygon): RaycastHit | null {
204+
const sides = polygon.getSides();
205+
const hits: RaycastHit[] = [];
206+
207+
for (const side of sides) {
208+
const hit = this.castLine(side);
209+
if (hit) {
210+
hits.push(hit);
211+
}
212+
}
213+
214+
if (hits.length === 0) {
215+
return null;
216+
}
217+
218+
// Return the closest hit
219+
hits.sort((a, b) => a.distance - b.distance);
220+
return { ...hits[0], shape: polygon };
221+
}
222+
223+
/**
224+
* Casts the ray against any shape
225+
*/
226+
public cast(shape: Shape): RaycastHit | null {
227+
if (shape instanceof Line) {
228+
return this.castLine(shape);
229+
} else if (shape instanceof Circle) {
230+
return this.castCircle(shape);
231+
} else if (shape instanceof Rectangle) {
232+
return this.castRectangle(shape);
233+
} else if (shape instanceof Ellipse) {
234+
return this.castEllipse(shape);
235+
} else if (shape instanceof Polygon) {
236+
return this.castPolygon(shape);
237+
}
238+
239+
return null;
240+
}
241+
242+
/**
243+
* Casts the ray against multiple shapes and returns the closest hit
244+
*/
245+
public castMultiple(shapes: Shape[]): RaycastHit | null {
246+
const hits: RaycastHit[] = [];
247+
248+
for (const shape of shapes) {
249+
const hit = this.cast(shape);
250+
if (hit) {
251+
hits.push(hit);
252+
}
253+
}
254+
255+
if (hits.length === 0) {
256+
return null;
257+
}
258+
259+
// Return the closest hit
260+
hits.sort((a, b) => a.distance - b.distance);
261+
return hits[0];
262+
}
263+
264+
/**
265+
* Casts the ray against multiple shapes and returns all hits sorted by distance
266+
*/
267+
public castAll(shapes: Shape[]): RaycastHit[] {
268+
const hits: RaycastHit[] = [];
269+
270+
for (const shape of shapes) {
271+
const hit = this.cast(shape);
272+
if (hit) {
273+
hits.push(hit);
274+
}
275+
}
276+
277+
hits.sort((a, b) => a.distance - b.distance);
278+
return hits;
279+
}
280+
281+
/**
282+
* Checks if the ray intersects any shape within maxDistance
283+
*/
284+
public intersects(shape: Shape, maxDistance?: number): boolean {
285+
const hit = this.cast(shape);
286+
287+
if (!hit) {
288+
return false;
289+
}
290+
291+
if (maxDistance !== undefined) {
292+
return hit.distance <= maxDistance;
293+
}
294+
295+
return true;
296+
}
297+
298+
/**
299+
* Reflects the ray off a surface normal
300+
*/
301+
public reflect(normal: Vector): Ray {
302+
// R = D - 2(D·N)N
303+
const dotProduct = this.direction.dot(normal);
304+
const reflection = this.direction.subtract(normal.multiply(2 * dotProduct));
305+
306+
return new Ray(this.origin, reflection);
307+
}
308+
309+
/**
310+
* Creates a ray reflected off a hit point
311+
*/
312+
public reflectFromHit(hit: RaycastHit): Ray {
313+
const reflection = this.direction.subtract(hit.normal.multiply(2 * this.direction.dot(hit.normal)));
314+
return new Ray(hit.point, reflection);
315+
}
316+
317+
/**
318+
* Gets the origin of the ray
319+
*/
320+
public getOrigin(): Vector {
321+
return this.origin;
322+
}
323+
324+
/**
325+
* Gets the direction of the ray (normalized)
326+
*/
327+
public getDirection(): Vector {
328+
return this.direction;
329+
}
330+
331+
/**
332+
* Gets the angle of the ray in degrees
333+
*/
334+
public getAngle(): number {
335+
return this.direction.heading();
336+
}
337+
338+
/**
339+
* Creates a line segment from the ray with a maximum length
340+
*/
341+
public toLine(maxLength: number = 1000): Line {
342+
const end = this.getPoint(maxLength);
343+
return Line.ofPoints(this.origin, end);
344+
}
345+
346+
/**
347+
* Checks if a point is on the ray (within tolerance)
348+
*/
349+
public containsPoint(point: Vector, tolerance: number = 0.001): boolean {
350+
const toPoint = point.subtract(this.origin);
351+
const projection = toPoint.dot(this.direction);
352+
353+
if (projection < 0) {
354+
return false; // Point is behind the ray
355+
}
356+
357+
const closestPoint = this.getPoint(projection);
358+
const distance = point.distanceBetween(closestPoint);
359+
360+
return distance <= tolerance;
361+
}
362+
363+
/**
364+
* Gets the distance from a point to the ray
365+
*/
366+
public distanceToPoint(point: Vector): number {
367+
const toPoint = point.subtract(this.origin);
368+
const projection = toPoint.dot(this.direction);
369+
370+
if (projection < 0) {
371+
return this.origin.distanceBetween(point);
372+
}
373+
374+
const closestPoint = this.getPoint(projection);
375+
return point.distanceBetween(closestPoint);
376+
}
377+
378+
/**
379+
* Gets the closest point on the ray to a given point
380+
*/
381+
public getClosestPoint(point: Vector): Vector {
382+
const toPoint = point.subtract(this.origin);
383+
const projection = toPoint.dot(this.direction);
384+
385+
if (projection < 0) {
386+
return this.origin;
387+
}
388+
389+
return this.getPoint(projection);
390+
}
391+
}

0 commit comments

Comments
 (0)