# AI 꾸러미 — 종이 오려 붙이기 (콜라주) — Paper cut-out collage (torn paper edge)
> 도형 테두리를 잡음으로 들쭉날쭉 밀고 흰 속지 띠 · 종이 결 · 흐린 그림자 · 살짝 기운 각도를 더해 색종이를 찢어 붙인 콜라주로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i332

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

## 주문서

### 만들어 줘: 종이 오려 붙이기 (콜라주) — Paper cut-out collage (torn paper edge)

#### 1. 목표
도형 친구들 이야기 장면을 색종이를 찢어 붙인 콜라주 그림체로 그려 줘 — 찢긴 흰 테두리, 종이 결, 살짝 기운 조각, 바닥에 떨어지는 그림자까지. 분위기는 크라프트지 위 색종이.

#### 2. 핵심 기술 용어
- **Paper cut-out collage (torn paper edge)** — 종이 오려 붙이기 — 찢긴 가장자리
- **Polygon edge displacement along normal** — 변을 잘게 나눠 바깥 방향으로 잡음만큼 밀기
- **Offscreen canvas sprite + blur shadow** — 조각마다 미리 구운 그림 · 흐린 그림자
- **Stop-motion (stepped time)** — 시간을 1/12초씩 끊어 스톱모션 느낌

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

#### 4. 조건
- 찢긴 모양 · 종이 결은 시드 고정 잡음으로 — 프레임마다 모양이 바뀌면 안 된다
- 조각 그림 · 그림자는 미리 캔버스에 굽고, 화면 크기나 찢긴 정도가 바뀔 때만 다시 굽는다
- 흰 속지 테두리 굵기는 들쭉날쭉하게 (한 굵기면 스티커처럼 보인다)
- 그림자는 흐리게 · 반투명(0.4 정도) · 오른쪽 아래로 1~2 단위 비켜서

#### 5. 완성 기준 (이게 보이면 성공)
- 조각마다 찢긴 흰 테두리와 종이 결, 바닥 그림자가 보인다
- 「찢긴 정도」 슬라이더(0.2 ~ 2.4)로 반듯한 오림부터 거칠게 찢은 것까지 바뀐다
- 가르는 선으로 반듯한 원본과 나란히 비교된다
- 들썩이는 조각은 그림자가 커지고 멀어져 떠오르는 것처럼 보인다

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

## 원리
- 도형의 각 변을 0.7 단위마다 잘게 나누고, 그 점을 변의 바깥 방향(법선)으로 잡음만큼 민다 — 찢긴 선이 된다.
- 색 면은 살짝 안쪽으로, 흰 속지 테두리는 조금 바깥으로 밀되 굵기를 낮은 잡음으로 들쭉날쭉하게 — 찢을 때 드러나는 흰 섬유.
- 조각마다 따로 캔버스에 구워(색 · 무늬 · 종이 결 · 옅은 사선 그러데이션) 두고, 같은 모양을 검게 흐린 그림자도 함께 굽는다.
- 그릴 때는 조각을 조금씩 돌리고 그림자를 오른쪽 아래로 비켜 깐다. 시간을 1/12초씩 끊어 하나씩 들썩이면 스톱모션처럼 보인다.

## 핵심 코드 — 찢긴 가장자리 — 색 면 테두리와 흰 속지 테두리 두 겹
(발췌: demos/demosHandB.ts torn() 을 정리)
```ts
type P = [number, number];
// hash2 · n1 = 시드 고정 잡음 (0~1 · -1~1)
function torn(pts: P[], seed: number, rough: number, rim: number): { col: P[]; wht: P[] } {
  let area = 0;
  for (let i = 0; i < pts.length; i++) {
    const a = pts[i], b = pts[(i + 1) % pts.length];
    area += a[0] * b[1] - b[0] * a[1];
  }
  const sg = area > 0 ? 1 : -1;               // 돌림 방향에 맞춰 바깥 법선 쪽을 고른다
  const col: P[] = [], wht: P[] = [];
  let s = 0, j = 0;
  for (let i = 0; i < pts.length; i++) {
    const a = pts[i], b = pts[(i + 1) % pts.length];
    const dx = b[0] - a[0], dy = b[1] - a[1];
    const L = Math.hypot(dx, dy) || 1;
    const nx = (dy / L) * sg, ny = (-dx / L) * sg;
    const k = Math.max(1, Math.ceil(L / 0.7));
    for (let q = 0; q < k; q++) {
      const f = q / k, ss = s + L * f;
      const px = a[0] + dx * f, py = a[1] + dy * f;
      const fine = hash2(j, seed) - 0.5;
      // 색 면: 낮은 잡음 + 잔 들쭉, 살짝 안쪽
      const co = (n1(ss * 0.2, seed) * 0.4 + fine * 0.3) * rough - 0.15 * rough;
      // 흰 속지: 굵기가 길이를 따라 들쭉날쭉
      const rw = rim > 0 ? rough * rim * (0.5 + 1.7 * Math.max(0, n1(ss * 0.08, seed + 5))) + 0.25 : 0;
      const wo = co + rw + (hash2(j, seed + 1) - 0.5) * 0.45 * rough * rim;
      col.push([px + nx * co, py + ny * co]);
      wht.push([px + nx * wo, py + ny * wo]);
      j++;
    }
    s += L;
  }
  return { col, wht };
}
```

