# AI 꾸러미 — 두 손가락 몸짓 (확대 · 회전) — Pinch-zoom and two-finger rotate
> 두 손가락 거리 비로 확대, 각도 차로 회전하되 중심을 두 손가락 가운데로 잡아, 손가락 밑 그림 점이 손가락을 그대로 따라오게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i422

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

## 주문서

### 만들어 줘: 두 손가락 몸짓 (확대 · 회전) — Pinch-zoom and two-finger rotate

#### 1. 목표
보물 지도 · 큰 그림에 두 손가락 확대 · 회전을 넣어 줘 — 시작할 때 두 점의 가운데 · 거리 · 각도를 기억하고, 배율 = 지금 거리 / 처음 거리, 회전 = 각도 차, 중심은 두 손가락 가운데. PC 는 휠 = 마우스 자리 중심 확대, Shift + 휠 = 회전. 화면은 손가락 밑 점 표시.

#### 2. 핵심 기술 용어
- **Pinch-zoom and two-finger rotate** — 두 손가락 확대 · 회전
- **Gesture centroid (midpoint)** — 몸짓 중심 — 두 손가락 가운데
- **Similarity transform** — 옮기기 + 확대 + 돌리기 한 번에
- **Wheel zoom at cursor** — 마우스 자리를 중심으로 휠 확대

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

#### 4. 조건
- 중심은 두 손가락 가운데 — 그림 가운데 아님
- 손가락이 늘거나 줄면 시작값(가운데 · 거리 · 각도 · 그림 상태)을 다시 잡기 (튀지 않게)
- 배율 범위 0.3 ~ 4, 거리는 최소 4px 로 (0 나누기 막기)
- PC: 휠 = 마우스 자리 중심 확대(e^(−dy × 0.0018)), Shift + 휠 = 회전
- touch-action: none, pointer 이벤트로 손가락마다 pointerId

#### 5. 완성 기준 (이게 보이면 성공)
- 켬: 두 손가락으로 벌리며 돌리면 처음 짚은 그림 위 점(네모)이 손가락 밑에 그대로 있다
- 끔: 같은 몸짓에 점이 손가락에서 빠져나가며 빨간 점선과 「…px 어긋남」
- 「배율 ×… · 회전 …°」 숫자, PC 휠로도 같은 동작

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

## 원리
- 시작할 때 기억: 두 손가락 가운데 m0, 거리 d0, 각도 a0, 그림의 자리 · 배율 · 회전.
- 지금 배율 k = 지금 거리 / d0, 회전 da = 지금 각도 − a0.
- 그림 자리 = 지금 가운데 m + 회전(da) × (처음 그림 자리 − m0) × k — 가운데를 기준으로 확대 · 돌리면 손가락 밑 점이 그대로다.
- 그림 가운데를 기준으로 하면(끔) 배율만 맞고 자리가 어긋나 손가락 밑 점이 빠져나간다.
- 한 손가락은 옮기기. 손가락 수가 바뀌면 그 순간 다시 시작값을 잡는다.

