# AI 꾸러미 — 4기통 엔진 단면 (크랭크 · 피스톤 · 캠 · 밸브)
> 크랭크 각 하나로 피스톤 · 커넥팅 로드 · 캠축 · 밸브 위치를 모두 식으로, 블록 앞 절반을 잘라 속을 보여 줌 — 흡입 · 압축 · 폭발 · 배기  
> 견본: https://ai-techstudio.web.app/#t/i541

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

## 주문서

### 만들어 줘: 4기통 엔진 단면 (크랭크 · 피스톤 · 캠 · 밸브)

#### 1. 목표
엔진 원리에 「4기통 엔진 단면 (크랭크 · 피스톤 · 캠 · 밸브)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 크랭크 각 하나로 피스톤 · 커넥팅 로드 · 캠축 · 밸브 위치를 모두 식으로, 블록 앞 절반을 잘라 속을 보여 줌 — 흡입 · 압축 · 폭발 · 배기

#### 2. 핵심 기술 용어
- 4기통 엔진 단면 (크랭크 · 피스톤 · 캠 · 밸브) — 크랭크 각 하나로 피스톤 · 커넥팅 로드 · 캠축 · 밸브 위치를 모두 식으로, 블록 앞 절반을 잘라 속을 보여 줌 — 흡입 · 압축 · 폭발 · 배기

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「크랭크 각 하나로 피스톤 · 커넥팅 로드 · 캠축 · 밸브 위치를 모두 식으로, 블록 앞 절반을 잘라 속을 보여 줌 — 흡입 · 압축 · 폭발 · 배기」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 크랭크 각 하나로 피스톤 · 커넥팅 로드 · 캠축 · 밸브 위치를 모두 식으로, 블록 앞 절반을 잘라 속을 보여 줌 — 흡입 · 압축 · 폭발 · 배기


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「크랭크 각 하나로 피스톤 · 커넥팅 로드 · 캠축 · 밸브 위치를 모두 식으로, 블록 앞 절반을 잘라 속을 보여 줌 — 흡입 · 압축 · 폭발 · 배기」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i541` · 분류: 기계 · 구조 › 기계 장치 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i541
- 쓰면 좋을 때: 쓰면 좋을 곳 — 엔진 원리 · 사인 그래프 · 비율 체험

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeEngine — `src/demos/demosMechEngine.ts:123`
```ts
function makeEngine(): Scene3D {
  const st = makeStage({ size: 3.3, bg: ['#e3e8ee', '#b6bfca'] });
  const { scene, cam } = st;
  const M = mechMats();
  const own: THREE.Material[] = [];
  const P = (o: THREE.MeshPhysicalMaterialParameters): THREE.MeshPhysicalMaterial => {
    const m = new THREE.MeshPhysicalMaterial(o);
    own.push(m);
    return m;
  };
  // 주조 알루미늄 블록 · 잘린 면(빗금)
  const cast = P({ color: 0x9aa3ad, metalness: 0.7, roughness: 0.55 });
  const hatch = (() => {
    const c = document.createElement('canvas');
    c.width = c.height = 64;
    const g = c.getContext('2d')!;
    g.fillStyle = '#d4553a';
    g.fillRect(0, 0, 64, 64);
    g.strokeStyle = 'rgba(120,30,20,0.55)';
    g.lineWidth = 5;
    for (let i = -64; i < 128; i += 16) {
      g.beginPath();
      g.moveTo(i, 64);
      g.lineTo(i + 64, 0);
      g.stroke();
    }
    const t = new THREE.CanvasTexture(c);
    t.wrapS = t.wrapT = THREE.RepeatWrapping;
    t.repeat.set(5, 5);
    t.colorSpace = THREE.SRGBColorSpace;
    return t;
  })();
  const cut = P({ color: 0xffffff, map: hatch, metalness: 0.1, roughness: 0.7 });
  const liner = P({ color: 0xc4c9cf, metalness: 0.9, roughness: 0.3, side: THREE.DoubleSide });
  const ceramic = P({ color: 0xf4f1ea, metalness: 0, roughness: 0.25, clearcoat: 0.8 });
  const pistonM = P({ color: 0xc9ced4, metalness: 0.85, roughness: 0.35, side: THREE.DoubleSide });

  const eng = new THREE.Group();
  eng.position.y = LIFT_Y0;
  scene.add(eng);

  /* ── 블록 · 헤드 (뒤 절반 + 거울 앞 절반) ── */
  const blockBack = new THREE.Group();
  const blockFront = new THREE.Group();
  blockFront.visible = false;
  eng.add(blockBack, blockFront);
  const both = (g: THREE.BufferGeometry, mats: THREE.Material | THREE.Material[], mirror = true): void => {
    blockBack.add(new THREE.Mesh(g, mats));
    if (mirror) blockFront.add(new THREE.Mesh(mirrorZ(g), cast));
  };
  const D = 0.55;
  // 기둥 (실린더 사이 · 메인 베어링 벽) — 위는 실린더 벽, 아래는 얇은 격벽
  for (const xj of XJ) {
    const end = Math.abs(xj) > 1.5;
    const s = Math.sign(xj);
    const pts: [number, number][] = end
      ? [
          [s * 1.66, DECK],
          [s * 2.0, DECK],
          [s * 2.0, 0.17],
          [s * 1.73, 0.17],
          [s * 1.73, 0.6],
          [s * 1.66, 0.68],
        ]
      : [
          [xj - 0.14, DECK],
          [xj + 0.14, DECK],
          [xj + 0.14, 0.68],
          [xj + 0.07, 0.6],
          [xj + 0.07, 0.17],
          [xj - 0.07, 0.17],
          [xj - 0.07, 0.6],
          [xj - 0.14, 0.68],
        ];
    if (end && s < 0) pts.reverse();
    both(slab(poly(pts), D), [cut, cast]);
  }
  // 실린더 벽 (뒤 반원통) · 뒤 판
  const linerG = new THREE.CylinderGeometry(0.31, 0.31, DECK - 0.62, 40, 1, true, Math.PI / 2, Math.PI);
  for (const xc of XC) {
    const m = new THREE.Mesh(linerG, liner);
    m.position.set(xc, (DECK + 0.62) / 2, 0);
    blockBack.add(m);
    const f = new THREE.Mesh(linerG, cast);
    f.position.copy(m.position);
    f.rotation.y = Math.PI;
    blockFront.add(f);
  }
  both(new THREE.BoxGeometry(4, DECK - 0.62, D - 0.31).translate(0, (DECK + 0.62) / 2, -0.31 - (D - 0.31) / 2), cast);
  both(new THREE.BoxGeometry(4, 1.24, 0.05).translate(0, 0, -D + 0.025), cast);
  // 기름통 (U 단면)
  both(
    slab(
      poly([
        [-2.0, 0.17],
        [-2.0, -0.52],
        [-1.9, -0.62],
        [1.9, -0.62],
        [2.0, -0.52],
        [2.0, 0.17],
        [1.95, 0.17],
        [1.95, -0.53],
        [1.88, -0.57],
        [-1.88, -0.57],
        [-1.95, -0.53],
        [-1.95, 0.17],
      ]),
      D,
    ),
    [cut, M.dark],
  );
  // 메인 베어링 하우징 (저널 둘레 고리)
  {
    const ring = new THREE.LatheGeometry(
      [
        [0.12, -0.07],
        [0.17, -0.07],
        [0.175, -0.06],
        [0.175, 0.06],
        [0.17, 0.07],
        [0.12, 0.07],
      ].map(([r, y]) => new THREE.Vector2(r, y)),
      40,
    ).rotateZ(Math.PI / 2);
    for (const xj of XJ) {
      const m = new THREE.Mesh(ring, cast);
      m.position.x = xj;
      blockBack.add(m);
    }
  }
  // 헤드 판 (밸브 · 플러그 구멍) — 앞쪽도 z 0.32 까지 남겨 밸브를 받친다
  {
    const s = poly([
      [-2.0, -0.55],
      [2.0, -0.55],
      [2.0, 0.32],
      [-2.0, 0.32],
    ]);
    for (const xc of XC) {
      s.holes.push(circlePath(0.095, xc, -CAM_Z, 40, true), circlePath(0.095, xc, CAM_Z, 40, true), circlePath(0.045, xc, 0, 24, true));
    }
    const g = bevelExtrude(s, 0.07, 0.01, 24, 2).rotateX(Math.PI / 2).translate(0, DECK + 0.035, 0);
    const m = new THREE.Mesh(g, [cast, cut]);
    eng.add(m);
    // 헤드 뒤 덩어리 · 캠 받침 탑
    both(new THREE.BoxGeometry(4, 0.7, 0.21).translate(0, DECK + 0.07 + 0.35, -D + 0.105), cast);
    const tower = new THREE.BoxGeometry(0.08, CAM_Y - DECK - 0.07, 0.15).translate(0, (CAM_Y + DECK + 0.07) / 2, 0);
    const cap = new THREE.CylinderGeometry(0.075, 0.075, 0.08, 24, 1, false, 0, Math.PI).rotateZ(Math.PI / 2);
    for (const xj of XJ)
      for (const z of [-CAM_Z, CAM_Z]) {
        const t = new THREE.Mesh(tower, cast);
        t.position.set(xj, 0, z);
        const c = new THREE.Mesh(cap, cast);
        c.position.set(xj, CAM_Y, z);
        eng.add(t, c);
      }
  }

  /* ── 크랭크축 ── */
  const crank = new THREE.Group();
  eng.add(crank);
  {
    const web = (() => {
      const s = new THREE.Shape();
      const a = 0.45;
      const Rc = 0.36;
      s.moveTo(0.13, R);
      s.absarc(0, R, 0.13, 0, Math.PI, false);
      s.lineTo(-0.17, 0);
      s.lineTo(Math.cos(Math.PI + a) * Rc, Math.sin(Math.PI + a) * Rc);
      s.absarc(0, 0, Rc, Math.PI + a, TAU - a, false);
      s.lineTo(0.17, 0);
      s.lineTo(0.13, R);
      return bevelExtrude(s, 0.06, 0.012, 32, 2).rotateY(Math.PI / 2);
    })();
    const pinG = new THREE.CylinderGeometry(0.08, 0.08, 0.15, 32).rotateZ(Math.PI / 2);
    const jour = (x0: number, x1: number): void => {
      const g = new THREE.CylinderGeometry(0.115, 0.115, x1 - x0, 32).rotateZ(Math.PI / 2);
      const m = new THREE.Mesh(g, M.steel);
      m.position.x = (x0 + x1) / 2;
      crank.add(m);
    };
    XC.forEach((xc, i) => {
      const throwG = new THREE.Group();
      throwG.rotation.x = PIN_PH[i]!;
      for (const s of [-1, 1]) {
        const w = new THREE.Mesh(web, M.steel);
        w.position.x = xc + s * 0.075;
        throwG.add(w);
      }
      const p = new THREE.Mesh(pinG, M.steel);
      p.position.set(xc, R, 0);
      throwG.add(p);
      crank.add(throwG);
      if (i < 3) jour(xc + 0.105, XC[i + 1]! - 0.105);
    });
    jour(-2.32, XC[0]! - 0.105);
    jour(XC[3]! + 0.105, 2.32);
  }
  // 플라이휠 (링 기어 · 덜어낸 구멍 · 볼트)
  {
    const holes = [circlePath(0.115, 0, 0, 32, true)];
    for (let i = 0; i < 6; i++) {
      const a = (i / 6) * TAU;
      holes.push(circlePath(0.075, Math.cos(a) * 0.31, Math.sin(a) * 0.31, 24, true));
      holes.push(circlePath(0.016, Math.cos(a + 0.52) * 0.17, Math.sin(a + 0.52) * 0.17, 12, true));
    }
    const g = bevelExtrude(toothed(0.52, 90, 0.025, holes), 0.08, 0.01, 4, 2).rotateY(Math.PI / 2);
    const fw = new THREE.Mesh(g, M.iron);
    fw.position.x = -2.24;
    crank.add(fw);
  }
  // 크랭크 풀리 (톱니 24)
  const PR = 0.09;
  {
    const g = bevelExtrude(toothed(PR, 24, 0.012, [circlePath(0.03, 0, 0, 16, true)]), 0.09, 0.006, 3, 1).rotateY(Math.PI / 2);
    const m = new THREE.Mesh(g, M.steel);
    m.position.x = 2.27;
    crank.add(m);
  }

  /* ── 피스톤 · 커넥팅 로드 · 밸브 · 플러그 (실린더마다) ── */
  const pistonG = lathe(
    [
      [0, 0.04],
      [0.255, 0.04],
      [0.262, -0.19],
      [0.275, -0.2],
      [0.295, -0.19],
      [0.295, 0.085],
      [0.276, 0.09],
      [0.276, 0.105],
      [0.295, 0.11],
      [0.295, 0.125],
      [0.276, 0.13],
      [0.276, 0.145],
      [0.295, 0.15],
      [0.295, 0.165],
      [0.276, 0.17],
      [0.276, 0.185],
      [0.295, 0.19],
      [0.297, 0.212],
      [0.287, 0.224],
      [0.16, 0.234],
      [0, 0.24],
    ],
    56,
  );
  const ppinG = new THREE.CylinderGeometry(0.045, 0.045, 0.56, 20).rotateZ(Math.PI / 2);
  // 커넥팅 로드: 대단(지름 큰 고리) · 소단 · 가운데 얇은 판 + 양쪽 테 (I 단면)
  const rodG = (() => {
    const R1 = 0.15;
    const R2 = 0.08;
    const w1 = 0.07;
    const w2 = 0.04;
    const ta = Math.acos(w1 / R1);
    const tb = Math.acos(w2 / R2);
    const yj1 = Math.sqrt(R1 * R1 - w1 * w1);
    const sb = Math.sqrt(R2 * R2 - w2 * w2);
    const s = new THREE.Shape();
    s.moveTo(w1, yj1);
    s.lineTo(w2, L - sb);
    s.absarc(0, L, R2, -tb, Math.PI + tb, false);
    s.lineTo(-w1, yj1);
    s.absarc(0, 0, R1, Math.PI - ta, TAU + ta, false);
    s.holes.push(circlePath(0.082, 0, 0, 32, true), circlePath(0.047, 0, L, 24, true));
    return { web: bevelExtrude(s, 0.045, 0.008, 32, 2).rotateY(Math.PI / 2), edge: [new THREE.Vector2(w1, yj1), new THREE.Vector2(w2, L - sb)] };
  })();
  const railG = new THREE.BoxGeometry(0.075, 1, 0.026);
  const boltG = new THREE.CylinderGeometry(0.022, 0.022, 0.04, 6);
  const valveG = lathe(
    [
      [0, -0.012],
      [0.088, -0.012],
      [0.092, -0.004],
      [0.06, 0.01],
      [0.025, 0.04],
      [0.017, 0.07],
      [0.017, 0.43],
      [0, 0.43],
    ],
    32,
  );
  const tappetG = lathe(
    [
      [0, 0],
      [0.055, 0],
      [0.055, 0.06],
      [0, 0.06],
    ],
    32,
  );
  const SPR0 = DECK + 0.07 + 0.04;
  const SPR1 = CAM_Y - RB - 0.06 - 0.012;
  const springG = (() => {
    const turns = 7;
    const pts: THREE.Vector3[] = [];
    for (let i = 0; i <= turns * 24; i++) {
      const a = (i / 24) * TAU;
      pts.push(new THREE.Vector3(Math.cos(a) * 0.045, i / (turns * 24), Math.sin(a) * 0.045));
    }
    return new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), turns * 24, 0.008, 6, false);
  })();
  const guideG = new THREE.CylinderGeometry(0.03, 0.03, 0.06, 16).translate(0, DECK + 0.07 + 0.03, 0);
  const plugParts = [
    { g: new THREE.CylinderGeometry(0.035, 0.035, 0.16, 16).translate(0, DECK + 0.02 + 0.08, 0), m: M.steel },
    { g: new THREE.CylinderGeometry(0.055, 0.055, 0.07, 6).translate(0, DECK + 0.18 + 0.035, 0), m: M.chrome },
    { g: lathe([[0, 0], [0.042, 0], [0.042, 0.06], [0.032, 0.1], [0.032, 0.3], [0.02, 0.36], [0, 0.36]], 24).translate(0, DECK + 0.25, 0), m: ceramic },
    { g: new THREE.CylinderGeometry(0.014, 0.018, 0.06, 12).translate(0, DECK + 0.64, 0), m: M.steel },
  ];
  const glowG = new THREE.CylinderGeometry(0.29, 0.29, 1, 40);
  interface Cyl {
    piston: THREE.Group;
    rod: THREE.Group;
    valves: { m: THREE.Mesh; tap: THREE.Mesh; spr: THREE.Mesh; z: number }[];
    glow: THREE.Mesh;
    glowM: THREE.MeshBasicMaterial;
    spark: THREE.Mesh;
    parts: THREE.Object3D[];
  }
  const cyls: Cyl[] = [];
  const sparkM = new THREE.MeshBasicMaterial({ color: 0xbfe6ff, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
  own.push(sparkM);
  XC.forEach((xc) => {
    const piston = new THREE.Group();
    piston.add(new THREE.Mesh(pistonG, pistonM));
    piston.add(new THREE.Mesh(ppinG, M.chrome));
    piston.position.x = xc;
    const rod = new THREE.Group();
    rod.add(new THREE.Mesh(rodG.web, M.steel));
    const [a, b] = rodG.edge as [THREE.Vector2, THREE.Vector2];
    for (const s of [-1, 1]) {
      const rail = new THREE.Mesh(railG, M.steel);
      between(rail, new THREE.Vector3(0, a.y, -s * (a.x - 0.012)), new THREE.Vector3(0, b.y, -s * (b.x - 0.012)));
      rail.scale.x = 1;
      rod.add(rail);
      const bolt = new THREE.Mesh(boltG, M.chrome);
      bolt.position.set(0, -0.125, s * 0.075);
      rod.add(bolt);
    }
    rod.position.x = xc;
    const valves: Cyl['valves'] = [];
    for (const z of [-CAM_Z, CAM_Z]) {
      const v = new THREE.Mesh(valveG, M.steel);
      const tap = new THREE.Mesh(tappetG, M.chrome);
      const spr = new THREE.Mesh(springG, M.copper);
      const gd = new THREE.Mesh(guideG, cast);
      v.position.set(xc, 0, z);
      tap.position.set(xc, 0, z);
      spr.position.set(xc, SPR0, z);
      gd.position.set(xc, 0, z);
      eng.add(v, tap, spr, gd);
      valves.push({ m: v, tap, spr, z });
    }
    const plug = new THREE.Group();
    for (const p of plugParts) plug.add(new THREE.Mesh(p.g, p.m));
    plug.position.x = xc;
    const glowM = new THREE.MeshBasicMaterial({ color: 0xff7a1a, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false });
    own.push(glowM);
    const glow = new THREE.Mesh(glowG, glowM);
    glow.position.x = xc;
    const spark = new THREE.Mesh(new THREE.SphereGeometry(0.05, 12, 8), sparkM);
    spark.position.set(xc, DECK - 0.01, 0);
    eng.add(piston, rod, plug, glow, spark);
    cyls.push({ piston, rod, valves, glow, glowM, spark, parts: [piston, rod, plug, glow, spark, ...valves.flatMap((x) => [x.m, x.tap, x.spr])] });
  });

  /* ── 캠축 두 개 (흡기 뒤 · 배기 앞) ── */
  const lobeG = (() => {
    const s = new THREE.Shape();
    const N = 96;
    for (let i = 0; i <= N; i++) {
      const f = (i / N) * TAU;
      const r = camProf(wrap(f));
      const x = -r * Math.sin(f);
      const y = r * Math.cos(f);
      if (i === 0) s.moveTo(x, y);
      else s.lineTo(x, y);
    }
    return bevelExtrude(s, 0.08, 0.008, 8, 2).rotateY(Math.PI / 2);
  })();
  const camShaftG = new THREE.CylinderGeometry(0.035, 0.035, 4.7, 20).rotateZ(Math.PI / 2);
  const cams: { g: THREE.Group; intake: boolean }[] = [];
  const CAM_SPR_R = PR * 2;
  for (const intake of [true, false]) {
    const g = new THREE.Group();
    g.position.set(0, CAM_Y, intake ? -CAM_Z : CAM_Z);
    const sh = new THREE.Mesh(camShaftG, M.steel);
    sh.position.x = 0.1;
    g.add(sh);
    XC.forEach((xc, i) => {
      const lobe = new THREE.Mesh(lobeG, M.steel);
      lobe.position.x = xc;
      // 들림이 가장 클 때 = 흡입은 720° 중 450°, 배기는 270° (그때 코가 아래 −Y)
      lobe.rotation.x = Math.PI - (FIRE[i]! + ((intake ? 450 : 270) * Math.PI) / 180) / 2;
      g.add(lobe);
    });
    // 캠 스프로킷 (톱니 48, 크랭크의 두 배 → 반 속도)
    const holes = [circlePath(0.03, 0, 0, 16, true)];
    for (let k = 0; k < 4; k++) holes.push(circlePath(0.045, Math.cos((k / 4) * TAU) * 0.1, Math.sin((k / 4) * TAU) * 0.1, 20, true));
// … (225줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### i541 견본 항목 — `src/demos/demosMechEngine.ts:750`
```ts
  i541: {
    kind: '3d',
    caption: '직렬 4기통 단면 — 크랭크 각 하나로 피스톤 · 로드 · 캠(½ 속도) · 밸브 · 벨트가 모두 식으로 움직이고, 폭발 행정은 주황 · 흡입은 푸르게',
    make: () => makeEngine(),
  }
```
