# AI 꾸러미 — 구 지도 (지구본 · 위도 경도) — Latitude/longitude to 3D (spherical coordinates)
> 위도 · 경도를 지구본 위 3D 점으로 바꾸고, 두 도시를 구면 보간(slerp)으로 이어 가장 짧은 큰 원 길과 비행 곡선을 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i262

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

## 주문서

### 만들어 줘: 구 지도 (지구본 · 위도 경도) — Latitude/longitude to 3D (spherical coordinates)

#### 1. 목표
도시 사이 비행 길을 지구본 위에 그려 줘 — 두 도시를 큰 원 길(점선)과 위로 솟은 비행 곡선으로 잇고, 비행기가 그 길을 따라가며 거리(km)가 보이게. 분위기는 밤하늘 속 지구본.

#### 2. 핵심 기술 용어
- **Latitude/longitude to 3D (spherical coordinates)** — 위도 · 경도 → 구 위 점
- **Great-circle route** — 구의 중심을 지나는 평면이 자른 가장 짧은 길
- **Spherical linear interpolation (slerp)** — 두 방향 사이를 구면 위로 고르게 잇기
- **three.js TubeGeometry + setDrawRange** — 곡선 관을 만들고 앞부분만 그려 자라게

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 위경도 → 3D 식은 SphereGeometry 의 무늬(UV) 방향과 맞추기 — 안 맞으면 도시 핀이 엉뚱한 나라에 꽂힌다
- 큰 원은 구면 보간(slerp)으로 — 직선 보간 후 normalize 만 하면 간격이 고르지 않다
- 땅 위 큰 원(점선 LineDashedMaterial + computeLineDistances)과 하늘의 비행 곡선을 따로
- 지구본은 도시 쌍이 바뀔 때 두 도시의 가운데가 보이도록 부드럽게 돌기 (각도는 −π~π 로 감아서 보간)

#### 5. 완성 기준 (이게 보이면 성공)
- 도시 핀이 실제 나라 자리(서울 · 런던 · 뉴욕 …)에 꽂혀 있다
- 점선 큰 원이 땅 위에, 노란 비행 곡선이 그 위로 솟아 자라고, 비행기가 길 방향을 보며 날아간다
- 「큰 원 거리 약 ○○ km」 가 실제와 비슷하다 (서울 – 런던 약 8,900 km)
- 「다음 도시」를 누르면 지구본이 돌아 새 길을 보여 준다

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

## 원리
- 경도 λ · 위도 φ 를 단위 구 위 점으로: (−cos(λ+180°)·cos φ, sin φ, sin(λ+180°)·cos φ) — three 의 SphereGeometry 무늬 좌표와 맞춘 식.
- 두 점 사이 각 θ = va.angleTo(vb), 땅 위 거리 = θ × 6371 km.
- 구면 보간: p(s) = va·sin((1−s)θ)/sin θ + vb·sin(sθ)/sin θ — 이 점들이 큰 원 위에 고르게 놓인다.
- 비행 곡선은 같은 점을 1.004 + sin(πs)·(0.05 + 0.07θ) 배로 띄워 가운데가 솟게 하고, 관(TubeGeometry)으로 만든다.
- 관은 setDrawRange 로 앞부분만 그려 길이 자라는 것처럼 보이고, 비행기는 curve.getPointAt(s) 에 두고 다음 점을 바라보게 한다.

