# AI 꾸러미 — 서브디비전 (각진 모양 → 매끈하게) — Catmull-Clark subdivision
> 각진 상자 틀을 Catmull-Clark 로 한 단계씩 나눠 매끈한 곡면으로 녹이고, 표시한 모서리만 날카롭게 남긴다.  
> 견본: https://ai-techstudio.web.app/#t/i448

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

## 주문서

### 만들어 줘: 서브디비전 (각진 모양 → 매끈하게) — Catmull-Clark subdivision

#### 1. 목표
상자 틀로 빚은 병아리를 낮은 폴리 틀에서 서브디비전으로 매끈하게 만들어 줘 — 단계 0 ~ 3, 표시한 모서리는 날카롭게. 분위기는 반들반들 장난감.

#### 2. 핵심 기술 용어
- **Catmull-Clark subdivision** — 사각 면을 넷으로 나누며 매끈하게
- **Loop subdivision** — 삼각 면을 넷으로 나누며 매끈하게
- **Control cage** — 낮은 폴리 틀 — 모양을 잡는 손잡이
- **Crease (sharp edge)** — 나눠도 매끈해지지 않게 표시한 모서리

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

#### 4. 조건
- 모서리는 두 정점 번호로 열쇠(작은 번호 × 65536 + 큰 번호)를 만들어 면끼리 공유
- 열린 가장자리(면이 하나뿐)와 표시한 주름 모서리는 날카롭게 처리
- 주름 표시는 나눈 뒤 두 반쪽 모서리로 물려준다
- 틀 선(주황)과 주름 선(분홍)을 겹쳐 보여, 어느 모서리가 날카로운지 알 수 있게
- Catmull-Clark(사각) · Loop(삼각)를 바꿔 볼 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 주황 틀 선 안에서 단계 0(각진 상자) → 3(매끈한 병아리)으로 한 단계씩 녹아내린다
- 「주름」을 끄면 날카롭던 부리 · 모서리까지 둥글어지고, 켜면 그대로 남는다
- 「Loop」로 바꾸면 삼각형으로 나눈 다른 결의 곡면이 된다
- 단계마다 면 수가 4배로 느는 것이 숫자로 보인다

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

## 원리
- 면마다 가운데 점(면 점), 모서리마다 새 점(모서리 점)을 만들고, 사각 면 하나를 넷으로 나눈다.
- 모서리 점 = (모서리 두 끝 + 양쪽 면 점) ÷ 4. 날카로운 모서리면 그냥 가운데.
- 원래 점 = (면 점 평균 F + 2 × 모서리 가운데 평균 R + (n − 3) × 원래 점) ÷ n. n 은 이웃 모서리 수.
- 날카로운 모서리가 둘 붙은 점은 (6 × 자기 + 양 끝) ÷ 8, 셋 이상이면 그대로 둔다.
- 한 단계에 면이 4배 — 단계 3이면 64배. 단계 사이는 모프로 녹아내리게 보여 준다.

