# AI 꾸러미 — 광선 추적 광학 (2D) — 2D ray optics (thin lens)
> 얇은 렌즈 공식 1/f = 1/a + 1/b 로 상의 자리를 구하고 대표 광선 셋을 렌즈에서 꺾어 그려, 물체가 움직이면 실상 · 허상이 생기는 과정을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i40

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 광선 추적 광학 (2D) — 2D ray optics (thin lens)

#### 1. 목표
볼록 렌즈 상 만들기 체험에 2D 광선 광학을 넣어 줘 — 볼록 렌즈와 대표 광선 셋, 1/f = 1/a + 1/b 로 구한 상, 초점 안쪽이면 점선 허상까지. 분위기는 어두운 실험실 · 빛나는 광선.

#### 2. 핵심 기술 용어
- **2D ray optics (thin lens)** — 2D 광선 광학 — 얇은 렌즈
- **Thin lens equation 1/f = 1/a + 1/b** — 물체 거리 a · 상 거리 b · 초점 거리 f
- **Principal rays** — 대표 광선 셋 — 나란히 · 가운데 · 초점 지나기
- **Snell's law n₁sinθ₁ = n₂sinθ₂** — 굴절 — 물에서 공기로 꺾이는 빛

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- a = f 근처(3% 안)는 「상이 안 생김」으로 따로 처리 (b 가 무한대로 튄다)
- 초점 안쪽(a < f)이면 허상을 점선 · 바로 선 화살표로
- 물체 거리 · 초점 거리 슬라이더는 f 의 배수로 (0.4f ~ 3.5f)
- 화면에 a · b 값과 「실상 · 허상 · 배율」 글을 늘 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 물체를 2f 에 두면 같은 크기의 거꾸로 선 실상이 2f 에 생긴다
- 물체가 f 안으로 들어가면 렌즈 앞쪽에 바로 선 큰 허상(점선)이 생긴다
- 세 광선이 상의 꼭대기 한 점에서 만난다 (허상은 뒤로 이은 점선이 만난다)
- 구석 창에서 입사각이 바뀌면 굴절각이 스넬 법칙대로 바뀐다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 상 거리 b = 1 / (1/f − 1/a), 배율 m = −b/a. b 가 음수면 렌즈 앞쪽에 바로 선 허상, a = f 근처면 상이 생기지 않는다(나란한 빛).
- 광선 ① 축에 나란히 들어와 렌즈를 지나 뒤 초점으로, ② 렌즈 가운데로 곧게, ③ 앞 초점을 지나 들어와 렌즈 뒤에선 축에 나란히.
- 세 광선이 실제로 만나는 점이 실상, 허상은 꺾인 광선을 뒤로 이어(점선) 만나는 점이다.
- 광선은 점선 무늬를 흘려(lineDashOffset) 빛이 나아가는 방향을 보여 준다.
- 작은 창: 스넬 법칙 θ₂ = asin(n₁/n₂ · sinθ₁) 로 물 → 공기 굴절을 따로 보여 준다.

