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
6 changes: 6 additions & 0 deletions src/OpticsLabConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ export const RAY_ALPHA_SKIP = 0.005;
export const RAY_CLIP_MARGIN_PX = 50;
/** Number of alpha buckets for batching draw calls (→ alpha granularity = 1/n). */
export const RAY_ALPHA_BUCKETS = 20;
/** Ray density at or above which point/arc sources render as continuous filled regions. */
export const CONTINUOUS_RAY_DENSITY_THRESHOLD = 0.8;
/** Fill opacity multiplier for continuous-ray rendering. */
export const CONTINUOUS_RAY_FILL_ALPHA_SCALE = 0.6;
/** Maximum distance² (model units²) between p1 endpoints to consider two segments compatible for filling. */
export const CONTINUOUS_RAY_P1_PROXIMITY_SQ = 1e-4;
/** Distance threshold (pixels) for image-convergence grid quantization. */
export const RAY_CONVERGENCE_THRESHOLD = 5;

Expand Down
4 changes: 4 additions & 0 deletions src/common/model/light-sources/ArcLightSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class ArcLightSource extends BaseLightSource {

const rays: SimulationRay[] = [];
let first = true;
let idx = 0;

for (let angle = startAngle; angle < endAngle - 1e-9; angle += angularStep) {
rays.push({
Expand All @@ -76,8 +77,11 @@ export class ArcLightSource extends BaseLightSource {
gap: first,
isNew: true,
wavelength: this.wavelength,
sourceId: this.id,
rayIndex: idx,
});
first = false;
idx++;
}

return rays;
Expand Down
4 changes: 4 additions & 0 deletions src/common/model/light-sources/PointSourceElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class PointSourceElement extends BaseLightSource {

const rays: SimulationRay[] = [];
let first = true;
let idx = 0;

for (let angle = startAngle; angle < Math.PI * 2 - 1e-5; angle += angularStep) {
rays.push({
Expand All @@ -46,8 +47,11 @@ export class PointSourceElement extends BaseLightSource {
gap: first,
isNew: true,
wavelength: this.wavelength,
sourceId: this.id,
rayIndex: idx,
});
first = false;
idx++;
}

return rays;
Expand Down
4 changes: 4 additions & 0 deletions src/common/model/optics/OpticsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface SimulationRay {
isNew: boolean;
/** Wavelength in nm (only used when color simulation is on). */
wavelength?: number | undefined;
/** ID of the emitting light source (used for continuous-ray rendering). */
sourceId?: string | undefined;
/** Index of this ray within its source's emission fan (used for continuous-ray rendering). */
rayIndex?: number | undefined;
}

// ── Display / Visualization Modes ────────────────────────────────────────────
Expand Down
12 changes: 12 additions & 0 deletions src/common/model/optics/RayTracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface TracedSegment {
isExtension: boolean;
/** Whether this ray would be seen by the observer (mode = "observer"). */
isObserved: boolean;
/** ID of the emitting light source (used for continuous-ray rendering). */
sourceId?: string | undefined;
/** Index of this ray within its source's emission fan (used for continuous-ray rendering). */
rayIndex?: number | undefined;
}

// ── Full Trace Result ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -152,6 +156,8 @@ export class RayTracer {
wavelength: ray.wavelength,
isExtension: false,
isObserved: false,
sourceId: ray.sourceId,
rayIndex: ray.rayIndex,
});

if (this.config.mode === "extended" || this.config.mode === "images") {
Expand All @@ -165,11 +171,15 @@ export class RayTracer {
const result = intersection.element.onRayIncident(ray, intersection);

if (!result.isAbsorbed && result.outgoingRay) {
result.outgoingRay.sourceId = ray.sourceId;
result.outgoingRay.rayIndex = ray.rayIndex;
queue.push({ ray: result.outgoingRay, depth: depth + 1 });
}

if (result.newRays) {
for (const newRay of result.newRays) {
newRay.sourceId = ray.sourceId;
newRay.rayIndex = ray.rayIndex;
queue.push({ ray: newRay, depth: depth + 1 });
}
}
Expand All @@ -187,6 +197,8 @@ export class RayTracer {
wavelength: ray.wavelength,
isExtension: false,
isObserved: false,
sourceId: ray.sourceId,
rayIndex: ray.rayIndex,
});

if ((this.config.mode === "extended" || this.config.mode === "images") && !ray.gap && !ray.isNew) {
Expand Down
180 changes: 179 additions & 1 deletion src/common/view/RayPropagationView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type { ModelViewTransform2 } from "scenerystack/phetcommon";
import { CanvasNode, type CanvasNodeOptions } from "scenerystack/scenery";
import { VisibleColor } from "scenerystack/scenery-phet";
import {
CONTINUOUS_RAY_DENSITY_THRESHOLD,
CONTINUOUS_RAY_FILL_ALPHA_SCALE,
CONTINUOUS_RAY_P1_PROXIMITY_SQ,
DEFAULT_RAY_DENSITY,
EXT_ALPHA_SCALE,
EXT_B,
EXT_G,
Expand Down Expand Up @@ -109,11 +113,61 @@ function clipSegment(
}
}

/**
* Group segments by sourceId → rayIndex, preserving trace order within each chain.
*/
function groupSegmentsBySource(segs: TracedSegment[]): Map<string, Map<number, TracedSegment[]>> {
const bySource = new Map<string, Map<number, TracedSegment[]>>();
for (const seg of segs) {
const sid = seg.sourceId;
const idx = seg.rayIndex;
if (sid === undefined || sid === null || idx === undefined || idx === null) {
continue;
}
let idxMap = bySource.get(sid);
if (!idxMap) {
idxMap = new Map();
bySource.set(sid, idxMap);
}
let chain = idxMap.get(idx);
if (!chain) {
chain = [];
idxMap.set(idx, chain);
}
chain.push(seg);
}
return bySource;
}

/**
* Quick reject: returns true if the quad defined by two segment endpoints
* is entirely outside the clip rectangle.
*/
function isQuadOutside(
ax1: number,
ay1: number,
ax2: number,
ay2: number,
bx1: number,
by1: number,
bx2: number,
by2: number,
r: ClipRect,
): boolean {
return (
(ax1 < r.xmin && ax2 < r.xmin && bx1 < r.xmin && bx2 < r.xmin) ||
(ax1 > r.xmax && ax2 > r.xmax && bx1 > r.xmax && bx2 > r.xmax) ||
(ay1 < r.ymin && ay2 < r.ymin && by1 < r.ymin && by2 < r.ymin) ||
(ay1 > r.ymax && ay2 > r.ymax && by1 > r.ymax && by2 > r.ymax)
);
}

// ── View class ────────────────────────────────────────────────────────────────

export class RayPropagationView extends CanvasNode {
private segments: TracedSegment[] = [];
private readonly modelViewTransform: ModelViewTransform2;
private rayDensity: number = DEFAULT_RAY_DENSITY;

public constructor(canvasBounds: Bounds2, modelViewTransform: ModelViewTransform2, options?: CanvasNodeOptions) {
super({
Expand All @@ -133,6 +187,14 @@ export class RayPropagationView extends CanvasNode {
this.invalidatePaint();
}

/**
* Update the current ray density. When density >= CONTINUOUS_RAY_DENSITY_THRESHOLD,
* point/arc source rays switch from individual lines to filled regions.
*/
public setRayDensity(density: number): void {
this.rayDensity = density;
}

/**
* Custom canvas painting. Called by Scenery during the display update pass.
* Must not mutate any Scenery node state.
Expand All @@ -153,7 +215,30 @@ export class RayPropagationView extends CanvasNode {

context.lineCap = "round";
this.paintExtensionRays(context, segs, clipRect);
this.paintForwardRays(context, segs, clipRect);

const isContinuous = this.rayDensity >= CONTINUOUS_RAY_DENSITY_THRESHOLD;
if (isContinuous) {
// Split segments: those with sourceId get continuous fill, others get line rendering
const continuousSegs: TracedSegment[] = [];
const discreteSegs: TracedSegment[] = [];
for (const seg of segs) {
if (
!seg.isExtension &&
seg.sourceId !== null &&
seg.sourceId !== undefined &&
seg.rayIndex !== null &&
seg.rayIndex !== undefined
) {
continuousSegs.push(seg);
} else {
discreteSegs.push(seg);
}
}
this.paintContinuousRays(context, continuousSegs, clipRect);
this.paintForwardRays(context, discreteSegs, clipRect);
} else {
this.paintForwardRays(context, segs, clipRect);
}
}

private paintExtensionRays(context: CanvasRenderingContext2D, segs: TracedSegment[], clipRect: ClipRect): void {
Expand Down Expand Up @@ -207,6 +292,99 @@ export class RayPropagationView extends CanvasNode {
context.setLineDash([]);
}

/**
* Render continuous filled regions between adjacent rays from point/arc sources.
* Groups segments by sourceId, builds per-rayIndex chains, then fills polygons
* between consecutive rayIndex pairs.
*/
private paintContinuousRays(context: CanvasRenderingContext2D, segs: TracedSegment[], clipRect: ClipRect): void {
if (segs.length === 0) {
return;
}

const bySource = groupSegmentsBySource(segs);

for (const [, idxMap] of bySource) {
const sortedIndices = Array.from(idxMap.keys()).sort((a, b) => a - b);
if (sortedIndices.length < 2) {
continue;
}

for (let k = 0; k < sortedIndices.length - 1; k++) {
const chainA = idxMap.get(sortedIndices[k] as number);
const chainB = idxMap.get(sortedIndices[k + 1] as number);
if (!(chainA && chainB)) {
continue;
}
this.fillBetweenChains(context, chainA, chainB, clipRect);
}
}
}

/**
* Fill polygons between two adjacent ray chains (consecutive rayIndex values
* from the same source). Walks chains in parallel and fills quads between
* matching segment pairs.
*/
private fillBetweenChains(
context: CanvasRenderingContext2D,
chainA: TracedSegment[],
chainB: TracedSegment[],
clipRect: ClipRect,
): void {
const mvt = this.modelViewTransform;
const minLen = Math.min(chainA.length, chainB.length);

for (let j = 0; j < minLen; j++) {
const segA = chainA[j];
const segB = chainB[j];
if (!(segA && segB)) {
break;
}

// Proximity check on p1 endpoints (model coordinates).
const dx1 = segA.p1.x - segB.p1.x;
const dy1 = segA.p1.y - segB.p1.y;
if (dx1 * dx1 + dy1 * dy1 > CONTINUOUS_RAY_P1_PROXIMITY_SQ) {
break; // Chains diverged (different optical elements or diffraction orders)
}

// Convert to view coordinates.
const ax1 = mvt.modelToViewX(segA.p1.x);
const ay1 = mvt.modelToViewY(segA.p1.y);
const ax2 = mvt.modelToViewX(segA.p2.x);
const ay2 = mvt.modelToViewY(segA.p2.y);
const bx1 = mvt.modelToViewX(segB.p1.x);
const by1 = mvt.modelToViewY(segB.p1.y);
const bx2 = mvt.modelToViewX(segB.p2.x);
const by2 = mvt.modelToViewY(segB.p2.y);

// Quick reject: all four corners on the same side of clip rect.
if (isQuadOutside(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2, clipRect)) {
continue;
}

// Average brightness and wavelength for the fill colour.
const avgBrightness = (segA.brightnessS + segA.brightnessP + segB.brightnessS + segB.brightnessP) * 0.5;
const alpha = Math.min(1, avgBrightness * RAY_ALPHA_SCALE * CONTINUOUS_RAY_FILL_ALPHA_SCALE);
if (alpha < RAY_ALPHA_SKIP) {
continue;
}

const wavelength = segA.wavelength ?? segB.wavelength ?? 550;
const c = VisibleColor.wavelengthToColor(wavelength);

context.fillStyle = `rgba(${c.r},${c.g},${c.b},${alpha.toFixed(3)})`;
context.beginPath();
context.moveTo(ax1, ay1);
context.lineTo(ax2, ay2);
context.lineTo(bx2, by2);
context.lineTo(bx1, by1);
context.closePath();
context.fill();
}
}

private paintForwardRays(context: CanvasRenderingContext2D, segs: TracedSegment[], clipRect: ClipRect): void {
const mvt = this.modelViewTransform;
context.lineWidth = RAY_LINE_WIDTH;
Expand Down
1 change: 1 addition & 0 deletions src/common/view/SimScreenView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export class RayTracingCommonView extends ScreenView {
});
rayDensityProperty.lazyLink((density) => {
model.scene.setRayDensity(density);
this.rayPropagationView.setRayDensity(density);
});

const densityControl = new NumberControl(uiStrings.rayDensityStringProperty, rayDensityProperty, densityRange, {
Expand Down
Loading