# AI 꾸러미 — 외곽선 (뒤집은 껍데기) — Inverted hull outline
> 같은 모양을 조금 키워 뒷면만 검게 그려, 물체 가장자리에 만화 같은 테두리 선을 두른다.  
> 견본: https://ai-techstudio.web.app/#t/u02

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

## 주문서

### 만들어 줘: 외곽선 (뒤집은 껍데기) — Inverted hull outline

#### 1. 목표
공을 붙여 만든 곰 인형에 뒤집은 껍데기 방식 외곽선을 둘러 줘 — 조금 큰 같은 모양의 뒷면만 검게. 느낌은 밝은 애니메이션.

#### 2. 핵심 기술 용어
- **Inverted hull outline** — 뒤집은 껍데기 외곽선 — 뒷면만 그린 조금 큰 복제
- **side: THREE.BackSide** — 뒷면만 그리기 — 앞면은 원래 물체가 가린다
- **Cel / toon outline** — 셀 애니메이션 선

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

#### 4. 조건
- 껍데기 재질은 MeshBasicMaterial + side: THREE.BackSide (빛 계산 없는 단색)
- 껍데기는 원래 메시의 자식으로 같은 geometry 를 함께 쓴다 (새 모양을 만들지 않기)
- 배율은 부위 반지름에 반비례 (1 + 굵기 / r) — 큰 부위만 선이 두꺼워지지 않게
- 눈 · 코 · 볼 같은 작은 조각 · 투명한 것에는 껍데기를 붙이지 않기
- 「선 굵기」 슬라이더와 껍데기를 빨간 반투명으로 들춰 보는 단추

#### 5. 완성 기준 (이게 보이면 성공)
- 왼쪽 선 없는 곰 · 오른쪽 껍데기 곰을 나란히 두면, 오른쪽에만 가장자리를 따라 고른 검은 선이 보인다
- 곰이 돌고 흔들려도 선이 끊기지 않고 굵기가 같다
- 「선 굵기」 0 ~ 0.12 슬라이더로 바로 바뀌고, 「들춰 보기」를 켜면 빨간 껍데기가 보인다
- 폰에서도 60fps

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

## 원리
- 원래 물체와 같은 모양을 아주 조금 키운 「껍데기」를 겹쳐 둔다.
- 껍데기는 뒷면(BackSide)만 검게 그린다. 앞쪽은 원래 물체가 가리고, 가장자리 밖으로 삐져나온 뒷면만 선으로 보인다.
- 선 굵기를 고르게 하려면 부위 크기 r 에 반비례해 키운다: 배율 = 1 + 굵기 / r.
- 눈 · 코 · 볼처럼 작은 조각은 껍데기를 붙이지 않아야 깔끔하다.

## 핵심 코드 — 부위마다 껍데기 붙이기 (크기에 반비례)
(발췌: demos/demosMaterial.ts u02 make() 의 bear() · part() 를 정리)
```ts
const ink = new THREE.MeshBasicMaterial({ color: 0x1c1a2e, side: THREE.BackSide });
let thick = 0.045;                       // 화면에 보이는 선 굵기 (월드 단위)
const shells: THREE.Mesh[] = [];

/** 공 하나를 붙이고, outline 이면 같은 모양 껍데기를 자식으로 */
function part(g: THREE.Group, r: number, mat: THREE.Material, x: number, y: number, z: number, outline = true) {
  const s = new THREE.Mesh(new THREE.SphereGeometry(r, 36, 24), mat);
  s.position.set(x, y, z);
  g.add(s);
  if (!outline) return;                  // 눈 · 코 같은 작은 조각은 건너뛴다
  const sh = new THREE.Mesh(s.geometry, ink);
  sh.userData.r = r;
  sh.scale.setScalar(1 + thick / r);     // 반지름에 반비례 — 모든 부위가 같은 굵기
  s.add(sh);
  shells.push(sh);
}

const fur = new THREE.MeshToonMaterial({ color: 0xc98a52, gradientMap: grad });
const bear = new THREE.Group();
part(bear, 0.82, fur, 0, 0, 0);          // 머리
part(bear, 0.36, fur, -0.55, 0.62, -0.05); // 귀
part(bear, 0.36, fur, 0.55, 0.62, -0.05);
part(bear, 0.09, eyeMat, -0.3, 0.16, 0.74, false);
scene.add(bear);
renderer.toneMapping = THREE.NoToneMapping;

// 굵기 바꾸기
function setThick(v: number) {
  thick = v;
  for (const s of shells) s.scale.setScalar(1 + v / s.userData.r);
}
```

## 흔한 실수 · 확인 목록
- [ ] **모든 껍데기를 같은 배율로 키우면 큰 부위만 선이 두껍다** — 배율을 1 + 굵기 / 반지름으로 — 견본은 부위마다 userData 에 반지름을 적어 두고 다시 맞춘다.
- [ ] **얇은 판 · 투명한 것에 붙이면 지저분하다** — 바닥 판 · 유리 · 작은 조각은 건너뛴다. 이 사이트의 toonify 도구는 크기 · 투명 여부로 거른다.
- [ ] **모서리 진 상자는 크기만 키우면 모서리에서 선이 벌어진다** — 정점을 법선 쪽으로 미는 셰이더(onBeforeCompile 에서 transformed += normal * 두께)로 바꾼다.
- [ ] **껍데기가 클릭 판정을 가로챈다** — 껍데기 메시는 raycast = () => {} 로 판정에서 빼 둔다.

