# AI 꾸러미 — 궤도 · 중력 우물 — N-body gravity (inverse-square law)
> 행성마다 거리 제곱에 반비례하는 중력을 더해 휘는 궤도를 만들고, 쏘기 전에 같은 계산을 앞당겨 궤적 점선과 「도착 · 충돌 예상」을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i290

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

## 주문서

### 만들어 줘: 궤도 · 중력 우물 — N-body gravity (inverse-square law)

#### 1. 목표
우주선 새총 퍼즐에 행성 중력 궤도를 만들어 줘 — 행성 중력으로 휘는 길, 새총처럼 당겨 쏘기, 쏘기 전 궤적 점선. 분위기는 반짝이는 밤 우주.

#### 2. 핵심 기술 용어
- **N-body gravity (inverse-square law)** — 거리² 에 반비례하는 중력을 여러 행성에서 더하기
- **Trajectory prediction** — 같은 물리를 미리 돌려 보는 궤적 점선
- **Semi-implicit Euler integration** — 속도 먼저 · 위치 나중 한 걸음
- **Gravity well** — 중력 우물 — 휜 격자로 보이기

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

#### 4. 조건
- 실제 비행과 미리 보기가 같은 가속 함수 · 같은 간격(1/120초)을 쓰기 — 다르면 점선이 거짓말을 한다
- 거리² 에 작은 값을 더해 가까이서 튀지 않게
- 미리 보기 끝을 「도착 예상 · 충돌 예상 · 날아감」으로 색을 달리해 표시
- 당긴 반대 방향으로 쏘기 (새총) — 당긴 길이 × 4 를 속도로, 40 ~ 260 으로 막기

#### 5. 완성 기준 (이게 보이면 성공)
- 발사대를 당기면 궤적 점선이 바로 바뀌고, 놓으면 우주선이 점선을 그대로 따라 난다
- 행성 질량 슬라이더를 키우면 길이 더 크게 휜다
- 중력 우물 격자가 행성 쪽으로 휘어 들어가 보인다
- 점선 끝 표시가 실제 결과(도착 · 쾅)와 같다

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

## 원리
- 행성마다 끌어당기는 가속 = GM × (행성 쪽 방향) / 거리³ — 거리² 에 반비례하는 힘을 방향 벡터로 나타낸 꼴. 여러 행성은 그냥 더한다.
- 거리² 에 작은 값(30)을 더해 행성 바로 옆에서 가속이 무한히 커지지 않게 한다.
- 1/120초마다 속도 += 가속 × h, 위치 += 속도 × h. 실제로 날 때와 미리 보기가 같은 함수 · 같은 간격이라 점선이 정확하다.
- 미리 보기는 700걸음을 돌려 3걸음마다 점을 남기고, 행성에 닿음 · 목표 도착 · 화면 밖으로 끝을 알린다.

## 핵심 코드 — 여러 행성 중력 + 궤적 미리 보기
(발췌: demos/demos2dMove.ts i290 acc() · sim() 을 정리)
```ts
const PL = [
  { x: 150, y: 96, gm: 3.6e5, r: 17 },
  { x: 222, y: 50, gm: 0.9e5, r: 8 },
];
const LA = { x: 28, y: 146 }, GOAL = { x: 256, y: 128, r: 10 };
const H = 1 / 120;                                // 비행 · 미리 보기 같은 간격

function acc(x: number, y: number, mass = 1): [number, number] {
  let ax = 0, ay = 0;
  for (const p of PL) {
    const dx = p.x - x, dy = p.y - y;
    const d2 = dx * dx + dy * dy + 30;            // +30 — 가까이서 무한대 막기
    const f = (p.gm * mass) / (d2 * Math.sqrt(d2)); // 방향 벡터 / 거리³ = 거리² 반비례
    ax += dx * f; ay += dy * f;
  }
  return [ax, ay];
}
type End = 'hit' | 'goal' | 'out' | 'none';
function sim(vx: number, vy: number, steps = 700): { pts: number[]; end: End } {
  let x = LA.x, y = LA.y;
  const pts: number[] = [];
  for (let i = 0; i < steps; i++) {
    const [ax, ay] = acc(x, y);
    vx += ax * H; vy += ay * H;                  // 속도 먼저
    x += vx * H; y += vy * H;                    // 위치 나중
    if (i % 3 === 0) pts.push(x, y);             // 점선 점
    for (const p of PL) if (Math.hypot(p.x - x, p.y - y) < p.r) return { pts, end: 'hit' };
    if (Math.hypot(GOAL.x - x, GOAL.y - y) < GOAL.r) return { pts, end: 'goal' };
    if (x < -40 || x > 320 || y < -60 || y > 215) return { pts, end: 'out' };
  }
  return { pts, end: 'none' };
}
```

