# AI 꾸러미 — 3D 를 따라가는 이름표 · 지시선 — 3D-anchored labels (Vector3.project)
> 3D 부품의 한 점을 매 프레임 화면 좌표로 투영해 HTML 이름표와 SVG 지시선을 붙이고, 그 점이 다른 면에 가려지면 이름표를 흐리게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i38

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

## 주문서

### 만들어 줘: 3D 를 따라가는 이름표 · 지시선 — 3D-anchored labels (Vector3.project)

#### 1. 목표
로켓 · 기계 부품 이름에 3D 를 따라가는 이름표와 지시선을 붙여 줘 — 물체가 돌아도 이름표가 그 부품을 가리키고, 부품이 뒤로 가면 흐려지게. 분위기는 흰 알약 이름표 · 노란 지시선.

#### 2. 핵심 기술 용어
- **3D-anchored labels (Vector3.project)** — 3D 점을 화면 좌표로 바꿔 이름표 붙이기
- **Leader lines (SVG overlay)** — 지시선 — 점에서 이름표까지 선
- **Occlusion test with Raycaster** — 카메라에서 쏜 광선으로 가려졌나 확인
- **CSS2DRenderer** — three.js 가 주는 같은 일의 도우미

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표
- three.js 는 CSS2DRenderer · CSS2DObject 로 같은 투영을 해 준다 — 가려짐 검사는 직접 해야 한다.

#### 4. 조건
- 투영 전에 scene.updateMatrixWorld() — 이번 프레임 위치로 계산
- 이름표는 화면 안으로 묶는다 (가장자리에서 잘리지 않게)
- 가려진 이름표는 숨기지 말고 흐리게 + 「(뒤)」 — 갑자기 사라지면 헷갈린다
- 이름표 이동은 transform 으로만

#### 5. 완성 기준 (이게 보이면 성공)
- 로켓이 돌아도 이름표 다섯 개가 각 부품을 계속 가리킨다
- 부품이 뒤로 돌아가면 이름표가 흐려지고 「(뒤)」 가 붙으며 지시선이 점선이 된다
- 창 크기를 바꿔도 이름표 자리가 맞다
- 폰 폭에서도 이름표가 화면 밖으로 나가지 않는다

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

## 원리
- 부품마다 「물체 안 좌표」 한 점을 정해 두고, 매 프레임 matrixWorld 로 세계 좌표 → project(camera) 로 −1 ~ 1 화면 좌표 → 픽셀로 바꾼다.
- 이름표(HTML div)는 그 점에서 옆으로(화면 폭 20%) 비켜 두고, SVG 선으로 점과 이름표를 잇는다.
- 가려짐: 카메라에서 그 점 쪽으로 광선을 쏴 먼저 맞은 면이 점보다 0.07 이상 가까우면 「뒤」 — 이름표 35%, 선은 점선 15%.
- 이름표는 transform: translate 로만 옮겨(will-change) 레이아웃을 다시 계산하지 않게 한다.

## 핵심 코드 — 3D 점 → 화면 이름표 · 가려짐 검사
(발췌: demos/demosStructure.ts i38 update() 를 정리)
```ts
import * as THREE from 'three';

interface Anchor { name: string; obj: THREE.Object3D; local: THREE.Vector3; side: number; el: HTMLDivElement; line: SVGLineElement }
const ray = new THREE.Raycaster();
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));

function placeLabels(anchors: Anchor[], cam: THREE.PerspectiveCamera, scene: THREE.Scene, hitList: THREE.Object3D[], w: number, h: number, fs: number) {
  scene.updateMatrixWorld();
  for (const a of anchors) {
    const wp = a.local.clone().applyMatrix4(a.obj.matrixWorld);      // 물체 안 점 → 세계
    const sp = wp.clone().project(cam);                              // 세계 → -1 ~ 1
    const x = ((sp.x + 1) / 2) * w;
    const y = ((1 - sp.y) / 2) * h;
    // 가려졌나? 카메라 → 점 사이에 다른 면이 먼저 닿으면 뒤
    const dir = wp.clone().sub(cam.position);
    const dist = dir.length();
    ray.set(cam.position, dir.normalize());
    const hit = ray.intersectObjects(hitList, false)[0];
    const hidden = !!hit && hit.distance < dist - 0.07;
    const lx = clamp(x + a.side * w * 0.2, fs * 3.5, w - fs * 3.5); // 옆으로 비켜, 화면 안으로
    const ly = y - fs * 0.8;
    a.el.style.transform = 'translate(' + lx + 'px, ' + ly + 'px) translate(-50%, -50%)';
    a.el.style.opacity = hidden ? '0.35' : '1';
    a.el.textContent = hidden ? a.name + ' (뒤)' : a.name;
    a.line.setAttribute('x1', String(x)); a.line.setAttribute('y1', String(y));
    a.line.setAttribute('x2', String(lx)); a.line.setAttribute('y2', String(ly));
    a.line.setAttribute('stroke-dasharray', hidden ? '4 4' : '');
  }
}
```

