diff --git a/.eslintrc.json b/.eslintrc.json index dfa0197..c56999e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,5 +21,5 @@ "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-non-null-assertion": "off" }, - "ignorePatterns": ["dist/", "node_modules/", "js/", "*.mjs"] + "ignorePatterns": ["dist/", "node_modules/", "js/", "*.mjs", "*.js"] } diff --git a/.gitignore b/.gitignore index 0683586..2524db6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ npm-debug.log dist/ .claude/settings.local.json CLAUDE.md +dist/main3d.bundle.js +dist/main3d.bundle.js +dist/main3d.js.map diff --git a/README.md b/README.md index 7391430..a56cf7f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,17 @@ const solver = new Solver(walls, source, 4); // 4 = max reflection order // Find paths to listener const listener = new Listener([60, 60]); const paths = solver.getPaths(listener); + +// Get detailed path information with angles +const detailedPaths = solver.getDetailedPaths(listener); +for (const path of detailedPaths) { + console.log(`Path length: ${path.totalPathLength.toFixed(2)}m`); + console.log(`Reflections: ${path.reflectionCount}`); + for (const reflection of path.reflections) { + const angleInDegrees = reflection.incidenceAngle * (180 / Math.PI); + console.log(` Wall ${reflection.wallId}: angle ${angleInDegrees.toFixed(1)}°`); + } +} ``` ### 3D Beam Tracing @@ -160,6 +171,61 @@ src/ ## API Reference +### 2D Types + +```typescript +type Point = [number, number]; +type PathPoint = [number, number, number | null]; // [x, y, wallId] +type ReflectionPath = PathPoint[]; + +interface ReflectionDetail { + wall: Wall; // The wall that was hit + wallId: number; // Index of the wall + hitPoint: Point; // Point where reflection occurred + incidenceAngle: number; // Angle of incidence (radians) + reflectionAngle: number; // Angle of reflection (radians) + incomingDirection: Point; // Normalized incoming ray direction + outgoingDirection: Point; // Normalized outgoing ray direction + wallNormal: Point; // Wall normal (toward incoming ray) + reflectionOrder: number; // Which reflection (1 = first, 2 = second, etc.) + wallPosition: number; // Parametric position on wall (0 = p1, 1 = p2) + cumulativeDistance: number; // Distance traveled up to this reflection + incomingSegmentLength: number; // Length of incoming segment + isGrazing: boolean; // True if angle near 90° (numerically unstable) +} + +interface SegmentDetail { + startPoint: Point; // Start of segment + endPoint: Point; // End of segment + length: number; // Segment length + segmentIndex: number; // Index (0 = first segment from listener) +} + +interface DetailedReflectionPath { + listenerPosition: Point; // Start point + sourcePosition: Point; // End point + totalPathLength: number; // Total path distance + reflectionCount: number; // Number of reflections + reflections: ReflectionDetail[]; // Details for each reflection + segments: SegmentDetail[]; // Details for each path segment + simplePath: ReflectionPath; // Original path representation +} +``` + +### 2D Classes + +- `Wall(p1: Point, p2: Point)` - Wall segment defined by two endpoints +- `Source(position: Point)` - Sound source position +- `Listener(position: Point)` - Listener position +- `Solver(walls, source, reflectionOrder?)` - Main solver + +#### Solver Methods (2D) + +| Method | Returns | Description | +|--------|---------|-------------| +| `getPaths(listener)` | `ReflectionPath[]` | Find all valid reflection paths | +| `getDetailedPaths(listener)` | `DetailedReflectionPath[]` | Find paths with full reflection details including angles | + ### 3D Types ```typescript diff --git a/src/__tests__/beamtrace2d.test.ts b/src/__tests__/beamtrace2d.test.ts index 5e40b6f..4519bc0 100644 --- a/src/__tests__/beamtrace2d.test.ts +++ b/src/__tests__/beamtrace2d.test.ts @@ -189,4 +189,534 @@ describe('BeamTrace2D', () => { } }); }); + + describe('Solver.getDetailedPaths', () => { + it('returns an array of detailed paths', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([50, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([60, 60]); + + const detailedPaths = solver.getDetailedPaths(listener); + expect(Array.isArray(detailedPaths)).toBe(true); + }); + + it('returns same number of paths as getPaths', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([50, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([60, 60]); + + const simplePaths = solver.getPaths(listener); + const detailedPaths = solver.getDetailedPaths(listener); + + expect(detailedPaths.length).toBe(simplePaths.length); + }); + + it('detailed path has correct structure', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([50, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([60, 60]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + // Check structure + expect(path.listenerPosition).toBeDefined(); + expect(path.sourcePosition).toBeDefined(); + expect(typeof path.totalPathLength).toBe('number'); + expect(typeof path.reflectionCount).toBe('number'); + expect(Array.isArray(path.reflections)).toBe(true); + expect(Array.isArray(path.simplePath)).toBe(true); + + // Listener position should match + expect(path.listenerPosition[0]).toBe(60); + expect(path.listenerPosition[1]).toBe(60); + + // Source position should match + expect(path.sourcePosition[0]).toBe(50); + expect(path.sourcePosition[1]).toBe(50); + } + }); + + it('direct path has zero reflections', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([50, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([60, 60]); + + const detailedPaths = solver.getDetailedPaths(listener); + + // Find the direct path + const directPath = detailedPaths.find(p => p.reflectionCount === 0); + expect(directPath).toBeDefined(); + expect(directPath!.reflections.length).toBe(0); + }); + + it('reflection details have correct structure', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + // Find a path with at least one reflection + const pathWithReflection = detailedPaths.find(p => p.reflectionCount > 0); + expect(pathWithReflection).toBeDefined(); + + for (const reflection of pathWithReflection!.reflections) { + // Check all required fields + expect(reflection.wall).toBeDefined(); + expect(typeof reflection.wallId).toBe('number'); + expect(Array.isArray(reflection.hitPoint)).toBe(true); + expect(reflection.hitPoint.length).toBe(2); + expect(typeof reflection.incidenceAngle).toBe('number'); + expect(typeof reflection.reflectionAngle).toBe('number'); + expect(Array.isArray(reflection.incomingDirection)).toBe(true); + expect(reflection.incomingDirection.length).toBe(2); + expect(Array.isArray(reflection.outgoingDirection)).toBe(true); + expect(reflection.outgoingDirection.length).toBe(2); + expect(Array.isArray(reflection.wallNormal)).toBe(true); + expect(reflection.wallNormal.length).toBe(2); + + // Wall reference should match wallId + expect(reflection.wall).toBe(walls[reflection.wallId]); + } + }); + + it('incidence angle equals reflection angle for specular reflection', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + expect(reflection.incidenceAngle).toBeCloseTo(reflection.reflectionAngle, 10); + } + } + }); + + it('angles are between 0 and PI/2 for valid reflections', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + expect(reflection.incidenceAngle).toBeGreaterThanOrEqual(0); + expect(reflection.incidenceAngle).toBeLessThanOrEqual(Math.PI / 2 + 0.01); // Small tolerance + } + } + }); + + it('direction vectors are normalized', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + // Check incoming direction is normalized + const inLen = Math.sqrt( + reflection.incomingDirection[0] ** 2 + reflection.incomingDirection[1] ** 2 + ); + expect(inLen).toBeCloseTo(1, 10); + + // Check outgoing direction is normalized + const outLen = Math.sqrt( + reflection.outgoingDirection[0] ** 2 + reflection.outgoingDirection[1] ** 2 + ); + expect(outLen).toBeCloseTo(1, 10); + + // Check wall normal is normalized + const normLen = Math.sqrt( + reflection.wallNormal[0] ** 2 + reflection.wallNormal[1] ** 2 + ); + expect(normLen).toBeCloseTo(1, 10); + } + } + }); + + it('total path length is positive and consistent', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([50, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([60, 60]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + expect(path.totalPathLength).toBeGreaterThan(0); + + // Calculate expected length from simple path + let expectedLength = 0; + for (let i = 0; i < path.simplePath.length - 1; i++) { + const p1 = path.simplePath[i]; + const p2 = path.simplePath[i + 1]; + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + expectedLength += Math.sqrt(dx * dx + dy * dy); + } + + expect(path.totalPathLength).toBeCloseTo(expectedLength, 10); + } + }); + + it('reflection count matches number of reflections array length', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + expect(path.reflectionCount).toBe(path.reflections.length); + } + }); + + it('throws error when listener is not provided', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + ]; + const source = new Source([50, 50]); + const solver = new Solver(walls, source, 2); + + // @ts-expect-error - testing runtime behavior with invalid input + expect(() => solver.getDetailedPaths(undefined)).toThrow('BeamTrace2D: listener is required'); + }); + + it('simplePath in detailed result matches original path', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([75, 50]); + + const simplePaths = solver.getPaths(listener); + const detailedPaths = solver.getDetailedPaths(listener); + + // Each detailed path's simplePath should match one of the simple paths + for (const detailedPath of detailedPaths) { + const matchingSimplePath = simplePaths.find(sp => + JSON.stringify(sp) === JSON.stringify(detailedPath.simplePath) + ); + expect(matchingSimplePath).toBeDefined(); + } + }); + + it('reflection order increments correctly', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (let i = 0; i < path.reflections.length; i++) { + expect(path.reflections[i].reflectionOrder).toBe(i + 1); + } + } + }); + + it('wall position is between 0 and 1', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + expect(reflection.wallPosition).toBeGreaterThanOrEqual(0); + expect(reflection.wallPosition).toBeLessThanOrEqual(1); + } + } + }); + + it('cumulative distance increases with each reflection', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + let prevCumulative = 0; + for (const reflection of path.reflections) { + expect(reflection.cumulativeDistance).toBeGreaterThan(prevCumulative); + prevCumulative = reflection.cumulativeDistance; + } + } + }); + + it('incoming segment length is positive', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + expect(reflection.incomingSegmentLength).toBeGreaterThan(0); + } + } + }); + + it('isGrazing is a boolean', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + expect(typeof reflection.isGrazing).toBe('boolean'); + } + } + }); + + it('segments array has correct length', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + // Number of segments = number of points - 1 + expect(path.segments.length).toBe(path.simplePath.length - 1); + } + }); + + it('segment details have correct structure', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (let i = 0; i < path.segments.length; i++) { + const segment = path.segments[i]; + expect(Array.isArray(segment.startPoint)).toBe(true); + expect(segment.startPoint.length).toBe(2); + expect(Array.isArray(segment.endPoint)).toBe(true); + expect(segment.endPoint.length).toBe(2); + expect(typeof segment.length).toBe('number'); + expect(segment.length).toBeGreaterThan(0); + expect(segment.segmentIndex).toBe(i); + } + } + }); + + it('segment lengths sum to total path length', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + const sumOfSegments = path.segments.reduce((sum, seg) => sum + seg.length, 0); + expect(sumOfSegments).toBeCloseTo(path.totalPathLength, 10); + } + }); + + it('segments connect start to end points correctly', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 2); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + // First segment starts at listener + expect(path.segments[0].startPoint[0]).toBeCloseTo(path.listenerPosition[0], 10); + expect(path.segments[0].startPoint[1]).toBeCloseTo(path.listenerPosition[1], 10); + + // Last segment ends at source + const lastSegment = path.segments[path.segments.length - 1]; + expect(lastSegment.endPoint[0]).toBeCloseTo(path.sourcePosition[0], 10); + expect(lastSegment.endPoint[1]).toBeCloseTo(path.sourcePosition[1], 10); + + // Each segment's end point is the next segment's start point + for (let i = 0; i < path.segments.length - 1; i++) { + expect(path.segments[i].endPoint[0]).toBeCloseTo(path.segments[i + 1].startPoint[0], 10); + expect(path.segments[i].endPoint[1]).toBeCloseTo(path.segments[i + 1].startPoint[1], 10); + } + } + }); + + it('wall position correctly identifies hit location on wall', () => { + // Create a simple room where we can predict the reflection point + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), // bottom wall + new Wall([100, 0], [100, 100]), // right wall + new Wall([100, 100], [0, 100]), // top wall + new Wall([0, 100], [0, 0]), // left wall + ]; + const source = new Source([50, 25]); // Near bottom + const solver = new Solver(walls, source, 1); + const listener = new Listener([50, 75]); // Near top + + const detailedPaths = solver.getDetailedPaths(listener); + + // Find path that reflects off bottom wall (wall 0) + const bottomReflection = detailedPaths.find(p => + p.reflections.length === 1 && p.reflections[0].wallId === 0 + ); + + if (bottomReflection) { + const reflection = bottomReflection.reflections[0]; + // For symmetric source/listener, hit point should be at x=50, which is t=0.5 on bottom wall + expect(reflection.wallPosition).toBeCloseTo(0.5, 1); + } + }); + + it('cumulative distance equals sum of previous segment lengths', () => { + const walls: Wall[] = [ + new Wall([0, 0], [100, 0]), + new Wall([100, 0], [100, 100]), + new Wall([100, 100], [0, 100]), + new Wall([0, 100], [0, 0]), + ]; + const source = new Source([25, 50]); + const solver = new Solver(walls, source, 3); + const listener = new Listener([75, 50]); + + const detailedPaths = solver.getDetailedPaths(listener); + + for (const path of detailedPaths) { + for (const reflection of path.reflections) { + // Cumulative distance should equal sum of segments up to this reflection + const segmentsUpToReflection = path.segments.slice(0, reflection.reflectionOrder); + const expectedCumulative = segmentsUpToReflection.reduce((sum, seg) => sum + seg.length, 0); + expect(reflection.cumulativeDistance).toBeCloseTo(expectedCumulative, 10); + } + } + }); + }); }); diff --git a/src/beamtrace2d.ts b/src/beamtrace2d.ts index e9645cb..9664920 100644 --- a/src/beamtrace2d.ts +++ b/src/beamtrace2d.ts @@ -31,6 +31,66 @@ export type PathPoint = [number, number, number | null]; /** Complete reflection path from listener to source */ export type ReflectionPath = PathPoint[]; +/** Detailed information about a single reflection point */ +export interface ReflectionDetail { + /** The wall that was hit */ + wall: Wall; + /** Index of the wall in the walls array */ + wallId: number; + /** Point where the reflection occurred [x, y] */ + hitPoint: Point; + /** Angle of incidence in radians (relative to wall normal) */ + incidenceAngle: number; + /** Angle of reflection in radians (relative to wall normal, equals incidence angle for specular reflection) */ + reflectionAngle: number; + /** Incoming ray direction vector (normalized) [x, y] - from previous point to hit point */ + incomingDirection: Point; + /** Outgoing ray direction vector (normalized) [x, y] - from hit point to next point */ + outgoingDirection: Point; + /** Wall normal vector (normalized) [x, y] - pointing toward the side the ray came from */ + wallNormal: Point; + /** Which reflection this is in the path (1 = first reflection, 2 = second, etc.) */ + reflectionOrder: number; + /** Parametric position along the wall (0 = p1, 1 = p2) */ + wallPosition: number; + /** Distance traveled before this reflection (cumulative path length up to this point) */ + cumulativeDistance: number; + /** Distance of the incoming segment (from previous point to this hit point) */ + incomingSegmentLength: number; + /** True if angle is very close to 90° (grazing incidence, may be numerically unstable) */ + isGrazing: boolean; +} + +/** Information about a single segment in the path */ +export interface SegmentDetail { + /** Start point of this segment */ + startPoint: Point; + /** End point of this segment */ + endPoint: Point; + /** Length of this segment */ + length: number; + /** Segment index (0 = first segment from listener) */ + segmentIndex: number; +} + +/** Detailed reflection path with complete information about each reflection */ +export interface DetailedReflectionPath { + /** Start point (listener position) */ + listenerPosition: Point; + /** End point (source position) */ + sourcePosition: Point; + /** Total path length */ + totalPathLength: number; + /** Number of reflections */ + reflectionCount: number; + /** Detailed information about each reflection, in order from listener to source */ + reflections: ReflectionDetail[]; + /** Information about each segment in the path */ + segments: SegmentDetail[]; + /** The original simple path representation */ + simplePath: ReflectionPath; +} + /** Line intersection result array: [x, y, onLine1, onLine2, onRay1, onRay2, wallId?] */ type IntersectionResult = [number, number, boolean, boolean, boolean, boolean, number?] | null; @@ -498,6 +558,69 @@ function inFrontOf(p0: Point, p1: Point, p2: Point): boolean { return n1[0] * (p0[0] - p1[0]) + n1[1] * (p0[1] - p1[1]) > 0; } +/** Calculates the normalized direction vector from p1 to p2 */ +function normalizeDirection(p1: Point, p2: Point): Point { + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + const len = Math.sqrt(dx * dx + dy * dy); + if (len === 0) return [0, 0]; + return [dx / len, dy / len]; +} + +/** Calculates the distance between two points */ +function distance(p1: Point, p2: Point): number { + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +/** Gets the wall normal vector (normalized), optionally oriented toward a reference point */ +function getWallNormal(wall: Wall, referencePoint?: Point): Point { + const dx = wall.p2[0] - wall.p1[0]; + const dy = wall.p2[1] - wall.p1[1]; + const len = Math.sqrt(dx * dx + dy * dy); + if (len === 0) return [0, 0]; + // Normal is perpendicular to wall direction + let normal: Point = [-dy / len, dx / len]; + + // If reference point provided, orient normal toward it + if (referencePoint) { + const toRef: Point = [referencePoint[0] - wall.p1[0], referencePoint[1] - wall.p1[1]]; + const dot = normal[0] * toRef[0] + normal[1] * toRef[1]; + if (dot < 0) { + normal = [-normal[0], -normal[1]]; + } + } + return normal; +} + +/** Calculates the angle between a direction vector and a wall normal (in radians) */ +function calculateIncidenceAngle(direction: Point, wallNormal: Point): number { + // Dot product gives cos(angle) between the vectors + // We want the angle with respect to the normal, so we use the incoming direction (negated) + const incomingDir: Point = [-direction[0], -direction[1]]; + const dot = incomingDir[0] * wallNormal[0] + incomingDir[1] * wallNormal[1]; + // Clamp to avoid floating point errors with acos + const clampedDot = Math.max(-1, Math.min(1, dot)); + return Math.acos(clampedDot); +} + +/** Calculates the parametric position (t) of a point along a wall (0 = p1, 1 = p2) */ +function calculateWallPosition(hitPoint: Point, wall: Wall): number { + const wallDx = wall.p2[0] - wall.p1[0]; + const wallDy = wall.p2[1] - wall.p1[1]; + const wallLengthSq = wallDx * wallDx + wallDy * wallDy; + if (wallLengthSq === 0) return 0; + + const pointDx = hitPoint[0] - wall.p1[0]; + const pointDy = hitPoint[1] - wall.p1[1]; + const t = (pointDx * wallDx + pointDy * wallDy) / wallLengthSq; + return Math.max(0, Math.min(1, t)); +} + +/** Threshold angle (in radians) for grazing incidence detection - within 5° of 90° */ +const GRAZING_THRESHOLD = Math.PI / 2 - (5 * Math.PI / 180); + /** Mirrors point p0 along line defined by p1 and p2 */ function pointMirror(p0: Point, p1: Point, p2: Point): Point { // Line normal @@ -554,6 +677,108 @@ export class Solver { return this.findPaths(listener, this.beams.mainNode); } + /** + * Get detailed information about all valid reflection paths from source to listener. + * Returns comprehensive data including wall references, hit points, and angles. + */ + getDetailedPaths(listener: Listener): DetailedReflectionPath[] { + if (!listener) { + throw new Error("BeamTrace2D: listener is required"); + } + + const simplePaths = this.getPaths(listener); + return simplePaths.map(path => this.convertToDetailedPath(path)); + } + + /** Convert a simple ReflectionPath to a DetailedReflectionPath */ + private convertToDetailedPath(path: ReflectionPath): DetailedReflectionPath { + const listenerPosition: Point = [path[0][0], path[0][1]]; + const sourcePosition: Point = [path[path.length - 1][0], path[path.length - 1][1]]; + + const reflections: ReflectionDetail[] = []; + const segments: SegmentDetail[] = []; + let totalPathLength = 0; + let cumulativeDistance = 0; + let reflectionOrder = 0; + + // Path goes from listener -> reflection points -> source + // So reflections are at indices 1 to path.length - 2 + for (let i = 0; i < path.length - 1; i++) { + const currentPoint: Point = [path[i][0], path[i][1]]; + const nextPoint: Point = [path[i + 1][0], path[i + 1][1]]; + + // Calculate segment length + const segmentLength = distance(currentPoint, nextPoint); + totalPathLength += segmentLength; + + // Store segment details + segments.push({ + startPoint: currentPoint, + endPoint: nextPoint, + length: segmentLength, + segmentIndex: i + }); + + // If next point is a reflection (has a wall ID), compute details + const wallId = path[i + 1][2]; + if (wallId !== null && i + 2 < path.length) { + reflectionOrder++; + cumulativeDistance += segmentLength; + + const hitPoint: Point = [path[i + 1][0], path[i + 1][1]]; + const prevPoint: Point = currentPoint; + const nextNextPoint: Point = [path[i + 2][0], path[i + 2][1]]; + + const wall = this.walls[wallId]; + + // Calculate directions + const incomingDirection = normalizeDirection(prevPoint, hitPoint); + const outgoingDirection = normalizeDirection(hitPoint, nextNextPoint); + + // Get wall normal oriented toward the incoming ray + const wallNormal = getWallNormal(wall, prevPoint); + + // Calculate incidence angle (angle between incoming ray and normal) + const incidenceAngle = calculateIncidenceAngle(incomingDirection, wallNormal); + + // For specular reflection, reflection angle equals incidence angle + const reflectionAngle = incidenceAngle; + + // Calculate wall position (parametric t value) + const wallPosition = calculateWallPosition(hitPoint, wall); + + // Check for grazing incidence + const isGrazing = incidenceAngle > GRAZING_THRESHOLD; + + reflections.push({ + wall, + wallId, + hitPoint, + incidenceAngle, + reflectionAngle, + incomingDirection, + outgoingDirection, + wallNormal, + reflectionOrder, + wallPosition, + cumulativeDistance, + incomingSegmentLength: segmentLength, + isGrazing + }); + } + } + + return { + listenerPosition, + sourcePosition, + totalPathLength, + reflectionCount: reflections.length, + reflections, + segments, + simplePath: path + }; + } + /** Recursive function for going through all beams */ private findPaths(listener: Listener, node: BeamNode): ReflectionPath[] { let pathArray: ReflectionPath[] = [];