## 흔한 실수 · 확인 목록
- [ ] **변을 그대로 두고 꼭짓점만 흔들면 찢긴 느낌이 안 난다** — 변을 잘게(0.7 단위) 나눈 점마다 밀어야 섬유처럼 잔 들쭉이 생긴다.
- [ ] **도형 돌림 방향이 반대면 테두리가 안쪽으로 파인다** — 넓이 부호로 돌림 방향을 구해 법선 부호를 맞춘다.
- [ ] **그림자를 매 프레임 blur 필터로 그리면 느리다** — 그림자는 조각과 함께 한 번만 흐려 구워 두고 drawImage 로 깐다.
- [ ] **조각을 모두 똑같이 기울이면 기계로 찍은 것 같다** — 시드 난수로 조각마다 다른 작은 각(±0.05 라디안 안팎)을 준다.

## 완성 기준 체크리스트
- [ ] 조각마다 찢긴 흰 테두리와 종이 결, 바닥 그림자가 보인다
- [ ] 「찢긴 정도」 슬라이더(0.2 ~ 2.4)로 반듯한 오림부터 거칠게 찢은 것까지 바뀐다
- [ ] 가르는 선으로 반듯한 원본과 나란히 비교된다
- [ ] 들썩이는 조각은 그림자가 커지고 멀어져 떠오르는 것처럼 보인다

