# AI 꾸러미 — 분해도 (exploded view) — Exploded view (assembly animation)
> 부품마다 「제자리」와 「끼우는 축 방향의 펼친 자리」를 정해 조립 순서대로 미끄러뜨려, 기계가 분해됐다 다시 조립되는 분해도를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i36

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

## 주문서

### 만들어 줘: 분해도 (exploded view) — Exploded view (assembly animation)

#### 1. 목표
톱니 상자 (축 · 와셔 · 톱니 · 덮개 · 나사 · 손잡이)을(를) 분해도로 보여 줘 — 부품이 조립 순서의 반대로 끼우는 축(점선)을 따라 펼쳐졌다가 다시 조립되고, 펼친 상태에선 번호 이름표가 붙게. 분위기는 밝은 제품 설명서.

#### 2. 핵심 기술 용어
- **Exploded view (assembly animation)** — 분해도 — 부품을 축 따라 펼치기
- **Staggered sequencing (smoothstep per step)** — 단계마다 시간을 엇갈려 차례로 움직이기
- **Assembly axis guide (LineDashedMaterial)** — 끼우는 축을 점선으로 표시
- **Screw motion (translate + rotate)** — 나사는 올라가는 만큼 돈다

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

#### 4. 조건
- 부품은 끼우는 축 방향으로만 움직인다 (축을 점선으로 보여 준다)
- 분해는 조립의 거꾸로 — 마지막에 끼운 것부터 빠진다
- 맞물리는 부품(톱니 · 나사)은 실제 비율대로 함께 돈다
- 카메라는 다 펼친 모습이 들어오게 거리를 정한다 (펼칠 때 화면 밖으로 나가지 않게)

#### 5. 완성 기준 (이게 보이면 성공)
- 조립된 톱니 상자가 손잡이 → 나사(돌며 빠짐) → 덮개 → 톱니 → 와셔 → 축 순서로 펼쳐진다
- 다 펼치면 「1. 축 2개 … 6. 손잡이 · 마개」 이름표와 지시선이 붙는다
- 다시 조립되고, 다 조이면 손잡이가 돌며 큰 톱니 1바퀴에 작은 톱니 1.8바퀴
- 「조립 ↔ 분해」 슬라이더로 아무 단계에서나 멈춰 볼 수 있다

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

## 원리
- 단계 = 조립 순서 (축 → 와셔 → 톱니 → 덮개 → 나사 → 손잡이). 단계마다 부품 목록과 「펼친 방향 · 거리」(off) 하나.
- 진행 값 u(0 ~ 단계 수)에서 단계 i 의 진행 f = smoothstep(u − (N − 1 − i)) — 분해는 마지막에 끼운 것부터 빠진다.
- 부품 위치 = 제자리 + off × f. 끼우는 방향으로만 움직이니 「어디에 어떻게 끼우나」가 그대로 보인다.
- 나사는 올라가는 만큼 돈다(f × 6바퀴). 다 조립됐을 때만 손잡이가 돌고 톱니가 잇수비(18 : 10)대로 맞물려 돈다.
- 다 펼치면 단계 이름표를 3D 자리에서 화면으로 투영해 지시선과 함께 붙인다 (번호 = 조립 순서).

