# AI 꾸러미 — 빈틈없이 까는 무늬 (테셀레이션) — Tessellation (tiling)
> 타일 하나의 꼭짓점을 격자 공식으로 만들어 평행 이동으로 이어 깔고 가운데부터 톡톡 튀어나오게 해, 한 꼭짓점에 모인 각의 합이 360° 일 때만 빈틈없이 덮이는 것을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i365

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

## 주문서

### 만들어 줘: 빈틈없이 까는 무늬 (테셀레이션) — Tessellation (tiling)

#### 1. 목표
정다각형 깔기 비교 (3 · 4 · 6 · 5 · 8+4)에 빈틈없이 까는 무늬(테셀레이션)를 넣어 줘 — 타일을 평행 이동으로 이어 깔고, 가운데 꼭짓점의 각과 합 360° 를 보여 주고, 변형 타일도. 분위기는 짙은 남색 타일 · 금빛 각.

#### 2. 핵심 기술 용어
- **Tessellation (tiling)** — 테셀레이션 — 빈틈없이 평면 덮기
- **Lattice translation** — 격자 벡터만큼 옮겨 이어 깔기
- **Vertex angle sum = 360°** — 한 꼭짓점에 모인 각의 합
- **Escher-style edge modification** — 한 변을 바꾼 만큼 맞은편도 똑같이 — 변형 타일

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

#### 4. 조건
- 꼭짓점 좌표는 격자 공식으로 정확히 (눈대중 좌표 금지 — 틈이 생긴다)
- 덮이지 않는 경우(정오각형)도 보여 주고 빈틈 각을 숫자로
- 가운데 꼭짓점의 각을 부채꼴 + 숫자로, 합 360° 를 식으로
- 변형 타일은 한 변을 바꾸면 맞은편 변을 같은 곡선으로 (평행 이동이면 그대로 · 회전이면 돌린 곡선)

#### 5. 완성 기준 (이게 보이면 성공)
- 정삼각형 · 정사각형 · 정육각형이 가운데부터 깔리고, 가운데 꼭짓점에 60°×6 · 90°×4 · 120°×3 = 360° 가 보인다
- 정오각형 셋을 모으면 「빈틈 36°」 가 빨갛게 보인다
- 정팔각형 + 정사각형(135° + 135° + 90°)도 빈틈없이 덮인다
- 변형 타일이 정사각형에서 구불구불한 모양으로 바뀌어도 빈틈이 없다

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

## 원리
- 정삼각형 격자: 점 p(i, j) = e·(i + j/2, j·√3/2) — 칸마다 위 · 아래 삼각형 둘.
- 정육각형: 가운데 (1.5e·i, (√3/2·i + √3·j)·e) 에서 꼭짓점 여섯. 팔각+사각: 간격 e(1+√2) 격자에 정팔각형, 틈마다 정사각형.
- 정오각형(108°)은 셋 모으면 324° — 36° 빈틈이 남아 덮을 수 없다. 빈틈을 빨간 부채꼴로 보여 준다.
- 변형 타일: 정사각형의 아래 변을 베지에 곡선으로 휘면 위 변도 똑같이, 왼 변을 휘면 오른 변도 — 평행 이동만으로 빈틈없이 맞는다.
- 타일마다 가운데에서 거리만큼 늦게, 튀는 이징(backOut)으로 커지며 나타나 「깔리는」 느낌을 준다.

