# AI 꾸러미 — 육각 타일 행성 (골드버그 다면체) — Goldberg polyhedron (hex sphere)
> 정이십면체를 잘게 나눈 구의 쌍대를 만들어, 육각형 칸과 늘 12개뿐인 오각형 칸으로 덮인 행성을 짓고 칸마다 이웃 · 높이를 준다.  
> 견본: https://ai-techstudio.web.app/#t/i539

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

## 주문서

### 만들어 줘: 육각 타일 행성 (골드버그 다면체) — Goldberg polyhedron (hex sphere)

#### 1. 목표
행성 땅따먹기 판을 골드버그 다면체(육각 타일 행성)로 만들어 줘 — 나누기 수를 바꿔도 오각형은 12개, 칸을 누르면 이웃이 빛나고 오일러 공식 숫자가 보이게. 분위기는 밝은 장난감 행성.

#### 2. 핵심 기술 용어
- **Goldberg polyhedron (hex sphere)** — 육각형 + 오각형 12개로 덮인 구
- **Geodesic icosphere subdivision** — 정이십면체 면을 n 등분해 구로 부풀리기
- **Dual mesh** — 꼭짓점 ↔ 면을 바꾼 그물 — 꼭짓점마다 칸 하나
- **Euler's formula V − E + F = 2** — 꼭짓점 − 모서리 + 면 = 2

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 측지 구 점은 위치 열쇠(소수 5자리 반올림)로 합쳐 면 경계의 겹친 점을 하나로
- 칸 테두리 점은 칸 중심의 접평면(tan · bit) 각도로 정렬해 이은다
- 모든 칸을 한 BufferGeometry 에 넣고 정점 색으로 칠하기 — 칸마다 메시를 만들지 않기
- 삼각형 번호 → 칸 번호 표(tileOfTri)를 두어 Raycaster 의 faceIndex 로 바로 칸을 찾기
- 칸 수 · 오각형 수 · V − E + F 를 화면에 보이기

#### 5. 완성 기준 (이게 보이면 성공)
- 나누기를 1 ~ 9 로 바꿔도 분홍 오각형은 늘 12개다
- 칸을 누르면 그 칸과 이웃(오각형 5 · 육각형 6)이 빛난다
- 「꼭짓점 − 모서리 + 면 = 2」 가 나누기를 바꿔도 늘 2 다
- 높이 기둥을 켜면 땅이 높을수록 칸이 솟는다

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

## 원리
- 정이십면체 20개 면을 각각 무게중심 좌표 (i, j) 로 n 등분해 작은 삼각형을 만들고, 점을 모두 구 위로 normalize 한다 (측지 구).
- 쌍대: 측지 구의 꼭짓점 하나 = 칸 하나. 그 꼭짓점을 둘러싼 삼각형들의 무게중심을 각도 순으로 이으면 칸의 테두리.
- 처음 정이십면체의 꼭짓점 12개만 이웃이 5개 → 오각형, 나머지는 모두 이웃 6개 → 육각형. 나누기를 늘려도 오각형은 늘 12개.
- 칸 수 F = 10n² + 2 (n = 4 면 162칸). 칸의 모서리 점 V = 삼각형 수, 모서리 E = 이웃 쌍 수 → V − E + F = 2.
- 칸마다 3D 잡음으로 높이를 정해 바다 · 모래 · 풀 · 숲 · 바위 · 눈 색과 기둥 높이(층마다 0.035)를 준다.

## 핵심 코드 — 측지 구 만들기 → 쌍대 칸 (테두리 = 둘레 삼각형 무게중심)
(발췌: demos/demosPlanet.ts buildHex 를 정리)
```ts
import * as THREE from 'three';
// B = 정이십면체 꼭짓점 12개(normalize), F0 = 면 20개의 꼭짓점 번호 (견본 그대로)
const verts: THREE.Vector3[] = [], keyMap = new Map<string, number>();
const vid = (p: THREE.Vector3) => {
  p.normalize();
  const k = Math.round(p.x * 1e5) + ',' + Math.round(p.y * 1e5) + ',' + Math.round(p.z * 1e5);
  let i = keyMap.get(k);
  if (i === undefined) { i = verts.length; verts.push(p.clone()); keyMap.set(k, i); } // 겹친 점 합치기
  return i;
};
const tris: number[] = [];
for (let f = 0; f < 20; f++) {
  const A = B[F0[f * 3]], Bv = B[F0[f * 3 + 1]], Cv = B[F0[f * 3 + 2]];
  const P = (i: number, j: number) => vid(new THREE.Vector3()
    .addScaledVector(A, (n - i - j) / n).addScaledVector(Bv, i / n).addScaledVector(Cv, j / n));
  for (let i = 0; i < n; i++) for (let j = 0; j < n - i; j++) {
    tris.push(P(i, j), P(i + 1, j), P(i, j + 1));
    if (i + j < n - 1) tris.push(P(i + 1, j), P(i + 1, j + 1), P(i, j + 1));
  }
}
// 쌍대: 꼭짓점마다 칸 — 둘레 삼각형의 무게중심이 칸의 모서리 점
const cent: THREE.Vector3[] = [], triOf: number[][] = verts.map(() => []), nbr: Set<number>[] = verts.map(() => new Set());
for (let t = 0; t < tris.length / 3; t++) {
  const [a, b, c] = [tris[t * 3], tris[t * 3 + 1], tris[t * 3 + 2]];
  cent.push(verts[a].clone().add(verts[b]).add(verts[c]).normalize());
  triOf[a].push(t); triOf[b].push(t); triOf[c].push(t);
  nbr[a].add(b).add(c); nbr[b].add(a).add(c); nbr[c].add(a).add(b);
}
const tan = new THREE.Vector3(), bit = new THREE.Vector3();
const ringOf = (v: number) => {
  const c = verts[v];
  tan.set(-c.z, 0, c.x); if (tan.lengthSq() < 1e-6) tan.set(1, 0, 0); tan.normalize();
  bit.crossVectors(c, tan);
  return triOf[v].map((t) => cent[t]).sort((p, q) => Math.atan2(p.dot(bit), p.dot(tan)) - Math.atan2(q.dot(bit), q.dot(tan)));
};
const isPenta = (v: number) => nbr[v].size === 5; // 늘 12개
const F = verts.length, V = tris.length / 3, E = nbr.reduce((s, x) => s + x.size, 0) / 2; // V − E + F = 2
```

