# AI 꾸러미 — 그래프 + 3D 연동 (슬라이더 하나로) — Linked views (3D model + graph)
> 각도 θ 하나로 3D 피스톤과 옆 그래프를 함께 움직이고, 3D 피스톤 높이와 그래프 위 점을 선으로 이어 「같은 값」임을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i39

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

## 주문서

### 만들어 줘: 그래프 + 3D 연동 (슬라이더 하나로) — Linked views (3D model + graph)

#### 1. 목표
엔진 피스톤 위치를 3D 와 그래프로 나란히 보여 줘 — 값 하나(각도)로 둘이 함께 움직이고, 3D 의 그 높이와 그래프 위 지금 점이 선으로 이어지게. 분위기는 어두운 남색 바탕 + 노란 곡선.

#### 2. 핵심 기술 용어
- **Linked views (3D model + graph)** — 3D 와 그래프를 같은 값으로 묶기
- **Single source of truth parameter** — 값 하나(θ)에서 모든 그림이 나온다
- **Vector3.project (world → screen)** — 3D 점을 화면 좌표로 바꿔 2D 그림과 맞추기
- **Slider-crank kinematics** — 크랭크 · 커넥팅 로드 · 피스톤 위치 식

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

#### 4. 조건
- 값은 θ 하나만 들고, 3D · 그래프 · 글씨가 모두 그 값에서 계산되게 (따로 시간을 재지 않기)
- 그래프 세로 눈금 = 3D 피스톤의 화면 높이 (project 로 맞추기)
- 3D 피스톤 ↔ 그래프 점을 잇는 점선, 지금 θ 를 도(°)로 표시
- 실제 곡선과 비교 곡선(사인)을 함께 — 차이가 보이게
- 첫 장면은 compileAsync 로 셰이더를 굽고 나서 그리기 (첫 프레임 멈춤 막기)

#### 5. 완성 기준 (이게 보이면 성공)
- 피스톤이 오르내리는 높이와 그래프 위 빨간 점의 높이가 늘 같다
- 슬라이더로 θ 를 끌면 3D 와 그래프가 함께 멈추고 함께 움직인다
- 노란 실제 곡선과 흰 점선 사인 곡선이 거의 겹치지만 꼭대기 · 바닥 모양이 조금 다르다
- 화면 비율이 바뀌어도 그래프 눈금이 3D 높이와 맞는다

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

## 원리
- 모든 그림은 각도 θ 하나에서 나온다: 3D 엔진은 eng.set(θ) 로 크랭크 · 로드 · 피스톤을 놓고, 그 결과 피스톤 높이 yp 를 돌려준다.
- 피스톤 높이 식: yp = r·cos θ + √(L² − r²·sin² θ) (r = 크랭크 반지름, L = 로드 길이). 비교용 점선은 L + r·cos θ (순수 사인꼴).
- 그래프 세로 눈금을 3D 와 맞추려고, 피스톤의 가장 높은 · 낮은 높이를 Vector3.project 로 화면 y 로 바꿔 그래프의 위 · 아래로 쓴다.
- 지금 θ 의 그래프 점과 3D 피스톤 윗면의 화면 위치를 점선으로 이으면, 「3D 의 이 높이 = 그래프의 이 점」이 바로 보인다.
- 「저절로 돌리기」를 끄면 슬라이더 θ 가 그대로 둘 다를 움직인다.

## 핵심 코드 — θ 하나 → 3D 피스톤 + 그래프 점 + 잇는 선
(발췌: demos/demosStructure.ts makeEngine set() · i39 update() 를 정리)
```ts
import * as THREE from 'three';
// 1) 3D: 각도 하나로 크랭크 · 로드 · 피스톤 놓기
const set = (theta: number): number => {
  crank.rotation.z = -theta;
  const px = r * Math.sin(theta), py = r * Math.cos(theta);
  const yp = r * Math.cos(theta) + Math.sqrt(L * L - r * r * Math.sin(theta) ** 2); // 피스톤 높이
  rod.position.set(px / 2, (py + yp) / 2, 0.24);
  rod.rotation.z = Math.atan2(px, yp - py);
  piston.position.y = yp + 0.25;
  return yp;
};
// 2) 매 프레임: 같은 θ 로 3D 와 그래프
const yp = set(th);
cam.updateMatrixWorld();
const scr = (yy: number) => ((1 - new THREE.Vector3(0, yy + offY, 0).project(cam).y) / 2) * h; // 3D 높이 → 화면 y
const sTop = scr(L + r), sBot = scr(L - r);
const Y = (v: number) => sBot + (sTop - sBot) * ((v - (L - r)) / (2 * r)); // 그래프 눈금 = 3D 눈금
const span = Math.PI * 4;
const X = (a: number) => gx0 + (gx1 - gx0) * ((a % span) / span);
g.beginPath();
for (let k = 0; k <= 200; k++) {
  const a = (k / 200) * span;
  const v = r * Math.cos(a) + Math.sqrt(L * L - r * r * Math.sin(a) ** 2);
  if (k) g.lineTo(X(a), Y(v)); else g.moveTo(X(a), Y(v));
}
g.strokeStyle = '#ffc23a'; g.lineWidth = 3; g.stroke();
// 3) 3D 피스톤 윗면 ↔ 그래프 점 잇기
const a = ((th % span) + span) % span;
const pistonX = ((new THREE.Vector3(0.45, pistonTop + offY, 0).project(cam).x + 1) / 2) * leftW;
g.setLineDash([3, 4]); g.strokeStyle = '#ff6b8b';
g.beginPath(); g.moveTo(pistonX, scr(pistonTop)); g.lineTo(X(a), Y(yp)); g.stroke();
g.setLineDash([]);
```