## 완성 기준 체크리스트
- [ ] 왼쪽 선 없는 곰 · 오른쪽 껍데기 곰을 나란히 두면, 오른쪽에만 가장자리를 따라 고른 검은 선이 보인다
- [ ] 곰이 돌고 흔들려도 선이 끊기지 않고 굵기가 같다
- [ ] 「선 굵기」 0 ~ 0.12 슬라이더로 바로 바뀌고, 「들춰 보기」를 켜면 빨간 껍데기가 보인다
- [ ] 폰에서도 60fps

## 이 기술 정보
- id: `u02` · 분류: 그래픽 · 셰이더 › 재질 · 그림체 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 재질은 단색이라 가볍지만 그리기 횟수가 물체 수만큼 2배가 된다. 수백 개면 합치거나 작은 조각은 건너뛴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u02
- 쓰면 좋을 때: 툰 셰이딩과 함께 만화 그림체를 만들 때 / 공 · 캡슐을 붙여 만든 귀여운 캐릭터 · 소품 / 배경에서 물체를 또렷이 떼어 보이고 싶을 때
- 쓰지 말 때: 얇은 판 · 바닥 · 투명한 물체 — 껍데기가 지저분해진다. 대신 후처리 외곽선(i03) / 모서리가 날카로운 상자 — 크기만 키우면 모서리 선이 벌어진다. 대신 법선으로 미는 셰이더 외곽선

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u02 견본 항목 — `src/demos/demosMaterial.ts:280`
```ts
  u02: {
    kind: '3d',
    caption: '왼쪽 그대로 · 오른쪽 조금 큰 껍데기의 뒷면만 검게 — 만화 선이 생겨요',
    make() {
      const B = base({ bg: gradBg('#9fd6f5', '#e8f6ff'), labels: ['외곽선 없음', '뒤집은 껍데기'], fit: [2.4, 1.15], target: [0, 0.05, 0] });
      lights(B.scene, 0xffffff, 0x9fb0c0, 1.0, 1.6);
      const grad = toonGrad();
      const ink = new THREE.MeshBasicMaterial({ color: 0x1c1a2e, side: THREE.BackSide });
      let thick = 0.045;
      const shells: THREE.Mesh[] = [];
      const bear = (withInk: boolean): THREE.Group => {
        const g = new THREE.Group();
        const fur = new THREE.MeshToonMaterial({ color: 0xc98a52, gradientMap: grad });
        const light = new THREE.MeshToonMaterial({ color: 0xf3dcc0, gradientMap: grad });
        const dark = new THREE.MeshToonMaterial({ color: 0x2a1c14, gradientMap: grad });
        const pink = new THREE.MeshToonMaterial({ color: 0xff9aa8, gradientMap: grad });
        const part = (r: number, m: THREE.Material, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1, outline = true): void => {
          const s = new THREE.Mesh(new THREE.SphereGeometry(r, 36, 24), m);
          s.position.set(x, y, z);
          s.scale.set(sx, sy, sz);
          g.add(s);
          if (withInk && outline) {
            const sh = new THREE.Mesh(s.geometry, ink);
            sh.userData['r'] = r;
            sh.scale.setScalar(1 + thick / r);
            s.add(sh);
            shells.push(sh);
          }
        };
        part(0.36, fur, -0.55, 0.62, -0.05);
        part(0.36, fur, 0.55, 0.62, -0.05);
        part(0.2, pink, -0.55, 0.62, 0.22, 1, 1, 0.5, false);
        part(0.2, pink, 0.55, 0.62, 0.22, 1, 1, 0.5, false);
        part(0.82, fur, 0, 0, 0, 1, 0.92, 0.95);
        part(0.34, light, 0, -0.2, 0.66, 1.15, 0.85, 0.8);
        part(0.11, dark, 0, -0.08, 0.92, 1.3, 0.9, 1, false);
        part(0.09, dark, -0.3, 0.16, 0.74, 1, 1.25, 1, false);
        part(0.09, dark, 0.3, 0.16, 0.74, 1, 1.25, 1, false);
        return g;
      };
      const L = bear(false);
      const R = bear(true);
      L.position.x = -1.2;
      R.position.x = 1.2;
      B.scene.add(L, R);
      return B.done({
        tone: THREE.NoToneMapping,
        update(t) {
          for (const g of [L, R]) {
            g.rotation.y = Math.sin(t * 0.7) * 0.75;
            g.rotation.z = Math.sin(t * 1.3) * 0.06;
            g.position.y = Math.abs(Math.sin(t * 1.6)) * 0.08;
          }
        },
        controls: [
          { type: 'range', label: '선 굵기', min: 0, max: 0.12, step: 0.005, value: thick, on: (v) => { thick = v; for (const s of shells) s.scale.setScalar(1 + v / (s.userData['r'] as number)); } },
          {
            type: 'toggle',
            label: '껍데기 들춰 보기 (빨간 반투명)',
            value: false,
            on: (v) => {
              ink.side = v ? THREE.FrontSide : THREE.BackSide;
              ink.color.set(v ? 0xff3355 : 0x1c1a2e);
              ink.transparent = v;
              ink.opacity = v ? 0.45 : 1;
              ink.depthWrite = !v;
              ink.needsUpdate = true;
            },
          },
        ],
      });
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [셰이더로 미는 외곽선](https://ai-techstudio.web.app/ai/t/u03.md) `u03`
- 먼저 알면 좋은 기술: [툰 셰이딩 (3단 명암)](https://ai-techstudio.web.app/ai/t/u01.md) `u01`
- 다음에 해 볼 기술: [후처리 외곽선 (깊이 · 법선 경계)](https://ai-techstudio.web.app/ai/t/i03.md) `i03` · [선택 강조 맥박 테](https://ai-techstudio.web.app/ai/t/i226.md) `i226`
- 참고 문서: [three.js 예제 — webgl_materials_variations_toon](https://threejs.org/examples/#webgl_materials_variations_toon)
