import { rgbToLab, labDist } from './colorUtils.js'; /** * CIEDE2000 color difference. * More perceptually uniform than Euclidean Lab distance. */ export function ciede2000(lab1, lab2) { const [L1, a1, b1] = lab1; const [L2, a2, b2] = lab2; const C1 = Math.sqrt(a1 * a1 + b1 * b1); const C2 = Math.sqrt(a2 * a2 + b2 * b2); const Cmean = (C1 + C2) / 2; const Cmean7 = Cmean ** 7; const G = 0.5 * (1 - Math.sqrt(Cmean7 / (Cmean7 + 6103515625))); // 25^7 const a1p = a1 * (1 + G); const a2p = a2 * (1 + G); const C1p = Math.sqrt(a1p * a1p + b1 * b1); const C2p = Math.sqrt(a2p * a2p + b2 * b2); let h1p = Math.atan2(b1, a1p) * 180 / Math.PI; if (h1p < 0) h1p += 360; let h2p = Math.atan2(b2, a2p) * 180 / Math.PI; if (h2p < 0) h2p += 360; const dLp = L2 - L1; const dCp = C2p - C1p; let dhp; if (C1p * C2p === 0) { dhp = 0; } else if (Math.abs(h2p - h1p) <= 180) { dhp = h2p - h1p; } else if (h2p - h1p > 180) { dhp = h2p - h1p - 360; } else { dhp = h2p - h1p + 360; } const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp * Math.PI / 360); const Lpmean = (L1 + L2) / 2; const Cpmean = (C1p + C2p) / 2; let Hpmean; if (C1p * C2p === 0) { Hpmean = h1p + h2p; } else if (Math.abs(h1p - h2p) <= 180) { Hpmean = (h1p + h2p) / 2; } else if (h1p + h2p < 360) { Hpmean = (h1p + h2p + 360) / 2; } else { Hpmean = (h1p + h2p - 360) / 2; } const T = 1 - 0.17 * Math.cos((Hpmean - 30) * Math.PI / 180) + 0.24 * Math.cos(2 * Hpmean * Math.PI / 180) + 0.32 * Math.cos((3 * Hpmean + 6) * Math.PI / 180) - 0.20 * Math.cos((4 * Hpmean - 63) * Math.PI / 180); const SL = 1 + 0.015 * (Lpmean - 50) ** 2 / Math.sqrt(20 + (Lpmean - 50) ** 2); const SC = 1 + 0.045 * Cpmean; const SH = 1 + 0.015 * Cpmean * T; const Cpmean7 = Cpmean ** 7; const RT = -2 * Math.sqrt(Cpmean7 / (Cpmean7 + 6103515625)) * Math.sin(60 * Math.exp(-(((Hpmean - 275) / 25) ** 2)) * Math.PI / 180); return Math.sqrt( (dLp / SL) ** 2 + (dCp / SC) ** 2 + (dHp / SH) ** 2 + RT * (dCp / SC) * (dHp / SH) ); } /** * Assign stroke colors to anchor groups using nearest-anchor distance. * * @param {{ anchors: {hex,r,g,b}[], strokes: {hex,r,g,b}[] }} structure * @param {{ hex: string, r: number, g: number, b: number }[]} allColors * @param {{ distFn?: 'lab' | 'ciede2000' }} options * @returns {number[][]} groups - array of color index arrays (anchor order) */ export function buildStructuralColorGroups(structure, allColors, options = {}) { const { anchors, strokes } = structure; const distFn = options.distFn || 'ciede2000'; // Build color index map const colorIndex = new Map(); allColors.forEach((c, i) => colorIndex.set(c.hex, i)); // Compute anchor Labs const anchorLabs = anchors.map(a => rgbToLab(a.r, a.g, a.b)); // Initialize groups with anchor colors const groups = anchors.map(a => { const idx = colorIndex.get(a.hex); return idx !== undefined ? [idx] : []; }); // Distance function const dist = distFn === 'ciede2000' ? ciede2000 : labDist; // Assign each stroke to nearest anchor for (const s of strokes) { const idx = colorIndex.get(s.hex); if (idx === undefined) continue; const sLab = rgbToLab(s.r, s.g, s.b); let bestGi = 0, bestDist = Infinity; for (let gi = 0; gi < anchorLabs.length; gi++) { const d = dist(sLab, anchorLabs[gi]); if (d < bestDist) { bestDist = d; bestGi = gi; } } groups[bestGi].push(idx); } return groups.filter(g => g.length > 0); }