# AI 꾸러미 — Manim식 수식 변환 — Equation transform (Manim TransformMatchingTex style)
> 식의 기호마다 고유 id 를 주고 단계마다 자리를 재어, 같은 기호는 새 자리로 날아가고 사라질 기호는 녹고 합쳐질 기호는 한 점으로 모이게 한다 (Manim 의 TransformMatchingTex 방식).  
> 견본: https://ai-techstudio.web.app/#t/i117

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

## 주문서

### 만들어 줘: Manim식 수식 변환 — Equation transform (Manim TransformMatchingTex style)

#### 1. 목표
일차방정식 풀이 2x + 3 = 11 → x = 4을(를) Manim 식 수식 변환으로 보여 줘 — 같은 기호는 남아서 새 자리로 날아가고, 나머지는 녹아 사라지거나 하나로 합쳐지게. 분위기는 짙은 남색 칠판 + 빛나는 기호.

#### 2. 핵심 기술 용어
- **Equation transform (Manim TransformMatchingTex style)** — 같은 기호끼리 짝지어 옮기는 수식 변환
- **Token matching by id** — 기호에 고유 이름을 붙여 단계 사이 짝짓기
- **Measured layout + position tween** — 숨은 줄에 놓아 자리를 재고 그 사이를 트윈

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 2D · 브라우저 — PC · 폰 모두

#### 4. 조건
- 기호에 고유 id — 같은 「3」 이라도 역할이 다르면 다른 id (t3 · n3)
- 자리는 숨은 줄에 실제로 놓아 잰다 (글자 폭을 짐작하지 않기). 상자 크기 · 글꼴 로딩이 바뀌면 다시 잰다
- 멀리 가는 기호는 위로 둥글게 — 다른 기호를 뚫고 지나가지 않게
- 머무름(1.5초) · 바뀜(1.15초) 번갈아, 바뀌는 중인 기호는 금빛으로 강조
- 단계 설명(「+3 이 = 을 넘어가면 −3」)을 함께 바꿔 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 「+3」 이 = 를 뛰어넘어 오른쪽으로 가며 「−3」 이 된다
- 「11 − 3」 이 한 점으로 모이며 「8」 이 톡 나타난다
- 마지막에 x = 4 만 남고, 다시 처음 식으로 이음새 없이 돌아간다
- 움직이는 기호만 금빛으로 빛나 어디로 가는지 눈으로 따라갈 수 있다

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

## 원리
- 기호마다 고유 id (c2 · x · plus · t3 · eq · n11 …). 단계는 id 목록 + 설명 + 합쳐짐(into: {n11: n8, minus: n8, t3: n8}).
- 각 단계를 숨은 flex 줄에 실제로 놓아 기호 가운데 x 를 잰다 (글꼴이 바뀌면 다시 잼).
- 두 단계에 다 있는 기호: x 를 lerp. 멀리 가면(글자 0.9개 넘게) 위로 둥글게 뛰어 넘는다 (−sin πk).
- 앞 단계에만 있는 기호: 합쳐질 곳이 있으면 그 자리로 날아가며 작아지고 사라짐, 없으면 아래로 녹아 사라짐.
- 뒤 단계에만 있는 기호: 합쳐진 결과는 마지막에 톡(easeOutBack), 나머지는 위에서 내려오며 나타남.

## 핵심 코드 — 기호 짝짓기 — 남음 · 합쳐짐 · 사라짐 · 나타남
(발췌: demos/demosMotionB.ts i117 의 update 를 정리)
```ts
// A, B = 지금 · 다음 단계의 { id: 가운데 x }, k = 0~1 (easeIO), fs = 글자 크기
const into = ST[j].into ?? {};                 // 예: { n11: 'n8', minus: 'n8', t3: 'n8' }
const targets = new Set(Object.values(into));
for (const id of Object.keys(TK)) {
  const a = A[id], b = B[id];
  let x = 0, y = 0, o = 0, s = 1;
  if (a !== undefined && b !== undefined) {           // 둘 다 있음: 날아감
    x = lerp(a, b, k);
    y = Math.abs(b - a) > fs * 0.9 ? -Math.sin(k * Math.PI) * fs * 0.75 : 0; // 멀면 위로 둥글게
    o = 1;
  } else if (a !== undefined) {                        // 앞에만: 합쳐지거나 녹음
    const tg = into[id];
    if (tg && B[tg] !== undefined) {
      x = lerp(a, B[tg], easeIO(k * 1.15));
      y = -Math.sin(Math.min(1, k * 1.15) * Math.PI) * fs * 0.35;
      o = 1 - ease((k - 0.55) / 0.4); s = 1 - 0.25 * k;
    } else { x = a; y = k * fs * 0.4; o = 1 - ease(k / 0.7); s = 1 - 0.3 * k; }
  } else if (b !== undefined) {                        // 뒤에만: 나타남
    x = b;
    if (targets.has(id)) { o = ease((k - 0.6) / 0.35); s = 0.5 + 0.5 * easeOutBack((k - 0.6) / 0.4); } // 합친 결과는 톡
    else { o = ease((k - 0.3) / 0.7); y = -(1 - k) * fs * 0.4; }
  }
  const e = el[id];
  e.style.opacity = o.toFixed(3);
  e.style.transform = 'translate(calc(-50% + ' + x.toFixed(1) + 'px), calc(-50% + ' + y.toFixed(1) + 'px)) scale(' + s.toFixed(3) + ')';
}
```

