# AI 꾸러미 — 칠판 · 분필 (번짐 · 지운 자국) — Chalkboard style (chalk texture stroke)
> 분필 결 무늬를 패턴으로 칠한 흔들린 선 · 손글씨에, 문지른 번짐과 흐릿한 지운 자국을 한 번 구운 칠판에 겹쳐 칠판 풀이처럼 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i333

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

## 주문서

### 만들어 줘: 칠판 · 분필 (번짐 · 지운 자국) — Chalkboard style (chalk texture stroke)

#### 1. 목표
수학 풀이 설명 (식 · 도형)을(를) 칠판에 분필로 쓰는 장면으로 그려 줘 — 결이 끊긴 분필 선, 문지른 번짐, 지우개로 지운 흐릿한 자국, 써 내려가는 순서까지. 분위기는 초록 칠판 · 흰 분필.

#### 2. 핵심 기술 용어
- **Chalkboard style (chalk texture stroke)** — 칠판 · 분필 그림체
- **CanvasPattern stroke (createPattern)** — 분필 결 그림을 패턴으로 만들어 선 색으로
- **Erased ghost (blurred offset copy)** — 지운 자국 — 흐리게 비켜 겹친 옛 글씨
- **Write-on reveal (clip progress)** — 잘라 보이기로 써 내려가는 연출

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

#### 4. 조건
- 분필 결 · 칠판 바탕 · 지운 자국은 미리 굽고, 크기 · 거칠기가 바뀔 때만 다시
- 패턴은 화면 배율에 맞춰 setTransform 으로 크기를 맞춘다 (확대해도 결 크기가 같게)
- 흔들림 · 손글씨는 시드 고정 — 프레임마다 글자가 떨리면 안 된다
- 써 내려가는 순서: 제목 → 그림 → 식 한 줄씩 → 답 동그라미

#### 5. 완성 기준 (이게 보이면 성공)
- 분필 선에 결이 끊긴 자국과 가루 번짐이 보이고, 반듯한 원본과 나란히 비교된다
- 풀이가 한 줄씩 써지고 분필 막대가 끝을 따라간다, 끝나면 지우개가 지나가며 지운다
- 「분필 거칠기」 · 「번짐 · 지운 자국」 슬라이더로 결과 번짐이 바뀐다
- 폰 폭에서도 글씨가 읽힌다

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

## 원리
- 분필 색마다 192×192 결 그림을 굽는다 — 길쭉한 잡음 · 잔 잡음 · 가끔 빈 점을 투명도로 넣어 선이 끊겨 보이게.
- 이 그림을 패턴(createPattern)으로 만들어 strokeStyle · fillStyle 로 쓰면 어떤 선 · 글자든 분필 결이 생긴다.
- 선은 길이를 따라 살짝 흔들고(wobbly), 그 아래 3배 굵기의 아주 옅은 선을 깔아 가루가 번진 테두리를 낸다.
- 지운 자국 = 같은 풀이를 흐림 필터로 뭉개 좌우로 비켜 두 번 겹친 그림을 옅게(0.22) 칠판 위에 깐다.
- 써 내려가기는 영역마다 시간 구간을 주고 그 진행만큼 사각형으로 잘라(clip) 보여 주며, 끝점에 분필 막대를 둔다.

