Skip to content

Commit 773cf3a

Browse files
committed
harden ray tracer against unphysical rays; type fuzz env consts
- RayTracer.processRayEntry: cull rays with non-finite brightness. The brightness cutoff now uses `!(b >= min)` so a NaN brightness (which a degenerate element could produce) is dropped too — `NaN < min` is false in IEEE-754, so the previous `<` check let a NaN ray propagate uncapped to maxRayDepth. Non-finite brightness contributes 0 to the truncation total so it cannot poison the accumulator. - RayTracer.processRayEntry: cull rays with a zero-length or non-finite direction at the tracer boundary. normalize() returns the zero vector for a zero input, so a degenerate element (e.g. a SegmentMirror with p1 === p2) could feed a zero direction into the tracer and emit a zero-length or NaN segment. Centralizes the guard the normalize() contract previously left to every caller. - tests/review-regressions.test.ts: add coverage for both guards via a minimal stub source, plus a control that a normal ray still traces. - tests/fuzz/fuzz.spec.ts: add explicit string types to FUZZ_SEED/RATE/ POINTERS env consts, clearing the biome useExplicitType warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PoybPnXW2tmUCtDfksrXJg
1 parent 2dab65c commit 773cf3a

3 files changed

Lines changed: 91 additions & 5 deletions

File tree

src/common/model/optics/RayTracer.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,21 @@ export class RayTracer {
197197
}
198198

