# AI 꾸러미 — 설명서 그림체 (액소노메트릭 일러스트) — Instruction manual illustration (axonometric)
> 정사영 카메라 · 흰 단색 재질 · 가는 모서리 선으로 설명서처럼 그리고, 단계마다 이번 부품만 주황으로 떨어뜨리며 지시점 · 평면도 · 축척을 얹는다.  
> 견본: https://ai-techstudio.web.app/#t/i70

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

## 주문서

### 만들어 줘: 설명서 그림체 (액소노메트릭 일러스트) — Instruction manual illustration (axonometric)

#### 1. 목표
쌓기나무 탑 조립을 설명서 그림체로 보여 줘 — 정사영 카메라 · 흰 단색 세계 · 가는 회색 모서리 선, 단계마다 이번 부품만 주황으로 화살표를 따라 떨어지고, 번호 지시점 · 평면도 · 축척 막대 · 부품 칸이 함께. 분위기는 흰 단색 + 주황 강조.

#### 2. 핵심 기술 용어
- **Instruction manual illustration (axonometric)** — 조립 설명서 그림체 (정사영 비스듬 보기)
- **OrthographicCamera** — 멀어도 크기가 같은 정사영 카메라
- **EdgesGeometry outline** — 각진 모서리만 뽑은 가는 선
- **Single accent color** — 이번 단계 부품만 강조색 하나

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

#### 4. 조건
- 카메라는 OrthographicCamera, 방향은 고정하고 단계마다 바라볼 곳 · 크기만 부드럽게
- 세계는 흰 단색, 강조색은 이번 단계 부품에만 하나 (주황) — 여러 색 쓰지 않기
- 모서리 선은 EdgesGeometry(각도 문턱 25°) — 둥근 면의 잔선이 안 나오게
- 지시점 · 확대 원 · 축척 막대 · 평면도는 3D 위치를 project 해서 HTML/SVG 로 (글씨 번역 가능)
- 톤 매핑 끄기(NoToneMapping) · 밝은 반구광으로 흰색이 회색으로 죽지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 단계마다 이번 부품만 주황으로 화살표와 함께 떨어져 끼워지고, 지난 부품은 흰색으로 남는다
- 번호 지시점 ①② 가 부품을 정확히 가리키고, 평면도 숫자가 쌓인 개수와 맞다
- 「원근 카메라로」를 켜면 축척 막대 글이 바뀌고 먼 쪽이 작아진다 — 정사영과 비교
- 「평소 그림」을 켜면 색 · 질감 그림으로 바뀌어 차이가 보인다

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

## 원리
- 정사영 카메라를 늘 같은 비스듬 방향 (1, 0.86, 1.1) 에서 30 만큼 떨어뜨려 두고, 단계마다 바라볼 곳 · 화면 크기만 lerp 로 바꾼다 — 2D 그림처럼 깔끔하지만 3D.
- 모든 부품은 흰 Lambert 재질 + EdgesGeometry(25°) 회색 선. 배경 집 · 나무는 35% 반투명 흰색 + 더 옅은 선.
- 이번 단계 부품만 주황 재질로 바꾸고, 2.6 위에서 화살표와 함께 bounce 로 떨어져 제자리에 끼운다 (부품마다 0.22초씩 늦게).
- 지시점 ①② 는 부품 윗점을 project 로 화면 좌표로 바꿔 SVG 선 · 원 · 번호로, 평면도는 위에서 본 칸마다 쌓인 개수를 작은 캔버스에 숫자로.
- 확대 원은 두 번째 정사영 카메라로 setViewport · setScissor 를 써서 같은 장면의 일부를 원 자리에 한 번 더 그린다.