## 핵심 코드 — 분필 결 그림 굽기 + 패턴 선
(발췌: demos/demosHandB.ts chalkTile() · makeChalk() 의 cl() 을 정리)
```ts
// bake(w, h, fn): 픽셀마다 fn 으로 색을 채운 캔버스 · pnoise2 = 반복되는 잡음 · hash2 = 점 잡음
function chalkTile(rgb: [number, number, number], seed: number, grit: number): HTMLCanvasElement {
  return bake(192, 192, (x, y, c) => {
    const n = pnoise2(x / 6, y / 16, 32, 12, seed);      // 길쭉한 결
    const n2 = pnoise2(x / 2, y / 2, 96, 96, seed + 3);  // 잔 결
    const sp = hash2(x, y, seed + 7);
    const a = 0.8 + (n - 0.5) * 0.95 * grit + (n2 - 0.5) * 0.55 * grit - (sp > 1 - 0.16 * grit ? 0.75 : 0);
    c[0] = rgb[0]; c[1] = rgb[1]; c[2] = rgb[2];
    c[3] = Math.max(0, Math.min(1, a)) * 255;            // 결은 투명도로 — 칠판이 비쳐 보인다
  });
}

const white = g.createPattern(chalkTile([240, 242, 234], 1, 1), 'repeat')!;

function chalkLine(g: CanvasRenderingContext2D, pts: [number, number][], w: number, ink: CanvasPattern, seed: number) {
  const q = wobbly(pts, false, seed, 0.55, 0.08);       // 길이를 따라 살짝 흔든 선
  g.lineCap = 'round'; g.lineJoin = 'round';
  g.strokeStyle = 'rgba(230,240,232,0.07)';             // 가루 번짐: 3.2배 굵은 아주 옅은 선
  g.lineWidth = w * 3.2;
  polyPath(g, q, false); g.stroke();
  g.strokeStyle = ink;
  g.lineWidth = w;
  polyPath(g, q, false); g.stroke();
}
```

## 흔한 실수 · 확인 목록
- [ ] **패턴을 화면 배율과 상관없이 쓰면 확대할 때 결이 뭉개진다** — 패턴에 setTransform(1/배율) 을 걸어 결 크기를 화면 픽셀 기준으로 맞춘다.
- [ ] **지운 자국을 매 프레임 blur 로 그리면 느리다** — 지운 자국은 한 번 흐려 구워 두고 globalAlpha 로 옅게 깐다.
- [ ] **분필 결을 글자 색 불투명도로 넣으면 회색 글자가 된다** — 결은 알파 채널에 넣어야 칠판 초록이 비쳐 분필답다.
- [ ] **모든 선이 한꺼번에 나타나면 「쓰는」 느낌이 없다** — 영역마다 시작 · 끝 시간을 주고 진행만큼 잘라 보이며, 끝점에 분필 막대를 그린다.

## 완성 기준 체크리스트
- [ ] 분필 선에 결이 끊긴 자국과 가루 번짐이 보이고, 반듯한 원본과 나란히 비교된다
- [ ] 풀이가 한 줄씩 써지고 분필 막대가 끝을 따라간다, 끝나면 지우개가 지나가며 지운다
- [ ] 「분필 거칠기」 · 「번짐 · 지운 자국」 슬라이더로 결과 번짐이 바뀐다
- [ ] 폰 폭에서도 글씨가 읽힌다

