# AI 꾸러미 — 자 · 각도기 · 컴퍼스 도구 — Geometric construction tools
> 화면 위 자 · 컴퍼스 · 각도기를 손가락으로 대고 돌려 선 긋기 · 원호 그리기 · 각 재기를 하게 해, 두 원이 만나는 점으로 정삼각형을 작도한다.  
> 견본: https://ai-techstudio.web.app/#t/i321

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

## 주문서

### 만들어 줘: 자 · 각도기 · 컴퍼스 도구 — Geometric construction tools

#### 1. 목표
작도 수업 (정삼각형 · 수직이등분선)에 자 · 컴퍼스 · 각도기 도구를 넣어 줘 — 끌어서 재고 긋기, 컴퍼스는 반지름을 정하고 돌려 원호, 각도기는 각 표시. 분위기는 진짜 문구 도구처럼.

#### 2. 핵심 기술 용어
- **Geometric construction tools** — 작도 도구 — 자 · 컴퍼스 · 각도기
- **Compass arc (radius lock)** — 컴퍼스 — 반지름을 정한 뒤 고정해 돌리기
- **Angle unwrapping (atan2 delta)** — 각 변화를 −π ~ π 로 맞춰 쌓기
- **Point snapping** — 이미 있는 점에 바늘 · 끝 붙이기

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

#### 4. 조건
- 컴퍼스는 「반지름 정하기 → 고정 → 돌리기」 두 단계 (계속 반지름이 바뀌면 원이 안 된다)
- 각 변화는 −π ~ π 로 감아 쌓기 (180° 를 지날 때 튀지 않게)
- 모든 도구의 시작 · 끝을 기존 점에 붙이기 (작도의 핵심 — 원의 교점에서 시작)
- 자는 cm, 컴퍼스는 반지름, 각도기는 ° 숫자를 그리는 동안 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 자로 AB 를 긋고, A · B 에 컴퍼스 바늘을 대고 같은 반지름 원호 두 개를 그리면 교점 C 가 생긴다
- AC · BC 를 이으면 정삼각형이 되고, 각도기로 재면 60° 가 나온다
- 컴퍼스를 끄는 동안 다리가 벌어졌다 돌아가며 「반지름 ○ cm」 가 보인다

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

## 원리
- 자: 누른 점 → 손가락까지 직선, 자 그림을 그 방향으로 돌려 놓고 길이를 cm(20px = 1cm)로 보여 준다.
- 컴퍼스: 바늘 = 누른 점. 끌기 시작하면 반지름 = 손가락 거리, 각 변화를 −π ~ π 로 맞춰 쌓다가 0.3 라디안을 넘으면 반지름을 고정 — 그 뒤로는 각도만 따라 원 위 점을 찍는다.
- 컴퍼스 다리는 두 다리 길이가 같은 이등변 삼각형 꼭대기(손잡이)를 √(L² − (r/2)²) 로 구해 그린다.
- 각도기: 기준 점에서 손가락 쪽 각을 0 ~ 180° 로 (화면 y 를 뒤집어 반시계가 +). 모든 도구의 시작 · 끝은 이미 있는 점 7px 안이면 그 점에 붙는다.

## 핵심 코드 — 컴퍼스 — 반지름 정하고 고정해 돌리기
(발췌: demos/demosDrawTools.ts instruments() drag() 의 컴퍼스 부분을 정리)
```ts
type P = { x: number; y: number };
type Op = { a: P; p: P; sweep: number; lastAng: number; r: number; trail: P[]; locked: boolean };
const dist = (a: P, b: P) => Math.hypot(a.x - b.x, a.y - b.y);

function compassDrag(op: Op, p0: P) {
  const ang = Math.atan2(p0.y - op.a.y, p0.x - op.a.x);
  const d = dist(p0, op.a);
  if (!op.locked) {
    if (d < 6) return;                          // 바늘 바로 옆은 무시
    if (op.r === 0) op.lastAng = ang;
    let da = ang - op.lastAng;                  // −π ~ π 로 감기
    if (da > Math.PI) da -= Math.PI * 2;
    if (da < -Math.PI) da += Math.PI * 2;
    op.sweep += da; op.lastAng = ang; op.r = d; op.p = p0;
    if (Math.abs(op.sweep) > 0.3) {             // 충분히 돌렸으면 반지름 고정
      op.locked = true;
      const a0 = ang - op.sweep;
      for (let k = 0; k <= 8; k++) {            // 지금까지 돈 만큼 원호 채우기
        const a = a0 + (op.sweep * k) / 8;
        op.trail.push({ x: op.a.x + Math.cos(a) * op.r, y: op.a.y + Math.sin(a) * op.r });
      }
    }
  } else {                                       // 고정된 뒤엔 각도만 따른다
    const q = { x: op.a.x + Math.cos(ang) * op.r, y: op.a.y + Math.sin(ang) * op.r };
    op.trail.push(q); op.p = q;
  }
}
/** 각도기: 0 ~ 180° (화면 y 가 아래라 뒤집음) */
function angleOf(v: P, p: P) {
  let d = (Math.atan2(-(p.y - v.y), p.x - v.x) * 180) / Math.PI;
  if (d < 0) d += 360;
  return Math.min(180, d > 270 ? 0 : d);
}
```

