Physics
THIN LENS RAY TRACING
Interactive optical simulation. Explore lens behavior, image formation, focal points, and principal rays. Real and virtual images visualized in real-time.
Ray Diagram
Click and drag the object arrow to change object distance.
Optical Properties
50
Object Distance (mm)
-50
Image Distance (mm)
100
Focal Length (mm)
1.00
Magnification
50
Object Height (mm)
50
Image Height (mm)
10.0
Power (D)
Virtual
Image Type
Upright
Orientation
Magnified
Size
Controls
100
150
50
1.0x
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
// Thin Lens Simulation
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Simulation State
const state = {
f: 100, // Focal length (mm)
do: 150, // Object distance (mm)
ho: 50, // Object height (mm)
lensType: 'converging',
mirrorMode: false,
zoom: 1,
showRays: { 1: true, 2: true, 3: true },
showElements: { focal: true, '2f': true, extensions: false },
dragging: false,
dragStart: null,
};
// Physics calculations
function calculateImage() {
const { f, do: do_val, ho } = state;
// Lens formula: 1/f = 1/do + 1/di
// di = (f * do) / (do - f)
let di;
if (Math.abs(do_val - f) < 0.1) {
di = Infinity; // Object at focal point
} else {
di = (f * do_val) / (do_val - f);
}
// Magnification: m = -di/do = hi/ho
let m = 0;
if (Math.abs(di) === Infinity) {
m = Infinity;
} else {
m = -di / do_val;
}
// Image height: hi = m * ho
let hi = m * ho;
// Power: P = 1/f (in diopters, f in meters)
const P = 1 / (f / 1000);
// Image type and orientation
const isReal = di > 0;
const isInverted = m < 0;
const isMagnified = Math.abs(m) > 1;
return { di, m, hi, P, isReal, isInverted, isMagnified };
}
// Pixel to world space conversion
function worldToCanvas(wx) {
const centerX = canvas.width / 2;
const ppm = 2; // pixels per mm
return centerX + wx * ppm * state.zoom;
}
function canvasToWorld(px) {
const centerX = canvas.width / 2;
const ppm = 2;
return (px - centerX) / (ppm * state.zoom);
}
// Draw lens
function drawLens() {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const lensHeight = 100 * state.zoom;
const lineWidth = 2;
ctx.strokeStyle = state.lensType === 'converging' ? '#4488ff' : '#ff8800';
ctx.lineWidth = lineWidth;
if (state.mirrorMode) {
// Draw mirror (curved arc)
const curveRadius = 60 * state.zoom;
const mirrorX = state.lensType === 'converging' ? cx - 15 : cx + 15;
ctx.beginPath();
ctx.arc(
mirrorX,
cy,
curveRadius,
Math.PI / 2,
-Math.PI / 2
);
ctx.stroke();
// Mirror backing
ctx.fillStyle = 'rgba(200, 200, 200, 0.1)';
ctx.fill();
} else {
// Draw lens (double convex or concave)
if (state.lensType === 'converging') {
// Convex (double-convex)
const bulge = 30 * state.zoom;
ctx.beginPath();
ctx.arc(cx - 8, cy, bulge, 0, 2 * Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx + 8, cy, bulge, 0, 2 * Math.PI);
ctx.stroke();
} else {
// Concave (double-concave)
const bulge = 30 * state.zoom;
ctx.beginPath();
ctx.arc(cx - 8, cy, bulge, 0, 2 * Math.PI);
ctx.stroke();
ctx.beginPath();
ctx.arc(cx + 8, cy, bulge, 0, 2 * Math.PI);
ctx.stroke();
}
// Lens line
ctx.beginPath();
ctx.moveTo(cx, cy - lensHeight / 2);
ctx.lineTo(cx, cy + lensHeight / 2);
ctx.stroke();
}
}
// Draw optical axis
function drawAxis() {
const cy = canvas.height / 2;
ctx.strokeStyle = 'rgba(85, 85, 85, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(0, cy);
ctx.lineTo(canvas.width, cy);
ctx.stroke();
ctx.setLineDash([]);
}
// Draw focal points
function drawFocalPoints() {
const { f } = state;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
ctx.fillStyle = '#4488ff';
ctx.font = 'bold 12px "DM Mono"';
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(68, 136, 255, 0.6)';
// Left focal point (F)
const fxLeft = worldToCanvas(-f);
ctx.beginPath();
ctx.arc(fxLeft, cy, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = '#4488ff';
ctx.font = '10px "DM Mono"';
ctx.fillText('F', fxLeft, cy - 12);
// Right focal point (F')
const fxRight = worldToCanvas(f);
ctx.fillStyle = 'rgba(68, 136, 255, 0.6)';
ctx.beginPath();
ctx.arc(fxRight, cy, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = '#4488ff';
ctx.fillText("F'", fxRight, cy - 12);
// 2F points
if (state.showElements['2f']) {
ctx.fillStyle = 'rgba(68, 136, 255, 0.3)';
ctx.font = '9px "DM Mono"';
const fx2Left = worldToCanvas(-2 * f);
ctx.beginPath();
ctx.arc(fx2Left, cy, 3, 0, 2 * Math.PI);
ctx.fill();
const fx2Right = worldToCanvas(2 * f);
ctx.beginPath();
ctx.arc(fx2Right, cy, 3, 0, 2 * Math.PI);
ctx.fill();
}
}
// Draw object arrow
function drawObject() {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const { do: do_val, ho } = state;
const ox = worldToCanvas(-do_val);
const oh_px = ho * 2 * state.zoom; // pixels per mm
// Arrow shaft
ctx.strokeStyle = '#f5c518';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ox, cy);
ctx.lineTo(ox, cy - oh_px);
ctx.stroke();
// Arrow head
ctx.fillStyle = '#f5c518';
ctx.beginPath();
ctx.moveTo(ox, cy - oh_px);
ctx.lineTo(ox - 6, cy - oh_px + 10);
ctx.lineTo(ox + 6, cy - oh_px + 10);
ctx.closePath();
ctx.fill();
// Label
ctx.fillStyle = '#f5c518';
ctx.font = '11px "DM Mono"';
ctx.textAlign = 'center';
ctx.fillText('Object', ox, cy + 20);
}
// Draw image arrow
function drawImage() {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const calc = calculateImage();
const { di, hi, isInverted } = calc;
if (Math.abs(di) === Infinity) return; // No image at infinity
const ix = worldToCanvas(di);
const ih_px = Math.abs(hi) * 2 * state.zoom;
ctx.lineWidth = 2;
// Real image (solid line, inverted)
if (calc.isReal) {
ctx.strokeStyle = '#00c896';
ctx.setLineDash([]);
} else {
// Virtual image (dashed line)
ctx.strokeStyle = '#ff8800';
ctx.setLineDash([5, 5]);
}
const startY = isInverted ? cy + ih_px : cy - ih_px;
ctx.beginPath();
ctx.moveTo(ix, cy);
ctx.lineTo(ix, startY);
ctx.stroke();
ctx.setLineDash([]);
// Arrow head
ctx.fillStyle = calc.isReal ? '#00c896' : '#ff8800';
ctx.beginPath();
ctx.moveTo(ix, startY);
ctx.lineTo(ix - 6, startY - (isInverted ? -10 : 10));
ctx.lineTo(ix + 6, startY - (isInverted ? -10 : 10));
ctx.closePath();
ctx.fill();
// Label
ctx.fillStyle = calc.isReal ? '#00c896' : '#ff8800';
ctx.font = '11px "DM Mono"';
ctx.textAlign = 'center';
ctx.fillText('Image', ix, cy + 20);
}
// Draw principal rays
function drawRays() {
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const { f, do: do_val, ho } = state;
const calc = calculateImage();
const { di, isReal } = calc;
const objectX = -do_val;
const objectTopY = ho;
const colors = ['#ff2200', '#00c896', '#4488ff'];
const rayIndices = [1, 2, 3];
rayIndices.forEach((rayNum) => {
if (!state.showRays[rayNum]) return;
ctx.strokeStyle = colors[rayNum - 1];
ctx.lineWidth = 1.5;
if (rayNum === 1) {
// Ray 1: parallel to axis → through F' (converging) or from F' (diverging)
const objPx = worldToCanvas(objectX);
const objPy = cy - objectTopY * 2 * state.zoom;
ctx.beginPath();
ctx.moveTo(objPx, objPy);
if (state.lensType === 'converging') {
// To focal point on right
const fPx = worldToCanvas(f);
ctx.lineTo(worldToCanvas(300), cy); // Parallel approach
ctx.lineTo(fPx, cy);
} else {
// From focal point on left
const fPx = worldToCanvas(-f);
ctx.lineTo(cx, objPy); // Approach to lens
// Diverging line from F
const slope = (objPy - cy) / (cx - fPx);
const exitY = cy + slope * (worldToCanvas(600) - cx);
ctx.lineTo(worldToCanvas(600), exitY);
if (state.showElements.extensions) {
ctx.setLineDash([3, 3]);
const extX = worldToCanvas(-600);
ctx.lineTo(extX, cy + slope * (extX - fPx));
ctx.setLineDash([]);
}
}
ctx.stroke();
} else if (rayNum === 2) {
// Ray 2: through center of lens → undeviated
const objPx = worldToCanvas(objectX);
const objPy = cy - objectTopY * 2 * state.zoom;
ctx.beginPath();
ctx.moveTo(objPx, objPy);
ctx.lineTo(cx, objPy);
if (Math.abs(di) !== Infinity) {
const imgPx = worldToCanvas(di);
const imgPy = cy - calc.hi * 2 * state.zoom;
ctx.lineTo(imgPx, imgPy);
} else {
ctx.lineTo(worldToCanvas(300), objPy);
}
ctx.stroke();
} else if (rayNum === 3) {
// Ray 3: through F (converging) or aimed at F (diverging) → parallel exit
const objPx = worldToCanvas(objectX);
const objPy = cy - objectTopY * 2 * state.zoom;
ctx.beginPath();
ctx.moveTo(objPx, objPy);
if (state.lensType === 'converging') {
// Through focal point on left
const fPx = worldToCanvas(-f);
ctx.lineTo(fPx, cy);
ctx.lineTo(worldToCanvas(300), objPy); // Exit parallel
} else {
// Aimed at focal point on right (diverging)
const fPx = worldToCanvas(f);
ctx.lineTo(cx, objPy); // Approach lens
// Exit parallel to axis
ctx.lineTo(worldToCanvas(300), cy);
if (state.showElements.extensions) {
ctx.setLineDash([3, 3]);
const slope = (objPy - cy) / (objPx - fPx);
const extY = cy + slope * (fPx - cx);
ctx.lineTo(fPx, extY);
ctx.setLineDash([]);
}
}
ctx.stroke();
}
});
}
// Main draw function
function draw() {
// Clear canvas
ctx.fillStyle = '#111111';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw elements
drawAxis();
drawRays();
drawFocalPoints();
drawObject();
drawImage();
drawLens();
// Info box
const calc = calculateImage();
updateInfoBox(calc);
updateStats(calc);
}
// Update info box based on special cases
function updateInfoBox(calc) {
const box = document.getElementById('info-box');
const { do: do_val, f } = state;
if (Math.abs(do_val - f) < 1) {
box.textContent = '⚠ Object at focal point: Image at infinity';
box.className = 'alert alert-warning';
} else if (state.lensType === 'converging' && do_val < f) {
box.textContent = '✓ Converging lens, object inside f: Virtual, upright, magnified (magnifying glass)';
box.className = 'alert alert-success';
} else if (state.lensType === 'converging' && Math.abs(do_val - 2 * f) < 1) {
box.textContent = 'Object at 2F: Image at 2F, same size, inverted (real)';
box.className = 'alert alert-info';
} else if (calc.isReal) {
box.textContent = `✓ Real image formed. Inverted, ${calc.isMagnified ? 'magnified' : 'reduced'}`;
box.className = 'alert alert-success';
} else {
box.textContent = `Virtual image (no real image). Upright, ${calc.isMagnified ? 'magnified' : 'reduced'}`;
box.className = 'alert alert-info';
}
}
// Update statistics display
function updateStats(calc) {
const { do: do_val, f, ho } = state;
const { di, m, hi, P, isReal, isInverted } = calc;
document.getElementById('stat-do').textContent = do_val.toFixed(0);
document.getElementById('stat-di').textContent =
Math.abs(di) === Infinity ? '∞' : di.toFixed(1);
document.getElementById('stat-f').textContent = f.toFixed(1);
document.getElementById('stat-m').textContent =
Math.abs(m) === Infinity ? '∞' : m.toFixed(2);
document.getElementById('stat-ho').textContent = ho.toFixed(0);
document.getElementById('stat-hi').textContent =
Math.abs(hi) === Infinity ? '∞' : Math.abs(hi).toFixed(1);
document.getElementById('stat-p').textContent = P.toFixed(2);
document.getElementById('stat-type').textContent = isReal ? 'Real' : 'Virtual';
document.getElementById('stat-orientation').textContent = isInverted ? 'Inverted' : 'Upright';
document.getElementById('stat-size').textContent = Math.abs(m) > 1 ? 'Magnified' : 'Reduced';
}
// Canvas resize
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
draw();
}
// Event listeners
document.getElementById('focal-slider').addEventListener('input', (e) => {
let value = parseFloat(e.target.value);
if (Math.abs(value) < 15) value = (value > 0 ? 1 : -1) * 20; // Skip near 0
state.f = value;
document.getElementById('focal-value').textContent = value.toFixed(0);
draw();
});
document.getElementById('do-slider').addEventListener('input', (e) => {
state.do = parseFloat(e.target.value);
document.getElementById('do-value').textContent = state.do.toFixed(0);
draw();
});
document.getElementById('ho-slider').addEventListener('input', (e) => {
state.ho = parseFloat(e.target.value);
document.getElementById('ho-value').textContent = state.ho.toFixed(0);
draw();
});
document.getElementById('zoom-slider').addEventListener('input', (e) => {
state.zoom = parseFloat(e.target.value);
document.getElementById('zoom-value').textContent = state.zoom.toFixed(1) + 'x';
draw();
});
// Lens type selection
document.querySelectorAll('[data-lens]').forEach((btn) => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('[data-lens]').forEach((b) => b.classList.remove('active'));
e.target.classList.add('active');
state.lensType = e.target.dataset.lens;
draw();
});
});
// Mirror mode selection
document.querySelectorAll('[data-mirror]').forEach((btn) => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('[data-mirror]').forEach((b) => b.classList.remove('active'));
e.target.classList.add('active');
state.mirrorMode = e.target.dataset.mirror === 'mirror';
draw();
});
});
// Ray toggles
document.querySelectorAll('[data-ray]').forEach((btn) => {
btn.addEventListener('click', (e) => {
const rayNum = parseInt(e.target.dataset.ray);
state.showRays[rayNum] = !state.showRays[rayNum];
e.target.classList.toggle('active');
draw();
});
});
// Show element toggles
document.querySelectorAll('[data-show]').forEach((btn) => {
btn.addEventListener('click', (e) => {
const elem = e.target.dataset.show;
state.showElements[elem] = !state.showElements[elem];
e.target.classList.toggle('active');
draw();
});
});
// Reset
document.getElementById('reset-btn').addEventListener('click', () => {
state.f = 100;
state.do = 150;
state.ho = 50;
state.zoom = 1;
state.lensType = 'converging';
state.mirrorMode = false;
document.getElementById('focal-slider').value = 100;
document.getElementById('do-slider').value = 150;
document.getElementById('ho-slider').value = 50;
document.getElementById('zoom-slider').value = 1;
document.getElementById('focal-value').textContent = '100';
document.getElementById('do-value').textContent = '150';
document.getElementById('ho-value').textContent = '50';
document.getElementById('zoom-value').textContent = '1.0x';
document.querySelectorAll('[data-lens]').forEach((b) => b.classList.remove('active'));
document.querySelector('[data-lens="converging"]').classList.add('active');
document.querySelectorAll('[data-mirror]').forEach((b) => b.classList.remove('active'));
document.querySelector('[data-mirror="lens"]').classList.add('active');
draw();
});
// Canvas drag to move object
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const cy = canvas.height / 2;
const objX = worldToCanvas(-state.do);
if (Math.abs(x - objX) < 20 && Math.abs(e.clientY - rect.top - cy) < 50) {
state.dragging = true;
state.dragStart = x;
}
});
canvas.addEventListener('mousemove', (e) => {
if (state.dragging) {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const delta = x - state.dragStart;
const worldDelta = canvasToWorld(delta) - canvasToWorld(0);
state.do -= worldDelta;
state.do = Math.max(10, Math.min(600, state.do));
document.getElementById('do-slider').value = state.do;
document.getElementById('do-value').textContent = state.do.toFixed(0);
state.dragStart = x;
draw();
}
});
canvas.addEventListener('mouseup', () => {
state.dragging = false;
});
canvas.addEventListener('mouseleave', () => {
state.dragging = false;
});
// Initialize
window.addEventListener('resize', resizeCanvas);
resizeCanvas();