# AI 꾸러미 — 선반 회전체 + 장식 (윤곽선 하나로) — Lathe / surface of revolution
> 반쪽 윤곽선 하나를 직선 · 둥근 모서리 · 원호로 그려 축으로 돌려, 체스 말 · 화분 · 꽃병을 홈 · 테 · 받침까지 한 번에 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i450

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

## 주문서

### 만들어 줘: 선반 회전체 + 장식 (윤곽선 하나로) — Lathe / surface of revolution

#### 1. 목표
체스 말 · 화분 · 꽃병 셋을 윤곽선 하나를 돌린 회전체로 만들어 줘 — 홈 · 테 · 받침 장식은 윤곽에서, 띠 색도 윤곽 구간으로. 분위기는 반들반들 도자기.

#### 2. 핵심 기술 용어
- **Lathe / surface of revolution** — 윤곽선을 축으로 돌린 회전체
- **THREE.LatheGeometry** — three.js 회전체 모양 (점 목록 · 둘레 조각 수)
- **Profile curve (fillet · arc)** — 윤곽 — 둥근 모서리 · 원호 · 매끈한 곡선
- **Per-profile vertex color** — 윤곽 구간마다 색 번호 → 띠 무늬

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

#### 4. 조건
- 윤곽은 아래 축(x = 0)에서 시작해 위 축(x = 0)으로 끝나야 바닥 · 꼭대기에 구멍이 없다
- x 는 0 아래로 내려가지 않게 (Math.max(0, x))
- 꺾이는 곳마다 둥글기를 주되, 양쪽 변 길이의 45% 를 넘지 않게
- 장식(금 구슬 고리 · 십자가)은 윤곽에서 그 높이의 반지름을 읽어 붙인다
- 「둘레 조각 수」 6 ~ 96 슬라이더로 각진 기둥 ↔ 매끈한 원 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 노란 윤곽선이 먼저 그려지고, 그 선이 축을 따라 돌며 체스 말 · 화분 · 꽃병이 된다
- 받침 · 홈 · 구슬 테가 윤곽에서 나오고, 금 띠 색이 구간대로 칠해진다
- 「둘레 조각 수」를 6 으로 내리면 육각 기둥, 64 면 매끈한 원이 된다
- 「새로 만들기」마다 비율 · 색이 조금씩 다른 세 가지가 나온다

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

## 원리
- (x = 축에서 거리, y = 높이) 점 목록을 아래에서 위로 그리면 반쪽 옆모습이 된다.
- LatheGeometry(점 목록, 둘레 조각 수) 가 그 선을 y 축으로 한 바퀴 돌려 면을 만든다.
- 꺾이는 점에는 둥글기 r 을 주어 두 변 사이를 2차 곡선 7점으로 깎는다 — 빛이 맺혀 고급스러워진다.
- 반원 홈 · 구슬 테는 원호(A), 꽃병 배는 매끈한 곡선(Catmull-Rom, S)으로 넣는다.
- 윤곽 구간마다 색 번호를 붙여 정점 색으로 칠하면 금 띠 · 색 띠가 저절로 생긴다.

## 핵심 코드 — 둥근 모서리 윤곽 → LatheGeometry + 띠 색
(발췌: demos/demosModelB.ts Prof.build() · latheDemo() buildG 를 정리)
```ts
type PNode = { x: number; y: number; r: number; c: number }; // r = 모서리 둥글기, c = 색 번호

function buildProfile(N: PNode[]): { pts: THREE.Vector2[]; cols: number[] } {
  const pts: THREE.Vector2[] = [], cols: number[] = [];
  const push = (x: number, y: number, c: number) => { pts.push(new THREE.Vector2(Math.max(0, x), y)); cols.push(c); };
  for (let i = 0; i < N.length; i++) {
    const n = N[i]!;
    if (n.r > 0 && i > 0 && i < N.length - 1) {
      const pr = N[i - 1]!, nx = N[i + 1]!;
      const l0 = Math.hypot(pr.x - n.x, pr.y - n.y), l1 = Math.hypot(nx.x - n.x, nx.y - n.y);
      const rr = Math.min(n.r, 0.45 * l0, 0.45 * l1); // 변 길이의 45% 까지만
      const ax = n.x + ((pr.x - n.x) / l0) * rr, ay = n.y + ((pr.y - n.y) / l0) * rr;
      const bx = n.x + ((nx.x - n.x) / l1) * rr, by = n.y + ((nx.y - n.y) / l1) * rr;
      for (let k = 0; k <= 6; k++) { // 2차 곡선으로 모서리 깎기
        const s = k / 6, u = 1 - s;
        push(u * u * ax + 2 * u * s * n.x + s * s * bx, u * u * ay + 2 * u * s * n.y + s * s * by, n.c);
      }
    } else push(n.x, n.y, n.c);
  }
  return { pts, cols };
}

const segs = 64;
const { pts, cols } = buildProfile(nodes);
const geo = new THREE.LatheGeometry(pts, segs);
const pal = [new THREE.Color(0xf1e4cc), new THREE.Color(0xe9b949)]; // 0 바탕 · 1 금 띠
const n = pts.length;
const col = new Float32Array((segs + 1) * n * 3);
for (let i = 0; i <= segs; i++)
  for (let j = 0; j < n; j++) pal[cols[j]!]!.toArray(col, (i * n + j) * 3);
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
const mesh = new THREE.Mesh(geo, new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.32, clearcoat: 0.8, side: THREE.DoubleSide }));
```

