VecAI_SVG2PSD / src /grouping.js
yeq6x's picture
Rewrite as Vite + React app with structural SVG color grouping
d7da259
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);
}