evOo-local/packages/core/src/oo-avatar.ts
cynarAI ba89574c83
Align Oo avatar eye spacing with evoo-logo (#173)
* 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 <cynarAI@users.noreply.github.com>

* Format brand-lock spacing test for Biome

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* Align mobile Expo packages for install --check in CI

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* ci: retrigger after flaky chart-links E2E

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-10-06 18:56:40 +02:00

309 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* The Oo avatar value: one string that describes how a bot looks.
*
* It travels everywhere a bot's `color` already travels (bot rows, group members,
* mentions, inbox), so every surface can draw the full look without new fields.
*
* Formats, newest first:
* - `oo1:c=A97EFE;sp=cat;ew=round` — the full look; only non-default fields are written.
* - `#8B5CF6::shape_3` — legacy colour + classic shape index.
* - `#8B5CF6` or a palette id such as `violet` — legacy colour; shape follows the identity.
* - `data:image/...` — an uploaded picture, not an Oo look.
*/
export const OO_SPECIES = [
"classic",
"cat",
"dog",
"bunny",
"bear",
"robot",
"ghost",
"frog",
"penguin",
] as const;
export const OO_ARMS = ["glass", "stub", "paw", "robot", "none"] as const;
export const OO_LASHES = ["none", "soft", "full", "flick"] as const;
export const OO_LINERS = ["none", "wing"] as const;
export const OO_EYESHADOWS = ["none", "rose", "lilac", "gold", "mint"] as const;
export const OO_IRISES = ["classic", "brown", "blue", "green", "violet"] as const;
export const OO_SPARKLES = ["dot", "star", "heart"] as const;
export const OO_BROWS = ["auto", "fine", "bushy"] as const;
export const OO_CHEEKS = ["none", "pink", "peach", "freckles"] as const;
export const OO_EYEWEAR = ["none", "round", "nerd", "sun", "heart", "monocle", "reading"] as const;
export const OO_HEADWEAR = [
"none",
"chef",
"crown",
"headphones",
"bow",
"beanie",
"flower",
"party",
] as const;
export const OO_NECKWEAR = ["none", "bowtie", "scarf", "bell"] as const;
/** Classic silhouettes, in the order of the legacy `::shape_N` index. */
export const OO_CLASSIC_SHAPE_COUNT = 8;
export const OO_ENERGY_RANGE = { min: 0.5, max: 1.6 } as const;
export type OoSpecies = (typeof OO_SPECIES)[number];
export type OoArms = (typeof OO_ARMS)[number];
export type OoLashes = (typeof OO_LASHES)[number];
export type OoLiner = (typeof OO_LINERS)[number];
export type OoEyeshadow = (typeof OO_EYESHADOWS)[number];
export type OoIris = (typeof OO_IRISES)[number];
export type OoSparkle = (typeof OO_SPARKLES)[number];
export type OoBrows = (typeof OO_BROWS)[number];
export type OoCheeks = (typeof OO_CHEEKS)[number];
export type OoEyewear = (typeof OO_EYEWEAR)[number];
export type OoHeadwear = (typeof OO_HEADWEAR)[number];
export type OoNeckwear = (typeof OO_NECKWEAR)[number];
export interface OoAvatar {
species: OoSpecies;
/** `#RRGGBB` for `oo1:` values; legacy values keep whatever colour they stored. */
color: string;
/** Classic silhouette index; `null` lets the identity pick one. */
shape: number | null;
arms: OoArms;
lashes: OoLashes;
liner: OoLiner;
eyeshadow: OoEyeshadow;
iris: OoIris;
sparkle: OoSparkle;
brows: OoBrows;
cheeks: OoCheeks;
eyewear: OoEyewear;
head: OoHeadwear;
neck: OoNeckwear;
/** Tempo and bounce of every animation. */
energy: number;
/** 0–1: how often playful extras (ear twitch, tongue, tail wag) happen. */
playful: number;
}
export const DEFAULT_OO_AVATAR: OoAvatar = {
species: "classic",
color: "#8B5CF6",
shape: null,
arms: "glass",
lashes: "none",
liner: "none",
eyeshadow: "none",
iris: "classic",
sparkle: "dot",
brows: "auto",
cheeks: "none",
eyewear: "none",
head: "none",
neck: "none",
energy: 1,
playful: 0.5,
};
export const OO_AVATAR_PREFIX = "oo1:";
/** Upper bound for an encoded `oo1:` value; every field written stays far below it. */
export const OO_AVATAR_MAX_LENGTH = 256;
type EnumField = Exclude<keyof OoAvatar, "color" | "shape" | "energy" | "playful">;
const ENUM_FIELDS: readonly { field: EnumField; key: string; values: readonly string[] }[] = [
{ field: "species", key: "sp", values: OO_SPECIES },
{ field: "arms", key: "ar", values: OO_ARMS },
{ field: "lashes", key: "la", values: OO_LASHES },
{ field: "liner", key: "li", values: OO_LINERS },
{ field: "eyeshadow", key: "es", values: OO_EYESHADOWS },
{ field: "iris", key: "ir", values: OO_IRISES },
{ field: "sparkle", key: "sk", values: OO_SPARKLES },
{ field: "brows", key: "br", values: OO_BROWS },
{ field: "cheeks", key: "ck", values: OO_CHEEKS },
{ field: "eyewear", key: "ew", values: OO_EYEWEAR },
{ field: "head", key: "hd", values: OO_HEADWEAR },
{ field: "neck", key: "nk", values: OO_NECKWEAR },
];
const HEX = /^#?([0-9a-f]{6})$/i;
const SHORT_HEX = /^#([0-9a-f]{3})$/i;
function normalizeHex(value: string): string | null {
const short = SHORT_HEX.exec(value);
if (short?.[1]) {
const [r, g, b] = short[1];
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
}
const full = HEX.exec(value);
return full?.[1] ? `#${full[1].toUpperCase()}` : null;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function roundTo(value: number, step: number): number {
return Math.round(value / step) * step;
}
/** Writes a look as an `oo1:` value. Colours that are not hex fall back to the default. */
export function encodeOoAvatar(avatar: OoAvatar): string {
const color = normalizeHex(avatar.color) ?? DEFAULT_OO_AVATAR.color;
const parts = [`c=${color.slice(1)}`];
for (const { field, key } of ENUM_FIELDS) {
if (avatar[field] !== DEFAULT_OO_AVATAR[field]) parts.push(`${key}=${avatar[field]}`);
}
if (avatar.shape !== null) {
parts.push(`sh=${clamp(Math.trunc(avatar.shape), 0, OO_CLASSIC_SHAPE_COUNT - 1)}`);
}
const energy = roundTo(clamp(avatar.energy, OO_ENERGY_RANGE.min, OO_ENERGY_RANGE.max), 0.05);
if (energy !== DEFAULT_OO_AVATAR.energy) parts.push(`en=${energy.toFixed(2)}`);
const playful = roundTo(clamp(avatar.playful, 0, 1), 0.05);
if (playful !== DEFAULT_OO_AVATAR.playful) parts.push(`pl=${playful.toFixed(2)}`);
return OO_AVATAR_PREFIX + parts.join(";");
}
/** How an `oo1:` value is written, for models that compose a look as text. */
export function ooLookFormat(): string {
const fields = ENUM_FIELDS.map(
({ field, key, values }) => `${key} ${field} (${values.join("|")})`,
);
return `oo1: followed by key=value pairs joined by ";", e.g. oo1:c=F4A7B9;sp=cat;ew=round;hd=bow. c is the body colour as hex (required). Other keys: ${fields.join(", ")}. Omitted keys keep their defaults.`;
}
function decodeV1(body: string): OoAvatar | null {
const avatar: OoAvatar = { ...DEFAULT_OO_AVATAR };
let hasColor = false;
for (const pair of body.split(";")) {
if (!pair) continue;
const eq = pair.indexOf("=");
if (eq <= 0) return null;
const key = pair.slice(0, eq);
const value = pair.slice(eq + 1);
if (key === "c") {
const color = normalizeHex(value);
if (!color) return null;
avatar.color = color;
hasColor = true;
continue;
}
if (key === "sh") {
if (!/^\d$/.test(value) || Number(value) >= OO_CLASSIC_SHAPE_COUNT) return null;
avatar.shape = Number(value);
continue;
}
if (key === "en" || key === "pl") {
if (!/^\d(?:\.\d{1,2})?$/.test(value)) return null;
const number = Number(value);
if (key === "en") {
if (number < OO_ENERGY_RANGE.min || number > OO_ENERGY_RANGE.max) return null;
avatar.energy = number;
} else {
if (number > 1) return null;
avatar.playful = number;
}
continue;
}
const spec = ENUM_FIELDS.find((entry) => entry.key === key);
// Unknown keys come from newer clients; keep the rest of the look.
if (!spec) continue;
if (!spec.values.includes(value)) return null;
(avatar as unknown as Record<string, string>)[spec.field] = value;
}
return hasColor ? avatar : null;
}
/**
* Reads any stored avatar value as a look. Returns `null` for uploaded pictures and
* for malformed `oo1:` values.
*/
export function decodeOoAvatar(value: string): OoAvatar | null {
if (value.startsWith("data:")) return null;
if (value.startsWith(OO_AVATAR_PREFIX)) {
if (value.length > OO_AVATAR_MAX_LENGTH) return null;
return decodeV1(value.slice(OO_AVATAR_PREFIX.length));
}
const [color = "", shapePart] = value.split("::shape_");
let shape: number | null = null;
if (shapePart !== undefined) {
shape = /^\d+$/.test(shapePart) ? Number(shapePart) % OO_CLASSIC_SHAPE_COUNT : 0;
if (!Number.isSafeInteger(shape)) shape = 0;
}
return { ...DEFAULT_OO_AVATAR, color: normalizeHex(color) ?? color, shape };
}
/** A look written by a model, as the canonical `oo1:` value, or `null` when it is not one. */
export function normalizeOoLook(value: unknown): string | null {
if (typeof value !== "string") return null;
const raw = value.trim();
const look = raw.startsWith(OO_AVATAR_PREFIX) ? decodeOoAvatar(raw) : null;
return look ? encodeOoAvatar(look) : null;
}
/** True when a stored avatar value is acceptable to persist. */
export function isValidAvatarValue(value: string): boolean {
if (!value.startsWith(OO_AVATAR_PREFIX)) return true;
return decodeOoAvatar(value) !== null;
}
/**
* The colour part of an avatar value, for places that need a plain CSS colour
* (cursors, rings, chips). Empty for pictures and unreadable values.
*/
export function ooAvatarColor(value: string): string {
return decodeOoAvatar(value)?.color ?? "";
}
/**
* Oo eye anchors in the 200×200 avatar rig. Every species draws the eyes here, so the
* 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: 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;
/** Lenses scale with the eye they sit on, so glasses keep the Oo asymmetry. */
export const OO_LENS_SCALE = 1.24;
export function ooLensRadius(eye: keyof typeof OO_RIG_EYES): number {
return OO_RIG_EYES[eye].r * OO_LENS_SCALE;
}