# AI 꾸러미 — 스케치 선 (넘침 · 두 번 긋기) — Sketchy rendering (overshoot · double stroke)
> 도형의 변을 하나씩 따로, 양 끝을 살짝 넘겨 떨리게 긋고 두 번 겹쳐 그어, 반듯한 도형을 연필로 슥슥 그린 스케치처럼 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i324

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

## 주문서

### 만들어 줘: 스케치 선 (넘침 · 두 번 긋기) — Sketchy rendering (overshoot · double stroke)

#### 1. 목표
도형 · 입체도형 그림을 연필 스케치 선으로 그려 줘 — 변마다 따로 모서리를 살짝 넘겨 긋고 두 번 겹쳐, 연한 보조선도. 분위기는 연필 공책 스케치.

#### 2. 핵심 기술 용어
- **Sketchy rendering (overshoot · double stroke)** — 스케치 선 — 모서리 넘침 · 두 번 긋기
- **Seeded hand jitter (1D noise along path)** — 길 따라 부드러운 잡음 떨림
- **Pressure taper (variable-width ribbon)** — 가운데 굵고 양끝 가는 띠로 한 획
- **Construction lines** — 연한 보조선 (구도 잡기)

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

#### 4. 조건
- 떨림 · 넘침은 씨앗이 정해진 난수(mulberry32) — Math.random 금지 (다시 그려도 같은 선)
- 꼭짓점이 적은 모양은 변마다 따로 긋기 (한 경로로 이어 그으면 모서리 넘침이 안 생긴다)
- 한 획은 굵기가 변하는 띠를 채워서 (lineWidth 하나로 긋지 않기)
- 두 번째 획은 씨앗 · 굵기 · 진하기를 바꿔 살짝 어긋나게
- 모양마다 미리 굽고, 조절 값이 바뀔 때만 다시 굽기

#### 5. 완성 기준 (이게 보이면 성공)
- 오른쪽 그림의 모서리마다 선이 살짝 넘쳐 교차하고, 선이 두 겹으로 보인다
- 돋보기로 모서리를 확대하면 왼쪽은 딱 맞게 끝나고 오른쪽은 넘쳐 있다
- 모서리 넘침 0 · 겹쳐 긋기 1번이면 떨리는 선 한 줄, 값을 올리면 스케치 느낌이 진해진다
- 연한 보조선이 변 밖까지 길게 뻗어 구도를 잡은 듯 보인다

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

## 원리
- 꼭짓점이 9개 이하인 모양은 변마다 따로 떼어(explode) 긋는다 — 그래야 모서리마다 넘침이 보인다.
- 선 양 끝을 진행 방향으로 「넘침 × (0.15 ~ 1.05)」만큼 늘린다. 닫힌 모양은 시작점을 무작위로 돌리고 끝을 첫 변 쪽으로 조금 더.
- 1.6 간격으로 다시 찍고, 점마다 법선 방향으로 큰 잡음 + 작은 잡음 + 전체 휨(sin) 만큼 민다 — 씨앗이 같으면 늘 같은 선.
- 한 획은 굵기가 변하는 띠: 굵기 = w × (0.32 + 0.68 × sin(π f)^0.45) — 가운데 굵고 양끝 가늘다. 끝은 반원으로 둥글게.
- 두 번째 획은 다른 씨앗 · 0.8배 굵기 · 0.75 진하기로 겹친다. 종이 결로 군데군데 지우고(destination-out) 종이에 multiply.

## 핵심 코드 — 모서리 넘침 + 떨림 + 가운데 굵은 연필 획
(발췌: demos/demosHandA.ts handPath() · pencilStroke() 를 정리 (닫힌 모양 · 잔 잡음은 줄임))
```ts
type P = [number, number];
function mulberry(a: number) {
  return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
/** 열린 선 한 개: 양 끝 넘침 · 법선 떨림 · 전체 휨 */
function handLine(a: P, b: P, seed: number, jit: number, over: number, step = 1.6): P[] {
  const r = mulberry(seed);
  const L = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
  const ux = (b[0] - a[0]) / L, uy = (b[1] - a[1]) / L;
  const o0 = over * (0.15 + r() * 0.9), o1 = over * (0.15 + r() * 0.9);
  const s0: P = [a[0] - ux * o0, a[1] - uy * o0], s1: P = [b[0] + ux * o1, b[1] + uy * o1];
  const LL = L + o0 + o1, n = Math.max(2, Math.ceil(LL / step) + 1);
  const bow = (r() - 0.5) * 2 * jit * 1.3, ph = r() * 10;
  const out: P[] = [];
  for (let i = 0; i < n; i++) {
    const f = i / (n - 1), s = f * LL;
    const off = jit * Math.sin(s / 24 + ph) * 0.9 + bow * Math.sin(Math.PI * f); // 견본은 1D 값 잡음 두 겹
    out.push([s0[0] + (s1[0] - s0[0]) * f - uy * off, s0[1] + (s1[1] - s0[1]) * f + ux * off]);
  }
  return out;
}
/** 가운데 굵고 양끝 가는 띠로 채우기 */
function pencilStroke(g: CanvasRenderingContext2D, p: P[], w: number) {
  const n = p.length, L: P[] = [], R: P[] = [];
  for (let i = 0; i < n; i++) {
    const f = i / (n - 1);
    const half = (w * (0.32 + 0.68 * Math.pow(Math.sin(Math.PI * (0.03 + f * 0.94)), 0.45))) / 2;
    const a = p[Math.max(0, i - 1)], c = p[Math.min(n - 1, i + 1)];
    const dl = Math.hypot(c[0] - a[0], c[1] - a[1]) || 1;
    const nx = -(c[1] - a[1]) / dl, ny = (c[0] - a[0]) / dl;
    L.push([p[i][0] + nx * half, p[i][1] + ny * half]); R.push([p[i][0] - nx * half, p[i][1] - ny * half]);
  }
  g.beginPath();
  [...L, ...R.reverse()].forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
  g.closePath(); g.fill();
}
// 두 번 긋기: pass 0 → 씨앗 s, 굵기 w, 진하기 0.95 / pass 1 → 씨앗 s + 7919, 굵기 0.8w, 진하기 0.75
```