## 핵심 코드 — Catmull-Clark 한 단계 — 점 옮기기 규칙 (주름 포함)
(발췌: demos/demosModelA.ts catmull() 을 정리)
```ts
const ek = (a: number, b: number) => (a < b ? a * 65536 + b : b * 65536 + a);
const sharp = (e: Edge) => e.f.length < 2 || m.cr.has(ek(e.a, e.b)); // 열린 가장자리 · 주름
const fp = m.f.map((f) => centroid(m, f)); // 면 점

// 원래 점 옮기기
for (let v = 0; v < nv; v++) {
  const p = m.v[v]!;
  const es = vE[v]!.map((i) => elist[i]!);
  const cs = es.filter(sharp);
  let q: THREE.Vector3;
  if (cs.length >= 3) q = p.clone();                        // 뾰족한 꼭짓점은 그대로
  else if (cs.length === 2) {                                // 주름 위 점: (6p + 양 끝) / 8
    const o = cs.map((e) => m.v[e.a === v ? e.b : e.a]!);
    q = p.clone().multiplyScalar(6).add(o[0]!).add(o[1]!).divideScalar(8);
  } else {                                                   // (F + 2R + (n-3)p) / n
    const n = es.length;
    const F = new THREE.Vector3();
    for (const fi of vF[v]!) F.add(fp[fi]!);
    F.divideScalar(vF[v]!.length);
    const R = new THREE.Vector3();
    for (const e of es) R.add(m.v[e.a]!).add(m.v[e.b]!);
    R.divideScalar(2 * n);
    q = F.add(R.multiplyScalar(2)).addScaledVector(p, n - 3).divideScalar(n);
  }
  out.v.push(q);
}
// 모서리 점: 날카로우면 가운데, 아니면 (두 끝 + 양쪽 면 점) / 4
for (const e of elist) {
  const mid = m.v[e.a]!.clone().add(m.v[e.b]!).multiplyScalar(0.5);
  out.v.push(sharp(e) ? mid : m.v[e.a]!.clone().add(m.v[e.b]!).add(fp[e.f[0]!]!).add(fp[e.f[1]!]!).multiplyScalar(0.25));
  if (m.cr.has(ek(e.a, e.b))) { out.cr.add(ek(e.a, nv + e.id)); out.cr.add(ek(nv + e.id, e.b)); } // 주름 물려주기
}
// 면 점을 넣고, 면마다 [꼭짓점, 다음 모서리 점, 면 점, 앞 모서리 점] 사각형 n 개로 나눈다
```

## 흔한 실수 · 확인 목록
- [ ] **주름 표시를 다음 단계로 안 물려주면 단계 2부터 둥글어진다** — 주름 모서리를 나눈 두 반쪽(끝 ↔ 모서리 점)에 주름 표시를 다시 넣는다.
- [ ] **열린 가장자리를 보통 모서리로 계산하면 구멍 테두리가 오그라든다** — 면이 하나뿐인 모서리는 날카로운 모서리로 다룬다.
- [ ] **단계 4 이상은 면 수가 폭발한다** — 견본은 단계 3까지. 그 이상 매끈함은 법선으로 충분하다.

## 완성 기준 체크리스트
- [ ] 주황 틀 선 안에서 단계 0(각진 상자) → 3(매끈한 병아리)으로 한 단계씩 녹아내린다
- [ ] 「주름」을 끄면 날카롭던 부리 · 모서리까지 둥글어지고, 켜면 그대로 남는다
- [ ] 「Loop」로 바꾸면 삼각형으로 나눈 다른 결의 곡면이 된다
- [ ] 단계마다 면 수가 4배로 느는 것이 숫자로 보인다

