# AI 꾸러미 — 3D 입체 글씨 (글꼴 윤곽 돌출 · 모서리 깎기) — Glyph outline extrusion (ExtrudeGeometry with bevel)
> 캔버스에 그린 글자의 윤곽을 마칭 스퀘어로 따고(구멍 포함) 단순화해, 돌출 + 모서리 깎기로 진짜 두께 있는 입체 글씨를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i17

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

## 주문서

### 만들어 줘: 3D 입체 글씨 (글꼴 윤곽 돌출 · 모서리 깎기) — Glyph outline extrusion (ExtrudeGeometry with bevel)

#### 1. 목표
한글 「수학」 · 숫자 「123」을(를) 두께 있는 입체 글자로 만들어 줘 — 글꼴 윤곽(구멍 포함)을 따서 돌출하고 모서리를 깎아, 빨강 도장 + 금 테 느낌으로 돌며 내려앉게.

#### 2. 핵심 기술 용어
- **Glyph outline extrusion (ExtrudeGeometry with bevel)** — 글꼴 윤곽을 밀어내 입체 글자 — 모서리 깎기
- **Marching squares (contour tracing)** — 픽셀 값에서 경계선 고리 따기
- **Ramer–Douglas–Peucker simplification** — 모양을 지키며 점 줄이기
- **THREE.Shape holes · toCreasedNormals** — 구멍 있는 모양 · 꺾임만 남기고 부드러운 법선

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

#### 4. 조건
- 윤곽은 글꼴 + 글자로 캐시하고, 다른 글자들은 한 프레임에 하나씩 미리 따기 (첫 make 300ms 이하)
- 웹 글꼴은 document.fonts.load(글꼴, 그 글자들) 이 끝난 뒤에만 쓴다 — fonts.check 는 받기 전에도 참이라 대체 글꼴 윤곽이 캐시에 박힌다
- 모서리 깎기는 bevelOffset 0 (바깥으로). 안쪽(−깎기)으로 줄이면 가는 획 · 오목한 모서리에서 앞면 윤곽이 꼬여 큰 삼각형이 튀어나온다
- 금속은 매트캡 그림으로 — 환경 반사(PMREM)는 첫 장면에 셰이더를 0.45초 동기로 굽는다
- 슬라이더(두께 · 깎기)로 다시 만들 때는 등장 연출을 다시 틀지 않는다

#### 5. 완성 기준 (이게 보이면 성공)
- 「수학」 · 「123」 · 「π≈3.14」 · 「놀이터」 · 「ABC」 가 두께 있는 글자로 돌며 떨어져 착지하고, ㅇ · A · B 의 구멍이 뚫려 있다
- 「윤곽선 보기」를 켜면 앞면에 바깥 윤곽(빨강) · 구멍(파랑) 선과 점이 겹쳐 보이고, 고리 · 구멍 · 점 · 삼각형 수가 나온다
- 두께 · 모서리 깎기 슬라이더를 끝까지 움직여도 앞면에 튀어나온 삼각형 · 줄무늬가 없다
- 재질 바꾸기(도장 + 금 테 · 크롬 · 구리 + 흰 도장)가 바로 바뀌고 폰에서도 60fps

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

## 원리
- 글꼴 파일(typeface JSON) 없이도 된다: 캔버스에 큰 글자(160px)를 그리고 알파 값을 읽는다.
- 마칭 스퀘어: 픽셀 네 귀 값이 0.5 를 넘나드는 변마다 점을 보간해 찍고 이으면 매끈한 닫힌 고리들이 나온다. 돌림 방향이 반대인 고리가 구멍(ㅇ · A · B).
- RDP 단순화(0.4px)로 점을 줄이고, 1px 안으로 붙은 점은 하나로 — 짧은 변은 모서리 깎기에서 가시가 된다.
- THREE.Shape(바깥) + holes(구멍) → ExtrudeGeometry(depth, bevel). 옆면 · 깎은 면은 toCreasedNormals 로 부드럽게, 앞면은 정확히 평평하게.
- 등장: 떨어지는 높이 y = 2.6(1 − k²) · 회전 (1 − k)²·2π 로 돌다가 앞을 보며 착지, 착지 순간 짜부라짐(감쇠 코사인).

