# AI 꾸러미 — 마칭 스퀘어 (등고선 · 경계) — Marching squares
> 칸 네 귀가 기준보다 높은지 낮은지로 16가지 선 조각을 고르고 이어, 높이 지도에서 매끈한 해안선 · 등고선을 뽑는다.  
> 견본: https://ai-techstudio.web.app/#t/i259

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

## 주문서

### 만들어 줘: 마칭 스퀘어 (등고선 · 경계) — Marching squares

#### 1. 목표
높이 지도 등고선에 마칭 스퀘어로 등고선을 그려 줘 — 칸을 하나씩 훑으며 선이 이어지고, 칸마다 조각 번호(0~15)가 보이게. 분위기는 어두운 바탕에 밝은 선.

#### 2. 핵심 기술 용어
- **Marching squares** — 네 귀 값으로 칸마다 선 조각 고르기
- **Isoline / contour line** — 같은 높이를 잇는 선
- **Linear interpolation on edges** — 모서리 위에서 기준값이 되는 곳 찾기
- **Saddle case (ambiguous)** — 대각선 귀만 높은 애매한 칸

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

#### 4. 조건
- 값은 칸이 아니라 격자 귀에 둔다 ((GX+1)×(GY+1) 개)
- 건너는 자리는 선형 보간 (끄면 가운데 → 계단으로 비교)
- 대각선 두 귀만 높은 애매한 칸(5 · 10번)은 건너는 점이 넷 — 둘씩 짝지어 선 두 개
- 칸을 하나씩 훑는 진행이 보이게, 등고선 개수 · 격자 칸 수를 바꿀 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 칸을 훑으며 선이 왼쪽 위부터 차례로 이어져 끊김 없는 해안선이 된다
- 보간을 끄면 선이 계단처럼 꺾이고, 켜면 매끈하다
- 등고선 개수를 늘리면 높이 띠 색과 선이 여러 겹 나온다
- 귀 점(● · ○)과 조각 번호가 실제 선 모양과 맞다

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

## 원리
- 격자 귀마다 높이 값이 있다. 기준(견본 0.5)보다 높으면 ●, 낮으면 ○ — 네 귀로 2⁴ = 16가지.
- 칸의 모서리 중 한쪽 귀는 ●, 다른 쪽은 ○ 인 모서리를 선이 지나간다.
- 지나는 자리는 보간으로: t = (기준 − a) / (b − a). 보간을 끄면 모서리 가운데라 계단처럼 보인다.
- 건너는 점을 두 개씩 이으면 선 조각, ● 귀와 건너는 점을 이은 다각형을 칠하면 땅이 된다.
- 기준을 여러 개(0.3 ~ 0.92)로 하면 높이 띠와 등고선이 여러 겹 나온다.

## 핵심 코드 — 칸 하나 — 높은 귀 다각형 + 건너는 선 조각
(발췌: demos/demosMapA.ts i259 draw() 를 정리)
```ts
const V = (x: number, y: number) => f[y * (GX + 1) + x]; // 값은 격자 귀에
const segs: [number, number][] = [];
g.beginPath();
for (let y = 0; y < GY; y++) for (let x = 0; x < GX; x++) {
  const cv: [number, number, number][] = [
    [x, y, V(x, y)], [x + 1, y, V(x + 1, y)], [x + 1, y + 1, V(x + 1, y + 1)], [x, y + 1, V(x, y + 1)],
  ];
  const poly: [number, number][] = [], cross: [number, number][] = [];
  for (let k = 0; k < 4; k++) {
    const a = cv[k], b = cv[(k + 1) % 4];
    if (a[2] >= th) poly.push([ox + a[0] * cs, oy + a[1] * cs]);       // 높은 귀
    if ((a[2] >= th) !== (b[2] >= th)) {                                 // 이 모서리를 선이 지난다
      const t = interp ? (th - a[2]) / (b[2] - a[2]) : 0.5;
      const q: [number, number] = [ox + (a[0] + (b[0] - a[0]) * t) * cs, oy + (a[1] + (b[1] - a[1]) * t) * cs];
      poly.push(q); cross.push(q);
    }
  }
  if (poly.length >= 3) { poly.forEach((q, k) => (k ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1]))); g.closePath(); }
  for (let k = 0; k + 1 < cross.length; k += 2) segs.push(cross[k], cross[k + 1]); // 애매한 칸은 점 4개 → 선 2개
}
g.fillStyle = '#7fc45c'; g.fill();          // 땅
g.beginPath();
for (let k = 0; k + 1 < segs.length; k += 2) { g.moveTo(segs[k][0], segs[k][1]); g.lineTo(segs[k + 1][0], segs[k + 1][1]); }
g.strokeStyle = '#fffbe8'; g.lineWidth = 2.2; g.stroke(); // 해안선
```

