# AI 꾸러미 — 변형 메시 (그림을 쭈욱 늘리기) — Mesh deformation (2D warp)
> 그림 한 장을 7×7 격자 삼각형으로 잘라 꼭짓점만 움직이고 삼각형마다 그림을 아핀 변환으로 붙여, 숨쉬기 · 깃발 출렁 · 당기기 · 말랑 흔들기를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i299

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

## 주문서

### 만들어 줘: 변형 메시 (그림을 쭈욱 늘리기) — Mesh deformation (2D warp)

#### 1. 목표
숨쉬는 캐릭터 그림을 격자 변형으로 움직여 줘 — 그림을 7×7 격자 삼각형으로 잘라 꼭짓점만 움직이고, 삼각형마다 그림을 아핀 변환으로 붙인다. 움직임은 숨쉬기 (위아래 눌림 + 가운데 볼록).

#### 2. 핵심 기술 용어
- **Mesh deformation (2D warp)** — 격자 꼭짓점을 움직여 그림 휘기
- **Affine texture mapping per triangle** — 삼각형마다 원래 → 새 자리 아핀 변환
- **ctx.clip + ctx.transform** — 삼각형만 잘라 변환해 그리기
- **Squash & stretch / breathing** — 찌그러짐 · 숨쉬기

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

#### 4. 조건
- 꼭짓점만 계산하고, 그림은 삼각형마다 아핀 변환으로 붙이기
- 삼각형 clip 은 중심에서 0.7px 바깥으로 넓혀 실금 막기
- 행렬식(det)이 0 에 가까운 삼각형은 건너뛰기
- 변형 4가지(숨쉬기 · 깃발 · 당기기 · 말랑)를 바꿔 볼 수 있게, 격자 보기 켬/끔

#### 5. 완성 기준 (이게 보이면 성공)
- 격자 보기를 켜면 분홍 꼭짓점 · 선이 움직이고 그림이 그 격자를 따라 휜다
- 숨쉬기에선 위아래로 눌리며 가운데가 볼록해진다
- 깃발에선 오른쪽 끝일수록 크게 출렁이고 물결 그늘이 진다
- 삼각형 사이에 실금이 보이지 않는다

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

## 원리
- 그림을 N×N(7×7) 칸으로 나누고 칸마다 삼각형 2개. 꼭짓점 (N+1)² = 64개만 계산한다.
- 삼각형마다 원래 그림 세 점 S → 새 세 점 D 로 가는 아핀 행렬을 푼다: M = D·S⁻¹ (2×2 역행렬 + 이동).
- 삼각형 모양으로 clip 하고 transform(M) 뒤 그림 전체를 drawImage — 그 삼각형 부분만 새 자리에 붙는다.
- 삼각형을 0.7px 바깥으로 넓혀 clip 해야 삼각형 사이 실금이 안 생긴다.
- 변형 식: 숨쉬기는 세로 1 − 0.09·sin(3.2t) + 가운데 볼록 0.13·sin(π v), 깃발은 u·12·sin(5.5u − 5t) 로 오른쪽일수록 크게.