## 핵심 코드 — 격자로 타일 꼭짓점 만들기 (삼각형 · 육각형 · 변형 타일)
(발췌: demos/demosMathA.ts i365 tiles() 를 정리)
```ts
type P2 = [number, number];
const R3 = Math.sqrt(3);

// 정삼각형: 칸마다 위 · 아래 둘
function triTiles(e: number, N: number): P2[][] {
  const p = (i: number, j: number): P2 => [e * (i + j / 2), e * ((j * R3) / 2)];
  const out: P2[][] = [];
  for (let i = -N * 2; i <= N * 2; i++)
    for (let j = -N; j <= N; j++) {
      out.push([p(i, j), p(i + 1, j), p(i, j + 1)]);
      out.push([p(i + 1, j), p(i + 1, j + 1), p(i, j + 1)]);
    }
  return out;
}

// 정육각형: 가운데 격자 + 꼭짓점 여섯
function hexTiles(e: number, N: number): P2[][] {
  const out: P2[][] = [];
  for (let i = -N; i <= N; i++)
    for (let j = -N; j <= N; j++) {
      const c: P2 = [e + 1.5 * e * i, ((R3 / 2) * i + R3 * j) * e];
      const pts: P2[] = [];
      for (let k = 0; k < 6; k++) pts.push([c[0] + e * Math.cos((k * Math.PI) / 3), c[1] + e * Math.sin((k * Math.PI) / 3)]);
      out.push(pts);
    }
  return out;
}

// 변형 타일: 아래 변 곡선 = 위 변 곡선 (e 만큼 옮긴 것), 왼 변 = 오른 변
function bez(p0: P2, c1: P2, c2: P2, p3: P2, k: number): P2 {
  const m = 1 - k;
  return [m * m * m * p0[0] + 3 * m * m * k * c1[0] + 3 * m * k * k * c2[0] + k * k * k * p3[0],
          m * m * m * p0[1] + 3 * m * m * k * c1[1] + 3 * m * k * k * c2[1] + k * k * k * p3[1]];
}
const bot = (x: number, y: number, e: number, m: number): P2[] =>
  Array.from({ length: 10 }, (_, k) => bez([x, y], [x + 0.3 * e, y - 0.32 * e * m], [x + 0.7 * e, y + 0.26 * e * m], [x + e, y], k / 10));
// 타일 (x, y): 아래 = bot(x, y) · 위 = bot(x, y + e) 를 거꾸로 — 같은 곡선이라 이웃과 꼭 맞는다
```

## 흔한 실수 · 확인 목록
- [ ] **꼭짓점을 반올림한 좌표로 그리면 타일 사이에 실금이 보인다** — 격자 공식으로 정확한 좌표를 쓰고, 테두리 선을 살짝 겹쳐 그린다.
- [ ] **변형 타일에서 한 변만 바꾸면 이웃과 겹치거나 틈이 생긴다** — 바꾼 변의 맞은편 변을 같은 곡선으로 — 위 변은 아래 변을 e 만큼 옮긴 것.
- [ ] **「정오각형은 왜 안 되나」를 말로만 하면 안 와닿는다** — 셋을 모아 놓고 빈틈 36° 를 빨간 부채꼴과 숫자로 보여 준다.
- [ ] **화면 밖까지 수천 개를 만들면 느리다** — 타일 가운데가 화면 반지름 안인 것만 남긴다.

## 완성 기준 체크리스트
- [ ] 정삼각형 · 정사각형 · 정육각형이 가운데부터 깔리고, 가운데 꼭짓점에 60°×6 · 90°×4 · 120°×3 = 360° 가 보인다
- [ ] 정오각형 셋을 모으면 「빈틈 36°」 가 빨갛게 보인다
- [ ] 정팔각형 + 정사각형(135° + 135° + 90°)도 빈틈없이 덮인다
- [ ] 변형 타일이 정사각형에서 구불구불한 모양으로 바뀌어도 빈틈이 없다

