# AI 꾸러미 — 먹 붓 (붓 압력 · 갈필) — Ink brush stroke (sumi-e)
> 붓 길을 따라 굵기가 들어갈 때 꾹 · 몸통 · 빠질 때 가늘게 변하는 띠로 먹을 칠하고, 붓털 34가닥을 마를수록 끊기게 그어, 먹 붓의 압력과 갈필을 낸다.  
> 견본: https://ai-techstudio.web.app/#t/i328

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

## 주문서

### 만들어 줘: 먹 붓 (붓 압력 · 갈필) — Ink brush stroke (sumi-e)

#### 1. 목표
붓글씨 · 제목 연출을 먹 붓으로 그려 줘 — 꾹 눌러 굵게 시작해 가늘게 빠지고, 마른 붓끝은 결이 갈라지고, 한지에 먹이 살짝 번지게. 분위기는 한지 위 수묵화.

#### 2. 핵심 기술 용어
- **Ink brush stroke (sumi-e)** — 먹 붓 — 수묵화 붓선
- **Pressure profile (entry · body · tail)** — 들어갈 때 · 몸통 · 빠질 때 굵기 곡선
- **Dry brush (bristle gaps)** — 갈필 — 마른 붓털이 갈라져 흰 틈
- **Ink bleed (blur halo)** — 먹 번짐 — 한지에 스민 흐린 테

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

#### 4. 조건
- 굵기는 들어감 · 몸통 · 빠짐 세 부분 곱 (한 가지 사인 곡선으로 하지 않기)
- 갈필은 붓털 가닥을 잡음 문턱으로 끊기 — 끝으로 갈수록 · 바깥 가닥일수록 더 끊기게
- 몸통은 darken 으로 겹쳐 이음새 없이
- 먹 번짐은 몸통 아래 먼저, 옅게
- 한 번 굽고, 그려지는 연출은 굵은 가면 선을 길이만큼 그려 source-in 으로 보이기

#### 5. 완성 기준 (이게 보이면 성공)
- 획이 꾹 눌러 시작해 굵게 가다가 붓끝으로 가늘게 빠진다
- 갈필을 올리면 획 끝 · 가장자리가 붓털 결로 갈라져 흰 틈이 생긴다
- 먹 번짐을 올리면 획 둘레에 흐린 먹 테가 번진다
- 붓 압력 차이 0 이면 같은 굵기 선이 된다

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

## 원리
- 붓 길을 0.9 간격으로 다시 찍는다. 진행 f(0 ~ 1)마다 굵기 = 들어감(f < 0.07 에서 꾹 부풀기) × 몸통(0.8 ~ 1.06 잡음) × 빠짐(f > 0.62 부터 (1−f)/0.38 의 0.75 제곱으로 가늘게).
- 「붓 압력 차이」 press 로 이 곡선을 얼마나 쓸지 섞는다 — 0 이면 같은 굵기 선.
- 번짐: 몸통 띠를 흐림 필터 · 0.18 ~ 0.48 투명도로 먼저 깐다.
- 몸통: 3점마다 사각 조각을 darken 으로 겹쳐 칠하고, 끝으로 갈수록 먹이 말라 옅어지게 색을 밝힌다.
- 붓털 34가닥: 붓 폭 안에 가닥마다 자리를 두고 길을 따라 긋되, 「마름 × (0.05 + 1.1 f² + 바깥 가닥일수록 더)」보다 잡음이 크면 끊는다 — 끝 · 가장자리부터 갈라진다.

## 핵심 코드 — 붓 굵기 곡선 · 갈필 붓털 끊기
(발췌: demos/demosHandA.ts brushStroke() 를 정리)
```ts
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
const smooth = (e0: number, e1: number, x: number) => { const t = clamp01((x - e0) / (e1 - e0)); return t * t * (3 - 2 * t); };

/** f = 0 ~ 1 진행, noise = 길 따라 0 ~ 1 잡음 */
function brushWidth(f: number, noise: number, Wmax: number, press: number) {
  const entry = f < 0.07 ? 0.82 + 0.35 * Math.sin((f / 0.07) * Math.PI) : 1; // 꾹 대기
  const body = 0.8 + 0.26 * noise;                                          // 몸통 흔들림
  const tail = f > 0.62 ? Math.pow(clamp01((1 - f) / 0.38), 0.75) : 1;      // 붓끝 빠짐
  const prof = entry * body * Math.max(0.05, tail);
  return Wmax * (1 - press + press * prof);                                // press 0 = 같은 굵기
}
/** 붓털 k (0 ~ M-1) 가 자리 i 에서 보이나 — 마를수록 · 끝일수록 · 바깥일수록 끊김 */
function bristleVisible(k: number, M: number, f: number, n: number, dry: number, ink: number) {
  const off = k / (M - 1) - 0.5;                 // −0.5 ~ 0.5 붓 폭 안 자리
  const edgeK = Math.abs(off) * 2;
  const gapT = dry * (0.05 + 1.1 * f * f + edgeK * edgeK * 0.6) - (ink - 0.75) * 0.5;
  return n <= 1 - gapT;
}
/** 몸통 색: 끝으로 갈수록 먹이 말라 옅어짐 (v 0 = 검정) */
function inkColor(f: number, dry: number, noise: number) {
  const v = 0.07 + 0.88 * dry * smooth(0, 1, f) * (0.7 + 0.3 * noise);
  return 'rgb(' + Math.round(v * 250 + 6) + ',' + Math.round(v * 238 + 5) + ',' + Math.round(v * 220 + 4) + ')';
}
// 그리기 순서: 번짐(blur, alpha 0.18 + bleed × 0.3) → 몸통 조각(darken, 3점마다) →
// 붓털 34가닥(굵기 Wmax / 34 × 1.1 ~ 2, 보이는 구간만 lineTo, 끊기면 moveTo)
```

