# AI 꾸러미 — 베를레 밧줄 · 사슬 — Verlet integration
> 점들을 지난 자리와의 차로 움직이고(베를레) 「이웃 점과 길이 지키기」를 12번 되풀이해 풀어, 출렁이는 밧줄 · 다리를 만들고 가위로 자르면 사탕이 떨어진다.  
> 견본: https://ai-techstudio.web.app/#t/i286

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

## 주문서

### 만들어 줘: 베를레 밧줄 · 사슬 — Verlet integration

#### 1. 목표
사탕을 매단 밧줄 두 가닥을 베를레 점 + 길이 제약으로 만들어 줘 — 점은 지난 자리와의 차로 움직이고, 길이 지키기를 12번 되풀이해 풀며, 끌어서 자를 수 있게. 화면은 밧줄 자르기 퍼즐.

#### 2. 핵심 기술 용어
- **Verlet integration** — 속도 대신 「지금 − 지난 자리」로 움직이기
- **Distance constraint (relaxation)** — 두 점 길이 지키기 — 여러 번 되풀이해 풀기
- **Inverse mass (pinned points)** — 역질량 0 = 고정된 점, 클수록 가볍다
- **Segment–segment intersection (cutting)** — 자르는 선과 밧줄 마디가 만나는지

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

#### 4. 조건
- 점 움직임은 베를레 (p − o) — 속도 변수를 따로 두지 않기
- 길이 제약은 역질량 비율로 나눠 옮기고, 전체를 여러 번(기본 12) 되풀이
- 고정점은 역질량 0
- 물리는 고정 간격 1/120초
- 되풀이 횟수 조절 · 자르기(끌어 긋기) · 다시 시작

#### 5. 완성 기준 (이게 보이면 성공)
- 사탕이 두 줄에 매달려 출렁이다 가만히 멈춘다
- 한 줄을 자르면 사탕이 다른 줄에 매달려 크게 흔들리고, 둘 다 자르면 다리에 떨어져 다리가 출렁인다
- 되풀이 횟수를 1~2 로 내리면 줄이 고무줄처럼 늘어나고, 올리면 팽팽하다
- 사탕을 끌어 흔들 수 있다

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

## 원리
- 점마다 지금 자리 p 와 지난 자리 o 만 둔다. 속도 = (p − o) × 0.997(공기 저항), 새 자리 = p + 속도 + 중력 520 × h².
- 마디마다 「두 점 거리 = 정한 길이」 제약. 길이가 d 면 차이 (d − len)/d 만큼 두 점을 서로 쪽으로 옮긴다 — 역질량 비율로 나눠서.
- 한 마디를 맞추면 이웃 마디가 틀어지므로 전체를 12번 되풀이해 푼다 (많을수록 덜 늘어난다).
- 고정점은 역질량 0 (안 움직임), 사탕은 0.2 (무겁다). 줄은 길이 × 1.02, 다리는 × 1.07 로 살짝 늘어지게.
- 자르기: 끌어 그은 선분과 밧줄 마디 선분이 만나면 그 제약을 끈다 — 사탕이 다리로 떨어진다.

