# AI 꾸러미 — 도장 붓 (크레파스 · 수채 · 분필) — Stamp brush (dab spacing)
> 획을 따로 한 겹에 그린 뒤 종이에 고정된 결 무늬로 군데군데 지워 얹어, 크레파스(종이 결) · 수채(번짐 · 겹치면 섞임) · 분필(칠판) 붓을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i313

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

## 주문서

### 만들어 줘: 도장 붓 (크레파스 · 수채 · 분필) — Stamp brush (dab spacing)

#### 1. 목표
스케치북 그림 게임에 도장 붓을 넣어 줘 — 크레파스 · 수채 · 분필 세 가지, 종이 결 무늬로 군데군데 빈 질감, 수채는 겹치면 진하게. 분위기는 크레파스 동화책.

#### 2. 핵심 기술 용어
- **Stamp brush (dab spacing)** — 도장 붓 — 선 따라 일정 간격으로 찍기
- **Paper grain mask (destination-out)** — 종이 결 무늬로 획을 지워 질감 내기
- **Multiply blending** — 곱하기 — 수채처럼 겹친 곳이 진하게
- **Stroke layer compositing** — 획 겹 따로 그려 손 뗄 때 합치기

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

#### 4. 조건
- 결 마스크는 처음 한 번만 굽기 (붓마다 한 장)
- 획은 따로 한 겹에 쌓고, 결 지우기는 그 겹에만 (바탕을 같이 지우지 않게)
- 마스크는 종이에 고정 — 획마다 옮기거나 새로 만들지 않기
- 수채는 multiply, 크레파스 · 분필은 source-over
- 찍는 간격은 이동 거리로 (프레임마다 한 번 찍으면 빠르게 그을 때 끊긴다)

#### 5. 완성 기준 (이게 보이면 성공)
- 크레파스 줄은 종이 결 사이로 흰 틈이 군데군데 보인다
- 수채 줄은 가장자리가 짙고, 두 줄이 겹치면 그 곳이 더 진하고 색이 섞인다
- 칠판 위 분필 줄은 거칠게 부스러진 흰 선이다
- 같은 자리를 다시 칠해도 결 무늬 틈은 같은 곳에 남는다

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

## 원리
- 붓마다 결 무늬 마스크를 종이 크기로 한 번 굽는다 — 값 잡음 두 겹으로 「이」 모양, 문턱을 넘은 곳만 알파가 남는다.
- 그리는 동안 획은 따로 한 겹(cur)에만 쌓는다. 크레파스 = 굵은 선 + 가장자리 부스러기 점, 수채 = 가장자리가 짙은 원 도장을 1.6px 마다 0.14 투명도로, 분필 = 작은 사각 점 7개씩.
- 보여 줄 때와 손 뗄 때 획 겹을 결 마스크로 destination-out 해서 군데군데 지운다. 마스크가 종이에 붙어 있으니 같은 곳은 늘 같이 빈다.
- 수채는 multiply 로 바탕에 얹어 겹친 곳이 더 진하고 색이 섞이고, 나머지는 source-over.

