import { getCharacterComponents } from "./character-components.js"; /** * Compose a complete SVG document from a body shape, eye style, and color. * * Ports the transform math from the Swift AvatarCompositor: * - Body transform: aspect-fit scale + center translation from body viewBox to output size * - Eye transform: remap from eye sourceViewBox to body viewBox (via faceCenter alignment), * then compose with the body-to-output transform * * @param bodyShapeId - ID of the body shape (e.g. "blob") * @param eyeStyleId - ID of the eye style (e.g. "grumpy") * @param colorId - ID of the body color (e.g. "green") * @param size - Output SVG width/height in px (default 512) * @returns A complete SVG document string */ export function composeSvg( bodyShapeId: string, eyeStyleId: string, colorId: string, size: number = 512, ): string { const components = getCharacterComponents(); const bodyShape = components.bodyShapes.find((b) => b.id === bodyShapeId); if (!bodyShape) { throw new Error( `Unknown body shape: "${bodyShapeId}". Valid IDs: ${components.bodyShapes.map((b) => b.id).join(", ")}`, ); } const eyeStyle = components.eyeStyles.find((e) => e.id === eyeStyleId); if (!eyeStyle) { throw new Error( `Unknown eye style: "${eyeStyleId}". Valid IDs: ${components.eyeStyles.map((e) => e.id).join(", ")}`, ); } const color = components.colors.find((c) => c.id === colorId); if (!color) { throw new Error( `Unknown color: "${colorId}". Valid IDs: ${components.colors.map((c) => c.id).join(", ")}`, ); } // Resolve face center: check overrides first, fall back to body shape default const override = components.faceCenterOverrides.find( (o) => o.bodyShape === bodyShapeId && o.eyeStyle === eyeStyleId, ); const faceCenter = override ? override.faceCenter : bodyShape.faceCenter; // Body transform: aspect-fit scale from body viewBox to output size const bodyVB = bodyShape.viewBox; const bodyScale = Math.min(size / bodyVB.width, size / bodyVB.height); const bodyTx = (size - bodyVB.width * bodyScale) / 2; const bodyTy = (size - bodyVB.height * bodyScale) / 2; // Eye remap transform: map eye sourceViewBox -> body viewBox coordinates, // aligning eyeCenter to faceCenter with aspect-fit scaling const eyeVB = eyeStyle.sourceViewBox; const remapScale = Math.min( bodyVB.width / eyeVB.width, bodyVB.height / eyeVB.height, ); const remapTx = faceCenter.x - eyeStyle.eyeCenter.x * remapScale; const remapTy = faceCenter.y - eyeStyle.eyeCenter.y * remapScale; // Compose remap with body transform (both are scale+translate, no rotation): // composedScale = bodyScale * remapScale // composedTx = bodyScale * remapTx + bodyTx // composedTy = bodyScale * remapTy + bodyTy const composedScale = bodyScale * remapScale; const composedTx = bodyScale * remapTx + bodyTx; const composedTy = bodyScale * remapTy + bodyTy; const bodyTransform = `matrix(${bodyScale},0,0,${bodyScale},${bodyTx},${bodyTy})`; const eyeTransform = `matrix(${composedScale},0,0,${composedScale},${composedTx},${composedTy})`; // Build SVG paths const bodyPath = ``; const eyePaths = eyeStyle.paths .map( (p) => ``, ) .join(""); return `${bodyPath}${eyePaths}`; }