UNPKG

cast-avatar

Version:

Dependency-free deterministic SVG avatar generator for browsers.

283 lines (222 loc) 10.3 kB
import { resolvePalette } from '../palettes.js'; import { svgFrame, eyeGroup } from './common.js'; // Deterministic dot pattern used for the stubble hair/beard styles. Walks a // hex-packed grid over the given box and emits a tiny circle wherever `test` // passes, producing a stippled "5 o'clock shadow" look with no randomness. function stipple(fill, opacity, { x0, x1, y0, y1, step, r, test }) { const dots = []; let row = 0; for (let y = y0; y <= y1; y += step) { const offset = row % 2 ? step / 2 : 0; for (let x = x0 + offset; x <= x1; x += step) { if (test(x, y)) { dots.push(`<circle cx="${Math.round(x)}" cy="${Math.round(y)}" r="${r}"/>`); } } row += 1; } return `<g fill="${fill}" opacity="${opacity}">${dots.join('')}</g>`; } function renderEyes(eyes) { if (eyes === 'smile') { return '<path d="M42 58q6 7 12 0" fill="none" stroke="#292524" stroke-width="4" stroke-linecap="round"/><path d="M74 58q6 7 12 0" fill="none" stroke="#292524" stroke-width="4" stroke-linecap="round"/>'; } if (eyes === 'sleepy') { return '<path d="M41 59h15" stroke="#292524" stroke-width="4" stroke-linecap="round"/><path d="M73 59h15" stroke="#292524" stroke-width="4" stroke-linecap="round"/>'; } if (eyes === 'wink') { return '<circle cx="48" cy="58" r="4" fill="#292524"/><path d="M74 58q6 5 12 0" fill="none" stroke="#292524" stroke-width="4" stroke-linecap="round"/>'; } return '<circle cx="48" cy="58" r="4" fill="#292524"/><circle cx="80" cy="58" r="4" fill="#292524"/>'; } function renderMouth(mouth) { if (mouth === 'open') { return '<ellipse cx="64" cy="83" rx="8" ry="8" fill="#7f1d1d"/><path d="M57 89q7 4 14 0" fill="none" stroke="#e8918a" stroke-width="3" stroke-linecap="round"/>'; } if (mouth === 'neutral') { return '<path d="M54 82h20" stroke="#7f1d1d" stroke-width="4" stroke-linecap="round"/>'; } return '<path d="M52 80q12 14 24 0" fill="none" stroke="#7f1d1d" stroke-width="4" stroke-linecap="round"/>'; } function renderHair(hairStyle, fill) { const attrs = `fill="${fill}"`; if (hairStyle === 'none' || hairStyle === 'hijab') { return ''; } if (hairStyle === 'long') { return `<path d="M26 105c-5-29 0-82 38-82s43 53 38 82c-12-8-16-23-16-41H42c0 18-4 33-16 41Z" ${attrs}/>`; } if (hairStyle === 'curly' || hairStyle === 'coily') { const circles = [ [35, 40, 13], [48, 31, 14], [64, 29, 15], [80, 31, 14], [93, 40, 13], [31, 55, 12], [97, 55, 12], [43, 50, 12], [64, 46, 13], [85, 50, 12] ]; const scale = hairStyle === 'coily' ? 0.82 : 1; return circles.map(([cx, cy, r]) => `<circle cx="${cx}" cy="${cy}" r="${Math.round(r * scale)}" ${attrs}/>`).join(''); } if (hairStyle === 'bun') { return `<circle cx="64" cy="21" r="14" ${attrs}/><path d="M31 54c3-22 16-33 33-33s30 11 33 33c-15-12-51-12-66 0Z" ${attrs}/>`; } if (hairStyle === 'afro') { return `<path d="M22 70c-4-34 16-54 42-54s46 20 42 54c-8-20-22-30-42-30s-34 10-42 30Z" ${attrs}/>`; } if (hairStyle === 'mohawk') { return `<path d="M55 54c0-24 3-40 9-40s9 16 9 40Z" ${attrs}/>`; } if (hairStyle === 'spiky') { return `<path d="M32 56 40 28 48 50 56 24 64 52 72 24 80 50 88 28 96 56Z" ${attrs}/>`; } if (hairStyle === 'stubble') { return stipple(fill, 0.6, { x0: 34, x1: 94, y0: 28, y1: 52, step: 5, r: 0.7, test: (x, y) => (x - 64) ** 2 + (y - 64) ** 2 <= 34 ** 2 }); } return `<path d="M30 57c1-23 15-36 34-36s33 13 34 36c-19-13-49-13-68 0Z" ${attrs}/>`; } function renderHeadwear(headwear) { if (headwear === 'beanie') { return '<path d="M31 54c1-22 15-35 33-35s32 13 33 35H31Z" fill="#334155"/><path d="M31 53h66" stroke="#64748b" stroke-width="8" stroke-linecap="round"/>'; } if (headwear === 'cap') { return '<path d="M62 53h46c3 0 4 5 0 7-19 3-33 1-46-3Z" fill="#7f1d1d"/><path d="M33 53c2-21 14-32 31-32s29 11 31 32H33Z" fill="#b91c1c"/><circle cx="64" cy="22" r="3" fill="#7f1d1d"/>'; } if (headwear === 'turban') { return '<path d="M30 55c0-23 16-36 34-36s34 13 34 36H30Z" fill="#0f766e"/><path d="M32 50q32-20 64 0" fill="none" stroke="#14b8a6" stroke-width="4"/><path d="M34 41q30-16 60 0" fill="none" stroke="#14b8a6" stroke-width="4"/><circle cx="34" cy="47" r="5" fill="#14b8a6"/>'; } if (headwear === 'bucket') { return '<path d="M41 50c0-18 10-29 23-29s23 11 23 29H41Z" fill="#a16207"/><path d="M27 49h74c3 0 4 7 0 9-12 3-62 3-74 0-4-2-3-9 0-9Z" fill="#ca8a04"/>'; } if (headwear === 'hijab') { return '<path d="M27 111c-5-23-3-54 6-69 7-13 18-21 31-21s25 8 32 21c9 15 11 46 6 69H27Z" fill="#4f46e5"/>'; } return ''; } function renderAccessories(accessories) { if (accessories === 'glasses') { return '<g fill="none" stroke="#1f2937" stroke-width="3"><circle cx="48" cy="59" r="10"/><circle cx="80" cy="59" r="10"/><path d="M58 59h12"/></g>'; } if (accessories === 'sunglasses') { return '<g fill="#111827"><rect x="36" y="50" width="24" height="17" rx="7"/><rect x="68" y="50" width="24" height="17" rx="7"/><path d="M60 58h8" stroke="#111827" stroke-width="4"/></g>'; } return ''; } function renderFacialHair(facialHair) { if (facialHair === 'stubble') { return stipple('#3f2d20', 0.42, { x0: 40, x1: 88, y0: 70, y1: 96, step: 5, r: 0.6, test: (x, y) => (x - 64) ** 2 + (y - 64) ** 2 <= 34 ** 2 && ((x - 64) / 10) ** 2 + ((y - 82) / 7) ** 2 > 1 && !(Math.abs(x - 64) < 7 && y < 78) }); } if (facialHair === 'mustache') { return '<path d="M51 75c6-5 10-5 13 0 3-5 7-5 13 0-7 6-18 6-26 0Z" fill="#3f2d20" opacity="0.9"/>'; } if (facialHair === 'goatee') { return '<rect x="61" y="78" width="6" height="5" rx="2" fill="#3f2d20" opacity="0.9"/><path d="M55 86c0 9 4 15 9 15s9-6 9-15c-4 4-14 4-18 0Z" fill="#3f2d20" opacity="0.9"/>'; } if (facialHair === 'beard') { return '<path d="M43 80c5 21 37 21 42 0-8 16-34 16-42 0Z" fill="#3f2d20" opacity="0.9"/>'; } if (facialHair === 'fullBeard') { return '<path d="M49 74c5-4 9-4 15 0 6-4 10-4 15 0-7 5-23 5-30 0Z" fill="#3f2d20" opacity="0.9"/><path d="M39 68c-1 24 9 42 25 42s26-18 25-42c-5 20-11 27-25 27s-20-7-25-27Z" fill="#3f2d20" opacity="0.9"/>'; } if (facialHair === 'sideburns') { return '<path d="M41 52c-2 12-1 22 3 30 2-2 3-5 2-10-1-7-1-14-1-20Z" fill="#3f2d20" opacity="0.9"/><path d="M87 52c2 12 1 22-3 30-2-2-3-5-2-10 1-7 1-14 1-20Z" fill="#3f2d20" opacity="0.9"/>'; } return ''; } function faceShapePath(faceShape, skin) { if (faceShape === 'oval') { return `<ellipse cx="64" cy="64" rx="33" ry="39" fill="${skin}"/>`; } if (faceShape === 'soft') { return `<rect x="31" y="29" width="66" height="72" rx="31" fill="${skin}"/>`; } return `<circle cx="64" cy="64" r="36" fill="${skin}"/>`; } function renderEyebrows(eyebrows, color) { const attrs = `fill="none" stroke="${color}" stroke-width="3" stroke-linecap="round"`; if (eyebrows === 'raised') { return `<path d="M41 50q7-5 14 0" ${attrs}/><path d="M73 50q7-5 14 0" ${attrs}/>`; } if (eyebrows === 'angled') { return `<path d="M41 49l14-3" ${attrs}/><path d="M87 49l-14-3" ${attrs}/>`; } return `<path d="M41 49h14" ${attrs}/><path d="M73 49h14" ${attrs}/>`; } function renderNose(nose) { const attrs = 'fill="none" stroke="#9a5b38" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"'; if (nose === 'button') { return `<path d="M60 71q4 5 8 0" ${attrs}/>`; } if (nose === 'wide') { return `<path d="M64 62l-7 14h14" ${attrs}/>`; } return `<path d="M64 63l-5 13h9" ${attrs}/>`; } function renderFreckles(freckles) { if (freckles !== 'light' && freckles !== 'heavy') { return ''; } return stipple('#8a5a3b', 0.5, { x0: 42, x1: 86, y0: 64, y1: 76, step: freckles === 'heavy' ? 5 : 7, r: 1.1, test: (x, y) => (x - 64) ** 2 + (y - 64) ** 2 <= 33 ** 2 && Math.abs(x - 64) > 8 }); } function renderBlush(blush) { if (blush !== 'soft') { return ''; } return '<ellipse cx="46" cy="68" rx="6" ry="4" fill="#fb7185" opacity="0.35"/><ellipse cx="82" cy="68" rx="6" ry="4" fill="#fb7185" opacity="0.35"/>'; } function renderEarrings(earrings) { if (earrings === 'studs') { return '<circle cx="30" cy="74" r="3" fill="#fde047"/><circle cx="98" cy="74" r="3" fill="#fde047"/>'; } if (earrings === 'hoops') { return '<circle cx="30" cy="77" r="5" fill="none" stroke="#fde047" stroke-width="2.5"/><circle cx="98" cy="77" r="5" fill="none" stroke="#fde047" stroke-width="2.5"/>'; } return ''; } function renderCollar(gender) { const attrs = 'fill="none" stroke="#00000030" stroke-width="3" stroke-linecap="round"'; if (gender === 'masculine') { return `<path d="M56 92l8 11 8-11" ${attrs}/>`; } if (gender === 'feminine') { return `<path d="M54 94q10 12 20 0" ${attrs}/>`; } return `<path d="M55 96h18" ${attrs}/>`; } export function renderCartoonAvatar(config) { const traits = config.traits; const palette = resolvePalette(config.palette); const skin = palette.skinTones[traits.skinTone] || palette.skinTones.medium; const hairFill = palette.hairColors[traits.hairColor] || palette.hairColors.brown; const browColor = palette.hairColors[traits.hairColor] || '#3f2d20'; const clothing = config.traits.clothing || config.clothing || '#64748b'; const headwear = traits.headwear; const hair = headwear === 'none' ? renderHair(traits.hairStyle, hairFill) : ''; const backLayer = headwear === 'hijab' ? renderHeadwear(headwear) : ''; const frontLayer = headwear === 'hijab' ? '' : renderHeadwear(headwear); return svgFrame(config, [ `<path d="M24 128c5-24 21-38 40-38s35 14 40 38H24Z" fill="${clothing}"/>`, renderCollar(traits.gender), backLayer, faceShapePath(traits.faceShape, skin), renderEarrings(traits.earrings), hair, frontLayer, renderEyebrows(traits.eyebrows, browColor), eyeGroup(config, renderEyes(traits.eyes)), renderNose(traits.nose), renderFreckles(traits.freckles), renderBlush(traits.blush), renderMouth(traits.mouth), renderFacialHair(traits.facialHair), renderAccessories(traits.accessories) ].join('')); }