# AI 꾸러미 — 그린 그림을 물체로 (선 → 물리) — Drawn physics (line to rigid body)
> 손을 떼는 순간 그은 선을 5px 간격 점 묶음으로 바꾸고, 베를레 적분 + 원래 모양 되맞추기로 단단한 물체처럼 떨어져 경사판을 굴러가게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i322

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

## 주문서

### 만들어 줘: 그린 그림을 물체로 (선 → 물리) — Drawn physics (line to rigid body)

#### 1. 목표
그려서 푸는 퍼즐에 「그린 선이 물체가 되는」 물리를 넣어 줘 — 손 떼면 선이 단단한 물체가 되어 떨어지고 경사판을 굴러가게. 분위기는 크레파스 스케치북.

#### 2. 핵심 기술 용어
- **Drawn physics (line to rigid body)** — 그린 선 → 물리 물체
- **Verlet integration** — 베를레 — 지금 · 이전 위치로 속도 없이 적분
- **Shape matching (rigid)** — 모양 되맞추기 — 원래 모양을 가장 잘 맞는 각으로 돌려 덮기
- **Point–segment collision** — 점과 선분 충돌 · 밀어내기

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

#### 4. 조건
- 손 뗄 때 선을 같은 간격 점으로 다시 찍기 (빠르게 그은 선도 고르게)
- 한 프레임 여러 번 나눠 적분 + 한 걸음 최대 이동 제한 (얇은 경사판 뚫림 막기)
- 충돌은 위치를 밀어내고 이전 위치를 고쳐 속도까지 바꾸기 (베를레 방식)
- 매 걸음 모양 되맞추기로 원래 모양 유지
- 물체 수 상한 · 화면 밖으로 떨어진 물체 지우기

#### 5. 완성 기준 (이게 보이면 성공)
- 동그라미를 그리고 손을 떼면 바퀴처럼 떨어져 경사판을 굴러 내려간다
- 막대를 그으면 떨어져 비스듬히 미끄러지고, 세모는 툭 떨어져 구른다
- 물체끼리 부딪히면 서로 밀어낸다
- 물체가 찌그러지지 않고 그린 모양 그대로 돈다

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

## 원리
- 손을 떼면 선을 5px 간격으로 다시 찍고, 처음 · 끝이 가까우면 닫힌 모양으로. 무게중심 기준 원래 모양(rest)을 기억한다.
- 베를레: 새 위치 = 지금 + (지금 − 이전) × 0.999 + 중력 × dt². 한 걸음 최대 3.5px 로 막아 뚫림을 줄인다. 한 프레임을 4번 나눠 돈다.
- 각 점을 경사판 · 땅 · 벽 선분과 비교해 반지름 2.6 안이면 바깥으로 밀어내고, 이전 위치를 고쳐 미끄러짐(마찰 0.1) · 튐(0.15)을 낸다. 물체끼리는 점과 점 거리로 밀어낸다.
- 모양 되맞추기: 무게중심을 구하고, 원래 모양을 가장 잘 맞게 돌리는 각 = atan2(Σ 외적, Σ 내적). 그 각으로 돌린 원래 모양으로 점을 덮으면 찌그러지지 않는 단단한 물체.

## 핵심 코드 — 베를레 한 걸음 + 모양 되맞추기
(발췌: demos/demosDrawTools.ts lineToPhysics() step() · shapeMatch() 를 정리)
```ts
type P = { x: number; y: number };
type Body = { p: P[]; o: P[]; rest: P[] };      // 지금 · 이전 위치 · 무게중심 기준 원래 모양
const GRAV = 420;

function verlet(b: Body, dt: number) {
  for (let i = 0; i < b.p.length; i++) {
    const q = b.p[i], o = b.o[i];
    let vx = (q.x - o.x) * 0.999;
    let vy = (q.y - o.y) * 0.999 + GRAV * dt * dt;
    const v = Math.hypot(vx, vy);
    if (v > 3.5) { vx *= 3.5 / v; vy *= 3.5 / v; } // 한 걸음 최대 — 뚫림 막기
    o.x = q.x; o.y = q.y;
    q.x += vx; q.y += vy;
  }
}
/** 원래 모양을 가장 잘 맞는 각으로 돌려 덮는다 — 단단한 물체 */
function shapeMatch(b: Body) {
  const n = b.p.length;
  let cx = 0, cy = 0;
  for (const q of b.p) { cx += q.x; cy += q.y; }
  cx /= n; cy /= n;
  let sc = 0, ss = 0;
  for (let i = 0; i < n; i++) {
    const r = b.rest[i], dx = b.p[i].x - cx, dy = b.p[i].y - cy;
    sc += r.x * dx + r.y * dy;                   // 내적 합
    ss += r.x * dy - r.y * dx;                   // 외적 합
  }
  const a = Math.atan2(ss, sc), c = Math.cos(a), s = Math.sin(a);
  for (let i = 0; i < n; i++) {
    const r = b.rest[i];
    b.p[i].x = cx + r.x * c - r.y * s;
    b.p[i].y = cy + r.x * s + r.y * c;
  }
}
// 프레임: dt = min(dt0, 1/30) / 4 로 4번 — verlet → (충돌 → shapeMatch) × 2
```