## 흔한 실수 · 확인 목록
- [ ] **이름표가 한 프레임 늦게 따라온다** — 투영 전에 updateMatrixWorld 를 불러 이번 프레임의 자리로 계산한다.
- [ ] **점이 물체 표면 바로 위라 자기 면에 맞아 늘 「가려짐」이 된다** — 맞은 거리가 점까지 거리보다 조금(0.07) 더 가까울 때만 가려진 것으로 친다.
- [ ] **이름표를 left · top 으로 옮기면 매 프레임 레이아웃을 다시 계산해 버벅인다** — transform: translate 로만 옮기고 will-change: transform 을 준다.
- [ ] **카메라 뒤로 간 점도 화면에 투영돼 엉뚱한 곳에 이름표가 뜬다** — project 결과 z 가 1 보다 크면(카메라 뒤) 이름표를 숨긴다.

## 완성 기준 체크리스트
- [ ] 로켓이 돌아도 이름표 다섯 개가 각 부품을 계속 가리킨다
- [ ] 부품이 뒤로 돌아가면 이름표가 흐려지고 「(뒤)」 가 붙으며 지시선이 점선이 된다
- [ ] 창 크기를 바꿔도 이름표 자리가 맞다
- [ ] 폰 폭에서도 이름표가 화면 밖으로 나가지 않는다

