# AI 꾸러미 — SDF 손 모델 굽기 (Surface Nets)
> 거리장으로 손 모양 · 뼈대 무게 — 일꾼(Worker)에서  
> 견본: https://ai-techstudio.web.app/#t/u30

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

## 주문서

### 만들어 줘: SDF 손 모델 굽기 (Surface Nets)

#### 1. 목표
3D 장면의 물체에 「SDF 손 모델 굽기 (Surface Nets)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 거리장으로 손 모양 · 뼈대 무게 — 일꾼(Worker)에서

#### 2. 핵심 기술 용어
- **SDF**
- **Surface Nets**
- **Worker**

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

#### 4. 조건
- 한 프레임 16ms 안 (폰 포함) — 무거운 계산은 처음에 한 번 또는 여러 프레임에 나눠서
- 새 재질 · 셰이더는 첫 화면 전에 미리 컴파일 (renderer.compileAsync)
- 화면 가장자리 흐림 · 비네트로 얼버무리지 않기
- 외부 라이브러리 추가 없이 three.js 와 three/examples 만

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「거리장으로 손 모양 · 뼈대 무게 — 일꾼(Worker)에서」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 거리장으로 손 모양 · 뼈대 무게 — 일꾼(Worker)에서


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「거리장으로 손 모양 · 뼈대 무게 — 일꾼(Worker)에서」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `u30` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u30

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### surfaceNets — `src/demos/demosStructure.ts:491`
```ts
function surfaceNets(f: (p: V3) => number, lo: V3, hi: V3, N: number): { geo: THREE.BufferGeometry; inside: number[] } {
  const span = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]);
  const hs = span / N;
  const nx = Math.ceil((hi[0] - lo[0]) / hs) + 1;
  const ny = Math.ceil((hi[1] - lo[1]) / hs) + 1;
  const nz = Math.ceil((hi[2] - lo[2]) / hs) + 1;
  const val = new Float32Array(nx * ny * nz);
  const id = (i: number, j: number, k: number): number => i + nx * (j + ny * k);
  const inside: number[] = [];
  for (let k = 0; k < nz; k++)
    for (let j = 0; j < ny; j++)
      for (let i = 0; i < nx; i++) {
        const p: V3 = [lo[0] + i * hs, lo[1] + j * hs, lo[2] + k * hs];
        const v = f(p);
        val[id(i, j, k)] = v;
        if (v < 0) inside.push(p[0], p[1], p[2]);
      }
  const cellV = new Int32Array((nx - 1) * (ny - 1) * (nz - 1)).fill(-1);
  const cid = (i: number, j: number, k: number): number => i + (nx - 1) * (j + (ny - 1) * k);
  const pos: number[] = [];
  const corners: V3[] = [];
  for (let c = 0; c < 8; c++) corners.push([c & 1, (c >> 1) & 1, (c >> 2) & 1]);
  const edges: [number, number][] = [
    [0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7],
  ];
  for (let k = 0; k < nz - 1; k++)
    for (let j = 0; j < ny - 1; j++)
      for (let i = 0; i < nx - 1; i++) {
        const cv: number[] = [];
        let mask = 0;
        for (let c = 0; c < 8; c++) {
          const o = corners[c]!;
          const v = val[id(i + o[0], j + o[1], k + o[2])]!;
          cv.push(v);
          if (v < 0) mask |= 1 << c;
        }
        if (mask === 0 || mask === 255) continue;
        let sx = 0;
        let sy = 0;
        let sz = 0;
        let n = 0;
        for (const [a, b] of edges) {
          const va = cv[a]!;
          const vb = cv[b]!;
          if (va < 0 === vb < 0) continue;
          const t = va / (va - vb);
          const A = corners[a]!;
          const B = corners[b]!;
          sx += A[0] + (B[0] - A[0]) * t;
          sy += A[1] + (B[1] - A[1]) * t;
          sz += A[2] + (B[2] - A[2]) * t;
          n++;
        }
        cellV[cid(i, j, k)] = pos.length / 3;
        pos.push(lo[0] + (i + sx / n) * hs, lo[1] + (j + sy / n) * hs, lo[2] + (k + sz / n) * hs);
      }
  const idx: number[] = [];
  const grad = (p: V3): V3 => {
    const e = 0.01;
    const g: V3 = [f([p[0] + e, p[1], p[2]]) - f([p[0] - e, p[1], p[2]]), f([p[0], p[1] + e, p[2]]) - f([p[0], p[1] - e, p[2]]), f([p[0], p[1], p[2] + e]) - f([p[0], p[1], p[2] - e])];
    const l = Math.hypot(...g) || 1;
    return [g[0] / l, g[1] / l, g[2] / l];
  };
  const quad = (a: number, b: number, c: number, d: number): void => {
    if (a < 0 || b < 0 || c < 0 || d < 0) return;
    const P = (q: number): V3 => [pos[q * 3]!, pos[q * 3 + 1]!, pos[q * 3 + 2]!];
    const pa = P(a);
    const pb = P(b);
    const pc = P(c);
    const u = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
    const v = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
    const nrm = [u[1]! * v[2]! - u[2]! * v[1]!, u[2]! * v[0]! - u[0]! * v[2]!, u[0]! * v[1]! - u[1]! * v[0]!];
    const gg = grad([(pa[0] + pc[0]) / 2, (pa[1] + pc[1]) / 2, (pa[2] + pc[2]) / 2]);
    if (nrm[0]! * gg[0] + nrm[1]! * gg[1] + nrm[2]! * gg[2] >= 0) idx.push(a, b, c, a, c, d);
    else idx.push(a, c, b, a, d, c);
  };
  for (let k = 0; k < nz - 1; k++)
    for (let j = 0; j < ny - 1; j++)
      for (let i = 0; i < nx - 1; i++) {
        const v0 = val[id(i, j, k)]! < 0;
        if (j > 0 && k > 0 && v0 !== val[id(i + 1, j, k)]! < 0) quad(cellV[cid(i, j - 1, k - 1)]!, cellV[cid(i, j, k - 1)]!, cellV[cid(i, j, k)]!, cellV[cid(i, j - 1, k)]!);
        if (i > 0 && k > 0 && v0 !== val[id(i, j + 1, k)]! < 0) quad(cellV[cid(i - 1, j, k - 1)]!, cellV[cid(i, j, k - 1)]!, cellV[cid(i, j, k)]!, cellV[cid(i - 1, j, k)]!);
        if (i > 0 && j > 0 && v0 !== val[id(i, j, k + 1)]! < 0) quad(cellV[cid(i - 1, j - 1, k)]!, cellV[cid(i, j - 1, k)]!, cellV[cid(i, j, k)]!, cellV[cid(i - 1, j, k)]!);
      }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
  geo.setIndex(idx);
  const nor: number[] = [];
  const col: number[] = [];
  const tmp = new THREE.Color();
  for (let q = 0; q < pos.length; q += 3) {
    const p: V3 = [pos[q]!, pos[q + 1]!, pos[q + 2]!];
    nor.push(...grad(p));
    // 뼈 무게 — 가까운 부위일수록 크게 (부드럽게 섞음)
    const d = handParts(p);
    let sum = 0;
    tmp.setRGB(0, 0, 0);
    d.forEach((dd, b) => {
      const w = Math.exp(-Math.max(0, dd) * 28);
      sum += w;
      tmp.r += BONE_COL[b === 0 ? 5 : b - 1]!.r * w;
      tmp.g += BONE_COL[b === 0 ? 5 : b - 1]!.g * w;
      tmp.b += BONE_COL[b === 0 ? 5 : b - 1]!.b * w;
    });
    col.push(tmp.r / sum, tmp.g / sum, tmp.b / sum);
  }
  geo.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3));
  geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
  return { geo, inside };
}
```

