# AI 꾸러미 — 기구학 연결 (크랭크 · 인벌류트 톱니 · 캠) — Involute gear profile (pressure angle 20°)
> 크랭크 각 θ 하나로 피스톤 · 인벌류트 톱니(16 : 32) · 캠 · 밀대 자리를 모두 식으로 계산해, 맞물림이 겹치지 않고 미끄러지듯 돌게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i37

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

## 주문서

### 만들어 줘: 기구학 연결 (크랭크 · 인벌류트 톱니 · 캠) — Involute gear profile (pressure angle 20°)

#### 1. 목표
엔진 크랭크 + 톱니 + 캠 (4행정 캠축)을(를) 각도 θ 하나로 움직여 줘 — 크랭크 · 피스톤은 x = r·cosθ + √(L² − r²sin²θ), 톱니는 인벌류트 치형으로 위상을 맞춰 겹치지 않게, 캠은 r(φ) 윤곽으로 밀대를 올리게. 분위기는 밝은 교과서 도해.

#### 2. 핵심 기술 용어
- **Involute gear profile (pressure angle 20°)** — 인벌류트 치형 — 압력각 20°, 모듈 · 잇수로 정해짐
- **Slider-crank kinematics** — 크랭크 · 연결 막대 · 피스톤 위치 식
- **Cam and flat-faced follower** — 캠과 납작 밀대 — 캠 윤곽의 가장 높은 점이 밀대를 올림
- **Gear ratio · mesh phase** — 잇수 비 · 맞물림 위상 (반 피치 어긋남)

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

#### 4. 조건
- 톱니 윤곽은 인벌류트 식으로(압력각 20°, 모듈 · 잇수 → 기초원 · 이끝원 · 이뿌리원), 양쪽 면은 대칭
- 두 바퀴는 같은 모듈, 중심 거리 = m(z₁ + z₂)/2, 위상 θ₂ = π + π/z₂ + (z₁/z₂)·θ₁ — 이 식을 빼면 첫 장면부터 이가 겹친다
- 돌출 모서리 깎기는 bevelOffset = −깎기 (윤곽이 부풀면 맞닿는 이가 파고든다)
- 잇수 17 미만이면 언더컷 · 간섭 확인 (이끝 반지름 ≤ √(rb² + (C·sin20°)²))
- 모든 자리는 각 θ 하나에서 계산 — 부품마다 따로 시간을 돌리지 않는다

#### 5. 완성 기준 (이게 보이면 성공)
- 확대해도 두 톱니가 겹치거나 틈이 벌어지지 않고 맞닿은 채 굴러간다 (멈춤 0 빠르기에서 확인)
- 「피치원 · 기초원 · 작용선」을 켜면 두 피치원이 한 점에서 닿고, 작용선이 두 기초원에 접한다
- 파랑이 2바퀴 돌 때 노랑(캠)이 1바퀴, 밀대가 캠 모양대로 오르내리고 그래프의 점이 따라간다
- 빠르기 슬라이더 0 ~ 3 에서도 피스톤 · 막대 · 톱니 · 밀대가 어긋나지 않고 폰에서 60fps

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

## 원리
- 톱니: 피치원 r = mz/2, 기초원 rb = r·cos20°, 이끝원 r + m, 이뿌리원 r − 1.25m. 반지름 R 의 면 점은 각 θ(R) = π/2z + inv(20°) − inv(acos(rb/R)) 에, inv(α) = tanα − α.
- 이 하나의 양쪽 면은 거울 대칭(가운데 ± θ(R)) — 이 곡선이 그대로 2D 윤곽 → 돌출. 모서리는 안쪽으로 깎아 이 면이 정확히 인벌류트 위에 남게.
- 맞물림: 중심 거리 = r₁ + r₂, 큰 바퀴 각 θ₂ = π + π/z₂ + (z₁/z₂)·θ₁ (반 피치 어긋나 홈이 이를 받음, 반대 방향). 16 : 32 → 2바퀴에 1바퀴.
- 피스톤 x = r·cosθ + √(L² − r²sin²θ). 캠은 반지름 r(φ) = r₀ + h·(1 − cos πk)/2 로 오르고 머물고 내리며, 납작 밀대 높이 = 돌린 캠 윤곽 점들의 가장 큰 y.
- 잇수 17 아래는 뿌리가 깎이는 언더컷 — 견본은 16 : 32 로 이끝 간섭 없이(검사: 400 각도에서 겹친 점 0) 맞물린다.

