# AI 꾸러미 — 9분할 그림 (캔버스로 한 장) — 9-slice scaling (nine-patch)
> 테두리 그림을 9조각으로 잘라 귀퉁이는 그대로 두고 변 · 가운데만 늘이되, 캔버스 한 장에 이어 그려 확대해도 조각 사이 실선이 생기지 않게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u56

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

## 주문서

### 만들어 줘: 9분할 그림 (캔버스로 한 장) — 9-slice scaling (nine-patch)

#### 1. 목표
종이 · 서류 같은 테두리 판을 9조각 그림으로 늘려 줘 — 귀퉁이 장식은 찌그러지지 않고, 캔버스 한 장에 이어 그려 이음새 실선이 없게. 그림은 금테 두른 크림 종이.

#### 2. 핵심 기술 용어
- **9-slice scaling (nine-patch)** — 9조각 늘리기 — 귀퉁이는 그대로, 변과 가운데만 늘림
- **CSS border-image** — CSS 의 9조각 테두리 (조각마다 따로 그려 이음새가 생김)
- **drawImage(src, sx, sy, sw, sh, dx, dy, dw, dh)** — 그림 일부를 잘라 원하는 크기로 그리기
- **ResizeObserver** — 요소 크기가 바뀌면 알려 주는 감시자

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 2D · 브라우저 — PC · 폰 모두

#### 4. 조건
- 자르는 자리는 그림 좌표, 놓는 자리는 캔버스 좌표 — 놓는 자리는 Math.round 로 정수에 맞춰 조각이 딱 붙게
- 테두리 두께는 요소의 CSS border 두께를 그대로 쓰기
- 캔버스 해상도는 2배(RES 2)로 — 확대돼 보여도 흐리지 않게
- 그림이 준비되기 전에는 CSS border-image 가 그대로 보이게 (빈칸 금지)
- 크기가 바뀌면 다시 굽고, 같은 크기는 캐시에서

#### 5. 완성 기준 (이게 보이면 성공)
- 그냥 늘리기 · 9분할을 나란히 보면 9분할 쪽 귀퉁이 장식이 찌그러지지 않는다
- 브라우저를 125% · 150% 로 확대해도 조각 사이 실선이 없다
- 판 크기를 바꾸면 테두리가 다시 맞춰진다
- 「자르는 선 보기」를 켜면 9조각 경계가 점선으로 보인다

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

## 원리
- 그림을 자를 자리 네 개(위 · 오른쪽 · 아래 · 왼쪽)로 3×3 조각을 낸다. 귀퉁이 4조각은 크기 그대로, 위아래 변은 가로로만, 좌우 변은 세로로만, 가운데는 양쪽으로 늘린다.
- 그냥 늘리면 귀퉁이 장식까지 찌그러진다 (견본 왼쪽).
- CSS border-image 는 9조각을 따로 그려서, 화면을 확대 · 축소하면 조각 사이에 얇은 선이 보인다.
- 그래서 요소 크기에 맞는 캔버스 한 장에 9조각을 정수 좌표로 딱 붙여 그리고, 그 그림을 배경으로 깐다. 크기가 바뀌면 다시 굽는다.

## 핵심 코드 — 9조각을 캔버스 한 장에 이어 그려 배경으로
(발췌: src/game/core/nineSlice.ts render() 를 정리)
```ts
const RES = 2; // 확대돼도 또렷하게
async function nineToBg(el: HTMLElement, img: HTMLImageElement, s: [number, number, number, number]) {
  const cs = getComputedStyle(el);
  const b = [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth].map((v) => parseFloat(v) * RES);
  const W = Math.round(el.offsetWidth * RES);
  const H = Math.round(el.offsetHeight * RES);
  const [st, sr, sb, sl] = s; // 그림에서 자를 자리 (border-image-slice 와 같은 값)
  const iw = img.naturalWidth, ih = img.naturalHeight;
  const sx = [0, sl, iw - sr, iw];
  const sy = [0, st, ih - sb, ih];
  // 놓는 자리는 정수로 — 조각이 딱 붙어 실선이 없다
  const dx = [0, Math.round(b[3]!), W - Math.round(b[1]!), W];
  const dy = [0, Math.round(b[0]!), H - Math.round(b[2]!), H];
  const cv = document.createElement('canvas');
  cv.width = W;
  cv.height = H;
  const g = cv.getContext('2d')!;
  g.imageSmoothingQuality = 'high';
  for (let r = 0; r < 3; r++)
    for (let c = 0; c < 3; c++) {
      const sw = sx[c + 1]! - sx[c]!, sh = sy[r + 1]! - sy[r]!;
      const dw = dx[c + 1]! - dx[c]!, dh = dy[r + 1]! - dy[r]!;
      if (sw > 0 && sh > 0 && dw > 0 && dh > 0) g.drawImage(img, sx[c]!, sy[r]!, sw, sh, dx[c]!, dy[r]!, dw, dh);
    }
  const blob = await new Promise<Blob | null>((ok) => cv.toBlob(ok));
  if (!blob) return;
  el.style.backgroundImage = 'url(' + URL.createObjectURL(blob) + ')';
  el.style.backgroundSize = '100% 100%';
  el.style.backgroundOrigin = 'border-box';
  el.style.borderImageSource = 'none'; // 이음새 나는 border-image 는 끈다
}
// 크기가 바뀌면 다시: new ResizeObserver((l) => l.forEach((e) => nineToBg(e.target as HTMLElement, img, s))).observe(el)
```

