# AI 꾸러미 — 목판화 · 판화 (깎은 선) — Woodcut / linocut print style
> 픽셀마다 「어느 면 위인가」에 따라 굵기가 다른 줄무늬 홈을 계산하고 가장자리를 잡음으로 일그러뜨려, 칼로 판 목판을 찍은 흑백 판화처럼 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i336

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

## 주문서

### 만들어 줘: 목판화 · 판화 (깎은 선) — Woodcut / linocut print style

#### 1. 목표
언덕 · 해 · 구름이 있는 풍경을 목판화 그림체로 그려 줘 — 칼로 판 흰 홈과 검은 줄, 줄 굵기로 명암, 거친 가장자리, 찍은 판화와 좌우가 뒤집힌 나무판까지. 분위기는 흑백 목판화 + 붉은 낙관.

#### 2. 핵심 기술 용어
- **Woodcut / linocut print style** — 목판화 · 고무판화 그림체
- **Line engraving (stripe width = tone)** — 줄 굵기로 명암 — 굵으면 어둡게
- **Domain warping (noise-displaced coordinates)** — 좌표를 잡음으로 밀어 거친 가장자리
- **Per-pixel procedural bake** — 픽셀마다 계산해 한 번 굽기

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

#### 4. 조건
- 줄무늬는 안티에일리어싱 식(clamp(0.5 + (e − d) / (픽셀 × 1.4)))으로 — 계단이 지면 안 된다
- 굽기 해상도는 기기 배율 1.5 까지만 (CPU 굽기가 길어지지 않게)
- 판화(찍은 것)와 나무판은 같은 먹 값에서 x 를 뒤집어 만든다
- 먹이 아주 드물게 빠진 점(0.6%)과 찍힘 얼룩으로 손으로 찍은 맛을 낸다

#### 5. 완성 기준 (이게 보이면 성공)
- 면마다 다른 방향 · 굵기의 줄로 명암이 보이고, 테두리가 거칠다
- 나무판(좌우 반대)이 종이를 벗기듯 판화로 바뀌는 장면이 반복된다
- 「거친 가장자리」 · 「칼자국 간격」 슬라이더로 결이 바뀐다
- 슬라이더를 움직여도 화면이 오래 멈추지 않는다

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

## 원리
- 줄무늬 한 줄 = 좌표를 간격(pitch)으로 나눈 나머지. 「나머지가 굵기의 절반보다 작으면 먹」 — 굵기(dark)가 클수록 어둡다.
- 면마다 줄 방향 · 굵기를 다르게: 정육면체 앞면은 세로 줄(위 0.24 → 아래 0.56), 윗면은 옅은 가로 줄, 옆면은 짙은 사선 줄, 하늘은 해를 도는 동심원.
- 좌표를 저주파 잡음으로 ±1.3 단위 밀어 판정하면(도메인 휘기) 테두리가 칼로 깎은 듯 거칠어진다.
- 먹 값을 한 번 계산해 두면 찍은 판화(크림 종이 · 먹색)와 나무판(좌우 반대 · 나뭇결)을 같은 값으로 굽는다.

## 핵심 코드 — 줄무늬 홈 · 거친 가장자리 판정
(발췌: demos/demosHandB.ts stripe() · woodInk() 앞부분을 정리)
```ts
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
// 안티에일리어싱 경계: 거리 d 가 e 보다 작으면 1 (먹)
const aaE = (d: number, e: number, px: number) => clamp01(0.5 + (e - d) / (px * 1.4));

/** 판 홈 줄무늬: dark(0~1) = 먹 줄 굵기 비율 = 어두움 */
function stripe(c: number, pitch: number, dark: number, px: number): number {
  const v = c / pitch;
  const fr = v - Math.floor(v);
  return aaE(Math.min(fr, 1 - fr) * pitch, dark * pitch * 0.5, px);
}

// 한 픽셀의 먹 값 (bx, by = 그림 좌표 · px = 한 픽셀 크기 · R 거칠기 · D 칼자국 간격)
function woodInkFront(bx: number, by: number, R: number, D: number, px: number, top: number): number {
  // 도메인 휘기: 판정 좌표를 잡음으로 밀면 테두리가 깎은 듯 들쭉
  const qx = bx + (vnoise(bx * 0.8, by * 0.8, 3) - 0.5) * 1.3 * R;
  const qy = by + (vnoise(bx * 0.8 + 9.1, by * 0.8, 4) - 0.5) * 1.3 * R;
  const lw = (vnoise(bx * 0.07, by * 0.07, 6) - 0.5) * 1.5 * R;   // 줄이 살짝 구불
  const tp = 0.7 + 0.6 * vnoise(bx * 0.11, by * 0.11, 12);          // 칼 힘 고르지 않게
  // 정육면체 앞면: 세로 줄, 아래로 갈수록 굵게(어둡게) + 테두리 먹 선
  const v = stripe(bx + lw, 2.5 * D, (0.24 + 0.32 * clamp01((qy - top) / 56)) * tp, px);
  return v; // 실제로는 Math.max(v, aaE(테두리까지 거리, 1.4, px))
}

// 판화 색: 크림 종이 ↔ 먹
// c = lerp(238 * p, 27, v), lerp(229 * p, 23, v), lerp(209 * p, 21, v)
// 나무판: 같은 v 를 x 를 뒤집어 (ink[y * W + (W - 1 - x)]) 나뭇결 색과 섞는다
```

