# AI 꾸러미 — 모눈 · 자석 맞춤 (스냅) — Snapping (grid · angle · point)
> 찍은 점을 다른 점 · 모눈 · 15° 각도 중 가까운 곳에 착 붙여, 대충 눌러도 반듯한 도형이 되고 변 길이 · 각도 · 넓이가 저절로 표시되게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i320

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

## 주문서

### 만들어 줘: 모눈 · 자석 맞춤 (스냅) — Snapping (grid · angle · point)

#### 1. 목표
도형 작도 · 넓이 재기에 모눈 · 자석 맞춤을 넣어 줘 — 찍은 점이 다른 점 · 모눈 · 15° 각도에 착, 변 길이 · 직각 표시 · 넓이가 저절로. 분위기는 모눈종이 공책.

#### 2. 핵심 기술 용어
- **Snapping (grid · angle · point)** — 스냅 — 모눈 · 각도 · 다른 점에 붙이기
- **Magnet snap priority** — 자석 우선순위 — 점 → 모눈 → 각도
- **Shoelace formula** — 신발끈 공식 — 다각형 넓이
- **Rubber-band preview** — 고무줄 미리 보기 — 다음 변 길이

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

#### 4. 조건
- 자석 우선순위를 정해 두기 (점 → 모눈 → 각도)
- 모눈 밖으로 나가지 않게 칸 번호를 범위 안으로 자르기
- 맞춰진 자리 표시 (원래 손 위치 → 붙은 자리 점선)
- 길이는 칸 단위로, 정수 근처면 정수로 표시
- 마우스 움직임에 따라 다음 변 고무줄 미리 보기 + 길이

#### 5. 완성 기준 (이게 보이면 성공)
- 대충 눌러도 점이 모눈 교차점에 착 붙고, 빨간 점선이 원래 누른 곳을 보여 준다
- 변마다 「4」 「3」 같은 칸 길이, 직각에는 작은 네모 표시
- 닫으면 「넓이 6칸」이 통 튀어나온다
- 모눈을 끄면 앞 점에서 15° 단위 방향으로만 붙는다

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

## 원리
- 자석 순서: 이미 찍은 점 8px 안 → 그 점, 아니면 모눈을 켰으면 가장 가까운 모눈점 = round((p − 원점) ÷ 칸) × 칸, 모눈을 끄면 앞 점에서의 각도를 15° 단위로 반올림.
- 맞춰진 자리와 손이 누른 자리가 다르면 빨간 점선으로 「끌려간 길」을 0.6초 보여 준다.
- 변마다 길이를 모눈 칸 수로 (정수에 가까우면 정수로) 변 바깥쪽에 표시. 꼭짓점 각이 90° ± 0.6 이면 직각 표시.
- 첫 점(3점 이상일 때)을 다시 누르면 닫히고 신발끈 공식 넓이 ÷ 칸² 으로 「넓이 ○칸」.

## 핵심 코드 — 점 · 모눈 · 각도 스냅 + 넓이
(발췌: demos/demosDrawTools.ts snapTool() snap() 과 넓이 계산을 정리)
```ts
type P = { x: number; y: number };
const GS = 20, O = { x: 30, y: 36 }, COLS = 17, ROWS = 9;
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
const dist = (a: P, b: P) => Math.hypot(a.x - b.x, a.y - b.y);

function snap(p: P, allPts: P[], last: P | null, grid = true, angle = true): P {
  for (const q of allPts) if (dist(q, p) < 8) return { ...q };    // 1. 다른 점 자석
  if (grid) return {                                              // 2. 모눈
    x: O.x + clamp(Math.round((p.x - O.x) / GS), 0, COLS) * GS,
    y: O.y + clamp(Math.round((p.y - O.y) / GS), 0, ROWS) * GS,
  };
  if (angle && last) {                                            // 3. 15° 각도
    const a = Math.atan2(p.y - last.y, p.x - last.x);
    const s = Math.round(a / (Math.PI / 12)) * (Math.PI / 12);
    const d = dist(p, last);
    return { x: last.x + Math.cos(s) * d, y: last.y + Math.sin(s) * d };
  }
  return p;
}
/** 길이 표시: 정수 근처면 정수 */
const fmtLen = (d: number) => { const L = d / GS; return Math.abs(L - Math.round(L)) < 0.03 ? String(Math.round(L)) : L.toFixed(2); };
/** 넓이 (칸 수) — 신발끈 공식 */
function areaCells(v: P[]) {
  let A = 0;
  for (let i = 0; i < v.length; i++) { const j = (i + 1) % v.length; A += v[i].x * v[j].y - v[j].x * v[i].y; }
  return Math.abs(A) / 2 / (GS * GS);
}
```