## 핵심 코드 — 위경도 → 3D, 큰 원 점선 + 솟은 비행 곡선
(발췌: demos/demosMapA.ts llVec · i262 nextRoute 를 정리)
```ts
import * as THREE from 'three';
// 경도 · 위도 → 단위 구 위 점 (SphereGeometry 무늬 좌표와 맞춤)
function llVec(lon: number, lat: number): THREE.Vector3 {
  const p = ((lon + 180) * Math.PI) / 180, t = (lat * Math.PI) / 180;
  return new THREE.Vector3(-Math.cos(p) * Math.cos(t), Math.sin(t), Math.sin(p) * Math.cos(t));
}
const va = llVec(126.98, 37.57), vb = llVec(-0.13, 51.5); // 서울 → 런던
const ang = va.angleTo(vb);
const distKm = ang * 6371;
const slerp = (s: number) => va.clone().multiplyScalar(Math.sin((1 - s) * ang) / Math.sin(ang))
  .add(vb.clone().multiplyScalar(Math.sin(s * ang) / Math.sin(ang)));
// 비행 곡선: 가운데가 솟게
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 80; i++) { const s = i / 80; pts.push(slerp(s).multiplyScalar(1.004 + Math.sin(Math.PI * s) * (0.05 + ang * 0.07))); }
const curve = new THREE.CatmullRomCurve3(pts);
const RAD = 8, TUB = 160;
const tubeGeo = new THREE.TubeGeometry(curve, TUB, 0.011, RAD, false);
globe.add(new THREE.Mesh(tubeGeo, new THREE.MeshBasicMaterial({ color: 0xffd34a })));
// 땅 위 큰 원 (점선)
const gpts: THREE.Vector3[] = [];
for (let i = 0; i <= 120; i++) gpts.push(slerp(i / 120).multiplyScalar(1.003));
const ground = new THREE.Line(new THREE.BufferGeometry().setFromPoints(gpts),
  new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 0.025, gapSize: 0.02, transparent: true, opacity: 0.85 }));
ground.computeLineDistances(); // 점선에 꼭 필요
globe.add(ground);
// 매 프레임: grow 0 → 1
tubeGeo.setDrawRange(0, Math.floor(grow * TUB) * RAD * 6); // 마디 하나 = 사각형 RAD 개 = 삼각형 2·RAD
plane.position.copy(curve.getPointAt(grow));
plane.lookAt(globe.localToWorld(curve.getPointAt(Math.min(1, grow + 0.01))));
```

## 흔한 실수 · 확인 목록
- [ ] **위경도 식이 구 무늬 방향과 다르면 도시가 엉뚱한 곳에 찍힌다** — three 의 SphereGeometry 는 경도 −180° 가 u = 0 이라 (lon + 180) 을 쓰고 x 에 − 를 붙인다.
- [ ] **두 점을 직선으로 섞고 normalize 하면 가운데가 몰려 비행기 속도가 들쭉날쭉하다** — sin 가중치 구면 보간(slerp)을 쓴다.
- [ ] **LineDashedMaterial 이 실선으로 보인다** — 선을 만든 뒤 computeLineDistances() 를 불러야 점선이 된다.
- [ ] **지구본을 돌릴 때 반대로 한 바퀴 휙 돈다** — 목표 각과의 차이를 −π~π 로 감아서 보간한다.

## 완성 기준 체크리스트
- [ ] 도시 핀이 실제 나라 자리(서울 · 런던 · 뉴욕 …)에 꽂혀 있다
- [ ] 점선 큰 원이 땅 위에, 노란 비행 곡선이 그 위로 솟아 자라고, 비행기가 길 방향을 보며 날아간다
- [ ] 「큰 원 거리 약 ○○ km」 가 실제와 비슷하다 (서울 – 런던 약 8,900 km)
- [ ] 「다음 도시」를 누르면 지구본이 돌아 새 길을 보여 준다