## 흔한 실수 · 확인 목록
- [ ] **값을 칸 가운데에 두면 귀 네 개가 없어 조각을 못 고른다** — 값 배열은 (GX+1)×(GY+1) 귀에 둔다.
- [ ] **보간 없이 모서리 가운데로 이으면 계단 선이 된다** — t = (기준 − a)/(b − a) 로 건너는 자리를 찾는다.
- [ ] **애매한 칸(대각선 귀만 높음)에서 선이 엇갈려 보일 수 있다** — 건너는 점을 순서대로 둘씩 짝짓는다. 더 정확히 하려면 칸 가운데 값(네 귀 평균)으로 이을 방향을 정한다.

## 완성 기준 체크리스트
- [ ] 칸을 훑으며 선이 왼쪽 위부터 차례로 이어져 끊김 없는 해안선이 된다
- [ ] 보간을 끄면 선이 계단처럼 꺾이고, 켜면 매끈하다
- [ ] 등고선 개수를 늘리면 높이 띠 색과 선이 여러 겹 나온다
- [ ] 귀 점(● · ○)과 조각 번호가 실제 선 모양과 맞다

## 이 기술 정보
- id: `i259` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 28×17 칸 × 기준 수. 칸마다 귀 4개 비교 · 보간 몇 번이라 매 프레임 다시 해도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i259
- 쓰면 좋을 때: 높이 · 온도 같은 값 지도에서 경계선을 뽑을 때 / 칸 지도를 매끈한 해안선으로 바꿀 때 / 등고선 읽기 체험
- 쓰지 말 때: 칸마다 다른 그림 조각이 필요한 타일맵 — 대신 자동 타일(i258) / 3D 덩어리 표면 — 대신 마칭 큐브

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i259 견본 항목 — `src/demos/demosMapA.ts:3731`
```ts
  i259: {
    kind: '2d',
    caption: '칸 네 귀가 기준보다 높은지(●) 낮은지(○)로 16가지 조각 — 칸을 하나씩 훑으며 이으면 매끈한 해안선 · 등고선',
    make() {
      let GX = 28;
      let GY = 17;
      let f = new Float32Array(0);
      let levels = 1;
      let interp = true;
      let caseNum = true;
      let p = 0;
      let heat = mkCanvas(1, 1);
      const run = runner({
        rate: 200,
        hold: 1.8,
        start(seed) {
          GY = Math.round(GX * 0.62);
          const r = mulberry(seed);
          const blobs: [number, number, number, number][] = [];
          const nb = 5 + Math.floor(r() * 4);
          for (let k = 0; k < nb; k++) blobs.push([r() * GX, r() * GY, (0.12 + r() * 0.2) * GX, k < 2 ? -0.5 : 0.55 + r() * 0.5]);
          f = new Float32Array((GX + 1) * (GY + 1));
          for (let y = 0; y <= GY; y++)
            for (let x = 0; x <= GX; x++) {
              let v = 0.18;
              for (const [bx, by, br, a] of blobs) v += a * Math.exp(-((x - bx) ** 2 + (y - by) ** 2) / (br * br));
              v += (fbm((x / GX) * 4 + seed, (y / GX) * 4, seed, 3) - 0.5) * 0.35;
              f[y * (GX + 1) + x] = v;
            }
          // 땅이 절반쯤 되게 값 펴기 (가운데값 → 0.5)
          {
            const so = Array.from(f).sort((a, b) => a - b);
            const q = (k: number): number => so[Math.floor(k * (so.length - 1))]!;
            const mid = q(0.52);
            const sc = 0.8 / Math.max(1e-6, q(0.92) - q(0.08));
            for (let i = 0; i < f.length; i++) f[i] = 0.5 + (f[i]! - mid) * sc;
          }
          heat = mkCanvas(GX + 1, GY + 1);
          const hg = c2(heat);
          const im = hg.createImageData(GX + 1, GY + 1);
          for (let i = 0; i < f.length; i++) {
            const c = rampAt(
              [
                [-0.2, [20, 40, 90]],
                [0.4, [40, 90, 140]],
                [0.6, [70, 140, 110]],
                [1.1, [200, 190, 140]],
              ],
              f[i]!,
            );
            im.data[i * 4] = c[0];
            im.data[i * 4 + 1] = c[1];
            im.data[i * 4 + 2] = c[2];
            im.data[i * 4 + 3] = 255;
          }
          hg.putImageData(im, 0, 0);
          p = 0;
        },
        step() {
          p += Math.max(1, Math.round((GX * GY) / 476));
          return p >= GX * GY + GX;
        },
      });
      run.restart();
      const ths = (): number[] => (levels === 1 ? [0.5] : Array.from({ length: levels }, (_, i) => 0.3 + (i * 0.62) / Math.max(1, levels - 1)));
      const BAND: V3[] = [hexV('#7fc45c'), hexV('#a9cf6a'), hexV('#d9c780'), hexV('#c39a6b'), hexV('#ece7e0')];
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          run.tick(dt);
          const u = ui(scaleOf(w, h));
          g.fillStyle = '#0e1828';
          g.fillRect(0, 0, w, h);
          const cs = Math.min((w - 8) / GX, (h - 8) / GY);
          const ox = (w - GX * cs) / 2;
          const oy = (h - GY * cs) / 2;
          g.globalAlpha = 0.55;
          g.imageSmoothingEnabled = true;
          g.drawImage(heat, ox - cs / 2, oy - cs / 2, (GX + 1) * cs, (GY + 1) * cs);
          g.globalAlpha = 1;
          const V = (x: number, y: number): number => f[y * (GX + 1) + x]!;
          const done = Math.min(GX * GY, p);
          const TH = ths();
          const segs: P2[][] = TH.map(() => []);
          TH.forEach((th, li) => {
            g.beginPath();
            for (let c = 0; c < done; c++) {
              const x = c % GX;
              const y = (c / GX) | 0;
              const cv: [number, number, number][] = [
                [x, y, V(x, y)],
                [x + 1, y, V(x + 1, y)],
                [x + 1, y + 1, V(x + 1, y + 1)],
                [x, y + 1, V(x, y + 1)],
              ];
              const poly: P2[] = [];
              const cross: P2[] = [];
              for (let k = 0; k < 4; k++) {
                const a = cv[k]!;
                const b = cv[(k + 1) % 4]!;
                if (a[2] >= th) poly.push([ox + a[0] * cs, oy + a[1] * cs]);
                if (a[2] >= th !== b[2] >= th) {
                  const tt = interp ? (th - a[2]) / (b[2] - a[2]) : 0.5;
                  const q: P2 = [ox + lerp(a[0], b[0], tt) * cs, oy + lerp(a[1], b[1], tt) * cs];
                  poly.push(q);
                  cross.push(q);
                }
              }
              if (poly.length >= 3) {
                poly.forEach((q, k) => (k ? g.lineTo(q[0], q[1]) : g.moveTo(q[0], q[1])));
                g.closePath();
              }
              for (let k = 0; k + 1 < cross.length; k += 2) segs[li]!.push(cross[k]!, cross[k + 1]!);
            }
            g.fillStyle = rgb(BAND[Math.min(BAND.length - 1, levels === 1 ? 0 : Math.round((li / Math.max(1, levels - 1)) * (BAND.length - 1)))]!, levels === 1 ? 0.92 : 0.9);
            g.fill();
          });
          // 선
          g.lineCap = 'round';
          segs.forEach((list, li) => {
            g.beginPath();
            for (let k = 0; k + 1 < list.length; k += 2) {
              g.moveTo(list[k]![0], list[k]![1]);
              g.lineTo(list[k + 1]![0], list[k + 1]![1]);
            }
            g.strokeStyle = levels === 1 ? '#fffbe8' : li === 0 ? '#fffbe8' : 'rgba(70,45,20,0.8)';
            g.lineWidth = (levels === 1 ? 2.2 : 1.4) * u;
            g.stroke();
          });
          // 격자 · 귀 점
          g.strokeStyle = 'rgba(255,255,255,0.08)';
          g.lineWidth = 1;
          g.beginPath();
          for (let x = 0; x <= GX; x++) {
            g.moveTo(ox + x * cs, oy);
            g.lineTo(ox + x * cs, oy + GY * cs);
          }
          for (let y = 0; y <= GY; y++) {
            g.moveTo(ox, oy + y * cs);
            g.lineTo(ox + GX * cs, oy + y * cs);
          }
          g.stroke();
          if (levels === 1 && cs >= 7) {
            const r = Math.max(1.2, cs * 0.12);
            for (let y = 0; y <= GY; y++)
              for (let x = 0; x <= GX; x++) {
                const on = V(x, y) >= 0.5;
                g.beginPath();
                g.arc(ox + x * cs, oy + y * cs, r, 0, TAU);
                if (on) {
                  g.fillStyle = 'rgba(255,255,255,0.85)';
                  g.fill();
                } else {
                  g.strokeStyle = 'rgba(255,255,255,0.35)';
                  g.lineWidth = 1;
                  g.stroke();
                }
              }
          }
          // 칸 번호
          if (caseNum && levels === 1 && cs >= 15) {
            for (let c = 0; c < done; c++) {
              const x = c % GX;
              const y = (c / GX) | 0;
              const n = (V(x, y) >= 0.5 ? 8 : 0) + (V(x + 1, y) >= 0.5 ? 4 : 0) + (V(x + 1, y + 1) >= 0.5 ? 2 : 0) + (V(x, y + 1) >= 0.5 ? 1 : 0);
              if (n === 0 || n === 15) continue;
              txt(g, String(n), ox + (x + 0.5) * cs, oy + (y + 0.5) * cs, cs * 0.34, 'rgba(20,40,20,0.75)', 'center', 800);
            }
          }
          // 지금 훑는 칸
          if (p < GX * GY && !run.holding) {
            const x = p % GX;
            const y = (p / GX) | 0;
            const n = (V(x, y) >= 0.5 ? 8 : 0) + (V(x + 1, y) >= 0.5 ? 4 : 0) + (V(x + 1, y + 1) >= 0.5 ? 2 : 0) + (V(x, y + 1) >= 0.5 ? 1 : 0);
            g.fillStyle = 'rgba(255,225,77,0.25)';
            g.fillRect(ox, oy + y * cs, GX * cs, cs);
            g.strokeStyle = '#ffe14d';
            g.lineWidth = 2 * u;
            g.strokeRect(ox + x * cs, oy + y * cs, cs, cs);
            if (levels === 1) {
              const bits = n.toString(2).padStart(4, '0');
              pill(g, `조각 ${bits} = ${n}번`, Math.min(w - 70 * u, ox + (x + 1) * cs + 4 * u), oy + y * cs - 6 * u + (y < 2 ? 22 * u : 0), 7.5 * u, 'rgba(255,225,77,0.95)', '#2a1d00');
            }
          }
          const label = run.holding ? (levels === 1 ? '해안선 완성' : `등고선 ${levels}개 완성`) : `칸 훑기 ${done} / ${GX * GY}`;
          pill(g, label, 8 * u, 13 * u, 9 * u, 'rgba(10,16,30,0.85)');
          void t;
        },
        controls: [
          seedCtl(run),
          speedCtl(run),
          { type: 'toggle', label: '가장자리 보간 (끄면 칸 가운데 → 계단)', value: true, on: (v) => { interp = v; } },
          { type: 'range', label: '등고선 개수', min: 1, max: 5, step: 1, value: 1, on: (v) => { levels = v; } },
          { type: 'range', label: '격자 칸 수', min: 12, max: 48, step: 2, value: 28, on: (v) => { GX = v; run.restart(run.seed); } },
          { type: 'toggle', label: '조각 번호', value: true, on: (v) => { caseNum = v; } },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245` · [자동 타일 (비트마스크 이음)](https://ai-techstudio.web.app/ai/t/i258.md) `i258`
- 다음에 해 볼 기술: [등고선 3D 지형 (높이 띠)](https://ai-techstudio.web.app/ai/t/i279.md) `i279`
- 참고 문서: [Wikipedia — Marching squares](https://en.wikipedia.org/wiki/Marching_squares)
