# AI 꾸러미 — 3D 강체 물리 (충돌 · 마찰 · 쌓기 — 직접 만든 엔진) — Rigid body dynamics (sequential impulses)
> 상자 · 공의 접촉점을 찾고 충격량을 여러 번 되풀이해 맞춰, 주사위가 구르고 쌓은 나무 탑이 진짜처럼 무너지는 3D 물리를 라이브러리 없이 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i24

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

## 주문서

### 만들어 줘: 3D 강체 물리 (충돌 · 마찰 · 쌓기 — 직접 만든 엔진) — Rigid body dynamics (sequential impulses)

#### 1. 목표
쟁반 속 주사위 굴리기 · 나무 탑 무너뜨리기를 직접 만든 3D 강체 물리로 만들어 줘 — 상자 · 공이 중력으로 떨어져 구르고, 쌓이고, 부딪혀 무너지게. 접촉은 반복 충격량(sequential impulses)으로 풀고, 분위기는 깨끗한 스튜디오 · 펠트 쟁반.

#### 2. 핵심 기술 용어
- **Rigid body dynamics (sequential impulses)** — 강체 물리 — 접촉마다 충격량을 차례로 되풀이해 맞추는 풀이
- **Separating Axis Theorem (SAT) + face clipping** — 분리축 15개로 상자 겹침 찾기 + 맞닿은 면을 잘라 접촉점 여러 개
- **Warm starting · Coulomb friction · Restitution** — 지난 걸음 충격량 미리 주기 · 마찰 한계 μ·N · 반발 계수
- **Fixed timestep (1/120 s) · Sleeping** — 고정 시간 걸음 · 멈춘 물체 계산 생략

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- 라이브러리를 쓸 수 있다면 Rapier(@dimforge/rapier3d) · cannon-es 가 같은 일을 한다. 이 견본은 원리를 보이려고 직접 만들었다.

#### 4. 조건
- 고정 시간 걸음 1/120초 — 프레임 시간을 Math.min(dt, 0.05) 로 자르지 말고 실제 지난 시간(0.25초까지)을 조각내 여러 걸음
- 반발(튀기) 판단은 warm start 를 주기 전 속도로 — 먼저 준 큰 충격량을 「세게 부딪힘」으로 읽으면 탑이 저절로 튄다
- warm start 는 지난 접촉 하나를 새 접촉 하나에만 (같은 옛 충격량을 둘이 받으면 걸음마다 불어난다)
- 잠자기: 느린 상태가 0.45초 이어지면 재우고, 잠들 만큼 느리지 않은 몸이 닿으면 깨운다 (깨어남이 번져야 위의 몸이 허공에 안 뜬다)
- 메시는 물리 몸의 위치 · 회전을 복사만 — 움직임의 주인은 물리 쪽 하나

#### 5. 완성 기준 (이게 보이면 성공)
- 「주사위 던지기」: 주사위 셋이 쟁반 벽에 부딪혀 구르다 멈추고, 각 주사위 위에 윗면 눈 · 위에 합이 뜬다
- 「탑 쌓기 → 공으로 무너뜨리기」: 24개 나무 탑이 떨림 없이 서 있다가(모두 잠듦) 공에 맞아 무너지고, 조각이 바닥을 뚫지 않고 멈춘다
- 반발 0 ↔ 0.9, 마찰 0 ↔ 1.2 를 바꾸면 튀는 정도 · 미끄러지는 정도가 눈에 띄게 달라진다
- 「접촉점 보기」로 빨간 점 + 법선(길이 = 충격량)이 보이고, 폰 · CPU 6배 느림에서도 같은 빠르기로 무너진다

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

## 원리
- 한 걸음(1/120초) = 중력으로 속도 바꾸기 → 닿은 곳(접촉점) 찾기 → 접촉마다 충격량을 10번 되풀이해 맞추기 → 속도로 위치 · 회전 옮기기.
- 접촉점: 상자는 꼭짓점 8개를 바닥 평면과 견주고, 상자끼리는 분리축 15개(면 3+3, 모서리 곱 9) 중 가장 얕게 겹친 축을 골라 맞은편 면을 잘라(clipping) 점 여러 개를 얻는다.
- 충격량 λ = (목표 속도 − 지금 속도) × 유효 질량. 누적값은 0 이상(당기지 않음), 마찰은 |마찰| ≤ μ × 수직 충격량 안으로 자른다.
- 지난 걸음의 충격량을 같은 자리 접촉에 미리 주면(warm start) 탑처럼 무게가 쌓인 곳도 떨리지 않는다. 다 멈춘 몸은 잠재우고(계산 생략), 움직이는 몸이 닿으면 깨운다.
- 프레임 시간은 실제 지난 시간(0.25초까지)을 1/120초 조각으로 나눠 여러 걸음 — 느린 폰에서도 시간이 느려지지 않는다.

## 핵심 코드 — 반복 충격량 한 접촉 — 마찰 먼저, 수직 나중 (누적값을 잘라 쓴다)
(발췌: demos/demosSim.ts RigidWorld.prestep() · solve() 를 정리)
```ts
// prestep: 유효 질량 · 목표 속도 (반발은 warm start 전 속도로 판단)
c.mN = 1 / k(c, c.n);                       // k = 1/mA + 1/mB + n·((I⁻¹(r×n))×r) 두 몸 합
const vn0 = relVel(c).dot(c.n);
let target = c.sep > 0
  ? -c.sep / dt                             // 떨어져 있으면 그만큼만 다가오게 (speculative)
  : (0.2 / dt) * Math.max(0, -c.sep - 0.004); // 파고든 만큼 조금씩 밀어내기 (Baumgarte)
if (vn0 < -0.8 && c.sep < -vn0 * dt) target = Math.max(target, -e * vn0); // 반발
c.target = target;

// solve: 되풀이 10번 × 모든 접촉
function solve(c) {
  const maxF = mu * c.jn;                   // 쿨롱 마찰 한계
  let vt = relVel(c).dot(c.t1);
  let nj = clamp(c.jt1 - vt * c.mT1, -maxF, maxF);
  apply(c, c.t1.clone().multiplyScalar(nj - c.jt1)); c.jt1 = nj;
  vt = relVel(c).dot(c.t2);
  nj = clamp(c.jt2 - vt * c.mT2, -maxF, maxF);
  apply(c, c.t2.clone().multiplyScalar(nj - c.jt2)); c.jt2 = nj;
  const vn = relVel(c).dot(c.n);
  nj = Math.max(0, c.jn + (c.target - vn) * c.mN);  // 밀기만 (누적 ≥ 0)
  apply(c, c.n.clone().multiplyScalar(nj - c.jn)); c.jn = nj;
}
// apply(P): a.v −= P/mA, a.w −= I⁻¹a(ra×P) · b.v += P/mB, b.w += I⁻¹b(rb×P)
// relVel = (vb + wb×rb) − (va + wa×ra)
```

