# AI 꾸러미 — 화면에 꼭 맞는 카메라 거리 — Fit camera to bounds (binary search)
> 판 모서리 8점을 화면에 투영해 95% 틀 안에 드는지 보고 카메라 거리를 반씩 좁혀서(이분 탐색), 판 크기 · 화면 비율이 바뀌어도 판이 꽉 차게 맞춘다.  
> 견본: https://ai-techstudio.web.app/#t/u41

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

## 주문서

### 만들어 줘: 화면에 꼭 맞는 카메라 거리 — Fit camera to bounds (binary search)

#### 1. 목표
크기가 바뀌는 퍼즐 판이 화면에 꽉 차도록 카메라 거리를 이분 탐색으로 찾아 줘 — 모서리가 95% 틀 밖이면 멀리, 안이면 가까이, 화면 비율이 바뀌어도. 분위기는 밝은 하늘색 도면.

#### 2. 핵심 기술 용어
- **Fit camera to bounds (binary search)** — 이분 탐색으로 맞는 카메라 거리 찾기
- **Vector3.project (NDC)** — 3D 점을 화면 좌표(−1 ~ 1)로 바꾸기
- **Bounding box corners** — 판을 감싸는 상자의 꼭짓점 8개
- **Bisection** — 범위를 반씩 줄이기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 판 꼭짓점은 높이까지(바닥 · 위) 8개 — 바닥 4점만 재면 차 지붕이 잘린다
- 맞춤 판정은 화면 좌표 max(|x|, |y|) ≤ 0.95 (5% 여백)
- 시험할 때마다 카메라 행렬 갱신(updateMatrixWorld · updateProjectionMatrix) 후 project()
- 마지막에 hi(들어가는 쪽) 를 쓴다
- 화면 크기 · 판이 바뀔 때만 다시 찾는다 (매 프레임 X)

#### 5. 완성 기준 (이게 보이면 성공)
- 판 크기가 바뀔 때마다 이분 탐색이 한 단계씩 보이며(「틀 안 → 더 가까이」 · 「틀 밖 → 더 멀리」) 카메라가 맞는 거리로 다가간다
- 모서리 점이 틀 밖이면 빨강, 안이면 초록으로 보이고 마지막엔 모두 초록
- 화면 비율을 바꿔도 판이 95% 틀 안에 꽉 찬다
- 아래 띠에 남은 거리 범위가 반씩 줄어든다

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

## 원리
- 원근 카메라에서는 판 높이 · 기울기 때문에 「반폭 / tan」 한 줄 식이 잘 안 맞는다. 대신 직접 재 본다.
- 판을 감싸는 상자 꼭짓점 8개(높이 0 과 0.6)를 project() 로 화면 좌표(−1 ~ 1)로 바꿔, 가장 큰 |x| · |y| 가 0.95 이하면 「들어간다」.
- 거리 범위 lo = 1, hi = 40 에서 가운데 mid 로 시험: 들어가면 hi = mid(더 가까이), 아니면 lo = mid(더 멀리). 10번이면 범위가 39/1024 ≈ 0.04 로 좁아진다.
- 마지막 hi 를 쓴다 — 늘 들어가는 쪽이라 판이 잘리지 않는다. 카메라 방향(0, 0.82, 0.57)은 그대로, 거리만 바뀐다.

## 핵심 코드 — 모서리 투영 판정 + 이분 탐색
(발췌: demos/demosStructure.ts u41 fits() · update() 를 정리)
```ts
const dir = new THREE.Vector3(0, 0.82, 0.57).normalize(); // 카메라 방향은 고정
const corners: THREE.Vector3[] = [];
function setBoard(W: number, D: number): void {
  corners.length = 0;
  for (const sx of [-1, 1]) for (const sy of [0, 0.6]) for (const sz of [-1, 1])
    corners.push(new THREE.Vector3((sx * W) / 2, sy, (sz * D) / 2)); // 높이까지 8점
}

const p = new THREE.Vector3();
function fits(cam: THREE.PerspectiveCamera, d: number, aspect: number): boolean {
  cam.position.copy(dir).multiplyScalar(d);
  cam.lookAt(0, 0, 0);
  cam.aspect = aspect;
  cam.updateMatrixWorld();
  cam.updateProjectionMatrix();
  let mx = 0;
  for (const c of corners) {
    p.copy(c).project(cam); // 화면 좌표 −1 ~ 1
    mx = Math.max(mx, Math.abs(p.x), Math.abs(p.y));
  }
  return mx <= 0.95;        // 5% 여백
}

function fitDistance(cam: THREE.PerspectiveCamera, aspect: number): number {
  let lo = 1, hi = 40;
  for (let i = 0; i < 10; i++) {
    const mid = (lo + hi) / 2;
    if (fits(cam, mid, aspect)) hi = mid; // 들어감 → 더 가까이
    else lo = mid;                        // 밖 → 더 멀리
  }
  return hi;                              // 늘 들어가는 쪽
}
```

