From 773cf3aae39902d00a3d709fd28b86ea0ecc0e43 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 16:45:27 +0000 Subject: [PATCH] harden ray tracer against unphysical rays; type fuzz env consts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01PoybPnXW2tmUCtDfksrXJg --- src/common/model/optics/RayTracer.ts | 16 +++++- tests/fuzz/fuzz.spec.ts | 6 +-- tests/review-regressions.test.ts | 74 +++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/src/common/model/optics/RayTracer.ts b/src/common/model/optics/RayTracer.ts index 14a5a40..2845e84 100644 --- a/src/common/model/optics/RayTracer.ts +++ b/src/common/model/optics/RayTracer.ts @@ -197,7 +197,21 @@ export class RayTracer { } const totalBrightness = ray.brightnessS + ray.brightnessP; - if (totalBrightness < this.config.minBrightness) { + // Cull dim rays. The negated `>=` form also catches a non-finite brightness + // (a NaN produced by a degenerate element): `NaN < minBrightness` is false in + // IEEE-754, so a bare `<` check would let an unphysical ray propagate uncapped + // to maxRayDepth. Non-finite brightness contributes 0 to the truncation total + // so it cannot poison the accumulator. + if (!(totalBrightness >= this.config.minBrightness)) { + return Number.isFinite(totalBrightness) ? totalBrightness : 0; + } + + // Cull rays with a degenerate direction. `normalize()` returns the zero vector + // for a zero input, so a degenerate element (e.g. a SegmentMirror with p1 === p2) + // can feed a zero-length or non-finite direction into the tracer. Such a ray has + // no meaningful propagation and would otherwise emit a zero-length or NaN segment. + const dir = ray.direction; + if (!(Number.isFinite(dir.x) && Number.isFinite(dir.y)) || (dir.x === 0 && dir.y === 0)) { return totalBrightness; } diff --git a/tests/fuzz/fuzz.spec.ts b/tests/fuzz/fuzz.spec.ts index 650740f..a6ac6f4 100644 --- a/tests/fuzz/fuzz.spec.ts +++ b/tests/fuzz/fuzz.spec.ts @@ -10,9 +10,9 @@ import { expect, test } from "@playwright/test"; const FUZZ_DURATION = parseInt(process.env["FUZZ_DURATION"] || "15", 10) * 1000; -const FUZZ_SEED = process.env["FUZZ_SEED"] || Math.floor(Math.random() * 1_000_000).toString(); -const FUZZ_RATE = process.env["FUZZ_RATE"] || "100"; -const FUZZ_POINTERS = process.env["FUZZ_POINTERS"] || "1"; +const FUZZ_SEED: string = process.env["FUZZ_SEED"] || Math.floor(Math.random() * 1_000_000).toString(); +const FUZZ_RATE: string = process.env["FUZZ_RATE"] || "100"; +const FUZZ_POINTERS: string = process.env["FUZZ_POINTERS"] || "1"; interface ConsoleMessage { type: string; diff --git a/tests/review-regressions.test.ts b/tests/review-regressions.test.ts index aaac349..7cdc82b 100644 --- a/tests/review-regressions.test.ts +++ b/tests/review-regressions.test.ts @@ -37,7 +37,7 @@ import { ArcMirror } from "../src/common/model/mirrors/ArcMirror.js"; import { deserializeElement } from "../src/common/model/optics/elementSerialization.js"; import { arcBounds, point } from "../src/common/model/optics/Geometry.js"; import { OpticsScene } from "../src/common/model/optics/OpticsScene.js"; -import type { SimulationRay } from "../src/common/model/optics/OpticsTypes.js"; +import type { OpticalElement, SimulationRay } from "../src/common/model/optics/OpticsTypes.js"; import { RayTracer } from "../src/common/model/optics/RayTracer.js"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -276,3 +276,75 @@ describe("beam-type sources tag emitted rays for per-source grouping", () => { expect(rays[0]?.sourceId).toBe(src.id); }); }); + +// ── 5. Tracer culls unphysical rays at the boundary ───────────────────────── +// +// The brightness cutoff at the top of processRayEntry uses `!(b >= min)` rather +// than `b < min` so that a non-finite (NaN) brightness — which a degenerate +// element could produce — is culled too; `NaN < min` is false in IEEE-754, so a +// bare `<` would let a NaN ray propagate uncapped to maxRayDepth. A ray with a +// zero-length or non-finite direction (normalize() returns the zero vector for a +// zero input) is likewise dropped before it can emit a zero-length or NaN segment. + +/** + * Minimal light source that emits exactly the rays it is handed. Used to inject a + * single crafted ray into the tracer without going through a real source's fan. + */ +class StubSource implements OpticalElement { + public readonly id = "stub-source"; + public readonly type = "stubSource"; + public readonly category = "lightSource" as const; + private readonly rays: SimulationRay[]; + public constructor(rays: SimulationRay[]) { + this.rays = rays; + } + public emitRays(): SimulationRay[] { + return this.rays.map((r) => ({ ...r })); + } + public checkRayIntersection(): null { + return null; + } + public onRayIncident(): { isAbsorbed: boolean } { + return { isAbsorbed: true }; + } + public serialize(): Record { + return { type: this.type, id: this.id }; + } + public getBounds(): { minX: number; minY: number; maxX: number; maxY: number } { + return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + } + public dispose(): void { + // no-op + } +} + +describe("tracer culls unphysical rays at the boundary", () => { + it("drops a ray with non-finite (NaN) brightness instead of propagating it", () => { + const nanRay = makeRay({ x: 0, y: 0 }, { x: 1, y: 0 }); + nanRay.brightnessS = Number.NaN; + const result = new RayTracer([new StubSource([nanRay])]).trace(); + // Culled before any segment is emitted; the truncation total stays finite. + expect(result.segments).toHaveLength(0); + expect(Number.isFinite(result.truncationError)).toBe(true); + }); + + it("drops a ray whose direction is the zero vector", () => { + const degenerateRay = makeRay({ x: 0, y: 0 }, { x: 0, y: 0 }); + const result = new RayTracer([new StubSource([degenerateRay])]).trace(); + expect(result.segments).toHaveLength(0); + }); + + it("still traces a normal ray from the same stub source", () => { + const goodRay = makeRay({ x: 0, y: 0 }, { x: 1, y: 0 }); + const result = new RayTracer([new StubSource([goodRay])]).trace(); + // A normal ray with nothing to hit escapes and records exactly one segment, + // with finite endpoints. + expect(result.segments).toHaveLength(1); + const seg = result.segments[0]; + expect(seg).toBeDefined(); + if (!seg) { + return; + } + expect(Number.isFinite(seg.p2.x) && Number.isFinite(seg.p2.y)).toBe(true); + }); +});