# AI 꾸러미 — 흐름선 · 입자 흐름 — Particle flow visualization (streaklines)
> 날개 둘레 흐름 식(주코프스키 변환)으로 칸마다 속도를 구해 공기 알갱이 520개를 흘리고, 빠르기에 따라 노랑 · 흰 · 파랑 꼬리선으로 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i44

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

## 주문서

### 만들어 줘: 흐름선 · 입자 흐름 — Particle flow visualization (streaklines)

#### 1. 목표
비행기 날개 둘레 흐름을 입자 꼬리로 보여 줘 — 알갱이 수백 개가 속도장을 따라 흐르고 빠른 곳은 노랑 · 느린 곳은 파랑으로, 받음각을 바꾸면 흐름이 바로 바뀌게. 분위기는 어두운 남색 바탕 빛나는 꼬리.

#### 2. 핵심 기술 용어
- **Particle flow visualization (streaklines)** — 알갱이를 흘려 꼬리로 흐름 보이기
- **Joukowski airfoil (potential flow)** — 원 둘레 흐름을 날개 모양으로 바꾸는 식
- **Midpoint (RK2) integration** — 반 걸음 앞 속도로 한 걸음 — 오일러보다 정확
- **three.js LineSegments + vertexColors** — 꼬리 선분 수천 개를 한 번에 · 선마다 색

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

#### 4. 조건
- 속도는 식으로 바로 계산 (격자 없이), 적분은 중간점(RK2)으로 한 프레임을 여러 번 나눠서
- 한 걸음 크기는 dt 를 0.05 로 잘라 너무 크지 않게, 속도가 너무 크면(4 넘음) 줄이기
- 꼬리 선분은 LineSegments 하나 + vertexColors — 알갱이마다 Line 객체를 만들지 않기
- 화면 밖 · 날개 안 알갱이는 다시 뿌리기
- 받음각 슬라이더, 빠름 · 보통 · 느림 색 범례

#### 5. 완성 기준 (이게 보이면 성공)
- 알갱이가 날개 위 · 아래로 갈라져 흐르고, 날개 위쪽이 노랗게(빠르게) 보인다
- 받음각을 키우면 위 · 아래 빠르기 차이가 커지고, 음수로 하면 반대가 된다
- 알갱이가 날개를 뚫고 지나가지 않는다
- 꼬리선이 끊김 없이 부드럽게 흐른다

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

## 원리
- 날개 둘레 공기 속도는 「원 둘레 흐름 + 소용돌이(순환)」를 주코프스키 변환 z = ζ + 1/ζ 로 날개 모양에 옮긴 식으로 계산한다 (견본은 복소수 곱 · 나눗셈 · 제곱근을 직접 씀).
- 알갱이는 원 평면(ζ) 좌표로 들고 다니며 한 프레임을 3번 나눠 중간점(RK2)으로 옮긴다 — 날개 앞 빠른 곳에서도 튀지 않는다.
- 화면 밖으로 나가거나 날개 안으로 들어간 알갱이는 왼쪽 끝에서 다시 태어난다.
- 알갱이마다 꼬리 점 12개를 두 프레임마다 한 칸씩 밀어, 선분 11개를 LineSegments 하나에 모아 그린다. 색은 빠르기(0.82 ~ 1.42)로 파랑 → 흰 → 노랑, 꼬리 끝으로 갈수록 어둡게.
- 받음각을 바꾸면 날개 모양을 다시 만들고 알갱이를 다시 뿌린다.

