# AI 꾸러미 — 점묘 (스티플링) — Stippling (weighted Voronoi stippling)
> 어두운 곳일수록 점을 많이 뿌린 뒤, 가중 보로노이 이완으로 점을 고르게 펴서 점의 밀도만으로 명암을 낸다.  
> 견본: https://ai-techstudio.web.app/#t/i331

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

## 주문서

### 만들어 줘: 점묘 (스티플링) — Stippling (weighted Voronoi stippling)

#### 1. 목표
정육면체 · 원그래프 같은 도형 그림을 점묘(스티플링) 그림체로 그려 줘 — 어두운 곳은 점이 촘촘하고 밝은 곳은 성기게, 점은 서로 겹치지 않고 고르게. 분위기는 흰 종이에 검은 펜 점.

#### 2. 핵심 기술 용어
- **Stippling (weighted Voronoi stippling)** — 점묘 — 점의 촘촘함으로 명암
- **Lloyd's relaxation** — 로이드 이완 — 점을 자기 칸의 무게중심으로 옮기기를 되풀이
- **Rejection sampling** — 밀도만큼 확률로 받아들여 처음 점 뿌리기
- **Uniform grid nearest-neighbor** — 격자 칸으로 가장 가까운 점 빨리 찾기

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

#### 4. 조건
- 이완은 한 번에 다 돌리지 말고 프레임마다 한 단계씩 (화면이 멈추지 않게), 단계별 점 좌표를 저장
- 가까운 점 찾기는 격자 칸(점 수의 제곱근 정도)으로 — 모든 점을 다 비교하지 않는다
- 난수는 시드 고정 (같은 그림이면 매번 같은 점)
- 점 크기는 점 수가 늘면 줄인다 (반지름 ∝ √(1500 / N))

#### 5. 완성 기준 (이게 보이면 성공)
- 처음엔 점이 뭉쳐 있다가, 이완 횟수가 늘수록 고르게 퍼지는 것이 보인다
- 옆에 놓은 회색 명암 그림과 비교해 어두운 곳 · 밝은 곳이 같은 자리다
- 점 개수 슬라이더(500 ~ 3500)를 바꾸면 처음부터 다시 퍼지고, 점 크기도 알맞게 바뀐다
- 폰에서도 화면이 멈칫하지 않는다

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

## 원리
- 그림을 작은 회색 지도(견본은 150×150)로 그리고, 칸마다 「어두움 = 점이 있을 확률」인 밀도 값을 만든다.
- 처음에는 밀도만큼 확률로 점을 받아들여 막 뿌린다 — 뭉친 곳과 빈 곳이 생긴다.
- 이완 한 번: 모든 칸을 가장 가까운 점에게 나눠 주고, 점을 「밀도로 무게를 단 자기 영역의 무게중심」으로 옮긴다.
- 이것을 수십 번(견본 36번) 되풀이하면 점 사이 간격이 고르게 되면서도 어두운 곳에 더 몰린다.

## 핵심 코드 — 로이드 이완 한 단계 (밀도로 무게를 단 무게중심으로 옮기기)
(발췌: demos/demosHandA.ts stipStep() 을 정리 (격자 찾기는 단순하게))
```ts
// dens: R×R 밀도(0~1, 어두울수록 큼) · pts: [x0,y0,x1,y1,...]
function stipStep(R: number, dens: Float32Array, pts: Float32Array, nearest: (x: number, y: number) => number): Float32Array {
  const n = pts.length / 2;
  const sx = new Float64Array(n), sy = new Float64Array(n), sw = new Float64Array(n);
  for (let y = 0; y < R; y++)
    for (let x = 0; x < R; x++) {
      const w = dens[y * R + x];
      if (w < 0.004) continue;               // 흰 곳은 건너뛴다
      const px = x + 0.5, py = y + 0.5;
      const q = nearest(px, py);             // 이 칸을 차지하는 점 (격자 칸으로 빨리 찾기)
      if (q < 0) continue;
      sx[q] += px * w; sy[q] += py * w; sw[q] += w;
    }
  const out = new Float32Array(n * 2);
  for (let i = 0; i < n; i++) {
    // 무게중심으로 옮긴다 — 차지한 칸이 없으면 그 자리
    out[i * 2] = sw[i] > 0 ? sx[i] / sw[i] : pts[i * 2];
    out[i * 2 + 1] = sw[i] > 0 ? sy[i] / sw[i] : pts[i * 2 + 1];
  }
  return out;
}

// 밀도: 밝기 l(0~1) 에서 — 아주 옅은 회색은 0 으로
const densOf = (l: number) => Math.pow(Math.max(0, 1 - l - 0.03), 1.25);
// 점 반지름: 어두운 곳은 조금 크게, 점마다 살짝 다르게
const radius = (base: number, d: number, h: number) => base * (0.8 + 0.35 * Math.sqrt(d)) * (0.85 + 0.3 * h);
```

