Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/common/model/optics/RayTracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 3 additions & 3 deletions tests/fuzz/fuzz.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 73 additions & 1 deletion tests/review-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<string, unknown> {
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);
});
});
Loading