## 흔한 실수 · 확인 목록
- [ ] **빠르게 떨어지는 점이 얇은 경사판을 뚫고 지나간다** — 한 프레임을 4번 나누고, 한 걸음 이동을 3.5px 로 막는다.
- [ ] **충돌 때 위치만 밀면 물체가 경사판에 붙어 미끄러지지 않거나 튄다** — 베를레는 이전 위치가 곧 속도 — 밀어낸 뒤 이전 위치를 고쳐 법선 · 접선 속도를 따로 줄인다 (마찰 0.1, 튐 0.15).
- [ ] **점마다 따로 충돌하니 물체가 찌그러진다** — 충돌 뒤 매번 모양 되맞추기로 원래 모양을 덮는다.

## 완성 기준 체크리스트
- [ ] 동그라미를 그리고 손을 떼면 바퀴처럼 떨어져 경사판을 굴러 내려간다
- [ ] 막대를 그으면 떨어져 비스듬히 미끄러지고, 세모는 툭 떨어져 구른다
- [ ] 물체끼리 부딪히면 서로 밀어낸다
- [ ] 물체가 찌그러지지 않고 그린 모양 그대로 돈다

## 이 기술 정보
- id: `i322` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 물체 6개까지, 점 수십 개 × 선분 5개 × 4 나눔 × 2 번. 물체끼리 점 쌍 비교가 가장 비싸다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i322
- 쓰면 좋을 때: 「선을 그려 공을 굴려 보내기」 같은 그려서 푸는 퍼즐 (SEVEN 레벨처럼) / 낙서가 살아 움직이는 놀이
- 쓰지 말 때: 정확한 물리 퍼즐(쌓기 · 균형) — 점 묶음 흉내는 무게 · 회전 관성이 대략이다. 대신 planck.js 같은 강체 엔진 / 물체가 수십 개 — 물체끼리 점 쌍 비교가 n² 로 늘어난다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### lineToPhysics — `src/demos/demosDrawTools.ts:3174`
```ts
function lineToPhysics(): Tool {
  type Body = { p: P[]; o: P[]; rest: P[]; closed: boolean; color: string; pop: number };
  const RAD = 2.6;
  const GRAV = 420;
  const RAMPS: [P, P][] = [
    [pt(30, 108), pt(232, 160)],
    [pt(384, 150), pt(262, 196)],
  ];
  const GROUND = PAPER.y + PAPER.h - 8;
  const COLORS = ['#e2483a', '#2f6fe0', '#36a85a', '#f29a16', '#8e44ad'];
  let bodies: Body[] = [];
  let cur: P[] | null = null;
  let ci = 0;
  const segs = (): [P, P][] => [...RAMPS, [pt(PAPER.x, GROUND), pt(PAPER.x + PAPER.w, GROUND)], [pt(PAPER.x + 2, PAPER.y - 200), pt(PAPER.x + 2, GROUND)], [pt(PAPER.x + PAPER.w - 2, PAPER.y - 200), pt(PAPER.x + PAPER.w - 2, GROUND)]];
  function make(raw: P[]): void {
    if (pathLen(raw) < 10) return;
    const closed = dist(raw[0]!, raw[raw.length - 1]!) < Math.max(12, pathLen(raw) * 0.15) && pathLen(raw) > 40;
    let p = resampleStep(raw, 5);
    if (closed && p.length > 3 && dist(p[0]!, p[p.length - 1]!) < 4) p = p.slice(0, -1);
    if (p.length < 2) return;
    let cx = 0;
    let cy = 0;
    p.forEach((q) => {
      cx += q.x;
      cy += q.y;
    });
    cx /= p.length;
    cy /= p.length;
    bodies.push({ p: p.map((q) => ({ ...q })), o: p.map((q) => ({ ...q })), rest: p.map((q) => pt(q.x - cx, q.y - cy)), closed, color: COLORS[ci++ % COLORS.length]!, pop: 0 });
    if (bodies.length > 6) bodies.shift();
  }
  function collideSeg(q: P, o: P, a: P, b: P): void {
    const c = nearestOnSeg(q, a, b);
    let dx = q.x - c.x;
    let dy = q.y - c.y;
    const d = Math.hypot(dx, dy);
    if (d >= RAD) return;
    if (d < 1e-6) {
      dx = -(b.y - a.y);
      dy = b.x - a.x;
      const L = Math.hypot(dx, dy) || 1;
      dx /= L;
      dy /= L;
      if (dy > 0) {
        dx = -dx;
        dy = -dy;
      }
    } else {
      dx /= d;
      dy /= d;
    }
    q.x = c.x + dx * RAD;
    q.y = c.y + dy * RAD;
    const vx = q.x - o.x;
    const vy = q.y - o.y;
    const vn = vx * dx + vy * dy;
    const tx = vx - vn * dx;
    const ty = vy - vn * dy;
    const fr = 0.1;
    const nvn = vn < 0 ? -vn * 0.15 : vn;
    o.x = q.x - (tx * (1 - fr) + dx * nvn);
    o.y = q.y - (ty * (1 - fr) + dy * nvn);
  }
  function shapeMatch(b: Body): void {
    const n = b.p.length;
    let cx = 0;
    let cy = 0;
    for (const q of b.p) {
      cx += q.x;
      cy += q.y;
    }
    cx /= n;
    cy /= n;
    let sc = 0;
    let ss = 0;
    for (let i = 0; i < n; i++) {
      const r = b.rest[i]!;
      const dx = b.p[i]!.x - cx;
      const dy = b.p[i]!.y - cy;
      sc += r.x * dx + r.y * dy;
      ss += r.x * dy - r.y * dx;
    }
    const a = Math.atan2(ss, sc);
    const c = Math.cos(a);
    const s = Math.sin(a);
    for (let i = 0; i < n; i++) {
      const r = b.rest[i]!;
      b.p[i]!.x = cx + r.x * c - r.y * s;
      b.p[i]!.y = cy + r.x * s + r.y * c;
    }
  }
  return {
    rings: true,
    cursor: () => 'pencil',
    color: () => COLORS[ci % COLORS.length]!,
    begin(p) {
      cur = [p];
    },
    drag(p) {
      if (cur && dist(cur[cur.length - 1]!, p) > 1) cur.push(p);
    },
    end() {
      if (cur) make(cur);
      cur = null;
    },
    step(dt0) {
      const SUB = 4;
      const dt = Math.min(dt0, 1 / 30) / SUB;
      const sg = segs();
      for (let s = 0; s < SUB; s++) {
        for (const b of bodies) {
          b.pop = Math.min(1, b.pop + dt * 4);
          for (let i = 0; i < b.p.length; i++) {
            const q = b.p[i]!;
            const o = b.o[i]!;
            let vx = (q.x - o.x) * 0.999;
            let vy = (q.y - o.y) * 0.999 + GRAV * dt * dt;
            const v = Math.hypot(vx, vy);
            if (v > 3.5) {
              vx *= 3.5 / v;
              vy *= 3.5 / v;
            }
            o.x = q.x;
            o.y = q.y;
            q.x += vx;
            q.y += vy;
          }
        }
        for (let pass = 0; pass < 2; pass++) {
          for (const b of bodies) for (let i = 0; i < b.p.length; i++) for (const [a, c] of sg) collideSeg(b.p[i]!, b.o[i]!, a, c);
          // 물체끼리
          for (let x = 0; x < bodies.length; x++)
            for (let y = x + 1; y < bodies.length; y++) {
              const A = bodies[x]!;
              const B = bodies[y]!;
              for (const qa of A.p)
                for (const qb of B.p) {
                  const dx = qb.x - qa.x;
                  const dy = qb.y - qa.y;
                  const d2 = dx * dx + dy * dy;
                  if (d2 >= RAD * RAD * 4 || d2 < 1e-8) continue;
                  const d = Math.sqrt(d2);
                  const k = (RAD * 2 - d) / d / 2;
                  qa.x -= dx * k;
                  qa.y -= dy * k;
                  qb.x += dx * k;
                  qb.y += dy * k;
                }
            }
          for (const b of bodies) shapeMatch(b);
        }
      }
      bodies = bodies.filter((b) => b.p[0]!.y < 400);
    },
    clear() {
      bodies = [];
      cur = null;
    },
    draw(g) {
      clipPaper(g, PAPER);
      // 경사판 · 땅
      for (const [a, b] of RAMPS) {
        const th = Math.atan2(b.y - a.y, b.x - a.x);
        const L = dist(a, b);
        g.save();
        g.translate(a.x, a.y);
        g.rotate(th);
        const wg = g.createLinearGradient(0, 0, 0, 7);
        wg.addColorStop(0, '#c8955e');
        wg.addColorStop(1, '#8c5d32');
        g.fillStyle = wg;
        g.fillRect(0, 0, L, 7);
        g.strokeStyle = 'rgba(80,48,20,0.5)';
        g.lineWidth = 0.5;
        for (let x = 22; x < L; x += 26) {
          g.beginPath();
          g.moveTo(x, 0);
          g.lineTo(x, 7);
          g.stroke();
        }
        g.strokeStyle = '#5c3a1c';
        g.lineWidth = 0.8;
        g.strokeRect(0, 0, L, 7);
        g.restore();
        // 받침 기둥
        const m = lerpP(a, b, 0.5);
        g.strokeStyle = '#7a5434';
        g.lineWidth = 3;
        g.beginPath();
        g.moveTo(m.x, m.y + 6);
        g.lineTo(m.x, GROUND);
        g.stroke();
      }
      g.fillStyle = '#d8c7a6';
      g.fillRect(PAPER.x, GROUND, PAPER.w, 10);
      g.strokeStyle = '#8a7350';
      g.lineWidth = 1.2;
      g.beginPath();
      g.moveTo(PAPER.x, GROUND);
      g.lineTo(PAPER.x + PAPER.w, GROUND);
      g.stroke();
      for (const b of bodies) {
        let cx = 0;
        let cy = 0;
        b.p.forEach((q) => {
          cx += q.x;
          cy += q.y;
        });
        cx /= b.p.length;
        cy /= b.p.length;
        const k = 1 + Math.sin(b.pop * Math.PI) * 0.12;
        const pts = b.p.map((q) => pt(cx + (q.x - cx) * k, cy + (q.y - cy) * k));
        g.strokeStyle = 'rgba(0,0,0,0.15)';
        g.lineWidth = RAD * 2;
        g.save();
        g.translate(1.2, 1.6);
        smoothPath(g, pts, b.closed);
        g.stroke();
        g.restore();
        g.strokeStyle = b.color;
        g.lineWidth = RAD * 2;
        smoothPath(g, pts, b.closed);
        g.stroke();
        g.strokeStyle = 'rgba(255,255,255,0.35)';
        g.lineWidth = 1;
        smoothPath(g, pts.map((q) => pt(q.x - 0.8, q.y - 0.8)), b.closed);
        g.stroke();
      }
      if (cur && cur.length > 1) {
        g.strokeStyle = '#4a4a55';
        g.lineWidth = 1.4;
        g.setLineDash([3, 2]);
        smoothPath(g, cur);
        g.stroke();
        g.setLineDash([]);
      }
      g.fillStyle = '#8a7f72';
      g.font = `600 7px ${F}`;
      g.textAlign = 'left';
      g.textBaseline = 'middle';
      g.fillText('선을 그으면 손을 떼는 순간 물체가 돼요 — 동그라미는 굴러가요', 24, 34);
    },
    script(loop) {
      const r = rng(loop * 3 + 7);
      const wheel = arcPts(70 + r() * 20, 58, 15, 15, -90, 275, 40);
      const stick = [pt(276 + r() * 20, 64), pt(352, 92 + r() * 10)];
      const tri = [pt(180, 40), pt(204, 74), pt(160, 74), pt(180, 40)];
      return [S(shaky(wheel, 1, loop), 180), WAIT(0.9), S(stick, 160), WAIT(0.9), S(resampleStep(tri, 3), 160), WAIT(3.6)];
    },
  };
}
```

### i322 견본 항목 — `src/demos/demosDrawTools.ts:3444`
```ts
  i322: dom('그은 선이 손을 떼는 순간 단단한 물체가 되어 떨어지고 경사판을 굴러감', () => lineToPhysics())
```

## 관련 기술
- 먼저 알면 좋은 기술: [베를레 밧줄 · 사슬](https://ai-techstudio.web.app/ai/t/i286.md) `i286` · [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312`
- 다음에 해 볼 기술: [말랑한 몸 (스프링 젤리 · 압력)](https://ai-techstudio.web.app/ai/t/i287.md) `i287`
