# AI 꾸러미 — 스윕 · 로프트 (곡선 따라 단면 끌기) — Sweep / loft along a curve
> 단면 모양을 곡선을 따라 끌고 가며 크기 · 비틀림 · 납작함을 바꿔, 뿔 · 덩굴 · 달팽이 껍데기 같은 관 모양을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i449

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

## 주문서

### 만들어 줘: 스윕 · 로프트 (곡선 따라 단면 끌기) — Sweep / loft along a curve

#### 1. 목표
비틀린 별 단면 뿔을 스윕으로 만들어 줘 — 단면이 곡선을 따라가며 굵기 · 비틀림 · 모양이 바뀌게. 분위기는 반짝이는 동화.

#### 2. 핵심 기술 용어
- **Sweep / loft along a curve** — 곡선을 따라 단면 끌기
- **Parallel transport frame** — 비틀림 없이 곡선을 따라가는 방향 틀
- **CatmullRomCurve3.getSpacedPoints** — 고른 간격으로 곡선 위 점 뽑기
- **Profile function radius(u) · twist(u)** — 진행 정도 u 에 따른 굵기 · 비틀림

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

#### 4. 조건
- 방향 틀은 평행 이동 방식 (이전 N 을 이어받기) — 점마다 위쪽 벡터로 새로 잡으면 곡선이 수직일 때 뒤집힌다
- 곡선 점은 getSpacedPoints 로 고른 간격
- 굵기 · 비틀림 · 단면을 u(0~1) 함수로 받아, 같은 함수 하나로 여러 모양
- 시작 끝은 뚜껑(가운데 점 하나)으로 막기
- 진행 정도 슬라이더로 단면이 곡선을 따라 끌려가는 모습 (앞머리 테 표시)

#### 5. 완성 기준 (이게 보이면 성공)
- 빛나는 테(단면)가 곡선을 따라가며 뒤에 관이 남는다
- 별 단면 뿔은 끝으로 갈수록 가늘어지며 비틀리고, 덩굴은 막대를 감아 오르고, 달팽이는 나선으로 말린다
- 「뿔 비틀림(바퀴)」 0 ~ 6 · 「단면 꽃잎 수」 0 ~ 8 · 「가늘어짐(지수)」 슬라이더로 뿔 모양이 바로 바뀐다
- 관이 어디서도 갑자기 뒤집혀 꼬이지 않는다

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

## 원리
- 곡선을 고른 간격 점 n 개로 나누고, 점마다 진행 방향 T 를 앞뒤 점 차이로 구한다.
- 첫 점에서 위쪽(0,1,0)을 T 에 수직으로 맞춘 N 을 잡고, 다음 점부터는 이전 N 에서 T 성분만 빼고 다시 맞춘다 (평행 이동 틀 — 갑자기 뒤집히지 않는다).
- B = T × N. 단면 각 a 의 점 = 중심 + N·cos a·r·sx + B·sin a·r·sy.
- 굵기 r(u), 비틀림 twist(u), 단면 모양 section(a, u), 납작함 sxy(u) 를 진행 정도 u 함수로 주면 뿔 · 별 · 나선이 된다.
- 링 사이를 삼각형 둘씩 이어 관을 만들고, drawRange 로 진행 정도만큼만 그려 「끌고 가는」 모습을 보여 준다.