## 흔한 실수 · 확인 목록
- [ ] **카메라 행렬을 갱신하지 않고 project() 하면 이전 자리로 잰다** — 자리를 바꿀 때마다 updateMatrixWorld() 와 updateProjectionMatrix() 뒤에 투영한다.
- [ ] **바닥 네 점만 재면 위로 솟은 물체가 잘린다** — 판 위 물체 높이(견본 0.6)까지 8점을 잰다.
- [ ] **마지막에 mid 를 쓰면 가끔 판이 살짝 잘린다** — hi 는 늘 「들어간」 거리라 hi 를 쓴다.

## 완성 기준 체크리스트
- [ ] 판 크기가 바뀔 때마다 이분 탐색이 한 단계씩 보이며(「틀 안 → 더 가까이」 · 「틀 밖 → 더 멀리」) 카메라가 맞는 거리로 다가간다
- [ ] 모서리 점이 틀 밖이면 빨강, 안이면 초록으로 보이고 마지막엔 모두 초록
- [ ] 화면 비율을 바꿔도 판이 95% 틀 안에 꽉 찬다
- [ ] 아래 띠에 남은 거리 범위가 반씩 줄어든다

## 이 기술 정보
- id: `u41` · 분류: 이펙트 · 연출 › 카메라 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 10번 × 꼭짓점 8개 투영 = 점 80개 계산. 화면 크기 · 판이 바뀔 때만 한다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u41
- 쓰면 좋을 때: 판 크기(가로 6 × 세로 3, 3 × 4.5 …)가 단계마다 바뀔 때 / 화면 비율(폰 가로 · 세로 · 태블릿)이 제각각일 때
- 쓰지 말 때: 정사영 카메라 — 거리 대신 zoom 이나 left/right 를 한 줄 식으로 바로 계산한다 / 판이 늘 같고 화면도 고정 — 한 번 정한 값이면 충분하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u41 견본 항목 — `src/demos/demosStructure.ts:1658`
```ts
  u41: {
    kind: '3d',
    caption: '이분 탐색: 모서리(점)가 95% 틀 밖이면 멀리, 안이면 가까이 — 절반씩 좁혀 판에 꼭 맞는 거리를 찾아요',
    make() {
      const scene = baseScene('#d6e4ff', '#f6f9ff');
      const root = new THREE.Group();
      scene.add(root);
      const sizes: [number, number][] = [
        [6, 3],
        [3, 4.5],
        [4, 4],
      ];
      const lot = new THREE.Mesh(new THREE.BoxGeometry(1, 0.2, 1), std(0x5d6475, 0.9));
      root.add(lot);
      const lineMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
      const cars: THREE.Mesh[] = [];
      const carCols = [0xff4f4f, 0x4aa8ff, 0xffc93c, 0x5ad17a, 0xb07cff];
      for (let i = 0; i < 6; i++) {
        const c = new THREE.Mesh(new RoundedBoxGeometry(0.9, 0.45, 0.5, 2, 0.12), std(carCols[i % 5]!, 0.4));
        root.add(c);
        cars.push(c);
      }
      const stripes = new THREE.Group();
      root.add(stripes);
      const stripeGeo = new THREE.PlaneGeometry(0.05, 0.8);
      const corners: THREE.Vector3[] = [];
      let W = 6;
      let D = 3;
      const setBoard = (k: number): void => {
        [W, D] = sizes[k % sizes.length]!;
        lot.scale.set(W, 1, D);
        cars.forEach((c, i) => {
          c.position.set(((i % 3) / 2 - 0.5) * (W - 1.4), 0.32, (Math.floor(i / 3) - 0.5) * (D - 1.2) * 0.8);
          c.rotation.y = i % 2 ? 0 : Math.PI / 2;
        });
        stripes.clear();
        for (let x = -W / 2 + 0.6; x < W / 2; x += 0.8) {
          const s = new THREE.Mesh(stripeGeo, lineMat);
          s.rotation.x = -Math.PI / 2;
          s.position.set(x, 0.11, -D / 2 + 0.5);
          stripes.add(s);
        }
        corners.length = 0;
        for (const sx of [-1, 1]) for (const sy of [0, 0.6]) for (const sz of [-1, 1]) corners.push(new THREE.Vector3((sx * W) / 2, sy, (sz * D) / 2));
      };
      const cam = new THREE.PerspectiveCamera(40, 1, 0.1, 200);
      const dir = new THREE.Vector3(0, 0.82, 0.57).normalize();
      const hud = new Hud();
      let board = -1;
      let lo = 1;
      let hi = 40;
      let mid = 20;
      let step = 0;
      let dist = 20;
      let verdict = '';
      const fits = (d: number, aspect: number): boolean => {
        cam.position.copy(dir).multiplyScalar(d);
        cam.lookAt(0, 0, 0);
        cam.aspect = aspect;
        cam.updateMatrixWorld();
        cam.updateProjectionMatrix();
        let mx = 0;
        for (const c of corners) {
          const p = c.clone().project(cam);
          mx = Math.max(mx, Math.abs(p.x), Math.abs(p.y));
        }
        return mx <= 0.95;
      };
      return {
        scene,
        camera: cam,
        update(t) {
          const cyc = 5.2;
          const k = Math.floor(t / cyc);
          if (k !== board) {
            board = k;
            setBoard(k);
            lo = 1;
            hi = 40;
            step = -1;
            mid = 3 + ((k * 7) % 5) * 6;
          }
          const s = Math.min(10, Math.floor(((t % cyc) - 0.6) / 0.32));
          root.rotation.y = 0;
          if (s > step && s >= 0) {
            step = s;
            if (s >= 10) {
              mid = hi;
              verdict = `찾았다! 거리 ${hi.toFixed(2)}`;
            } else {
              mid = (lo + hi) / 2;
              const ok = fits(mid, cam.aspect);
              if (ok) hi = mid;
              else lo = mid;
              verdict = ok ? '틀 안 → 더 가까이' : '틀 밖 → 더 멀리';
            }
          }
          dist += (mid - dist) * 0.25;
        },
        render(r, w, h) {
          cam.aspect = w / h;
          cam.position.copy(dir).multiplyScalar(dist);
          cam.lookAt(0, 0, 0);
          cam.updateProjectionMatrix();
          cam.updateMatrixWorld();
          r.render(scene, cam);
          const pts = corners.map((c) => c.clone().project(cam));
          hud.draw(r, w, h, `${w}x${h}${pts.map((p) => `${p.x.toFixed(3)},${p.y.toFixed(3)}`).join()}${step}`, (g) => {
            const s = fsz(w, h);
            g.setLineDash([s * 0.4, s * 0.3]);
            g.strokeStyle = '#ff5a36';
            g.lineWidth = Math.max(1.5, s * 0.12);
            g.strokeRect(w * 0.025, h * 0.025, w * 0.95, h * 0.95);
            g.setLineDash([]);
            for (const p of pts) {
              const out = Math.abs(p.x) > 0.95 || Math.abs(p.y) > 0.95;
              g.fillStyle = out ? '#ff3b3b' : '#22c55e';
              g.beginPath();
              g.arc(((p.x + 1) / 2) * w, ((1 - p.y) / 2) * h, s * 0.32, 0, TAU);
              g.fill();
              g.strokeStyle = '#fff';
              g.lineWidth = 2;
              g.stroke();
            }
            if (step >= 0) pill(g, s * 0.8, s * 0.8, step >= 10 ? verdict : `${step + 1}번째: ${verdict}`, s * 0.85, step >= 10 ? 'rgba(34,160,90,0.95)' : 'rgba(20,28,60,0.72)');
            // 남은 범위 띠
            const bx = w * 0.55;
            const bw = w * 0.38;
            const by = h - s * 2;
            g.fillStyle = 'rgba(30,40,80,0.25)';
            g.fillRect(bx, by, bw, s * 0.5);
            g.fillStyle = '#4a7dff';
            g.fillRect(bx + ((lo - 1) / 39) * bw, by, Math.max(2, ((hi - lo) / 39) * bw), s * 0.5);
            g.fillStyle = '#1d2a55';
            g.font = `700 ${s * 0.7}px ${FONT}`;
            g.textAlign = 'left';
            g.textBaseline = 'bottom';
            g.fillText(`거리 범위 ${lo.toFixed(1)} ~ ${hi.toFixed(1)}`, bx, by - 2);
          });
        },
        dispose() {
          stripeGeo.dispose();
          lineMat.dispose();
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [비대칭 카메라 (off-axis 투영)](https://ai-techstudio.web.app/ai/t/u40.md) `u40`
