# AI 꾸러미 — 마커 · 형광펜 (겹치면 진하게) — Multiply blend mode
> 마커 · 형광펜 줄을 곱하기(multiply) 혼합으로 그어, 겹친 줄은 더 진해지고 아래 인쇄 선 · 글씨는 가려지지 않고 비쳐 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i329

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

## 주문서

### 만들어 줘: 마커 · 형광펜 (겹치면 진하게) — Multiply blend mode

#### 1. 목표
중요 표시 · 밑줄에 마커 · 형광펜 줄을 넣어 줘 — 곱하기 혼합으로 겹친 줄이 진하고 아래 글씨가 비치게, 줄이 하나씩 쓱 그어지게. 분위기는 형광펜 공부 노트.

#### 2. 핵심 기술 용어
- **Multiply blend mode** — 곱하기 혼합 — 아래 색 × 위 색
- **Highlighter / marker stroke** — 형광펜 · 마커 줄
- **Clip to hand-drawn boundary** — 손으로 따라 그은 테두리 안에만
- **Ink pooling** — 펜을 댄 곳에 잉크가 살짝 고임

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

#### 4. 조건
- globalCompositeOperation = multiply 로 줄 긋기 (끝나면 source-over 로 되돌리기)
- 줄 끝은 lineCap butt — 형광펜처럼 네모난 끝
- 줄을 하나씩 쓱 그어지게 (시작에서 끝으로 늘이기)
- 면 칠은 손으로 따라 그은 테두리 안으로 clip
- 덮어 칠하기와 나란히 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 오른쪽은 형광 줄 아래 글씨 · 선이 그대로 비치고, 왼쪽(덮어 칠하기)은 가려진다
- 노랑 · 분홍 형광펜이 겹친 곳이 섞인 진한 색이 된다
- 마커 줄끼리 겹친 곳이 줄무늬처럼 더 진하다
- 잉크 진하기 · 줄 간격 슬라이더로 겹침 정도가 바뀐다

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

## 원리
- 곱하기: 결과 = 아래 색 × 위 색 (0 ~ 1). 흰 종이 위는 펜 색 그대로, 검은 글씨 위는 검정 그대로 — 그래서 글씨가 가려지지 않는다.
- 같은 펜 줄이 두 번 겹치면 펜 색² 이 되어 더 진하다. 노랑 위 분홍은 둘이 섞인 주황빛.
- 덮어 칠하기(source-over)는 위 색이 아래를 가린다 — 왼쪽 비교 그림에서 글씨가 사라진다.
- 면을 마커로 칠할 땐 면을 기울인 줄로 잘라 줄마다 끝을 조금 넘기고, 손으로 따라 그은 듯 살짝 큰 테두리 안으로 clip 한다. 펜을 댄 시작점엔 잉크 고임 타원(투명도 0.18).

## 핵심 코드 — 곱하기 혼합 형광펜 줄 (쓱 그어지게)
(발췌: demos/demosHandA.ts i329 panel() 의 형광펜 · 마커 부분을 정리)
```ts
type P = [number, number];
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));

/** t0 초에 시작해 0.35초 동안 a → b 로 그어지는 형광펜 */
function highlight(g: CanvasRenderingContext2D, a: P, b: P, color: string, width: number,
                   tc: number, t0: number, ink = 0.85, multiply = true) {
  const f = clamp01((tc - t0) / 0.35);
  if (f <= 0) return;
  g.save();
  g.globalCompositeOperation = multiply ? 'multiply' : 'source-over'; // 겹치면 진하게 · 글씨 비침
  g.globalAlpha = (multiply ? ink : 1) * 0.9;
  g.strokeStyle = color;
  g.lineCap = 'butt';                          // 형광펜 네모 끝
  g.lineWidth = width;
  g.beginPath();
  g.moveTo(a[0], a[1]);
  g.lineTo(a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f);
  g.stroke();
  if (multiply) {                              // 펜을 댄 곳 잉크 고임
    g.globalAlpha = ink * 0.18;
    g.fillStyle = color;
    g.beginPath();
    g.ellipse(a[0], a[1], width * 0.22, width * 0.48, Math.atan2(b[1] - a[1], b[0] - a[0]), 0, Math.PI * 2);
    g.fill();
  }
  g.restore();
}
// 글씨를 먼저 그리고 그 위에: highlight(g, [x0, y], [x1, y], '#ffe94d', 글자 크기 × 1.05, tc, 2.7)
//                         highlight(g, [x2, y + 1], [x3, y + 1], '#ff8cc6', …, tc, 3.1)
```

