# AI 꾸러미 — 재귀로 그리는 도형 (프랙탈) — Recursive fractal drawing
> 도형 하나를 같은 규칙으로 쪼개는 함수를 자기 자신으로 다시 부르는 재귀로 시에르핀스키 삼각형 · 코흐 눈송이를 그리고, 무작위 점 찍기(카오스 게임)로도 같은 삼각형이 생기게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i382

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

## 주문서

### 만들어 줘: 재귀로 그리는 도형 (프랙탈) — Recursive fractal drawing

#### 1. 목표
시에르핀스키 · 코흐 눈송이 설명에 재귀로 그리는 프랙탈을 넣어 줘 — 단계를 올릴 때마다 같은 규칙으로 쪼개고, 단계마다 개수 · 둘레 · 넓이 숫자가 바뀌게. 분위기는 밤하늘 금빛 선.

#### 2. 핵심 기술 용어
- **Recursive fractal drawing** — 재귀로 그리는 프랙탈
- **Sierpinski triangle · Koch snowflake** — 시에르핀스키 삼각형 · 코흐 눈송이
- **Chaos game (iterated function system)** — 카오스 게임 — 꼭짓점 쪽으로 반씩 뛰기
- **Self-similarity** — 자기 닮음 — 부분이 전체를 닮음

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

#### 4. 조건
- 재귀는 단계 n 을 줄여 가며 n = 0 에서 멈춘다 (끝 조건 먼저)
- 한 단계 전체를 경로 하나에 모아 fill · stroke 한 번 (삼각형마다 따로 칠하지 않는다)
- 단계마다 개수 · 둘레 · 넓이 숫자를 식과 함께 보여 준다
- 카오스 게임 점은 화면 밖 캔버스에 누적

#### 5. 완성 기준 (이게 보이면 성공)
- 시에르핀스키 삼각형이 단계마다 가운데가 뚫리며 자라고, 「삼각형 3ⁿ 개 · 넓이 (3/4)ⁿ」 숫자가 바뀐다
- 코흐 눈송이가 단계마다 뾰족이가 돋아나며, 둘레는 커지고 넓이는 8/5 배에 다가간다
- 카오스 게임에서 무작위 점이 몇 초 만에 같은 삼각형 무늬를 만든다
- 단계 슬라이더로 원하는 단계에서 멈출 수 있다

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

## 원리
- 시에르핀스키: tri(a, b, c, n) 는 n = 0 이면 삼각형을 그리고, 아니면 세 변의 가운데 점으로 작은 삼각형 셋을 만들어 tri(…, n − 1) 를 세 번 부른다.
- 단계 n 이면 삼각형 3ⁿ 개, 넓이 (3/4)ⁿ → 0, 둘레 (3/2)ⁿ → 무한.
- 코흐: 변마다 1/3 · 2/3 점을 잡고 가운데 1/3 을 60° 돌려 뾰족이를 세운다 — 변 3·4ⁿ 개, 둘레 (4/3)ⁿ, 넓이는 8/5 배로 다가간다.
- 마지막 단계만 진행 k(0 → 1)로 섞으면(가운데 점 → 뾰족이) 단계가 부드럽게 자란다.
- 카오스 게임: 아무 점에서 시작해 세 꼭짓점 중 하나를 무작위로 골라 그 쪽으로 반만 뛰기를 수천 번 — 점이 시에르핀스키 삼각형 위에만 모인다.