## 핵심 코드 — 인벌류트 톱니 윤곽 + 맞물림 위상 + 캠 밀대
(발췌: demos/demosStructure.ts involutePts · i37 을 정리 (식은 demos/demosMechGear.ts gearPts 와 같음))
```ts
const PA = (20 * Math.PI) / 180;
const inv = (a) => Math.tan(a) - a;
function involutePts(z, m) {
  const rp = (m * z) / 2, rb = rp * Math.cos(PA), ra = rp + m, rf = rp - 1.25 * m;
  const half = Math.PI / (2 * z) + inv(PA);
  const th = (r) => half - inv(Math.acos(Math.min(1, rb / Math.max(r, rb))));
  const rs = [rf, ...(rf < rb ? [rb] : [])];
  for (let i = 1; i <= 6; i++) rs.push(Math.max(rf, rb) + (ra - Math.max(rf, rb)) * (i / 6));
  const P = (r, a) => new THREE.Vector2(r * Math.cos(a), r * Math.sin(a));
  const pts = [];
  for (let i = 0; i < z; i++) {
    const c = (i * 2 * Math.PI) / z;
    for (const r of rs) pts.push(P(r, c - th(r)));                   // 한쪽 면 (아래 → 이끝)
    for (const r of [...rs].reverse()) pts.push(P(r, c + th(r)));   // 거울 면 (이끝 → 아래)
    const a0 = c + th(rf), a1 = c + (2 * Math.PI) / z - th(rf);
    for (let k = 1; k <= 3; k++) pts.push(P(rf, a0 + ((a1 - a0) * k) / 4)); // 이뿌리 호
  }
  return pts;
}
const bev = m * 0.16;
const geo = new THREE.ExtrudeGeometry(new THREE.Shape(involutePts(16, 0.07)), {
  depth: 0.2 - 2 * bev, bevelEnabled: true, bevelThickness: bev, bevelSize: bev, bevelOffset: -bev, bevelSegments: 2,
});
// 매 프레임: 각 θ 하나로 전부
g1.rotation.z = -th;
g2.rotation.z = Math.PI + Math.PI / 32 + (16 / 32) * th;   // 중심 거리 0.07·(16+32)/2
let top = 0;                                               // 납작 밀대 = 돌린 캠 윤곽의 가장 큰 y
for (const p of camPts) top = Math.max(top, p.clone().rotateAround(O, g2.rotation.z).y);
tappet.position.y = g2.position.y + top;
```

## 흔한 실수 · 확인 목록
- [ ] **사다리꼴 이는 돌 때 서로 파고든다** — 이 면이 인벌류트가 아니면 맞닿는 점의 속도가 안 맞아 겹치거나 벌어진다. 인벌류트 식으로 윤곽을 만든다.
- [ ] **위상 식을 빼면 첫 장면부터 이가 겹친다** — 큰 바퀴를 π + π/z₂ 만큼 돌려 놓아야 이 0 번 앞에 홈이 온다. 견본 검사기로 반 피치 틀리면 겹친 점 239개, 맞으면 0.
- [ ] **모서리를 바깥으로 깎으면 이가 부풀어 겹친다** — ExtrudeGeometry 의 bevelSize 는 윤곽을 그만큼 키운다. bevelOffset = −bevelSize 로 옆벽을 원래 윤곽에 둔다.
- [ ] **잇수가 너무 적으면 이끝이 상대 뿌리를 판다** — 압력각 20° 는 17개 아래에서 언더컷. 12 : 24 는 간섭이 있어 16 : 32 로 바꿨다 (이끝 ≤ √(rb² + (C·sin20°)²)).

## 완성 기준 체크리스트
- [ ] 확대해도 두 톱니가 겹치거나 틈이 벌어지지 않고 맞닿은 채 굴러간다 (멈춤 0 빠르기에서 확인)
- [ ] 「피치원 · 기초원 · 작용선」을 켜면 두 피치원이 한 점에서 닿고, 작용선이 두 기초원에 접한다
- [ ] 파랑이 2바퀴 돌 때 노랑(캠)이 1바퀴, 밀대가 캠 모양대로 오르내리고 그래프의 점이 따라간다
- [ ] 빠르기 슬라이더 0 ~ 3 에서도 피스톤 · 막대 · 톱니 · 밀대가 어긋나지 않고 폰에서 60fps

