From 71613064ead60444e4669f663d53830c01173a9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 13:13:28 +0000 Subject: [PATCH] Align Oo avatar eye spacing with evoo-logo overlap ratio Derive small-eye placement from evoo-logo.svg center spacing (~0.94 of r_big + r_small) for the shared rig, EvooMascot brand lock, and face crops. Co-authored-by: cynarAI --- apps/mobile/components/oo-figure.tsx | 3 +- packages/core/src/oo-avatar.test.ts | 9 ++ packages/core/src/oo-avatar.ts | 39 ++++- packages/core/src/oo-rig.ts | 8 +- packages/ui-web/CHARACTER.md | 4 +- .../src/evoo-character/EvooCharacterSvg.tsx | 143 +++++++++++++----- .../src/evoo-character/brand-lock.test.ts | 15 ++ .../ui-web/src/evoo-character/brand-lock.ts | 30 +++- packages/ui-web/src/oo-figure.tsx | 3 +- 9 files changed, 209 insertions(+), 45 deletions(-) diff --git a/apps/mobile/components/oo-figure.tsx b/apps/mobile/components/oo-figure.tsx index 1ff8dcb..8739305 100644 --- a/apps/mobile/components/oo-figure.tsx +++ b/apps/mobile/components/oo-figure.tsx @@ -3,6 +3,7 @@ import { buildOoRig, createOoLife, OO_LIST_MOMENTS, + OO_RIG_FACE_VIEWBOX, OO_RIG_INSET, OO_RIG_SIZE, OO_STILL_TIME, @@ -26,7 +27,7 @@ import Svg, { } from "react-native-svg"; const VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`; -const FACE_VIEWBOX = "47 62 112 112"; +const FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX; /** Native frames redraw the SVG tree, so they run at half the display rate. */ const FRAME_SECONDS = 1 / 30; /** Above this size an Oo always moves; smaller ones only for list moments. */ diff --git a/packages/core/src/oo-avatar.test.ts b/packages/core/src/oo-avatar.test.ts index b0f0d86..cb00a0b 100644 --- a/packages/core/src/oo-avatar.test.ts +++ b/packages/core/src/oo-avatar.test.ts @@ -7,6 +7,7 @@ import { normalizeOoLook, OO_AVATAR_MAX_LENGTH, OO_EYE_ASYMMETRY_MIN_RATIO, + OO_EYE_CENTER_SPACING_RATIO, OO_RIG_EYES, type OoAvatar, ooAvatarColor, @@ -131,4 +132,12 @@ describe("oo rig brand lock", () => { expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO); expect(ooLensRadius("big")).toBeGreaterThan(OO_RIG_EYES.big.r); }); + + it("matches evoo-logo eye spacing (centers close / slight overlap)", () => { + const { big, small } = OO_RIG_EYES; + const centerDistance = small.cx - big.cx; + const sumRadii = big.r + small.r; + expect(centerDistance / sumRadii).toBeCloseTo(OO_EYE_CENTER_SPACING_RATIO, 3); + expect(centerDistance / sumRadii).toBeLessThan(1); + }); }); diff --git a/packages/core/src/oo-avatar.ts b/packages/core/src/oo-avatar.ts index a4ec9cf..14c352c 100644 --- a/packages/core/src/oo-avatar.ts +++ b/packages/core/src/oo-avatar.ts @@ -257,11 +257,46 @@ export function ooAvatarColor(value: string): string { * pair never moves when the body changes. The small eye is the big one divided by φ. */ export const OO_GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; + +/** Sclera center distance ÷ sum of radii in `apps/web/public/evoo-logo.svg` (slight overlap). */ +export const OO_EYE_CENTER_SPACING_RATIO = 55 / (34 + 24.5); + +/** Small-eye vertical drop as a fraction of the big sclera radius (same logo). */ +export const OO_EYE_SMALL_VERTICAL_OFFSET_RATIO = (60 - 52) / 34; + +/** Place the small eye from the big anchor using the logo spacing ratio. */ +export function ooEyeCentersFromLogoRatio( + big: Readonly<{ cx: number; cy: number; r: number }>, + smallR: number, +): { cx: number; cy: number } { + const centerDistance = OO_EYE_CENTER_SPACING_RATIO * (big.r + smallR); + return { + cx: big.cx + centerDistance, + cy: big.cy + OO_EYE_SMALL_VERTICAL_OFFSET_RATIO * big.r, + }; +} + +const OO_RIG_EYE_BIG = { cx: 80, cy: 108, r: 21 } as const; +const OO_RIG_EYE_SMALL_R = OO_RIG_EYE_BIG.r / OO_GOLDEN_RATIO; +const OO_RIG_EYE_SMALL_CENTER = ooEyeCentersFromLogoRatio(OO_RIG_EYE_BIG, OO_RIG_EYE_SMALL_R); + export const OO_RIG_EYES = { - big: { cx: 80, cy: 108, r: 21 }, - small: { cx: 126, cy: 111, r: 21 / OO_GOLDEN_RATIO }, + big: OO_RIG_EYE_BIG, + small: { ...OO_RIG_EYE_SMALL_CENTER, r: OO_RIG_EYE_SMALL_R }, } as const; +const OO_RIG_FACE_PAD = { side: 12, above: 25, below: 30 } as const; + +/** Face crop for eyewear / eye-style pickers; keeps both sclerae in frame at every size. */ +export const OO_RIG_FACE_VIEWBOX = (() => { + const { big, small } = OO_RIG_EYES; + const x = Math.floor(Math.min(big.cx - big.r, small.cx - small.r) - OO_RIG_FACE_PAD.side); + const y = Math.floor(big.cy - big.r - OO_RIG_FACE_PAD.above); + const right = Math.ceil(Math.max(big.cx + big.r, small.cx + small.r) + OO_RIG_FACE_PAD.side); + const bottom = Math.ceil(Math.max(big.cy + big.r, small.cy + small.r) + OO_RIG_FACE_PAD.below); + return `${x} ${y} ${right - x} ${bottom - y}`; +})(); + /** Minimum big÷small ratio for eyes, lenses and closed-eye arcs (Oo trademark). */ export const OO_EYE_ASYMMETRY_MIN_RATIO = 1.45; diff --git a/packages/core/src/oo-rig.ts b/packages/core/src/oo-rig.ts index 159a2b6..c883be3 100644 --- a/packages/core/src/oo-rig.ts +++ b/packages/core/src/oo-rig.ts @@ -683,9 +683,9 @@ function cheeks(avatar: OoAvatar): OoShape[] { [58, 129, 1.6], [64, 133, 1.6], [69, 128, 1.6], - [141, 131, 1.4], - [146, 135, 1.4], - [150, 130, 1.4], + [127, 131, 1.4], + [132, 135, 1.4], + [136, 130, 1.4], ]; return dots.map(([cx, cy, r]) => ({ type: "circle", @@ -699,7 +699,7 @@ function cheeks(avatar: OoAvatar): OoShape[] { const fill = cheek === "peach" ? "#FFB08A" : PINK; return [ { type: "ellipse", cx: 62, cy: 131, rx: 9.5, ry: 5.5, fill, opacity: 0.55 }, - { type: "ellipse", cx: 144, cy: 133, rx: 7.5, ry: 4.5, fill, opacity: 0.55 }, + { type: "ellipse", cx: 130, cy: 133, rx: 7.5, ry: 4.5, fill, opacity: 0.55 }, ]; } diff --git a/packages/ui-web/CHARACTER.md b/packages/ui-web/CHARACTER.md index fcd6bd5..f3ebeba 100644 --- a/packages/ui-web/CHARACTER.md +++ b/packages/ui-web/CHARACTER.md @@ -10,6 +10,7 @@ All Oo's — `EvooMascot`, `BotAvatar`, Wordmark, every pose — must stay insta 1. **Consistent size asymmetry** between the two eyes (big O vs small o). Never symmetrize. Never equal radii. 2. **Kulleraugen** — big, round, glossy googly/cute sclera with dark pupils and catchlights. +3. **Logo eye spacing** — sclera centers follow `evoo-logo.svg` (`OO_EYE_CENTER_SPACING_RATIO` ≈ 0.94: close together, slight overlap). Personality (mouth, hands, accessories, poses) builds **on top** of this signature. Never replace with Grok-bot-style emoji faces. @@ -18,8 +19,9 @@ Personality (mouth, hands, accessories, poses) builds **on top** of this signatu | `EVOO_EYE_BIG_R` | 30 | Capital O sclera | | `EVOO_EYE_SMALL_R` | 18.54 | Lowercase o sclera (~φ⁻¹ of big) | | `EVOO_EYE_ASYMMETRY_MIN_RATIO` | 1.45 | CI guard — catches accidental symmetrization | +| `OO_EYE_CENTER_SPACING_RATIO` | ~0.940 | Center distance ÷ (r_big + r_small) from `evoo-logo.svg` | -Enforced in `evoo-character/brand-lock.ts` + `brand-lock.test.ts`. Poses may adjust `eyeOpen` (squint), `gaze`, brows — **not** eye size parity. +Bot avatars use the same ratio via `OO_RIG_EYES` in `packages/core/src/oo-avatar.ts`. Enforced in `evoo-character/brand-lock.ts`, `oo-avatar.test.ts`, and `brand-lock.test.ts`. Poses may adjust `eyeOpen` (squint), `gaze`, brows — **not** eye size parity or spacing drift. --- diff --git a/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx b/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx index bf6d142..c3d62f6 100644 --- a/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx +++ b/packages/ui-web/src/evoo-character/EvooCharacterSvg.tsx @@ -1,8 +1,16 @@ import { + EVOO_EYE_BIG_CX, + EVOO_EYE_BIG_CY, EVOO_EYE_BIG_R, + EVOO_EYE_SMALL_CX, + EVOO_EYE_SMALL_CY, EVOO_EYE_SMALL_R, EVOO_OO_TRADEMARK, + EVOO_PUPIL_BIG_CX, + EVOO_PUPIL_BIG_CY, EVOO_PUPIL_BIG_R, + EVOO_PUPIL_SMALL_CX, + EVOO_PUPIL_SMALL_CY, EVOO_PUPIL_SMALL_R, } from "./brand-lock.js"; import { EVOO_CHARACTER_SPRING, EVOO_POSE_TRANSITION_S } from "./motion-tokens.js"; @@ -11,9 +19,17 @@ import { POSE_DEFINITIONS } from "./poses.js"; import type { EvooMouthStyle, EvooPose } from "./types.js"; export { + EVOO_EYE_BIG_CX, + EVOO_EYE_BIG_CY, EVOO_EYE_BIG_R, + EVOO_EYE_SMALL_CX, + EVOO_EYE_SMALL_CY, EVOO_EYE_SMALL_R, + EVOO_PUPIL_BIG_CX, + EVOO_PUPIL_BIG_CY, EVOO_PUPIL_BIG_R, + EVOO_PUPIL_SMALL_CX, + EVOO_PUPIL_SMALL_CY, EVOO_PUPIL_SMALL_R, } from "./brand-lock.js"; @@ -66,8 +82,8 @@ function Brow({ const worried = raise === "worried"; if (side === "left" && !showLeft) return null; if (side === "right" && !showRight) return null; - const cx = side === "left" ? 44 : 88; - const cy = side === "left" ? 28 : 32; + const cx = side === "left" ? EVOO_EYE_BIG_CX : EVOO_EYE_SMALL_CX; + const cy = side === "left" ? 28 : EVOO_EYE_SMALL_CY - 28; const tilt = side === "left" ? (worried ? 8 : -10) : worried ? -8 : 10; return ( - - - + + + ); case "clipboard": @@ -580,7 +616,7 @@ export function EvooCharacterSvg({ className="evoo-eye-big" data-evoo-brand={EVOO_OO_TRADEMARK} style={{ - transformOrigin: "44px 54px", + transformOrigin: `${EVOO_EYE_BIG_CX}px ${EVOO_EYE_BIG_CY}px`, animation: def.bodyAnimation === "evoo-executive-nod" ? "evoo-executive-nod 2s ease-in-out infinite" @@ -588,8 +624,8 @@ export function EvooCharacterSvg({ }} > - + - - - + + + {(def.eyeSparkle || pose === "success") && ( @@ -656,7 +711,7 @@ export function EvooCharacterSvg({ className="evoo-eye-small" data-evoo-brand={EVOO_OO_TRADEMARK} style={{ - transformOrigin: "88px 60px", + transformOrigin: `${EVOO_EYE_SMALL_CX}px ${EVOO_EYE_SMALL_CY}px`, animation: def.bodyAnimation === "evoo-executive-nod" ? "evoo-executive-nod 2s ease-in-out infinite" @@ -664,8 +719,8 @@ export function EvooCharacterSvg({ }} > - - - + + + )} diff --git a/packages/ui-web/src/evoo-character/brand-lock.test.ts b/packages/ui-web/src/evoo-character/brand-lock.test.ts index 581a709..e3e0ea7 100644 --- a/packages/ui-web/src/evoo-character/brand-lock.test.ts +++ b/packages/ui-web/src/evoo-character/brand-lock.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { assertEvooEyeBrandLock, EVOO_EYE_ASYMMETRY_MIN_RATIO, + EVOO_EYE_BIG_CX, + EVOO_EYE_BIG_CY, EVOO_EYE_BIG_R, + EVOO_EYE_CENTER_SPACING_RATIO, + EVOO_EYE_SMALL_CX, + EVOO_EYE_SMALL_CY, EVOO_EYE_SMALL_R, EVOO_OO_TRADEMARK, EVOO_PUPIL_BIG_R, @@ -25,6 +30,16 @@ describe("evOo Oo brand lock", () => { expect(EVOO_PUPIL_BIG_R / EVOO_PUPIL_SMALL_R).toBeCloseTo(EVOO_EYE_BIG_R / EVOO_EYE_SMALL_R, 1); }); + it("places mascot eyes at the logo spacing ratio", () => { + const centerDistance = EVOO_EYE_SMALL_CX - EVOO_EYE_BIG_CX; + const sumRadii = EVOO_EYE_BIG_R + EVOO_EYE_SMALL_R; + expect(centerDistance / sumRadii).toBeCloseTo(EVOO_EYE_CENTER_SPACING_RATIO, 3); + expect(EVOO_EYE_SMALL_CY).toBeCloseTo( + EVOO_EYE_BIG_CY + ((60 - 52) / 34) * EVOO_EYE_BIG_R, + 1, + ); + }); + it("rejects symmetrized eye radii", () => { expect(() => assertEvooEyeBrandLock(20, 20)).toThrow(/asymmetric Kulleraugen/); expect(() => assertEvooEyeBrandLock(20, 19)).toThrow(/asymmetry ratio/); diff --git a/packages/ui-web/src/evoo-character/brand-lock.ts b/packages/ui-web/src/evoo-character/brand-lock.ts index 5afb17b..1618c0d 100644 --- a/packages/ui-web/src/evoo-character/brand-lock.ts +++ b/packages/ui-web/src/evoo-character/brand-lock.ts @@ -1,4 +1,8 @@ -import { OO_EYE_ASYMMETRY_MIN_RATIO } from "@rakazo/core"; +import { + OO_EYE_ASYMMETRY_MIN_RATIO, + OO_EYE_CENTER_SPACING_RATIO, + ooEyeCentersFromLogoRatio, +} from "@rakazo/core"; /** * evOo brand lock — non-negotiable (Ingo). @@ -8,6 +12,7 @@ import { OO_EYE_ASYMMETRY_MIN_RATIO } from "@rakazo/core"; * All mascot surfaces (EvooMascot, BotAvatar, Wordmark) MUST keep: * 1. Consistent size asymmetry between the two eyes (big O vs small o). * 2. Kulleraugen — big, round, glossy googly/cute sclera + pupils. + * 3. Eye center spacing matched to `evoo-logo.svg` (close / slight overlap). * * Personality (mouth, hands, accessories, poses) builds ON TOP of this signature. * Never symmetrize the eyes. Never use Grok-style emoji faces. Never equal eye radii. @@ -22,16 +27,39 @@ export const EVOO_EYE_BIG_R = 30; /** Small eye (lowercase o) — ~φ⁻¹ of big eye; trademark size gap. */ export const EVOO_EYE_SMALL_R = 18.54; +/** Big sclera anchor in the mascot viewBox (capital O). */ +export const EVOO_EYE_BIG_CX = 44; +export const EVOO_EYE_BIG_CY = 54; + +const _mascotSmallCenter = ooEyeCentersFromLogoRatio( + { cx: EVOO_EYE_BIG_CX, cy: EVOO_EYE_BIG_CY, r: EVOO_EYE_BIG_R }, + EVOO_EYE_SMALL_R, +); + +/** Small sclera anchor derived from the logo spacing ratio. */ +export const EVOO_EYE_SMALL_CX = _mascotSmallCenter.cx; +export const EVOO_EYE_SMALL_CY = _mascotSmallCenter.cy; + +/** Logo-matched center distance ÷ sum of sclera radii (re-export for docs/tests). */ +export const EVOO_EYE_CENTER_SPACING_RATIO = OO_EYE_CENTER_SPACING_RATIO; + /** Pupil radii scale with sclera; golden-ratio proportion preserved. */ export const EVOO_PUPIL_BIG_R = 14.2; export const EVOO_PUPIL_SMALL_R = EVOO_PUPIL_BIG_R * 0.618034; +/** Pupil anchors track sclera; offsets unchanged from the original mascot art. */ +export const EVOO_PUPIL_BIG_CX = EVOO_EYE_BIG_CX + 4; +export const EVOO_PUPIL_BIG_CY = EVOO_EYE_BIG_CY + 3; +export const EVOO_PUPIL_SMALL_CX = EVOO_EYE_SMALL_CX + 0.37; +export const EVOO_PUPIL_SMALL_CY = EVOO_EYE_SMALL_CY + 0.94; + /** Minimum big÷small ratio — catches accidental symmetrization in tests/CI. */ export const EVOO_EYE_ASYMMETRY_MIN_RATIO = OO_EYE_ASYMMETRY_MIN_RATIO; export const EVOO_BRAND_LOCK_RULES = [ "Two eyes only: one big O, one small o — never equal radii.", "Kulleraugen: round white sclera, dark glossy pupil, primary + secondary catchlights.", + "Eye spacing follows evoo-logo.svg — centers closer than r_big + r_small (slight overlap).", "Poses may change gaze, blink, squint (eyeOpen), brows, mouth, hands, accessories — not eye size parity.", "No Grok-bot emoji face replacement; no symmetric sticker eyes.", ] as const; diff --git a/packages/ui-web/src/oo-figure.tsx b/packages/ui-web/src/oo-figure.tsx index b9228ff..06d538a 100644 --- a/packages/ui-web/src/oo-figure.tsx +++ b/packages/ui-web/src/oo-figure.tsx @@ -3,6 +3,7 @@ import { buildOoRig, createOoLife, OO_LIST_MOMENTS, + OO_RIG_FACE_VIEWBOX, OO_RIG_INSET, OO_RIG_SIZE, OO_STILL_TIME, @@ -34,7 +35,7 @@ export interface OoFigureProps { /** The body fills the avatar box; ears, hats and effects may reach past it. */ const OO_VIEWBOX = `${OO_RIG_INSET} ${OO_RIG_INSET + 4} ${OO_RIG_SIZE - 2 * OO_RIG_INSET} ${OO_RIG_SIZE - 2 * OO_RIG_INSET}`; -const OO_FACE_VIEWBOX = "47 62 112 112"; +const OO_FACE_VIEWBOX = OO_RIG_FACE_VIEWBOX; /** Above this size an Oo always moves; smaller ones only for list moments. */ const LIVELY_SIZE = 56;