## 핵심 코드 — 시에르핀스키 재귀 · 코흐 한 단계 · 카오스 게임
(발췌: demos/demosMathB.ts i382 tri() · koch() · drawChaos() 를 정리)
```ts
type P = [number, number];
const mid = (a: P, b: P): P => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];

/** 시에르핀스키: 경로에 쌓기만 — 다 쌓은 뒤 fill 한 번 */
function tri(g: CanvasRenderingContext2D, a: P, b: P, c: P, n: number) {
  if (n === 0) {                                  // 끝 조건 먼저
    g.moveTo(a[0], a[1]); g.lineTo(b[0], b[1]); g.lineTo(c[0], c[1]); g.closePath();
    return;
  }
  const ab = mid(a, b), bc = mid(b, c), ca = mid(c, a);
  tri(g, a, ab, ca, n - 1);
  tri(g, ab, b, bc, n - 1);
  tri(g, ca, bc, c, n - 1);                       // 가운데는 비운다
}

/** 코흐 한 단계: 변마다 가운데 1/3 을 60° 세우기 (k: 0 평평 → 1 뾰족) */
function kochStep(pts: number[], k: number): number[] {
  const np: number[] = [];
  for (let i = 0; i + 3 < pts.length; i += 2) {
    const x0 = pts[i], y0 = pts[i + 1], x1 = pts[i + 2], y1 = pts[i + 3];
    const ax = x0 + (x1 - x0) / 3, ay = y0 + (y1 - y0) / 3;
    const bx = x0 + ((x1 - x0) * 2) / 3, by = y0 + ((y1 - y0) * 2) / 3;
    const vx = bx - ax, vy = by - ay, c = 0.5, s = -Math.sqrt(3) / 2;
    const px = ax + vx * c - vy * s, py = ay + vx * s + vy * c;   // 60° 돌린 꼭대기
    const mx = (ax + bx) / 2, my = (ay + by) / 2;
    np.push(x0, y0, ax, ay, mx + (px - mx) * k, my + (py - my) * k, bx, by);
  }
  np.push(pts[pts.length - 2], pts[pts.length - 1]);
  return np;
}

/** 카오스 게임: 무작위 꼭짓점 쪽으로 반씩 — 점은 화면 밖 캔버스에 쌓는다 */
function chaos(off: CanvasRenderingContext2D, V: P[], start: P, n: number): P {
  let cp = start;
  for (let i = 0; i < n; i++) {
    const v = V[Math.floor(Math.random() * 3)];
    cp = mid(cp, v);
    off.fillRect(cp[0] - 0.5, cp[1] - 0.5, 1, 1);
  }
  return cp;
}
```

## 흔한 실수 · 확인 목록
- [ ] **끝 조건을 빼먹거나 n 을 안 줄이면 재귀가 끝나지 않아 탭이 멈춘다** — 함수 맨 앞에 n === 0 조건, 부를 때마다 n − 1.
- [ ] **작은 삼각형마다 fill 을 부르면 수천 번 칠해 느리다** — 경로에 모두 쌓은 뒤 fill 한 번.
- [ ] **카오스 게임 점을 매 프레임 처음부터 다시 찍으면 느려진다** — 화면 밖 캔버스에 새 점만 더하고 그 캔버스를 붙인다.
- [ ] **단계가 뚝 바뀌면 무엇이 늘었는지 안 보인다** — 마지막 단계를 진행 값으로 섞어(가운데 점 → 꼭대기) 자라는 모습을 보여 준다.

## 완성 기준 체크리스트
- [ ] 시에르핀스키 삼각형이 단계마다 가운데가 뚫리며 자라고, 「삼각형 3ⁿ 개 · 넓이 (3/4)ⁿ」 숫자가 바뀐다
- [ ] 코흐 눈송이가 단계마다 뾰족이가 돋아나며, 둘레는 커지고 넓이는 8/5 배에 다가간다
- [ ] 카오스 게임에서 무작위 점이 몇 초 만에 같은 삼각형 무늬를 만든다
- [ ] 단계 슬라이더로 원하는 단계에서 멈출 수 있다

