# AI 꾸러미 — 설계도 · 모눈 도면 (블루프린트) — Blueprint style (technical drawing)
> 파란 종이에 모눈 · 접은 자국을 굽고 흰 선 · 치수선 · 화살표 · 손글씨 주석을 얹어, 전개도가 접히는 설계도(블루프린트)를 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i339

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

## 주문서

### 만들어 줘: 설계도 · 모눈 도면 (블루프린트) — Blueprint style (technical drawing)

#### 1. 목표
정육면체 전개도가 접히는 장면을 설계도(블루프린트) 그림체로 그려 줘 — 파란 모눈 종이, 흰 선, 치수선과 화살표, 손글씨 주석, 표제란까지. 분위기는 낡은 청사진 (얼룩 · 접은 자국).

#### 2. 핵심 기술 용어
- **Blueprint style (technical drawing)** — 설계도 그림체 — 파란 바탕 흰 선
- **Dimension line with arrowheads** — 치수선 — 보조선 · 양쪽 화살표 · 글자
- **Isometric projection** — 등각 투영 — 3D 를 30° 비스듬히 그린 그림
- **Painter's algorithm (depth sort)** — 먼 면부터 그리기

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 자르는 선 = 실선, 접는 선 = 점선, 풀칠 날개 = 짧은 점선으로 구분
- 치수 글자는 선 방향으로 돌리되 거꾸로 서지 않게 (각을 ±90° 안으로)
- 면 그리기 순서는 깊이 정렬 — 접히는 중에 뒤 면이 앞을 덮으면 안 된다
- 손떨림 · 손글씨는 시드 고정

#### 5. 완성 기준 (이게 보이면 성공)
- 파란 모눈 종이에 흰 선 전개도 · 치수선(12 cm · 3 cm) · 「접는 선 · 자르는 선」 주석 · 표제란이 보인다
- 옆의 정육면체가 펼쳐졌다 접히고, 다 접히면 치수선이 나타난다
- 「손떨림」 0 이면 반듯한 도면, 올리면 손으로 그린 설계 스케치
- 「종이 낡음」으로 얼룩 · 접은 자국이 짙어진다

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

## 원리
- 바탕: 왼쪽 위 → 오른쪽 아래로 짙어지는 파랑에 잡음 얼룩 · 섬유를 넣어 한 번 굽고, 5 단위 모눈(25 단위마다 진하게)과 가운데 십자 접은 자국을 그린다.
- 선은 흰색 + 2.8배 굵기 옅은 빛 선을 아래에 깔고, 길이를 따라 살짝 흔들어(손떨림) 손으로 그은 맛을 낸다.
- 치수선: 두 점을 법선 방향으로 띄워 보조선 둘 · 본선 · 양끝 화살표(±0.35 라디안)를 긋고, 글자는 선 방향으로 돌리되 거꾸로 서지 않게 ±90° 안으로 맞춘다.
- 접히는 정육면체: 접는 각 θ 로 각 면의 꼭짓점을 (cos θ, sin θ) 로 계산해 등각 투영하고, x+y+z 합으로 정렬해 먼 면부터 그린다.

## 핵심 코드 — 치수선 (보조선 · 화살표 · 돌린 글자)
(발췌: demos/demosHandB.ts makeBlueprint() 의 head() · dim() 을 정리)
```ts
type P = [number, number];
function head(g: CanvasRenderingContext2D, b: P, a: P, col: string) {
  const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
  g.fillStyle = col;
  g.beginPath();
  g.moveTo(b[0], b[1]);
  g.lineTo(b[0] - Math.cos(ang - 0.35) * 3, b[1] - Math.sin(ang - 0.35) * 3);
  g.lineTo(b[0] - Math.cos(ang + 0.35) * 3, b[1] - Math.sin(ang + 0.35) * 3);
  g.closePath();
  g.fill();
}

// line(pts, w) = 흔들린 흰 선 그리기 · text(s, x, y, size) = 손글씨 가운데 맞춤
function dim(g: CanvasRenderingContext2D, a: P, b: P, off: number, lab: string, col: string) {
  const dx = b[0] - a[0], dy = b[1] - a[1];
  const l = Math.hypot(dx, dy) || 1;
  const ux = -dy / l, uy = dx / l;                  // 선에 수직인 방향
  const A: P = [a[0] + ux * off, a[1] + uy * off];
  const B: P = [b[0] + ux * off, b[1] + uy * off];
  const sg = Math.sign(off);
  line([[a[0] + ux * sg * 1.2, a[1] + uy * sg * 1.2], [A[0] + ux * sg * 2, A[1] + uy * sg * 2]], 0.45); // 보조선
  line([[b[0] + ux * sg * 1.2, b[1] + uy * sg * 1.2], [B[0] + ux * sg * 2, B[1] + uy * sg * 2]], 0.45);
  line([A, B], 0.55);                               // 본선
  head(g, A, B, col);
  head(g, B, A, col);
  let ang = Math.atan2(dy, dx);
  if (ang > Math.PI / 2) ang -= Math.PI;            // 글자가 거꾸로 서지 않게
  if (ang < -Math.PI / 2) ang += Math.PI;
  g.save();
  g.translate((A[0] + B[0]) / 2 + ux * sg * 3.6, (A[1] + B[1]) / 2 + uy * sg * 3.6);
  g.rotate(ang);
  text(lab, 0, 2.2, 6.4);
  g.restore();
}
```