## 흔한 실수 · 확인 목록
- [ ] **미리 보기와 실제 비행의 간격이 다르면 점선과 다른 곳으로 난다** — 둘 다 같은 acc · 같은 h(1/120). 실제 비행도 고정 간격 루프로 돌린다.
- [ ] **행성 바로 옆을 지나면 속도가 폭발한다** — 거리² 에 작은 값(견본 30)을 더하고, 행성 반지름 안에 들면 충돌로 끝낸다.
- [ ] **질량을 바꿀 때마다 매 프레임 좋은 각을 찾으면 느리다** — 각도 × 속도 표 검색(search)은 질량이 바뀔 때만 한 번 돌린다.

## 완성 기준 체크리스트
- [ ] 발사대를 당기면 궤적 점선이 바로 바뀌고, 놓으면 우주선이 점선을 그대로 따라 난다
- [ ] 행성 질량 슬라이더를 키우면 길이 더 크게 휜다
- [ ] 중력 우물 격자가 행성 쪽으로 휘어 들어가 보인다
- [ ] 점선 끝 표시가 실제 결과(도착 · 쾅)와 같다

## 이 기술 정보
- id: `i290` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 미리 보기 700걸음 × 행성 2개. 조준할 때만 매 프레임. 좋은 각 찾기(search)는 질량 바꿀 때만 한 번.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i290
- 쓰면 좋을 때: 「행성을 스치면 휘어 간다」를 직접 해 보는 퍼즐 / 쏘기 전에 결과를 예상해 보게 하는 조준 놀이
- 쓰지 말 때: 정확한 천체 궤도 계산 — 오일러는 오래 돌리면 에너지가 샌다. 대신 베를레 · RK4 / 중력이 하나뿐인 포물선 — 식 하나로 바로 그리면 된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i290 — `src/demos/demos2dMove.ts:3143`
```ts
const i290: Demo2D = {
  kind: '2d',
  caption: '행성 중력으로 휘는 길을 미리 계산해 점선으로 — 조준이 바뀌면 궤적이 바로 따라 바뀐다',
  make() {
    const inp = new Input();
    let mass = 1;
    let preview = true;
    let grid = true;
    const PL = [
      { x: 150, y: 96, gm: 3.6e5, r: 17, c1: '#ffcf8a', c2: '#e2603a', ring: true },
      { x: 222, y: 50, gm: 0.9e5, r: 8, c1: '#d6f0ff', c2: '#6c8fd8', ring: false },
    ];
    const LA = { x: 28, y: 146 };
    const GOAL = { x: 256, y: 128, r: 10 };
    const H = 1 / 120;
    const acc = (x: number, y: number): [number, number] => {
      let ax = 0;
      let ay = 0;
      for (const p of PL) {
        const dx = p.x - x;
        const dy = p.y - y;
        const d2 = dx * dx + dy * dy + 30;
        const f = (p.gm * mass) / (d2 * Math.sqrt(d2));
        ax += dx * f;
        ay += dy * f;
      }
      return [ax, ay];
    };
    type End = 'hit' | 'goal' | 'out' | 'none';
    const sim = (vx: number, vy: number, steps: number, keep: boolean): { pts: number[]; end: End } => {
      let x = LA.x;
      let y = LA.y;
      const pts: number[] = [];
      for (let i = 0; i < steps; i++) {
        const [ax, ay] = acc(x, y);
        vx += ax * H;
        vy += ay * H;
        x += vx * H;
        y += vy * H;
        if (keep && i % 3 === 0) pts.push(x, y);
        for (const p of PL) if (Math.hypot(p.x - x, p.y - y) < p.r) return { pts, end: 'hit' };
        if (Math.hypot(GOAL.x - x, GOAL.y - y) < GOAL.r) return { pts, end: 'goal' };
        if (x < -40 || x > 320 || y < -60 || y > 215) return { pts, end: 'out' };
      }
      return { pts, end: 'none' };
    };
    let good: [number, number][] = [];
    let bad: [number, number][] = [];
    const search = (): void => {
      good = [];
      bad = [];
      for (let sp = 120; sp <= 200; sp += 20)
        for (let a = -1.45; a <= -0.05; a += 0.02) {
          const r = sim(Math.cos(a) * sp, Math.sin(a) * sp, 900, false);
          if (r.end === 'goal') good.push([a, sp]);
          else if (r.end === 'hit') bad.push([a, sp]);
        }
    };
    search();
    let phase = 0;
    let cyc = 0;
    let aimA = -0.8;
    let aimS = 150;
    let plan: [number, number] = [-0.8, 150];
    const pickPlan = (): void => {
      const list = cyc % 2 === 0 && good.length ? good : bad.length ? bad : good;
      const R = rng(cyc * 31 + 5);
      plan = list.length ? list[Math.floor(R() * list.length)]! : [-0.8, 150];
    };
    pickPlan();
    let ship: { x: number; y: number; vx: number; vy: number; tr: number[]; t: number } | null = null;
    let result: { s: string; t: number; x: number; y: number; ok: boolean } | null = null;
    const boom: Part[] = [];
    let aiming = false;
    const stars: number[] = [];
    {
      const R = rng(9);
      for (let i = 0; i < 90; i++) stars.push(R() * 280, R() * 175, R() * 0.9 + 0.2, R() * 6);
    }
    const fire = (vx: number, vy: number): void => {
      ship = { x: LA.x, y: LA.y, vx, vy, tr: [], t: 0 };
      result = null;
    };
    const step = fixed(H);
    return {
      controls: [
        { type: 'range', label: '행성 질량', min: 0.3, max: 2, step: 0.05, value: 1, on: (v) => ((mass = v), search(), pickPlan()) },
        { type: 'toggle', label: '미리 보는 궤적 점선', value: true, on: (v) => (preview = v) },
        { type: 'toggle', label: '중력 우물 격자', value: true, on: (v) => (grid = v) },
      ] as Control[],
      draw(g, w, h, t, dt) {
        const v = viewOf(w, h);
        inp.sync(g, v);
        for (const e of inp.take()) {
          if (e.k === 'down' && Math.hypot(e.x - LA.x, e.y - LA.y) < 40) {
            aiming = true;
            cyc = -1;
          } else if (e.k === 'up' && aiming) {
            aiming = false;
            fire(Math.cos(aimA) * aimS, Math.sin(aimA) * aimS);
            phase = 99;
          }
        }
        if (aiming) {
          const dx = LA.x - inp.x;
          const dy = LA.y - inp.y;
          aimA = Math.atan2(dy, dx);
          aimS = clamp(Math.hypot(dx, dy) * 4, 40, 260);
        } else if (cyc >= 0) {
          phase += dt;
          if (phase < 1.8) {
            const k = phase / 1.8;
            const e = 1 - Math.pow(1 - k, 3);
            aimA = plan[0] + Math.sin(k * 7) * 0.35 * (1 - e);
            aimS = plan[1];
          } else if (!ship && phase < 2) fire(Math.cos(plan[0]) * plan[1], Math.sin(plan[0]) * plan[1]);
          if (phase > 7) {
            cyc++;
            phase = 0;
            ship = null;
            result = null;
            pickPlan();
          }
        } else if (!ship && !aiming) {
          // 손으로 쏜 뒤 다시 자동으로
          phase += dt;
          if (phase > 103) {
            cyc = 0;
            phase = 0;
            pickPlan();
          }
        }
        step(dt, (H2) => {
          if (!ship) return;
          const s = ship;
          const [ax, ay] = acc(s.x, s.y);
          s.vx += ax * H2;
          s.vy += ay * H2;
          s.x += s.vx * H2;
          s.y += s.vy * H2;
          s.t += H2;
          if (Math.floor(s.t * 60) % 2 === 0) {
            s.tr.push(s.x, s.y);
            if (s.tr.length > 160) s.tr.splice(0, 2);
          }
          for (const p of PL)
            if (Math.hypot(p.x - s.x, p.y - s.y) < p.r) {
              for (let i = 0; i < 26; i++) {
                const a = Math.random() * TAU;
                const sp = 30 + Math.random() * 80;
                boom.push({ x: s.x, y: s.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: 0.7, max: 0.7, size: 1.4, color: i % 3 ? '#ffb347' : '#fff1a8', grav: 0 });
              }
              result = { s: '쾅!', t: 0, x: s.x, y: s.y, ok: false };
              ship = null;
              return;
            }
          if (Math.hypot(GOAL.x - s.x, GOAL.y - s.y) < GOAL.r) {
            for (let i = 0; i < 24; i++) {
              const a = (i / 24) * TAU;
              boom.push({ x: GOAL.x, y: GOAL.y, vx: Math.cos(a) * 60, vy: Math.sin(a) * 60, life: 0.8, max: 0.8, size: 1.3, color: '#fff6a8', grav: 0 });
            }
            result = { s: '도착!', t: 0, x: GOAL.x, y: GOAL.y - 16, ok: true };
            ship = null;
            return;
          }
          if (s.x < -40 || s.x > 320 || s.y < -60 || s.y > 215) ship = null;
        });
        stepParts(boom, dt);
        if (result) result.t += dt;

        fillBg(g, w, h, '#070a1e', '#141a3d');
        enter(g, v);
        for (let i = 0; i < stars.length; i += 4) {
          g.globalAlpha = 0.35 + 0.5 * Math.abs(Math.sin(t * 0.8 + stars[i + 3]!));
          g.fillStyle = '#fff';
          g.fillRect(stars[i]!, stars[i + 1]!, stars[i + 2]!, stars[i + 2]!);
        }
        g.globalAlpha = 1;
        // 중력 우물 격자
        if (grid) {
          const warp = (x: number, y: number): [number, number] => {
            let wx = x;
            let wy = y;
            for (const p of PL) {
              const dx = p.x - x;
              const dy = p.y - y;
              const d = Math.hypot(dx, dy) || 1;
              const pull = Math.min(d * 0.85, (p.gm * mass) / 9000 / (d + 10));
              wx += (dx / d) * pull;
              wy += (dy / d) * pull;
            }
            return [wx, wy];
          };
          g.strokeStyle = 'rgba(110,140,255,.17)';
          g.lineWidth = 0.6;
          for (let x = 0; x <= 280; x += 14) {
            g.beginPath();
            for (let y = 0; y <= 175; y += 7) {
              const [a, b] = warp(x, y);
              if (y) g.lineTo(a, b);
              else g.moveTo(a, b);
            }
            g.stroke();
          }
          for (let y = 0; y <= 175; y += 14) {
            g.beginPath();
            for (let x = 0; x <= 280; x += 7) {
              const [a, b] = warp(x, y);
              if (x) g.lineTo(a, b);
              else g.moveTo(a, b);
            }
            g.stroke();
          }
        }
        // 목표 별
        g.save();
        g.translate(GOAL.x, GOAL.y);
        g.rotate(t * 0.8);
        g.globalCompositeOperation = 'lighter';
        const gg = g.createRadialGradient(0, 0, 0, 0, 0, 16);
        gg.addColorStop(0, 'rgba(255,240,150,.7)');
        gg.addColorStop(1, 'rgba(255,200,80,0)');
        g.fillStyle = gg;
        g.beginPath();
        g.arc(0, 0, 16, 0, TAU);
        g.fill();
        g.globalCompositeOperation = 'source-over';
        g.fillStyle = '#ffe066';
        g.beginPath();
        for (let i = 0; i < 10; i++) {
          const r = i % 2 ? 3.2 : 7.5;
          const a = (i / 10) * TAU - Math.PI / 2;
          if (i) g.lineTo(Math.cos(a) * r, Math.sin(a) * r);
          else g.moveTo(Math.cos(a) * r, Math.sin(a) * r);
        }
        g.closePath();
        g.fill();
        g.strokeStyle = '#c98a00';
        g.lineWidth = 0.6;
        g.stroke();
        g.restore();
        // 행성
        for (const p of PL) {
          g.globalCompositeOperation = 'lighter';
          const halo = g.createRadialGradient(p.x, p.y, p.r * 0.8, p.x, p.y, p.r * 2.2);
          halo.addColorStop(0, 'rgba(255,170,120,.25)');
          halo.addColorStop(1, 'rgba(255,170,120,0)');
          g.fillStyle = halo;
          g.beginPath();
          g.arc(p.x, p.y, p.r * 2.2, 0, TAU);
          g.fill();
          g.globalCompositeOperation = 'source-over';
          if (p.ring) {
            g.strokeStyle = 'rgba(255,220,170,.55)';
            g.lineWidth = 2;
            g.beginPath();
            g.ellipse(p.x, p.y, p.r * 1.8, p.r * 0.45, -0.3, Math.PI, TAU);
            g.stroke();
          }
          const pg = g.createRadialGradient(p.x - p.r * 0.4, p.y - p.r * 0.4, 1, p.x, p.y, p.r);
          pg.addColorStop(0, p.c1);
          pg.addColorStop(1, p.c2);
          g.fillStyle = pg;
          g.beginPath();
          g.arc(p.x, p.y, p.r * Math.sqrt(mass) ** 0.3, 0, TAU);
          g.fill();
          g.save();
          g.clip();
          g.strokeStyle = 'rgba(255,255,255,.15)';
          g.lineWidth = 2;
          for (let k = -2; k <= 2; k++) {
            g.beginPath();
            g.moveTo(p.x - p.r, p.y + k * p.r * 0.35);
            g.lineTo(p.x + p.r, p.y + k * p.r * 0.35 - 3);
            g.stroke();
          }
          g.fillStyle = 'rgba(0,0,30,.3)';
          g.beginPath();
          g.arc(p.x + p.r * 0.5, p.y + p.r * 0.5, p.r, 0, TAU);
          g.fill();
          g.restore();
          if (p.ring) {
            g.strokeStyle = 'rgba(255,220,170,.75)';
            g.lineWidth = 2;
            g.beginPath();
            g.ellipse(p.x, p.y, p.r * 1.8, p.r * 0.45, -0.3, 0, Math.PI);
            g.stroke();
          }
        }
        // 미리 보기 점선
        const aimV: [number, number] = [Math.cos(aimA) * aimS, Math.sin(aimA) * aimS];
        if (preview && !ship && (aiming || (cyc >= 0 && phase < 2))) {
          const r = sim(aimV[0], aimV[1], 700, true);
          const n = r.pts.length / 2;
          for (let i = 0; i < n; i += 2) {
            const k = 1 - i / n;
            g.fillStyle = r.end === 'goal' ? `rgba(140,255,190,${(0.25 + 0.7 * k).toFixed(3)})` : r.end === 'hit' ? `rgba(255,130,130,${(0.25 + 0.7 * k).toFixed(3)})` : `rgba(200,220,255,${(0.2 + 0.7 * k).toFixed(3)})`;
            g.beginPath();
            g.arc(r.pts[i * 2]!, r.pts[i * 2 + 1]!, 1.1, 0, TAU);
            g.fill();
          }
          if (r.pts.length) {
            const ex = r.pts[r.pts.length - 2]!;
            const ey = r.pts[r.pts.length - 1]!;
            pill(g, r.end === 'goal' ? '도착 예상' : r.end === 'hit' ? '충돌 예상' : '날아감', ex, ey - 9, 5.5, r.end === 'goal' ? '#22b36b' : r.end === 'hit' ? '#e2365b' : '#55608f');
          }
        }
        // 지나온 길
        if (ship) {
          const s = ship;
          for (let i = 0; i < s.tr.length; i += 2) {
            g.fillStyle = `rgba(120,220,255,${((i / s.tr.length) * 0.8).toFixed(3)})`;
            g.beginPath();
            g.arc(s.tr[i]!, s.tr[i + 1]!, 0.9, 0, TAU);
            g.fill();
          }
        }
        // 발사대
        g.fillStyle = '#3c4470';
        rr(g, LA.x - 10, LA.y + 4, 20, 8, 3);
        g.fill();
        g.strokeStyle = '#8c6a48';
        g.lineWidth = 2;
        g.beginPath();
        g.moveTo(LA.x - 6, LA.y + 6);
        g.lineTo(LA.x - 7, LA.y - 6);
        g.moveTo(LA.x + 6, LA.y + 6);
        g.lineTo(LA.x + 7, LA.y - 6);
        g.stroke();
        if (aiming) {
          g.strokeStyle = '#ffcf6a';
          g.lineWidth = 0.8;
          g.beginPath();
          g.moveTo(LA.x - 7, LA.y - 6);
          g.lineTo(inp.x, inp.y);
          g.lineTo(LA.x + 7, LA.y - 6);
          g.stroke();
        }
        // 우주선
        const drawShip = (x: number, y: number, a: number, flame: boolean): void => {
          g.save();
          g.translate(x, y);
          g.rotate(a);
          if (flame) {
            g.globalCompositeOperation = 'lighter';
            g.fillStyle = 'rgba(255,170,60,.9)';
            g.beginPath();
            g.moveTo(-5, -2);
            g.lineTo(-10 - Math.random() * 4, 0);
            g.lineTo(-5, 2);
            g.fill();
            g.globalCompositeOperation = 'source-over';
          }
          g.fillStyle = '#f2f4ff';
          g.beginPath();
          g.moveTo(7, 0);
          g.quadraticCurveTo(2, -4.5, -5, -3.5);
          g.lineTo(-5, 3.5);
          g.quadraticCurveTo(2, 4.5, 7, 0);
          g.fill();
          g.fillStyle = '#ff5d6c';
          g.beginPath();
          g.moveTo(-3, -3.5);
          g.lineTo(-6.5, -6);
          g.lineTo(-5, -1.5);
          g.moveTo(-3, 3.5);
          g.lineTo(-6.5, 6);
          g.lineTo(-5, 1.5);
          g.fill();
          g.fillStyle = '#5bc8ff';
          g.beginPath();
          g.arc(1.5, 0, 1.6, 0, TAU);
          g.fill();
          g.restore();
        };
        if (ship) drawShip(ship.x, ship.y, Math.atan2(ship.vy, ship.vx), true);
        else if (!result || result.t > 1.2) drawShip(LA.x, LA.y - 2, aimA, false);
        drawParts(g, boom, true);
        if (result && result.t < 1.6) pill(g, result.s, result.x, result.y - result.t * 6, 8, result.ok ? '#22b36b' : '#e2365b');
        txt(g, v.big ? '발사대를 뒤로 당겼다 놓기' : '조준 → 궤적 미리 보기 → 발사', 8, 9, 7, 'rgba(220,230,255,.7)', 'left', 700);
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [쓸고 지나는 충돌 (빠른 물체 뚫림 막기)](https://ai-techstudio.web.app/ai/t/i285.md) `i285`
- 다음에 해 볼 기술: [탄막 무늬 (극좌표 · 나선)](https://ai-techstudio.web.app/ai/t/i291.md) `i291`
