# AI 꾸러미 — 부드러운 붓 선 (곡선 보정 · 압력) — Stroke smoothing (moving average)
> 손이 찍은 떨리는 점을 이동 평균으로 다듬고 중점 2차 곡선으로 잇고, 빠르게 그은 곳은 가늘게 해, 서툰 손도 매끈한 붓 선이 되게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i312

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

## 주문서

### 만들어 줘: 부드러운 붓 선 (곡선 보정 · 압력) — Stroke smoothing (moving average)

#### 1. 목표
그림 그리기 놀이에 부드러운 붓 선을 넣어 줘 — 흔들리는 손 점을 이동 평균 · 2차 곡선으로 매끈하게, 빠르면 가늘게. 분위기는 깔끔한 펜 선.

#### 2. 핵심 기술 용어
- **Stroke smoothing (moving average)** — 이동 평균 — 앞뒤 점과 평균 내 떨림 줄이기
- **Midpoint quadratic Bézier** — 점 사이 중점을 2차 곡선으로 잇기
- **Velocity-based stroke width** — 빠르면 가늘게 — 붓 압력 흉내
- **Taper** — 선 시작 · 끝을 가늘게

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 거의 같은 자리 점(0.4px 안)은 버리기 — 멈춘 손가락이 점을 쌓아 굵기가 튄다
- 평균 칸 수는 끝에서 줄이기 (끝점이 안쪽으로 끌려오지 않게)
- 굵기는 바로 바꾸지 말고 목표로 조금씩 따라가기
- 보정 끔/켬 · 손 점 보기 토글로 원래 점과 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 빨간 손 점은 떨리는데 그 위 검은 선은 매끈하다
- 빠르게 그은 곳은 가늘고 천천히 그은 곳은 굵다, 시작 · 끝은 가늘어진다
- 보정 세기 0 이면 점을 그대로 잇고, 10 이면 크게 둥글어진다

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

## 원리
- 이동 평균: 점 i 를 앞뒤 k 칸(기본 4) 점들의 평균으로 바꾼다. 양 끝에선 k 를 줄여 끝점이 끌려가지 않게.
- 평균 낸 점 사이 중점 m0 · m1 을 잇되, 그 사이 점 c 를 조절점으로 한 2차 베지에로 4토막씩 — 모서리 없이 둥글게 이어진다.
- 두 점 사이 거리 = 빠르기. 목표 굵기 = 4.6 − 빠르기 × 0.5 (1.2 ~ 4.6), 지금 굵기는 목표 쪽으로 0.22 씩만 따라가 갑자기 변하지 않는다.
- 처음 · 끝 6점은 굵기를 0.3 배까지 줄여 붓을 대고 떼는 느낌을 낸다.

## 핵심 코드 — 이동 평균 + 빠르기 굵기 + 중점 2차 곡선
(발췌: demos/demosDrawTools.ts smoothBrush() smoothed() 를 정리)
```ts
type P = { x: number; y: number };
const dist = (a: P, b: P) => Math.hypot(a.x - b.x, a.y - b.y);
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));

function smoothed(raw: P[], win = 4): { p: P; w: number }[] {
  const n = raw.length;
  if (n < 2) return raw.map((p) => ({ p, w: 3 }));
  const a = raw.map((_, i) => {                       // 이동 평균 (끝에선 칸 줄임)
    const k = Math.min(win, i, n - 1 - i);
    let sx = 0, sy = 0;
    for (let j = i - k; j <= i + k; j++) { sx += raw[j].x; sy += raw[j].y; }
    return { x: sx / (2 * k + 1), y: sy / (2 * k + 1) };
  });
  const ws: number[] = [];
  let w = 0;
  for (let i = 0; i < n; i++) {                       // 빠르면 가늘게, 끝은 가늘게
    const sp = i ? dist(raw[i], raw[i - 1]) : 2;
    const target = clamp(4.6 - sp * 0.5, 1.2, 4.6);
    w = i === 0 ? target : w + (target - w) * 0.22;
    ws.push(w * (0.3 + 0.7 * Math.min(1, (i + 1) / 6, (n - i) / 6)));
  }
  const out = [{ p: a[0], w: ws[0] }];
  for (let i = 1; i < n - 1; i++) {                   // 중점 → 중점 2차 곡선, 4토막
    const m0 = { x: (a[i - 1].x + a[i].x) / 2, y: (a[i - 1].y + a[i].y) / 2 };
    const m1 = { x: (a[i].x + a[i + 1].x) / 2, y: (a[i].y + a[i + 1].y) / 2 };
    for (let s = 1; s <= 4; s++) {
      const u = s / 4, v = 1 - u;
      out.push({
        p: { x: v * v * m0.x + 2 * v * u * a[i].x + u * u * m1.x, y: v * v * m0.y + 2 * v * u * a[i].y + u * u * m1.y },
        w: (ws[i - 1] + ws[i]) / 2 + (((ws[i] + ws[i + 1]) / 2) - (ws[i - 1] + ws[i]) / 2) * u,
      });
    }
  }
  out.push({ p: a[n - 1], w: ws[n - 1] });
  return out; // 이웃 두 점마다 lineWidth = 평균 굵기로 짧게 stroke
}
```