## 흔한 실수 · 확인 목록
- [ ] **치수 글자를 선 각도 그대로 돌리면 왼쪽으로 가는 선에서 글자가 거꾸로 선다** — 각을 ±90° 안으로 접어 늘 읽는 방향이 되게 한다.
- [ ] **접히는 면을 정해진 순서로 그리면 중간에 뒤 면이 앞을 덮는다** — 프레임마다 면의 깊이(x+y+z 합)로 정렬해 먼 면부터 그린다.
- [ ] **모눈을 그림 좌표로만 그리면 화면 가장자리가 비어 보인다** — 모눈은 바탕을 구울 때 화면 전체에, 그림 원점에 맞춰 5 단위로 깐다.
- [ ] **흰 선만 그으면 밋밋하다** — 아래에 2.8배 굵기의 아주 옅은 하늘색 선을 깔아 청사진 빛 번짐을 낸다.

## 완성 기준 체크리스트
- [ ] 파란 모눈 종이에 흰 선 전개도 · 치수선(12 cm · 3 cm) · 「접는 선 · 자르는 선」 주석 · 표제란이 보인다
- [ ] 옆의 정육면체가 펼쳐졌다 접히고, 다 접히면 치수선이 나타난다
- [ ] 「손떨림」 0 이면 반듯한 도면, 올리면 손으로 그린 설계 스케치
- [ ] 「종이 낡음」으로 얼룩 · 접은 자국이 짙어진다

