# AI 꾸러미 — 펜 도구 (베지에 조절점) — Pen tool (cubic Bézier path)
> 누르면 꼭짓점, 끌면 양쪽 대칭 곡선 손잡이를 만드는 펜 도구로 3차 베지에 곡선을 잇고, 첫 점을 누르면 닫힌 모양 · 손잡이를 끌어 고치게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i319

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

## 주문서

### 만들어 줘: 펜 도구 (베지에 조절점) — Pen tool (cubic Bézier path)

#### 1. 목표
곡선 · 함수 모양 그리기에 펜 도구를 넣어 줘 — 누르면 꼭짓점, 끌면 곡선 손잡이, 첫 점을 누르면 닫힌 모양, 손잡이를 끌어 고치기. 분위기는 깔끔한 디자인 도구.

#### 2. 핵심 기술 용어
- **Pen tool (cubic Bézier path)** — 펜 도구 — 3차 베지에 곡선 잇기
- **Anchor point · control handle** — 꼭짓점 · 곡선 손잡이
- **Smooth (mirrored) handles** — 들어오는 손잡이 = 나가는 손잡이 반대쪽
- **bezierCurveTo** — 캔버스 3차 곡선 그리기

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

#### 4. 조건
- 꼭짓점은 { 위치, 들어오는 손잡이, 나가는 손잡이 } 셋으로
- 끌 때 반대 손잡이를 거울로 맞춰 매끈하게 (2 × 위치 − 손가락)
- 누르기 판정 순서: 손잡이 → 꼭짓점 → 새 점 (손잡이가 꼭짓점에 가려 못 잡지 않게)
- 첫 점 근처를 누르면 닫기, 닫힌 모양은 반투명 채우기
- 손잡이 보기 끔/켬

#### 5. 완성 기준 (이게 보이면 성공)
- 누르기만 하면 꺾인 선, 누른 채 끌면 매끈한 곡선이 이어진다
- 첫 점을 누르면 모양이 닫히고 색으로 채워진다
- 손잡이 끝을 끌면 곡선 모양이 바뀌고, 꼭짓점을 끌면 손잡이와 함께 옮겨진다
- 그리는 중 마지막 점에서 마우스까지 점선 미리 보기

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

## 원리
- 꼭짓점마다 { p, hin, hout } — 위치와 들어오는 · 나가는 손잡이. 손잡이가 위치와 같으면 뾰족한 꼭짓점.
- 점 i 에서 i+1 까지 = bezierCurveTo(i.hout, (i+1).hin, (i+1).p). 닫힌 모양이면 마지막에서 첫 점까지 한 번 더.
- 새 점을 누른 채 끌면 hout = 손가락, hin = 위치 기준 반대쪽 (2p − 손가락) — 매끈하게 이어진다.
- 눌렀을 때 손잡이 · 꼭짓점 6px 안이면 그것을 끌어 고친다. 꼭짓점을 끌면 두 손잡이도 같이 옮긴다.
- 그리는 중엔 마지막 점에서 마우스까지 점선 곡선으로 미리 보기, 첫 점 7px 안이면 닫기.

## 핵심 코드 — 꼭짓점 · 손잡이 구조와 그리기 · 끌기
(발췌: demos/demosDrawTools.ts bezierPen() trace() · drag() 를 정리)
```ts
type P = { x: number; y: number };
type A = { p: P; hin: P; hout: P };            // 꼭짓점 + 들어오는 · 나가는 손잡이
type Path = { a: A[]; closed: boolean };

function trace(g: CanvasRenderingContext2D, path: Path) {
  const a = path.a;
  if (!a.length) return;
  g.beginPath();
  g.moveTo(a[0].p.x, a[0].p.y);
  const n = path.closed ? a.length : a.length - 1;
  for (let i = 0; i < n; i++) {
    const s = a[i], e = a[(i + 1) % a.length];
    g.bezierCurveTo(s.hout.x, s.hout.y, e.hin.x, e.hin.y, e.p.x, e.p.y);
  }
  if (path.closed) g.closePath();
}
/** kind: 새 점 · 나가는 손잡이 · 들어오는 손잡이 · 꼭짓점 */
function dragTo(kind: 'new' | 'out' | 'in' | 'anchor', a: A, p: P) {
  if (kind === 'new' || kind === 'out') {
    a.hout = { ...p };
    a.hin = { x: 2 * a.p.x - p.x, y: 2 * a.p.y - p.y };   // 반대쪽 거울 — 매끈
  } else if (kind === 'in') {
    a.hin = { ...p };
    a.hout = { x: 2 * a.p.x - p.x, y: 2 * a.p.y - p.y };
  } else {
    const dx = p.x - a.p.x, dy = p.y - a.p.y;            // 손잡이도 같이
    a.p = { x: a.p.x + dx, y: a.p.y + dy };
    a.hin = { x: a.hin.x + dx, y: a.hin.y + dy };
    a.hout = { x: a.hout.x + dx, y: a.hout.y + dy };
  }
}
// 새 점: { p, hin: p, hout: p } 로 넣고 dragTo('new', …), 첫 점 7px 안을 누르면 closed = true
```

