test

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>Printer ICC A2B PCS CLUT Generator</title>
  <style>
    body {
      margin: 0;
      background: #111;
      color: #eee;
      font-family: Arial, sans-serif;
      overflow: hidden;
    }

    #topBar {
      position: fixed;
      left: 0;
      top: 0;
      right: 0;
      height: 54px;
      background: rgba(0, 0, 0, 0.78);
      display: flex;
      align-items: center;
      gap: 10px;
      padding: 8px 12px;
      box-sizing: border-box;
      z-index: 20;
      border-bottom: 1px solid rgba(255,255,255,0.12);
    }

    #topBar button {
      padding: 8px 12px;
      border: 0;
      border-radius: 8px;
      cursor: pointer;
      font-weight: bold;
      background: #f2f2f2;
      color: #111;
    }

    #topBar button:hover {
      background: #ddd;
    }

    #topBar .title {
      font-weight: bold;
      color: #9dffb7;
      margin-right: auto;
      white-space: nowrap;
    }

    #main {
      position: fixed;
      top: 54px;
      bottom: 0;
      left: 0;
      right: 0;
      display: grid;
      grid-template-columns: 410px 1fr;
    }

    #leftPanel {
      background: #181818;
      border-right: 1px solid rgba(255,255,255,0.12);
      padding: 12px;
      overflow: auto;
      box-sizing: border-box;
    }

    #leftPanel h3 {
      margin: 8px 0 8px;
      color: #8ecbff;
      font-size: 15px;
    }

    textarea {
      width: 100%;
      height: 320px;
      background: #0d0d0d;
      color: #ddd;
      border: 1px solid rgba(255,255,255,0.18);
      border-radius: 8px;
      padding: 10px;
      box-sizing: border-box;
      font-family: Consolas, monospace;
      font-size: 12px;
      resize: vertical;
    }

    .control {
      display: grid;
      grid-template-columns: 132px 1fr 52px;
      gap: 8px;
      align-items: center;
      margin: 8px 0;
      font-size: 12px;
      color: #ddd;
    }

    .control input {
      width: 100%;
    }

    .note {
      font-size: 12px;
      line-height: 1.45;
      color: #bbb;
      background: rgba(255,255,255,0.05);
      border: 1px solid rgba(255,255,255,0.08);
      border-radius: 8px;
      padding: 10px;
      margin-top: 10px;
    }

    #status {
      font-size: 12px;
      line-height: 1.45;
      color: #d7f5ff;
      background: rgba(60,160,255,0.08);
      border: 1px solid rgba(60,160,255,0.18);
      border-radius: 8px;
      padding: 10px;
      margin-top: 10px;
      white-space: pre-wrap;
    }

    #canvasWrap {
      position: relative;
      overflow: hidden;
    }

    canvas {
      display: block;
      width: 100%;
      height: 100%;
      background: #101010;
    }

    .label {
      position: absolute;
      top: 12px;
      padding: 7px 10px;
      border-radius: 8px;
      background: rgba(0,0,0,0.55);
      font-weight: bold;
      font-size: 14px;
      z-index: 5;
    }

    #labelA {
      left: 18px;
      color: #8ecbff;
    }

    #labelB {
      right: 18px;
      color: #9dffb7;
    }

    .legend {
      position: absolute;
      left: 18px;
      bottom: 18px;
      z-index: 5;
      padding: 9px 11px;
      border-radius: 8px;
      background: rgba(0,0,0,0.55);
      color: #ddd;
      font-size: 12px;
      line-height: 1.5;
    }
  </style>
</head>
<body>
  <div id="topBar">
    <div class="title">프린터 ICC A2B → PCS Lab CLUT 16×16×16 생성</div>
    <button id="makeDemo">데모 측색값 생성</button>
    <button id="buildClut">A2B CLUT 생성</button>
    <button id="toggleRotation">회전 정지</button>
    <button id="downloadJson">JSON 다운로드</button>
    <button id="downloadCsv">CSV 다운로드</button>
  </div>

  <div id="main">
    <div id="leftPanel">
      <h3>입력: 프린터 측색 RGB / Lab</h3>
      <textarea id="input"></textarea>

      <h3>알고리즘 조절</h3>

      <div class="control">
        <label>CLUT size</label>
        <input id="clutSize" type="range" min="8" max="33" step="1" value="16" />
        <span id="clutSizeValue">16</span>
      </div>

      <div class="control">
        <label>localSmooth</label>
        <input id="localSmooth" type="range" min="8" max="42" step="1" value="22" />
        <span id="localSmoothValue">22</span>
      </div>

      <div class="control">
        <label>gamutGuard</label>
        <input id="gamutGuard" type="range" min="0" max="1" step="0.05" value="0.85" />
        <span id="gamutGuardValue">0.85</span>
      </div>

      <div class="control">
        <label>antiShrink</label>
        <input id="antiShrink" type="range" min="0" max="1" step="0.05" value="0.80" />
        <span id="antiShrinkValue">0.80</span>
      </div>

      <div class="control">
        <label>edgeProtect</label>
        <input id="edgeProtect" type="range" min="1" max="8" step="1" value="4" />
        <span id="edgeProtectValue">4</span>
      </div>

      <div class="control">
        <label>vertexBoost</label>
        <input id="vertexBoost" type="range" min="0" max="1" step="0.05" value="0.75" />
        <span id="vertexBoostValue">0.75</span>
      </div>

      <div class="control">
        <label>grayProtect</label>
        <input id="grayProtect" type="range" min="0" max="1" step="0.05" value="0.85" />
        <span id="grayProtectValue">0.85</span>
      </div>

      <div class="control">
        <label>edgeFeather</label>
        <input id="edgeFeather" type="range" min="0" max="1" step="0.05" value="0.30" />
        <span id="edgeFeatherValue">0.30</span>
      </div>

      <div class="note">
        입력 형식은 JSON 또는 CSV 모두 가능합니다.<br><br>
        JSON 예:<br>
        <code>[{"rgb":[100,0,0],"lab":[48,68,45]}]</code><br><br>
        CSV 예:<br>
        <code>R,G,B,L,a,b</code><br>
        <code>100,0,0,48,68,45</code><br><br>
        RGB는 0~100 범위입니다. 결과 CLUT도 RGB 0~100, PCS Lab 기준입니다.
      </div>

      <div id="status">대기 중</div>
    </div>

    <div id="canvasWrap">
      <div id="labelA" class="label">측색 프린터 Lab 미리보기</div>
      <div id="labelB" class="label">생성된 A2B CLUT Lab 미리보기</div>
      <div class="legend">
        흰 선: rgbcmy 허리라인 polygon<br>
        회색 선: black / white point 연결<br>
        붉은 점: 외곽 축소 의심 측색값<br>
        오른쪽: 16³ CLUT PCS Lab
      </div>
      <canvas id="canvas"></canvas>
    </div>
  </div>

  <script>
    // ============================================================
    // 0. 전역 상태
    // ============================================================

    const inputEl = document.getElementById("input");
    const statusEl = document.getElementById("status");
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    let measurementSamples = [];
    let clutResult = null;
    let model = null;

    let rotationEnabled = true;
    let rotationTime = 0;
    let lastTime = performance.now();

    const controls = {
      clutSize: document.getElementById("clutSize"),
      localSmooth: document.getElementById("localSmooth"),
      gamutGuard: document.getElementById("gamutGuard"),
      antiShrink: document.getElementById("antiShrink"),
      edgeProtect: document.getElementById("edgeProtect"),
      vertexBoost: document.getElementById("vertexBoost"),
      grayProtect: document.getElementById("grayProtect"),
      edgeFeather: document.getElementById("edgeFeather")
    };

    const controlValues = {
      clutSize: document.getElementById("clutSizeValue"),
      localSmooth: document.getElementById("localSmoothValue"),
      gamutGuard: document.getElementById("gamutGuardValue"),
      antiShrink: document.getElementById("antiShrinkValue"),
      edgeProtect: document.getElementById("edgeProtectValue"),
      vertexBoost: document.getElementById("vertexBoostValue"),
      grayProtect: document.getElementById("grayProtectValue"),
      edgeFeather: document.getElementById("edgeFeatherValue")
    };

    function getParams() {
      const p = {
        clutSize: Number(controls.clutSize.value),
        localSmooth: Number(controls.localSmooth.value),
        gamutGuard: Number(controls.gamutGuard.value),
        antiShrink: Number(controls.antiShrink.value),
        edgeProtect: Number(controls.edgeProtect.value),
        vertexBoost: Number(controls.vertexBoost.value),
        grayProtect: Number(controls.grayProtect.value),
        edgeFeather: Number(controls.edgeFeather.value)
      };

      for (const key of Object.keys(p)) {
        controlValues[key].textContent = String(p[key]);
      }

      return p;
    }

    for (const key of Object.keys(controls)) {
      controls[key].addEventListener("input", () => {
        getParams();
      });
    }

    getParams();

    function resizeCanvas() {
      const wrap = document.getElementById("canvasWrap");
      canvas.width = wrap.clientWidth * window.devicePixelRatio;
      canvas.height = wrap.clientHeight * window.devicePixelRatio;
      ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
    }

    window.addEventListener("resize", resizeCanvas);
    resizeCanvas();

    // ============================================================
    // 1. 기본 유틸
    // ============================================================

    function clamp(x, a, b) {
      return Math.max(a, Math.min(b, x));
    }

    function lerp(a, b, t) {
      return a * (1 - t) + b * t;
    }

    function smoothstep(edge0, edge1, x) {
      if (Math.abs(edge1 - edge0) < 1e-12) {
        return x >= edge1 ? 1 : 0;
      }

      const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
      return t * t * (3 - 2 * t);
    }

    function percentile(values, q) {
      const arr = values
        .filter(Number.isFinite)
        .slice()
        .sort((a, b) => a - b);

      if (arr.length === 0) return NaN;
      if (arr.length === 1) return arr[0];

      const pos = (arr.length - 1) * q;
      const lo = Math.floor(pos);
      const hi = Math.ceil(pos);
      const t = pos - lo;

      return arr[lo] * (1 - t) + arr[hi] * t;
    }

    function median(values) {
      return percentile(values, 0.5);
    }

    function dist3(a, b) {
      const dx = a[0] - b[0];
      const dy = a[1] - b[1];
      const dz = a[2] - b[2];
      return Math.sqrt(dx * dx + dy * dy + dz * dz);
    }

    function chroma(lab) {
      return Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2]);
    }

    function hueAngle(lab) {
      let h = Math.atan2(lab[2], lab[1]);
      if (h < 0) h += Math.PI * 2;
      return h;
    }

    function angleDiff(a, b) {
      let d = Math.abs(a - b) % (Math.PI * 2);
      if (d > Math.PI) d = Math.PI * 2 - d;
      return d;
    }

    function rgbDistance(a, b) {
      const dr = a[0] - b[0];
      const dg = a[1] - b[1];
      const db = a[2] - b[2];
      return Math.sqrt(dr * dr + dg * dg + db * db);
    }

    function rgbColor(rgb, alpha = 1) {
      const r = Math.round(clamp(rgb[0], 0, 100) * 2.55);
      const g = Math.round(clamp(rgb[1], 0, 100) * 2.55);
      const b = Math.round(clamp(rgb[2], 0, 100) * 2.55);
      return `rgba(${r},${g},${b},${alpha})`;
    }

    function downloadText(filename, text, mime) {
      const blob = new Blob([text], { type: mime });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    }

    // ============================================================
    // 2. Demo printer RGB/Lab 생성
    // ============================================================

    function srgbToLinear(c) {
      c = c / 255;
      return c <= 0.04045
        ? c / 12.92
        : Math.pow((c + 0.055) / 1.055, 2.4);
    }

    function xyzToLab(x, y, z) {
      const Xn = 95.047;
      const Yn = 100.000;
      const Zn = 108.883;

      let xr = x / Xn;
      let yr = y / Yn;
      let zr = z / Zn;

      function f(t) {
        return t > 0.008856
          ? Math.pow(t, 1 / 3)
          : 7.787 * t + 16 / 116;
      }

      const fx = f(xr);
      const fy = f(yr);
      const fz = f(zr);

      return [
        116 * fy - 16,
        500 * (fx - fy),
        200 * (fy - fz)
      ];
    }

    function rgb100ToSyntheticPrinterLab(rgb) {
      const R = clamp(rgb[0], 0, 100) * 2.55;
      const G = clamp(rgb[1], 0, 100) * 2.55;
      const B = clamp(rgb[2], 0, 100) * 2.55;

      const r = srgbToLinear(R);
      const g = srgbToLinear(G);
      const b = srgbToLinear(B);

      let X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) * 100;
      let Y = (0.2126729 * r + 0.7151522 * g + 0.0721750 * b) * 100;
      let Z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) * 100;

      let lab = xyzToLab(X, Y, Z);

      const maxc = Math.max(rgb[0], rgb[1], rgb[2]);
      const minc = Math.min(rgb[0], rgb[1], rgb[2]);
      const sat = (maxc - minc) / 100;

      // 프린터답게 L과 chroma를 약간 줄이고, yellow/red는 비교적 강하게 둠.
      let L = 5.5 + lab[0] * 0.88;
      let a = lab[1] * (0.70 + 0.08 * sat);
      let bb = lab[2] * (0.72 + 0.10 * sat);

      // 종이 white / black 한계
      L = clamp(L, 4.2, 94.5);

      // 프린터 색역 비대칭성
      const h = Math.atan2(bb, a);
      const yellowBoost = Math.max(0, Math.cos(h - 1.62));
      const blueLoss = Math.max(0, Math.cos(h + 1.62));

      bb += yellowBoost * sat * 4.5;
      a -= blueLoss * sat * 3.2;

      return [L, a, bb];
    }

    function seededRandom(seed) {
      const x = Math.sin(seed * 12.9898) * 43758.5453123;
      return x - Math.floor(x);
    }

    function makeDemoSamples() {
      const samples = [];
      let seed = 1;

      const must = [
        [0, 0, 0],
        [100, 100, 100],
        [100, 0, 0],
        [100, 100, 0],
        [0, 100, 0],
        [0, 100, 100],
        [0, 0, 100],
        [100, 0, 100],
        [50, 50, 50],
        [25, 25, 25],
        [75, 75, 75]
      ];

      for (const rgb of must) {
        const lab = rgb100ToSyntheticPrinterLab(rgb);
        samples.push({
          rgb,
          lab: lab.map(v => +v.toFixed(3))
        });
      }

      // 실제 측색처럼 불규칙 샘플 생성
      for (let i = 0; i < 180; i++) {
        let r, g, b;

        if (i < 80) {
          // 외곽 중심 샘플
          r = Math.round(seededRandom(seed++) * 100);
          g = Math.round(seededRandom(seed++) * 100);
          b = Math.round(seededRandom(seed++) * 100);

          const face = Math.floor(seededRandom(seed++) * 6);
          if (face === 0) r = 0;
          if (face === 1) r = 100;
          if (face === 2) g = 0;
          if (face === 3) g = 100;
          if (face === 4) b = 0;
          if (face === 5) b = 100;
        } else {
          r = Math.round(seededRandom(seed++) * 100);
          g = Math.round(seededRandom(seed++) * 100);
          b = Math.round(seededRandom(seed++) * 100);
        }

        const rgb = [r, g, b];
        let lab = rgb100ToSyntheticPrinterLab(rgb);

        const sat = (Math.max(r, g, b) - Math.min(r, g, b)) / 100;
        const edge = Math.max(
          1 - Math.min(r, 100 - r) / 50,
          1 - Math.min(g, 100 - g) / 50,
          1 - Math.min(b, 100 - b) / 50
        );

        // 측정 노이즈
        lab[0] += (seededRandom(seed++) - 0.5) * 1.2;
        lab[1] += (seededRandom(seed++) - 0.5) * 1.6 * sat;
        lab[2] += (seededRandom(seed++) - 0.5) * 1.6 * sat;

        // 일부 외곽 out-of-gamut 압축 현상 데모
        let outOfGamut = false;
        const isProblem =
          edge > 0.82 &&
          sat > 0.60 &&
          (
            (r > 84 && g < 30 && b < 35) ||
            (r < 25 && g > 75 && b > 75) ||
            (r < 25 && g < 30 && b > 75)
          );

        if (isProblem) {
          outOfGamut = true;

          const C = chroma(lab);
          const h = hueAngle(lab);
          const newC = C * 0.72;

          lab[1] = Math.cos(h) * newC;
          lab[2] = Math.sin(h) * newC;
          lab[0] -= 1.0;
        }

        samples.push({
          rgb,
          lab: lab.map(v => +v.toFixed(3)),
          outOfGamut
        });
      }

      return samples;
    }

    function loadDemo() {
      const demo = makeDemoSamples();
      inputEl.value = JSON.stringify(demo, null, 2);
      statusEl.textContent =
        "데모 측색값 생성 완료\n" +
        "RGB/Lab 샘플 수: " + demo.length + "\n" +
        "붉은 outOfGamut 샘플 일부 포함";
    }

    // ============================================================
    // 3. 입력 파싱
    // ============================================================

    function normalizeSample(sample) {
      let rgb;
      let lab;
      let outOfGamut = false;

      if (Array.isArray(sample)) {
        if (sample.length < 6) return null;

        rgb = [
          Number(sample[0]),
          Number(sample[1]),
          Number(sample[2])
        ];

        lab = [
          Number(sample[3]),
          Number(sample[4]),
          Number(sample[5])
        ];

        const meta = sample[6];

        if (meta === false) outOfGamut = true;
        if (meta && typeof meta === "object") {
          if (meta.outOfGamut === true) outOfGamut = true;
          if (meta.inGamut === false) outOfGamut = true;
        }
      } else if (sample && typeof sample === "object") {
        rgb = sample.rgb || sample.RGB;
        lab = sample.lab || sample.Lab || sample.LAB;

        if (!rgb || !lab) return null;

        rgb = [Number(rgb[0]), Number(rgb[1]), Number(rgb[2])];
        lab = [Number(lab[0]), Number(lab[1]), Number(lab[2])];

        if (sample.outOfGamut === true) outOfGamut = true;
        if (sample.inGamut === false) outOfGamut = true;
        if (sample.gamut === "out" || sample.gamut === "oog") outOfGamut = true;
      } else {
        return null;
      }

      if (
        !rgb.every(Number.isFinite) ||
        !lab.every(Number.isFinite)
      ) {
        return null;
      }

      rgb = rgb.map(v => clamp(v, 0, 100));

      return {
        rgb,
        lab,
        outOfGamut
      };
    }

    function parseInput(text) {
      text = text.trim();

      if (!text) {
        throw new Error("입력값이 비어 있습니다.");
      }

      let raw;

      try {
        raw = JSON.parse(text);
        if (!Array.isArray(raw)) {
          throw new Error("JSON은 배열이어야 합니다.");
        }

        const parsed = raw
          .map(normalizeSample)
          .filter(Boolean);

        if (parsed.length < 8) {
          throw new Error("유효한 샘플이 너무 적습니다. 최소 8개 이상 필요합니다.");
        }

        return parsed;
      } catch (e) {
        // CSV fallback
      }

      const lines = text
        .split(/\r?\n/)
        .map(v => v.trim())
        .filter(v => v && !v.startsWith("#"));

      const parsed = [];

      for (const line of lines) {
        if (/^[A-Za-z]/.test(line)) continue;

        const parts = line
          .split(/[,\t ]+/)
          .map(Number)
          .filter(Number.isFinite);

        if (parts.length >= 6) {
          parsed.push({
            rgb: [
              clamp(parts[0], 0, 100),
              clamp(parts[1], 0, 100),
              clamp(parts[2], 0, 100)
            ],
            lab: [parts[3], parts[4], parts[5]],
            outOfGamut: parts[6] === 1
          });
        }
      }

      if (parsed.length < 8) {
        throw new Error("CSV 파싱 실패 또는 유효 샘플 부족.");
      }

      return parsed;
    }

    // ============================================================
    // 4. 다항 회귀 + 잔차 보간용 수학
    // ============================================================

    function basisRGB(rgb) {
      const r = rgb[0] / 100;
      const g = rgb[1] / 100;
      const b = rgb[2] / 100;

      return [
        1,
        r, g, b,
        r * g, r * b, g * b,
        r * r, g * g, b * b,
        r * g * b
      ];
    }

    function solveLinearSystem(A, b) {
      const n = A.length;
      const M = A.map((row, i) => row.concat([b[i]]));

      for (let col = 0; col < n; col++) {
        let pivot = col;

        for (let row = col + 1; row < n; row++) {
          if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
            pivot = row;
          }
        }

        if (Math.abs(M[pivot][col]) < 1e-12) {
          return null;
        }

        [M[col], M[pivot]] = [M[pivot], M[col]];

        const div = M[col][col];

        for (let k = col; k <= n; k++) {
          M[col][k] /= div;
        }

        for (let row = 0; row < n; row++) {
          if (row === col) continue;

          const factor = M[row][col];

          for (let k = col; k <= n; k++) {
            M[row][k] -= factor * M[col][k];
          }
        }
      }

      return M.map(row => row[n]);
    }

    function fitPolynomial(samples, channelIndex) {
      const dim = basisRGB([0, 0, 0]).length;
      const A = Array.from({ length: dim }, () => Array(dim).fill(0));
      const b = Array(dim).fill(0);

      for (const s of samples) {
        const x = basisRGB(s.rgb);
        const y = s.lab[channelIndex];

        const w = s.outOfGamut ? 0.22 : 1.0;

        for (let i = 0; i < dim; i++) {
          b[i] += w * x[i] * y;

          for (let j = 0; j < dim; j++) {
            A[i][j] += w * x[i] * x[j];
          }
        }
      }

      for (let i = 0; i < dim; i++) {
        A[i][i] += 1e-6;
      }

      const coef = solveLinearSystem(A, b);

      if (!coef) {
        return Array(dim).fill(0);
      }

      return coef;
    }

    function evalPolynomial(coef, rgb) {
      const x = basisRGB(rgb);
      let y = 0;

      for (let i = 0; i < coef.length; i++) {
        y += coef[i] * x[i];
      }

      return y;
    }

    function fitA2BBase(samples) {
      const coefL = fitPolynomial(samples, 0);
      const coefa = fitPolynomial(samples, 1);
      const coefb = fitPolynomial(samples, 2);

      const fitted = samples.map(s => {
        const base = [
          evalPolynomial(coefL, s.rgb),
          evalPolynomial(coefa, s.rgb),
          evalPolynomial(coefb, s.rgb)
        ];

        return {
          ...s,
          base,
          residual: [
            s.lab[0] - base[0],
            s.lab[1] - base[1],
            s.lab[2] - base[2]
          ]
        };
      });

      return {
        coefL,
        coefa,
        coefb,
        samples: fitted
      };
    }

    function localResidualPredict(baseModel, rgb, params) {
      const sigma = params.localSmooth;
      let sumW = 0;
      const res = [0, 0, 0];

      for (const s of baseModel.samples) {
        const d = rgbDistance(rgb, s.rgb);
        if (d > sigma * 2.8) continue;

        let w = Math.exp(-0.5 * (d / sigma) * (d / sigma));

        // out-of-gamut 측색값은 잔차 보간 영향 감소
        if (s.outOfGamut) w *= 0.12;

        sumW += w;

        res[0] += w * s.residual[0];
        res[1] += w * s.residual[1];
        res[2] += w * s.residual[2];
      }

      if (sumW < 1e-9) {
        return [0, 0, 0];
      }

      return [
        res[0] / sumW,
        res[1] / sumW,
        res[2] / sumW
      ];
    }

    // ============================================================
    // 5. Gamut model
    //
    // 목적:
    // - rgbcmy 허리라인 꼭짓점 검출
    // - black / white point 검출
    // - Lab a/b 평면에서 허리라인 polygon 생성
    // - out-of-gamut 압축값 때문에 외곽이 안쪽으로 끌리는 현상 방지
    // ============================================================

    const vertexTargets = [
      { name: "R", rgb: [100, 0, 0] },
      { name: "Y", rgb: [100, 100, 0] },
      { name: "G", rgb: [0, 100, 0] },
      { name: "C", rgb: [0, 100, 100] },
      { name: "B", rgb: [0, 0, 100] },
      { name: "M", rgb: [100, 0, 100] }
    ];

    function findNearestByRGB(samples, target) {
      let best = samples[0];
      let bestScore = Infinity;

      for (const s of samples) {
        const d = rgbDistance(s.rgb, target);
        const c = chroma(s.lab);
        const score = d - c * 0.05 + (s.outOfGamut ? 15 : 0);

        if (score < bestScore) {
          bestScore = score;
          best = s;
        }
      }

      return best;
    }

    function convexHull2D(points) {
      const pts = points
        .map(p => ({ x: p[0], y: p[1], ref: p[2] }))
        .sort((a, b) => a.x === b.x ? a.y - b.y : a.x - b.x);

      if (pts.length <= 3) {
        return pts.map(p => [p.x, p.y, p.ref]);
      }

      function cross(o, a, b) {
        return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
      }

      const lower = [];

      for (const p of pts) {
        while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) {
          lower.pop();
        }
        lower.push(p);
      }

      const upper = [];

      for (let i = pts.length - 1; i >= 0; i--) {
        const p = pts[i];

        while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) {
          upper.pop();
        }

        upper.push(p);
      }

      upper.pop();
      lower.pop();

      return lower.concat(upper).map(p => [p.x, p.y, p.ref]);
    }

    function raySegmentIntersectionRadius(theta, p1, p2) {
      const dx = Math.cos(theta);
      const dy = Math.sin(theta);

      const x1 = p1[0];
      const y1 = p1[1];
      const x2 = p2[0];
      const y2 = p2[1];

      const sx = x2 - x1;
      const sy = y2 - y1;

      const det = dx * (-sy) - dy * (-sx);

      if (Math.abs(det) < 1e-9) return null;

      const t = (x1 * (-sy) - y1 * (-sx)) / det;
      const u = (dx * y1 - dy * x1) / det;

      if (t >= 0 && u >= 0 && u <= 1) {
        return t;
      }

      return null;
    }

    function polygonRadiusAtAngle(poly, theta) {
      let best = 0;

      for (let i = 0; i < poly.length; i++) {
        const p1 = poly[i];
        const p2 = poly[(i + 1) % poly.length];

        const r = raySegmentIntersectionRadius(theta, p1, p2);

        if (r !== null && r > best) {
          best = r;
        }
      }

      return best;
    }

    function fillMissingCircular(arr) {
      const n = arr.length;
      const result = arr.slice();

      for (let i = 0; i < n; i++) {
        if (Number.isFinite(result[i])) continue;

        let left = i - 1;
        let right = i + 1;

        for (let k = 0; k < n; k++) {
          const idx = (i - 1 - k + n) % n;
          if (Number.isFinite(arr[idx])) {
            left = idx;
            break;
          }
        }

        for (let k = 0; k < n; k++) {
          const idx = (i + 1 + k) % n;
          if (Number.isFinite(arr[idx])) {
            right = idx;
            break;
          }
        }

        if (Number.isFinite(arr[left]) && Number.isFinite(arr[right])) {
          result[i] = (arr[left] + arr[right]) * 0.5;
        } else {
          result[i] = 0;
        }
      }

      return result;
    }

    function smoothCircular(arr, passes) {
      let a = arr.slice();
      const n = a.length;

      for (let pass = 0; pass < passes; pass++) {
        const next = a.slice();

        for (let i = 0; i < n; i++) {
          const l = a[(i - 1 + n) % n];
          const c = a[i];
          const r = a[(i + 1) % n];

          next[i] = l * 0.25 + c * 0.50 + r * 0.25;
        }

        a = next;
      }

      return a;
    }

    function rollingMaxCircular(arr, radius) {
      const n = arr.length;
      const out = [];

      for (let i = 0; i < n; i++) {
        let m = -Infinity;

        for (let k = -radius; k <= radius; k++) {
          const j = (i + k + n) % n;
          m = Math.max(m, arr[j]);
        }

        out.push(m);
      }

      return out;
    }

    function buildGamutModel(samples, params) {
      const safeSamples = samples.filter(s => !s.outOfGamut);
      const gamutSamples = safeSamples.length >= 12 ? safeSamples : samples;

      const blackPoint = samples.reduce((best, s) => {
        return s.lab[0] < best.lab[0] ? s : best;
      }, samples[0]);

      const whitePoint = samples.reduce((best, s) => {
        return s.lab[0] > best.lab[0] ? s : best;
      }, samples[0]);

      const vertices = vertexTargets.map(v => {
        const found = findNearestByRGB(gamutSamples, v.rgb);

        return {
          name: v.name,
          targetRGB: v.rgb,
          rgb: found.rgb,
          lab: found.lab,
          chroma: chroma(found.lab),
          hue: hueAngle(found.lab),
          sample: found
        };
      });

      // rgbcmy 꼭짓점 + 고채도 측색점으로 허리라인 hull 구성.
      const chromas = gamutSamples.map(s => chroma(s.lab));
      const c90 = percentile(chromas, 0.82);

      const hullCandidates = [];

      for (const v of vertices) {
        hullCandidates.push([v.lab[1], v.lab[2], v]);
      }

      for (const s of gamutSamples) {
        const C = chroma(s.lab);
        const L = s.lab[0];

        if (C >= c90 && L > blackPoint.lab[0] + 5 && L < whitePoint.lab[0] - 4) {
          hullCandidates.push([s.lab[1], s.lab[2], s]);
        }
      }

      const waistHull = convexHull2D(hullCandidates);

      const hueBins = 96;
      const lBins = 28;

      let radiusTable = Array.from({ length: lBins }, () => Array(hueBins).fill(NaN));
      let countTable = Array.from({ length: lBins }, () => Array(hueBins).fill(0));
      let oogTable = Array.from({ length: lBins }, () => Array(hueBins).fill(0));

      const blackL = blackPoint.lab[0];
      const whiteL = whitePoint.lab[0];
      const lRange = Math.max(1, whiteL - blackL);

      for (const s of samples) {
        const L = s.lab[0];
        const C = chroma(s.lab);

        if (C < 1e-6) continue;

        const h = hueAngle(s.lab);
        const hi = clamp(Math.floor(h / (Math.PI * 2) * hueBins), 0, hueBins - 1);
        const li = clamp(Math.floor((L - blackL) / lRange * lBins), 0, lBins - 1);

        if (!Number.isFinite(radiusTable[li][hi])) {
          radiusTable[li][hi] = C;
        } else {
          // 외곽은 평균이 아니라 outer envelope 방향으로 누적
          radiusTable[li][hi] = Math.max(radiusTable[li][hi], C);
        }

        countTable[li][hi] += 1;
        if (s.outOfGamut) oogTable[li][hi] += 1;
      }

      // 비어 있는 hue bin을 waist hull radius로 채움
      for (let li = 0; li < lBins; li++) {
        const tL = li / (lBins - 1);
        const lightShape =
          Math.sin(Math.PI * clamp(tL, 0, 1));

        const minShape = Math.pow(Math.max(0, lightShape), 0.78);

        for (let hi = 0; hi < hueBins; hi++) {
          const theta = (hi + 0.5) / hueBins * Math.PI * 2;
          const waistR = polygonRadiusAtAngle(waistHull, theta);

          const fallback = waistR * minShape;

          if (!Number.isFinite(radiusTable[li][hi])) {
            radiusTable[li][hi] = fallback;
          } else {
            const current = radiusTable[li][hi];

            // vertexBoost: rgbcmy hull보다 너무 안쪽이면 허리라인 쪽으로 보강
            const guardR = fallback * params.vertexBoost;
            radiusTable[li][hi] = Math.max(current, guardR);
          }
        }

        radiusTable[li] = fillMissingCircular(radiusTable[li]);
      }

      // hue 방향 오목한 눌림 방지
      const protect = Math.max(1, Math.round(params.edgeProtect));

      for (let li = 0; li < lBins; li++) {
        const raw = radiusTable[li].slice();
        let envelope = rollingMaxCircular(raw, protect);
        envelope = smoothCircular(envelope, 2);

        for (let hi = 0; hi < hueBins; hi++) {
          const current = raw[hi];
          const env = Math.max(current, envelope[hi]);

          const count = countTable[li][hi];
          const oogRatio = count > 0 ? oogTable[li][hi] / count : 0;

          const shrinkAmount = clamp((env - current) / Math.max(env * 0.35, 1e-9), 0, 1);

          const guard =
            clamp(
              params.antiShrink * shrinkAmount +
              params.gamutGuard * oogRatio * 0.75,
              0,
              1
            );

          radiusTable[li][hi] = lerp(current, env, guard);
        }

        radiusTable[li] = smoothCircular(radiusTable[li], 1);
      }

      // L 방향 smoothing. 단, shrink 방향으로 무너지는 것은 방지.
      for (let hi = 0; hi < hueBins; hi++) {
        const col = [];

        for (let li = 0; li < lBins; li++) {
          col.push(radiusTable[li][hi]);
        }

        const sm = col.slice();

        for (let pass = 0; pass < 2; pass++) {
          const next = sm.slice();

          for (let li = 1; li < lBins - 1; li++) {
            const v = sm[li - 1] * 0.25 + sm[li] * 0.50 + sm[li + 1] * 0.25;
            next[li] = Math.max(v, col[li] * 0.92);
          }

          for (let li = 0; li < lBins; li++) {
            sm[li] = next[li];
          }
        }

        for (let li = 0; li < lBins; li++) {
          radiusTable[li][hi] = sm[li];
        }
      }

      function radiusAt(L, hue) {
        const lf = clamp((L - blackL) / lRange, 0, 1) * (lBins - 1);
        const li0 = Math.floor(lf);
        const li1 = Math.min(li0 + 1, lBins - 1);
        const lt = lf - li0;

        let hf = (hue % (Math.PI * 2)) / (Math.PI * 2) * hueBins;
        if (hf < 0) hf += hueBins;

        const hi0 = Math.floor(hf) % hueBins;
        const hi1 = (hi0 + 1) % hueBins;
        const ht = hf - Math.floor(hf);

        const r00 = radiusTable[li0][hi0];
        const r01 = radiusTable[li0][hi1];
        const r10 = radiusTable[li1][hi0];
        const r11 = radiusTable[li1][hi1];

        const r0 = lerp(r00, r01, ht);
        const r1 = lerp(r10, r11, ht);

        return lerp(r0, r1, lt);
      }

      function isBoundaryRGB(rgb) {
        const r = rgb[0];
        const g = rgb[1];
        const b = rgb[2];

        const nearFace = Math.max(
          1 - Math.min(r, 100 - r) / 50,
          1 - Math.min(g, 100 - g) / 50,
          1 - Math.min(b, 100 - b) / 50
        );

        const sat = (Math.max(r, g, b) - Math.min(r, g, b)) / 100;

        return clamp(nearFace * sat, 0, 1);
      }

      function protectLab(rgb, lab) {
        const L = lab[0];
        const a = lab[1];
        const bb = lab[2];

        const C = Math.sqrt(a * a + bb * bb);

        if (C < 1e-6) {
          return lab.slice();
        }

        const h = Math.atan2(bb, a) < 0
          ? Math.atan2(bb, a) + Math.PI * 2
          : Math.atan2(bb, a);

        const boundary = isBoundaryRGB(rgb);
        const envC = radiusAt(L, h);

        // 회색축 보호
        const rgbSat = (Math.max(rgb[0], rgb[1], rgb[2]) - Math.min(rgb[0], rgb[1], rgb[2])) / 100;
        const grayKeep = 1 - params.grayProtect * (1 - rgbSat);

        // 외곽에 가까운 RGB인데 chroma가 envelope보다 비정상적으로 작으면 복원
        const minAllowed =
          envC *
          boundary *
          params.gamutGuard *
          grayKeep *
          0.88;

        let targetC = C;

        if (C < minAllowed) {
          const lift = clamp((minAllowed - C) / Math.max(minAllowed, 1e-9), 0, 1);
          targetC = lerp(C, minAllowed, params.antiShrink * lift);
        }

        // 너무 바깥으로 폭주하면 부드럽게 제한
        const maxAllowed = envC * (1.00 + 0.12 * params.edgeFeather);
        if (targetC > maxAllowed) {
          targetC = lerp(targetC, maxAllowed, 0.65);
        }

        return [
          clamp(L, blackL, whiteL),
          Math.cos(h) * targetC,
          Math.sin(h) * targetC
        ];
      }

      return {
        blackPoint,
        whitePoint,
        vertices,
        waistHull,
        radiusTable,
        radiusAt,
        protectLab,
        blackL,
        whiteL,
        hueBins,
        lBins
      };
    }

    // ============================================================
    // 6. A2B CLUT 생성
    // ============================================================

    function predictA2BLab(baseModel, gamutModel, rgb, params) {
      const base = [
        evalPolynomial(baseModel.coefL, rgb),
        evalPolynomial(baseModel.coefa, rgb),
        evalPolynomial(baseModel.coefb, rgb)
      ];

      const residual = localResidualPredict(baseModel, rgb, params);

      let lab = [
        base[0] + residual[0],
        base[1] + residual[1],
        base[2] + residual[2]
      ];

      lab = gamutModel.protectLab(rgb, lab);

      return lab;
    }

    function buildCLUT(samples, params) {
      const baseModel = fitA2BBase(samples);
      const gamutModel = buildGamutModel(samples, params);

      const size = params.clutSize;
      const entries = [];

      for (let ri = 0; ri < size; ri++) {
        for (let gi = 0; gi < size; gi++) {
          for (let bi = 0; bi < size; bi++) {
            const r = size === 1 ? 0 : (ri / (size - 1)) * 100;
            const g = size === 1 ? 0 : (gi / (size - 1)) * 100;
            const b = size === 1 ? 0 : (bi / (size - 1)) * 100;

            const rgb = [r, g, b];
            const lab = predictA2BLab(baseModel, gamutModel, rgb, params);

            entries.push({
              ri,
              gi,
              bi,
              rgb: rgb.map(v => +v.toFixed(6)),
              lab: lab.map(v => +v.toFixed(6))
            });
          }
        }
      }

      return {
        size,
        inputEncoding: "RGB_0_100",
        pcs: "Lab",
        layout: "entries ordered by ri, gi, bi",
        blackPoint: {
          rgb: gamutModel.blackPoint.rgb,
          lab: gamutModel.blackPoint.lab
        },
        whitePoint: {
          rgb: gamutModel.whitePoint.rgb,
          lab: gamutModel.whitePoint.lab
        },
        vertices: gamutModel.vertices.map(v => ({
          name: v.name,
          targetRGB: v.targetRGB,
          detectedRGB: v.rgb,
          lab: v.lab
        })),
        entries,
        baseModel,
        gamutModel
      };
    }

    function runBuild() {
      try {
        const params = getParams();
        measurementSamples = parseInput(inputEl.value);

        clutResult = buildCLUT(measurementSamples, params);
        model = clutResult.gamutModel;

        const safeCount = measurementSamples.filter(s => !s.outOfGamut).length;
        const oogCount = measurementSamples.length - safeCount;

        statusEl.textContent =
          "A2B CLUT 생성 완료\n" +
          "입력 측색 샘플: " + measurementSamples.length + "\n" +
          "gamut-safe 샘플: " + safeCount + "\n" +
          "out-of-gamut 의심/표시 샘플: " + oogCount + "\n" +
          "CLUT size: " + clutResult.size + " × " + clutResult.size + " × " + clutResult.size + "\n" +
          "CLUT entries: " + clutResult.entries.length + "\n\n" +
          "Black point Lab: " + clutResult.blackPoint.lab.map(v => v.toFixed(3)).join(", ") + "\n" +
          "White point Lab: " + clutResult.whitePoint.lab.map(v => v.toFixed(3)).join(", ");

        console.clear();
        console.log("measurementSamples", measurementSamples);
        console.log("clutResult", clutResult);
        console.log("CLUT entries", clutResult.entries);
      } catch (err) {
        statusEl.textContent = "오류: " + err.message;
        console.error(err);
      }
    }

    // ============================================================
    // 7. 다운로드
    // ============================================================

    function makeJsonOutput() {
      if (!clutResult) {
        throw new Error("먼저 A2B CLUT를 생성하세요.");
      }

      return JSON.stringify({
        size: clutResult.size,
        inputEncoding: clutResult.inputEncoding,
        pcs: clutResult.pcs,
        blackPoint: clutResult.blackPoint,
        whitePoint: clutResult.whitePoint,
        vertices: clutResult.vertices,
        entries: clutResult.entries
      }, null, 2);
    }

    function makeCsvOutput() {
      if (!clutResult) {
        throw new Error("먼저 A2B CLUT를 생성하세요.");
      }

      const lines = [];
      lines.push("ri,gi,bi,R,G,B,L,a,b");

      for (const e of clutResult.entries) {
        lines.push([
          e.ri,
          e.gi,
          e.bi,
          e.rgb[0],
          e.rgb[1],
          e.rgb[2],
          e.lab[0],
          e.lab[1],
          e.lab[2]
        ].join(","));
      }

      return lines.join("\n");
    }

    // ============================================================
    // 8. 3D Canvas 시각화
    // ============================================================

    function rotateX(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0],
        p[1] * c - p[2] * s,
        p[1] * s + p[2] * c
      ];
    }

    function rotateY(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0] * c + p[2] * s,
        p[1],
        -p[0] * s + p[2] * c
      ];
    }

    function transformLab(lab, time) {
      // Lab: L vertical, a horizontal, b depth
      let p = [
        lab[1] / 45,
        (lab[0] - 50) / 40,
        lab[2] / 45
      ];

      p = rotateX(p, -0.62);
      p = rotateY(p, time * 0.00032);

      return p;
    }

    function project(p, cx, cy, scale) {
      const d = 5.0;
      const k = d / (d + p[2]);

      return {
        x: cx + p[0] * scale * k,
        y: cy - p[1] * scale * k,
        z: p[2]
      };
    }

    function drawAxes(cx, cy, scale, time) {
      const axes = [
        { a: [0, 0, 0], b: [1.8, 0, 0], color: "rgba(255,120,120,0.6)", label: "+a" },
        { a: [0, 0, 0], b: [0, 1.6, 0], color: "rgba(255,255,255,0.55)", label: "L" },
        { a: [0, 0, 0], b: [0, 0, 1.8], color: "rgba(255,230,120,0.6)", label: "+b" }
      ];

      for (const ax of axes) {
        let a = ax.a;
        let b = ax.b;

        a = rotateX(a, -0.62);
        a = rotateY(a, time * 0.00032);

        b = rotateX(b, -0.62);
        b = rotateY(b, time * 0.00032);

        const sa = project(a, cx, cy, scale);
        const sb = project(b, cx, cy, scale);

        ctx.strokeStyle = ax.color;
        ctx.lineWidth = 1;

        ctx.beginPath();
        ctx.moveTo(sa.x, sa.y);
        ctx.lineTo(sb.x, sb.y);
        ctx.stroke();

        ctx.fillStyle = ax.color;
        ctx.font = "12px Arial";
        ctx.fillText(ax.label, sb.x + 4, sb.y);
      }
    }

    function drawLabPoint(lab, rgb, cx, cy, scale, time, radius, alpha, stroke) {
      const p = transformLab(lab, time);
      const s = project(p, cx, cy, scale);

      ctx.beginPath();
      ctx.arc(s.x, s.y, radius, 0, Math.PI * 2);
      ctx.fillStyle = rgbColor(rgb, alpha);
      ctx.fill();

      if (stroke) {
        ctx.strokeStyle = stroke;
        ctx.lineWidth = 1.2;
        ctx.stroke();
      }
    }

    function drawLineLab(labA, labB, cx, cy, scale, time, color, width) {
      const pa = project(transformLab(labA, time), cx, cy, scale);
      const pb = project(transformLab(labB, time), cx, cy, scale);

      ctx.strokeStyle = color;
      ctx.lineWidth = width;
      ctx.beginPath();
      ctx.moveTo(pa.x, pa.y);
      ctx.lineTo(pb.x, pb.y);
      ctx.stroke();
    }

    function drawWaistPolygon(gamutModel, cx, cy, scale, time) {
      if (!gamutModel) return;

      const vertices = gamutModel.vertices
        .slice()
        .sort((a, b) => a.hue - b.hue);

      if (vertices.length < 3) return;

      ctx.strokeStyle = "rgba(255,255,255,0.72)";
      ctx.lineWidth = 2;

      ctx.beginPath();

      for (let i = 0; i < vertices.length; i++) {
        const v = vertices[i];
        const p = project(transformLab(v.lab, time), cx, cy, scale);

        if (i === 0) ctx.moveTo(p.x, p.y);
        else ctx.lineTo(p.x, p.y);
      }

      ctx.closePath();
      ctx.stroke();

      ctx.fillStyle = "rgba(255,255,255,0.9)";
      ctx.font = "12px Arial";

      for (const v of vertices) {
        const p = project(transformLab(v.lab, time), cx, cy, scale);
        ctx.fillText(v.name, p.x + 4, p.y - 4);
      }

      // black / white point와 꼭짓점 연결
      for (const v of vertices) {
        drawLineLab(gamutModel.blackPoint.lab, v.lab, cx, cy, scale, time, "rgba(180,180,180,0.20)", 1);
        drawLineLab(gamutModel.whitePoint.lab, v.lab, cx, cy, scale, time, "rgba(180,180,180,0.20)", 1);
      }

      drawLabPoint(gamutModel.blackPoint.lab, [0,0,0], cx, cy, scale, time, 5, 1, "rgba(255,255,255,0.9)");
      drawLabPoint(gamutModel.whitePoint.lab, [100,100,100], cx, cy, scale, time, 5, 1, "rgba(0,0,0,0.9)");
    }

    function drawMeasurementView(cx, cy, scale, time) {
      drawAxes(cx, cy, scale, time);

      if (!measurementSamples.length) return;

      const rendered = measurementSamples.map(s => {
        const p = transformLab(s.lab, time);
        const scr = project(p, cx, cy, scale);
        return { sample: s, p, scr };
      }).sort((a, b) => a.p[2] - b.p[2]);

      for (const item of rendered) {
        const s = item.sample;
        const radius = s.outOfGamut ? 4.0 : 2.6;
        const stroke = s.outOfGamut ? "rgba(255,60,45,0.95)" : null;

        drawLabPoint(
          s.lab,
          s.rgb,
          cx,
          cy,
          scale,
          time,
          radius,
          s.outOfGamut ? 0.90 : 0.82,
          stroke
        );
      }

      if (model) {
        drawWaistPolygon(model, cx, cy, scale, time);
      }
    }

    function drawClutView(cx, cy, scale, time) {
      drawAxes(cx, cy, scale, time);

      if (!clutResult) return;

      const entries = clutResult.entries;

      const rendered = entries.map(e => {
        const p = transformLab(e.lab, time);
        const scr = project(p, cx, cy, scale);
        return { e, p, scr };
      }).sort((a, b) => a.p[2] - b.p[2]);

      for (const item of rendered) {
        const e = item.e;

        const maxc = Math.max(e.rgb[0], e.rgb[1], e.rgb[2]);
        const minc = Math.min(e.rgb[0], e.rgb[1], e.rgb[2]);
        const sat = (maxc - minc) / 100;

        const radius = 1.2 + sat * 1.2;

        drawLabPoint(
          e.lab,
          e.rgb,
          cx,
          cy,
          scale,
          time,
          radius,
          0.64,
          null
        );
      }

      if (model) {
        drawWaistPolygon(model, cx, cy, scale, time);
      }
    }

    function drawDivider(width, height) {
      ctx.strokeStyle = "rgba(255,255,255,0.14)";
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(width / 2, 0);
      ctx.lineTo(width / 2, height);
      ctx.stroke();

      ctx.fillStyle = "rgba(255,255,255,0.18)";
      ctx.font = "38px Arial";
      ctx.fillText("→", width / 2 - 12, height / 2);
    }

    function draw() {
      const wrap = document.getElementById("canvasWrap");
      const w = wrap.clientWidth;
      const h = wrap.clientHeight;

      const now = performance.now();
      const dt = now - lastTime;
      lastTime = now;

      if (rotationEnabled) {
        rotationTime += dt;
      }

      ctx.clearRect(0, 0, w, h);

      drawDivider(w, h);

      const scale = Math.min(w, h) * 0.26;

      drawMeasurementView(w * 0.25, h * 0.55, scale, rotationTime);
      drawClutView(w * 0.75, h * 0.55, scale, rotationTime);

      requestAnimationFrame(draw);
    }

    // ============================================================
    // 9. 이벤트
    // ============================================================

    document.getElementById("makeDemo").addEventListener("click", () => {
      loadDemo();
      runBuild();
    });

    document.getElementById("buildClut").addEventListener("click", () => {
      runBuild();
    });

    document.getElementById("toggleRotation").addEventListener("click", () => {
      rotationEnabled = !rotationEnabled;
      document.getElementById("toggleRotation").textContent =
        rotationEnabled ? "회전 정지" : "회전 시작";
    });

    document.getElementById("downloadJson").addEventListener("click", () => {
      try {
        downloadText(
          "printer_a2b_clut_16_lab.json",
          makeJsonOutput(),
          "application/json"
        );
      } catch (e) {
        statusEl.textContent = "오류: " + e.message;
      }
    });

    document.getElementById("downloadCsv").addEventListener("click", () => {
      try {
        downloadText(
          "printer_a2b_clut_16_lab.csv",
          makeCsvOutput(),
          "text/csv"
        );
      } catch (e) {
        statusEl.textContent = "오류: " + e.message;
      }
    });

    // 초기 실행
    loadDemo();
    runBuild();
    draw();
  </script>