## 흔한 실수 · 확인 목록
- [ ] **CSS border-image 만 쓰면 확대 · 축소할 때 조각 사이에 얇은 선이 보인다** — 9조각을 캔버스 한 장에 정수 좌표로 이어 그려 배경으로 깐다 (수학 검문소 자료 종이 · 근무 일지에서 겪고 고침).
- [ ] **놓는 자리를 소수로 두면 조각 사이에 반 화소 틈이 생긴다** — dx · dy 를 Math.round 로 정수에 맞춘다.
- [ ] **게임 CSS 가 background 에 !important 를 쓰면 깐 그림이 안 보인다** — style.setProperty(k, v, 'important') 로 같이 important 로 넣는다 (nineSlice.ts 가 이렇게 한다).
- [ ] **크기가 바뀔 때마다 새 그림 주소를 만들면 메모리가 샌다** — 열쇠(그림 · 크기 · 두께)로 캐시하고, 넘치면 오래된 주소를 URL.revokeObjectURL 로 놓아준다.

## 완성 기준 체크리스트
- [ ] 그냥 늘리기 · 9분할을 나란히 보면 9분할 쪽 귀퉁이 장식이 찌그러지지 않는다
- [ ] 브라우저를 125% · 150% 로 확대해도 조각 사이 실선이 없다
- [ ] 판 크기를 바꾸면 테두리가 다시 맞춰진다
- [ ] 「자르는 선 보기」를 켜면 9조각 경계가 점선으로 보인다