## 핵심 코드 — 베를레 걸음 + 길이 제약 되풀이 풀기
(발췌: demos/demos2dMove.ts i286 physics() 를 정리 (사탕 ↔ 다리 충돌은 줄임))
```ts
const px: number[] = [], py: number[] = [], ox: number[] = [], oy: number[] = [], inv: number[] = [];
interface Con { a: number; b: number; len: number; alive: boolean }
const cons: Con[] = [];
const add = (x: number, y: number, w: number) => { px.push(x); py.push(y); ox.push(x); oy.push(y); inv.push(w); return px.length - 1; };
// a → b 사이에 점 n-1 개, 길이는 slack 배로 (줄 1.02 · 다리 1.07)
function chain(a: number, b: number, n: number, slack: number) {
  const ids = [a];
  for (let i = 1; i < n; i++) ids.push(add(px[a]! + ((px[b]! - px[a]!) * i) / n, py[a]! + ((py[b]! - py[a]!) * i) / n, 1));
  ids.push(b);
  const d = Math.hypot(px[b]! - px[a]!, py[b]! - py[a]!) / n;
  for (let i = 0; i < n; i++) cons.push({ a: ids[i]!, b: ids[i + 1]!, len: d * slack, alive: true });
}

function physics(h: number, iters = 12) {
  const G = 520;
  for (let i = 0; i < px.length; i++) { // 베를레: 지난 자리와의 차가 속도
    if (inv[i] === 0) continue;          // 고정점
    const vx = (px[i]! - ox[i]!) * 0.997, vy = (py[i]! - oy[i]!) * 0.997;
    ox[i] = px[i]!; oy[i] = py[i]!;
    px[i] = px[i]! + vx;
    py[i] = py[i]! + vy + G * h * h;
  }
  for (let it = 0; it < iters; it++)      // 길이 지키기를 여러 번
    for (const c of cons) {
      if (!c.alive) continue;
      const wa = inv[c.a]!, wb = inv[c.b]!, ws = wa + wb;
      if (ws === 0) continue;
      const dx = px[c.b]! - px[c.a]!, dy = py[c.b]! - py[c.a]!;
      const d = Math.hypot(dx, dy) || 1e-6;
      const diff = (d - c.len) / d;
      px[c.a] = px[c.a]! + dx * diff * (wa / ws); py[c.a] = py[c.a]! + dy * diff * (wa / ws);
      px[c.b] = px[c.b]! - dx * diff * (wb / ws); py[c.b] = py[c.b]! - dy * diff * (wb / ws);
    }
}
// 만들기: const p1 = add(92, 14, 0), p2 = add(204, 24, 0), candy = add(150, 80, 0.2);
// chain(p1, candy, 13, 1.02); chain(p2, candy, 12, 1.02);
// 자르기: 그은 선분과 마디 선분이 만나면 c.alive = false
```

## 흔한 실수 · 확인 목록
- [ ] **제약을 한 번만 풀면 줄이 고무줄처럼 늘어난다** — 전체를 여러 번(12) 되풀이한다. 마디가 많을수록 더 필요하다.
- [ ] **두 점을 반씩만 옮기면 고정점이 끌려 움직인다** — 역질량 비율(wa/ws · wb/ws)로 나누고 고정점은 역질량 0.
- [ ] **dt 를 프레임마다 바꾸면 베를레가 터지거나 힘이 바뀐다** — 베를레는 걸음 간격이 같다고 보는 식이라 1/120초 고정 간격으로 돌린다.
- [ ] **공기 저항(0.997)이 없으면 영원히 흔들린다** — 속도에 1보다 조금 작은 값을 곱한다.

## 완성 기준 체크리스트
- [ ] 사탕이 두 줄에 매달려 출렁이다 가만히 멈춘다
- [ ] 한 줄을 자르면 사탕이 다른 줄에 매달려 크게 흔들리고, 둘 다 자르면 다리에 떨어져 다리가 출렁인다
- [ ] 되풀이 횟수를 1~2 로 내리면 줄이 고무줄처럼 늘어나고, 올리면 팽팽하다
- [ ] 사탕을 끌어 흔들 수 있다

