From d1ae988024bf93a6f74e9b2db93caa7bdafd9bbc Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 23:38:41 -0300 Subject: [PATCH] Fix Catti reaction mouths rendering left-shifted instead of centered Catti's dual cat-mouth (two mirrored Q-curve paths sharing a center start point) caused extractMouthPositionFromElements to return only the left half's coordinates. Every mouth generator then computed cx=(100+82)/2=91 instead of the true center x=100. The fix scans all Q-curve paths in the mouth section and computes the full horizontal extent. For Catti: startX=82, endX=118, center=100. Single-path mouths are unaffected (the extra-path loop simply doesn't execute). Froggi's dual-path mouth already had symmetric bounds so its result is unchanged. --- src/blobbi/ui/lib/mouth/detection.ts | 51 ++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/blobbi/ui/lib/mouth/detection.ts b/src/blobbi/ui/lib/mouth/detection.ts index 5f6c36fd..502d9674 100644 --- a/src/blobbi/ui/lib/mouth/detection.ts +++ b/src/blobbi/ui/lib/mouth/detection.ts @@ -82,24 +82,45 @@ function detectMouthByMarker(svgText: string): MouthDetectionResult | null { /** * Extract mouth position from mouth SVG elements. + * + * When the mouth section contains multiple Q-curve paths (e.g. Catti's + * dual cat-mouth), computes the full horizontal extent across all paths + * so the detected position represents the whole mouth, not just the + * first half. Single-path mouths are unaffected. */ function extractMouthPositionFromElements(elements: string): MouthPosition | null { - const pathMatch = elements.match(/d="M\s*([\d.]+)\s+([\d.]+)\s*Q\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)"/); - if (pathMatch) { - const strokeWidthMatch = elements.match(/stroke-width="([^"]*)"/); - const strokeWidth = strokeWidthMatch ? strokeWidthMatch[1] : '2.5'; - - return { - startX: parseFloat(pathMatch[1]), - startY: parseFloat(pathMatch[2]), - controlX: parseFloat(pathMatch[3]), - controlY: parseFloat(pathMatch[4]), - endX: parseFloat(pathMatch[5]), - endY: parseFloat(pathMatch[6]), - strokeAttrs: `stroke="#1f2937" stroke-width="${strokeWidth}"`, - }; + const qCurveRegex = /d="M\s*([\d.]+)\s+([\d.]+)\s*Q\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)"/g; + const firstMatch = qCurveRegex.exec(elements); + if (!firstMatch) { + return null; } - return null; + + const strokeWidthMatch = elements.match(/stroke-width="([^"]*)"/); + const strokeWidth = strokeWidthMatch ? strokeWidthMatch[1] : '2.5'; + + // Start from the first path's coordinates + let minX = Math.min(parseFloat(firstMatch[1]), parseFloat(firstMatch[5])); + let maxX = Math.max(parseFloat(firstMatch[1]), parseFloat(firstMatch[5])); + const startY = parseFloat(firstMatch[2]); + const controlY = parseFloat(firstMatch[4]); + const endY = parseFloat(firstMatch[6]); + + // Widen bounds with any additional Q-curve paths in the section + let extra: RegExpExecArray | null; + while ((extra = qCurveRegex.exec(elements)) !== null) { + minX = Math.min(minX, parseFloat(extra[1]), parseFloat(extra[5])); + maxX = Math.max(maxX, parseFloat(extra[1]), parseFloat(extra[5])); + } + + return { + startX: minX, + startY, + controlX: (minX + maxX) / 2, + controlY, + endX: maxX, + endY, + strokeAttrs: `stroke="#1f2937" stroke-width="${strokeWidth}"`, + }; } // ─── Regex-Based Detection ────────────────────────────────────────────────────