## 핵심 코드 — 획 겹 → 결 마스크로 지우기 → 바탕에 합치기
(발췌: demos/demosDrawTools.ts stampBrush() composeTmp() · end() 를 정리)
```ts
// base: 바탕 겹, cur: 지금 획 겹, tmp: 합칠 준비 겹 — 모두 같은 크기 캔버스
// masks[kind]: 붓마다 한 번 구운 결 무늬 (알파 = 지울 곳)
function composeTmp(tmp: CanvasRenderingContext2D, cur: HTMLCanvasElement, mask: HTMLCanvasElement) {
  tmp.save();
  tmp.setTransform(1, 0, 0, 1, 0, 0);
  tmp.clearRect(0, 0, tmp.canvas.width, tmp.canvas.height);
  tmp.drawImage(cur, 0, 0);
  tmp.globalCompositeOperation = 'destination-out'; // 결 무늬만큼 획을 지운다
  tmp.drawImage(mask, 0, 0);
  tmp.restore();
}
function endStroke(base: CanvasRenderingContext2D, tmpC: HTMLCanvasElement, kind: 'crayon' | 'water' | 'chalk') {
  base.save();
  base.setTransform(1, 0, 0, 1, 0, 0);
  base.globalCompositeOperation = kind === 'water' ? 'multiply' : 'source-over'; // 수채는 겹치면 진하게
  base.drawImage(tmpC, 0, 0);
  base.restore();
}
/** 수채 도장 — 가장자리가 짙은 원 */
function mkWaterStamp(col: string) {
  const c = document.createElement('canvas');
  c.width = c.height = 64;
  const g = c.getContext('2d')!;
  const gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);
  gr.addColorStop(0, col + '55'); gr.addColorStop(0.72, col + '99');
  gr.addColorStop(0.86, col + 'cc'); gr.addColorStop(1, col + '00');
  g.fillStyle = gr; g.fillRect(0, 0, 64, 64);
  return c;
}
// 수채 찍기: 두 점 사이를 1.6px 마다, globalAlpha 0.14, 반지름 8 ± 흔들림
```

## 흔한 실수 · 확인 목록
- [ ] **결 마스크를 바탕에 바로 쓰면 이미 칠한 그림까지 지워진다** — 획은 따로 한 겹에 그리고 그 겹에만 destination-out, 그 다음 바탕에 합친다.
- [ ] **수채를 source-over 로 얹으면 겹쳐도 진해지지 않는다** — multiply 로 합친다 — 겹친 곳이 곱해져 진해지고 색이 섞인다.
- [ ] **결 무늬를 획마다 새로 만들면 같은 곳을 칠해도 틈이 달라 지저분하다** — 종이 크기로 한 번 굽고 고정 — 진짜 종이 결처럼 늘 같은 자리가 빈다.

## 완성 기준 체크리스트
- [ ] 크레파스 줄은 종이 결 사이로 흰 틈이 군데군데 보인다
- [ ] 수채 줄은 가장자리가 짙고, 두 줄이 겹치면 그 곳이 더 진하고 색이 섞인다
- [ ] 칠판 위 분필 줄은 거칠게 부스러진 흰 선이다
- [ ] 같은 자리를 다시 칠해도 결 무늬 틈은 같은 곳에 남는다

