# AI 꾸러미 — 접히는 전개도 (경첩 회전) — Hinged net folding (hierarchical transforms)
> 전개도의 면마다 「부모 면과 맞닿은 변」을 경첩 축으로 정해 행렬을 부모에서 자식으로 곱해 내려가며 접어, 전개도가 입체로 접히고 안 되는 전개도는 겹친 면이 빨갛게 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i362

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

## 주문서

### 만들어 줘: 접히는 전개도 (경첩 회전) — Hinged net folding (hierarchical transforms)

#### 1. 목표
정육면체 · 삼각기둥 · 사각뿔 전개도를 경첩처럼 접히는 전개도로 만들어 줘 — 면마다 부모 면과 맞닿은 변을 축으로 차례로 접히고, 접히지 않는 전개도는 겹치는 면을 빨갛게. 분위기는 파스텔 색종이 면.

#### 2. 핵심 기술 용어
- **Hinged net folding (hierarchical transforms)** — 경첩 접기 — 부모 면 기준으로 차례로 돌리기
- **Rotation about an arbitrary axis (Matrix4.makeRotationAxis)** — 맞닿은 변을 축으로 돌리기
- **Translate–rotate–translate back** — 축 위 점으로 옮겨 돌리고 되돌리기
- **Overlap check (centroid + normal)** — 다 접었을 때 두 면이 같은 자리 · 같은 방향이면 겹침

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

#### 4. 조건
- 면은 matrixAutoUpdate = false 로 두고 계산한 행렬을 직접 넣는다
- 부모 → 자식 순서로 계산 (자식이 부모 행렬을 쓴다)
- 접는 방향은 면 가운데가 축의 어느 쪽인지로 자동으로 (손으로 부호를 넣지 않는다)
- 겹치는 면은 polygonOffset 으로 깜빡임(z-fighting)을 막는다

#### 5. 완성 기준 (이게 보이면 성공)
- 정육면체 전개도 ① · ②(계단)가 접혀 정육면체가 된다
- 안 되는 전개도(같은 쪽 두 장 · 2×3 직사각형)는 다 접으면 겹친 면이 빨갛게 깜빡이고 빈 자리가 점선으로 보인다
- 삼각기둥 · 사각뿔도 맞는 각으로 접혀 닫힌다
- 「접은 정도」 슬라이더로 아무 데서나 멈춰 볼 수 있다

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

## 원리
- 전개도 = 평면 위 면 목록. 면마다 부모 면 번호와 접는 각(정육면체 90°, 삼각기둥 옆면 120°, 사각뿔 옆면 180° − acos(1/√3))을 둔다.
- 부모와 함께 가진 두 꼭짓점이 경첩 축. 면 가운데가 축의 어느 쪽인지로 접는 방향(부호)을 정한다.
- 면 i 의 행렬 = 부모 행렬 × 옮기기(축 위 점) × 축 회전(부호 × 각 × 진행) × 되돌리기 — 부모가 접히면 자식도 따라간다.
- 깊이(뿌리에서 몇 번째)마다 0.38초씩 늦춰 접으면 바깥 면이 차례로 일어선다.
- 다 접은 상태에서 두 면의 가운데가 0.03 안 · 법선이 거의 같으면 겹침 → 그 면들을 빨갛게, 빈 자리는 점선 유령 면으로 보여 준다.