## 흔한 실수 · 확인 목록
- [ ] **줄 경계를 if 로 0/1 만 주면 계단 · 지글거림이 생긴다** — 거리 기반 안티에일리어싱(aaE)으로 경계를 한 픽셀 폭만큼 부드럽게.
- [ ] **기기 배율 3 그대로 픽셀마다 계산하면 굽기가 몇 초 걸린다** — 굽기 배율을 1.5 로 묶는다. 판화 결은 그 정도로 충분하다.
- [ ] **줄 방향을 모든 면에 같게 하면 입체가 안 읽힌다** — 면마다 방향(세로 · 가로 · 사선)과 굵기를 달리해 빛 방향을 나타낸다.
- [ ] **나무판을 따로 그리면 판화와 모양이 어긋난다** — 먹 값 배열을 하나만 계산하고, 나무판은 x 를 뒤집어 같은 배열을 읽는다.

## 완성 기준 체크리스트
- [ ] 면마다 다른 방향 · 굵기의 줄로 명암이 보이고, 테두리가 거칠다
- [ ] 나무판(좌우 반대)이 종이를 벗기듯 판화로 바뀌는 장면이 반복된다
- [ ] 「거친 가장자리」 · 「칼자국 간격」 슬라이더로 결이 바뀐다
- [ ] 슬라이더를 움직여도 화면이 오래 멈추지 않는다