## 이 기술 정보
- id: `i333` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 칠판 바탕 · 분필 결 3장 · 지운 자국은 화면 크기나 값이 바뀔 때만 굽는다. 매 프레임은 패턴 선 몇십 개.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i333
- 쓰면 좋을 때: 풀이 · 증명을 한 줄씩 써 가며 설명할 때 / 수업 · 교실 분위기의 화면
- 쓰지 말 때: 글자가 아주 작은 화면 — 분필 결이 글자를 갉아 읽기 어렵다. 작은 글씨는 결을 약하게 / 빠르게 바뀌는 점수 · 숫자 표시 — 평범한 글꼴이 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeChalk — `src/demos/demosHandB.ts:1189`
```ts
function makeChalk() {
  let grit = 1;
  let smear = 1;
  let cmp = true;
  let kB = '';
  let board: HTMLCanvasElement | null = null;
  let kT = '';
  let tiles: HTMLCanvasElement[] = [];
  let kG = '';
  let ghost: HTMLCanvasElement | null = null;
  const REG = [
    { x: 18, y: 12, w: 140, h: 28, t0: 0, t1: 1.3 },
    { x: 24, y: 54, w: 86, h: 84, t0: 1.4, t1: 2.8 },
    { x: 114, y: 58, w: 150, h: 20, t0: 2.9, t1: 4.0 },
    { x: 114, y: 87, w: 110, h: 20, t0: 4.1, t1: 4.8 },
    { x: 114, y: 117, w: 156, h: 22, t0: 4.9, t1: 6.2 },
    { x: 0, y: 0, w: 0, h: 0, t0: 6.3, t1: 7.3 },
  ];
  interface M {
    chalk: boolean;
    ink: [Ink, Ink, Ink];
    jit: number;
  }
  const content = (g: G, m: M, rev: (i: number) => number, t: number): P | null => {
    let tip: P | null = null;
    const [W, Y, PK] = m.ink;
    g.lineCap = 'round';
    g.lineJoin = 'round';
    const cl = (pts: P[], w: number, ink: Ink, closed = false, seed = 1): void => {
      const q = m.chalk ? wobbly(pts, closed, seed, 0.55, 0.08) : closed ? [...pts, pts[0]!] : pts;
      if (m.chalk) {
        g.strokeStyle = 'rgba(230,240,232,0.07)';
        g.lineWidth = w * 3.2;
        polyPath(g, q, false);
        g.stroke();
      }
      g.strokeStyle = ink;
      g.lineWidth = w;
      polyPath(g, q, false);
      g.stroke();
    };
    const tx = (s: string, x: number, y: number, size: number, ink: Ink, seed: number): { x0: number; x1: number } => {
      if (m.chalk) handText(g, s, x + 0.5, y + 0.4, size, seed, m.jit, { fill: 'rgba(230,240,232,0.06)', weight: 700, stroke: 2.4 });
      return handText(g, s, x, y, size, seed, m.jit, { fill: ink, weight: 600 });
    };
    const reg = (i: number, fn: () => void): void => {
      const p = rev(i);
      if (p <= 0) return;
      const R = REG[i]!;
      if (p < 1) {
        g.save();
        g.beginPath();
        g.rect(R.x, R.y, R.w * p, R.h);
        g.clip();
        fn();
        g.restore();
        tip = [R.x + R.w * p, R.y + R.h * 0.62 + Math.sin(t * 34) * R.h * 0.2];
      } else fn();
    };
    reg(0, () => {
      tx('정육면체의 겉넓이', 24, 29, 15, Y, 3);
      const pts: P[] = [];
      for (let x = 22; x <= 152; x += 3) pts.push([x, 35.5 + (m.chalk ? Math.sin(x * 0.2) * 1.1 : 0)]);
      cl(pts, 1.3, Y, false, 4);
    });
    reg(1, () => {
      const c = cube(64, 94, 44);
      cl(c.front, 1.5, W, true, 5);
      cl([c.front[0]!, c.top[3]!, c.top[2]!, c.front[1]!], 1.5, W, false, 6);
      cl([c.top[2]!, c.side[2]!, c.front[2]!], 1.5, W, false, 7);
      g.setLineDash([2.6, 2.4]);
      cubeHidden(c).forEach((l, i) => cl(l, 1.1, W, false, 8 + i));
      g.setLineDash([]);
      tx('3', (c.front[2]![0] + c.front[3]![0]) / 2 - 3, c.front[2]![1] + 12, 11, Y, 11);
      tx('3', c.front[0]![0] - 10, (c.front[0]![1] + c.front[3]![1]) / 2 + 4, 11, Y, 12);
      tx('3', (c.side[2]![0] + c.side[3]![0]) / 2 + 4, (c.side[2]![1] + c.side[3]![1]) / 2 + 9, 11, Y, 13);
    });
    reg(2, () => tx('한 면 = 3 × 3 = 9', 118, 73, 13, W, 21));
    reg(3, () => tx('면의 수 = 6', 118, 102, 13, W, 22));
    reg(4, () => tx('겉넓이 = 9 × 6 = 54', 118, 133, 13, W, 23));
    const p5 = rev(5);
    if (p5 > 0) {
      g.font = `600 13px ${F}`;
      const wAll = g.measureText('겉넓이 = 9 × 6 = 54').width;
      const w54 = g.measureText('54').width;
      const cx = 118 + wAll - w54 / 2 + 3;
      const cy = 128.5;
      const circ: P[] = [];
      for (let i = 0; i <= 64; i++) {
        const a = -2.5 + (i / 64) * TAU * 1.12;
        const rr = 1 + 0.06 * Math.sin(a * 1.7) + 0.05 * (i / 64);
        circ.push([cx + Math.cos(a) * 15 * rr, cy + Math.sin(a) * 10.5 * rr]);
      }
      const cum = cumLen(circ);
      const k = clamp01(p5 / 0.6);
      const part = upTo(circ, cum, cum[cum.length - 1]! * k);
      cl(part, 1.6, PK, false, 31);
      if (k < 1) tip = part[part.length - 1]!;
      const k2 = clamp01((p5 - 0.6) / 0.4);
      if (k2 > 0) {
        // 구석 낙서: 웃는 세모
        const tr = tri(246, 36, 26);
        const lines: P[][] = [
          [...tr.pts, tr.pts[0]!],
          arcPts(tr.sm.c[0], tr.sm.c[1], tr.sm.r, tr.sm.a0, tr.sm.a1, 10),
          [
            [272, 22],
            [276, 18],
          ],
          [
            [274, 30],
            [279, 30],
          ],
          [
            [220, 22],
            [216, 18],
          ],
        ];
        lines.forEach((l, i) => {
          const cc = cumLen(l);
          const kk = clamp01(k2 * lines.length - i);
          if (kk > 0) cl(upTo(l, cc, cc[cc.length - 1]! * kk), 1.2, PK, false, 40 + i);
        });
        if (k2 > 0.5) {
          g.fillStyle = PK;
          tr.eyes.forEach((p) => {
            g.beginPath();
            g.arc(p[0], p[1], 1.4, 0, TAU);
            g.fill();
          });
        }
        if (k2 < 1) tip = [246, 36];
      }
    }
    return tip;
  };
  const inkOf = (gg: G, sc: number): [Ink, Ink, Ink] => [patIn(gg, tiles[0]!, sc), patIn(gg, tiles[1]!, sc), patIn(gg, tiles[2]!, sc)];
  const bakeGhost = (f: Fit): HTMLCanvasElement => {
    const a = mk(f.w * f.s, f.h * f.s);
    const ga = c2(a);
    ga.setTransform(f.s * f.u, 0, 0, f.s * f.u, f.s * f.ox, f.s * f.oy);
    content(ga, { chalk: true, ink: inkOf(ga, 1 / (f.u * f.s)), jit: 1 }, () => 1, 0);
    const b = mk(f.w * f.s, f.h * f.s);
    const gb = c2(b);
    gb.filter = `blur(${(2.2 * f.s * f.u).toFixed(1)}px)`;
    gb.drawImage(a, 0, 0);
    gb.globalAlpha = 0.6;
    gb.drawImage(a, 7 * f.s * f.u, 1 * f.s);
    gb.drawImage(a, -5 * f.s * f.u, -1 * f.s);
    return b;
  };
  const stick = (g: G, p: P): void => {
    g.save();
    g.translate(p[0], p[1]);
    g.rotate(-0.95);
    g.fillStyle = 'rgba(0,0,0,0.3)';
    g.beginPath();
    g.roundRect(1.5, 1, 16, 3.8, 1.6);
    g.fill();
    const gr = g.createLinearGradient(0, -1.9, 0, 1.9);
    gr.addColorStop(0, '#ffffff');
    gr.addColorStop(1, '#c9c9bf');
    g.fillStyle = gr;
    g.beginPath();
    g.roundRect(0, -1.9, 16, 3.8, 1.6);
    g.fill();
    g.restore();
  };
  const eraser = (g: G, x: number, y: number): void => {
    g.save();
    g.translate(x, y);
    g.rotate(-0.08);
    const r = rng(9);
    g.fillStyle = 'rgba(230,236,226,0.22)';
    for (let i = 0; i < 14; i++) {
      g.beginPath();
      g.arc(-16 - r() * 18, (r() - 0.5) * 14, 0.8 + r() * 2.2, 0, TAU);
      g.fill();
    }
    g.fillStyle = 'rgba(0,0,0,0.35)';
    g.beginPath();
    g.roundRect(-12, -5, 28, 15, 2);
    g.fill();
    g.fillStyle = '#8e8e8a';
    g.fillRect(-14, 1, 28, 7);
    g.strokeStyle = 'rgba(0,0,0,0.25)';
    g.lineWidth = 0.5;
    for (let i = -12; i < 14; i += 2.2) {
      g.beginPath();
      g.moveTo(i, 1.5);
      g.lineTo(i, 7.5);
      g.stroke();
    }
    const gr = g.createLinearGradient(0, -6, 0, 1);
    gr.addColorStop(0, '#d39a5e');
    gr.addColorStop(1, '#8b5a30');
    g.fillStyle = gr;
    g.beginPath();
    g.roundRect(-14, -6, 28, 7.5, 2);
    g.fill();
    g.restore();
  };
  return {
    draw(g: G, w: number, h: number, t: number): void {
      reset(g);
      const f = fitOf(g, w, h);
      const kb = keyOf(f, smear);
      if (kb !== kB) {
        board = bakeBoard(f, smear);
        kB = kb;
      }
      const kt = keyOf(f, grit);
      if (kt !== kT) {
        tiles = [chalkTile([240, 242, 234], 1, grit), chalkTile([247, 222, 118], 2, grit), chalkTile([246, 150, 172], 3, grit)];
        kT = kt;
      }
      if (kG !== kt) {
        ghost = bakeGhost(f);
        kG = kt;
      }
      g.drawImage(board!, 0, 0, w, h);
      g.globalAlpha = 0.22 * smear;
      g.drawImage(ghost!, 0, 0, w, h);
      g.globalAlpha = 1;
      const tc = t % 12;
      const ex = tc >= 9.4 ? lerp(-24, 300, clamp01((tc - 9.4) / 2)) : -60;
      enter(g, f);
      g.save();
      g.beginPath();
      g.rect(ex, -600, 3000, 3000);
      g.clip();
      const rev = (i: number): number => clamp01((tc - REG[i]!.t0) / (REG[i]!.t1 - REG[i]!.t0));
      const tip = content(g, { chalk: true, ink: inkOf(g, 1 / (f.u * f.s)), jit: 1 }, rev, t);
      g.restore();
      if (tip && tc < 9.4) stick(g, tip);
      if (tc >= 9.4 && tc < 11.4) eraser(g, ex, 92 + 54 * Math.sin(((tc - 9.4) / 2) * TAU * 3.5));
      g.restore();
      if (cmp)
        splitter(
          g,
          w,
          h,
          t,
          () => {
            g.fillStyle = '#fafaf7';
            g.fillRect(0, 0, w, h);
            enter(g, f);
            content(g, { chalk: false, ink: ['#1f2937', '#c2410c', '#db2777'], jit: 0 }, () => 1, t);
            g.restore();
          },
          '분필',
        );
    },
    controls: [
      { type: 'range', label: '분필 거칠기', min: 0.2, max: 2, step: 0.1, value: grit, on: (v) => (grit = v) },
      { type: 'range', label: '번짐 · 지운 자국', min: 0, max: 2.5, step: 0.1, value: smear, on: (v) => (smear = v) },
      cmpToggle(cmp, (v) => (cmp = v)),
    ] as Control[],
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [스케치 선 (넘침 · 두 번 긋기)](https://ai-techstudio.web.app/ai/t/i324.md) `i324`
- 다음에 해 볼 기술: [손글씨 글자 흔들기](https://ai-techstudio.web.app/ai/t/i337.md) `i337` · [설계도 · 모눈 도면 (블루프린트)](https://ai-techstudio.web.app/ai/t/i339.md) `i339`