## 핵심 코드 — 삼각형 하나를 새 자리로 아핀 변환해 그리기
(발췌: demos/demos2dLook.ts drawTri() · mkI299() 를 정리)
```ts
function drawTri(g: CanvasRenderingContext2D, img: CanvasImageSource,
  s0x: number, s0y: number, s1x: number, s1y: number, s2x: number, s2y: number,   // 원래 그림 세 점
  d0x: number, d0y: number, d1x: number, d1y: number, d2x: number, d2y: number) { // 새 세 점
  const cx = (d0x + d1x + d2x) / 3, cy = (d0y + d1y + d2y) / 3;
  const ex = (x: number, y: number) => { const l = Math.hypot(x - cx, y - cy) || 1; return [x + ((x - cx) / l) * 0.7, y + ((y - cy) / l) * 0.7]; };
  const a = ex(d0x, d0y), b = ex(d1x, d1y), c = ex(d2x, d2y);
  g.save();
  g.beginPath(); g.moveTo(a[0]!, a[1]!); g.lineTo(b[0]!, b[1]!); g.lineTo(c[0]!, c[1]!); g.closePath();
  g.clip(); // 0.7px 넓힌 삼각형 — 실금 막기
  const S00 = s1x - s0x, S01 = s2x - s0x, S10 = s1y - s0y, S11 = s2y - s0y;
  const det = S00 * S11 - S01 * S10;
  if (Math.abs(det) > 1e-9) {
    const i00 = S11 / det, i01 = -S01 / det, i10 = -S10 / det, i11 = S00 / det; // S 의 역행렬
    const D00 = d1x - d0x, D01 = d2x - d0x, D10 = d1y - d0y, D11 = d2y - d0y;
    const ma = D00 * i00 + D01 * i10, mc = D00 * i01 + D01 * i11; // M = D · S⁻¹
    const mb = D10 * i00 + D11 * i10, md = D10 * i01 + D11 * i11;
    g.transform(ma, mb, mc, md, d0x - ma * s0x - mc * s0y, d0y - mb * s0x - md * s0y);
    g.drawImage(img, 0, 0);
  }
  g.restore();
}

// 7×7 격자: 꼭짓점 (i, j) 의 새 자리 → 칸마다 삼각형 2개
const N = 7, IS = 160, SZ = 118, cs = IS / N;
for (let j = 0; j <= N; j++) for (let i = 0; i <= N; i++) {
  const u = i / N, v = j / N;
  let x = X0 + u * SZ, y = Y0 + v * SZ;
  const s = Math.sin(t * 3.2); // 숨쉬기
  x = 140 + (x - 140) * (1 + 0.13 * s * Math.sin(Math.PI * v));
  y = Y0 + SZ - (Y0 + SZ - y) * (1 - 0.09 * s);
  vx[j * (N + 1) + i] = x; vy[j * (N + 1) + i] = y;
}
// drawTri(g, pic, sx, sy, sx+cs, sy, sx, sy+cs, A, B, C) · drawTri(g, pic, sx+cs, sy, sx+cs, sy+cs, sx, sy+cs, B, D, C)
```

## 흔한 실수 · 확인 목록
- [ ] **삼각형을 딱 맞게 clip 하면 삼각형 사이에 실금이 보인다** — 중심에서 0.7px 바깥으로 넓혀 clip 한다.
- [ ] **꼭짓점이 뒤집혀(접혀) 행렬식이 0 이면 그림이 사라지거나 튄다** — |det| 가 아주 작으면 그 삼각형은 건너뛰고, 변형 크기를 칸 크기보다 작게.
- [ ] **그림 전체를 삼각형마다 drawImage 하니 큰 그림이면 느리다** — 그림을 필요한 크기로 미리 줄여 두거나, 많으면 WebGL 메시로.
- [ ] **말랑 흔들기에서 바닥까지 흔들리면 미끄러져 보인다** — 아래 줄(v = 1)은 고정하고 위로 갈수록 크게 (1 − v) 를 곱한다.

## 완성 기준 체크리스트
- [ ] 격자 보기를 켜면 분홍 꼭짓점 · 선이 움직이고 그림이 그 격자를 따라 휜다
- [ ] 숨쉬기에선 위아래로 눌리며 가운데가 볼록해진다
- [ ] 깃발에선 오른쪽 끝일수록 크게 출렁이고 물결 그늘이 진다
- [ ] 삼각형 사이에 실금이 보이지 않는다