## 핵심 코드 — 단계별로 축을 따라 펼치기 · 나사 돌리기
(발췌: demos/demosStructure.ts i36 step() · put() · update() 를 정리)
```ts
import * as THREE from 'three';

const smooth = (x: number) => { const k = Math.min(1, Math.max(0, x)); return k * k * (3 - 2 * k); };
type Part = { o: THREE.Object3D; home: THREE.Vector3 };
interface Step { name: string; off: THREE.Vector3; parts: Part[]; spin?: number }
const steps: Step[] = [];                       // 조립 순서대로 넣는다

function step(name: string, off: [number, number, number], spin?: number): Step {
  const s: Step = { name, off: new THREE.Vector3(...off), parts: [], spin };
  steps.push(s);
  return s;
}
function put(s: Step, o: THREE.Object3D, x: number, y: number, z: number, root: THREE.Object3D) {
  o.position.set(x, y, z);
  root.add(o);
  s.parts.push({ o, home: new THREE.Vector3(x, y, z) });
}
// 예: const sShaft = step('축 2개', [0, -1.35, 0]);  const sScrew = step('나사 4개', [0, 2.05, 0], Math.PI * 2 * 6);

/** u: 0 = 다 조립 · N = 다 분해 */
function apply(u: number) {
  const N = steps.length;
  steps.forEach((s, i) => {
    const f = smooth(u - (N - 1 - i));          // 마지막에 끼운 것부터 빠진다
    for (const p of s.parts) {
      p.o.position.copy(p.home).addScaledVector(s.off, f);
      if (s.spin) p.o.rotation.y = -f * s.spin; // 나사: 올라가는 만큼 돈다
    }
  });
}

// 다 조립됐을 때만 톱니가 잇수비대로 맞물려 돈다 (18 : 10)
function turnGears(spin: number, big: THREE.Object3D, small: THREE.Object3D) {
  big.rotation.z = spin;
  small.rotation.z = (-spin * 18) / 10 + Math.PI / 10;   // 반대로 · 1.8배 · 반 이 어긋나 맞물림
}
```

## 흔한 실수 · 확인 목록
- [ ] **부품을 중심에서 사방으로 흩어 놓으면 어디에 끼우는지 모른다** — 부품마다 끼우는 축 방향(off)으로만 미끄러지게 하고, 축을 점선으로 보여 준다.
- [ ] **모든 부품이 한꺼번에 움직이면 순서가 안 보인다** — 단계마다 진행을 1씩 엇갈려(smoothstep(u − 순번)) 하나씩 움직인다.
- [ ] **톱니가 서로 파고들어 보인다** — 작은 톱니를 이 하나 반만큼(π/잇수) 돌려 놓고, 잇수비대로 반대로 돌린다.
- [ ] **이름표를 3D 글자로 붙이면 돌 때 읽기 어렵다** — 3D 자리를 화면 좌표로 투영해 HUD 에 2D 이름표 · 지시선으로 그린다 (i38).

## 완성 기준 체크리스트
- [ ] 조립된 톱니 상자가 손잡이 → 나사(돌며 빠짐) → 덮개 → 톱니 → 와셔 → 축 순서로 펼쳐진다
- [ ] 다 펼치면 「1. 축 2개 … 6. 손잡이 · 마개」 이름표와 지시선이 붙는다
- [ ] 다시 조립되고, 다 조이면 손잡이가 돌며 큰 톱니 1바퀴에 작은 톱니 1.8바퀴
- [ ] 「조립 ↔ 분해」 슬라이더로 아무 단계에서나 멈춰 볼 수 있다