## 흔한 실수 · 확인 목록
- [ ] **막 뿌린 점만 쓰면 얼룩덜룩하다** — 확률로 뿌리면 뭉침 · 빈틈이 생긴다. 이완을 20번 이상 돌려야 점묘다운 고른 간격이 된다.
- [ ] **모든 점과 모든 칸을 비교하면 한 단계에 수 초 걸린다** — 점을 격자 칸에 넣고 가까운 칸부터 고리 모양으로 찾는다 — 찾은 거리가 고리 반지름보다 작으면 멈춘다.
- [ ] **밀도에 0 이 많으면 점이 흰 곳에 남는다** — 처음 뿌릴 때 밀도로 걸러 받고, 이완도 밀도로 무게를 달아 흰 곳에서 끌려 나오게 한다.
- [ ] **이완을 한 프레임에 다 돌리면 화면이 멈춘다** — 프레임마다 한 단계만 돌리고 단계별 결과를 저장해 보여 준다 (무거운 생성은 프레임당 몇 ms 예산).

## 완성 기준 체크리스트
- [ ] 처음엔 점이 뭉쳐 있다가, 이완 횟수가 늘수록 고르게 퍼지는 것이 보인다
- [ ] 옆에 놓은 회색 명암 그림과 비교해 어두운 곳 · 밝은 곳이 같은 자리다
- [ ] 점 개수 슬라이더(500 ~ 3500)를 바꾸면 처음부터 다시 퍼지고, 점 크기도 알맞게 바뀐다
- [ ] 폰에서도 화면이 멈칫하지 않는다

## 이 기술 정보
- id: `i331` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 이완 한 번 = 밀도 지도 2만 2천 칸 × 가까운 점 찾기. 프레임마다 한 번씩 나눠 돌리고, 끝난 점 배치는 저장해 다시 쓴다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i331
- 쓰면 좋을 때: 명암이 분명한 도형 · 아이콘을 손그림 느낌으로 보여 줄 때 / 「밀도 · 비율」 개념을 눈으로 보여 줄 때
- 쓰지 말 때: 매 프레임 바뀌는 그림 — 이완 계산이 무겁다. 움직이는 그림엔 디더링 · 스크린톤(i335)을 쓴다 / 색이 많은 그림 — 점묘는 한 색 명암에 맞다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### stipStep — `src/demos/demosHandA.ts:1138`
```ts
function stipStep(st: Stip): void {
  const { R, dens, n } = st;
  const pts = st.cur;
  const Gn = Math.max(4, Math.ceil(Math.sqrt(n) * 0.9));
  const cs = R / Gn;
  const head = new Int32Array(Gn * Gn).fill(-1);
  const next = new Int32Array(n);
  for (let i = 0; i < n; i++) {
    const cx = Math.min(Gn - 1, Math.floor(pts[i * 2]! / cs));
    const cy = Math.min(Gn - 1, Math.floor(pts[i * 2 + 1]! / cs));
    next[i] = head[cy * Gn + cx]!;
    head[cy * Gn + cx] = i;
  }
  const sx = new Float64Array(n);
  const sy = new Float64Array(n);
  const sw = new Float64Array(n);
  for (let y = 0; y < R; y++)
    for (let x = 0; x < R; x++) {
      const w = dens[y * R + x]!;
      if (w < 0.004) continue;
      const px = x + 0.5;
      const py = y + 0.5;
      const cx = Math.floor(px / cs);
      const cy = Math.floor(py / cs);
      let best = -1;
      let bd = Infinity;
      for (let ring = 0; ring < Gn; ring++) {
        for (let j = cy - ring; j <= cy + ring; j++) {
          if (j < 0 || j >= Gn) continue;
          for (let i = cx - ring; i <= cx + ring; i++) {
            if (i < 0 || i >= Gn) continue;
            if (ring > 0 && i !== cx - ring && i !== cx + ring && j !== cy - ring && j !== cy + ring) continue;
            for (let q = head[j * Gn + i]!; q >= 0; q = next[q]!) {
              const dx = pts[q * 2]! - px;
              const dy = pts[q * 2 + 1]! - py;
              const dd = dx * dx + dy * dy;
              if (dd < bd) {
                bd = dd;
                best = q;
              }
            }
          }
        }
        if (best >= 0 && Math.sqrt(bd) < ring * cs) break;
      }
      if (best < 0) continue;
      sx[best] = sx[best]! + px * w;
      sy[best] = sy[best]! + py * w;
      sw[best] = sw[best]! + w;
    }
  const nx = new Float32Array(n * 2);
  for (let i = 0; i < n; i++) {
    if (sw[i]! > 0) {
      nx[i * 2] = sx[i]! / sw[i]!;
      nx[i * 2 + 1] = sy[i]! / sw[i]!;
    } else {
      nx[i * 2] = pts[i * 2]!;
      nx[i * 2 + 1] = pts[i * 2 + 1]!;
    }
  }
  st.cur = nx;
  st.snaps.push(nx);
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [연필 빗금 명암 (해칭 · 교차 빗금)](https://ai-techstudio.web.app/ai/t/i325.md) `i325`
- 다음에 해 볼 기술: [만화 펜선 + 스크린톤](https://ai-techstudio.web.app/ai/t/i335.md) `i335` · [목판화 · 판화 (깎은 선)](https://ai-techstudio.web.app/ai/t/i336.md) `i336`
- 참고 문서: [Wikipedia — Stippling](https://en.wikipedia.org/wiki/Stippling) · [Wikipedia — Lloyd's algorithm](https://en.wikipedia.org/wiki/Lloyd%27s_algorithm)