## 이 기술 정보
- id: `i339` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 파란 종이는 크기 · 낡음 값이 바뀔 때만 굽는다. 매 프레임은 선 수십 개 · 면 6장.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i339
- 쓰면 좋을 때: 전개도 · 작도 · 측정 문제를 「설계」처럼 보여 줄 때 / 구조 · 부품 설명 화면
- 쓰지 말 때: 아이들이 색으로 구별해야 하는 화면 — 설계도는 한 색이다. 그럴 땐 색 칠한 그림 / 정밀한 3D 회전 — 직접 짠 등각 투영보다 three.js 정사영 카메라가 낫다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeBlueprint — `src/demos/demosHandB.ts:2818`
```ts
function makeBlueprint() {
  let wob = 0.6;
  let wear = 1;
  let cmp = true;
  let kB = '';
  let paper: HTMLCanvasElement | null = null;
  interface M {
    col: string;
    glow: string | null;
    wob: number;
    hand: number;
    fill: string;
  }
  const S = 17;
  const NX = 30;
  const NY = 28;
  const N = (c: number, r: number): P => [NX + c * S, NY + r * S];
  const content = (g: G, m: M, ff: number): void => {
    let sd = 1;
    g.lineCap = 'round';
    g.lineJoin = 'round';
    const L = (pts: P[], w = 0.9, closed = false, dash: number[] | null = null): void => {
      const q = wobbly(pts, closed, sd++, m.wob, 0.07, 2);
      if (dash) g.setLineDash(dash);
      if (m.glow) {
        g.strokeStyle = m.glow;
        g.lineWidth = w * 2.8;
        polyPath(g, q, false);
        g.stroke();
      }
      g.strokeStyle = m.col;
      g.lineWidth = w;
      polyPath(g, q, false);
      g.stroke();
      g.setLineDash([]);
    };
    const T = (s: string, x: number, y: number, size: number, al: 'left' | 'center' | 'right' = 'left'): void => {
      handText(g, s, x, y, size, sd++ * 7, m.hand, { fill: m.col, weight: 500, al });
    };
    const head = (b: P, a: P): void => {
      const ang = Math.atan2(b[1] - a[1], b[0] - a[0]);
      g.fillStyle = m.col;
      g.beginPath();
      g.moveTo(b[0], b[1]);
      g.lineTo(b[0] - Math.cos(ang - 0.35) * 3, b[1] - Math.sin(ang - 0.35) * 3);
      g.lineTo(b[0] - Math.cos(ang + 0.35) * 3, b[1] - Math.sin(ang + 0.35) * 3);
      g.closePath();
      g.fill();
    };
    const dim = (a: P, b: P, off: number, lab: string): void => {
      const dx = b[0] - a[0];
      const dy = b[1] - a[1];
      const l = Math.hypot(dx, dy) || 1;
      const ux = -dy / l;
      const uy = dx / l;
      const A: P = [a[0] + ux * off, a[1] + uy * off];
      const B: P = [b[0] + ux * off, b[1] + uy * off];
      const sg = Math.sign(off);
      L([[a[0] + ux * sg * 1.2, a[1] + uy * sg * 1.2], [A[0] + ux * sg * 2, A[1] + uy * sg * 2]], 0.45);
      L([[b[0] + ux * sg * 1.2, b[1] + uy * sg * 1.2], [B[0] + ux * sg * 2, B[1] + uy * sg * 2]], 0.45);
      L([A, B], 0.55);
      head(A, B);
      head(B, A);
      let ang = Math.atan2(dy, dx);
      if (ang > Math.PI / 2) ang -= Math.PI;
      if (ang < -Math.PI / 2) ang += Math.PI;
      g.save();
      g.translate((A[0] + B[0]) / 2 + ux * sg * 3.6, (A[1] + B[1]) / 2 + uy * sg * 3.6);
      g.rotate(ang);
      T(lab, 0, 2.2, 6.4, 'center');
      g.restore();
    };
    // 전개도 (자르는 선 = 실선, 접는 선 = 점선)
    const outline: P[] = [N(1, 0), N(2, 0), N(2, 2), N(3, 2), N(3, 3), N(2, 3), N(2, 4), N(1, 4), N(1, 3), N(0, 3), N(0, 2), N(1, 2)];
    polyPath(g, outline);
    g.fillStyle = m.fill;
    g.fill();
    // 풀칠 날개
    const tab = (a: P, b: P, out: P): void => {
      const pts: P[] = [a, [lerp(a[0], b[0], 0.2) + out[0], lerp(a[1], b[1], 0.2) + out[1]], [lerp(a[0], b[0], 0.8) + out[0], lerp(a[1], b[1], 0.8) + out[1]], b];
      L(pts, 0.6, false, [1.6, 1.4]);
    };
    tab(N(1, 0), N(1, 1), [-4, 0]);
    tab(N(2, 2), N(3, 2), [0, -4]);
    tab(N(2, 4), N(1, 4), [0, 4]);
    L(outline, 1.05, true);
    for (const [a, b] of [
      [N(1, 1), N(2, 1)],
      [N(1, 2), N(2, 2)],
      [N(1, 2), N(1, 3)],
      [N(2, 2), N(2, 3)],
      [N(1, 3), N(2, 3)],
    ] as [P, P][])
      L([a, b], 0.7, false, [2.2, 1.6]);
    const lab = (s: string, c: number, r: number): void => T(s, NX + (c + 0.5) * S, NY + (r + 0.5) * S + 2, 5.6, 'center');
    lab('윗면', 1, 0);
    lab('뒷면', 1, 1);
    lab('밑면', 1, 2);
    lab('옆', 0, 2);
    lab('옆', 2, 2);
    lab('앞면', 1, 3);
    dim(N(0, 0), N(0, 4), 9, '12 cm');
    dim(N(1, 4), N(2, 4), 9, '3 cm');
    // 주석 (지시선 + 손글씨)
    L([[NX + 1.6 * S, NY + 2 * S], [96, 52]], 0.5);
    head([NX + 1.6 * S, NY + 2 * S], [96, 52]);
    T('접는 선', 98, 54, 7);
    L([[NX + 3 * S, NY + 2.6 * S], [96, 80]], 0.5);
    head([NX + 3 * S, NY + 2.6 * S], [96, 80]);
    T('자르는 선', 98, 82, 7);
    T('겉넓이 = 6 × 3 × 3', 20, 132, 8.5);
    T('= 54 cm²', 52, 145, 8.5);
    // 접히는 정육면체 (등각 투영)
    const S3 = 22;
    const O: P = [205, 84];
    const pr = (x: number, y: number, z: number): P => [O[0] + (x - z) * 0.866 * S3, O[1] + ((x + z) * 0.5 - y) * S3];
    const th = ff * (Math.PI / 2);
    const c = Math.cos(th);
    const sn = Math.sin(th);
    const c2v = Math.cos(2 * th);
    const s2v = Math.sin(2 * th);
    const faces: [number, number, number][][] = [
      [
        [0, 0, 0],
        [1, 0, 0],
        [1, 0, 1],
        [0, 0, 1],
      ],
      [
        [0, 0, 0],
        [1, 0, 0],
        [1, sn, -c],
        [0, sn, -c],
      ],
      [
        [0, 0, 1],
        [1, 0, 1],
        [1, sn, 1 + c],
        [0, sn, 1 + c],
      ],
      [
        [0, 0, 0],
        [0, 0, 1],
        [-c, sn, 1],
        [-c, sn, 0],
      ],
      [
        [1, 0, 0],
        [1, 0, 1],
        [1 + c, sn, 1],
        [1 + c, sn, 0],
      ],
      [
        [0, sn, 1 + c],
        [1, sn, 1 + c],
        [1, sn + s2v, 1 + c + c2v],
        [0, sn + s2v, 1 + c + c2v],
      ],
    ];
    faces
      .map((fc) => ({ fc, z: fc.reduce((a, p) => a + p[0] + p[1] + p[2], 0) }))
      .sort((a, b) => a.z - b.z)
      .forEach(({ fc }) => {
        const pts = fc.map((p) => pr(p[0], p[1], p[2]));
        polyPath(g, pts);
        g.fillStyle = m.fill;
        g.fill();
        L(pts, 0.95, true);
      });
    const dA = smooth(0.9, 1, ff);
    if (dA > 0) {
      g.globalAlpha = dA;
      dim(pr(0, 0, 1), pr(1, 0, 1), 7, '3 cm');
      dim(pr(1, 1, 1), pr(1, 0, 1), -7, '3 cm');
      g.globalAlpha = 1;
    }
    // 표제란
    L(
      [
        [192, 140],
        [272, 140],
        [272, 170],
        [192, 170],
      ],
      0.8,
      true,
    );
    L([[192, 153], [272, 153]], 0.5);
    L([[236, 153], [236, 170]], 0.5);
    T('정육면체 전개도', 196, 149.5, 7);
    T('축척 1:1', 196, 164, 6);
    T('도면 07', 240, 164, 6);
  };
  return {
    draw(g: G, w: number, h: number, t: number): void {
      reset(g);
      const f = fitOf(g, w, h);
      const kb = keyOf(f, wear);
      if (kb !== kB) {
        paper = bakeBlue(f, wear);
        kB = kb;
      }
      g.drawImage(paper!, 0, 0, w, h);
      const tc = t % 7;
      const ff = tc < 1 ? 0 : tc < 3 ? ease((tc - 1) / 2) : tc < 5 ? 1 : tc < 6.5 ? 1 - ease((tc - 5) / 1.5) : 0;
      enter(g, f);
      content(g, { col: 'rgba(238,246,255,0.94)', glow: 'rgba(190,225,255,0.16)', wob, hand: 1, fill: 'rgba(28,84,160,0.55)' }, ff);
      g.restore();
      if (cmp)
        splitter(
          g,
          w,
          h,
          t,
          () => {
            g.fillStyle = '#fbfcfd';
            g.fillRect(0, 0, w, h);
            enter(g, f);
            g.strokeStyle = 'rgba(30,41,59,0.07)';
            g.lineWidth = 0.4;
            g.beginPath();
            for (let x = -200; x < 480; x += 5) {
              g.moveTo(x, -200);
              g.lineTo(x, 400);
            }
            for (let y = -200; y < 400; y += 5) {
              g.moveTo(-200, y);
              g.lineTo(480, y);
            }
            g.stroke();
            content(g, { col: '#1e293b', glow: null, wob: 0, hand: 0, fill: 'rgba(226,232,240,0.85)' }, ff);
            g.restore();
          },
          '설계도',
        );
    },
    controls: [
      { type: 'range', label: '손떨림', min: 0, max: 1.6, step: 0.05, value: wob, on: (v) => (wob = v) },
      { type: 'range', label: '종이 낡음 (얼룩 · 접은 자국)', min: 0, max: 2, step: 0.1, value: wear, on: (v) => (wear = v) },
      cmpToggle(cmp, (v) => (cmp = v)),
    ] as Control[],
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [손글씨 글자 흔들기](https://ai-techstudio.web.app/ai/t/i337.md) `i337` · [칠판 · 분필 (번짐 · 지운 자국)](https://ai-techstudio.web.app/ai/t/i333.md) `i333`
- 다음에 해 볼 기술: [접히는 전개도 (경첩 회전)](https://ai-techstudio.web.app/ai/t/i362.md) `i362`