## 핵심 코드 — 얇은 렌즈 공식 · 대표 광선 셋
(발췌: demos/demosStructure.ts i40 draw() 의 계산 부분을 정리)
```ts
type P = [number, number];
// lx: 렌즈 x · ax: 광축 y · f: 초점 거리(px) · a: 물체 거리 · ho: 물체 높이 · w: 화면 폭
function lensRays(lx: number, ax: number, f: number, a: number, ho: number, w: number) {
  const near = Math.abs(a - f) < f * 0.03;               // 초점 위: 상이 안 생김
  const b = near ? Infinity : 1 / (1 / f - 1 / a);        // 1/f = 1/a + 1/b
  const m = -b / a;                                       // 배율 (음수 = 거꾸로)
  const top: P = [lx - a, ax - ho];                       // 물체 화살 끝
  const ext = (x0: number, y0: number, x1: number, y1: number, toX: number): P => [toX, y0 + ((y1 - y0) * (toX - x0)) / (x1 - x0)];

  const rays: P[][] = [];
  const p1: P = [lx, top[1]];
  rays.push([top, p1, ext(lx, top[1], lx + f, ax, w + 10)]);      // ① 나란히 → 뒤 초점
  rays.push([top, ext(top[0], top[1], lx, ax, w + 10)]);          // ② 가운데 → 곧게
  if (a > f * 1.02) {                                              // ③ 앞 초점 지나 → 나란히
    const hit = ext(top[0], top[1], lx - f, ax, lx);
    rays.push([top, hit, [w + 10, hit[1]]]);
  } else if (a < f * 0.98) {                                       // 초점 안쪽: 초점에서 물체를 지나는 선을 이어
    const hit = ext(lx - f, ax, top[0], top[1], lx);
    rays.push([top, hit, [w + 10, hit[1]]]);
  }
  const image = near ? null : { x: lx + b, h: ho * m, virtual: b < 0 };  // 허상이면 점선으로 그린다
  return { rays, image };
}

// 스넬 법칙 (물 n=1.5 → 공기 n=1.0 쪽 예시)
const refract = (th1: number, n1 = 1.0, n2 = 1.5) => Math.asin(Math.max(-1, Math.min(1, (n1 / n2) * Math.sin(th1))));
```

## 흔한 실수 · 확인 목록
- [ ] **a = f 에서 b 가 무한대가 되어 상 화살이 화면 밖으로 튄다** — a 가 f 의 3% 안이면 「상이 안 생김(나란한 빛)」으로 따로 보여 준다.
- [ ] **허상도 실선으로 그리면 아이들이 진짜 빛이 모인 줄 안다** — 허상과 뒤로 이은 광선은 점선 · 옅은 색으로 구별한다.
- [ ] **세 번째 광선을 초점 안쪽에서도 같은 식으로 그리면 반대로 꺾인다** — 초점 안쪽이면 초점에서 물체 끝을 지나는 직선을 렌즈까지 이어 쓴다.
- [ ] **asin 에 1 보다 큰 값이 들어가면 NaN 이 된다** — 값을 −1 ~ 1 로 묶는다 — 넘으면 전반사다.

## 완성 기준 체크리스트
- [ ] 물체를 2f 에 두면 같은 크기의 거꾸로 선 실상이 2f 에 생긴다
- [ ] 물체가 f 안으로 들어가면 렌즈 앞쪽에 바로 선 큰 허상(점선)이 생긴다
- [ ] 세 광선이 상의 꼭대기 한 점에서 만난다 (허상은 뒤로 이은 점선이 만난다)
- [ ] 구석 창에서 입사각이 바뀌면 굴절각이 스넬 법칙대로 바뀐다

