# AI 꾸러미 — 되돌리기 · 다시 하기 (명령 기록) — Undo / redo (command pattern)
> 획 하나를 명령 하나로 기록해 쌓고 「지금 위치」만 앞뒤로 옮겨 되돌리기 · 다시 하기를 만들고, 되돌린 뒤 새로 그으면 다시 하기 갈래를 잘라 낸다.  
> 견본: https://ai-techstudio.web.app/#t/i317

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

## 주문서

### 만들어 줘: 되돌리기 · 다시 하기 (명령 기록) — Undo / redo (command pattern)

#### 1. 목표
그리기 판에 되돌리기 · 다시 하기를 넣어 줘 — 획마다 명령으로 쌓고 지금 위치를 옮기기, 지우개도 획으로, 새로 그으면 다시 하기 갈래가 잘리게. 분위기는 깔끔한 도구 화면.

#### 2. 핵심 기술 용어
- **Undo / redo (command pattern)** — 되돌리기 · 다시 하기 — 명령 기록
- **History stack with cursor index** — 기록 목록 + 지금 위치 번호
- **Replay (rebuild from commands)** — 처음부터 명령을 다시 그려 화면 만들기
- **Redo branch truncation** — 새 명령이 오면 다시 하기 쪽을 버림

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

#### 4. 조건
- 상태가 아니라 명령(다시 그릴 재료)을 기록
- 지우개도 명령으로 (destination-out) — 지운 것도 되돌리기
- 되돌린 뒤 새 명령이면 idx 뒤를 잘라 내기
- 그리는 중엔 마지막 점만 덧그리기, 전체 다시 그리기는 되돌리기 · 다시 하기 때만
- 되돌릴 것이 없으면 단추를 흐리게

#### 5. 완성 기준 (이게 보이면 성공)
- ↶ 를 누르면 마지막 획부터 하나씩 사라지고, ↷ 로 다시 나타난다
- 지우개로 지운 것도 ↶ 로 되살아난다
- 되돌린 뒤 새로 그으면 기록 판의 뒤 명령들이 줄 그어지며 미끄러져 사라지고 「갈래가 잘려요」가 뜬다
- 기록 판에 지금 위치 화살표가 보인다

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

## 원리
- 명령 = 다시 그리는 데 필요한 것 전부 (펜/지우개 · 점들 · 색). 목록 stack 과 「여기까지 적용됨」 번호 idx 를 든다.
- 되돌리기 = idx − 1, 다시 하기 = idx + 1. 그런 뒤 빈 캔버스에 stack[0] ~ stack[idx − 1] 을 차례로 다시 그린다.
- 지우개도 destination-out 으로 그리는 획 하나 — 그래서 지우기도 되돌릴 수 있다.
- 되돌린 상태(idx < 길이)에서 새로 그으면 idx 뒤 명령들을 버리고 새 명령을 붙인다 — 갈래가 잘린다.
- 그리는 중에는 새 점만 이어 그리고, 다시 그리기(rebuild)는 되돌리기 · 다시 하기 때만.

## 핵심 코드 — 명령 목록 + 지금 위치 + 갈래 자르기
(발췌: demos/demosDrawTools.ts undoRedo() 를 정리)
```ts
type P = { x: number; y: number };
type Cmd = { kind: 'pen' | 'erase'; pts: P[]; color: string };
let stack: Cmd[] = [];
let idx = 0;                                   // stack[0 .. idx-1] 이 화면에 적용됨
let cur: Cmd | null = null;

function drawCmd(g: CanvasRenderingContext2D, c: Cmd, from = 1) {
  g.save();
  g.globalCompositeOperation = c.kind === 'erase' ? 'destination-out' : 'source-over';
  g.strokeStyle = c.kind === 'erase' ? '#000' : c.color; g.lineWidth = c.kind === 'erase' ? 15 : 3.2;
  g.lineCap = g.lineJoin = 'round';
  g.beginPath();
  const s = Math.max(0, from - 1);
  g.moveTo(c.pts[s].x, c.pts[s].y);
  for (let i = s + 1; i < c.pts.length; i++) g.lineTo(c.pts[i].x, c.pts[i].y);
  if (c.pts.length === 1) g.lineTo(c.pts[0].x + 0.01, c.pts[0].y);
  g.stroke();
  g.restore();
}
function rebuild(g: CanvasRenderingContext2D) {
  g.clearRect(0, 0, g.canvas.width, g.canvas.height);
  for (let i = 0; i < idx; i++) drawCmd(g, stack[i]);
}
const undo = (g: CanvasRenderingContext2D) => { if (idx > 0) { idx--; rebuild(g); } };
const redo = (g: CanvasRenderingContext2D) => { if (idx < stack.length) { idx++; rebuild(g); } };

function begin(g: CanvasRenderingContext2D, p: P, erase: boolean, color: string) {
  if (idx < stack.length) stack = stack.slice(0, idx); // 다시 하기 갈래 자르기
  cur = { kind: erase ? 'erase' : 'pen', pts: [p], color };
  stack.push(cur); idx = stack.length;
  drawCmd(g, cur);
}
function drag(g: CanvasRenderingContext2D, p: P) {
  if (!cur) return;
  cur.pts.push(p);
  drawCmd(g, cur, cur.pts.length - 1);         // 새 토막만 덧그림
}
```