## 이 기술 정보
- id: `i38` · 분류: 물리 · 수학 원리 › 원리 설명 · 구조 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 이름표마다 투영 한 번 + 광선 한 번. 다섯 개 정도면 아무 부담 없다 (수십 개면 광선 검사를 몇 프레임에 한 번).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i38
- 쓰면 좋을 때: 부품 · 장기 · 도형 부분의 이름을 가르칠 때 / 돌아가는 3D 물체에 값(각 · 길이)을 붙일 때
- 쓰지 말 때: 이름표가 수십 개 — 겹쳐 읽을 수 없다. 고른 것만 보이게 / 글자를 3D 텍스처로 붙이기 — 돌면 비스듬해 읽기 어렵다. 화면 이름표가 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i38 견본 항목 — `src/demos/demosStructure.ts:3897`
```ts
  i38: {
    kind: 'dom',
    caption: '3D 부품 자리를 화면 좌표로 찍어 HTML 이름표 + 지시선 — 로켓이 돌아 부품이 뒤로 가면 이름표가 흐려져요',
    make(box) {
      ownAcquire();
      const { g, fit } = boxCanvas(box);
      const svgNS = 'http://www.w3.org/2000/svg';
      const svg = document.createElementNS(svgNS, 'svg');
      svg.setAttribute('style', 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible');
      box.appendChild(svg);
      const scene = darkScene();
      const rocket = new THREE.Group();
      scene.add(rocket);
      const bodyM = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.55, 2, 40), std(0xf2f4ff, 0.35, 0.2));
      const nose = new THREE.Mesh(new THREE.ConeGeometry(0.5, 0.9, 40), std(0xff4f6b, 0.35));
      nose.position.y = 1.45;
      const win = new THREE.Mesh(new THREE.TorusGeometry(0.2, 0.05, 10, 30), std(0x8a96ad, 0.3, 0.8));
      win.position.set(0, 0.45, 0.5);
      const glass = new THREE.Mesh(new THREE.CircleGeometry(0.2, 30), new THREE.MeshStandardMaterial({ color: 0x5ad1ff, emissive: 0x1a6aa0, roughness: 0.1 }));
      glass.position.set(0, 0.45, 0.51);
      const fins: THREE.Mesh[] = [];
      const finShape = new THREE.Shape();
      finShape.moveTo(0, 0);
      finShape.lineTo(0.55, -0.35);
      finShape.lineTo(0.55, -0.75);
      finShape.lineTo(0, -0.45);
      const finGeo = new THREE.ExtrudeGeometry(finShape, { depth: 0.06, bevelEnabled: false });
      finGeo.translate(0, 0, -0.03);
      for (let i = 0; i < 3; i++) {
        const f = new THREE.Mesh(finGeo, std(0xff4f6b, 0.4));
        const a = (i / 3) * TAU + Math.PI / 2;
        f.position.set(Math.cos(a) * 0.5, -0.45, Math.sin(a) * 0.5);
        f.rotation.y = -a;
        rocket.add(f);
        fins.push(f);
      }
      const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.42, 0.35, 30, 1, true), std(0x4a5468, 0.4, 0.7, { side: THREE.DoubleSide }));
      nozzle.position.y = -1.17;
      const flame = new THREE.Mesh(new THREE.ConeGeometry(0.26, 0.8, 24), new THREE.MeshBasicMaterial({ color: 0xffa040, transparent: true, opacity: 0.85 }));
      flame.position.y = -1.7;
      flame.rotation.x = Math.PI;
      rocket.add(bodyM, nose, win, glass, nozzle, flame);
      const anchors: { name: string; obj: THREE.Object3D; local: THREE.Vector3; side: number; el: HTMLDivElement; line: SVGLineElement; dot: SVGCircleElement }[] = [];
      const mkLabel = (name: string, obj: THREE.Object3D, local: THREE.Vector3, side: number): void => {
        const el = document.createElement('div');
        el.textContent = name;
        el.style.cssText = `position:absolute;left:0;top:0;padding:3px 9px;border-radius:999px;background:#ffffff;color:#1d2a55;font:700 13px ${FONT};white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.3);transition:opacity .2s;will-change:transform`;
        box.appendChild(el);
        const line = document.createElementNS(svgNS, 'line');
        line.setAttribute('stroke', '#ffd34a');
        line.setAttribute('stroke-width', '2');
        const dot = document.createElementNS(svgNS, 'circle');
        dot.setAttribute('r', '4');
        dot.setAttribute('fill', '#ffd34a');
        svg.append(line, dot);
        anchors.push({ name, obj, local, side, el, line, dot });
      };
      mkLabel('뾰족 머리', nose, new THREE.Vector3(0.16, 0.12, 0.08), 1);
      mkLabel('창문', glass, new THREE.Vector3(0, 0, 0.02), -1);
      mkLabel('몸통', bodyM, new THREE.Vector3(-0.38, -0.2, -0.38), 1);
      mkLabel('날개', fins[0]!, new THREE.Vector3(0.45, -0.5, 0), -1);
      mkLabel('분사구', nozzle, new THREE.Vector3(0.3, -0.05, -0.22), 1);
      const cam = new THREE.PerspectiveCamera(30, 1, 0.1, 50);
      const ray = new THREE.Raycaster();
      const hitList = [bodyM, nose, win, glass, nozzle, ...fins];
      return {
        update(t) {
          const { w, h, dpr } = fit();
          rocket.rotation.y = t * 0.7;
          rocket.rotation.z = Math.sin(t * 0.5) * 0.12;
          rocket.position.y = Math.sin(t * 1.3) * 0.08;
          flame.scale.set(1, 0.85 + Math.sin(t * 25) * 0.15, 1);
          const tn = Math.tan((cam.fov * Math.PI) / 360);
          const d = Math.max(1.9 / tn, 2.6 / (tn * (w / h)));
          cam.position.set(0, 0.4, d);
          cam.lookAt(0, 0.05, 0);
          scene.updateMatrixWorld();
          glPaint(g, dpr, scene, cam, 0, 0, w, h);
          const fs = clamp(Math.min(w, h * 1.6) * 0.045, 11, 18);
          for (const a of anchors) {
            const wp = a.local.clone().applyMatrix4(a.obj.matrixWorld);
            const sp = wp.clone().project(cam);
            const x = ((sp.x + 1) / 2) * w;
            const y = ((1 - sp.y) / 2) * h;
            // 가려졌나? 카메라 → 점 사이에 다른 면이 먼저 닿으면 숨김
            const dir = wp.clone().sub(cam.position);
            const dist = dir.length();
            ray.set(cam.position, dir.normalize());
            const hit = ray.intersectObjects(hitList, false)[0];
            const hidden = !!hit && hit.distance < dist - 0.07;
            const lx = clamp(x + a.side * w * 0.2, fs * 3.5, w - fs * 3.5);
            const ly = y - fs * 0.8;
            a.el.style.fontSize = `${fs}px`;
            a.el.style.transform = `translate(${lx}px, ${ly}px) translate(-50%, -50%)`;
            a.el.style.opacity = hidden ? '0.35' : '1';
            a.el.textContent = hidden ? `${a.name} (뒤)` : a.name;
            a.line.setAttribute('x1', String(x));
            a.line.setAttribute('y1', String(y));
            a.line.setAttribute('x2', String(lx));
            a.line.setAttribute('y2', String(ly));
            a.line.setAttribute('opacity', hidden ? '0.15' : '1');
            a.line.setAttribute('stroke-dasharray', hidden ? '4 4' : '');
            a.dot.setAttribute('cx', String(x));
            a.dot.setAttribute('cy', String(y));
            a.dot.setAttribute('opacity', hidden ? '0.15' : '1');
          }
        },
        dispose() {
          disposeScene(scene);
          ownRelease();
          box.innerHTML = '';
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [분해도 (exploded view)](https://ai-techstudio.web.app/ai/t/i36.md) `i36`
- 다음에 해 볼 기술: [단면 자르기 (스텐실 단면 · 원뿔 곡선)](https://ai-techstudio.web.app/ai/t/i35.md) `i35`
- 참고 문서: [three.js 예제 — CSS2D label](https://threejs.org/examples/#css2d_label)
