# AI 꾸러미 — 도형 모핑 (모양 바꾸기) — Shape morphing (point-matched path interpolation)
> 삼각형 · 사각형 · 오각형 · 육각형 · 원을 모두 같은 점 60개로 다시 나눠 두고, 점끼리 이어 섞어 모양이 부드럽게 바뀌게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i96

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

## 주문서

### 만들어 줘: 도형 모핑 (모양 바꾸기) — Shape morphing (point-matched path interpolation)

#### 1. 목표
삼각형 → 사각형 → … → 원 (변이 늘면 원에 가까워짐)을(를) 도형 모핑으로 보여 줘 — 모든 모양을 같은 점 개수로 맞춰 두고 점끼리 이어서 바꾸기. 분위기는 밝은 파스텔.

#### 2. 핵심 기술 용어
- **Shape morphing (point-matched path interpolation)** — 점 수를 맞춘 경로 보간
- **Resampling** — 모양 둘레를 같은 수의 점으로 다시 나누기
- **Start-point alignment** — 첫 점을 같은 방향(12시)에 맞춰 비틀림 막기

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

#### 4. 조건
- 모든 모양을 같은 점 개수(60)로 미리 계산해 둔다 — 매 프레임 다시 나누지 않기
- 60 이 변 개수로 나누어떨어지게 (3 · 4 · 5 · 6 모두 OK)
- 첫 점을 같은 방향(12시)으로 돌려 맞추기 — 안 하면 섞는 동안 꼬인다
- 머무름(견본 0.9초) · 바뀜(0.75초)을 번갈아 주기로 반복
- 「점 보기」 켬으로 60개 점이 어떻게 짝지어지는지 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 삼각형이 사각형 · 오각형 · 육각형 · 원으로 차례로 부드럽게 바뀌고 다시 삼각형으로 돌아온다
- 바뀌는 동안 모양이 비틀리거나 안으로 접히지 않는다
- 「점 60개 보기」 를 켜면 점들이 짝을 지어 미끄러지는 것이 보인다

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

## 원리
- 모양마다 점 개수가 다르면 이어 섞을 수 없다. 그래서 둘레를 모두 같은 개수(60)로 다시 나눈다.
- k 각형은 변마다 60 / k 개씩 — 꼭짓점 사이를 직선 보간으로 채운다. 원은 각도를 60 등분.
- 첫 점을 12시 방향에 가장 가까운 점으로 돌려 맞춰야, 섞을 때 모양이 꼬이지(비틀리지) 않는다.
- 섞기: 점 q 마다 lerp(A[q], B[q], k), k 는 inOut 이징. 섞는 동안 살짝 부풀었다(1 + 0.06·sin πk) 돌아온다.

## 핵심 코드 — 정다각형 · 원을 점 60개로 + 첫 점 맞추기 + 보간
(발췌: demos/demosMotionA.ts polyPts() · i96 의 draw 를 정리)
```ts
const NPT = 60, TAU = Math.PI * 2;
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
function polyPts(k: number, R: number): [number, number][] {
  const out: [number, number][] = [];
  if (k === 0) { // 원
    for (let i = 0; i < NPT; i++) { const a = -Math.PI / 2 + (i / NPT) * TAU; out.push([Math.cos(a) * R, Math.sin(a) * R]); }
    return out;
  }
  const off = -Math.PI / 2 + (k % 2 === 0 ? Math.PI / k : 0); // 짝수 각형은 바닥이 평평하게
  const per = NPT / k;
  for (let s = 0; s < k; s++) {
    const a0 = off + (s / k) * TAU, a1 = off + ((s + 1) / k) * TAU;
    for (let j = 0; j < per; j++) {
      const f = j / per;
      out.push([lerp(Math.cos(a0), Math.cos(a1), f) * R, lerp(Math.sin(a0), Math.sin(a1), f) * R]);
    }
  }
  // 첫 점을 12시에 가장 가까운 점으로 — 모양끼리 비틀림이 덜하게
  let best = 0, bestA = 9;
  out.forEach(([x, y], i) => { const d = Math.abs(Math.atan2(x, -y)); if (d < bestA - 1e-6) { bestA = d; best = i; } });
  return out.slice(best).concat(out.slice(0, best));
}
const pts = [3, 4, 5, 6, 0].map((k) => polyPts(k, 52)); // 미리 한 번

// 그리기: A → B 를 k(0~1, inOut 이징)로
g.beginPath();
for (let q = 0; q < NPT; q++) {
  const x = lerp(A[q][0], B[q][0], k), y = lerp(A[q][1], B[q][1], k);
  if (q === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.closePath();
g.fill();
```

