# AI 꾸러미 — 손가락 칼날 궤적 (슬래시)
> 그은 곳에 굵어졌다 가늘어지는 빛 칼날 — 캣멀-롬 띠  
> 견본: https://ai-techstudio.web.app/#t/i213

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

## 주문서

### 만들어 줘: 손가락 칼날 궤적 (슬래시)

#### 1. 목표
숫자 베기에 「손가락 칼날 궤적 (슬래시)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 그은 곳에 굵어졌다 가늘어지는 빛 칼날 — 캣멀-롬 띠

#### 2. 핵심 기술 용어
- 손가락 칼날 궤적 (슬래시) — 그은 곳에 굵어졌다 가늘어지는 빛 칼날 — 캣멀-롬 띠

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 화면 코드와 계산 코드를 나눠서 (계산은 순수 함수)
- 한 프레임 16ms 안 — 오래 걸리는 계산은 나눠서

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「그은 곳에 굵어졌다 가늘어지는 빛 칼날 — 캣멀-롬 띠」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 그은 곳에 굵어졌다 가늘어지는 빛 칼날 — 캣멀-롬 띠


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

## 완성 기준 체크리스트
- [ ] 견본처럼 「그은 곳에 굵어졌다 가늘어지는 빛 칼날 — 캣멀-롬 띠」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i213` · 분류: 이펙트 · 연출 › 손맛 · 주스 · 공통 · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i213
- 쓰면 좋을 때: 쓰면 좋을 곳 — 숫자 베기 · 분수 자르기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### D213 — `src/demos/demosJuice.ts:1645`
```ts
const D213: Demo = {
  kind: '2d',
  caption: '숫자 열매를 쓱 — 그은 자리에 굵어졌다 가늘어지는 빛 칼날 (캣멀-롬 띠), 열매는 반으로 갈라짐',
  make() {
    let juice = true;
    let keep = 0.16;
    const FR = ['8', '½', '12', '7', '¾', '3'];
    const FCOL: [string, string][] = [
      ['#2f9e44', '#ff6b6b'],
      ['#f08c00', '#ffd8a8'],
      ['#7048e8', '#e5dbff'],
      ['#e03131', '#fff5f5'],
      ['#1c7ed6', '#d0ebff'],
      ['#f59f00', '#fff3bf'],
    ];
    const cuts = new Map<number, { at: number; ang: number }>();
    const hist: { x: number; y: number; t: number }[] = [];
    let prev: { x: number; y: number } | null = null;
    let canvas: HTMLCanvasElement | null = null;
    let ptr = { x: 0, y: 0, at: -1 };
    const onMove = (e: PointerEvent): void => {
      ptr = { x: e.offsetX, y: e.offsetY, at: performance.now() };
    };
    const SPAWN = 0.85;
    const LIFE = 2.2;
    const fruitPos = (j: number, t: number, w: number, h: number, u: number): [number, number] => {
      const a = t - j * SPAWN;
      const x0 = w * (0.22 + hash(j, 1) * 0.56);
      const vx = (w / 2 - x0) * 0.3 + (hash(j, 2) - 0.5) * 24 * u;
      const H = h * (0.62 + hash(j, 3) * 0.16) + 20 * u;
      const tp = 1.05;
      const G = (2 * H) / (tp * tp);
      return [x0 + vx * a, h + 20 * u - (G * tp * a - 0.5 * G * a * a)];
    };
    return {
      draw(g, w, h, t) {
        reset(g);
        const u = Math.min(w / 280, h / 175);
        if (!canvas) {
          canvas = g.canvas as HTMLCanvasElement;
          canvas.addEventListener('pointermove', onMove);
        }
        bg(g, w, h, '#1b3a2e', '#08160f');
        // 나무 판 결
        g.globalAlpha = 0.18;
        for (let i = 0; i < 9; i++) {
          g.fillStyle = i % 2 ? '#2f5d3a' : '#173523';
          g.fillRect(0, (i * h) / 9, w, h / 9);
        }
        g.globalAlpha = 1;
        // 손가락 위치
        let fx = 0;
        let fy = 0;
        let down = false;
        const userLive = performance.now() - ptr.at < 90;
        if (userLive) {
          fx = ptr.x;
          fy = ptr.y;
          down = true;
        } else {
          const SW = 0.8;
          const k = Math.floor(t / SW);
          const lp = (t % SW) / 0.32;
          if (lp <= 1) {
            down = true;
            // 살아 있는 열매를 지나가게
            const tm = k * SW + 0.16;
            const j = Math.floor(tm / SPAWN);
            const [tx, ty] = fruitPos(j, tm, w, h, u);
            const ang = (hash(k, 9) - 0.5) * 1.6 + (k % 2 ? Math.PI : 0);
            const L = 90 * u;
            const ax = tx - Math.cos(ang) * L;
            const ay = ty - Math.sin(ang) * L;
            const bx = tx + Math.cos(ang) * L;
            const by = ty + Math.sin(ang) * L;
            const e = lp < 0.5 ? 2 * lp * lp : 1 - Math.pow(-2 * lp + 2, 2) / 2;
            const bend = Math.sin(e * Math.PI) * 18 * u * (hash(k, 5) > 0.5 ? 1 : -1);
            fx = lerp(ax, bx, e) - Math.sin(ang) * bend;
            fy = lerp(ay, by, e) + Math.cos(ang) * bend;
          }
        }
        if (down) hist.push({ x: fx, y: fy, t });
        while (hist.length && t - hist[0]!.t > keep) hist.shift();
        if (hist.length > 80) hist.splice(0, hist.length - 80);
        // 자르기
        const j0 = Math.max(0, Math.floor((t - LIFE) / SPAWN));
        const j1 = Math.floor(t / SPAWN);
        if (down && prev) {
          for (let j = j0; j <= j1; j++) {
            if (cuts.has(j)) continue;
            const [x, y] = fruitPos(j, t, w, h, u);
            const r = 17 * u;
            const dx = fx - prev.x;
            const dy = fy - prev.y;
            const L2 = dx * dx + dy * dy;
            if (L2 < 1) continue;
            const k = clamp01(((x - prev.x) * dx + (y - prev.y) * dy) / L2);
            const d = Math.hypot(prev.x + dx * k - x, prev.y + dy * k - y);
            if (d < r) cuts.set(j, { at: t, ang: Math.atan2(dy, dx) });
          }
        }
        prev = down ? { x: fx, y: fy } : null;
        for (const key of cuts.keys()) if (key < j0 - 2) cuts.delete(key);
        // 열매
        for (let j = j0; j <= j1; j++) {
          const [x, y] = fruitPos(j, t, w, h, u);
          const r = 17 * u;
          const [rind, flesh] = FCOL[j % 6]!;
          const c = cuts.get(j);
          if (!c) {
            ball(g, x, y, r, rind, FR[j % 6]!);
            continue;
          }
          const a = t - c.at;
          for (const side of [-1, 1]) {
            const nx = -Math.sin(c.ang) * side;
            const ny = Math.cos(c.ang) * side;
            const off = 26 * u * a;
            g.save();
            g.translate(x + nx * off, y + ny * off + 60 * u * a * a);
            g.rotate(c.ang + side * a * 3);
            g.beginPath();
            g.arc(0, 0, r, side > 0 ? 0 : Math.PI, side > 0 ? Math.PI : TAU);
            g.closePath();
            g.fillStyle = rind;
            g.fill();
            g.beginPath();
            g.arc(0, 0, r * 0.82, side > 0 ? 0 : Math.PI, side > 0 ? Math.PI : TAU);
            g.closePath();
            g.fillStyle = flesh;
            g.fill();
            g.restore();
          }
          if (juice) {
            sparks(g, x, y, a, 14, j, 46 * u, [flesh, '#ffffff'], 0.5, 40 * u, 3 * u);
            if (a < 0.6) {
              g.globalAlpha = 1 - a / 0.6;
              otext(g, '+1', x, y - 20 * u - a * 30 * u, 12 * u, '#fff', '#1b3a2e', 3 * u);
              g.globalAlpha = 1;
            }
          }
        }
        // 궤적
        if (hist.length >= 2) {
          if (!juice) {
            g.strokeStyle = 'rgba(255,255,255,.7)';
            g.lineWidth = 1.5 * u;
            g.beginPath();
            hist.forEach((p, i) => (i ? g.lineTo(p.x, p.y) : g.moveTo(p.x, p.y)));
            g.stroke();
          } else {
            // 캣멀-롬으로 촘촘히
            const pts: [number, number][] = [];
            for (let i = 0; i < hist.length - 1; i++) {
              const p0 = hist[Math.max(0, i - 1)]!;
              const p1 = hist[i]!;
              const p2 = hist[i + 1]!;
              const p3 = hist[Math.min(hist.length - 1, i + 2)]!;
              for (let s = 0; s < 4; s++) {
                const k = s / 4;
                const k2 = k * k;
                const k3 = k2 * k;
                const cr = (a0: number, a1: number, a2: number, a3: number): number => 0.5 * (2 * a1 + (-a0 + a2) * k + (2 * a0 - 5 * a1 + 4 * a2 - a3) * k2 + (-a0 + 3 * a1 - 3 * a2 + a3) * k3);
                pts.push([cr(p0.x, p1.x, p2.x, p3.x), cr(p0.y, p1.y, p2.y, p3.y)]);
              }
            }
            const last = hist[hist.length - 1]!;
            pts.push([last.x, last.y]);
            const ribbon = (W: number, col: string): void => {
              const L: [number, number][] = [];
              const R: [number, number][] = [];
              const n = pts.length;
              for (let i = 0; i < n; i++) {
                const f = i / (n - 1);
                const a = pts[Math.max(0, i - 1)]!;
                const b = pts[Math.min(n - 1, i + 1)]!;
                let tx = b[0] - a[0];
                let ty = b[1] - a[1];
                const tl = Math.hypot(tx, ty) || 1;
                tx /= tl;
                ty /= tl;
                const wd = W * Math.sin(Math.PI * Math.min(0.999, Math.pow(f, 1.6)));
                const p = pts[i]!;
                L.push([p[0] - ty * wd, p[1] + tx * wd]);
                R.push([p[0] + ty * wd, p[1] - tx * wd]);
              }
              g.beginPath();
              L.forEach((p, i) => (i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])));
              for (let i = R.length - 1; i >= 0; i--) g.lineTo(R[i]![0], R[i]![1]);
              g.closePath();
              g.fillStyle = col;
              g.fill();
            };
            g.save();
            g.globalCompositeOperation = 'lighter';
            ribbon(11 * u, 'rgba(70,190,255,.25)');
            ribbon(6.5 * u, 'rgba(140,225,255,.55)');
            ribbon(2.6 * u, 'rgba(255,255,255,.95)');
            glow(g, last.x, last.y, 12 * u, '#bdf0ff', 0.6);
            g.restore();
          }
        }
        reset(g);
      },
      controls: [
        tgl('빛 칼날 (끄면 그냥 선)', juice, (v) => (juice = v)),
        rng('남는 시간 (초)', 0.05, 0.4, 0.01, keep, (v) => (keep = v)),
      ],
      dispose() {
        canvas?.removeEventListener('pointermove', onMove);
      },
    };
  },
};
```

## 관련 기술
- 이 효과를 만든 기술: [꼬리 · 리본 (궤적)](https://ai-techstudio.web.app/ai/t/i65.md) `i65`