## 핵심 코드 — 중간점 적분으로 알갱이 옮기기 + 꼬리 선분 버퍼 채우기
(발췌: demos/demosStructure.ts i44 update() 를 정리 (vel · toDisplay · spawn 은 같은 견본의 주코프스키 식))
```ts
import * as THREE from 'three';
const N = 520, TR = 12;
const pos = new Float32Array(N * (TR - 1) * 6), col = new Float32Array(N * (TR - 1) * 6);
const lg = new THREE.BufferGeometry();
lg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
lg.setAttribute('color', new THREE.BufferAttribute(col, 3));
scene.add(new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.95 })));
const slow = new THREE.Color(0x4a8bff), mid = new THREE.Color(0xf4f8ff), fast = new THREE.Color(0xffb52e), cc = new THREE.Color();

function update(dt: number, frame: number) {
  const step = Math.min(dt, 0.05) * 1.6;
  for (let i = 0; i < N; i++) {
    const p = P[i];
    for (let k = 0; k < 3; k++) {             // 한 프레임을 3번 나눠
      const h = step / 3;
      const [v1] = vel(p.z);
      const zm: [number, number] = [p.z[0] + v1[0] * h * 0.5, p.z[1] + v1[1] * h * 0.5];
      const [v2, sp] = vel(zm);                // 반 걸음 앞 속도로
      p.z = [p.z[0] + v2[0] * h, p.z[1] + v2[1] * h];
      p.sp = sp;
    }
    const D = toDisplay(p.z);
    if (D[0] > 4.4 || Math.abs(D[1]) > 2.6 || insideWing(p.z) || !Number.isFinite(D[0])) { P[i] = spawn(false); continue; }
    if (frame % 2 === 0) { p.trail.pop(); p.trail.unshift([D[0], D[1]]); } else p.trail[0] = [D[0], D[1]];
  }
  let o = 0;
  for (const p of P) {
    const k = Math.min(1, Math.max(0, (p.sp - 0.82) / 0.6)); // 빠르기 → 0~1
    if (k < 0.4) cc.copy(slow).lerp(mid, k / 0.4); else cc.copy(mid).lerp(fast, (k - 0.4) / 0.6);
    for (let s = 0; s < TR - 1; s++) {
      const a = p.trail[s], b = p.trail[s + 1], fade = 1 - s / (TR - 1);
      pos.set([a[0] * SC, a[1] * SC, p.depth, b[0] * SC, b[1] * SC, p.depth], o * 6);
      col.set([cc.r * fade, cc.g * fade, cc.b * fade, cc.r * fade * 0.7, cc.g * fade * 0.7, cc.b * fade * 0.7], o * 6);
      o++;
    }
  }
  lg.attributes.position.needsUpdate = true;
  lg.attributes.color.needsUpdate = true;
}
```

## 흔한 실수 · 확인 목록
- [ ] **오일러(속도 × dt 한 번)로 옮기면 빠른 곳에서 알갱이가 날개 속으로 튄다** — 중간점(RK2)으로, 한 프레임을 3번 나눠 옮기고 속도 크기에 상한을 둔다.
- [ ] **알갱이마다 THREE.Line 을 만들면 그리기 호출이 수백 번이다** — 모든 꼬리를 LineSegments 하나의 버퍼에 넣고 정점 색으로 칠한다.
- [ ] **화면 밖으로 나간 알갱이를 그대로 두면 흐름이 점점 비어 간다** — 나가거나 날개 안에 든 알갱이는 바로 왼쪽 끝에서 다시 태어나게 한다.

## 완성 기준 체크리스트
- [ ] 알갱이가 날개 위 · 아래로 갈라져 흐르고, 날개 위쪽이 노랗게(빠르게) 보인다
- [ ] 받음각을 키우면 위 · 아래 빠르기 차이가 커지고, 음수로 하면 반대가 된다
- [ ] 알갱이가 날개를 뚫고 지나가지 않는다
- [ ] 꼬리선이 끊김 없이 부드럽게 흐른다