## 핵심 코드 — 두 손가락 가운데를 중심으로 확대 · 회전
(발췌: demos/demosInput.ts i422 startGest() · apply() · zoomAt())
```ts
const rot = (p: V2, a: number): V2 => ({ x: p.x * Math.cos(a) - p.y * Math.sin(a), y: p.x * Math.sin(a) + p.y * Math.cos(a) });
let pic = { x: 150, y: 172, s: 0.74, r: 0 }; // 그림 자리 · 배율 · 회전
const touches = new Map<number, V2>();
let gest: { a: number; b: number; m0: V2; d0: number; a0: number; pic0: typeof pic } | null = null;

function startGest(): void { // 손가락이 둘이 되는 순간 (줄었다 늘 때도 다시)
  const [a, b] = [...touches.keys()];
  const pa = touches.get(a)!, pb = touches.get(b)!;
  gest = { a, b, m0: { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 },
    d0: Math.max(4, Math.hypot(pb.x - pa.x, pb.y - pa.y)), a0: Math.atan2(pb.y - pa.y, pb.x - pa.x), pic0: { ...pic } };
}

function apply(): void {
  if (!gest) return;
  const pa = touches.get(gest.a), pb = touches.get(gest.b);
  if (!pa || !pb) return;
  const m = { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 };
  const k = Math.max(4, Math.hypot(pb.x - pa.x, pb.y - pa.y)) / gest.d0; // 거리 비 = 배율
  const da = Math.atan2(pb.y - pa.y, pb.x - pa.x) - gest.a0;             // 각도 차 = 회전
  const s = Math.min(4, Math.max(0.3, gest.pic0.s * k));
  const kk = s / gest.pic0.s;
  const q = rot({ x: (gest.pic0.x - gest.m0.x) * kk, y: (gest.pic0.y - gest.m0.y) * kk }, da);
  pic = { x: m.x + q.x, y: m.y + q.y, s, r: gest.pic0.r + da }; // 가운데 기준 → 손가락 밑 점이 그대로
}

// PC 휠: 마우스 자리 (cx, cy) 중심
function zoomAt(cx: number, cy: number, k: number, da: number): void {
  const s = Math.min(4, Math.max(0.3, pic.s * k));
  const kk = s / pic.s;
  const q = rot({ x: (pic.x - cx) * kk, y: (pic.y - cy) * kk }, da);
  pic = { x: cx + q.x, y: cy + q.y, s, r: pic.r + da };
}
// 휠: zoomAt(x, y, Math.exp(-dy * 0.0018), 0) · Shift + 휠: zoomAt(x, y, 1, dy * 0.004)
```

## 흔한 실수 · 확인 목록
- [ ] **그림 가운데를 기준으로 확대하면 손가락 밑 점이 빠져나간다** — 두 손가락 가운데를 기준으로 — 견본 「끔」이 바로 이 실수다.
- [ ] **한 손가락을 떼었다 다시 대면 그림이 확 튄다** — 손가락 수가 바뀌는 순간마다 시작값을 다시 잡는다 (startGest · startPan).
- [ ] **두 손가락 몸짓을 하면 브라우저 페이지가 확대된다** — 캔버스에 touch-action: none.

## 완성 기준 체크리스트
- [ ] 켬: 두 손가락으로 벌리며 돌리면 처음 짚은 그림 위 점(네모)이 손가락 밑에 그대로 있다
- [ ] 끔: 같은 몸짓에 점이 손가락에서 빠져나가며 빨간 점선과 「…px 어긋남」
- [ ] 「배율 ×… · 회전 …°」 숫자, PC 휠로도 같은 동작