## 이 기술 정보
- id: `i286` · 분류: 2D · 화면 › 2D 움직임 · 충돌 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 점 수십 개 × 제약 수십 개 × 12번. 1/120초 고정 간격으로도 폰이 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i286
- 쓰면 좋을 때: 밧줄 · 사슬 · 다리처럼 출렁이는 줄 / 자르기 · 매달기 퍼즐
- 쓰지 말 때: 딱딱한 막대 · 상자 — 강체 물리가 낫다 / 아주 긴 줄(마디 수백) — 되풀이 횟수를 크게 올려야 늘어나지 않는다. 대신 물리 엔진의 관절

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i286 — `src/demos/demos2dMove.ts:1738`
```ts
const i286: Demo2D = {
  kind: '2d',
  caption: '점들을 「길이 지키기」로 여러 번 풀어 밧줄 · 다리가 된다 — 가위로 자르면 사탕이 출렁 다리로',
  make() {
    const inp = new Input();
    let iters = 12;
    let debug = false;
    // 점
    let px: number[] = [];
    let py: number[] = [];
    let ox: number[] = [];
    let oy: number[] = [];
    let inv: number[] = [];
    interface Con {
      a: number;
      b: number;
      len: number;
      kind: 0 | 1;
      alive: boolean;
    }
    let cons: Con[] = [];
    let candy = 0;
    let bridge: number[] = [];
    let spin = 0;
    let T = 0;
    let cuts = 0;
    let grab = false;
    const flares: Part[] = [];
    const slashes: { x0: number; y0: number; x1: number; y1: number; t: number }[] = [];
    const add = (x: number, y: number, w: number): number => {
      px.push(x);
      py.push(y);
      ox.push(x);
      oy.push(y);
      inv.push(w);
      return px.length - 1;
    };
    const chain = (a: number, b: number, n: number, slack: number, kind: 0 | 1): number[] => {
      const ids = [a];
      for (let i = 1; i < n; i++) ids.push(add(lerp(px[a]!, px[b]!, i / n), lerp(py[a]!, py[b]!, i / n), 1));
      ids.push(b);
      const d = Math.hypot(px[b]! - px[a]!, py[b]! - py[a]!) / n;
      for (let i = 0; i < n; i++) cons.push({ a: ids[i]!, b: ids[i + 1]!, len: d * slack, kind, alive: true });
      return ids;
    };
    const build = (): void => {
      px = [];
      py = [];
      ox = [];
      oy = [];
      inv = [];
      cons = [];
      const p1 = add(92, 14, 0);
      const p2 = add(204, 24, 0);
      candy = add(150, 80, 0.2);
      chain(p1, candy, 13, 1.02, 0);
      chain(p2, candy, 12, 1.02, 0);
      const b0 = add(34, 138, 0);
      const b1 = add(246, 138, 0);
      bridge = chain(b0, b1, 16, 1.07, 1);
      ox[candy] = px[candy]! - 1.4;
      T = 0;
      cuts = 0;
      spin = 0;
    };
    build();
    const cutCrossing = (x0: number, y0: number, x1: number, y1: number): boolean => {
      let any = false;
      for (const c of cons) {
        if (!c.alive || c.kind === 1) continue;
        const ax = px[c.a]!;
        const ay = py[c.a]!;
        const bx = px[c.b]!;
        const by = py[c.b]!;
        const d = (x1 - x0) * (by - ay) - (y1 - y0) * (bx - ax);
        if (Math.abs(d) < 1e-6) continue;
        const s = ((ax - x0) * (by - ay) - (ay - y0) * (bx - ax)) / d;
        const u2 = ((ax - x0) * (y1 - y0) - (ay - y0) * (x1 - x0)) / d;
        if (s >= 0 && s <= 1 && u2 >= 0 && u2 <= 1) {
          c.alive = false;
          any = true;
          const cx = (ax + bx) / 2;
          const cy = (ay + by) / 2;
          for (let i = 0; i < 10; i++) {
            const a = Math.random() * TAU;
            flares.push({ x: cx, y: cy, vx: Math.cos(a) * 50, vy: Math.sin(a) * 50, life: 0.35, max: 0.35, size: 1.3, color: i % 2 ? '#fff6c4' : '#ffb347', grav: 60 });
          }
        }
      }
      return any;
    };
    const scriptCut = (k: number): void => {
      // 줄 k(0/1) 의 5번째 마디를 가로지르는 선
      const list = cons.filter((c) => c.kind === 0);
      const c = k === 0 ? list[5] : list[13 + 6];
      if (!c) return;
      const cx = (px[c.a]! + px[c.b]!) / 2;
      const cy = (py[c.a]! + py[c.b]!) / 2;
      const ang = Math.atan2(py[c.b]! - py[c.a]!, px[c.b]! - px[c.a]!) + Math.PI / 2;
      const x0 = cx - Math.cos(ang) * 14;
      const y0 = cy - Math.sin(ang) * 14;
      const x1 = cx + Math.cos(ang) * 14;
      const y1 = cy + Math.sin(ang) * 14;
      slashes.push({ x0, y0, x1, y1, t: 0 });
      cutCrossing(x0, y0, x1, y1);
    };
    const RC = 9;
    const physics = (h: number): void => {
      const G = 520;
      for (let i = 0; i < px.length; i++) {
        if (inv[i] === 0) continue;
        const vx = (px[i]! - ox[i]!) * 0.997;
        const vy = (py[i]! - oy[i]!) * 0.997;
        ox[i] = px[i]!;
        oy[i] = py[i]!;
        px[i] = px[i]! + vx;
        py[i] = py[i]! + vy + G * h * h;
      }
      if (grab) {
        px[candy] = lerp(px[candy]!, clamp(inp.x, 10, 270), 0.4);
        py[candy] = lerp(py[candy]!, clamp(inp.y, 10, 165), 0.4);
      }
      for (let it = 0; it < iters; it++) {
        for (const c of cons) {
          if (!c.alive) continue;
          const wa = inv[c.a]!;
          const wb = inv[c.b]!;
          const ws = wa + wb;
          if (ws === 0) continue;
          const dx = px[c.b]! - px[c.a]!;
          const dy = py[c.b]! - py[c.a]!;
          const d = Math.hypot(dx, dy) || 1e-6;
          const diff = (d - c.len) / d;
          px[c.a] = px[c.a]! + dx * diff * (wa / ws);
          py[c.a] = py[c.a]! + dy * diff * (wa / ws);
          px[c.b] = px[c.b]! - dx * diff * (wb / ws);
          py[c.b] = py[c.b]! - dy * diff * (wb / ws);
        }
        // 사탕 ↔ 다리
        for (let i = 0; i + 1 < bridge.length; i++) {
          const a = bridge[i]!;
          const b = bridge[i + 1]!;
          const ex = px[b]! - px[a]!;
          const ey = py[b]! - py[a]!;
          const L2 = ex * ex + ey * ey || 1;
          const u = clamp01(((px[candy]! - px[a]!) * ex + (py[candy]! - py[a]!) * ey) / L2);
          const qx = px[a]! + ex * u;
          const qy = py[a]! + ey * u;
          let nx = px[candy]! - qx;
          let ny = py[candy]! - qy;
          const d = Math.hypot(nx, ny);
          const R = RC + 2.5;
          if (d < R && d > 1e-6) {
            nx /= d;
            ny /= d;
            const pen = R - d;
            const wc = inv[candy]!;
            const wa = inv[a]! * (1 - u);
            const wb = inv[b]! * u;
            const ws = wc + wa + wb || 1;
            px[candy] = px[candy]! + nx * pen * (wc / ws);
            py[candy] = py[candy]! + ny * pen * (wc / ws);
            px[a] = px[a]! - nx * pen * (wa / ws);
            py[a] = py[a]! - ny * pen * (wa / ws);
            px[b] = px[b]! - nx * pen * (wb / ws);
            py[b] = py[b]! - ny * pen * (wb / ws);
            // 마찰
            ox[candy] = lerp(ox[candy]!, px[candy]!, 0.02);
          }
        }
      }
      if (py[candy]! > 168) py[candy] = 168;
      spin += (px[candy]! - ox[candy]!) * 0.12;
    };
    const step = fixed(1 / 120);
    let last = { x: 0, y: 0 };
    return {
      controls: [
        { type: 'range', label: '길이 맞추기 반복 횟수', min: 1, max: 30, step: 1, value: 12, on: (v) => (iters = v) },
        { type: 'toggle', label: '점 · 막대 보기', value: false, on: (v) => (debug = v) },
        { type: 'button', label: '다시 매달기', on: () => build() },
      ] 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') {
            grab = Math.hypot(e.x - px[candy]!, e.y - py[candy]!) < RC + 6;
            last = { x: e.x, y: e.y };
            cuts = 99;
          } else grab = false;
        }
        if (inp.down && !grab && (inp.x !== last.x || inp.y !== last.y)) {
          if (cutCrossing(last.x, last.y, inp.x, inp.y)) slashes.push({ x0: last.x, y0: last.y, x1: inp.x, y1: inp.y, t: 0 });
          last = { x: inp.x, y: inp.y };
        }
        step(dt, (H) => {
          T += H;
          physics(H);
        });
        if (cuts < 99) {
          if (cuts === 0 && T > 1.4) {
            scriptCut(0);
            cuts = 1;
          } else if (cuts === 1 && T > 2.5) {
            scriptCut(1);
            cuts = 2;
          } else if (cuts === 2 && T > 5.6) build();
        } else if (T > 4 && !inp.down && cons.every((c) => c.kind === 1 || !c.alive) && T > 9) build();
        stepParts(flares, dt);
        for (const s of slashes) s.t += dt;
        while (slashes.length && slashes[0]!.t > 0.5) slashes.shift();

        fillBg(g, w, h, '#2a1f45', '#45305c');
        enter(g, v);
        // 벽지 점
        g.fillStyle = 'rgba(255,220,255,.05)';
        for (let y = 6; y < 175; y += 12)
          for (let x = (y / 12) % 2 ? 6 : 0; x < 280; x += 12) {
            g.beginPath();
            g.arc(x, y, 1.6, 0, TAU);
            g.fill();
          }
        const lamp = g.createRadialGradient(150, 40, 0, 150, 40, 140);
        lamp.addColorStop(0, 'rgba(255,210,150,.16)');
        lamp.addColorStop(1, 'rgba(255,210,150,0)');
        g.fillStyle = lamp;
        g.fillRect(0, 0, 280, 175);
        // 다리 기둥
        for (const bx of [px[bridge[0]!]!, px[bridge[bridge.length - 1]!]!]) {
          g.fillStyle = '#8a5a34';
          rr(g, bx - 4, 132, 8, 45, 2);
          g.fill();
          g.fillStyle = '#b07a48';
          rr(g, bx - 5, 128, 10, 6, 2);
          g.fill();
        }
        // 다리 널빤지
        g.lineCap = 'round';
        for (let i = 0; i + 1 < bridge.length; i++) {
          const a = bridge[i]!;
          const b = bridge[i + 1]!;
          const ang = Math.atan2(py[b]! - py[a]!, px[b]! - px[a]!);
          const L = Math.hypot(px[b]! - px[a]!, py[b]! - py[a]!);
          g.save();
          g.translate((px[a]! + px[b]!) / 2, (py[a]! + py[b]!) / 2);
          g.rotate(ang);
          g.fillStyle = i % 2 ? '#c88a52' : '#d79a5f';
          rr(g, -L / 2 + 0.4, -1, L - 0.8, 5, 1.2);
          g.fill();
          g.fillStyle = 'rgba(255,255,255,.25)';
          g.fillRect(-L / 2 + 1, -0.6, L - 2, 0.8);
          g.strokeStyle = 'rgba(90,50,20,.6)';
          g.lineWidth = 0.5;
          g.strokeRect(-L / 2 + 0.4, -1, L - 0.8, 5);
          g.restore();
        }
        g.strokeStyle = '#e8d2a6';
        g.lineWidth = 1;
        g.beginPath();
        bridge.forEach((id, i) => (i ? g.lineTo(px[id]!, py[id]! - 1) : g.moveTo(px[id]!, py[id]! - 1)));
        g.stroke();
        // 밧줄
        for (const c of cons) {
          if (!c.alive || c.kind === 1) continue;
          const ax = px[c.a]!;
          const ay = py[c.a]!;
          const bx = px[c.b]!;
          const by = py[c.b]!;
          const st = Math.hypot(bx - ax, by - ay) / c.len;
          g.strokeStyle = st > 1.25 ? '#ff8a6a' : '#6b4320';
          g.lineWidth = 3;
          g.beginPath();
          g.moveTo(ax, ay);
          g.lineTo(bx, by);
          g.stroke();
          g.strokeStyle = st > 1.25 ? '#ffc0a0' : '#c99559';
          g.lineWidth = 1.8;
          g.stroke();
          g.strokeStyle = 'rgba(80,45,15,.7)';
          g.lineWidth = 0.6;
          const mx = (ax + bx) / 2;
          const my = (ay + by) / 2;
          g.beginPath();
          g.moveTo(mx - 0.8, my - 1);
          g.lineTo(mx + 0.8, my + 1);
          g.stroke();
        }
        // 못
        for (let i = 0; i < 2; i++) {
          const x = px[i]!;
          const y = py[i]!;
          const ng = g.createRadialGradient(x - 1, y - 1, 0, x, y, 4);
          ng.addColorStop(0, '#fff2b8');
          ng.addColorStop(1, '#b8862a');
          g.fillStyle = ng;
          g.beginPath();
          g.arc(x, y, 3.6, 0, TAU);
          g.fill();
          g.strokeStyle = 'rgba(60,40,10,.6)';
          g.lineWidth = 0.6;
          g.stroke();
        }
        // 사탕
        const cx = px[candy]!;
        const cy = py[candy]!;
        g.save();
        g.translate(cx, cy);
        g.rotate(spin);
        g.fillStyle = '#ff6fa8';
        for (const s of [-1, 1]) {
          g.beginPath();
          g.moveTo(s * RC * 0.8, 0);
          g.lineTo(s * RC * 1.75, -5);
          g.quadraticCurveTo(s * RC * 1.55, 0, s * RC * 1.75, 5);
          g.closePath();
          g.fill();
          g.strokeStyle = '#c23a72';
          g.lineWidth = 0.6;
          g.stroke();
        }
        g.beginPath();
        g.arc(0, 0, RC, 0, TAU);
        const cg = g.createRadialGradient(-3, -3, 1, 0, 0, RC);
        cg.addColorStop(0, '#fff');
        cg.addColorStop(0.35, '#ffd1e4');
        cg.addColorStop(1, '#ff5d9a');
        g.fillStyle = cg;
        g.fill();
        g.save();
        g.clip();
        g.strokeStyle = 'rgba(255,255,255,.85)';
        g.lineWidth = 2.2;
        for (let k = -2; k <= 2; k++) {
          g.beginPath();
          g.moveTo(k * 5 - 10, -10);
          g.lineTo(k * 5 + 10, 10);
          g.stroke();
        }
        g.restore();
        g.strokeStyle = '#c23a72';
        g.lineWidth = 0.9;
        g.beginPath();
        g.arc(0, 0, RC, 0, TAU);
        g.stroke();
        g.restore();
        // 자르기 칼선
        for (const s of slashes) {
          const k = s.t / 0.5;
          g.globalAlpha = 1 - k;
          g.strokeStyle = '#fff';
          g.lineWidth = 2.2 * (1 - k);
          g.lineCap = 'round';
          g.beginPath();
          g.moveTo(s.x0, s.y0);
          g.lineTo(s.x1, s.y1);
          g.stroke();
          if (k < 0.6) txt(g, '✂', s.x1 + 4, s.y1 - 4, 9, '#fff', 'center', 800);
          g.globalAlpha = 1;
        }
        g.lineCap = 'butt';
        drawParts(g, flares, true);
        if (debug) {
          g.strokeStyle = 'rgba(0,230,255,.6)';
          g.lineWidth = 0.5;
          for (const c of cons) {
            if (!c.alive) continue;
            g.beginPath();
            g.moveTo(px[c.a]!, py[c.a]!);
            g.lineTo(px[c.b]!, py[c.b]!);
            g.stroke();
          }
          for (let i = 0; i < px.length; i++) {
            g.fillStyle = inv[i] === 0 ? '#ffe066' : '#00e5ff';
            g.beginPath();
            g.arc(px[i]!, py[i]!, inv[i] === 0 ? 2 : 1.1, 0, TAU);
            g.fill();
          }
          g.strokeStyle = 'rgba(255,230,100,.8)';
          g.setLineDash([2, 2]);
          g.beginPath();
          g.arc(cx, cy, RC + 2.5, 0, TAU);
          g.stroke();
          g.setLineDash([]);
        }
        txt(g, `반복 ${iters}번`, 272, 9, 7, 'rgba(255,255,255,.65)', 'right', 700);
        if (v.big) txt(g, '긋기 = 자르기 · 사탕 끌기', 8, 9, 7, 'rgba(255,255,255,.65)', 'left', 700);
        void t;
        g.restore();
      },
      dispose() {
        inp.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [플랫폼 점프 손맛 (코요테 · 버퍼 · 가변 점프)](https://ai-techstudio.web.app/ai/t/i282.md) `i282`
- 다음에 해 볼 기술: [말랑한 몸 (스프링 젤리 · 압력)](https://ai-techstudio.web.app/ai/t/i287.md) `i287` · [2D 물 표면 (스프링 기둥 · 퍼짐)](https://ai-techstudio.web.app/ai/t/i288.md) `i288`
- 참고 문서: [Wikipedia — Verlet integration](https://en.wikipedia.org/wiki/Verlet_integration)
