# AI 꾸러미 — 연필 빗금 명암 (해칭 · 교차 빗금) — Hatching · cross-hatching
> 면의 어둡기에 따라 빗금 겹 수를 정해 — 밝으면 한 겹, 어두울수록 방향을 바꾼 빗금이 한 겹씩 더 — 밝기를 선의 밀도로 나타내는 연필 명암.  
> 견본: https://ai-techstudio.web.app/#t/i325

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

## 주문서

### 만들어 줘: 연필 빗금 명암 (해칭 · 교차 빗금) — Hatching · cross-hatching

#### 1. 목표
입체도형 명암 (정육면체 · 원기둥)에 연필 빗금 명암을 넣어 줘 — 어두운 면일수록 방향을 바꾼 빗금이 한 겹씩 더, 줄마다 손떨림. 분위기는 연필 공책.

#### 2. 핵심 기술 용어
- **Hatching · cross-hatching** — 빗금 · 교차 빗금 명암
- **Tone thresholds (layered hatching)** — 어둡기 문턱마다 한 겹씩
- **Polygon scanline spans** — 기울인 줄로 면을 잘라 구간 구하기
- **Multiply compositing** — 겹 위에 곱하기로 얹기

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

#### 4. 조건
- 겹 수는 어둡기 문턱으로 (밝기 = 선 밀도)
- 겹마다 각도를 바꿔 교차, 교차를 끄면 같은 방향으로 촘촘히
- 줄 끝 · 가운데를 씨앗 난수로 흔들어 손으로 그은 느낌 (Math.random 금지)
- 면 밖으로 너무 삐져나가지 않게 줄 끝 흔들림은 간격 크기 정도로
- 겹마다 미리 굽고 multiply 로 얹기

#### 5. 완성 기준 (이게 보이면 성공)
- 정육면체의 밝은 윗면은 빗금 한 겹, 옆면은 두 겹, 가장 어두운 면은 네 방향이 교차한다
- 왼쪽 회색 명암과 나란히 보면 밝기 순서가 같다
- 빗금 간격 슬라이더로 촘촘 ↔ 성김, 교차 빗금을 끄면 한 방향 줄이 촘촘해진다
- 겹이 하나씩 쌓이는 동안 오른쪽 위 「○겹」 표시가 바뀐다

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

## 원리
- 면마다 어둡기 tone(0 ~ 1)이 있다. 문턱 [0.04, 0.32, 0.56, 0.76] 을 넘을 때마다 한 겹 — 가장 어두운 면은 4겹.
- 겹마다 방향이 다르다: 45° → −45° → 0° → 90°. 교차를 끄면 모두 45° 로, 겹마다 간격을 1 / (1 + 0.8k) 로 좁혀 반 칸씩 엇갈린다.
- 면을 그 각도로 돌린 좌표에서 간격마다 가로줄로 잘라 왼쪽 · 오른쪽 끝을 구한다(spans) — 볼록 다각형이면 줄 하나 = 구간 하나.
- 줄마다 시작 · 끝을 안팎으로 조금씩 흔들고(−0.3 ~ 0.6 간격), 가운데를 살짝 휘게 2차 곡선으로, 진하기 0.5 ~ 0.95 · 굵기도 조금씩 다르게.
- 겹마다 미리 구워 multiply 로 얹는다. 겹이 하나씩 쌓이는 순서를 보여 주면 원리가 바로 보인다.