## 이 기술 정보
- id: `i36` · 분류: 기계 · 구조 › 구조 · 조립 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 부품 위치만 바꾼다. 부품 수십 개는 폰도 가볍다 (모양이 많으면 고정 부품은 합쳐 그린다).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i36
- 쓰면 좋을 때: 기계 · 장난감이 어떻게 생겼는지 · 어떻게 조립하는지 보여 줄 때 / 입체를 조각으로 나눠 보여 주는 수학 체험
- 쓰지 말 때: 부품을 그냥 중심에서 사방으로 흩뿌리기 — 어디에 끼우는지 안 보인다. 끼우는 축을 따라 / 부품이 수백 개 — 무리로 묶어 단계를 줄인다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i36 견본 항목 — `src/demos/demosStructure.ts:3388`
```ts
  i36: {
    kind: '3d',
    caption: '톱니 상자를 조립 순서대로 분해했다 다시 조립해요 — 부품은 끼우는 축(점선)을 따라서만 움직이고, 다 조이면 손잡이가 톱니를 돌려요 (분해도)',
    make() {
      const scene = baseScene('#e6ecff', '#fbfcff');
      const root = new THREE.Group();
      scene.add(root);
      const hud = new Hud();
      // ── 치수 (단위 = cm 대신 한 칸) — 톱니 모듈 0.1: 큰 톱니 18개(피치 반지름 0.9) · 작은 톱니 10개(0.5) → 축 사이 1.4
      const BX = -0.55;
      const SX = 0.85;
      const POSTS: [number, number][] = [
        [-1.38, -0.78],
        [1.38, -0.78],
        [-1.38, 0.78],
        [1.38, 0.78],
      ];
      const SHAFT_R = 0.08;
      const COVER_Y = 0.82;
      const COVER_T = 0.1;
      const mDark = std(0x4a5a8a, 0.5, 0.15);
      const mSteel = std(0xe4e9f2, 0.3, 0.35);
      const mPost = std(0xb9c2d3, 0.35, 0.3);
      const mScrew = std(0x8a93a6, 0.35, 0.35);
      const mBrass = std(0xffc23a, 0.35, 0.35);
      const mCopper = std(0xff8a5c, 0.35, 0.3);
      const mRed = std(0xff4f7b, 0.35, 0.1);
      const mHole = std(0x161b29, 0.8, 0.2);

      // 바닥 판 + 축받이 2 + 기둥 4
      const base = new THREE.Group();
      const plate = new THREE.Mesh(new RoundedBoxGeometry(3.2, 0.2, 2.0, 3, 0.06), mDark);
      base.add(plate);
      for (const x of [BX, SX]) {
        const boss = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.22, 0.14, 28), mPost);
        boss.position.set(x, 0.17, 0);
        const hole = new THREE.Mesh(new THREE.CircleGeometry(SHAFT_R + 0.01, 20).rotateX(-Math.PI / 2), mHole);
        hole.position.set(x, 0.241, 0);
        base.add(boss, hole);
      }
      for (const [x, z] of POSTS) {
        const post = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.12, COVER_Y - 0.1, 6), mPost);
        post.position.set(x, 0.1 + (COVER_Y - 0.1) / 2, z);
        const hole = new THREE.Mesh(new THREE.CircleGeometry(0.05, 16).rotateX(-Math.PI / 2), mHole);
        hole.position.set(x, COVER_Y + 0.001, z);
        base.add(post, hole);
      }
      root.add(base);

      // 단계 = 조립 순서. off = 펼친 자리 (제자리에서 축을 따라)
      type Part = { o: THREE.Object3D; home: THREE.Vector3 };
      interface Step {
        name: string;
        how: string;
        off: THREE.Vector3;
        parts: Part[];
        label: THREE.Vector3;
        side: 1 | -1;
        spin?: number;
      }
      const steps: Step[] = [];
      const step = (name: string, how: string, off: [number, number, number], label: [number, number, number], side: 1 | -1, spin?: number): Step => {
        const s: Step = { name, how, off: new THREE.Vector3(...off), parts: [], label: new THREE.Vector3(...label), side, spin };
        steps.push(s);
        return s;
      };
      const put = (s: Step, o: THREE.Object3D, x: number, y: number, z: number): void => {
        o.position.set(x, y, z);
        root.add(o);
        s.parts.push({ o, home: new THREE.Vector3(x, y, z) });
      };

      // ① 축 2개 — 아래에서 축받이 구멍으로 (밑에 턱)
      const sShaft = step('축 2개', '바닥 아래에서 축받이 구멍으로 밀어 올리기', [0, -1.35, 0], [SX + 0.35, 0.5, 0], 1);
      const shaftGeo = new THREE.CylinderGeometry(SHAFT_R, SHAFT_R, 1.25, 20);
      for (const x of [BX, SX]) {
        const g = new THREE.Group();
        const rod = new THREE.Mesh(shaftGeo, mSteel);
        rod.position.y = 0.625;
        const collar = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.15, 0.05, 24), mSteel);
        collar.position.y = -0.025;
        g.add(rod, collar);
        put(sShaft, g, x, -0.1, 0);
      }
      // ② 와셔 2개
      const sWasher = step('와셔 2개', '축에 끼워 축받이 위로 내리기', [0, 0.5, 0], [SX + 0.35, 0.26, 0], 1);
      const washerGeo = new THREE.LatheGeometry(
        [
          [SHAFT_R + 0.005, -0.02],
          [0.17, -0.02],
          [0.17, 0.02],
          [SHAFT_R + 0.005, 0.02],
          [SHAFT_R + 0.005, -0.02],
        ].map(([r, y]) => new THREE.Vector2(r, y)),
        32,
      );
      for (const x of [BX, SX]) put(sWasher, new THREE.Mesh(washerGeo, mSteel), x, 0.26, 0);
      // ③ 톱니 2개 — 축에 끼우며 이를 맞물림 (작은 톱니가 내려오며 돌아 이가 맞는다)
      const sGear = step('톱니바퀴 2개', '축에 끼우며 이를 맞물리기', [0, 1.05, 0], [SX + 0.62, 0.36, 0], 1);
      const big = new THREE.Mesh(gearGeo(18, 0.1, 0.16, SHAFT_R + 0.006), mBrass);
      big.rotation.x = -Math.PI / 2;
      const bigG = new THREE.Group();
      bigG.add(big);
      put(sGear, bigG, BX, 0.36, 0);
      const small = new THREE.Mesh(gearGeo(10, 0.1, 0.16, SHAFT_R + 0.006), mCopper);
      small.rotation.x = -Math.PI / 2;
      const smallG = new THREE.Group();
      smallG.add(small);
      put(sGear, smallG, SX, 0.36, 0);
      // ④ 덮개 — 축 구멍 2 · 나사 구멍 4 (투명 아크릴)
      const sCover = step('덮개', '축과 기둥 구멍에 맞춰 내리기', [0, 1.6, 0], [1.62, COVER_Y + 0.05, 0], 1);
      const cs = new THREE.Shape();
      {
        const w = 1.6;
        const d = 1.0;
        const r = 0.16;
        cs.moveTo(-w + r, -d);
        cs.lineTo(w - r, -d);
        cs.quadraticCurveTo(w, -d, w, -d + r);
        cs.lineTo(w, d - r);
        cs.quadraticCurveTo(w, d, w - r, d);
        cs.lineTo(-w + r, d);
        cs.quadraticCurveTo(-w, d, -w, d - r);
        cs.lineTo(-w, -d + r);
        cs.quadraticCurveTo(-w, -d, -w + r, -d);
        const hole = (x: number, y: number, rr: number): void => {
          const p = new THREE.Path();
          p.absarc(x, y, rr, 0, TAU, true);
          cs.holes.push(p);
        };
        for (const x of [BX, SX]) hole(x, 0, SHAFT_R + 0.02);
        for (const [x, z] of POSTS) hole(x, -z, 0.06);
      }
      const coverGeo = new THREE.ExtrudeGeometry(cs, { depth: COVER_T, bevelEnabled: false, curveSegments: 12 }).rotateX(-Math.PI / 2);
      const cover = new THREE.Group();
      cover.add(new THREE.Mesh(coverGeo, new THREE.MeshPhysicalMaterial({ color: 0xa8d8ff, transparent: true, opacity: 0.32, roughness: 0.08, depthWrite: false })));
      cover.add(new THREE.LineSegments(new THREE.EdgesGeometry(coverGeo, 30), new THREE.LineBasicMaterial({ color: 0x3f7fc0, transparent: true, opacity: 0.85 })));
      put(sCover, cover, 0, COVER_Y, 0);
      // ⑤ 나사 4개 — 나사산 (돌며 박힌다)
      const sScrew = step('나사 4개', '덮개 구멍으로 돌려 조이기', [0, 2.05, 0], [1.62, COVER_Y + 0.4, 0.78], 1, TAU * 6);
      const prof: [number, number][] = [[0, -0.66], [0.035, -0.68]];
      for (let y = -0.64; y < -0.04; y += 0.05) prof.push([0.052, y], [0.038, y + 0.025]);
      prof.push([0.045, -0.02], [0.045, 0], [0.11, 0], [0.11, 0.035], [0.095, 0.07], [0.06, 0.085], [0, 0.088]);
      const screwGeo = new THREE.LatheGeometry(
        prof.map(([r, y]) => new THREE.Vector2(r, y)),
        24,
      );
      const slotGeo = new THREE.BoxGeometry(0.17, 0.03, 0.03);
      for (const [x, z] of POSTS) {
        const g = new THREE.Group();
        const sc = new THREE.Mesh(screwGeo, mScrew);
        const slot = new THREE.Mesh(slotGeo, mHole);
        slot.position.y = 0.08;
        const slot2 = slot.clone();
        slot2.rotation.y = Math.PI / 2;
        g.add(sc, slot, slot2);
        put(sScrew, g, x, COVER_Y + COVER_T, z);
      }
      // ⑥ 손잡이 + 축 끝 마개
      const sCrank = step('손잡이 · 마개', '축 끝에 끼우기', [0, 1.75, 0], [BX - 0.35, 1.1, 0], -1);
      const crank = new THREE.Group();
      const hub = new THREE.Mesh(new THREE.CylinderGeometry(0.14, 0.14, 0.16, 24), mDark);
      hub.position.y = 0.08;
      const arm = new THREE.Mesh(new RoundedBoxGeometry(0.62, 0.06, 0.13, 2, 0.025), mDark);
      arm.position.set(0.31, 0.13, 0);
      const knobStem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.18, 12), mSteel);
      knobStem.position.set(0.58, 0.24, 0);
      const knob = new THREE.Mesh(new THREE.CapsuleGeometry(0.075, 0.14, 4, 14), mRed);
      knob.position.set(0.58, 0.4, 0);
      crank.add(hub, arm, knobStem, knob);
      crank.rotation.y = Math.PI;
      put(sCrank, crank, BX, COVER_Y + COVER_T + 0.02, 0);
      const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.11, 0.1, 6), mDark);
      put(sCrank, cap, SX, COVER_Y + COVER_T + 0.07, 0);

      // 끼우는 축 (점선) — 축 2 · 나사 4
      const guideMat = new THREE.LineDashedMaterial({ color: 0x4a5a8a, dashSize: 0.07, gapSize: 0.06, transparent: true, opacity: 0 });
      const axes: [number, number, number, number][] = [
        [BX, 0, -1.55, 3.0],
        [SX, 0, -1.55, 2.4],
        ...POSTS.map(([x, z]) => [x, z, 0.85, 3.15] as [number, number, number, number]),
      ];
      for (const [x, z, y0, y1] of axes) {
        const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(x, y0, z), new THREE.Vector3(x, y1, z)]), guideMat);
        l.computeLineDistances();
        root.add(l);
      }

      const N = steps.length;
      const st = steps.map(() => 0);
      let auto = true;
      let manual = 0;
      let u = 0;
      let lastU = 0;
      let assembling = true;
      let spin = 0;
      let lastT = 0;
      let aspect = 1.6;
      const HOLD0 = 3.2;
      const DIS = N * 0.55;
      const HOLD1 = 2.6;
      const ASM = N * 1.0;
      const CYCLE = HOLD0 + DIS + HOLD1 + ASM;
      const cam = new THREE.PerspectiveCamera(32, 1, 0.1, 60);
      const v = new THREE.Vector3();
      const proj = (p: THREE.Vector3, w: number, h: number): [number, number] => {
        v.copy(p).applyMatrix4(root.matrixWorld).project(cam);
        return [(v.x * 0.5 + 0.5) * w, (1 - (v.y * 0.5 + 0.5)) * h];
      };
      return {
        scene,
        camera: cam,
        update(t) {
          const dt = Math.min(0.1, Math.max(0, t - lastT));
          lastT = t;
          if (auto) {
            const T = t % CYCLE;
            if (T < HOLD0) u = 0;
            else if (T < HOLD0 + DIS) u = ((T - HOLD0) / DIS) * N;
            else if (T < HOLD0 + DIS + HOLD1) u = N;
            else u = N * (1 - (T - HOLD0 - DIS - HOLD1) / ASM);
          } else u = manual * N;
          if (u !== lastU) assembling = u < lastU;
          lastU = u;
          // 분해는 마지막 단계부터: 손잡이 → 나사 → 덮개 → 톱니 → 와셔 → 축
          steps.forEach((s, i) => {
            const f = smooth(u - (N - 1 - i));
            st[i] = f;
            for (const p of s.parts) p.o.position.copy(p.home).addScaledVector(s.off, f);
          });
          // 나사: 올라가는 만큼 돈다 (나사산을 따라)
          const fs = st[steps.indexOf(sScrew)]!;
          for (const p of sScrew.parts) p.o.rotation.y = -fs * (sScrew.spin ?? 0);
          // 다 조였을 때만 손잡이가 돌고 톱니가 맞물려 돈다 (잇수 18 : 10)
          if (u < 0.001) spin += dt * 0.9;
          const fg = st[steps.indexOf(sGear)]!;
          big.rotation.z = spin;
          small.rotation.z = (-spin * 18) / 10 + Math.PI / 10 + fg * 0.9;
          crank.rotation.y = Math.PI + spin;
          guideMat.opacity = clamp(u * 1.2, 0, 1) * 0.75;
          root.rotation.y = Math.sin(t * 0.25) * 0.35 + 0.25;
          // 카메라: 다 펼친 모습이 들어오게 고정 (조금씩만 다가감)
          const e = smooth(u / N);
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(3.25 / tn, 2.7 / (tn * aspect)) * lerp(0.9, 1, e);
          const ty = lerp(0.75, 0.85, e);
          cam.position.set(0, ty + d * 0.42, d);
          cam.lookAt(0, ty, 0);
        },
        resize(w, h) {
          aspect = w / h;
        },
        render(r, w, h) {
          r.render(scene, cam);
          root.updateMatrixWorld();
          const S = fsz(w, h);
          const j = Math.min(N - 1, Math.floor(u));
          const i = N - 1 - j;
          const moving = u > 0.001 && u < N - 0.001;
          const exploded = u >= N - 0.001;
          const labels = exploded
            ? steps.map((s, k) => {
                const at = s.label.clone().addScaledVector(s.off, st[k]!);
                const [x, y] = proj(at, w, h);
                return { k, s, x, y };
              })
            : [];
          const key = `${w}x${h}|${moving ? `${i}${assembling}` : exploded ? labels.map((l) => `${l.x | 0},${l.y | 0}`).join(';') : 'done'}`;
          hud.draw(r, w, h, key, (g) => {
            if (moving) {
              const s = steps[i]!;
              pill(g, S * 0.6, S * 0.6, `${assembling ? '조립' : '분해'} ${i + 1}/${N} · ${s.name} — ${assembling ? s.how : '빼기'}`, S * 0.8);
            } else if (exploded) {
              pill(g, S * 0.6, S * 0.6, `분해도 · 부품 ${N}종 — 번호 = 조립 순서`, S * 0.8);
              for (const l of labels) {
                const t = `${l.k + 1}. ${l.s.name}`;
                g.font = `700 ${S * 0.72}px ${FONT}`;
                const tw = g.measureText(t).width + S * 0.72 * 1.1;
                const gx = l.s.side > 0 ? l.x + S * 1.6 : l.x - S * 1.6 - tw;
                g.strokeStyle = 'rgba(40,52,90,0.75)';
                g.lineWidth = 1.5;
                g.beginPath();
                g.moveTo(l.x, l.y);
                g.lineTo(l.s.side > 0 ? gx : gx + tw, l.y);
                g.stroke();
                g.fillStyle = '#28345a';
                g.beginPath();
                g.arc(l.x, l.y, 3, 0, TAU);
                g.fill();
                pill(g, gx, l.y - S * 0.72 * 0.825, t, S * 0.72, 'rgba(255,255,255,0.94)', '#1d2747');
              }
            } else {
              pill(g, S * 0.6, S * 0.6, '다 조립됨 — 손잡이를 돌리면 큰 톱니 1바퀴에 작은 톱니 1.8바퀴', S * 0.8);
            }
          });
        },
        controls: [
          { type: 'toggle', label: '저절로', value: true, on: (x) => (auto = x) },
          { type: 'range', label: '조립 ↔ 분해 (순서대로)', min: 0, max: 1, step: 0.005, value: 0, on: (x) => ((auto = false), (manual = x)) },
        ],
        dispose() {
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [단면 자르기 (스텐실 단면 · 원뿔 곡선)](https://ai-techstudio.web.app/ai/t/i35.md) `i35`
- 다음에 해 볼 기술: [기구학 연결 (크랭크 · 인벌류트 톱니 · 캠)](https://ai-techstudio.web.app/ai/t/i37.md) `i37` · [3D 를 따라가는 이름표 · 지시선](https://ai-techstudio.web.app/ai/t/i38.md) `i38`