## 흔한 실수 · 확인 목록
- [ ] **굵기를 점마다 바로 바꾸면 선이 울퉁불퉁 끊긴다** — 목표 굵기로 0.22 씩만 따라가고, 토막 굵기는 이웃 두 점의 평균으로.
- [ ] **손가락을 멈춘 자리에 점이 쌓여 굵은 혹이 생긴다** — 앞 점과 0.4px 안이면 버린다.
- [ ] **평균을 끝까지 같은 칸으로 내면 선 끝이 짧아진다** — 끝에선 남은 점 수만큼만 평균 (k = min(win, i, n − 1 − i)).

## 완성 기준 체크리스트
- [ ] 빨간 손 점은 떨리는데 그 위 검은 선은 매끈하다
- [ ] 빠르게 그은 곳은 가늘고 천천히 그은 곳은 굵다, 시작 · 끝은 가늘어진다
- [ ] 보정 세기 0 이면 점을 그대로 잇고, 10 이면 크게 둥글어진다

## 이 기술 정보
- id: `i312` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 획마다 점 수 × 평균 칸 수. 짧은 토막을 lineWidth 바꿔 여러 번 stroke — 획이 수백 개면 다 그린 획은 캔버스에 구워 둔다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i312
- 쓰면 좋을 때: 손가락 · 마우스로 그리는 모든 그림 놀이 / 아이들이 그은 삐뚤한 선을 보기 좋게
- 쓰지 말 때: 정확한 자리를 지나야 하는 선(작도 · 길 그리기) — 다듬으면 모서리가 뭉갠다. 대신 스냅(i320) / 압력 펜이 있는 기기 — PointerEvent.pressure 를 굵기에 그대로 쓰는 편이 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### smoothBrush — `src/demos/demosDrawTools.ts:810`
```ts
function smoothBrush(): Tool {
  let strokes: P[][] = [];
  let cur: P[] | null = null;
  let win = 4;
  let showRaw = true;
  let on = true;
  const INK = '#1d2747';
  function smoothed(raw: P[]): { p: P; w: number }[] {
    const n = raw.length;
    if (n < 2) return raw.map((p) => ({ p, w: 3 }));
    const a: P[] = raw.map((_, i) => {
      let sx = 0;
      let sy = 0;
      let c = 0;
      const k = Math.min(win, i, n - 1 - i);
      for (let j = i - k; j <= i + k; j++) {
        sx += raw[j]!.x;
        sy += raw[j]!.y;
        c++;
      }
      return pt(sx / c, sy / c);
    });
    const ws: number[] = [];
    let w = 0;
    for (let i = 0; i < n; i++) {
      const sp = i ? dist(raw[i]!, raw[i - 1]!) : 2;
      const target = clamp(4.6 - sp * 0.5, 1.2, 4.6);
      w = i === 0 ? target : w + (target - w) * 0.22;
      const f = Math.min(1, (i + 1) / 6, (n - i) / 6);
      ws.push(w * (0.3 + 0.7 * f));
    }
    const out: { p: P; w: number }[] = [{ p: a[0]!, w: ws[0]! }];
    for (let i = 1; i < n - 1; i++) {
      const m0 = lerpP(a[i - 1]!, a[i]!, 0.5);
      const m1 = lerpP(a[i]!, a[i + 1]!, 0.5);
      const c = a[i]!;
      for (let s = 1; s <= 4; s++) {
        const u = s / 4;
        const x = (1 - u) * (1 - u) * m0.x + 2 * (1 - u) * u * c.x + u * u * m1.x;
        const y = (1 - u) * (1 - u) * m0.y + 2 * (1 - u) * u * c.y + u * u * m1.y;
        out.push({ p: pt(x, y), w: lerp((ws[i - 1]! + ws[i]!) / 2, (ws[i]! + ws[i + 1]!) / 2, u) });
      }
    }
    out.push({ p: a[n - 1]!, w: ws[n - 1]! });
    return out;
  }
  function drawStroke(g: G, raw: P[]): void {
    if (raw.length < 2) return;
    if (!on) {
      g.strokeStyle = INK;
      g.lineWidth = 2.8;
      poly(g, raw);
      g.stroke();
    } else {
      const s = smoothed(raw);
      g.strokeStyle = INK;
      for (let i = 1; i < s.length; i++) {
        g.lineWidth = (s[i - 1]!.w + s[i]!.w) / 2;
        g.beginPath();
        g.moveTo(s[i - 1]!.p.x, s[i - 1]!.p.y);
        g.lineTo(s[i]!.p.x, s[i]!.p.y);
        g.stroke();
      }
    }
    if (showRaw) {
      g.strokeStyle = 'rgba(226,72,58,0.45)';
      g.lineWidth = 0.6;
      poly(g, raw);
      g.stroke();
      g.fillStyle = 'rgba(226,72,58,0.8)';
      for (const q of raw) {
        g.beginPath();
        g.arc(q.x, q.y, 0.85, 0, Math.PI * 2);
        g.fill();
      }
    }
  }
  return {
    cursor: () => 'pencil',
    color: () => INK,
    begin(p) {
      cur = [p];
      strokes.push(cur);
    },
    drag(p) {
      if (cur && dist(cur[cur.length - 1]!, p) > 0.4) cur.push(p);
    },
    end() {
      cur = null;
    },
    clear() {
      strokes = [];
      cur = null;
    },
    draw(g) {
      clipPaper(g, PAPER);
      for (const s of strokes) drawStroke(g, s);
      g.font = `600 8px ${F}`;
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      if (showRaw) {
        g.fillStyle = 'rgba(226,72,58,0.9)';
        g.beginPath();
        g.arc(26, 226, 1.6, 0, Math.PI * 2);
        g.fill();
        g.fillText('손이 찍은 점 (떨림)', 31, 226.5);
      }
      g.fillStyle = INK;
      g.fillRect(120, 225.5, 14, 2.2);
      g.fillText(on ? '보정한 선 — 빠르면 가늘게' : '보정 끔 — 점을 그대로 이음', 138, 226.5);
    },
    script(loop) {
      const r = rng(loop * 31 + 5);
      // 고리 글씨 (늘어진 사이클로이드) — 점 간격 = 빠르기
      const a: P[] = [];
      let t = 0;
      const ph = r() * 3;
      while (40 + 11 * t < 365) {
        a.push(pt(40 + 11 * t - 17 * Math.sin(t), 92 - 19 * Math.cos(t) + Math.sin(t * 0.3) * 4));
        t += 0.06 + 0.11 * (0.5 + 0.5 * Math.sin(t * 0.55 + ph));
      }
      const b: P[] = [];
      let x = 40;
      while (x < 362) {
        b.push(pt(x, 172 + 24 * Math.sin(x * 0.04 + ph) + 8 * Math.sin(x * 0.13)));
        x += 1.2 + 5.5 * (0.5 + 0.5 * Math.sin(x * 0.03 + ph * 2));
      }
      const sa = shaky(a, 2.4, loop + 1);
      const sb = shaky(b, 2.4, loop + 9);
      return [{ k: 'stroke', pts: sa, dur: sa.length / 130 }, WAIT(0.2), { k: 'stroke', pts: sb, dur: sb.length / 90 }, WAIT(1.8)];
    },
    controls: [
      { type: 'range', label: '보정 세기 (이동 평균 칸)', min: 0, max: 10, step: 1, value: 4, on: (v) => (win = v) },
      { type: 'toggle', label: '곡선 보정 · 굵기', value: true, on: (v) => (on = v) },
      { type: 'toggle', label: '손 점 보기', value: true, on: (v) => (showRaw = v) },
    ],
  };
}
```

### i312 견본 항목 — `src/demos/demosDrawTools.ts:3434`
```ts
  i312: dom('흔들리는 손 점(빨강)을 이동 평균 · 2차 곡선으로 매끈하게 — 빠르게 그은 곳은 가늘게', () => smoothBrush())
```

## 관련 기술
- 다음에 해 볼 기술: [도장 붓 (크레파스 · 수채 · 분필)](https://ai-techstudio.web.app/ai/t/i313.md) `i313` · [먹 붓 (붓 압력 · 갈필)](https://ai-techstudio.web.app/ai/t/i328.md) `i328`
