# AI 꾸러미 — 지그재그 칠하기 (scribble fill) — Scribble fill (zigzag hatching)
> 면을 기울인 줄로 잘라 왔다 갔다 이어 한 줄 지그재그로 칠하고, 끝이 테두리를 살짝 삐져나가게 해, 색연필로 낙서하듯 칠한 느낌을 낸다.  
> 견본: https://ai-techstudio.web.app/#t/i326

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

## 주문서

### 만들어 줘: 지그재그 칠하기 (scribble fill) — Scribble fill (zigzag hatching)

#### 1. 목표
색칠 영역 · 도형을 지그재그 낙서 칠로 채워 줘 — 면을 한 줄로 왔다 갔다, 끝이 테두리를 살짝 넘고, 칠해지는 순서가 보이게. 분위기는 색연필 낙서.

#### 2. 핵심 기술 용어
- **Scribble fill (zigzag hatching)** — 지그재그 칠하기 — 한 줄로 왔다 갔다
- **Boustrophedon path** — 밭갈이 길 — 줄마다 방향을 바꿔 잇기
- **Polyline reveal (draw up to length)** — 길이만큼만 그려 칠해지는 애니메이션
- **Pattern stroke (canvas pattern)** — 종이 결 무늬를 붓 색으로

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

#### 4. 조건
- 면마다 한 줄로 이어진 길 (줄마다 따로 그으면 낙서 느낌이 안 난다)
- 줄 끝을 테두리 기준으로 앞뒤로 흔들어 삐져나가게
- 길은 단위 좌표로 한 번 만들어 캐시, 화면 크기에 맞춰 곱하기만
- 칠해지는 순서 애니메이션 (누적 길이까지만 그리기)
- 테두리 연필 선은 칠 위에 따로 얹기

#### 5. 완성 기준 (이게 보이면 성공)
- 면이 색연필로 왔다 갔다 칠한 한 줄로 채워지고, 끝이 테두리를 조금씩 넘는다
- 칠이 한쪽 끝부터 차례로 그려지는 모습이 보인다
- 줄 간격 · 삐져나감 슬라이더, 「다시 칠하기」로 다른 낙서
- 선 색에 종이 결 틈이 보인다

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

## 원리
- 면마다 칠 방향 각도를 무작위로 고른다 (±0.45 ~ 0.95 라디안). 그 각도로 간격(기본 0.022)마다 잘라 줄 구간들을 구한다.
- 줄 i 의 끝 B → 다음 줄 시작 A2 → … 로 이어 한 줄을 만든다. 끝점은 줄 방향으로 「삐져나감 × 간격」만큼 앞뒤로 흔든다 — 테두리를 넘거나 못 미친다.
- 각 이음 사이 가운데에 옆으로 휜 점을 하나 끼워 꺾이는 곳이 뾰족한 손 낙서처럼.
- 만든 길의 누적 길이를 들고, 시간에 따라 「여기까지만」 그리면 칠해지는 모습이 보인다. 선 색은 종이 결을 뺀 색 무늬 패턴, multiply.

## 핵심 코드 — 면을 지그재그 한 줄로
(발췌: demos/demosHandA.ts zigzag() 를 정리 (spans 는 i325 와 같음))
```ts
type P = [number, number];
declare function spans(pts: P[], ang: number, gap: number): [P, P][]; // i325 의 볼록 다각형 줄 자르기

function zigzag(pts: P[], r: () => number, gap: number, over: number): P[] {
  const ang = (r() < 0.5 ? 1 : -1) * (0.45 + r() * 0.5);   // 칠 방향
  const rows = spans(pts, ang, gap);
  const ca = Math.cos(ang), sa = Math.sin(ang);
  const out: P[] = [];
  rows.forEach(([a, b], i) => {
    const eb = (r() - 0.35) * gap * over * 1.6;              // 끝이 넘거나 못 미침
    const A: P = i === 0 ? a : out[out.length - 1];
    const B: P = [b[0] + ca * eb, b[1] + sa * eb];
    const bend = (r() - 0.5) * gap * 1.2;
    if (i === 0) out.push(A);
    out.push([(A[0] + B[0]) / 2 - sa * bend, (A[1] + B[1]) / 2 + ca * bend]); // 가운데 휜 점
    out.push(B);
    const nx = rows[i + 1];
    if (nx) {                                                 // 다음 줄 시작으로 되돌아감
      const ea2 = (r() - 0.35) * gap * over * 1.6;
      const A2: P = [nx[0][0] - ca * ea2, nx[0][1] - sa * ea2];
      const bend2 = (r() - 0.5) * gap * 1.2;
      out.push([(B[0] + A2[0]) / 2 - sa * bend2, (B[1] + A2[1]) / 2 + ca * bend2]);
      out.push(A2);
    }
  });
  return out;
}
// 그리기: lineWidth = 그림 크기 × 0.013, lineJoin · lineCap round, multiply,
// 누적 길이 len 까지만 lineTo (끝 토막은 비율로 잘라) — 칠해지는 애니메이션
```