## 핵심 코드 — 면을 기울인 줄로 자르기 + 어둡기 겹 빗금
(발췌: demos/demosHandA.ts spans() · bakeHatchLayer() 를 정리)
```ts
type P = [number, number];
const HATCH_TH = [0.04, 0.32, 0.56, 0.76];                   // 이 어둡기를 넘으면 겹 하나 더
const HATCH_ANG = [Math.PI / 4, -Math.PI / 4, 0, Math.PI / 2];

/** 볼록 다각형을 각도 ang 의 줄로 gap 간격 자른 구간들 */
function spans(pts: P[], ang: number, gap: number): [P, P][] {
  const c = Math.cos(ang), s = Math.sin(ang);
  const rp = pts.map((p): P => [p[0] * c + p[1] * s, -p[0] * s + p[1] * c]);
  const ys = rp.map((p) => p[1]), y0 = Math.min(...ys), y1 = Math.max(...ys);
  const out: [P, P][] = [];
  for (let y = y0 + gap * 0.5; y < y1; y += gap) {
    let xa = Infinity, xb = -Infinity;
    for (let i = 0; i < rp.length; i++) {
      const a = rp[i], b = rp[(i + 1) % rp.length];
      if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y)) {
        const x = a[0] + ((y - a[1]) / (b[1] - a[1])) * (b[0] - a[0]);
        xa = Math.min(xa, x); xb = Math.max(xb, x);
      }
    }
    if (xb > xa) { const back = (x: number): P => [x * c - y * s, x * s + y * c]; out.push([back(xa), back(xb)]); }
  }
  return out;
}
/** 겹 k 하나: 어둡기가 문턱을 넘는 면에만 */
function hatchLayer(g: CanvasRenderingContext2D, fills: { pts: P[]; tone: number }[], k: number, gap: number, r: () => number) {
  const ang = HATCH_ANG[k], ca = Math.cos(ang), sa = Math.sin(ang);
  g.strokeStyle = '#2c2a35'; g.lineCap = 'round';
  for (const f of fills) {
    if (f.tone <= HATCH_TH[k]) continue;
    for (const [a, b] of spans(f.pts, ang, gap)) {
      const i0 = (r() * 0.9 - 0.3) * gap * 1.4, i1 = (r() * 0.9 - 0.3) * gap * 1.4; // 끝 흔들기
      const x0 = a[0] + ca * i0, y0 = a[1] + sa * i0, x1 = b[0] - ca * i1, y1 = b[1] - sa * i1;
      const bend = (r() - 0.5) * gap * 0.5;
      g.globalAlpha = 0.5 + r() * 0.45;
      g.lineWidth = 0.42 + r() * 0.35;
      g.beginPath(); g.moveTo(x0, y0);
      g.quadraticCurveTo((x0 + x1) / 2 - sa * bend, (y0 + y1) / 2 + ca * bend, x1, y1);
      g.stroke();
    }
  }
  g.globalAlpha = 1;
}
```

## 흔한 실수 · 확인 목록
- [ ] **모든 면에 같은 빗금을 깔면 명암이 안 생긴다** — 어둡기 문턱마다 겹을 하나씩 — 겹 수가 곧 어둡기.
- [ ] **줄을 자로 그은 듯 반듯하면 기계 무늬처럼 보인다** — 줄 끝 · 휨 · 진하기 · 굵기를 씨앗 난수로 조금씩 다르게.
- [ ] **spans 를 오목한 모양에 쓰면 오목한 틈까지 줄이 건너간다** — 견본 spans 는 볼록 다각형용(왼쪽 끝 · 오른쪽 끝만). 오목한 모양은 볼록 조각으로 나누거나 clip 으로 자른다.

## 완성 기준 체크리스트
- [ ] 정육면체의 밝은 윗면은 빗금 한 겹, 옆면은 두 겹, 가장 어두운 면은 네 방향이 교차한다
- [ ] 왼쪽 회색 명암과 나란히 보면 밝기 순서가 같다
- [ ] 빗금 간격 슬라이더로 촘촘 ↔ 성김, 교차 빗금을 끄면 한 방향 줄이 촘촘해진다
- [ ] 겹이 하나씩 쌓이는 동안 오른쪽 위 「○겹」 표시가 바뀐다

## 이 기술 정보
- id: `i325` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 겹마다 한 번 굽기 (면 수 × 줄 수). 그 뒤 drawImage 4번.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i325
- 쓰면 좋을 때: 입체도형의 밝은 면 · 어두운 면을 손그림으로 / 흑백 펜 · 연필 그림체
- 쓰지 말 때: 아주 작은 면 · 둥근 면이 많은 그림 — 빗금이 잘게 끊긴다. 대신 점묘(i331)나 연한 칠 / 색이 중요한 그림 — 빗금은 명암만 말한다

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