## 이 기술 정보
- id: `i448` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 단계마다 면 4배. 틀 수백 면이면 단계 3에 수만 면 — 한 번 짓고 굳히는 데는 문제없지만 매 프레임 다시 나누면 무겁다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i448
- 쓰면 좋을 때: 캐릭터 머리 · 소품처럼 적은 점으로 모양을 잡고 매끈하게 보이고 싶을 때 / 틀을 고치면 곡면이 따라 바뀌어야 할 때
- 쓰지 말 때: 처음부터 둥근 기본 도형으로 될 때 — SphereGeometry · RoundedBox 가 빠르다 / 말랑하게 모양이 계속 바뀌는 몸 — 대신 메타볼(i447)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoSubdiv — `src/demos/demosModelA.ts:2233`
```ts
function demoSubdiv(): Scene3D {
  const st = new Stage({ cam: [0, 1.55, 5.2], look: [0, 0.85, 0], island: 1.55 });
  const mat = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.42, clearcoat: 0.5, clearcoatRoughness: 0.3, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
  const S = { shape: 0, loop: false, crease: true, cage: true, auto: true, level: 3, seed: 1 };
  const holder = new THREE.Group();
  st.spin.add(holder);
  let levels: THREE.Mesh[] = [];
  let decals: { obj: THREE.Object3D; hits: { p: V3; n: V3 }[] }[] = [];
  let ms = 0;
  const lineMat = new THREE.LineBasicMaterial({ color: 0xffb347, transparent: true, opacity: 0.9, toneMapped: false });
  const creaseMat = new THREE.LineBasicMaterial({ color: 0xff3d8b, toneMapped: false });
  const cageGrp = new THREE.Group();
  holder.add(cageGrp);
  const ray = new THREE.Raycaster();
  const build = (): void => {
    const t0 = performance.now();
    for (const m of levels) {
      st.models.splice(st.models.indexOf(m), 1);
      m.geometry.dispose();
    }
    disposeTree(holder);
    holder.clear();
    cageGrp.clear();
    holder.add(cageGrp);
    levels = [];
    decals = [];
    const src = S.shape === 0 ? chickCage(S.seed) : carCage();
    if (!S.crease) src.m.cr.clear();
    const cage0 = S.loop ? triangulate(src.m) : src.m;
    // 틀 선
    const lp: number[] = [];
    const cp: number[] = [];
    const seen = new Set<number>();
    for (const f of src.m.f)
      for (let i = 0; i < f.length; i++) {
        const a = f[i]!, b = f[(i + 1) % f.length]!;
        const k = ek(a, b);
        if (seen.has(k)) continue;
        seen.add(k);
        const arr = src.m.cr.has(k) ? cp : lp;
        arr.push(...src.m.v[a]!.toArray(), ...src.m.v[b]!.toArray());
      }
    const lg = new THREE.BufferGeometry().setAttribute('position', new THREE.Float32BufferAttribute(lp, 3));
    const cg = new THREE.BufferGeometry().setAttribute('position', new THREE.Float32BufferAttribute(cp, 3));
    cageGrp.add(new THREE.LineSegments(lg, lineMat), new THREE.LineSegments(cg, creaseMat));
    // 단계 0 ~ 3
    const targets: Float32Array[] = [];
    let cur = cage0;
    const L0 = levelGeo(cur, null, null);
    levels.push(new THREE.Mesh(L0.geo, mat));
    targets.push(L0.target);
    for (let k = 1; k <= 3; k++) {
      const prevN = k === 1 ? null : vertexNormals(cur);
      const prevFlat = k === 1 ? cur.f.map((f) => newell(cur, f).normalize()) : null;
      const sub = S.loop ? loopSub(cur, prevN) : catmull(cur, prevN);
      const L = levelGeo(sub.m, sub, prevFlat);
      const mesh = new THREE.Mesh(L.geo, mat);
      mesh.updateMorphTargets();
      levels.push(mesh);
      targets.push(L.target);
      cur = sub.m;
    }
    for (const m of levels) holder.add(st.model(m));
    // 얼굴 · 등 — 단계마다 표면 위 자리를 미리 찾아 둔다
    const probes = targets.map((tp) => new THREE.Mesh(new THREE.BufferGeometry().setAttribute('position', new THREE.BufferAttribute(tp, 3)), new THREE.MeshBasicMaterial({ side: THREE.DoubleSide })));
    for (const d of src.decals) {
      const obj = d.make();
      const hits = probes.map((pm) => {
        ray.set(new THREE.Vector3(...d.o), new THREE.Vector3(...d.d));
        const h = ray.intersectObject(pm)[0];
        return h ? { p: h.point.clone(), n: h.face!.normal.clone() } : { p: new THREE.Vector3(...d.o), n: new THREE.Vector3(0, 0, 1) };
      });
      holder.add(obj);
      decals.push({ obj, hits });
    }
    for (const pm of probes) pm.geometry.dispose();
    for (const e of src.extra) holder.add(e);
    holder.position.y = S.shape === 0 ? 0.95 : 0.52;
    holder.scale.setScalar(S.shape === 0 ? 0.78 : 0.95);
    ms = performance.now() - t0;
  };
  build();
  let frameNo = 0;
  const PH = [1.1, 0.8, 0.5, 0.8, 0.5, 0.8, 1.7];
  const TOT = PH.reduce((a, b) => a + b, 0);
  const pv = new THREE.Vector3();
  const nv = new THREE.Vector3();
  const Zp = new THREE.Vector3(0, 0, 1);
  return st.out({
    update(t) {
      let level = S.level;
      let inf = 1;
      if (S.auto) {
        let k = t % TOT;
        let ph = 0;
        while (ph < PH.length - 1 && k > PH[ph]!) k -= PH[ph++]!;
        if (ph === 0) level = 0;
        else {
          level = Math.ceil(ph / 2);
          inf = ph % 2 === 1 ? ease(k / PH[ph]!) : 1;
        }
      }
      const warm = frameNo++ < 2;
      levels.forEach((m, i) => {
        m.visible = warm || i === level;
        if (m.morphTargetInfluences) m.morphTargetInfluences[0] = inf;
      });
      for (const d of decals) {
        const a = d.hits[Math.max(0, level - 1)]!;
        const b = d.hits[level]!;
        const w = level === 0 ? 1 : inf;
        pv.copy(a.p).lerp(b.p, w);
        nv.copy(a.n).lerp(b.n, w).normalize();
        d.obj.position.copy(pv);
        d.obj.quaternion.setFromUnitVectors(Zp, nv);
      }
      cageGrp.visible = S.cage;
      st.spin.rotation.y = Math.sin(t * 0.5) * 0.7 + (S.shape === 1 ? 0.5 : 0);
      st.hud.tl.set(`▲ 삼각형 ${fmt(st.tris())}`);
      st.hud.bl.set(`${S.loop ? 'Loop' : 'Catmull-Clark'} 단계 ${level}${S.crease && S.shape === 1 ? ' · 분홍 = 날카로운 모서리' : ''} · ${ms.toFixed(0)}ms`);
    },
    controls: [
      { type: 'toggle', label: '자동 재생 (단계 올리기)', value: true, on: (v) => (S.auto = v) },
      { type: 'range', label: '나누기 단계', min: 0, max: 3, step: 1, value: 3, on: (v) => ((S.level = v), (S.auto = false)) },
      { type: 'range', label: '모양 (0 병아리 · 1 장난감 차)', min: 0, max: 1, step: 1, value: 0, on: (v) => ((S.shape = v), build()) },
      { type: 'toggle', label: 'Loop 방식 (삼각형으로 나누기)', value: false, on: (v) => ((S.loop = v), build()) },
      { type: 'toggle', label: '날카로운 모서리 (차 바닥)', value: true, on: (v) => ((S.crease = v), build()) },
      { type: 'toggle', label: '처음 상자 틀 보기', value: true, on: (v) => (S.cage = v) },
      { type: 'toggle', label: '와이어프레임', value: false, on: (v) => (st.wire.on = v) },
      { type: 'button', label: '새로 만들기 (색 · 비율)', on: () => ((S.seed += 1), build()) },
    ],
    dispose() {
      lineMat.dispose();
      creaseMat.dispose();
    },
  });
}
```

### i448 견본 항목 — `src/demos/demosModelA.ts:2744`
```ts
  i448: { kind: '3d', caption: '주황 선 = 처음 각진 상자 틀 — Catmull-Clark 로 한 단계씩 나눌 때마다 매끈한 병아리로 녹아내림', make: demoSubdiv }
```

## 관련 기술
- 먼저 알면 좋은 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
- 다음에 해 볼 기술: [옆모습 윤곽 돌출 + 모서리 깎기 (베벨)](https://ai-techstudio.web.app/ai/t/i468.md) `i468`
- 참고 문서: [Wikipedia — Catmull–Clark subdivision surface](https://en.wikipedia.org/wiki/Catmull%E2%80%93Clark_subdivision_surface) · [Wikipedia — Loop subdivision surface](https://en.wikipedia.org/wiki/Loop_subdivision_surface)