</body>
</html>

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>Printer ICC A2B PCS CLUT Gamut Guard</title>
  <style>
    body {
      margin: 0;
      background: #101010;
      color: #eeeeee;
      font-family: Arial, sans-serif;
      overflow: hidden;
    }

    canvas {
      display: block;
      background: #101010;
    }

    .panel {
      position: fixed;
      left: 12px;
      top: 12px;
      width: 430px;
      max-height: calc(100vh - 24px);
      overflow: auto;
      padding: 12px;
      border-radius: 14px;
      background: rgba(0, 0, 0, 0.72);
      box-shadow: 0 0 18px rgba(0,0,0,0.45);
      z-index: 10;
      font-size: 13px;
    }

    .panel h2 {
      margin: 0 0 8px 0;
      font-size: 16px;
      color: #ffffff;
    }

    .panel .desc {
      color: #cfcfcf;
      line-height: 1.45;
      margin-bottom: 10px;
    }

    textarea {
      width: 100%;
      height: 210px;
      box-sizing: border-box;
      resize: vertical;
      background: #181818;
      color: #e8e8e8;
      border: 1px solid #444;
      border-radius: 10px;
      padding: 8px;
      font-family: Consolas, monospace;
      font-size: 12px;
      line-height: 1.35;
    }

    button {
      padding: 8px 11px;
      margin: 3px 3px 3px 0;
      border: 0;
      border-radius: 999px;
      background: #ffffff;
      color: #111111;
      font-weight: bold;
      cursor: pointer;
    }

    button:hover {
      background: #dddddd;
    }

    .grid {
      display: grid;
      grid-template-columns: 130px 180px 55px;
      gap: 6px 8px;
      align-items: center;
      margin-top: 8px;
    }

    .grid input {
      width: 180px;
    }

    .status {
      margin-top: 8px;
      padding: 8px;
      border-radius: 10px;
      background: rgba(255,255,255,0.08);
      color: #dddddd;
      line-height: 1.5;
      white-space: pre-line;
    }

    .legend {
      position: fixed;
      right: 16px;
      top: 14px;
      z-index: 8;
      padding: 10px 12px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.58);
      color: #dddddd;
      font-size: 13px;
      line-height: 1.6;
    }

    .titleLeft {
      position: fixed;
      left: 470px;
      top: 16px;
      z-index: 7;
      color: #8ecbff;
      font-weight: bold;
      font-size: 17px;
      background: rgba(0,0,0,0.40);
      padding: 7px 10px;
      border-radius: 10px;
    }

    .titleRight {
      position: fixed;
      right: 24px;
      top: 86px;
      z-index: 7;
      color: #9dffb7;
      font-weight: bold;
      font-size: 17px;
      background: rgba(0,0,0,0.40);
      padding: 7px 10px;
      border-radius: 10px;
    }
  </style>