## 흔한 실수 · 확인 목록
- [ ] **지우개를 「캔버스에서 직접 지우기」로 하면 되돌릴 수 없다** — 지우개도 destination-out 획 명령으로 기록한다.
- [ ] **되돌린 뒤 새로 그었는데 다시 하기가 옛 획을 되살린다** — 새 명령을 넣기 전에 stack 을 idx 까지로 자른다.
- [ ] **그릴 때마다 전체를 다시 그리면 획이 많아질수록 느려진다** — 그리는 중엔 새 토막만, 전체 다시 그리기는 되돌리기 · 다시 하기 때만.

## 완성 기준 체크리스트
- [ ] ↶ 를 누르면 마지막 획부터 하나씩 사라지고, ↷ 로 다시 나타난다
- [ ] 지우개로 지운 것도 ↶ 로 되살아난다
- [ ] 되돌린 뒤 새로 그으면 기록 판의 뒤 명령들이 줄 그어지며 미끄러져 사라지고 「갈래가 잘려요」가 뜬다
- [ ] 기록 판에 지금 위치 화살표가 보인다

## 이 기술 정보
- id: `i317` · 분류: 2D · 화면 › 그리기 도구 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 되돌릴 때만 전체 다시 그리기 (명령 수 × 점 수). 수백 획까지는 순식간.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i317
- 쓰면 좋을 때: 모든 그리기 · 편집 도구 / 퍼즐에서 수 되돌리기 (명령 = 한 수)
- 쓰지 말 때: 명령이 수천 개로 길어지는 그림 — 매번 처음부터 다시 그리면 느려진다. 몇십 개마다 그림을 찍어 둔(스냅샷) 지점부터 다시 / 되돌릴 수 없어야 하는 게임 규칙 (주사위 등) — 기록에서 빼기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### undoRedo — `src/demos/demosDrawTools.ts:2037`
```ts
function undoRedo(env: Env): Tool {
  type Cmd = { kind: 'pen' | 'erase'; pts: P[]; color: string; n: number };
  const paper: Rect = { x: 14, y: 16, w: 278, h: 220 };
  const layer = mkLayer(env.R);
  let stack: Cmd[] = [];
  let idx = 0;
  let cur: Cmd | null = null;
  let erase = false;
  let counter = 0;
  let dropped: { c: Cmd; t: number; slot: number }[] = [];
  let flash: { which: 'u' | 'r'; t: number } | null = null;
  const COLORS = ['#1d2747', '#e2483a', '#2f6fe0', '#36a85a', '#f29a16'];
  const BU = pt(326, 40);
  const BR = pt(366, 40);
  function drawCmd(g: G, c: Cmd, from = 1): void {
    g.save();
    clipPaper(g, paper);
    if (c.kind === 'erase') {
      g.globalCompositeOperation = 'destination-out';
      g.lineWidth = 15;
      g.strokeStyle = '#000';
    } else {
      g.lineWidth = 3.2;
      g.strokeStyle = c.color;
    }
    g.beginPath();
    const s = Math.max(0, from - 1);
    g.moveTo(c.pts[s]!.x, c.pts[s]!.y);
    for (let i = s + 1; i < c.pts.length; i++) g.lineTo(c.pts[i]!.x, c.pts[i]!.y);
    if (c.pts.length === 1) g.lineTo(c.pts[0]!.x + 0.01, c.pts[0]!.y);
    g.stroke();
    g.restore();
  }
  function rebuild(): void {
    clearLayer(layer);
    for (let i = 0; i < idx; i++) drawCmd(layer.g, stack[i]!);
  }
  const undo = (): void => {
    if (idx > 0) {
      idx--;
      rebuild();
    }
    flash = { which: 'u', t: 0 };
  };
  const redo = (): void => {
    if (idx < stack.length) {
      idx++;
      rebuild();
    }
    flash = { which: 'r', t: 0 };
  };
  return {
    paper,
    cursor: () => (erase ? 'eraser' : 'pencil'),
    color: () => COLORS[counter % COLORS.length]!,
    begin(p) {
      if (dist(p, BU) < 14) return undo();
      if (dist(p, BR) < 14) return redo();
      if (p.x > paper.x + paper.w) return;
      // 새 명령 → 다시 하기 갈래 잘림
      if (idx < stack.length) {
        stack.slice(idx).forEach((c, k) => dropped.push({ c, t: 0, slot: idx + k }));
        stack = stack.slice(0, idx);
      }
      counter++;
      cur = { kind: erase ? 'erase' : 'pen', pts: [p], color: COLORS[(counter - 1) % COLORS.length]!, n: counter };
      stack.push(cur);
      idx = stack.length;
      drawCmd(layer.g, cur);
    },
    drag(p) {
      if (!cur) return;
      if (dist(cur.pts[cur.pts.length - 1]!, p) < 0.6) return;
      cur.pts.push(p);
      drawCmd(layer.g, cur, cur.pts.length - 1);
    },
    end() {
      cur = null;
    },
    step(dt) {
      for (const d of dropped) d.t += dt;
      dropped = dropped.filter((d) => d.t < 1.4);
      if (flash) {
        flash.t += dt;
        if (flash.t > 0.4) flash = null;
      }
    },
    clear() {
      stack = [];
      idx = 0;
      counter = 0;
      cur = null;
      erase = false;
      dropped = [];
      clearLayer(layer);
    },
    draw(g) {
      g.drawImage(layer.c, 0, 0, W, H);
      // 기록 판
      const px = 300;
      g.fillStyle = 'rgba(16,20,30,0.88)';
      rrect(g, px, 16, 92, 220, 6);
      g.fill();
      g.strokeStyle = 'rgba(255,255,255,0.08)';
      g.lineWidth = 0.8;
      g.stroke();
      const btn = (c: P, label: string, active: boolean, fl: boolean): void => {
        g.fillStyle = fl ? '#ffd166' : active ? '#2f6fe0' : 'rgba(255,255,255,0.12)';
        g.beginPath();
        g.arc(c.x, c.y, fl ? 12.5 : 11, 0, Math.PI * 2);
        g.fill();
        g.fillStyle = fl ? '#1d2747' : active ? '#fff' : 'rgba(255,255,255,0.35)';
        g.font = `800 13px ${F}`;
        g.textAlign = 'center';
        g.textBaseline = 'middle';
        g.fillText(label, c.x, c.y + 1);
      };
      btn(BU, '↶', idx > 0, flash?.which === 'u');
      btn(BR, '↷', idx < stack.length, flash?.which === 'r');
      g.fillStyle = 'rgba(255,255,255,0.55)';
      g.font = `700 7px ${F}`;
      g.fillText('되돌리기', BU.x, 59);
      g.fillText('다시', BR.x, 59);
      g.fillStyle = 'rgba(255,255,255,0.4)';
      g.fillRect(px + 8, 67, 76, 0.6);
      g.fillStyle = 'rgba(255,255,255,0.75)';
      g.font = `800 7.5px ${F}`;
      g.textAlign = 'left';
      g.fillText('명령 기록', px + 8, 76);
      const show = Math.max(0, stack.length - 8);
      const chip = (c: Cmd, slot: number, active: boolean, alpha: number, strike: boolean, slide = 0): void => {
        const y = 86 + (slot - show) * 17;
        if (y > 226) return;
        g.save();
        g.translate(slide, 0);
        g.globalAlpha = alpha;
        g.fillStyle = active ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.03)';
        rrect(g, px + 8, y, 76, 14, 4);
        g.fill();
        if (!active) {
          g.setLineDash([2, 2]);
          g.strokeStyle = 'rgba(255,255,255,0.3)';
          g.lineWidth = 0.6;
          g.stroke();
          g.setLineDash([]);
        }
        g.fillStyle = c.kind === 'erase' ? '#f4f1ec' : c.color;
        g.beginPath();
        g.arc(px + 16, y + 7, 3, 0, Math.PI * 2);
        g.fill();
        g.fillStyle = active ? '#fff' : 'rgba(255,255,255,0.45)';
        g.font = `700 7px ${F}`;
        g.textAlign = 'left';
        g.fillText(c.kind === 'erase' ? `⌫ 지우개 ${c.n}` : `✎ 선 ${c.n}`, px + 23, y + 7.5);
        if (strike) {
          g.strokeStyle = '#ff7a6b';
          g.lineWidth = 1;
          g.beginPath();
          g.moveTo(px + 10, y + 7);
          g.lineTo(px + 82, y + 7);
          g.stroke();
        }
        g.restore();
      };
      for (const d of dropped) chip(d.c, d.slot + 1, false, 1 - d.t / 1.4, true, d.t * 30);
      stack.forEach((c, i) => chip(c, i, i < idx, 1, false));
      if (dropped.length) {
        const d = dropped[0]!;
        g.globalAlpha = 1 - d.t / 1.4;
        pill(g, 150, 30, '새 획 → 「다시 하기」 갈래가 잘려요', 7.5, '#e2483a', '#fff');
        g.globalAlpha = 1;
      }
      // 지금 위치 화살표
      const ay = 86 + (idx - show) * 17 - 1.5;
      if (ay >= 84 && ay < 232) {
        g.fillStyle = '#ffd166';
        g.beginPath();
        g.moveTo(px + 2, ay - 3);
        g.lineTo(px + 7, ay);
        g.lineTo(px + 2, ay + 3);
        g.fill();
        g.fillRect(px + 8, ay - 0.4, 76, 0.8);
      }
    },
    script() {
      const face = (cx: number, cy: number): Act[] => [
        S(shaky(arcPts(cx, cy, 58, 56, -90, 275, 50), 1.4, 1), 300),
        S(arcPts(cx - 20, cy - 14, 4, 6, 0, 360, 14), 120),
        S(arcPts(cx + 20, cy - 14, 4, 6, 0, 360, 14), 120),
        S(arcPts(cx, cy + 4, 28, 22, 20, 160, 20), 220),
      ];
      return [
        ...face(150, 124),
        DO(() => (erase = true)),
        S([pt(112, 150), pt(150, 158), pt(190, 148), pt(180, 136), pt(140, 146), pt(118, 140)], 200),
        DO(() => (erase = false)),
        WAIT(0.5),
        TAP(BU.x, BU.y),
        WAIT(0.6),
        TAP(BU.x, BU.y),
        WAIT(0.6),
        TAP(BR.x, BR.y),
        WAIT(0.7),
        S([pt(150, 116), pt(146, 132), pt(154, 133)], 90),
        WAIT(2),
      ];
    },
    controls: [
      { type: 'button', label: '↶ 되돌리기', on: () => undo() },
      { type: 'button', label: '↷ 다시 하기', on: () => redo() },
      { type: 'toggle', label: '지우개', value: false, on: (v) => (erase = v) },
    ],
  };
}
```

### i317 견본 항목 — `src/demos/demosDrawTools.ts:3439`
```ts
  i317: dom('획 하나 = 명령 하나 — 되돌리기 · 다시 하기, 새로 그으면 다시 하기 갈래가 잘림', (e) => undoRedo(e))
```

## 관련 기술
- 먼저 알면 좋은 기술: [부드러운 붓 선 (곡선 보정 · 압력)](https://ai-techstudio.web.app/ai/t/i312.md) `i312`
- 다음에 해 볼 기술: [대칭 그리기 (만화경 · 거울)](https://ai-techstudio.web.app/ai/t/i318.md) `i318`