## 핵심 코드 — 캔버스 글자 → 윤곽 고리 → 구멍 짝짓기 → 돌출 + 깎기
(발췌: demos/demosStructure.ts traceGlyph · glyphGeo 를 정리 (marchLoops · rdpClosed 는 같은 파일))
```ts
import { toCreasedNormals } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
const S = 160;
cg.font = S + 'px "Black Han Sans"';
cg.textAlign = 'center';
cg.fillText(ch, W / 2, base);
const img = cg.getImageData(0, 0, W, H).data;
const v = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) v[i] = img[i * 4 + 3] / 255;
// 마칭 스퀘어(0.5 경계, 보간) → 고리마다 RDP 0.4px → 글꼴 크기 1 = 1 단위
const loops = marchLoops(v, W, H).map((L) => rdpClosed(L, 0.4).map(([x, y]) => new THREE.Vector2((x - W / 2) / S, (base - y) / S)));
// 가장 큰 고리와 돌림 방향이 같으면 바깥, 반대면 구멍 → 그 구멍을 품은 가장 작은 바깥에
const sign = Math.sign(area(loops.reduce((a, b) => (Math.abs(area(b)) > Math.abs(area(a)) ? b : a))));
const outers = loops.filter((l) => Math.sign(area(l)) === sign).map((l) => ({ l, s: new THREE.Shape(l) }));
for (const h of loops.filter((l) => Math.sign(area(l)) !== sign)) {
  const box = outers.filter((o) => inLoop(h[0], o.l)).sort((a, b) => Math.abs(area(a.l)) - Math.abs(area(b.l)))[0];
  box?.s.holes.push(new THREE.Path(h));
}
const geo = new THREE.ExtrudeGeometry(outers.map((o) => o.s), {
  depth: 0.25, steps: 1, curveSegments: 1,
  bevelEnabled: true, bevelThickness: 0.022, bevelSize: 0.022, bevelOffset: 0, bevelSegments: 3,
});
geo.scale(100, 100, 100);                       // 같은 점 찾기 격자(0.01)보다 크게
const smooth = toCreasedNormals(geo, (40 * Math.PI) / 180);
smooth.scale(0.01, 0.01, 0.01);
// 앞뒷면(묶음 0)은 법선을 정확히 (0, 0, ±1) 로 — 안 하면 긴 삼각형 따라 줄무늬
const mesh = new THREE.Mesh(smooth, [paint, new THREE.MeshMatcapMaterial({ matcap: gold })]);
```

## 흔한 실수 · 확인 목록
- [ ] **fonts.check 만 믿으면 대체 글꼴 윤곽이 캐시된다** — 구글 글꼴은 글자 묶음(unicode-range)별로 받는데 check 는 받기 전에도 참을 줬다. load(글꼴, 쓸 글자) 의 약속이 끝난 뒤에만 그 글꼴로 딴다.
- [ ] **모서리를 안쪽으로 깎으면 앞면에 큰 삼각형이 튄다** — bevelOffset = −깎기 는 앞면 윤곽을 줄이는데, 삼각형 나누기는 원래 윤곽으로 해서 가는 획에서 꼬인다. bevelOffset 0 으로.
- [ ] **앞면까지 부드럽게 하면 크롬 글자에 대각 줄무늬** — toCreasedNormals 가 앞면 가장자리 점을 깎은 면과 섞는다. 앞뒷면 묶음의 법선을 (0, 0, ±1) 로 되돌린다.
- [ ] **금속에 환경 반사(PMREM)를 쓰면 첫 장면이 0.45초 멈춘다** — scene.environment 를 처음 쓸 때 PMREM 블러 셰이더를 동기로 굽는다. 매트캡 그림 한 장이면 멈춤 없이 금 · 크롬 · 구리가 된다.
- [ ] **점이 너무 촘촘하면 깎기에서 가시가 생긴다** — 1px 안으로 붙은 점은 하나로 합치고 RDP 로 줄인다 (글자당 점 30 ~ 60개).

## 완성 기준 체크리스트
- [ ] 「수학」 · 「123」 · 「π≈3.14」 · 「놀이터」 · 「ABC」 가 두께 있는 글자로 돌며 떨어져 착지하고, ㅇ · A · B 의 구멍이 뚫려 있다
- [ ] 「윤곽선 보기」를 켜면 앞면에 바깥 윤곽(빨강) · 구멍(파랑) 선과 점이 겹쳐 보이고, 고리 · 구멍 · 점 · 삼각형 수가 나온다
- [ ] 두께 · 모서리 깎기 슬라이더를 끝까지 움직여도 앞면에 튀어나온 삼각형 · 줄무늬가 없다
- [ ] 재질 바꾸기(도장 + 금 테 · 크롬 · 구리 + 흰 도장)가 바로 바뀌고 폰에서도 60fps