</head>
<body>
  <div class="panel">
    <h2>프린터 ICC A2B → PCS Lab CLUT 생성</h2>
    <div class="desc">
      입력 형식: <b>R,G,B,L,a,b[,flag]</b><br />
      RGB는 0~100 범위, Lab은 측색값입니다.<br />
      flag는 선택사항이며 <b>oog</b>, <b>out</b>, <b>outOfGamut</b>이면 외곽 추정 영향도를 낮춥니다.
    </div>

    <textarea id="inputText"></textarea>

    <div style="margin-top:8px;">
      <button id="loadDemo">데모 측색값 생성</button>
      <button id="build">A2B CLUT 생성</button>
      <button id="toggleRotation">회전 정지</button>
      <button id="downloadClut">CLUT CSV 다운로드</button>
      <button id="downloadVertices">검출 꼭짓점 CSV 다운로드</button>
    </div>

    <div class="grid">
      <label>CLUT size</label>
      <input id="clutSize" type="range" min="9" max="25" step="1" value="16" />
      <span id="clutSizeValue">16</span>

      <label>fitSmooth</label>
      <input id="fitSmooth" type="range" min="0.08" max="0.45" step="0.01" value="0.22" />
      <span id="fitSmoothValue">0.22</span>

      <label>gamutGuard</label>
      <input id="gamutGuard" type="range" min="0.00" max="1.00" step="0.05" value="0.85" />
      <span id="gamutGuardValue">0.85</span>

      <label>antiShrink</label>
      <input id="antiShrink" type="range" min="0.00" max="1.00" step="0.05" value="0.78" />
      <span id="antiShrinkValue">0.78</span>

      <label>outlineSmooth</label>
      <input id="outlineSmooth" type="range" min="1" max="10" step="1" value="4" />
      <span id="outlineSmoothValue">4</span>

      <label>outerProtect</label>
      <input id="outerProtect" type="range" min="1" max="12" step="1" value="5" />
      <span id="outerProtectValue">5</span>

      <label>protrusionAllow</label>
      <input id="protrusionAllow" type="range" min="0.00" max="1.00" step="0.05" value="0.80" />
      <span id="protrusionAllowValue">0.80</span>

      <label>oogWeight</label>
      <input id="oogWeight" type="range" min="0.02" max="0.50" step="0.02" value="0.12" />
      <span id="oogWeightValue">0.12</span>
    </div>

    <div id="status" class="status">준비됨</div>
  </div>

  <div class="titleLeft">측정값 미리보기: Printer RGB → Lab</div>
  <div class="titleRight">CLUT 미리보기: 16³ RGB → PCS Lab</div>

  <div class="legend">
    <b>색상 표시</b><br />
    파란 점: 측색 gamut-safe 샘플<br />
    붉은 점: out-of-gamut / 압축 의심 샘플<br />
    흰 선: 검출된 RGBCMY 허리라인 polygon<br />
    연두 점: 생성된 A2B CLUT Lab<br />
    노란 점: RGBCMY 꼭짓점<br />
    흰 점: White / Black point
  </div>

  <canvas id="canvas"></canvas>

  <script>
    // =====================================================
    // Canvas
    // =====================================================

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    function resizeCanvas() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }

    window.addEventListener("resize", resizeCanvas);
    resizeCanvas();

    // =====================================================
    // UI
    // =====================================================

    const inputText = document.getElementById("inputText");
    const statusBox = document.getElementById("status");

    const controls = {
      clutSize: document.getElementById("clutSize"),
      fitSmooth: document.getElementById("fitSmooth"),
      gamutGuard: document.getElementById("gamutGuard"),
      antiShrink: document.getElementById("antiShrink"),
      outlineSmooth: document.getElementById("outlineSmooth"),
      outerProtect: document.getElementById("outerProtect"),
      protrusionAllow: document.getElementById("protrusionAllow"),
      oogWeight: document.getElementById("oogWeight")
    };

    const values = {
      clutSize: document.getElementById("clutSizeValue"),
      fitSmooth: document.getElementById("fitSmoothValue"),
      gamutGuard: document.getElementById("gamutGuardValue"),
      antiShrink: document.getElementById("antiShrinkValue"),
      outlineSmooth: document.getElementById("outlineSmoothValue"),
      outerProtect: document.getElementById("outerProtectValue"),
      protrusionAllow: document.getElementById("protrusionAllowValue"),
      oogWeight: document.getElementById("oogWeightValue")
    };

    function getParams() {
      const p = {
        clutSize: Number(controls.clutSize.value),
        fitSmooth: Number(controls.fitSmooth.value),
        gamutGuard: Number(controls.gamutGuard.value),
        antiShrink: Number(controls.antiShrink.value),
        outlineSmooth: Number(controls.outlineSmooth.value),
        outerProtect: Number(controls.outerProtect.value),
        protrusionAllow: Number(controls.protrusionAllow.value),
        oogWeight: Number(controls.oogWeight.value)
      };

      for (const k of Object.keys(values)) {
        values[k].textContent = String(p[k]);
      }

      return p;
    }

    for (const k of Object.keys(controls)) {
      controls[k].addEventListener("input", () => {
        getParams();
      });
    }

    // =====================================================
    // Utilities
    // =====================================================

    function clamp(x, a, b) {
      return Math.max(a, Math.min(b, x));
    }

    function lerp(a, b, t) {
      return a * (1 - t) + b * t;
    }

    function smoothstep(edge0, edge1, x) {
      if (edge0 === edge1) return x < edge0 ? 0 : 1;

      const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
      return t * t * (3 - 2 * t);
    }

    function hypot2(x, y) {
      return Math.sqrt(x * x + y * y);
    }

    function percentile(values, q) {
      const arr = values
        .filter(Number.isFinite)
        .slice()
        .sort((a, b) => a - b);

      if (arr.length === 0) return 0;

      const pos = (arr.length - 1) * q;
      const lo = Math.floor(pos);
      const hi = Math.ceil(pos);
      const t = pos - lo;

      return arr[lo] * (1 - t) + arr[hi] * t;
    }

    function median(values) {
      return percentile(values, 0.5);
    }

    function angleOf(a, b) {
      let x = Math.atan2(b, a);
      if (x < 0) x += Math.PI * 2;
      return x;
    }

    function angleDiff(a, b) {
      let d = Math.abs(a - b) % (Math.PI * 2);
      if (d > Math.PI) d = Math.PI * 2 - d;
      return d;
    }

    function cross2(ax, ay, bx, by) {
      return ax * by - ay * bx;
    }

    function gaussianSmoothArray(values, passes) {
      let arr = values.slice();

      for (let pass = 0; pass < passes; pass++) {
        const next = arr.slice();

        for (let i = 1; i < arr.length - 1; i++) {
          next[i] =
            arr[i - 1] * 0.25 +
            arr[i] * 0.50 +
            arr[i + 1] * 0.25;
        }

        arr = next;
      }

      return arr;
    }

    function rollingMin(values, radius) {
      const out = [];

      for (let i = 0; i < values.length; i++) {
        let m = Infinity;

        for (let k = -radius; k <= radius; k++) {
          const j = clamp(i + k, 0, values.length - 1);
          m = Math.min(m, values[j]);
        }

        out.push(m);
      }

      return out;
    }

    function rollingMax(values, radius) {
      const out = [];

      for (let i = 0; i < values.length; i++) {
        let m = -Infinity;

        for (let k = -radius; k <= radius; k++) {
          const j = clamp(i + k, 0, values.length - 1);
          m = Math.max(m, values[j]);
        }

        out.push(m);
      }

      return out;
    }

    function solveLinearSystem(A, b) {
      const n = A.length;
      const M = A.map((row, i) => [...row, b[i]]);

      for (let col = 0; col < n; col++) {
        let pivot = col;

        for (let row = col + 1; row < n; row++) {
          if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
            pivot = row;
          }
        }

        [M[col], M[pivot]] = [M[pivot], M[col]];

        const div = M[col][col];

        if (Math.abs(div) < 1e-12) {
          return null;
        }

        for (let k = col; k <= n; k++) {
          M[col][k] /= div;
        }

        for (let row = 0; row < n; row++) {
          if (row === col) continue;

          const factor = M[row][col];

          for (let k = col; k <= n; k++) {
            M[row][k] -= factor * M[col][k];
          }
        }
      }

      return M.map(row => row[n]);
    }

    function downloadText(filename, text) {
      const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");

      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();

      document.body.removeChild(a);
      URL.revokeObjectURL(url);
    }

    // =====================================================
    // Demo printer measurement data
    //
    // 실제 프린터 측색값처럼 보이도록 만든 데모입니다.
    // 일부 외곽 샘플은 out-of-gamut 압축 현상을 흉내 내기 위해
    // oog 플래그를 부여했습니다.
    // =====================================================

    function pseudoNoise(n) {
      const x = Math.sin(n * 12.9898) * 43758.5453;
      return x - Math.floor(x);
    }

    function trilerpLab(r, g, b) {
      const K = { L: 12.0, a:  0.4, b: -1.2 };
      const R = { L: 47.5, a: 64.0, b: 42.0 };
      const G = { L: 72.5, a:-54.0, b: 51.0 };
      const B = { L: 28.0, a: 25.0, b:-61.0 };
      const Y = { L: 88.0, a: -8.0, b: 78.0 };
      const M = { L: 45.5, a: 56.0, b:-39.0 };
      const C = { L: 76.0, a:-37.0, b:-28.0 };
      const W = { L: 95.0, a:  1.1, b: -3.0 };

      function mix(v000, v100, v010, v001, v110, v101, v011, v111, key) {
        const c00 = lerp(v000[key], v100[key], r);
        const c10 = lerp(v010[key], v110[key], r);
        const c01 = lerp(v001[key], v101[key], r);
        const c11 = lerp(v011[key], v111[key], r);

        const c0 = lerp(c00, c10, g);
        const c1 = lerp(c01, c11, g);

        return lerp(c0, c1, b);
      }

      const lab = {
        L: mix(K, R, G, B, Y, M, C, W, "L"),
        a: mix(K, R, G, B, Y, M, C, W, "a"),
        b: mix(K, R, G, B, Y, M, C, W, "b")
      };

      const maxv = Math.max(r, g, b);
      const minv = Math.min(r, g, b);
      const sat = maxv - minv;
      const light = (r + g + b) / 3;

      const chroma = hypot2(lab.a, lab.b);
      const hue = angleOf(lab.a, lab.b);

      const hueBoost =
        1.0 +
        0.04 * Math.sin(3 * hue + 0.3) -
        0.03 * Math.cos(5 * hue);

      const lMask =
        0.82 +
        0.18 * Math.sin(Math.PI * clamp((lab.L - 8) / 90, 0, 1));

      const printChromaScale =
        (0.86 + 0.12 * sat) *
        hueBoost *
        lMask;

      if (chroma > 1e-6) {
        lab.a *= printChromaScale;
        lab.b *= printChromaScale;
      }

      lab.L +=
        1.2 * Math.sin(Math.PI * light) -
        0.8 * sat * (1 - light);

      return lab;
    }

    function demoDataText() {
      const rows = [];
      rows.push("R,G,B,L,a,b,flag");

      const levels = [0, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100];
      let seed = 1;

      for (const R of levels) {
        for (const G of levels) {
          for (const B of levels) {
            const r = R / 100;
            const g = G / 100;
            const b = B / 100;

            let lab = trilerpLab(r, g, b);

            const maxv = Math.max(r, g, b);
            const minv = Math.min(r, g, b);
            const sat = maxv - minv;

            const hue = angleOf(lab.a, lab.b);

            const n1 = pseudoNoise(seed++);
            const n2 = pseudoNoise(seed++);
            const n3 = pseudoNoise(seed++);

            lab.L += (n1 - 0.5) * 0.55;
            lab.a += (n2 - 0.5) * 0.85;
            lab.b += (n3 - 0.5) * 0.85;

            let flag = "safe";

            const isOuterDevice =
              sat > 0.74 &&
              (
                R < 14 || R > 86 ||
                G < 14 || G > 86 ||
                B < 14 || B > 86
              );

            const problemHue =
              Math.exp(-Math.pow(angleDiff(hue, angleOf(-45, 55)) / 0.45, 2)) > 0.22 ||
              Math.exp(-Math.pow(angleDiff(hue, angleOf( 60,-38)) / 0.48, 2)) > 0.22 ||
              Math.exp(-Math.pow(angleDiff(hue, angleOf(-40,-28)) / 0.55, 2)) > 0.25;

            if (isOuterDevice && problemHue) {
              const compression = 0.78;
              lab.a *= compression;
              lab.b *= compression;
              lab.L += 0.4;
              flag = "oog";
            }

            rows.push([
              R.toFixed(1),
              G.toFixed(1),
              B.toFixed(1),
              lab.L.toFixed(3),
              lab.a.toFixed(3),
              lab.b.toFixed(3),
              flag
            ].join(","));
          }
        }
      }

      return rows.join("\n");
    }

    // =====================================================
    // Input parsing
    // =====================================================

    function parseInput(text) {
      const lines = text
        .split(/\r?\n/)
        .map(v => v.trim())
        .filter(v => v.length > 0 && !v.startsWith("#"));

      const samples = [];

      for (const line of lines) {
        const parts = line
          .split(/[,\t; ]+/)
          .map(v => v.trim())
          .filter(v => v.length > 0);

        if (parts.length < 6) continue;

        const R = Number(parts[0]);
        const G = Number(parts[1]);
        const B = Number(parts[2]);
        const L = Number(parts[3]);
        const a = Number(parts[4]);
        const bb = Number(parts[5]);

        if (![R, G, B, L, a, bb].every(Number.isFinite)) {
          continue;
        }

        const flag = parts.slice(6).join(" ").toLowerCase();

        const outOfGamut =
          flag.includes("oog") ||
          flag.includes("out") ||
          flag.includes("bad") ||
          flag.includes("clip") ||
          flag.includes("compressed") ||
          flag.includes("true") ||
          flag === "1";

        samples.push({
          R: clamp(R, 0, 100),
          G: clamp(G, 0, 100),
          B: clamp(B, 0, 100),
          L,
          a,
          b: bb,
          outOfGamut,
          label: ""
        });
      }

      return samples;
    }

    // =====================================================
    // White / Black / RGBCMY vertex detection
    // =====================================================

    const VERTEX_IDEAL_RGB = [
      { name: "R", R: 100, G:   0, B:   0 },
      { name: "Y", R: 100, G: 100, B:   0 },
      { name: "G", R:   0, G: 100, B:   0 },
      { name: "C", R:   0, G: 100, B: 100 },
      { name: "B", R:   0, G:   0, B: 100 },
      { name: "M", R: 100, G:   0, B: 100 }
    ];

    function rgbDistance(s, target) {
      const dR = (s.R - target.R) / 100;
      const dG = (s.G - target.G) / 100;
      const dB = (s.B - target.B) / 100;
      return Math.sqrt(dR * dR + dG * dG + dB * dB);
    }

    function chromaOf(s, center) {
      const da = s.a - center.a;
      const db = s.b - center.b;
      return Math.sqrt(da * da + db * db);
    }

    function detectWhiteBlack(samples) {
      let white = null;
      let black = null;
      let bestWhite = -Infinity;
      let bestBlack = Infinity;

      for (const s of samples) {
        const rgbMean = (s.R + s.G + s.B) / 3;
        const rgbBlack = (300 - s.R - s.G - s.B) / 3;
        const C = Math.sqrt(s.a * s.a + s.b * s.b);

        const whiteScore =
          s.L * 1.35 +
          rgbMean * 0.35 -
          C * 0.08 -
          (s.outOfGamut ? 12 : 0);

        const blackScore =
          s.L * 1.40 -
          rgbBlack * 0.28 +
          C * 0.04 +
          (s.outOfGamut ? 10 : 0);

        if (whiteScore > bestWhite) {
          bestWhite = whiteScore;
          white = s;
        }

        if (blackScore < bestBlack) {
          bestBlack = blackScore;
          black = s;
        }
      }

      return { white, black };
    }

    function neutralCenterAtL(L, black, white) {
      const t = clamp((L - black.L) / Math.max(1e-9, white.L - black.L), 0, 1);

      return {
        a: lerp(black.a, white.a, t),
        b: lerp(black.b, white.b, t)
      };
    }

    function detectRGBVertices(samples, black, white) {
      const vertices = [];
      const safe = samples.filter(s => !s.outOfGamut);
      const useSamples = safe.length >= 10 ? safe : samples;

      for (const ideal of VERTEX_IDEAL_RGB) {
        let best = null;
        let bestScore = -Infinity;

        for (const s of useSamples) {
          const d = rgbDistance(s, ideal);
          const c0 = neutralCenterAtL(s.L, black, white);
          const C = chromaOf(s, c0);

          const proximity = Math.exp(-(d * d) / (2 * 0.30 * 0.30));

          const deviceSat =
            (Math.max(s.R, s.G, s.B) - Math.min(s.R, s.G, s.B)) / 100;

          const score =
            proximity * 4.0 +
            C / 42.0 +
            deviceSat * 1.4 -
            (s.outOfGamut ? 2.0 : 0);

          if (score > bestScore) {
            bestScore = score;
            best = s;
          }
        }

        const center = neutralCenterAtL(best.L, black, white);
        const hue = angleOf(best.a - center.a, best.b - center.b);

        vertices.push({
          name: ideal.name,
          R: best.R,
          G: best.G,
          B: best.B,
          L: best.L,
          a: best.a,
          b: best.b,
          hue,
          source: best
        });
      }

      vertices.sort((p, q) => p.hue - q.hue);

      return vertices;
    }

    // =====================================================
    // Polygon waist model
    //
    // 위에서 본 a*b* 허리라인 polygon입니다.
    // 이 polygon은 기본 안전 외곽입니다.
    // 측정 표면이 polygon보다 더 튀어나온 경우는 outer envelope로 허용합니다.
    // 반대로 안쪽으로 눌린 부분은 polygon보다 안쪽으로 들어가지 않게 보호합니다.
    // =====================================================

    function polygonRayIntersection(vertices, theta, black, white) {
      const d = {
        x: Math.cos(theta),
        y: Math.sin(theta)
      };

      let best = null;

      const meanL = vertices.reduce((sum, v) => sum + v.L, 0) / vertices.length;
      const center = neutralCenterAtL(meanL, black, white);

      for (let i = 0; i < vertices.length; i++) {
        const j = (i + 1) % vertices.length;

        const vi = vertices[i];
        const vj = vertices[j];

        const ax = vi.a - center.a;
        const ay = vi.b - center.b;
        const bx = vj.a - center.a;
        const by = vj.b - center.b;

        const ex = bx - ax;
        const ey = by - ay;

        const denom = cross2(d.x, d.y, ex, ey);

        if (Math.abs(denom) < 1e-9) continue;

        const t = cross2(ax, ay, ex, ey) / denom;
        const u = cross2(ax, ay, d.x, d.y) / denom;

        if (t >= 0 && u >= -1e-6 && u <= 1 + 1e-6) {
          if (!best || t < best.radius) {
            best = {
              radius: t,
              L: lerp(vi.L, vj.L, clamp(u, 0, 1)),
              edgeIndex: i,
              u: clamp(u, 0, 1)
            };
          }
        }
      }

      if (!best) {
        const radii = vertices.map(v => {
          const c = neutralCenterAtL(v.L, black, white);
          return hypot2(v.a - c.a, v.b - c.b);
        });

        return {
          radius: percentile(radii, 0.85),
          L: meanL,
          edgeIndex: 0,
          u: 0
        };
      }

      return best;
    }

    function buildGamutModel(samples, params) {
      const { white, black } = detectWhiteBlack(samples);
      const vertices = detectRGBVertices(samples, black, white);

      const Lmin = black.L;
      const Lmax = white.L;

      const hueBins = 96;
      const lBins = 48;

      const rawOuter = Array.from({ length: lBins }, () =>
        Array(hueBins).fill(NaN)
      );

      const rawCount = Array.from({ length: lBins }, () =>
        Array(hueBins).fill(0)
      );

      function baseConeRadiusAt(L, theta) {
        const waist = polygonRayIntersection(vertices, theta, black, white);

        const Lw = waist.L;
        const Rw = waist.radius;

        let scale;

        if (L <= Lw) {
          scale = (L - Lmin) / Math.max(1e-9, Lw - Lmin);
        } else {
          scale = (Lmax - L) / Math.max(1e-9, Lmax - Lw);
        }

        scale = clamp(scale, 0, 1);

        return Rw * scale;
      }

      function binIndexL(L) {
        return clamp(
          Math.round(((L - Lmin) / Math.max(1e-9, Lmax - Lmin)) * (lBins - 1)),
          0,
          lBins - 1
        );
      }

      function binIndexH(theta) {
        const h = ((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
        return clamp(Math.floor((h / (Math.PI * 2)) * hueBins), 0, hueBins - 1);
      }

      for (const s of samples) {
        const center = neutralCenterAtL(s.L, black, white);
        const da = s.a - center.a;
        const db = s.b - center.b;
        const r = hypot2(da, db);

        if (r < 1e-6) continue;

        const theta = angleOf(da, db);
        const li = binIndexL(s.L);
        const hi = binIndexH(theta);

        if (s.outOfGamut) {
          continue;
        }

        if (!Number.isFinite(rawOuter[li][hi])) {
          rawOuter[li][hi] = r;
        } else {
          rawOuter[li][hi] = Math.max(rawOuter[li][hi], r);
        }

        rawCount[li][hi]++;
      }

      for (let li = 0; li < lBins; li++) {
        const L = lerp(Lmin, Lmax, li / (lBins - 1));

        for (let hi = 0; hi < hueBins; hi++) {
          const theta = (hi / hueBins) * Math.PI * 2;
          const base = baseConeRadiusAt(L, theta);

          if (!Number.isFinite(rawOuter[li][hi])) {
            rawOuter[li][hi] = base;
          } else {
            rawOuter[li][hi] = Math.max(base, rawOuter[li][hi]);
          }
        }
      }

      let outer = rawOuter.map(row => row.slice());

      for (let pass = 0; pass < params.outlineSmooth; pass++) {
        const next = outer.map(row => row.slice());

        for (let li = 0; li < lBins; li++) {
          for (let hi = 0; hi < hueBins; hi++) {
            let sum = 0;
            let wsum = 0;

            for (let dl = -1; dl <= 1; dl++) {
              for (let dh = -1; dh <= 1; dh++) {
                const lj = clamp(li + dl, 0, lBins - 1);
                const hj = (hi + dh + hueBins) % hueBins;

                const w = dl === 0 && dh === 0 ? 4 : 1;

                sum += outer[lj][hj] * w;
                wsum += w;
              }
            }

            next[li][hi] = sum / wsum;
          }
        }

        outer = next;
      }

      const protect = Math.max(1, Math.round(params.outerProtect));

      for (let li = 0; li < lBins; li++) {
        const row = outer[li];
        const maxRow = rollingMax(row.concat(row, row), protect);
        const middle = maxRow.slice(hueBins, hueBins * 2);

        for (let hi = 0; hi < hueBins; hi++) {
          outer[li][hi] = Math.max(row[hi], middle[hi] * params.protrusionAllow);
        }
      }

      for (let li = 0; li < lBins; li++) {
        const L = lerp(Lmin, Lmax, li / (lBins - 1));

        for (let hi = 0; hi < hueBins; hi++) {
          const theta = (hi / hueBins) * Math.PI * 2;
          const base = baseConeRadiusAt(L, theta);

          outer[li][hi] = Math.max(base, outer[li][hi]);
        }
      }

      function interpolateOuter(L, theta) {
        const lNorm = clamp((L - Lmin) / Math.max(1e-9, Lmax - Lmin), 0, 1);
        const hNorm = (((theta % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2);

        const lf = lNorm * (lBins - 1);
        const l0 = Math.floor(lf);
        const l1 = Math.min(l0 + 1, lBins - 1);
        const lt = lf - l0;

        const hf = hNorm * hueBins;
        const h0 = Math.floor(hf) % hueBins;
        const h1 = (h0 + 1) % hueBins;
        const ht = hf - Math.floor(hf);

        const v00 = outer[l0][h0];
        const v01 = outer[l0][h1];
        const v10 = outer[l1][h0];
        const v11 = outer[l1][h1];

        const v0 = lerp(v00, v01, ht);
        const v1 = lerp(v10, v11, ht);

        return lerp(v0, v1, lt);
      }

      function boundaryRadiusAt(L, theta) {
        const base = baseConeRadiusAt(L, theta);
        const observed = interpolateOuter(L, theta);

        return Math.max(
          base,
          lerp(base, Math.max(base, observed), params.gamutGuard)
        );
      }

      function gamutVolumeEstimate() {
        const lSteps = 90;
        const hSteps = 180;

        let volume = 0;
        const dL = (Lmax - Lmin) / (lSteps - 1);
        const dTheta = (Math.PI * 2) / hSteps;

        for (let li = 0; li < lSteps; li++) {
          const L = lerp(Lmin, Lmax, li / (lSteps - 1));

          for (let hi = 0; hi < hSteps; hi++) {
            const theta = (hi / hSteps) * Math.PI * 2;
            const r = boundaryRadiusAt(L, theta);

            volume += 0.5 * r * r * dTheta * dL;
          }
        }

        return volume;
      }

      return {
        white,
        black,
        vertices,
        Lmin,
        Lmax,
        hueBins,
        lBins,
        outer,
        rawOuter,
        boundaryRadiusAt,
        baseConeRadiusAt,
        neutralCenterAtL: L => neutralCenterAtL(L, black, white),
        volume: gamutVolumeEstimate()
      };
    }

    // =====================================================
    // RGB -> Lab fitting
    //
    // 측색 데이터는 CLUT보다 적거나 많을 수 있으므로
    // local quadratic weighted regression을 사용합니다.
    // out-of-gamut 샘플은 fitting 영향도를 낮춥니다.
    // =====================================================

    function localQuadraticPredict(rgb, samples, params) {
      const qR = rgb.R / 100;
      const qG = rgb.G / 100;
      const qB = rgb.B / 100;

      const sigma = params.fitSmooth;
      const kMax = Math.min(72, samples.length);

      const near = samples.map(s => {
        const dR = s.R / 100 - qR;
        const dG = s.G / 100 - qG;
        const dB = s.B / 100 - qB;
        const d2 = dR * dR + dG * dG + dB * dB;

        return { s, d2 };
      });

      near.sort((a, b) => a.d2 - b.d2);

      const use = near.slice(0, kMax);

      const nBasis = 10;
      const ridge = 1e-5;

      const A = Array.from({ length: nBasis }, () => Array(nBasis).fill(0));
      const bL = Array(nBasis).fill(0);
      const ba = Array(nBasis).fill(0);
      const bb = Array(nBasis).fill(0);

      let totalW = 0;

      for (const item of use) {
        const s = item.s;

        const dR = s.R / 100 - qR;
        const dG = s.G / 100 - qG;
        const dB = s.B / 100 - qB;

        const d2 = dR * dR + dG * dG + dB * dB;

        let w = Math.exp(-0.5 * d2 / Math.max(1e-9, sigma * sigma));

        if (s.outOfGamut) {
          w *= params.oogWeight;
        }

        const basis = [
          1,
          dR,
          dG,
          dB,
          dR * dR,
          dG * dG,
          dB * dB,
          dR * dG,
          dR * dB,
          dG * dB
        ];

        totalW += w;

        for (let i = 0; i < nBasis; i++) {
          bL[i] += w * basis[i] * s.L;
          ba[i] += w * basis[i] * s.a;
          bb[i] += w * basis[i] * s.b;

          for (let j = 0; j < nBasis; j++) {
            A[i][j] += w * basis[i] * basis[j];
          }
        }
      }

      if (totalW < 1e-9) {
        return idwFallback(rgb, samples, params);
      }

      for (let i = 0; i < nBasis; i++) {
        A[i][i] += ridge;
      }

      const cL = solveLinearSystem(A.map(row => row.slice()), bL);
      const ca = solveLinearSystem(A.map(row => row.slice()), ba);
      const cb = solveLinearSystem(A.map(row => row.slice()), bb);

      if (!cL || !ca || !cb) {
        return idwFallback(rgb, samples, params);
      }

      return {
        L: cL[0],
        a: ca[0],
        b: cb[0]
      };
    }

    function idwFallback(rgb, samples, params) {
      let wsum = 0;
      let L = 0;
      let a = 0;
      let b = 0;

      for (const s of samples) {
        const dR = (s.R - rgb.R) / 100;
        const dG = (s.G - rgb.G) / 100;
        const dB = (s.B - rgb.B) / 100;

        const d2 = dR * dR + dG * dG + dB * dB;

        let w = 1 / Math.pow(d2 + 0.0025, 1.35);

        if (s.outOfGamut) {
          w *= params.oogWeight;
        }

        wsum += w;
        L += w * s.L;
        a += w * s.a;
        b += w * s.b;
      }

      return {
        L: L / wsum,
        a: a / wsum,
        b: b / wsum
      };
    }

    // =====================================================
    // Gamut guard projection
    //
    // 목적:
    // - 외곽이 out-of-gamut 압축값 때문에 안쪽으로 끌려 들어가는 현상 방지
    // - polygon보다 튀어나온 실제 측색 outer envelope는 허용
    // - 오목하게 눌린 부분은 base polygon / outer envelope로 복원
    // =====================================================

    function applyGamutGuard(rawLab, rgb, model, params) {
      const L = clamp(rawLab.L, model.Lmin, model.Lmax);
      const center = model.neutralCenterAtL(L);

      let da = rawLab.a - center.a;
      let db = rawLab.b - center.b;

      let r = hypot2(da, db);

      if (r < 1e-9) {
        return {
          L,
          a: center.a,
          b: center.b
        };
      }

      const theta = angleOf(da, db);
      const boundary = Math.max(1e-9, model.boundaryRadiusAt(L, theta));

      const maxRGB = Math.max(rgb.R, rgb.G, rgb.B);
      const minRGB = Math.min(rgb.R, rgb.G, rgb.B);
      const deviceSat = (maxRGB - minRGB) / 100;

      const nearCubeSurface =
        1 -
        Math.min(
          rgb.R,
          100 - rgb.R,
          rgb.G,
          100 - rgb.G,
          rgb.B,
          100 - rgb.B
        ) / 50;

      const surfaceStrength = clamp(nearCubeSurface, 0, 1);

      const expectedRatio =
        Math.pow(deviceSat, 0.88) *
        (0.58 + 0.42 * surfaceStrength);

      const minProtectedRadius =
        boundary *
        expectedRatio *
        params.antiShrink;

      if (r < minProtectedRadius) {
        r = lerp(r, minProtectedRadius, params.gamutGuard);
      }

      if (r > boundary) {
        r = lerp(r, boundary, 0.96);
      }

      da = Math.cos(theta) * r;
      db = Math.sin(theta) * r;

      return {
        L,
        a: center.a + da,
        b: center.b + db
      };
    }

    // =====================================================
    // CLUT generation
    //
    // RGB 0~100, size x size x size.
    // 기본 size는 16, 즉 16/16/16입니다.
    // =====================================================

    function generateCLUT(samples, model, params) {
      const size = params.clutSize;
      const clut = [];

      for (let ri = 0; ri < size; ri++) {
        const R = (ri / (size - 1)) * 100;

        for (let gi = 0; gi < size; gi++) {
          const G = (gi / (size - 1)) * 100;

          for (let bi = 0; bi < size; bi++) {
            const B = (bi / (size - 1)) * 100;

            const rgb = { R, G, B };

            const raw = localQuadraticPredict(rgb, samples, params);
            const guarded = applyGamutGuard(raw, rgb, model, params);

            clut.push({
              ri,
              gi,
              bi,
              R,
              G,
              B,
              rawL: raw.L,
              rawa: raw.a,
              rawb: raw.b,
              L: guarded.L,
              a: guarded.a,
              b: guarded.b
            });
          }
        }
      }

      return clut;
    }

    function clutToCSV(clut) {
      const rows = [
        "ri,gi,bi,R,G,B,L,a,b,rawL,rawa,rawb"
      ];

      for (const p of clut) {
        rows.push([
          p.ri,
          p.gi,
          p.bi,
          p.R.toFixed(6),
          p.G.toFixed(6),
          p.B.toFixed(6),
          p.L.toFixed(6),
          p.a.toFixed(6),
          p.b.toFixed(6),
          p.rawL.toFixed(6),
          p.rawa.toFixed(6),
          p.rawb.toFixed(6)
        ].join(","));
      }

      return rows.join("\n");
    }

    function verticesToCSV(model) {
      const rows = [
        "name,R,G,B,L,a,b,hue"
      ];

      rows.push([
        "WHITE",
        model.white.R,
        model.white.G,
        model.white.B,
        model.white.L.toFixed(6),
        model.white.a.toFixed(6),
        model.white.b.toFixed(6),
        ""
      ].join(","));

      rows.push([
        "BLACK",
        model.black.R,
        model.black.G,
        model.black.B,
        model.black.L.toFixed(6),
        model.black.a.toFixed(6),
        model.black.b.toFixed(6),
        ""
      ].join(","));

      for (const v of model.vertices) {
        rows.push([
          v.name,
          v.R.toFixed(6),
          v.G.toFixed(6),
          v.B.toFixed(6),
          v.L.toFixed(6),
          v.a.toFixed(6),
          v.b.toFixed(6),
          v.hue.toFixed(8)
        ].join(","));
      }

      return rows.join("\n");
    }

    // =====================================================
    // Rendering
    // =====================================================

    let measuredSamples = [];
    let gamutModel = null;
    let clutData = [];

    let rotationEnabled = true;
    let rotationTime = 0;
    let lastFrameTime = performance.now();

    function labToWorld(L, a, b) {
      return [
        a / 75,
        (L - 50) / 42,
        b / 75
      ];
    }

    function rotateX(p, angle) {
      const c = Math.cos(angle);
      const s = Math.sin(angle);

      return [
        p[0],
        p[1] * c - p[2] * s,
        p[1] * s + p[2] * c
      ];
    }

    function rotateY(p, angle) {
      const c = Math.cos(angle);
      const s = Math.sin(angle);

      return [
        p[0] * c + p[2] * s,
        p[1],
        -p[0] * s + p[2] * c
      ];
    }

    function rotateZ(p, angle) {
      const c = Math.cos(angle);
      const s = Math.sin(angle);

      return [
        p[0] * c - p[1] * s,
        p[0] * s + p[1] * c,
        p[2]
      ];
    }

    function transform(p, time) {
      let q = p;

      q = rotateX(q, -0.55);
      q = rotateZ(q, 0.15);
      q = rotateY(q, time * 0.00032);

      return q;
    }

    function project(p, cx, cy, scale) {
      const d = 5.0;
      const k = d / (d + p[2]);

      return {
        x: cx + p[0] * scale * k,
        y: cy - p[1] * scale * k,
        z: p[2]
      };
    }

    function labColorApprox(L, a, b, alpha) {
      const C = hypot2(a, b);
      const h = angleOf(a, b);

      const light = clamp(L / 100, 0, 1);
      const sat = clamp(C / 90, 0, 1);

      const r = Math.floor(80 + 160 * light + 60 * sat * Math.cos(h));
      const g = Math.floor(80 + 160 * light + 60 * sat * Math.cos(h - 2.1));
      const bl = Math.floor(80 + 160 * light + 60 * sat * Math.cos(h + 2.1));

      return `rgba(${clamp(r,0,255)},${clamp(g,0,255)},${clamp(bl,0,255)},${alpha})`;
    }

    function drawAxis(cx, cy, scale, time) {
      const axes = [
        { p1: [-1.4, 0, 0], p2: [1.4, 0, 0], color: "rgba(255,120,120,0.45)", label: "a*" },
        { p1: [0, -1.4, 0], p2: [0, 1.4, 0], color: "rgba(255,255,255,0.35)", label: "L*" },
        { p1: [0, 0, -1.4], p2: [0, 0, 1.4], color: "rgba(120,160,255,0.45)", label: "b*" }
      ];

      for (const ax of axes) {
        const p1 = project(transform(ax.p1, time), cx, cy, scale);
        const p2 = project(transform(ax.p2, time), cx, cy, scale);

        ctx.beginPath();
        ctx.moveTo(p1.x, p1.y);
        ctx.lineTo(p2.x, p2.y);
        ctx.strokeStyle = ax.color;
        ctx.lineWidth = 1.2;
        ctx.stroke();

        ctx.fillStyle = ax.color;
        ctx.font = "13px Arial";
        ctx.fillText(ax.label, p2.x + 4, p2.y + 4);
      }
    }

    function drawMeasured(time) {
      const cx = canvas.width * 0.43;
      const cy = canvas.height * 0.55;
      const scale = Math.min(canvas.width, canvas.height) * 0.24;

      drawAxis(cx, cy, scale, time);

      const items = measuredSamples.map(s => {
        const w = labToWorld(s.L, s.a, s.b);
        const q = transform(w, time);
        const screen = project(q, cx, cy, scale);

        return { s, q, screen };
      });

      items.sort((a, b) => a.q[2] - b.q[2]);

      for (const item of items) {
        const s = item.s;
        const radius = s.outOfGamut ? 3.0 : 2.2;

        ctx.beginPath();
        ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);

        if (s.outOfGamut) {
          ctx.fillStyle = "rgba(255,85,65,0.80)";
        } else {
          ctx.fillStyle = "rgba(110,185,255,0.78)";
        }

        ctx.fill();
      }

      if (gamutModel) {
        drawModelWire(gamutModel, cx, cy, scale, time, true);
      }
    }

    function drawCLUT(time) {
      const cx = canvas.width * 0.76;
      const cy = canvas.height * 0.55;
      const scale = Math.min(canvas.width, canvas.height) * 0.24;

      drawAxis(cx, cy, scale, time);

      const skip = Math.max(1, Math.floor(clutData.length / 2500));

      const items = [];

      for (let i = 0; i < clutData.length; i += skip) {
        const p = clutData[i];
        const w = labToWorld(p.L, p.a, p.b);
        const q = transform(w, time);
        const screen = project(q, cx, cy, scale);

        items.push({ p, q, screen });
      }

      items.sort((a, b) => a.q[2] - b.q[2]);

      for (const item of items) {
        const p = item.p;

        ctx.beginPath();
        ctx.arc(item.screen.x, item.screen.y, 2.0, 0, Math.PI * 2);
        ctx.fillStyle = labColorApprox(p.L, p.a, p.b, 0.76);
        ctx.fill();
      }

      if (gamutModel) {
        drawModelWire(gamutModel, cx, cy, scale, time, false);
      }
    }

    function drawModelWire(model, cx, cy, scale, time, showVertices) {
      function drawLabLine(points, color, width) {
        ctx.beginPath();

        for (let i = 0; i < points.length; i++) {
          const p = points[i];
          const w = labToWorld(p.L, p.a, p.b);
          const q = transform(w, time);
          const s = project(q, cx, cy, scale);

          if (i === 0) ctx.moveTo(s.x, s.y);
          else ctx.lineTo(s.x, s.y);
        }

        ctx.strokeStyle = color;
        ctx.lineWidth = width;
        ctx.stroke();
      }

      const poly = model.vertices.map(v => ({
        L: v.L,
        a: v.a,
        b: v.b
      }));

      if (poly.length > 0) {
        drawLabLine(poly.concat([poly[0]]), "rgba(255,255,255,0.76)", 1.8);
      }

      for (const v of model.vertices) {
        drawLabLine([
          { L: model.black.L, a: model.black.a, b: model.black.b },
          { L: v.L, a: v.a, b: v.b },
          { L: model.white.L, a: model.white.a, b: model.white.b }
        ], "rgba(255,255,255,0.22)", 0.8);
      }

      if (showVertices) {
        const special = [
          { name: "W", ...model.white },
          { name: "K", ...model.black },
          ...model.vertices
        ];

        for (const p of special) {
          const w = labToWorld(p.L, p.a, p.b);
          const q = transform(w, time);
          const s = project(q, cx, cy, scale);

          ctx.beginPath();
          ctx.arc(s.x, s.y, p.name === "W" || p.name === "K" ? 5 : 4, 0, Math.PI * 2);
          ctx.fillStyle = p.name === "W" || p.name === "K"
            ? "rgba(255,255,255,0.95)"
            : "rgba(255,225,80,0.95)";
          ctx.fill();

          ctx.fillStyle = "#ffffff";
          ctx.font = "12px Arial";
          ctx.fillText(p.name, s.x + 6, s.y - 4);
        }
      }

      const hueSteps = 96;
      const lSteps = 8;

      for (let li = 0; li < lSteps; li++) {
        const L = lerp(model.Lmin, model.Lmax, li / (lSteps - 1));
        const ring = [];

        for (let hi = 0; hi <= hueSteps; hi++) {
          const theta = (hi / hueSteps) * Math.PI * 2;
          const r = model.boundaryRadiusAt(L, theta);
          const c = model.neutralCenterAtL(L);

          ring.push({
            L,
            a: c.a + Math.cos(theta) * r,
            b: c.b + Math.sin(theta) * r
          });
        }

        drawLabLine(ring, "rgba(157,255,183,0.18)", 0.9);
      }
    }

    function drawDivider() {
      ctx.strokeStyle = "rgba(255,255,255,0.13)";
      ctx.lineWidth = 1;

      ctx.beginPath();
      ctx.moveTo(canvas.width * 0.60, 0);
      ctx.lineTo(canvas.width * 0.60, canvas.height);
      ctx.stroke();

      ctx.fillStyle = "rgba(255,255,255,0.16)";
      ctx.font = "40px Arial";
      ctx.fillText("→", canvas.width * 0.60 - 13, canvas.height * 0.50);
    }

    function draw() {
      const now = performance.now();
      const delta = now - lastFrameTime;
      lastFrameTime = now;

      if (rotationEnabled) {
        rotationTime += delta;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);

      drawDivider();

      if (measuredSamples.length > 0) {
        drawMeasured(rotationTime);
      }

      if (clutData.length > 0) {
        drawCLUT(rotationTime);
      }

      requestAnimationFrame(draw);
    }

    // =====================================================
    // Build pipeline
    // =====================================================

    function buildPipeline() {
      const params = getParams();

      measuredSamples = parseInput(inputText.value);

      if (measuredSamples.length < 12) {
        statusBox.textContent = "샘플이 너무 적습니다. 최소 12개 이상 필요합니다.";
        return;
      }

      gamutModel = buildGamutModel(measuredSamples, params);
      clutData = generateCLUT(measuredSamples, gamutModel, params);

      const safeCount = measuredSamples.filter(s => !s.outOfGamut).length;
      const oogCount = measuredSamples.length - safeCount;

      const vertexText = gamutModel.vertices
        .map(v => `${v.name}=RGB(${v.R.toFixed(1)},${v.G.toFixed(1)},${v.B.toFixed(1)}) Lab(${v.L.toFixed(1)},${v.a.toFixed(1)},${v.b.toFixed(1)})`)
        .join("\n");

      statusBox.textContent =
        `측색 샘플: ${measuredSamples.length}개\n` +
        `gamut-safe: ${safeCount}개, out-of-gamut/압축 의심: ${oogCount}개\n` +
        `CLUT: ${params.clutSize} x ${params.clutSize} x ${params.clutSize} = ${clutData.length}개\n` +
        `예상 in-gamut Lab 부피: ${gamutModel.volume.toFixed(1)} Lab³\n` +
        `White point: RGB(${gamutModel.white.R.toFixed(1)}, ${gamutModel.white.G.toFixed(1)}, ${gamutModel.white.B.toFixed(1)}) Lab(${gamutModel.white.L.toFixed(2)}, ${gamutModel.white.a.toFixed(2)}, ${gamutModel.white.b.toFixed(2)})\n` +
        `Black point: RGB(${gamutModel.black.R.toFixed(1)}, ${gamutModel.black.G.toFixed(1)}, ${gamutModel.black.B.toFixed(1)}) Lab(${gamutModel.black.L.toFixed(2)}, ${gamutModel.black.a.toFixed(2)}, ${gamutModel.black.b.toFixed(2)})\n\n` +
        vertexText;

      console.clear();
      console.log("Measured samples:", measuredSamples);
      console.log("Gamut model:", gamutModel);
      console.log("A2B CLUT RGB->Lab:", clutData);
    }

    document.getElementById("loadDemo").addEventListener("click", () => {
      inputText.value = demoDataText();
      buildPipeline();
    });

    document.getElementById("build").addEventListener("click", () => {
      buildPipeline();
    });

    document.getElementById("toggleRotation").addEventListener("click", e => {
      rotationEnabled = !rotationEnabled;
      e.target.textContent = rotationEnabled ? "회전 정지" : "회전 시작";
    });

    document.getElementById("downloadClut").addEventListener("click", () => {
      if (!clutData.length) {
        buildPipeline();
      }

      if (!clutData.length) return;

      const csv = clutToCSV(clutData);
      downloadText("printer_a2b_rgb_to_lab_clut.csv", csv);
    });

    document.getElementById("downloadVertices").addEventListener("click", () => {
      if (!gamutModel) {
        buildPipeline();
      }

      if (!gamutModel) return;

      const csv = verticesToCSV(gamutModel);
      downloadText("printer_gamut_vertices_white_black.csv", csv);
    });

    inputText.value = demoDataText();
    getParams();
    buildPipeline();
    draw();
  </script>
</body>
</html>

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>Printer ICC A2B PCS CLUT Generator RGB+Lab</title>
  <style>
    body {
      margin: 0;
      background: #101012;
      color: #eee;
      font-family: Arial, sans-serif;
      overflow: hidden;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
      background: #101012;
    }

    .panel {
      position: fixed;
      left: 12px;
      top: 12px;
      width: 420px;
      max-height: calc(100vh - 24px);
      overflow: auto;
      z-index: 10;
      padding: 12px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.74);
      box-shadow: 0 0 18px rgba(0, 0, 0, 0.45);
    }

    .panel h2 {
      margin: 0 0 8px 0;
      font-size: 16px;
      color: #9dffb7;
    }

    .panel p {
      margin: 6px 0;
      line-height: 1.45;
      color: #ccc;
      font-size: 12px;
    }

    textarea {
      width: 100%;
      height: 185px;
      box-sizing: border-box;
      resize: vertical;
      background: #19191c;
      color: #e8e8e8;
      border: 1px solid #333;
      border-radius: 8px;
      padding: 8px;
      font-family: Consolas, monospace;
      font-size: 12px;
      line-height: 1.4;
    }

    .buttons {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 6px;
      margin-top: 8px;
    }

    button {
      border: 0;
      border-radius: 8px;
      padding: 8px 9px;
      font-weight: bold;
      cursor: pointer;
      background: #f2f2f2;
      color: #111;
    }

    button:hover {
      background: #dcdcdc;
    }

    .controls {
      margin-top: 10px;
      display: grid;
      grid-template-columns: 145px 160px 54px;
      gap: 5px 8px;
      align-items: center;
      font-size: 12px;
    }

    input[type="range"] {
      width: 160px;
    }

    .info {
      margin-top: 10px;
      padding: 8px;
      border-radius: 8px;
      background: rgba(255, 255, 255, 0.06);
      font-size: 12px;
      color: #ddd;
      white-space: pre-line;
      line-height: 1.45;
    }

    .legend {
      position: fixed;
      right: 14px;
      top: 14px;
      z-index: 10;
      padding: 10px 12px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.62);
      font-size: 13px;
      line-height: 1.5;
      color: #ddd;
    }

    .titleLeft,
    .titleRight {
      position: fixed;
      top: 14px;
      z-index: 6;
      padding: 7px 10px;
      border-radius: 9px;
      background: rgba(0, 0, 0, 0.55);
      font-weight: bold;
      font-size: 15px;
    }

    .titleLeft {
      left: 455px;
      color: #8ecbff;
    }

    .titleRight {
      right: 345px;
      color: #9dffb7;
    }
  </style>
</head>
<body>
  <canvas id="canvas"></canvas>

  <div class="titleLeft">측정 RGB/Lab 미리보기</div>
  <div class="titleRight">생성된 A2B CLUT Lab 미리보기</div>

  <div class="legend">
    <b>표시</b><br />
    흰색 선: 최종 gamut-guard 표면<br />
    붉은 선: raw 측색 외곽 추정<br />
    큰 흰 점: RGB/CMY 꼭짓점<br />
    노란 점: White / Black point<br />
    <br />
    CLUT: RGB 16 × 16 × 16<br />
    RGB 범위: 0 ~ 100<br />
    출력: RGB, Lab CSV/JSON
  </div>

  <div class="panel">
    <h2>프린터 ICC A2B → PCS Lab CLUT 생성</h2>

    <p>
      입력 형식은 한 줄마다 <b>R G B L a b</b> 입니다.
      RGB는 0~100 범위, Lab은 측색 Lab입니다.
    </p>

    <textarea id="dataInput"></textarea>

    <div class="buttons">
      <button id="loadDemo">데모 RGB/Lab 생성</button>
      <button id="generate">A2B CLUT 생성</button>
      <button id="toggleRotation">회전 정지</button>
      <button id="downloadCsv">CSV 다운로드</button>
      <button id="downloadJson">JSON 다운로드</button>
      <button id="copyJson">JSON 복사</button>
    </div>

    <div class="controls">
      <label>rgbFitSmooth</label>
      <input id="rgbFitSmooth" type="range" min="0.04" max="0.35" step="0.01" value="0.16" />
      <span id="rgbFitSmoothValue">0.16</span>

      <label>edgeSmooth</label>
      <input id="edgeSmooth" type="range" min="1" max="12" step="1" value="5" />
      <span id="edgeSmoothValue">5</span>

      <label>antiShrink</label>
      <input id="antiShrink" type="range" min="0" max="1" step="0.05" value="0.88" />
      <span id="antiShrinkValue">0.88</span>

      <label>gamutGuard</label>
      <input id="gamutGuard" type="range" min="0" max="1" step="0.05" value="0.90" />
      <span id="gamutGuardValue">0.90</span>

      <label>edgeProtect</label>
      <input id="edgeProtect" type="range" min="1" max="8" step="1" value="3" />
      <span id="edgeProtectValue">3</span>

      <label>outlineFollow</label>
      <input id="outlineFollow" type="range" min="0" max="1" step="0.05" value="0.55" />
      <span id="outlineFollowValue">0.55</span>

      <label>measurementFollow</label>
      <input id="measurementFollow" type="range" min="0" max="1" step="0.05" value="0.72" />
      <span id="measurementFollowValue">0.72</span>

      <label>radialPower</label>
      <input id="radialPower" type="range" min="0.65" max="1.40" step="0.05" value="0.92" />
      <span id="radialPowerValue">0.92</span>
    </div>

    <div id="info" class="info">준비 중...</div>
  </div>

  <script>
    // ============================================================
    // 0. Canvas
    // ============================================================

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    function resize() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }

    window.addEventListener("resize", resize);
    resize();

    // ============================================================
    // 1. Basic utilities
    // ============================================================

    function clamp(x, a, b) {
      return Math.max(a, Math.min(b, x));
    }

    function lerp(a, b, t) {
      return a * (1 - t) + b * t;
    }

    function smoothstep(a, b, x) {
      if (a === b) return x < a ? 0 : 1;
      const t = clamp((x - a) / (b - a), 0, 1);
      return t * t * (3 - 2 * t);
    }

    function normDeg(h) {
      h = h % 360;
      if (h < 0) h += 360;
      return h;
    }

    function angleDiffDeg(a, b) {
      let d = normDeg(a) - normDeg(b);
      while (d > 180) d -= 360;
      while (d < -180) d += 360;
      return d;
    }

    function angleDistanceDeg(a, b) {
      return Math.abs(angleDiffDeg(a, b));
    }

    function circularLerpDeg(a, b, t) {
      return normDeg(a + angleDiffDeg(b, a) * t);
    }

    function percentile(values, q) {
      const arr = values
        .filter(Number.isFinite)
        .slice()
        .sort((a, b) => a - b);

      if (arr.length === 0) return NaN;
      if (arr.length === 1) return arr[0];

      const pos = (arr.length - 1) * q;
      const lo = Math.floor(pos);
      const hi = Math.ceil(pos);
      const t = pos - lo;

      return arr[lo] * (1 - t) + arr[hi] * t;
    }

    function median(values) {
      return percentile(values, 0.5);
    }

    function rand(seed) {
      const x = Math.sin(seed * 12.9898) * 43758.5453123;
      return x - Math.floor(x);
    }

    function rgbDist2(a, b) {
      const dr = (a.r - b.r) / 100;
      const dg = (a.g - b.g) / 100;
      const db = (a.b - b.b) / 100;
      return dr * dr + dg * dg + db * db;
    }

    function solveLinearSystem(A, b) {
      const n = A.length;
      const M = A.map((row, i) => [...row, b[i]]);

      for (let col = 0; col < n; col++) {
        let pivot = col;

        for (let row = col + 1; row < n; row++) {
          if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
            pivot = row;
          }
        }

        if (Math.abs(M[pivot][col]) < 1e-12) {
          return null;
        }

        [M[col], M[pivot]] = [M[pivot], M[col]];

        const div = M[col][col];

        for (let k = col; k <= n; k++) {
          M[col][k] /= div;
        }

        for (let row = 0; row < n; row++) {
          if (row === col) continue;

          const factor = M[row][col];

          for (let k = col; k <= n; k++) {
            M[row][k] -= factor * M[col][k];
          }
        }
      }

      return M.map(row => row[n]);
    }

    // ============================================================
    // 2. Lab / LCh
    // ============================================================

    function labToLch(lab, centerAB) {
      const da = lab.a - centerAB.a;
      const db = lab.b - centerAB.b;
      const C = Math.sqrt(da * da + db * db);
      const h = normDeg(Math.atan2(db, da) * 180 / Math.PI);

      return {
        L: lab.L,
        a: lab.a,
        b: lab.b,
        C,
        h
      };
    }

    function lchToLab(L, C, h, centerAB) {
      const rad = h * Math.PI / 180;

      return {
        L,
        a: centerAB.a + C * Math.cos(rad),
        b: centerAB.b + C * Math.sin(rad)
      };
    }

    function labCss(lab, centerAB) {
      const lch = labToLch(lab, centerAB || { a: 0, b: 0 });
      const light = clamp(lab.L, 12, 90);
      const sat = clamp(30 + lch.C * 0.72, 16, 92);
      return `hsl(${lch.h.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
    }

    // ============================================================
    // 3. RGB / HSL
    // ============================================================

    function rgbToHsl(r, g, b) {
      r /= 100;
      g /= 100;
      b /= 100;

      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);
      const l = (max + min) / 2;

      let h = 0;
      let s = 0;

      if (max !== min) {
        const d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);

        switch (max) {
          case r:
            h = (g - b) / d + (g < b ? 6 : 0);
            break;
          case g:
            h = (b - r) / d + 2;
            break;
          case b:
            h = (r - g) / d + 4;
            break;
        }

        h *= 60;
      }

      return {
        h: normDeg(h),
        s: clamp(s, 0, 1),
        l: clamp(l, 0, 1)
      };
    }

    // ============================================================
    // 4. Demo RGB/Lab printer samples
    // ============================================================

    const DEMO_ANCHORS = [
      { name: "R", rgbHue: 0,   labHue: 37,  L: 50.5, C: 73 },
      { name: "Y", rgbHue: 60,  labHue: 94,  L: 86.0, C: 88 },
      { name: "G", rgbHue: 120, labHue: 142, L: 57.0, C: 66 },
      { name: "C", rgbHue: 180, labHue: 213, L: 62.0, C: 48 },
      { name: "B", rgbHue: 240, labHue: 284, L: 34.5, C: 56 },
      { name: "M", rgbHue: 300, labHue: 330, L: 52.0, C: 78 }
    ];

    function interpolateByRgbHue(anchors, rgbHue, key) {
      const h = normDeg(rgbHue);
      const ordered = anchors.slice().sort((a, b) => a.rgbHue - b.rgbHue);

      for (let i = 0; i < ordered.length; i++) {
        const a = ordered[i];
        const b = ordered[(i + 1) % ordered.length];

        let ah = a.rgbHue;
        let bh = b.rgbHue;

        if (i === ordered.length - 1) bh += 360;

        let hh = h;
        if (hh < ah) hh += 360;

        if (hh >= ah && hh <= bh) {
          const t = (hh - ah) / Math.max(1e-9, bh - ah);

          if (key === "labHue") {
            let ha = a.labHue;
            let hb = b.labHue;

            while (hb <= ha) hb += 360;

            return normDeg(lerp(ha, hb, t));
          }

          return lerp(a[key], b[key], t);
        }
      }

      return ordered[0][key];
    }

    function demoLabFromRgb(r, g, b, seed) {
      const blackL = 7.8;
      const whiteL = 95.4;

      const hsl = rgbToHsl(r, g, b);
      const labHue = interpolateByRgbHue(DEMO_ANCHORS, hsl.h, "labHue");
      const waistL = interpolateByRgbHue(DEMO_ANCHORS, hsl.h, "L");
      const waistC = interpolateByRgbHue(DEMO_ANCHORS, hsl.h, "C");

      const neutralL = lerp(blackL, whiteL, hsl.l);

      let coloredL;

      if (hsl.l <= 0.5) {
        const t = smoothstep(0, 0.5, hsl.l);
        coloredL = lerp(blackL, waistL, t);
      } else {
        const t = smoothstep(0.5, 1, hsl.l);
        coloredL = lerp(waistL, whiteL, t);
      }

      let L = lerp(neutralL, coloredL, Math.pow(hsl.s, 0.72));

      let Cmax;

      if (L <= waistL) {
        const t = smoothstep(blackL, waistL, L);
        Cmax = waistC * Math.pow(t, 0.76);
      } else {
        const t = smoothstep(waistL, whiteL, L);
        Cmax = waistC * Math.pow(1 - t, 0.70);
      }

      let C = Cmax * Math.pow(hsl.s, 0.90);

      // 실제 프린터에서 존재할 법한 압축 / 오목 현상
      const dentR =
        Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 35) / 15, 2)) *
        Math.exp(-0.5 * Math.pow((L - 45) / 11, 2));

      const dentY =
        Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 105) / 18, 2)) *
        Math.exp(-0.5 * Math.pow((L - 78) / 12, 2));

      const dentM =
        Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 320) / 17, 2)) *
        Math.exp(-0.5 * Math.pow((L - 45) / 10, 2));

      const dent = Math.max(dentR, dentY, dentM);

      if (hsl.s > 0.72) {
        C *= 1 - 0.30 * dent;
      }

      // 일부 영역은 실제 gamut이 polygon보다 돌출
      const bump =
        Math.exp(-0.5 * Math.pow(angleDistanceDeg(labHue, 78) / 15, 2)) *
        Math.exp(-0.5 * Math.pow((L - 82) / 10, 2));

      C *= 1 + 0.12 * bump;

      // 약간의 장치 비선형
      L += (r - b) * 0.006 * hsl.s;
      C *= 0.98 + 0.04 * Math.sin((r + g * 0.7 + b * 0.35) * 0.04);

      // 측정 노이즈
      const n1 = rand(seed + 1) - 0.5;
      const n2 = rand(seed + 2) - 0.5;
      const n3 = rand(seed + 3) - 0.5;

      L += n1 * 0.75;
      C += n2 * 1.0;

      const h = labHue + n3 * 1.2;
      const lab = lchToLab(L, Math.max(0, C), h, { a: 0, b: 0 });

      return lab;
    }

    function makeDemoText() {
      const levels = [0, 8, 18, 32, 48, 64, 80, 92, 100];
      const lines = [];
      let seed = 10;

      for (const r of levels) {
        for (const g of levels) {
          for (const b of levels) {
            const lab = demoLabFromRgb(r, g, b, seed++);
            lines.push(
              [
                r.toFixed(1),
                g.toFixed(1),
                b.toFixed(1),
                lab.L.toFixed(3),
                lab.a.toFixed(3),
                lab.b.toFixed(3)
              ].join(" ")
            );
          }
        }
      }

      // 꼭짓점과 중성축 보강 샘플
      const extras = [
        [0, 0, 0],
        [100, 100, 100],
        [100, 0, 0],
        [100, 100, 0],
        [0, 100, 0],
        [0, 100, 100],
        [0, 0, 100],
        [100, 0, 100]
      ];

      for (const [r, g, b] of extras) {
        for (let i = 0; i < 6; i++) {
          const rr = clamp(r + (rand(seed++) - 0.5) * 2.0, 0, 100);
          const gg = clamp(g + (rand(seed++) - 0.5) * 2.0, 0, 100);
          const bb = clamp(b + (rand(seed++) - 0.5) * 2.0, 0, 100);
          const lab = demoLabFromRgb(rr, gg, bb, seed++);

          lines.push(
            [
              rr.toFixed(2),
              gg.toFixed(2),
              bb.toFixed(2),
              lab.L.toFixed(3),
              lab.a.toFixed(3),
              lab.b.toFixed(3)
            ].join(" ")
          );
        }
      }

      return lines.join("\n");
    }

    // ============================================================
    // 5. Parse input
    // ============================================================

    function parseInput(text) {
      const samples = [];

      const lines = text
        .split(/\r?\n/)
        .map(line => line.trim())
        .filter(line => line && !line.startsWith("#"));

      for (const line of lines) {
        const nums = line
          .replace(/[,\t;]+/g, " ")
          .split(/\s+/)
          .map(Number)
          .filter(Number.isFinite);

        if (nums.length >= 6) {
          const r = clamp(nums[0], 0, 100);
          const g = clamp(nums[1], 0, 100);
          const b = clamp(nums[2], 0, 100);

          samples.push({
            r,
            g,
            b,
            L: nums[3],
            a: nums[4],
            bLab: nums[5],
            lab: {
              L: nums[3],
              a: nums[4],
              b: nums[5]
            }
          });
        }
      }

      return samples;
    }

    // ============================================================
    // 6. Detect white, black, neutral, RGB/CMY vertices
    // ============================================================

    function sampleChroma(sample, centerAB) {
      const da = sample.lab.a - centerAB.a;
      const db = sample.lab.b - centerAB.b;
      return Math.sqrt(da * da + db * db);
    }

    function nearestRgbSample(samples, target) {
      return samples
        .slice()
        .sort((a, b) => rgbDist2(a, target) - rgbDist2(b, target))[0];
    }

    function detectWhiteBlack(samples) {
      const whiteTarget = { r: 100, g: 100, b: 100 };
      const blackTarget = { r: 0, g: 0, b: 0 };

      let white = nearestRgbSample(samples, whiteTarget);
      let black = nearestRgbSample(samples, blackTarget);

      const whiteDist = Math.sqrt(rgbDist2(white, whiteTarget)) * 100;
      const blackDist = Math.sqrt(rgbDist2(black, blackTarget)) * 100;

      if (whiteDist > 18) {
        white = samples.slice().sort((a, b) => b.L - a.L)[0];
      }

      if (blackDist > 18) {
        black = samples.slice().sort((a, b) => a.L - b.L)[0];
      }

      return {
        white,
        black
      };
    }

    function estimateNeutralAB(samples, white, black) {
      const grayLike = samples.filter(s => {
        const max = Math.max(s.r, s.g, s.b);
        const min = Math.min(s.r, s.g, s.b);
        return max - min <= 8;
      });

      const use = grayLike.length >= 5
        ? grayLike
        : samples
            .slice()
            .sort((a, b) => {
              const da = Math.abs(a.r - a.g) + Math.abs(a.g - a.b);
              const db = Math.abs(b.r - b.g) + Math.abs(b.g - b.b);
              return da - db;
            })
            .slice(0, Math.max(5, Math.floor(samples.length * 0.08)));

      const aMedian = median(use.map(s => s.lab.a));
      const bMedian = median(use.map(s => s.lab.b));

      return {
        a: Number.isFinite(aMedian) ? aMedian : (white.lab.a + black.lab.a) * 0.5,
        b: Number.isFinite(bMedian) ? bMedian : (white.lab.b + black.lab.b) * 0.5
      };
    }

    function detectVertices(samples, centerAB) {
      const targets = [
        { name: "R", rgbHue: 0,   r: 100, g: 0,   b: 0 },
        { name: "Y", rgbHue: 60,  r: 100, g: 100, b: 0 },
        { name: "G", rgbHue: 120, r: 0,   g: 100, b: 0 },
        { name: "C", rgbHue: 180, r: 0,   g: 100, b: 100 },
        { name: "B", rgbHue: 240, r: 0,   g: 0,   b: 100 },
        { name: "M", rgbHue: 300, r: 100, g: 0,   b: 100 }
      ];

      const vertices = [];

      for (const t of targets) {
        const ranked = samples
          .map(s => {
            const d = Math.sqrt(rgbDist2(s, t)) * 100;
            const C = sampleChroma(s, centerAB);

            return {
              sample: s,
              d,
              C,
              score: d - C * 0.08
            };
          })
          .sort((a, b) => a.score - b.score);

        let chosen = ranked[0];

        const near = ranked.filter(x => x.d <= Math.max(10, chosen.d + 8));

        if (near.length > 0) {
          chosen = near.sort((a, b) => b.C - a.C)[0];
        }

        const lab = chosen.sample.lab;
        const lch = labToLch(lab, centerAB);

        vertices.push({
          name: t.name,
          rgbHue: t.rgbHue,
          targetRGB: [t.r, t.g, t.b],
          sourceRGB: [chosen.sample.r, chosen.sample.g, chosen.sample.b],
          L: lab.L,
          a: lab.a,
          b: lab.b,
          C: lch.C,
          h: lch.h
        });
      }

      return vertices;
    }

    // ============================================================
    // 7. Polygon waist fallback
    // ============================================================

    function rayPolygonIntersectionC(hue, vertices, centerAB) {
      const dir = {
        x: Math.cos(hue * Math.PI / 180),
        y: Math.sin(hue * Math.PI / 180)
      };

      const poly = vertices.map(v => ({
        x: v.a - centerAB.a,
        y: v.b - centerAB.b,
        L: v.L,
        C: v.C,
        h: v.h,
        name: v.name
      }));

      let bestT = Infinity;
      let bestL = vertices[0].L;
      let bestEdge = "";

      for (let i = 0; i < poly.length; i++) {
        const p = poly[i];
        const q = poly[(i + 1) % poly.length];

        const ex = q.x - p.x;
        const ey = q.y - p.y;

        const det = dir.x * (-ey) - dir.y * (-ex);

        if (Math.abs(det) < 1e-9) continue;

        const px = p.x;
        const py = p.y;

        const t = (px * (-ey) - py * (-ex)) / det;
        const u = (dir.x * py - dir.y * px) / det;

        if (t >= 0 && u >= -1e-6 && u <= 1 + 1e-6) {
          if (t < bestT) {
            bestT = t;
            bestL = lerp(p.L, q.L, clamp(u, 0, 1));
            bestEdge = p.name + "-" + q.name;
          }
        }
      }

      if (!Number.isFinite(bestT)) {
        const nearest = vertices
          .slice()
          .sort((a, b) => angleDistanceDeg(a.h, hue) - angleDistanceDeg(b.h, hue))[0];

        return {
          C: nearest.C,
          L: nearest.L,
          edge: nearest.name
        };
      }

      return {
        C: bestT,
        L: bestL,
        edge: bestEdge
      };
    }

    // ============================================================
    // 8. Grid helpers
    // ============================================================

    function createGrid(rows, cols, fill = NaN) {
      return Array.from({ length: rows }, () =>
        Array.from({ length: cols }, () => fill)
      );
    }

    function fillMissingGrid(grid, fallbackFn) {
      const rows = grid.length;
      const cols = grid[0].length;
      const out = createGrid(rows, cols, 0);

      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          out[r][c] = Number.isFinite(grid[r][c])
            ? grid[r][c]
            : fallbackFn(r, c);
        }
      }

      return out;
    }

    function smoothGridCyclic(grid, passes) {
      const rows = grid.length;
      const cols = grid[0].length;
      let g = grid.map(row => row.slice());

      for (let pass = 0; pass < passes; pass++) {
        const n = createGrid(rows, cols, 0);

        for (let r = 0; r < rows; r++) {
          const r0 = Math.max(0, r - 1);
          const r1 = Math.min(rows - 1, r + 1);

          for (let c = 0; c < cols; c++) {
            const c0 = (c - 1 + cols) % cols;
            const c1 = (c + 1) % cols;

            n[r][c] =
              g[r][c] * 0.42 +
              g[r0][c] * 0.14 +
              g[r1][c] * 0.14 +
              g[r][c0] * 0.15 +
              g[r][c1] * 0.15;
          }
        }

        g = n;
      }

      return g;
    }

    function maxGridNeighborhood(grid, radiusL, radiusH) {
      const rows = grid.length;
      const cols = grid[0].length;
      const out = createGrid(rows, cols, 0);

      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          let m = -Infinity;

          for (let dr = -radiusL; dr <= radiusL; dr++) {
            const rr = clamp(r + dr, 0, rows - 1);

            for (let dc = -radiusH; dc <= radiusH; dc++) {
              const cc = (c + dc + cols) % cols;
              m = Math.max(m, grid[rr][cc]);
            }
          }

          out[r][c] = m;
        }
      }

      return out;
    }

    function bilinearCyclic(grid, lNorm, hue) {
      const rows = grid.length;
      const cols = grid[0].length;

      const y = clamp(lNorm, 0, 1) * (rows - 1);
      const r0 = Math.floor(y);
      const r1 = Math.min(rows - 1, r0 + 1);
      const ty = y - r0;

      const x = normDeg(hue) / 360 * cols;
      const c0 = Math.floor(x) % cols;
      const c1 = (c0 + 1) % cols;
      const tx = x - Math.floor(x);

      const a = lerp(grid[r0][c0], grid[r0][c1], tx);
      const b = lerp(grid[r1][c0], grid[r1][c1], tx);

      return lerp(a, b, ty);
    }

    // ============================================================
    // 9. Gamut surface model
    // ============================================================

    function buildGamutModel(samples, params) {
      const wb = detectWhiteBlack(samples);
      const white = wb.white;
      const black = wb.black;

      const centerAB = estimateNeutralAB(samples, white, black);
      const vertices = detectVertices(samples, centerAB);

      const lchList = samples.map(s => ({
        ...labToLch(s.lab, centerAB),
        r: s.r,
        g: s.g,
        bRgb: s.b,
        sample: s
      }));

      const Lmin = Math.min(black.L, white.L);
      const Lmax = Math.max(black.L, white.L);
      const Lrange = Math.max(1e-6, Lmax - Lmin);

      const L_BINS = 35;
      const H_BINS = 72;

      const bins = Array.from({ length: L_BINS }, () =>
        Array.from({ length: H_BINS }, () => [])
      );

      for (const p of lchList) {
        if (p.C < 2.0) continue;

        const lNorm = clamp((p.L - Lmin) / Lrange, 0, 1);
        const li = clamp(Math.round(lNorm * (L_BINS - 1)), 0, L_BINS - 1);
        const hi = Math.floor(normDeg(p.h) / 360 * H_BINS) % H_BINS;

        bins[li][hi].push(p.C);
      }

      const rawGrid = createGrid(L_BINS, H_BINS, NaN);
      const countGrid = createGrid(L_BINS, H_BINS, 0);

      for (let l = 0; l < L_BINS; l++) {
        for (let h = 0; h < H_BINS; h++) {
          const values = bins[l][h];
          countGrid[l][h] = values.length;

          if (values.length >= 2) {
            rawGrid[l][h] = percentile(values, 0.96);
          } else if (values.length === 1) {
            rawGrid[l][h] = values[0];
          }
        }
      }

      function fallbackC(lIndex, hIndex) {
        const lNorm = lIndex / (L_BINS - 1);
        const L = Lmin + lNorm * Lrange;
        const hue = hIndex / H_BINS * 360;

        const poly = rayPolygonIntersectionC(hue, vertices, centerAB);
        const waistC = poly.C;
        const waistL = poly.L;

        if (L <= waistL) {
          const t = smoothstep(Lmin, waistL, L);
          return waistC * Math.pow(t, 0.78);
        }

        const t = smoothstep(waistL, Lmax, L);
        return waistC * Math.pow(1 - t, 0.72);
      }

      const fallbackGrid = createGrid(L_BINS, H_BINS, 0);

      for (let l = 0; l < L_BINS; l++) {
        for (let h = 0; h < H_BINS; h++) {
          fallbackGrid[l][h] = fallbackC(l, h);
        }
      }

      const filledRaw = fillMissingGrid(rawGrid, (l, h) => fallbackGrid[l][h]);

      const guardedRaw = createGrid(L_BINS, H_BINS, 0);

      for (let l = 0; l < L_BINS; l++) {
        for (let h = 0; h < H_BINS; h++) {
          const raw = filledRaw[l][h];
          const fallback = fallbackGrid[l][h];
          const hasData = countGrid[l][h] >= 1;

          if (!hasData) {
            guardedRaw[l][h] = fallback;
            continue;
          }

          // polygon보다 더 튀어나온 측정값은 허용.
          // polygon보다 안쪽으로 움푹 들어간 값은 antiShrink로 복원.
          if (raw >= fallback) {
            guardedRaw[l][h] = raw;
          } else {
            const dentRatio = (fallback - raw) / Math.max(fallback, 1e-6);
            const restore = clamp(params.antiShrink * smoothstep(0.06, 0.30, dentRatio), 0, 1);
            guardedRaw[l][h] = lerp(raw, fallback, restore);
          }
        }
      }

      const radius = Math.max(1, Math.round(params.edgeProtect));
      let envelope = maxGridNeighborhood(guardedRaw, radius, radius);
      envelope = smoothGridCyclic(envelope, 2);

      const preSmooth = createGrid(L_BINS, H_BINS, 0);

      for (let l = 0; l < L_BINS; l++) {
        for (let h = 0; h < H_BINS; h++) {
          const raw = guardedRaw[l][h];
          const env = envelope[l][h];

          const shrink = clamp(
            (env - raw) / Math.max(env * 0.35, 1e-6),
            0,
            1
          );

          const localGuard = clamp(params.gamutGuard * shrink, 0, 1);
          preSmooth[l][h] = lerp(raw, env, localGuard);
        }
      }

      const smoothed = smoothGridCyclic(preSmooth, params.edgeSmooth);
      const finalGrid = createGrid(L_BINS, H_BINS, 0);

      for (let l = 0; l < L_BINS; l++) {
        for (let h = 0; h < H_BINS; h++) {
          const raw = preSmooth[l][h];
          const smooth = smoothed[l][h];
          const fallback = fallbackGrid[l][h];

          // smoothing 때문에 외곽이 과도하게 줄어드는 것 방지
          finalGrid[l][h] = Math.max(
            lerp(smooth, raw, params.outlineFollow),
            fallback * 0.88
          );
        }
      }

      // Black / White point는 chroma 0으로 수렴
      for (let h = 0; h < H_BINS; h++) {
        finalGrid[0][h] = 0;
        finalGrid[L_BINS - 1][h] = 0;
      }

      const maxC = Math.max(...finalGrid.flat());

      function surfaceCAt(L, hue) {
        const lNorm = clamp((L - Lmin) / Lrange, 0, 1);
        return clamp(bilinearCyclic(finalGrid, lNorm, hue), 0, maxC * 1.10);
      }

      const volume = estimateGamutVolume({
        finalGrid,
        Lmin,
        Lmax,
        Lrange
      });

      const model = {
        white,
        black,
        centerAB,
        vertices,
        lchList,
        Lmin,
        Lmax,
        Lrange,
        rawGrid: filledRaw,
        fallbackGrid,
        finalGrid,
        maxC,
        surfaceCAt,
        volume
      };

      model.rgbHueToLabHue = makeRgbHueMapper(vertices);
      model.waistLAtRgbHue = makeWaistLMapper(vertices);

      return model;
    }

    function estimateGamutVolume(model) {
      const grid = model.finalGrid;
      const rows = grid.length;
      const cols = grid[0].length;

      const dL = model.Lrange / Math.max(1, rows - 1);
      const dTheta = 2 * Math.PI / cols;

      let volume = 0;

      for (let r = 0; r < rows; r++) {
        let area = 0;

        for (let c = 0; c < cols; c++) {
          const c1 = (c + 1) % cols;
          const R0 = grid[r][c];
          const R1 = grid[r][c1];

          area += 0.5 * R0 * R1 * Math.sin(dTheta);
        }

        volume += Math.max(0, area) * dL;
      }

      return volume;
    }

    // ============================================================
    // 10. RGB hue mappers from detected vertices
    // ============================================================

    function makeRgbHueMapper(vertices) {
      const ordered = vertices
        .map(v => ({
          rgbHue: v.rgbHue,
          labHue: v.h
        }))
        .sort((a, b) => a.rgbHue - b.rgbHue);

      const labUnwrapped = [];

      for (let i = 0; i < ordered.length; i++) {
        let h = ordered[i].labHue;

        if (i === 0) {
          labUnwrapped.push(h);
        } else {
          while (h <= labUnwrapped[i - 1]) h += 360;
          labUnwrapped.push(h);
        }
      }

      return function mapHue(rgbHue) {
        const h = normDeg(rgbHue);

        for (let i = 0; i < ordered.length; i++) {
          const a = ordered[i];
          const b = ordered[(i + 1) % ordered.length];

          let ah = a.rgbHue;
          let bh = b.rgbHue;

          if (i === ordered.length - 1) bh += 360;

          let hh = h;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);

            let ha = labUnwrapped[i];
            let hb = labUnwrapped[(i + 1) % ordered.length];

            if (i === ordered.length - 1) hb += 360;

            return normDeg(lerp(ha, hb, t));
          }
        }

        return normDeg(labUnwrapped[0]);
      };
    }

    function makeWaistLMapper(vertices) {
      const ordered = vertices
        .map(v => ({
          rgbHue: v.rgbHue,
          L: v.L
        }))
        .sort((a, b) => a.rgbHue - b.rgbHue);

      return function waistLAtRgbHue(rgbHue) {
        const h = normDeg(rgbHue);

        for (let i = 0; i < ordered.length; i++) {
          const a = ordered[i];
          const b = ordered[(i + 1) % ordered.length];

          let ah = a.rgbHue;
          let bh = b.rgbHue;

          if (i === ordered.length - 1) bh += 360;

          let hh = h;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);
            return lerp(a.L, b.L, t);
          }
        }

        return ordered[0].L;
      };
    }

    // ============================================================
    // 11. Local RGB → Lab MLS
    // ============================================================

    function basisRgb(q) {
      const r = q.r / 100;
      const g = q.g / 100;
      const b = q.b / 100;

      return [
        1,
        r,
        g,
        b,
        r * r,
        g * g,
        b * b,
        r * g,
        r * b,
        g * b
      ];
    }

    function predictLabLocalMLS(q, samples, params) {
      const sigma = params.rgbFitSmooth;
      const ridge = 1e-4;

      const ranked = samples
        .map(s => {
          const dr = (s.r - q.r) / 100;
          const dg = (s.g - q.g) / 100;
          const db = (s.b - q.b) / 100;
          const d2 = dr * dr + dg * dg + db * db;

          return {
            sample: s,
            d2
          };
        })
        .sort((a, b) => a.d2 - b.d2)
        .slice(0, Math.min(56, samples.length));

      const nBasis = 10;
      const A = Array.from({ length: nBasis }, () => Array(nBasis).fill(0));
      const bL = Array(nBasis).fill(0);
      const ba = Array(nBasis).fill(0);
      const bb = Array(nBasis).fill(0);

      let totalW = 0;

      for (const item of ranked) {
        const s = item.sample;
        const phi = basisRgb(s);

        const d = Math.sqrt(item.d2);
        const w = Math.exp(-0.5 * Math.pow(d / Math.max(0.015, sigma), 2));

        totalW += w;

        for (let i = 0; i < nBasis; i++) {
          bL[i] += w * phi[i] * s.L;
          ba[i] += w * phi[i] * s.a;
          bb[i] += w * phi[i] * s.bLab;

          for (let j = 0; j < nBasis; j++) {
            A[i][j] += w * phi[i] * phi[j];
          }
        }
      }

      if (totalW < 1e-8) {
        return ranked[0].sample.lab;
      }

      for (let i = 0; i < nBasis; i++) {
        A[i][i] += ridge;
      }

      const coefL = solveLinearSystem(A.map(row => row.slice()), bL);
      const coefa = solveLinearSystem(A.map(row => row.slice()), ba);
      const coefb = solveLinearSystem(A.map(row => row.slice()), bb);

      if (!coefL || !coefa || !coefb) {
        let sw = 0;
        let L = 0;
        let a = 0;
        let b = 0;

        for (const item of ranked.slice(0, 24)) {
          const w = 1 / Math.max(1e-7, item.d2);
          sw += w;
          L += w * item.sample.L;
          a += w * item.sample.a;
          b += w * item.sample.bLab;
        }

        return {
          L: L / sw,
          a: a / sw,
          b: b / sw
        };
      }

      const qBasis = basisRgb(q);

      function dot(coef) {
        let v = 0;

        for (let i = 0; i < nBasis; i++) {
          v += coef[i] * qBasis[i];
        }

        return v;
      }

      return {
        L: dot(coefL),
        a: dot(coefa),
        b: dot(coefb)
      };
    }

    // ============================================================
    // 12. Gamut guarded A2B CLUT generation
    // ============================================================

    function rgbToA2BLab(q, samples, model, params) {
      const local = predictLabLocalMLS(q, samples, params);

      const hsl = rgbToHsl(q.r, q.g, q.b);
      const localLch = labToLch(local, model.centerAB);

      // RGB hue에서 검출된 RGB/CMY 꼭짓점의 Lab hue로 매핑
      const mappedHue = model.rgbHueToLabHue(hsl.h);

      const hueBlend = clamp((1 - params.measurementFollow) * Math.pow(hsl.s, 0.85), 0, 0.45);
      const h = circularLerpDeg(localLch.h, mappedHue, hueBlend);

      const L = clamp(local.L, model.Lmin, model.Lmax);

      const Cmax = model.surfaceCAt(L, h);
      const radialTarget = Cmax * Math.pow(hsl.s, params.radialPower);

      let C = localLch.C;

      // 고채도 영역에서 측정 MLS가 오목하게 들어가면 gamut surface 쪽으로 복원
      if (C < radialTarget) {
        const edgeStrength = Math.pow(hsl.s, 1.65);
        const restore = clamp(params.antiShrink * edgeStrength * (1 - params.measurementFollow * 0.35), 0, 1);
        C = lerp(C, radialTarget, restore);
      }

      // 표면 밖으로 과도하게 나가는 경우에는 안쪽으로 제한
      C = Math.min(C, Cmax);

      // 중성축은 측정값 우선
      const neutralKeep = smoothstep(0.02, 0.16, hsl.s);
      C = lerp(localLch.C, C, neutralKeep);

      const guarded = lchToLab(L, C, h, model.centerAB);

      // L은 측정 MLS를 우선 유지
      guarded.L = L;

      return guarded;
    }

    function buildCLUT(samples, model, params) {
      const size = 16;
      const data = [];

      for (let ri = 0; ri < size; ri++) {
        for (let gi = 0; gi < size; gi++) {
          for (let bi = 0; bi < size; bi++) {
            const r = ri / (size - 1) * 100;
            const g = gi / (size - 1) * 100;
            const b = bi / (size - 1) * 100;

            const lab = rgbToA2BLab({ r, g, b }, samples, model, params);

            data.push({
              r,
              g,
              b,
              L: lab.L,
              a: lab.a,
              bLab: lab.b
            });
          }
        }
      }

      return {
        size,
        data
      };
    }

    // ============================================================
    // 13. Download
    // ============================================================

    function downloadText(filename, text, mime) {
      const blob = new Blob([text], { type: mime });
      const url = URL.createObjectURL(blob);

      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);

      URL.revokeObjectURL(url);
    }

    function clutToCSV(clut) {
      const lines = ["R,G,B,L,a,b"];

      for (const p of clut.data) {
        lines.push([
          p.r.toFixed(6),
          p.g.toFixed(6),
          p.b.toFixed(6),
          p.L.toFixed(6),
          p.a.toFixed(6),
          p.bLab.toFixed(6)
        ].join(","));
      }

      return lines.join("\n");
    }

    function clutToJSON(state) {
      return JSON.stringify({
        description: "Printer ICC A2B PCS Lab CLUT generated from measured RGB/Lab data",
        clutSize: state.clut.size,
        rgbRange: [0, 100],
        whitePoint: {
          RGB: [state.model.white.r, state.model.white.g, state.model.white.b],
          Lab: [state.model.white.L, state.model.white.a, state.model.white.bLab]
        },
        blackPoint: {
          RGB: [state.model.black.r, state.model.black.g, state.model.black.b],
          Lab: [state.model.black.L, state.model.black.a, state.model.black.bLab]
        },
        neutralAB: state.model.centerAB,
        estimatedGamutVolumeLab3: state.model.volume,
        vertices: state.model.vertices,
        clut: state.clut.data.map(p => ({
          RGB: [
            Number(p.r.toFixed(6)),
            Number(p.g.toFixed(6)),
            Number(p.b.toFixed(6))
          ],
          Lab: [
            Number(p.L.toFixed(6)),
            Number(p.a.toFixed(6)),
            Number(p.bLab.toFixed(6))
          ]
        }))
      }, null, 2);
    }

    // ============================================================
    // 14. UI state
    // ============================================================

    const dataInput = document.getElementById("dataInput");
    const info = document.getElementById("info");

    const controls = {
      rgbFitSmooth: document.getElementById("rgbFitSmooth"),
      edgeSmooth: document.getElementById("edgeSmooth"),
      antiShrink: document.getElementById("antiShrink"),
      gamutGuard: document.getElementById("gamutGuard"),
      edgeProtect: document.getElementById("edgeProtect"),
      outlineFollow: document.getElementById("outlineFollow"),
      measurementFollow: document.getElementById("measurementFollow"),
      radialPower: document.getElementById("radialPower")
    };

    const valueEls = {
      rgbFitSmooth: document.getElementById("rgbFitSmoothValue"),
      edgeSmooth: document.getElementById("edgeSmoothValue"),
      antiShrink: document.getElementById("antiShrinkValue"),
      gamutGuard: document.getElementById("gamutGuardValue"),
      edgeProtect: document.getElementById("edgeProtectValue"),
      outlineFollow: document.getElementById("outlineFollowValue"),
      measurementFollow: document.getElementById("measurementFollowValue"),
      radialPower: document.getElementById("radialPowerValue")
    };

    function getParams() {
      const params = {
        rgbFitSmooth: Number(controls.rgbFitSmooth.value),
        edgeSmooth: Number(controls.edgeSmooth.value),
        antiShrink: Number(controls.antiShrink.value),
        gamutGuard: Number(controls.gamutGuard.value),
        edgeProtect: Number(controls.edgeProtect.value),
        outlineFollow: Number(controls.outlineFollow.value),
        measurementFollow: Number(controls.measurementFollow.value),
        radialPower: Number(controls.radialPower.value)
      };

      for (const key of Object.keys(params)) {
        valueEls[key].textContent = String(params[key]);
      }

      return params;
    }

    let state = {
      samples: [],
      model: null,
      clut: null
    };

    function generateAll() {
      const params = getParams();
      const samples = parseInput(dataInput.value);

      if (samples.length < 20) {
        info.textContent = "RGB/Lab 측정 데이터가 너무 적습니다. 최소 20개 이상을 권장합니다.";
        return;
      }

      const model = buildGamutModel(samples, params);
      const clut = buildCLUT(samples, model, params);

      state = {
        samples,
        model,
        clut
      };

      const vertexText = model.vertices.map(v => {
        return `${v.name}: sourceRGB=[${v.sourceRGB.map(x => x.toFixed(1)).join(", ")}], ` +
          `Lab=[${v.L.toFixed(2)}, ${v.a.toFixed(2)}, ${v.b.toFixed(2)}], ` +
          `C=${v.C.toFixed(2)}, h=${v.h.toFixed(1)}`;
      }).join("\n");

      info.textContent =
        `입력 RGB/Lab 개수: ${samples.length}\n` +
        `CLUT 크기: ${clut.size} × ${clut.size} × ${clut.size} = ${clut.data.length}\n` +
        `예상 Lab 색역 부피: ${model.volume.toFixed(0)} Lab³\n\n` +
        `White RGB=[${model.white.r.toFixed(1)}, ${model.white.g.toFixed(1)}, ${model.white.b.toFixed(1)}] ` +
        `Lab=[${model.white.L.toFixed(2)}, ${model.white.a.toFixed(2)}, ${model.white.bLab.toFixed(2)}]\n` +
        `Black RGB=[${model.black.r.toFixed(1)}, ${model.black.g.toFixed(1)}, ${model.black.b.toFixed(1)}] ` +
        `Lab=[${model.black.L.toFixed(2)}, ${model.black.a.toFixed(2)}, ${model.black.bLab.toFixed(2)}]\n\n` +
        `RGB/CMY 꼭짓점:\n${vertexText}\n\n` +
        `알고리즘:\n` +
        `1. RGB 값으로 RGB/CMY 꼭짓점, White, Black 검출\n` +
        `2. Lab a*b* 위에서 허리라인 육각 polygon 구성\n` +
        `3. 측색값이 polygon보다 돌출되면 허용\n` +
        `4. 오목하게 눌린 외곽은 antiShrink + outer envelope로 복원\n` +
        `5. RGB→Lab은 local MLS로 예측 후 gamut surface 안에 부드럽게 할당`;

      console.clear();
      console.log("입력 samples:", samples);
      console.log("gamut model:", model);
      console.log("A2B CLUT:", clut);
    }

    document.getElementById("loadDemo").addEventListener("click", () => {
      dataInput.value = makeDemoText();
      generateAll();
    });

    document.getElementById("generate").addEventListener("click", generateAll);

    document.getElementById("downloadCsv").addEventListener("click", () => {
      if (!state.clut) return;
      downloadText("a2b_rgb_lab_clut_16.csv", clutToCSV(state.clut), "text/csv");
    });

    document.getElementById("downloadJson").addEventListener("click", () => {
      if (!state.clut) return;
      downloadText("a2b_rgb_lab_clut_16.json", clutToJSON(state), "application/json");
    });

    document.getElementById("copyJson").addEventListener("click", async () => {
      if (!state.clut) return;
      await navigator.clipboard.writeText(clutToJSON(state));
      info.textContent += "\n\nJSON이 클립보드에 복사되었습니다.";
    });

    for (const key of Object.keys(controls)) {
      controls[key].addEventListener("input", () => {
        if (dataInput.value.trim()) {
          generateAll();
        }
      });
    }

    // ============================================================
    // 15. 3D rendering
    // ============================================================

    let rotationEnabled = true;
    let rotationTime = 0;
    let lastFrame = performance.now();

    document.getElementById("toggleRotation").addEventListener("click", () => {
      rotationEnabled = !rotationEnabled;
      document.getElementById("toggleRotation").textContent =
        rotationEnabled ? "회전 정지" : "회전 시작";
    });

    function rotateX(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0],
        p[1] * c - p[2] * s,
        p[1] * s + p[2] * c
      ];
    }

    function rotateY(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0] * c + p[2] * s,
        p[1],
        -p[0] * s + p[2] * c
      ];
    }

    function transform3D(p, time) {
      let q = p;
      q = rotateX(q, -0.78);
      q = rotateY(q, time * 0.00036);
      return q;
    }

    function project3D(p, cx, cy, scale) {
      const d = 7.5;
      const k = d / (d + p[2]);

      return {
        x: cx + p[0] * scale * k,
        y: cy - p[1] * scale * k,
        z: p[2]
      };
    }

    function labToPoint3D(lab, centerAB) {
      return [
        (lab.a - centerAB.a) / 52,
        (lab.L - 50) / 28,
        (lab.b - centerAB.b) / 52
      ];
    }

    function drawAxes(cx, cy, scale, time) {
      const axes = [
        { a: [0, -1.7, 0], b: [0, 1.7, 0], color: "rgba(255,255,255,0.25)" },
        { a: [-1.8, 0, 0], b: [1.8, 0, 0], color: "rgba(255,120,120,0.25)" },
        { a: [0, 0, -1.8], b: [0, 0, 1.8], color: "rgba(120,180,255,0.25)" }
      ];

      for (const ax of axes) {
        const p0 = project3D(transform3D(ax.a, time), cx, cy, scale);
        const p1 = project3D(transform3D(ax.b, time), cx, cy, scale);

        ctx.beginPath();
        ctx.moveTo(p0.x, p0.y);
        ctx.lineTo(p1.x, p1.y);
        ctx.strokeStyle = ax.color;
        ctx.lineWidth = 1;
        ctx.stroke();
      }
    }

    function drawPointCloud(labs, centerAB, cx, cy, scale, time, radiusBase) {
      const rendered = labs.map(lab => {
        const p3 = labToPoint3D(lab, centerAB);
        const q = transform3D(p3, time);

        return {
          lab,
          world: q,
          screen: project3D(q, cx, cy, scale)
        };
      });

      rendered.sort((a, b) => a.world[2] - b.world[2]);

      for (const item of rendered) {
        const lch = labToLch(item.lab, centerAB);
        const radius = radiusBase + Math.min(2.0, lch.C / 60);

        ctx.beginPath();
        ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
        ctx.fillStyle = labCss(item.lab, centerAB);
        ctx.globalAlpha = 0.82;
        ctx.fill();
        ctx.globalAlpha = 1;
      }
    }

    function drawLineLab(points, centerAB, cx, cy, scale, time, color, width) {
      ctx.beginPath();

      for (let i = 0; i < points.length; i++) {
        const p3 = labToPoint3D(points[i], centerAB);
        const q = transform3D(p3, time);
        const s = project3D(q, cx, cy, scale);

        if (i === 0) ctx.moveTo(s.x, s.y);
        else ctx.lineTo(s.x, s.y);
      }

      ctx.strokeStyle = color;
      ctx.lineWidth = width;
      ctx.stroke();
    }

    function drawGamutSurface(model, cx, cy, scale, time) {
      const centerAB = model.centerAB;

      // final surface rings
      for (let li = 3; li <= 31; li += 5) {
        const L = model.Lmin + (li / 34) * model.Lrange;
        const ring = [];

        for (let hi = 0; hi <= 72; hi++) {
          const hue = hi / 72 * 360;
          const C = model.surfaceCAt(L, hue);
          ring.push(lchToLab(L, C, hue, centerAB));
        }

        drawLineLab(ring, centerAB, cx, cy, scale, time, "rgba(255,255,255,0.38)", 1.0);
      }

      // vertical hue lines
      for (let hi = 0; hi < 12; hi++) {
        const hue = hi / 12 * 360;
        const line = [];

        for (let li = 0; li <= 34; li++) {
          const L = model.Lmin + (li / 34) * model.Lrange;
          const C = model.surfaceCAt(L, hue);
          line.push(lchToLab(L, C, hue, centerAB));
        }

        drawLineLab(line, centerAB, cx, cy, scale, time, "rgba(255,255,255,0.28)", 1.0);
      }

      // raw waist ring
      const rawRing = [];

      for (let hi = 0; hi <= 72; hi++) {
        const hue = hi / 72 * 360;
        const poly = rayPolygonIntersectionC(hue, model.vertices, model.centerAB);
        const L = poly.L;
        const lNorm = clamp((L - model.Lmin) / model.Lrange, 0, 1);
        const C = bilinearCyclic(model.rawGrid, lNorm, hue);
        rawRing.push(lchToLab(L, C, hue, centerAB));
      }

      drawLineLab(rawRing, centerAB, cx, cy, scale, time, "rgba(255,80,65,0.48)", 1.4);

      // vertices
      for (const v of model.vertices) {
        const p3 = labToPoint3D(v, centerAB);
        const q = transform3D(p3, time);
        const s = project3D(q, cx, cy, scale);

        ctx.beginPath();
        ctx.arc(s.x, s.y, 5.4, 0, Math.PI * 2);
        ctx.fillStyle = "rgba(255,255,255,0.96)";
        ctx.fill();

        ctx.fillStyle = "#fff";
        ctx.font = "12px Arial";
        ctx.fillText(v.name, s.x + 7, s.y - 7);
      }

      // black / white
      for (const item of [
        { sample: model.white, label: "W" },
        { sample: model.black, label: "K" }
      ]) {
        const lab = item.sample.lab;
        const p3 = labToPoint3D(lab, centerAB);
        const q = transform3D(p3, time);
        const s = project3D(q, cx, cy, scale);

        ctx.beginPath();
        ctx.arc(s.x, s.y, 5.4, 0, Math.PI * 2);
        ctx.fillStyle = "rgba(255,220,90,0.96)";
        ctx.fill();

        ctx.fillStyle = "#ffdc5a";
        ctx.font = "12px Arial";
        ctx.fillText(item.label, s.x + 7, s.y - 7);
      }
    }

    function drawDivider() {
      ctx.fillStyle = "rgba(255,255,255,0.16)";
      ctx.fillRect(canvas.width / 2, 0, 1, canvas.height);

      ctx.fillStyle = "rgba(255,255,255,0.20)";
      ctx.font = "42px Arial";
      ctx.fillText("→", canvas.width / 2 - 16, canvas.height / 2);
    }

    function draw() {
      const now = performance.now();
      const delta = now - lastFrame;
      lastFrame = now;

      if (rotationEnabled) {
        rotationTime += delta;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      drawDivider();

      if (state.model && state.clut) {
        const leftCx = canvas.width * 0.36;
        const rightCx = canvas.width * 0.74;
        const cy = canvas.height * 0.56;
        const scale = Math.min(canvas.width, canvas.height) * 0.18;

        drawAxes(leftCx, cy, scale, rotationTime);
        drawPointCloud(
          state.samples.map(s => s.lab),
          state.model.centerAB,
          leftCx,
          cy,
          scale,
          rotationTime,
          2.1
        );
        drawGamutSurface(state.model, leftCx, cy, scale, rotationTime);

        const clutLabs = state.clut.data.map(p => ({
          L: p.L,
          a: p.a,
          b: p.bLab
        }));

        drawAxes(rightCx, cy, scale, rotationTime);
        drawPointCloud(
          clutLabs.filter((_, i) => i % 2 === 0),
          state.model.centerAB,
          rightCx,
          cy,
          scale,
          rotationTime,
          1.45
        );
        drawGamutSurface(state.model, rightCx, cy, scale, rotationTime);

        ctx.fillStyle = "#8ecbff";
        ctx.font = "14px Arial";
        ctx.fillText("측정 RGB/Lab 점군 + 검출된 gamut 표면", canvas.width * 0.27, canvas.height - 34);

        ctx.fillStyle = "#9dffb7";
        ctx.fillText("16³ RGB CLUT에 할당된 PCS Lab", canvas.width * 0.66, canvas.height - 34);
      } else {
        ctx.fillStyle = "#ddd";
        ctx.font = "18px Arial";
        ctx.fillText("왼쪽 패널에서 데모 생성 또는 RGB/Lab 입력 후 A2B CLUT 생성을 누르세요.", 470, canvas.height / 2);
      }

      requestAnimationFrame(draw);
    }

    // ============================================================
    // 16. Init
    // ============================================================

    dataInput.value = makeDemoText();
    generateAll();
    draw();
  </script>
</body>
</html>

Printer ICC A2B RGB→PCS Lab CLUT Generator
측정 RGB/Lab 미리보기
생성된 A2B CLUT Lab 미리보기
표시
흰색 선: anti-concave gamut 표면
붉은 선: raw 측정 외곽
큰 흰점: RGB/CMY 꼭짓점
노란 점: White / Black
점선 느낌의 중심: gray spine

CLUT: RGB 16 × 16 × 16
RGB 범위: 0 ~ 100

Printer ICC A2B → PCS Lab CLUT 생성

입력 형식: R G B L a b
RGB는 0~100 범위, Lab은 측색값입니다. RGB와 Lab이 함께 있으므로 측정 샘플 기반 RGB→Lab 모델을 직접 만들고, 외곽 Lab 표면은 RGB/CMY 꼭짓점 polygon과 anti-concave guard로 보호합니다.

localK 22 mqBlend 0.55 surfaceSmooth 4 antiConcave 0.85 gamutGuard 0.90 edgeProtect 3 outlineFollow 0.55 graySpineStrength 0.95 grayWidth 0.16 radialPower 0.92
초기화 중...

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>Printer ICC A2B RGB→PCS Lab CLUT Generator</title>
  <style>
    body {
      margin: 0;
      background: #101012;
      color: #eee;
      font-family: Arial, sans-serif;
      overflow: hidden;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
      background: #101012;
    }

    .panel {
      position: fixed;
      left: 12px;
      top: 12px;
      width: 420px;
      max-height: calc(100vh - 24px);
      overflow: auto;
      z-index: 20;
      padding: 12px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.74);
      box-shadow: 0 0 22px rgba(0, 0, 0, 0.45);
    }

    .panel h2 {
      margin: 0 0 8px 0;
      font-size: 16px;
      color: #9dffb7;
    }

    .panel p {
      margin: 6px 0;
      color: #ccc;
      font-size: 12px;
      line-height: 1.45;
    }

    textarea {
      width: 100%;
      height: 185px;
      box-sizing: border-box;
      resize: vertical;
      padding: 8px;
      border-radius: 8px;
      border: 1px solid #34343a;
      background: #19191d;
      color: #e8e8e8;
      font-family: Consolas, monospace;
      font-size: 12px;
      line-height: 1.4;
    }

    .buttons {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 6px;
      margin-top: 8px;
    }

    button {
      padding: 8px 9px;
      border: 0;
      border-radius: 8px;
      background: #f1f1f1;
      color: #111;
      font-weight: bold;
      cursor: pointer;
    }

    button:hover {
      background: #dcdcdc;
    }

    .controls {
      margin-top: 10px;
      display: grid;
      grid-template-columns: 142px 170px 54px;
      gap: 5px 8px;
      align-items: center;
      font-size: 12px;
    }

    input[type="range"] {
      width: 170px;
    }

    .info {
      margin-top: 10px;
      padding: 8px;
      border-radius: 8px;
      background: rgba(255,255,255,0.06);
      color: #ddd;
      font-size: 12px;
      line-height: 1.45;
      white-space: pre-line;
    }

    .titleLeft,
    .titleRight {
      position: fixed;
      top: 14px;
      z-index: 8;
      padding: 7px 10px;
      border-radius: 9px;
      background: rgba(0,0,0,0.56);
      font-weight: bold;
      font-size: 15px;
    }

    .titleLeft {
      left: 455px;
      color: #8ecbff;
    }

    .titleRight {
      right: 300px;
      color: #9dffb7;
    }

    .legend {
      position: fixed;
      right: 14px;
      top: 14px;
      z-index: 20;
      width: 260px;
      padding: 10px 12px;
      border-radius: 12px;
      background: rgba(0,0,0,0.66);
      color: #ddd;
      font-size: 13px;
      line-height: 1.48;
    }
  </style>
</head>
<body>
  <canvas id="canvas"></canvas>

  <div class="titleLeft">측정 RGB, Lab 미리보기</div>
  <div class="titleRight">생성된 A2B CLUT Lab 미리보기</div>

  <div class="legend">
    <b>표시</b><br>
    파란/컬러 점: 측정 Lab 또는 CLUT Lab<br>
    흰색 선: 예상 gamut volume model<br>
    붉은 선: 측정 raw 외곽<br>
    큰 흰 점: RGB/CMY 꼭짓점<br>
    노란 점: White / Black<br>
    회색 선: anti-hook gray spine<br>
    <br>
    출력 CLUT: RGB 0~100, 16×16×16
  </div>

  <div class="panel">
    <h2>프린터 ICC A2B → PCS Lab CLUT 생성</h2>
    <p>
      입력 형식: 한 줄에 <b>R G B L a b</b>. RGB는 0~100 범위입니다.
      RGB 측정값을 실제 장치 좌표로 사용하고, Lab은 PCS 출력값으로 사용합니다.
    </p>

    <textarea id="inputText"></textarea>

    <div class="buttons">
      <button id="makeDemo">데모 RGB/Lab 생성</button>
      <button id="generate">A2B CLUT 생성</button>
      <button id="toggleRotation">회전 정지</button>
      <button id="downloadCsv">CSV 다운로드</button>
      <button id="downloadJson">JSON 다운로드</button>
      <button id="copyJson">JSON 복사</button>
    </div>

    <div class="controls">
      <label>mqK</label>
      <input id="mqK" type="range" min="8" max="32" step="1" value="18">
      <span id="mqKValue">18</span>

      <label>mqEps</label>
      <input id="mqEps" type="range" min="0.02" max="0.30" step="0.01" value="0.085">
      <span id="mqEpsValue">0.085</span>

      <label>rbfBlend</label>
      <input id="rbfBlend" type="range" min="0.00" max="1.00" step="0.05" value="0.78">
      <span id="rbfBlendValue">0.78</span>

      <label>spineStrength</label>
      <input id="spineStrength" type="range" min="0.00" max="1.00" step="0.05" value="0.95">
      <span id="spineStrengthValue">0.95</span>

      <label>grayTolerance</label>
      <input id="grayTolerance" type="range" min="2" max="18" step="1" value="8">
      <span id="grayToleranceValue">8</span>

      <label>edgeSmooth</label>
      <input id="edgeSmooth" type="range" min="1" max="12" step="1" value="5">
      <span id="edgeSmoothValue">5</span>

      <label>gamutGuard</label>
      <input id="gamutGuard" type="range" min="0.00" max="1.00" step="0.05" value="0.86">
      <span id="gamutGuardValue">0.86</span>

      <label>antiShrink</label>
      <input id="antiShrink" type="range" min="0.00" max="1.00" step="0.05" value="0.82">
      <span id="antiShrinkValue">0.82</span>

      <label>edgeProtect</label>
      <input id="edgeProtect" type="range" min="1" max="8" step="1" value="3">
      <span id="edgeProtectValue">3</span>

      <label>outlineFollow</label>
      <input id="outlineFollow" type="range" min="0.00" max="1.00" step="0.05" value="0.56">
      <span id="outlineFollowValue">0.56</span>

      <label>radialPower</label>
      <input id="radialPower" type="range" min="0.65" max="1.45" step="0.05" value="0.92">
      <span id="radialPowerValue">0.92</span>
    </div>

    <div id="info" class="info">준비 중...</div>
  </div>

  <script>
    // ============================================================
    // Canvas
    // ============================================================

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    function resize() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }

    window.addEventListener("resize", resize);
    resize();

    // ============================================================
    // Basic utilities
    // ============================================================

    function clamp(x, a, b) {
      return Math.max(a, Math.min(b, x));
    }

    function lerp(a, b, t) {
      return a * (1 - t) + b * t;
    }

    function smoothstep(a, b, x) {
      if (a === b) return x < a ? 0 : 1;
      const t = clamp((x - a) / (b - a), 0, 1);
      return t * t * (3 - 2 * t);
    }

    function rand(seed) {
      const x = Math.sin(seed * 12.9898) * 43758.5453123;
      return x - Math.floor(x);
    }

    function normDeg(h) {
      h = h % 360;
      if (h < 0) h += 360;
      return h;
    }

    function angleDiffDeg(a, b) {
      let d = normDeg(a) - normDeg(b);
      while (d > 180) d -= 360;
      while (d < -180) d += 360;
      return d;
    }

    function angleDistanceDeg(a, b) {
      return Math.abs(angleDiffDeg(a, b));
    }

    function mixHueDeg(a, b, t) {
      const d = angleDiffDeg(b, a);
      return normDeg(a + d * t);
    }

    function percentile(values, q) {
      const arr = values
        .filter(Number.isFinite)
        .slice()
        .sort((a, b) => a - b);

      if (arr.length === 0) return NaN;
      if (arr.length === 1) return arr[0];

      const pos = (arr.length - 1) * q;
      const lo = Math.floor(pos);
      const hi = Math.ceil(pos);
      const f = pos - lo;

      return arr[lo] * (1 - f) + arr[hi] * f;
    }

    function median(values) {
      return percentile(values, 0.5);
    }

    function createGrid(rows, cols, fill = 0) {
      return Array.from({ length: rows }, () =>
        Array.from({ length: cols }, () => fill)
      );
    }

    function solveLinearSystem(A, b) {
      const n = A.length;
      const M = A.map((row, i) => [...row, b[i]]);

      for (let col = 0; col < n; col++) {
        let pivot = col;

        for (let row = col + 1; row < n; row++) {
          if (Math.abs(M[row][col]) > Math.abs(M[pivot][col])) {
            pivot = row;
          }
        }

        if (Math.abs(M[pivot][col]) < 1e-12) return null;

        [M[col], M[pivot]] = [M[pivot], M[col]];

        const div = M[col][col];

        for (let k = col; k <= n; k++) {
          M[col][k] /= div;
        }

        for (let row = 0; row < n; row++) {
          if (row === col) continue;

          const factor = M[row][col];

          for (let k = col; k <= n; k++) {
            M[row][k] -= factor * M[col][k];
          }
        }
      }

      return M.map(row => row[n]);
    }

    // ============================================================
    // Color helpers
    // ============================================================

    function rgbToHsl(r, g, b) {
      r /= 100;
      g /= 100;
      b /= 100;

      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);
      const l = (max + min) / 2;

      let h = 0;
      let s = 0;

      if (max !== min) {
        const d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);

        if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
        else if (max === g) h = (b - r) / d + 2;
        else h = (r - g) / d + 4;

        h *= 60;
      }

      return {
        h: normDeg(h),
        s: clamp(s, 0, 1),
        l: clamp(l, 0, 1)
      };
    }

    function labCss(lab) {
      const h = normDeg(Math.atan2(lab.b, lab.a) * 180 / Math.PI);
      const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
      const sat = clamp(30 + C * 0.72, 18, 92);
      const light = clamp(lab.L, 12, 88);
      return `hsl(${h.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
    }

    function labToLchWithNeutral(lab, neutralAtL) {
      const n = neutralAtL(lab.L);
      const da = lab.a - n.a;
      const db = lab.b - n.b;
      const C = Math.sqrt(da * da + db * db);
      const h = normDeg(Math.atan2(db, da) * 180 / Math.PI);

      return {
        L: lab.L,
        a: lab.a,
        b: lab.b,
        C,
        h
      };
    }

    function lchToLabWithNeutral(L, C, h, neutralAtL) {
      const n = neutralAtL(L);
      const rad = h * Math.PI / 180;

      return {
        L,
        a: n.a + C * Math.cos(rad),
        b: n.b + C * Math.sin(rad)
      };
    }

    // ============================================================
    // Demo RGB/Lab samples
    // ============================================================

    const demoAnchors = [
      { name: "R", rgb: [100, 0, 0],     rgbHue: 0,   labHue: 36,  L: 50.5, C: 75 },
      { name: "Y", rgb: [100, 100, 0],   rgbHue: 60,  labHue: 94,  L: 85.5, C: 88 },
      { name: "G", rgb: [0, 100, 0],     rgbHue: 120, labHue: 142, L: 57.0, C: 66 },
      { name: "C", rgb: [0, 100, 100],   rgbHue: 180, labHue: 218, L: 62.5, C: 52 },
      { name: "B", rgb: [0, 0, 100],     rgbHue: 240, labHue: 285, L: 33.5, C: 58 },
      { name: "M", rgb: [100, 0, 100],   rgbHue: 300, labHue: 334, L: 52.0, C: 76 }
    ];

    function cyclicInterpolateByRgbHue(anchors, rgbHue, key) {
      const h = normDeg(rgbHue);
      const sorted = anchors.slice().sort((a, b) => a.rgbHue - b.rgbHue);

      for (let i = 0; i < sorted.length; i++) {
        const a = sorted[i];
        const b = sorted[(i + 1) % sorted.length];

        let ah = a.rgbHue;
        let bh = b.rgbHue;
        let hh = h;

        if (i === sorted.length - 1) bh += 360;
        if (hh < ah) hh += 360;

        if (hh >= ah && hh <= bh) {
          const t = (hh - ah) / Math.max(1e-9, bh - ah);

          if (key === "labHue") {
            return mixHueDeg(a.labHue, b.labHue, t);
          }

          return lerp(a[key], b[key], t);
        }
      }

      return sorted[0][key];
    }

    function demoSurfaceC(L, rgbHue, blackL, whiteL) {
      const waistL = cyclicInterpolateByRgbHue(demoAnchors, rgbHue, "L");
      const waistC = cyclicInterpolateByRgbHue(demoAnchors, rgbHue, "C");

      if (L <= waistL) {
        const t = smoothstep(blackL, waistL, L);
        return waistC * Math.pow(t, 0.78);
      }

      const t = smoothstep(waistL, whiteL, L);
      return waistC * Math.pow(1 - t, 0.72);
    }

    function demoRgbToLab(r, g, b, seed) {
      const black = { L: 7.2, a: 0.5, b: -1.6 };
      const white = { L: 94.5, a: 0.2, b: -2.2 };

      const hsl = rgbToHsl(r, g, b);

      const grayT = (r + g + b) / 300;
      const neutralL = lerp(black.L, white.L, Math.pow(grayT, 0.94));
      const neutralA = lerp(black.a, white.a, grayT);
      const neutralB = lerp(black.b, white.b, grayT);

      const waistL = cyclicInterpolateByRgbHue(demoAnchors, hsl.h, "L");

      let colorL;

      if (hsl.l <= 0.5) {
        const t = smoothstep(0, 0.5, hsl.l);
        colorL = lerp(black.L, waistL, t);
      } else {
        const t = smoothstep(0.5, 1, hsl.l);
        colorL = lerp(waistL, white.L, t);
      }

      let L = lerp(neutralL, colorL, hsl.s);

      const labHue = cyclicInterpolateByRgbHue(demoAnchors, hsl.h, "labHue");
      let Cmax = demoSurfaceC(L, hsl.h, black.L, white.L);
      let C = Cmax * Math.pow(hsl.s, 0.92);

      // 일부 실제 프린터에서 생길 법한 국소 압축
      const dR = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 12) / 18, 2)) *
                 Math.exp(-0.5 * Math.pow((L - 45) / 12, 2));
      const dY = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 72) / 15, 2)) *
                 Math.exp(-0.5 * Math.pow((L - 82) / 10, 2));
      const dM = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 304) / 18, 2)) *
                 Math.exp(-0.5 * Math.pow((L - 46) / 12, 2));

      const compression = Math.max(dR, dY, dM);

      if (hsl.s > 0.65) {
        C *= 1 - 0.22 * compression;
      }

      // 일부 영역은 실제 gamut 돌출
      const protrude = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 150) / 18, 2)) *
                       Math.exp(-0.5 * Math.pow((L - 60) / 13, 2));

      C *= 1 + 0.10 * protrude;

      const noise = (rand(seed) - 0.5);
      L += noise * 1.2;
      C += (rand(seed + 11) - 0.5) * 1.6;

      const h = labHue + (rand(seed + 21) - 0.5) * 1.8;
      const rad = h * Math.PI / 180;

      const a = neutralA + Math.max(0, C) * Math.cos(rad) + (rand(seed + 31) - 0.5) * 0.7;
      const bb = neutralB + Math.max(0, C) * Math.sin(rad) + (rand(seed + 41) - 0.5) * 0.7;

      return {
        L: clamp(L, 0, 100),
        a,
        b: bb
      };
    }

    function makeDemoText() {
      const levels = [0, 16, 32, 50, 68, 84, 100];
      const lines = ["R,G,B,L,a,b"];
      let seed = 1;

      for (const r of levels) {
        for (const g of levels) {
          for (const b of levels) {
            const lab = demoRgbToLab(r, g, b, seed++);
            lines.push(`${r.toFixed(3)},${g.toFixed(3)},${b.toFixed(3)},${lab.L.toFixed(3)},${lab.a.toFixed(3)},${lab.b.toFixed(3)}`);
          }
        }
      }

      // Gray spine samples
      for (let i = 0; i <= 28; i++) {
        const v = i / 28 * 100;
        const lab = demoRgbToLab(v, v, v, seed++);
        lines.push(`${v.toFixed(3)},${v.toFixed(3)},${v.toFixed(3)},${lab.L.toFixed(3)},${lab.a.toFixed(3)},${lab.b.toFixed(3)}`);
      }

      // RGB/CMY vertex reinforcement samples
      const vertexRgbs = [
        [100, 0, 0], [100, 100, 0], [0, 100, 0],
        [0, 100, 100], [0, 0, 100], [100, 0, 100],
        [0, 0, 0], [100, 100, 100]
      ];

      for (const rgb of vertexRgbs) {
        for (let i = 0; i < 6; i++) {
          const r = clamp(rgb[0] + (rand(seed++) - 0.5) * 3.0, 0, 100);
          const g = clamp(rgb[1] + (rand(seed++) - 0.5) * 3.0, 0, 100);
          const b = clamp(rgb[2] + (rand(seed++) - 0.5) * 3.0, 0, 100);
          const lab = demoRgbToLab(r, g, b, seed++);
          lines.push(`${r.toFixed(3)},${g.toFixed(3)},${b.toFixed(3)},${lab.L.toFixed(3)},${lab.a.toFixed(3)},${lab.b.toFixed(3)}`);
        }
      }

      return lines.join("\n");
    }

    // ============================================================
    // Input parser
    // ============================================================

    function parseInput(text) {
      const rows = [];

      const lines = text
        .split(/\r?\n/)
        .map(x => x.trim())
        .filter(x => x && !x.startsWith("#"));

      for (const line of lines) {
        const nums = line
          .replace(/[,\t;]+/g, " ")
          .split(/\s+/)
          .map(Number)
          .filter(Number.isFinite);

        if (nums.length >= 6) {
          rows.push({
            r: clamp(nums[0], 0, 100),
            g: clamp(nums[1], 0, 100),
            b: clamp(nums[2], 0, 100),
            L: nums[3],
            a: nums[4],
            labB: nums[5]
          });
        }
      }

      return rows;
    }

    // ============================================================
    // White / Black / RGBCMY detection
    // ============================================================

    function rgbDistance(p, target) {
      const dr = (p.r - target[0]) / 100;
      const dg = (p.g - target[1]) / 100;
      const db = (p.b - target[2]) / 100;
      return Math.sqrt(dr * dr + dg * dg + db * db);
    }

    function sampleLab(p) {
      return {
        L: p.L,
        a: p.a,
        b: p.labB
      };
    }

    function findNearestRgbSample(samples, target) {
      let best = samples[0];
      let bestScore = Infinity;

      for (const p of samples) {
        const d = rgbDistance(p, target);
        const hsl = rgbToHsl(p.r, p.g, p.b);
        const colorBonus = hsl.s * 0.015;
        const score = d - colorBonus;

        if (score < bestScore) {
          bestScore = score;
          best = p;
        }
      }

      return best;
    }

    function detectKeyPoints(samples, neutralAtLTemp) {
      const blackSample = findNearestRgbSample(samples, [0, 0, 0]);
      const whiteSample = findNearestRgbSample(samples, [100, 100, 100]);

      const targets = [
        { name: "R", rgb: [100, 0, 0], rgbHue: 0 },
        { name: "Y", rgb: [100, 100, 0], rgbHue: 60 },
        { name: "G", rgb: [0, 100, 0], rgbHue: 120 },
        { name: "C", rgb: [0, 100, 100], rgbHue: 180 },
        { name: "B", rgb: [0, 0, 100], rgbHue: 240 },
        { name: "M", rgb: [100, 0, 100], rgbHue: 300 }
      ];

      const vertices = targets.map(t => {
        const p = findNearestRgbSample(samples, t.rgb);
        const lab = sampleLab(p);
        const lch = labToLchWithNeutral(lab, neutralAtLTemp);

        return {
          name: t.name,
          rgb: t.rgb,
          rgbHue: t.rgbHue,
          r: p.r,
          g: p.g,
          b: p.b,
          L: lab.L,
          a: lab.a,
          labB: lab.b,
          C: lch.C,
          h: lch.h
        };
      });

      return {
        black: sampleLab(blackSample),
        white: sampleLab(whiteSample),
        blackSample,
        whiteSample,
        vertices
      };
    }

    // ============================================================
    // Anti-hook gray spine
    // ============================================================

    function smoothArrayTriplets(arr, passes) {
      let out = arr.map(p => ({ ...p }));

      for (let pass = 0; pass < passes; pass++) {
        const n = out.map(p => ({ ...p }));

        for (let i = 1; i < out.length - 1; i++) {
          n[i].L = out[i - 1].L * 0.25 + out[i].L * 0.50 + out[i + 1].L * 0.25;
          n[i].a = out[i - 1].a * 0.25 + out[i].a * 0.50 + out[i + 1].a * 0.25;
          n[i].b = out[i - 1].b * 0.25 + out[i].b * 0.50 + out[i + 1].b * 0.25;
        }

        out = n;
      }

      return out;
    }

    function buildGraySpine(samples, params) {
      const blackSample = findNearestRgbSample(samples, [0, 0, 0]);
      const whiteSample = findNearestRgbSample(samples, [100, 100, 100]);

      const black = sampleLab(blackSample);
      const white = sampleLab(whiteSample);

      const bins = Array.from({ length: 21 }, () => []);

      for (const p of samples) {
        const maxRGB = Math.max(p.r, p.g, p.b);
        const minRGB = Math.min(p.r, p.g, p.b);

        if (maxRGB - minRGB <= params.grayTolerance) {
          const t = clamp((p.r + p.g + p.b) / 300, 0, 1);
          const idx = clamp(Math.round(t * 20), 0, 20);
          bins[idx].push(sampleLab(p));
        }
      }

      bins[0].push(black);
      bins[20].push(white);

      let spine = [];

      for (let i = 0; i <= 20; i++) {
        const t = i / 20;
        const values = bins[i];

        if (values.length > 0) {
          spine.push({
            t,
            L: median(values.map(p => p.L)),
            a: median(values.map(p => p.a)),
            b: median(values.map(p => p.b))
          });
        } else {
          spine.push({
            t,
            L: lerp(black.L, white.L, t),
            a: lerp(black.a, white.a, t),
            b: lerp(black.b, white.b, t)
          });
        }
      }

      spine = smoothArrayTriplets(spine, 4);

      spine[0] = { t: 0, ...black };
      spine[20] = { t: 1, ...white };

      // Monotonic L enforcement to prevent gray axis hook.
      for (let i = 1; i < spine.length; i++) {
        if (spine[i].L < spine[i - 1].L + 0.02) {
          spine[i].L = spine[i - 1].L + 0.02;
        }
      }

      spine[20].L = white.L;

      function spineAtT(t) {
        const x = clamp(t, 0, 1) * 20;
        const i = Math.floor(x);
        const j = Math.min(20, i + 1);
        const f = x - i;

        return {
          L: lerp(spine[i].L, spine[j].L, f),
          a: lerp(spine[i].a, spine[j].a, f),
          b: lerp(spine[i].b, spine[j].b, f)
        };
      }

      function neutralAtL(L) {
        if (L <= spine[0].L) {
          return { a: spine[0].a, b: spine[0].b };
        }

        if (L >= spine[20].L) {
          return { a: spine[20].a, b: spine[20].b };
        }

        for (let i = 0; i < 20; i++) {
          const a = spine[i];
          const b = spine[i + 1];

          if (L >= a.L && L <= b.L) {
            const t = (L - a.L) / Math.max(1e-6, b.L - a.L);

            return {
              a: lerp(a.a, b.a, t),
              b: lerp(a.b, b.b, t)
            };
          }
        }

        return {
          a: 0,
          b: 0
        };
      }

      return {
        black,
        white,
        spine,
        spineAtT,
        neutralAtL
      };
    }

    // ============================================================
    // Hue mapping from RGB device hue to measured Lab hue
    // ============================================================

    function makeRgbHueToLabHue(vertices) {
      const ordered = vertices.slice().sort((a, b) => a.rgbHue - b.rgbHue);

      return function map(rgbHue) {
        const h = normDeg(rgbHue);

        for (let i = 0; i < ordered.length; i++) {
          const a = ordered[i];
          const b = ordered[(i + 1) % ordered.length];

          let ah = a.rgbHue;
          let bh = b.rgbHue;
          let hh = h;

          if (i === ordered.length - 1) bh += 360;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);
            return mixHueDeg(a.h, b.h, t);
          }
        }

        return ordered[0].h;
      };
    }

    function makeRgbHueToWaistL(vertices) {
      const ordered = vertices.slice().sort((a, b) => a.rgbHue - b.rgbHue);

      return function map(rgbHue) {
        const h = normDeg(rgbHue);

        for (let i = 0; i < ordered.length; i++) {
          const a = ordered[i];
          const b = ordered[(i + 1) % ordered.length];

          let ah = a.rgbHue;
          let bh = b.rgbHue;
          let hh = h;

          if (i === ordered.length - 1) bh += 360;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);
            return lerp(a.L, b.L, t);
          }
        }

        return ordered[0].L;
      };
    }

    // ============================================================
    // Gamut volume model
    // ============================================================

    function rayPolygonC(hue, vertices) {
      const dir = {
        x: Math.cos(hue * Math.PI / 180),
        y: Math.sin(hue * Math.PI / 180)
      };

      const poly = vertices.map(v => ({
        x: v.C * Math.cos(v.h * Math.PI / 180),
        y: v.C * Math.sin(v.h * Math.PI / 180),
        L: v.L,
        name: v.name
      }));

      let bestT = Infinity;
      let bestL = vertices[0].L;
      let bestEdge = "";

      for (let i = 0; i < poly.length; i++) {
        const p = poly[i];
        const q = poly[(i + 1) % poly.length];

        const ex = q.x - p.x;
        const ey = q.y - p.y;

        const det = dir.x * (-ey) - dir.y * (-ex);
        if (Math.abs(det) < 1e-10) continue;

        const px = p.x;
        const py = p.y;

        const t = (px * (-ey) - py * (-ex)) / det;
        const u = (dir.x * py - dir.y * px) / det;

        if (t >= 0 && u >= -1e-6 && u <= 1 + 1e-6) {
          if (t < bestT) {
            bestT = t;
            bestL = lerp(p.L, q.L, clamp(u, 0, 1));
            bestEdge = p.name + "-" + q.name;
          }
        }
      }

      if (!Number.isFinite(bestT)) {
        const nearest = vertices
          .slice()
          .sort((a, b) => angleDistanceDeg(a.h, hue) - angleDistanceDeg(b.h, hue))[0];

        return {
          C: nearest.C,
          L: nearest.L,
          edge: nearest.name
        };
      }

      return {
        C: bestT,
        L: bestL,
        edge: bestEdge
      };
    }

    function smoothGridCyclic(grid, passes) {
      const rows = grid.length;
      const cols = grid[0].length;
      let g = grid.map(row => row.slice());

      for (let pass = 0; pass < passes; pass++) {
        const n = createGrid(rows, cols, 0);

        for (let r = 0; r < rows; r++) {
          const r0 = Math.max(0, r - 1);
          const r1 = Math.min(rows - 1, r + 1);

          for (let c = 0; c < cols; c++) {
            const c0 = (c - 1 + cols) % cols;
            const c1 = (c + 1) % cols;

            n[r][c] =
              g[r][c] * 0.42 +
              g[r0][c] * 0.14 +
              g[r1][c] * 0.14 +
              g[r][c0] * 0.15 +
              g[r][c1] * 0.15;
          }
        }

        g = n;
      }

      return g;
    }

    function maxGridNeighborhood(grid, radiusL, radiusH) {
      const rows = grid.length;
      const cols = grid[0].length;
      const out = createGrid(rows, cols, 0);

      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          let m = -Infinity;

          for (let dr = -radiusL; dr <= radiusL; dr++) {
            const rr = clamp(r + dr, 0, rows - 1);

            for (let dc = -radiusH; dc <= radiusH; dc++) {
              const cc = (c + dc + cols) % cols;
              m = Math.max(m, grid[rr][cc]);
            }
          }

          out[r][c] = m;
        }
      }

      return out;
    }

    function bilinearCyclic(grid, lNorm, hue) {
      const rows = grid.length;
      const cols = grid[0].length;

      const y = clamp(lNorm, 0, 1) * (rows - 1);
      const r0 = Math.floor(y);
      const r1 = Math.min(rows - 1, r0 + 1);
      const fy = y - r0;

      const x = normDeg(hue) / 360 * cols;
      const c0 = Math.floor(x) % cols;
      const c1 = (c0 + 1) % cols;
      const fx = x - Math.floor(x);

      const a = lerp(grid[r0][c0], grid[r0][c1], fx);
      const b = lerp(grid[r1][c0], grid[r1][c1], fx);

      return lerp(a, b, fy);
    }

    function buildGamutVolumeModel(samples, gray, key, params) {
      const neutralAtL = gray.neutralAtL;

      const tempKey = detectKeyPoints(samples, neutralAtL);
      const vertices = tempKey.vertices;

      const rgbHueToLabHue = makeRgbHueToLabHue(vertices);
      const rgbHueToWaistL = makeRgbHueToWaistL(vertices);

      const lchSamples = samples.map(p => {
        const lab = sampleLab(p);
        const lch = labToLchWithNeutral(lab, neutralAtL);

        return {
          ...p,
          lab,
          L: lab.L,
          a: lab.a,
          labB: lab.b,
          C: lch.C,
          h: lch.h
        };
      });

      const Lmin = gray.black.L;
      const Lmax = gray.white.L;
      const Lrange = Math.max(1e-6, Lmax - Lmin);

      const L_BINS = 37;
      const H_BINS = 72;

      const bins = Array.from({ length: L_BINS }, () =>
        Array.from({ length: H_BINS }, () => [])
      );

      for (const p of lchSamples) {
        if (p.C < 1.5) continue;

        const li = clamp(Math.round(((p.L - Lmin) / Lrange) * (L_BINS - 1)), 0, L_BINS - 1);
        const hi = Math.floor(normDeg(p.h) / 360 * H_BINS) % H_BINS;

        bins[li][hi].push(p.C);
      }

      const rawGrid = createGrid(L_BINS, H_BINS, NaN);
      const countGrid = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const values = bins[li][hi];
          countGrid[li][hi] = values.length;

          if (values.length >= 2) {
            rawGrid[li][hi] = percentile(values, 0.965);
          }
        }
      }

      function fallbackC(li, hi) {
        const lNorm = li / (L_BINS - 1);
        const L = Lmin + lNorm * Lrange;
        const hue = hi / H_BINS * 360;

        const poly = rayPolygonC(hue, vertices);
        const waistC = poly.C;
        const waistL = poly.L;

        if (L <= waistL) {
          const t = smoothstep(Lmin, waistL, L);
          return waistC * Math.pow(t, 0.78);
        }

        const t = smoothstep(waistL, Lmax, L);
        return waistC * Math.pow(1 - t, 0.72);
      }

      const fallbackGrid = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          fallbackGrid[li][hi] = fallbackC(li, hi);
        }
      }

      const filledRaw = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          filledRaw[li][hi] = Number.isFinite(rawGrid[li][hi])
            ? rawGrid[li][hi]
            : fallbackGrid[li][hi];
        }
      }

      const guarded = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const raw = filledRaw[li][hi];
          const fallback = fallbackGrid[li][hi];
          const hasData = countGrid[li][hi] >= 2;

          if (!hasData) {
            guarded[li][hi] = fallback;
            continue;
          }

          // 측정값이 polygon보다 돌출된 것은 허용.
          // 측정값이 눌린 경우만 antiShrink.
          if (raw >= fallback) {
            guarded[li][hi] = raw;
          } else {
            const dentRatio = clamp((fallback - raw) / Math.max(fallback, 1e-6), 0, 1);

            if (dentRatio > 0.08) {
              guarded[li][hi] = lerp(raw, fallback, params.antiShrink * smoothstep(0.08, 0.45, dentRatio));
            } else {
              guarded[li][hi] = raw;
            }
          }
        }
      }

      const radius = Math.max(1, Math.round(params.edgeProtect));
      let envelope = maxGridNeighborhood(guarded, radius, radius);
      envelope = smoothGridCyclic(envelope, 2);

      const protectedGrid = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const raw = guarded[li][hi];
          const env = envelope[li][hi];

          const shrink = clamp(
            (env - raw) / Math.max(env * 0.36, 1e-6),
            0,
            1
          );

          const guard = params.gamutGuard * smoothstep(0.05, 0.90, shrink);

          protectedGrid[li][hi] = lerp(raw, env, guard);
        }
      }

      let smooth = smoothGridCyclic(protectedGrid, params.edgeSmooth);

      const finalGrid = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const smoothed = smooth[li][hi];
          const protectedValue = protectedGrid[li][hi];
          const fallback = fallbackGrid[li][hi];

          finalGrid[li][hi] = Math.max(
            lerp(smoothed, protectedValue, params.outlineFollow),
            fallback * 0.84
          );
        }
      }

      for (let hi = 0; hi < H_BINS; hi++) {
        finalGrid[0][hi] = 0;
        finalGrid[L_BINS - 1][hi] = 0;
      }

      const maxC = Math.max(...finalGrid.flat());

      function surfaceCAt(L, hue) {
        const lNorm = clamp((L - Lmin) / Lrange, 0, 1);
        return clamp(bilinearCyclic(finalGrid, lNorm, hue), 0, maxC * 1.12);
      }

      return {
        Lmin,
        Lmax,
        Lrange,
        L_BINS,
        H_BINS,
        vertices,
        rgbHueToLabHue,
        rgbHueToWaistL,
        rawGrid: filledRaw,
        fallbackGrid,
        finalGrid,
        maxC,
        surfaceCAt
      };
    }

    // ============================================================
    // Local multiquadric RBF in RGB device space
    // ============================================================

    function mqRgbDistance(q, p) {
      const dr = (q.r - p.r) / 100;
      const dg = (q.g - p.g) / 100;
      const db = (q.b - p.b) / 100;
      return Math.sqrt(dr * dr + dg * dg + db * db);
    }

    function weightedFallbackLab(q, samples, k) {
      const nearest = samples
        .map(p => ({ p, d: mqRgbDistance(q, p) }))
        .sort((a, b) => a.d - b.d)
        .slice(0, Math.max(4, k));

      let sw = 0;
      let L = 0;
      let a = 0;
      let b = 0;

      for (const item of nearest) {
        const w = 1 / Math.max(1e-6, item.d * item.d);
        sw += w;
        L += w * item.p.L;
        a += w * item.p.a;
        b += w * item.p.labB;
      }

      if (sw <= 0) {
        return sampleLab(samples[0]);
      }

      return {
        L: L / sw,
        a: a / sw,
        b: b / sw
      };
    }

    function localMQRbfLab(q, samples, params) {
      const k = clamp(Math.round(params.mqK), 4, 40);
      const eps = params.mqEps;
      const nugget = 0.0015;

      const nearest = samples
        .map(p => ({ p, d: mqRgbDistance(q, p) }))
        .sort((a, b) => a.d - b.d)
        .slice(0, Math.min(k, samples.length));

      if (nearest.length === 0) {
        return { L: 50, a: 0, b: 0 };
      }

      if (nearest[0].d < 1e-8) {
        return sampleLab(nearest[0].p);
      }

      const n = nearest.length;

      if (n < 4) {
        return weightedFallbackLab(q, samples, k);
      }

      const A = createGrid(n, n, 0);

      for (let i = 0; i < n; i++) {
        for (let j = 0; j < n; j++) {
          const d = mqRgbDistance(nearest[i].p, nearest[j].p);
          A[i][j] = Math.sqrt(d * d + eps * eps);
        }

        A[i][i] += nugget;
      }

      const yL = nearest.map(x => x.p.L);
      const ya = nearest.map(x => x.p.a);
      const yb = nearest.map(x => x.p.labB);

      const wL = solveLinearSystem(A.map(row => row.slice()), yL);
      const wa = solveLinearSystem(A.map(row => row.slice()), ya);
      const wb = solveLinearSystem(A.map(row => row.slice()), yb);

      if (!wL || !wa || !wb) {
        return weightedFallbackLab(q, samples, k);
      }

      let L = 0;
      let a = 0;
      let b = 0;

      for (let i = 0; i < n; i++) {
        const d = mqRgbDistance(q, nearest[i].p);
        const phi = Math.sqrt(d * d + eps * eps);

        L += wL[i] * phi;
        a += wa[i] * phi;
        b += wb[i] * phi;
      }

      return {
        L: clamp(L, 0, 100),
        a,
        b
      };
    }

    // ============================================================
    // A2B prediction
    // ============================================================

    function applySpineAndGamutGuard(q, baseLab, model, params) {
      const hsl = rgbToHsl(q.r, q.g, q.b);
      const grayT = clamp((q.r + q.g + q.b) / 300, 0, 1);
      const spineLab = model.gray.spineAtT(grayT);

      if (hsl.s < 0.002) {
        return spineLab;
      }

      // Anti-hook gray spine.
      const grayBlend =
        params.spineStrength *
        Math.pow(1 - hsl.s, 3.2);

      let lab = {
        L: lerp(baseLab.L, spineLab.L, grayBlend),
        a: lerp(baseLab.a, spineLab.a, grayBlend),
        b: lerp(baseLab.b, spineLab.b, grayBlend)
      };

      // RGB hue → measured Lab hue alignment.
      const lch = labToLchWithNeutral(lab, model.gray.neutralAtL);
      const mappedHue = model.volume.rgbHueToLabHue(hsl.h);
      const hue = mixHueDeg(lch.h, mappedHue, 0.20 * hsl.s);

      const Cmax = model.volume.surfaceCAt(lab.L, hue);
      const expectedC = Cmax * Math.pow(hsl.s, params.radialPower);

      let C = lch.C;

      // out-of-gamut compression / edge shrink restore.
      if (hsl.s > 0.45 && C < expectedC * 0.88) {
        const shrink = clamp((expectedC * 0.88 - C) / Math.max(expectedC, 1e-6), 0, 1);
        const restore = params.gamutGuard * params.antiShrink * smoothstep(0.03, 0.42, shrink) * hsl.s;
        C = lerp(C, expectedC, restore);
      }

      // excessive overshoot clamp.
      if (C > Cmax * 1.055) {
        C = lerp(C, Cmax * 1.02, params.gamutGuard);
      }

      return lchToLabWithNeutral(lab.L, Math.max(0, C), hue, model.gray.neutralAtL);
    }

    function predictA2BLab(q, model, params) {
      const rbfLab = localMQRbfLab(q, model.samples, params);
      const fallbackLab = rgbHslFallbackLab(q, model, params);

      const baseLab = {
        L: lerp(fallbackLab.L, rbfLab.L, params.rbfBlend),
        a: lerp(fallbackLab.a, rbfLab.a, params.rbfBlend),
        b: lerp(fallbackLab.b, rbfLab.b, params.rbfBlend)
      };

      return applySpineAndGamutGuard(q, baseLab, model, params);
    }

    function rgbHslFallbackLab(q, model, params) {
      const hsl = rgbToHsl(q.r, q.g, q.b);
      const grayT = clamp((q.r + q.g + q.b) / 300, 0, 1);
      const spineLab = model.gray.spineAtT(grayT);

      if (hsl.s < 0.002) {
        return spineLab;
      }

      const waistL = model.volume.rgbHueToWaistL(hsl.h);
      const labHue = model.volume.rgbHueToLabHue(hsl.h);

      let colorL;

      if (hsl.l <= 0.5) {
        const t = smoothstep(0, 0.5, hsl.l);
        colorL = lerp(model.gray.black.L, waistL, t);
      } else {
        const t = smoothstep(0.5, 1, hsl.l);
        colorL = lerp(waistL, model.gray.white.L, t);
      }

      const L = lerp(spineLab.L, colorL, hsl.s);
      const Cmax = model.volume.surfaceCAt(L, labHue);
      const C = Cmax * Math.pow(hsl.s, params.radialPower);

      return lchToLabWithNeutral(L, C, labHue, model.gray.neutralAtL);
    }

    function buildA2BModel(samples, params) {
      const gray = buildGraySpine(samples, params);
      const key = detectKeyPoints(samples, gray.neutralAtL);
      const volume = buildGamutVolumeModel(samples, gray, key, params);

      return {
        samples,
        gray,
        key,
        volume
      };
    }

    function buildCLUT(model, params) {
      const size = 16;
      const data = [];

      for (let ri = 0; ri < size; ri++) {
        for (let gi = 0; gi < size; gi++) {
          for (let bi = 0; bi < size; bi++) {
            const q = {
              r: ri / (size - 1) * 100,
              g: gi / (size - 1) * 100,
              b: bi / (size - 1) * 100
            };

            const lab = predictA2BLab(q, model, params);

            data.push({
              r: q.r,
              g: q.g,
              b: q.b,
              L: lab.L,
              a: lab.a,
              labB: lab.b
            });
          }
        }
      }

      return {
        size,
        data
      };
    }

    // ============================================================
    // Download helpers
    // ============================================================

    function downloadText(filename, text, mime) {
      const blob = new Blob([text], { type: mime });
      const url = URL.createObjectURL(blob);

      const a = document.createElement("a");
      a.href = url;
      a.download = filename;

      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);

      URL.revokeObjectURL(url);
    }

    function clutToCsv(clut) {
      const lines = ["R,G,B,L,a,b"];

      for (const p of clut.data) {
        lines.push([
          p.r.toFixed(6),
          p.g.toFixed(6),
          p.b.toFixed(6),
          p.L.toFixed(6),
          p.a.toFixed(6),
          p.labB.toFixed(6)
        ].join(","));
      }

      return lines.join("\n");
    }

    function clutToJson(state) {
      return JSON.stringify({
        title: "Printer ICC A2B RGB to PCS Lab CLUT",
        rgbRange: [0, 100],
        clutSize: [16, 16, 16],
        blackPoint: state.model.gray.black,
        whitePoint: state.model.gray.white,
        vertices: state.model.volume.vertices.map(v => ({
          name: v.name,
          measuredRGB: [v.r, v.g, v.b],
          Lab: [v.L, v.a, v.labB],
          C: v.C,
          h: v.h
        })),
        graySpine: state.model.gray.spine,
        clut: state.clut.data.map(p => ({
          RGB: [
            Number(p.r.toFixed(6)),
            Number(p.g.toFixed(6)),
            Number(p.b.toFixed(6))
          ],
          Lab: [
            Number(p.L.toFixed(6)),
            Number(p.a.toFixed(6)),
            Number(p.labB.toFixed(6))
          ]
        }))
      }, null, 2);
    }

    // ============================================================
    // UI state
    // ============================================================

    const inputText = document.getElementById("inputText");
    const info = document.getElementById("info");

    const controls = {
      mqK: document.getElementById("mqK"),
      mqEps: document.getElementById("mqEps"),
      rbfBlend: document.getElementById("rbfBlend"),
      spineStrength: document.getElementById("spineStrength"),
      grayTolerance: document.getElementById("grayTolerance"),
      edgeSmooth: document.getElementById("edgeSmooth"),
      gamutGuard: document.getElementById("gamutGuard"),
      antiShrink: document.getElementById("antiShrink"),
      edgeProtect: document.getElementById("edgeProtect"),
      outlineFollow: document.getElementById("outlineFollow"),
      radialPower: document.getElementById("radialPower")
    };

    const valueEls = {
      mqK: document.getElementById("mqKValue"),
      mqEps: document.getElementById("mqEpsValue"),
      rbfBlend: document.getElementById("rbfBlendValue"),
      spineStrength: document.getElementById("spineStrengthValue"),
      grayTolerance: document.getElementById("grayToleranceValue"),
      edgeSmooth: document.getElementById("edgeSmoothValue"),
      gamutGuard: document.getElementById("gamutGuardValue"),
      antiShrink: document.getElementById("antiShrinkValue"),
      edgeProtect: document.getElementById("edgeProtectValue"),
      outlineFollow: document.getElementById("outlineFollowValue"),
      radialPower: document.getElementById("radialPowerValue")
    };

    function getParams() {
      const params = {
        mqK: Number(controls.mqK.value),
        mqEps: Number(controls.mqEps.value),
        rbfBlend: Number(controls.rbfBlend.value),
        spineStrength: Number(controls.spineStrength.value),
        grayTolerance: Number(controls.grayTolerance.value),
        edgeSmooth: Number(controls.edgeSmooth.value),
        gamutGuard: Number(controls.gamutGuard.value),
        antiShrink: Number(controls.antiShrink.value),
        edgeProtect: Number(controls.edgeProtect.value),
        outlineFollow: Number(controls.outlineFollow.value),
        radialPower: Number(controls.radialPower.value)
      };

      for (const key of Object.keys(params)) {
        valueEls[key].textContent = String(params[key]);
      }

      return params;
    }

    let state = {
      samples: [],
      model: null,
      clut: null
    };

    function generateAll() {
      const params = getParams();
      const samples = parseInput(inputText.value);

      if (samples.length < 24) {
        info.textContent = "입력 RGB/Lab 샘플이 너무 적습니다. 최소 24개 이상을 권장합니다.";
        return;
      }

      const model = buildA2BModel(samples, params);
      const clut = buildCLUT(model, params);

      state = {
        samples,
        model,
        clut
      };

      const vText = model.volume.vertices.map(v =>
        `${v.name}: RGB≈[${v.r.toFixed(1)}, ${v.g.toFixed(1)}, ${v.b.toFixed(1)}], Lab=[${v.L.toFixed(2)}, ${v.a.toFixed(2)}, ${v.labB.toFixed(2)}], C=${v.C.toFixed(2)}, h=${v.h.toFixed(1)}`
      ).join("\n");

      info.textContent =
        `입력 샘플: ${samples.length}개\n` +
        `생성 CLUT: 16×16×16 = ${clut.data.length}개\n\n` +
        `Black: L=${model.gray.black.L.toFixed(2)}, a=${model.gray.black.a.toFixed(2)}, b=${model.gray.black.b.toFixed(2)}\n` +
        `White: L=${model.gray.white.L.toFixed(2)}, a=${model.gray.white.a.toFixed(2)}, b=${model.gray.white.b.toFixed(2)}\n\n` +
        `검출 RGB/CMY 꼭짓점:\n${vText}\n\n` +
        `적용 알고리즘:\n` +
        `- RGB 공간 local multiquadric RBF\n` +
        `- anti-hook gray spine\n` +
        `- RGB/CMY 허리라인 polygon\n` +
        `- black/white point 기반 팽이형 volume model\n` +
        `- in-gamut 돌출 허용\n` +
        `- 눌림/압축 외곽 antiShrink 복원\n` +
        `- CLUT RGB 0~100 균등 생성`;

      console.clear();
      console.log("samples", samples);
      console.log("model", model);
      console.log("clut", clut);
    }

    document.getElementById("makeDemo").addEventListener("click", () => {
      inputText.value = makeDemoText();
      generateAll();
    });

    document.getElementById("generate").addEventListener("click", generateAll);

    document.getElementById("downloadCsv").addEventListener("click", () => {
      if (!state.clut) return;
      downloadText("printer_a2b_rgb_lab_clut_16.csv", clutToCsv(state.clut), "text/csv");
    });

    document.getElementById("downloadJson").addEventListener("click", () => {
      if (!state.clut) return;
      downloadText("printer_a2b_rgb_lab_clut_16.json", clutToJson(state), "application/json");
    });

    document.getElementById("copyJson").addEventListener("click", async () => {
      if (!state.clut) return;
      await navigator.clipboard.writeText(clutToJson(state));
      info.textContent += "\n\nJSON이 클립보드에 복사되었습니다.";
    });

    for (const key of Object.keys(controls)) {
      controls[key].addEventListener("input", () => {
        getParams();

        if (inputText.value.trim()) {
          generateAll();
        }
      });
    }

    // ============================================================
    // 3D rendering
    // ============================================================

    let rotationEnabled = true;
    let rotationTime = 0;
    let lastFrameTime = performance.now();

    document.getElementById("toggleRotation").addEventListener("click", () => {
      rotationEnabled = !rotationEnabled;
      document.getElementById("toggleRotation").textContent =
        rotationEnabled ? "회전 정지" : "회전 시작";
    });

    function rotateX(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0],
        p[1] * c - p[2] * s,
        p[1] * s + p[2] * c
      ];
    }

    function rotateY(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0] * c + p[2] * s,
        p[1],
        -p[0] * s + p[2] * c
      ];
    }

    function transform3D(p, time) {
      let q = p;
      q = rotateX(q, -0.78);
      q = rotateY(q, time * 0.00036);
      return q;
    }

    function project3D(p, cx, cy, scale) {
      const d = 7.5;
      const k = d / (d + p[2]);

      return {
        x: cx + p[0] * scale * k,
        y: cy - p[1] * scale * k,
        z: p[2]
      };
    }

    function labToPoint3D(lab, neutralAtL) {
      const n = neutralAtL(lab.L);

      return [
        (lab.a - n.a) / 50,
        (lab.L - 50) / 28,
        (lab.b - n.b) / 50
      ];
    }

    function drawAxes(cx, cy, scale, time) {
      const axes = [
        { a: [0, -1.8, 0], b: [0, 1.8, 0], color: "rgba(255,255,255,0.25)" },
        { a: [-1.9, 0, 0], b: [1.9, 0, 0], color: "rgba(255,110,110,0.22)" },
        { a: [0, 0, -1.9], b: [0, 0, 1.9], color: "rgba(110,170,255,0.22)" }
      ];

      for (const ax of axes) {
        const p0 = project3D(transform3D(ax.a, time), cx, cy, scale);
        const p1 = project3D(transform3D(ax.b, time), cx, cy, scale);

        ctx.beginPath();
        ctx.moveTo(p0.x, p0.y);
        ctx.lineTo(p1.x, p1.y);
        ctx.strokeStyle = ax.color;
        ctx.lineWidth = 1;
        ctx.stroke();
      }
    }

    function drawPointCloud(points, neutralAtL, cx, cy, scale, time, radius) {
      const rendered = points.map(p => {
        const lab = {
          L: p.L,
          a: p.a,
          b: p.labB !== undefined ? p.labB : p.b
        };

        const q = transform3D(labToPoint3D(lab, neutralAtL), time);

        return {
          lab,
          world: q,
          screen: project3D(q, cx, cy, scale)
        };
      });

      rendered.sort((a, b) => a.world[2] - b.world[2]);

      for (const item of rendered) {
        ctx.beginPath();
        ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
        ctx.fillStyle = labCss(item.lab);
        ctx.globalAlpha = 0.82;
        ctx.fill();
        ctx.globalAlpha = 1;
      }
    }

    function drawLineLab(labs, neutralAtL, cx, cy, scale, time, color, width) {
      ctx.beginPath();

      for (let i = 0; i < labs.length; i++) {
        const q = transform3D(labToPoint3D(labs[i], neutralAtL), time);
        const s = project3D(q, cx, cy, scale);

        if (i === 0) ctx.moveTo(s.x, s.y);
        else ctx.lineTo(s.x, s.y);
      }

      ctx.strokeStyle = color;
      ctx.lineWidth = width;
      ctx.stroke();
    }

    function drawVolumeModel(model, cx, cy, scale, time) {
      const neutralAtL = model.gray.neutralAtL;

      // volume rings
      for (let li = 4; li <= 32; li += 5) {
        const L = model.volume.Lmin + (li / 36) * model.volume.Lrange;
        const ring = [];

        for (let hi = 0; hi <= 96; hi++) {
          const h = hi / 96 * 360;
          const C = model.volume.surfaceCAt(L, h);
          ring.push(lchToLabWithNeutral(L, C, h, neutralAtL));
        }

        drawLineLab(ring, neutralAtL, cx, cy, scale, time, "rgba(255,255,255,0.34)", 1.0);
      }

      // vertical hue lines
      for (let hi = 0; hi < 12; hi++) {
        const h = hi / 12 * 360;
        const line = [];

        for (let li = 0; li <= 36; li++) {
          const L = model.volume.Lmin + (li / 36) * model.volume.Lrange;
          const C = model.volume.surfaceCAt(L, h);
          line.push(lchToLabWithNeutral(L, C, h, neutralAtL));
        }

        drawLineLab(line, neutralAtL, cx, cy, scale, time, "rgba(255,255,255,0.24)", 1.0);
      }

      // raw waist line
      const rawLine = [];

      for (let hi = 0; hi <= 96; hi++) {
        const h = hi / 96 * 360;
        const poly = rayPolygonC(h, model.volume.vertices);
        const L = poly.L;
        const lNorm = clamp((L - model.volume.Lmin) / model.volume.Lrange, 0, 1);
        const C = bilinearCyclic(model.volume.rawGrid, lNorm, h);
        rawLine.push(lchToLabWithNeutral(L, C, h, neutralAtL));
      }

      drawLineLab(rawLine, neutralAtL, cx, cy, scale, time, "rgba(255,80,70,0.46)", 1.2);

      // anti-hook gray spine
      const spineLabs = model.gray.spine.map(p => ({
        L: p.L,
        a: p.a,
        b: p.b
      }));

      drawLineLab(spineLabs, neutralAtL, cx, cy, scale, time, "rgba(200,200,200,0.78)", 2.0);

      // vertices
      for (const v of model.volume.vertices) {
        const lab = {
          L: v.L,
          a: v.a,
          b: v.labB
        };

        const q = transform3D(labToPoint3D(lab, neutralAtL), time);
        const s = project3D(q, cx, cy, scale);

        ctx.beginPath();
        ctx.arc(s.x, s.y, 5.3, 0, Math.PI * 2);
        ctx.fillStyle = "rgba(255,255,255,0.96)";
        ctx.fill();

        ctx.fillStyle = "#fff";
        ctx.font = "12px Arial";
        ctx.fillText(v.name, s.x + 7, s.y - 7);
      }

      // black / white
      for (const p of [
        { label: "K", lab: model.gray.black },
        { label: "W", lab: model.gray.white }
      ]) {
        const q = transform3D(labToPoint3D(p.lab, neutralAtL), time);
        const s = project3D(q, cx, cy, scale);

        ctx.beginPath();
        ctx.arc(s.x, s.y, 5.5, 0, Math.PI * 2);
        ctx.fillStyle = "rgba(255,220,90,0.98)";
        ctx.fill();

        ctx.fillStyle = "#ffdc5a";
        ctx.font = "12px Arial";
        ctx.fillText(p.label, s.x + 7, s.y - 7);
      }
    }

    function drawTopDownPolygonInset(model) {
      const x0 = canvas.width - 280;
      const y0 = canvas.height - 245;
      const w = 250;
      const h = 210;

      ctx.save();

      ctx.fillStyle = "rgba(0,0,0,0.50)";
      ctx.fillRect(x0, y0, w, h);

      ctx.strokeStyle = "rgba(255,255,255,0.18)";
      ctx.strokeRect(x0, y0, w, h);

      ctx.fillStyle = "#ddd";
      ctx.font = "12px Arial";
      ctx.fillText("2D 허리라인 polygon", x0 + 10, y0 + 18);

      const vertices = model.volume.vertices;
      const maxC = Math.max(...vertices.map(v => v.C), 1);

      function mapPoint(C, hue) {
        const rad = hue * Math.PI / 180;
        const x = x0 + w / 2 + Math.cos(rad) * C / maxC * 88;
        const y = y0 + h / 2 - Math.sin(rad) * C / maxC * 88;
        return { x, y };
      }

      ctx.beginPath();

      for (let i = 0; i < vertices.length; i++) {
        const v = vertices[i];
        const p = mapPoint(v.C, v.h);

        if (i === 0) ctx.moveTo(p.x, p.y);
        else ctx.lineTo(p.x, p.y);
      }

      ctx.closePath();
      ctx.strokeStyle = "rgba(255,255,255,0.80)";
      ctx.lineWidth = 1.5;
      ctx.stroke();

      for (const v of vertices) {
        const p = mapPoint(v.C, v.h);

        ctx.beginPath();
        ctx.arc(p.x, p.y, 4.2, 0, Math.PI * 2);
        ctx.fillStyle = "#fff";
        ctx.fill();

        ctx.fillStyle = "#fff";
        ctx.font = "11px Arial";
        ctx.fillText(v.name, p.x + 5, p.y - 5);
      }

      ctx.restore();
    }

    function drawDivider() {
      ctx.strokeStyle = "rgba(255,255,255,0.14)";
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(canvas.width / 2, 0);
      ctx.lineTo(canvas.width / 2, canvas.height);
      ctx.stroke();

      ctx.fillStyle = "rgba(255,255,255,0.18)";
      ctx.font = "42px Arial";
      ctx.fillText("→", canvas.width / 2 - 16, canvas.height / 2);
    }

    function draw() {
      const now = performance.now();
      const delta = now - lastFrameTime;
      lastFrameTime = now;

      if (rotationEnabled) {
        rotationTime += delta;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      drawDivider();

      if (state.model && state.clut) {
        const scale = Math.min(canvas.width, canvas.height) * 0.18;
        const leftCx = canvas.width * 0.36;
        const rightCx = canvas.width * 0.74;
        const cy = canvas.height * 0.56;

        drawAxes(leftCx, cy, scale, rotationTime);
        drawPointCloud(state.samples, state.model.gray.neutralAtL, leftCx, cy, scale, rotationTime, 2.3);
        drawVolumeModel(state.model, leftCx, cy, scale, rotationTime);

        drawAxes(rightCx, cy, scale, rotationTime);
        const step = Math.max(1, Math.floor(state.clut.data.length / 2300));
        const clutPreview = state.clut.data.filter((_, i) => i % step === 0);
        drawPointCloud(clutPreview, state.model.gray.neutralAtL, rightCx, cy, scale, rotationTime, 1.65);
        drawVolumeModel(state.model, rightCx, cy, scale, rotationTime);

        drawTopDownPolygonInset(state.model);

        ctx.fillStyle = "#8ecbff";
        ctx.font = "14px Arial";
        ctx.fillText("입력 측정 RGB/Lab + 예상 volume", canvas.width * 0.28, canvas.height - 28);

        ctx.fillStyle = "#9dffb7";
        ctx.fillText("생성된 16³ A2B CLUT Lab", canvas.width * 0.67, canvas.height - 28);
      } else {
        ctx.fillStyle = "#ddd";
        ctx.font = "18px Arial";
        ctx.fillText("왼쪽 패널에서 데모 생성 또는 RGB/Lab 입력 후 A2B CLUT 생성을 누르세요.", 470, canvas.height / 2);
      }

      requestAnimationFrame(draw);
    }

    // ============================================================
    // Init
    // ============================================================

    inputText.value = makeDemoText();
    generateAll();
    draw();
  </script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <title>Printer ICC A2B RGB→PCS Lab CLUT Generator</title>
  <style>
    body {
      margin: 0;
      background: #0f1012;
      color: #eee;
      font-family: Arial, sans-serif;
      overflow: hidden;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
      background: #101114;
    }

    .panel {
      position: fixed;
      left: 12px;
      top: 12px;
      width: 420px;
      max-height: calc(100vh - 24px);
      overflow: auto;
      z-index: 10;
      padding: 12px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.74);
      box-shadow: 0 0 18px rgba(0,0,0,0.45);
    }

    .panel h2 {
      margin: 0 0 8px 0;
      font-size: 16px;
      color: #9dffb7;
    }

    .panel p {
      margin: 6px 0;
      color: #ccc;
      font-size: 12px;
      line-height: 1.45;
    }

    textarea {
      width: 100%;
      height: 185px;
      box-sizing: border-box;
      resize: vertical;
      background: #191a1f;
      color: #eaeaea;
      border: 1px solid #333842;
      border-radius: 8px;
      padding: 8px;
      font-family: Consolas, monospace;
      font-size: 12px;
      line-height: 1.4;
    }

    .buttons {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 6px;
      margin-top: 8px;
    }

    button {
      border: 0;
      border-radius: 8px;
      padding: 8px 9px;
      font-weight: bold;
      cursor: pointer;
      background: #f2f2f2;
      color: #111;
    }

    button:hover {
      background: #dcdcdc;
    }

    .controls {
      margin-top: 10px;
      display: grid;
      grid-template-columns: 150px 155px 58px;
      gap: 5px 8px;
      align-items: center;
      font-size: 12px;
    }

    input[type="range"] {
      width: 155px;
    }

    .info {
      margin-top: 10px;
      padding: 8px;
      border-radius: 8px;
      background: rgba(255,255,255,0.065);
      font-size: 12px;
      color: #ddd;
      white-space: pre-line;
      line-height: 1.45;
    }

    .legend {
      position: fixed;
      right: 14px;
      top: 14px;
      z-index: 10;
      padding: 10px 12px;
      border-radius: 12px;
      background: rgba(0,0,0,0.64);
      font-size: 13px;
      line-height: 1.55;
      color: #ddd;
    }

    .titleLeft,
    .titleRight {
      position: fixed;
      top: 14px;
      z-index: 6;
      padding: 7px 10px;
      border-radius: 9px;
      background: rgba(0,0,0,0.56);
      font-weight: bold;
      font-size: 15px;
    }

    .titleLeft {
      left: 455px;
      color: #8ecbff;
    }

    .titleRight {
      right: 320px;
      color: #9dffb7;
    }
  </style>
