# AI 꾸러미 — 그려지는 애니 (손이 그리는 순서)
> 연필 끝이 선을 따라 그리고 그 뒤 색이 칠해짐 — 그리는 과정이 연출  
> 견본: https://ai-techstudio.web.app/#t/i338

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

## 주문서

### 만들어 줘: 그려지는 애니 (손이 그리는 순서)

#### 1. 목표
설명 영상에 「그려지는 애니 (손이 그리는 순서)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 연필 끝이 선을 따라 그리고 그 뒤 색이 칠해짐 — 그리는 과정이 연출

#### 2. 핵심 기술 용어
- 그려지는 애니 (손이 그리는 순서) — 연필 끝이 선을 따라 그리고 그 뒤 색이 칠해짐 — 그리는 과정이 연출

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

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「연필 끝이 선을 따라 그리고 그 뒤 색이 칠해짐 — 그리는 과정이 연출」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 연필 끝이 선을 따라 그리고 그 뒤 색이 칠해짐 — 그리는 과정이 연출


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「연필 끝이 선을 따라 그리고 그 뒤 색이 칠해짐 — 그리는 과정이 연출」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i338` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i338
- 쓰면 좋을 때: 쓰면 좋을 곳 — 설명 영상 · 정답 그리기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeDrawAnim — `src/demos/demosHandB.ts:2582`
```ts
function makeDrawAnim() {
  let speed = 1;
  let showTool = true;
  let eraseSk = true;
  let clock = 0;
  let kP = '';
  let sheet: HTMLCanvasElement | null = null;
  // 그릴 것: 원그래프 · 웃는 세모 · 정육면체
  const PC: P = [62, 98];
  const PR = 30;
  const tr = tri(146, 100, 72);
  const cb = cube(232, 102, 40);
  const slices = pieSlices(PC[0], PC[1], PR);
  const radii: P[][] = [-Math.PI / 2, Math.PI / 2, Math.PI / 2 + TAU / 3].map((a) => [PC, [PC[0] + Math.cos(a) * PR, PC[1] + Math.sin(a) * PR]]);
  const r = rng(3);
  const SK: Stroke[] = [];
  const INK: Stroke[] = [];
  const FILL: Stroke[] = [];
  let sd = 1;
  const skEdges = (pts: P[], closed: boolean, passes = 2): void => {
    const n = closed ? pts.length : pts.length - 1;
    for (let pass = 0; pass < passes; pass++)
      for (let i = 0; i < n; i++) SK.push(mkS(wobbly(over(pts[i]!, pts[(i + 1) % pts.length]!, 1 + r() * 3, 1.5 + r() * 3.5), false, sd++, 0.9, 0.1, 1.5), sd));
  };
  // ① 연필 밑그림 — 넘치게 · 두 번
  SK.push(mkS(wobbly(arcPts(PC[0], PC[1], PR + 1, -2, -2 + TAU + 0.5, 50), false, sd++, 1.4, 0.06, 1.5), sd));
  SK.push(mkS(wobbly(arcPts(PC[0], PC[1], PR - 0.5, -1.2, -1.2 + TAU + 0.3, 50), false, sd++, 1.3, 0.06, 1.5), sd));
  radii.forEach((l) => SK.push(mkS(wobbly(over(l[0]!, l[1]!, 0, 2.5), false, sd++, 0.8, 0.1, 1.5), sd)));
  skEdges(tr.pts, true);
  tr.eyes.forEach((e) => SK.push(mkS(wobbly(arcPts(e[0], e[1], tr.er * 1.4, 0, TAU + 0.6, 14), false, sd++, 0.5, 0.2, 1), sd)));
  SK.push(mkS(wobbly(arcPts(tr.sm.c[0], tr.sm.c[1], tr.sm.r, tr.sm.a0 - 0.2, tr.sm.a1 + 0.2, 12), false, sd++, 0.6, 0.1, 1.5), sd));
  skEdges(cb.front, true, 1);
  skEdges([cb.front[0]!, cb.top[3]!, cb.top[2]!, cb.side[2]!, cb.front[2]!], false, 1);
  skEdges([cb.front[1]!, cb.top[2]!], false, 1);
  // ② 펜 선 — 한 번에 또렷하게
  INK.push(mkS(wobbly(arcPts(PC[0], PC[1], PR, -Math.PI / 2, -Math.PI / 2 + TAU, 60), false, sd++, 0.35, 0.08, 1.2), sd));
  radii.forEach((l) => INK.push(mkS(resample(l, false, 1.2), sd++)));
  INK.push(mkS(wobbly(tr.pts, true, sd++, 0.3, 0.08, 1.2), sd));
  tr.eyes.forEach((e) => INK.push(mkS(arcPts(e[0], e[1], tr.er, 0, TAU, 14), sd++, { dot: { p: e, r: tr.er } })));
  INK.push(mkS(arcPts(tr.sm.c[0], tr.sm.c[1], tr.sm.r, tr.sm.a0, tr.sm.a1, 14), sd++));
  INK.push(mkS(wobbly(cb.front, true, sd++, 0.3, 0.08, 1.2), sd));
  INK.push(mkS(wobbly([cb.front[0]!, cb.top[3]!, cb.top[2]!, cb.front[1]!], false, sd++, 0.3, 0.08, 1.2), sd));
  INK.push(mkS(wobbly([cb.top[2]!, cb.side[2]!, cb.front[2]!], false, sd++, 0.3, 0.08, 1.2), sd));
  // ③ 색칠 — 마커로 지그재그
  slices.forEach((sl, i) => FILL.push(mkS(zigzag(sl.pts, -0.7 + i * 0.5, 2.6), sd++, { clip: sl.pts, col: sl.c })));
  FILL.push(mkS(zigzag(tr.pts, -0.8, 2.8), sd++, { clip: tr.pts, col: COL.yellow }));
  tr.ch.forEach((c) => {
    const cp = arcPts(c[0], c[1], tr.cr * 1.1, 0, TAU, 12);
    FILL.push(mkS(zigzag(cp, 0.6, 1.4), sd++, { clip: cp, col: COL.pink }));
  });
  (
    [
      [cb.front, COL.front, -0.6],
      [cb.top, COL.top, 0.1],
      [cb.side, COL.side, -1.3],
    ] as [P[], string, number][]
  ).forEach(([p, c, a]) => FILL.push(mkS(zigzag(p, a, 2.6), sd++, { clip: p, col: c })));
  const S1 = 2.4;
  const S2 = 3.0;
  const S3 = 2.8;
  const HOLD = 1.8;
  const FADE = 0.8;
  const TOTAL = S1 + S2 + S3 + HOLD + FADE;
  const drawList = (_g: G, list: Stroke[], st: StageAt, fn: (s: Stroke, d: number) => void): void => {
    for (let i = 0; i < list.length && i <= st.idx; i++) {
      if (i < st.idx) fn(list[i]!, Infinity);
      else if (!st.travel) fn(list[i]!, st.d);
    }
  };
  const chips = (g: G, f: Fit, stage: number): void => {
    const u = Math.min(f.u, 1.35);
    const labs = ['① 연필 밑그림', '② 펜 선', '③ 색칠'];
    g.font = `700 ${8.5 * u}px ${F}`;
    const ws = labs.map((l) => g.measureText(l).width + 12 * u);
    const gap = 5 * u;
    let x = f.w / 2 - (ws.reduce((a, b) => a + b, 0) + gap * 2) / 2;
    labs.forEach((l, i) => {
      const hh = 14 * u;
      const y = f.oy + 6 * f.u + 5 * u;
      g.fillStyle = i === stage ? 'rgba(124,58,237,0.92)' : i < stage ? 'rgba(40,48,64,0.75)' : 'rgba(40,48,64,0.35)';
      g.beginPath();
      g.roundRect(x, y, ws[i]!, hh, hh / 2);
      g.fill();
      g.fillStyle = i <= stage ? '#fff' : 'rgba(255,255,255,0.75)';
      g.textAlign = 'center';
      g.textBaseline = 'middle';
      g.fillText(l + (i < stage ? ' ✓' : ''), x + ws[i]! / 2, y + hh / 2 + 0.5 * u);
      x += ws[i]! + gap;
    });
    g.textAlign = 'left';
    g.textBaseline = 'alphabetic';
  };
  return {
    draw(g: G, w: number, h: number, _t: number, dt: number): void {
      reset(g);
      clock += Math.min(0.1, Math.max(0, dt)) * speed;
      const f = fitOf(g, w, h);
      const kp = keyOf(f);
      if (kp !== kP) {
        sheet = bakeSheet(f, [252, 251, 247], 6 * f.u);
        kP = kp;
      }
      g.drawImage(sheet!, 0, 0, w, h);
      const T = clock % TOTAL;
      const p1 = clamp01(T / S1);
      const p2 = clamp01((T - S1) / S2);
      const p3 = clamp01((T - S1 - S2) / S3);
      const fade = clamp01((T - (S1 + S2 + S3 + HOLD)) / FADE);
      const st1 = stageAt(SK, p1);
      const st2 = stageAt(INK, p2);
      const st3 = stageAt(FILL, p3);
      enter(g, f);
      g.globalAlpha = 1 - fade;
      g.lineCap = 'round';
      g.lineJoin = 'round';
      // 연필 밑그림 (색칠할 때 지우개로 옅어짐)
      const skA = eraseSk ? 1 - 0.85 * smooth(0, 0.25, p3) : 1;
      g.globalAlpha = (1 - fade) * skA;
      g.strokeStyle = 'rgba(80,80,88,0.55)';
      g.lineWidth = 0.7;
      drawList(g, SK, st1, (s, d) => {
        polyPath(g, d === Infinity ? s.pts : upTo(s.pts, s.cum, d), false);
        g.stroke();
      });
      // 색칠
      g.globalAlpha = 1 - fade;
      if (p3 > 0) {
        g.globalCompositeOperation = 'multiply';
        drawList(g, FILL, st3, (s, d) => {
          g.save();
          polyPath(g, s.clip!);
          g.clip();
          g.strokeStyle = s.col!;
          g.globalAlpha = (1 - fade) * 0.85;
          g.lineWidth = 3.8;
          polyPath(g, d === Infinity ? s.pts : upTo(s.pts, s.cum, d), false);
          g.stroke();
          g.restore();
        });
        g.globalCompositeOperation = 'source-over';
      }
      // 펜 선
      if (p2 > 0)
        drawList(g, INK, st2, (s, d) => {
          taper(g, s.pts, 1.7, 0.55, s.seed, '#1a1a1e', d);
          if (s.dot && d === Infinity) {
            g.fillStyle = '#1a1a1e';
            g.beginPath();
            g.arc(s.dot.p[0], s.dot.p[1], s.dot.r, 0, TAU);
            g.fill();
          }
        });
      g.globalAlpha = 1;
      if (showTool && fade === 0) {
        if (p1 < 1) tool(g, 'pencil', st1.tip, '#000', st1.travel ? 1 : 0);
        else if (p2 < 1) tool(g, 'pen', st2.tip, '#000', st2.travel ? 1 : 0);
        else if (p3 < 1) tool(g, 'marker', st3.tip, FILL[Math.min(st3.idx, FILL.length - 1)]!.col!, st3.travel ? 1 : 0);
      }
      g.restore();
      chips(g, f, p1 < 1 ? 0 : p2 < 1 ? 1 : p3 < 1 ? 2 : 3);
    },
    controls: [
      { type: 'range', label: '그리는 속도', min: 0.3, max: 2.5, step: 0.1, value: speed, on: (v) => (speed = v) },
      { type: 'toggle', label: '도구(연필 · 펜 · 마커) 보이기', value: showTool, on: (v) => (showTool = v) },
      { type: 'toggle', label: '색칠할 때 밑그림 지우기', value: eraseSk, on: (v) => (eraseSk = v) },
      { type: 'button', label: '처음부터 다시 그리기', on: () => (clock = 0) },
    ] as Control[],
  };
}
```

### i338 견본 항목 — `src/demos/demosHandB.ts:3071`
```ts
  i338: { kind: '2d', caption: '① 연필 밑그림 → ② 펜 선 → ③ 색칠 — 도구 끝이 선을 따라가며 그리는 순서 자체가 연출', make: makeDrawAnim }
```

## 관련 기술
- 이 효과를 만든 기술: [화이트보드 그리기 애니메이션](https://ai-techstudio.web.app/ai/t/i79.md) `i79`