## 핵심 코드 — 흰 재질 + 모서리 선 부품, 정사영 카메라, 이번 단계 부품 떨어뜨리기
(발췌: demos/demosManual.ts make() 의 edged · 카메라 · 부품 부분을 정리)
```ts
import * as THREE from 'three';
renderer.toneMapping = THREE.NoToneMapping;
scene.add(new THREE.HemisphereLight(0xffffff, 0xd4d6da, 2.35));
const white = new THREE.MeshLambertMaterial({ color: 0xf6f6f3 });
const accent = new THREE.MeshLambertMaterial({ color: 0xff7a1a });
const lineM = new THREE.LineBasicMaterial({ color: 0x8c8c88 });
const edged = (geo: THREE.BufferGeometry, mat: THREE.Material) => {
  const m = new THREE.Mesh(geo, mat);
  m.castShadow = m.receiveShadow = true;
  m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 25), lineM)); // 25° 넘게 꺾인 모서리만
  return m;
};
// 정사영 카메라 — 방향 고정, 바라볼 곳 · 크기만 단계마다
const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
const DIR = new THREE.Vector3(1, 0.86, 1.1).normalize();
function placeCam(dt: number, aspect: number) {
  const kk = 1 - Math.exp(-dt * 3);
  cam.target.lerp(cam.toTarget, kk);
  cam.size += (cam.toSize - cam.size) * kk;
  ortho.left = (-cam.size / 2) * aspect; ortho.right = (cam.size / 2) * aspect;
  ortho.top = cam.size / 2; ortho.bottom = -cam.size / 2;
  ortho.position.copy(cam.target).addScaledVector(DIR, 30);
  ortho.lookAt(cam.target);
  ortho.updateProjectionMatrix();
}
// 이번 단계 부품: 주황 + 위에서 통통 떨어져 끼우기
let order = 0;
for (const p of parts) {
  p.mesh.visible = p.step <= step;
  if (p.step !== step) { p.mesh.position.copy(p.home); setMat(p.mesh, white); continue; }
  const k = Math.min(1, Math.max(0, (stepT - 0.25 - order++ * 0.22) / 0.7));
  p.mesh.position.set(p.home.x, p.home.y + (1 - easeBounce(k)) * 2.6, p.home.z);
  setMat(p.mesh, accent);
}
// 지시점: 부품 윗점 → 화면 좌표 → SVG 선 · 원 · 번호
const proj = (v: THREE.Vector3) => { const q = v.clone().project(ortho); return [((q.x + 1) / 2) * w, ((1 - q.y) / 2) * gh]; };
```

## 흔한 실수 · 확인 목록
- [ ] **원근 카메라로 그리면 설명서 느낌이 사라지고 축척 막대가 맞지 않는다** — 정사영 카메라를 쓴다. 원근은 비교용으로만.
- [ ] **EdgesGeometry 문턱을 낮게 두면 둥근 면에 잔선이 가득하다** — 각도 문턱을 25° 쯤으로 둔다.
- [ ] **강조색을 여러 개 쓰면 무엇을 봐야 할지 흐려진다** — 세계는 흰 단색, 이번 단계 부품만 주황 하나.
- [ ] **확대 원을 그릴 때 setViewport 의 y 를 화면 위에서 재면 엉뚱한 자리에 그려진다** — WebGL 뷰포트는 아래에서 위로 잰다 — vy = 높이 − (원 위 + 원 크기).

## 완성 기준 체크리스트
- [ ] 단계마다 이번 부품만 주황으로 화살표와 함께 떨어져 끼워지고, 지난 부품은 흰색으로 남는다
- [ ] 번호 지시점 ①② 가 부품을 정확히 가리키고, 평면도 숫자가 쌓인 개수와 맞다
- [ ] 「원근 카메라로」를 켜면 축척 막대 글이 바뀌고 먼 쪽이 작아진다 — 정사영과 비교
- [ ] 「평소 그림」을 켜면 색 · 질감 그림으로 바뀌어 차이가 보인다