## 핵심 코드 — 경첩 행렬로 전개도 접기
(발췌: demos/demosMathA.ts i362 의 경첩 준비 · fold() 를 정리)
```ts
import * as THREE from 'three';

type P2 = [number, number];
interface NetFace { pts: P2[]; parent: number; angle: number }   // 뿌리 면은 parent = -1

const tmpA = new THREE.Matrix4(), tmpB = new THREE.Matrix4(), tmpC = new THREE.Matrix4();
const centroid = (ps: P2[]): P2 => [ps.reduce((s, p) => s + p[0], 0) / ps.length, ps.reduce((s, p) => s + p[1], 0) / ps.length];

function hinges(faces: NetFace[]) {
  return faces.map((f) => {
    if (f.parent < 0) return { P: new THREE.Vector3(), axis: new THREE.Vector3(1, 0, 0), sign: 1 };
    const par = faces[f.parent];
    const sh = f.pts.filter((p) => par.pts.some((q) => Math.abs(q[0] - p[0]) < 1e-6 && Math.abs(q[1] - p[1]) < 1e-6));
    const a = new THREE.Vector3(sh[0][0], 0, sh[0][1]);
    const b = new THREE.Vector3(sh[1][0], 0, sh[1][1]);
    const axis = b.clone().sub(a).normalize();                 // 맞닿은 변 = 경첩
    const c = centroid(f.pts);
    const cc = new THREE.Vector3(c[0], 0, c[1]).sub(a).applyAxisAngle(axis, 0.1);
    return { P: a, axis, sign: cc.y > 0 ? 1 : -1 };            // 살짝 돌려 위로 가면 + 방향
  });
}

/** ks[i]: 면 i 의 접힌 정도 0 ~ 1 · M[i]: 결과 행렬 (부모가 앞에 있어야 한다) */
function fold(faces: NetFace[], H: ReturnType<typeof hinges>, ks: number[], M: THREE.Matrix4[]) {
  faces.forEach((f, i) => {
    if (f.parent < 0) { M[i].identity(); return; }
    const p = H[i].P;
    tmpA.makeTranslation(p.x, p.y, p.z);
    tmpB.makeRotationAxis(H[i].axis, H[i].sign * f.angle * ks[i]);
    tmpC.makeTranslation(-p.x, -p.y, -p.z);
    M[i].copy(M[f.parent]).multiply(tmpA).multiply(tmpB).multiply(tmpC);   // 부모를 따라간다
  });
}
// 그릴 때: mesh.matrixAutoUpdate = false; mesh.matrix.copy(M[i]);
```

## 흔한 실수 · 확인 목록
- [ ] **면마다 따로 돌리면 바깥 면이 부모를 안 따라가 허공에 뜬다** — 자식 행렬 = 부모 행렬 × 경첩 회전 — 부모부터 차례로 계산한다.
- [ ] **접는 방향 부호를 손으로 넣으면 면마다 틀린다** — 면 가운데를 축으로 조금 돌려 위로 가는지 보고 부호를 자동으로 정한다.
- [ ] **다 접었을 때 겹친 면이 지글지글 깜빡인다** — 면마다 다른 polygonOffset 을 줘 깊이를 조금씩 비킨다.
- [ ] **사각뿔 옆면을 90° 로 접으면 꼭짓점이 안 만난다** — 정사각뿔 옆면 각은 180° − acos(1/√3) — 입체마다 실제 이면각을 쓴다.

## 완성 기준 체크리스트
- [ ] 정육면체 전개도 ① · ②(계단)가 접혀 정육면체가 된다
- [ ] 안 되는 전개도(같은 쪽 두 장 · 2×3 직사각형)는 다 접으면 겹친 면이 빨갛게 깜빡이고 빈 자리가 점선으로 보인다
- [ ] 삼각기둥 · 사각뿔도 맞는 각으로 접혀 닫힌다
- [ ] 「접은 정도」 슬라이더로 아무 데서나 멈춰 볼 수 있다

## 이 기술 정보
- id: `i362` · 분류: 물리 · 수학 원리 › 수학 시각화 기법 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 면 6장 · 행렬 곱 몇 번 — 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i362
- 쓰면 좋을 때: 전개도 · 입체도형 체험 / 상자가 열리고 접히는 연출
- 쓰지 말 때: 곡면(원기둥 옆면) — 한 면이 휘어야 해서 경첩 접기로는 안 된다. 정점을 직접 휘게 한다 / 면이 수백 장 — 경첩 사슬이 깊어지면 계산 · 정렬이 복잡하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### fmt — `src/demos/demosMathA.ts:33`
```ts
function fmt(v: number, d = 1): string {
  const m = Math.pow(10, d);
  let s = (Math.round(v * m) / m).toFixed(d);
  if (s.includes('.')) s = s.replace(/\.?0+$/, '');
  if (s === '-0') s = '0';
  return s.replace('-', '−');
}
```