## 이 기술 정보
- id: `i40` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 선 몇 개와 공식 하나 — 폰도 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i40
- 쓰면 좋을 때: 렌즈 · 상 · 초점을 눈으로 확인하는 과학 · 수학 체험 / 비례식 · 역수 공식이 「왜」 쓰이는지 보여 줄 때
- 쓰지 말 때: 사실적인 3D 빛 · 그림자 — 레이 트레이싱 렌더러나 3D 엔진 조명 / 렌즈 두께 · 수차까지 정확히 — 얇은 렌즈 근사를 넘는 계산이 필요하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i40 견본 항목 — `src/demos/demosStructure.ts:4141`
```ts
  i40: {
    kind: '2d',
    caption: '물체(초록 화살)가 움직이면 세 광선이 렌즈에서 꺾여 상(분홍)이 생겨요 — 1/f = 1/a + 1/b · 초점 안쪽이면 허상(점선)',
    make() {
      let auto = true;
      let manA = 2;
      let fK = 1;
      return {
        draw(g, w, h, t) {
          const bg = g.createLinearGradient(0, 0, 0, h);
          bg.addColorStop(0, '#101a3e');
          bg.addColorStop(1, '#1e2c63');
          g.fillStyle = bg;
          g.fillRect(0, 0, w, h);
          const fs = clamp(Math.min(w, h * 1.6) * 0.038, 10, 18);
          const ax = h * 0.58;
          const lx = w * 0.5;
          const f = w * 0.12 * fK;
          const aN = auto ? lerp(0.55, 3.0, pingpong(t, 10)) : manA;
          const a = aN * f;
          const ho = h * 0.2;
          const ox = lx - a;
          // 광축 · 초점
          g.strokeStyle = 'rgba(255,255,255,0.3)';
          g.lineWidth = 1;
          g.beginPath();
          g.moveTo(0, ax);
          g.lineTo(w, ax);
          g.stroke();
          for (const s of [-1, 1]) {
            for (const k of [1, 2]) {
              g.fillStyle = k === 1 ? '#ffd34a' : 'rgba(255,211,74,0.45)';
              g.beginPath();
              g.arc(lx + s * f * k, ax, k === 1 ? 4 : 3, 0, TAU);
              g.fill();
            }
            g.fillStyle = '#ffd34a';
            g.font = `700 ${fs * 0.85}px ${FONT}`;
            g.textAlign = 'center';
            g.textBaseline = 'top';
            g.fillText('F', lx + s * f, ax + 6);
          }
          // 렌즈 (볼록)
          const lh = h * 0.42;
          g.fillStyle = 'rgba(140,210,255,0.28)';
          g.strokeStyle = 'rgba(170,225,255,0.9)';
          g.lineWidth = 1.5;
          g.beginPath();
          g.moveTo(lx, ax - lh);
          g.quadraticCurveTo(lx + lh * 0.22, ax, lx, ax + lh);
          g.quadraticCurveTo(lx - lh * 0.22, ax, lx, ax - lh);
          g.fill();
          g.stroke();
          // 상 거리 b · 배율
          const near = Math.abs(a - f) < f * 0.03;
          const b = near ? Infinity : 1 / (1 / f - 1 / a);
          const m = -b / a;
          const ix = lx + b;
          const hi = ho * m;
          const top: [number, number] = [ox, ax - ho];
          const flow = -t * 40;
          const ray = (pts: [number, number][], col: string, dashed = false): void => {
            g.strokeStyle = col;
            g.lineWidth = dashed ? 1.5 : 2.2;
            g.setLineDash(dashed ? [5, 5] : [14, 6]);
            g.lineDashOffset = dashed ? 0 : flow;
            g.beginPath();
            pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
            g.stroke();
            g.setLineDash([]);
          };
          const ext = (x0: number, y0: number, x1: number, y1: number, toX: number): [number, number] => [toX, y0 + ((y1 - y0) * (toX - x0)) / (x1 - x0)];
          const R1 = '#ffd34a';
          const R2 = '#5ae0ff';
          const R3 = '#ff7ab0';
          // 1) 축에 나란히 → 렌즈 지나 뒤 초점으로
          const p1: [number, number] = [lx, top[1]];
          ray([top, p1, ext(lx, top[1], lx + f, ax, w + 10)], R1);
          // 2) 렌즈 가운데 → 곧게
          ray([top, ext(top[0], top[1], lx, ax, w + 10)], R2);
          // 3) 앞 초점 지나 → 렌즈 뒤 축에 나란히
          if (a > f * 1.02) {
            const hit = ext(top[0], top[1], lx - f, ax, lx);
            ray([top, hit, [w + 10, hit[1]]], R3);
          } else if (a < f * 0.98) {
            const hit = ext(lx - f, ax, top[0], top[1], lx);
            ray([top, hit, [w + 10, hit[1]]], R3);
            ray([[lx - f, ax], top], 'rgba(255,122,176,0.5)', true);
          }
          // 허상: 광선을 뒤로 이어 만난 곳
          if (!near && b < 0) {
            ray([p1, ext(lx, top[1], lx + f, ax, ix)], 'rgba(255,211,74,0.55)', true);
            ray([top, [ix, ax - hi]], 'rgba(90,224,255,0.55)', true);
          }
          const arrow = (x: number, hgt: number, col: string, dashed: boolean): void => {
            g.strokeStyle = col;
            g.fillStyle = col;
            g.lineWidth = 4;
            g.setLineDash(dashed ? [6, 5] : []);
            g.beginPath();
            g.moveTo(x, ax);
            g.lineTo(x, ax - hgt);
            g.stroke();
            g.setLineDash([]);
            const d = hgt > 0 ? 1 : -1;
            g.beginPath();
            g.moveTo(x, ax - hgt - d * 2);
            g.lineTo(x - 8, ax - hgt + d * 12);
            g.lineTo(x + 8, ax - hgt + d * 12);
            g.closePath();
            g.fill();
          };
          arrow(ox, ho, '#5ad17a', false);
          if (!near && Math.abs(ix) < w * 3 && Math.abs(hi) < h * 2) arrow(ix, hi, '#ff6b9b', b < 0);
          // 글씨
          g.fillStyle = '#fff';
          g.font = `700 ${fs}px ${FONT}`;
          g.textAlign = 'left';
          g.textBaseline = 'top';
          const bTxt = near ? '∞ (상이 안 생김)' : b < 0 ? `${(b / f).toFixed(2)}f (허상)` : `${(b / f).toFixed(2)}f`;
          g.fillText(`1/f = 1/a + 1/b    a = ${aN.toFixed(2)}f  →  b = ${bTxt}`, fs * 0.7, fs * 0.6);
          g.fillStyle = 'rgba(255,255,255,0.7)';
          g.font = `600 ${fs * 0.85}px ${FONT}`;
          g.fillText(near ? '초점 위: 나란한 빛' : b < 0 ? `초점 안쪽 → 바로 선 큰 허상 (${Math.abs(m).toFixed(1)}배)` : `거꾸로 선 실상 (${Math.abs(m).toFixed(1)}배)`, fs * 0.7, fs * 2.1);
          // 작은 창: 스넬 법칙 (물 → 공기 꺾임)
          if (w < 520) return;
          const bw = Math.min(w * 0.26, h * 0.42);
          const bx = w - bw - fs * 0.6;
          const by = fs * 0.6;
          const bh = bw * 0.62;
          g.fillStyle = 'rgba(255,255,255,0.06)';
          g.fillRect(bx, by, bw, bh / 2);
          g.fillStyle = 'rgba(80,170,255,0.3)';
          g.fillRect(bx, by + bh / 2, bw, bh / 2);
          g.strokeStyle = 'rgba(255,255,255,0.4)';
          g.strokeRect(bx, by, bw, bh);
          const th1 = (0.15 + 0.5 * pingpong(t, 6)) * 1;
          const th2 = Math.asin(clamp((1.0 / 1.5) * Math.sin(th1), -1, 1));
          const cx = bx + bw / 2;
          const cy = by + bh / 2;
          const L = bh * 0.48;
          g.strokeStyle = '#ffd34a';
          g.lineWidth = 2;
          g.beginPath();
          g.moveTo(cx - Math.sin(th1) * L, cy - Math.cos(th1) * L);
          g.lineTo(cx, cy);
          g.lineTo(cx + Math.sin(th2) * L, cy + Math.cos(th2) * L);
          g.stroke();
          g.fillStyle = '#fff';
          g.font = `600 ${fs * 0.7}px ${FONT}`;
          g.textAlign = 'center';
          g.textBaseline = 'top';
          g.fillText('n₁sinθ₁ = n₂sinθ₂', cx, by + bh + 3);
        },
        controls: [
          { type: 'toggle', label: '물체 저절로 움직이기', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '물체 거리 a (f 의 몇 배)', min: 0.4, max: 3.5, step: 0.05, value: 2, on: (v) => ((auto = false), (manA = v)) },
          { type: 'range', label: '초점 거리 f', min: 0.6, max: 1.5, step: 0.05, value: 1, on: (v) => (fK = v) },
        ],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [그래프 + 3D 연동 (슬라이더 하나로)](https://ai-techstudio.web.app/ai/t/i39.md) `i39`
- 참고 문서: [PhET — Geometric Optics](https://phet.colorado.edu/sims/html/geometric-optics/latest/geometric-optics_all.html) · [Wikipedia — Thin lens](https://en.wikipedia.org/wiki/Thin_lens)