## 흔한 실수 · 확인 목록
- [ ] **윤곽이 축에서 시작 · 끝나지 않으면 바닥 · 꼭대기에 구멍이 뚫린다** — 첫 점과 마지막 점의 x 를 0 으로 둔다 (견본 L(0, 0, 0) 로 시작).
- [ ] **모서리 둥글기를 크게 주면 짧은 변에서 곡선이 뒤집혀 꼬인다** — 둥글기를 양쪽 변 길이의 45% 이하로 자른다.
- [ ] **LatheGeometry 는 정점 순서가 (둘레 i, 윤곽 j) 라 색 배열 순서를 틀리기 쉽다** — 색 인덱스는 (i × 윤곽 점 수 + j). 둘레는 segs + 1 줄이다.

## 완성 기준 체크리스트
- [ ] 노란 윤곽선이 먼저 그려지고, 그 선이 축을 따라 돌며 체스 말 · 화분 · 꽃병이 된다
- [ ] 받침 · 홈 · 구슬 테가 윤곽에서 나오고, 금 띠 색이 구간대로 칠해진다
- [ ] 「둘레 조각 수」를 6 으로 내리면 육각 기둥, 64 면 매끈한 원이 된다
- [ ] 「새로 만들기」마다 비율 · 색이 조금씩 다른 세 가지가 나온다