## 이 기술 정보
- id: `i313` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 결 마스크는 붓마다 종이 크기 한 장(처음 한 번). 그리는 중에는 화면 크기 drawImage 2 ~ 3번이 매 프레임 — 큰 화면 폰에선 주의.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i313
- 쓰면 좋을 때: 손그림 · 동화책 그림체의 그리기 놀이 / 같은 그리기 판에서 붓 종류를 고르게 할 때
- 쓰지 말 때: 아주 큰 캔버스에서 매 프레임 합치기 — 화면 크기 drawImage 가 여러 번이라 무겁다. 대신 손 뗄 때만 합치고 그리는 중엔 획 영역만 / 또렷한 선이 필요한 도구 — 부드러운 붓 선(i312)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### stampBrush — `src/demos/demosDrawTools.ts:974`
```ts
function stampBrush(env: Env): Tool {
  const base = mkLayer(env.R);
  const cur = mkLayer(env.R);
  const tmp = mkLayer(env.R);
  const masks: Record<BrushKind, HTMLCanvasElement> = { crayon: grainMask(env.R, 'crayon'), water: grainMask(env.R, 'water'), chalk: grainMask(env.R, 'chalk') };
  const COLORS: Record<BrushKind, string[]> = {
    crayon: ['#e2483a', '#2f6fe0', '#f29a16', '#36a85a', '#8e44ad'],
    water: ['#f2c230', '#2f8fd8', '#e8556d', '#33a67c'],
    chalk: ['#ffffff', '#ffe27a', '#9fe3ff', '#ffb3c7'],
  };
  const SLATE: Rect = { x: 24, y: 154, w: 352, h: 74 };
  let chosen: BrushKind | 'auto' = 'auto';
  let kind: BrushKind = 'crayon';
  let color = '#e2483a';
  let last: P | null = null;
  let stampIdx = 0;
  let drawing = false;
  const ci: Record<BrushKind, number> = { crayon: 0, water: 0, chalk: 0 };
  let waterStamp: HTMLCanvasElement | null = null;
  const r = rng(3);
  function mkWaterStamp(col: string): HTMLCanvasElement {
    const c = document.createElement('canvas');
    c.width = c.height = 64;
    const g = c.getContext('2d')!;
    const gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);
    gr.addColorStop(0, col + '55');
    gr.addColorStop(0.72, col + '99');
    gr.addColorStop(0.86, col + 'cc');
    gr.addColorStop(1, col + '00');
    g.fillStyle = gr;
    g.fillRect(0, 0, 64, 64);
    return c;
  }
  function stamp(a: P, b: P): void {
    const g = cur.g;
    if (kind === 'crayon') {
      g.strokeStyle = color;
      g.lineWidth = 7;
      g.beginPath();
      g.moveTo(a.x, a.y);
      g.lineTo(b.x, b.y);
      g.stroke();
      g.fillStyle = color;
      const L = dist(a, b);
      for (let i = 0; i < L * 1.2; i++) {
        const u = r();
        const q = lerpP(a, b, u);
        const an = r() * Math.PI * 2;
        const rr = 3 + r() * 1.6;
        g.fillRect(q.x + Math.cos(an) * rr, q.y + Math.sin(an) * rr, 0.7, 0.7);
      }
    } else if (kind === 'water') {
      const L = dist(a, b);
      const n = Math.max(1, Math.floor(L / 1.6));
      g.globalAlpha = 0.14;
      for (let i = 1; i <= n; i++) {
        const q = lerpP(a, b, i / n);
        const rr = 8 + Math.sin(stampIdx++ * 0.21) * 1.5 + r() * 1.5;
        g.drawImage(waterStamp!, q.x - rr + (r() - 0.5) * 1.2, q.y - rr + (r() - 0.5) * 1.2, rr * 2, rr * 2);
      }
      g.globalAlpha = 1;
    } else {
      const L = dist(a, b);
      const n = Math.max(1, Math.floor(L / 0.8));
      g.fillStyle = color;
      for (let i = 1; i <= n; i++) {
        const q = lerpP(a, b, i / n);
        for (let k = 0; k < 7; k++) {
          const an = r() * Math.PI * 2;
          const rr = Math.sqrt(r()) * 4.2;
          g.globalAlpha = 0.35 + r() * 0.6;
          g.fillRect(q.x + Math.cos(an) * rr, q.y + Math.sin(an) * rr, 0.6 + r() * 1.1, 0.5 + r() * 0.8);
        }
      }
      g.globalAlpha = 1;
    }
  }
  function composeTmp(): void {
    clearLayer(tmp);
    const t = tmp.g;
    t.save();
    t.setTransform(1, 0, 0, 1, 0, 0);
    t.drawImage(cur.c, 0, 0);
    t.globalCompositeOperation = 'destination-out';
    t.drawImage(masks[kind], 0, 0);
    t.restore();
  }
  const blend = (): GlobalCompositeOperation => (kind === 'water' ? 'multiply' : 'source-over');
  const laneOf = (p: P): BrushKind => (p.y < 86 ? 'crayon' : p.y < SLATE.y ? 'water' : 'chalk');
  return {
    cursor: () => (kind === 'crayon' ? 'pencil' : 'brush'),
    color: () => color,
    begin(p) {
      kind = chosen === 'auto' ? laneOf(p) : chosen;
      const list = COLORS[kind];
      color = list[ci[kind]++ % list.length]!;
      if (kind === 'water') waterStamp = mkWaterStamp(color);
      clearLayer(cur);
      last = p;
      drawing = true;
      stamp(p, pt(p.x + 0.01, p.y));
    },
    drag(p) {
      if (!last || !drawing) return;
      if (dist(last, p) < 0.8) return;
      stamp(last, p);
      last = p;
    },
    end() {
      if (!drawing) return;
      drawing = false;
      composeTmp();
      base.g.save();
      base.g.setTransform(1, 0, 0, 1, 0, 0);
      base.g.globalCompositeOperation = blend();
      base.g.drawImage(tmp.c, 0, 0);
      base.g.restore();
      clearLayer(cur);
      last = null;
    },
    clear() {
      clearLayer(base);
      clearLayer(cur);
      drawing = false;
      ci.crayon = ci.water = ci.chalk = 0;
    },
    draw(g) {
      clipPaper(g, PAPER);
      // 칠판 띠
      g.fillStyle = '#7a5434';
      rrect(g, SLATE.x - 4, SLATE.y - 4, SLATE.w + 8, SLATE.h + 8, 3);
      g.fill();
      g.fillStyle = '#9a6e45';
      rrect(g, SLATE.x - 4, SLATE.y - 4, SLATE.w + 8, 2.5, 1.5);
      g.fill();
      const sg = g.createLinearGradient(0, SLATE.y, 0, SLATE.y + SLATE.h);
      sg.addColorStop(0, '#2c3d35');
      sg.addColorStop(1, '#1f2c26');
      g.fillStyle = sg;
      g.fillRect(SLATE.x, SLATE.y, SLATE.w, SLATE.h);
      g.fillStyle = 'rgba(255,255,255,0.035)';
      for (let i = 0; i < 5; i++) {
        g.beginPath();
        g.ellipse(SLATE.x + 40 + i * 70, SLATE.y + 30 + (i % 2) * 14, 34, 9, -0.15, 0, Math.PI * 2);
        g.fill();
      }
      g.drawImage(base.c, 0, 0, W, H);
      if (drawing) {
        composeTmp();
        g.save();
        g.globalCompositeOperation = blend();
        g.drawImage(tmp.c, 0, 0, W, H);
        g.restore();
      }
      g.font = `700 8.5px ${F}`;
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      const lab = (y: number, txt: string, col: string): void => {
        g.fillStyle = col;
        g.fillText(txt, 24, y);
      };
      lab(36, '크레파스', '#9a7f62');
      lab(98, '수채 물감', '#9a7f62');
      lab(SLATE.y + 10, '분필', 'rgba(255,255,255,0.55)');
    },
    script(loop) {
      const ph = loop * 1.3;
      const wave = (y: number, amp: number, f: number, p: number, x0 = 72, x1 = 365): P[] => {
        const o: P[] = [];
        for (let x = x0; x <= x1; x += 3) o.push(pt(x, y + Math.sin(x * f + p) * amp));
        return o;
      };
      return [
        S(wave(56, 14, 0.045, ph), 260),
        S(wave(60, 10, 0.07, ph + 2, 90, 350), 280),
        S(wave(116, 12, 0.035, ph + 1), 220),
        S(wave(122, 14, 0.05, ph + 4, 100, 360), 220),
        S(wave(184, 12, 0.05, ph + 3, 70, 365), 260),
        S(wave(204, 9, 0.08, ph, 110, 340), 280),
        WAIT(1.6),
      ];
    },
    controls: [
      { type: 'button', label: '붓: 줄 따라 자동', on: () => (chosen = 'auto') },
      { type: 'button', label: '크레파스', on: () => (chosen = 'crayon') },
      { type: 'button', label: '수채', on: () => (chosen = 'water') },
      { type: 'button', label: '분필', on: () => (chosen = 'chalk') },
    ],
  };
}
```

### i313 견본 항목 — `src/demos/demosDrawTools.ts:3435`
```ts
  i313: dom('결 무늬를 선 따라 찍기 — 크레파스(종이 결) · 수채(번짐 · 겹치면 섞임) · 분필(칠판)', (e) => stampBrush(e))
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312`
- 다음에 해 볼 기술: [색연필 · 크레파스 결 (종이 이)](https://ai-techstudio.web.app/ai/t/i330.md) `i330` · [수채화 (번짐 · 가장자리 짙게)](https://ai-techstudio.web.app/ai/t/i327.md) `i327`