## 흔한 실수 · 확인 목록
- [ ] **손잡이를 하나만 바꾸면 꼭짓점에서 곡선이 꺾인다** — 반대 손잡이를 2 × 위치 − 손가락으로 함께 맞춘다 (매끈한 꼭짓점).
- [ ] **손잡이가 꼭짓점 위에 겹쳐 잡을 수 없다** — 누르기 판정에서 손잡이를 먼저 보고, 위치와 같은(길이 1 이하) 손잡이는 건너뛴다.
- [ ] **닫힌 모양의 마지막 곡선이 빠진다** — 닫혔으면 마지막 꼭짓점 → 첫 꼭짓점 곡선까지 그리고 closePath.

## 완성 기준 체크리스트
- [ ] 누르기만 하면 꺾인 선, 누른 채 끌면 매끈한 곡선이 이어진다
- [ ] 첫 점을 누르면 모양이 닫히고 색으로 채워진다
- [ ] 손잡이 끝을 끌면 곡선 모양이 바뀌고, 꼭짓점을 끌면 손잡이와 함께 옮겨진다
- [ ] 그리는 중 마지막 점에서 마우스까지 점선 미리 보기

## 이 기술 정보
- id: `i319` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 곡선 몇 개 다시 그리기 — 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i319
- 쓰면 좋을 때: 매끈한 곡선 · 모양을 정확히 만들고 고쳐야 할 때 / 곡선 길 · 레일 · 경로 편집기
- 쓰지 말 때: 아이들이 빠르게 그리는 놀이 — 손잡이 조작이 어렵다. 대신 부드러운 붓 선(i312) / 정확한 직선 · 각도 작도 — 스냅(i320)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### bezierPen — `src/demos/demosDrawTools.ts:2387`
```ts
function bezierPen(): Tool {
  type A = { p: P; hin: P; hout: P };
  type Path = { a: A[]; closed: boolean; fill: string };
  let paths: Path[] = [];
  let cur: Path | null = null;
  let drag: { kind: 'new' | 'anchor' | 'in' | 'out'; a: A; start: P } | null = null;
  let hoverP: P | null = null;
  let showHandles = true;
  let fills = 0;
  const FILLS = ['rgba(232,86,109,0.22)', 'rgba(47,143,216,0.2)', 'rgba(54,168,90,0.2)', 'rgba(242,154,22,0.22)'];
  function trace(g: G, p: Path): void {
    const a = p.a;
    if (!a.length) return;
    g.beginPath();
    g.moveTo(a[0]!.p.x, a[0]!.p.y);
    const n = p.closed ? a.length : a.length - 1;
    for (let i = 0; i < n; i++) {
      const s = a[i]!;
      const e = a[(i + 1) % a.length]!;
      g.bezierCurveTo(s.hout.x, s.hout.y, e.hin.x, e.hin.y, e.p.x, e.p.y);
    }
    if (p.closed) g.closePath();
  }
  function hit(p: P): { kind: 'anchor' | 'in' | 'out'; a: A } | null {
    const list = cur ? [cur, ...paths] : paths;
    for (const ph of list)
      for (const a of ph.a) {
        if (showHandles && dist(a.hout, p) < 6 && dist(a.hout, a.p) > 1) return { kind: 'out', a };
        if (showHandles && dist(a.hin, p) < 6 && dist(a.hin, a.p) > 1) return { kind: 'in', a };
        if (dist(a.p, p) < 6) return { kind: 'anchor', a };
      }
    return null;
  }
  return {
    cursor: () => 'pen',
    color: () => '#1d2747',
    hover(p) {
      hoverP = p;
    },
    begin(p) {
      if (cur && cur.a.length >= 2 && dist(p, cur.a[0]!.p) < 7) {
        cur.closed = true;
        paths.push(cur);
        cur = null;
        return;
      }
      const h = hit(p);
      if (h && !(cur && h.a === cur.a[cur.a.length - 1] && h.kind === 'anchor')) {
        drag = { kind: h.kind, a: h.a, start: p };
        return;
      }
      if (!cur) {
        cur = { a: [], closed: false, fill: FILLS[fills++ % FILLS.length]! };
        if (paths.length > 4) paths.shift();
      }
      const a: A = { p: { ...p }, hin: { ...p }, hout: { ...p } };
      cur.a.push(a);
      drag = { kind: 'new', a, start: p };
    },
    drag(p) {
      if (!drag) return;
      const a = drag.a;
      if (drag.kind === 'new' || drag.kind === 'out') {
        a.hout = { ...p };
        a.hin = pt(2 * a.p.x - p.x, 2 * a.p.y - p.y);
      } else if (drag.kind === 'in') {
        a.hin = { ...p };
        a.hout = pt(2 * a.p.x - p.x, 2 * a.p.y - p.y);
      } else {
        const dx = p.x - a.p.x;
        const dy = p.y - a.p.y;
        a.p = pt(a.p.x + dx, a.p.y + dy);
        a.hin = pt(a.hin.x + dx, a.hin.y + dy);
        a.hout = pt(a.hout.x + dx, a.hout.y + dy);
      }
    },
    end() {
      drag = null;
    },
    clear() {
      paths = [];
      cur = null;
      drag = null;
    },
    draw(g) {
      clipPaper(g, PAPER);
      for (const p of paths) {
        trace(g, p);
        g.fillStyle = p.fill;
        g.fill();
        g.strokeStyle = '#1d2747';
        g.lineWidth = 2.2;
        g.stroke();
      }
      if (cur) {
        trace(g, cur);
        g.strokeStyle = '#1d2747';
        g.lineWidth = 2.2;
        g.stroke();
        const l = cur.a[cur.a.length - 1];
        if (l && hoverP && !drag) {
          g.strokeStyle = 'rgba(47,111,224,0.55)';
          g.lineWidth = 1;
          g.setLineDash([3, 3]);
          g.beginPath();
          g.moveTo(l.p.x, l.p.y);
          g.bezierCurveTo(l.hout.x, l.hout.y, hoverP.x, hoverP.y, hoverP.x, hoverP.y);
          g.stroke();
          g.setLineDash([]);
          if (cur.a.length >= 2 && dist(hoverP, cur.a[0]!.p) < 7) {
            g.strokeStyle = '#36a85a';
            g.lineWidth = 1.4;
            g.beginPath();
            g.arc(cur.a[0]!.p.x, cur.a[0]!.p.y, 6, 0, Math.PI * 2);
            g.stroke();
          }
        }
      }
      if (!showHandles) return;
      const list = cur ? [...paths, cur] : paths;
      for (const p of list)
        for (const a of p.a) {
          const has = dist(a.hout, a.p) > 1;
          if (has) {
            g.strokeStyle = 'rgba(47,111,224,0.8)';
            g.lineWidth = 0.8;
            g.beginPath();
            g.moveTo(a.hin.x, a.hin.y);
            g.lineTo(a.hout.x, a.hout.y);
            g.stroke();
            g.fillStyle = '#2f6fe0';
            for (const h of [a.hin, a.hout]) {
              g.beginPath();
              g.arc(h.x, h.y, 2.3, 0, Math.PI * 2);
              g.fill();
            }
          }
          g.fillStyle = '#fff';
          g.strokeStyle = '#2f6fe0';
          g.lineWidth = 1.2;
          g.fillRect(a.p.x - 2.6, a.p.y - 2.6, 5.2, 5.2);
          g.strokeRect(a.p.x - 2.6, a.p.y - 2.6, 5.2, 5.2);
        }
      g.font = `600 7.5px ${F}`;
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      g.fillStyle = '#8a7f72';
      g.fillText('누르기 = 꼭짓점 · 누른 채 끌기 = 곡선 손잡이 · 첫 점 누르기 = 닫기 · 점 · 손잡이 끌어 고치기', 24, 228);
    },
    script(loop) {
      const dx = loop % 2 ? 40 : -10;
      const A0 = pt(200 + dx, 200);
      const B = pt(136 + dx, 112);
      const C = pt(200 + dx, 100);
      const D = pt(264 + dx, 112);
      const dr = (p: P, d: P): Act => S([p, pt(p.x + d.x, p.y + d.y)], 110, 1.5);
      return [
        WAIT(0.2),
        TAP(A0.x, A0.y),
        WAIT(0.25),
        dr(B, pt(0, -52)),
        WAIT(0.25),
        TAP(C.x, C.y),
        WAIT(0.25),
        dr(D, pt(0, 52)),
        WAIT(0.3),
        TAP(A0.x, A0.y),
        WAIT(0.6),
        S([pt(B.x, B.y - 52), pt(B.x - 24, B.y - 60)], 80, 1.2),
        WAIT(0.4),
        S([pt(D.x, D.y + 52), pt(D.x + 22, D.y + 58)], 80, 1.2),
        WAIT(1.6),
      ];
    },
    controls: [{ type: 'toggle', label: '손잡이 보기', value: true, on: (v) => (showHandles = v) }],
  };
}
```

### i319 견본 항목 — `src/demos/demosDrawTools.ts:3441`
```ts
  i319: dom('펜 도구 — 누르면 꼭짓점, 끌면 곡선 손잡이, 첫 점을 누르면 닫힌 모양 · 손잡이 끌어 고치기', () => bezierPen())
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312`
- 다음에 해 볼 기술: [모눈 · 자석 맞춤 (스냅)](https://ai-techstudio.web.app/ai/t/i320.md) `i320`
- 참고 문서: [MDN — CanvasRenderingContext2D.bezierCurveTo()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/bezierCurveTo)