## 흔한 실수 · 확인 목록
- [ ] **글자 폭을 짐작해 자리를 정하면 글꼴에 따라 겹친다** — 숨은 flex 줄에 실제로 놓고 offsetLeft 로 잰다. 글꼴이 늦게 로딩되면 document.fonts.check 로 알아채 다시 잰다.
- [ ] **같은 숫자를 같은 id 로 두면 엉뚱한 기호가 날아간다** — 역할마다 다른 id (상수 3 = t3, 결과 4 = n4).
- [ ] **모든 기호를 직선으로 옮기면 서로 뚫고 지나간다** — 멀리 가는 기호만 위로 둥글게 뛰어넘게 한다.
- [ ] **2x 처럼 붙여 쓰는 곳이 띄어쓰기 간격으로 벌어진다** — 견본은 c2 다음 x 에 음수 여백(.tight)을 주고 잰다.

## 완성 기준 체크리스트
- [ ] 「+3」 이 = 를 뛰어넘어 오른쪽으로 가며 「−3」 이 된다
- [ ] 「11 − 3」 이 한 점으로 모이며 「8」 이 톡 나타난다
- [ ] 마지막에 x = 4 만 남고, 다시 처음 식으로 이음새 없이 돌아간다
- [ ] 움직이는 기호만 금빛으로 빛나 어디로 가는지 눈으로 따라갈 수 있다