### i362 견본 항목 — `src/demos/demosMathA.ts:777`
```ts
  i362: {
    kind: '3d',
    caption: '전개도가 경첩처럼 접혀 정육면체 · 삼각기둥 · 사각뿔이 됨 — 안 되는 전개도는 겹치는 면이 빨갛게',
    make(T) {
      const scene = new T.Scene();
      const sun = studio(scene, { shadowBox: 4 });
      void sun;
      const cam = new T.PerspectiveCamera(40, 1.6, 0.1, 100);
      const hud = new Hud();
      const UP = new T.Vector3(0, 1, 0);
      type Built = {
        def: NetDef;
        group: THREE.Group;
        meshes: THREE.Mesh[];
        mats: THREE.MeshStandardMaterial[];
        P: THREE.Vector3[];
        axis: THREE.Vector3[];
        sign: number[];
        depth: number[];
        overlap: boolean[];
        center: THREE.Vector3;
        solidC: THREE.Vector3;
        bad: boolean;
        M: THREE.Matrix4[];
        ghosts: THREE.Object3D[];
        nOver: number;
      };
      const tmpA = new T.Matrix4();
      const tmpB = new T.Matrix4();
      const tmpC = new T.Matrix4();
      const built: Built[] = NETS.map((def, ni) => {
        const group = new T.Group();
        const meshes: THREE.Mesh[] = [];
        const mats: THREE.MeshStandardMaterial[] = [];
        const P: THREE.Vector3[] = [];
        const axis: THREE.Vector3[] = [];
        const sign: number[] = [];
        const depth: number[] = [];
        let mnx = 1e9;
        let mxx = -1e9;
        let mnz = 1e9;
        let mxz = -1e9;
        def.faces.forEach((f, i) => {
          const pos: number[] = [];
          for (let k = 1; k < f.pts.length - 1; k++) {
            for (const p of [f.pts[0]!, f.pts[k]!, f.pts[k + 1]!]) pos.push(p[0], 0, p[1]);
          }
          for (const p of f.pts) {
            mnx = Math.min(mnx, p[0]);
            mxx = Math.max(mxx, p[0]);
            mnz = Math.min(mnz, p[1]);
            mxz = Math.max(mxz, p[1]);
          }
          const geo = new T.BufferGeometry();
          geo.setAttribute('position', new T.Float32BufferAttribute(pos, 3));
          geo.computeVertexNormals();
          const mat = new T.MeshStandardMaterial({
            color: FACE_COL[i % FACE_COL.length]!,
            roughness: 0.55,
            emissive: 0x000000,
            side: T.DoubleSide,
            polygonOffset: true,
            polygonOffsetFactor: -(1 + i * 0.6),
            polygonOffsetUnits: -(1 + i),
          });
          const mesh = new T.Mesh(geo, mat);
          mesh.castShadow = true;
          mesh.matrixAutoUpdate = false;
          const lp: number[] = [];
          for (let k = 0; k < f.pts.length; k++) {
            const a = f.pts[k]!;
            const b = f.pts[(k + 1) % f.pts.length]!;
            lp.push(a[0], 0, a[1], b[0], 0, b[1]);
          }
          const lg = new T.BufferGeometry();
          lg.setAttribute('position', new T.Float32BufferAttribute(lp, 3));
          const edges = new T.LineSegments(lg, new T.LineBasicMaterial({ color: 0x24345e }));
          mesh.add(edges);
          group.add(mesh);
          meshes.push(mesh);
          mats.push(mat);
          // 경첩
          if (f.parent < 0) {
            P.push(new T.Vector3());
            axis.push(new T.Vector3(1, 0, 0));
            sign.push(1);
            depth.push(0);
          } else {
            const par = def.faces[f.parent]!;
            const sh = f.pts.filter((p) => par.pts.some((q) => Math.abs(q[0] - p[0]) < 1e-6 && Math.abs(q[1] - p[1]) < 1e-6));
            const a = new T.Vector3(sh[0]![0], 0, sh[0]![1]);
            const b = new T.Vector3(sh[1]![0], 0, sh[1]![1]);
            const ax = b.clone().sub(a).normalize();
            const c = centroid(f.pts);
            const cc = new T.Vector3(c[0], 0, c[1]).sub(a).applyAxisAngle(ax, 0.1);
            P.push(a);
            axis.push(ax);
            sign.push(cc.y > 0 ? 1 : -1);
            depth.push(depth[f.parent]! + 1);
          }
        });
        const M = def.faces.map(() => new T.Matrix4());
        const b: Built = {
          def,
          group,
          meshes,
          mats,
          P,
          axis,
          sign,
          depth,
          overlap: def.faces.map(() => false),
          center: new T.Vector3((mnx + mxx) / 2, 0, (mnz + mxz) / 2),
          solidC: new T.Vector3(),
          bad: false,
          M,
          ghosts: [],
          nOver: 0,
        };
        // 다 접었을 때 — 겹침 계산
        fold(b, def.faces.map(() => 1));
        const cs = def.faces.map((f, i) => {
          const c = centroid(f.pts);
          return new T.Vector3(c[0], 0, c[1]).applyMatrix4(M[i]!);
        });
        const ns = def.faces.map((_f, i) => UP.clone().transformDirection(M[i]!));
        for (let i = 0; i < cs.length; i++)
          for (let j = i + 1; j < cs.length; j++)
            if (cs[i]!.distanceTo(cs[j]!) < 0.03 && Math.abs(ns[i]!.dot(ns[j]!)) > 0.99) {
              b.overlap[i] = true;
              b.overlap[j] = true;
              b.bad = true;
            }
        b.nOver = b.overlap.filter((v) => v).length;
        if (b.bad && def.solid === '정육면체') {
          const c0 = centroid(def.faces[0]!.pts);
          const cc = new T.Vector3(c0[0], 0.5, c0[1]);
          for (const d of [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, 0, 1], [0, 0, -1]] as [number, number, number][]) {
            const dv = new T.Vector3(...d);
            const at = cc.clone().addScaledVector(dv, 0.5);
            if (cs.some((c) => c.distanceTo(at) < 0.03)) continue;
            const gm = new T.Mesh(
              new T.PlaneGeometry(0.96, 0.96),
              new T.MeshBasicMaterial({ color: 0xff4d4d, transparent: true, opacity: 0.18, side: T.DoubleSide, depthWrite: false }),
            );
            gm.quaternion.setFromUnitVectors(new T.Vector3(0, 0, 1), dv);
            gm.position.copy(at);
            const ol = new T.LineSegments(new T.EdgesGeometry(new T.PlaneGeometry(0.96, 0.96)), new T.LineDashedMaterial({ color: 0xff7070, dashSize: 0.08, gapSize: 0.06 }));
            ol.computeLineDistances();
            gm.add(ol);
            gm.visible = false;
            group.add(gm);
            b.ghosts.push(gm);
          }
        }
        for (const c of cs) b.solidC.add(c);
        b.solidC.multiplyScalar(1 / cs.length);
        group.position.set(-b.center.x, 0.025, -b.center.z);
        group.visible = ni === 0;
        scene.add(group);
        return b;
      });
      function fold(b: Built, ks: number[]): void {
        b.def.faces.forEach((f, i) => {
          const m = b.M[i]!;
          if (f.parent < 0) {
            m.identity();
            return;
          }
          const p = b.P[i]!;
          tmpA.makeTranslation(p.x, p.y, p.z);
          tmpB.makeRotationAxis(b.axis[i]!, b.sign[i]! * f.angle * ks[i]!);
          tmpC.makeTranslation(-p.x, -p.y, -p.z);
          m.copy(b.M[f.parent]!).multiply(tmpA).multiply(tmpB).multiply(tmpC);
        });
      }
      let pick = 0;
      let auto = true;
      let amount = 100;
      let t0 = 0;
      let req = false;
      const PER = 5.6;
      let cur = 0;
      let rw = 280;
      let rh = 175;
      const target = new T.Vector3();
      return {
        scene,
        camera: cam,
        controls: [
          { type: 'range', label: '전개도 (0 자동 · 1 ~ 6)', min: 0, max: 6, step: 1, value: 0, on: (v) => ((pick = v), (req = true)) },
          { type: 'toggle', label: '저절로 접기', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '접은 정도 % (저절로 끄면)', min: 0, max: 100, step: 1, value: 100, on: (v) => (amount = v) },
          { type: 'button', label: '다시 접기', on: () => (req = true) },
        ],
        resize(w, h) {
          rw = w;
          rh = h;
        },
        update(t) {
          if (req) {
            t0 = t;
            req = false;
          }
          const tt = Math.max(0, t - t0);
          const ni = pick > 0 ? pick - 1 : Math.floor(tt / PER) % NETS.length;
          const lt = tt % PER;
          if (ni !== cur) {
            built[cur]!.group.visible = false;
            cur = ni;
          }
          const b = built[cur]!;
          b.group.visible = true;
          const back = auto ? easeIO(seg(lt, 4.65, 5.45)) : 0;
          const ks = b.def.faces.map((_f, i) => {
            if (!auto) return amount / 100;
            const d = b.depth[i]!;
            return easeIO(seg(lt, 0.45 + (d - 1) * 0.38, 1.55 + (d - 1) * 0.38)) * (1 - back);
          });
          fold(b, ks);
          b.meshes.forEach((m, i) => m.matrix.copy(b.M[i]!));
          const all = Math.min(...ks.slice(1));
          const red = b.bad && all > 0.985;
          const pulse = 0.5 + 0.5 * Math.sin(t * 9);
          for (const gm of b.ghosts) gm.visible = red;
          b.mats.forEach((m, i) => {
            if (red && b.overlap[i]) {
              m.color.setHex(0xff4d4d);
              m.emissive.setHex(0x661010).multiplyScalar(pulse);
            } else {
              m.color.setHex(FACE_COL[i % FACE_COL.length]!);
              m.emissive.setHex(FACE_COL[i % FACE_COL.length]!).multiplyScalar(0.16);
            }
          });
          const avg = ks.slice(1).reduce((s, v) => s + v, 0) / Math.max(1, ks.length - 1);
          target.set(0, 0, 0).lerp(b.solidC.clone().sub(b.center).setY(b.solidC.y * 0.9), avg);
          const az = 0.7 + t * 0.16;
          const el = 0.72;
          const r = lerp(7.4, 4.9, avg);
          cam.position.set(target.x + Math.sin(az) * Math.cos(el) * r, target.y + Math.sin(el) * r, target.z + Math.cos(az) * Math.cos(el) * r);
          cam.lookAt(target);
          const deg = (b.def.faces[1]!.angle * 180) / Math.PI;
          const degs = [...new Set(b.def.faces.slice(1).map((f) => fmt((f.angle * 180) / Math.PI, 1)))].join('° · ');
          void deg;
          const status = all > 0.985 ? (b.bad ? 'bad' : 'ok') : 'fold';
          hud.draw(rw, rh, `${cur}|${status}`, (g, _w, h, u) => {
            hudTitle(g, u, b.def.name, `접는 각 ${degs}° · 면 ${b.def.faces.length}개`);
            if (status === 'ok') pill(g, `✓ ${b.def.solid} 완성`, 14 * u, h - 18 * u, 11 * u, 'rgba(90,169,255,0.92)', '#08101f', 'left');
            else if (status === 'bad')
              pill(g, `✕ 겹치는 면 ${b.nOver}장 · 빈 곳 ${b.ghosts.length}곳 — 정육면체가 안 돼요`, 14 * u, h - 18 * u, 10.5 * u, 'rgba(255,77,77,0.95)', '#fff', 'left');
          });
        },
        render(r) {
          render3d(r, scene, cam, hud, { shadow: true });
        },
        dispose() {
          disposeAll(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [설계도 · 모눈 도면 (블루프린트)](https://ai-techstudio.web.app/ai/t/i339.md) `i339`
- 다음에 해 볼 기술: [분해도 (exploded view)](https://ai-techstudio.web.app/ai/t/i36.md) `i36`