## 이 기술 정보
- id: `i365` · 분류: 물리 · 수학 원리 › 수학 시각화 기법 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 화면을 덮는 타일 수백 개를 경로로 칠할 뿐. 깔기가 끝나면 구워 두면 더 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i365
- 쓰면 좋을 때: 도형 · 대칭 · 각의 합을 보여 줄 때 / 반복 무늬 배경 · 바닥을 코드로 만들 때
- 쓰지 말 때: 아주 넓은 화면을 작은 타일로 매 프레임 다시 계산 — 한 번 캔버스에 구워 패턴으로 쓴다 / 사진 같은 질감 바닥 — 텍스처 그림이 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i365 견본 항목 — `src/demos/demosMathA.ts:1496`
```ts
  i365: {
    kind: '2d',
    caption: '정다각형 · 변형 타일이 빈틈없이 평면을 덮음 — 한 꼭짓점에 모인 각의 합이 꼭 360° 일 때만',
    make() {
      let pick = 0;
      let t0 = 0;
      let req = false;
      const PER = 4.4;
      const controls: Control[] = [
        { type: 'range', label: '모양 (0 자동 · 1 삼각형 · 2 사각형 · 3 육각형 · 4 오각형 · 5 팔각+사각 · 6 변형 타일)', min: 0, max: 6, step: 1, value: 0, on: (v) => ((pick = v), (req = true)) },
        { type: 'button', label: '다시 깔기', on: () => (req = true) },
      ];
      type Tile = { pts: P2[]; shade: number; c: P2 };
      const R3 = Math.sqrt(3);
      function bez(p0: P2, c1: P2, c2: P2, p3: P2, k: number): P2 {
        const m = 1 - k;
        return [
          m * m * m * p0[0] + 3 * m * m * k * c1[0] + 3 * m * k * k * c2[0] + k * k * k * p3[0],
          m * m * m * p0[1] + 3 * m * m * k * c1[1] + 3 * m * k * k * c2[1] + k * k * k * p3[1],
        ];
      }
      function tiles(mode: number, e: number, R: number, morph: number): Tile[] {
        const out: Tile[] = [];
        const push = (pts: P2[], shade: number): void => {
          const c = centroid(pts);
          if (len(c) < R) out.push({ pts, shade, c });
        };
        const N = Math.ceil(R / e) + 2;
        if (mode === 0) {
          const p = (i: number, j: number): P2 => [e * (i + j / 2), e * ((j * R3) / 2)];
          for (let i = -N * 2; i <= N * 2; i++)
            for (let j = -N; j <= N; j++) {
              push([p(i, j), p(i + 1, j), p(i, j + 1)], 0);
              push([p(i + 1, j), p(i + 1, j + 1), p(i, j + 1)], 1);
            }
        } else if (mode === 1) {
          for (let i = -N; i <= N; i++)
            for (let j = -N; j <= N; j++)
              push(
                [
                  [i * e, j * e],
                  [(i + 1) * e, j * e],
                  [(i + 1) * e, (j + 1) * e],
                  [i * e, (j + 1) * e],
                ],
                (((i + j) % 2) + 2) % 2,
              );
        } else if (mode === 2) {
          for (let i = -N; i <= N; i++)
            for (let j = -N; j <= N; j++) {
              const c: P2 = [e + 1.5 * e * i, ((R3 / 2) * i + R3 * j) * e];
              const pts: P2[] = [];
              for (let k = 0; k < 6; k++) pts.push([c[0] + e * Math.cos((k * Math.PI) / 3), c[1] + e * Math.sin((k * Math.PI) / 3)]);
              push(pts, (((i - j) % 3) + 3) % 3);
            }
        } else if (mode === 3) {
          for (let k = 0; k < 3; k++) {
            const th = (k * 108 * Math.PI) / 180;
            const pts: P2[] = [[0, 0]];
            let cur: P2 = [0, 0];
            for (let m = 0; m < 4; m++) {
              const d = th + (m * 72 * Math.PI) / 180;
              cur = [cur[0] + e * 1.25 * Math.cos(d), cur[1] + e * 1.25 * Math.sin(d)];
              pts.push(cur);
            }
            out.push({ pts, shade: k, c: centroid(pts) });
          }
        } else if (mode === 4) {
          const s = e * (1 + Math.SQRT2);
          const Ro = e / (2 * Math.sin(Math.PI / 8));
          const V: P2 = [s / 2, e / 2];
          const M = Math.ceil(R / s) + 2;
          for (let i = -M; i <= M; i++)
            for (let j = -M; j <= M; j++) {
              const pts: P2[] = [];
              for (let k = 0; k < 8; k++) {
                const a = Math.PI / 8 + (k * Math.PI) / 4;
                pts.push([i * s + Ro * Math.cos(a) - V[0], j * s + Ro * Math.sin(a) - V[1]]);
              }
              push(pts, (((i + j) % 2) + 2) % 2);
              const q: P2[] = [];
              for (let k = 0; k < 4; k++) {
                const a = (k * Math.PI) / 2;
                q.push([(i + 0.5) * s + (e / Math.SQRT2) * Math.cos(a) - V[0], (j + 0.5) * s + (e / Math.SQRT2) * Math.sin(a) - V[1]]);
              }
              push(q, 2);
            }
        } else {
          const m = morph;
          const bot = (x: number, y: number): P2[] => {
            const r: P2[] = [];
            for (let k = 0; k < 10; k++)
              r.push(bez([x, y], [x + 0.3 * e, y - 0.32 * e * m], [x + 0.7 * e, y + 0.26 * e * m], [x + e, y], k / 10));
            return r;
          };
          const lef = (x: number, y: number): P2[] => {
            const r: P2[] = [];
            for (let k = 0; k < 10; k++)
              r.push(bez([x, y], [x - 0.28 * e * m, y + 0.3 * e], [x + 0.26 * e * m, y + 0.7 * e], [x, y + e], k / 10));
            return r;
          };
          for (let i = -N; i <= N; i++)
            for (let j = -N; j <= N; j++) {
              const x = i * e;
              const y = j * e;
              const b = bot(x, y);
              const r = lef(x + e, y);
              const tp = bot(x, y + e).reverse();
              const l = lef(x, y).reverse();
              const pts: P2[] = [...b, [x + e, y], ...r.slice(1), [x + e, y + e], ...tp.slice(0, -1), [x, y + e], ...l.slice(0, -1)];
              push(pts, (((i + j) % 2) + 2) % 2);
            }
        }
        return out;
      }
      const NM = ['정삼각형', '정사각형', '정육각형', '정오각형', '정팔각형 + 정사각형', '변형 타일 (밀어서 잇기)'];
      const SH = [
        ['#203a66', '#2c5291', '#3b68ad'],
        ['#203a66', '#2c5291', '#3b68ad'],
        ['#203a66', '#2c5291', '#3b68ad'],
        ['#203a66', '#2c5291', '#3b68ad'],
        ['#26457a', '#2f5594', '#7cc4ff'],
        ['#2a4c86', '#ffc44d', '#3b68ad'],
      ];
      return {
        controls,
        draw(g, w, h, t) {
          reset(g);
          if (req) {
            t0 = t;
            req = false;
          }
          const tt = Math.max(0, t - t0);
          const mode = pick > 0 ? pick - 1 : Math.floor(tt / PER) % 6;
          const lt = tt % PER;
          const u = Math.min(w / 280, h / 175);
          darkBg(g, w, h);
          const cx = w * 0.5;
          const cy = h * 0.56;
          const e = (mode === 4 ? 17 : mode === 5 ? 30 : mode === 2 ? 22 : 30) * u;
          const R = Math.hypot(w, h) * 0.62;
          const morph = mode === 5 ? easeIO(seg(lt, 1.5, 2.4)) : 0;
          const ts = tiles(mode, e, R, morph);
          const maxD = Math.hypot(w / 2, h / 2);
          const fadeOut = 1 - ease(seg(lt, PER - 0.35, PER));
          g.globalAlpha = fadeOut;
          const S = (p: P2): P2 => [cx + p[0], cy - p[1]];
          const shades = SH[mode]!;
          for (const tl of ts) {
            const d = len(tl.c);
            const st = 0.05 + (d / maxD) * 1.2;
            const k = backOut(seg(lt, st, st + 0.42));
            if (k <= 0.001) continue;
            const pts = tl.pts.map((p) => S(add(tl.c, mul(sub(p, tl.c), k))));
            fillStroke(g, pts, shades[tl.shade] ?? shades[0]!, 'rgba(190,215,255,0.55)', Math.max(1, 1.1 * u));
          }
          // 가운데 꼭짓점의 각
          const wv = ease(seg(lt, 1.6, 2.2));
          const lines: number[] = [];
          if (mode !== 5 && wv > 0) {
            const O = S([0, 0]);
            const rr = 15 * u;
            let tot = 0;
            for (const tl of ts) {
              const i = tl.pts.findIndex((p) => len(p) < 1e-6 * e + 0.01);
              if (i < 0) continue;
              const pv = tl.pts[(i + tl.pts.length - 1) % tl.pts.length]!;
              const nx = tl.pts[(i + 1) % tl.pts.length]!;
              const a1 = Math.atan2(-pv[1], pv[0]);
              const a2 = Math.atan2(-nx[1], nx[0]);
              let d = a2 - a1;
              while (d <= -Math.PI) d += TAU;
              while (d > Math.PI) d -= TAU;
              const s0 = d > 0 ? a1 : a2;
              const sw = Math.abs(d);
              const deg = (sw * 180) / Math.PI;
              tot += deg;
              lines.push(deg);
              g.globalAlpha = fadeOut * wv;
              wedge(g, O, rr * (0.6 + 0.4 * wv), s0, s0 + sw * wv, 'rgba(255,196,77,0.92)', 'rgba(10,14,27,0.9)', 1.2 * u);
              const mid = s0 + sw / 2;
              const lp: P2 = [O[0] + Math.cos(mid) * rr * 1.75, O[1] + Math.sin(mid) * rr * 1.75];
              txtHalo(g, `${fmt(deg, 0)}°`, lp[0], lp[1], 9 * u, '#ffe2a0', 'rgba(10,14,27,0.85)');
            }
            if (mode === 3) {
              const gap = 360 - tot;
              const a0 = -((324 * Math.PI) / 180);
              wedge(g, O, rr * 1.15, a0 - (gap * Math.PI) / 180, a0, 'rgba(255,93,93,0.95)', '#fff', 1.2 * u);
              const mid = a0 - (gap * Math.PI) / 360;
              txtHalo(g, `빈틈 ${fmt(gap, 0)}°`, O[0] + Math.cos(mid) * rr * 3.1, O[1] + Math.sin(mid) * rr * 3.1, 10 * u, '#ffb3b3', 'rgba(10,14,27,0.9)');
            }
            dotP(g, O, 2.5 * u, '#fff');
            g.globalAlpha = fadeOut;
          }
          // 글
          g.globalAlpha = fadeOut;
          g.fillStyle = 'rgba(8,12,24,0.72)';
          g.beginPath();
          g.roundRect(8 * u, 7 * u, w - 16 * u, 34 * u, 10 * u);
          g.fill();
          txt(g, NM[mode]!, 16 * u, 17 * u, 12 * u, DK.ink, 'left', 800);
          let eq = '';
          if (mode === 5) eq = morph > 0.5 ? '한 변을 바꾼 만큼 맞은편도 똑같이 → 밀기만으로 빈틈없이' : '정사각형에서 시작';
          else if (lines.length) {
            const sum = lines.reduce((s, v) => s + v, 0);
            const allEq = lines.every((v) => Math.abs(v - lines[0]!) < 0.01);
            eq = (allEq ? `${fmt(lines[0]!, 0)}° × ${lines.length}` : lines.map((v) => `${fmt(v, 0)}°`).join(' + ')) + ` = ${fmt(sum, 0)}°`;
            if (mode === 3) eq += '  → 덮을 수 없어요';
          }
          txt(g, eq || '한 꼭짓점에 모인 각을 보세요', 16 * u, 32 * u, 9.5 * u, mode === 3 && wv > 0.5 ? '#ffb3b3' : DK.acc, 'left', 700);
          reset(g);
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [재귀로 그리는 도형 (프랙탈)](https://ai-techstudio.web.app/ai/t/i382.md) `i382`
- 참고 문서: [Wikipedia — Euclidean tilings by convex regular polygons](https://en.wikipedia.org/wiki/Euclidean_tilings_by_convex_regular_polygons)