## 흔한 실수 · 확인 목록
- [ ] **닫힌 다각형을 한 경로로 그으면 모서리 넘침이 생기지 않는다** — 꼭짓점이 적은 모양은 변마다 따로 떼어 긋는다 (견본 explode — 9개 이하).
- [ ] **두 번째 획을 같은 씨앗으로 그으면 한 줄처럼 겹쳐 버린다** — 씨앗을 바꾸고 굵기 0.8배 · 진하기 0.75 로 살짝 어긋나게.
- [ ] **떨림을 점마다 따로 무작위로 주면 지글지글한 톱니가 된다** — 길 따라 부드럽게 변하는 1D 잡음 + 전체 휨(sin)으로 준다.

## 완성 기준 체크리스트
- [ ] 오른쪽 그림의 모서리마다 선이 살짝 넘쳐 교차하고, 선이 두 겹으로 보인다
- [ ] 돋보기로 모서리를 확대하면 왼쪽은 딱 맞게 끝나고 오른쪽은 넘쳐 있다
- [ ] 모서리 넘침 0 · 겹쳐 긋기 1번이면 떨리는 선 한 줄, 값을 올리면 스케치 느낌이 진해진다
- [ ] 연한 보조선이 변 밖까지 길게 뻗어 구도를 잡은 듯 보인다

## 이 기술 정보
- id: `i324` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 모양마다 한 번 화면 밖 캔버스에 굽고 drawImage. 굽기는 조절 값이 바뀔 때만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i324
- 쓰면 좋을 때: 도형 · 그래프 설명을 손으로 그린 공책 느낌으로 / 스케치북 · 낙서 그림체 게임의 모든 선
- 쓰지 말 때: 정확한 길이 · 각도를 재는 화면 — 넘침 · 떨림이 오해를 만든다. 대신 반듯한 선 / 작은 아이콘 · 글씨 — 떨림이 읽기를 방해한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### handPath — `src/demos/demosHandA.ts:412`
```ts
function handPath(pts: P[], closed: boolean, seed: number, jit: number, over: number, sc: number, step = 1.6): RS {
  const r = mulberry(seed);
  let path: P[] = pts.slice();
  const unit = (a: P, b: P): P => {
    const d = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
    return [(b[0] - a[0]) / d, (b[1] - a[1]) / d];
  };
  if (closed) {
    const k = Math.floor(r() * path.length);
    path = path.slice(k).concat(path.slice(0, k + 1));
    if (over > 0 && path.length > 2) {
      const u = unit(path[0]!, path[1]!);
      const o = over * (0.4 + r() * 0.9);
      path.push([path[0]![0] + u[0] * o, path[0]![1] + u[1] * o]);
    }
  } else if (over > 0 && path.length > 1) {
    const u0 = unit(path[1]!, path[0]!);
    const o0 = over * (0.15 + r() * 0.9);
    path.unshift([path[0]![0] + u0[0] * o0, path[0]![1] + u0[1] * o0]);
    const n = path.length;
    const u1 = unit(path[n - 2]!, path[n - 1]!);
    const o1 = over * (0.15 + r() * 0.9);
    path.push([path[n - 1]![0] + u1[0] * o1, path[n - 1]![1] + u1[1] * o1]);
  }
  const rs = resample(path, step * sc);
  const bow = (r() - 0.5) * 2 * jit * 1.3;
  const out: P[] = [];
  const n = rs.p.length;
  for (let i = 0; i < n; i++) {
    const a = rs.p[Math.max(0, i - 1)]!;
    const c = rs.p[Math.min(n - 1, i + 1)]!;
    const dl = Math.hypot(c[0] - a[0], c[1] - a[1]) || 1;
    const nx = -(c[1] - a[1]) / dl;
    const ny = (c[0] - a[0]) / dl;
    const s = rs.s[i]!;
    const off = jit * ((n1(s / (24 * sc), seed) - 0.5) * 1.8 + (n1(s / (5 * sc), seed + 3) - 0.5) * 0.45) + bow * Math.sin((Math.PI * s) / (rs.L || 1));
    out.push([rs.p[i]![0] + nx * off, rs.p[i]![1] + ny * off]);
  }
  return { p: out, s: rs.s, L: rs.L };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312`
- 다음에 해 볼 기술: [선 들끓기 (line boil)](https://ai-techstudio.web.app/ai/t/i323.md) `i323` · [연필 빗금 명암 (해칭 · 교차 빗금)](https://ai-techstudio.web.app/ai/t/i325.md) `i325`