### i325 견본 항목 — `src/demos/demosHandA.ts:1401`
```ts
  i325: {
    kind: '2d',
    caption: '왼쪽 회색 명암 · 오른쪽 빗금 — 어두운 면일수록 방향을 바꾼 빗금이 한 겹씩 더 쌓여요',
    make() {
      const bk = new Bakery();
      let gapK = 1;
      let jit = 1;
      let cross = true;
      let pick = 0;
      let split = true;
      let ver = 0;
      return {
        draw(g, w, h, t) {
          const { i, tc, a } = cyc(t, 4.8, pick);
          const s = SUBJ[i]!;
          const st = stage(g, w, h, split, '회색 명암', '연필 빗금', '#495057');
          if (st.L) put(g, bk.get(`g${i}`, st.L.S, st.d, (b) => bakeClean(b, s, 'gray')), st.L, a);
          const S = st.R.S;
          let shown = 0;
          for (let k = 0; k < 4; k++) {
            const t0 = 0.35 + k * 0.5;
            const al = smooth(t0, t0 + 0.3, tc);
            if (al <= 0) continue;
            shown = k + 1;
            put(g, bk.get(`h${i}-${k}-${ver}`, S, st.d, (b) => bakeHatchLayer(b, s, k, gapK, jit, cross)), st.R, a * al, 'multiply');
          }
          put(g, bk.get(`o${i}-${ver}`, S, st.d, (b) => bakePencil(b, s, { seed: 2, jit: 0.6, over: 1.2, passes: 1, w: 1.5, col: '#26242e', grain: 0.4 })), st.R, a, 'multiply');
          // 겹 수 표시
          const u = st.u;
          const R = st.R;
          for (let k = 0; k < 4; k++) {
            const x = R.x + R.w - 6 * u - (4 - k) * 9.5 * u;
            const y = R.y + 5 * u;
            g.fillStyle = k < shown ? '#495057' : 'rgba(60,50,40,0.15)';
            g.fillRect(x, y, 8 * u, 9 * u);
            if (k < shown) {
              g.save();
              g.beginPath();
              g.rect(x, y, 8 * u, 9 * u);
              g.clip();
              g.strokeStyle = 'rgba(255,255,255,0.8)';
              g.lineWidth = 0.7 * u;
              for (let q = 0; q <= k; q++) {
                const an = HATCH_ANG[q]!;
                for (let z = -12; z <= 12; z += 3) {
                  const cx = x + 4 * u - Math.sin(an) * z * u;
                  const cy = y + 4.5 * u + Math.cos(an) * z * u;
                  g.beginPath();
                  g.moveTo(cx - Math.cos(an) * 10 * u, cy - Math.sin(an) * 10 * u);
                  g.lineTo(cx + Math.cos(an) * 10 * u, cy + Math.sin(an) * 10 * u);
                  g.stroke();
                }
              }
              g.restore();
            }
          }
          tag(g, `${shown}겹`, R.x + R.w - 6 * u - 4 * 9.5 * u - 3 * u, R.y + 12.2 * u, u, 'rgba(40,36,50,0.8)');
        },
        controls: [
          { type: 'range', label: '빗금 간격 (작을수록 촘촘)', min: 0.6, max: 1.8, step: 0.05, value: 1, on: (v) => { gapK = v; ver++; } },
          { type: 'range', label: '손떨림', min: 0, max: 3, step: 0.1, value: 1, on: (v) => { jit = v; ver++; } },
          { type: 'toggle', label: '교차 빗금 (끄면 한 방향만 촘촘히)', value: true, on: (v) => { cross = v; ver++; } },
          pickCtl((v) => (pick = v)),
          splitCtl((v) => (split = v)),
        ],
        dispose() {
          bk.clear();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [스케치 선 (넘침 · 두 번 긋기)](https://ai-techstudio.web.app/ai/t/i324.md) `i324`
- 다음에 해 볼 기술: [지그재그 칠하기 (scribble fill)](https://ai-techstudio.web.app/ai/t/i326.md) `i326` · [점묘 (스티플링)](https://ai-techstudio.web.app/ai/t/i331.md) `i331`
- 참고 문서: [Wikipedia — Hatching](https://en.wikipedia.org/wiki/Hatching)