## 흔한 실수 · 확인 목록
- [ ] **반발을 warm start 뒤 속도로 판단하면 쌓은 탑이 저절로 튀어 오른다** — 아래 몸에 먼저 준 큰 지지 충격량이 위 접촉에선 「빠르게 다가옴」으로 읽힌다. 모든 접촉의 목표 속도를 먼저 정한 뒤 warm start 를 따로 준다.
- [ ] **빠른 공이 얇은 판을 뚫고 지나간다** — 1/120초 걸음 + 미리 접촉(margin 2cm 안이면 speculative 접촉, 목표 −sep/dt)으로 다가오는 속도를 막는다. 아주 빠르면 걸음을 더 잘게.
- [ ] **몸마다 따로 잠재우면 밑의 몸이 빠져도 위의 몸이 허공에 떠 있다** — 잠든 몸에 「잠들 만큼 느리지 않은」 몸이 닿으면 깨운다 — 깨어남이 닿은 몸을 따라 번진다 (섬 단위 잠자기와 같은 효과).
- [ ] **탑 블록을 딱 붙여 놓으면 시작하자마자 튄다** — 층 사이 1.5mm · 옆 2cm 틈을 두고 놓는다 — 처음부터 겹친 만큼을 밀어내느라 생기는 힘이 없다.
- [ ] **「확률」 체험을 무작위 숫자로 흉내 내면 아이들이 속았다고 느낀다** — 주사위는 진짜 물리로 굴리고, 멈춘 뒤 위를 보는 몸 축(가장 큰 y 성분)으로 눈을 읽는다.

## 완성 기준 체크리스트
- [ ] 「주사위 던지기」: 주사위 셋이 쟁반 벽에 부딪혀 구르다 멈추고, 각 주사위 위에 윗면 눈 · 위에 합이 뜬다
- [ ] 「탑 쌓기 → 공으로 무너뜨리기」: 24개 나무 탑이 떨림 없이 서 있다가(모두 잠듦) 공에 맞아 무너지고, 조각이 바닥을 뚫지 않고 멈춘다
- [ ] 반발 0 ↔ 0.9, 마찰 0 ↔ 1.2 를 바꾸면 튀는 정도 · 미끄러지는 정도가 눈에 띄게 달라진다
- [ ] 「접촉점 보기」로 빨간 점 + 법선(길이 = 충격량)이 보이고, 폰 · CPU 6배 느림에서도 같은 빠르기로 무너진다