## 이 기술 정보
- id: `i336` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 화면 픽셀마다 잡음 여러 번 — 굽는 데 수백 ms. 배율을 1.5 로 묶고, 값이 바뀔 때만 다시 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i336
- 쓰면 좋을 때: 옛이야기 · 역사 장면처럼 무게 있는 분위기 / 「판화는 좌우가 뒤집힌다」를 보여 주는 미술 체험
- 쓰지 말 때: 매 프레임 바뀌는 장면 — 픽셀마다 계산이라 CPU 로는 굽기만 한다. 움직이려면 셰이더로 옮긴다 / 작은 아이콘 — 줄 간격이 보이지 않을 만큼 작으면 회색 덩어리가 된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### woodInk — `src/demos/demosHandB.ts:2101`
```ts
function woodInk(bx: number, by: number, R: number, D: number, px: number): number {
  const qx = bx + (vnoise(bx * 0.8, by * 0.8, 3) - 0.5) * 1.3 * R;
  const qy = by + (vnoise(bx * 0.8 + 9.1, by * 0.8, 4) - 0.5) * 1.3 * R;
  const lw = (vnoise(bx * 0.07, by * 0.07, 6) - 0.5) * 1.5 * R + (vnoise(bx * 0.5, by * 0.5, 7) - 0.5) * 0.35 * R;
  const tp = 0.7 + 0.6 * vnoise(bx * 0.11, by * 0.11, 12);
  let v = 0;
  if (qx > 176 && qx < 236 && qy > 88 && qy < 141 && inPoly(WT.pts, qx, qy)) {
    v = aaE(distPoly(WT.pts, qx, qy), 1.7, px);
    const sh = smooth(-4, 16, qx - WT.x + (qy - WT.y) * 0.3);
    v = Math.max(v, stripe(bx * 0.7 + by * 0.7 + lw, 2.2 * D, 0.55 * sh * tp, px));
    for (const e of WT.eyes) {
      const de = Math.hypot(qx - e[0], qy - e[1]);
      if (de < WT.er * 1.6) {
        v = Math.max(v, aaE(de, WT.er * 1.2, px));
        if (Math.hypot(qx - e[0] + WT.er * 0.4, qy - e[1] + WT.er * 0.45) < WT.er * 0.42) v = 0;
      }
    }
    const ds = Math.hypot(qx - WT.sm.c[0], qy - WT.sm.c[1]);
    const ang = Math.atan2(qy - WT.sm.c[1], qx - WT.sm.c[0]);
    if (ang > WT.sm.a0 && ang < WT.sm.a1) v = Math.max(v, aaE(Math.abs(ds - WT.sm.r), 0.95, px));
    for (const ch of WT.ch) if (Math.hypot(qx - ch[0], qy - ch[1]) < WT.cr) v = Math.max(v, stripe(bx - by, 1.5, 0.45, px));
  } else if (qx > 50 && qx < 130 && qy > 56 && qy < 142 && (inPoly(WC.front, qx, qy) || inPoly(WC.top, qx, qy) || inPoly(WC.side, qx, qy))) {
    if (inPoly(WC.front, qx, qy)) {
      v = stripe(bx + lw, 2.5 * D, (0.24 + 0.32 * clamp01((qy - WC.front[0]![1]) / 56)) * tp, px);
      v = Math.max(v, aaE(distPoly(WC.front, qx, qy), 1.4, px));
    } else if (inPoly(WC.top, qx, qy)) {
      v = stripe(by + lw, 2.3 * D, 0.15 * tp, px);
      v = Math.max(v, aaE(distPoly(WC.top, qx, qy), 1.4, px));
    } else {
      v = stripe(0.629 * bx + 0.777 * by + lw, 2.2 * D, 0.74, px);
      v = Math.max(v, aaE(distPoly(WC.side, qx, qy), 1.4, px));
    }
  } else if (qy > yG(qx)) {
    const c = qy - yG(qx);
    v = c < 1.8 ? 1 : stripe(by - yG(bx) + lw * 0.5, 2.7 * D, (0.45 + 0.4 * clamp01(c / 28)) * tp, px);
    if ((qx > 62 && qx < 134 && c < 4.5) || (qx > 182 && qx < 232 && c < 3.5)) v = 1;
  } else if (qy > yH(qx)) {
    const c = qy - yH(qx);
    v = c < 1.3 ? 1 : stripe(by + lw, 2.4 * D, (0.22 + 0.25 * clamp01(c / 18)) * tp, px);
  } else {
    const d = Math.hypot(qx - 150, qy - 40);
    // 구름
    let cd = 1e9;
    for (const [cx, cy, r] of [
      [30, 34, 8],
      [41, 28, 10],
      [53, 33, 8],
      [238, 26, 7],
      [249, 21, 9],
      [260, 26, 7],
    ] as [number, number, number][])
      cd = Math.min(cd, Math.hypot(qx - cx, qy - cy) - r);
    const cloudBottom = qx < 140 ? 38 : 30;
    if (cd < 0 && qy < cloudBottom) {
      v = Math.max(aaE(-cd, 1.1, px), aaE(cloudBottom - qy, 1.1, px));
      v = Math.max(v, stripe(by + lw, 2.4, 0.12 * smooth(cloudBottom - 8, cloudBottom, qy), px));
    } else if (d < 12.5) v = d > 3 ? stripe(d, 3.2, 0.16, px) : 0;
    else if (d < 15.4) v = aaE(Math.abs(d - 13.9), 1.4, px);
    else {
      const a = Math.atan2(qy - 40, qx - 150);
      const ray = Math.floor((a + Math.PI) / (TAU / 22)) % 2 === 0;
      const dark = (0.1 + 0.62 * smooth(16, 200, d)) * (ray ? 0.3 : 1);
      v = stripe(Math.hypot(bx - 150, by - 40) + lw, 3 * D, dark * tp, px);
    }
  }
  v *= 1 - 0.25 * smooth(0.58, 0.8, fbm(bx * 0.012, by * 0.1, 40));
  return v;
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [점묘 (스티플링)](https://ai-techstudio.web.app/ai/t/i331.md) `i331` · [만화 펜선 + 스크린톤](https://ai-techstudio.web.app/ai/t/i335.md) `i335`
- 다음에 해 볼 기술: [설계도 · 모눈 도면 (블루프린트)](https://ai-techstudio.web.app/ai/t/i339.md) `i339`
