# AI 꾸러미 — 나사 · 볼트 · 패널 이음선 — Panel lines via baked normal map
> 패널 이음선 · 환기구 · 각인은 높이 그림을 구운 법선 맵으로 새기고, 나사 58개는 InstancedMesh 로 한 번에 박아, 삼각형을 늘리지 않고 기계 디테일을 채운다.  
> 견본: https://ai-techstudio.web.app/#t/i470

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

## 주문서

### 만들어 줘: 나사 · 볼트 · 패널 이음선 — Panel lines via baked normal map

#### 1. 목표
SF 블래스터 옆면에 기계 디테일을 넣어 줘 — 패널선 · 환기구 · 각인은 법선 맵으로 새기고, 나사는 인스턴스로 수십 개 박기. 분위기는 SF 장난감.

#### 2. 핵심 기술 용어
- **Panel lines via baked normal map** — 높이 그림 → 법선 맵으로 새긴 패널선
- **Height-to-normal (Sobel filter)** — 소벨 필터로 높이 기울기 → 법선
- **Instanced screws / bolts (InstancedMesh)** — 나사 수십 개를 그리기 한 번에
- **Greeble / kitbash detail** — 작은 기계 부품 디테일

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

#### 4. 조건
- 패널선 · 나사 자리는 같은 치수 데이터(cm)에서 — 홈과 나사가 어긋나지 않게
- 높이 → 법선은 소벨 필터, 높이에 아주 작은 잡음(±0.8)을 섞어 기계로 찍은 티를 줄인다
- 읽을 캔버스는 willReadFrequently: true 로 (GPU 되읽기 멈춤 막기), 굽기는 나눠서
- 나사는 InstancedMesh — 개수만큼 Mesh 를 만들지 않는다
- 법선 세기(normalScale) 슬라이더로 깊이 조절, 와이어프레임으로 삼각형이 그대로임을 보여 준다

#### 5. 완성 기준 (이게 보이면 성공)
- 레이저가 지나간 자리부터 패널선 · 환기구 · 각인이 새겨진다
- 이어서 나사 58개가 왼쪽부터 하나씩 돌며 박히고, 화면에 「그리기 몇 번에 58개」가 보인다
- 「패널선 깊이」 0 ~ 2.5 로 홈이 얕아지거나 깊어진다
- 와이어프레임을 켜면 판 삼각형은 그대로(홈이 모양에 없음)다

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

## 원리
- 캔버스에 회색 128 바탕을 칠하고 홈은 어둡게(26 ~ 64), 솟은 테는 밝게(178 ~ 200) 그린다 — 패널선 · 환기 홈 · 각인 글자 · 나사 둘레 홈까지 같은 cm 좌표로.
- 높이 그림을 소벨 3×3 으로 미분해 법선 (−dx·k, −dy·k, 1) 을 정규화해 RGB 로 굽는다 (k = 세기).
- 이 법선 맵을 판 재질에 붙이면 삼각형은 그대로인데 빛을 받는 모양이 파인 홈처럼 보인다. normalScale 로 깊이를 조절한다.
- 나사는 머리(회전체) + 육각 홈(원기둥 6면, 정점 색으로 어둡게)을 한 모양으로 합치고, 양면 29 자리 × 2 = 58 개를 InstancedMesh 로.
- 박히는 연출은 인스턴스 행렬을 바꿔: 위로 2.4 떠서 1.5바퀴 돌며 내려와 살짝 튄다.

## 핵심 코드 — 높이 그림 → 법선 맵 (소벨) + 나사 인스턴스
(발췌: demos/lib/blaster.ts heightToNormal() · 나사 kit · demos/demosHard.ts demo470() 을 정리)
```ts
// 1) 높이 h[W*H] (캔버스 R 값: 128 바탕 · 홈 어둡게 · 테 밝게) → 법선 맵
function heightToNormal(h: Float32Array, W: number, H: number, k: number): THREE.DataTexture {
  const d = new Uint8Array(W * H * 4);
  const at = (x: number, y: number) => h[THREE.MathUtils.clamp(y, 0, H - 1) * W + THREE.MathUtils.clamp(x, 0, W - 1)]!;
  for (let y = 0; y < H; y++) {
    const o = (H - 1 - y) * W * 4; // 캔버스 위 행 = 텍스처 위
    for (let x = 0; x < W; x++) {
      const nx = -((at(x + 1, y - 1) + 2 * at(x + 1, y) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1))) * k * 0.25;
      const ny = -((at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1)) - (at(x - 1, y + 1) + 2 * at(x, y + 1) + at(x + 1, y + 1))) * k * 0.25;
      const l = 1 / Math.sqrt(nx * nx + ny * ny + 1), i = o + x * 4;
      d[i] = (nx * l * 0.5 + 0.5) * 255; d[i + 1] = (ny * l * 0.5 + 0.5) * 255; d[i + 2] = (l * 0.5 + 0.5) * 255; d[i + 3] = 255;
    }
  }
  const t = new THREE.DataTexture(d, W, H, THREE.RGBAFormat);
  t.minFilter = THREE.LinearMipmapLinearFilter; t.generateMipmaps = true; t.anisotropy = 8; t.needsUpdate = true;
  return t;
}
const plateMat = new THREE.MeshStandardMaterial({ color: 0x2e3239, metalness: 0.55, roughness: 0.4, normalMap: heightToNormal(h, W, H, 0.034) });
plateMat.normalScale.set(depth, depth); // 패널선 깊이

// 2) 나사 — 머리 + 육각 홈을 한 모양으로, 자리마다 인스턴스
const screws = new THREE.InstancedMesh(screwGeo, screwMat, SCREWS.length * 2);
const m = new THREE.Matrix4();
SCREWS.forEach(([x, y, hz], i) => [1, -1].forEach((s, j) => {
  m.makeRotationFromEuler(new THREE.Euler(0, s > 0 ? 0 : Math.PI, 0)).multiply(new THREE.Matrix4().makeRotationZ(Math.random() * Math.PI));
  m.setPosition(x, y, hz * s);
  screws.setMatrixAt(i * 2 + j, m);
}));
// 박히는 연출: base × 위로 (1−e)·2.4 + 튐 × Z 회전 (1−e)·1.5바퀴 를 setMatrixAt, instanceMatrix.needsUpdate = true
```