## 이 기술 정보
- id: `i382` · 분류: 물리 · 수학 원리 › 수학 시각화 기법 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 7단계 시에르핀스키 = 삼각형 2,187개 · 코흐 6단계 = 변 12,288개 경로 하나. 카오스 게임 점 1만 4천 개는 따로 캔버스에 쌓아 한 번에 붙인다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i382
- 쓰면 좋을 때: 같은 규칙의 반복 · 수열 · 극한을 보여 줄 때 / 눈송이 · 나뭇가지 같은 자연스러운 무늬를 코드로
- 쓰지 말 때: 단계를 너무 높이기(시에르핀스키 9단계 이상) — 3ⁿ 으로 늘어 한 프레임에 못 그린다 / 카오스 게임 점을 매 프레임 다시 찍기 — 화면 밖 캔버스에 쌓아 두고 붙인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i382 — `src/demos/demosMathB.ts:2276`
```ts
const i382 = cv('같은 규칙을 거듭하면 — 시에르핀스키 삼각형(넓이 → 0), 코흐 눈송이(둘레 → 무한, 넓이 → 8/5배), 카오스 게임', () => {
  let view = 0;
  let autoLv = true;
  let lvSet = 4;
  let clock = 0;
  const A: [number, number] = [95, 24];
  const B: [number, number] = [20, 24 + 75 * Math.sqrt(3)];
  const C: [number, number] = [170, 24 + 75 * Math.sqrt(3)];
  const SIER = 7 * 1.25 + 1.2;
  const KOCH = 6 * 1.35 + 1.2;
  const CHAOS = 6.5;
  const dots = new Dots(156, 136);
  let cp: [number, number] = [95, 100];
  let cn = 0;
  let jumps: [number, number, number, number][] = [];
  let lastChaosCycle = -1;
  function tri(g: G, a: [number, number], b: [number, number], c: [number, number], n: number, holes: boolean): void {
    if (n === 0) {
      if (!holes) {
        g.moveTo(a[0], a[1]);
        g.lineTo(b[0], b[1]);
        g.lineTo(c[0], c[1]);
        g.closePath();
      }
      return;
    }
    const ab: [number, number] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
    const bc: [number, number] = [(b[0] + c[0]) / 2, (b[1] + c[1]) / 2];
    const ca: [number, number] = [(c[0] + a[0]) / 2, (c[1] + a[1]) / 2];
    if (holes && n === 1) {
      g.moveTo(ab[0], ab[1]);
      g.lineTo(bc[0], bc[1]);
      g.lineTo(ca[0], ca[1]);
      g.closePath();
      return;
    }
    tri(g, a, ab, ca, n - 1, holes);
    tri(g, ab, b, bc, n - 1, holes);
    tri(g, ca, bc, c, n - 1, holes);
  }
  function koch(n: number, k: number): number[] {
    const cx = 95;
    const cy = 100;
    const R = 66;
    let pts: number[] = [];
    for (let i = 0; i < 3; i++) {
      const a = -Math.PI / 2 + (i * Math.PI * 2) / 3;
      pts.push(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
    }
    pts.push(pts[0]!, pts[1]!);
    for (let lv = 1; lv <= n; lv++) {
      const kk = lv === n ? k : 1;
      const np: number[] = [];
      for (let i = 0; i + 3 < pts.length; i += 2) {
        const x0 = pts[i]!;
        const y0 = pts[i + 1]!;
        const x1 = pts[i + 2]!;
        const y1 = pts[i + 3]!;
        const ax = x0 + (x1 - x0) / 3;
        const ay = y0 + (y1 - y0) / 3;
        const bx = x0 + ((x1 - x0) * 2) / 3;
        const by = y0 + ((y1 - y0) * 2) / 3;
        const vx = bx - ax;
        const vy = by - ay;
        const c = 0.5;
        const s = -Math.sqrt(3) / 2;
        const px = ax + vx * c - vy * s;
        const py = ay + vx * s + vy * c;
        const mx = (ax + bx) / 2;
        const my = (ay + by) / 2;
        np.push(x0, y0, ax, ay, lerp(mx, px, kk), lerp(my, py, kk), bx, by);
      }
      np.push(pts[pts.length - 2]!, pts[pts.length - 1]!);
      pts = np;
    }
    return pts;
  }
  function drawSier(g: G, lv: number, k: number): void {
    g.beginPath();
    tri(g, A, B, C, lv, false);
    g.fillStyle = accFill(g, A[1], B[1]);
    g.fill();
    if (lv >= 1 && k < 1) {
      g.beginPath();
      tri(g, A, B, C, lv, true);
      g.fillStyle = accFill(g, A[1], B[1], 1 - k);
      g.fill();
    }
    const rx = 182;
    text(g, '시에르핀스키 삼각형', rx, 24, 8.5, inkA(0.6), 'left', 700);
    mtext(g, ['단계 ', ['c', ACC, String(lv)]], rx, 44, 14, 'l', INK, 800);
    mtext(g, ['삼각형 3', ['^', String(lv)], ` = ${comma(3 ** lv)}개`], rx, 68, 8.5);
    mtext(g, ['넓이 (', ['/', '3', '4'], ')', ['^', String(lv)], ` = ${(0.75 ** lv).toFixed(3)}`], rx, 90, 8.5);
    mtext(g, ['둘레 (', ['/', '3', '2'], ')', ['^', String(lv)], ` = ${(1.5 ** lv).toFixed(2)}배`], rx, 116, 8.5);
    text(g, '넓이 → 0, 둘레 → 무한', rx, 140, 7.5, ACC);
  }
  function drawKoch(g: G, lv: number, k: number): void {
    const p = koch(lv, k);
    g.beginPath();
    g.moveTo(p[0]!, p[1]!);
    for (let i = 2; i < p.length; i += 2) g.lineTo(p[i]!, p[i + 1]!);
    g.closePath();
    g.fillStyle = accA(0.14);
    g.fill();
    g.lineJoin = 'round';
    g.strokeStyle = ACC;
    g.lineWidth = 1.1;
    g.stroke();
    const rx = 182;
    text(g, '코흐 눈송이', rx, 24, 8.5, inkA(0.6), 'left', 700);
    mtext(g, ['단계 ', ['c', ACC, String(lv)]], rx, 44, 14, 'l', INK, 800);
    mtext(g, ['변 3·4', ['^', String(lv)], ` = ${comma(3 * 4 ** lv)}개`], rx, 66, 8.5);
    mtext(g, ['둘레 (', ['/', '4', '3'], ')', ['^', String(lv)], ` = ${((4 / 3) ** lv).toFixed(2)}배`], rx, 88, 8.5);
    const area = 1 + 0.6 * (1 - (4 / 9) ** lv);
    text(g, `넓이 ${area.toFixed(3)}배`, rx, 110, 8.5);
    mtext(g, ['둘레 → 무한, 넓이 → ', ['/', '8', '5'], '배'], rx, 132, 7.5, 'l', ACC);
  }
  function drawChaos(g: G, tau: number, cycle: number): void {
    if (cycle !== lastChaosCycle) {
      lastChaosCycle = cycle;
      dots.clear();
      cn = 0;
      jumps = [];
      cp = [lerp(40, 150, Math.random()), lerp(60, 140, Math.random())];
    }
    const V = [A, B, C];
    const target = tau < 1.8 ? Math.floor(tau / 0.2) : Math.min(14000, 9 + Math.round(Math.pow(14000, Math.min(1, (tau - 1.8) / 3.5))));
    const oc = dots.ctx(g);
    const kk = dots.k;
    oc.fillStyle = 'rgba(255,192,74,0.85)';
    const rad = Math.max(0.5, 0.45 * kk);
    while (cn < target) {
      const v = V[Math.floor(Math.random() * 3)]!;
      const nx = (cp[0] + v[0]) / 2;
      const ny = (cp[1] + v[1]) / 2;
      if (jumps.length < 9) jumps.push([cp[0], cp[1], nx, ny]);
      cp = [nx, ny];
      cn++;
      oc.fillRect((nx - 17) * kk - rad, (ny - 22) * kk - rad, rad * 2, rad * 2);
    }
    dots.blit(g, 17, 22);
    for (const v of V) glowDot(g, v[0], v[1], 2.6, COOL);
    if (tau < 2.6) {
      g.globalAlpha = 1 - smooth(1.9, 2.6, tau);
      jumps.forEach((j, i) => {
        g.setLineDash([2, 2]);
        g.strokeStyle = inkA(0.5);
        g.lineWidth = 0.7;
        g.beginPath();
        g.moveTo(j[0], j[1]);
        g.lineTo(j[2], j[3]);
        g.stroke();
        g.setLineDash([]);
        if (i === jumps.length - 1) glowDot(g, j[2], j[3], 2.2);
      });
      g.globalAlpha = 1;
    }
    const rx = 182;
    text(g, '카오스 게임', rx, 24, 8.5, inkA(0.6), 'left', 700);
    mtext(g, ['점 ', ['c', ACC, comma(cn)], '개'], rx, 44, 13, 'l', INK, 800);
    text(g, '꼭짓점 셋 중 하나를', rx, 68, 8, inkA(0.85));
    text(g, '무작위로 골라', rx, 80, 8, inkA(0.85));
    text(g, '그 중간으로 뛰기', rx, 92, 8, inkA(0.85));
    text(g, '무작위인데도', rx, 118, 7.5, ACC);
    text(g, '같은 삼각형 무늬!', rx, 130, 7.5, ACC);
  }
  return {
    draw(g, _t, dt) {
      clock += dt;
      if (view === 0) {
        const all = SIER + KOCH + CHAOS;
        const cyc = Math.floor(clock / all);
        const tt = clock - cyc * all;
        if (tt < SIER) {
          const lv = Math.min(6, Math.floor(tt / 1.25));
          drawSier(g, lv, smooth(0, 0.6, tt - lv * 1.25));
        } else if (tt < SIER + KOCH) {
          const s = tt - SIER;
          const lv = Math.min(5, Math.floor(s / 1.35));
          drawKoch(g, lv, smooth(0, 0.7, s - lv * 1.35));
        } else drawChaos(g, tt - SIER - KOCH, cyc);
      } else if (view === 3) {
        const cyc = Math.floor(clock / (CHAOS + 2));
        drawChaos(g, clock - cyc * (CHAOS + 2), cyc);
      } else {
        const maxL = view === 1 ? 7 : 6;
        let lv = Math.min(lvSet, maxL);
        let k = 1;
        if (autoLv) {
          const per = 1.3;
          const all = (maxL + 1) * per + 1;
          const tt = clock % all;
          lv = Math.min(maxL, Math.floor(tt / per));
          k = smooth(0, 0.6, tt - lv * per);
        }
        if (view === 1) drawSier(g, lv, k);
        else drawKoch(g, lv, k);
      }
    },
    controls: [
      { type: 'range', label: '보기 (0 자동 · 1 시에르핀스키 · 2 코흐 · 3 카오스 게임)', min: 0, max: 3, step: 1, value: 0, on: (v) => ((view = v), (clock = 0), (lastChaosCycle = -1)) },
      { type: 'toggle', label: '단계 자동으로 올리기', value: true, on: (v) => (autoLv = v) },
      { type: 'range', label: '단계 (자동을 끄면)', min: 0, max: 7, step: 1, value: 4, on: (v) => ((lvSet = v), (autoLv = false)) },
    ],
  };
});
```

## 관련 기술
- 먼저 알면 좋은 기술: [빈틈없이 까는 무늬 (테셀레이션)](https://ai-techstudio.web.app/ai/t/i365.md) `i365`
- 다음에 해 볼 기술: [반복 실험 시뮬레이션 (분포가 쌓이는 그래프)](https://ai-techstudio.web.app/ai/t/i380.md) `i380`
- 참고 문서: [Wikipedia — Sierpiński triangle](https://en.wikipedia.org/wiki/Sierpi%C5%84ski_triangle) · [Wikipedia — Koch snowflake](https://en.wikipedia.org/wiki/Koch_snowflake) · [Wikipedia — Chaos game](https://en.wikipedia.org/wiki/Chaos_game)