## 흔한 실수 · 확인 목록
- [ ] **굵기를 sin 하나로 주면 붓이 아니라 나뭇잎 모양이 된다** — 들어갈 때 꾹 · 몸통 · 빠질 때 가늘게 세 부분으로 나눈다 (빠짐은 62% 지점부터).
- [ ] **갈필을 아무 데나 무작위로 끊으면 점선처럼 보인다** — 길 따라 부드러운 잡음 + 끝 · 바깥 가닥일수록 높은 문턱 — 붓털 결 따라 길게 갈라진다.
- [ ] **몸통 조각을 source-over 로 겹치면 이음새가 진하게 보인다** — darken 으로 겹쳐 겹친 곳이 더 진해지지 않게.

## 완성 기준 체크리스트
- [ ] 획이 꾹 눌러 시작해 굵게 가다가 붓끝으로 가늘게 빠진다
- [ ] 갈필을 올리면 획 끝 · 가장자리가 붓털 결로 갈라져 흰 틈이 생긴다
- [ ] 먹 번짐을 올리면 획 둘레에 흐린 먹 테가 번진다
- [ ] 붓 압력 차이 0 이면 같은 굵기 선이 된다

## 이 기술 정보
- id: `i328` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 획 하나 = 몸통 조각 수백 개 + 붓털 34가닥 × 점 수백 + 흐림 1번. 미리 구우면 drawImage 뿐.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i328
- 쓰면 좋을 때: 한국 전통 · 붓글씨 연출 / 제목 글씨나 획이 적은 그림을 힘 있게
- 쓰지 말 때: 실시간으로 손가락 따라 그리기 — 획마다 붓털 34가닥 × 수백 점이라 무겁다. 대신 굵기 곡선만 쓰고 붓털은 손 뗀 뒤 굽기 / 작은 글씨 — 갈필 틈이 획을 끊는다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### bakeClean — `src/demos/demosHandA.ts:637`
```ts
function bakeClean(b: BK, s: Subject, mode: 'color' | 'gray' | 'line'): void {
  const g = b.g;
  if (mode !== 'line') {
    const [lc, lg] = layer(b, 0);
    for (const f of s.fills) {
      if (mode === 'color') lg.fillStyle = f.color;
      else {
        const v = Math.round(255 * (1 - f.tone * 0.86));
        lg.fillStyle = `rgb(${v},${v},${v})`;
      }
      polyPath(lg, mapPts(b, f.pts));
      lg.fill();
    }
    if (mode === 'gray') {
      lg.globalCompositeOperation = 'source-atop';
      const gr = lg.createLinearGradient(b.m, b.m, b.m + b.S, b.m + b.S);
      gr.addColorStop(0, 'rgba(255,255,255,0.25)');
      gr.addColorStop(1, 'rgba(0,0,0,0.22)');
      lg.fillStyle = gr;
      lg.fillRect(0, 0, b.m * 2 + b.S, b.m * 2 + b.S);
      lg.globalCompositeOperation = 'source-over';
    }
    blit(g, lc);
  }
  g.strokeStyle = INK;
  g.lineWidth = b.S * 0.016;
  for (const l of s.lines) {
    linePath(g, mapPts(b, l.pts), l.closed);
    g.stroke();
  }
  g.fillStyle = INK;
  for (const dt of s.dots) {
    const c = X(b, dt.c);
    g.beginPath();
    g.arc(c[0], c[1], dt.r * b.S, 0, TAU);
    g.fill();
  }
}
```