## 이 기술 정보
- id: `i450` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 윤곽 점 수 × 둘레 조각 수(견본 64) 정점. 말 하나 수천 삼각형 — 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i450
- 쓰면 좋을 때: 체스 말 · 병 · 화분 · 기둥처럼 축 대칭인 모양 / 같은 틀로 크기 · 비율만 바꾼 변형을 많이 만들 때
- 쓰지 말 때: 한쪽으로 휘거나 비틀린 모양 — 대신 스윕(i449) / 각진 옆모습을 두께로 민 모양 — 대신 윤곽 돌출(i468)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### latheDemo — `src/demos/demosModelB.ts:745`
```ts
function latheDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = bgTex('#1b2140', '#3c4a7a');
  scene.background = bg;
  addLights(scene, { sky: 0xe4ecff, ground: 0x5a4a40, hemi: 1.0, sun: 2.4, dir: [-3, 6, 4], ext: 3 });
  const env = new Env();
  const camera = new THREE.PerspectiveCamera(36, 1.6, 0.1, 50);
  // 체스판 바닥
  const cc = document.createElement('canvas');
  cc.width = cc.height = 128;
  const g2 = cc.getContext('2d')!;
  for (let i = 0; i < 2; i++)
    for (let j = 0; j < 2; j++) {
      g2.fillStyle = (i + j) % 2 ? '#93b3a0' : '#efe4cf';
      g2.fillRect(i * 64, j * 64, 64, 64);
    }
  const ct = new THREE.CanvasTexture(cc);
  ct.colorSpace = THREE.SRGBColorSpace;
  ct.wrapS = ct.wrapT = THREE.RepeatWrapping;
  ct.repeat.set(6, 3);
  ct.anisotropy = 4;
  const board = new THREE.Mesh(new THREE.PlaneGeometry(4.8, 2.4), new THREE.MeshStandardMaterial({ map: ct, roughness: 0.55 }));
  board.rotation.x = -PI / 2;
  board.receiveShadow = true;
  const frame = new THREE.Mesh(new THREE.BoxGeometry(5.04, 0.14, 2.64), new THREE.MeshStandardMaterial({ color: 0x7a4e33, roughness: 0.5 }));
  frame.position.y = -0.071;
  frame.receiveShadow = true;
  scene.add(board, frame);

  const gold = new THREE.MeshPhysicalMaterial({ color: 0xf0c050, metalness: 0.7, roughness: 0.28, clearcoat: 0.5 });
  const lineMat = new THREE.MeshBasicMaterial({ color: 0xffd84a, toneMapped: false, transparent: true });
  const tipMat = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true });
  const axisMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.35, toneMapped: false });

  let pieces: Piece[] = [];
  let seed = 7;
  let segs = 64;
  let anim = true;
  let showLine = true;
  let wire = false;
  let deco = true;
  let buildMs = 0;
  const tags = new Tags(2, ['rgba(12,16,34,0.72)', 'rgba(70,40,120,0.78)']);

  const job = new Job();
  const warm = new Warm();
  function build(): void {
    for (const p of pieces) {
      scene.remove(p.root);
      freeAll(p.root);
    }
    pieces = [];
    job.start(buildG(pieces));
  }
  // 말 하나씩 나눠 만든다 (프레임마다 3ms)
  function* buildG(list: Piece[]): G {
    const rng = rngOf(seed);
    const specs = [chessSpec(rng), potSpec(rng), vaseSpec(rng)];
    for (let k = 0; k < specs.length; k++) {
      const sp = specs[k]!;
      const sx = 0.92 + rng() * 0.16;
      const sy = 0.92 + rng() * 0.16;
      const { pts, cols } = sp.prof.build(sx, sy);
      const geo = new THREE.LatheGeometry(pts, segs);
      if (late()) yield;
      const n = pts.length;
      const pal = sp.palette.map((c) => new THREE.Color(c));
      const col = new Float32Array((segs + 1) * n * 3);
      for (let i = 0; i <= segs; i++)
        for (let j = 0; j < n; j++) {
          const c = pal[cols[j]!] ?? pal[0]!;
          const o = (i * n + j) * 3;
          col[o] = c.r;
          col[o + 1] = c.g;
          col[o + 2] = c.b;
        }
      geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
      if (late()) yield;
      const mat = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: sp.rough, clearcoat: sp.coat, clearcoatRoughness: 0.2, side: THREE.DoubleSide, wireframe: wire });
      const mesh = new THREE.Mesh(geo, mat);
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      const root = new THREE.Group();
      root.position.set((k - 1) * 1.45, 0, k === 1 ? 0.15 : 0);
      root.add(mesh);
      // 윤곽선 (phi = 0 자리) — 그려지고, 돌아간다
      const line = new THREE.Group();
      const curve = new THREE.CatmullRomCurve3(
        pts.map((q) => new THREE.Vector3(0, q.y, q.x)),
        false,
        'centripetal',
      );
      const tubeSegs = 240;
      const tube = new THREE.Mesh(new THREE.TubeGeometry(curve, tubeSegs, 0.018, 6), lineMat);
      const tip = new THREE.Mesh(new THREE.SphereGeometry(0.045, 12, 8), tipMat);
      line.add(tube, tip);
      if (late()) yield;
      const H = pts[n - 1]!.y;
      const axis = new THREE.Mesh(new THREE.CylinderGeometry(0.005, 0.005, H + 0.3, 6), axisMat);
      axis.position.y = (H + 0.3) / 2 - 0.05;
      root.add(line, axis);
      if (late()) yield;
      const decos = sp.deco(pts, gold, rng);
      for (const d of decos) {
        d.userData.s = d.scale.clone();
        root.add(d);
      }
      if (list !== pieces) {
        freeAll(root);
        return;
      }
      warm.add(root, scene, () => list === pieces);
      pieces.push({ root, mesh, line, tube, tip, axis, decos, perSeg: (n - 1) * 6, segs, tubeSegs, tris: segs * (n - 1) * 2, npts: n, mat });
      (tube.userData as { curve?: THREE.Curve<THREE.Vector3> }).curve = curve;
      if (late()) yield;
    }
    buildMs = job.spent;
  }
  build();

  const CYC = 6.6;
  let stage = 0;
  return {
    scene,
    camera,
    update(t) {
      if (job.busy) job.step();
      camera.position.set(Math.sin(t * 0.2) * 0.6, 1.95, 5.0);
      camera.lookAt(0, 0.68, 0);
      pieces.forEach((p, k) => {
        const lt = anim ? (t + 100 * CYC - k * 0.35) % CYC : 4.5;
        if (k === 0) stage = lt < 1.45 ? 0 : lt < 3.05 ? 1 : 2;
        const drawP = smooth(0.1, 1.4, lt);
        const sweep = easeIO(sat((lt - 1.5) / 1.5));
        const g = p.mesh.geometry;
        const cnt = Math.ceil(sweep * p.segs) * p.perSeg;
        g.setDrawRange(0, cnt);
        p.mesh.visible = cnt > 0;
        const tg = p.tube.geometry;
        tg.setDrawRange(0, Math.floor(drawP * p.tubeSegs) * 36);
        p.line.rotation.y = sweep * TAU;
        const lineA = 1 - smooth(3.0, 3.5, lt);
        p.line.visible = showLine && lineA > 0.01;
        p.axis.visible = showLine && lineA > 0.01;
        lineMat.opacity = 1;
        p.tip.visible = drawP > 0 && drawP < 1;
        const curve = (p.tube.userData as { curve?: THREE.Curve<THREE.Vector3> }).curve;
        if (curve && p.tip.visible) p.tip.position.copy(curve.getPointAt(Math.min(drawP, 1)));
        p.decos.forEach((d, i) => {
          const s = deco ? easeBack(sat((lt - 3.1 - i * 0.05) / 0.35)) : 0;
          const b = d.userData.s as THREE.Vector3;
          d.scale.set(b.x * s + 1e-4, b.y * s + 1e-4, b.z * s + 1e-4);
          d.visible = s > 0.001;
        });
        const sh = lt > CYC - 0.45 ? 1 - easeIO((lt - (CYC - 0.45)) / 0.45) : 1;
        p.root.scale.setScalar(Math.max(0.001, sh));
        p.root.rotation.y = t * 0.25 + k;
      });
      tags.set(0, `삼각형 ${fmt(pieces.reduce((a, p) => a + p.tris, 0))} · 윤곽 점 ${pieces.reduce((a, p) => a + p.npts, 0)}개 · 만들기 ${buildMs.toFixed(1)}ms`);
      tags.set(1, ['① 윤곽선 한 줄 그리기', '② 축을 따라 360° 돌리기', '③ 장식 달기 — 테 · 홈 · 받침은 윤곽에서'][stage]!);
    },
    render(r, w, h) {
      env.apply(r, scene, 0.45);
      if (warm.tick(r, scene, camera, 0x2a3358)) shadowed(r, () => r.render(scene, camera));
      tags.draw(r, w, h, [
        [0.02, 0.06, -1],
        [0.5, 0.92, 0],
      ]);
    },
    controls: [
      { type: 'toggle', label: '과정 보기 (그리기 → 돌리기)', value: true, on: (v) => (anim = v) },
      { type: 'toggle', label: '윤곽선 보기', value: true, on: (v) => (showLine = v) },
      { type: 'toggle', label: '장식 달기', value: true, on: (v) => (deco = v) },
      {
        type: 'toggle',
        label: '와이어프레임',
        value: false,
        on: (v) => {
          wire = v;
          for (const p of pieces) p.mat.wireframe = v;
        },
      },
      {
        type: 'range',
        label: '둘레 조각 수',
        min: 6,
        max: 96,
        step: 2,
        value: 64,
        on: (v) => {
          segs = v;
          build();
        },
      },
      {
        type: 'button',
        label: '새로 만들기',
        on: () => {
          seed = (seed * 7919 + 13) % 100000;
          build();
        },
      },
    ] as Control[],
    dispose() {
      for (const p of pieces) freeAll(p.root);
      freeAll(board);
      freeAll(frame);
      ct.dispose();
      gold.dispose();
      lineMat.dispose();
      tipMat.dispose();
      axisMat.dispose();
      bg.dispose();
      env.dispose();
      tags.dispose();
    },
  };
}
```

### i450 견본 항목 — `src/demos/demosModelB.ts:3143`
```ts
  i450: { kind: '3d', caption: '윤곽선 한 줄을 그려 축으로 돌리면 체스 말 · 화분 · 꽃병 — 장식은 윤곽에서', make: () => latheDemo() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [스윕 · 로프트 (곡선 따라 단면 끌기)](https://ai-techstudio.web.app/ai/t/i449.md) `i449`
- 다음에 해 볼 기술: [스윕 · 로프트 (곡선 따라 단면 끌기)](https://ai-techstudio.web.app/ai/t/i449.md) `i449` · [옆모습 윤곽 돌출 + 모서리 깎기 (베벨)](https://ai-techstudio.web.app/ai/t/i468.md) `i468` · [GPU 경로 추적 (사진 같은 렌더 · 샘플 쌓기)](https://ai-techstudio.web.app/ai/t/i545.md) `i545`
- 참고 문서: [three.js 문서 — LatheGeometry](https://threejs.org/docs/#api/en/geometries/LatheGeometry)
