# AI 꾸러미 — 분리축 충돌 (SAT · 다각형) — Separating Axis Theorem (SAT)
> 두 볼록 다각형을 변마다의 법선 축에 그림자로 비춰 보고, 하나라도 틈이 있으면 안 겹침 · 다 겹치면 가장 얕게 겹친 축으로 그만큼 밀어낸다.  
> 견본: https://ai-techstudio.web.app/#t/i284

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

## 주문서

### 만들어 줘: 분리축 충돌 (SAT · 다각형) — Separating Axis Theorem (SAT)

#### 1. 목표
돌아가는 다각형 조각 끼우기의 충돌을 분리축(SAT)으로 검사해 줘 — 두 다각형 변의 법선마다 그림자 구간을 비교하고, 다 겹치면 가장 얕은 축으로 그 깊이만큼 밀어낸다. 화면은 축 · 그림자 구간이 보이는 설명 화면.

#### 2. 핵심 기술 용어
- **Separating Axis Theorem (SAT)** — 분리축 정리 — 갈라 놓는 축이 하나라도 있으면 안 겹침
- **Projection interval [min, max]** — 축에 비춘 그림자 구간
- **Minimum Translation Vector (MTV)** — 가장 얕은 축 × 겹친 깊이 = 밀어낼 양
- **Convex polygon** — 볼록 다각형 (오목하면 나눠야 함)

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

#### 4. 조건
- 두 도형 모두의 변 법선을 축으로 (한쪽만 보면 틀린다)
- 법선은 길이 1 로 — 겹침 깊이가 실제 거리가 되게
- 밀어낼 방향은 A 중심 − B 중심 쪽으로 부호 맞추기
- 오목 도형은 볼록 조각으로 나눠서
- 축 · 그림자 구간 보기 켬/끔, 밀어내기 켬/끔

#### 5. 완성 기준 (이게 보이면 성공)
- 도형을 끌어 겹치면 빨갛게 바뀌고, 떼면 원래 색
- 설명 보기에서 축마다 두 그림자 구간이 보이고, 틈이 있는 축이 표시된다
- 밀어내기를 켜면 겹친 도형이 가장 얕은 축으로 딱 붙을 만큼만 밀려난다
- 도형이 돌아가도 판정이 맞다

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

## 원리
- 두 볼록 도형이 안 겹치면, 둘 사이를 가르는 직선이 있다. 그 직선에 수직인 축에 비춘 두 그림자는 서로 떨어져 있다.
- 다각형에서 그런 축 후보는 각 변의 법선뿐이다 — A 변들 + B 변들의 법선(길이 1)을 모두 시험한다.
- 축마다 꼭짓점을 내적해 [min, max] 그림자를 만들고 겹침 ov = min(maxA, maxB) − max(minA, minB). ov ≤ 0 인 축이 하나라도 있으면 안 겹침.
- 모두 겹치면 ov 가 가장 작은 축이 밀어낼 방향. 방향은 중심 A − B 쪽을 보게 부호를 맞추고, A 를 n × 깊이만큼 옮긴다.

## 핵심 코드 — 분리축 검사 + 가장 얕은 축으로 밀어내기
(발췌: demos/demos2dMove.ts axesOf() · proj() · sat() 를 정리)
```ts
type Pt = [number, number];
function axesOf(P: Pt[]): Pt[] { // 변마다 법선 (길이 1)
  const out: Pt[] = [];
  for (let i = 0; i < P.length; i++) {
    const a = P[i]!, b = P[(i + 1) % P.length]!;
    const ex = b[0] - a[0], ey = b[1] - a[1];
    const l = Math.hypot(ex, ey) || 1;
    out.push([ey / l, -ex / l]);
  }
  return out;
}
function proj(P: Pt[], n: Pt): [number, number] { // 축에 비친 그림자 [min, max]
  let mn = Infinity, mx = -Infinity;
  for (const p of P) { const d = p[0] * n[0] + p[1] * n[1]; if (d < mn) mn = d; if (d > mx) mx = d; }
  return [mn, mx];
}
const center = (P: Pt[]): Pt => [P.reduce((s, p) => s + p[0], 0) / P.length, P.reduce((s, p) => s + p[1], 0) / P.length];

function sat(A: Pt[], B: Pt[]): { hit: boolean; depth: number; n: Pt } {
  const ca = center(A), cb = center(B);
  let depth = Infinity;
  let best: Pt = [1, 0];
  for (const n of [...axesOf(A), ...axesOf(B)]) {
    const a = proj(A, n), b = proj(B, n);
    const ov = Math.min(a[1], b[1]) - Math.max(a[0], b[0]);
    if (ov <= 0) return { hit: false, depth: 0, n }; // 틈 있는 축 하나면 끝
    if (ov < depth) {
      depth = ov;
      const s = (ca[0] - cb[0]) * n[0] + (ca[1] - cb[1]) * n[1] < 0 ? -1 : 1; // A 쪽을 보게
      best = [n[0] * s, n[1] * s];
    }
  }
  return { hit: true, depth, n: best };
}

// 밀어내기: A 를 n × depth 만큼
const r = sat(A, B);
if (r.hit) { ax += r.n[0] * r.depth; ay += r.n[1] * r.depth; }
```