199199
const totalBrightness = ray.brightnessS + ray.brightnessP;
200-
if (totalBrightness < this.config.minBrightness) {
200+
// Cull dim rays. The negated `>=` form also catches a non-finite brightness
201+
// (a NaN produced by a degenerate element): `NaN < minBrightness` is false in
202+
// IEEE-754, so a bare `<` check would let an unphysical ray propagate uncapped
203+
// to maxRayDepth. Non-finite brightness contributes 0 to the truncation total
204+
// so it cannot poison the accumulator.
205+
if (!(totalBrightness >= this.config.minBrightness)) {
206+
return Number.isFinite(totalBrightness) ? totalBrightness : 0;
207+
}
208+
209+
// Cull rays with a degenerate direction. `normalize()` returns the zero vector
210+
// for a zero input, so a degenerate element (e.g. a SegmentMirror with p1 === p2)
211+
// can feed a zero-length or non-finite direction into the tracer. Such a ray has
212+
// no meaningful propagation and would otherwise emit a zero-length or NaN segment.
213+
const dir = ray.direction;
214+
if (!(Number.isFinite(dir.x) && Number.isFinite(dir.y)) || (dir.x === 0 && dir.y === 0)) {
201215
return totalBrightness;
202216
}
203217

tests/fuzz/fuzz.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
import { expect, test } from "@playwright/test";
1111

1212
const FUZZ_DURATION = parseInt(process.env["FUZZ_DURATION"] || "15", 10) * 1000;
13-
const FUZZ_SEED = process.env["FUZZ_SEED"] || Math.floor(Math.random() * 1_000_000).toString();
14-
const FUZZ_RATE = process.env["FUZZ_RATE"] || "100";
15-
const FUZZ_POINTERS = process.env["FUZZ_POINTERS"] || "1";
13+
const FUZZ_SEED: string = process.env["FUZZ_SEED"] || Math.floor(Math.random() * 1_000_000).toString();
14+
const FUZZ_RATE: string = process.env["FUZZ_RATE"] || "100";
15+
const FUZZ_POINTERS: string = process.env["FUZZ_POINTERS"] || "1";
1616

1717
interface ConsoleMessage {
1818
type: string;

tests/review-regressions.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { ArcMirror } from "../src/common/model/mirrors/ArcMirror.js";
3737
import { deserializeElement } from "../src/common/model/optics/elementSerialization.js";
3838
import { arcBounds, point } from "../src/common/model/optics/Geometry.js";
3939
import { OpticsScene } from "../src/common/model/optics/OpticsScene.js";
40-
import type { SimulationRay } from "../src/common/model/optics/OpticsTypes.js";
40+
import type { OpticalElement, SimulationRay } from "../src/common/model/optics/OpticsTypes.js";
4141
import { RayTracer } from "../src/common/model/optics/RayTracer.js";
4242

4343
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -276,3 +276,75 @@ describe("beam-type sources tag emitted rays for per-source grouping", () => {
276276
expect(rays[0]?.sourceId).toBe(src.id);
277277
});
278278
});
279+
280+
// ── 5. Tracer culls unphysical rays at the boundary ─────────────────────────
281+
//
282+
// The brightness cutoff at the top of processRayEntry uses `!(b >= min)` rather
283+
// than `b < min` so that a non-finite (NaN) brightness — which a degenerate
284+
// element could produce — is culled too; `NaN < min` is false in IEEE-754, so a
285+
// bare `<` would let a NaN ray propagate uncapped to maxRayDepth. A ray with a
286+
// zero-length or non-finite direction (normalize() returns the zero vector for a
287+
// zero input) is likewise dropped before it can emit a zero-length or NaN segment.
288+
289+
/**
290+
* Minimal light source that emits exactly the rays it is handed. Used to inject a
291+
* single crafted ray into the tracer without going through a real source's fan.
292+
*/
293+
class StubSource implements OpticalElement {
294+
public readonly id = "stub-source";
295+
public readonly type = "stubSource";
296+
public readonly category = "lightSource" as const;
297+
private readonly rays: SimulationRay[];
298+
public constructor(rays: SimulationRay[]) {
299+
this.rays = rays;
300+
}
301+
public emitRays(): SimulationRay[] {
302+
return this.rays.map((r) => ({ ...r }));
303+
}
304+
public checkRayIntersection(): null {
305+
return null;
306+
}
307+
public onRayIncident(): { isAbsorbed: boolean } {
308+
return { isAbsorbed: true };
309+
}
310+
public serialize(): Record<string, unknown> {
311+
return { type: this.type, id: this.id };
312+
}
313+
public getBounds(): { minX: number; minY: number; maxX: number; maxY: number } {
314+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
315+
}
316+
public dispose(): void {
317+
// no-op
318+
}
319+
}
320+
321+
describe("tracer culls unphysical rays at the boundary", () => {
322+
it("drops a ray with non-finite (NaN) brightness instead of propagating it", () => {
323+
const nanRay = makeRay({ x: 0, y: 0 }, { x: 1, y: 0 });
324+
nanRay.brightnessS = Number.NaN;
325+
const result = new RayTracer([new StubSource([nanRay])]).trace();
326+
// Culled before any segment is emitted; the truncation total stays finite.
327+
expect(result.segments).toHaveLength(0);
328+
expect(Number.isFinite(result.truncationError)).toBe(true);
329+
});
330+
331+
it("drops a ray whose direction is the zero vector", () => {
332+
const degenerateRay = makeRay({ x: 0, y: 0 }, { x: 0, y: 0 });
333+
const result = new RayTracer([new StubSource([degenerateRay])]).trace();
334+
expect(result.segments).toHaveLength(0);
335+
});
336+
337+
it("still traces a normal ray from the same stub source", () => {
338+
const goodRay = makeRay({ x: 0, y: 0 }, { x: 1, y: 0 });
339+
const result = new RayTracer([new StubSource([goodRay])]).trace();
340+
// A normal ray with nothing to hit escapes and records exactly one segment,
341+
// with finite endpoints.
342+
expect(result.segments).toHaveLength(1);
343+
const seg = result.segments[0];
344+
expect(seg).toBeDefined();
345+
if (!seg) {
346+
return;
347+
}
348+
expect(Number.isFinite(seg.p2.x) && Number.isFinite(seg.p2.y)).toBe(true);
349+
});
350+
});

0 commit comments

Comments
 (0)