## 흔한 실수 · 확인 목록
- [ ] **줄마다 따로 그으면 줄무늬 무늬일 뿐 낙서처럼 안 보인다** — 줄 끝 → 다음 줄 시작으로 이어 한 줄 길로 만든다.
- [ ] **끝을 테두리에 딱 맞추면 기계로 칠한 듯하다** — 끝을 줄 방향으로 앞뒤로 흔들어 조금씩 삐져나가게 (견본 −0.35 ~ 0.65 × 간격 × 넘침).
- [ ] **매 프레임 길을 새로 만들면 낙서가 바뀌어 번쩍인다** — 씨앗 난수로 한 번 만들어 캐시하고, 「다시 칠하기」 때만 씨앗을 바꾼다.

## 완성 기준 체크리스트
- [ ] 면이 색연필로 왔다 갔다 칠한 한 줄로 채워지고, 끝이 테두리를 조금씩 넘는다
- [ ] 칠이 한쪽 끝부터 차례로 그려지는 모습이 보인다
- [ ] 줄 간격 · 삐져나감 슬라이더, 「다시 칠하기」로 다른 낙서
- [ ] 선 색에 종이 결 틈이 보인다

## 이 기술 정보
- id: `i326` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 면마다 길을 한 번 만들어 캐시. 그리기는 길 하나 stroke — 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i326
- 쓰면 좋을 때: 아이 그림 · 색연필 그림체의 면 칠 / 막대그래프 같은 단순한 면을 손으로 칠한 느낌으로
- 쓰지 말 때: 오목하거나 구멍 난 면 — 견본 spans 는 볼록 다각형만. 대신 볼록 조각으로 나누기 / 넓은 배경 전체 — 줄이 너무 많아진다. 대신 수채(i327)나 결 칠(i330)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkPoly — `src/demos/demosHandA.ts:776`
```ts
function mkPoly(pts: P[], color: string): Poly {
  const cum = [0];
  for (let i = 1; i < pts.length; i++) cum.push(cum[i - 1]! + Math.hypot(pts[i]![0] - pts[i - 1]![0], pts[i]![1] - pts[i - 1]![1]));
  return { pts, L: cum[cum.length - 1]!, cum, color };
}
```

### i326 견본 항목 — `src/demos/demosHandA.ts:1474`
```ts
  i326: {
    kind: '2d',
    caption: '왼쪽 반듯하게 채운 면 · 오른쪽 색연필로 왔다 갔다 낙서하듯 칠해요 — 끝이 테두리를 살짝 삐져나가요',
    make() {
      const bk = new Bakery();
      let gapK = 1;
      let over = 1;
      let pick = 0;
      let split = true;
      let seed = 1;
      const cache = new Map<string, Poly[]>();
      const polys = (s: Subject, i: number, S: number, ox: number, oy: number): Poly[] => {
        const key = `${i}-${seed}-${gapK}-${over}`;
        let unit = cache.get(key);
        if (!unit) {
          unit = s.fills.map((f, fi) => mkPoly(zigzag(f, seed * 97 + fi * 13 + i * 5, 0.022 * gapK, over), f.color));
          cache.set(key, unit);
        }
        return unit.map((pl) => mkPoly(pl.pts.map((p): P => [ox + p[0] * S, oy + p[1] * S]), pl.color));
      };
      return {
        draw(g, w, h, t) {
          const period = 5;
          const { i, tc, a } = cyc(t, period, pick);
          const s = SUBJ[i]!;
          const st = stage(g, w, h, split, '반듯한 원본', '지그재그 칠', '#f76707');
          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;
          const pls = polys(s, i, R.S, R.sx, R.sy);
          const total = pls.reduce((q, p) => q + p.L, 0);
          let len = total * smooth(0.25, 3.2, tc);
          g.save();
          g.globalAlpha = a * 0.92;
          g.globalCompositeOperation = 'multiply';
          g.lineWidth = R.S * 0.013;
          g.lineJoin = 'round';
          g.lineCap = 'round';
          for (const pl of pls) {
            const cp = pat(g, colorTooth(pl.color));
            cp.setTransform(new DOMMatrix().scale(0.7));
            g.strokeStyle = cp;
            drawPolyUpTo(g, pl, len);
            len -= pl.L;
            if (len <= 0) break;
          }
          g.restore();
          put(g, bk.get(`o${i}`, R.S, st.d, (b) => bakePencil(b, s, { seed: 4, jit: 0.7, over: 1, passes: 1, w: 1.6, col: '#2a2733', grain: 0.4 })), R, a, 'multiply');
        },
        controls: [
          { type: 'range', label: '줄 간격', min: 0.6, max: 2, step: 0.05, value: 1, on: (v) => (gapK = v) },
          { type: 'range', label: '삐져나감', min: 0, max: 2, step: 0.1, value: 1, on: (v) => (over = v) },
          { type: 'button', label: '다시 칠하기', on: () => { seed++; cache.clear(); } },
          pickCtl((v) => (pick = v)),
          splitCtl((v) => (split = v)),
        ],
        dispose() {
          bk.clear();
          cache.clear();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [연필 빗금 명암 (해칭 · 교차 빗금)](https://ai-techstudio.web.app/ai/t/i325.md) `i325`
- 다음에 해 볼 기술: [색연필 · 크레파스 결 (종이 이)](https://ai-techstudio.web.app/ai/t/i330.md) `i330` · [마커 · 형광펜 (겹치면 진하게)](https://ai-techstudio.web.app/ai/t/i329.md) `i329`