## 이 기술 정보
- id: `u56` · 분류: 2D · 화면 › 2D 그리기 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 크기가 바뀔 때만 한 번 굽는다 (해상도 2배). 같은 크기는 캐시에서 재사용 (견본 코드는 최대 60장).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u56
- 쓰면 좋을 때: 테두리 그림 하나로 크기가 다른 판 · 단추를 많이 만들 때 / 화면 크기에 맞춰 늘어나는 HTML 판에 그린 테두리를 입힐 때
- 쓰지 말 때: 가운데에 무늬 · 글자가 있는 그림 — 가운데도 늘어나 찌그러진다. 대신 가운데는 단색, 무늬는 따로 얹기 / 테두리를 코드로 그릴 수 있는 단순한 모양 — CSS border · SVG 가 더 가볍다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u56 견본 항목 — `src/demos/demosDraw2d.ts:691`
```ts
  u56: {
    kind: '2d',
    caption: '왼쪽 그냥 늘리기(귀퉁이 장식이 찌그러짐) · 오른쪽 9분할(귀퉁이는 그대로, 변과 가운데만 늘어남)',
    make() {
      const N = 60;
      const C = 18;
      const RES = 4;
      const src = mkCanvas(N * RES, N * RES);
      {
        const s = ctx(src);
        s.scale(RES, RES);
        const gr = s.createLinearGradient(0, 0, 0, N);
        gr.addColorStop(0, '#ffe08a');
        gr.addColorStop(0.5, '#e0a93a');
        gr.addColorStop(1, '#a8701c');
        s.fillStyle = gr;
        s.beginPath();
        s.roundRect(1, 1, N - 2, N - 2, 12);
        s.fill();
        s.strokeStyle = '#6e4510';
        s.lineWidth = 2;
        s.stroke();
        s.fillStyle = '#fff7e4';
        s.beginPath();
        s.roundRect(12, 12, N - 24, N - 24, 4);
        s.fill();
        s.strokeStyle = '#b07a22';
        s.lineWidth = 1.5;
        s.stroke();
        s.strokeStyle = 'rgba(255,255,255,.7)';
        s.lineWidth = 1;
        s.beginPath();
        s.roundRect(5, 5, N - 10, N - 10, 8);
        s.stroke();
        for (const [x, y] of [
          [7, 7],
          [N - 7, 7],
          [7, N - 7],
          [N - 7, N - 7],
        ] as const) {
          s.fillStyle = '#d63a4a';
          s.beginPath();
          s.moveTo(x, y - 5);
          s.lineTo(x + 5, y);
          s.lineTo(x, y + 5);
          s.lineTo(x - 5, y);
          s.closePath();
          s.fill();
          s.fillStyle = '#ffd0d6';
          s.beginPath();
          s.arc(x - 1, y - 1.5, 1.3, 0, Math.PI * 2);
          s.fill();
        }
      }
      let guides = true;
      const nine = (g: G, x: number, y: number, W: number, H: number, c: number): void => {
        const sx = [0, C, N - C, N];
        const dx = [x, x + c, x + W - c, x + W];
        const dy = [y, y + c, y + H - c, y + H];
        for (let j = 0; j < 3; j++)
          for (let i = 0; i < 3; i++) {
            const sw = sx[i + 1]! - sx[i]!;
            const sh = sx[j + 1]! - sx[j]!;
            const ww = dx[i + 1]! - dx[i]!;
            const hh = dy[j + 1]! - dy[j]!;
            if (ww > 0 && hh > 0) g.drawImage(src, sx[i]! * RES, sx[j]! * RES, sw * RES, sh * RES, dx[i]! - 0.25, dy[j]! - 0.25, ww + 0.5, hh + 0.5);
          }
      };
      const lines = (g: G, x: number, y: number, W: number, c: number, u: number): void => {
        g.fillStyle = '#7a5a2a';
        txt(g, '근무 지시서', x + W / 2, y + c + 7 * u, 8 * u, '#6e4510');
        g.fillStyle = 'rgba(110,69,16,.25)';
        for (let i = 0; i < 2; i++) g.fillRect(x + c + 2 * u, y + c + (15 + i * 7) * u, W - 2 * c - 4 * u, 1.5 * u);
      };
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          bg(g, w, h, '#3a2a55', '#171026');
          const hw = w / 2;
          const W = hw * (0.42 + 0.42 * (0.5 + 0.5 * Math.sin(t * 0.9)));
          const H = h * (0.34 + 0.36 * (0.5 + 0.5 * Math.sin(t * 1.25 + 1)));
          const c = C * u * 1.15;
          const y = h * 0.56 - H / 2;
          g.imageSmoothingEnabled = true;
          // 왼쪽 그냥 늘리기
          const lx = hw / 2 - W / 2;
          g.drawImage(src, lx, y, W, H);
          // 오른쪽 9분할
          const rx = hw + hw / 2 - W / 2;
          nine(g, rx, y, W, H, c);
          if (W > 2 * c + 20 * u && H > 2 * c + 22 * u) lines(g, rx, y, W, c, u);
          if (guides) {
            g.save();
            g.setLineDash([3 * u, 3 * u]);
            g.strokeStyle = `rgba(80,220,255,${0.55 + 0.35 * Math.sin(t * 3)})`;
            g.lineWidth = 1.2 * u;
            g.beginPath();
            for (const xx of [rx + c, rx + W - c]) {
              g.moveTo(xx, y - 6 * u);
              g.lineTo(xx, y + H + 6 * u);
            }
            for (const yy of [y + c, y + H - c]) {
              g.moveTo(rx - 6 * u, yy);
              g.lineTo(rx + W + 6 * u, yy);
            }
            g.stroke();
            g.restore();
          }
          splitLabels(g, w, h, u, '그냥 늘리기', '9분할');
        },
        controls: [{ type: 'toggle', label: '자르는 선 보기', value: true, on: (v) => (guides = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [반짝 단추 그림 (glossy)](https://ai-techstudio.web.app/ai/t/u60.md) `u60` · [SVG 필터 (종이 결 feTurbulence)](https://ai-techstudio.web.app/ai/t/u58.md) `u58`
- 참고 문서: [Wikipedia — 9-slice scaling](https://en.wikipedia.org/wiki/9-slice_scaling) · [MDN — border-image-slice](https://developer.mozilla.org/en-US/docs/Web/CSS/border-image-slice)