## 흔한 실수 · 확인 목록
- [ ] **형광펜을 source-over 로 그으면 아래 글씨가 가려진다** — multiply 로 — 흰 곳만 물들고 검은 글씨는 그대로 비친다.
- [ ] **어두운 배경에서 곱하기 줄이 안 보인다** — 곱하기는 밝은 종이용. 어두운 화면이면 screen · lighter 를 쓴다.
- [ ] **혼합 모드를 되돌리지 않아 다음 그림까지 곱해진다** — save · restore 로 감싸거나 끝나면 source-over 로 되돌린다.

## 완성 기준 체크리스트
- [ ] 오른쪽은 형광 줄 아래 글씨 · 선이 그대로 비치고, 왼쪽(덮어 칠하기)은 가려진다
- [ ] 노랑 · 분홍 형광펜이 겹친 곳이 섞인 진한 색이 된다
- [ ] 마커 줄끼리 겹친 곳이 줄무늬처럼 더 진하다
- [ ] 잉크 진하기 · 줄 간격 슬라이더로 겹침 정도가 바뀐다

## 이 기술 정보
- id: `i329` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 혼합 모드만 바꾼 stroke — 비용 차이 거의 없음.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i329
- 쓰면 좋을 때: 중요 표시 · 밑줄 · 정답 형광펜 / 마커로 칠한 일러스트 느낌
- 쓰지 말 때: 어두운 바탕 — 곱하기는 더 어둡게만 해서 안 보인다. 대신 screen 이나 lighter / 불투명하게 덮어야 하는 칠 — 그냥 source-over

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i329 견본 항목 — `src/demos/demosHandA.ts:1695`
```ts
  i329: {
    kind: '2d',
    caption: '왼쪽 그냥 덮어 칠하면 글씨 · 선이 가려져요 — 오른쪽 곱하기 혼합은 겹친 줄이 진해지고 아래 글씨가 비쳐요',
    make() {
      let multiply = true;
      let ink = 0.85;
      let gapK = 1;
      let pick = 0;
      let split = true;
      interface MS { a: P; b: P; color: string; w: number; clip: P[] }
      const cache = new Map<string, MS[]>();
      const strokesOf = (s: Subject, i: number): MS[] => {
        const key = `${i}-${gapK}`;
        let o = cache.get(key);
        if (o) return o;
        o = [];
        const r = mulberry(i * 7 + 1);
        const w = 0.07;
        s.fills.forEach((f, fi) => {
          // 손으로 테두리 따라 칠한 경계 (살짝 넘침 · 흔들림)
          const c = centroid(f.pts);
          const small = f.pts.length > 10;
          const ex = f.pts.map((q): P => [c[0] + (q[0] - c[0]) * (small ? 1.15 : 1.035), c[1] + (q[1] - c[1]) * (small ? 1.15 : 1.035)]);
          const clip = handPath(ex, true, i * 31 + fi * 7, 0.007, 0, 0.01, 1).p;
          const ang = 0.35 + r() * 0.3;
          const rows = spans(f.pts, ang, w * 0.78 * gapK * (f.pts.length > 10 ? 0.6 : 1));
          const ca = Math.cos(ang);
          const sa = Math.sin(ang);
          for (const [a, b] of rows) {
            const e0 = (0.15 + r() * 0.5) * w;
            const e1 = (0.15 + r() * 0.5) * w;
            o.push({ a: [a[0] - ca * e0, a[1] - sa * e0], b: [b[0] + ca * e1, b[1] + sa * e1], color: f.color, w: small ? w * 0.6 : w, clip });
          }
        });
        cache.set(key, o);
        return o;
      };
      const panel = (g: G, p: Panel, s: Subject, ms: MS[], tc: number, mul: boolean, u: number, a: number): void => {
        const S = p.S;
        const T = (q: P): P => [p.sx + q[0] * S, p.sy + q[1] * S];
        // 인쇄된 선 · 글씨
        g.save();
        g.globalAlpha = a;
        g.strokeStyle = INK;
        g.lineWidth = S * 0.012;
        for (const l of s.lines) {
          linePath(g, l.pts.map(T), l.closed);
          g.stroke();
        }
        g.fillStyle = INK;
        for (const dt of s.dots) {
          const c = T(dt.c);
          g.beginPath();
          g.arc(c[0], c[1], dt.r * S, 0, TAU);
          g.fill();
        }
        const fs = Math.max(8 * u, S * 0.11);
        g.font = `700 ${fs}px ${F}`;
        g.textAlign = 'center';
        g.textBaseline = 'middle';
        const ty = p.sy + S + fs * 0.75;
        g.fillText(s.name, p.x + p.w / 2, ty);
        const tw = g.measureText(s.name).width;
        g.textAlign = 'left';
        // 마커 줄 — 하나씩 쓱
        g.globalCompositeOperation = mul ? 'multiply' : 'source-over';
        g.lineCap = 'butt';
        const inkA = mul ? ink : 1;
        const n = ms.length;
        ms.forEach((m, k) => {
          const t0 = 0.3 + (k / n) * 2.3;
          const f = clamp01((tc - t0) / 0.1);
          if (f <= 0) return;
          const A = T(m.a);
          const B0 = T(m.b);
          const B: P = [lerp(A[0], B0[0], f), lerp(A[1], B0[1], f)];
          g.save();
          linePath(g, m.clip.map(T), true);
          g.clip();
          g.globalAlpha = a * inkA;
          g.strokeStyle = m.color;
          g.lineWidth = m.w * S;
          g.beginPath();
          g.moveTo(A[0], A[1]);
          g.lineTo(B[0], B[1]);
          g.stroke();
          // 펜을 댄 곳에 잉크가 살짝 고임
          if (mul) {
            g.globalAlpha = a * ink * 0.18;
            g.fillStyle = m.color;
            g.beginPath();
            g.ellipse(A[0], A[1], m.w * S * 0.22, m.w * S * 0.48, Math.atan2(B0[1] - A[1], B0[0] - A[0]), 0, TAU);
            g.fill();
          }
          g.restore();
        });
        // 형광펜 두 줄 (노랑 · 분홍이 겹침)
        const hl = (x0: number, x1: number, col: string, t0: number, dy: number): void => {
          const f = clamp01((tc - t0) / 0.35);
          if (f <= 0) return;
          g.globalAlpha = a * inkA * 0.9;
          g.strokeStyle = col;
          g.lineCap = 'butt';
          g.lineWidth = fs * 1.05;
          g.beginPath();
          g.moveTo(x0, ty + dy);
          g.lineTo(lerp(x0, x1, f), ty + dy - fs * 0.06 * f);
          g.stroke();
        };
        const cx = p.x + p.w / 2;
        hl(cx - tw * 0.62, cx + tw * 0.4, '#ffe94d', 2.7, 0);
        hl(cx - tw * 0.05, cx + tw * 0.62, '#ff8cc6', 3.1, fs * 0.08);
        g.restore();
      };
      return {
        draw(g, w, h, t) {
          const { i, tc, a } = cyc(t, 5, pick);
          const s = SUBJ[i]!;
          const st = stage(g, w, h, split, '덮어 칠하기', multiply ? '곱하기 혼합' : '곱하기 끔', '#d6336c', 'white', 20);
          const ms = strokesOf(s, i);
          if (st.L) panel(g, st.L, s, ms, tc, false, st.u, a);
          panel(g, st.R, s, ms, tc, multiply, st.u, a);
        },
        controls: [
          { type: 'toggle', label: '곱하기 혼합 (오른쪽)', value: true, on: (v) => (multiply = v) },
          { type: 'range', label: '잉크 진하기', min: 0.3, max: 1, step: 0.05, value: 0.85, on: (v) => (ink = v) },
          { type: 'range', label: '줄 간격 (작을수록 많이 겹침)', min: 0.5, max: 1.3, step: 0.05, value: 1, on: (v) => { gapK = v; cache.clear(); } },
          pickCtl((v) => (pick = v)),
          splitCtl((v) => (split = v)),
        ],
        dispose() {
          cache.clear();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [지그재그 칠하기 (scribble fill)](https://ai-techstudio.web.app/ai/t/i326.md) `i326`
- 다음에 해 볼 기술: [색연필 · 크레파스 결 (종이 이)](https://ai-techstudio.web.app/ai/t/i330.md) `i330`
- 참고 문서: [MDN — globalCompositeOperation](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation)
