# AI 꾸러미 — 도형 조각 잘라 옮기기 애니메이션 — Equal-area transformation (dissection)
> 도형을 조각으로 잘라 미끄러지듯 옮겨 다른 모양으로 맞추는 애니메이션 — 예: 평행사변형의 삼각형을 옮겨 직사각형으로.  
> 견본: https://ai-techstudio.web.app/#t/i360

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

## 주문서

### 만들어 줘: 도형 조각 잘라 옮기기 애니메이션 — Equal-area transformation (dissection)

#### 1. 목표
평행사변형 → 직사각형 (넓이 설명) 장면에 도형 조각 잘라 옮기기 애니메이션을 만들어 줘 — 도형을 조각으로 나누고, 조각이 미끄러지듯 움직여 새 모양으로 딱 맞게. 그림체는 모눈종이 위 색종이.

#### 2. 핵심 기술 용어
- **Equal-area transformation (dissection)** — 등적 변형 — 잘라 옮겨 붙이기
- **Parallelogram area = base × height** — 평행사변형 넓이 = 밑변 × 높이
- **Canvas 2D path animation** — 캔버스 2D 도형 움직임
- **Easing (ease-in-out)** — 천천히 시작 · 천천히 끝나는 움직임

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

#### 4. 조건
- 좌표는 모눈 칸 단위 정수 (밑변 6 · 높이 4 처럼) — 칸을 세어 확인 가능
- 움직임은 단계별: 자르는 선 → 들어 올려 옮기기 → 맞춰 넣기 → 「넓이 같음 ✓」
- 빈 자리를 점선으로 남겨 어디서 왔는지 보이게
- 기울기 슬라이더는 밑변보다 작은 범위로만

#### 5. 완성 기준 (이게 보이면 성공)
- 평행사변형 → 직사각형 장면이 저절로 반복되고, 끝에 「넓이 = 밑변 × 높이 = 24」와 칸 수가 보인다
- 삼각형 두 장을 돌려 붙여 평행사변형이 되는 두 번째 장면이 있다
- 기울기 슬라이더를 바꿔도 넓이 숫자는 그대로다
- 폰 폭(390px)에서도 글씨 · 도형이 잘리지 않는다

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

## 원리
- 평행사변형의 왼쪽 끝 꼭짓점에서 수직으로 자르면 직각삼각형 하나가 떨어진다.
- 그 삼각형을 밑변 길이만큼 옆으로 옮기면 빈 자리에 꼭 맞아 직사각형이 된다.
- 자르고 옮기기만 했으니 넓이는 그대로 — 직사각형 넓이(밑변 × 높이)가 곧 평행사변형 넓이.
- 모눈 칸 위에 정수 좌표로 그리면 아이가 칸 수를 직접 세어 확인할 수 있다.

