diff --git a/src/OpticsLabConstants.ts b/src/OpticsLabConstants.ts index 74e00c5..53b95b9 100644 --- a/src/OpticsLabConstants.ts +++ b/src/OpticsLabConstants.ts @@ -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; diff --git a/src/common/model/light-sources/ArcLightSource.ts b/src/common/model/light-sources/ArcLightSource.ts index cd7654d..61081d5 100644 --- a/src/common/model/light-sources/ArcLightSource.ts +++ b/src/common/model/light-sources/ArcLightSource.ts @@ -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({ @@ -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; diff --git a/src/common/model/light-sources/PointSourceElement.ts b/src/common/model/light-sources/PointSourceElement.ts index fb0fb0d..f1fe8f9 100644 --- a/src/common/model/light-sources/PointSourceElement.ts +++ b/src/common/model/light-sources/PointSourceElement.ts @@ -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({ @@ -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; diff --git a/src/common/model/optics/OpticsTypes.ts b/src/common/model/optics/OpticsTypes.ts index aef70c8..0a56899 100644 --- a/src/common/model/optics/OpticsTypes.ts +++ b/src/common/model/optics/OpticsTypes.ts @@ -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 ──────────────────────────────────────────── diff --git a/src/common/model/optics/RayTracer.ts b/src/common/model/optics/RayTracer.ts index 06f9c3f..d459c6b 100644 --- a/src/common/model/optics/RayTracer.ts +++ b/src/common/model/optics/RayTracer.ts @@ -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 ──────────────────────────────────────────────────────── @@ -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") { @@ -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 }); } } @@ -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) { diff --git a/src/common/view/RayPropagationView.ts b/src/common/view/RayPropagationView.ts index ebb5bab..b62285a 100644 --- a/src/common/view/RayPropagationView.ts +++ b/src/common/view/RayPropagationView.ts @@ -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, @@ -109,11 +113,61 @@ function clipSegment( } } +/** + * Group segments by sourceId → rayIndex, preserving trace order within each chain. + */ +function groupSegmentsBySource(segs: TracedSegment[]): Map> { + const bySource = new Map>(); + 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({ @@ -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. @@ -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 { @@ -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; diff --git a/src/common/view/SimScreenView.ts b/src/common/view/SimScreenView.ts index c57eb70..0ca4342 100644 --- a/src/common/view/SimScreenView.ts +++ b/src/common/view/SimScreenView.ts @@ -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, {