## 핵심 코드 — 평행 이동 틀로 단면 끌기
(발췌: demos/demosModelA.ts buildSweep() 을 정리)
```ts
function buildSweep(pts: THREE.Vector3[], radial: number, radius: (u: number) => number,
  section?: (a: number, u: number) => number, twist?: (u: number) => number): THREE.BufferGeometry {
  const n = pts.length, TAU = Math.PI * 2;
  const pos = new Float32Array(n * radial * 3);
  const idx: number[] = [];
  const T = new THREE.Vector3(), N = new THREE.Vector3(), B = new THREE.Vector3();
  for (let i = 0; i < n; i++) {
    const u = i / (n - 1), p = pts[i]!;
    T.subVectors(pts[Math.min(n - 1, i + 1)]!, pts[Math.max(0, i - 1)]!).normalize();
    if (i === 0) {
      N.set(0, 1, 0).addScaledVector(T, -T.y);
      if (N.lengthSq() < 1e-4) N.set(1, 0, 0).addScaledVector(T, -T.x);
      N.normalize();
    } else N.addScaledVector(T, -N.dot(T)).normalize(); // 이전 N 을 이어받는다 = 뒤집힘 없음
    B.crossVectors(T, N);
    const r = radius(u), tw = twist ? twist(u) : 0;
    for (let j = 0; j < radial; j++) {
      const a = (j / radial) * TAU;
      const rr = r * (section ? section(a - tw, u) : 1);
      const ca = Math.cos(a) * rr, sa = Math.sin(a) * rr, k = (i * radial + j) * 3;
      pos[k] = p.x + N.x * ca + B.x * sa;
      pos[k + 1] = p.y + N.y * ca + B.y * sa;
      pos[k + 2] = p.z + N.z * ca + B.z * sa;
    }
  }
  for (let i = 0; i < n - 1; i++)
    for (let j = 0; j < radial; j++) {
      const a = i * radial + j, a1 = i * radial + ((j + 1) % radial);
      idx.push(a, a1, a + radial, a1, a1 + radial, a + radial);
    }
  const g = new THREE.BufferGeometry();
  g.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  g.setIndex(idx);
  g.computeVertexNormals();
  return g;
}
// 견본 뿔: radius = u => 0.25 * Math.pow(1 - u, 0.9) + 0.003, section = a => 1 + 0.15 * Math.cos(5 * a), twist = u => 3 * Math.PI * 2 * u
// 진행 보기: geo.setDrawRange(0, segs * radial * 6);
```

## 흔한 실수 · 확인 목록
- [ ] **점마다 위쪽 벡터로 틀을 새로 잡으면 곡선이 서는 곳에서 관이 휙 뒤집힌다** — 첫 점만 위쪽으로 잡고, 다음부터는 이전 N 에서 T 성분을 빼서 이어받는다 (평행 이동).
- [ ] **곡선 점 간격이 고르지 않으면 굵기 변화가 울퉁불퉁하다** — getPoints 대신 getSpacedPoints 로 호 길이가 고른 점을 뽑는다.
- [ ] **끝이 뚫려 안이 보인다** — 시작 끝에 가운데 점 하나 + 부채꼴 삼각형으로 뚜껑을 만든다. 가늘어지는 끝은 굵기를 0 가까이.

## 완성 기준 체크리스트
- [ ] 빛나는 테(단면)가 곡선을 따라가며 뒤에 관이 남는다
- [ ] 별 단면 뿔은 끝으로 갈수록 가늘어지며 비틀리고, 덩굴은 막대를 감아 오르고, 달팽이는 나선으로 말린다
- [ ] 「뿔 비틀림(바퀴)」 0 ~ 6 · 「단면 꽃잎 수」 0 ~ 8 · 「가늘어짐(지수)」 슬라이더로 뿔 모양이 바로 바뀐다
- [ ] 관이 어디서도 갑자기 뒤집혀 꼬이지 않는다