## 이 기술 정보
- id: `i299` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 7×7 = 삼각형 98개 × (clip + drawImage). 그림 하나는 괜찮지만 여러 개면 폰에서 무거워진다. WebGL 이면 같은 일이 훨씬 싸다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i299
- 쓰면 좋을 때: 그림 한 장으로 숨쉬기 · 출렁 같은 작은 생기를 줄 때 / 잡아당기기 · 누르기에 반응하는 말랑한 물체
- 쓰지 말 때: 팔다리가 크게 움직이는 동작 — 격자가 꼬인다. 대신 뼈대(i300) / 격자를 아주 촘촘하게 (20×20 넘게) — 삼각형마다 clip · drawImage 라 느려진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI299 — `src/demos/demos2dLook.ts:1125`
```ts
function mkI299() {
  const IS = 160;
  const pic = mkCanvas(IS, IS);
  {
    const x = c2(pic);
    const sky = x.createLinearGradient(0, 0, 0, IS);
    sky.addColorStop(0, '#8fd3ff');
    sky.addColorStop(1, '#e8f7ff');
    x.fillStyle = sky;
    x.fillRect(0, 0, IS, IS);
    x.fillStyle = '#fff6b0';
    x.beginPath();
    x.arc(128, 30, 14, 0, TAU);
    x.fill();
    x.fillStyle = '#fff';
    for (const [cx, cy] of [
      [30, 30],
      [50, 26],
      [42, 36],
    ] as const) {
      x.beginPath();
      x.arc(cx, cy, 10, 0, TAU);
      x.fill();
    }
    x.fillStyle = '#7fd36a';
    x.beginPath();
    x.moveTo(0, 120);
    x.quadraticCurveTo(50, 100, 90, 118);
    x.quadraticCurveTo(130, 104, 160, 116);
    x.lineTo(160, 160);
    x.lineTo(0, 160);
    x.fill();
    x.fillStyle = '#5bb04d';
    x.fillRect(0, 140, IS, 20);
    for (let i = 0; i < 9; i++) {
      x.fillStyle = ['#ff7eb6', '#ffd84d', '#fff'][i % 3]!;
      x.beginPath();
      x.arc(10 + i * 18, 132 + (i % 2) * 8, 3, 0, TAU);
      x.fill();
    }
    x.drawImage(buddy(128, 'mint'), 16, 20, 128, 128);
    x.lineWidth = 6;
    x.strokeStyle = '#ffffff';
    x.strokeRect(3, 3, IS - 6, IS - 6);
  }
  const N = 7;
  let grid = true;
  let auto = true;
  let mode = 0;
  const NAMES = ['숨쉬기', '깃발 출렁', '쭈욱 당기기', '말랑 흔들'];
  const controls: Control[] = [
    { type: 'toggle', label: '격자 보기', value: grid, on: (v) => (grid = v) },
    { type: 'toggle', label: '자동으로 바꾸기', value: auto, on: (v) => (auto = v) },
    {
      type: 'button',
      label: '다음 변형',
      on: () => {
        auto = false;
        mode = (mode + 1) % 4;
      },
    },
  ];
  const vx = new Float32Array((N + 1) * (N + 1));
  const vy = new Float32Array((N + 1) * (N + 1));
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#22284a', '#10132a');
      const m = auto ? Math.floor(t / 3.2) % 4 : mode;
      const lt = auto ? t % 3.2 : t;
      const SZ = 118;
      const X0 = 140 - SZ / 2;
      const Y0 = 34;
      let grab: [number, number] | null = null;
      for (let j = 0; j <= N; j++)
        for (let i = 0; i <= N; i++) {
          const u = i / N;
          const v = j / N;
          let x = X0 + u * SZ;
          let y = Y0 + v * SZ;
          if (m === 0) {
            const s = Math.sin(lt * 3.2);
            const sy = 1 - 0.09 * s;
            const bul = 0.13 * s * Math.sin(Math.PI * v);
            x = 140 + (x - 140) * (1 + bul);
            y = Y0 + SZ - (Y0 + SZ - y) * sy;
          } else if (m === 1) {
            y += u * 12 * Math.sin(u * 5.5 - lt * 5);
            x += -u * 3 * (1 + Math.cos(u * 5.5 - lt * 5)) + u * 2;
          } else if (m === 2) {
            const k = lt % 3.2;
            const pull = k < 1.2 ? smooth(k / 1.2) : Math.exp(-(k - 1.2) * 2.5) * Math.cos((k - 1.2) * 11);
            const gx = 0.85;
            const gy = 0.2;
            const d2 = (u - gx) ** 2 + (v - gy) ** 2;
            const wgt = Math.exp(-d2 / 0.12);
            x += wgt * pull * 34;
            y -= wgt * pull * 22;
            grab = [X0 + gx * SZ + pull * 34, Y0 + gy * SZ - pull * 22];
          } else {
            const fixed = v;
            x += Math.sin(lt * 7 + v * 4) * 6 * (1 - fixed) * Math.sin(Math.PI * u + 0.2);
            y += Math.sin(lt * 9 + u * 5) * 3 * (1 - fixed);
          }
          vx[j * (N + 1) + i] = x;
          vy[j * (N + 1) + i] = y;
        }
      // 그림자
      g.fillStyle = 'rgba(0,0,0,.3)';
      g.beginPath();
      g.ellipse(140, Y0 + SZ + 6, SZ * 0.55, 6, 0, 0, TAU);
      g.fill();
      const cs = IS / N;
      for (let j = 0; j < N; j++)
        for (let i = 0; i < N; i++) {
          const a = j * (N + 1) + i;
          const b = a + 1;
          const c = a + N + 1;
          const d = c + 1;
          const sx = i * cs;
          const sy = j * cs;
          let sh = 0;
          if (m === 1) sh = Math.cos((i + 0.5) / N * 5.5 - lt * 5) * 0.16 * ((i + 0.5) / N);
          drawTri(g, pic, sx, sy, sx + cs, sy, sx, sy + cs, vx[a]!, vy[a]!, vx[b]!, vy[b]!, vx[c]!, vy[c]!, sh);
          drawTri(g, pic, sx + cs, sy, sx + cs, sy + cs, sx, sy + cs, vx[b]!, vy[b]!, vx[d]!, vy[d]!, vx[c]!, vy[c]!, sh);
        }
      if (grid) {
        g.strokeStyle = 'rgba(255,90,160,.75)';
        g.lineWidth = 0.6;
        g.beginPath();
        for (let j = 0; j <= N; j++)
          for (let i = 0; i <= N; i++) {
            const a = j * (N + 1) + i;
            if (i < N) {
              g.moveTo(vx[a]!, vy[a]!);
              g.lineTo(vx[a + 1]!, vy[a + 1]!);
            }
            if (j < N) {
              g.moveTo(vx[a]!, vy[a]!);
              g.lineTo(vx[a + N + 1]!, vy[a + N + 1]!);
            }
            if (i < N && j < N) {
              g.moveTo(vx[a + 1]!, vy[a + 1]!);
              g.lineTo(vx[a + N + 1]!, vy[a + N + 1]!);
            }
          }
        g.stroke();
        g.fillStyle = '#ff5aa0';
        for (let i = 0; i < vx.length; i++) {
          g.beginPath();
          g.arc(vx[i]!, vy[i]!, 1.3, 0, TAU);
          g.fill();
        }
      }
      if (grab) {
        const [gx, gy] = grab as [number, number];
        g.fillStyle = '#fff';
        g.strokeStyle = '#2a2a40';
        g.lineWidth = 1.2;
        g.beginPath();
        g.moveTo(gx, gy);
        g.lineTo(gx + 4, gy + 12);
        g.lineTo(gx + 7, gy + 8);
        g.lineTo(gx + 12, gy + 13);
        g.lineTo(gx + 14, gy + 11);
        g.lineTo(gx + 9, gy + 6);
        g.lineTo(gx + 13, gy + 4);
        g.closePath();
        g.fill();
        g.stroke();
      }
      pill(g, `그림 1장 · 격자 ${N}×${N} 꼭짓점만 움직임 — ${NAMES[m]}`, 140, 14, 8, 'rgba(255,90,160,.2)', '#ffe6f2');
      g.restore();
    },
  };
}
```

### i299 견본 항목 — `src/demos/demos2dLook.ts:3782`
```ts
  i299: { kind: '2d', caption: '그림 한 장을 7×7 격자 삼각형으로 잘라 꼭짓점만 움직임 — 숨쉬기 · 깃발 · 당기기 · 말랑', make: mkI299 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [그림자 · 반사 (2D 바닥)](https://ai-techstudio.web.app/ai/t/i297.md) `i297`
- 다음에 해 볼 기술: [2D 뼈대 애니 (코드로)](https://ai-techstudio.web.app/ai/t/i300.md) `i300` · [말랑한 몸 (스프링 젤리 · 압력)](https://ai-techstudio.web.app/ai/t/i287.md) `i287`
- 참고 문서: [Wikipedia — Affine transformation](https://en.wikipedia.org/wiki/Affine_transformation)