## 이 기술 정보
- id: `i24` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 3D · 난이도 어려움 · 폰 부담 보통 (폰 주의) — 견본은 몸 25개 · 접촉점 최대 300개 × 되풀이 10번 × 1초 120걸음. 넓은 단계는 감싸는 공으로 거르고 잠든 몸끼리는 건너뛴다. PC 한 프레임 1ms 안쪽, 폰은 몸 50개 안쪽으로.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i24
- 쓰면 좋을 때: 진짜로 굴러가 멈춘 윗면으로 읽는 주사위 · 확률 체험 / 쌓기 · 무너뜨리기처럼 물체끼리 부딪히는 손맛이 중요할 때 / 라이브러리를 들일 수 없거나, 물리 원리 자체를 보여 주고 싶을 때
- 쓰지 말 때: 앞뒤로 안 움직이는 장면 — 2D 물리(u48 planck)가 훨씬 가볍고 정확하다 / 정해진 칸 위로만 움직이는 퍼즐 — 물리 없이 규칙으로 / 몸이 수백 개 · 관절 · 오목한 모양 — 이때는 Rapier 같은 검증된 엔진을

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### RigidWorld — `src/demos/demosSim.ts:447`
```ts
class RigidWorld {
  bodies: RBody[] = [];
  planes: { n: THREE.Vector3; d: number }[] = [];
  contacts: RContact[] = [];
  private pool: RContact[] = [];
  private cache = new Map<string, RCache>();
  gravity = -9.81;
  e = 0.3;
  mu = 0.5;
  iters = 10;

  wakeAll(): void {
    for (const b of this.bodies) {
      b.sleep = false;
      b.still = 0;
    }
  }
  step(dt: number): void {
    const B = this.bodies;
    // 1) 중력 · 감쇠 (깨어 있는 몸만)
    for (const b of B) {
      if (!b.on || b.sleep) continue;
      rbFrame(b);
      b.v.y += this.gravity * dt;
      b.v.multiplyScalar(1 / (1 + 0.04 * dt));
      b.w.multiplyScalar(1 / (1 + (b.box ? 0.25 : 0.7) * dt));
      const wl = b.w.length();
      if (wl > 60) b.w.multiplyScalar(60 / wl);
    }
    // 2) 접촉 찾기
    this.pool.push(...this.contacts);
    this.contacts.length = 0;
    this.collide();
    // 3) 미리 계산 + 지난 충격량 미리 주기
    const C = this.contacts;
    // (반발 판단은 미리 주기 전 속도로 — 아래 몸에 먼저 준 큰 충격량을 「세게 부딪힘」으로 잘못 읽으면 탑이 튄다)
    for (const c of C) this.prestep(c, dt);
    for (const c of C) this.warm(c);
    // 4) 충격량 되풀이
    for (let it = 0; it < this.iters; it++) for (const c of C) this.solve(c);
    // 5) 다음 걸음을 위해 충격량 기억
    this.cache.clear();
    for (const c of C) {
      let arr = this.cache.get(c.key);
      if (!arr) this.cache.set(c.key, (arr = []));
      arr.push({ used: false, lx: c.lx, ly: c.ly, lz: c.lz, jn: c.jn, fx: c.t1.x * c.jt1 + c.t2.x * c.jt2, fy: c.t1.y * c.jt1 + c.t2.y * c.jt2, fz: c.t1.z * c.jt1 + c.t2.z * c.jt2 });
    }
    // 6) 위치 · 회전 적분 + 잠자기
    for (const b of B) {
      if (!b.on || b.sleep) continue;
      b.p.addScaledVector(b.v, dt);
      const q = b.q;
      const wx = b.w.x * dt * 0.5;
      const wy = b.w.y * dt * 0.5;
      const wz = b.w.z * dt * 0.5;
      const x = q.x;
      const y = q.y;
      const z = q.z;
      const ww = q.w;
      q.set(x + wx * ww + wy * z - wz * y, y + wy * ww + wz * x - wx * z, z + wz * ww + wx * y - wy * x, ww - wx * x - wy * y - wz * z).normalize();
      if (b.v.lengthSq() < 0.012 && b.w.lengthSq() < 0.03) b.still += dt;
      else b.still = 0;
      if (b.still > 0.45) {
        b.sleep = true;
        b.v.set(0, 0, 0);
        b.w.set(0, 0, 0);
      }
    }
  }

  private add(a: RBody | null, b: RBody, n: THREE.Vector3, p: THREE.Vector3, sep: number, key: string): void {
    const c =
      this.pool.pop() ??
      ({ n: new THREE.Vector3(), p: new THREE.Vector3(), ra: new THREE.Vector3(), rb: new THREE.Vector3(), t1: new THREE.Vector3(), t2: new THREE.Vector3() } as RContact);
    c.a = a;
    c.b = b;
    c.n.copy(n);
    c.p.copy(p);
    c.sep = sep;
    c.key = key;
    const d = _va.subVectors(p, b.p);
    c.lx = d.dot(b.ax[0]);
    c.ly = d.dot(b.ax[1]);
    c.lz = d.dot(b.ax[2]);
    this.contacts.push(c);
  }

  private collide(): void {
    const B = this.bodies;
    const M = RB_MARGIN;
    // 몸 vs 평면
    for (const b of B) {
      if (!b.on || b.sleep) continue;
      this.planes.forEach((pl, pi) => {
        const cd = pl.n.dot(b.p) - pl.d;
        if (cd > b.rad + M) return;
        if (!b.box) {
          if (cd - b.r < M) this.add(null, b, pl.n, _v2.copy(b.p).addScaledVector(pl.n, -b.r), cd - b.r, `${b.id}p${pi}`);
          return;
        }
        for (let k = 0; k < 8; k++) {
          _v2.copy(b.p)
            .addScaledVector(b.ax[0], k & 1 ? b.h.x : -b.h.x)
            .addScaledVector(b.ax[1], k & 2 ? b.h.y : -b.h.y)
            .addScaledVector(b.ax[2], k & 4 ? b.h.z : -b.h.z);
          const s = pl.n.dot(_v2) - pl.d;
          if (s < M) this.add(null, b, pl.n, _v2, s, `${b.id}p${pi}`);
        }
      });
    }
    // 몸 vs 몸 (넓은 단계 = 감싸는 공)
    for (let i = 0; i < B.length; i++) {
      const a = B[i]!;
      if (!a.on) continue;
      for (let j = i + 1; j < B.length; j++) {
        const b = B[j]!;
        if (!b.on || (a.sleep && b.sleep)) continue;
        const rr = a.rad + b.rad + M;
        if (a.p.distanceToSquared(b.p) > rr * rr) continue;
        const n0 = this.contacts.length;
        if (a.box && b.box) this.boxBox(a, b);
        else if (a.box) this.boxSphere(a, b);
        else if (b.box) this.boxSphere(b, a);
        else this.sphereSphere(a, b);
        // 잠든 몸에 「잠들 만큼 느리지 않은」 몸이 닿으면 깨운다 — 깨어남이 닿은 몸을 따라 번져서,
        // 밑의 몸이 빠져나갔는데 위의 잠든 몸이 허공에 떠 있는 일이 없게 (섬 단위 잠자기와 같은 효과)
        if (this.contacts.length > n0 && a.sleep !== b.sleep) {
          const aw = a.sleep ? b : a;
          const sl = a.sleep ? a : b;
          if (aw.v.lengthSq() > 0.012 || aw.w.lengthSq() > 0.03) {
            sl.sleep = false;
            sl.still = 0;
          }
        }
      }
    }
  }
  private sphereSphere(a: RBody, b: RBody): void {
    _n.subVectors(b.p, a.p);
    const d = _n.length();
    const sep = d - a.r - b.r;
    if (sep > RB_MARGIN) return;
    if (d < 1e-6) _n.set(0, 1, 0);
    else _n.divideScalar(d);
    this.add(a, b, _n, _v2.copy(a.p).addScaledVector(_n, a.r + sep * 0.5), sep, `${a.id}-${b.id}`);
  }
  /** 상자 a vs 공 b — 공 가운데에서 상자 위 가장 가까운 점 */
  private boxSphere(a: RBody, b: RBody): void {
    _v1.subVectors(b.p, a.p);
    const lx = _v1.dot(a.ax[0]);
    const ly = _v1.dot(a.ax[1]);
    const lz = _v1.dot(a.ax[2]);
    const cx = clamp(lx, -a.h.x, a.h.x);
    const cy = clamp(ly, -a.h.y, a.h.y);
    const cz = clamp(lz, -a.h.z, a.h.z);
    let sep: number;
    if (cx !== lx || cy !== ly || cz !== lz) {
      // 공 가운데가 상자 밖
      _v2.copy(a.p).addScaledVector(a.ax[0], cx).addScaledVector(a.ax[1], cy).addScaledVector(a.ax[2], cz);
      _n.subVectors(b.p, _v2);
      const d = _n.length();
      sep = d - b.r;
      if (sep > RB_MARGIN) return;
      _n.divideScalar(d);
    } else {
      // 가운데가 상자 안 — 가장 가까운 면으로
      const dx = a.h.x - Math.abs(lx);
      const dy = a.h.y - Math.abs(ly);
      const dz = a.h.z - Math.abs(lz);
      if (dx <= dy && dx <= dz) {
        _n.copy(a.ax[0]).multiplyScalar(sgn(lx));
        sep = -dx - b.r;
      } else if (dy <= dz) {
        _n.copy(a.ax[1]).multiplyScalar(sgn(ly));
        sep = -dy - b.r;
      } else {
        _n.copy(a.ax[2]).multiplyScalar(sgn(lz));
        sep = -dz - b.r;
      }
      _v2.copy(b.p).addScaledVector(_n, -b.r);
    }
    this.add(a, b, _n, _v2, sep, `${a.id}-${b.id}`);
  }
  /** 상자 vs 상자 — 분리축 15개(SAT) 중 가장 얕은 축. 면이면 맞은편 면을 잘라 접촉점 여러 개, 모서리면 두 모서리 가장 가까운 점 하나 */
  private boxBox(A: RBody, B: RBody): void {
    const M = RB_MARGIN;
    const T = _T.subVectors(B.p, A.p);
    const a = A.ax;
    const b = B.ax;
    const ha = [A.h.x, A.h.y, A.h.z];
    const hb = [B.h.x, B.h.y, B.h.z];
    const C = _C;
    for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) C[i * 3 + j] = Math.abs(a[i]!.dot(b[j]!)) + 1e-6;
    let best = -Infinity;
    let type = 0;
    let ai = 0;
    for (let i = 0; i < 3; i++) {
      const s = Math.abs(T.dot(a[i]!)) - (ha[i]! + hb[0]! * C[i * 3]! + hb[1]! * C[i * 3 + 1]! + hb[2]! * C[i * 3 + 2]!);
      if (s > M) return;
      if (s > best) {
        best = s;
        type = 0;
        ai = i;
      }
    }
    for (let j = 0; j < 3; j++) {
      const s = Math.abs(T.dot(b[j]!)) - (ha[0]! * C[j]! + ha[1]! * C[3 + j]! + ha[2]! * C[6 + j]! + hb[j]!);
      if (s > M) return;
      if (s > best + 1e-4) {
        best = s;
        type = 1;
        ai = j;
      }
    }
    let eBest = -Infinity;
    let ei = 0;
    let ej = 0;
    for (let i = 0; i < 3; i++)
      for (let j = 0; j < 3; j++) {
        _L.crossVectors(a[i]!, b[j]!);
        const len = _L.length();
        if (len < 1e-3) continue;
        _L.divideScalar(len);
        const s =
          Math.abs(T.dot(_L)) -
          (ha[0]! * Math.abs(a[0]!.dot(_L)) + ha[1]! * Math.abs(a[1]!.dot(_L)) + ha[2]! * Math.abs(a[2]!.dot(_L)) + hb[0]! * Math.abs(b[0]!.dot(_L)) + hb[1]! * Math.abs(b[1]!.dot(_L)) + hb[2]! * Math.abs(b[2]!.dot(_L)));
        if (s > M) return;
        if (s > eBest) {
          eBest = s;
          ei = i;
          ej = j;
          _bestL.copy(_L);
        }
      }
    const key = `${A.id}-${B.id}`;
    if (eBest > best + 0.01) {
      // 모서리 vs 모서리
      const L = _bestL;
      if (L.dot(T) < 0) L.negate();
      const pa = _v1.copy(A.p);
      for (let k = 0; k < 3; k++) if (k !== ei) pa.addScaledVector(a[k]!, ha[k]! * sgn(a[k]!.dot(L)));
      const pb = _v2.copy(B.p);
      for (let k = 0; k < 3; k++) if (k !== ej) pb.addScaledVector(b[k]!, -hb[k]! * sgn(b[k]!.dot(L)));
      const d1 = a[ei]!;
      const d2 = b[ej]!;
      const r = _v3.subVectors(pa, pb);
      const bb = d1.dot(d2);
      const c = d1.dot(r);
      const f = d2.dot(r);
      const den = 1 - bb * bb;
      let s = den > 1e-6 ? (bb * f - c) / den : 0;
      s = clamp(s, -ha[ei]!, ha[ei]!);
      const t = clamp(bb * s + f, -hb[ej]!, hb[ej]!);
      pa.addScaledVector(d1, s);
      pb.addScaledVector(d2, t);
      pa.add(pb).multiplyScalar(0.5);
      this.add(A, B, L, pa, eBest, key);
      return;
    }
    // 면 vs 면 — ref 의 면에 inc 의 가장 맞서는 면을 잘라 얹는다
    const ref = type === 0 ? A : B;
    const inc = type === 0 ? B : A;
    const rh = type === 0 ? ha : hb;
    const ih = type === 0 ? hb : ha;
    // n = ref 에서 inc 쪽으로
    const n = _n.copy(ref.ax[ai]!).multiplyScalar(sgn((type === 0 ? 1 : -1) * T.dot(ref.ax[ai]!)));
    const rc = _v1.copy(ref.p).addScaledVector(n, rh[ai]!);
    // inc 에서 n 과 가장 마주 보는 면
    let k = 0;
    let kd = -1;
    for (let kk = 0; kk < 3; kk++) {
      const d = Math.abs(inc.ax[kk]!.dot(n));
      if (d > kd) {
        kd = d;
        k = kk;
      }
    }
    const fc = _v2.copy(inc.p).addScaledVector(inc.ax[k]!, -sgn(inc.ax[k]!.dot(n)) * ih[k]!);
    const e1 = inc.ax[(k + 1) % 3]!;
    const e2 = inc.ax[(k + 2) % 3]!;
    const h1 = ih[(k + 1) % 3]!;
    const h2 = ih[(k + 2) % 3]!;
    const P = _poly;
    P.length = 0;
    for (const [s1, s2] of [
      [1, 1],
      [-1, 1],
      [-1, -1],
      [1, -1],
    ] as const)
      P.push(fc.x + e1.x * h1 * s1 + e2.x * h2 * s2, fc.y + e1.y * h1 * s1 + e2.y * h2 * s2, fc.z + e1.z * h1 * s1 + e2.z * h2 * s2);
    // ref 면의 네 옆 평면으로 자르기
    const u = ref.ax[(ai + 1) % 3]!;
    const w = ref.ax[(ai + 2) % 3]!;
    const hu = rh[(ai + 1) % 3]!;
    const hw = rh[(ai + 2) % 3]!;
    const uc = u.dot(rc);
    const wc = w.dot(rc);
    clipPoly(P, _poly2, u.x, u.y, u.z, uc + hu);
    clipPoly(_poly2, P, -u.x, -u.y, -u.z, -uc + hu);
    clipPoly(P, _poly2, w.x, w.y, w.z, wc + hw);
    clipPoly(_poly2, P, -w.x, -w.y, -w.z, -wc + hw);
    const cn = _v3.copy(n);
    if (type === 1) cn.negate(); // 접촉 법선은 늘 A → B
    for (let i = 0; i < P.length; i += 3) {
      const px = P[i]!;
      const py = P[i + 1]!;
      const pz = P[i + 2]!;
      const s = n.x * (px - rc.x) + n.y * (py - rc.y) + n.z * (pz - rc.z);
      if (s > M) continue;
      // 자른 다각형에 거의 같은 점이 둘 생길 수 있다 — 하나만
      let dup = false;
      for (let j = 0; j < i; j += 3) if (Math.abs(P[j]! - px) + Math.abs(P[j + 1]! - py) + Math.abs(P[j + 2]! - pz) < 0.004) dup = true;
      if (dup) continue;
      _L.set(px - n.x * s * 0.5, py - n.y * s * 0.5, pz - n.z * s * 0.5);
      this.add(A, B, cn, _L, s, key);
    }
  }

  private prestep(c: RContact, dt: number): void {
    const a = c.a;
    const b = c.b;
    c.A = a && a.on && !a.sleep ? a : null;
    c.B = b.on && !b.sleep ? b : null;
    if (a) c.ra.subVectors(c.p, a.p);
    else c.ra.set(0, 0, 0);
    c.rb.subVectors(c.p, b.p);
    const n = c.n;
    // 접선 둘 (법선에 수직)
    if (Math.abs(n.x) > 0.57) c.t1.set(n.y, -n.x, 0).normalize();
    else c.t1.set(0, n.z, -n.y).normalize();
    c.t2.crossVectors(n, c.t1);
    c.mN = 1 / Math.max(1e-9, this.k(c, n));
    c.mT1 = 1 / Math.max(1e-9, this.k(c, c.t1));
    c.mT2 = 1 / Math.max(1e-9, this.k(c, c.t2));
    const vn = this.relVel(c, _v1).dot(n);
    // 목표 vn: 떨어져 있으면 그만큼만 다가오게(speculative) · 파고들었으면 조금씩 밀어내기 · 세게 닿으면 튀기(반발)
    let target = c.sep > 0 ? -c.sep / dt : (RB_BETA / dt) * Math.max(0, -c.sep - RB_SLOP);
    if (vn < -0.8 && c.sep < -vn * dt) target = Math.max(target, -this.e * vn);
    c.target = target;
  }
  private warm(c: RContact): void {
    const n = c.n;
    // warm start — 지난 걸음 같은 자리(b 몸 좌표 6cm 안) 접촉의 충격량
    c.jn = c.jt1 = c.jt2 = 0;
    const old = this.cache.get(c.key);
    if (old) {
      let bd = 0.0036;
      let o: RCache[number] | null = null;
      for (const x of old) {
        if (x.used) continue; // 한 번만 — 두 접촉이 같은 옛 충격량을 나눠 받으면 걸음마다 불어나 탑이 튄다
        const d = (x.lx - c.lx) ** 2 + (x.ly - c.ly) ** 2 + (x.lz - c.lz) ** 2;
        if (d < bd) {
          bd = d;
          o = x;
        }
      }
      if (o) {
        o.used = true;
        c.jn = o.jn;
        c.jt1 = o.fx * c.t1.x + o.fy * c.t1.y + o.fz * c.t1.z;
        c.jt2 = o.fx * c.t2.x + o.fy * c.t2.y + o.fz * c.t2.z;
        _v2.copy(n).multiplyScalar(c.jn).addScaledVector(c.t1, c.jt1).addScaledVector(c.t2, c.jt2);
        this.apply(c, _v2);
      }
    }
  }
  /** 방향 d 로의 유효 질량 역수 = 1/mA + 1/mB + d·((I⁻¹(r×d))×r) */
  private k(c: RContact, d: THREE.Vector3): number {
    let k = 0;
    if (c.A) {
      k += c.A.im;
      rbIw(c.A, _v2.crossVectors(c.ra, d), _v3);
      k += _v2.crossVectors(_v3, c.ra).dot(d);
    }
    if (c.B) {
      k += c.B.im;
      rbIw(c.B, _v2.crossVectors(c.rb, d), _v3);
      k += _v2.crossVectors(_v3, c.rb).dot(d);
    }
    return k;
  }
  /** 접촉점의 상대 속도 (b − a) */
  private relVel(c: RContact, out: THREE.Vector3): THREE.Vector3 {
    out.set(0, 0, 0);
    if (c.B) out.copy(c.B.v).add(_v3.crossVectors(c.B.w, c.rb));
    if (c.A) out.sub(c.A.v).sub(_v3.crossVectors(c.A.w, c.ra));
    return out;
  }
  /** 충격량 P 를 a 에 −, b 에 + */
  private apply(c: RContact, P: THREE.Vector3): void {
    if (c.A) {
      c.A.v.addScaledVector(P, -c.A.im);
      c.A.w.sub(rbIw(c.A, _v3.crossVectors(c.ra, P), _T));
    }
    if (c.B) {
      c.B.v.addScaledVector(P, c.B.im);
      c.B.w.add(rbIw(c.B, _v3.crossVectors(c.rb, P), _T));
    }
  }
// … (23줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### makeRigidDemo — `src/demos/demosSim.ts:956`
```ts
function makeRigidDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bgTex = skyTexture('#e6edf5', '#b4c1d0');
  scene.background = bgTex;
  scene.add(new THREE.HemisphereLight(0xf4f8ff, 0x6a7380, 0.95));
  const key = new THREE.DirectionalLight(0xfff3e2, 2.4);
  key.position.set(-3.5, 9, 5);
  key.castShadow = true;
  key.shadow.mapSize.set(2048, 2048);
  const sc = key.shadow.camera;
  sc.left = sc.bottom = -6.5;
  sc.right = sc.top = 6.5;
  sc.near = 1;
  sc.far = 25;
  key.shadow.bias = -0.0004;
  key.shadow.normalBias = 0.02;
  const fill = new THREE.DirectionalLight(0xdfe9ff, 0.55);
  fill.position.set(5, 4, -3);
  scene.add(key, fill);
  const cam = new THREE.PerspectiveCamera(36, 1.6, 0.1, 80);

  /* 무대: 펠트 깐 나무 쟁반 + 투명 벽 */
  const W = 4;
  const D = 2.5;
  const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0xd3dae3, roughness: 0.95 }));
  ground.position.y = -0.32;
  ground.receiveShadow = true;
  const rimTex = woodTex(7);
  rimTex.repeat.set(2, 1);
  const rimMat = new THREE.MeshStandardMaterial({ color: 0xb98552, map: rimTex, roughness: 0.55 });
  const base = new THREE.Mesh(new RoundedBoxGeometry(2 * W + 0.8, 0.32, 2 * D + 0.8, 2, 0.08), new THREE.MeshStandardMaterial({ color: 0x7a5232, roughness: 0.6 }));
  base.position.y = -0.16;
  const felt = new THREE.Mesh(new THREE.PlaneGeometry(2 * W, 2 * D).rotateX(-Math.PI / 2), new THREE.MeshStandardMaterial({ color: 0x2e6a70, roughness: 1 }));
  felt.position.y = 0.002;
  felt.receiveShadow = base.receiveShadow = true;
  scene.add(ground, base, felt);
  const rimX = new RoundedBoxGeometry(0.3, 0.44, 2 * D + 0.6, 2, 0.06);
  const rimZ = new RoundedBoxGeometry(2 * W, 0.44, 0.3, 2, 0.06);
  for (const s of [-1, 1]) {
    const a = new THREE.Mesh(rimX, rimMat);
    a.position.set(s * (W + 0.15), 0.22, 0);
    const b = new THREE.Mesh(rimZ, rimMat);
    b.position.set(0, 0.22, s * (D + 0.15));
    a.castShadow = a.receiveShadow = b.castShadow = b.receiveShadow = true;
    scene.add(a, b);
  }
  // 투명 벽 (물리 벽은 끝없이 높은 평면 — 보이는 높이까지만 유리로)
  const glassMat = new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: 0.07, roughness: 0.1, depthWrite: false, side: THREE.DoubleSide });
  const GH = 1.9;
  for (const s of [-1, 1]) {
    const gx = new THREE.Mesh(new THREE.PlaneGeometry(2 * D, GH).rotateY(Math.PI / 2), glassMat);
    gx.position.set(s * W, 0.44 + GH / 2, 0);
    const gz = new THREE.Mesh(new THREE.PlaneGeometry(2 * W, GH), glassMat);
    gz.position.set(0, 0.44 + GH / 2, s * D);
    scene.add(gx, gz);
  }
  {
    const y0 = 0.44;
    const y1 = 0.44 + GH;
    const P: number[] = [];
    const cs = [
      [-W, -D],
      [W, -D],
      [W, D],
      [-W, D],
    ];
    for (let i = 0; i < 4; i++) {
      const [x, z] = cs[i]!;
      const [x2, z2] = cs[(i + 1) % 4]!;
      P.push(x!, y1, z!, x2!, y1, z2!, x!, y0, z!, x!, y1, z!);
    }
    const lg = new THREE.BufferGeometry();
    lg.setAttribute('position', new THREE.Float32BufferAttribute(P, 3));
    scene.add(new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.45 })));
  }

  /* 물리 세계 */
  const world = new RigidWorld();
  world.planes.push(
    { n: new THREE.Vector3(0, 1, 0), d: 0 },
    { n: new THREE.Vector3(1, 0, 0), d: -W },
    { n: new THREE.Vector3(-1, 0, 0), d: -W },
    { n: new THREE.Vector3(0, 0, 1), d: -D },
    { n: new THREE.Vector3(0, 0, -1), d: -D },
  );
  let nid = 0;
  const addBody = (box: boolean, sx: number, sy: number, sz: number, density: number, mesh: THREE.Mesh): RBody => {
    mesh.castShadow = mesh.receiveShadow = true;
    mesh.visible = false;
    scene.add(mesh);
    const b = rbMake(nid++, box, sx, sy, sz, density, mesh);
    world.bodies.push(b);
    return b;
  };
  const DS = 0.5;
  const dieGeo = new RoundedBoxGeometry(DS, DS, DS, 3, 0.07);
  const dieMats = DIE_FACE.map((n) => new THREE.MeshStandardMaterial({ map: dieFaceTex(n), roughness: 0.32 }));
  const dice = [0, 1, 2].map(() => addBody(true, DS, DS, DS, 1, new THREE.Mesh(dieGeo, dieMats)));
  const sphere = (r: number): THREE.SphereGeometry => new THREE.SphereGeometry(r, 32, 20);
  const ballMat = (a: string, b: string): THREE.MeshStandardMaterial => new THREE.MeshStandardMaterial({ map: ballTex(a, b), roughness: 0.35 });
  const smalls = [
    ['#ffcf3a', '#ffffff'],
    ['#3a9cff', '#ffffff'],
  ].map(([a, b]) => addBody(false, 0.36, 0, 0, 0.8, new THREE.Mesh(sphere(0.18), ballMat(a!, b!))));
  const PL = [1.5, 0.3, 0.48] as const;
  const plankGeo = new RoundedBoxGeometry(PL[0], PL[1], PL[2], 2, 0.035);
  const plankTex = woodTex(3);
  const plankMats = [0xffffff, 0xf3e2c8, 0xe9d2ae, 0xfff0d8].map((c) => new THREE.MeshStandardMaterial({ color: c, map: plankTex, roughness: 0.6 }));
  const LAYERS = 8;
  const planks: RBody[] = [];
  for (let i = 0; i < LAYERS * 3; i++) planks.push(addBody(true, PL[0], PL[1], PL[2], 0.6, new THREE.Mesh(plankGeo, plankMats[(i * 7) % 4]!)));
  const bigBall = addBody(false, 0.76, 0, 0, 7, new THREE.Mesh(sphere(0.38), ballMat('#e8423a', '#ffffff')));
  const pokes = [
    ['#ff8a3a', '#ffe9c8'],
    ['#5ad07a', '#ffffff'],
    ['#a06cff', '#ffffff'],
  ].map(([a, b]) => addBody(false, 0.44, 0, 0, 1.2, new THREE.Mesh(sphere(0.22), ballMat(a!, b!))));
  let pokeNext = 0;

  /* 접촉점 보기 — 빨간 점 + 법선 */
  const MAXC = 600;
  const dotMesh = new THREE.InstancedMesh(new THREE.SphereGeometry(0.035, 8, 6), new THREE.MeshBasicMaterial({ color: 0xff2a2a, depthTest: false, toneMapped: false }), MAXC);
  dotMesh.renderOrder = 10;
  dotMesh.frustumCulled = false;
  const nPos = new Float32Array(MAXC * 6);
  const nGeo = new THREE.BufferGeometry();
  nGeo.setAttribute('position', new THREE.BufferAttribute(nPos, 3));
  const nLines = new THREE.LineSegments(nGeo, new THREE.LineBasicMaterial({ color: 0xffe14a, depthTest: false, toneMapped: false }));
  nLines.renderOrder = 11;
  nLines.frustumCulled = false;
  dotMesh.visible = nLines.visible = false;
  scene.add(dotMesh, nLines);
  let showContacts = false;

  /* 장면 고르기 */
  type Mode = 'dice' | 'tower';
  let mode: Mode = 'dice';
  let modeT = 0;
  let launched = false;
  let manualUntil = -1;
  const HOLD = new THREE.Vector3(-3.3, 0.95, 0.1);
  const off = (): void => {
    for (const b of world.bodies) {
      b.on = false;
      b.mesh.visible = false;
    }
  };
  const put = (b: RBody, x: number, y: number, z: number, q?: THREE.Quaternion): void => {
    b.on = true;
    b.sleep = false;
    b.still = 0;
    b.p.set(x, y, z);
    if (q) b.q.copy(q);
    else b.q.identity();
    b.v.set(0, 0, 0);
    b.w.set(0, 0, 0);
    rbFrame(b);
    b.mesh.visible = true;
  };
  const rq = new THREE.Quaternion();
  const re = new THREE.Euler();
  const setMode = (m: Mode): void => {
    off();
    mode = m;
    modeT = 0;
    launched = false;
    const R = Math.random;
    if (m === 'dice') {
      dice.forEach((b, i) => {
        re.set(R() * TAU, R() * TAU, R() * TAU);
        put(b, -3.1 + R() * 0.3, 1.2 + i * 0.6, -1 + i * 1 + (R() - 0.5) * 0.3, rq.setFromEuler(re));
        b.v.set(5 + R() * 2.5, 0.6 + R() * 1.2, (R() - 0.5) * 2.4);
        b.w.set((R() - 0.5) * 24, (R() - 0.5) * 24, (R() - 0.5) * 24);
      });
      smalls.forEach((b, i) => {
        put(b, 0.6 + i * 1.2, 2.2 + i * 0.4, (i ? 1 : -1) * 0.9);
        b.v.set(-1 + R() * 2, 0, (R() - 0.5) * 2);
      });
    } else {
      const cx = 1.7;
      const g = 0.0015; // 층 사이 작은 틈 — 처음부터 겹치지 않게
      const yaw = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2);
      for (let L = 0; L < LAYERS; L++)
        for (let k = 0; k < 3; k++) {
          const b = planks[L * 3 + k]!;
          const o = (k - 1) * 0.5;
          const y = PL[1] / 2 + L * (PL[1] + g) + g;
          if (L % 2 === 0) put(b, cx, y, o);
          else put(b, cx + o, y, 0, yaw);
        }
      bigBall.on = false;
      bigBall.p.copy(HOLD);
      bigBall.q.identity();
      bigBall.mesh.visible = true;
    }
  };
  setMode('dice');

  /* 크게 보기: 누르면 물체를 튕기고, 빈 곳이면 공을 떨어뜨린다 */
  const drag = new Drag();
  const ray = new THREE.Raycaster();
  const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
  const hitP = new THREE.Vector3();
  const poke = (x: number, y: number): void => {
    ray.setFromCamera(new THREE.Vector2(x, y), cam);
    const live = world.bodies.filter((b) => b.on);
    const hit = ray.intersectObjects(
      live.map((b) => b.mesh),
      false,
    )[0];
    if (hit) {
      const b = live.find((x) => x.mesh === hit.object)!;
      b.sleep = false;
      b.still = 0;
      b.v.add(new THREE.Vector3((Math.random() - 0.5) * 2, 4.5, (Math.random() - 0.5) * 2));
      b.w.add(new THREE.Vector3((Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14, (Math.random() - 0.5) * 14));
      return;
    }
    if (!ray.ray.intersectPlane(floorPlane, hitP)) return;
    const b = pokes[pokeNext++ % pokes.length]!;
    put(b, clamp(hitP.x, -W + 0.3, W - 0.3), 2.6, clamp(hitP.z, -D + 0.3, D - 0.3));
  };

  const overlay = new Overlay();
  let tags: Tag[] = [];
  const H = 1 / 120;
  let acc = 0;
  let last = -1;
  let stepsLast = 0;
  let bigView = false;
  const proj = new THREE.Vector3();
  return {
    scene,
    camera: cam,
    update(t) {
      // 실제 지난 시간(0.25초까지)을 1/120초 걸음으로 조각낸다 — 느린 기기에서도 시간이 느려지지 않게
      const now = performance.now();
      const rdt = last < 0 ? 1 / 60 : clamp((now - last) / 1000, 0, 0.25);
      last = now;
      drag.tick(rdt);
      for (const tp of drag.taps.splice(0)) {
        poke(tp.x, tp.y);
        manualUntil = t + 15;
      }
      drag.dx = drag.dy = drag.wheel = 0;
      modeT += rdt;
      if (mode === 'tower') {
        if (!launched && modeT > 1.6) {
          launched = true;
          put(bigBall, HOLD.x, HOLD.y, HOLD.z);
          bigBall.v.set(12, 1.6, (Math.random() - 0.5) * 0.5);
        }
        if (!launched) bigBall.p.set(HOLD.x, HOLD.y + Math.sin(modeT * 6) * 0.06, HOLD.z);
      }
      if (t > manualUntil && modeT > (mode === 'dice' ? 6.5 : 9)) setMode(mode === 'dice' ? 'tower' : 'dice');
      acc += rdt;
      let n = 0;
      while (acc >= H && n < 30) {
        world.step(H);
        acc -= H;
        n++;
      }
      stepsLast = n;
      for (const b of world.bodies) {
        if (!b.mesh.visible) continue;
        b.mesh.position.copy(b.p);
        b.mesh.quaternion.copy(b.q);
      }
      // 카메라: 쟁반을 비스듬히 내려다보며 천천히 흔들림
      cam.position.set(Math.sin(t * 0.18) * 1.6, 6.4, 8.6);
      cam.lookAt(0.2, 0.55, 0);
      cam.updateMatrixWorld();
      // 접촉점
      const C = world.contacts;
      dotMesh.visible = nLines.visible = showContacts;
      if (showContacts) {
        const m = new THREE.Matrix4();
        const cnt = Math.min(MAXC, C.length);
        for (let i = 0; i < cnt; i++) {
          const c = C[i]!;
          dotMesh.setMatrixAt(i, m.makeTranslation(c.p.x, c.p.y, c.p.z));
          const L = 0.12 + Math.min(0.5, c.jn * 6);
          nPos.set([c.p.x, c.p.y, c.p.z, c.p.x + c.n.x * L, c.p.y + c.n.y * L, c.p.z + c.n.z * L], i * 6);
        }
        dotMesh.count = cnt;
        dotMesh.instanceMatrix.needsUpdate = true;
        nGeo.setDrawRange(0, cnt * 2);
        nGeo.attributes.position!.needsUpdate = true;
      }
      // 글씨
      tags = [];
      const live = world.bodies.filter((b) => b.on);
      const awake = live.filter((b) => !b.sleep).length;
      if (mode === 'dice') {
        const settled = modeT > 1 && dice.every((b) => b.sleep || (b.v.lengthSq() < 0.02 && b.w.lengthSq() < 0.05));
        if (settled) {
          const vals = dice.map(dieTop);
          dice.forEach((b, i) => {
            proj.copy(b.p).setY(b.p.y + 0.45).project(cam);
            tags.push({ text: String(vals[i]), x: (proj.x + 1) / 2, y: (1 - proj.y) / 2, ax: 0.5, ay: 1, bg: 'rgba(214,52,46,0.92)', big: true });
          });
          tags.push({ text: `윗면 눈  ${vals.join(' + ')} = ${vals.reduce((a, b) => a + b, 0)}`, x: 0.5, y: 0.04, ax: 0.5, ay: 0, big: true });
        } else tags.push({ text: '주사위 굴러가는 중…', x: 0.5, y: 0.04, ax: 0.5, ay: 0, big: true });
      } else tags.push({ text: launched ? '공으로 무너뜨리기!' : `나무 탑 ${planks.length}개 쌓기`, x: 0.5, y: 0.04, ax: 0.5, ay: 0, big: true });
      tags.push({
        text: bigView ? `물체 ${live.length} · 깨어 있음 ${awake} · 잠듦 ${live.length - awake} · 접촉점 ${C.length} · 1/120초 × ${stepsLast}걸음` : `접촉점 ${C.length} · 잠듦 ${live.length - awake}/${live.length}`,
        x: 0.015,
        y: 0.97,
        ax: 0,
        ay: 1,
      });
    },
    render(r, w, h) {
      bigView = w >= 700;
      drag.attach(w, h);
      r.shadowMap.enabled = true;
      r.render(scene, cam);
      overlay.draw(r, w, h, tags);
    },
    controls: [
      {
        type: 'button',
        label: '주사위 던지기',
        on: () => {
          setMode('dice');
          manualUntil = Infinity;
        },
      },
      {
        type: 'button',
        label: '탑 쌓기 → 공으로 무너뜨리기',
        on: () => {
          setMode('tower');
          manualUntil = Infinity;
        },
      },
      { type: 'range', label: '반발 (튀는 정도)', min: 0, max: 0.9, step: 0.05, value: 0.3, on: (v) => ((world.e = v), world.wakeAll()) },
      { type: 'range', label: '마찰 (μ)', min: 0, max: 1.2, step: 0.05, value: 0.5, on: (v) => ((world.mu = v), world.wakeAll()) },
      { type: 'toggle', label: '접촉점 보기 (빨간 점 + 법선)', value: false, on: (v) => (showContacts = v) },
    ],
    dispose() {
      drag.dispose();
      overlay.dispose();
      disposeScene(scene);
      bgTex.dispose();
    },
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [2D 물리 엔진 (Box2D · planck)](https://ai-techstudio.web.app/ai/t/u48.md) `u48`
- 다음에 해 볼 기술: [SPH · 입자 물](https://ai-techstudio.web.app/ai/t/i47.md) `i47` · [무리 짓기 (boids)](https://ai-techstudio.web.app/ai/t/i50.md) `i50`
- 참고 문서: [Erin Catto — Box2D 발표 자료 (sequential impulses · warm starting)](https://box2d.org/publications/) · [Wikipedia — Hyperplane separation theorem (SAT)](https://en.wikipedia.org/wiki/Hyperplane_separation_theorem) · [Rapier (JS) 문서](https://rapier.rs/docs/user_guides/javascript/getting_started_js)
