# AI 꾸러미 — 사슬 IK (FABRIK — 꼬리 · 촉수 · 목) — FABRIK (Forward And Backward Reaching IK)
> 관절 사슬을 끝 → 뿌리, 뿌리 → 끝으로 번갈아 당기는 FABRIK 로 촉수 끝이 목표를 따라가게 하고, 관절마다 굽는 각도를 제한한다.  
> 견본: https://ai-techstudio.web.app/#t/i460

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

## 주문서

### 만들어 줘: 사슬 IK (FABRIK — 꼬리 · 촉수 · 목) — FABRIK (Forward And Backward Reaching IK)

#### 1. 목표
화분에서 자란 촉수 (반딧불 따라가기)을(를) 사슬 IK(FABRIK)로 움직여 줘 — 끝이 목표를 따라가고, 관절마다 굽는 각도는 제한. 분위기는 귀엽고 말랑한.

#### 2. 핵심 기술 용어
- **FABRIK (Forward And Backward Reaching IK)** — 앞뒤로 번갈아 당기는 사슬 IK
- **Joint angle constraint (cone limit)** — 관절마다 굽는 각도 제한
- **Chain IK → bone rotation** — 관절 위치를 뼈 회전으로 바꾸기
- **CCD IK** — 다른 사슬 IK 방법 (three.js CCDIKSolver)

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

#### 4. 조건
- 마디 길이는 늘 같게 — 당길 때 방향만 바꾸고 길이는 l 로 다시 놓는다
- 앞으로 단계에서 앞 마디 방향과의 각을 제한 (첫 마디는 위쪽 기준)
- 되풀이는 끝이 목표에 닿으면 멈추고, 최대 횟수(8)로 묶는다
- 닿지 못하는 목표면 끝 → 목표 사이에 선을 그려 보여 준다
- 관절 수 · 각도 제한 슬라이더로 사슬 성격을 바꿀 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 반딧불이 날아다니면 촉수 끝이 따라가며 몸 전체가 부드럽게 휜다
- 「관절마다 굽는 각도 제한」을 5° 로 줄이면 뻣뻣해져 닿지 못하고 선이 보이며, 90° 면 마구 꺾인다
- 「관절 수」 3 ~ 24 로 바꾸면 각진 팔 ↔ 매끈한 촉수가 된다
- 마우스로 목표를 직접 움직일 수 있다

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

## 원리
- 관절 위치 p0(뿌리) ~ pn(끝), 마디 길이 l 은 고정.
- 뒤로: 끝 pn 을 목표에 두고, 끝에서 뿌리 쪽으로 각 관절을 앞 관절에서 l 거리에 다시 놓는다.
- 앞으로: 뿌리 p0 를 제자리에 두고, 뿌리에서 끝 쪽으로 같은 일을 하면서 앞 마디와의 각이 제한(32°)을 넘으면 제한까지 돌린다.
- 이 두 번을 끝이 목표에 닿을(1mm) 때까지 최대 8번 되풀이한다.
- 관절 위치가 정해지면 뼈마다 「부모 기준 위쪽 → 다음 관절 방향」 회전을 setFromUnitVectors 로 넣어 SkinnedMesh 를 굽힌다.

## 핵심 코드 — FABRIK 한 번 + 각도 제한 + 뼈 회전
(발췌: demos/demosRigA.ts i460 solve() 를 정리)
```ts
const UP = new THREE.Vector3(0, 1, 0);
const dir = new THREE.Vector3(), prev = new THREE.Vector3(), ax = new THREE.Vector3();
function solve(p: THREE.Vector3[], base: THREE.Vector3, target: THREE.Vector3, l: number, maxA: number) {
  const n = p.length - 1;
  for (let it = 0; it < 8; it++) {
    // 뒤로: 끝을 목표에, 뿌리 쪽으로 l 씩
    p[n]!.copy(target);
    for (let i = n - 1; i >= 0; i--) {
      dir.subVectors(p[i]!, p[i + 1]!).normalize();
      p[i]!.copy(p[i + 1]!).addScaledVector(dir, l);
    }
    // 앞으로: 뿌리를 제자리에, 끝 쪽으로 l 씩 + 각도 제한
    p[0]!.copy(base);
    for (let i = 1; i <= n; i++) {
      dir.subVectors(p[i]!, p[i - 1]!).normalize();
      if (i === 1) prev.copy(UP); else prev.subVectors(p[i - 1]!, p[i - 2]!).normalize();
      const ang = Math.acos(THREE.MathUtils.clamp(prev.dot(dir), -1, 1));
      if (ang > maxA) {
        ax.crossVectors(prev, dir);
        if (ax.lengthSq() < 1e-10) ax.set(1, 0, 0);
        dir.copy(prev).applyAxisAngle(ax.normalize(), maxA); // 제한까지만 굽기
      }
      p[i]!.copy(p[i - 1]!).addScaledVector(dir, l);
    }
    if (p[n]!.distanceTo(target) < 1e-3) break;
  }
}
// 관절 위치 → 뼈 회전 (부모 회전을 빼고)
const qW = new THREE.Quaternion(), qInv = new THREE.Quaternion();
for (let i = 0; i < n; i++) {
  dir.subVectors(p[i + 1]!, p[i]!).normalize().applyQuaternion(qInv.copy(qW).invert());
  bones[i]!.quaternion.setFromUnitVectors(UP, dir);
  qW.multiply(bones[i]!.quaternion);
}
```