## 이 기술 정보
- id: `i17` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 윤곽 따기 글자당 1 ~ 8ms(처음 한 번, 캐시) · 돌출 0.3 ~ 5ms. 글자 「수학」 은 삼각형 약 2천 개. 한 장면 0.2ms.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i17
- 쓰면 좋을 때: 게임 제목 · 결과 화면처럼 글자가 주인공인 3D 장면 / 한글처럼 typeface 파일을 만들기 번거로운 글꼴 / 숫자 블록 · 전광판 숫자를 입체로
- 쓰지 말 때: 긴 문장 · 작은 글씨 — 대신 평면 SDF/MSDF 글자(troika-three-text)나 HTML 층 / 글자가 매 프레임 바뀌는 점수 — 윤곽 따기가 글자마다 1 ~ 8ms 라 숫자 10개를 미리 만들어 두고 바꿔 끼운다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### traceGlyph — `src/demos/demosStructure.ts:784`
```ts
function traceGlyph(ch: string, font: string): GlyphOutline {
  const key = font + '|' + ch;
  const hit = glyphCache.get(key);
  if (hit) return hit;
  const S = GLYPH_PX;
  const cv = document.createElement('canvas');
  const cg = cv.getContext('2d', { willReadFrequently: true })!;
  cg.font = font.replace('{px}', S + 'px');
  const adv = cg.measureText(ch).width;
  const pad = 6;
  const W = Math.ceil(adv + S * 0.3) + pad * 2;
  const H = Math.ceil(S * 1.45) + pad * 2;
  const base = pad + S * 1.08;
  const cx = W / 2;
  cv.width = W;
  cv.height = H;
  cg.font = font.replace('{px}', S + 'px');
  cg.textAlign = 'center';
  cg.textBaseline = 'alphabetic';
  cg.fillStyle = '#000';
  cg.fillText(ch, cx, base);
  const img = cg.getImageData(0, 0, W, H).data;
  const v = new Float32Array(W * H);
  for (let i = 0; i < W * H; i++) v[i] = img[i * 4 + 3]! / 255;
  const raw = marchLoops(v, W, H);
  const loops: { pts: THREE.Vector2[]; area: number }[] = [];
  for (const L of raw) {
    // 보간된 마칭 스퀘어 점은 이미 매끈하다 — 바로 단순화(0.4 px)하고, 1 px 안으로 붙은 점은 하나로 (짧은 변은 모서리 깎기에서 뾰족 가시가 된다)
    const sim: [number, number][] = [];
    for (const q of rdpClosed(L, 0.4)) {
      const last = sim[sim.length - 1];
      if (!last || Math.hypot(q[0] - last[0], q[1] - last[1]) > 1) sim.push(q);
    }
    if (sim.length > 3 && Math.hypot(sim[0]![0] - sim[sim.length - 1]![0], sim[0]![1] - sim[sim.length - 1]![1]) <= 1) sim.pop();
    if (sim.length < 3) continue;
    const pts = sim.map(([x, y]) => new THREE.Vector2((x - cx) / S, (base - y) / S));
    const area = loopArea(pts);
    if (Math.abs(area) < 2e-4) continue;
    loops.push({ pts, area });
  }
  let big = 0;
  for (const l of loops) if (Math.abs(l.area) > Math.abs(big)) big = l.area;
  const outers = loops.filter((l) => Math.sign(l.area) === Math.sign(big));
  const holes = loops.filter((l) => Math.sign(l.area) !== Math.sign(big));
  const shapes = outers.map((o) => ({ o, s: new THREE.Shape(o.pts) }));
  for (const hl of holes) {
    let best: (typeof shapes)[number] | null = null;
    for (const c of shapes) if (inLoop(hl.pts[0]!, c.o.pts) && (!best || Math.abs(c.o.area) < Math.abs(best.o.area))) best = c;
    best?.s.holes.push(new THREE.Path(hl.pts));
  }
  let minY = 0;
  for (const l of loops) for (const q of l.pts) minY = Math.min(minY, q.y);
  const out: GlyphOutline = {
    loops: [...outers.map((l) => ({ pts: l.pts, hole: false })), ...holes.map((l) => ({ pts: l.pts, hole: true }))],
    shapes: shapes.map((s) => s.s),
    adv: adv / S,
    minY,
  };
  glyphCache.set(key, out);
  return out;
}
```