## 흔한 실수 · 확인 목록
- [ ] **3D 와 그래프가 각자 시간을 재면 조금씩 어긋난다** — 값 θ 하나만 들고 둘 다 그 값에서 계산한다.
- [ ] **그래프 세로 눈금을 따로 정하면 3D 피스톤 높이와 안 맞는다** — 피스톤 최고 · 최저 높이를 project 로 화면 y 로 바꿔 그래프 눈금으로 쓴다.
- [ ] **공용 렌더러로 처음 그릴 때 셰이더 컴파일로 1~2초 멈춘다** — 견본처럼 처음엔 compileAsync 만 하고 끝난 뒤부터 그린다.

## 완성 기준 체크리스트
- [ ] 피스톤이 오르내리는 높이와 그래프 위 빨간 점의 높이가 늘 같다
- [ ] 슬라이더로 θ 를 끌면 3D 와 그래프가 함께 멈추고 함께 움직인다
- [ ] 노란 실제 곡선과 흰 점선 사인 곡선이 거의 겹치지만 꼭대기 · 바닥 모양이 조금 다르다
- [ ] 화면 비율이 바뀌어도 그래프 눈금이 3D 높이와 맞는다

## 이 기술 정보
- id: `i39` · 분류: 물리 · 수학 원리 › 원리 설명 · 구조 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 3D 엔진 부품 몇 개 + 2D 곡선 점 200개 둘. 3D 는 공용 렌더러로 그려 2D 캔버스에 붙인다(drawImage).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i39
- 쓰면 좋을 때: 움직이는 기계 · 물리를 식 · 그래프와 함께 설명할 때 / 「이 곡선이 무엇을 뜻하나」를 보여 줄 때 / 슬라이더 하나로 원인과 결과를 같이 보여 줄 때
- 쓰지 말 때: 값이 여러 개라 한 그래프로 못 보일 때 — 그래프를 나누거나 단계 이야기(i45)로 / 3D 가 꼭 필요 없을 때 — 2D 그림 + 그래프가 더 가볍다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### lerp — `src/demos/demosStructure.ts:32`
```ts
const lerp = (a: number, b: number, k: number): number => a + (b - a) * k;
```