## 흔한 실수 · 확인 목록
- [ ] **각도 제한을 「뒤로」 단계에도 걸면 사슬이 목표 쪽으로 못 간다** — 견본처럼 뿌리에서 나가는 「앞으로」 단계에서만 제한한다.
- [ ] **두 마디가 정확히 일직선이거나 반대면 회전축이 0 이 된다** — 외적 길이가 0 에 가까우면 아무 축(1, 0, 0)을 쓴다.
- [ ] **관절 위치를 그대로 뼈 회전으로 넣으면 부모 회전이 겹쳐 꼬인다** — 누적 회전 qW 의 역으로 방향을 부모 기준으로 바꾼 뒤 setFromUnitVectors.

## 완성 기준 체크리스트
- [ ] 반딧불이 날아다니면 촉수 끝이 따라가며 몸 전체가 부드럽게 휜다
- [ ] 「관절마다 굽는 각도 제한」을 5° 로 줄이면 뻣뻣해져 닿지 못하고 선이 보이며, 90° 면 마구 꺾인다
- [ ] 「관절 수」 3 ~ 24 로 바꾸면 각진 팔 ↔ 매끈한 촉수가 된다
- [ ] 마우스로 목표를 직접 움직일 수 있다

## 이 기술 정보
- id: `i460` · 분류: 3D 모델 · 캐릭터 › 캐릭터 · 리깅 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 관절 12개 × 8번 되풀이 — 0.05ms 수준. 사슬 수십 개도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i460
- 쓰면 좋을 때: 관절이 많은 꼬리 · 촉수 · 목 · 뱀 · 로봇 팔 / 끝이 목표를 따라가야 할 때 (먹이 쫓기 · 가리키기)
- 쓰지 말 때: 관절이 둘뿐인 팔 · 다리 — 대신 두 뼈 IK(i459) 가 정확하고 빠르다 / 물리로 출렁여야 하는 꼬리 — 대신 흔들리는 뼈(i462)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i460 견본 항목 — `src/demos/demosRigA.ts:1711`
```ts
  i460: {
    kind: '3d',
    caption: '관절 여러 개를 끝 → 뿌리 → 끝으로 번갈아 당겨요 (FABRIK) — 촉수가 반딧불을 따라 휘고, 관절마다 굽는 각도는 제한',
    make() {
      const st = makeStage([0.4, 1.75, 4.7], [0, 1.2, 0], 34, 1.9, '#24365a', '#0f1626');
      const bt = blobTex();
      const gt = glowTex();
      // 화분
      const pts: THREE.Vector2[] = [];
      for (let i = 0; i <= 14; i++) {
        const u = i / 14;
        pts.push(new THREE.Vector2(0.22 + 0.1 * u + (u > 0.82 ? 0.035 : 0), u * 0.4));
      }
      pts.push(new THREE.Vector2(0.3, 0.4), new THREE.Vector2(0.27, 0.34), new THREE.Vector2(0, 0.34));
      const gPot = new THREE.LatheGeometry(pts, 48);
      const mPot = new THREE.MeshStandardMaterial({ color: 0xc8643c, roughness: 0.75 });
      const pot = new THREE.Mesh(gPot, mPot);
      pot.castShadow = true;
      pot.receiveShadow = true;
      st.scene.add(pot);
      const gSoil = new THREE.CircleGeometry(0.28, 32);
      const mSoil = new THREE.MeshStandardMaterial({ color: 0x4a3026, roughness: 1 });
      const soil = new THREE.Mesh(gSoil, mSoil);
      soil.rotation.x = -Math.PI / 2;
      soil.position.y = 0.36;
      st.scene.add(soil);
      const psh = blob(bt, 1.1);
      st.scene.add(psh);
      // 반딧불 목표
      const glowM = new THREE.SpriteMaterial({ map: gt, color: 0xfff09a, blending: THREE.AdditiveBlending, depthWrite: false, transparent: true });
      const glow = new THREE.Sprite(glowM);
      glow.scale.setScalar(0.55);
      const core = new THREE.Mesh(new THREE.SphereGeometry(0.045, 16, 12), new THREE.MeshBasicMaterial({ color: 0xfffbe0 }));
      const light = new THREE.PointLight(0xffe28a, 1.6, 2.5, 2);
      const fly = new THREE.Group();
      fly.add(glow, core, light);
      st.scene.add(fly);
      const gCyl = new THREE.CylinderGeometry(1, 1, 1, 8);
      gCyl.translate(0, 0.5, 0);
      const mLine = new THREE.MeshBasicMaterial({ color: 0xfff09a, transparent: true, opacity: 0.4, depthTest: false });
      const reachLine = new THREE.Mesh(gCyl, mLine);
      reachLine.renderOrder = 25;
      st.scene.add(reachLine);
      const BASE = new THREE.Vector3(0, 0.34, 0);
      const L = 2.0;
      let N = 12;
      let maxA = (32 * Math.PI) / 180;
      let showJ = true;
      // 촉수 (관절 수가 바뀌면 다시 짓기)
      const mT = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.4, clearcoat: 0.55, clearcoatRoughness: 0.35, sheen: 0.4, sheenColor: new THREE.Color(0xffc8f0) });
      const gSuck = new THREE.TorusGeometry(1, 0.42, 8, 18);
      const mSuck = new THREE.MeshStandardMaterial({ color: 0xf6b5d2, roughness: 0.5 });
      const gJ = new THREE.SphereGeometry(0.03, 12, 8);
      const mJ = new THREE.MeshBasicMaterial({ color: 0xffffff, depthTest: false, transparent: true });
      const mSeg = new THREE.MeshBasicMaterial({ color: 0x5ad1ff, depthTest: false, transparent: true, opacity: 0.85 });
      let tent: { mesh: THREE.SkinnedMesh; bones: THREE.Bone[]; joints: THREE.Mesh[]; segs: THREE.Mesh[]; p: THREE.Vector3[]; geo: THREE.BufferGeometry; group: THREE.Group } | null = null;
      const radius = (s: number): number => 0.13 * Math.pow(1 - s, 0.8) + 0.02;
      const build = (n: number): void => {
        if (tent) {
          st.scene.remove(tent.group);
          tent.geo.dispose();
          tent.mesh.skeleton.dispose();
        }
        const l = L / n;
        const RS = 24;
        const HS = n * 6;
        const pos: number[] = [];
        const nor: number[] = [];
        const col: number[] = [];
        const si: number[] = [];
        const sw: number[] = [];
        const idx: number[] = [];
        const cTop = new THREE.Color(0x8e5ce0);
        const cTip = new THREE.Color(0xc79bff);
        const cUnder = new THREE.Color(0xf7b2d4);
        const cc = new THREE.Color();
        const pushW = (y: number): void => {
          const s = y / l;
          const k = Math.floor(s);
          const fr = s - k;
          let a: number;
          let b: number;
          let wa: number;
          if (fr < 0.5) {
            a = k - 1;
            b = k;
            wa = 0.5 - fr;
          } else {
            a = k;
            b = k + 1;
            wa = 1.5 - fr;
          }
          a = clamp(a, 0, n - 1);
          b = clamp(b, 0, n - 1);
          si.push(a, b, 0, 0);
          sw.push(wa, 1 - wa, 0, 0);
        };
        for (let j = 0; j <= HS; j++) {
          const s = j / HS;
          const y = s * L;
          const r = radius(s);
          for (let i = 0; i <= RS; i++) {
            const th = (i / RS) * Math.PI * 2;
            const cx = Math.sin(th);
            const cz = Math.cos(th);
            pos.push(cx * r, y, cz * r);
            nor.push(cx, 0.08, cz);
            const under = sstep(0.2, 0.85, cz);
            cc.copy(cTop).lerp(cTip, s * 0.8).lerp(cUnder, under);
            col.push(cc.r, cc.g, cc.b);
            pushW(Math.min(y, L - 1e-4));
          }
        }
        for (let j = 0; j < HS; j++)
          for (let i = 0; i < RS; i++) {
            const a = j * (RS + 1) + i;
            const b = a + RS + 1;
            idx.push(a, b, a + 1, b, b + 1, a + 1);
          }
        // 끝 막기
        const tipI = pos.length / 3;
        pos.push(0, L + 0.02, 0);
        nor.push(0, 1, 0);
        col.push(cTip.r, cTip.g, cTip.b);
        pushW(L - 1e-4);
        for (let i = 0; i < RS; i++) idx.push(HS * (RS + 1) + i, tipI, HS * (RS + 1) + i + 1);
        const geo = new THREE.BufferGeometry();
        geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
        geo.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
        geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
        geo.setAttribute('skinIndex', new THREE.Uint16BufferAttribute(si, 4));
        geo.setAttribute('skinWeight', new THREE.Float32BufferAttribute(sw, 4));
        geo.setIndex(idx);
        geo.computeVertexNormals();
        const mesh = new THREE.SkinnedMesh(geo, mT);
        mesh.castShadow = true;
        mesh.frustumCulled = false;
        const bones: THREE.Bone[] = [];
        for (let i = 0; i < n; i++) {
          const b = new THREE.Bone();
          b.position.set(0, i === 0 ? 0 : l, 0);
          if (i > 0) bones[i - 1]!.add(b);
          bones.push(b);
          // 빨판 둘 (뼈에 붙음 — 아랫면 +z)
          for (let k = 0; k < 2; k++) {
            const s = (i + 0.3 + k * 0.45) / n;
            if (s > 0.93) continue;
            const r = radius(s);
            const su = new THREE.Mesh(gSuck, mSuck);
            su.position.set(0, (0.3 + k * 0.45) * l, r * 0.93);
            su.scale.setScalar(r * 0.32);
            b.add(su);
          }
        }
        mesh.add(bones[0]!);
        mesh.updateMatrixWorld(true);
        mesh.bind(new THREE.Skeleton(bones));
        mesh.position.copy(BASE);
        const group = new THREE.Group();
        group.add(mesh);
        const joints: THREE.Mesh[] = [];
        const segs: THREE.Mesh[] = [];
        const p: THREE.Vector3[] = [];
        for (let i = 0; i <= n; i++) {
          // 처음엔 S 자로 말린 모양
          const s = i / n;
          p.push(new THREE.Vector3(Math.sin(s * 3) * 0.25 * s, BASE.y + s * L * 0.9, 0));
          const j = new THREE.Mesh(gJ, mJ);
          j.renderOrder = 26;
          group.add(j);
          joints.push(j);
          if (i < n) {
            const sg = new THREE.Mesh(gCyl, mSeg);
            sg.renderOrder = 25;
            group.add(sg);
            segs.push(sg);
          }
        }
        st.scene.add(group);
        tent = { mesh, bones, joints, segs, p, geo, group };
      };
      build(N);
      const target = new THREE.Vector3(0.5, 1.6, 0.3);
      const want = new THREE.Vector3();
      const ptr = new BigPointer();
      const prev = new THREE.Vector3();
      const dir = new THREE.Vector3();
      const ax = new THREE.Vector3();
      const qW = new THREE.Quaternion();
      const solve = (): void => {
        const T = tent!;
        const p = T.p;
        const n = p.length - 1;
        const l = L / n;
        for (let it = 0; it < 8; it++) {
          // 뒤로: 끝을 목표에 두고 뿌리 쪽으로
          p[n]!.copy(target);
          for (let i = n - 1; i >= 0; i--) {
            dir.subVectors(p[i]!, p[i + 1]!).normalize();
            p[i]!.copy(p[i + 1]!).addScaledVector(dir, l);
          }
          // 앞으로: 뿌리를 제자리에 두고 끝 쪽으로 + 각도 제한
          p[0]!.copy(BASE);
          for (let i = 1; i <= n; i++) {
            dir.subVectors(p[i]!, p[i - 1]!).normalize();
            if (i === 1) prev.copy(Y);
            else prev.subVectors(p[i - 1]!, p[i - 2]!).normalize();
            const ang = Math.acos(clamp(prev.dot(dir), -1, 1));
            if (ang > maxA) {
              ax.crossVectors(prev, dir);
              if (ax.lengthSq() < 1e-10) ax.set(1, 0, 0);
              ax.normalize();
              dir.copy(prev).applyAxisAngle(ax, maxA);
            }
            p[i]!.copy(p[i - 1]!).addScaledVector(dir, l);
          }
          if (p[n]!.distanceTo(target) < 1e-3) break;
        }
        // 관절 위치 → 뼈 회전
        qW.identity();
        for (let i = 0; i < n; i++) {
          dir.subVectors(p[i + 1]!, p[i]!).normalize().applyQuaternion(_q1.copy(qW).invert());
          T.bones[i]!.quaternion.setFromUnitVectors(Y, dir);
          qW.multiply(T.bones[i]!.quaternion);
        }
        for (let i = 0; i <= n; i++) {
          T.joints[i]!.position.copy(p[i]!);
          T.joints[i]!.visible = showJ;
          if (i < n) {
            const sg = T.segs[i]!;
            sg.visible = showJ;
            const d = _vd.subVectors(p[i + 1]!, p[i]!);
            sg.position.copy(p[i]!);
            sg.quaternion.setFromUnitVectors(Y, d.clone().normalize());
            sg.scale.set(0.008, d.length(), 0.008);
          }
        }
        const gap = p[n]!.distanceTo(target);
        reachLine.visible = gap > 0.03;
        if (reachLine.visible) {
          const d = _vd.subVectors(target, p[n]!);
          reachLine.position.copy(p[n]!);
          reachLine.quaternion.setFromUnitVectors(Y, d.clone().normalize());
          reachLine.scale.set(0.006, d.length(), 0.006);
        }
      };
      return {
        scene: st.scene,
        camera: st.cam,
        update(t, dt) {
          dt = Math.min(dt, 0.05);
          if (ptr.live() && ptr.hit(st.cam, new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0, 0.2), want)) {
            want.y = Math.max(want.y, 0.25);
          } else {
            want.set(1.3 * Math.sin(t * 0.8), 1.35 + 0.95 * Math.sin(t * 1.27 + 1), 0.55 * Math.sin(t * 0.6));
          }
          target.lerp(want, damp(dt, 0.18));
          fly.position.copy(target);
          glowM.opacity = 0.75 + 0.25 * Math.sin(t * 9);
          solve();
        },
        render(r, w, h) {
          ptr.attach(w, h);
          stageRender(st, r);
        },
        controls: [
          {
            type: 'range',
            label: '관절 수',
            min: 3,
            max: 24,
            step: 1,
            value: N,
            on: (v) => {
              if (v !== N) {
                N = v;
                build(N);
              }
            },
          },
          { type: 'range', label: '관절마다 굽는 각도 제한 (°)', min: 5, max: 90, step: 1, value: 32, on: (v) => (maxA = (v * Math.PI) / 180) },
          { type: 'toggle', label: '관절 · 뼈 보기', value: true, on: (v) => (showJ = v) },
        ] as Control[],
        dispose() {
          ptr.dispose();
          if (tent) {
            tent.geo.dispose();
            tent.mesh.skeleton.dispose();
          }
          [gPot, gSoil, gCyl, gSuck, gJ].forEach((g) => g.dispose());
          [mPot, mSoil, mT, mSuck, mJ, mSeg, mLine, glowM].forEach((m) => m.dispose());
          disposeMesh(core);
          disposeMesh(psh);
          bt.dispose();
          gt.dispose();
          light.dispose();
          st.own.forEach((o) => o.dispose());
        },
      } satisfies Scene3D;
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [두 뼈 IK (팔 · 다리가 목표를 잡기)](https://ai-techstudio.web.app/ai/t/i459.md) `i459`
- 다음에 해 볼 기술: [흔들리는 뼈 (귀 · 머리카락 · 꼬리 2차 움직임)](https://ai-techstudio.web.app/ai/t/i462.md) `i462` · [발 땅에 붙이기 (울퉁불퉁한 지형)](https://ai-techstudio.web.app/ai/t/i465.md) `i465`
- 참고 문서: [three.js 예제 — webgl_animation_skinning_ik (CCDIKSolver)](https://threejs.org/examples/#webgl_animation_skinning_ik)