## 흔한 실수 · 확인 목록
- [ ] **한 도형의 축만 보면 안 겹친 것을 겹쳤다고 한다** — A 변 법선 + B 변 법선을 모두 시험한다.
- [ ] **법선을 정규화하지 않으면 밀어내는 거리가 틀린다** — 법선을 길이 1 로 나눈다.
- [ ] **밀어낼 방향 부호를 안 맞추면 오히려 더 파고든다** — 중심 A − B 와 내적해 음수면 뒤집는다.
- [ ] **오목한 도형(별 · L 모양)에 그대로 쓰면 틈 사이를 겹침으로 본다** — 볼록 조각 여러 개로 나눠 조각마다 검사한다.

## 완성 기준 체크리스트
- [ ] 도형을 끌어 겹치면 빨갛게 바뀌고, 떼면 원래 색
- [ ] 설명 보기에서 축마다 두 그림자 구간이 보이고, 틈이 있는 축이 표시된다
- [ ] 밀어내기를 켜면 겹친 도형이 가장 얕은 축으로 딱 붙을 만큼만 밀려난다
- [ ] 도형이 돌아가도 판정이 맞다

## 이 기술 정보
- id: `i284` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 축 수 = 두 도형 변 수의 합, 축마다 꼭짓점 내적. 다각형 몇 쌍은 아무 부담 없다. 쌍이 많으면 먼저 쿼드트리(i263)로 가까운 쌍만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i284
- 쓰면 좋을 때: 돌아간 다각형끼리 정확한 겹침 · 밀어내기가 필요할 때 / 물리 엔진 없이 도형 퍼즐의 「끼워지나」를 판단할 때
- 쓰지 말 때: 오목한 도형 — 그대로는 틀린다. 대신 볼록 조각으로 나누기 / 원 · 둥근 모양 — 원끼리는 거리만 비교하면 된다 / 아주 빠른 물체 — 한 걸음에 지나쳐 버린다. 대신 쓸기 검사(i285)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i284 — `src/demos/demos2dMove.ts:1311`
```ts
const i284: Demo2D = {
  kind: '2d',
  caption: '축마다 두 도형의 그림자를 비춰 본다 — 하나라도 틈이 있으면 안 겹침, 다 겹치면 가장 얕은 축으로 밀어내기',
  make() {
    const inp = new Input();
    const SETS: { a: Pt[]; b: Pt[]; ca: [string, string]; cb: [string, string] }[] = [
      { a: regular(5, 20, -Math.PI / 2), b: regular(4, 34, Math.PI / 4, 1.35, 0.62), ca: ['#ffd36e', '#ff7a45'], cb: ['#7ef0d0', '#21a5a0'] },
      { a: regular(3, 22, -Math.PI / 2), b: regular(6, 28), ca: ['#ff9fd0', '#e0457f'], cb: ['#a8c8ff', '#4a6fe0'] },
      { a: regular(4, 19, Math.PI / 4, 1.2, 1), b: regular(5, 29, 0.3, 1, 0.8), ca: ['#c6ff8a', '#3fb45a'], cb: ['#ffc2a1', '#e8644a'] },
    ];
    let set = 0;
    let push = true;
    let guides = true;
    let spin = 1;
    let ang = 0;
    let angB = 0;
    let drag = false;
    let ax = 70;
    let ay = 90;
    const BX = 100;
    const BY = 92;
    return {
      controls: [
        { type: 'toggle', label: '밀어내기', value: true, on: (v) => (push = v) },
        { type: 'toggle', label: '그림자 비추는 선', value: true, on: (v) => (guides = v) },
        { type: 'range', label: '도는 빠르기', min: 0, max: 2, step: 0.1, value: 1, on: (v) => (spin = v) },
        { type: 'button', label: '다른 모양', on: () => (set = (set + 1) % SETS.length) },
      ] as Control[],
      draw(g, w, h, t, dt) {
        const v = viewOf(w, h);
        inp.sync(g, v);
        for (const e of inp.take()) drag = e.k === 'down' && e.x < 196;
        ang += dt * 0.9 * spin;
        angB += dt * 0.25 * spin;
        if (drag && inp.down) {
          ax = lerp(ax, clamp(inp.x, 10, 190), 0.35);
          ay = lerp(ay, clamp(inp.y, 22, 165), 0.35);
        } else {
          drag = false;
          const k = t * 0.55;
          ax = lerp(ax, BX + Math.cos(k) * 62, 0.2);
          ay = lerp(ay, BY + Math.sin(k * 2.1) * 30, 0.2);
        }
        const S = SETS[set]!;
        const B = place(S.b, BX, BY, angB);
        let A = place(S.a, ax, ay, ang);
        const r = sat(A, B);
        const ghost = A;
        if (r.hit && push) A = place(S.a, ax + r.n[0] * r.depth, ay + r.n[1] * r.depth, ang);
        const showHit = r.hit && !push;

        fillBg(g, w, h, '#151a33', '#232a4f');
        enter(g, v);
        // 모눈
        g.strokeStyle = 'rgba(120,140,220,.08)';
        g.lineWidth = 0.5;
        for (let x = 0; x <= 196; x += 14) {
          g.beginPath();
          g.moveTo(x, 16);
          g.lineTo(x, 175);
          g.stroke();
        }
        for (let y = 16; y <= 175; y += 14) {
          g.beginPath();
          g.moveTo(0, y);
          g.lineTo(196, y);
          g.stroke();
        }
        const fi = r.axes.length ? Math.floor(t / 0.9) % r.axes.length : 0;
        const show = r.hit && push && Math.floor(t / 0.9) % 2 === 1 ? r.mi : fi;
        const fa = r.axes[show];
        // 비추는 축 선 + 그림자
        if (fa && guides) {
          const n = fa.n;
          const pn: Pt = [-n[1], n[0]];
          const O: Pt = [98 + pn[0] * 0, 95];
          // 선을 도형 아래 · 옆쪽으로 비킨다
          const off = 58;
          const o: Pt = [O[0] + pn[0] * off * (pn[1] > 0 ? 1 : -1), O[1] + pn[1] * off * (pn[1] > 0 ? 1 : -1)];
          const base = o[0] * n[0] + o[1] * n[1];
          const at = (s: number): Pt => [o[0] + n[0] * (s - base), o[1] + n[1] * (s - base)];
          g.save();
          rr(g, 0, 16, 196, 159, 0);
          g.clip();
          const e0 = at(base - 200);
          const e1 = at(base + 200);
          g.strokeStyle = 'rgba(255,255,255,.35)';
          g.lineWidth = 0.8;
          g.beginPath();
          g.moveTo(e0[0], e0[1]);
          g.lineTo(e1[0], e1[1]);
          g.stroke();
          const shade = (P: Pt[], pr: [number, number, number, number], col: string, k: number): void => {
            g.setLineDash([2, 2]);
            g.strokeStyle = col;
            g.globalAlpha = 0.55;
            g.lineWidth = 0.7;
            for (const idx of [pr[2], pr[3]]) {
              const v0 = P[idx]!;
              const q = at(v0[0] * n[0] + v0[1] * n[1]);
              g.beginPath();
              g.moveTo(v0[0], v0[1]);
              g.lineTo(q[0], q[1]);
              g.stroke();
            }
            g.setLineDash([]);
            g.globalAlpha = 1;
            const a0 = at(pr[0]);
            const a1 = at(pr[1]);
            g.strokeStyle = col;
            g.lineCap = 'round';
            g.lineWidth = 4;
            g.beginPath();
            g.moveTo(a0[0] + pn[0] * k, a0[1] + pn[1] * k);
            g.lineTo(a1[0] + pn[0] * k, a1[1] + pn[1] * k);
            g.stroke();
            g.lineCap = 'butt';
          };
          shade(A, proj(A, n), S.ca[1], -3);
          shade(B, proj(B, n), S.cb[1], 3);
          const pa = proj(A, n);
          const lo = Math.max(pa[0], fa.b[0]);
          const hi = Math.min(pa[1], fa.b[1]);
          const q0 = at(lo);
          const q1 = at(hi);
          g.lineWidth = 2;
          g.strokeStyle = hi > lo ? '#ff3d6e' : '#45ff9a';
          g.beginPath();
          g.moveTo(q0[0], q0[1]);
          g.lineTo(q1[0], q1[1]);
          g.stroke();
          const mid: Pt = [(q0[0] + q1[0]) / 2, (q0[1] + q1[1]) / 2];
          pill(g, hi > lo ? `겹침 ${(hi - lo).toFixed(0)}` : '틈!', mid[0] + pn[0] * 10, mid[1] + pn[1] * 10, 6, hi > lo ? '#ff3d6e' : '#22c06e');
          g.restore();
        }
        drawPoly(g, B, S.cb[0], S.cb[1]);
        if (r.hit && push) drawPoly(g, ghost, 'rgba(255,255,255,.55)', '', true);
        drawPoly(g, A, showHit ? '#ff8a9a' : S.ca[0], showHit ? '#e02a4a' : S.ca[1]);
        if (r.hit && push) {
          const c0 = ghost.reduce((s, p) => [s[0] + p[0] / ghost.length, s[1] + p[1] / ghost.length] as Pt, [0, 0] as Pt);
          arrow(g, c0[0], c0[1], c0[0] + r.n[0] * Math.max(r.depth, 8), c0[1] + r.n[1] * Math.max(r.depth, 8), '#fff', 1.4, 4);
        }
        // 위 상태 띠
        const msg = r.hit ? (push ? '모든 축에서 겹침 → 가장 얕은 축으로 밀어냄' : '모든 축에서 겹침 → 부딪힘!') : '틈이 있는 축 발견 → 안 겹침';
        pill(g, msg, 98, 9, 7, r.hit ? '#e2365b' : '#1f9e5c');
        // 오른쪽: 축 목록
        rr(g, 199, 3, 78, 169, 6);
        g.fillStyle = 'rgba(8,10,28,.65)';
        g.fill();
        txt(g, '축마다 그림자', 238, 11, 7, '#cfd6ff', 'center', 800);
        const n = r.axes.length;
        const rowH = Math.min(17, 152 / n);
        r.axes.forEach((q, i) => {
          const y = 22 + i * rowH + rowH / 2;
          if (i === show) {
            rr(g, 201, y - rowH / 2 + 0.5, 74, rowH - 1, 3);
            g.fillStyle = 'rgba(255,255,255,.1)';
            g.fill();
          }
          // 방향 표시
          g.save();
          g.translate(209, y);
          g.rotate(Math.atan2(q.n[1], q.n[0]));
          g.strokeStyle = q.own ? S.cb[0] : S.ca[0];
          g.lineWidth = 1.3;
          g.beginPath();
          g.moveTo(-4, 0);
          g.lineTo(4, 0);
          g.stroke();
          g.restore();
          const lo = Math.min(q.a[0], q.b[0]);
          const hi = Math.max(q.a[1], q.b[1]);
          const mid = (lo + hi) / 2;
          const k = 52 / Math.max(90, hi - lo);
          const X = (s: number): number => 245 + (s - mid) * k;
          const bh = Math.max(2, rowH * 0.22);
          g.fillStyle = S.ca[1];
          g.fillRect(X(q.a[0]), y - bh - 0.5, X(q.a[1]) - X(q.a[0]), bh);
          g.fillStyle = S.cb[1];
          g.fillRect(X(q.b[0]), y + 0.5, X(q.b[1]) - X(q.b[0]), bh);
          if (q.ov > 0) {
            g.fillStyle = 'rgba(255,61,110,.55)';
            g.fillRect(X(Math.max(q.a[0], q.b[0])), y - bh - 1, X(Math.min(q.a[1], q.b[1])) - X(Math.max(q.a[0], q.b[0])), bh * 2 + 2);
          } else txt(g, '틈', 272, y, 6, '#45ff9a', 'right', 800);
          if (i === r.mi && r.hit) txt(g, '★', 273, y, 6, '#ffe066', 'right', 800);
        });
        if (v.big) txt(g, '노란 도형을 끌어 보세요', 98, 170, 6.5, 'rgba(255,255,255,.65)', 'center', 600);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [공간 나누기 (쿼드트리)](https://ai-techstudio.web.app/ai/t/i263.md) `i263`
- 다음에 해 볼 기술: [쓸고 지나는 충돌 (빠른 물체 뚫림 막기)](https://ai-techstudio.web.app/ai/t/i285.md) `i285`
- 참고 문서: [Wikipedia — Hyperplane separation theorem](https://en.wikipedia.org/wiki/Hyperplane_separation_theorem)
