# AI 꾸러미 — 도형 알아보기 (손그림 → 원 · 네모 · 세모) — Sketch shape recognition
> 삐뚤게 그린 선에서 닫힘 · 꼭짓점 수 · 둥근 정도를 재어 원 · 타원 · 삼각형 · 사각형 · 직선을 알아채고, 반듯한 모양으로 착 바꿔 준다.  
> 견본: https://ai-techstudio.web.app/#t/i315

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

## 주문서

### 만들어 줘: 도형 알아보기 (손그림 → 원 · 네모 · 세모) — Sketch shape recognition

#### 1. 목표
도형 그리기 퀴즈에 손그림 도형 알아보기를 넣어 줘 — 그린 선의 닫힘 · 꼭짓점 · 둥근 정도로 원 · 삼각형 · 사각형 · 직선을 알아채 반듯하게 바꾸고 이름표. 분위기는 연필 공책.

#### 2. 핵심 기술 용어
- **Sketch shape recognition** — 손그림 도형 알아보기
- **Ramer–Douglas–Peucker simplification** — RDP — 덜 중요한 점을 빼 꼭짓점만 남기기
- **Resampling (equal spacing)** — 선을 같은 간격 점 64개로 다시 찍기
- **Coefficient of variation (ellipse fit)** — 중심 거리의 흩어짐으로 둥근 정도 재기

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

#### 4. 조건
- 먼저 같은 간격으로 다시 찍기 (빠르게 · 느리게 그린 차이 없애기)
- RDP 허용값 · 판단 문턱은 모양 크기(대각선)에 비례하게
- 알아보지 못하면 「모양 없음」으로 그대로 두기 (억지로 바꾸지 않기)
- 바뀌는 순간을 애니메이션으로 — 점마다 새 모양 위 가장 가까운 점으로
- 이름표에 확신 % 를 함께

#### 5. 완성 기준 (이게 보이면 성공)
- 대충 그린 원이 반듯한 원으로 착 바뀌고 「원 ✓ 90%」 이름표가 뜬다
- 삐뚤한 네 꼭짓점 모양은 반듯한 직사각형(거의 같으면 정사각형)이 된다
- 닫히지 않은 꼬불꼬불 선은 「모양 없음 — 그대로 둠」
- 작게 그려도 크게 그려도 같은 결과

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

## 원리
- 선을 같은 간격 점 64개로 다시 찍는다. 처음 · 끝 거리가 길이의 22% 안이면 닫힌 모양, 아니면 직선(곧은 정도 > 0.9)이거나 자유 곡선.
- 닫힌 모양은 중심에서 가장 먼 점과 그 점에서 가장 먼 점으로 둘로 나눠, 각각 RDP(허용 = 대각선 × 0.085)로 꼭짓점만 남긴다. 거의 곧게 이어지는 꼭짓점(꺾임 < 0.42 라디안)은 뺀다.
- 상자에 맞춘 타원까지의 정규 거리를 재서 흩어짐(변동 계수)이 0.075 보다 작으면 원 · 타원. 가로세로 비 0.8 ~ 1.25 면 원.
- 꼭짓점 3개 = 삼각형, 4개 = 주축 방향 직사각형(거의 수평이면 수평으로), 가로세로 비 0.88 ~ 1.14 면 정사각형. 선과 모양의 평균 어긋남으로 「몇 %」 확신을 낸다.
- 손그림 점마다 새 모양 위 가장 가까운 점으로 0.38초 동안 back 이징으로 옮겨 「착」 붙는다.

## 핵심 코드 — RDP 로 꼭짓점 남기기 · 닫힌 모양 판정
(발췌: demos/demosDrawTools.ts rdp() · recognize() 를 정리)
```ts
type P = { x: number; y: number };
const dist = (a: P, b: P) => Math.hypot(a.x - b.x, a.y - b.y);

/** 처음 · 끝을 잇는 선에서 가장 먼 점이 eps 보다 멀면 거기서 나눠 다시 */
function rdp(p: P[], eps: number): P[] {
  if (p.length < 3) return p.slice();
  const a = p[0], b = p[p.length - 1], L = dist(a, b) || 1e-6;
  let md = -1, mi = 0;
  for (let i = 1; i < p.length - 1; i++) {
    const q = p[i];
    const d = Math.abs((b.x - a.x) * (a.y - q.y) - (a.x - q.x) * (b.y - a.y)) / L;
    if (d > md) { md = d; mi = i; }
  }
  if (md > eps) return rdp(p.slice(0, mi + 1), eps).slice(0, -1).concat(rdp(p.slice(mi), eps));
  return [a, b];
}
/** 둥근 정도: 상자 타원까지 정규 거리의 변동 계수 (작을수록 원) */
function roundness(ring: P[], minx: number, maxx: number, miny: number, maxy: number) {
  const ex = (minx + maxx) / 2, ey = (miny + maxy) / 2;
  const ax = (maxx - minx) / 2 || 1, ay = (maxy - miny) / 2 || 1;
  let m = 0, m2 = 0;
  for (const q of ring) { const v = Math.hypot((q.x - ex) / ax, (q.y - ey) / ay); m += v; m2 += v * v; }
  m /= ring.length;
  return Math.sqrt(Math.max(0, m2 / ring.length - m * m)) / m;
}
// 판정: closed = dist(처음, 끝) < 0.22 × 길이
// (cv < 0.075 && 꼭짓점 ≥ 4) || 꼭짓점 ≥ 6 → 원/타원, 3 → 삼각형, 4 → 직사각형
// eps = 대각선 × 0.085, 꼭짓점 빼기: 꺾임 < 0.42 rad 이거나 변 < 대각선 × 0.08
```