## 이 기술 정보
- id: `i422` · 분류: 게임 시스템 · AI › 입력 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 움직일 때 삼각 함수 몇 번. 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i422
- 쓰면 좋을 때: 큰 지도 · 그림을 폰에서 확대해 볼 때 / 3D 보기에서 확대 · 회전을 손으로
- 쓰지 말 때: 작은 판 퍼즐 — 확대가 필요 없고 실수만 늘린다 / 브라우저 확대와 겹칠 때 — 캔버스에 touch-action: none 을 꼭

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i422 — `src/demos/demosInput.ts:1554`
```ts
const i422 = demo<P422>({
  caption: '두 손가락 벌리며 돌리기 — 끔: 그림 가운데 기준이라 손가락 밑 점이 빠져나감(빨강) · 켬: 두 손가락 가운데 기준, 점이 그대로',
  howto: '두 손가락으로 벌리고 돌려 보세요. PC: Shift + 끌기 = 두 손가락(보라 = 가상 손가락), 휠 = 확대, Shift + 휠 = 회전.',
  onName: '두 손가락 가운데를 중심으로',
  cycle: 3.8,
  pinchEmu: true,
  prm: () => ({}),
  script: (u) => [
    path(u, [
      [0, 182, 262, 0],
      [0.3, 175, 205, 0],
      [0.45, 175, 205, 1],
      [1.5, 170, 238, 1],
      [2.0, 170, 238, 1],
      [3.0, 175, 205, 1],
      [3.15, 175, 205, 0],
      [3.8, 182, 262, 0],
    ]),
    path(
      u,
      [
        [0, 246, 236, 0],
        [0.3, 225, 185, 0],
        [0.45, 225, 185, 1],
        [1.5, 234, 167, 1],
        [2.0, 234, 167, 1],
        [3.0, 225, 185, 1],
        [3.15, 225, 185, 0],
        [3.8, 246, 236, 0],
      ],
      2,
    ),
  ],
  create: (env) => {
    const HOME = { x: 150, y: 172, s: 0.74, r: 0 };
    let pic = { ...HOME };
    const touches = new Map<number, V2>();
    let gest: { a: number; b: number; m0: V2; d0: number; a0: number; pic0: typeof pic; pins: V2[] } | null = null;
    let pan: { id: number; x0: number; y0: number; pic0: typeof pic } | null = null;
    let v: Verdict = { text: '두 손가락으로 벌려요', color: COL.dim };
    let maxOff = 0;
    const toLocal = (p: V2): V2 => {
      const q = rot({ x: p.x - pic.x, y: p.y - pic.y }, -pic.r);
      return { x: q.x / pic.s, y: q.y / pic.s };
    };
    const toWorld = (l: V2): V2 => {
      const q = rot({ x: l.x * pic.s, y: l.y * pic.s }, pic.r);
      return { x: pic.x + q.x, y: pic.y + q.y };
    };
    const startGest = (): void => {
      const ids = [...touches.keys()];
      const a = ids[0]!;
      const b = ids[1]!;
      const pa = touches.get(a)!;
      const pb = touches.get(b)!;
      gest = { a, b, m0: { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 }, d0: Math.max(4, dist(pa.x, pa.y, pb.x, pb.y)), a0: Math.atan2(pb.y - pa.y, pb.x - pa.x), pic0: { ...pic }, pins: [toLocal(pa), toLocal(pb)] };
      pan = null;
      maxOff = 0;
    };
    const startPan = (id: number): void => {
      const p = touches.get(id);
      if (p) pan = { id, x0: p.x, y0: p.y, pic0: { ...pic } };
    };
    const apply = (): void => {
      if (gest) {
        const pa = touches.get(gest.a);
        const pb = touches.get(gest.b);
        if (!pa || !pb) return;
        const m = { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 };
        const k = Math.max(4, dist(pa.x, pa.y, pb.x, pb.y)) / gest.d0;
        const da = Math.atan2(pb.y - pa.y, pb.x - pa.x) - gest.a0;
        const s = clamp(gest.pic0.s * k, 0.3, 4);
        const kk = s / gest.pic0.s;
        if (env.on()) {
          const q = rot({ x: (gest.pic0.x - gest.m0.x) * kk, y: (gest.pic0.y - gest.m0.y) * kk }, da);
          pic = { x: m.x + q.x, y: m.y + q.y, s, r: gest.pic0.r + da };
        } else pic = { x: gest.pic0.x, y: gest.pic0.y, s, r: gest.pic0.r + da };
        const w0 = toWorld(gest.pins[0]!);
        const w1 = toWorld(gest.pins[1]!);
        const off = Math.max(dist(w0.x, w0.y, pa.x, pa.y), dist(w1.x, w1.y, pb.x, pb.y));
        maxOff = Math.max(maxOff, off);
        v = off > 6 ? { text: `손가락 밑 점이 ${Math.round(off)}px 어긋남`, color: COL.off } : { text: '손가락 밑 점 그대로', color: COL.on };
      } else if (pan) {
        const p = touches.get(pan.id);
        if (p) pic = { ...pan.pic0, x: pan.pic0.x + p.x - pan.x0, y: pan.pic0.y + p.y - pan.y0 };
      }
    };
    const zoomAt = (cx: number, cy: number, k: number, da: number): void => {
      const s = clamp(pic.s * k, 0.3, 4);
      const kk = s / pic.s;
      const c = env.on() ? { x: cx, y: cy } : { x: pic.x, y: pic.y };
      const q = rot({ x: (pic.x - c.x) * kk, y: (pic.y - c.y) * kk }, da);
      pic = { x: c.x + q.x, y: c.y + q.y, s, r: pic.r + da };
    };
    return {
      reset() {
        pic = { ...HOME };
        touches.clear();
        gest = null;
        pan = null;
        maxOff = 0;
        v = { text: '두 손가락으로 벌려요', color: COL.dim };
      },
      down(id, x, y) {
        touches.set(id, { x, y });
        if (touches.size === 2) startGest();
        else if (touches.size === 1) startPan(id);
      },
      move(id, x, y) {
        if (!touches.has(id)) return;
        touches.set(id, { x, y });
        apply();
      },
      up(id) {
        touches.delete(id);
        if (gest && (gest.a === id || gest.b === id)) {
          gest = null;
          const rest = [...touches.keys()];
          if (rest.length >= 2) startGest();
          else if (rest.length === 1) startPan(rest[0]!);
        } else if (pan && pan.id === id) pan = null;
      },
      cancel(id) {
        touches.delete(id);
        if (gest && (gest.a === id || gest.b === id)) gest = null;
        if (pan && pan.id === id) pan = null;
      },
      wheel(x, y, dy, shift) {
        if (shift) zoomAt(x, y, 1, clamp(dy, -100, 100) * 0.004);
        else zoomAt(x, y, Math.exp(-clamp(dy, -100, 100) * 0.0018), 0);
        v = { text: shift ? `휠 회전 · ${env.on() ? '마우스 자리 중심' : '그림 가운데 중심'}` : `휠 확대 · ${env.on() ? '마우스 자리 중심' : '그림 가운데 중심'}`, color: env.on() ? COL.on : COL.warn };
      },
      step() {},
      draw(g) {
        // 그림 (보물 지도)
        g.save();
        g.translate(pic.x, pic.y);
        g.rotate(pic.r);
        g.scale(pic.s, pic.s);
        g.shadowColor = 'rgba(0,0,0,0.55)';
        g.shadowBlur = 18;
        g.shadowOffsetY = 8;
        rr(g, -90, -90, 180, 180, 14);
        const gr = g.createLinearGradient(-90, -90, 90, 90);
        gr.addColorStop(0, '#f6e7c3');
        gr.addColorStop(1, '#dcc08a');
        g.fillStyle = gr;
        g.fill();
        g.shadowColor = 'transparent';
        g.save();
        rr(g, -90, -90, 180, 180, 14);
        g.clip();
        g.fillStyle = '#7fc6d9';
        g.fillRect(-90, -90, 180, 180);
        for (let i = -90; i <= 90; i += 15) {
          line(g, i, -90, i, 90, 'rgba(255,255,255,0.18)', 0.7);
          line(g, -90, i, 90, i, 'rgba(255,255,255,0.18)', 0.7);
        }
        g.beginPath();
        g.moveTo(-58, -20);
        g.bezierCurveTo(-60, -66, 10, -72, 34, -48);
        g.bezierCurveTo(70, -30, 66, 22, 44, 44);
        g.bezierCurveTo(20, 70, -40, 64, -52, 32);
        g.bezierCurveTo(-66, 10, -56, 0, -58, -20);
        g.closePath();
        g.fillStyle = '#efd79c';
        g.fill();
        g.lineWidth = 3;
        g.strokeStyle = '#c9a463';
        g.stroke();
        g.beginPath();
        g.ellipse(-8, -6, 30, 22, 0.3, 0, TAU);
        g.fillStyle = '#7dbb5a';
        g.fill();
        g.beginPath();
        g.moveTo(-40, 34);
        g.bezierCurveTo(-20, 10, 0, 40, 18, 18);
        g.bezierCurveTo(30, 4, 26, -10, 34, -24);
        g.setLineDash([4, 4]);
        g.lineWidth = 2;
        g.strokeStyle = '#9b3b2a';
        g.stroke();
        g.setLineDash([]);
        cross(g, 36, -28, 7, '#c4242f', 3.5);
        // 나무 셋
        for (const [tx, ty] of [
          [-20, -16],
          [0, -2],
          [-30, 4],
        ] as [number, number][]) {
          disc(g, tx, ty, 6, '#3d8a3a');
          disc(g, tx - 1.5, ty - 2, 2.5, 'rgba(255,255,255,0.25)');
        }
        // 나침반
        g.save();
        g.translate(62, 62);
        ring(g, 0, 0, 13, '#5d4a2c', 1.5);
        g.beginPath();
        g.moveTo(0, -12);
        g.lineTo(3.5, 0);
        g.lineTo(0, 12);
        g.lineTo(-3.5, 0);
        g.closePath();
        g.fillStyle = '#5d4a2c';
        g.fill();
        txt(g, 'N', 0, -19, 8, '#5d4a2c', 'center', 800);
        g.restore();
        g.restore();
        rr(g, -90, -90, 180, 180, 14);
        g.lineWidth = 3;
        g.strokeStyle = '#8a6a3a';
        g.stroke();
        g.restore();
        // 중심 표시
        if (gest) {
          const pa = touches.get(gest.a);
          const pb = touches.get(gest.b);
          if (pa && pb) {
            line(g, pa.x, pa.y, pb.x, pb.y, 'rgba(255,255,255,0.35)', 1.2, [3, 4]);
            const c = env.on() ? { x: (pa.x + pb.x) / 2, y: (pa.y + pb.y) / 2 } : { x: pic.x, y: pic.y };
            const cc = env.on() ? COL.on : COL.off;
            ring(g, c.x, c.y, 7, cc, 2);
            line(g, c.x - 11, c.y, c.x + 11, c.y, cc, 1.5);
            line(g, c.x, c.y - 11, c.x, c.y + 11, cc, 1.5);
            txt(g, env.on() ? '중심: 손가락 가운데' : '중심: 그림 가운데', c.x, c.y + 20, 9.5, cc, 'center', 700);
            gest.pins.forEach((pl, i) => {
              const w = toWorld(pl);
              const f = i === 0 ? pa : pb;
              const off = dist(w.x, w.y, f.x, f.y);
              if (off > 4) line(g, f.x, f.y, w.x, w.y, COL.off, 2, [4, 3]);
              g.save();
              g.translate(w.x, w.y);
              g.rotate(Math.PI / 4);
              g.fillStyle = off > 6 ? COL.off : COL.on;
              g.fillRect(-4.5, -4.5, 9, 9);
              g.strokeStyle = '#fff';
              g.lineWidth = 1.2;
              g.strokeRect(-4.5, -4.5, 9, 9);
              g.restore();
            });
          }
        }
      },
      verdict: () => v,
      extra: () => [
        `배율 ×${(pic.s / HOME.s).toFixed(2)} · 회전 ${Math.round((pic.r * 180) / Math.PI)}°`,
        gest ? `두 점 거리 비 = 배율, 두 점 각도 차 = 회전` : '한 손가락 = 옮기기, 두 손가락 = 확대 · 회전',
        `가장 크게 어긋난 거리 ${Math.round(maxOff)}px`,
        '네모 점 = 처음 손가락이 짚은 그림 위 자리',
      ],
    };
  },
  controls: () => [],
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [기기 기울이기 · 흔들기](https://ai-techstudio.web.app/ai/t/u51.md) `u51` · [길게 누르기 · 두 번 누르기 구별](https://ai-techstudio.web.app/ai/t/i421.md) `i421`
- 다음에 해 볼 기술: [끌어다 놓기 미리 보기](https://ai-techstudio.web.app/ai/t/i424.md) `i424`
- 참고 문서: [MDN — Pinch zoom gestures (Pointer events)](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures)