### u30 견본 항목 — `src/demos/demosStructure.ts:1194`
```ts
  u30: {
    kind: '3d',
    caption: '거리장(점이 손 안쪽인지)을 격자로 찍고 → Surface Nets 로 매끈한 껍질 · 색은 뼈마다 무게 (격자가 촘촘해질수록 매끈)',
    make() {
      const scene = darkScene();
      const RES = [8, 12, 18, 26, 36];
      const lo: V3 = [-0.75, -0.72, -0.42];
      const hi: V3 = [1.15, 1.55, 0.45];
      const built = RES.map((N) => surfaceNets(handSdf, lo, hi, N));
      const root = new THREE.Group();
      scene.add(root);
      const weightMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.55, transparent: true, side: THREE.DoubleSide });
      const skinMat = new THREE.MeshStandardMaterial({ color: 0xffc9a8, roughness: 0.6, transparent: true, side: THREE.DoubleSide });
      const mesh = new THREE.Mesh(built[0]!.geo, weightMat);
      const ptsGeo = built.map((b) => new THREE.BufferGeometry().setAttribute('position', new THREE.Float32BufferAttribute(b.inside, 3)));
      const ptsMat = new THREE.PointsMaterial({ color: 0x8fe3ff, size: 0.05, transparent: true, depthWrite: false });
      const pts = new THREE.Points(ptsGeo[0], ptsMat);
      const frame = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2])), new THREE.LineBasicMaterial({ color: 0x8fa8ff, transparent: true, opacity: 0.5 }));
      frame.position.set((lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2);
      root.add(mesh, pts, frame);
      root.position.set(-0.2, -0.4, 0);
      const cam = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
      const hud = new Hud();
      let weights = true;
      let state = '';
      return {
        scene,
        camera: cam,
        update(t) {
          const P = 2.6;
          const i = Math.floor(t / P) % RES.length;
          const ph = (t % P) / P;
          mesh.geometry = built[i]!.geo;
          pts.geometry = ptsGeo[i]!;
          mesh.material = weights ? weightMat : skinMat;
          const show = smooth((ph - 0.3) / 0.25);
          (mesh.material as THREE.MeshStandardMaterial).opacity = show;
          mesh.visible = show > 0.01;
          ptsMat.opacity = 1 - show * 0.85;
          ptsMat.size = ((hi[1] - lo[1]) / RES[i]!) * 0.75;
          root.rotation.y = Math.sin(t * 0.6) * 0.6;
          state = `${i}|${ph < 0.3 ? 0 : ph < 0.55 ? 1 : 2}|${Math.floor(Math.min(1, ph / 0.55) * 10)}`;
        },
        resize(w, h) {
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(1.45 / tn, 1.45 / (tn * (w / h)));
          cam.position.set(0, 0.3, d);
          cam.lookAt(0, 0.05, 0);
        },
        render(r, w, h) {
          r.render(scene, cam);
          hud.draw(r, w, h, `${w}x${h}${state}`, (g) => {
            const [si, sp, sb] = state.split('|').map(Number) as [number, number, number];
            const s = fsz(w, h, 0.9);
            const N = RES[si]!;
            pill(g, s * 0.6, s * 0.6, `격자 ${N}칸 · 꼭짓점 ${built[si]!.geo.getAttribute('position').count}개`, s);
            const bw = w * 0.34;
            const bx = w - bw - s * 0.8;
            const by = s * 0.75;
            g.fillStyle = 'rgba(255,255,255,0.18)';
            g.beginPath();
            g.roundRect(bx, by, bw, s * 1.3, s * 0.65);
            g.fill();
            g.fillStyle = sp === 2 ? '#7be38f' : '#ffc94a';
            g.beginPath();
            g.roundRect(bx, by, Math.max(s * 1.3, bw * (sb / 10)), s * 1.3, s * 0.65);
            g.fill();
            g.fillStyle = '#1a2040';
            g.font = `700 ${s * 0.8}px ${FONT}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.fillText(sp === 0 ? '일꾼: 거리 재는 중' : sp === 1 ? '일꾼: 껍질 굽는 중' : '완성!', bx + bw / 2, by + s * 0.68);
          });
        },
        controls: [{ type: 'toggle', label: '뼈 무게 색 보기', value: true, on: (v) => (weights = v) }],
        dispose() {
          built.forEach((b) => b.geo.dispose());
          ptsGeo.forEach((g) => g.dispose());
          mesh.geometry = new THREE.BufferGeometry();
          pts.geometry = new THREE.BufferGeometry();
          skinMat.dispose();
          weightMat.dispose();
          disposeScene(scene);
          hud.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