</head>
<body>
  <canvas id="canvas"></canvas>

  <div class="titleLeft">측색 RGB/Lab 샘플 미리보기</div>
  <div class="titleRight">생성된 16³ A2B CLUT Lab 미리보기</div>

  <div class="legend">
    <b>표시</b><br />
    작은 점: 측색 샘플 / CLUT Lab<br />
    흰색 링: 최종 gamut surface<br />
    붉은 링: raw 측색 외곽<br />
    큰 흰점: RGB/CMY 꼭짓점<br />
    노란 점: White / Black<br />
    <br />
    CLUT: RGB 0~100, 16×16×16<br />
    입력 형식: R G B L a b
  </div>

  <div class="panel">
    <h2>Printer ICC A2B → PCS Lab CLUT 생성</h2>
    <p>
      RGB와 측색 Lab을 사용해 A2B RGB→PCS Lab 16³ CLUT를 생성합니다.
      외곽이 out-of-gamut/과측정/압축값에 의해 안쪽으로 끌려 들어가지 않도록
      anti-shrink volume guard, RGB/CMY 허리라인 polygon, anti-hook neutral spine,
      local MQ-RBF residual 보정을 함께 사용합니다.
    </p>

    <textarea id="sampleInput"></textarea>

    <div class="buttons">
      <button id="loadDemo">데모 RGB/Lab 생성</button>
      <button id="generate">A2B CLUT 생성</button>
      <button id="toggleRotation">회전 정지</button>
      <button id="downloadCsv">CSV 다운로드</button>
      <button id="downloadJson">JSON 다운로드</button>
      <button id="copyJson">JSON 복사</button>
    </div>

    <div class="controls">
      <label>surfaceSmooth</label>
      <input id="surfaceSmooth" type="range" min="1" max="10" step="1" value="4" />
      <span id="surfaceSmoothValue">4</span>

      <label>edgeSmooth</label>
      <input id="edgeSmooth" type="range" min="1" max="12" step="1" value="5" />
      <span id="edgeSmoothValue">5</span>

      <label>antiShrink</label>
      <input id="antiShrink" type="range" min="0" max="1" step="0.05" value="0.88" />
      <span id="antiShrinkValue">0.88</span>

      <label>gamutGuard</label>
      <input id="gamutGuard" type="range" min="0" max="1" step="0.05" value="0.90" />
      <span id="gamutGuardValue">0.90</span>

      <label>edgeProtect</label>
      <input id="edgeProtect" type="range" min="1" max="8" step="1" value="3" />
      <span id="edgeProtectValue">3</span>

      <label>outlineFollow</label>
      <input id="outlineFollow" type="range" min="0" max="1" step="0.05" value="0.55" />
      <span id="outlineFollowValue">0.55</span>

      <label>rbfResidual</label>
      <input id="rbfResidual" type="range" min="0" max="1" step="0.05" value="0.35" />
      <span id="rbfResidualValue">0.35</span>

      <label>radialPower</label>
      <input id="radialPower" type="range" min="0.65" max="1.40" step="0.05" value="0.90" />
      <span id="radialPowerValue">0.90</span>

      <label>graySpineSmooth</label>
      <input id="graySpineSmooth" type="range" min="1" max="10" step="1" value="5" />
      <span id="graySpineSmoothValue">5</span>

      <label>volumeAnchor</label>
      <input id="volumeAnchor" type="range" min="0" max="1" step="0.05" value="0.75" />
      <span id="volumeAnchorValue">0.75</span>
    </div>

    <div id="info" class="info">준비 중...</div>
  </div>

  <script>
    // ============================================================
    // 0. Canvas
    // ============================================================

    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");

    function resize() {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    }

    window.addEventListener("resize", resize);
    resize();

    // ============================================================
    // 1. Basic utilities
    // ============================================================

    function clamp(x, a, b) {
      return Math.max(a, Math.min(b, x));
    }

    function lerp(a, b, t) {
      return a * (1 - t) + b * t;
    }

    function smoothstep(a, b, x) {
      if (a === b) return x < a ? 0 : 1;
      const t = clamp((x - a) / (b - a), 0, 1);
      return t * t * (3 - 2 * t);
    }

    function rand(seed) {
      const x = Math.sin(seed * 12.9898) * 43758.5453123;
      return x - Math.floor(x);
    }

    function normDeg(h) {
      h = h % 360;
      if (h < 0) h += 360;
      return h;
    }

    function angleDiffDeg(a, b) {
      let d = normDeg(a) - normDeg(b);
      while (d > 180) d -= 360;
      while (d < -180) d += 360;
      return d;
    }

    function angleDistanceDeg(a, b) {
      return Math.abs(angleDiffDeg(a, b));
    }

    function percentile(values, q) {
      const arr = values
        .filter(Number.isFinite)
        .slice()
        .sort((a, b) => a - b);

      if (arr.length === 0) return NaN;
      if (arr.length === 1) return arr[0];

      const pos = (arr.length - 1) * q;
      const lo = Math.floor(pos);
      const hi = Math.ceil(pos);
      const t = pos - lo;

      return arr[lo] * (1 - t) + arr[hi] * t;
    }

    function median(values) {
      return percentile(values, 0.5);
    }

    function distanceRGB(a, b) {
      const dr = (a.r - b.r) / 100;
      const dg = (a.g - b.g) / 100;
      const db = (a.b - b.b) / 100;
      return Math.sqrt(dr * dr + dg * dg + db * db);
    }

    function createGrid(rows, cols, fill = NaN) {
      return Array.from({ length: rows }, () =>
        Array.from({ length: cols }, () => fill)
      );
    }

    function fillMissing1D(values) {
      const arr = values.slice();

      for (let i = 0; i < arr.length; i++) {
        if (Number.isFinite(arr[i])) continue;

        let l = i - 1;
        let r = i + 1;

        while (l >= 0 && !Number.isFinite(arr[l])) l--;
        while (r < arr.length && !Number.isFinite(arr[r])) r++;

        if (l >= 0 && r < arr.length) {
          arr[i] = (arr[l] + arr[r]) * 0.5;
        } else if (l >= 0) {
          arr[i] = arr[l];
        } else if (r < arr.length) {
          arr[i] = arr[r];
        } else {
          arr[i] = 0;
        }
      }

      return arr;
    }

    function smooth1D(values, passes, keepEnds = true) {
      let arr = values.slice();
      const first = arr[0];
      const last = arr[arr.length - 1];

      for (let p = 0; p < passes; p++) {
        const next = arr.slice();

        for (let i = 1; i < arr.length - 1; i++) {
          next[i] = arr[i - 1] * 0.25 + arr[i] * 0.50 + arr[i + 1] * 0.25;
        }

        if (keepEnds) {
          next[0] = first;
          next[next.length - 1] = last;
        }

        arr = next;
      }

      return arr;
    }

    function smoothGridCyclic(grid, passes) {
      const rows = grid.length;
      const cols = grid[0].length;
      let g = grid.map(row => row.slice());

      for (let pass = 0; pass < passes; pass++) {
        const n = createGrid(rows, cols, 0);

        for (let r = 0; r < rows; r++) {
          const r0 = Math.max(0, r - 1);
          const r1 = Math.min(rows - 1, r + 1);

          for (let c = 0; c < cols; c++) {
            const c0 = (c - 1 + cols) % cols;
            const c1 = (c + 1) % cols;

            n[r][c] =
              g[r][c] * 0.42 +
              g[r0][c] * 0.14 +
              g[r1][c] * 0.14 +
              g[r][c0] * 0.15 +
              g[r][c1] * 0.15;
          }
        }

        g = n;
      }

      return g;
    }

    function maxGridNeighborhood(grid, radiusL, radiusH) {
      const rows = grid.length;
      const cols = grid[0].length;
      const out = createGrid(rows, cols, 0);

      for (let r = 0; r < rows; r++) {
        for (let c = 0; c < cols; c++) {
          let m = -Infinity;

          for (let dr = -radiusL; dr <= radiusL; dr++) {
            const rr = clamp(r + dr, 0, rows - 1);

            for (let dc = -radiusH; dc <= radiusH; dc++) {
              const cc = (c + dc + cols) % cols;
              m = Math.max(m, grid[rr][cc]);
            }
          }

          out[r][c] = m;
        }
      }

      return out;
    }

    function bilinearCyclic(grid, lNorm, hue) {
      const rows = grid.length;
      const cols = grid[0].length;

      const y = clamp(lNorm, 0, 1) * (rows - 1);
      const r0 = Math.floor(y);
      const r1 = Math.min(rows - 1, r0 + 1);
      const ty = y - r0;

      const x = normDeg(hue) / 360 * cols;
      const c0 = Math.floor(x) % cols;
      const c1 = (c0 + 1) % cols;
      const tx = x - Math.floor(x);

      const a = lerp(grid[r0][c0], grid[r0][c1], tx);
      const b = lerp(grid[r1][c0], grid[r1][c1], tx);

      return lerp(a, b, ty);
    }

    // ============================================================
    // 2. Color utilities
    // ============================================================

    function rgbToHsl(r, g, b) {
      r /= 100;
      g /= 100;
      b /= 100;

      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);
      const l = (max + min) * 0.5;

      let h = 0;
      let s = 0;

      if (max !== min) {
        const d = max - min;
        s = l > 0.5 ? d / (2 - max - min) : d / (max + min);

        if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
        else if (max === g) h = (b - r) / d + 2;
        else h = (r - g) / d + 4;

        h *= 60;
      }

      return {
        h: normDeg(h),
        s: clamp(s, 0, 1),
        l: clamp(l, 0, 1)
      };
    }

    function neutralAtL(spine, L) {
      const x = clamp((L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin), 0, 1);
      const pos = x * (spine.count - 1);
      const i = Math.floor(pos);
      const j = Math.min(spine.count - 1, i + 1);
      const t = pos - i;

      return {
        a: lerp(spine.aBins[i], spine.aBins[j], t),
        b: lerp(spine.bBins[i], spine.bBins[j], t)
      };
    }

    function labToLch(lab, spine) {
      const n = neutralAtL(spine, lab.L);
      const da = lab.a - n.a;
      const db = lab.b - n.b;

      const C = Math.sqrt(da * da + db * db);
      const h = normDeg(Math.atan2(db, da) * 180 / Math.PI);

      return {
        L: lab.L,
        a: lab.a,
        b: lab.b,
        C,
        h
      };
    }

    function lchToLab(L, C, h, spine) {
      const n = neutralAtL(spine, L);
      const rad = h * Math.PI / 180;

      return {
        L,
        a: n.a + C * Math.cos(rad),
        b: n.b + C * Math.sin(rad)
      };
    }

    function labCss(lab) {
      const h = normDeg(Math.atan2(lab.b, lab.a) * 180 / Math.PI);
      const C = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
      const light = clamp(lab.L, 14, 88);
      const sat = clamp(28 + C * 0.75, 20, 90);
      return `hsl(${h.toFixed(1)}deg ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;
    }

    // ============================================================
    // 3. Demo RGB/Lab samples
    // ============================================================

    const DEMO_ANCHORS = [
      { name: "R", rgbHue: 0,   labHue: 36,  L: 51, C: 74 },
      { name: "Y", rgbHue: 60,  labHue: 94,  L: 86, C: 89 },
      { name: "G", rgbHue: 120, labHue: 142, L: 58, C: 66 },
      { name: "C", rgbHue: 180, labHue: 213, L: 64, C: 49 },
      { name: "B", rgbHue: 240, labHue: 284, L: 34, C: 58 },
      { name: "M", rgbHue: 300, labHue: 330, L: 52, C: 78 }
    ];

    function interpolateCyclicByRgbHue(anchors, hue, key) {
      const h = normDeg(hue);

      for (let i = 0; i < anchors.length; i++) {
        const a = anchors[i];
        const b = anchors[(i + 1) % anchors.length];

        let ah = a.rgbHue;
        let bh = b.rgbHue;
        if (i === anchors.length - 1) bh += 360;

        let hh = h;
        if (hh < ah) hh += 360;

        if (hh >= ah && hh <= bh) {
          const t = (hh - ah) / Math.max(1e-9, bh - ah);

          if (key === "labHue") {
            let ha = a.labHue;
            let hb = b.labHue;
            if (i === anchors.length - 1) hb += 360;
            return normDeg(lerp(ha, hb, t));
          }

          return lerp(a[key], b[key], t);
        }
      }

      return anchors[0][key];
    }

    function demoRgbToLab(r, g, b) {
      const hsl = rgbToHsl(r, g, b);

      const black = { L: 7.8, a: 0.5, b: -1.1 };
      const white = { L: 95.2, a: -0.7, b: -2.0 };

      const labHue = interpolateCyclicByRgbHue(DEMO_ANCHORS, hsl.h, "labHue");
      const waistL = interpolateCyclicByRgbHue(DEMO_ANCHORS, hsl.h, "L");
      const waistC = interpolateCyclicByRgbHue(DEMO_ANCHORS, hsl.h, "C");

      const neutralL = lerp(black.L, white.L, hsl.l);

      let coloredL;

      if (hsl.l <= 0.5) {
        const t = smoothstep(0, 0.5, hsl.l);
        coloredL = lerp(black.L, waistL, t);
      } else {
        const t = smoothstep(0.5, 1, hsl.l);
        coloredL = lerp(waistL, white.L, t);
      }

      let L = lerp(neutralL, coloredL, hsl.s);

      let Cshape;
      if (L <= waistL) {
        const t = smoothstep(black.L, waistL, L);
        Cshape = waistC * Math.pow(t, 0.78);
      } else {
        const t = smoothstep(waistL, white.L, L);
        Cshape = waistC * Math.pow(1 - t, 0.72);
      }

      let C = Cshape * Math.pow(hsl.s, 0.88);

      // 실제 프린터에서 존재할 법한 돌출 영역
      const bumpY = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 60) / 13, 2)) *
                    Math.exp(-0.5 * Math.pow((L - 82) / 9, 2));

      const bumpM = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 300) / 15, 2)) *
                    Math.exp(-0.5 * Math.pow((L - 48) / 10, 2));

      C *= 1 + 0.10 * bumpY + 0.07 * bumpM;

      // out-of-gamut 또는 잉크 한계로 눌린 듯한 측정 영역
      const dentR = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 10) / 16, 2)) *
                    Math.exp(-0.5 * Math.pow((L - 42) / 10, 2));

      const dentG = Math.exp(-0.5 * Math.pow(angleDistanceDeg(hsl.h, 118) / 18, 2)) *
                    Math.exp(-0.5 * Math.pow((L - 68) / 12, 2));

      if (hsl.s > 0.72) {
        C *= 1 - 0.34 * Math.max(dentR, dentG);
      }

      const rad = labHue * Math.PI / 180;

      const grayA = lerp(black.a, white.a, clamp((L - black.L) / (white.L - black.L), 0, 1));
      const grayB = lerp(black.b, white.b, clamp((L - black.L) / (white.L - black.L), 0, 1));

      return {
        L,
        a: grayA + C * Math.cos(rad),
        b: grayB + C * Math.sin(rad)
      };
    }

    function makeDemoSampleText() {
      const lines = [];
      let seed = 1;

      const levels = [0, 6, 12, 20, 30, 42, 55, 70, 85, 100];

      for (const r of levels) {
        for (const g of levels) {
          for (const b of levels) {
            const hsl = rgbToHsl(r, g, b);

            // 너무 조밀한 내부 중복 일부는 제외
            if (hsl.s < 0.12 && ![0, 20, 42, 70, 100].includes(r)) continue;

            const lab = demoRgbToLab(r, g, b);

            const noiseScale = 0.55 + hsl.s * 0.65;

            lab.L += (rand(seed++) - 0.5) * 0.75;
            lab.a += (rand(seed++) - 0.5) * noiseScale;
            lab.b += (rand(seed++) - 0.5) * noiseScale;

            lines.push([
              r.toFixed(2),
              g.toFixed(2),
              b.toFixed(2),
              lab.L.toFixed(3),
              lab.a.toFixed(3),
              lab.b.toFixed(3)
            ].join(" "));
          }
        }
      }

      // 정확한 RGB/CMY 꼭짓점과 그 주변 앵커 강화
      const corners = [
        [100, 0, 0],
        [100, 100, 0],
        [0, 100, 0],
        [0, 100, 100],
        [0, 0, 100],
        [100, 0, 100],
        [0, 0, 0],
        [100, 100, 100]
      ];

      for (const c of corners) {
        for (let i = 0; i < 5; i++) {
          const r = clamp(c[0] + (rand(seed++) - 0.5) * 2.2, 0, 100);
          const g = clamp(c[1] + (rand(seed++) - 0.5) * 2.2, 0, 100);
          const b = clamp(c[2] + (rand(seed++) - 0.5) * 2.2, 0, 100);

          const lab = demoRgbToLab(r, g, b);

          lab.L += (rand(seed++) - 0.5) * 0.55;
          lab.a += (rand(seed++) - 0.5) * 0.55;
          lab.b += (rand(seed++) - 0.5) * 0.55;

          lines.push([
            r.toFixed(2),
            g.toFixed(2),
            b.toFixed(2),
            lab.L.toFixed(3),
            lab.a.toFixed(3),
            lab.b.toFixed(3)
          ].join(" "));
        }
      }

      return lines.join("\n");
    }

    // ============================================================
    // 4. Parse input
    // ============================================================

    function parseSampleText(text) {
      const samples = [];

      const lines = text
        .split(/\r?\n/)
        .map(line => line.trim())
        .filter(line => line && !line.startsWith("#"));

      for (const line of lines) {
        const nums = line
          .replace(/[,\t;]+/g, " ")
          .split(/\s+/)
          .map(Number)
          .filter(Number.isFinite);

        if (nums.length >= 6) {
          samples.push({
            r: clamp(nums[0], 0, 100),
            g: clamp(nums[1], 0, 100),
            b: clamp(nums[2], 0, 100),
            L: nums[3],
            a: nums[4],
            bLab: nums[5]
          });
        }
      }

      return samples;
    }

    // ============================================================
    // 5. Black / White / anti-hook neutral spine
    // ============================================================

    function detectBlackWhite(samples) {
      const scoredBlack = samples.map(s => {
        const rgbSum = s.r + s.g + s.b;
        const C0 = Math.sqrt(s.a * s.a + s.bLab * s.bLab);

        return {
          s,
          score: rgbSum * 0.75 + s.L * 0.25 + C0 * 0.03
        };
      });

      const scoredWhite = samples.map(s => {
        const rgbSum = s.r + s.g + s.b;
        const C0 = Math.sqrt(s.a * s.a + s.bLab * s.bLab);

        return {
          s,
          score: rgbSum * 0.75 + s.L * 1.20 - C0 * 0.04
        };
      });

      scoredBlack.sort((a, b) => a.score - b.score);
      scoredWhite.sort((a, b) => b.score - a.score);

      const blackSet = scoredBlack.slice(0, Math.max(1, Math.min(6, scoredBlack.length)));
      const whiteSet = scoredWhite.slice(0, Math.max(1, Math.min(6, scoredWhite.length)));

      function avg(set) {
        return {
          L: median(set.map(x => x.s.L)),
          a: median(set.map(x => x.s.a)),
          b: median(set.map(x => x.s.bLab))
        };
      }

      return {
        black: avg(blackSet),
        white: avg(whiteSet)
      };
    }

    function buildNeutralSpine(samples, params) {
      const wb = detectBlackWhite(samples);
      const black = wb.black;
      const white = wb.white;

      const Lmin = Math.min(black.L, white.L);
      const Lmax = Math.max(black.L, white.L);

      const grayCandidates = samples.filter(s => {
        const maxRGB = Math.max(s.r, s.g, s.b);
        const minRGB = Math.min(s.r, s.g, s.b);
        const hsl = rgbToHsl(s.r, s.g, s.b);

        return (maxRGB - minRGB <= 8) || hsl.s < 0.08;
      });

      let useGray = grayCandidates;

      if (useGray.length < 8) {
        const sorted = samples
          .map(s => ({
            s,
            C0: Math.sqrt(s.a * s.a + s.bLab * s.bLab)
          }))
          .sort((a, b) => a.C0 - b.C0);

        useGray = sorted.slice(0, Math.max(8, Math.floor(samples.length * 0.12))).map(x => x.s);
      }

      const count = 25;
      let aBins = Array(count).fill(NaN);
      let bBins = Array(count).fill(NaN);
      let lBins = Array.from({ length: count }, (_, i) => Lmin + (i / (count - 1)) * (Lmax - Lmin));

      const bins = Array.from({ length: count }, () => []);

      for (const s of useGray) {
        const idx = clamp(
          Math.round(((s.L - Lmin) / Math.max(1e-9, Lmax - Lmin)) * (count - 1)),
          0,
          count - 1
        );

        bins[idx].push(s);
      }

      for (let i = 0; i < count; i++) {
        if (bins[i].length > 0) {
          aBins[i] = median(bins[i].map(s => s.a));
          bBins[i] = median(bins[i].map(s => s.bLab));
        }
      }

      aBins[0] = black.a;
      bBins[0] = black.b;
      aBins[count - 1] = white.a;
      bBins[count - 1] = white.b;

      aBins = fillMissing1D(aBins);
      bBins = fillMissing1D(bBins);

      // anti-hook spine:
      // 회색축이 끝에서 갈고리처럼 휘지 않도록 black/white endpoint를 고정하고 smoothing
      aBins = smooth1D(aBins, params.graySpineSmooth, true);
      bBins = smooth1D(bBins, params.graySpineSmooth, true);

      // 과도한 국소 휨 제한
      for (let pass = 0; pass < 2; pass++) {
        for (let i = 1; i < count - 1; i++) {
          const predA = (aBins[i - 1] + aBins[i + 1]) * 0.5;
          const predB = (bBins[i - 1] + bBins[i + 1]) * 0.5;

          aBins[i] = lerp(aBins[i], predA, 0.22);
          bBins[i] = lerp(bBins[i], predB, 0.22);
        }

        aBins[0] = black.a;
        bBins[0] = black.b;
        aBins[count - 1] = white.a;
        bBins[count - 1] = white.b;
      }

      return {
        black,
        white,
        Lmin,
        Lmax,
        count,
        lBins,
        aBins,
        bBins
      };
    }

    // ============================================================
    // 6. Detect RGB/CMY vertices
    // ============================================================

    const TARGET_VERTICES = [
      { name: "R", rgbHue: 0,   rgb: { r: 100, g: 0,   b: 0   } },
      { name: "Y", rgbHue: 60,  rgb: { r: 100, g: 100, b: 0   } },
      { name: "G", rgbHue: 120, rgb: { r: 0,   g: 100, b: 0   } },
      { name: "C", rgbHue: 180, rgb: { r: 0,   g: 100, b: 100 } },
      { name: "B", rgbHue: 240, rgb: { r: 0,   g: 0,   b: 100 } },
      { name: "M", rgbHue: 300, rgb: { r: 100, g: 0,   b: 100 } }
    ];

    function detectVertices(samples, spine) {
      const vertices = [];

      const lchSamples = samples.map(s => {
        const lch = labToLch(
          { L: s.L, a: s.a, b: s.bLab },
          spine
        );

        return {
          ...s,
          C: lch.C,
          h: lch.h
        };
      });

      for (const target of TARGET_VERTICES) {
        let best = null;
        let bestScore = -Infinity;

        for (const s of lchSamples) {
          const d = distanceRGB(
            { r: s.r, g: s.g, b: s.b },
            target.rgb
          );

          const hsl = rgbToHsl(s.r, s.g, s.b);

          const score =
            s.C -
            d * 62 +
            hsl.s * 18 -
            Math.abs((s.r + s.g + s.b) - (target.rgb.r + target.rgb.g + target.rgb.b)) * 0.035;

          if (score > bestScore) {
            bestScore = score;
            best = s;
          }
        }

        vertices.push({
          name: target.name,
          rgbHue: target.rgbHue,
          rgb: target.rgb,
          r: best.r,
          g: best.g,
          b: best.b,
          L: best.L,
          a: best.a,
          bLab: best.bLab,
          C: best.C,
          h: best.h
        });
      }

      return vertices;
    }

    function makeRgbHueToLabHueMapper(vertices) {
      const ordered = vertices
        .map(v => ({
          rgbHue: v.rgbHue,
          labHue: v.h
        }))
        .sort((a, b) => a.rgbHue - b.rgbHue);

      const unwrapped = [];

      for (let i = 0; i < ordered.length; i++) {
        let h = ordered[i].labHue;

        if (i > 0) {
          while (h <= unwrapped[i - 1].labHueUnwrapped) {
            h += 360;
          }
        }

        unwrapped.push({
          ...ordered[i],
          labHueUnwrapped: h
        });
      }

      return function map(rgbHue) {
        const h = normDeg(rgbHue);

        for (let i = 0; i < unwrapped.length; i++) {
          const a = unwrapped[i];
          const b = unwrapped[(i + 1) % unwrapped.length];

          let ah = a.rgbHue;
          let bh = b.rgbHue;

          if (i === unwrapped.length - 1) {
            bh += 360;
          }

          let hh = h;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);

            let ha = a.labHueUnwrapped;
            let hb = b.labHueUnwrapped;

            if (i === unwrapped.length - 1) {
              hb += 360;
            }

            return normDeg(lerp(ha, hb, t));
          }
        }

        return normDeg(unwrapped[0].labHueUnwrapped);
      };
    }

    function makeWaistMapper(vertices, key) {
      const ordered = vertices
        .map(v => ({
          rgbHue: v.rgbHue,
          value: key === "L" ? v.L : v.C
        }))
        .sort((a, b) => a.rgbHue - b.rgbHue);

      return function at(rgbHue) {
        const h = normDeg(rgbHue);

        for (let i = 0; i < ordered.length; i++) {
          const a = ordered[i];
          const b = ordered[(i + 1) % ordered.length];

          let ah = a.rgbHue;
          let bh = b.rgbHue;

          if (i === ordered.length - 1) {
            bh += 360;
          }

          let hh = h;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);
            return lerp(a.value, b.value, t);
          }
        }

        return ordered[0].value;
      };
    }

    function makeLabHueWaistMapper(vertices, key) {
      const ordered = vertices
        .map(v => ({
          h: v.h,
          value: key === "L" ? v.L : v.C
        }))
        .sort((a, b) => a.h - b.h);

      return function at(labHue) {
        const h = normDeg(labHue);

        for (let i = 0; i < ordered.length; i++) {
          const a = ordered[i];
          const b = ordered[(i + 1) % ordered.length];

          let ah = a.h;
          let bh = b.h;

          if (i === ordered.length - 1) {
            bh += 360;
          }

          let hh = h;
          if (hh < ah) hh += 360;

          if (hh >= ah && hh <= bh) {
            const t = (hh - ah) / Math.max(1e-9, bh - ah);
            return lerp(a.value, b.value, t);
          }
        }

        return ordered[0].value;
      };
    }

    // ============================================================
    // 7. Build gamut volume model
    // ============================================================

    function baseBiconeC(L, hue, model) {
      const waistC = model.waistCAtLabHue(hue);
      const waistL = model.waistLAtLabHue(hue);

      if (L <= waistL) {
        const t = smoothstep(model.spine.Lmin, waistL, L);
        return waistC * Math.pow(t, 0.78);
      }

      const t = smoothstep(waistL, model.spine.Lmax, L);
      return waistC * Math.pow(1 - t, 0.72);
    }

    function addVolumeAnchor(grid, lNorm, hue, C, strength, radiusL, radiusH) {
      const rows = grid.length;
      const cols = grid[0].length;

      const lc = clamp(Math.round(lNorm * (rows - 1)), 0, rows - 1);
      const hc = Math.floor(normDeg(hue) / 360 * cols) % cols;

      for (let dl = -radiusL; dl <= radiusL; dl++) {
        const li = clamp(lc + dl, 0, rows - 1);

        for (let dh = -radiusH; dh <= radiusH; dh++) {
          const hi = (hc + dh + cols) % cols;

          const ql = dl / Math.max(1, radiusL);
          const qh = dh / Math.max(1, radiusH);
          const w = Math.exp(-0.5 * (ql * ql + qh * qh));

          grid[li][hi] = Math.max(grid[li][hi], C * lerp(0.92, 1.0, strength) * w + grid[li][hi] * (1 - w));
        }
      }
    }

    function buildGamutModel(samples, params) {
      const spine = buildNeutralSpine(samples, params);
      const vertices = detectVertices(samples, spine);

      const rgbHueToLabHue = makeRgbHueToLabHueMapper(vertices);
      const waistLAtRgbHue = makeWaistMapper(vertices, "L");
      const waistCAtRgbHue = makeWaistMapper(vertices, "C");

      const waistLAtLabHue = makeLabHueWaistMapper(vertices, "L");
      const waistCAtLabHue = makeLabHueWaistMapper(vertices, "C");

      const lchSamples = samples.map(s => {
        const lab = { L: s.L, a: s.a, b: s.bLab };
        const lch = labToLch(lab, spine);
        const hsl = rgbToHsl(s.r, s.g, s.b);

        return {
          ...s,
          L: s.L,
          lab,
          C: lch.C,
          h: lch.h,
          rgbHue: hsl.h,
          sat: hsl.s,
          rgbLight: hsl.l
        };
      });

      const modelBase = {
        spine,
        vertices,
        rgbHueToLabHue,
        waistLAtRgbHue,
        waistCAtRgbHue,
        waistLAtLabHue,
        waistCAtLabHue
      };

      const L_BINS = 41;
      const H_BINS = 96;

      const rawBins = Array.from({ length: L_BINS }, () =>
        Array.from({ length: H_BINS }, () => [])
      );

      for (const s of lchSamples) {
        if (s.C < 1.5) continue;

        const lNorm = clamp(
          (s.L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
          0,
          1
        );

        const li = clamp(Math.round(lNorm * (L_BINS - 1)), 0, L_BINS - 1);
        const hi = Math.floor(normDeg(s.h) / 360 * H_BINS) % H_BINS;

        rawBins[li][hi].push(s.C);
      }

      const rawGrid = createGrid(L_BINS, H_BINS, NaN);
      const countGrid = createGrid(L_BINS, H_BINS, 0);
      const fallbackGrid = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        const lNorm = li / (L_BINS - 1);
        const L = spine.Lmin + lNorm * (spine.Lmax - spine.Lmin);

        for (let hi = 0; hi < H_BINS; hi++) {
          const hue = hi / H_BINS * 360;

          fallbackGrid[li][hi] = baseBiconeC(L, hue, modelBase);

          const arr = rawBins[li][hi];
          countGrid[li][hi] = arr.length;

          if (arr.length >= 1) {
            rawGrid[li][hi] = percentile(arr, 0.985);
          }
        }
      }

      let guarded = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const fallback = fallbackGrid[li][hi];
          const raw = Number.isFinite(rawGrid[li][hi]) ? rawGrid[li][hi] : fallback;

          if (!Number.isFinite(rawGrid[li][hi])) {
            guarded[li][hi] = fallback;
            continue;
          }

          const dent = fallback - raw;

          if (dent > fallback * 0.08) {
            guarded[li][hi] = lerp(raw, fallback, params.antiShrink);
          } else {
            guarded[li][hi] = Math.max(raw, fallback * 0.92);
          }
        }
      }

      // volume anchors:
      // 매우 멀리 있는 특정 색상 샘플이 예측 부피를 작게 만들지 않도록
      // 실제 돌출 샘플과 꼭짓점을 주변에 확장 앵커로 추가한다.
      for (const v of vertices) {
        const lNorm = clamp(
          (v.L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
          0,
          1
        );

        addVolumeAnchor(
          guarded,
          lNorm,
          v.h,
          v.C,
          params.volumeAnchor,
          2,
          3
        );
      }

      for (const s of lchSamples) {
        if (s.sat < 0.65) continue;

        const fallback = baseBiconeC(s.L, s.h, modelBase);

        if (s.C >= fallback * 1.02 || s.sat > 0.92) {
          const lNorm = clamp(
            (s.L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
            0,
            1
          );

          addVolumeAnchor(
            guarded,
            lNorm,
            s.h,
            Math.max(s.C, fallback),
            params.volumeAnchor,
            1,
            2
          );
        }
      }

      // outer envelope:
      // 외곽이 국소적으로 오목하게 안쪽으로 끌려 들어가는 것을 막는다.
      const radius = Math.max(1, Math.round(params.edgeProtect));
      let envelope = maxGridNeighborhood(guarded, radius, radius);
      envelope = smoothGridCyclic(envelope, 2);

      const preSmooth = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const g = guarded[li][hi];
          const e = envelope[li][hi];

          const shrink = clamp(
            (e - g) / Math.max(e * 0.34, 1e-9),
            0,
            1
          );

          const localGuard = clamp(params.gamutGuard * shrink, 0, 1);

          preSmooth[li][hi] = lerp(g, e, localGuard);
        }
      }

      let smooth = smoothGridCyclic(preSmooth, params.edgeSmooth);

      const finalGrid = createGrid(L_BINS, H_BINS, 0);

      for (let li = 0; li < L_BINS; li++) {
        for (let hi = 0; hi < H_BINS; hi++) {
          const protectedValue = preSmooth[li][hi] * 0.965;
          const followValue = lerp(smooth[li][hi], preSmooth[li][hi], params.outlineFollow);

          finalGrid[li][hi] = Math.max(
            followValue,
            protectedValue,
            fallbackGrid[li][hi] * 0.90
          );
        }
      }

      for (let hi = 0; hi < H_BINS; hi++) {
        finalGrid[0][hi] = 0;
        finalGrid[L_BINS - 1][hi] = 0;
      }

      const finalModel = {
        ...modelBase,
        lchSamples,
        L_BINS,
        H_BINS,
        rawGrid,
        fallbackGrid,
        preSmoothGrid: preSmooth,
        finalGrid
      };

      finalModel.surfaceCAt = function surfaceCAt(L, hue) {
        const lNorm = clamp(
          (L - spine.Lmin) / Math.max(1e-9, spine.Lmax - spine.Lmin),
          0,
          1
        );

        const base = bilinearCyclic(finalGrid, lNorm, hue);

        // local MQ-like grid blend
        if (params.surfaceSmooth <= 1) {
          return base;
        }

        const rows = finalGrid.length;
        const cols = finalGrid[0].length;

        const y = lNorm * (rows - 1);
        const cy = Math.round(y);
        const ch = Math.round(normDeg(hue) / 360 * cols) % cols;

        const radL = 2;
        const radH = 3;
        const eps = 0.10;

        let sw = 0;
        let sy = 0;

        for (let dl = -radL; dl <= radL; dl++) {
          const li = clamp(cy + dl, 0, rows - 1);

          for (let dh = -radH; dh <= radH; dh++) {
            const hi = (ch + dh + cols) % cols;

            const ql = dl / Math.max(1, radL);
            const qh = dh / Math.max(1, radH);
            const d = Math.sqrt(ql * ql + qh * qh);
            const w = 1 / Math.sqrt(d * d + eps * eps);

            sw += w;
            sy += w * finalGrid[li][hi];
          }
        }

        const mq = sy / Math.max(1e-9, sw);
        const blended = lerp(base, mq, 0.28);

        return Math.max(blended, base * 0.96);
      };

      return finalModel;
    }

    // ============================================================
    // 8. RGB→Lab base volume and residual correction
    // ============================================================

    function baseLabFromRgb(r, g, b, model, params) {
      const hsl = rgbToHsl(r, g, b);

      const labHue = model.rgbHueToLabHue(hsl.h);
      const waistL = model.waistLAtRgbHue(hsl.h);

      const neutralL = lerp(model.spine.black.L, model.spine.white.L, hsl.l);

      let coloredL;

      if (hsl.l <= 0.5) {
        const t = smoothstep(0, 0.5, hsl.l);
        coloredL = lerp(model.spine.black.L, waistL, t);
      } else {
        const t = smoothstep(0.5, 1, hsl.l);
        coloredL = lerp(waistL, model.spine.white.L, t);
      }

      const L = lerp(neutralL, coloredL, hsl.s);
      const Cmax = model.surfaceCAt(L, labHue);
      const C = Cmax * Math.pow(hsl.s, params.radialPower);

      return lchToLab(L, C, labHue, model.spine);
    }

    function radialSafeResidual(sample, base, model) {
      const sLab = {
        L: sample.L,
        a: sample.a,
        b: sample.bLab
      };

      const sampleLch = labToLch(sLab, model.spine);
      const baseLch = labToLch(base, model.spine);
      const hsl = rgbToHsl(sample.r, sample.g, sample.b);

      let dL = sLab.L - base.L;
      let da = sLab.a - base.a;
      let db = sLab.b - base.b;

      // 고채도 외곽에서 샘플이 base보다 훨씬 안쪽이면
      // out-of-gamut 압축 또는 눌림으로 보고 radial negative residual을 약하게만 반영한다.
      if (hsl.s > 0.70 && sampleLch.C < baseLch.C * 0.90) {
        da *= 0.18;
        db *= 0.18;
        dL *= 0.45;
      }

      return {
        r: sample.r,
        g: sample.g,
        b: sample.b,
        dL,
        da,
        db,
        weight: hsl.s > 0.70 ? 0.75 : 1.0
      };
    }

    function buildResidualControls(samples, model, params) {
      return samples.map(s => {
        const base = baseLabFromRgb(s.r, s.g, s.b, model, params);
        return radialSafeResidual(s, base, model);
      });
    }

    function predictResidualRGB(r, g, b, controls) {
      const query = { r, g, b };

      const nearest = controls
        .map(c => ({
          c,
          d: distanceRGB(query, c)
        }))
        .sort((a, b) => a.d - b.d)
        .slice(0, 24);

      let sw = 0;
      let dL = 0;
      let da = 0;
      let db = 0;

      for (const item of nearest) {
        const d = item.d;
        const w = item.c.weight / Math.sqrt(d * d + 0.018 * 0.018);

        sw += w;
        dL += w * item.c.dL;
        da += w * item.c.da;
        db += w * item.c.db;
      }

      if (sw <= 0) {
        return { dL: 0, da: 0, db: 0 };
      }

      return {
        dL: dL / sw,
        da: da / sw,
        db: db / sw
      };
    }

    function guardedFinalLabFromRgb(r, g, b, model, params, residualControls) {
      const base = baseLabFromRgb(r, g, b, model, params);
      const residual = predictResidualRGB(r, g, b, residualControls);

      let lab = {
        L: base.L + residual.dL * params.rbfResidual,
        a: base.a + residual.da * params.rbfResidual,
        b: base.b + residual.db * params.rbfResidual
      };

      lab.L = clamp(lab.L, model.spine.Lmin, model.spine.Lmax);

      const hsl = rgbToHsl(r, g, b);

      let lch = labToLch(lab, model.spine);
      const maxC = model.surfaceCAt(lab.L, lch.h);

      // 외곽이 안쪽으로 끌려들어가는 것을 방지
      if (hsl.s > 0.72) {
        const baseLch = labToLch(base, model.spine);
        const minimumC = baseLch.C * (1 - params.gamutGuard * 0.28);

        if (lch.C < minimumC) {
          lab = lchToLab(lab.L, minimumC, lch.h, model.spine);
          lch = labToLch(lab, model.spine);
        }
      }

      // 표면 밖으로 과도하게 튀는 값은 부드럽게 제한
      if (lch.C > maxC * 1.035) {
        lab = lchToLab(lab.L, maxC * 1.035, lch.h, model.spine);
      }

      return lab;
    }

    // ============================================================
    // 9. Build 16³ CLUT
    // ============================================================

    function buildCLUT(samples, model, params) {
      const residualControls = buildResidualControls(samples, model, params);

      const size = 16;
      const data = [];

      for (let ri = 0; ri < size; ri++) {
        for (let gi = 0; gi < size; gi++) {
          for (let bi = 0; bi < size; bi++) {
            const r = ri / (size - 1) * 100;
            const g = gi / (size - 1) * 100;
            const b = bi / (size - 1) * 100;

            const lab = guardedFinalLabFromRgb(
              r,
              g,
              b,
              model,
              params,
              residualControls
            );

            data.push({
              r,
              g,
              b,
              L: lab.L,
              a: lab.a,
              bLab: lab.b
            });
          }
        }
      }

      return {
        size,
        data,
        residualControls
      };
    }

    // ============================================================
    // 10. Download
    // ============================================================

    function downloadText(filename, text, mime) {
      const blob = new Blob([text], { type: mime });
      const url = URL.createObjectURL(blob);

      const a = document.createElement("a");
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);

      URL.revokeObjectURL(url);
    }

    function clutToCSV(clut) {
      const lines = ["R,G,B,L,a,b"];

      for (const p of clut.data) {
        lines.push([
          p.r.toFixed(6),
          p.g.toFixed(6),
          p.b.toFixed(6),
          p.L.toFixed(6),
          p.a.toFixed(6),
          p.bLab.toFixed(6)
        ].join(","));
      }

      return lines.join("\n");
    }

    function clutToJSON(state) {
      return JSON.stringify({
        description: "Printer ICC A2B RGB→PCS Lab CLUT generated from measured RGB/Lab samples",
        clutSize: state.clut.size,
        rgbRange: [0, 100],
        whitePoint: state.model.spine.white,
        blackPoint: state.model.spine.black,
        vertices: state.model.vertices.map(v => ({
          name: v.name,
          measuredRGB: [
            Number(v.r.toFixed(6)),
            Number(v.g.toFixed(6)),
            Number(v.b.toFixed(6))
          ],
          Lab: [
            Number(v.L.toFixed(6)),
            Number(v.a.toFixed(6)),
            Number(v.bLab.toFixed(6))
          ],
          chroma: Number(v.C.toFixed(6)),
          hue: Number(v.h.toFixed(6))
        })),
        clut: state.clut.data.map(p => ({
          RGB: [
            Number(p.r.toFixed(6)),
            Number(p.g.toFixed(6)),
            Number(p.b.toFixed(6))
          ],
          Lab: [
            Number(p.L.toFixed(6)),
            Number(p.a.toFixed(6)),
            Number(p.bLab.toFixed(6))
          ]
        }))
      }, null, 2);
    }

    // ============================================================
    // 11. UI state
    // ============================================================

    const sampleInput = document.getElementById("sampleInput");
    const info = document.getElementById("info");

    const controls = {
      surfaceSmooth: document.getElementById("surfaceSmooth"),
      edgeSmooth: document.getElementById("edgeSmooth"),
      antiShrink: document.getElementById("antiShrink"),
      gamutGuard: document.getElementById("gamutGuard"),
      edgeProtect: document.getElementById("edgeProtect"),
      outlineFollow: document.getElementById("outlineFollow"),
      rbfResidual: document.getElementById("rbfResidual"),
      radialPower: document.getElementById("radialPower"),
      graySpineSmooth: document.getElementById("graySpineSmooth"),
      volumeAnchor: document.getElementById("volumeAnchor")
    };

    const valueEls = {
      surfaceSmooth: document.getElementById("surfaceSmoothValue"),
      edgeSmooth: document.getElementById("edgeSmoothValue"),
      antiShrink: document.getElementById("antiShrinkValue"),
      gamutGuard: document.getElementById("gamutGuardValue"),
      edgeProtect: document.getElementById("edgeProtectValue"),
      outlineFollow: document.getElementById("outlineFollowValue"),
      rbfResidual: document.getElementById("rbfResidualValue"),
      radialPower: document.getElementById("radialPowerValue"),
      graySpineSmooth: document.getElementById("graySpineSmoothValue"),
      volumeAnchor: document.getElementById("volumeAnchorValue")
    };

    function getParams() {
      const params = {};

      for (const key of Object.keys(controls)) {
        params[key] = Number(controls[key].value);
        valueEls[key].textContent = String(params[key]);
      }

      return params;
    }

    let state = {
      samples: [],
      model: null,
      clut: null
    };

    function generateAll() {
      const params = getParams();
      const samples = parseSampleText(sampleInput.value);

      if (samples.length < 20) {
        info.textContent = "RGB/Lab 샘플이 너무 적습니다. 최소 20개 이상을 권장합니다.";
        return;
      }

      const model = buildGamutModel(samples, params);
      const clut = buildCLUT(samples, model, params);

      state = {
        samples,
        model,
        clut
      };

      const vertexText = model.vertices
        .map(v =>
          `${v.name}: RGB≈[${v.r.toFixed(1)}, ${v.g.toFixed(1)}, ${v.b.toFixed(1)}], ` +
          `Lab=[${v.L.toFixed(2)}, ${v.a.toFixed(2)}, ${v.bLab.toFixed(2)}], ` +
          `C=${v.C.toFixed(2)}, h=${v.h.toFixed(1)}`
        )
        .join("\n");

      const approxVolume = estimateVolume(model);

      info.textContent =
        `입력 RGB/Lab 샘플 수: ${samples.length}\n` +
        `생성 CLUT: ${clut.size}×${clut.size}×${clut.size} = ${clut.data.length}\n` +
        `예측 in-gamut 상대 부피: ${approxVolume.toFixed(2)}\n\n` +
        `Black: L=${model.spine.black.L.toFixed(2)}, a=${model.spine.black.a.toFixed(2)}, b=${model.spine.black.b.toFixed(2)}\n` +
        `White: L=${model.spine.white.L.toFixed(2)}, a=${model.spine.white.a.toFixed(2)}, b=${model.spine.white.b.toFixed(2)}\n\n` +
        `검출 RGB/CMY 꼭짓점:\n${vertexText}\n\n` +
        `적용 알고리즘:\n` +
        `- RGB/CMY 허리라인 polygon 검출\n` +
        `- Black/White anti-hook gray spine 생성\n` +
        `- out-of-gamut 압축/오목 외곽 anti-shrink 복원\n` +
        `- 튀어나온 실제 in-gamut 표면은 volume anchor로 허용\n` +
        `- local MQ-RBF residual로 샘플 유사성 보정\n` +
        `- 최종 RGB 0~100 A2B PCS Lab CLUT 생성`;

      console.clear();
      console.log("Samples:", samples);
      console.log("Model:", model);
      console.log("CLUT:", clut);
    }

    function estimateVolume(model) {
      let sum = 0;

      const L_STEPS = 64;
      const H_STEPS = 144;

      for (let li = 0; li < L_STEPS; li++) {
        const t = li / (L_STEPS - 1);
        const L = model.spine.Lmin + t * (model.spine.Lmax - model.spine.Lmin);

        for (let hi = 0; hi < H_STEPS; hi++) {
          const h = hi / H_STEPS * 360;
          const C = model.surfaceCAt(L, h);
          sum += C * C;
        }
      }

      return sum / (L_STEPS * H_STEPS);
    }

    document.getElementById("loadDemo").addEventListener("click", () => {
      sampleInput.value = makeDemoSampleText();
      generateAll();
    });

    document.getElementById("generate").addEventListener("click", generateAll);

    document.getElementById("downloadCsv").addEventListener("click", () => {
      if (!state.clut) return;
      downloadText("a2b_rgb_lab_clut_16.csv", clutToCSV(state.clut), "text/csv");
    });

    document.getElementById("downloadJson").addEventListener("click", () => {
      if (!state.clut) return;
      downloadText("a2b_rgb_lab_clut_16.json", clutToJSON(state), "application/json");
    });

    document.getElementById("copyJson").addEventListener("click", async () => {
      if (!state.clut) return;
      await navigator.clipboard.writeText(clutToJSON(state));
      info.textContent += "\n\nJSON이 클립보드에 복사되었습니다.";
    });

    for (const key of Object.keys(controls)) {
      controls[key].addEventListener("input", () => {
        if (sampleInput.value.trim()) {
          generateAll();
        }
      });
    }

    // ============================================================
    // 12. 3D rendering
    // ============================================================

    let rotationEnabled = true;
    let rotationTime = 0;
    let lastFrame = performance.now();

    document.getElementById("toggleRotation").addEventListener("click", () => {
      rotationEnabled = !rotationEnabled;
      document.getElementById("toggleRotation").textContent =
        rotationEnabled ? "회전 정지" : "회전 시작";
    });

    function rotateX(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0],
        p[1] * c - p[2] * s,
        p[1] * s + p[2] * c
      ];
    }

    function rotateY(p, a) {
      const c = Math.cos(a);
      const s = Math.sin(a);

      return [
        p[0] * c + p[2] * s,
        p[1],
        -p[0] * s + p[2] * c
      ];
    }

    function transform3D(p, time) {
      let q = p;
      q = rotateX(q, -0.78);
      q = rotateY(q, time * 0.00036);
      return q;
    }

    function project3D(p, cx, cy, scale) {
      const d = 7.5;
      const k = d / (d + p[2]);

      return {
        x: cx + p[0] * scale * k,
        y: cy - p[1] * scale * k,
        z: p[2]
      };
    }

    function labToPoint3D(lab, model) {
      const n = neutralAtL(model.spine, lab.L);

      return [
        (lab.a - n.a) / 52,
        (lab.L - 50) / 28,
        (lab.b - n.b) / 52
      ];
    }

    function drawAxes(cx, cy, scale, time) {
      const axes = [
        { a: [0, -1.75, 0], b: [0, 1.75, 0], color: "rgba(255,255,255,0.24)" },
        { a: [-1.85, 0, 0], b: [1.85, 0, 0], color: "rgba(255,120,120,0.24)" },
        { a: [0, 0, -1.85], b: [0, 0, 1.85], color: "rgba(120,180,255,0.24)" }
      ];

      for (const ax of axes) {
        const p0 = project3D(transform3D(ax.a, time), cx, cy, scale);
        const p1 = project3D(transform3D(ax.b, time), cx, cy, scale);

        ctx.beginPath();
        ctx.moveTo(p0.x, p0.y);
        ctx.lineTo(p1.x, p1.y);
        ctx.strokeStyle = ax.color;
        ctx.lineWidth = 1;
        ctx.stroke();
      }
    }

    function drawPointCloud(labs, model, cx, cy, scale, time, radiusBase) {
      const rendered = labs.map(lab => {
        const p = labToPoint3D(lab, model);
        const q = transform3D(p, time);

        return {
          lab,
          world: q,
          screen: project3D(q, cx, cy, scale)
        };
      });

      rendered.sort((a, b) => a.world[2] - b.world[2]);

      for (const item of rendered) {
        const lch = labToLch(item.lab, model.spine);
        const radius = radiusBase + Math.min(2.0, lch.C / 70);

        ctx.beginPath();
        ctx.arc(item.screen.x, item.screen.y, radius, 0, Math.PI * 2);
        ctx.fillStyle = labCss(item.lab);
        ctx.globalAlpha = 0.82;
        ctx.fill();
        ctx.globalAlpha = 1;
      }
    }

    function drawLineLab(points, model, cx, cy, scale, time, color, width) {
      ctx.beginPath();

      for (let i = 0; i < points.length; i++) {
        const p3 = labToPoint3D(points[i], model);
        const q = transform3D(p3, time);
        const s = project3D(q, cx, cy, scale);

        if (i === 0) ctx.moveTo(s.x, s.y);
        else ctx.lineTo(s.x, s.y);
      }

      ctx.strokeStyle = color;
      ctx.lineWidth = width;
      ctx.stroke();
    }

    function drawGamutSurface(model, cx, cy, scale, time) {
      // final rings
      for (let li = 4; li <= 36; li += 5) {
        const L = model.spine.Lmin + (li / 40) * (model.spine.Lmax - model.spine.Lmin);
        const ring = [];

        for (let hi = 0; hi <= 144; hi++) {
          const h = hi / 144 * 360;
          const C = model.surfaceCAt(L, h);
          ring.push(lchToLab(L, C, h, model.spine));
        }

        drawLineLab(ring, model, cx, cy, scale, time, "rgba(255,255,255,0.36)", 1.0);
      }

      // vertical hue lines
      for (let hi = 0; hi < 12; hi++) {
        const h = hi / 12 * 360;
        const line = [];

        for (let li = 0; li <= 40; li++) {
          const L = model.spine.Lmin + (li / 40) * (model.spine.Lmax - model.spine.Lmin);
          const C = model.surfaceCAt(L, h);
          line.push(lchToLab(L, C, h, model.spine));
        }

        drawLineLab(line, model, cx, cy, scale, time, "rgba(255,255,255,0.24)", 1.0);
      }

      // raw waist ring
      const rawRing = [];

      for (let hi = 0; hi <= 144; hi++) {
        const h = hi / 144 * 360;
        const waistL = model.waistLAtLabHue(h);
        const lNorm = clamp(
          (waistL - model.spine.Lmin) / Math.max(1e-9, model.spine.Lmax - model.spine.Lmin),
          0,
          1
        );

        const C = bilinearCyclic(model.preSmoothGrid, lNorm, h);
        rawRing.push(lchToLab(waistL, C, h, model.spine));
      }

      drawLineLab(rawRing, model, cx, cy, scale, time, "rgba(255,80,65,0.48)", 1.25);

      // polygon waist vertices
      const poly = model.vertices
        .slice()
        .sort((a, b) => a.h - b.h)
        .map(v => ({
          L: v.L,
          a: v.a,
          b: v.bLab
        }));

      poly.push(poly[0]);

      drawLineLab(poly, model, cx, cy, scale, time, "rgba(255,255,255,0.75)", 1.8);

      for (const v of model.vertices) {
        const p3 = labToPoint3D(
          { L: v.L, a: v.a, b: v.bLab },
          model
        );

        const q = transform3D(p3, time);
        const s = project3D(q, cx, cy, scale);

        ctx.beginPath();
        ctx.arc(s.x, s.y, 5.2, 0, Math.PI * 2);
        ctx.fillStyle = "rgba(255,255,255,0.96)";
        ctx.fill();

        ctx.fillStyle = "#fff";
        ctx.font = "12px Arial";
        ctx.fillText(v.name, s.x + 7, s.y - 7);
      }

      for (const item of [
        { label: "W", p: model.spine.white },
        { label: "K", p: model.spine.black }
      ]) {
        const p3 = labToPoint3D(item.p, model);
        const q = transform3D(p3, time);
        const s = project3D(q, cx, cy, scale);

        ctx.beginPath();
        ctx.arc(s.x, s.y, 5.4, 0, Math.PI * 2);
        ctx.fillStyle = "rgba(255,220,90,0.96)";
        ctx.fill();

        ctx.fillStyle = "#ffdc5a";
        ctx.font = "12px Arial";
        ctx.fillText(item.label, s.x + 7, s.y - 7);
      }

      // anti-hook gray spine
      const spineLine = [];

      for (let i = 0; i < model.spine.count; i++) {
        spineLine.push({
          L: model.spine.lBins[i],
          a: model.spine.aBins[i],
          b: model.spine.bBins[i]
        });
      }

      drawLineLab(spineLine, model, cx, cy, scale, time, "rgba(255,220,90,0.72)", 1.7);
    }

    function drawCLUT(clut, model, cx, cy, scale, time) {
      const skip = Math.max(1, Math.floor(clut.data.length / 2200));
      const labs = [];

      for (let i = 0; i < clut.data.length; i += skip) {
        const p = clut.data[i];

        labs.push({
          L: p.L,
          a: p.a,
          b: p.bLab
        });
      }

      drawPointCloud(labs, model, cx, cy, scale, time, 1.45);
    }

    function drawLabels() {
      ctx.fillStyle = "rgba(255,255,255,0.14)";
      ctx.fillRect(canvas.width / 2, 0, 1, canvas.height);

      ctx.fillStyle = "rgba(255,255,255,0.20)";
      ctx.font = "42px Arial";
      ctx.fillText("→", canvas.width / 2 - 16, canvas.height / 2);
    }

    function draw() {
      const now = performance.now();
      const delta = now - lastFrame;
      lastFrame = now;

      if (rotationEnabled) {
        rotationTime += delta;
      }

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      drawLabels();

      if (state.model && state.clut) {
        const leftCx = canvas.width * 0.37;
        const rightCx = canvas.width * 0.75;
        const cy = canvas.height * 0.56;
        const scale = Math.min(canvas.width, canvas.height) * 0.18;

        const sampleLabs = state.samples.map(s => ({
          L: s.L,
          a: s.a,
          b: s.bLab
        }));

        drawAxes(leftCx, cy, scale, rotationTime);
        drawPointCloud(sampleLabs, state.model, leftCx, cy, scale, rotationTime, 2.0);
        drawGamutSurface(state.model, leftCx, cy, scale, rotationTime);

        drawAxes(rightCx, cy, scale, rotationTime);
        drawCLUT(state.clut, state.model, rightCx, cy, scale, rotationTime);
        drawGamutSurface(state.model, rightCx, cy, scale, rotationTime);

        ctx.fillStyle = "#8ecbff";
        ctx.font = "14px Arial";
        ctx.fillText("측색 RGB/Lab 샘플 + 검출 외곽/스파인", canvas.width * 0.27, canvas.height - 34);

        ctx.fillStyle = "#9dffb7";
        ctx.fillText("생성된 16³ RGB→PCS Lab CLUT", canvas.width * 0.66, canvas.height - 34);
      } else {
        ctx.fillStyle = "#ddd";
        ctx.font = "18px Arial";
        ctx.fillText("왼쪽 패널에서 데모 생성 또는 RGB/Lab 입력 후 A2B CLUT 생성을 누르세요.", 460, canvas.height / 2);
      }

      requestAnimationFrame(draw);
    }

    // ============================================================
    // 13. Init
    // ============================================================

    sampleInput.value = makeDemoSampleText();
    generateAll();
    draw();
  </script>
</body>
</html>

haha