# AI 꾸러미 — 색연필 · 크레파스 결 (종이 이) — Paper tooth texture
> 칠한 겹의 알파를 종이 결 높낮이 지도와 비교해 골짜기만 비게 해, 크레파스 · 색연필 색이 군데군데 하얗게 빠지고 세게 누를수록 메워지는 질감을 낸다.  
> 견본: https://ai-techstudio.web.app/#t/i330

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

## 주문서

### 만들어 줘: 색연필 · 크레파스 결 (종이 이) — Paper tooth texture

#### 1. 목표
아이 그림 느낌 색칠에 색연필 · 크레파스 결 질감을 넣어 줘 — 종이 결 골짜기엔 색이 안 묻어 하얗게 비고, 세게 누를수록 메워지게. 분위기는 크레파스 동화책.

#### 2. 핵심 기술 용어
- **Paper tooth texture** — 종이 이 — 종이 결의 높낮이
- **Height-threshold alpha mask** — 결 높이가 문턱보다 낮은 곳의 색 지우기
- **Pressure-dependent threshold** — 누르는 힘이 셀수록 문턱이 낮아짐
- **Tileable noise field** — 이음새 없이 반복되는 잡음 256×256

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

#### 4. 조건
- 종이 결 지도는 한 번만 만들고 이음새 없이 반복 (& 255 로 감싸 읽기)
- 결은 종이에 고정 — 그림을 옮겨도 같은 자리가 비게 (그림 좌표가 아니라 캔버스 좌표로 읽기)
- 문턱은 누르는 힘으로, 경계는 smoothstep 으로 부드럽게
- 결 깎기는 칠한 겹에만 (테두리 · 바탕까지 같이 깎지 않게 따로 겹)
- 한 번 굽고 캐시

#### 5. 완성 기준 (이게 보이면 성공)
- 칠한 면에 종이 결 모양으로 하얀 틈이 군데군데 보인다
- 누르는 힘을 약 → 강으로 바꾸면 틈이 점점 메워진다
- 크레파스는 굵고 거친 결 줄무늬, 색연필은 가늘고 촘촘한 결
- 왼쪽 꽉 찬 칠과 나란히 보면 차이가 바로 보인다

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

## 원리
- 종이 결 지도: 256×256 이음새 없는 잡음 두 겹 + 픽셀 잡음 — 값이 높으면 종이 산, 낮으면 골짜기. 한 번 만들어 모든 그림이 같이 쓴다.
- 면을 줄로 칠한 겹을 다 그린 뒤, 픽셀마다 결 값 tv 와 문턱 th 를 smoothstep(th − soft, th + soft, tv) 로 비교해 알파에 곱한다 — 골짜기는 색이 빠진다.
- 누르는 힘 p 가 셀수록 문턱이 낮아진다: 크레파스 th = 0.55 − 0.55p, 색연필 th = 0.7 − 0.4p. 세게 누르면 골짜기까지 메워진다.
- 크레파스는 덜 겹친(옅은) 곳일수록 골짜기를 더 건너뛰게 tv + (알파 − 1) × 0.95 를 비교해 결 따라 줄무늬가 생긴다. 색연필은 가는 줄 두 방향 + 부드러운 문턱(0.12).

## 핵심 코드 — 종이 결 문턱으로 칠한 겹 깎기
(발췌: demos/demosHandA.ts toothMask() 를 정리 (toothField 는 256×256 잡음))
```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); };

/** T: 256×256 결 높이(0 ~ 1, 이음새 없음). lc: 칠한 겹 캔버스 */
function toothMask(lc: HTMLCanvasElement, T: Float32Array, th: number, soft: number,
                   scale: number, keepMul: number, waxy: boolean) {
  const g = lc.getContext('2d')!;
  const im = g.getImageData(0, 0, lc.width, lc.height);
  const d = im.data, W = lc.width, H = lc.height, inv = 1 / scale;
  for (let y = 0; y < H; y++) {
    const ty = (Math.floor(y * inv) & 255) * 256;          // 반복해서 읽기
    for (let x = 0; x < W; x++) {
      const o = (y * W + x) * 4 + 3;
      const a = d[o];
      if (a === 0) continue;
      const tv = T[ty + (Math.floor(x * inv) & 255)];
      if (waxy) d[o] = 255 * smooth(th - soft, th + soft, tv + (a / 255 - 1) * 0.95); // 크레파스: 옅은 곳이 더 빔
      else d[o] = a * smooth(th - soft, th + soft, tv) * keepMul;                     // 색연필
    }
  }
  g.putImageData(im, 0, 0);
}
// 누르는 힘 p (0 ~ 1):
// 크레파스  toothMask(lc, T, 0.55 - p * 0.55, 0.05, 1.05 * dpr, 1, true)
// 색연필    toothMask(lc, T, 0.7 - p * 0.4, 0.12, 0.6 * dpr, 0.55 + p * 0.45, false)
```

