From 8c1315b8277ead69c5722a18bdc068be1aee8b1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 00:35:57 +0000 Subject: [PATCH 1/2] feat: render point/arc source rays as continuous filled regions at high density When ray density exceeds a threshold (0.8), rays from PointSourceElement and ArcLightSource are rendered as filled polygons between adjacent rays instead of individual lines. This produces a smooth continuous beam appearance that extends through all optical interactions (reflections, refractions, diffraction). A proximity check on segment endpoints prevents incorrect filling between divergent ray paths. https://claude.ai/code/session_01DpSEWjiYmCQCBJG4ZDWaMB --- src/OpticsLabConstants.ts | 6 + .../model/light-sources/ArcLightSource.ts | 4 + .../model/light-sources/PointSourceElement.ts | 4 + src/common/model/optics/OpticsTypes.ts | 4 + src/common/model/optics/RayTracer.ts | 12 ++ src/common/view/RayPropagationView.ts | 135 +++++++++++++++++- src/common/view/SimScreenView.ts | 1 + 7 files changed, 165 insertions(+), 1 deletion(-) 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..4c9f536 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, @@ -114,6 +118,7 @@ function clipSegment( 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 +138,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 +166,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 +243,103 @@ 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 mvt = this.modelViewTransform; + + // Group segments by sourceId, then by rayIndex (preserving trace order within each chain). + const bySource = new Map>(); + for (const seg of segs) { + const sid = seg.sourceId!; + const idx = seg.rayIndex!; + 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); + } + + // For each source, fill between consecutive rayIndex chains. + 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 idxA = sortedIndices[k]!; + const idxB = sortedIndices[k + 1]!; + const chainA = idxMap.get(idxA)!; + const chainB = idxMap.get(idxB)!; + const minLen = Math.min(chainA.length, chainB.length); + + for (let j = 0; j < minLen; j++) { + const segA = chainA[j]!; + const segB = chainB[j]!; + + // 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. + const allLeft = ax1 < clipRect.xmin && ax2 < clipRect.xmin && bx1 < clipRect.xmin && bx2 < clipRect.xmin; + const allRight = ax1 > clipRect.xmax && ax2 > clipRect.xmax && bx1 > clipRect.xmax && bx2 > clipRect.xmax; + const allTop = ay1 < clipRect.ymin && ay2 < clipRect.ymin && by1 < clipRect.ymin && by2 < clipRect.ymin; + const allBottom = ay1 > clipRect.ymax && ay2 > clipRect.ymax && by1 > clipRect.ymax && by2 > clipRect.ymax; + if (allLeft || allRight || allTop || allBottom) { + 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, { From fb0122368aa9227f272a3f0dbfcfda1a4d7e6edb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Mar 2026 00:37:30 +0000 Subject: [PATCH 2/2] refactor: extract helpers to reduce complexity in paintContinuousRays Split the monolithic paintContinuousRays method into smaller functions (groupSegmentsBySource, isQuadOutside, fillBetweenChains) to satisfy the linter's cognitive complexity limit and remove non-null assertions. https://claude.ai/code/session_01DpSEWjiYmCQCBJG4ZDWaMB --- src/common/view/RayPropagationView.ts | 193 ++++++++++++++++---------- 1 file changed, 119 insertions(+), 74 deletions(-) diff --git a/src/common/view/RayPropagationView.ts b/src/common/view/RayPropagationView.ts index 4c9f536..b62285a 100644 --- a/src/common/view/RayPropagationView.ts +++ b/src/common/view/RayPropagationView.ts @@ -113,6 +113,55 @@ 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 { @@ -253,27 +302,8 @@ export class RayPropagationView extends CanvasNode { return; } - const mvt = this.modelViewTransform; - - // Group segments by sourceId, then by rayIndex (preserving trace order within each chain). - const bySource = new Map>(); - for (const seg of segs) { - const sid = seg.sourceId!; - const idx = seg.rayIndex!; - 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); - } + const bySource = groupSegmentsBySource(segs); - // For each source, fill between consecutive rayIndex chains. for (const [, idxMap] of bySource) { const sortedIndices = Array.from(idxMap.keys()).sort((a, b) => a - b); if (sortedIndices.length < 2) { @@ -281,65 +311,80 @@ export class RayPropagationView extends CanvasNode { } for (let k = 0; k < sortedIndices.length - 1; k++) { - const idxA = sortedIndices[k]!; - const idxB = sortedIndices[k + 1]!; - const chainA = idxMap.get(idxA)!; - const chainB = idxMap.get(idxB)!; - const minLen = Math.min(chainA.length, chainB.length); - - for (let j = 0; j < minLen; j++) { - const segA = chainA[j]!; - const segB = chainB[j]!; - - // 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. - const allLeft = ax1 < clipRect.xmin && ax2 < clipRect.xmin && bx1 < clipRect.xmin && bx2 < clipRect.xmin; - const allRight = ax1 > clipRect.xmax && ax2 > clipRect.xmax && bx1 > clipRect.xmax && bx2 > clipRect.xmax; - const allTop = ay1 < clipRect.ymin && ay2 < clipRect.ymin && by1 < clipRect.ymin && by2 < clipRect.ymin; - const allBottom = ay1 > clipRect.ymax && ay2 > clipRect.ymax && by1 > clipRect.ymax && by2 > clipRect.ymax; - if (allLeft || allRight || allTop || allBottom) { - 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(); + 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;