### i328 견본 항목 — `src/demos/demosHandA.ts:1582`
```ts
  i328: {
    kind: '2d',
    caption: '왼쪽 같은 굵기 선 · 오른쪽 먹 붓 — 꾹 눌러 굵게 시작해 가늘게 빠지고, 마른 붓끝은 결이 갈라져요',
    make() {
      const bk = new Bakery();
      let press = 0.85;
      let dry = 0.55;
      let bleed = 0.5;
      let pick = 0;
      let split = true;
      let ver = 0;
      let maskC: HTMLCanvasElement | null = null;
      const bakeBrush = (b: BK, s: Subject): void => {
        const W = b.S * 0.062;
        s.brush.forEach((pth, k) => {
          brushStroke(b, mapPts(b, pth.pts), pth.closed, W * (pth.bw ?? 1), { press, dry, bleed, seed: 11 + k * 17 });
        });
        if (s === TRI) {
          s.dots.forEach((dt, k) => {
            const c = X(b, dt.c);
            const r = dt.r * b.S;
            brushStroke(b, [[c[0] - r * 0.2, c[1] - r * 0.9], [c[0] + r * 0.1, c[1] + r * 0.8]], false, r * 1.9, { press: 0.9, dry: dry * 0.3, bleed, seed: 70 + k });
          });
        }
      };
      return {
        draw(g, w, h, t) {
          const period = 5;
          const { i, tc, a } = cyc(t, period, pick);
          const s = SUBJ[i]!;
          const st = stage(g, w, h, split, '같은 굵기 선', '먹 붓', '#212529', 'hanji');
          if (st.L) put(g, bk.get(`c${i}`, st.L.S, st.d, (b) => bakeClean(b, s, 'line')), st.L, a);
          const R = st.R;
          const c = bk.get(`k${i}-${ver}`, R.S, st.d, (b) => bakeBrush(b, s));
          // 붓이 지나간 만큼만 보이게 (굵은 길 가면)
          const lens = s.brush.map((p) => {
            let L = 0;
            for (let q = 1; q < p.pts.length; q++) L += Math.hypot(p.pts[q]![0] - p.pts[q - 1]![0], p.pts[q]![1] - p.pts[q - 1]![1]);
            if (p.closed) L += Math.hypot(p.pts[0]![0] - p.pts[p.pts.length - 1]![0], p.pts[0]![1] - p.pts[p.pts.length - 1]![1]);
            return L;
          });
          const total = lens.reduce((q, v) => q + v, 0) + (s === TRI ? 0.15 : 0);
          let prog = total * smooth(0.2, 2.8, tc);
          if (!maskC) maskC = mk(1, 1);
          if (maskC.width !== c.width || maskC.height !== c.height) {
            maskC.width = c.width;
            maskC.height = c.height;
          }
          const mg = maskC.getContext('2d')!;
          mg.setTransform(1, 0, 0, 1, 0, 0);
          reset(mg);
          mg.clearRect(0, 0, maskC.width, maskC.height);
          const sc = c.width / (R.S * 1.24);
          const m = R.S * 0.12;
          mg.setTransform(sc, 0, 0, sc, 0, 0);
          mg.strokeStyle = '#000';
          mg.lineWidth = R.S * 0.2;
          const toB = (p: P): P => [m + p[0] * R.S, m + p[1] * R.S];
          s.brush.forEach((p, k) => {
            if (prog <= 0) return;
            const pts = p.closed ? [...p.pts, p.pts[0]!, p.pts[1]!] : p.pts;
            const pl = mkPoly(pts.map(toB), '');
            const want = Math.min(prog, lens[k]!) * R.S;
            drawPolyUpTo(mg, pl, prog >= lens[k]! ? pl.L + R.S : want);
            prog -= lens[k]!;
          });
          if (prog > 0) {
            mg.fillStyle = '#000';
            for (const dt of s.dots) {
              const q = toB(dt.c);
              mg.beginPath();
              mg.arc(q[0], q[1], R.S * 0.08, 0, TAU);
              mg.fill();
            }
          }
          mg.setTransform(1, 0, 0, 1, 0, 0);
          mg.globalCompositeOperation = 'source-in';
          mg.drawImage(c, 0, 0);
          put(g, maskC, R, a, 'multiply');
          // 낙관
          const u = st.u;
          const sx = R.x + R.w - 15 * u;
          const sy = R.y + R.h - 15 * u;
          g.save();
          g.globalAlpha = 0.85 * a;
          g.fillStyle = '#c92a2a';
          g.beginPath();
          g.roundRect(sx, sy, 10 * u, 10 * u, 1.5 * u);
          g.fill();
          g.fillStyle = '#f8e9d8';
          g.font = `800 ${6.6 * u}px ${F}`;
          g.textAlign = 'center';
          g.textBaseline = 'middle';
          g.fillText('수', sx + 5 * u, sy + 5.4 * u);
          g.restore();
          g.textAlign = 'left';
        },
        controls: [
          { type: 'range', label: '붓 압력 차이', min: 0, max: 1, step: 0.05, value: 0.85, on: (v) => { press = v; ver++; } },
          { type: 'range', label: '갈필 (마른 붓)', min: 0, max: 1, step: 0.05, value: 0.55, on: (v) => { dry = v; ver++; } },
          { type: 'range', label: '먹 번짐', min: 0, max: 1, step: 0.05, value: 0.5, on: (v) => { bleed = v; ver++; } },
          pickCtl((v) => (pick = v)),
          splitCtl((v) => (split = v)),
        ],
        dispose() {
          bk.clear();
          maskC = null;
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312` · [스케치 선 (넘침 · 두 번 긋기)](https://ai-techstudio.web.app/ai/t/i324.md) `i324`
- 다음에 해 볼 기술: [마커 · 형광펜 (겹치면 진하게)](https://ai-techstudio.web.app/ai/t/i329.md) `i329`