## 흔한 실수 · 확인 목록
- [ ] **모눈 스냅이 먼저면 이미 찍은 점에 정확히 못 돌아간다** — 다른 점 자석을 가장 먼저 — 닫기 · 이어 그리기가 정확해진다.
- [ ] **부동소수 길이 2.9999 가 그대로 보인다** — 정수와 0.03 안이면 정수로 표시.
- [ ] **붙는 게 안 보이면 「내 손이 이상하다」고 느낀다** — 원래 누른 곳 → 붙은 자리 점선을 잠깐 보여 준다.

## 완성 기준 체크리스트
- [ ] 대충 눌러도 점이 모눈 교차점에 착 붙고, 빨간 점선이 원래 누른 곳을 보여 준다
- [ ] 변마다 「4」 「3」 같은 칸 길이, 직각에는 작은 네모 표시
- [ ] 닫으면 「넓이 6칸」이 통 튀어나온다
- [ ] 모눈을 끄면 앞 점에서 15° 단위 방향으로만 붙는다

## 이 기술 정보
- id: `i320` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 점 수십 개 비교 — 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i320
- 쓰면 좋을 때: 작도 · 넓이 재기처럼 정확한 점이 필요한 수학 그리기 / 폰 손가락처럼 정확히 누르기 어려울 때
- 쓰지 말 때: 자유로운 그림 그리기 — 붙는 게 오히려 방해. 대신 부드러운 붓 선(i312) / 곡선 모양 — 펜 도구(i319)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### snapTool — `src/demos/demosDrawTools.ts:2566`
```ts
function snapTool(): Tool {
  const GS = 20;
  const O = pt(30, 36);
  const COLS = 17;
  const ROWS = 9;
  type Poly = { pts: P[]; closed: boolean; t: number };
  let polys: Poly[] = [];
  let cur: Poly | null = null;
  let hoverP: P | null = null;
  let grid = true;
  let angle = true;
  let pulls: { from: P; to: P; t: number }[] = [];
  const gridSnap = (p: P): P => pt(O.x + clamp(Math.round((p.x - O.x) / GS), 0, COLS) * GS, O.y + clamp(Math.round((p.y - O.y) / GS), 0, ROWS) * GS);
  function snap(p: P): P {
    // 다른 점 자석
    const all = polys.flatMap((q) => q.pts).concat(cur?.pts ?? []);
    for (const q of all) if (dist(q, p) < 8) return { ...q };
    if (grid) return gridSnap(p);
    if (angle && cur && cur.pts.length) {
      const l = cur.pts[cur.pts.length - 1]!;
      const a = Math.atan2(p.y - l.y, p.x - l.x);
      const s = Math.round(a / (Math.PI / 12)) * (Math.PI / 12);
      const d = dist(p, l);
      return pt(l.x + Math.cos(s) * d, l.y + Math.sin(s) * d);
    }
    return p;
  }
  const fmtLen = (d: number): string => {
    const L = d / GS;
    return Math.abs(L - Math.round(L)) < 0.03 ? String(Math.round(L)) : L.toFixed(2);
  };
  function edgeLabel(g: G, a: P, b: P, cx: number, cy: number): void {
    const m = lerpP(a, b, 0.5);
    let nx = -(b.y - a.y);
    let ny = b.x - a.x;
    const L = Math.hypot(nx, ny) || 1;
    nx /= L;
    ny /= L;
    if ((m.x - cx) * nx + (m.y - cy) * ny < 0) {
      nx = -nx;
      ny = -ny;
    }
    pill(g, m.x + nx * 9, m.y + ny * 9, fmtLen(dist(a, b)), 7, '#fff3c4', '#5a4300');
  }
  return {
    rings: true,
    cursor: () => 'pencil',
    color: () => '#2f6fe0',
    hover(p) {
      hoverP = p;
    },
    begin(raw) {
      const p0 = raw;
      if (!cur) {
        cur = { pts: [], closed: false, t: 0 };
        if (polys.length > 3) polys.shift();
      }
      if (cur.pts.length >= 3 && dist(snap(p0), cur.pts[0]!) < 1) {
        cur.closed = true;
        cur.t = 0;
        polys.push(cur);
        cur = null;
        return;
      }
      const p = snap(p0);
      if (dist(p, p0) > 0.5) pulls.push({ from: p0, to: p, t: 0 });
      cur.pts.push(p);
    },
    drag() {},
    end() {},
    step(dt) {
      for (const q of pulls) q.t += dt;
      pulls = pulls.filter((q) => q.t < 0.6);
      for (const q of polys) q.t += dt;
    },
    clear() {
      polys = [];
      cur = null;
      pulls = [];
    },
    draw(g) {
      clipPaper(g, PAPER);
      // 모눈
      g.strokeStyle = 'rgba(70,130,210,0.16)';
      g.lineWidth = 0.6;
      g.beginPath();
      for (let i = 0; i <= COLS; i++) {
        g.moveTo(O.x + i * GS, O.y);
        g.lineTo(O.x + i * GS, O.y + ROWS * GS);
      }
      for (let j = 0; j <= ROWS; j++) {
        g.moveTo(O.x, O.y + j * GS);
        g.lineTo(O.x + COLS * GS, O.y + j * GS);
      }
      g.stroke();
      g.fillStyle = 'rgba(70,130,210,0.35)';
      for (let i = 0; i <= COLS; i++)
        for (let j = 0; j <= ROWS; j++) {
          g.beginPath();
          g.arc(O.x + i * GS, O.y + j * GS, 0.9, 0, Math.PI * 2);
          g.fill();
        }
      const all = cur ? [...polys, cur] : polys;
      for (const pl of all) {
        const v = pl.pts;
        if (!v.length) continue;
        let cx = 0;
        let cy = 0;
        v.forEach((q) => {
          cx += q.x;
          cy += q.y;
        });
        cx /= v.length;
        cy /= v.length;
        if (pl.closed) {
          const k = clamp(pl.t / 0.3, 0, 1);
          g.fillStyle = `rgba(47,111,224,${0.16 * k})`;
          poly(g, v, true);
          g.fill();
        }
        g.strokeStyle = '#2f6fe0';
        g.lineWidth = 2;
        poly(g, v, pl.closed);
        g.stroke();
        const n = v.length;
        const ne = pl.closed ? n : n - 1;
        for (let i = 0; i < ne; i++) edgeLabel(g, v[i]!, v[(i + 1) % n]!, cx, cy);
        if (pl.closed) {
          for (let i = 0; i < n; i++) {
            const a = v[(i - 1 + n) % n]!;
            const b = v[i]!;
            const c = v[(i + 1) % n]!;
            const a1 = Math.atan2(a.y - b.y, a.x - b.x);
            const a2 = Math.atan2(c.y - b.y, c.x - b.x);
            let d = Math.abs(a2 - a1);
            if (d > Math.PI) d = Math.PI * 2 - d;
            const deg = (d * 180) / Math.PI;
            g.strokeStyle = '#e2483a';
            g.lineWidth = 1;
            if (Math.abs(deg - 90) < 0.6) {
              const ux = Math.cos(a1) * 6;
              const uy = Math.sin(a1) * 6;
              const vx = Math.cos(a2) * 6;
              const vy = Math.sin(a2) * 6;
              g.beginPath();
              g.moveTo(b.x + ux, b.y + uy);
              g.lineTo(b.x + ux + vx, b.y + uy + vy);
              g.lineTo(b.x + vx, b.y + vy);
              g.stroke();
            } else {
              const mid = Math.atan2(Math.sin(a1) + Math.sin(a2), Math.cos(a1) + Math.cos(a2));
              g.beginPath();
              let s0 = a1;
              let s1 = a2;
              if (((s1 - s0 + Math.PI * 3) % (Math.PI * 2)) - Math.PI < 0) [s0, s1] = [s1, s0];
              g.arc(b.x, b.y, 8, s0, s1);
              g.stroke();
              g.fillStyle = '#c0392b';
              g.font = `700 6.5px ${F}`;
              g.textAlign = 'center';
              g.textBaseline = 'middle';
              g.fillText(`${Math.round(deg)}°`, b.x + Math.cos(mid) * 17, b.y + Math.sin(mid) * 17);
            }
          }
          let A = 0;
          for (let i = 0; i < n; i++) A += v[i]!.x * v[(i + 1) % n]!.y - v[(i + 1) % n]!.x * v[i]!.y;
          A = Math.abs(A) / 2 / (GS * GS);
          const k = easeBack(clamp((pl.t - 0.15) / 0.35, 0, 1));
          g.save();
          g.translate(cx, cy);
          g.scale(k, k);
          pill(g, 0, 0, `넓이 ${Math.abs(A - Math.round(A)) < 0.01 ? Math.round(A) : A.toFixed(1)}칸`, 8.5, '#2f6fe0', '#fff');
          g.restore();
        }
        g.fillStyle = '#fff';
        g.strokeStyle = '#2f6fe0';
        g.lineWidth = 1.3;
        for (const q of v) {
          g.beginPath();
          g.arc(q.x, q.y, 2.4, 0, Math.PI * 2);
          g.fill();
          g.stroke();
        }
      }
      // 끌려가는 자석 표시
      for (const q of pulls) {
        const u = q.t / 0.6;
        g.globalAlpha = 1 - u;
        g.strokeStyle = '#e2483a';
        g.lineWidth = 0.9;
        g.setLineDash([2, 2]);
        g.beginPath();
        g.moveTo(q.from.x, q.from.y);
        g.lineTo(q.to.x, q.to.y);
        g.stroke();
        g.setLineDash([]);
        g.fillStyle = 'rgba(226,72,58,0.6)';
        g.beginPath();
        g.arc(q.from.x, q.from.y, 1.8, 0, Math.PI * 2);
        g.fill();
        g.strokeStyle = '#2f6fe0';
        g.beginPath();
        g.arc(q.to.x, q.to.y, 3 + u * 9, 0, Math.PI * 2);
        g.stroke();
        g.globalAlpha = 1;
      }
      // 고무줄 미리 보기
      if (hoverP && hoverP.x > PAPER.x && hoverP.x < PAPER.x + PAPER.w && hoverP.y > PAPER.y && hoverP.y < PAPER.y + PAPER.h) {
        const s = snap(hoverP);
        if (cur && cur.pts.length) {
          const l = cur.pts[cur.pts.length - 1]!;
          g.strokeStyle = 'rgba(47,111,224,0.5)';
          g.lineWidth = 1.2;
          g.setLineDash([3, 3]);
          g.beginPath();
          g.moveTo(l.x, l.y);
          g.lineTo(s.x, s.y);
          g.stroke();
          g.setLineDash([]);
          if (dist(l, s) > 4) pill(g, (l.x + s.x) / 2, (l.y + s.y) / 2 - 8, fmtLen(dist(l, s)), 6.5, 'rgba(47,111,224,0.85)', '#fff');
        }
        g.strokeStyle = 'rgba(47,111,224,0.7)';
        g.lineWidth = 0.8;
        g.beginPath();
        g.moveTo(s.x - 5, s.y);
        g.lineTo(s.x + 5, s.y);
        g.moveTo(s.x, s.y - 5);
        g.lineTo(s.x, s.y + 5);
        g.stroke();
      }
      g.fillStyle = '#8a7f72';
      g.font = `600 7px ${F}`;
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      g.fillText(`한 칸 = 1 · ${grid ? '모눈 맞춤' : angle ? '15° 각도 맞춤' : '맞춤 끔'} · 첫 점을 누르면 닫힘`, 24, 228);
    },
    script(loop) {
      const jr = rng(loop * 9 + 4);
      const P_ = (i: number, j: number): Act => TAP(O.x + i * GS + (jr() - 0.5) * 12, O.y + j * GS + (jr() - 0.5) * 12);
      const acts: Act[] = [WAIT(0.2), P_(1, 8), WAIT(0.25), P_(5, 8), WAIT(0.25), P_(1, 5), WAIT(0.25), P_(1, 8), WAIT(0.9)];
      if (loop % 2) acts.push(P_(8, 8), WAIT(0.2), P_(13, 8), WAIT(0.2), P_(16, 4), WAIT(0.2), P_(11, 4), WAIT(0.2), P_(8, 8));
      else acts.push(P_(8, 7), WAIT(0.2), P_(14, 7), WAIT(0.2), P_(14, 3), WAIT(0.2), P_(8, 3), WAIT(0.2), P_(8, 7));
      acts.push(WAIT(1.8));
      return acts;
    },
    controls: [
      { type: 'toggle', label: '모눈 맞춤 (스냅)', value: true, on: (v) => (grid = v) },
      { type: 'toggle', label: '15° 각도 맞춤 (모눈 끔일 때)', value: true, on: (v) => (angle = v) },
    ],
  };
}
```

### i320 견본 항목 — `src/demos/demosDrawTools.ts:3442`
```ts
  i320: dom('찍은 점이 모눈에 착 (빨간 점선 = 원래 손 위치) — 변 길이 · 각도 · 넓이가 저절로', () => snapTool())
```

## 관련 기술
- 먼저 알면 좋은 기술: [도형 알아보기 (손그림 → 원 · 네모 · 세모)](https://ai-techstudio.web.app/ai/t/i315.md) `i315`
- 다음에 해 볼 기술: [자 · 각도기 · 컴퍼스 도구](https://ai-techstudio.web.app/ai/t/i321.md) `i321`