## 이 기술 정보
- id: `i44` · 분류: 물리 · 수학 원리 › 원리 설명 · 구조 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 알갱이 520 × 한 프레임 3걸음 × 속도 계산 2번 ≈ 3,100번 복소수 계산 + 꼬리 선분 5,720개 버퍼 갱신. PC 는 가볍고 폰은 알갱이 수를 줄이면 된다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i44
- 쓰면 좋을 때: 공기 · 물 · 피처럼 안 보이는 흐름을 보여 줄 때 / 빠른 곳 · 느린 곳(압력 차)을 색으로 설명할 때 / 속도장이 식으로 주어질 때
- 쓰지 말 때: 흐름이 서로 밀고 당기며 바뀌어야 할 때 — 대신 GPU 유체(i46) / 알갱이가 수만 개 — CPU 계산 대신 GPU(셰이더) 입자로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i44 견본 항목 — `src/demos/demosStructure.ts:4636`
```ts
  i44: {
    kind: '3d',
    caption: '날개 둘레 공기 알갱이 — 위쪽은 빠르게(노랑) 아래쪽은 느리게(파랑) 흘러 양력이 생겨요 (주코프스키 흐름 식으로 계산)',
    make() {
      type Cx = [number, number];
      const mul = (a: Cx, b: Cx): Cx => [a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0]];
      const div = (a: Cx, b: Cx): Cx => {
        const d = b[0] * b[0] + b[1] * b[1] || 1e-9;
        return [(a[0] * b[0] + a[1] * b[1]) / d, (a[1] * b[0] - a[0] * b[1]) / d];
      };
      const csqrt = (a: Cx): Cx => {
        const r = Math.hypot(a[0], a[1]);
        const re = Math.sqrt(Math.max(0, (r + a[0]) / 2));
        const im = Math.sign(a[1] || 1) * Math.sqrt(Math.max(0, (r - a[0]) / 2));
        return [re, im];
      };
      const MU: Cx = [-0.09, 0.08];
      const R = Math.hypot(1 - MU[0], -MU[1]);
      const beta = -Math.atan2(-MU[1], 1 - MU[0]);
      let alpha = (7 * Math.PI) / 180;
      const SC = 0.55;
      const toZ = (z: Cx): Cx => [z[0] + z[0] / (z[0] ** 2 + z[1] ** 2), z[1] - z[1] / (z[0] ** 2 + z[1] ** 2)];
      const rot = (a: Cx, ang: number): Cx => [a[0] * Math.cos(ang) - a[1] * Math.sin(ang), a[0] * Math.sin(ang) + a[1] * Math.cos(ang)];
      /** ζ 평면 속도 dζ/dt 와 실제 빠르기 */
      const vel = (zeta: Cx): [Cx, number] => {
        const s: Cx = [zeta[0] - MU[0], zeta[1] - MU[1]];
        const G = -4 * Math.PI * R * Math.sin(alpha + beta);
        const ea: Cx = [Math.cos(alpha), -Math.sin(alpha)];
        const eb: Cx = [Math.cos(alpha), Math.sin(alpha)];
        const s2 = mul(s, s);
        let dw = mul(eb, [R * R, 0]);
        dw = div(dw, s2);
        dw = [ea[0] - dw[0], ea[1] - dw[1]];
        const circ = div([0, -G / (2 * Math.PI)], s);
        dw = [dw[0] + circ[0], dw[1] + circ[1]];
        const z2 = mul(zeta, zeta);
        const dz = [1 - div([1, 0], z2)[0], -div([1, 0], z2)[1]] as Cx;
        const conjV = div(dw, dz);
        const v: Cx = [conjV[0], -conjV[1]];
        const speed = Math.hypot(v[0], v[1]);
        const dzeta = div(v, dz);
        const m = Math.hypot(dzeta[0], dzeta[1]);
        const k = m > 4 ? 4 / m : 1;
        return [[dzeta[0] * k, dzeta[1] * k], speed];
      };
      const fromDisplay = (D: Cx): Cx | null => {
        const z = rot(D, alpha);
        const q = csqrt([z[0] * z[0] - z[1] * z[1] - 4, 2 * z[0] * z[1]]);
        const a: Cx = [(z[0] + q[0]) / 2, (z[1] + q[1]) / 2];
        const b: Cx = [(z[0] - q[0]) / 2, (z[1] - q[1]) / 2];
        const da = Math.hypot(a[0] - MU[0], a[1] - MU[1]);
        const db = Math.hypot(b[0] - MU[0], b[1] - MU[1]);
        const best = da > db ? a : b;
        return Math.max(da, db) > R * 1.01 ? best : null;
      };
      const toDisplay = (zeta: Cx): Cx => rot(toZ(zeta), -alpha);
      const scene = baseScene('#0e1636', '#22336e');
      const wingMat = std(0xf2f5ff, 0.35, 0.1);
      let wing: THREE.Mesh | null = null;
      const buildWing = (): void => {
        if (wing) {
          scene.remove(wing);
          wing.geometry.dispose();
        }
        const sh = new THREE.Shape();
        for (let i = 0; i <= 120; i++) {
          const th = (i / 120) * TAU;
          const D = toDisplay([MU[0] + R * Math.cos(th), MU[1] + R * Math.sin(th)]);
          if (i) sh.lineTo(D[0] * SC, D[1] * SC);
          else sh.moveTo(D[0] * SC, D[1] * SC);
        }
        const g = new THREE.ExtrudeGeometry(sh, { depth: 1.8, bevelEnabled: false, curveSegments: 1 });
        g.translate(0, 0, -0.9);
        wing = new THREE.Mesh(g, wingMat);
        scene.add(wing);
      };
      buildWing();
      const N = 520;
      const TR = 12;
      const P: { z: Cx; depth: number; trail: Cx[]; sp: number }[] = [];
      const spawn = (anywhere: boolean): { z: Cx; depth: number; trail: Cx[]; sp: number } => {
        for (let k = 0; k < 30; k++) {
          const D: Cx = anywhere ? [(Math.random() - 0.5) * 8.4, (Math.random() - 0.5) * 4.4] : [-4.2 - Math.random() * 0.3, (Math.random() - 0.5) * 4.4];
          const zt = fromDisplay(D);
          if (zt) {
            const d = toDisplay(zt);
            return { z: zt, depth: (Math.random() - 0.5) * 1.7, trail: Array.from({ length: TR }, () => [d[0], d[1]] as Cx), sp: 1 };
          }
        }
        return { z: [-4, 0], depth: 0, trail: Array.from({ length: TR }, () => [-4, 0] as Cx), sp: 1 };
      };
      for (let i = 0; i < N; i++) P.push(spawn(true));
      const segs = N * (TR - 1);
      const pos = new Float32Array(segs * 2 * 3);
      const col = new Float32Array(segs * 2 * 3);
      const lg = new THREE.BufferGeometry();
      lg.setAttribute('position', new THREE.BufferAttribute(pos, 3));
      lg.setAttribute('color', new THREE.BufferAttribute(col, 3));
      const lines = new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.95 }));
      scene.add(lines);
      const slow = new THREE.Color(0x4a8bff);
      const mid = new THREE.Color(0xf4f8ff);
      const fast = new THREE.Color(0xffb52e);
      const cc = new THREE.Color();
      const cam = new THREE.PerspectiveCamera(34, 1, 0.1, 60);
      const hud = new Hud();
      let lastAlpha = alpha;
      let frame = 0;
      return {
        scene,
        camera: cam,
        update(_t, dt) {
          if (alpha !== lastAlpha) {
            lastAlpha = alpha;
            buildWing();
            for (let i = 0; i < N; i++) P[i] = spawn(true);
          }
          const step = Math.min(dt, 0.05) * 1.6;
          frame++;
          for (let i = 0; i < N; i++) {
            const p = P[i]!;
            const n = 3;
            for (let k = 0; k < n; k++) {
              const h = step / n;
              const [v1] = vel(p.z);
              const zm: Cx = [p.z[0] + v1[0] * h * 0.5, p.z[1] + v1[1] * h * 0.5];
              const [v2, sp] = vel(zm);
              p.z = [p.z[0] + v2[0] * h, p.z[1] + v2[1] * h];
              p.sp = sp;
            }
            const D = toDisplay(p.z);
            const inside = Math.hypot(p.z[0] - MU[0], p.z[1] - MU[1]) < R * 1.002;
            if (D[0] > 4.4 || Math.abs(D[1]) > 2.6 || inside || !Number.isFinite(D[0])) {
              P[i] = spawn(false);
              continue;
            }
            if (frame % 2 === 0) {
              p.trail.pop();
              p.trail.unshift([D[0], D[1]]);
            } else p.trail[0] = [D[0], D[1]];
          }
          let o = 0;
          for (const p of P) {
            const k = clamp((p.sp - 0.82) / 0.6, 0, 1);
            if (k < 0.4) cc.copy(slow).lerp(mid, k / 0.4);
            else cc.copy(mid).lerp(fast, (k - 0.4) / 0.6);
            for (let s = 0; s < TR - 1; s++) {
              const a = p.trail[s]!;
              const b = p.trail[s + 1]!;
              const fade = 1 - s / (TR - 1);
              pos.set([a[0] * SC, a[1] * SC, p.depth, b[0] * SC, b[1] * SC, p.depth], o * 6);
              col.set([cc.r * fade, cc.g * fade, cc.b * fade, cc.r * fade * 0.7, cc.g * fade * 0.7, cc.b * fade * 0.7], o * 6);
              o++;
            }
          }
          lg.attributes['position']!.needsUpdate = true;
          lg.attributes['color']!.needsUpdate = true;
        },
        resize(w, h) {
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(1.45 / tn, 2.35 / (tn * (w / h)));
          cam.position.set(d * 0.1, d * 0.22, d);
          cam.lookAt(0, -0.05, 0);
        },
        render(r, w, h) {
          r.render(scene, cam);
          hud.draw(r, w, h, `${w}x${h}${alpha}`, (g) => {
            const s = fsz(w, h, 0.85);
            pill(g, s * 0.6, s * 0.6, `받음각 ${Math.round((alpha * 180) / Math.PI)}°`, s);
            let x = w - s * 0.6;
            for (const [n, c] of [
              ['빠름', '#e09a10'],
              ['보통', '#8a96b8'],
              ['느림', '#3a6ee0'],
            ] as [string, string][])
              x -= pill(g, x, s * 0.6, n, s * 0.8, c, '#fff', 'right') + s * 0.3;
          });
        },
        controls: [{ type: 'range', label: '받음각(°)', min: -4, max: 14, step: 1, value: 7, on: (v) => (alpha = (v * Math.PI) / 180) }],
        dispose() {
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [그래프 + 3D 연동 (슬라이더 하나로)](https://ai-techstudio.web.app/ai/t/i39.md) `i39`
- 다음에 해 볼 기술: [GPU 유체 (stable fluids — 셰이더 핑퐁)](https://ai-techstudio.web.app/ai/t/i46.md) `i46`
- 참고 문서: [Bartosz Ciechanowski — Airfoil](https://ciechanow.ski/airfoil/) · [Wikipedia — Joukowsky transform](https://en.wikipedia.org/wiki/Joukowsky_transform)