## 이 기술 정보
- id: `i70` · 분류: 기계 · 구조 › 구조 · 조립 · 3D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 부품 십여 개 + 모서리 선. 그림자 지도 1장(1024²). 확대 원이 켜진 단계만 한 번 더 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i70
- 쓰면 좋을 때: 조립 · 쌓기 순서를 단계로 설명할 때 / 무엇을 봐야 할지 강조해야 하는 원리 설명 / 도면처럼 깔끔하면서 돌려 볼 수 있어야 할 때
- 쓰지 말 때: 분위기 · 질감이 중요한 게임 장면 — 평소 그림(색 · 빛)으로 / 원근감이 필요한 넓은 풍경 — 정사영은 깊이가 잘 안 느껴진다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### make — `src/demos/demosManual.ts:144`
```ts
function make(mode: Mode) {
  return (box: HTMLElement) => {
    const root = document.createElement('div');
    root.className = 'mn-root';
    root.innerHTML = `<style>${CSS}</style>
      <div class="mn-top"><div><b>쌓기나무 탑 설명서</b><small>설명서 그림체 견본</small></div><span class="mn-step"></span></div>
      <canvas class="mn-gl"></canvas><svg class="mn-svg"></svg>
      <div class="mn-tray"><i>이번 단계 부품</i><div></div></div>
      <div class="mn-mini"><i>평면도</i><canvas></canvas></div>
      <div class="mn-scale"><u></u><span>${UNIT}</span></div>
      <div class="mn-zoom"></div>
      <div class="mn-tag"></div>
      <div class="mn-panel"><div class="mn-no"></div><div><h3></h3><ol></ol><div class="mn-meta"></div><div class="mn-note"></div></div>
        <div class="mn-btns"><button class="back">이전</button><button class="go">다음 단계</button></div></div>`;
    box.appendChild(root);
    const $ = <T extends Element>(s: string): T => root.querySelector(s) as T;
    const canvas = $<HTMLCanvasElement>('canvas.mn-gl');
    const svg = $<SVGSVGElement>('svg.mn-svg');
    const mini = $<HTMLCanvasElement>('.mn-mini canvas');
    const zoomEl = $<HTMLElement>('.mn-zoom');
    const tag = $<HTMLElement>('.mn-tag');

    const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    renderer.toneMapping = THREE.NoToneMapping;
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.setClearColor(0xfafaf8);
    renderer.autoClear = false;

    const scene = new THREE.Scene();
    scene.add(new THREE.HemisphereLight(0xffffff, 0xd4d6da, 2.35));
    const sun = new THREE.DirectionalLight(0xffffff, 1.5);
    sun.position.set(-4, 10, 5);
    sun.castShadow = true;
    sun.shadow.mapSize.set(1024, 1024);
    Object.assign(sun.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 1, far: 30 });
    sun.shadow.radius = 4;
    sun.shadow.bias = -0.0005;
    scene.add(sun);

    /* 재질 — 흰 · 강조 · 반투명 배경 · 선 */
    const white = new THREE.MeshLambertMaterial({ color: 0xf6f6f3 });
    const accent = new THREE.MeshLambertMaterial({ color: ORANGE });
    const ghost = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.35, depthWrite: false });
    const groundM = new THREE.MeshLambertMaterial({ color: 0xffffff });
    const lineM = new THREE.LineBasicMaterial({ color: 0x8c8c88 });
    const ghostLineM = new THREE.LineBasicMaterial({ color: 0xc8c8c4 });
    const colorMats = new Map<number, THREE.MeshLambertMaterial>();
    const colorMat = (c: number): THREE.MeshLambertMaterial => {
      let m = colorMats.get(c);
      if (!m) colorMats.set(c, (m = new THREE.MeshLambertMaterial({ color: c })));
      return m;
    };
    const geos: THREE.BufferGeometry[] = [];
    const lines: THREE.LineSegments[] = [];
    const edged = (geo: THREE.BufferGeometry, mat: THREE.Material, lm = lineM): THREE.Mesh => {
      geos.push(geo);
      const m = new THREE.Mesh(geo, mat);
      m.castShadow = true;
      m.receiveShadow = true;
      const eg = new THREE.EdgesGeometry(geo, 25);
      geos.push(eg);
      const l = new THREE.LineSegments(eg, lm);
      m.add(l);
      lines.push(l);
      return m;
    };

    /* 바닥 · 모눈 · 배경(집 · 나무) */
    const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), groundM);
    geos.push(ground.geometry);
    ground.rotation.x = -Math.PI / 2;
    ground.receiveShadow = true;
    scene.add(ground);
    const grid = new THREE.GridHelper(24, 24, 0xdcdcd8, 0xe8e8e4);
    grid.position.y = 0.002;
    scene.add(grid);
    const ghosts: { m: THREE.Mesh; color: number }[] = [];
    const addGhost = (geo: THREE.BufferGeometry, x: number, y: number, z: number, color: number, ry = 0): void => {
      const m = edged(geo, ghost, ghostLineM);
      m.castShadow = true;
      m.position.set(x, y, z);
      m.rotation.y = ry;
      scene.add(m);
      ghosts.push({ m, color });
    };
    // 집 (몸통 + 지붕)
    addGhost(new THREE.BoxGeometry(3.2, 2.4, 2.6), -4.6, 1.2, -3.6, 0xf2d7a8);
    const roof = new THREE.ConeGeometry(2.5, 1.3, 4);
    roof.rotateY(Math.PI / 4);
    addGhost(roof, -4.6, 3.05, -3.6, 0xc8553a);
    // 나무 (원뿔 + 줄기)
    for (const [x, z, s] of [
      [4.2, -3.8, 1.2],
      [5.4, -1.2, 0.9],
      [-6.2, 2.2, 1],
    ] as const) {
      addGhost(new THREE.ConeGeometry(0.8 * s, 2.4 * s, 10), x, 1.6 * s, z, 0x5ab25a);
      addGhost(new THREE.CylinderGeometry(0.1 * s, 0.12 * s, 0.6 * s, 8), x, 0.3 * s, z, 0x8a5a36);
    }

    /* 부품 */
    const parts: Part[] = [];
    const board = edged(new THREE.BoxGeometry(2.4, 0.2, 2.4), white);
    scene.add(board);
    parts.push({ mesh: board, home: new THREE.Vector3(0, 0.1, 0), step: 1, no: 1, color: 0xd9a46a });
    const cubeGeo = new THREE.BoxGeometry(0.98, 0.98, 0.98);
    const cubeCols = [0x3a8ee8, 0xe8453c, 0xffc53a, 0x4ab85a, 0x9a6ae8, 0x2ac0c8, 0xff8a3a];
    const cubes: [number, number, number, number, [number, number]][] = [
      // x, layer, z, step, cell(col,row)
      [-0.5, 0, -0.5, 2, [0, 0]],
      [0.5, 0, -0.5, 2, [1, 0]],
      [-0.5, 0, 0.5, 2, [0, 1]],
      [0.5, 0, 0.5, 2, [1, 1]],
      [-0.5, 1, -0.5, 3, [0, 0]],
      [-0.5, 1, 0.5, 3, [0, 1]],
      [-0.5, 2, -0.5, 4, [0, 0]],
    ];
    const noIn: Record<number, number> = {};
    cubes.forEach(([x, layer, z, step, cell], i) => {
      const m = edged(i === 0 ? cubeGeo : cubeGeo.clone(), white);
      scene.add(m);
      noIn[step] = (noIn[step] ?? 0) + 1;
      parts.push({ mesh: m, home: new THREE.Vector3(x, 0.2 + 0.5 + layer, z), step, no: noIn[step]!, color: cubeCols[i]!, cell });
    });
    // 깃발 (막대 + 천)
    const flag = new THREE.Group();
    const pole = edged(new THREE.CylinderGeometry(0.03, 0.03, 0.9, 8), white);
    pole.position.y = 0.45;
    const cloth = edged(new THREE.ExtrudeGeometry(new THREE.Shape([new THREE.Vector2(0, 0), new THREE.Vector2(0.42, -0.13), new THREE.Vector2(0, -0.26)]), { depth: 0.02, bevelEnabled: false }), white);
    cloth.position.set(0.02, 0.88, 0);
    flag.add(pole, cloth);
    scene.add(flag);
    parts.push({ mesh: flag, home: new THREE.Vector3(-0.5, 0.2 + 3, -0.5), step: 4, no: 2, color: 0xe8453c });

    /* 화살표 (부품마다 하나) */
    const arrows = parts.map(() => {
      const g = new THREE.Group();
      const am = new THREE.MeshBasicMaterial({ color: ORANGE, transparent: true });
      const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.8, 8), am);
      shaft.position.y = 0.55;
      const head = new THREE.Mesh(new THREE.ConeGeometry(0.12, 0.26, 12), am);
      head.rotation.x = Math.PI;
      head.position.y = 0.05;
      geos.push(shaft.geometry, head.geometry);
      g.add(shaft, head);
      g.visible = false;
      scene.add(g);
      return { g, am };
    });

    /* 카메라 — 정사영 (설명서) / 원근 (비교) */
    const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
    const persp = new THREE.PerspectiveCamera(32, 1, 0.1, 100);
    const DIR = new THREE.Vector3(1, 0.86, 1.1).normalize();
    const zoomCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
    const cam = { target: new THREE.Vector3(0, 1.2, 0), size: 8.5, toTarget: new THREE.Vector3(0, 1.2, 0), toSize: 8.5 };

    /* 상태 */
    let step = mode === 'devices' ? 4 : 0;
    let stepT = 0;
    let auto = true;
    let usePersp = false;
    let accentOn = true;
    let linesOn = true;
    let colorful = false;
    let flipT = 0;
    let w = 1;
    let h = 1;
    let ph = 0;

    const setStep = (k: number): void => {
      step = (k + STEPS.length) % STEPS.length;
      stepT = 0;
      cam.toTarget.set(...STEPS[step]!.target);
      cam.toSize = STEPS[step]!.size;
      drawPanel();
    };
    $<HTMLButtonElement>('.mn-btns .back').onclick = () => {
      auto = false;
      setStep(step - 1);
    };
    $<HTMLButtonElement>('.mn-btns .go').onclick = () => {
      auto = false;
      setStep(step + 1);
    };

    function drawPanel(): void {
      const s = STEPS[step]!;
      $<HTMLElement>('.mn-step').textContent = `${step} / ${STEPS.length - 1}`;
      $<HTMLElement>('.mn-no').textContent = step ? String(step) : '◎';
      $<HTMLElement>('.mn-panel h3').textContent = s.title;
      $<HTMLElement>('.mn-panel ol').innerHTML = s.items.map((t, i) => `<li><b>${i + 1}</b>${t}</li>`).join('');
      $<HTMLElement>('.mn-meta').textContent = s.meta;
      const note = $<HTMLElement>('.mn-note');
      note.textContent = s.note ?? '';
      note.style.display = s.note ? '' : 'none';
      $<HTMLElement>('.mn-tray div').innerHTML = s.parts
        .map((p) => `<figure><span>${iconSvg(p.icon)}<em>×${p.n}</em></span>${p.name}</figure>`)
        .join('');
      $<HTMLElement>('.mn-tray').style.display = s.parts.length ? '' : 'none';
      $<HTMLButtonElement>('.mn-btns .go').textContent = step === STEPS.length - 1 ? '처음으로' : '다음 단계';
    }

    function drawMini(): void {
      const sz = Math.round(70 * Number(root.style.getPropertyValue('--s') || 1));
      const dpr = Math.min(devicePixelRatio, 2);
      if (mini.width !== sz * dpr) {
        mini.width = mini.height = sz * dpr;
        mini.style.width = mini.style.height = `${sz}px`;
      }
      const g = mini.getContext('2d')!;
      g.setTransform(dpr, 0, 0, dpr, 0, 0);
      g.clearRect(0, 0, sz, sz);
      const c = sz / 2;
      const cell = sz * 0.3;
      g.strokeStyle = '#bbb';
      g.lineWidth = 1;
      g.strokeRect(c - cell * 1.2, c - cell * 1.2, cell * 2.4, cell * 2.4);
      const height: number[][] = [
        [0, 0],
        [0, 0],
      ];
      const cur: boolean[][] = [
        [false, false],
        [false, false],
      ];
      for (const p of parts) {
        if (!p.cell || p.step > step) continue;
        height[p.cell[1]]![p.cell[0]]!++;
        if (p.step === step) cur[p.cell[1]]![p.cell[0]] = true;
      }
      for (let r = 0; r < 2; r++)
        for (let q = 0; q < 2; q++) {
          const x = c - cell + q * cell;
          const y = c - cell + r * cell;
          const n = height[r]![q]!;
          g.fillStyle = n ? (cur[r]![q] && accentOn ? '#ff7a1a' : '#e6e6e2') : '#fff';
          g.fillRect(x + 1, y + 1, cell - 2, cell - 2);
          g.strokeStyle = '#666';
          g.strokeRect(x + 1, y + 1, cell - 2, cell - 2);
          if (n) {
            g.fillStyle = cur[r]![q] && accentOn ? '#fff' : '#151515';
            g.font = `700 ${Math.round(cell * 0.5)}px Pretendard, system-ui`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.fillText(String(n), x + cell / 2, y + cell / 2 + 1);
          }
        }
    }

    const easeBounce = (x: number): number => {
      const n1 = 7.5625;
      const d1 = 2.75;
      if (x < 1 / d1) return n1 * x * x;
      if (x < 2 / d1) return n1 * (x -= 1.5 / d1) * x + 0.75;
      if (x < 2.5 / d1) return n1 * (x -= 2.25 / d1) * x + 0.9375;
      return n1 * (x -= 2.625 / d1) * x + 0.984375;
    };

    const proj = (v: THREE.Vector3, c: THREE.Camera): [number, number] => {
      const p = v.clone().project(c);
      return [((p.x + 1) / 2) * w, ((1 - p.y) / 2) * (h - ph)];
    };

    setStep(step);

    const controls: Control[] = [
      { type: 'button', label: '다음 단계', on: () => ((auto = false), setStep(step + 1)) },
      { type: 'button', label: '처음부터', on: () => setStep(0) },
      { type: 'toggle', label: '저절로 넘기기', value: true, on: (v) => (auto = v) },
      { type: 'toggle', label: '원근 카메라로 (끄면 정사영)', value: false, on: (v) => (usePersp = v) },
      { type: 'toggle', label: '강조색 (이번 단계만 주황)', value: true, on: (v) => ((accentOn = v), drawMini()) },
      { type: 'toggle', label: '모서리 선', value: true, on: (v) => (linesOn = v) },
      { type: 'toggle', label: '평소 그림 (색 · 질감)', value: false, on: (v) => (colorful = v) },
    ];

    return {
      controls,
      update(t: number, dt: number): void {
        w = Math.max(1, box.clientWidth);
        h = Math.max(1, box.clientHeight);
        const sc = Math.max(0.45, Math.min(1.25, Math.min(w / 820, h / 640)));
        root.style.setProperty('--s', String(sc));
        const small = w < 520;
        root.classList.toggle('small', small);
        ph = Math.round((small ? 64 : 150) * sc);
        root.style.setProperty('--ph', `${ph}px`);
        const gh = h - ph;

        // 저절로 넘기기 · i71 은 평소 그림 ↔ 설명서 그림을 번갈아
        stepT += dt;
        if (auto && stepT > (step === 0 || step === STEPS.length - 1 ? 3.2 : 2.8)) setStep(mode === 'devices' && step === STEPS.length - 1 ? 3 : step + 1);
        let showColor = colorful;
        if (mode === 'accent') {
          flipT += dt;
          showColor = colorful || Math.floor(flipT / 2.6) % 2 === 1;
          tag.textContent = showColor ? '평소 그림' : '설명서 그림 — 단색 + 강조색';
          tag.style.display = '';
        } else tag.style.display = 'none';
        root.classList.toggle('colorful', showColor);

        // 부품 — 떨어져 끼워짐 · 색
        const s = STEPS[step]!;
        let order = 0;
        parts.forEach((p, i) => {
          const a = arrows[i]!;
          if (p.step > step || (step === 0 && false)) {
            p.mesh.visible = step === 0;
            a.g.visible = false;
            p.mesh.position.copy(p.home);
          } else {
            p.mesh.visible = true;
            if (p.step === step) {
              const k = Math.min(1, Math.max(0, (stepT - 0.25 - order * 0.22) / 0.7));
              order++;
              const drop = (1 - easeBounce(k)) * 2.6;
              p.mesh.position.set(p.home.x, p.home.y + drop, p.home.z);
              a.g.visible = k < 1 && accentOn && !showColor;
              a.g.position.set(p.home.x, p.home.y + drop + 0.55, p.home.z);
              a.am.opacity = 1 - k * 0.6;
            } else {
              p.mesh.position.copy(p.home);
              a.g.visible = false;
            }
          }
          const cur = p.step === step || step === 0;
          const mat = showColor ? colorMat(p.color) : cur && accentOn && step !== 0 ? accent : white;
          p.mesh.traverse((o) => {
            const m = o as THREE.Mesh;
            if (m.isMesh) m.material = mat;
          });
        });
        // 처음 화면: 모든 부품을 주황으로 「무엇을 만드나」
        if (step === 0)
          parts.forEach((p) =>
            p.mesh.traverse((o) => {
              const m = o as THREE.Mesh;
              if (m.isMesh) m.material = showColor ? colorMat(p.color) : accentOn ? accent : white;
            }),
          );
        for (const gh2 of ghosts) gh2.m.material = showColor ? colorMat(gh2.color) : ghost;
        groundM.color.setHex(showColor ? 0x8fcf6a : 0xffffff);
        grid.visible = !showColor;
        renderer.setClearColor(showColor ? 0xcfe9ff : 0xfafaf8);
        for (const l of lines) l.visible = linesOn && !showColor;

        // 카메라 — 단계마다 부드럽게
        const kk = 1 - Math.exp(-dt * 3);
        cam.target.lerp(cam.toTarget, kk);
        cam.size += (cam.toSize - cam.size) * kk;
        const aspect = w / gh;
        const sway = Math.sin(t * 0.25) * 0.08;
        const dir = DIR.clone().applyAxisAngle(new THREE.Vector3(0, 1, 0), sway);
        ortho.left = (-cam.size / 2) * aspect;
        ortho.right = (cam.size / 2) * aspect;
        ortho.top = cam.size / 2;
        ortho.bottom = -cam.size / 2;
        ortho.position.copy(cam.target).addScaledVector(dir, 30);
        ortho.lookAt(cam.target);
        ortho.updateProjectionMatrix();
        persp.aspect = aspect;
        const dist = cam.size / 2 / Math.tan(THREE.MathUtils.degToRad(persp.fov / 2));
        persp.position.copy(cam.target).addScaledVector(dir, dist * 0.62);
        persp.lookAt(cam.target);
        persp.fov = 52;
        persp.updateProjectionMatrix();
        const camera = usePersp ? persp : ortho;
        camera.updateMatrixWorld();

        // 그리기
        const dpr = renderer.getPixelRatio();
        const size = renderer.getSize(new THREE.Vector2());
        if (size.x !== w || size.y !== gh) renderer.setSize(w, gh, false);
        renderer.setViewport(0, 0, w, gh);
        renderer.setScissorTest(false);
        renderer.clear();
        renderer.render(scene, camera);

        // 화면 위 장치 — 지시점 · 확대 원 · 축척
        let svgHtml = '';
        const R = 8 * sc;
        if (!showColor && step > 0 && step < STEPS.length - 1) {
          for (const p of parts) {
            if (p.step !== step || (s.zoom && p.mesh === flag)) continue;
            const top = p.home.clone().add(new THREE.Vector3(0, p.mesh === board ? 0.1 : 0.5, 0));
            const [x, y] = proj(top, camera);
            const ox = x - 26 * sc;
            const oy = y - 30 * sc;
            svgHtml += `<line x1="${x}" y1="${y}" x2="${ox}" y2="${oy}" stroke="#151515" stroke-width="1"/><circle cx="${x}" cy="${y}" r="${2 * sc}" fill="#151515"/><circle cx="${ox}" cy="${oy}" r="${R}" fill="#fff" stroke="#151515" stroke-width="1.2"/><text x="${ox}" y="${oy + 3.5 * sc}" text-anchor="middle" font-size="${10 * sc}" font-weight="700" fill="#151515">${p.no}</text>`;
          }
        }
        // 확대 원 (깃발)
        const showZoom = !showColor && s.zoom && !small;
        zoomEl.style.display = showZoom ? '' : 'none';
        if (showZoom) {
          const zr = Math.round(Math.min(w, gh) * 0.2);
          const zx = Math.round(w - zr - 22 * sc);
          const zy = Math.round(gh * 0.62 - zr);
// … (56줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

### i70 견본 항목 — `src/demos/demosManual.ts:602`
```ts
  i70: { kind: 'dom', caption: '쌓기나무 탑 조립 설명서 — 정사영 · 흰 단색 · 이번 부품만 주황 · 모서리 선 · 단계마다 카메라 이동 · 지시점 · 확대 원 · 평면도', make: make('full') }
```

## 관련 기술
- 먼저 알면 좋은 기술: [단계 이야기 (scrollytelling · 다음 단추)](https://ai-techstudio.web.app/ai/t/i45.md) `i45`
- 다음에 해 볼 기술: [분해도 (exploded view)](https://ai-techstudio.web.app/ai/t/i36.md) `i36` · [화이트보드 그리기 애니메이션](https://ai-techstudio.web.app/ai/t/i79.md) `i79`
- 참고 문서: [three.js 문서 — EdgesGeometry](https://threejs.org/docs/#api/en/geometries/EdgesGeometry)
