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();