## 흔한 실수 · 확인 목록
- [ ] **반지름을 계속 손가락 거리로 쓰면 찌그러진 원이 된다** — 0.3 라디안 돌린 순간 반지름을 고정하고, 그 뒤로는 각도만 따른다.
- [ ] **180° 를 지날 때 각 변화가 2π 만큼 튄다** — 각 차이를 −π ~ π 로 감아서 쌓는다.
- [ ] **원의 교점에서 정확히 시작할 수 없어 작도가 어긋난다** — 도구 시작 · 끝을 기존 점(선 끝 · 원 중심 · 이름표 점) 7px 안에 붙인다.

## 완성 기준 체크리스트
- [ ] 자로 AB 를 긋고, A · B 에 컴퍼스 바늘을 대고 같은 반지름 원호 두 개를 그리면 교점 C 가 생긴다
- [ ] AC · BC 를 이으면 정삼각형이 되고, 각도기로 재면 60° 가 나온다
- [ ] 컴퍼스를 끄는 동안 다리가 벌어졌다 돌아가며 「반지름 ○ cm」 가 보인다

## 이 기술 정보
- id: `i321` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 도구 그림 하나 + 기록한 선 · 원호 몇 개 — 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i321
- 쓰면 좋을 때: 작도 단원 — 자 · 컴퍼스로 정삼각형 · 수직이등분선 / 「손으로 재 보는」 각도 · 길이 활동
- 쓰지 말 때: 빠른 그리기 놀이 — 도구 조작이 느리다. 대신 스냅(i320) / 정확한 수치 입력이 필요한 문제 — 손 조작은 오차가 있다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### instruments — `src/demos/demosDrawTools.ts:2819`
```ts
function instruments(): Tool {
  type Mode = 'ruler' | 'compass' | 'protractor';
  type Item = { k: 'line'; a: P; b: P } | { k: 'arc'; c: P; r: number; pts: P[] } | { k: 'angle'; v: P; deg: number; to: P } | { k: 'label'; p: P; text: string; t: number };
  let items: Item[] = [];
  let mode: Mode = 'ruler';
  let op: { a: P; p: P; sweep: number; lastAng: number; r: number; trail: P[]; locked: boolean } | null = null;
  let pose: { mode: Mode; a: P; p: P; r: number } | null = null;
  const CM = 20;
  function snapPt(p: P): P {
    for (const it of items) {
      const cands = it.k === 'line' ? [it.a, it.b] : it.k === 'arc' ? [it.c] : it.k === 'angle' ? [it.v] : [it.p];
      for (const q of cands) if (dist(q, p) < 7) return { ...q };
    }
    return p;
  }
  function drawRuler(g: G, a: P, p: P): void {
    const th = Math.atan2(p.y - a.y, p.x - a.x);
    g.save();
    g.translate(a.x, a.y);
    g.rotate(th);
    g.fillStyle = 'rgba(210,232,255,0.55)';
    g.strokeStyle = 'rgba(60,110,170,0.7)';
    g.lineWidth = 0.8;
    rrect(g, -10, 0.5, 224, 24, 2.5);
    g.fill();
    g.stroke();
    g.fillStyle = 'rgba(255,255,255,0.45)';
    g.fillRect(-8, 2, 220, 3);
    g.strokeStyle = 'rgba(30,60,100,0.85)';
    for (let mm = 0; mm <= 100; mm++) {
      const x = mm * 2;
      const L = mm % 10 === 0 ? 8 : mm % 5 === 0 ? 5.5 : 3.2;
      g.lineWidth = mm % 10 === 0 ? 0.6 : 0.35;
      g.beginPath();
      g.moveTo(x, 0.5);
      g.lineTo(x, 0.5 + L);
      g.stroke();
    }
    g.fillStyle = 'rgba(30,60,100,0.9)';
    g.font = `700 5.5px ${F}`;
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    for (let c = 0; c <= 10; c++) g.fillText(String(c), c * CM, 14);
    g.font = `600 4.5px ${F}`;
    g.fillText('cm', 210, 20);
    g.restore();
  }
  function drawCompass(g: G, c: P, p: P): void {
    const r = dist(c, p);
    const Lg = Math.max(66, r / 2 + 14);
    const m = lerpP(c, p, 0.5);
    const hgt = Math.sqrt(Math.max(0, Lg * Lg - (r / 2) * (r / 2)));
    let nx = r > 0.1 ? (p.y - c.y) / r : 0;
    let ny = r > 0.1 ? -(p.x - c.x) / r : -1;
    if (ny > 0.2) {
      nx = -nx;
      ny = -ny;
    }
    const hx = m.x + nx * hgt;
    const hy = m.y + ny * hgt;
    g.save();
    g.fillStyle = 'rgba(0,0,0,0.12)';
    g.beginPath();
    g.moveTo(c.x + 3, c.y + 3);
    g.lineTo(hx + 7, hy + 7);
    g.lineTo(p.x + 3, p.y + 3);
    g.lineWidth = 3;
    g.strokeStyle = 'rgba(0,0,0,0.12)';
    g.stroke();
    for (const [x, y, wd] of [
      [c.x, c.y, 3.2],
      [p.x, p.y, 3.6],
    ] as [number, number, number][]) {
      const gr = g.createLinearGradient(hx, hy, x, y);
      gr.addColorStop(0, '#eef1f5');
      gr.addColorStop(0.5, '#9aa3ad');
      gr.addColorStop(1, '#6c757f');
      g.strokeStyle = gr;
      g.lineWidth = wd;
      g.beginPath();
      g.moveTo(hx, hy);
      g.lineTo(lerp(hx, x, 0.88), lerp(hy, y, 0.88));
      g.stroke();
    }
    // 바늘 · 연필심
    g.strokeStyle = '#3b4048';
    g.lineWidth = 1;
    g.beginPath();
    g.moveTo(lerp(hx, c.x, 0.88), lerp(hy, c.y, 0.88));
    g.lineTo(c.x, c.y);
    g.stroke();
    g.strokeStyle = '#e9c08f';
    g.lineWidth = 2.6;
    g.beginPath();
    g.moveTo(lerp(hx, p.x, 0.88), lerp(hy, p.y, 0.88));
    g.lineTo(lerp(hx, p.x, 0.96), lerp(hy, p.y, 0.96));
    g.stroke();
    g.strokeStyle = '#2b2b33';
    g.lineWidth = 1.4;
    g.beginPath();
    g.moveTo(lerp(hx, p.x, 0.96), lerp(hy, p.y, 0.96));
    g.lineTo(p.x, p.y);
    g.stroke();
    // 손잡이
    g.fillStyle = '#c0392b';
    rrect(g, hx - 2.5, hy - 14, 5, 11, 2);
    g.fill();
    const gk = g.createRadialGradient(hx - 1, hy - 1, 0, hx, hy, 5);
    gk.addColorStop(0, '#fff');
    gk.addColorStop(1, '#7c858f');
    g.fillStyle = gk;
    g.beginPath();
    g.arc(hx, hy, 4.2, 0, Math.PI * 2);
    g.fill();
    g.restore();
  }
  function drawProtractor(g: G, v: P, p: P): void {
    const R = 70;
    g.save();
    g.fillStyle = 'rgba(255,236,170,0.45)';
    g.strokeStyle = 'rgba(160,120,20,0.8)';
    g.lineWidth = 0.8;
    g.beginPath();
    g.arc(v.x, v.y, R, Math.PI, 0);
    g.lineTo(v.x + R, v.y + 4);
    g.lineTo(v.x - R, v.y + 4);
    g.closePath();
    g.fill();
    g.stroke();
    g.beginPath();
    g.arc(v.x, v.y, R * 0.45, Math.PI, 0);
    g.stroke();
    for (let d = 0; d <= 180; d += 5) {
      const a = (-d * Math.PI) / 180;
      const L = d % 10 === 0 ? 7 : 4;
      g.lineWidth = d % 30 === 0 ? 0.7 : 0.4;
      g.beginPath();
      g.moveTo(v.x + Math.cos(a) * R, v.y + Math.sin(a) * R);
      g.lineTo(v.x + Math.cos(a) * (R - L), v.y + Math.sin(a) * (R - L));
      g.stroke();
      if (d % 30 === 0) {
        g.fillStyle = 'rgba(110,80,10,0.95)';
        g.font = `700 5.5px ${F}`;
        g.textAlign = 'center';
        g.textBaseline = 'middle';
        g.fillText(String(d), v.x + Math.cos(a) * (R - 13), v.y + Math.sin(a) * (R - 13));
      }
    }
    g.fillStyle = '#a07814';
    g.beginPath();
    g.arc(v.x, v.y, 1.5, 0, Math.PI * 2);
    g.fill();
    g.restore();
    angleMark(g, v, p, true);
  }
  function angleOf(v: P, p: P): number {
    let d = (Math.atan2(-(p.y - v.y), p.x - v.x) * 180) / Math.PI;
    if (d < 0) d += 360;
    return Math.min(180, d > 270 ? 0 : d);
  }
  function angleMark(g: G, v: P, p: P, live: boolean): void {
    const deg = angleOf(v, p);
    const a = (-deg * Math.PI) / 180;
    g.strokeStyle = '#e2483a';
    g.lineWidth = 1.3;
    g.setLineDash([3, 2]);
    g.beginPath();
    g.moveTo(v.x, v.y);
    g.lineTo(v.x + 60, v.y);
    g.stroke();
    g.setLineDash([]);
    if (live) {
      g.beginPath();
      g.moveTo(v.x, v.y);
      g.lineTo(v.x + Math.cos(a) * 80, v.y + Math.sin(a) * 80);
      g.stroke();
    }
    g.fillStyle = 'rgba(226,72,58,0.18)';
    g.beginPath();
    g.moveTo(v.x, v.y);
    g.arc(v.x, v.y, 20, 0, a, true);
    g.closePath();
    g.fill();
    g.beginPath();
    g.arc(v.x, v.y, 20, 0, a, true);
    g.stroke();
    pill(g, v.x + Math.cos(a / 2) * 34, v.y + Math.sin(a / 2) * 34, `${Math.round(deg)}°`, 7.5, '#e2483a', '#fff');
  }
  return {
    cursor: () => 'pencil',
    color: () => '#2b2b33',
    begin(p0) {
      const p = snapPt(p0);
      op = { a: p, p, sweep: 0, lastAng: 0, r: 0, trail: [], locked: false };
      pose = { mode, a: p, p, r: 0 };
    },
    drag(p0) {
      if (!op) return;
      if (mode === 'compass') {
        const ang = Math.atan2(p0.y - op.a.y, p0.x - op.a.x);
        const d = dist(p0, op.a);
        if (!op.locked) {
          if (d < 6) return;
          if (op.r === 0) op.lastAng = ang;
          let da = ang - op.lastAng;
          if (da > Math.PI) da -= Math.PI * 2;
          if (da < -Math.PI) da += Math.PI * 2;
          op.sweep += da;
          op.lastAng = ang;
          op.r = d;
          op.p = p0;
          if (Math.abs(op.sweep) > 0.3) {
            op.locked = true;
            op.r = d;
            const a0 = ang - op.sweep;
            for (let k = 0; k <= 8; k++) {
              const a = a0 + (op.sweep * k) / 8;
              op.trail.push(pt(op.a.x + Math.cos(a) * op.r, op.a.y + Math.sin(a) * op.r));
            }
          }
        } else {
          const q = pt(op.a.x + Math.cos(ang) * op.r, op.a.y + Math.sin(ang) * op.r);
          op.trail.push(q);
          op.p = q;
        }
      } else {
        op.p = p0;
      }
      if (pose) {
        pose.p = op.p;
        pose.r = op.r;
      }
    },
    end() {
      if (!op) return;
      if (mode === 'ruler') {
        const b = snapPt(op.p);
        if (dist(op.a, b) > 3) items.push({ k: 'line', a: op.a, b });
        if (pose) pose.p = b;
      } else if (mode === 'compass') {
        if (op.trail.length > 1) items.push({ k: 'arc', c: op.a, r: op.r, pts: op.trail });
      } else if (dist(op.a, op.p) > 5) items.push({ k: 'angle', v: op.a, deg: angleOf(op.a, op.p), to: op.p });
      op = null;
    },
    step(dt) {
      for (const it of items) if (it.k === 'label') it.t += dt;
    },
    clear() {
      items = [];
      op = null;
      pose = null;
    },
    draw(g) {
      clipPaper(g, PAPER);
      for (const it of items) {
        if (it.k === 'line') {
          g.strokeStyle = '#2b2b33';
          g.lineWidth = 1.5;
          poly(g, [it.a, it.b]);
          g.stroke();
        } else if (it.k === 'arc') {
          g.strokeStyle = '#6b6b78';
          g.lineWidth = 1;
          poly(g, it.pts);
          g.stroke();
          g.fillStyle = '#6b6b78';
          g.beginPath();
          g.arc(it.c.x, it.c.y, 1.2, 0, Math.PI * 2);
          g.fill();
        } else if (it.k === 'angle') {
          angleMark(g, it.v, it.to, false);
        } else {
          const k = easeBack(clamp(it.t / 0.3, 0, 1));
          g.save();
          g.translate(it.p.x, it.p.y);
          g.scale(k, k);
          if (it.text.length <= 2) {
            g.fillStyle = '#1d2747';
            g.beginPath();
            g.arc(0, 0, 2.2, 0, Math.PI * 2);
            g.fill();
            g.font = `800 9px ${F}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.fillText(it.text, 0, it.text === 'C' ? -9 : 9);
          } else pill(g, 0, 0, it.text, 8.5, '#36a85a', '#fff');
          g.restore();
        }
      }
      if (op && mode === 'ruler') {
        g.strokeStyle = '#2b2b33';
        g.lineWidth = 1.5;
        poly(g, [op.a, op.p]);
        g.stroke();
      }
      if (op && mode === 'compass' && op.trail.length > 1) {
        g.strokeStyle = '#6b6b78';
        g.lineWidth = 1;
        poly(g, op.trail);
        g.stroke();
      }
      if (pose) {
        if (pose.mode === 'ruler') {
          drawRuler(g, pose.a, dist(pose.a, pose.p) > 2 ? pose.p : pt(pose.a.x + 10, pose.a.y));
          if (op && mode === 'ruler') pill(g, op.p.x, op.p.y - 12, `${(dist(op.a, op.p) / CM).toFixed(1)} cm`, 7.5, '#1d2747', '#fff');
        } else if (pose.mode === 'compass') {
          drawCompass(g, pose.a, op && mode === 'compass' ? op.p : pose.p);
          if (op && mode === 'compass' && op.r > 5) pill(g, pose.a.x, pose.a.y + 12, `반지름 ${(op.r / CM).toFixed(1)} cm`, 7, '#1d2747', '#fff');
        } else drawProtractor(g, pose.a, pose.p);
      }
      g.fillStyle = '#8a7f72';
      g.font = `600 7px ${F}`;
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      g.fillText(`지금 도구: ${mode === 'ruler' ? '자 — 끌어서 곧은 선' : mode === 'compass' ? '컴퍼스 — 중심을 누르고 돌리기' : '각도기 — 꼭짓점을 누르고 끌기'}`, 24, 228);
    },
    script() {
      const A = pt(140, 176);
      const B = pt(260, 176);
      const r = dist(A, B);
      const Cp = pt(200, 176 - r * Math.sin(Math.PI / 3));
      const arc = (c: P, a0: number, a1: number): P[] => [c, ...arcPts(c.x, c.y, r, r, a0, a1, 40)];
      const setMode = (m: Mode): Act => DO(() => (mode = m));
      const label = (p: P, text: string): Act => DO(() => items.push({ k: 'label', p, text, t: 0 }));
      return [
        setMode('ruler'),
        S([A, B], 140),
        label(A, 'A'),
        label(B, 'B'),
        WAIT(0.4),
        setMode('compass'),
        { k: 'stroke', pts: arc(A, 0, -82), dur: 1.2 },
        WAIT(0.2),
        { k: 'stroke', pts: arc(B, 180, 262), dur: 1.2 },
        label(Cp, 'C'),
        WAIT(0.4),
        setMode('ruler'),
        S([A, Cp], 160),
        S([B, Cp], 160),
        WAIT(0.3),
        setMode('protractor'),
        S([A, lerpP(A, Cp, 0.75)], 90),
        label(pt(200, 205), '정삼각형 — 세 각 모두 60°'),
        WAIT(2.2),
      ];
    },
    controls: [
      { type: 'button', label: '자', on: () => (mode = 'ruler') },
      { type: 'button', label: '컴퍼스', on: () => (mode = 'compass') },
      { type: 'button', label: '각도기', on: () => (mode = 'protractor') },
    ],
  };
}
```

### i321 견본 항목 — `src/demos/demosDrawTools.ts:3443`
```ts
  i321: dom('자 · 컴퍼스 · 각도기로 작도 — 두 원이 만나는 점으로 정삼각형 그리기', () => instruments())
```

## 관련 기술
- 먼저 알면 좋은 기술: [모눈 · 자석 맞춤 (스냅)](https://ai-techstudio.web.app/ai/t/i320.md) `i320`
- 다음에 해 볼 기술: [그린 그림을 물체로 (선 → 물리)](https://ai-techstudio.web.app/ai/t/i322.md) `i322`