## 이 기술 정보
- id: `i117` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 기호 10여 개의 transform · opacity 만 바꾼다. 자리 재기는 크기 · 글꼴이 바뀔 때만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i117
- 쓰면 좋을 때: 식이 한 단계씩 바뀌는 풀이 과정을 보여 줄 때 — 「무엇이 어디로 갔나」 가 핵심 / 이항 · 약분처럼 기호가 이동하는 규칙을 설명할 때
- 쓰지 말 때: 분수 · 지수가 겹친 복잡한 식 — 한 줄 배치로는 어렵다. 대신 KaTeX 로 그린 뒤 요소마다 짝짓기 / 단계가 아주 많을 때 — 핵심 단계만 고른다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i117 견본 항목 — `src/demos/demosMotionB.ts:1241`
```ts
  i117: {
    kind: 'dom',
    caption: '2x + 3 = 11 → x = 4 — 같은 기호는 남아서 새 자리로 날아가고, 나머지는 녹아 사라지거나 하나로 합쳐져요',
    make(box) {
      const TK: Record<string, [string, string]> = {
        c2: ['2', 'n'],
        x: ['x', 'v'],
        plus: ['+', 'o'],
        t3: ['3', 'n'],
        eq: ['=', 'e'],
        n11: ['11', 'n'],
        minus: ['−', 'o'],
        n8: ['8', 'n'],
        div: ['÷', 'o'],
        n4: ['4', 'a'],
      };
      const ST: { ids: string[]; note: string; into?: Record<string, string> }[] = [
        { ids: ['c2', 'x', 'plus', 't3', 'eq', 'n11'], note: '처음 식' },
        { ids: ['c2', 'x', 'eq', 'n11', 'minus', 't3'], note: '+3 이 = 을 넘어가면 −3' },
        { ids: ['c2', 'x', 'eq', 'n8'], note: '11 − 3 = 8', into: { n11: 'n8', minus: 'n8', t3: 'n8' } },
        { ids: ['x', 'eq', 'n8', 'div', 'c2'], note: '곱한 2 가 넘어가면 ÷ 2' },
        { ids: ['x', 'eq', 'n4'], note: '8 ÷ 2 = 4 — 답!', into: { n8: 'n4', div: 'n4', c2: 'n4' } },
      ];
      const root = domRoot(
        box,
        'mb-eq',
        `.mb-eq{background:radial-gradient(circle at 50% 38%,#2a3a80,#0b0f2a 75%)}
         .mb-eq .tk,.mb-eq .meas{font-family:${TF};font-size:15cqmin;line-height:1}
         .mb-eq .meas .tight{margin-left:-.2em}
         .mb-eq .tk{position:absolute;left:50%;top:43%;white-space:pre;will-change:transform,opacity}
         .mb-eq .meas{position:absolute;left:0;top:0;display:flex;gap:.3em;visibility:hidden;white-space:pre}
         .mb-eq .n{color:#fff}.mb-eq .v{color:#ff7ab6}.mb-eq .o{color:#7cf0ff}.mb-eq .e{color:#c9d3ff}.mb-eq .a{color:#ffd23f}
         .mb-eq .tk.mv{color:#ffe27a;text-shadow:0 0 2cqmin rgba(255,226,122,.8)}
         .mb-eq .nt{position:absolute;left:0;right:0;top:70%;text-align:center;font-weight:800;font-size:6cqmin;color:#ffe9a8}
         .mb-eq .dots{position:absolute;left:50%;bottom:7%;transform:translateX(-50%);display:flex;gap:1.8cqmin}
         .mb-eq .dots i{width:2.4cqmin;height:2.4cqmin;border-radius:50%;background:rgba(255,255,255,.25);transition:background .3s}
         .mb-eq .dots i.on{background:#ffd23f}
         .mb-eq .ln{position:absolute;left:12%;right:12%;top:61%;height:.5cqmin;border-radius:1cqmin;background:rgba(255,255,255,.12)}`,
        `<div class="meas"></div><div class="ln"></div>${Object.entries(TK)
          .map(([id, [s, c]]) => `<span class="tk ${c}" data-id="${id}">${s}</span>`)
          .join('')}<div class="nt"></div><div class="dots">${ST.map(() => '<i></i>').join('')}</div>`,
      );
      const meas = q(root, '.meas');
      const note = q(root, '.nt');
      const dots = qa(root, '.dots i');
      const el: Record<string, HTMLElement> = {};
      for (const s of qa(root, '.tk')) el[s.dataset['id']!] = s;
      let pos: Record<string, number>[] = [];
      let fs = 20;
      let key = '';
      const measure = (): void => {
        fs = parseFloat(getComputedStyle(meas).fontSize) || 20;
        pos = ST.map((st) => {
          meas.innerHTML = st.ids.map((id, n) => `<span${id === 'x' && st.ids[n - 1] === 'c2' ? ' class="tight"' : ''}>${TK[id]![0]}</span>`).join('');
          const W = meas.offsetWidth;
          const out: Record<string, number> = {};
          Array.from(meas.children).forEach((c, i) => {
            const e = c as HTMLElement;
            out[st.ids[i]!] = e.offsetLeft + e.offsetWidth / 2 - W / 2;
          });
          return out;
        });
      };
      let speed = 1;
      let tt = 0;
      const H = 1.5;
      const M = 1.15;
      const SEG = H + M;
      return {
        update(_t, dt) {
          const k2 = `${root.clientWidth}x${root.clientHeight}x${document.fonts.check('20px "Black Han Sans"')}`;
          if (k2 !== key) {
            key = k2;
            measure();
          }
          tt += dt * speed;
          const tc = fract(tt / (SEG * ST.length)) * SEG * ST.length;
          const i = Math.floor(tc / SEG);
          const j = (i + 1) % ST.length;
          const local = tc - i * SEG;
          const k = local < H ? 0 : easeIO((local - H) / M);
          const A = pos[i]!;
          const B = pos[j]!;
          const into = ST[j]!.into ?? {};
          const targets = new Set(Object.values(into));
          for (const id of Object.keys(TK)) {
            const e = el[id]!;
            const a = A[id];
            const b = B[id];
            let x = 0;
            let y = 0;
            let o = 0;
            let s = 1;
            let mv = false;
            if (a !== undefined && b !== undefined) {
              x = lerp(a, b, k);
              const far = Math.abs(b - a) > fs * 0.9;
              y = far ? -Math.sin(k * Math.PI) * fs * 0.75 : 0;
              o = 1;
              mv = Math.abs(b - a) > 2 && k > 0 && k < 1;
            } else if (a !== undefined) {
              const tg = into[id];
              if (tg && B[tg] !== undefined) {
                x = lerp(a, B[tg]!, easeIO(k * 1.15));
                y = -Math.sin(Math.min(1, k * 1.15) * Math.PI) * fs * 0.35;
                o = 1 - ease((k - 0.55) / 0.4);
                s = 1 - 0.25 * k;
                mv = k > 0;
              } else {
                x = a;
                y = k * fs * 0.4;
                o = 1 - ease(k / 0.7);
                s = 1 - 0.3 * k;
              }
            } else if (b !== undefined) {
              x = b;
              if (targets.has(id)) {
                o = ease((k - 0.6) / 0.35);
                s = 0.5 + 0.5 * easeOutBack((k - 0.6) / 0.4);
              } else {
                o = ease((k - 0.3) / 0.7);
                y = -(1 - k) * fs * 0.4;
              }
            }
            e.style.opacity = o.toFixed(3);
            e.style.transform = `translate(calc(-50% + ${x.toFixed(1)}px), calc(-50% + ${y.toFixed(1)}px)) scale(${s.toFixed(3)})`;
            e.classList.toggle('mv', mv);
          }
          const ni = k < 0.5 ? i : j;
          if (note.textContent !== ST[ni]!.note) note.textContent = ST[ni]!.note;
          note.style.opacity = String(Math.abs(1 - k * 2));
          dots.forEach((d, n) => d.classList.toggle('on', n === ni));
        },
        controls: [{ type: 'range', label: '빠르기', min: 0.3, max: 2.5, step: 0.1, value: 1, on: (v) => (speed = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [도형 모핑 (모양 바꾸기)](https://ai-techstudio.web.app/ai/t/i96.md) `i96`
- 다음에 해 볼 기술: [타임라인 연출 + 프레임 효과음](https://ai-techstudio.web.app/ai/t/i127.md) `i127`
- 참고 문서: [Manim Community — 공식 문서](https://docs.manim.community/)