### i17 견본 항목 — `src/demos/demosStructure.ts:2483`
```ts
  i17: {
    kind: '3d',
    caption: '글꼴 윤곽을 따서(마칭 스퀘어 → 단순화) 구멍까지 살린 모양을 돌출 + 모서리 깎기 — 진짜 두께 있는 금속 · 도장 글자가 돌며 내려앉아요',
    make() {
      const TEXTS = ['수학', '123', 'π≈3.14', '놀이터', 'ABC'];
      const scene = baseScene('#ffe9c7', '#fff8ee');
      // 금속은 매트캡(구 그림 한 장)으로 — 환경 반사(PMREM)는 첫 장면에 셰이더를 0.45초 동기로 구워 멈칫해서 쓰지 않는다
      const gold = matcapTex(['#3a1e00', '#a8640a', '#ffc44a', '#fff4c8'], false);
      const chrome = matcapTex(['#1c2028', '#7d8794', '#c9daee', '#ffffff'], true);
      const copper = matcapTex(['#2c0e04', '#8a3a1a', '#e88a5a', '#ffe0c8'], false);
      const key = new THREE.DirectionalLight(0xfff1dc, 2.2);
      key.position.set(-2.5, 5, 4);
      key.castShadow = true;
      key.shadow.mapSize.set(1024, 1024);
      key.shadow.camera.left = key.shadow.camera.bottom = -4.5;
      key.shadow.camera.right = key.shadow.camera.top = 4.5;
      key.shadow.camera.near = 1;
      key.shadow.camera.far = 14;
      key.shadow.bias = -0.0008;
      key.shadow.normalBias = 0.02;
      scene.add(key);
      const floor = new THREE.Mesh(new THREE.CircleGeometry(6, 64), std(0xf3e2c8, 0.85));
      floor.rotation.x = -Math.PI / 2;
      floor.receiveShadow = true;
      scene.add(floor);
      // 재질 세 벌: [앞뒷면(도장) 색들, 옆면 · 깎은 면]
      const PAINT = [0xff4f6b, 0x3d8bff, 0x2fc28a, 0xffb020, 0xa36bff, 0xff7a3c];
      const presets: { name: string; face: THREE.Material[]; side: THREE.Material }[] = [
        {
          name: '도장 + 금 테',
          face: PAINT.map((c) => new THREE.MeshPhysicalMaterial({ color: c, roughness: 0.38, clearcoat: 1, clearcoatRoughness: 0.06 })),
          side: new THREE.MeshMatcapMaterial({ matcap: gold }),
        },
        {
          name: '크롬',
          face: [new THREE.MeshMatcapMaterial({ matcap: chrome })],
          side: new THREE.MeshMatcapMaterial({ matcap: chrome, color: 0xd8e0ea }),
        },
        {
          name: '구리 무광 + 흰 도장',
          face: [new THREE.MeshPhysicalMaterial({ color: 0xfff6ec, roughness: 0.5, clearcoat: 0.6, clearcoatRoughness: 0.25 })],
          side: new THREE.MeshMatcapMaterial({ matcap: copper }),
        },
      ];
      const outlineMat = new THREE.LineBasicMaterial({ color: 0xff2d5c, depthTest: false, transparent: true });
      const holeMat = new THREE.LineBasicMaterial({ color: 0x1f6bff, depthTest: false, transparent: true });
      const dotMat = new THREE.PointsMaterial({ color: 0x1d2a55, size: 4, sizeAttenuation: false, depthTest: false, transparent: true });
      // 글꼴: 굵은 제목 글꼴(Black Han Sans)은 글자 묶음별로 내려받는다 — fonts.check 는 받기 전에도 참이라 믿을 수 없어 load 가 끝난 뒤에만 쓴다.
      // 그 전에는 굵은 기본 글꼴로 먼저 따고, 오면 다시 딴다 ({px} = 글자 크기 자리)
      const ALL_TXT = TEXTS.join('');
      const BHS = '{px} "Black Han Sans", "Pretendard Variable", sans-serif';
      const fontOf = (): string => (bhsReady ? BHS : '900 {px} "Pretendard Variable", "Malgun Gothic", system-ui, sans-serif');
      let font = fontOf();
      if (!bhsReady)
        void document.fonts
          ?.load('40px "Black Han Sans"', ALL_TXT)
          .then((f) => {
            bhsReady = f.length > 0;
            if (fontOf() !== font) dirty = true;
          })
          .catch(() => undefined);
      let textIdx = 0;
      let depth = 0.25;
      let bevel = 0.022;
      let mat = 0;
      let showLines = false;
      let auto = true;
      let dirty = true;
      let replay = true;
      let t0 = 0;
      let tNow = 0;
      let width = 2;
      let camD = 0;
      const group = new THREE.Group();
      scene.add(group);
      const geoCache = new Map<string, THREE.BufferGeometry>();
      let letters: { root: THREE.Group; mesh: THREE.Mesh; lines: THREE.Group; x: number }[] = [];
      let stats = { loops: 0, holes: 0, pts: 0, tris: 0 };
      const clearLetters = (): void => {
        for (const l of letters) {
          l.lines.traverse((o) => (o as THREE.Line).geometry?.dispose());
          group.remove(l.root);
        }
        letters = [];
      };
      const build = (): void => {
        dirty = false;
        if (replay) t0 = tNow;
        replay = false;
        font = fontOf();
        clearLetters();
        const chars = [...TEXTS[textIdx]!];
        const outs = chars.map((c) => traceGlyph(c, font));
        const gap = 0.05 + bevel * 2;
        width = outs.reduce((a, o) => a + o.adv + gap, -gap);
        const minY = Math.min(...outs.map((o) => o.minY));
        let x = -width / 2;
        stats = { loops: 0, holes: 0, pts: 0, tris: 0 };
        const P = presets[mat]!;
        outs.forEach((o, i) => {
          const cx = x + o.adv / 2;
          x += o.adv + gap;
          if (!o.shapes.length) return;
          const gk = `${font}|${chars[i]}|${depth}|${bevel}`;
          let geo = geoCache.get(gk);
          if (!geo) {
            geo = glyphGeo(o, depth, bevel);
            geoCache.set(gk, geo);
          }
          const mesh = new THREE.Mesh(geo, [P.face[i % P.face.length]!, P.side]);
          mesh.castShadow = true;
          mesh.position.y = -minY;
          // 2D 윤곽 보기 — 앞면 바로 앞에 선 · 점
          const lines = new THREE.Group();
          const zf = depth / 2 + bevel + 0.004;
          for (const L of o.loops) {
            const arr = new Float32Array(L.pts.length * 3);
            L.pts.forEach((q, k) => arr.set([q.x, q.y, zf], k * 3));
            const lg = new THREE.BufferGeometry();
            lg.setAttribute('position', new THREE.BufferAttribute(arr, 3));
            const ln = new THREE.LineLoop(lg, L.hole ? holeMat : outlineMat);
            ln.renderOrder = 9;
            const dots = new THREE.Points(lg, dotMat);
            dots.renderOrder = 10;
            lines.add(ln, dots);
            stats.loops++;
            if (L.hole) stats.holes++;
            stats.pts += L.pts.length;
          }
          lines.visible = showLines;
          mesh.add(lines);
          stats.tris += geo.attributes['position']!.count / 3;
          const root = new THREE.Group();
          root.add(mesh);
          root.position.x = cx;
          group.add(root);
          letters.push({ root, mesh, lines, x: cx });
        });
      };
      const rebuildGeo = (): void => {
        for (const g of geoCache.values()) g.dispose();
        geoCache.clear();
        dirty = true;
      };
      const cam = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
      const hud = new Hud();
      let warmK = 0;
      const ALL = [...new Set(TEXTS.join(''))];
      build();
      return {
        scene,
        camera: cam,
        update(t) {
          tNow = t;
          if (auto && t - t0 > 7) {
            textIdx = (textIdx + 1) % TEXTS.length;
            dirty = replay = true;
          }
          if (dirty) build();
          // 다음 글자들 윤곽은 프레임마다 하나씩 미리 (글자 하나 1 ~ 3ms)
          if (warmK < ALL.length && t > 1) traceGlyph(ALL[warmK++]!, font);
          const lt0 = t - t0;
          letters.forEach((l, i) => {
            const lt = lt0 - i * 0.16;
            const r = l.root;
            if (lt < 0) {
              r.visible = false;
              return;
            }
            r.visible = true;
            const fall = 0.85;
            if (lt < fall) {
              // 떨어지며 돈다 (중력처럼 k² · 회전은 점점 느려져 앞을 보며 멈춤)
              const k = lt / fall;
              r.position.y = 2.6 * (1 - k * k);
              r.rotation.y = (1 - k) ** 2 * TAU;
              r.rotation.x = (1 - k) * 0.5;
              r.scale.set(1, 1, 1);
            } else {
              const s = lt - fall;
              const sq = Math.exp(-s * 7) * Math.cos(s * 22);
              r.position.y = Math.max(0, Math.sin(Math.min(s / 0.32, 1) * Math.PI) * 0.16 * Math.exp(-s * 3));
              r.scale.set(1 + sq * 0.12, 1 - sq * 0.16, 1 + sq * 0.12);
              r.rotation.x = 0;
              r.rotation.y = Math.sin(t * 0.9 + i * 0.8) * 0.16 * Math.min(1, s);
            }
          });
          group.rotation.y = Math.sin(t * 0.35) * 0.18;
        },
        resize(w, h) {
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const want = Math.max(1.05 / tn, (width / 2 + 0.45) / (tn * (w / h)));
          camD = camD ? lerp(camD, want, 0.08) : want;
          cam.position.set(0, camD * 0.32 + 0.35, camD);
          cam.lookAt(0, 0.4, 0);
        },
        render(r, w, h) {
          r.render(scene, cam);
          hud.draw(r, w, h, `${w}x${h}${textIdx}${mat}${showLines}${stats.pts}${depth}${bevel}`, (g) => {
            const s = fsz(w, h, 0.85);
            pill(g, s * 0.6, s * 0.6, `「${TEXTS[textIdx]}」 · ${presets[mat]!.name}`, s, 'rgba(60,36,10,0.78)');
            if (showLines) pill(g, s * 0.6, s * 2.5, `윤곽 ${stats.loops - stats.holes} · 구멍 ${stats.holes} · 점 ${stats.pts} · 삼각형 ${stats.tris}`, s * 0.85, 'rgba(255,255,255,0.88)', '#1d2a55');
            pill(g, w - s * 0.6, s * 0.6, `두께 ${depth.toFixed(2)} · 깎기 ${bevel.toFixed(3)}`, s * 0.85, 'rgba(255,160,40,0.92)', '#2a1800', 'right');
          });
        },
        controls: [
          { type: 'button', label: '글자 바꾸기', on: () => ((textIdx = (textIdx + 1) % TEXTS.length), (dirty = replay = true)) },
          { type: 'toggle', label: '저절로 바꾸기', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '두께', min: 0.04, max: 0.6, step: 0.01, value: 0.25, on: (v) => ((depth = v), rebuildGeo()) },
          { type: 'range', label: '모서리 깎기', min: 0, max: 0.05, step: 0.002, value: 0.022, on: (v) => ((bevel = v), rebuildGeo()) },
          { type: 'button', label: '재질 바꾸기', on: () => ((mat = (mat + 1) % presets.length), (dirty = true)) },
          { type: 'toggle', label: '윤곽선 보기 (2D 점 · 선)', value: false, on: (v) => ((showLines = v), letters.forEach((l) => (l.lines.visible = v))) },
        ],
        dispose() {
          clearLetters();
          for (const g of geoCache.values()) g.dispose();
          for (const p of presets) for (const m of [...p.face, p.side]) m.dispose();
          outlineMat.dispose();
          holeMat.dispose();
          dotMat.dispose();
          for (const x of [gold, chrome, copper]) x.dispose();
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [3D 입체 글자 · 로고 등장](https://ai-techstudio.web.app/ai/t/i115.md) `i115` · [SDF 글자 빛 · 테](https://ai-techstudio.web.app/ai/t/i242.md) `i242`
- 먼저 알면 좋은 기술: [선반 회전체 + 장식 (윤곽선 하나로)](https://ai-techstudio.web.app/ai/t/i450.md) `i450` · [마칭 스퀘어 (등고선 · 경계)](https://ai-techstudio.web.app/ai/t/i259.md) `i259` · [매트캡 (MatCap)](https://ai-techstudio.web.app/ai/t/i09.md) `i09`
- 다음에 해 볼 기술: [3D 입체 글자 · 로고 등장](https://ai-techstudio.web.app/ai/t/i115.md) `i115` · [SDF 글자 빛 · 테](https://ai-techstudio.web.app/ai/t/i242.md) `i242`
- 참고 문서: [three.js 예제 — webgl_geometry_text](https://threejs.org/examples/#webgl_geometry_text) · [three.js 소스 — ExtrudeGeometry](https://github.com/mrdoob/three.js/blob/dev/src/geometries/ExtrudeGeometry.js) · [Wikipedia — Marching squares](https://en.wikipedia.org/wiki/Marching_squares) · [Wikipedia — Ramer–Douglas–Peucker algorithm](https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm)