## 핵심 코드 — 삼각형 조각을 옮겨 직사각형 만들기 (캔버스 2D)
(발췌: demos/demosMathA.ts i360 draw() 의 첫 장면을 정리)
```ts
type P = [number, number];
const B = 6, H = 4, s = 2;                   // 밑변 · 높이 · 윗변을 민 칸 (s < B)
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
const easeIO = (x: number) => (x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2);

function poly(g: CanvasRenderingContext2D, pts: P[], fill?: string, stroke?: string) {
  g.beginPath();
  pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
  g.closePath();
  if (fill) { g.fillStyle = fill; g.fill(); }
  if (stroke) { g.strokeStyle = stroke; g.lineWidth = 2; g.stroke(); }
}

function draw(g: CanvasRenderingContext2D, w: number, h: number, t: number) {
  const cell = Math.min((w * 0.86) / 10.4, (h * 0.47) / H);
  const ox = w / 2 - ((B + s) * cell) / 2, oy = h * 0.79;
  const S = ([x, y]: P): P => [ox + x * cell, oy - y * cell];   // 칸 → 화면
  const mv = easeIO(clamp01((t - 1.2) / 1.25));                // 옮기기 진행 0 → 1
  const lift = Math.sin(mv * Math.PI) * 0.6;                    // 옮기는 동안 살짝 들어 올림
  g.setLineDash([4, 4]);
  poly(g, ([[0, 0], [s, 0], [s, H]] as P[]).map(S), undefined, 'rgba(239,91,60,0.55)'); // 빈 자리
  g.setLineDash([]);
  poly(g, ([[s, 0], [B, 0], [B + s, H], [s, H]] as P[]).map(S), '#f3e3c8', '#3a3020');  // 남는 부분
  const tri = ([[0, 0], [s, 0], [s, H]] as P[]).map(([x, y]) => S([x + B * mv, y + lift]));
  g.save();
  g.shadowColor = 'rgba(80,40,20,0.35)';
  g.shadowBlur = 12 * Math.sin(mv * Math.PI);
  poly(g, tri, 'rgba(239,91,60,0.86)', '#a8361f');               // 잘라 옮기는 조각
  g.restore();
  if (mv >= 1) {
    g.fillStyle = '#3a3020';
    g.font = '800 16px Pretendard, sans-serif';
    g.textAlign = 'center';
    g.fillText('넓이 = 밑변 × 높이 = ' + B * H + '칸', w / 2, 28);
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **기울기를 밑변보다 크게 하면 수직 한 번 자르기로 안 맞는다** — 윗변 꼭짓점이 밑변 밖으로 나가 잘린 삼각형이 빈 자리에 안 맞는다. 슬라이더 범위를 s < B 로 묶는다.
- [ ] **소수 좌표로 그리면 칸을 셀 수 없다** — 꼭짓점은 칸 단위 정수로, 화면 변환은 한 함수(S)로만.
- [ ] **조각이 순간 이동하면 「잘라 옮겼다」가 안 보인다** — 들어 올림(그림자) → 옮김 → 내려놓기를 이징으로 0.8~1.2초.
- [ ] **폰에서 글씨가 너무 작다** — 글씨 크기를 화면 크기 비율(u = min(w/280, h/175))로 키운다.

## 완성 기준 체크리스트
- [ ] 평행사변형 → 직사각형 장면이 저절로 반복되고, 끝에 「넓이 = 밑변 × 높이 = 24」와 칸 수가 보인다
- [ ] 삼각형 두 장을 돌려 붙여 평행사변형이 되는 두 번째 장면이 있다
- [ ] 기울기 슬라이더를 바꿔도 넓이 숫자는 그대로다
- [ ] 폰 폭(390px)에서도 글씨 · 도형이 잘리지 않는다

## 이 기술 정보
- id: `i360` · 분류: 물리 · 수학 원리 › 수학 시각화 기법 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 도형 몇 개 — 폰에서도 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i360
- 쓰면 좋을 때: 넓이 공식이 「왜」 그런지 보여 줄 때 / 삼각형 넓이 = 평행사변형의 절반 (두 장을 돌려 붙이기)
- 쓰지 말 때: 기울기가 밑변보다 큰 평행사변형 — 수직 한 번 자르기로는 직사각형이 안 된다 (여러 번 잘라야 함)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### rotAbout — `src/demos/demosMathA.ts:59`
```ts
function rotAbout(p: P2, c: P2, a: number): P2 {
  const s = Math.sin(a);
  const co = Math.cos(a);
  const x = p[0] - c[0];
  const y = p[1] - c[1];
  return [c[0] + x * co - y * s, c[1] + x * s + y * co];
}
```

### i360 견본 항목 — `src/demos/demosMathA.ts:510`
```ts
  i360: {
    kind: '2d',
    caption: '평행사변형의 삼각형 조각을 오려 옮기면 직사각형, 삼각형 두 장은 평행사변형 — 넓이는 그대로',
    make() {
      const B = 6;
      const H = 4;
      const D = 4.8;
      let s = 2;
      let mode = 0;
      let t0 = 0;
      let req = false;
      const controls: Control[] = [
        { type: 'range', label: '장면 (0 자동 · 1 평행사변형 · 2 삼각형)', min: 0, max: 2, step: 1, value: 0, on: (v) => ((mode = v), (req = true)) },
        { type: 'range', label: '기울기 (윗변을 민 칸)', min: 1, max: 4, step: 0.5, value: 2, on: (v) => (s = v) },
        { type: 'button', label: '다시', on: () => (req = true) },
      ];
      return {
        controls,
        draw(g, w, h, t) {
          reset(g);
          if (req) {
            t0 = t;
            req = false;
          }
          const tt = Math.max(0, t - t0);
          const u = Math.min(w / 280, h / 175);
          let scene: number;
          let lt: number;
          if (mode === 0) {
            const k = tt % (D * 2);
            scene = k < D ? 0 : 1;
            lt = k - scene * D;
          } else {
            scene = mode - 1;
            lt = tt % D;
          }
          const cell = Math.min((w * 0.86) / 10.4, (h * 0.47) / H);
          const ox = Math.round(w / 2 - ((B + s) * cell) / 2);
          const oy = Math.round(h * 0.79);
          paperBg(g, w, h, cell, ox, oy);
          const S = (p: P2): P2 => [ox + p[0] * cell, oy - p[1] * cell];
          const lw = Math.max(1.6, 2 * u);
          const fs = 11 * u;
          g.globalAlpha = 1 - ease(seg(lt, D - 0.4, D));
          if (scene === 0) {
            const cut = seg(lt, 0.45, 1.05);
            const mv = easeIO(seg(lt, 1.2, 2.45));
            const done = ease(seg(lt, 2.45, 2.85));
            const rest: P2[] = [[s, 0], [B, 0], [B + s, H], [s, H]];
            const lift = Math.sin(mv * Math.PI) * 0.6;
            const tri = ([[0, 0], [s, 0], [s, H]] as P2[]).map((p) => [p[0] + B * mv, p[1] + lift] as P2);
            // 빈 자리
            if (mv > 0) {
              g.setLineDash([4 * u, 4 * u]);
              fillStroke(g, ([[0, 0], [s, 0], [s, H]] as P2[]).map(S), 'rgba(239,91,60,0.06)', 'rgba(239,91,60,0.55)', lw * 0.7);
              g.setLineDash([]);
            }
            fillStroke(g, rest.map(S), PA.soft, null);
            // 조각
            g.save();
            g.shadowColor = 'rgba(80,40,20,0.35)';
            g.shadowBlur = 12 * u * Math.sin(mv * Math.PI);
            g.shadowOffsetY = 5 * u * Math.sin(mv * Math.PI);
            fillStroke(g, tri.map(S), 'rgba(239,91,60,0.86)', null);
            g.restore();
            if (cut < 1) fillStroke(g, ([[0, 0], [B, 0], [B + s, H], [s, H]] as P2[]).map(S), null, PA.ink, lw);
            else {
              fillStroke(g, rest.map(S), null, PA.ink, lw);
              fillStroke(g, tri.map(S), null, PA.accD, lw);
            }
            // 자르는 선 · 높이
            if (cut > 0 && mv < 1) {
              const a = S([s, H]);
              const b = S([s, H - H * cut]);
              line(g, a, b, PA.acc, lw, [5 * u, 4 * u]);
              if (cut >= 1 && mv === 0) rightMark(g, S([s, 0]), [1, 0], [0, -1], 7 * u, PA.acc, lw * 0.7);
              txtHalo(g, `높이 ${H}`, S([s, H / 2])[0] + 22 * u, S([s, H / 2])[1], fs, PA.accD, '#f8f4ea');
            }
            // 이름 · 치수
            dim(g, S([0, 0]), S([B, 0]), [0, 9 * u], `밑변 ${B}`, PA.ink, fs * 0.95, lw * 0.55, '#f8f4ea');
            if (done > 0) {
              g.globalAlpha *= done;
              const rc: P2[] = [[s, 0], [B + s, 0], [B + s, H], [s, H]];
              fillStroke(g, rc.map(S), null, PA.accD, lw * 1.3);
              dim(g, S([B + s, 0]), S([B + s, H]), [11 * u, 0], `${H}`, PA.ink, fs, lw * 0.55, '#f8f4ea');
              const c = S([s + B / 2, H / 2]);
              txtHalo(g, `${B * H}칸`, c[0], c[1] - 7 * u, 17 * u, PA.ink, 'rgba(248,244,234,0.85)');
              pill(g, '넓이 같음 ✓', c[0], c[1] + 13 * u, 9.5 * u, PA.acc, '#fff');
              g.globalAlpha = 1 - ease(seg(lt, D - 0.4, D));
            }
            txt(g, done > 0.5 ? '직사각형으로 바꾸어도 넓이 그대로' : '평행사변형 → 직사각형', w / 2, 15 * u, 13 * u, PA.ink, 'center', 800);
            txt(g, `넓이 = 밑변 × 높이 = ${B} × ${H} = ${B * H}`, w / 2, 32 * u, 10.5 * u, done > 0.5 ? PA.accD : PA.dim, 'center', 700);
          } else {
            const tri: P2[] = [[0, 0], [B, 0], [s, H]];
            const M: P2 = [(B + s) / 2, H / 2];
            const rot = easeIO(seg(lt, 0.8, 2.3));
            const lifting = Math.sin(rot * Math.PI);
            const done = ease(seg(lt, 2.3, 2.7));
            const copy = tri.map((p) => rotAbout(p, M, Math.PI * rot));
            fillStroke(g, tri.map(S), PA.soft, PA.ink, lw);
            const show = ease(seg(lt, 0.35, 0.8));
            if (show > 0) {
              g.save();
              g.globalAlpha *= show;
              g.shadowColor = 'rgba(80,40,20,0.35)';
              g.shadowBlur = 12 * u * lifting;
              g.shadowOffsetY = 5 * u * lifting;
              fillStroke(g, copy.map(S), 'rgba(239,91,60,0.8)', null);
              g.restore();
              g.save();
              g.globalAlpha *= show;
              fillStroke(g, copy.map(S), null, PA.accD, lw);
              g.restore();
            }
            if (rot > 0 && rot < 1) {
              const m = S(M);
              dotP(g, m, 3.5 * u, PA.accD, '#fff');
              g.strokeStyle = PA.accD;
              g.lineWidth = lw * 0.7;
              g.beginPath();
              g.arc(m[0], m[1], 12 * u, Math.PI, Math.PI + Math.PI * rot, false);
              g.stroke();
            }
            dim(g, S([0, 0]), S([B, 0]), [0, 9 * u], `밑변 ${B}`, PA.ink, fs * 0.95, lw * 0.55, '#f8f4ea');
            if (done > 0) {
              g.globalAlpha *= done;
              fillStroke(g, ([[0, 0], [B, 0], [B + s, H], [s, H]] as P2[]).map(S), null, PA.ink, lw * 1.3);
              line(g, S([s, H]), S([s, 0]), PA.blue, lw * 0.8, [4 * u, 3 * u]);
              rightMark(g, S([s, 0]), [1, 0], [0, -1], 6 * u, PA.blue, lw * 0.6);
              txtHalo(g, `높이 ${H}`, S([s, H * 0.3])[0] - 4 * u, S([s, H * 0.3])[1], fs * 0.9, PA.blue, '#f8f4ea', 'right');
              const c1 = S(centroid(tri));
              const c2 = S(centroid(copy));
              txtHalo(g, `${(B * H) / 2}`, c1[0], c1[1], 14 * u, PA.ink, 'rgba(248,244,234,0.8)');
              txtHalo(g, `${(B * H) / 2}`, c2[0], c2[1], 14 * u, '#fff', 'rgba(184,55,31,0.6)');
              g.globalAlpha = 1 - ease(seg(lt, D - 0.4, D));
            }
            txt(g, done > 0.5 ? '삼각형 두 장 = 평행사변형 하나' : '삼각형을 돌려 붙이면?', w / 2, 15 * u, 13 * u, PA.ink, 'center', 800);
            txt(g, `삼각형 넓이 = ${B} × ${H} ÷ 2 = ${(B * H) / 2}`, w / 2, 32 * u, 10.5 * u, done > 0.5 ? PA.accD : PA.dim, 'center', 700);
          }
          reset(g);
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [빈틈없이 까는 무늬 (테셀레이션)](https://ai-techstudio.web.app/ai/t/i365.md) `i365` · [접히는 전개도 (경첩 회전)](https://ai-techstudio.web.app/ai/t/i362.md) `i362`
- 참고 문서: [Wikipedia — Parallelogram (Area)](https://en.wikipedia.org/wiki/Parallelogram)