## 흔한 실수 · 확인 목록
- [ ] **점 개수가 다른 모양끼리 섞으면 점이 엉뚱하게 날아간다** — 모두 같은 개수로 다시 나눈다. 60 은 3 · 4 · 5 · 6 으로 나누어떨어져 꼭짓점이 점 위에 딱 온다.
- [ ] **첫 점 방향이 다르면 섞는 동안 모양이 꼬인다** — 12시에 가장 가까운 점을 첫 점으로 돌려 맞춘다.
- [ ] **원을 조금씩 다른 방향에서 시작하면 섞을 때 돈다** — 원도 −90°(12시)부터 같은 방향으로 점을 찍는다.

## 완성 기준 체크리스트
- [ ] 삼각형이 사각형 · 오각형 · 육각형 · 원으로 차례로 부드럽게 바뀌고 다시 삼각형으로 돌아온다
- [ ] 바뀌는 동안 모양이 비틀리거나 안으로 접히지 않는다
- [ ] 「점 60개 보기」 를 켜면 점들이 짝을 지어 미끄러지는 것이 보인다

## 이 기술 정보
- id: `i96` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 점 60개 보간 · 경로 하나. 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i96
- 쓰면 좋을 때: 「변의 수를 늘리면 원에 가까워진다」 처럼 모양 사이의 관계를 보여 줄 때 / 아이콘 · 단추 모양을 상태에 따라 바꿀 때
- 쓰지 말 때: 구멍이 있는 모양 · 여러 조각 모양 — 점 짝짓기가 어렵다. 대신 섞어 사라지기(크로스페이드) / 아주 복잡한 그림 — SVG 모핑 라이브러리를 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i96 — `src/demos/demosMotionA.ts:774`
```ts
const i96 = {
  kind: '2d' as const,
  caption: '삼각형 → 사각형 → 오각형 → 육각형 → 원 — 모두 점 60개로 맞춰 두고 점끼리 이어서 바꿔요',
  make() {
    const o = { s: 1 };
    let tt = 0;
    let showPts = false;
    const SH = [
      { k: 3, n: '삼각형', c: '#ff7aa8' },
      { k: 4, n: '사각형', c: '#ffb347' },
      { k: 5, n: '오각형', c: '#ffe066' },
      { k: 6, n: '육각형', c: '#7be0a0' },
      { k: 0, n: '원', c: '#7ec8ff' },
    ];
    const pts = SH.map((s) => polyPts(s.k, 52));
    return {
      controls: [speedCtl(o), { type: 'toggle' as const, label: '점 60개 보기', value: false, on: (v: boolean) => (showPts = v) }],
      draw(g: G, w: number, h: number, _t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        const HOLD = 0.9;
        const MOR = 0.75;
        const T = SH.length * (HOLD + MOR);
        const p = tt % T;
        const i = Math.min(SH.length - 1, Math.floor(p / (HOLD + MOR)));
        const lp = p - i * (HOLD + MOR);
        const k = inOut(seg(lp, HOLD, HOLD + MOR));
        const j = (i + 1) % SH.length;
        const A = pts[i]!;
        const B = pts[j]!;
        bg(g, w, h, '#26306a', '#0e1435');
        view(g, w, h);
        const cx = 105;
        const cy = 88;
        // 바닥 그림자
        g.fillStyle = 'rgba(0,0,0,.25)';
        g.beginPath();
        g.ellipse(cx, cy + 66, 46, 6, 0, 0, TAU);
        g.fill();
        const bounce = 1 + 0.06 * Math.sin(k * Math.PI);
        const rot = k * (TAU / 12);
        g.save();
        g.translate(cx, cy);
        g.rotate(rot * (1 - k) * 0);
        g.scale(bounce, bounce);
        g.beginPath();
        for (let q = 0; q < NPT; q++) {
          const x = lerp(A[q]![0], B[q]![0], k);
          const y = lerp(A[q]![1], B[q]![1], k);
          if (q === 0) g.moveTo(x, y);
          else g.lineTo(x, y);
        }
        g.closePath();
        const ca = SH[i]!.c;
        const cb = SH[j]!.c;
        const gr = g.createLinearGradient(-50, -55, 50, 55);
        gr.addColorStop(0, k < 0.5 ? ca : cb);
        gr.addColorStop(1, '#5b4bd6');
        g.fillStyle = gr;
        g.fill();
        g.lineJoin = 'round';
        g.strokeStyle = '#fff';
        g.lineWidth = 3;
        g.stroke();
        // 꼭짓점
        const vk = SH[k < 0.5 ? i : j]!.k;
        const va = 1 - Math.min(1, Math.sin(k * Math.PI) * 2);
        if (vk > 0 && va > 0) {
          const P = k < 0.5 ? A : B;
          for (let q = 0; q < NPT; q += NPT / vk) {
            g.fillStyle = `rgba(255,255,255,${va})`;
            g.beginPath();
            g.arc(P[q]![0], P[q]![1], 4, 0, TAU);
            g.fill();
          }
        }
        if (showPts) {
          for (let q = 0; q < NPT; q++) {
            g.fillStyle = q === 0 ? '#ff3b6b' : 'rgba(255,255,255,.85)';
            g.beginPath();
            g.arc(lerp(A[q]![0], B[q]![0], k), lerp(A[q]![1], B[q]![1], k), q === 0 ? 2.6 : 1.3, 0, TAU);
            g.fill();
          }
        }
        g.restore();
        // 오른쪽 이름 · 변의 수
        const cur = SH[k < 0.5 ? i : j]!;
        const tx = 215;
        txt(g, cur.n, tx, 62, 20, '#fff', 'center', TF, 400);
        txt(g, cur.k ? `변 ${cur.k}개` : '변이 끝없이 많아요', tx, 86, 10, 'rgba(255,255,255,.75)', 'center', F, 700);
        // 순서 띠
        SH.forEach((s, q) => {
          const x = 176 + q * 20;
          const on = q === (k < 0.5 ? i : j);
          g.save();
          g.translate(x, 118);
          g.scale(on ? 1.25 : 1, on ? 1.25 : 1);
          g.beginPath();
          const pp = polyPts(s.k, 6.5);
          pp.forEach(([px, py], r) => (r ? g.lineTo(px, py) : g.moveTo(px, py)));
          g.closePath();
          g.fillStyle = on ? s.c : 'rgba(255,255,255,.25)';
          g.fill();
          g.restore();
        });
        txt(g, '변을 늘리면 원에 가까워져요', tx, 142, 8, 'rgba(255,255,255,.55)', 'center', F, 600);
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [매개 모핑 (이음새 없는 변신)](https://ai-techstudio.web.app/ai/t/i97.md) `i97`
- 먼저 알면 좋은 기술: [캔버스 그러데이션 · 흐림 · 빛 섞기](https://ai-techstudio.web.app/ai/t/u52.md) `u52`
- 다음에 해 볼 기술: [Manim식 수식 변환](https://ai-techstudio.web.app/ai/t/i117.md) `i117` · [이음새 없는 반복 고리](https://ai-techstudio.web.app/ai/t/i120.md) `i120`