## 이 기술 정보
- id: `i449` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 한 번 짓는 모양이라 가볍다. 링 수 × 단면 점 수 정점 (예: 120 × 24 ≈ 3천).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i449
- 쓰면 좋을 때: 뿔 · 꼬리 · 줄기 · 밧줄 · 관처럼 길쭉하고 휘는 모양 / 굵기 · 단면이 길이 따라 바뀌어야 할 때 (TubeGeometry 는 굵기가 한 가지)
- 쓰지 말 때: 곧은 축으로 돌린 모양(병 · 체스 말) — 대신 선반 회전체(i450) / 굵기가 일정한 관 — TubeGeometry 로 충분

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### demoSweep — `src/demos/demosModelA.ts:2524`
```ts
function demoSweep(): Scene3D {
  const st = new Stage({ cam: [0, 1.55, 5.3], look: [0, 0.75, 0], island: 1.7 });
  const mat = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.38, clearcoat: 0.6, clearcoatRoughness: 0.25, side: THREE.DoubleSide, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
  const hornMat = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.28, clearcoat: 0.9, iridescence: 0.5, iridescenceIOR: 1.4, polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 });
  const capMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.4, side: THREE.DoubleSide, emissive: 0x223344 });
  const ringMat = new THREE.LineBasicMaterial({ color: 0xaef4ff, toneMapped: false, transparent: true, depthTest: false });
  const pathMat = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 0.05, gapSize: 0.04, transparent: true, opacity: 0.55, toneMapped: false });
  const S = { auto: true, prog: 1, turns: 3, lobes: 5, taper: 0.9, path: true, seed: 1 };
  const W = st.spin;
  const V = (): SweepView => new SweepView(st, W, mat, capMat, ringMat, pathMat);
  const horn = new SweepView(st, W, hornMat, capMat, ringMat, pathMat);
  const vine = V();
  const flower = V();
  const shell = V();
  const body = V();
  const stalks = [V(), V()];
  // 고정 소품: 방석 · 금 고리 · 막대 · 눈알
  const props = new THREE.Group();
  W.add(props);
  const P2 = (x: number, y: number): THREE.Vector2 => new THREE.Vector2(x, y);
  const cushion = new THREE.Mesh(new THREE.LatheGeometry([P2(0.001, 0), P2(0.38, 0.0), P2(0.47, 0.06), P2(0.45, 0.14), P2(0.3, 0.19), P2(0.001, 0.2)], 40), new THREE.MeshStandardMaterial({ color: 0xff8fb8, roughness: 0.6 }));
  const gold = new THREE.MeshStandardMaterial({ color: 0xffc94a, metalness: 1, roughness: 0.25 });
  const ringG = new THREE.Mesh(new THREE.TorusGeometry(0.26, 0.045, 12, 40).rotateX(Math.PI / 2), gold);
  ringG.position.y = 0.2;
  const hornBase = new THREE.Group();
  hornBase.add(cushion, ringG);
  hornBase.position.set(0.05, 0, -0.35);
  const stake = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.05, 1.75, 10).translate(0, 0.875, 0), new THREE.MeshStandardMaterial({ color: 0xa2774c, roughness: 0.85 }));
  const stakeTip = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.1, 10).translate(0, 1.8, 0), stake.material);
  const vineBase = new THREE.Group();
  vineBase.add(stake, stakeTip);
  vineBase.position.set(-1.0, 0, -0.15);
  const snail = new THREE.Group();
  snail.position.set(0.95, 0, 0.55);
  snail.rotation.y = 0.4;
  props.add(hornBase, vineBase, snail);
  for (const o of [cushion, ringG, stake, stakeTip]) o.castShadow = o.receiveShadow = true;
  const eyeBalls: THREE.Group[] = [];
  for (let s = 0; s < 2; s++) {
    const g = new THREE.Group();
    const w = new THREE.Mesh(new THREE.SphereGeometry(0.06, 14, 10), new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.3 }));
    const p = new THREE.Mesh(new THREE.SphereGeometry(0.034, 12, 8), new THREE.MeshPhysicalMaterial({ color: 0x14100c, roughness: 0.1, clearcoat: 1 }));
    p.position.set(-0.02, 0.005, 0.035);
    g.add(w, p);
    snail.add(g);
    eyeBalls.push(g);
  }
  // 덩굴 잎 (하트 잎 카드)
  const leafG = leafCardGeo(true);
  const leaves = st.model(new THREE.InstancedMesh(leafG, new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.55, side: THREE.DoubleSide }), 24), false);
  vineBase.add(leaves);
  let leafU: { u: number; m: THREE.Matrix4 }[] = [];
  // 스윕 다시 만들기
  for (const sv of [vine, flower, shell, body, ...stalks]) {
    sv.mesh.removeFromParent();
    sv.cap.removeFromParent();
    sv.ring.removeFromParent();
    sv.path.removeFromParent();
  }
  for (const sv of [vine, flower]) vineBase.add(sv.mesh, sv.cap, sv.ring, sv.path);
  for (const sv of [shell, body, ...stalks]) snail.add(sv.mesh, sv.cap, sv.ring, sv.path);
  for (const o of [horn.mesh, horn.cap, horn.ring, horn.path]) hornBase.add(o);
  let ms = 0;
  const cA = new THREE.Color(0xc9b6ff), cB = new THREE.Color(0xffb3d9), cC = new THREE.Color(0xfff0c8);
  const buildHorn = (): void => {
    const R0 = rng(S.seed);
    const bend = 0.1 + R0() * 0.25;
    horn.set(buildSweep({
      pts: crPts([[0, 0.2, 0], [0.02, 0.6, 0], [bend * 0.5, 1.05, 0.02], [bend, 1.5, 0.05]], 120),
      radial: 48,
      radius: (u) => 0.25 * Math.pow(1 - u, S.taper) + 0.003,
      section: (a) => (S.lobes ? 1 + 0.15 * Math.cos(S.lobes * a) : 1),
      twist: (u) => S.turns * TAU * u,
      color: (u, a, o) => {
        if (u < 0.5) o.copy(cA).lerp(cB, u * 2);
        else o.copy(cB).lerp(cC, (u - 0.5) * 2);
        if (S.lobes) o.multiplyScalar(0.82 + 0.18 * (0.5 + 0.5 * Math.cos(S.lobes * a)));
      },
      startCap: true,
    }));
  };
  const build = (): void => {
    const t0 = performance.now();
    const R0 = rng(S.seed * 3 + 1);
    buildHorn();
    // 덩굴: 막대를 감고 오르는 나선
    const turns = 2.2 + R0() * 0.8;
    const vp: V3[] = [];
    for (let i = 0; i < 150; i++) {
      const u = i / 149;
      const a = turns * TAU * u;
      const r = 0.085 + 0.012 * Math.sin(a * 3);
      vp.push(new THREE.Vector3(Math.cos(a) * r, 0.02 + 1.5 * u, Math.sin(a) * r));
    }
    const g0 = new THREE.Color(0x4f9a3a), g1 = new THREE.Color(0x8fd16a);
    vine.set(buildSweep({ pts: vp, radial: 10, radius: (u) => 0.03 * (1 - u * 0.45), color: (u, _a, o) => o.copy(g0).lerp(g1, u), startCap: true }));
    leafU = [];
    const m4 = new THREE.Matrix4();
    for (let u = 0.1; u < 0.93; u += 0.075) {
      const i = Math.round(u * 149);
      const p = vp[i]!;
      const out = new THREE.Vector3(p.x, 0, p.z).normalize();
      const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), out.clone().multiplyScalar(0.8).add(new THREE.Vector3(0, 0.6, 0)).normalize());
      q.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), R0() * 0.8 - 0.4));
      const s = 0.17 + R0() * 0.07;
      leafU.push({ u, m: m4.clone().compose(p.clone().addScaledVector(out, 0.02), q, new THREE.Vector3(s, s, s)) });
    }
    leaves.count = leafU.length;
    leafU.forEach((_l, i) => leaves.setColorAt(i, new THREE.Color(0x5fb34a).offsetHSL(0, 0, (i % 3) * 0.04)));
    // 나팔꽃: 덩굴 끝에서 벌어지는 로프트
    const E = vp[vp.length - 1]!;
    const dir = new THREE.Vector3(E.x, 0, E.z).normalize();
    const fp = crPts([[E.x, E.y, E.z], [E.x + dir.x * 0.08, E.y + 0.07, E.z + dir.z * 0.08], [E.x + dir.x * 0.17, E.y + 0.18, E.z + dir.z * 0.17]], 40);
    const fA = new THREE.Color(0xfff6d0), fB = new THREE.Color(0x7d6bff), fW = new THREE.Color(0xffffff);
    flower.set(buildSweep({
      pts: fp,
      radial: 60,
      radius: (u) => 0.022 + 0.25 * Math.pow(u, 2.3),
      section: (a, u) => 1 + 0.09 * u * Math.cos(5 * a),
      color: (u, a, o) => {
        o.copy(fA).lerp(fB, ease(clamp((u - 0.3) / 0.6, 0, 1)));
        const star = Math.pow(0.5 + 0.5 * Math.cos(5 * a), 6);
        o.lerp(fW, star * 0.55 * u);
      },
    }));
    // 달팽이 껍데기: 로그 나선, 고리가 맞닿게 굵기 = 반지름 × 0.36
    const sp: V3[] = [];
    const th1 = 3.6 * Math.PI;
    for (let i = 0; i < 140; i++) {
      const th = (i / 139) * th1;
      const r = 0.055 * Math.exp(0.18 * th);
      sp.push(new THREE.Vector3(0.05 + Math.cos(-th + 2.2) * r, 0.5 + Math.sin(-th + 2.2) * r, 0.02 * Math.sin(th * 0.5)));
    }
    const s0 = new THREE.Color(0xe7a35a), s1 = new THREE.Color(0x9c5a32), s2 = new THREE.Color(0xfbe1b0);
    shell.set(buildSweep({
      pts: sp,
      radial: 28,
      radius: (u) => 0.055 * Math.exp(0.18 * u * th1) * 0.37 * (1 + 0.03 * Math.sin(u * 90)),
      color: (u, a, o) => {
        const band = 0.5 + 0.5 * Math.cos(a * 3);
        o.copy(s0).lerp(s1, band * 0.6).lerp(s2, Math.max(0, Math.cos(a)) * 0.35);
        o.multiplyScalar(0.92 + 0.08 * Math.sin(u * 90));
      },
      startCap: true,
    }));
    // 달팽이 몸: 납작한 꼬리 → 둥근 머리 (단면 크기 · 납작함이 바뀌는 로프트)
    const bp = crPts([[0.62, 0.05, 0], [0.3, 0.09, 0], [-0.1, 0.1, 0], [-0.42, 0.13, 0], [-0.6, 0.3, 0], [-0.64, 0.5, 0]], 70);
    const b0 = new THREE.Color(0xbfe08a), b1 = new THREE.Color(0xf3f8c8);
    body.set(buildSweep({
      pts: bp,
      radial: 28,
      radius: (u) => {
        const base = u < 0.15 ? lerp(0.02, 0.15, ease(u / 0.15)) : u < 0.7 ? 0.15 : u < 0.8 ? lerp(0.15, 0.12, (u - 0.7) / 0.1) : 0.12 + 0.02 * Math.sin(((u - 0.8) / 0.2) * Math.PI);
        return u > 0.88 ? base * Math.sqrt(Math.max(0.02, 1 - ((u - 0.88) / 0.12) ** 2)) : base;
      },
      sxy: (u) => (u < 0.65 ? [0.62, 1.25] : [lerp(0.62, 1, ease((u - 0.65) / 0.2)), lerp(1.25, 1, ease((u - 0.65) / 0.2))]),
      color: (_u, a, o) => o.copy(b0).lerp(b1, Math.max(0, -Math.cos(a)) * 0.8),
      startCap: true,
    }));
    [-1, 1].forEach((s, k) => {
      const tip = new THREE.Vector3(-0.72, 0.86, s * 0.11);
      stalks[k]!.set(buildSweep({ pts: crPts([[-0.62, 0.58, s * 0.04], [-0.66, 0.72, s * 0.07], [tip.x, tip.y, tip.z]], 20), radial: 10, radius: (u) => 0.026 - u * 0.008, color: (_u, _a, o) => o.copy(b0), startCap: true }));
      eyeBalls[k]!.position.copy(tip);
    });
    ms = performance.now() - t0;
  };
  build();
  const m4 = new THREE.Matrix4();
  const sc = new THREE.Matrix4();
  return st.out({
    update(t) {
      let k = S.prog * 6;
      if (S.auto) {
        const T = t % 9;
        k = T < 6.2 ? T : T < 8.3 ? 6.2 : 6.2 * (1 - ease((T - 8.3) / 0.7));
      }
      const pr = (a: number, b: number): number => clamp((k - a) / (b - a), 0, 1);
      horn.progress(pr(0.5, 3.4), S.path);
      vine.progress(pr(0.1, 3.0), S.path);
      flower.progress(pr(3.0, 4.0), S.path);
      body.progress(pr(0.2, 1.7), S.path);
      shell.progress(pr(1.6, 4.4), S.path);
      stalks[0]!.progress(pr(1.7, 2.3), false);
      stalks[1]!.progress(pr(1.7, 2.3), false);
      const ep = easeBack(pr(2.2, 2.7));
      for (const e of eyeBalls) e.scale.setScalar(Math.max(1e-3, ep));
      const vp = pr(0.1, 3.0);
      leafU.forEach((l, i) => {
        const s = Math.max(1e-3, easeBack((vp - l.u) / 0.12));
        sc.makeScale(s, s, s);
        leaves.setMatrixAt(i, m4.copy(l.m).multiply(sc));
      });
      leaves.instanceMatrix.needsUpdate = true;
      st.spin.rotation.y = Math.sin(t * 0.35) * 0.45;
      st.hud.tl.set(`▲ 삼각형 ${fmt(st.tris())}`);
      st.hud.bl.set(`단면 × 경로 · 뿔 비틀림 ${S.turns}바퀴 · 꽃잎 ${S.lobes} · ${ms.toFixed(0)}ms`);
    },
    controls: [
      { type: 'toggle', label: '자동 재생 (끌어 만들기)', value: true, on: (v) => (S.auto = v) },
      { type: 'range', label: '진행', min: 0, max: 1, step: 0.01, value: 1, on: (v) => ((S.prog = v), (S.auto = false)) },
      { type: 'range', label: '뿔 비틀림 (바퀴)', min: 0, max: 6, step: 0.5, value: 3, on: (v) => ((S.turns = v), buildHorn()) },
      { type: 'range', label: '단면 꽃잎 수 (0 = 동그라미)', min: 0, max: 8, step: 1, value: 5, on: (v) => ((S.lobes = v), buildHorn()) },
      { type: 'range', label: '가늘어짐 (지수)', min: 0.3, max: 2.2, step: 0.1, value: 0.9, on: (v) => ((S.taper = v), buildHorn()) },
      { type: 'toggle', label: '경로 점선 보기', value: true, on: (v) => (S.path = v) },
      { type: 'toggle', label: '와이어프레임', value: false, on: (v) => (st.wire.on = v) },
      { type: 'button', label: '새로 만들기 (곡선)', on: () => ((S.seed += 1), build()) },
    ],
    dispose() {
      leafG.dispose();
    },
  });
}
```

### i449 견본 항목 — `src/demos/demosModelA.ts:2745`
```ts
  i449: { kind: '3d', caption: '빛나는 테 = 지금 끌고 가는 단면 — 비틀린 별 단면 뿔 · 감아 오르는 덩굴과 나팔꽃 · 나선 달팽이', make: demoSweep }
```

## 관련 기술
- 먼저 알면 좋은 기술: [선반 회전체 + 장식 (윤곽선 하나로)](https://ai-techstudio.web.app/ai/t/i450.md) `i450`
- 다음에 해 볼 기술: [선반 회전체 + 장식 (윤곽선 하나로)](https://ai-techstudio.web.app/ai/t/i450.md) `i450` · [옆모습 윤곽 돌출 + 모서리 깎기 (베벨)](https://ai-techstudio.web.app/ai/t/i468.md) `i468` · [GPU 경로 추적 (사진 같은 렌더 · 샘플 쌓기)](https://ai-techstudio.web.app/ai/t/i545.md) `i545`
- 참고 문서: [three.js 문서 — TubeGeometry](https://threejs.org/docs/#api/en/geometries/TubeGeometry)
