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

1550 lines
42 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.

/**
* Oo avatar rig: the drawing of an Oo as plain shape data in a 200×200 box.
*
* Web (SVG) and mobile (react-native-svg) both render this tree, so every species,
* accessory and prop exists exactly once. Parts named with `part` are the ones the
* motion layer (`oo-motion.ts`) moves each frame.
*/
import type { OoAvatar, OoEyewear, OoHeadwear, OoNeckwear, OoSpecies } from "./oo-avatar.js";
import { OO_RIG_EYES, ooLensRadius } from "./oo-avatar.js";
import { OO_CLASSIC_SHAPE_KEYS, OO_CLASSIC_SHAPES } from "./oo-palette.js";
export const OO_RIG_SIZE = 200;
/** Margin around the body that renderers crop away so the Oo fills its avatar box. */
export const OO_RIG_INSET = 20;
export const OO_MOMENTS = [
"idle",
"greeting",
"listening",
"speaking",
"thinking",
"working",
"reading",
"waiting",
"offering",
"cooking",
"autopilot",
"success",
"concerned",
"asleep",
] as const;
export type OoMoment = (typeof OO_MOMENTS)[number];
/**
* Level of detail. Small avatars drop arms, props and effects so they stay readable:
* `xs` up to 28 px, `mid` up to 56 px, `full` above.
*/
export type OoLod = "xs" | "mid" | "full";
export function ooLodForSize(px: number): OoLod {
if (px <= 28) return "xs";
if (px <= 56) return "mid";
return "full";
}
export type OoPart =
| "rig"
| "shadow"
| "eye-big"
| "eye-small"
| "pupil-big"
| "pupil-small"
| "closed-big"
| "closed-small"
| "happy-big"
| "happy-small"
| "brow-big"
| "brow-small"
| "mouth"
| "tongue"
| "arm-left"
| "arm-right"
| "hand-right"
| "tail"
| "ear-left"
| "ear-right"
| "bulb"
| "fx";
export type OoFxKind =
| "dot"
| "zzz"
| "spark"
| "sweat"
| "wave"
| "bubble"
| "bubble-dot"
| "steam"
| "moon"
| "star";
export interface OoPaint {
/** A colour, `none`, or `$body` / `$shine` for the renderer's body gradients. */
fill?: string;
stroke?: string;
strokeWidth?: number;
opacity?: number;
fillOpacity?: number;
strokeOpacity?: number;
round?: boolean;
dash?: string;
}
export type OoShape =
| ({ type: "path"; d: string } & OoPaint)
| ({ type: "circle"; cx: number; cy: number; r: number } & OoPaint)
| ({ type: "ellipse"; cx: number; cy: number; rx: number; ry: number } & OoPaint)
| ({ type: "rect"; x: number; y: number; width: number; height: number; rx?: number } & OoPaint)
| OoGroup;
export interface OoGroup {
type: "group";
part?: OoPart;
/** Rotation pivot for ears and tails. */
pivot?: readonly [number, number];
fx?: { kind: OoFxKind; index: number };
transform?: string;
hidden?: boolean;
children: OoShape[];
}
export interface OoBodyColors {
base: string;
dark: string;
light: string;
edge: string;
}
export interface OoRig {
shapes: OoShape[];
colors: OoBodyColors;
mouthColor: string;
/** Species tweaks the motion layer needs. */
mouthStyle: "smile" | "cat" | "wide";
floatScale: number;
}
const INK = "#2A2233";
const PINK = "#FF8FB5";
const ROBOT_GREY = "#8A93A6";
/* ---------- small helpers ---------- */
function hexToRgb(hex: string): [number, number, number] {
let clean = hex.replace("#", "");
if (clean.length === 3)
clean = clean
.split("")
.map((c) => c + c)
.join("");
const n = Number.parseInt(clean, 16) || 0;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function ooMix(a: string, b: string, t: number): string {
const A = hexToRgb(a);
const B = hexToRgb(b);
const channel = (i: number) =>
Math.round((A[i] ?? 0) + ((B[i] ?? 0) - (A[i] ?? 0)) * t)
.toString(16)
.padStart(2, "0");
return `#${channel(0)}${channel(1)}${channel(2)}`.toUpperCase();
}
function brightness(hex: string): number {
const [r, g, b] = hexToRgb(hex);
return (r * 299 + g * 587 + b * 114) / 1000;
}
export function ooBodyColors(base: string): OoBodyColors {
const nearWhite = brightness(base) > 235;
return {
base,
dark: ooMix(base, "#2A1E3A", 0.22),
light: ooMix(base, "#FFFFFF", 0.45),
edge: nearWhite ? "#CFC6DA" : ooMix(base, "#2A1E3A", 0.3),
};
}
function superellipse(cx: number, cy: number, rx: number, ry: number, n: number): string {
let d = "";
for (let i = 0; i < 80; i++) {
const a = (i / 80) * Math.PI * 2;
const c = Math.cos(a);
const s = Math.sin(a);
const x = cx + rx * Math.sign(c) * Math.abs(c) ** (2 / n);
const y = cy + ry * Math.sign(s) * Math.abs(s) ** (2 / n);
d += `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`;
}
return `${d}Z`;
}
export function ooStarPath(x: number, y: number, s: number): string {
return `M${x} ${y - s}Q${x} ${y} ${x + s} ${y}Q${x} ${y} ${x} ${y + s}Q${x} ${y} ${x - s} ${y}Q${x} ${y} ${x} ${y - s}Z`;
}
export function ooHeartPath(x: number, y: number, s: number): string {
const f = (v: number) => Number(v.toFixed(2));
return `M${f(x)} ${f(y + s * 0.9)}C${f(x - s * 1.6)} ${f(y - s * 0.1)} ${f(x - s * 0.7)} ${f(y - s * 1.25)} ${f(x)} ${f(y - s * 0.4)}C${f(x + s * 0.7)} ${f(y - s * 1.25)} ${f(x + s * 1.6)} ${f(y - s * 0.1)} ${f(x)} ${f(y + s * 0.9)}Z`;
}
const g = (children: OoShape[], extra: Omit<OoGroup, "type" | "children"> = {}): OoGroup => ({
type: "group",
children,
...extra,
});
/* ---------- species ---------- */
interface SpeciesDef {
/** Head anchor: where hats sit. */
top: number;
body: (shape: number) => { d: string; transform?: string; top?: number };
back?: (c: OoBodyColors) => OoShape[];
front?: (c: OoBodyColors) => OoShape[];
over?: (c: OoBodyColors) => OoShape[];
feet?: boolean;
feetColor?: string;
mouth?: "cat" | "wide";
mouthColor?: string;
bodyOpacity?: number;
float?: number;
}
/** Classic silhouettes live in a 228.5 box; this places them where the rig body sits. */
const CLASSIC_SCALE = 0.53;
const CLASSIC_TOPS: Record<string, number> = { tablet: 40, wedge: 6, cloud: 22, teardrop: 0 };
const SPECIES: Record<OoSpecies, SpeciesDef> = {
classic: {
top: 58,
body: (shape) => {
const key = OO_CLASSIC_SHAPE_KEYS[shape] ?? "hex";
return {
d: OO_CLASSIC_SHAPES[key] ?? "",
transform: `translate(39.4 53.4) scale(${CLASSIC_SCALE})`,
top: 53.4 + (CLASSIC_TOPS[key] ?? 0) * CLASSIC_SCALE + 4,
};
},
},
cat: {
top: 66,
mouth: "cat",
body: () => ({ d: superellipse(100, 117, 59, 52, 2.6) }),
back: (c) => [
g(
[
{
type: "path",
d: "M52 88L57 44Q59 38 64 42L90 68Z",
fill: c.base,
stroke: c.edge,
strokeWidth: 1.5,
},
{ type: "path", d: "M60 78L61 53L79 69Z", fill: PINK, opacity: 0.85 },
],
{ part: "ear-left", pivot: [70, 74] },
),
g(
[
{
type: "path",
d: "M148 88L143 44Q141 38 136 42L110 68Z",
fill: c.base,
stroke: c.edge,
strokeWidth: 1.5,
},
{ type: "path", d: "M140 78L139 53L121 69Z", fill: PINK, opacity: 0.85 },
],
{ part: "ear-right", pivot: [130, 74] },
),
g(
[
{
type: "path",
d: "M146 154Q180 152 174 122Q171 108 181 102",
fill: "none",
stroke: c.dark,
strokeWidth: 10,
round: true,
},
],
{ part: "tail", pivot: [148, 154] },
),
],
over: () => [
{ type: "path", d: "M100 131L106 131L103 134.5Z", fill: PINK },
{
type: "path",
d: "M58 131L38 127M58 137L38 139M148 133L166 129M148 139L166 141",
stroke: INK,
strokeOpacity: 0.35,
strokeWidth: 1.6,
round: true,
},
],
},
dog: {
top: 64,
body: () => ({ d: superellipse(100, 117, 60, 52, 2.4) }),
back: (c) => [
g(
[
{
type: "path",
d: "M148 142Q172 132 170 112",
fill: "none",
stroke: c.dark,
strokeWidth: 10,
round: true,
},
],
{ part: "tail", pivot: [150, 142] },
),
],
over: (c) => [
g([{ type: "path", d: "M50 74Q30 78 32 112Q34 128 48 122Q56 102 58 80Z", fill: c.dark }], {
part: "ear-left",
pivot: [56, 76],
}),
g(
[
{
type: "path",
d: "M150 74Q170 78 168 112Q166 128 152 122Q144 102 142 80Z",
fill: c.dark,
},
],
{
part: "ear-right",
pivot: [144, 76],
},
),
{ type: "ellipse", cx: 103, cy: 130, rx: 5.5, ry: 3.8, fill: INK },
{ type: "ellipse", cx: 101.5, cy: 128.8, rx: 1.6, ry: 1, fill: "#FFFFFF", opacity: 0.7 },
],
},
bunny: {
top: 70,
body: () => ({ d: superellipse(100, 119, 56, 50, 2.4) }),
back: (c) => [
g(
[
g(
[
{
type: "ellipse",
cx: 80,
cy: 42,
rx: 11,
ry: 31,
fill: c.base,
stroke: c.edge,
strokeWidth: 1.5,
},
],
{
transform: "rotate(-10 80 42)",
},
),
g([{ type: "ellipse", cx: 80, cy: 44, rx: 5, ry: 22, fill: PINK, opacity: 0.8 }], {
transform: "rotate(-10 80 44)",
}),
],
{ part: "ear-left", pivot: [80, 72] },
),
g(
[
g(
[
{
type: "ellipse",
cx: 120,
cy: 42,
rx: 11,
ry: 31,
fill: c.base,
stroke: c.edge,
strokeWidth: 1.5,
},
],
{
transform: "rotate(10 120 42)",
},
),
g([{ type: "ellipse", cx: 120, cy: 44, rx: 5, ry: 22, fill: PINK, opacity: 0.8 }], {
transform: "rotate(10 120 44)",
}),
],
{ part: "ear-right", pivot: [120, 72] },
),
g(
[
{
type: "circle",
cx: 154,
cy: 150,
r: 10,
fill: c.light,
stroke: c.edge,
strokeWidth: 1.2,
},
],
{
part: "tail",
pivot: [152, 150],
},
),
],
over: () => [{ type: "path", d: "M100 131L106 131L103 134Z", fill: PINK }],
},
bear: {
top: 62,
body: () => ({ d: superellipse(100, 116, 61, 55, 2.3) }),
back: (c) => [
g(
[
{ type: "circle", cx: 58, cy: 68, r: 16, fill: c.base, stroke: c.edge, strokeWidth: 1.5 },
{ type: "circle", cx: 58, cy: 68, r: 8, fill: c.light },
],
{ part: "ear-left", pivot: [60, 70] },
),
g(
[
{
type: "circle",
cx: 142,
cy: 68,
r: 16,
fill: c.base,
stroke: c.edge,
strokeWidth: 1.5,
},
{ type: "circle", cx: 142, cy: 68, r: 8, fill: c.light },
],
{ part: "ear-right", pivot: [140, 70] },
),
],
front: (c) => [
{ type: "ellipse", cx: 103, cy: 137, rx: 19, ry: 13, fill: c.light, opacity: 0.9 },
],
over: () => [{ type: "ellipse", cx: 103, cy: 129.5, rx: 5, ry: 3.5, fill: INK }],
},
robot: {
top: 62,
mouthColor: "#7FF3E0",
body: () => ({
d: "M68 62H132Q158 62 158 88V140Q158 166 132 166H68Q42 166 42 140V88Q42 62 68 62Z",
}),
back: () => [
{ type: "path", d: "M100 62V40", stroke: ROBOT_GREY, strokeWidth: 3.5, round: true },
g([{ type: "circle", cx: 100, cy: 35, r: 6.5, fill: "#FFD25A" }], { part: "bulb" }),
{ type: "rect", x: 33, y: 100, width: 11, height: 24, rx: 4, fill: ROBOT_GREY },
{ type: "rect", x: 156, y: 100, width: 11, height: 24, rx: 4, fill: ROBOT_GREY },
],
front: () => [
{ type: "rect", x: 52, y: 80, width: 96, height: 74, rx: 22, fill: "#1E1A2B", opacity: 0.9 },
],
},
ghost: {
top: 60,
feet: false,
float: 1.6,
bodyOpacity: 0.93,
body: () => ({
d: "M42 122Q42 60 100 60Q158 60 158 122L158 160Q148.5 171 139 160Q129.5 149 120 160Q110.5 171 101 160Q91.5 149 82 160Q72.5 171 63 160Q52.5 149 42 160Z",
}),
},
frog: {
top: 70,
mouth: "wide",
body: () => ({ d: superellipse(100, 121, 64, 47, 2.2) }),
// The two bumps echo the Oo: a big one and a small one.
back: (c) => [
g(
[{ type: "circle", cx: 74, cy: 82, r: 20, fill: c.base, stroke: c.edge, strokeWidth: 1.5 }],
{
part: "ear-left",
pivot: [74, 84],
},
),
g(
[
{
type: "circle",
cx: 128,
cy: 85,
r: 15,
fill: c.base,
stroke: c.edge,
strokeWidth: 1.5,
},
],
{
part: "ear-right",
pivot: [128, 86],
},
),
],
},
penguin: {
top: 58,
feetColor: "#FF9F2E",
body: () => ({ d: superellipse(100, 114, 57, 57, 2.0) }),
front: () => [
{ type: "ellipse", cx: 103, cy: 114, rx: 45, ry: 36, fill: "#FFFFFF" },
{ type: "ellipse", cx: 100, cy: 150, rx: 34, ry: 17, fill: "#FFFFFF" },
],
over: () => [
{
type: "path",
d: "M96 126L110 126L103 133Z",
fill: "#FF9F2E",
stroke: "#E07E12",
strokeWidth: 1,
},
],
},
};
/* ---------- face ---------- */
const EYESHADOW: Record<string, string> = {
rose: "#FF8FB5",
lilac: "#B48CFF",
gold: "#F5C04A",
mint: "#5FD9B0",
};
const IRIS: Record<string, string> = {
brown: "#8A5A34",
blue: "#3D8BFF",
green: "#2DBE7E",
violet: "#8E5CF7",
};
type EyeKey = "big" | "small";
/** Outer side of each eye: the big eye looks left, the small eye right. */
const OUTER: Record<EyeKey, -1 | 1> = { big: -1, small: 1 };
export const OO_PUPIL_RATIO = 0.457;
function lashes(eye: EyeKey, style: OoAvatar["lashes"]): OoShape[] {
if (style === "none") return [];
const { cx, cy, r } = OO_RIG_EYES[eye];
const out = OUTER[eye];
const k = eye === "big" ? 1 : 0.72;
const set =
style === "soft"
? { angles: [22, 46, 70], length: 6, width: 2 }
: style === "full"
? { angles: [8, 28, 48, 68, 88], length: 7.5, width: 2.4 }
: { angles: [52, 78], length: 12, width: 2.6 };
let d = "";
for (const deg of set.angles) {
const t = (deg * Math.PI) / 180;
const t2 = ((deg + 14) * Math.PI) / 180;
const length = set.length * k;
const p = (v: number) => v.toFixed(1);
d += `M${p(cx + out * r * Math.sin(t))} ${p(cy - r * Math.cos(t))}`;
d += `Q${p(cx + out * (r + length * 0.55) * Math.sin(t))} ${p(cy - (r + length * 0.55) * Math.cos(t))} `;
d += `${p(cx + out * (r + length) * Math.sin(t2))} ${p(cy - (r + length) * Math.cos(t2))}`;
}
return [
{
type: "path",
d,
fill: "none",
stroke: INK,
strokeWidth: set.width * (eye === "big" ? 1 : 0.85),
round: true,
},
];
}
function eye(key: EyeKey, avatar: OoAvatar, lod: OoLod): OoShape[] {
const { cx, cy, r } = OO_RIG_EYES[key];
const pr = r * OO_PUPIL_RATIO;
const out = OUTER[key];
const big = key === "big";
const open: OoShape[] = [];
const shadow = EYESHADOW[avatar.eyeshadow];
if (shadow)
open.push({
type: "ellipse",
cx,
cy: cy - 3,
rx: r + 4,
ry: r + 5,
fill: shadow,
opacity: 0.55,
});
open.push({
type: "circle",
cx,
cy,
r,
fill: "#FFFFFF",
stroke: INK,
strokeOpacity: 0.3,
strokeWidth: 1.6,
});
const pupil: OoShape[] = [];
const iris = IRIS[avatar.iris];
if (iris) {
pupil.push({
type: "circle",
cx,
cy,
r: pr * 1.3,
fill: iris,
stroke: INK,
strokeOpacity: 0.35,
strokeWidth: 1.2,
});
pupil.push({ type: "circle", cx, cy, r: pr * 0.72, fill: "#1B1622" });
} else {
pupil.push({ type: "circle", cx, cy, r: pr, fill: "#1B1622" });
}
const sx = cx - pr * 0.4;
const sy = cy - pr * 0.45;
const ss = pr * 0.42;
if (avatar.sparkle === "star")
pupil.push({ type: "path", d: ooStarPath(sx, sy, ss * 1.5), fill: "#FFFFFF" });
else if (avatar.sparkle === "heart")
pupil.push({ type: "path", d: ooHeartPath(sx, sy, ss * 1.05), fill: "#FFFFFF" });
else pupil.push({ type: "circle", cx: sx, cy: sy, r: ss, fill: "#FFFFFF" });
pupil.push({
type: "circle",
cx: cx + pr * 0.38,
cy: cy + pr * 0.34,
r: pr * 0.16,
fill: "#FFFFFF",
opacity: 0.9,
});
open.push(g(pupil, { part: big ? "pupil-big" : "pupil-small" }));
if (avatar.liner === "wing") {
const x0 = cx - out * r * 0.35;
const y0 = cy - r * 0.94;
const ex = cx + out * r;
const ey = cy - 1;
const w = big ? 10 : 7;
open.push({
type: "path",
d: `M${x0.toFixed(1)} ${y0.toFixed(1)}A${r.toFixed(2)} ${r.toFixed(2)} 0 0 ${out < 0 ? 0 : 1} ${ex.toFixed(1)} ${ey}L${(ex + out * w).toFixed(1)} ${(ey - w * 0.85).toFixed(1)}`,
fill: "none",
stroke: INK,
strokeWidth: big ? 3.4 : 2.6,
round: true,
});
}
if (lod !== "xs") open.push(...lashes(key, avatar.lashes));
const closed: OoShape[] = [
{
type: "path",
d: `M${(cx - r).toFixed(1)} ${cy}Q${cx} ${(cy + r * 0.62).toFixed(1)} ${(cx + r).toFixed(1)} ${cy}`,
fill: "none",
stroke: INK,
strokeWidth: big ? 4 : 3.2,
round: true,
},
];
if (avatar.lashes !== "none" && lod !== "xs") {
closed.push({
type: "path",
d: `M${(cx + out * r * 0.95).toFixed(1)} ${cy + 1}l${out * 5} 4M${(cx + out * r * 0.65).toFixed(1)} ${(cy + r * 0.35).toFixed(1)}l${out * 3} 5`,
stroke: INK,
strokeWidth: 2,
round: true,
});
}
const happy: OoShape[] = [
{
type: "path",
d: `M${(cx - r * 0.95).toFixed(1)} ${(cy + r * 0.25).toFixed(1)}Q${cx} ${(cy - r * 0.95).toFixed(1)} ${(cx + r * 0.95).toFixed(1)} ${(cy + r * 0.25).toFixed(1)}`,
fill: "none",
stroke: INK,
strokeWidth: big ? 4.4 : 3.4,
round: true,
},
];
return [
g(open, { part: big ? "eye-big" : "eye-small" }),
g(closed, { part: big ? "closed-big" : "closed-small", hidden: true }),
g(happy, { part: big ? "happy-big" : "happy-small", hidden: true }),
];
}
function cheeks(avatar: OoAvatar): OoShape[] {
const cheek = avatar.cheeks === "none" && avatar.species === "frog" ? "pink" : avatar.cheeks;
if (cheek === "none") return [];
if (cheek === "freckles") {
const dots: [number, number, number][] = [
[58, 129, 1.6],
[64, 133, 1.6],
[69, 128, 1.6],
[127, 131, 1.4],
[132, 135, 1.4],
[136, 130, 1.4],
];
return dots.map(([cx, cy, r]) => ({
type: "circle",
cx,
cy,
r,
fill: "#C77B55",
opacity: 0.7,
}));
}
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: 130, cy: 133, rx: 7.5, ry: 4.5, fill, opacity: 0.55 },
];
}
function brows(avatar: OoAvatar): OoShape[] {
const width = avatar.brows === "bushy" ? 5.5 : 3.2;
return [
g(
[
{
type: "path",
d: "M-12 0Q0 -3.5 12 0",
fill: "none",
stroke: INK,
strokeWidth: width,
round: true,
},
],
{
part: "brow-big",
hidden: true,
},
),
g(
[
{
type: "path",
d: "M-8 0Q0 -2.6 8 0",
fill: "none",
stroke: INK,
strokeWidth: width * 0.85,
round: true,
},
],
{
part: "brow-small",
hidden: true,
},
),
];
}
/* ---------- accessories ---------- */
function eyewear(kind: OoEyewear): OoShape[] {
const B = OO_RIG_EYES.big;
const S = OO_RIG_EYES.small;
const lb = ooLensRadius("big");
const ls = ooLensRadius("small");
const temples = (inset: number) =>
`M${(B.cx - lb).toFixed(1)} ${B.cy - inset}L${(B.cx - lb - 10).toFixed(1)} ${B.cy - inset - 5}M${(S.cx + ls).toFixed(1)} ${(S.cy - inset).toFixed(1)}L${(S.cx + ls + 10).toFixed(1)} ${(S.cy - inset - 5).toFixed(1)}`;
const bridge = `M${(B.cx + lb).toFixed(1)} ${B.cy - 2}Q${((B.cx + lb + S.cx - ls) / 2).toFixed(1)} ${B.cy - 7} ${(S.cx - ls).toFixed(1)} ${(S.cy - 2).toFixed(1)}`;
switch (kind) {
case "round":
case "sun": {
const lens =
kind === "sun"
? { fill: "#1B1622", fillOpacity: 0.9 }
: { fill: "#FFFFFF", fillOpacity: 0.08 };
const shapes: OoShape[] = [
{ type: "circle", cx: B.cx, cy: B.cy, r: lb, ...lens, stroke: INK, strokeWidth: 3.2 },
{ type: "circle", cx: S.cx, cy: S.cy, r: ls, ...lens, stroke: INK, strokeWidth: 3.2 },
{
type: "path",
d: bridge + temples(3),
fill: "none",
stroke: INK,
strokeWidth: 3,
round: true,
},
];
if (kind === "sun") {
shapes.push({
type: "path",
d: `M${B.cx - 12} ${B.cy - 8}L${B.cx - 4} ${B.cy - 16}M${S.cx - 7} ${(S.cy - 5).toFixed(1)}L${S.cx - 2} ${(S.cy - 10).toFixed(1)}`,
stroke: "#FFFFFF",
strokeOpacity: 0.7,
strokeWidth: 2.4,
round: true,
});
}
return shapes;
}
case "nerd":
return [
{
type: "rect",
x: B.cx - lb,
y: B.cy - lb + 2,
width: lb * 2,
height: lb * 2 - 4,
rx: 10,
fill: "#FFFFFF",
fillOpacity: 0.08,
stroke: INK,
strokeWidth: 5,
},
{
type: "rect",
x: S.cx - ls,
y: S.cy - ls + 2,
width: ls * 2,
height: ls * 2 - 4,
rx: 8,
fill: "#FFFFFF",
fillOpacity: 0.08,
stroke: INK,
strokeWidth: 5,
},
{
type: "path",
d: `M${(B.cx + lb).toFixed(1)} ${B.cy - 4}L${(S.cx - ls).toFixed(1)} ${(S.cy - 4).toFixed(1)}${temples(6)}`,
stroke: INK,
strokeWidth: 4,
round: true,
},
];
case "heart":
return [
{
type: "path",
d: ooHeartPath(B.cx, B.cy + 1, lb - 1),
fill: "#FF5E9C",
fillOpacity: 0.32,
stroke: "#E23B7D",
strokeWidth: 3,
round: true,
},
{
type: "path",
d: ooHeartPath(S.cx, S.cy + 1, ls - 1),
fill: "#FF5E9C",
fillOpacity: 0.32,
stroke: "#E23B7D",
strokeWidth: 3,
round: true,
},
{
type: "path",
d: `M${(B.cx + lb - 3).toFixed(1)} ${B.cy - 6}L${(S.cx - ls + 2).toFixed(1)} ${(S.cy - 6).toFixed(1)}`,
stroke: "#E23B7D",
strokeWidth: 3,
round: true,
},
];
case "monocle":
return [
{
type: "circle",
cx: B.cx,
cy: B.cy,
r: lb,
fill: "#FFFFFF",
fillOpacity: 0.1,
stroke: "#C9A040",
strokeWidth: 3.4,
},
{
type: "path",
d: `M${B.cx - B.r + 2} ${B.cy + B.r}Q${B.cx - B.r - 6} ${B.cy + 44} ${B.cx - 10} ${B.cy + 58}`,
fill: "none",
stroke: "#C9A040",
strokeWidth: 1.6,
dash: "2.5 2.5",
},
];
case "reading": {
const half = (cx: number, cy: number, r: number) =>
`M${(cx - r).toFixed(1)} ${(cy + 5).toFixed(1)}A${r.toFixed(1)} ${r.toFixed(1)} 0 0 0 ${(cx + r).toFixed(1)} ${(cy + 5).toFixed(1)}Z`;
return [
{
type: "path",
d: half(B.cx, B.cy, lb - 1) + half(S.cx, S.cy, ls - 1),
fill: "#FFFFFF",
fillOpacity: 0.14,
stroke: INK,
strokeWidth: 3,
},
{
type: "path",
d: `M${(B.cx + lb - 1).toFixed(1)} ${B.cy + 6}Q${(B.cx + S.cx) / 2 + 3} ${B.cy} ${(S.cx - ls + 1).toFixed(1)} ${(S.cy + 6).toFixed(1)}`,
fill: "none",
stroke: INK,
strokeWidth: 3,
},
];
}
default:
return [];
}
}
function headwear(kind: OoHeadwear, t: number): OoShape[] {
switch (kind) {
case "chef":
return [
{
type: "circle",
cx: 84,
cy: t - 16,
r: 13,
fill: "#FFFFFF",
stroke: "#D9D3DE",
strokeWidth: 1.4,
},
{
type: "circle",
cx: 116,
cy: t - 16,
r: 13,
fill: "#FFFFFF",
stroke: "#D9D3DE",
strokeWidth: 1.4,
},
{
type: "circle",
cx: 100,
cy: t - 25,
r: 16,
fill: "#FFFFFF",
stroke: "#D9D3DE",
strokeWidth: 1.4,
},
{
type: "rect",
x: 78,
y: t - 14,
width: 44,
height: 17,
rx: 4,
fill: "#FFFFFF",
stroke: "#D9D3DE",
strokeWidth: 1.4,
},
];
case "crown":
return [
{
type: "path",
d: `M78 ${t + 2}L77 ${t - 20}L89 ${t - 9}L100 ${t - 26}L111 ${t - 9}L123 ${t - 20}L122 ${t + 2}Z`,
fill: "#FFCB45",
stroke: "#D99A12",
strokeWidth: 2,
round: true,
},
{ type: "circle", cx: 100, cy: t - 7, r: 3.6, fill: "#FF5E9C" },
{ type: "circle", cx: 87, cy: t - 4, r: 2.4, fill: "#5FD9B0" },
{ type: "circle", cx: 113, cy: t - 4, r: 2.4, fill: "#5FD9B0" },
];
case "headphones":
return [
{
type: "path",
d: `M42 ${t + 46}Q40 ${t - 16} 100 ${t - 16}Q160 ${t - 16} 158 ${t + 46}`,
fill: "none",
stroke: INK,
strokeWidth: 6,
round: true,
},
{ type: "rect", x: 31, y: t + 32, width: 18, height: 30, rx: 8, fill: INK },
{ type: "rect", x: 151, y: t + 32, width: 18, height: 30, rx: 8, fill: INK },
{ type: "rect", x: 35, y: t + 37, width: 7, height: 20, rx: 3.5, fill: PINK },
{ type: "rect", x: 158, y: t + 37, width: 7, height: 20, rx: 3.5, fill: PINK },
];
case "bow":
return [
g(
[
{
type: "path",
d: "M0 0L-17 -11Q-21 0 -17 11Z",
fill: "#FF6FA5",
stroke: "#E0457F",
strokeWidth: 1.6,
round: true,
},
{
type: "path",
d: "M0 0L17 -11Q21 0 17 11Z",
fill: "#FF6FA5",
stroke: "#E0457F",
strokeWidth: 1.6,
round: true,
},
{ type: "circle", cx: 0, cy: 0, r: 5, fill: "#E0457F" },
],
{ transform: `translate(128 ${t + 2}) rotate(14)` },
),
];
case "beanie":
return [
{
type: "path",
d: `M56 ${t + 14}Q56 ${t - 30} 100 ${t - 30}Q144 ${t - 30} 144 ${t + 14}Z`,
fill: "#FF8A5B",
},
{
type: "path",
d: `M76 ${t - 24}V${t + 10}M100 ${t - 29}V${t + 10}M124 ${t - 24}V${t + 10}`,
stroke: "#E86E40",
strokeWidth: 3,
},
{ type: "rect", x: 52, y: t + 6, width: 96, height: 13, rx: 6.5, fill: "#E86E40" },
{ type: "circle", cx: 100, cy: t - 33, r: 9, fill: "#FFE1D2" },
];
case "flower": {
const petals: OoShape[] = [];
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2 - Math.PI / 2;
petals.push({
type: "circle",
cx: Number((132 + Math.cos(a) * 7).toFixed(1)),
cy: Number((t + 3 + Math.sin(a) * 7).toFixed(1)),
r: 6,
fill: "#FFC1D8",
});
}
return [...petals, { type: "circle", cx: 132, cy: t + 3, r: 4.6, fill: "#FFCB45" }];
}
case "party":
return [
g(
[
{ type: "path", d: `M86 ${t + 4}L100 ${t - 34}L114 ${t + 4}Z`, fill: "#7CC8FF" },
{
type: "path",
d: `M92 ${t - 12}L108 ${t - 8}M89 ${t - 2}L111 ${t + 2}`,
stroke: "#FF6FA5",
strokeWidth: 4,
},
{ type: "circle", cx: 100, cy: t - 35, r: 5, fill: "#FFCB45" },
],
{ transform: `rotate(-12 100 ${t})` },
),
];
default:
return [];
}
}
function neckwear(kind: OoNeckwear, y: number): OoShape[] {
switch (kind) {
case "bowtie":
return [
{
type: "path",
d: `M103 ${y}L88 ${y - 8}V${y + 8}ZM103 ${y}L118 ${y - 8}V${y + 8}Z`,
fill: "#E23B7D",
stroke: "#B8215D",
strokeWidth: 1.5,
round: true,
},
{ type: "circle", cx: 103, cy: y, r: 3.6, fill: "#B8215D" },
];
case "scarf":
return [
{
type: "path",
d: `M58 ${y - 8}Q100 ${y + 6} 146 ${y - 8}L146 ${y + 2}Q100 ${y + 16} 58 ${y + 2}Z`,
fill: "#5FB4FF",
},
{
type: "path",
d: `M122 ${y + 4}L128 ${y + 24}L138 ${y + 21}L132 ${y + 2}Z`,
fill: "#3D97FE",
},
];
case "bell":
return [
{
type: "path",
d: `M70 ${y - 6}Q103 ${y + 4} 136 ${y - 6}`,
fill: "none",
stroke: "#E23B7D",
strokeWidth: 5,
round: true,
},
{
type: "circle",
cx: 103,
cy: y + 3,
r: 7,
fill: "#FFCB45",
stroke: "#D99A12",
strokeWidth: 1.5,
},
{ type: "path", d: `M99 ${y + 5}H107`, stroke: "#D99A12", strokeWidth: 1.5 },
];
default:
return [];
}
}
function arm(style: OoAvatar["arms"], c: OoBodyColors): OoShape[] {
switch (style) {
case "glass":
return [
{
type: "rect",
x: -7,
y: -6,
width: 14,
height: 38,
rx: 7,
fill: "#FFFFFF",
fillOpacity: 0.62,
stroke: INK,
strokeOpacity: 0.2,
strokeWidth: 1.4,
},
{ type: "rect", x: -3.4, y: -1, width: 2.6, height: 25, rx: 1.3, fill: "#FFFFFF" },
];
case "stub":
return [
{
type: "rect",
x: -7,
y: -6,
width: 14,
height: 38,
rx: 7,
fill: c.base,
stroke: c.edge,
strokeWidth: 1.4,
},
];
case "paw":
return [
{
type: "rect",
x: -6.5,
y: -6,
width: 13,
height: 30,
rx: 6.5,
fill: c.base,
stroke: c.edge,
strokeWidth: 1.4,
},
{ type: "circle", cx: 0, cy: 27, r: 8.5, fill: c.base, stroke: c.edge, strokeWidth: 1.4 },
{ type: "ellipse", cx: 0, cy: 29, rx: 3.6, ry: 2.8, fill: PINK },
{ type: "circle", cx: -4, cy: 23.5, r: 1.5, fill: PINK },
{ type: "circle", cx: 0, cy: 22.3, r: 1.5, fill: PINK },
{ type: "circle", cx: 4, cy: 23.5, r: 1.5, fill: PINK },
];
case "robot":
return [
{
type: "rect",
x: -4,
y: -5,
width: 8,
height: 19,
rx: 3,
fill: "#A7B0C2",
stroke: "#6B7383",
strokeWidth: 1.2,
},
{ type: "circle", cx: 0, cy: 15, r: 5, fill: "#6B7383" },
{
type: "rect",
x: -4,
y: 15,
width: 8,
height: 14,
rx: 3,
fill: "#A7B0C2",
stroke: "#6B7383",
strokeWidth: 1.2,
},
{
type: "path",
d: "M-5 30Q-7 36 -2 37M5 30Q7 36 2 37",
fill: "none",
stroke: "#6B7383",
strokeWidth: 2.2,
round: true,
},
];
default:
return [];
}
}
/* ---------- props and effects from the moment ---------- */
function frontProp(moment: OoMoment): OoShape[] {
switch (moment) {
case "working":
case "autopilot":
// The laptop lid carries the Oo mark.
return [
{ type: "rect", x: 69, y: 138, width: 62, height: 35, rx: 6, fill: "#3A3346" },
{ type: "circle", cx: 95, cy: 155, r: 5, fill: "#FFB3CF" },
{ type: "circle", cx: 107, cy: 156, r: 5 / 1.618, fill: "#FFB3CF" },
{ type: "path", d: "M62 172H138L133 179H67Z", fill: "#5A5266" },
];
case "reading":
return [
{ type: "rect", x: 80, y: 136, width: 40, height: 44, rx: 5, fill: "#C9935A" },
{ type: "rect", x: 84, y: 142, width: 32, height: 34, rx: 2, fill: "#FFFFFF" },
{ type: "rect", x: 92, y: 132, width: 16, height: 7, rx: 2.5, fill: "#8A8F99" },
{
type: "path",
d: "M88 150H112M88 157H112M88 164H104",
stroke: "#C8C1CF",
strokeWidth: 2.4,
round: true,
},
];
case "cooking":
return [
{ type: "rect", x: 66, y: 149, width: 68, height: 6, rx: 3, fill: "#6A7282" },
{ type: "rect", x: 70, y: 153, width: 60, height: 25, rx: 9, fill: "#4A5160" },
{ type: "rect", x: 58, y: 157, width: 14, height: 5, rx: 2.5, fill: "#4A5160" },
{ type: "rect", x: 128, y: 157, width: 14, height: 5, rx: 2.5, fill: "#4A5160" },
];
default:
return [];
}
}
function handProp(moment: OoMoment): OoShape[] {
switch (moment) {
case "offering":
return [
g(
[
{
type: "rect",
x: -2,
y: -28,
width: 44,
height: 32,
rx: 7,
fill: "#FFFFFF",
stroke: INK,
strokeOpacity: 0.18,
strokeWidth: 1.4,
},
{ type: "rect", x: 5, y: -21, width: 22, height: 4, rx: 2, fill: PINK },
{ type: "rect", x: 5, y: -13, width: 30, height: 3, rx: 1.5, fill: "#E2DBE7" },
{ type: "rect", x: 5, y: -6, width: 16, height: 7, rx: 3.5, fill: INK },
{
type: "path",
d: "M10 -2.5l2 2 3.5-4",
stroke: "#FFFFFF",
strokeWidth: 1.5,
fill: "none",
round: true,
},
],
{ transform: "translate(-40 2) rotate(-6)" },
),
];
case "cooking":
return [
{ type: "rect", x: -2.2, y: -14, width: 4.4, height: 40, rx: 2.2, fill: "#C98E52" },
{ type: "ellipse", cx: 0, cy: 28, rx: 6, ry: 4, fill: "#C98E52" },
];
default:
return [];
}
}
function headlamp(top: number): OoShape[] {
return [
{
type: "path",
d: `M93 ${top + 19}L60 ${top + 70}H140L107 ${top + 19}Z`,
fill: "#FFE27A",
opacity: 0.16,
},
{
type: "path",
d: `M48 ${top + 22}Q100 ${top + 6} 152 ${top + 22}`,
fill: "none",
stroke: "#3A3346",
strokeWidth: 5,
round: true,
},
{
type: "circle",
cx: 100,
cy: top + 13,
r: 7,
fill: "#FFE27A",
stroke: "#3A3346",
strokeWidth: 2.5,
},
];
}
function fx(moment: OoMoment): OoShape[] {
const item = (kind: OoFxKind, index: number, children: OoShape[]) =>
g(children, { part: "fx", fx: { kind, index } });
const out: OoShape[] = [];
switch (moment) {
case "thinking": {
const dots = [
[148, 64, 4],
[160, 48, 6],
[176, 30, 9],
] as const;
for (const [i, [cx, cy, r]] of dots.entries()) {
out.push(
item("dot", i, [
{
type: "circle",
cx,
cy,
r,
fill: "#FFFFFF",
stroke: INK,
strokeOpacity: 0.25,
strokeWidth: 1.5,
},
]),
);
}
break;
}
case "asleep":
for (let i = 0; i < 3; i++) {
const s = 6 + i * 2;
out.push(
item("zzz", i, [
{
type: "path",
d: `M0 0H${s}L0 ${s}H${s}`,
fill: "none",
stroke: INK,
strokeOpacity: 0.55,
strokeWidth: 2.4,
round: true,
},
]),
);
}
break;
case "success":
for (let i = 0; i < 6; i++) {
out.push(
item("spark", i, [
{
type: "path",
d: ooStarPath(0, 0, 7),
fill: ["#FFCB45", "#FF8FB5", "#7CC8FF"][i % 3],
},
]),
);
}
break;
case "greeting":
for (let i = 0; i < 2; i++)
out.push(
item("spark", 10 + i, [{ type: "path", d: ooStarPath(0, 0, 6), fill: "#FFCB45" }]),
);
break;
case "offering":
out.push(item("spark", 20, [{ type: "path", d: ooStarPath(0, 0, 5), fill: "#FFCB45" }]));
break;
case "concerned":
out.push(
item("sweat", 0, [
{
type: "path",
d: "M0 -9Q6 0 6 4A6 6 0 0 1 -6 4Q-6 0 0 -9Z",
fill: "#9AD3FF",
stroke: "#5FA8E0",
strokeWidth: 1.2,
},
]),
);
break;
case "listening":
for (let i = 0; i < 3; i++) {
const h = 10 + i * 7;
out.push(
item("wave", i, [
{
type: "path",
d: `M0 -${h}Q-${7 + i * 4} 0 0 ${h}`,
fill: "none",
stroke: INK,
strokeOpacity: 0.4,
strokeWidth: 2.4,
round: true,
},
]),
);
}
break;
case "waiting":
out.push(
item("bubble", 0, [
{
type: "path",
d: "M0 0H40Q48 0 48 8V20Q48 28 40 28H14L6 35L8 28Q0 28 0 20V8Q0 0 8 0Z",
fill: "#FFFFFF",
stroke: INK,
strokeOpacity: 0.2,
strokeWidth: 1.4,
},
]),
);
for (let i = 0; i < 3; i++)
out.push(
item("bubble-dot", i, [
{ type: "circle", cx: 0, cy: 0, r: 3, fill: INK, fillOpacity: 0.6 },
]),
);
break;
case "cooking":
for (let i = 0; i < 3; i++) {
out.push(
item("steam", i, [
{
type: "path",
d: "M0 0Q-5 -7 0 -14Q5 -21 0 -28",
fill: "none",
stroke: "#B9B2C2",
strokeWidth: 3,
round: true,
},
]),
);
}
break;
case "autopilot":
out.push(
item("moon", 0, [
{ type: "path", d: "M0 -13A13 13 0 1 0 11 7A10 10 0 1 1 0 -13Z", fill: "#FFE27A" },
]),
);
for (let i = 0; i < 3; i++)
out.push(item("star", i, [{ type: "path", d: ooStarPath(0, 0, 4.5), fill: "#FFE27A" }]));
break;
default:
break;
}
return out;
}
/* ---------- assembly ---------- */
export interface OoRigInput {
avatar: OoAvatar;
/** Resolved body colour (`#RRGGBB`). */
color: string;
/** Classic silhouette index used when the avatar names none. */
fallbackShape: number;
moment: OoMoment;
lod: OoLod;
}
export function buildOoRig({ avatar, color, fallbackShape, moment, lod }: OoRigInput): OoRig {
const species = SPECIES[avatar.species] ?? SPECIES.classic;
const c = ooBodyColors(color);
const body = species.body(avatar.shape ?? fallbackShape);
const top = body.top ?? species.top;
const mid = lod !== "xs";
const full = lod === "full";
const head = avatar.head !== "none" ? avatar.head : moment === "cooking" ? "chef" : "none";
const feetColor = species.feetColor ?? c.dark;
const bodyPaint = (fill: string, extra: OoPaint = {}): OoShape =>
body.transform
? g([{ type: "path", d: body.d, fill, ...extra }], { transform: body.transform })
: { type: "path", d: body.d, fill, ...extra };
const rig: OoShape[] = [...(species.back?.(c) ?? [])];
if (species.feet !== false) {
rig.push({ type: "ellipse", cx: 80, cy: 167, rx: 14, ry: 7.5, fill: feetColor });
rig.push({ type: "ellipse", cx: 122, cy: 167, rx: 14, ry: 7.5, fill: feetColor });
}
rig.push(
bodyPaint("$body", {
stroke: c.edge,
// Classic paths are scaled down; keep the outline as thin as the others.
strokeWidth: body.transform ? 1.6 / CLASSIC_SCALE : 1.6,
...(species.bodyOpacity ? { opacity: species.bodyOpacity } : {}),
}),
);
rig.push(bodyPaint("$shine"));
rig.push(...(species.front?.(c) ?? []));
rig.push(...cheeks(avatar));
rig.push(...eye("big", avatar, lod), ...eye("small", avatar, lod));
rig.push(...brows(avatar));
const mouthColor = species.mouthColor ?? INK;
rig.push(g([{ type: "path", d: "", fill: "#FF7FA6" }], { part: "tongue" }));
rig.push(
g(
[
{
type: "path",
d: "M94 138Q103 146 112 138",
fill: "none",
stroke: mouthColor,
strokeWidth: 3.2,
round: true,
},
],
{
part: "mouth",
},
),
);
rig.push(...(species.over?.(c) ?? []));
rig.push(...eyewear(avatar.eyewear));
if (mid) {
rig.push(...neckwear(avatar.neck, avatar.species === "frog" ? 160 : 158));
rig.push(...headwear(head, top));
}
if (full && moment === "autopilot") rig.push(...headlamp(top));
if (mid) {
rig.push(...frontProp(moment));
rig.push(
g(arm(avatar.arms, c), { part: "arm-left", transform: "translate(46 124) rotate(12)" }),
);
rig.push(
g(
[
...arm(avatar.arms, c),
g([g(handProp(moment), { part: "hand-right" })], { transform: "translate(0 30)" }),
],
{
part: "arm-right",
transform: "translate(154 124) rotate(-12)",
},
),
);
}
const shapes: OoShape[] = [
g([{ type: "ellipse", cx: 100, cy: 178, rx: 46, ry: 6, fill: INK, opacity: 0.12 }], {
part: "shadow",
}),
g(rig, { part: "rig" }),
];
if (full) shapes.push(...fx(moment));
return {
shapes,
colors: c,
mouthColor,
mouthStyle: species.mouth ?? "smile",
floatScale: species.float ?? 1,
};
}