### i39 견본 항목 — `src/demos/demosStructure.ts:4013`
```ts
  i39: {
    kind: 'dom',
    caption: '피스톤(왼쪽 3D)과 높이 그래프(오른쪽)가 같은 각도 θ 하나로 움직여요 — 점선은 사인 곡선, 거의 닮았어요',
    make(box) {
      ownAcquire();
      const { g, fit } = boxCanvas(box);
      const scene = baseScene('#1d2752', '#2f3f7c');
      const eng = makeEngine({ valves: false });
      eng.root.position.y = -1.2;
      scene.add(eng.root);
      const cam = new THREE.PerspectiveCamera(30, 1, 0.1, 50);
      let auto = true;
      let th = 0;
      let manual = 0;
      return {
        update(_t, dt) {
          const { w, h, dpr } = fit();
          if (auto) th += dt * 1.6;
          else th = manual;
          const yp = eng.set(th);
          const lw = w * 0.42;
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(1.85 / tn, 1.2 / (tn * (lw / h)));
          cam.position.set(0.9, 0.5, d);
          cam.lookAt(0, 0.05, 0);
          scene.updateMatrixWorld();
          g.setTransform(dpr, 0, 0, dpr, 0, 0);
          const bg = g.createLinearGradient(0, 0, 0, h);
          bg.addColorStop(0, '#1d2752');
          bg.addColorStop(1, '#2f3f7c');
          g.fillStyle = bg;
          g.fillRect(0, 0, w, h);
          glPaint(g, dpr, scene, cam, 0, 0, lw, h);
          // 그래프
          const gx0 = lw + w * 0.05;
          const gx1 = w - w * 0.04;
          const r = eng.r;
          const L = eng.L;
          const ymin = L - r;
          const ymax = L + r;
          const top = eng.pistonTop() - 0.5;
          // 3D 피스톤 높이 → 화면 y (그래프 세로 눈금을 3D 와 같게)
          cam.updateMatrixWorld();
          const scr = (yy: number): number => ((1 - new THREE.Vector3(0, yy + 0.25 + eng.root.position.y, 0).project(cam).y) / 2) * h;
          const sTop = scr(ymax);
          const sBot = scr(ymin);
          const Y = (v: number): number => lerp(sBot, sTop, (v - ymin) / (ymax - ymin));
          const span = TAU * 2;
          const X = (a: number): number => lerp(gx0, gx1, (a % span) / span);
          g.strokeStyle = 'rgba(255,255,255,0.25)';
          g.lineWidth = 1;
          g.beginPath();
          g.moveTo(gx0, sTop - 10);
          g.lineTo(gx0, sBot + 10);
          g.lineTo(gx1, sBot + 10);
          g.stroke();
          const fs = clamp(Math.min(w, h * 1.6) * 0.036, 10, 18);
          g.fillStyle = 'rgba(255,255,255,0.7)';
          g.font = `600 ${fs}px ${FONT}`;
          g.textAlign = 'right';
          g.textBaseline = 'top';
          g.fillText('θ →', gx1, sBot + 14);
          g.textAlign = 'left';
          g.textBaseline = 'bottom';
          g.fillText('피스톤 높이', gx0 + 4, sTop - 12);
          // 사인 (점선)
          g.setLineDash([5, 5]);
          g.strokeStyle = 'rgba(255,255,255,0.55)';
          g.beginPath();
          for (let k = 0; k <= 200; k++) {
            const a = (k / 200) * span;
            const v = L + r * Math.cos(a);
            if (k) g.lineTo(lerp(gx0, gx1, k / 200), Y(v));
            else g.moveTo(gx0, Y(v));
          }
          g.stroke();
          g.setLineDash([]);
          // 실제 곡선
          g.strokeStyle = '#ffc23a';
          g.lineWidth = 3;
          g.beginPath();
          for (let k = 0; k <= 200; k++) {
            const a = (k / 200) * span;
            const v = r * Math.cos(a) + Math.sqrt(L * L - r * r * Math.sin(a) ** 2);
            if (k) g.lineTo(lerp(gx0, gx1, k / 200), Y(v));
            else g.moveTo(gx0, Y(v));
          }
          g.stroke();
          const a = ((th % span) + span) % span;
          const px = X(a);
          const py = Y(yp);
          // 3D 피스톤 → 그래프 점 잇기
          const pistonY = scr(top);
          const pistonX = ((new THREE.Vector3(0.45, top + 0.25 + eng.root.position.y, 0).project(cam).x + 1) / 2) * lw;
          g.setLineDash([3, 4]);
          g.strokeStyle = '#ff6b8b';
          g.lineWidth = 1.5;
          g.beginPath();
          g.moveTo(pistonX, pistonY);
          g.lineTo(px, py);
          g.stroke();
          g.setLineDash([]);
          g.fillStyle = '#ff6b8b';
          g.beginPath();
          g.arc(px, py, 6, 0, TAU);
          g.fill();
          g.strokeStyle = '#fff';
          g.lineWidth = 2;
          g.stroke();
          g.fillStyle = '#fff';
          g.font = `700 ${fs}px ${FONT}`;
          g.textAlign = 'left';
          g.textBaseline = 'top';
          g.fillText(`θ = ${Math.round(((a % TAU) * 180) / Math.PI)}°`, gx0 + 4, 10);
        },
        controls: [
          { type: 'toggle', label: '저절로 돌리기', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '각도 θ (라디안)', min: 0, max: 12.56, step: 0.01, value: 0, on: (v) => ((auto = false), (manual = v)) },
        ],
        dispose() {
          disposeScene(scene);
          ownRelease();
          box.innerHTML = '';
        },
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [값과 그래프 연동 (끌면 바로 다시 그리기)](https://ai-techstudio.web.app/ai/t/i376.md) `i376`
- 먼저 알면 좋은 기술: [3D 를 따라가는 이름표 · 지시선](https://ai-techstudio.web.app/ai/t/i38.md) `i38`
- 다음에 해 볼 기술: [단계 이야기 (scrollytelling · 다음 단추)](https://ai-techstudio.web.app/ai/t/i45.md) `i45` · [광선 추적 광학 (2D)](https://ai-techstudio.web.app/ai/t/i40.md) `i40`
- 참고 문서: [Distill — Why Momentum Really Works (연동 그림 예)](https://distill.pub/2017/momentum/)