## 이 기술 정보
- id: `i262` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 지구 무늬 캔버스 2048×1024 를 한 번 굽고, 구 96×64 조각 하나. 길은 점 80개 곡선 · 관 160마디라 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i262
- 쓰면 좋을 때: 두 도시 사이 최단 거리 · 비행 경로를 보여 줄 때 / 위도 · 경도 좌표를 3D 로 찍어야 할 때 / 세계 지도 게임의 지구본 화면
- 쓰지 말 때: 넓이 · 모양을 평면에서 비교할 때 — 대신 지도 투영(i261) / 정밀한 측지 거리(타원체)가 필요할 때 — 구 모델은 0.5% 안팎 오차

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i262 견본 항목 — `src/demos/demosMapA.ts:4418`
```ts
  i262: {
    kind: '3d',
    caption: '위도 · 경도 → 지구본 위 점 — 두 도시를 잇는 가장 짧은 길은 지구 중심을 지나는 평면이 자른 「큰 원」, 비행기는 그 길로',
    make() {
      const scene = new THREE.Scene();
      scene.background = new THREE.Color(0x060a18);
      const cam = new THREE.PerspectiveCamera(32, 1.6, 0.1, 200);
      cam.position.set(0, 0, 4.3);
      scene.add(new THREE.AmbientLight(0x8090b0, 0.55));
      const sun = new THREE.DirectionalLight(0xfff4e0, 2.6);
      sun.position.set(-3, 2, 4);
      scene.add(sun);
      // 지구 무늬
      const tc = mkCanvas(2048, 1024);
      {
        const g = c2(tc);
        const X = (lon: number): number => ((lon + 180) / 360) * 2048;
        const Y = (lat: number): number => ((90 - lat) / 180) * 1024;
        const og = g.createLinearGradient(0, 0, 0, 1024);
        og.addColorStop(0, '#1d4f84');
        og.addColorStop(0.5, '#2a7cbf');
        og.addColorStop(1, '#1d4f84');
        g.fillStyle = og;
        g.fillRect(0, 0, 2048, 1024);
        const drawLand = (l: Land): void => {
          g.beginPath();
          l.pts.forEach((p, i) => (i ? g.lineTo(X(p[0]), Y(p[1])) : g.moveTo(X(p[0]), Y(p[1]))));
          g.closePath();
        };
        g.lineJoin = 'round';
        for (const l of WORLD) {
          drawLand(l);
          g.strokeStyle = 'rgba(120,200,230,0.55)';
          g.lineWidth = 9;
          g.stroke();
        }
        for (const l of WORLD) {
          drawLand(l);
          g.fillStyle = l.ice ? '#eef4f8' : '#7cb85c';
          g.fill();
          g.strokeStyle = l.ice ? '#c8d8e4' : '#4f7f3a';
          g.lineWidth = 1.5;
          g.stroke();
        }
        // 땅 결 (사막 · 숲 · 얼음)
        g.save();
        for (const l of WORLD) drawLand(l);
        g.clip();
        for (let y = 0; y < 1024; y += 4)
          for (let x = 0; x < 2048; x += 4) {
            const lat = 90 - (y / 1024) * 180;
            const n = fbm(x * 0.006, y * 0.006, 4, 3);
            const dry = Math.abs(Math.abs(lat) - 24) < 12 ? 0.6 : 0;
            if (Math.abs(lat) > 62) {
              g.fillStyle = `rgba(240,246,250,${smooth(62, 70, Math.abs(lat)) * 0.9})`;
              g.fillRect(x, y, 4, 4);
            } else if (n + dry * 0.25 > 0.62) {
              g.fillStyle = 'rgba(214,190,120,0.55)';
              g.fillRect(x, y, 4, 4);
            } else if (n < 0.4) {
              g.fillStyle = 'rgba(40,100,45,0.35)';
              g.fillRect(x, y, 4, 4);
            }
          }
        g.restore();
        g.strokeStyle = 'rgba(255,255,255,0.13)';
        g.lineWidth = 1.5;
        for (let lon = -180; lon <= 180; lon += 30) {
          g.beginPath();
          g.moveTo(X(lon), 0);
          g.lineTo(X(lon), 1024);
          g.stroke();
        }
        for (let lat = -60; lat <= 60; lat += 30) {
          g.beginPath();
          g.moveTo(0, Y(lat));
          g.lineTo(2048, Y(lat));
          g.stroke();
        }
        g.strokeStyle = 'rgba(255,220,120,0.35)';
        g.lineWidth = 2.5;
        g.beginPath();
        g.moveTo(0, Y(0));
        g.lineTo(2048, Y(0));
        g.stroke();
      }
      const tex = new THREE.CanvasTexture(tc);
      tex.colorSpace = THREE.SRGBColorSpace;
      tex.anisotropy = 4;
      const outer = new THREE.Group();
      const inner = new THREE.Group();
      outer.add(inner);
      scene.add(outer);
      const gGeo = new THREE.SphereGeometry(1, 96, 64);
      const gMat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.75, metalness: 0 });
      inner.add(new THREE.Mesh(gGeo, gMat));
      // 대기 빛
      const halo = mkCanvas(256, 256);
      {
        const g = c2(halo);
        const gr = g.createRadialGradient(128, 128, 60, 128, 128, 128);
        gr.addColorStop(0, 'rgba(90,170,255,0)');
        gr.addColorStop(0.62, 'rgba(90,170,255,0.55)');
        gr.addColorStop(0.72, 'rgba(70,140,255,0.25)');
        gr.addColorStop(1, 'rgba(40,80,255,0)');
        g.fillStyle = gr;
        g.fillRect(0, 0, 256, 256);
      }
      const hTex = new THREE.CanvasTexture(halo);
      const hMat = new THREE.SpriteMaterial({ map: hTex, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true });
      const hSpr = new THREE.Sprite(hMat);
      hSpr.scale.set(2.75, 2.75, 1);
      hSpr.position.z = -0.3;
      scene.add(hSpr);
      // 별
      const sp = new Float32Array(700 * 3);
      for (let i = 0; i < 700; i++) {
        const v = new THREE.Vector3().randomDirection().multiplyScalar(60);
        sp[i * 3] = v.x;
        sp[i * 3 + 1] = v.y;
        sp[i * 3 + 2] = v.z;
      }
      const sGeo = new THREE.BufferGeometry();
      sGeo.setAttribute('position', new THREE.BufferAttribute(sp, 3));
      const sMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.18, transparent: true, opacity: 0.8, depthWrite: false });
      scene.add(new THREE.Points(sGeo, sMat));
      const CITY: Record<string, LL> = { 서울: [126.98, 37.57], 뉴욕: [-74, 40.7], 런던: [-0.13, 51.5], 시드니: [151.2, -33.9], 리우: [-43.2, -22.9], 카이로: [31.2, 30], 로스앤젤레스: [-118.2, 34], 케이프타운: [18.4, -33.9] };
      const ROUTES: [string, string][] = [
        ['서울', '런던'],
        ['서울', '뉴욕'],
        ['런던', '뉴욕'],
        ['서울', '시드니'],
        ['카이로', '리우'],
        ['로스앤젤레스', '시드니'],
        ['케이프타운', '서울'],
      ];
      const V = (ll: LL, r = 1): THREE.Vector3 => {
        const v = llVec(ll[0], ll[1]);
        return new THREE.Vector3(v[0], v[1], v[2]).multiplyScalar(r);
      };
      const route = new THREE.Group();
      inner.add(route);
      const disposables: { dispose(): void }[] = [];
      let tubeGeo: THREE.TubeGeometry | null = null;
      let TUB = 160;
      const RAD = 8;
      let plane: THREE.Mesh | null = null;
      let curve: THREE.CatmullRomCurve3 | null = null;
      let distKm = 0;
      let ri = -1;
      let rt = 0;
      let speed = 1;
      let llOn = true;
      let llGroup: THREE.Group | null = null;
      let wantY = 0;
      let wantX = 0;
      const label = (s: string, col: string): THREE.Sprite => {
        const c = mkCanvas(512, 112);
        const g = c2(c);
        g.font = `800 40px ${F}`;
        const tw = Math.min(500, g.measureText(s).width + 44);
        rr(g, (512 - tw) / 2, 16, tw, 80, 40);
        g.fillStyle = 'rgba(8,14,30,0.82)';
        g.fill();
        g.strokeStyle = col;
        g.lineWidth = 4;
        g.stroke();
        txt(g, s, 256, 57, 40, '#fff', 'center', 800);
        const t2 = new THREE.CanvasTexture(c);
        t2.colorSpace = THREE.SRGBColorSpace;
        const m = new THREE.SpriteMaterial({ map: t2, depthTest: false, transparent: true });
        disposables.push(t2, m);
        const s2 = new THREE.Sprite(m);
        s2.scale.set(0.82, 0.18, 1);
        s2.renderOrder = 20;
        return s2;
      };
      const distSpr = { s: null as THREE.Sprite | null };
      const nextRoute = (): void => {
        for (const d of disposables) d.dispose();
        disposables.length = 0;
        route.clear();
        if (distSpr.s) scene.remove(distSpr.s);
        ri = (ri + 1) % ROUTES.length;
        rt = 0;
        const [na, nb] = ROUTES[ri]!;
        const A = CITY[na]!;
        const B = CITY[nb]!;
        const va = V(A);
        const vb = V(B);
        const ang = va.angleTo(vb);
        distKm = ang * 6371;
        const pts: THREE.Vector3[] = [];
        const N = 80;
        for (let i = 0; i <= N; i++) {
          const s = i / N;
          const p = new THREE.Vector3().copy(va).multiplyScalar(Math.sin((1 - s) * ang) / Math.sin(ang)).add(vb.clone().multiplyScalar(Math.sin(s * ang) / Math.sin(ang)));
          pts.push(p.multiplyScalar(1.004 + Math.sin(Math.PI * s) * (0.05 + ang * 0.07)));
        }
        curve = new THREE.CatmullRomCurve3(pts);
        TUB = 160;
        tubeGeo = new THREE.TubeGeometry(curve, TUB, 0.011, RAD, false);
        tubeGeo.setDrawRange(0, 0);
        const tMat = new THREE.MeshBasicMaterial({ color: 0xffd34a });
        disposables.push(tubeGeo, tMat);
        route.add(new THREE.Mesh(tubeGeo, tMat));
        // 땅 위 큰 원 (점선)
        const gpts: THREE.Vector3[] = [];
        for (let i = 0; i <= 120; i++) {
          const s = i / 120;
          gpts.push(new THREE.Vector3().copy(va).multiplyScalar(Math.sin((1 - s) * ang) / Math.sin(ang)).add(vb.clone().multiplyScalar(Math.sin(s * ang) / Math.sin(ang))).multiplyScalar(1.003));
        }
        const gGeo2 = new THREE.BufferGeometry().setFromPoints(gpts);
        const gM = new THREE.LineDashedMaterial({ color: 0xffffff, dashSize: 0.025, gapSize: 0.02, transparent: true, opacity: 0.85 });
        const gl = new THREE.Line(gGeo2, gM);
        gl.computeLineDistances();
        disposables.push(gGeo2, gM);
        route.add(gl);
        // 도시 핀 · 이름
        const pinGeo = new THREE.SphereGeometry(0.022, 16, 12);
        const pinMat = new THREE.MeshBasicMaterial({ color: 0xff5a6a });
        disposables.push(pinGeo, pinMat);
        for (const [nm, ll] of [
          [na, A],
          [nb, B],
        ] as [string, LL][]) {
          const pin = new THREE.Mesh(pinGeo, pinMat);
          pin.position.copy(V(ll, 1.01));
          route.add(pin);
          const ns = ll[1] >= 0 ? 'N' : 'S';
          const ew = ll[0] >= 0 ? 'E' : 'W';
          const l = label(`${nm} ${Math.abs(ll[1]).toFixed(0)}°${ns} ${Math.abs(ll[0]).toFixed(0)}°${ew}`, '#ff5a6a');
          l.position.copy(V(ll, 1.2));
          route.add(l);
        }
        // 비행기
        const pGeo = new THREE.ConeGeometry(0.03, 0.09, 10);
        pGeo.rotateX(Math.PI / 2);
        const pMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
        disposables.push(pGeo, pMat);
        plane = new THREE.Mesh(pGeo, pMat);
        route.add(plane);
        // 출발 도시의 위도선 · 경도선
        llGroup = new THREE.Group();
        const par: THREE.Vector3[] = [];
        for (let lon = -180; lon <= 180; lon += 3) par.push(V([lon, A[1]], 1.003));
        const mer: THREE.Vector3[] = [];
        for (let lat = -90; lat <= 90; lat += 3) mer.push(V([A[0], lat], 1.003));
        for (const list of [par, mer]) {
          const gg = new THREE.BufferGeometry().setFromPoints(list);
          const mm = new THREE.LineBasicMaterial({ color: 0x7ef0c8, transparent: true, opacity: 0.85 });
          disposables.push(gg, mm);
          llGroup.add(new THREE.Line(gg, mm));
        }
        route.add(llGroup);
        llGroup.visible = llOn;
        // 가운데로 돌리기
        const mid = va.clone().add(vb).normalize();
        wantY = Math.atan2(-mid.x, mid.z);
        wantX = Math.asin(clamp(mid.y, -1, 1)) * 0.85;
        const ds = label(`큰 원 거리 약 ${Math.round(distKm / 10) * 10} km`, '#ffd34a');
        ds.scale.set(1.5, 0.33, 1);
        ds.position.set(0, -1.32, 0.4);
        distSpr.s = ds;
        ds.visible = false;
        scene.add(ds);
      };
      nextRoute();
      inner.rotation.y = wantY;
      outer.rotation.x = wantX;
      const angLerp = (a: number, b: number, k: number): number => {
        let d = b - a;
        while (d > Math.PI) d -= TAU;
        while (d < -Math.PI) d += TAU;
        return a + d * k;
      };
      return {
        scene,
        camera: cam,
        update(_t, dt) {
          const d = Math.min(dt, 0.1) * speed;
          rt += d;
          const k = 1 - Math.exp(-Math.min(dt, 0.1) * 3);
          inner.rotation.y = angLerp(inner.rotation.y, wantY, k);
          outer.rotation.x = lerp(outer.rotation.x, wantX, k);
          const grow = clamp01((rt - 0.9) / 2.2);
          if (tubeGeo) tubeGeo.setDrawRange(0, Math.floor(ease(grow) * TUB) * RAD * 6);
          if (plane && curve) {
            const s = clamp(ease(grow), 0.001, 0.999);
            plane.position.copy(curve.getPointAt(s));
            plane.lookAt(inner.localToWorld(curve.getPointAt(Math.min(1, s + 0.01))));
            plane.visible = grow > 0 && grow < 1;
          }
          if (distSpr.s) distSpr.s.visible = grow >= 1;
          if (rt > 5.4) nextRoute();
        },
        controls: [
          { type: 'button', label: '다음 도시', on: () => nextRoute() },
          { type: 'range', label: '속도', min: 0.25, max: 3, step: 0.25, value: 1, on: (v) => { speed = v; } },
          { type: 'toggle', label: '출발 도시의 위도선 · 경도선', value: true, on: (v) => { llOn = v; if (llGroup) llGroup.visible = v; } },
        ],
        dispose() {
          for (const d of disposables) d.dispose();
          tex.dispose();
          gGeo.dispose();
          gMat.dispose();
          hTex.dispose();
          hMat.dispose();
          sGeo.dispose();
          sMat.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [구면 → 평면 지도 투영](https://ai-techstudio.web.app/ai/t/i261.md) `i261`
- 다음에 해 볼 기술: [육각 타일 행성 (골드버그 다면체)](https://ai-techstudio.web.app/ai/t/i539.md) `i539` · [디오라마 지도 (3D 섬 판)](https://ai-techstudio.web.app/ai/t/i275.md) `i275`
- 참고 문서: [Wikipedia — Great-circle distance](https://en.wikipedia.org/wiki/Great-circle_distance) · [Wikipedia — Slerp](https://en.wikipedia.org/wiki/Slerp)