## 흔한 실수 · 확인 목록
- [ ] **결 무늬를 그림마다 다르게 만들면 같은 종이 위인데 질감이 제각각이다** — 결 지도 하나를 모든 그림이 같이 쓰고, 캔버스 좌표로 읽어 종이에 고정한다.
- [ ] **문턱을 딱 자르면(0/1) 가장자리가 계단처럼 깨진다** — smoothstep 으로 문턱 둘레를 부드럽게 (크레파스 0.05 · 색연필 0.12).
- [ ] **매 프레임 getImageData 로 깎으면 폰에서 끊긴다** — 한 번 구워 캐시. 실시간이 필요하면 같은 계산을 셰이더로 옮긴다.
- [ ] **레티나 화면에서 결이 너무 잘다** — 결 읽는 배율에 기기 픽셀 비율(dpr)을 곱한다 (견본 scale × b.d).

## 완성 기준 체크리스트
- [ ] 칠한 면에 종이 결 모양으로 하얀 틈이 군데군데 보인다
- [ ] 누르는 힘을 약 → 강으로 바꾸면 틈이 점점 메워진다
- [ ] 크레파스는 굵고 거친 결 줄무늬, 색연필은 가늘고 촘촘한 결
- [ ] 왼쪽 꽉 찬 칠과 나란히 보면 차이가 바로 보인다

## 이 기술 정보
- id: `i330` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의) — 굽는 순간 그림 크기 픽셀을 한 번 훑음 (500×500 = 25만 픽셀). 구운 뒤엔 drawImage 뿐.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i330
- 쓰면 좋을 때: 아이 그림 · 크레파스 그림체의 면 칠 / 도장 붓(i313) · 지그재그 칠(i326)에 종이 결을 더할 때
- 쓰지 말 때: 매 프레임 바뀌는 큰 그림 — getImageData · putImageData 가 화면 크기마다 무겁다. 대신 셰이더로 하거나 한 번 굽기 / 매끈한 디지털 그림체

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — 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();
  }
}
```

### i330 견본 항목 — `src/demos/demosHandA.ts:1833`
```ts
  i330: {
    kind: '2d',
    caption: '왼쪽 꽉 찬 칠 · 오른쪽 크레파스 — 종이 결의 골짜기엔 색이 안 묻어 하얗게 비고, 세게 누를수록 메워져요',
    make() {
      const bk = new Bakery();
      let crayon = true;
      let fixP = 0;
      let pick = 0;
      let split = true;
      const PR = [0.25, 0.55, 0.9];
      return {
        draw(g, w, h, t) {
          const period = 6;
          const { i, tc, a } = cyc(t, period, pick);
          const s = SUBJ[i]!;
          const st = stage(g, w, h, split, '반듯한 원본', crayon ? '크레파스' : '색연필', '#f08c00');
          if (st.L) put(g, bk.get(`c${i}`, st.L.S, st.d, (b) => bakeClean(b, s, 'color')), st.L, a);
          const R = st.R;
          // 누르는 힘: 약 → 중 → 강 (겹쳐 바뀜)
          let lv = 0;
          let mixK = 0;
          if (fixP > 0) lv = fixP - 1;
          else {
            const x = clamp01((tc - 0.3) / (period - 0.9)) * 3;
            lv = Math.min(2, Math.floor(x));
            mixK = lv < 2 ? smooth(0.75, 1, x - lv) : 0;
          }
          const get = (k: number): HTMLCanvasElement => bk.get(`y${i}-${k}-${crayon ? 1 : 0}`, R.S, st.d, (b) => bakeCrayon(b, s, PR[k]!, crayon));
          put(g, get(lv), R, a * (1 - mixK), 'multiply');
          if (mixK > 0) put(g, get(lv + 1), R, a * mixK, 'multiply');
          // 힘 눈금
          const u = st.u;
          const cur = lv + mixK;
          tag(g, '힘', R.x + R.w - 33 * u, R.y + 12.2 * u, u);
          for (let k = 0; k < 3; k++) {
            const on = cur >= k - 0.01;
            g.fillStyle = on ? '#f08c00' : 'rgba(60,50,40,0.18)';
            g.beginPath();
            g.arc(R.x + R.w - 27 * u + k * 9 * u, R.y + 10 * u, (2.2 + k * 0.9) * u, 0, TAU);
            g.fill();
          }
        },
        controls: [
          { type: 'toggle', label: '크레파스 (끄면 색연필)', value: true, on: (v) => (crayon = v) },
          { type: 'range', label: '누르는 힘 (0 = 약→강 자동 · 1 약 · 2 중 · 3 강)', min: 0, max: 3, step: 1, value: 0, on: (v) => (fixP = v) },
          pickCtl((v) => (pick = v)),
          splitCtl((v) => (split = v)),
        ],
        dispose() {
          bk.clear();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [도장 붓 (크레파스 · 수채 · 분필)](https://ai-techstudio.web.app/ai/t/i313.md) `i313` · [지그재그 칠하기 (scribble fill)](https://ai-techstudio.web.app/ai/t/i326.md) `i326`
- 다음에 해 볼 기술: [선 들끓기 (line boil)](https://ai-techstudio.web.app/ai/t/i323.md) `i323`