## 이 기술 정보
- id: `i332` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 조각은 처음 한 번(또는 찢긴 정도를 바꿀 때만) 굽고, 매 프레임은 drawImage 몇 장뿐. 크라프트 배경도 한 번만 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i332
- 쓰면 좋을 때: 동화 · 이야기 장면을 따뜻한 손작업 느낌으로 / 조각이 하나씩 들썩이는 메뉴 · 제목 연출
- 쓰지 말 때: 조각이 계속 모양을 바꾸는 장면 — 찢긴 모양은 미리 구워 두는 방식이라 매번 다시 굽으면 무겁다 / 정확한 도형을 읽어야 하는 문제 화면 — 테두리가 흔들려 길이 · 각을 재기 어렵다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeCollage — `src/demos/demosHandB.ts:937`
```ts
function makeCollage() {
  let rough = 1.2;
  let tilt = 1;
  let cmp = true;
  let kBg = '';
  let bgC: HTMLCanvasElement | null = null;
  let kP = '';
  let pieces: Piece[] = [];
  const items: Item[] = [
    { k: 'seven', x: 62, y: 100, s: 78 },
    { k: 'tri', x: 150, y: 104, s: 74 },
    { k: 'pie', x: 232, y: 58, s: 27 },
    { k: 'cube', x: 222, y: 134, s: 32 },
    { k: 'text', x: 18, y: 30, s: 15, str: '도형 친구' },
  ];
  const build = (f: Fit): void => {
    const sc = f.u * f.s;
    const r = rng(17);
    const T = (a: number): number => (r() - 0.5) * a * tilt;
    const mv = (p: P[], dx: number, dy: number): P[] => p.map((q) => [q[0] + dx, q[1] + dy] as P);
    const out: Piece[] = [];
    // 정육면체 — 세 면을 따로 오린 종이 (윗면은 모눈종이)
    const cb = cube(222, 134, 32);
    out.push(piece(mv(cb.side, 0.9, 0.2), paintFlat('#226e69'), sc, 3, rough, T(0.1), true));
    out.push(piece(mv(cb.top, 0.2, -0.9), paintGraph('#f4efdf', 'rgba(60,110,200,0.55)', 3.2), sc, 4, rough, T(0.1), true));
    out.push(piece(cb.front, paintFlat('#33a49c'), sc, 5, rough, T(0.08), true));
    // 원그래프 — 조각마다 다른 종이, 살짝 벌어지게
    const pz: ((g: G, b: Box) => void)[] = [paintFlat('#f08a3c'), paintDots('#3b78d8', 'rgba(255,255,255,0.35)', 3.6, 0.55), paintStripes('#45ad69', 'rgba(255,255,255,0.28)', 3.4, 1.1)];
    pieSlices(232, 58, 27).forEach((sl, i) => {
      out.push(piece(mv(sl.pts, Math.cos(sl.mid) * 2.4, Math.sin(sl.mid) * 2.4), pz[i]!, sc, 10 + i, rough, T(0.14), true));
    });
    // 숫자 7 — 물방울 무늬 빨간 종이
    out.push(piece(seven(62, 100, 78), paintDots('#e2483d', 'rgba(130,12,10,0.32)', 5, 1.15), sc, 20, rough, T(0.12) - 0.05 * tilt, false));
    // 웃는 세모 — 눈 · 볼 · 입도 따로 오려 붙인 종이
    const tr = tri(150, 104, 74);
    out.push(
      piece(tr.pts, paintFlat('#f5c33b'), sc, 21, rough, T(0.08) + 0.03 * tilt, false, (g) => {
        g.fillStyle = 'rgba(0,0,0,0.2)';
        tr.ch.forEach((p) => {
          g.beginPath();
          g.arc(p[0] + 0.4, p[1] + 0.6, tr.cr * 1.05, 0, TAU);
          g.fill();
        });
        g.fillStyle = '#f68aa1';
        tr.ch.forEach((p) => {
          g.beginPath();
          g.arc(p[0], p[1], tr.cr * 1.05, 0, TAU);
          g.fill();
        });
        tr.eyes.forEach((p) => {
          g.fillStyle = 'rgba(0,0,0,0.25)';
          g.beginPath();
          g.arc(p[0] + 0.5, p[1] + 0.7, tr.er * 1.3, 0, TAU);
          g.fill();
          g.fillStyle = '#1d1d22';
          g.beginPath();
          g.arc(p[0], p[1], tr.er * 1.3, 0, TAU);
          g.fill();
          g.fillStyle = '#fff';
          g.beginPath();
          g.arc(p[0] - tr.er * 0.4, p[1] - tr.er * 0.45, tr.er * 0.42, 0, TAU);
          g.fill();
        });
        g.lineCap = 'round';
        g.lineWidth = 2.6;
        g.strokeStyle = 'rgba(0,0,0,0.22)';
        g.beginPath();
        g.arc(tr.sm.c[0] + 0.5, tr.sm.c[1] + 0.7, tr.sm.r, tr.sm.a0, tr.sm.a1);
        g.stroke();
        g.strokeStyle = '#1d1d22';
        g.beginPath();
        g.arc(tr.sm.c[0], tr.sm.c[1], tr.sm.r, tr.sm.a0, tr.sm.a1);
        g.stroke();
      }),
    );
    // 제목 — 글자마다 다른 종이에서 오린 협박 편지 글자
    const TL: [string, string, string, string][] = [
      ['도', '#f7f3e8', '#1f1f1f', F],
      ['형', '#e9473b', '#fff6e8', 'serif'],
      ['친', '#2d2d2d', '#f6d04d', F],
      ['구', '#4d8be0', '#ffffff', 'serif'],
    ];
    TL.forEach(([ch, bgc, fg, font], i) => {
      const x = 26 + i * 16 + (i > 1 ? 6 : 0);
      const y = 24 + (r() - 0.5) * 3;
      const a = T(0.4);
      const big = 11 + r() * 3;
      out.push(
        piece(rectPts(x, y, 13.5, 16, 0, r, 1.6), paintFlat(bgc), sc, 30 + i, rough * 0.7, a, true, (g) => {
          g.fillStyle = fg;
          g.font = `900 ${big}px ${font}`;
          g.textAlign = 'center';
          g.textBaseline = 'middle';
          g.fillText(ch, x, y + 0.8);
        }),
      );
    });
    // 투명 테이프
    const tape = (cx: number, cy: number, a: number, sd: number): Piece => piece(rectPts(cx, cy, 22, 8, a, r, 0.6), paintFlat('rgba(240,228,192,0.72)'), sc, sd, 0.9, 0, false, undefined, 0, 0.12);
    out.push(tape(150, 66, 0.25, 40), tape(40, 60, -0.55, 41), tape(253, 36, 0.6, 42));
    pieces = out;
  };
  return {
    draw(g: G, w: number, h: number, t: number): void {
      reset(g);
      const f = fitOf(g, w, h);
      const k1 = keyOf(f);
      if (k1 !== kBg) {
        bgC = bakeKraft(f);
        kBg = k1;
      }
      const k2 = keyOf(f, rough, tilt);
      if (k2 !== kP) {
        build(f);
        kP = k2;
      }
      g.drawImage(bgC!, 0, 0, w, h);
      enter(g, f);
      const lifters = pieces.map((p, i) => (p.lift ? i : -1)).filter((i) => i >= 0);
      const tq = Math.floor(t * 12) / 12; // 스톱모션처럼 12장/초
      const per = 2.2;
      const act = lifters[Math.floor(tq / per) % lifters.length] ?? -1;
      const ph = (tq % per) / per;
      const lift = Math.sin(Math.PI * clamp01((ph - 0.1) / 0.6));
      pieces.forEach((p, i) => drawPiece(g, p, i === act ? lift : 0));
      g.restore();
      if (cmp) splitter(g, w, h, t, () => drawClean(g, f, items, '#f4f2ec'), '콜라주');
    },
    controls: [
      { type: 'range', label: '찢긴 정도', min: 0.2, max: 2.4, step: 0.1, value: rough, on: (v) => (rough = v) },
      { type: 'range', label: '조각 기울기', min: 0, max: 2.5, step: 0.1, value: tilt, on: (v) => (tilt = v) },
      cmpToggle(cmp, (v) => (cmp = v)),
    ] as Control[],
  };
}
```

## 관련 기술
- 먼저 알면 좋은 기술: [색연필 · 크레파스 결 (종이 이)](https://ai-techstudio.web.app/ai/t/i330.md) `i330`
- 다음에 해 볼 기술: [만화 펜선 + 스크린톤](https://ai-techstudio.web.app/ai/t/i335.md) `i335`