## 흔한 실수 · 확인 목록
- [ ] **고정 문턱(px)을 쓰면 작은 그림 · 큰 그림 결과가 다르다** — RDP 허용값 · 꼭짓점 판단을 모양 대각선에 비례하게 (견본 0.085 · 0.08 · 0.12).
- [ ] **원을 RDP 하면 꼭짓점이 여러 개라 다각형으로 나온다** — 먼저 둥근 정도(변동 계수)로 원인지 보고, 꼭짓점 6개 이상도 원으로 본다.
- [ ] **닫힌 모양을 시작점에서 바로 RDP 하면 시작점이 꼭짓점이 된다** — 중심에서 가장 먼 점(실제 꼭짓점일 가능성이 큼)부터 돌려 시작하고 둘로 나눠 RDP.

## 완성 기준 체크리스트
- [ ] 대충 그린 원이 반듯한 원으로 착 바뀌고 「원 ✓ 90%」 이름표가 뜬다
- [ ] 삐뚤한 네 꼭짓점 모양은 반듯한 직사각형(거의 같으면 정사각형)이 된다
- [ ] 닫히지 않은 꼬불꼬불 선은 「모양 없음 — 그대로 둠」
- [ ] 작게 그려도 크게 그려도 같은 결과

## 이 기술 정보
- id: `i315` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 획 하나 끝날 때 점 64개로 한 번 계산 — 비용 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i315
- 쓰면 좋을 때: 「원을 그려 봐」 같은 도형 그리기 퀴즈 / 손으로 대충 그린 도형을 반듯하게 바꾸는 그리기 도구
- 쓰지 말 때: 글자 · 복잡한 그림 알아보기 — 꼭짓점 세기로는 안 된다. 대신 모양 비교(i316) 같은 템플릿 방식 / 별 · 하트처럼 정해진 모양 여럿 — $1 인식기 같은 템플릿 비교가 더 맞다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### shapeRecog — `src/demos/demosDrawTools.ts:1603`
```ts
function shapeRecog(): Tool {
  type Item = { raw: P[]; shape: Shape | null; from: P[]; to: P[]; t: number };
  let items: Item[] = [];
  let cur: P[] | null = null;
  function finish(raw: P[]): void {
    if (raw.length < 4 || pathLen(raw) < 14) {
      items.push({ raw, shape: null, from: [], to: [], t: 9 });
      return;
    }
    const sh = recognize(raw);
    const from = resampleN(raw, 80);
    const closed = sh.kind !== '직선' && sh.kind !== '자유 곡선';
    const to = sh.kind === '자유 곡선' ? from : from.map((q) => (closed ? nearestOnPoly(q, sh.pts) : nearestOnSeg(q, sh.pts[0]!, sh.pts[1]!)));
    items.push({ raw, shape: sh, from, to, t: 0 });
    if (items.length > 7) items.shift();
  }
  return {
    cursor: () => 'pencil',
    color: () => '#3b3b44',
    begin(p) {
      cur = [p];
    },
    drag(p) {
      if (cur && dist(cur[cur.length - 1]!, p) > 0.6) cur.push(p);
    },
    end() {
      if (cur) finish(cur);
      cur = null;
    },
    step(dt) {
      for (const it of items) it.t += dt;
    },
    clear() {
      items = [];
      cur = null;
    },
    draw(g) {
      clipPaper(g, PAPER);
      for (const it of items) {
        const sh = it.shape;
        if (!sh || sh.kind === '자유 곡선') {
          g.strokeStyle = '#4a4a55';
          g.lineWidth = 1.6;
          smoothPath(g, it.raw);
          g.stroke();
          if (sh && it.t < 1.5) {
            const c = it.raw[it.raw.length - 1]!;
            g.globalAlpha = 1 - it.t / 1.5;
            pill(g, c.x, c.y - 10, '모양 없음 — 그대로 둠', 7, 'rgba(60,60,70,0.85)', '#fff');
            g.globalAlpha = 1;
          }
          continue;
        }
        const hold = 0.28;
        const u = clamp((it.t - hold) / 0.38, 0, 1);
        const closed = sh.kind !== '직선';
        if (it.t < hold + 0.38) {
          // 손그림 → 반듯한 모양으로 착
          g.strokeStyle = 'rgba(74,74,85,0.35)';
          g.lineWidth = 1.4;
          smoothPath(g, it.raw);
          g.stroke();
          const k = easeBack(u);
          const m = it.from.map((q, i) => lerpP(q, it.to[i]!, k));
          g.strokeStyle = u > 0 ? '#2f6fe0' : '#4a4a55';
          g.lineWidth = 1.8;
          poly(g, m);
          g.stroke();
          continue;
        }
        const pop = clamp((it.t - hold - 0.38) / 0.25, 0, 1);
        g.fillStyle = 'rgba(47,111,224,0.12)';
        g.strokeStyle = '#2f6fe0';
        g.lineWidth = 2.2;
        poly(g, sh.pts, closed);
        if (closed) g.fill();
        g.stroke();
        if (sh.kind !== '원' && sh.kind !== '타원') {
          g.fillStyle = '#fff';
          for (const v of sh.pts) {
            g.beginPath();
            g.arc(v.x, v.y, 2, 0, Math.PI * 2);
            g.fill();
            g.stroke();
          }
        }
        // 이름표
        let minY = Infinity;
        let sx = 0;
        for (const q of sh.pts) {
          minY = Math.min(minY, q.y);
          sx += q.x;
        }
        sx /= sh.pts.length;
        g.save();
        g.globalAlpha = pop;
        g.translate(sx, minY - 11);
        g.scale(0.7 + 0.3 * easeBack(pop), 0.7 + 0.3 * easeBack(pop));
        pill(g, 0, 0, `${sh.kind} ✓ ${Math.round(sh.score * 100)}%`, 8, '#2f6fe0', '#fff');
        g.restore();
      }
      if (cur && cur.length > 1) {
        g.strokeStyle = '#4a4a55';
        g.lineWidth = 1.6;
        smoothPath(g, cur);
        g.stroke();
      }
    },
    script(loop) {
      const r = rng(loop * 13 + 2);
      const circle = (cx: number, cy: number, rr: number): P[] => {
        const a0 = r() * 360;
        return arcPts(cx, cy, rr * (1 + (r() - 0.5) * 0.12), rr * (1 + (r() - 0.5) * 0.12), a0, a0 + 372, 60);
      };
      const polyPts = (v: P[]): P[] => {
        const o: P[] = [];
        const vv = v.concat([v[0]!, lerpP(v[0]!, v[1]!, 0.12)]);
        for (let i = 0; i < vv.length - 1; i++) o.push(...resampleStep([vv[i]!, vv[i + 1]!], 3).slice(0, -1));
        o.push(vv[vv.length - 1]!);
        return o;
      };
      const tri = polyPts([pt(205, 84), pt(250, 168), pt(158, 162)]);
      const rect = polyPts([pt(282, 100), pt(370, 104), pt(367, 160), pt(284, 156)]);
      const shapes = [circle(88, 128, 44), tri, rect];
      const order = loop % 2 ? [2, 0, 1] : [0, 1, 2];
      const acts: Act[] = [WAIT(0.2)];
      for (const i of order) acts.push(S(shaky(shapes[i]!, 2.6, loop * 3 + i), 230), WAIT(0.75));
      acts.push(S(shaky(resampleStep([pt(60, 205), pt(340, 210)], 4), 1.6, loop), 300), WAIT(1.8));
      return acts;
    },
  };
}
```

### i315 견본 항목 — `src/demos/demosDrawTools.ts:3437`
```ts
  i315: dom('삐뚤한 손그림 → 원 · 삼각형 · 사각형 · 직선으로 알아보고 반듯하게 착', () => shapeRecog())
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312`
- 다음에 해 볼 기술: [숫자 손글씨 알아보기](https://ai-techstudio.web.app/ai/t/i316.md) `i316` · [모눈 · 자석 맞춤 (스냅)](https://ai-techstudio.web.app/ai/t/i320.md) `i320`
- 참고 문서: [Wikipedia — Ramer–Douglas–Peucker algorithm](https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm)