## 이 기술 정보
- id: `i37` · 분류: 기계 · 구조 › 구조 · 조립 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 모양은 처음 한 번 돌출(톱니 하나 수 ms), 매 프레임은 회전 값 몇 개와 캠 점 180개 최댓값만. 한 장면 1ms 안.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i37
- 쓰면 좋을 때: 엔진 · 시계 · 기계 장치의 원리 설명 / 톱니비 · 각속도(ω₁z₁ = ω₂z₂) 수업 / 자동 인형처럼 캠 모양이 움직임을 정하는 장치
- 쓰지 말 때: 부딪히고 튕기는 자유 운동 — 대신 물리 엔진(Rapier · cannon-es) / 사다리꼴 · 삼각형 이 모양 — 돌 때 이가 서로 파고들거나 틈이 벌어진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i37 견본 항목 — `src/demos/demosStructure.ts:3696`
```ts
  i37: {
    kind: '3d',
    caption: '각도 θ 하나로 모든 부품 자리를 계산 — 피스톤 x = r·cosθ + √(L² − r²sin²θ), 인벌류트 톱니 16 : 32 → 캠은 2바퀴에 1바퀴, 캠 모양 r(φ) 이 밀대를 올린다',
    make() {
      const scene = baseScene('#e3ecff', '#fafcff');
      const eng = makeEngine({ valves: false });
      eng.root.position.set(-2.3, -1.35, 0);
      eng.root.scale.setScalar(0.85);
      scene.add(eng.root);
      // 톱니: 모듈 0.08, 16 : 32 (잇수 17 아래는 뿌리가 깎이는 언더컷 — 16 은 이끝을 줄이지 않아도 맞물림 간섭이 없는 경계)
      const M = 0.07;
      const Z1 = 16;
      const Z2 = 32;
      const r1 = (M * Z1) / 2;
      const r2 = (M * Z2) / 2;
      const gy = -0.32;
      const g1 = new THREE.Mesh(involuteGearGeo(Z1, M, 0.2), std(0x4aa8ff, 0.35, 0.5));
      const g2 = new THREE.Mesh(involuteGearGeo(Z2, M, 0.2), std(0xffc23a, 0.3, 0.7));
      g1.position.set(0.1, gy, 0);
      g2.position.set(0.1 + r1 + r2, gy, 0);
      const axleGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.5, 20).rotateX(Math.PI / 2);
      const axleMat = std(0xc9d2de, 0.25, 0.85);
      for (const g of [g1, g2]) {
        const ax = new THREE.Mesh(axleGeo, axleMat);
        ax.position.copy(g.position);
        scene.add(ax);
      }
      const dotGeo = new THREE.SphereGeometry(0.06, 12, 8);
      const dotMat = new THREE.MeshBasicMaterial({ color: 0xff3355 });
      const d1 = new THREE.Mesh(dotGeo, dotMat);
      d1.position.set(r1 * 0.62, 0, 0.12);
      g1.add(d1);
      scene.add(g1, g2);
      // 캠: 큰 톱니 축 앞에 붙어 같이 돈다 (반 빠르기 = 4행정 엔진의 캠축)
      const R0 = 0.4;
      const LIFT = 0.3;
      const camS = new THREE.Shape();
      const NC = 180;
      for (let i = 0; i < NC; i++) {
        const a = (i / NC) * TAU;
        const rr = camRadius(a, R0, LIFT);
        if (i) camS.lineTo(rr * Math.cos(a), rr * Math.sin(a));
        else camS.moveTo(rr * Math.cos(a), rr * Math.sin(a));
      }
      const camHole = new THREE.Path();
      camHole.absarc(0, 0, 0.1, 0, TAU, true);
      camS.holes.push(camHole);
      const camGeo = new THREE.ExtrudeGeometry(camS, { depth: 0.1, bevelEnabled: true, bevelThickness: 0.012, bevelSize: 0.012, bevelOffset: -0.012, bevelSegments: 2, curveSegments: 8 });
      camGeo.translate(0, 0, 0.17);
      const cam3 = new THREE.Mesh(camGeo, std(0xff6b8b, 0.35, 0.3));
      g2.add(cam3);
      // 납작 밀대(태핏) + 막대 + 안내 고리 + 용수철 — 캠 윤곽에서 가장 높은 점에 밀대 밑면이 닿는다
      const steel = std(0xc9d2de, 0.25, 0.85);
      const tappet = new THREE.Group();
      const plate = new THREE.Mesh(new RoundedBoxGeometry(0.5, 0.06, 0.16, 2, 0.02), steel);
      plate.position.y = 0.03;
      const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.1, 12), steel);
      rod.position.y = 0.06 + 0.55;
      tappet.add(plate, rod);
      tappet.position.set(g2.position.x, 0, 0.22);
      scene.add(tappet);
      const guideY = gy + 1.5;
      const guide = new THREE.Mesh(new THREE.TorusGeometry(0.07, 0.025, 8, 20), std(0x5a6b8a, 0.4, 0.6));
      guide.rotation.x = Math.PI / 2;
      guide.position.set(g2.position.x, guideY, 0.22);
      const bracket = new THREE.Mesh(new RoundedBoxGeometry(0.6, 0.08, 0.08, 2, 0.02), std(0x5a6b8a, 0.4, 0.6));
      bracket.position.set(g2.position.x + 0.3, guideY, 0.22);
      scene.add(guide, bracket);
      const SPN = 120;
      const springPos = new Float32Array(SPN * 3);
      for (let i = 0; i < SPN; i++) {
        const a = (i / SPN) * TAU * 7;
        springPos.set([Math.cos(a) * 0.09, i / (SPN - 1), Math.sin(a) * 0.09], i * 3);
      }
      const springGeo = new THREE.BufferGeometry();
      springGeo.setAttribute('position', new THREE.BufferAttribute(springPos, 3));
      const spring = new THREE.Line(springGeo, new THREE.LineBasicMaterial({ color: 0x2a3550 }));
      spring.position.set(g2.position.x, 0, 0.22);
      scene.add(spring);
      // 피치원 · 기초원 · 작용선 (보기 켜고 끄기)
      const circ = (rr: number, color: number, op: number): THREE.LineLoop => {
        const pts: THREE.Vector3[] = [];
        for (let i = 0; i < 96; i++) pts.push(new THREE.Vector3(rr * Math.cos((i / 96) * TAU), rr * Math.sin((i / 96) * TAU), 0));
        return new THREE.LineLoop(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color, transparent: true, opacity: op, depthTest: false }));
      };
      const guides = new THREE.Group();
      for (const [g, rr] of [
        [g1, r1],
        [g2, r2],
      ] as [THREE.Mesh, number][]) {
        const pc = circ(rr, 0x1d2a55, 0.7);
        const bc = circ(rr * Math.cos(PA20), 0x1f6bff, 0.8);
        for (const c of [pc, bc]) {
          c.position.copy(g.position).setZ(0.13);
          c.renderOrder = 6;
          guides.add(c);
        }
      }
      const ca = Math.cos(PA20);
      const sa = Math.sin(PA20);
      const T1 = new THREE.Vector3(g1.position.x + r1 * ca * ca, gy + r1 * ca * sa, 0.13);
      const T2 = new THREE.Vector3(g2.position.x - r2 * ca * ca, gy - r2 * ca * sa, 0.13);
      const loa = new THREE.Line(new THREE.BufferGeometry().setFromPoints([T1, T2]), new THREE.LineBasicMaterial({ color: 0xd4145a, depthTest: false }));
      loa.renderOrder = 7;
      const pp = new THREE.Mesh(new THREE.CircleGeometry(0.035, 16), new THREE.MeshBasicMaterial({ color: 0xd4145a, depthTest: false }));
      pp.position.set(g1.position.x + r1, gy, 0.13);
      pp.renderOrder = 7;
      guides.add(loa, pp);
      scene.add(guides);
      const cam = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
      const hud = new Hud();
      let speed = 1;
      let th = 0;
      let x = 0;
      let lift = 0;
      let camPhi = 0;
      const tmp = new THREE.Vector2();
      const O2 = new THREE.Vector2();
      return {
        scene,
        camera: cam,
        update(_t, dt) {
          th += dt * 1.8 * speed;
          x = eng.set(th);
          // 크랭크와 같은 축 방향으로 돈다고 보고(θ), 맞물림: θ₂ = π + π/z₂ + (z₁/z₂)·θ — 이 0 번이 맞은편 홈에 들어가는 위상
          g1.rotation.z = -th;
          g2.rotation.z = Math.PI + Math.PI / Z2 + (Z1 / Z2) * th;
          // 납작 밀대: 캠 윤곽 점을 돌려 가장 높은 y (지지 함수) — 캠이 볼록하니 그 점이 닿는 점
          let top = 0;
          for (let i = 0; i < NC; i++) {
            const a = (i / NC) * TAU;
            const rr = camRadius(a, R0, LIFT);
            tmp.set(rr * Math.cos(a), rr * Math.sin(a)).rotateAround(O2, g2.rotation.z);
            if (tmp.y > top) top = tmp.y;
          }
          lift = top - R0;
          camPhi = ((Math.PI / 2 - g2.rotation.z) % TAU + TAU) % TAU;
          tappet.position.y = gy + top + 0.012;
          const sb = tappet.position.y + 0.08;
          spring.position.y = sb;
          spring.scale.y = Math.max(0.05, guideY - 0.04 - sb);
        },
        resize(w, h) {
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(2.05 / tn, 3.7 / (tn * (w / h)));
          cam.position.set(0.1, 0.2, d);
          cam.lookAt(0.1, -0.05, 0);
        },
        render(r, w, h) {
          r.render(scene, cam);
          const deg = Math.round(((th * 180) / Math.PI) % 360);
          hud.draw(r, w, h, `${w}x${h}${deg}${Math.round(lift * 1000)}`, (g) => {
            const s = fsz(w, h, 0.8);
            pill(g, s * 0.6, s * 0.6, `θ = ${deg}°   x = ${x.toFixed(2)}`, s, 'rgba(30,40,80,0.8)');
            const b1 = Math.floor(th / TAU);
            const b2 = Math.floor((th * Z1) / Z2 / TAU);
            pill(g, w - s * 0.6, s * 0.6, `파랑 ${b1}바퀴 · 노랑(캠) ${b2}바퀴`, s, 'rgba(255,160,40,0.92)', '#2a1800', 'right');
            pill(g, w / 2, h - s * 2.3, 'x = r·cosθ + √(L² − r²·sin²θ)    ω₁·z₁ = ω₂·z₂    밀대 = max r(φ)', s * 0.9, 'rgba(255,255,255,0.88)', '#1d2a55', 'center');
            // 캠 들림 그래프 (한 바퀴 φ → 들림)
            const gw = Math.min(w * 0.24, s * 11);
            const gh = gw * 0.42;
            const gx = w * 0.4 - gw / 2;
            const gyy = s * 3.2;
            g.fillStyle = 'rgba(255,255,255,0.85)';
            g.beginPath();
            g.roundRect(gx - s * 0.4, gyy - s * 0.4, gw + s * 0.8, gh + s * 1.9, s * 0.5);
            g.fill();
            g.strokeStyle = '#ff6b8b';
            g.lineWidth = Math.max(1.5, s * 0.14);
            g.beginPath();
            for (let i = 0; i <= 90; i++) {
              const a = (i / 90) * TAU;
              const yy = gyy + gh - ((camRadius(a, R0, LIFT) - R0) / LIFT) * gh;
              if (i) g.lineTo(gx + (i / 90) * gw, yy);
              else g.moveTo(gx, yy);
            }
            g.stroke();
            const px = gx + (camPhi / TAU) * gw;
            g.fillStyle = '#d4145a';
            g.beginPath();
            g.arc(px, gyy + gh - (lift / LIFT) * gh, s * 0.3, 0, TAU);
            g.fill();
            g.fillStyle = '#1d2a55';
            g.font = `700 ${s * 0.75}px ${FONT}`;
            g.textBaseline = 'top';
            g.textAlign = 'left';
            g.fillText(`캠 들림 ${lift.toFixed(2)}`, gx, gyy + gh + s * 0.35);
          });
        },
        controls: [
          { type: 'range', label: '빠르기', min: 0, max: 3, step: 0.1, value: 1, on: (v) => (speed = v) },
          { type: 'toggle', label: '피치원 · 기초원 · 작용선', value: true, on: (v) => (guides.visible = v) },
        ],
        dispose() {
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [선반 회전체 + 장식 (윤곽선 하나로)](https://ai-techstudio.web.app/ai/t/i450.md) `i450`
- 다음에 해 볼 기술: [톱니바퀴 장치 (인벌류트 톱니 · 기어비 · 유성 기어)](https://ai-techstudio.web.app/ai/t/i540.md) `i540` · [4기통 엔진 단면 (크랭크 · 피스톤 · 캠 · 밸브)](https://ai-techstudio.web.app/ai/t/i541.md) `i541`
- 참고 문서: [Bartosz Ciechanowski — Gears](https://ciechanow.ski/gears/) · [Wikipedia — Involute gear](https://en.wikipedia.org/wiki/Involute_gear) · [Wikipedia — Cam (mechanism)](https://en.wikipedia.org/wiki/Cam)