## 흔한 실수 · 확인 목록
- [ ] **캔버스 getImageData 를 그냥 부르면 GPU 되읽기로 멈칫한다** — 읽을 캔버스는 getContext("2d", { willReadFrequently: true }) 로 처음부터 CPU 쪽에 둔다.
- [ ] **캔버스 위아래와 텍스처 v 방향이 반대라 홈이 뒤집혀 보인다** — 법선 맵에 쓸 때 행을 (H − 1 − y) 로 뒤집는다.
- [ ] **나사를 Mesh 로 58개 만들면 그리기 호출이 58번** — 머리 + 홈을 mergeGeometries 로 한 모양으로 만들고 InstancedMesh 하나로.
- [ ] **법선 맵 홈은 옆에서 보면 평평한 게 들킨다** — 아주 비스듬한 각도에서 보이는 큰 홈은 실제로 깎는다. 패널선 · 각인처럼 얕은 것만 법선 맵으로.

## 완성 기준 체크리스트
- [ ] 레이저가 지나간 자리부터 패널선 · 환기구 · 각인이 새겨진다
- [ ] 이어서 나사 58개가 왼쪽부터 하나씩 돌며 박히고, 화면에 「그리기 몇 번에 58개」가 보인다
- [ ] 「패널선 깊이」 0 ~ 2.5 로 홈이 얕아지거나 깊어진다
- [ ] 와이어프레임을 켜면 판 삼각형은 그대로(홈이 모양에 없음)다

## 이 기술 정보
- id: `i470` · 분류: 기계 · 구조 › 구조 · 조립 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 실행 중엔 법선 맵 한 장 + 나사 그리기 1번(부품 판마다). 굽기는 시작 때 한 번, 줄 묶음마다 쉬며 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i470
- 쓰면 좋을 때: 판 부품이 밋밋해 「CG 티」가 날 때 / 나사 · 볼트 · 리벳처럼 같은 작은 부품이 수십 개일 때
- 쓰지 말 때: 아주 가까이서 옆으로 보는 홈 — 법선 맵은 실루엣이 안 바뀐다. 그땐 진짜로 깎기(CSG i451) / 부품 하나하나 따로 움직여야 할 때 — 인스턴스 대신 개별 메시

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### timed — `src/demos/demosHard.ts:1634`
```ts
function timed(id: string, f: () => Scene3D): () => Scene3D {
  return () => {
    const t0 = performance.now();
    const sc = f();
    STATS.make[id] = +(performance.now() - t0).toFixed(2);
    const r0 = sc.render!.bind(sc);
    sc.render = (r, w, h) => {
      const a = performance.now();
      r0(r, w, h);
      STATS.frame[id] = +lerp(STATS.frame[id] ?? 0, performance.now() - a, 0.05).toFixed(2);
      STATS.kitMs = +kitBuildMs().toFixed(1);
    };
    return sc;
  };
}
```

### i470 견본 항목 — `src/demos/demosHard.ts:1653`
```ts
  i470: { kind: '3d', caption: '레이저가 패널선 · 환기구를 새기고(법선 맵) 나사 58개가 톡톡 박힌다(인스턴스)', make: timed('i470', demo470) }
```

## 관련 기술
- 먼저 알면 좋은 기술: [옆모습 윤곽 돌출 + 모서리 깎기 (베벨)](https://ai-techstudio.web.app/ai/t/i468.md) `i468` · [그린 요철 · 노멀 맵](https://ai-techstudio.web.app/ai/t/u10.md) `u10`
- 다음에 해 볼 기술: [실사 PBR 텍스처 (무료 CC0 스캔 재질)](https://ai-techstudio.web.app/ai/t/i474.md) `i474` · [데칼 (글자 · 경고 표시 · 총알 자국)](https://ai-techstudio.web.app/ai/t/i479.md) `i479`