## 흔한 실수 · 확인 목록
- [ ] **면 경계의 같은 점을 따로 만들면 칸이 둘로 쪼개지고 이웃 수가 틀린다** — 위치를 반올림한 열쇠로 같은 점을 하나로 합친다.
- [ ] **테두리 점을 아무 순서로 이으면 칸이 꼬인 별 모양이 된다** — 칸 중심의 접평면 두 축으로 각도를 구해 정렬한다.
- [ ] **칸마다 메시를 만들면 800칸에서 그리기 호출이 800번이다** — 한 BufferGeometry 에 모으고 정점 색으로 칠한다. 칸 색 바꾸기는 그 칸의 정점 범위(ranges)만 고친다.

## 완성 기준 체크리스트
- [ ] 나누기를 1 ~ 9 로 바꿔도 분홍 오각형은 늘 12개다
- [ ] 칸을 누르면 그 칸과 이웃(오각형 5 · 육각형 6)이 빛난다
- [ ] 「꼭짓점 − 모서리 + 면 = 2」 가 나누기를 바꿔도 늘 2 다
- [ ] 높이 기둥을 켜면 땅이 높을수록 칸이 솟는다

## 이 기술 정보
- id: `i539` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 나누기 9 = 812칸 · 칸마다 윗면 + 옆벽 삼각형 수십 개를 한 메시(정점 색)로 합쳐 그리기 1번. 칸 고르기는 삼각형 → 칸 번호 표로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i539
- 쓰면 좋을 때: 구 위에서 칸 게임 · 땅따먹기를 할 때 (네모 격자는 극에서 찌그러짐) / 오일러 공식 · 다면체를 체험으로 보여 줄 때 / 행성 지도 · 전략 게임
- 쓰지 말 때: 평면 판 — 대신 육각 격자 좌표(i256) / 칸이 모두 똑같아야 할 때 — 구는 오각형 12개가 꼭 생긴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeHex — `src/demos/demosPlanet.ts:1511`
```ts
function makeHex(): Scene3D {
  const scene = new THREE.Scene();
  const bg = pastelSky('#1d2b55', '#2d4a7c', '#5a7fb0');
  scene.background = bg;
  const cam = new THREE.PerspectiveCamera(38, 1.6, 0.1, 50);
  cam.position.set(0, 0.6, 4.4);
  cam.lookAt(0, 0, 0);
  scene.add(new THREE.HemisphereLight(0xe6f2ff, 0x3a4060, 1.4));
  const sun = new THREE.DirectionalLight(0xfff2dc, 2.2);
  sun.position.set(3, 2.5, 4);
  scene.add(sun);
  const mat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.75, flatShading: false });
  const holder = new THREE.Group();
  scene.add(holder);
  let n = 4;
  let pillars = true;
  let pentaOnly = false;
  let hp: HexPlanet | null = null;
  let mesh: THREE.Mesh | null = null;
  let sel = -1;
  let selT = 0;
  const rebuild = (): void => {
    if (mesh) {
      holder.remove(mesh);
      mesh.geometry.dispose();
    }
    hp = buildHex(n, pillars, 21);
    mesh = new THREE.Mesh(hp.geo, mat);
    holder.add(mesh);
    sel = -1;
    paint();
  };
  const paint = (): void => {
    if (!hp) return;
    const c = hp.geo.attributes.color as THREE.BufferAttribute;
    const arr = c.array as Float32Array;
    arr.set(hp.base);
    const tint = (tile: number, r: number, g: number, b: number, k: number): void => {
      const { start, count } = hp!.ranges[tile]!;
      for (let i = start; i < start + count; i++) {
        arr[i * 3] = lerp(arr[i * 3]!, r, k);
        arr[i * 3 + 1] = lerp(arr[i * 3 + 1]!, g, k);
        arr[i * 3 + 2] = lerp(arr[i * 3 + 2]!, b, k);
      }
    };
    hp.penta.forEach((p, i) => {
      if (p) tint(i, 1, 0.32, 0.45, 0.85);
      else if (pentaOnly) tint(i, 0.5, 0.55, 0.65, 0.7);
    });
    if (sel >= 0) {
      tint(sel, 1, 0.85, 0.2, 0.9);
      for (const j of hp.nbr[sel]!) tint(j, 1, 0.95, 0.6, 0.6);
    }
    c.needsUpdate = true;
  };
  rebuild();
  const input = new Input(false);
  const rayc = new THREE.Raycaster();
  let spin = 0;
  let tilt = 0.25;
  let isBig = false;
  const overlay = new Overlay();
  return {
    scene,
    camera: cam,
    update(_t, dt) {
      if (input.drag.dx || input.drag.dy) {
        spin += input.drag.dx * 0.006;
        tilt = clamp(tilt + input.drag.dy * 0.006, -1.2, 1.2);
        input.drag.dx = input.drag.dy = 0;
      } else spin += dt * 0.18;
      holder.rotation.set(tilt, spin, 0);
      // 카드: 칸 하나를 골라 이웃과 함께 비추기 (2초마다)
      if (!isBig && hp && (selT -= dt) < 0) {
        selT = 2;
        const cands = hp.penta.map((p, i) => (p ? i : -1)).filter((i) => i >= 0);
        sel = Math.random() < 0.4 ? cands[Math.floor(Math.random() * cands.length)]! : Math.floor(Math.random() * hp.F);
        paint();
      }
    },
    render(r, w, h) {
      isBig = w >= 700;
      if (isBig) input.attach(w, h);
      while (input.taps.length && mesh && hp) {
        const tp = input.taps.shift()!;
        rayc.setFromCamera(new THREE.Vector2(tp.x, tp.y), cam);
        const hit = rayc.intersectObject(mesh, false)[0];
        if (hit && hit.faceIndex !== undefined && hit.faceIndex !== null) {
          sel = hp.tileOfTri[hit.faceIndex]!;
          paint();
        }
      }
      r.render(scene, cam);
      if (!hp) return;
      const pen = hp.penta.filter(Boolean).length;
      const tags: Tag[] = [
        { text: `나누기 ${n} — 칸 ${hp.F}개 = 육각형 ${hp.F - pen} + 오각형 ${pen}`, x: 0.03, y: 0.05, ax: 0, ay: 0, bg: 'rgba(20,28,48,0.7)' },
        { text: `꼭짓점 ${hp.V} − 모서리 ${hp.E} + 면 ${hp.F} = ${hp.V - hp.E + hp.F}`, x: 0.03, y: 0.15, ax: 0, ay: 0, bg: 'rgba(170,60,90,0.75)' },
      ];
      if (sel >= 0) tags.push({ text: `고른 칸: ${hp.penta[sel] ? '오각형 — 이웃 5칸' : '육각형 — 이웃 6칸'}`, x: 0.97, y: 0.05, ax: 1, ay: 0, bg: 'rgba(160,120,20,0.8)' });
      if (isBig) tags.push({ text: '칸 누르기 = 이웃 보기 · 끌기 = 돌리기 · 오각형(분홍)은 늘 12개', x: 0.5, y: 0.96, ax: 0.5, ay: 1 });
      overlay.draw(r, w, h, tags);
    },
    controls: [
      { type: 'range', label: '나누기 (진동수)', min: 1, max: 9, step: 1, value: 4, on: (v) => ((n = v), rebuild()) },
      { type: 'toggle', label: '높이 기둥 (땅이 높을수록 솟게)', value: true, on: (v) => ((pillars = v), rebuild()) },
      { type: 'toggle', label: '오각형만 또렷하게', value: false, on: (v) => ((pentaOnly = v), paint()) },
    ],
    dispose() {
      input.dispose();
      overlay.dispose();
      mesh?.geometry.dispose();
      mat.dispose();
      bg.dispose();
    },
  };
}
```

### i539 견본 항목 — `src/demos/demosPlanet.ts:1633`
```ts
  i539: { kind: '3d', caption: '정이십면체를 나눈 구의 쌍대 — 칸은 육각형, 오각형은 나누기와 상관없이 늘 12개 · 꼭짓점 − 모서리 + 면 = 2', make: () => makeHex() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [육각 격자 좌표 (큐브 · 축)](https://ai-techstudio.web.app/ai/t/i256.md) `i256` · [구 지도 (지구본 · 위도 경도)](https://ai-techstudio.web.app/ai/t/i262.md) `i262`
- 다음에 해 볼 기술: [디오라마 지도 (3D 섬 판)](https://ai-techstudio.web.app/ai/t/i275.md) `i275`
- 참고 문서: [Wikipedia — Goldberg polyhedron](https://en.wikipedia.org/wiki/Goldberg_polyhedron) · [Wikipedia — Geodesic polyhedron](https://en.wikipedia.org/wiki/Geodesic_polyhedron)
