From e547bc9f7b83e000404fd68d14fd4433f957675a Mon Sep 17 00:00:00 2001 From: smontlouis Date: Thu, 13 Aug 2026 18:31:27 +0200 Subject: [PATCH] feat: import standalone Avatar Lab --- .gitignore | 11 + .nvmrc | 1 + .prettierignore | 6 + .prettierrc | 10 + .yarnrc.yml | 1 + AGENTS.md | 34 + CONTEXT.md | 68 + README.md | 51 + components.json | 22 + ...l-avatar-studio-as-a-versioned-document.md | 26 + .../0023-name-avatar-sequences-animations.md | 26 + index.html | 13 + legacy/index.html | 4015 +++++++++++ legacy/spherical-eyes.html | 2894 ++++++++ package.json | 50 + scripts/generate-standalone-engine.mjs | 33 + src/App.tsx | 6015 +++++++++++++++++ src/__tests__/ambient-motion-test.ts | 46 + src/__tests__/avatars-test.ts | 59 + src/__tests__/canvas-preview-test.ts | 37 + src/__tests__/exporter-test.ts | 154 + src/__tests__/expression-editing-test.ts | 24 + src/__tests__/geometry-editor-test.ts | 91 + src/__tests__/i18n-test.ts | 45 + src/__tests__/manipulation-session-test.ts | 47 + src/__tests__/playback-test.ts | 43 + src/__tests__/presets-test.ts | 26 + src/__tests__/rendered-rotation-gizmo-test.ts | 20 + src/__tests__/rendered-scene-test.ts | 72 + src/__tests__/sequences-test.ts | 97 + src/__tests__/snapshot-exporter-test.ts | 41 + src/__tests__/studio-document-test.ts | 171 + src/ambientMotion.ts | 96 + src/avatars.ts | 211 + src/body.ts | 117 + src/canvasPreview.ts | 12 + src/components/ui/accordion.tsx | 57 + src/components/ui/alert-dialog.tsx | 71 + src/components/ui/badge.tsx | 35 + src/components/ui/button.tsx | 44 + src/components/ui/card.tsx | 56 + src/components/ui/context-menu.tsx | 62 + src/components/ui/field.tsx | 63 + src/components/ui/input.tsx | 20 + src/components/ui/select.tsx | 70 + src/components/ui/separator.tsx | 24 + src/components/ui/switch.tsx | 21 + src/components/ui/tabs.tsx | 42 + src/components/ui/tooltip.tsx | 41 + src/defaultStudioDocument.json | 2146 ++++++ src/exporter.ts | 547 ++ src/expressionEditing.ts | 60 + src/geometry.ts | 1251 ++++ src/i18n.ts | 514 ++ src/i18n.zh.ts | 377 ++ src/lib/utils.ts | 4 + src/main.tsx | 11 + src/manipulationSession.ts | 35 + src/playback.ts | 68 + src/presets.ts | 219 + src/proceduralBrowserRuntime.ts | 351 + src/renderedRotationGizmo.ts | 43 + src/renderedScene.ts | 82 + src/sequences.ts | 288 + src/snapshotExporter.ts | 84 + src/standaloneEngine.entry.ts | 2 + src/standaloneEngine.generated.ts | 2 + src/storedZip.ts | 61 + src/studioDocument.ts | 140 + src/styles.css | 2177 ++++++ src/surfaces.ts | 665 ++ tsconfig.json | 24 + vite.config.ts | 23 + vitest.config.ts | 9 + yarn.lock | 1992 ++++++ 75 files changed, 26466 insertions(+) create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .yarnrc.yml create mode 100644 AGENTS.md create mode 100644 CONTEXT.md create mode 100644 README.md create mode 100644 components.json create mode 100644 docs/adr/0022-model-avatar-studio-as-a-versioned-document.md create mode 100644 docs/adr/0023-name-avatar-sequences-animations.md create mode 100644 index.html create mode 100644 legacy/index.html create mode 100644 legacy/spherical-eyes.html create mode 100644 package.json create mode 100644 scripts/generate-standalone-engine.mjs create mode 100644 src/App.tsx create mode 100644 src/__tests__/ambient-motion-test.ts create mode 100644 src/__tests__/avatars-test.ts create mode 100644 src/__tests__/canvas-preview-test.ts create mode 100644 src/__tests__/exporter-test.ts create mode 100644 src/__tests__/expression-editing-test.ts create mode 100644 src/__tests__/geometry-editor-test.ts create mode 100644 src/__tests__/i18n-test.ts create mode 100644 src/__tests__/manipulation-session-test.ts create mode 100644 src/__tests__/playback-test.ts create mode 100644 src/__tests__/presets-test.ts create mode 100644 src/__tests__/rendered-rotation-gizmo-test.ts create mode 100644 src/__tests__/rendered-scene-test.ts create mode 100644 src/__tests__/sequences-test.ts create mode 100644 src/__tests__/snapshot-exporter-test.ts create mode 100644 src/__tests__/studio-document-test.ts create mode 100644 src/ambientMotion.ts create mode 100644 src/avatars.ts create mode 100644 src/body.ts create mode 100644 src/canvasPreview.ts create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/context-menu.tsx create mode 100644 src/components/ui/field.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/defaultStudioDocument.json create mode 100644 src/exporter.ts create mode 100644 src/expressionEditing.ts create mode 100644 src/geometry.ts create mode 100644 src/i18n.ts create mode 100644 src/i18n.zh.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/manipulationSession.ts create mode 100644 src/playback.ts create mode 100644 src/presets.ts create mode 100644 src/proceduralBrowserRuntime.ts create mode 100644 src/renderedRotationGizmo.ts create mode 100644 src/renderedScene.ts create mode 100644 src/sequences.ts create mode 100644 src/snapshotExporter.ts create mode 100644 src/standaloneEngine.entry.ts create mode 100644 src/standaloneEngine.generated.ts create mode 100644 src/storedZip.ts create mode 100644 src/studioDocument.ts create mode 100644 src/styles.css create mode 100644 src/surfaces.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54a42d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +dist/ +node_modules/ +.yarn/ +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +*.local +.DS_Store +coverage/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..1d9b783 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.12.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4754fa6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +legacy/ +src/defaultStudioDocument.json +src/standaloneEngine.generated.ts +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fca7ef8 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "jsxSingleQuote": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100 +} diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..55dcece --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + +## Project + +Bible Strong Avatar Lab is a standalone React 19 + Vite 8 application for procedural SVG avatars. +Read `CONTEXT.md` before changing behavior or persistence semantics. + +## Commands + +```bash +yarn dev +yarn typecheck +yarn test +yarn build +yarn check +``` + +## Engineering rules + +- Use TypeScript in strict mode. +- Keep geometry, playback and document operations framework-independent. +- Preserve the split between durable React state and high-frequency Motion values. +- Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization. +- Add or update focused tests for domain behavior. +- Run `yarn typecheck` and the relevant test file while working, then `yarn check` before committing. +- `src/standaloneEngine.generated.ts` is generated. Update it with `yarn engine`, never by hand. +- Keep English, French and Simplified Chinese copy synchronized across `src/i18n.ts` and `src/i18n.zh.ts`. +- Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested. + +## UI conventions + +- Reuse components under `src/components/ui/` rather than adding one-off controls. +- Keep selection, hover, focus and destructive actions visually consistent across avatars, expressions and animations. +- Prefer transform/opacity animation over layout animation for continuous interactions. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..f8b0c29 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,68 @@ +# Avatar Lab Context + +## Purpose + +Bible Strong Avatar Lab is a browser-based authoring tool for procedural 2D avatars. It combines +3D-inspired geometry with SVG rendering so creators can build a body from primitives, define a +neutral face, author expressions and compose reusable animations. Generated packages do not depend +on the Studio UI. + +## Ubiquitous language + +**Avatar** +A reusable character whose body, colors and neutral eyes define its persistent visual identity. + +**Neutral appearance** +The persistent visual base of an Avatar before an Expression is applied. Avoid “default pose”. + +**Pose** +The Avatar's current temporary visual configuration. A Pose is not persisted directly. + +**Base behavior library** +The immutable bundled collection of Expressions and Animations inherited by an Avatar that has not +customized its behavior. + +**Avatar behavior library** +The Avatar-owned collection of Expressions and Animations created atomically from the Base behavior +library on that Avatar's first behavior mutation. + +**Expression** +A saved visual preset within the Base behavior library or one Avatar behavior library. Eye values +are relative to the owning Avatar's Neutral appearance. Optional color overrides are temporary. + +**Animation** +A saved sequence of Expressions from the same behavior library, including transitions and blink +behavior. Avoid “state” in user-facing copy. + +**Playback** +The active execution of one Animation. It exclusively controls the animated Pose until direct user +manipulation pauses it. + +## Invariants + +- An Avatar inherits the Base behavior library until its first Expression or Animation mutation. +- The first behavior mutation copies Expressions and Animations together so references stay valid. +- Subsequent behavior mutations affect only the owning Avatar. +- Duplicating an Avatar duplicates its Avatar behavior library when one exists. +- Transferring an Animation must also transfer every Expression it references. +- The primary body shape carries the facial coordinate system and eyes. +- Secondary primitives have independent local dimensions, position and rotation. +- Expressions remain compatible across body surfaces because they operate in the common facial frame. +- `geometry.ts` and the exported procedural engine stay independent from React. +- `defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required. +- Live editing may update the preview, but unsaved avatar/expression edits must remain reversible. + +## Architecture boundaries + +- Pure geometry and document logic live in framework-independent TypeScript modules. +- `App.tsx` coordinates UI and application state; avoid moving domain calculations into components. +- Motion values own frame-by-frame visual updates; React state owns durable editor state. +- Exports receive explicit avatar, expression and animation data rather than reading browser storage. +- The generated standalone engine is committed and checked for freshness in local validation. + +## Current persistence + +The complete Studio document is persisted in browser local storage. JSON project export/import is the +portable backup mechanism. There is no remote backend in this repository. + +Accepted architecture decisions live under `docs/adr/`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b61f2e2 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Bible Strong Avatar Lab + +Standalone React/Vite studio for creating procedural 2D avatars, expressions and animations. +The project was extracted from Bible Strong so it can evolve independently. + +## Start + +Requirements: Node.js 22.12+ and Corepack/Yarn 4. + +```bash +corepack enable +yarn install +yarn dev +``` + +Open `http://localhost:5173/`. + +## Validation + +```bash +yarn typecheck +yarn test +yarn build +``` + +`yarn check` runs the generated-engine freshness check and the full validation suite. + +## Project map + +- `src/App.tsx`: Studio interface and orchestration. +- `src/geometry.ts`: framework-independent procedural projection. +- `src/surfaces.ts`: supported head surfaces and primitive definitions. +- `src/renderedScene.ts`: stable render layer backed by Motion values. +- `src/playback.ts`: expression and animation playback. +- `src/studioDocument.ts`: versioned project document and browser persistence. +- `src/exporter.ts`: React/TypeScript and standalone JavaScript exports. +- `src/snapshotExporter.ts`: static SVG and PNG photo-mode exports. +- `src/defaultStudioDocument.json`: bundled avatars, expressions and animations. +- `scripts/generate-standalone-engine.mjs`: embeds the procedural runtime used by exports. +- `legacy/`: the two self-contained HTML prototypes that preceded the React app. +- `docs/adr/`: accepted architecture decisions inherited from the prototype. + +## Product model + +- An avatar owns its body and neutral eye appearance. +- The bundled behavior library supplies the initial expressions and animations. +- On the first behavior edit, an avatar receives its own independent copy of both. +- An animation references expressions from the same behavior library. +- The Studio document can be exported/imported as JSON for portability. + +See [CONTEXT.md](./CONTEXT.md) for the domain language and architecture constraints. diff --git a/components.json b/components.json new file mode 100644 index 0000000..b86ac10 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md b/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md new file mode 100644 index 0000000..a8df01e --- /dev/null +++ b/docs/adr/0022-model-avatar-studio-as-a-versioned-document.md @@ -0,0 +1,26 @@ +# ADR-0022: Model Avatar Studio as a versioned document + +## Status + +Accepted + +## Context + +Avatar Studio previously persisted Avatars, Expressions, Animations, and Playback separately. Animation +steps referenced Expressions by their current array position, so inserting or deleting an +Expression required coordinated remapping and could leave partially persisted data. + +## Decision + +Avatar Studio persists one versioned Studio document. Every Expression has a durable identity and +State steps reference that identity. Existing local data is migrated automatically by resolving +legacy expression indexes against the migrated Expression catalog. An Avatar owns its Neutral +appearance; global Expressions describe relative changes, and optional Expression colors remain +temporary overrides. Playback exclusively owns the animated Pose until a direct user manipulation +pauses it. Changing Avatar preserves the current Playback position. + +## Consequences + +Studio mutations preserve cross-object invariants in one transaction. Reordering Expressions no +longer changes Animation meaning. The project is pre-release and the bundled version-2 document is +the current baseline; no compatibility migration for earlier prototypes is maintained. diff --git a/docs/adr/0023-name-avatar-sequences-animations.md b/docs/adr/0023-name-avatar-sequences-animations.md new file mode 100644 index 0000000..0566ffc --- /dev/null +++ b/docs/adr/0023-name-avatar-sequences-animations.md @@ -0,0 +1,26 @@ +# ADR-0023: Name Avatar Studio sequences Animations + +## Status + +Accepted + +## Context + +ADR-0022 introduced the technical `State` model for a persisted sequence of Expressions. In the +Studio interface, “State” did not clearly communicate that the object is a complete, playable +animation. “Sequence” was also used for the same object in editor copy, creating three competing +names. + +## Decision + +The product term is **Animation**. An Animation contains ordered Expression steps, transitions, +playback behavior and blink settings. **Playback** remains the running execution of an Animation. + +Existing internal identifiers such as `AvatarSequence`, `stateId` and legacy storage keys may remain +until a dedicated migration is justified. They must not leak into user-facing copy or exported APIs. + +## Consequences + +Avatar Studio uses one consistent user-facing term in its tabs, editor, player and export flow. +The current versioned document remains unchanged because this decision changes terminology rather +than its internal shape. diff --git a/index.html b/index.html new file mode 100644 index 0000000..8c86814 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Bible Strong Avatar Lab + + +
+ + + diff --git a/legacy/index.html b/legacy/index.html new file mode 100644 index 0000000..6cba140 --- /dev/null +++ b/legacy/index.html @@ -0,0 +1,4015 @@ + + + + + + + + Atelier GrokBot — prototype + + + +
+
+ + + +

+ Le visage combine trois systèmes indépendants : des contours SVG dessinés à l’avance, un + petit déplacement du regard, puis une projection sphérique utilisée pendant les rotations. + Les états choisissent et enchaînent ces briques. +

+
+
+ +
+
+
+ + + + +
+ +
+
+
+
+

1. Expression SVG

+

+ Chaque bouton charge deux chemins de 48 points. Les grandes différences de + position et de taille sont déjà contenues dans ces chemins. +

+
+ +
+ +
+

2. Regard indépendant

+ + +
+ Limites exactes : le pointeur est borné à ±0,6, puis + multiplié par 22 horizontalement et 14 verticalement, soit environ ±13,2 × + ±8,4 unités. +
+
+ +
+

3. Rotation et profondeur

+ + +
+ +
+
+ +
+
+ +
+
+ Effet périphérique : un œil proche du bord voit sa largeur + multipliée par un rapport de cosinus. Lorsqu’il passe derrière la tête, il + devient invisible. +
+
+ +
+

4. Clignement et cadence

+
+ +
+
+ +
+

+ Un clignement dure 320 ms : fermeture pendant 42 % du temps, réouverture + pendant 58 %, avec une hauteur minimale de 4 %. +

+
+
+
+
+ + + + + + +
+
+
+ +
+ + + +
+
+ + diff --git a/legacy/spherical-eyes.html b/legacy/spherical-eyes.html new file mode 100644 index 0000000..4a9805f --- /dev/null +++ b/legacy/spherical-eyes.html @@ -0,0 +1,2894 @@ + + + + + + Yeux sphériques — prototype + + + + +
+
+
+ + Deux yeux capsules projetés sur une sphère + + Les contours des yeux sont recalculés en direct selon la rotation de la tête et leurs + paramètres locaux. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + L + H + R + S + E + + +

+ Glisse la boule pour tourner la tête. Clique un œil pour afficher ses poignées : + largeur, hauteur, rotation, taille globale et espacement. Les anneaux colorés règlent + X/Y/Z dans le coin supérieur droit ; l’anneau jaune tourne autour de la caméra. +

+
+
+ +
+

Prototype 02 · géométrie procédurale

+

Deux yeux.
Une seule forme.

+

+ Chaque œil est un rectangle long totalement arrondi. La taille et l’inclinaison sont + définies localement, puis la rotation de tête déforme réellement le contour sur une sphère + 3D avant de le ramener dans le SVG. +

+ + + +
+
+

Rotation de la tête

+

+ Les trois axes utilisent de vraies rotations 3D successives. Le drag de la boule est + libre ; les anneaux colorés contraignent X/Y/Z et l’anneau jaune suit la caméra. +

+ + + +
+ arcball · q(1, 0, 0, 0) +
+
+ +
+

Forme commune

+

+ Les deux yeux partagent la même capsule de base. On peut l’allonger, l’épaissir, + l’écarter et redimensionner tout le regard. +

+
+
+ Largeur + +
+
+ + +
+
+
+
+ Hauteur + +
+
+ + +
+
+
+
+ Taille globale + +
+
+ + +
+
+ + +
+ +
+

Rotation des yeux

+

+ La rotation se fait avant la projection : même inclinée, la capsule épouse ensuite la + courbure de la tête. +

+ + +
+ +
+

Inspection

+

+ Les guides permettent de voir que le résultat vient de points 3D et non d’une simple + transformation CSS du SVG entier. +

+ + + +
+ gauche · visible + droite · visible + silhouette · rayon 120.00 u +
+ +
+
+ + + + +
+
+ + +
+
+
+

Modifier l’expression 00

+

Les modifications restent locales à ce prototype.

+
+ +
+
+ +
+
+
+ + +
+
+
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..e239415 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "bible-strong-avatar-lab", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "yarn@4.12.0", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "yarn engine && vite", + "build": "yarn engine && vite build", + "preview": "vite preview", + "engine": "node scripts/generate-standalone-engine.mjs", + "engine:check": "node scripts/generate-standalone-engine.mjs --check", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "yarn engine:check && yarn format:check && yarn typecheck && yarn test && yarn build" + }, + "dependencies": { + "@base-ui/react": "^1.7.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.31.0", + "motion": "^13.0.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@babel/core": "^7.29.0", + "@babel/parser": "^7.28.6", + "@rolldown/plugin-babel": "^0.2.3", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "babel-plugin-react-compiler": "^1.0.0", + "prettier": "^3.8.1", + "rolldown": "^1.0.0-rc.5", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.3", + "vite": "^8.0.13", + "vitest": "^4.1.10" + } +} diff --git a/scripts/generate-standalone-engine.mjs b/scripts/generate-standalone-engine.mjs new file mode 100644 index 0000000..34274cc --- /dev/null +++ b/scripts/generate-standalone-engine.mjs @@ -0,0 +1,33 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { build } from 'vite' + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) +const result = await build({ + configFile: false, + logLevel: 'silent', + build: { + write: false, + minify: true, + lib: { + entry: path.join(root, 'src/standaloneEngine.entry.ts'), + formats: ['iife'], + name: 'AvatarProceduralEngine', + }, + }, +}) +const outputs = Array.isArray(result) ? result.flatMap(item => item.output) : result.output +const chunk = outputs.find(item => item.type === 'chunk') +if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.') + +const outputPath = path.join(root, 'src/standaloneEngine.generated.ts') +const source = `// Generated by scripts/generate-standalone-engine.mjs. Do not edit.\nexport const standaloneEngineSource = ${JSON.stringify(chunk.code)}\n` + +if (process.argv.includes('--check')) { + const current = await readFile(outputPath, 'utf8').catch(() => '') + if (current !== source) throw new Error('Standalone avatar engine is stale. Run yarn engine.') +} else { + await writeFile(outputPath, source) +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..75e9096 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,6015 @@ +import { + animate, + motion, + useMotionValue, + useMotionValueEvent, + useReducedMotion, +} from 'motion/react' +import { + useEffect, + useEffectEvent, + useId, + useRef, + useState, + type CSSProperties, + type RefObject, +} from 'react' +import { + ArrowLeft, + Camera, + ChevronDown, + ChevronUp, + Copy, + Download, + FileCode2, + GripVertical, + Info, + Pause, + Pencil, + Play, + Plus, + RotateCcw, + Square, + Trash2, + Upload, +} from 'lucide-react' + +import { Button } from './components/ui/button' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from './components/ui/accordion' +import { Badge } from './components/ui/badge' +import { Card, CardDescription, CardHeader, CardTitle } from './components/ui/card' +import { Field, FieldTitle } from './components/ui/field' +import { Input } from './components/ui/input' +import { Separator } from './components/ui/separator' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './components/ui/select' +import { Switch } from './components/ui/switch' +import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './components/ui/tooltip' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from './components/ui/context-menu' +import { StudioLanguageProvider, useStudioLanguage, type StudioLanguage } from './i18n' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from './components/ui/alert-dialog' + +import { + bodyPrimitiveTypes, + createBodyNode, + duplicateBodyNode, + MAX_BODY_NODES, + type BodyNode, +} from './body' +import { + applyAvatarEyeDefaults, + cloneAvatarBehavior, + createAvatar, + defaultAvatarEyes, + resolveAvatarBehavior, + type AvatarBehaviorLibrary, + type StudioAvatar, + type AvatarColors, + type AvatarEyeDefaults, +} from './avatars' +import { + expressionFields, + poseFromExpression, + renderAvatar, + renderBodyNodeEditor, + renderEyeEditor, + rotateBodyNodeAroundLocalAxis, + rotateExpressionAroundAxis, + rotateExpressionAroundCamera, + rotateExpressionWithArcball, + rotationRing, + translateBodyNodeInCameraPlane, + translateBodyNodeAlongLocalAxis, + type AvatarPose, + type Expression, + type Point3, +} from './geometry' +import { defaultExpression } from './presets' +import { + createSequence, + createSequenceStep, + duplicateSequence, + findExpressionIndex, + getSequenceSpring, + groupSequences, + readSequenceClock, + remapSequencesAfterExpressionDelete, + type AvatarSequence, + type SequenceStep, +} from './sequences' +import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion' +import { surfaceLabels, surfacePresets, type SurfaceConfig } from './surfaces' +import { + createStudioDocumentStore, + loadStudioDocument, + parseImportedStudioDocument, + persistStudioDocument, + serializeStudioDocument, + type StudioDocument, + type StatePlaybackSelection, +} from './studioDocument' +import { + advancePlaybackTimeline, + beginPlayback, + createPlaybackTimeline, + pausePlaybackTimeline, + schedulePlaybackBlink, + schedulePlaybackStep, + stopPlaybackTimeline, +} from './playback' +import { + createRenderedColors, + createRenderedScene, + findBodyNodePath, + paintRenderedColors, + paintRenderedOffset, + paintRenderedScene, + type RenderedScene, + type RenderedColors, +} from './renderedScene' +import { + createRenderedRotationGizmo, + paintRenderedRotationGizmo, + type RenderedRotationGizmo, +} from './renderedRotationGizmo' +import { scaleEye, updateEyeDimension, updateEyePosition } from './expressionEditing' +import { resolveCanvasPreviewExpression, type CanvasPreviewTarget } from './canvasPreview' +import { + beginManipulation, + beginManipulationFromRenderedValue, + finishManipulation, + previewManipulation, + type ManipulationSession, +} from './manipulationSession' +import { + avatarExportFileName, + createAvatarExportPayload, + generateJavaScriptAvatarPackage, + generateReactAvatarPackage, +} from './exporter' +import { + serializeAvatarSnapshot, + snapshotFileName, + type SnapshotBackground, +} from './snapshotExporter' + +type Mode = 'manual' | 'expressions' | 'states' | 'export' +type ExportFormat = 'react' | 'javascript' +type SnapshotFormat = 'svg' | 'png' +type PlaybackStatus = 'playing' | 'paused' | 'stopped' + +const downloadBlob = (blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.append(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 0) +} +type Side = 'Left' | 'Right' +type NumericProps = { + label: string + value: number + min?: number + max?: number + step?: number + unit?: string + onChange: (value: number) => void + onActiveChange?: (active: boolean) => void +} +type Highlight = 'head' | 'left' | 'right' | 'both' | null + +const RETARGET_BLEND_MS = 120 +const INSPECTOR_FRAME_MS = 1000 / 24 +const AMBIENT_FRAME_MS = 1000 / 30 +const createExpressionId = () => `expression-${crypto.randomUUID()}` +const emptyBodyNodes: BodyNode[] = [] +const previewGeometryCache = new WeakMap< + Expression, + WeakMap< + SurfaceConfig, + WeakMap }> + > +>() + +const poseWithAvatarEyes = (expression: Expression, eyes: AvatarEyeDefaults) => + poseFromExpression(applyAvatarEyeDefaults(expression, eyes)) + +const getPreviewGeometry = ( + expression: Expression, + surface: SurfaceConfig, + bodyNodes: BodyNode[], + eyes: AvatarEyeDefaults = defaultAvatarEyes +) => { + let surfaceCache = previewGeometryCache.get(expression) + if (!surfaceCache) { + surfaceCache = new WeakMap() + previewGeometryCache.set(expression, surfaceCache) + } + let bodyCache = surfaceCache.get(surface) + if (!bodyCache) { + bodyCache = new WeakMap() + surfaceCache.set(surface, bodyCache) + } + const positionKey = JSON.stringify(eyes) + const cached = bodyCache.get(bodyNodes) + if (cached?.positionKey === positionKey) return cached.geometry + const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, { + includeWire: false, + bodyNodes, + }) + bodyCache.set(bodyNodes, { positionKey, geometry }) + return geometry +} + +const bounded = (value: number, min?: number, max?: number) => + Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value)) + +const useEscapeToCancel = (cancel: () => void) => { + const cancelEvent = useEffectEvent(cancel) + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') cancelEvent() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, []) +} + +const scaleSurface = ( + surface: SurfaceConfig, + size: number, + minimums: Pick +) => { + const currentSize = Math.max(surface.width, surface.height, surface.depth) || 1 + const factor = size / currentSize + return { + ...surface, + width: bounded(surface.width * factor, minimums.width, 300), + height: bounded(surface.height * factor, minimums.height, 300), + depth: bounded(surface.depth * factor, minimums.depth, 300), + } +} + +const formatSeconds = (milliseconds: number, language: StudioLanguage) => + `${new Intl.NumberFormat(language === 'fr' ? 'fr-FR' : language === 'zh-CN' ? 'zh-CN' : 'en-US', { + maximumFractionDigits: 1, + }).format(milliseconds / 1000)} s` + +const parseHexColor = (color: string) => { + const value = color.replace('#', '') + return [0, 2, 4].map(offset => Number.parseInt(value.slice(offset, offset + 2), 16)) +} + +const interpolateHexColor = (from: string, to: string, progress: number) => { + const fromChannels = parseHexColor(from) + const toChannels = parseHexColor(to) + return `#${fromChannels + .map((channel, index) => + Math.round(channel + (toChannels[index] - channel) * progress) + .toString(16) + .padStart(2, '0') + ) + .join('')}` +} + +const resolveColors = (expression: Expression, colors: AvatarColors): AvatarColors => ({ + body: expression.bodyColor ?? colors.body, + eyes: expression.eyeColor ?? colors.eyes, +}) + +function ColorField({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (value: string) => void +}) { + const { t } = useStudioLanguage() + const translatedLabel = t(label) + return ( + + {translatedLabel} + + onChange(event.currentTarget.value)} + /> + { + const next = event.currentTarget.value.toUpperCase() + if (/^#[0-9A-F]{6}$/.test(next)) onChange(next.toLowerCase()) + }} + onBlur={event => { + if (!/^#[0-9A-F]{6}$/.test(event.currentTarget.value.toUpperCase())) { + event.currentTarget.value = value.toUpperCase() + } + }} + /> + + + ) +} + +function AmbientMotionField({ + label, + value, + options, + onChange, +}: { + label: string + value: Motion + options: { value: Motion; label: string }[] + onChange: (value: Motion) => void +}) { + const { t } = useStudioLanguage() + return ( + + {t(label)} + + + ) +} + +function NumericField({ + label, + value, + min, + max, + step = 0.1, + unit = '', + onChange, + onActiveChange, +}: NumericProps) { + const { t } = useStudioLanguage() + const translatedLabel = t(label) + const dragRef = useRef<{ x: number; value: number } | null>(null) + const editingRef = useRef(false) + const [draftValue, setDraftValue] = useState(() => + String(Number(value.toFixed(step < 0.1 ? 2 : 1))) + ) + + useEffect(() => { + if (!editingRef.current) { + setDraftValue(String(Number(value.toFixed(step < 0.1 ? 2 : 1)))) + } + }, [step, value]) + + const commitDraftValue = (rawValue: string) => { + const parsedValue = Number(rawValue.replace(',', '.')) + const nextValue = + Number.isFinite(parsedValue) && rawValue.trim() !== '' + ? bounded(parsedValue, min, max) + : value + + setDraftValue(String(Number(nextValue.toFixed(step < 0.1 ? 2 : 1)))) + onChange(nextValue) + } + + const startScrub = (event: React.PointerEvent) => { + event.preventDefault() + onActiveChange?.(true) + dragRef.current = { x: event.clientX, value } + event.currentTarget.setPointerCapture(event.pointerId) + } + + const scrub = (event: React.PointerEvent) => { + if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return + const speed = event.shiftKey ? 10 : event.altKey ? 0.1 : 1 + const next = dragRef.current.value + (event.clientX - dragRef.current.x) * step * speed + onChange(bounded(next, min, max)) + } + + const stopScrub = () => { + dragRef.current = null + onActiveChange?.(false) + } + + return ( + + + + + ) +} + +function LinkButton({ + linked, + onClick, + label, +}: { + linked: boolean + onClick: () => void + label: string +}) { + const { t } = useStudioLanguage() + return ( + + ) +} + +function RotationGizmo({ + expression, + rendered, + onChange, + onActiveChange, + onReset, +}: { + expression: Expression + rendered: RenderedRotationGizmo + onChange: (next: Expression) => void + onActiveChange: (active: boolean) => void + onReset: () => void +}) { + const { t } = useStudioLanguage() + const drag = useRef< + | { + type: 'axis' + axis: 'x' | 'y' | 'z' + startPoint: readonly [number, number] + tangent: readonly [number, number] + expression: Expression + } + | { type: 'view'; startAngle: number; expression: Expression } + | null + >(null) + const pose = poseFromExpression(expression) + const rings = { + x: rotationRing(pose, 'x'), + y: rotationRing(pose, 'y'), + z: rotationRing(pose, 'z'), + } + const toLocal = (event: React.PointerEvent): readonly [number, number] => { + const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect() + return [ + ((event.clientX - rectangle.left) / rectangle.width) * 86 - 43, + ((event.clientY - rectangle.top) / rectangle.height) * 86 - 43, + ] + } + const unitVector = (from: Point3, to: Point3): readonly [number, number] => { + const x = to[0] - from[0] + const y = to[1] - from[1] + const length = Math.hypot(x, y) || 1 + return [x / length, y / length] + } + const startAxis = (axis: 'x' | 'y' | 'z', event: React.PointerEvent) => { + event.stopPropagation() + onActiveChange(true) + const point = toLocal(event) + const ring = rings[axis] + let closestIndex = 0 + let closestDistance = Number.POSITIVE_INFINITY + ring.slice(0, -1).forEach((ringPoint, index) => { + const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) + if (distance < closestDistance) { + closestIndex = index + closestDistance = distance + } + }) + const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] + const next = ring[(closestIndex + 1) % (ring.length - 1)] + drag.current = { + type: 'axis', + axis, + startPoint: point, + tangent: unitVector(previous, next), + expression, + } + event.currentTarget.setPointerCapture(event.pointerId) + } + const startView = (event: React.PointerEvent) => { + event.stopPropagation() + onActiveChange(true) + const point = toLocal(event) + drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression } + event.currentTarget.setPointerCapture(event.pointerId) + } + const move = (event: React.PointerEvent) => { + if (!drag.current) return + const point = toLocal(event) + if (drag.current.type === 'view') { + const currentAngle = Math.atan2(point[1], point[0]) + const delta = Math.atan2( + Math.sin(currentAngle - drag.current.startAngle), + Math.cos(currentAngle - drag.current.startAngle) + ) + onChange(rotateExpressionAroundCamera(drag.current.expression, delta)) + return + } + const signedDistance = + (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] + + (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1] + onChange( + rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5) + ) + } + const stop = () => { + drag.current = null + onActiveChange(false) + } + const cancel = () => { + if (drag.current) onChange(drag.current.expression) + stop() + } + useEscapeToCancel(cancel) + return ( +
+ + + startAxis('y', event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + startAxis('x', event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + startAxis('z', event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + + +
+ ) +} + +type TransformAxis = 'x' | 'y' | 'z' + +function BodyNodeGizmo({ + svgRef, + pose, + node, + onPreview, + onCommit, +}: { + svgRef: React.RefObject + pose: AvatarPose + node: BodyNode + onPreview: (next: BodyNode) => void + onCommit: (next: BodyNode) => void +}) { + const { t } = useStudioLanguage() + const geometry = renderBodyNodeEditor(pose, node) + const [activeControl, setActiveControl] = useState< + { mode: 'translate' | 'rotate'; axis: TransformAxis } | { mode: 'plane' } | undefined + >(undefined) + const drag = useRef< + | { + mode: 'translate' | 'rotate' + axis: TransformAxis + startPoint: readonly [number, number] + direction: readonly [number, number] + scale: number + node: BodyNode + } + | { + mode: 'plane' + startPoint: readonly [number, number] + node: BodyNode + } + | undefined + >(undefined) + const latestNode = useRef(node) + const manipulation = useRef | null>(null) + const previewFrame = useRef(undefined) + const axes: TransformAxis[] = ['x', 'y', 'z'] + const toSvg = (event: React.PointerEvent): readonly [number, number] => { + const rectangle = svgRef.current!.getBoundingClientRect() + return [ + ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, + ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, + ] + } + const directionBetween = (from: Point3, to: Point3): readonly [number, number] => { + const x = to[0] - from[0] + const y = to[1] - from[1] + const length = Math.hypot(x, y) || 1 + return [x / length, y / length] + } + const ringPath = (points: Point3[]) => + `M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z` + const startTranslate = (axis: TransformAxis, event: React.PointerEvent) => { + event.stopPropagation() + const endpoint = geometry.axes[axis] + const length = Math.max( + Math.hypot(endpoint[0] - geometry.center[0], endpoint[1] - geometry.center[1]), + 1 + ) + drag.current = { + mode: 'translate', + axis, + startPoint: toSvg(event), + direction: directionBetween(geometry.center, endpoint), + scale: 34 / length, + node, + } + setActiveControl({ mode: 'translate', axis }) + latestNode.current = node + manipulation.current = beginManipulation(node) + event.currentTarget.setPointerCapture(event.pointerId) + } + const startRotate = (axis: TransformAxis, event: React.PointerEvent) => { + event.stopPropagation() + const point = toSvg(event) + const ring = geometry.rings[axis] + let closestIndex = 0 + let closestDistance = Infinity + ring.slice(0, -1).forEach((ringPoint, index) => { + const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) + if (distance < closestDistance) { + closestIndex = index + closestDistance = distance + } + }) + const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] + const next = ring[(closestIndex + 1) % (ring.length - 1)] + const radius = Math.max( + Math.hypot( + ring[closestIndex][0] - geometry.center[0], + ring[closestIndex][1] - geometry.center[1] + ), + 8 + ) + drag.current = { + mode: 'rotate', + axis, + startPoint: point, + direction: directionBetween(previous, next), + scale: 180 / Math.PI / radius, + node, + } + setActiveControl({ mode: 'rotate', axis }) + latestNode.current = node + manipulation.current = beginManipulation(node) + event.currentTarget.setPointerCapture(event.pointerId) + } + const startPlaneTranslate = (event: React.PointerEvent) => { + event.stopPropagation() + drag.current = { mode: 'plane', startPoint: toSvg(event), node } + setActiveControl({ mode: 'plane' }) + latestNode.current = node + manipulation.current = beginManipulation(node) + event.currentTarget.setPointerCapture(event.pointerId) + } + const move = (event: React.PointerEvent) => { + if (!drag.current) return + const interaction = drag.current + const point = toSvg(event) + const deltaX = point[0] - interaction.startPoint[0] + const deltaY = point[1] - interaction.startPoint[1] + const next = (() => { + if (interaction.mode === 'plane') { + return translateBodyNodeInCameraPlane(interaction.node, pose, deltaX, deltaY) + } + const delta = + (deltaX * interaction.direction[0] + deltaY * interaction.direction[1]) * interaction.scale + return interaction.mode === 'translate' + ? translateBodyNodeAlongLocalAxis(interaction.node, interaction.axis, delta) + : rotateBodyNodeAroundLocalAxis(interaction.node, interaction.axis, delta) + })() + latestNode.current = next + if (manipulation.current) { + previewManipulation(manipulation.current, next) + } + if (previewFrame.current !== undefined) return + previewFrame.current = requestAnimationFrame(() => { + previewFrame.current = undefined + onPreview(latestNode.current) + }) + } + const stop = () => { + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) + previewFrame.current = undefined + if (manipulation.current) { + finishManipulation(manipulation.current, 'commit', { preview: onPreview, commit: onCommit }) + } + manipulation.current = null + drag.current = undefined + setActiveControl(undefined) + } + const cancel = () => { + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) + previewFrame.current = undefined + if (manipulation.current) { + finishManipulation(manipulation.current, 'cancel', { preview: onPreview, commit: onCommit }) + } + manipulation.current = null + drag.current = undefined + setActiveControl(undefined) + } + useEscapeToCancel(cancel) + const resetTransform = (event: React.MouseEvent) => { + event.stopPropagation() + const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } + latestNode.current = resetNode + onCommit(resetNode) + } + + useEffect( + () => () => { + if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) + }, + [] + ) + + return ( + + {axes.map(axis => ( + + startRotate(axis, event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + + + ))} + {axes.map(axis => ( + + startTranslate(axis, event)} + onPointerMove={move} + onPointerUp={stop} + onPointerCancel={cancel} + /> + + + + {axis.toUpperCase()} + + + ))} + + + { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } + latestNode.current = resetNode + onCommit(resetNode) + }} + > + + ↺ + + + ) +} + +function AvatarCanvas({ + expression, + avatarEyes, + surface, + scene, + rotationGizmo, + showWire, + bodyEditing, + selectedBodyNodeId, + selectedBodyNode, + selectedSide, + linked, + highlight, + onHighlightChange, + onBodyNodeSelect, + onBodyNodePreview, + onBodyNodeChange, + onEyeSelect, + onPreview, + onChange, + onReset, + onEyeChange, + playback, + onManipulationStart, +}: { + expression: Expression + avatarEyes: AvatarEyeDefaults + surface: SurfaceConfig + scene: RenderedScene + rotationGizmo: RenderedRotationGizmo + showWire: boolean + bodyEditing: boolean + selectedBodyNodeId: 'primary' | string | null + selectedBodyNode: BodyNode | null + selectedSide: -1 | 1 | null + linked: { width: boolean; height: boolean; size: boolean } + highlight: Highlight + onHighlightChange: (highlight: Highlight) => void + onBodyNodeSelect: (id: 'primary' | string | null) => void + onBodyNodePreview: (next: BodyNode) => void + onBodyNodeChange: (next: BodyNode) => void + onEyeSelect: (side: -1 | 1) => void + onPreview: (next: Expression, target: CanvasPreviewTarget) => void + onChange: (next: Expression) => void + onReset: (next: Expression) => void + onEyeChange?: (next: Expression) => void + playback: { name: string; status: Exclude } | null + onManipulationStart: () => Expression +}) { + const { t } = useStudioLanguage() + const { + wirePaths, + backPaths, + frontPaths, + backNodeIds, + frontNodeIds, + headPath, + leftPath, + rightPath, + leftOpacity, + rightOpacity, + offsetX, + offsetY, + } = scene + const svgRef = useRef(null) + const [activeDragType, setActiveDragType] = useState< + 'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null + >(null) + const drag = useRef< + | { + type: 'arcball' + startPoint: readonly [number, number] + expression: Expression + } + | { + type: 'width' | 'height' | 'size' | 'spacing' | 'rotate' + side: -1 | 1 + startPoint: readonly [number, number] + expression: Expression + center: Point3 + widthAxis: readonly [number, number] + heightAxis: readonly [number, number] + spacingAxis: readonly [number, number] + startPointerAngle: number + startDistance: number + } + | null + >(null) + const canvasManipulation = useRef | null>(null) + const editor = + selectedSide === null + ? null + : renderEyeEditor(poseWithAvatarEyes(expression, avatarEyes), surface, selectedSide) + const selectedBodyPath = (() => { + if (!bodyEditing || !selectedBodyNodeId) return null + return findBodyNodePath(scene, selectedBodyNodeId) + })() + + const toSvg = (event: React.PointerEvent): readonly [number, number] => { + const rectangle = svgRef.current!.getBoundingClientRect() + return [ + ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, + ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, + ] + } + const unitVector = (from: Point3, to: Point3): readonly [number, number] => { + const x = to[0] - from[0] + const y = to[1] - from[1] + const length = Math.hypot(x, y) || 1 + return [x / length, y / length] + } + const startDrag = (event: React.PointerEvent) => { + const session = beginManipulationFromRenderedValue(onManipulationStart) + const renderedExpression = session.initial + onBodyNodeSelect('primary') + onHighlightChange('head') + drag.current = { + type: 'arcball', + startPoint: toSvg(event), + expression: renderedExpression, + } + canvasManipulation.current = session + setActiveDragType('arcball') + svgRef.current!.setPointerCapture(event.pointerId) + } + const selectBodyPath = ( + event: React.PointerEvent, + nodeId: string | null | undefined + ) => { + if (!nodeId || !bodyEditing) { + startDrag(event) + return + } + event.stopPropagation() + onBodyNodeSelect(nodeId) + } + const selectEye = (side: -1 | 1, event: React.PointerEvent) => { + event.stopPropagation() + onBodyNodeSelect(null) + onEyeSelect(side) + } + const startHandle = ( + type: 'width' | 'height' | 'size' | 'spacing' | 'rotate', + event: React.PointerEvent + ) => { + event.stopPropagation() + if (selectedSide === null || !editor) return + const session = beginManipulationFromRenderedValue(onManipulationStart) + const renderedExpression = session.initial + onHighlightChange(selectedSide < 0 ? 'left' : 'right') + const point = toSvg(event) + const editableExpression = bodyEditing + ? applyAvatarEyeDefaults(renderedExpression, avatarEyes) + : renderedExpression + const livePose = poseWithAvatarEyes(renderedExpression, avatarEyes) + const liveEditor = renderEyeEditor(livePose, surface, selectedSide) + const leftEditor = renderEyeEditor(livePose, surface, -1) + const rightEditor = renderEyeEditor(livePose, surface, 1) + drag.current = { + type, + side: selectedSide, + startPoint: point, + expression: editableExpression, + center: liveEditor.center, + widthAxis: unitVector(liveEditor.center, liveEditor.widthHandle), + heightAxis: unitVector(liveEditor.center, liveEditor.heightHandle), + spacingAxis: unitVector(leftEditor.center, rightEditor.center), + startPointerAngle: Math.atan2( + point[1] - liveEditor.center[1], + point[0] - liveEditor.center[0] + ), + startDistance: Math.max( + Math.hypot(point[0] - liveEditor.center[0], point[1] - liveEditor.center[1]), + 1 + ), + } + canvasManipulation.current = beginManipulation(editableExpression) + setActiveDragType(type) + svgRef.current!.setPointerCapture(event.pointerId) + } + const move = (event: React.PointerEvent) => { + if (!drag.current) return + const point = toSvg(event) + if (drag.current.type === 'arcball') { + const next = rotateExpressionWithArcball( + drag.current.expression, + drag.current.startPoint, + point + ) + if (canvasManipulation.current) { + previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'head')) + } + return + } + const interaction = drag.current + const suffix = interaction.side < 0 ? 'Left' : 'Right' + const deltaX = point[0] - interaction.startPoint[0] + const deltaY = point[1] - interaction.startPoint[1] + const along = (axis: readonly [number, number]) => deltaX * axis[0] + deltaY * axis[1] + let next = { ...interaction.expression } + const side = interaction.side < 0 ? 'Left' : 'Right' + if (interaction.type === 'width') { + const value = bounded( + interaction.expression[`width${suffix}`] + along(interaction.widthAxis) * 2, + 10, + 100 + ) + next = updateEyeDimension(interaction.expression, side, 'width', value, linked.width) + } else if (interaction.type === 'height') { + const value = bounded( + interaction.expression[`height${suffix}`] + along(interaction.heightAxis) * 2, + 10, + 100 + ) + next = updateEyeDimension(interaction.expression, side, 'height', value, linked.height) + } else if (interaction.type === 'size') { + const distance = Math.hypot( + point[0] - interaction.center[0], + point[1] - interaction.center[1] + ) + const factor = distance / interaction.startDistance + const targetSize = + Math.max( + interaction.expression[`width${suffix}`], + interaction.expression[`height${suffix}`] + ) * factor + next = scaleEye(interaction.expression, side, targetSize, linked.size) + } else if (interaction.type === 'spacing') { + const startSpacing = interaction.expression.spacing + const spacing = bounded(startSpacing + along(interaction.spacingAxis), 0, 150) + next.spacing = spacing + } else { + const currentAngle = Math.atan2( + point[1] - interaction.center[1], + point[0] - interaction.center[0] + ) + const deltaAngle = Math.atan2( + Math.sin(currentAngle - interaction.startPointerAngle), + Math.cos(currentAngle - interaction.startPointerAngle) + ) + next[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] = + interaction.expression[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] + + (deltaAngle * 180) / Math.PI + } + if (canvasManipulation.current) { + previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'eyes')) + } + } + const commitDrag = () => { + const interaction = drag.current + const session = canvasManipulation.current + if (interaction && session) { + finishManipulation(session, 'commit', { + preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), + commit: interaction.type === 'arcball' ? onChange : (onEyeChange ?? onChange), + }) + } + canvasManipulation.current = null + drag.current = null + setActiveDragType(null) + onHighlightChange(null) + } + const cancelDrag = () => { + const interaction = drag.current + const session = canvasManipulation.current + if (interaction && session) { + finishManipulation(session, 'cancel', { + preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), + commit: onChange, + }) + } + canvasManipulation.current = null + drag.current = null + setActiveDragType(null) + onHighlightChange(null) + } + useEscapeToCancel(cancelDrag) + return ( +
+ {playback && ( + + + + )} + + + + + + + + {backPaths.map((pathValue, index) => ( + selectBodyPath(event, backNodeIds.current[index])} + /> + ))} + { + onBodyNodeSelect('primary') + startDrag(event) + }} + /> + + {(showWire || highlight === 'head') && + wirePaths.map((pathValue, index) => ( + + ))} + selectEye(-1, event)} + /> + selectEye(1, event)} + /> + + {frontPaths.map((pathValue, index) => ( + selectBodyPath(event, frontNodeIds.current[index])} + /> + ))} + + {selectedBodyPath && ( + + )} + {bodyEditing && selectedBodyNode && ( + + )} + {editor?.visible && ( + + {activeDragType !== null && activeDragType !== 'arcball' && ( + + )} + + + + + + + + + + + )} + + onHighlightChange(active ? 'head' : null)} + onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })} + /> +
+ X Y Z +
+
+ ) +} + +type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate' +type HandleStart = (type: EyeHandle, event: React.PointerEvent) => void + +function EditorCircle({ + point, + label, + type, + onStart, +}: { + point: Point3 + label: string + type: EyeHandle + onStart: HandleStart +}) { + return ( + onStart(type, event)} + > + + + {label} + + + ) +} + +function EditorSquare({ + point, + label, + type, + onStart, +}: { + point: Point3 + label: string + type: EyeHandle + onStart: HandleStart +}) { + return ( + onStart(type, event)} + > + + + {label} + + + ) +} + +function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) { + const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes) + return ( + + ) +} + +function ExpressionPreview({ + expression, + surface, + bodyNodes, + colors, + avatarEyes, + id, +}: { + expression: Expression + surface: SurfaceConfig + bodyNodes: BodyNode[] + colors: AvatarColors + avatarEyes: AvatarEyeDefaults + id: string +}) { + const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes) + const resolvedColors = resolveColors(expression, colors) + const clipId = `preview-${id}` + return ( + + ) +} + +function ExpressionCard({ + expression, + index, + active, + surface, + bodyNodes, + colors, + avatarEyes, + previewId, + onSelect, + onEdit, + onDuplicate, + onDelete, + draggable, + onDragStart, + onDragEnter, + onDragOver, + onDrop, + onDragEnd, +}: { + expression: Expression + index: number + active: boolean + surface: SurfaceConfig + bodyNodes: BodyNode[] + colors: AvatarColors + avatarEyes: AvatarEyeDefaults + previewId: string + onSelect: () => void + onEdit?: () => void + onDuplicate?: () => void + onDelete?: () => void + draggable?: boolean + onDragStart?: (event: React.DragEvent) => void + onDragEnter?: () => void + onDragOver?: (event: React.DragEvent) => void + onDrop?: (event: React.DragEvent) => void + onDragEnd?: () => void +}) { + const { t } = useStudioLanguage() + const card = ( + + ) + if (!onEdit) return card + return ( + + + + + {t('Modifier')} + + {onDuplicate && ( + + {t('Dupliquer')} + + )} + {onDelete && ( + <> + + + {t('Supprimer')} + + + )} + + + ) +} + +function ExpressionWorkspace({ + editing, + avatarColors, + backButtonRef, + onChange, + onCancel, + onSave, + onDuplicate, + onDelete, +}: { + editing: { index: number | null; draft: Expression } + avatarColors: AvatarColors + backButtonRef: RefObject + onChange: (draft: Expression) => void + onCancel: () => void + onSave: () => void + onDuplicate: () => void + onDelete: () => void +}) { + const { t } = useStudioLanguage() + const [linked, setLinked] = useState({ + width: true, + height: true, + size: true, + rotation: true, + }) + const update = (changes: Partial) => onChange({ ...editing.draft, ...changes }) + const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { + onChange(updateEyeDimension(editing.draft, side, dimension, value, linked[dimension])) + } + const updateSize = (side: Side, value: number) => { + onChange(scaleEye(editing.draft, side, value, linked.size)) + } + const updateRotation = (side: Side, value: number) => { + onChange({ + ...editing.draft, + [side === 'Left' ? 'leftAngle' : 'rightAngle']: value, + ...(linked.rotation ? { [side === 'Left' ? 'rightAngle' : 'leftAngle']: -value } : {}), + }) + } + + return ( + <> +
+ +
+

{t('Preset en mémoire')}

+

+ {editing.index === null + ? t('Nouvelle expression') + : t(`Modifier l’expression ${String(editing.index).padStart(2, '0')}`)} +

+

{t('L’avatar à gauche affiche cette expression en direct.')}

+
+
+
+
+ + +

{t('Couleur du corps')}

+ update({ bodyColor })} + /> + {editing.draft.bodyColor && ( + + )} +
+ +

{t('Mouvement perpétuel')}

+ update({ bodyMotion })} + /> +

+ {t('Ajoute une légère présence ou un tremblement continu au corps.')} +

+
+ +

{t('Rotation de la tête')}

+ {(['headX', 'headY', 'headZ'] as const).map(field => ( + update({ [field]: value })} + /> + ))} +
+
+ + +

{t('Couleur des yeux')}

+ update({ eyeColor })} + /> + {editing.draft.eyeColor && ( + + )} +
+ +

{t('Mouvement perpétuel')}

+ update({ eyeMotion })} + /> +

+ {t('Anime le regard par petites saccades naturelles ou par tremblement.')} +

+
+ {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { width: 'Largeur', height: 'Hauteur', size: 'Taille proportionnelle' }[ + dimension + ] + )} +

+ + setLinked(current => ({ ...current, [dimension]: !current[dimension] })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = editing.draft[`width${side}`] + const height = editing.draft[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + dimension === 'size' + ? updateSize(side, next) + : updateDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + +

{t('Position et espacement')}

+
+ {(['Left', 'Right'] as Side[]).map(side => ( +
+

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

+ update({ [`positionX${side}`]: value })} + /> + update({ [`positionY${side}`]: value })} + /> +
+ ))} +
+
+ update({ spacing: value })} + /> +
+
+ +
+

{t('Rotation locale')}

+ + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ updateRotation('Left', value)} + /> + updateRotation('Right', value)} + /> +
+
+
+ + + update({ perspective: value })} + /> + + +
+
+
+
+ {editing.index !== null && ( + + )} + +
+
+ +
+
+ + ) +} + +function SequenceWorkspace({ + editing, + expressions, + surface, + bodyNodes, + colors, + avatarEyes, + selectedStepId, + backButtonRef, + reduceMotion, + onSelectedStepChange, + onChange, + onPreviewStep, + onEditExpression, + onPlay, + onPause, + onStop, + playing, + active, + onCancel, + onSave, + onDuplicate, + onDelete, +}: { + editing: { sourceId: string | null; draft: AvatarSequence } + expressions: Expression[] + surface: SurfaceConfig + bodyNodes: BodyNode[] + colors: AvatarColors + avatarEyes: AvatarEyeDefaults + selectedStepId: string | null + backButtonRef: RefObject + reduceMotion: boolean + onSelectedStepChange: (id: string | null) => void + onChange: (draft: AvatarSequence) => void + onPreviewStep: (step: SequenceStep) => void + onEditExpression: (index: number, expression: Expression) => void + onPlay: () => void + onPause: () => void + onStop: () => void + playing: boolean + active: boolean + onCancel: () => void + onSave: () => void + onDuplicate: () => void + onDelete: () => void +}) { + const { t } = useStudioLanguage() + const draggedStepId = useRef(null) + const selectedStep = + editing.draft.steps.find(step => step.id === selectedStepId) ?? editing.draft.steps[0] + + const updateStep = (changes: Partial) => { + if (!selectedStep) return + onChange({ + ...editing.draft, + steps: editing.draft.steps.map(step => + step.id === selectedStep.id ? { ...step, ...changes } : step + ), + }) + } + + const moveStep = (targetId: string) => { + const draggedId = draggedStepId.current + if (!draggedId || draggedId === targetId) return + const dragged = editing.draft.steps.find(step => step.id === draggedId) + const targetIndex = editing.draft.steps.findIndex(step => step.id === targetId) + if (!dragged || targetIndex < 0) return + const next = editing.draft.steps.filter(step => step.id !== draggedId) + next.splice(targetIndex, 0, dragged) + onChange({ ...editing.draft, steps: next }) + } + + return ( + <> +
+ +
+

{t('Éditeur d’animation')}

+

{editing.sourceId ? t('Modifier l’animation') : t('Nouvelle animation')}

+

{t('Compose les expressions, leur cadence et les clignements de cette animation.')}

+
+
+ + {active && ( + + )} +
+
+ +
+ + + + {t('Nom')} + onChange({ ...editing.draft, name: event.currentTarget.value })} + /> + + + {t('Catégorie')} + + + + {t('Description')} + + onChange({ ...editing.draft, description: event.currentTarget.value }) + } + /> + + + {t('Mode de lecture')} + + + + + + + + {editing.draft.steps.length ? ( +
+ {editing.draft.steps.map((step, position) => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (!preset) return null + const card = ( + + ) + return ( + { + draggedStepId.current = step.id + onSelectedStepChange(step.id) + }} + onDragEnter={() => moveStep(step.id)} + onDragOver={event => event.preventDefault()} + onDragEnd={() => { + draggedStepId.current = null + }} + > + + + + onEditExpression(expressionIndex, preset)} + > + {t('Modifier')} + + + + + ) + })} +
+ ) : ( +

{t('Ajoute une expression pour commencer.')}

+ )} + + {selectedStep ? ( +
+
+
+

{t('Étape sélectionnée')}

+

{t('Durée visible avant de passer à l’expression suivante.')}

+
+ +
+ updateStep({ holdMs })} + /> + updateStep({ transitionMs })} + /> + + {t('Transition')} + + +
+ ) : null} +
+ + + +
+ {expressions.map((preset, index) => ( + + ))} +
+
+
+ + + +
+ {t('Activer les clignements')} + + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, enabled }, + }) + } + /> +
+ + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, initialDelayMs }, + }) + } + /> +
+ + onChange({ + ...editing.draft, + blink: { + ...editing.draft.blink, + minIntervalMs, + maxIntervalMs: Math.max(minIntervalMs, editing.draft.blink.maxIntervalMs), + }, + }) + } + /> + + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, maxIntervalMs }, + }) + } + /> +
+ + onChange({ + ...editing.draft, + blink: { ...editing.draft.blink, durationMs }, + }) + } + /> +
+
+
+ +
+
+ {editing.sourceId && ( + + )} + +
+ +
+ + ) +} + +function StudioApp() { + const { language, setLanguage, t } = useStudioLanguage() + const [mode, setMode] = useState('manual') + const [initialDocument] = useState(loadStudioDocument) + const [documentStore] = useState(() => createStudioDocumentStore(initialDocument)) + const initialLibrary = initialDocument.library + const [avatars, setAvatars] = useState(initialLibrary.avatars) + const [activeAvatarId, setActiveAvatarId] = useState(initialLibrary.activeAvatarId) + const initialAvatar = + initialLibrary.avatars.find(avatar => avatar.id === initialLibrary.activeAvatarId) ?? + initialLibrary.avatars[0] + const [baseBehavior] = useState(() => ({ + expressions: initialDocument.expressions, + sequences: initialDocument.sequences, + })) + const initialBehavior = resolveAvatarBehavior(initialAvatar, baseBehavior) + const [surface, setSurface] = useState(initialAvatar.body.primary) + const [bodyNodes, setBodyNodes] = useState(initialAvatar.body.nodes) + const [selectedBodyNodeId, setSelectedBodyNodeId] = useState<'primary' | string | null>('primary') + const [selectedEyeSide, setSelectedEyeSide] = useState<-1 | 1 | null>(null) + const [expressions, setExpressions] = useState(initialBehavior.expressions) + const [sequences, setSequences] = useState(initialBehavior.sequences) + const [exportFormat, setExportFormat] = useState('react') + const [exportAnimationIds, setExportAnimationIds] = useState(() => + initialBehavior.sequences.map(animation => animation.id) + ) + const [snapshotBackground, setSnapshotBackground] = useState('transparent') + const [snapshotColorFrom, setSnapshotColorFrom] = useState('#F5F7FC') + const [snapshotColorTo, setSnapshotColorTo] = useState('#C9D5FF') + const [snapshotSize, setSnapshotSize] = useState('1024') + const [snapshotFormat, setSnapshotFormat] = useState('png') + const [photoFlash, setPhotoFlash] = useState(0) + const initialStatePlayback = initialDocument.playback + const updateStudioLibrary = (library: typeof initialDocument.library) => + documentStore.update({ library }) + const persistStatePlayback = (playback: StatePlaybackSelection) => + documentStore.update({ playback }) + const [bodyEditing, setBodyEditing] = useState(false) + const modeRef = useRef(mode) + useEffect(() => { + modeRef.current = mode + }, [mode]) + const avatarEditSnapshot = useRef<{ + avatars: StudioAvatar[] + activeAvatarId: string + } | null>(null) + const workspaceBackButtonRef = useRef(null) + const [focusAvatarName, setFocusAvatarName] = useState(false) + const initialExpression = expressions[0] ?? defaultExpression + const [expression, setExpression] = useState({ ...initialExpression }) + const initialDisplayColors = resolveColors(initialExpression, initialAvatar.colors) + const [renderedColors] = useState(() => createRenderedColors(initialDisplayColors)) + const setDisplayColors = (next: AvatarColors) => { + paintRenderedColors(renderedColors, next) + } + const [deleteAvatarOpen, setDeleteAvatarOpen] = useState(false) + const [deleteExpressionOpen, setDeleteExpressionOpen] = useState(false) + const [deleteSequenceOpen, setDeleteSequenceOpen] = useState(false) + const [pendingProjectImport, setPendingProjectImport] = useState<{ + document: StudioDocument + fileName: string + } | null>(null) + const [projectImportError, setProjectImportError] = useState(null) + const projectImportRef = useRef(null) + const [statePlayerExpanded, setStatePlayerExpanded] = useState(false) + const [activeExpression, setActiveExpression] = useState(null) + const [editing, setEditing] = useState<{ index: number | null; draft: Expression } | null>(null) + const [showWire, setShowWire] = useState(false) + const [springSpeed, setSpringSpeed] = useState(7) + const [linked, setLinked] = useState({ + width: true, + height: true, + size: true, + position: true, + rotation: true, + }) + const [highlight, setHighlight] = useState(null) + const [selectedState, setSelectedState] = useState(() => + sequences.some(sequence => sequence.id === initialStatePlayback.stateId) + ? initialStatePlayback.stateId! + : sequences.some(sequence => sequence.id === 'idle') + ? 'idle' + : (sequences[0]?.id ?? '') + ) + const [activeState, setActiveState] = useState(null) + const [statePlaying, setStatePlaying] = useState(false) + const [playbackStatus, setPlaybackStatus] = useState(() => + initialStatePlayback.stateId === null ? 'stopped' : 'paused' + ) + const [playbackVisual, setPlaybackVisual] = useState({ + position: null as number | null, + run: 0, + durationMs: 0, + }) + const [sequenceEditing, setSequenceEditing] = useState<{ + sourceId: string | null + draft: AvatarSequence + } | null>(null) + const [selectedSequenceStepId, setSelectedSequenceStepId] = useState(null) + const stateTimer = useRef | null>(null) + const activeSequenceRef = useRef(null) + const editorStateSnapshot = useRef<{ + stateId: string + playing: boolean + expression: Expression + } | null>(null) + const initialStatePlaybackApplied = useRef(false) + const blinkTimer = useRef | null>(null) + const [initialPlaybackTimeline] = useState(createPlaybackTimeline) + const playbackTimeline = useRef(initialPlaybackTimeline) + const reduceMotion = useReducedMotion() + + const avatarsRef = useRef(avatars) + const expressionsRef = useRef(expressions) + const sequencesRef = useRef(sequences) + const draggedAvatarId = useRef(null) + const avatarDragOrigin = useRef(null) + const avatarDragPreview = useRef(avatars) + const [draggingAvatarId, setDraggingAvatarId] = useState(null) + const draggedExpressionId = useRef(null) + const expressionDragOrigin = useRef(null) + const expressionDragPreview = useRef(expressions) + const [draggingExpressionId, setDraggingExpressionId] = useState(null) + const draggedStateId = useRef(null) + const stateDragOrigin = useRef(null) + const stateDragPreview = useRef(sequences) + const [draggingStateId, setDraggingStateId] = useState(null) + const activeAvatarIdRef = useRef(activeAvatarId) + const surfaceRef = useRef(surface) + const bodyNodesRef = useRef(bodyNodes) + const showWireRef = useRef(showWire) + const highlightRef = useRef(highlight) + const persistActiveBehavior = ( + nextExpressions: Expression[], + nextSequences: AvatarSequence[] + ) => { + const behavior = cloneAvatarBehavior({ + expressions: nextExpressions, + sequences: nextSequences, + }) + const nextAvatars = avatarsRef.current.map(avatar => + avatar.id === activeAvatarIdRef.current ? { ...avatar, behavior } : avatar + ) + avatarsRef.current = nextAvatars + setAvatars(nextAvatars) + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: nextAvatars }) + } + const updateStudioExpressions = (nextExpressions: Expression[]) => { + expressionsRef.current = nextExpressions + persistActiveBehavior(nextExpressions, sequencesRef.current) + } + const updateStudioSequences = (nextSequences: AvatarSequence[]) => { + sequencesRef.current = nextSequences + persistActiveBehavior(expressionsRef.current, nextSequences) + } + const [initialRender] = useState(() => { + const pose = poseFromExpression(initialExpression) + return { + pose, + geometry: renderAvatar( + poseWithAvatarEyes(initialExpression, initialAvatar.eyes), + surface, + 1, + { bodyNodes } + ), + } + }) + const { pose: initialPose, geometry: initialGeometry } = initialRender + const displayedPose = useRef(initialPose) + const transitionFrame = useRef(null) + const ambientFrame = useRef(null) + const ambientStartedAt = useRef(0) + const lastAmbientElapsed = useRef(0) + const lastAmbientFrame = useRef(0) + const ambientSignature = useRef('none:none') + const transitionTarget = useRef({ ...initialExpression }) + const canonicalTarget = useRef({ ...initialExpression }) + const retargetFrom = useRef(null) + const retargetTo = useRef(null) + const retargetStartedAt = useRef(null) + const [transitionVelocity] = useState( + () => + Object.fromEntries(expressionFields.map(field => [field, 0])) as Record< + (typeof expressionFields)[number], + number + > + ) + const lastTransitionTime = useRef(null) + const lastInspectorFrame = useRef(0) + const springSpeedRef = useRef(springSpeed) + const sequenceTransitionRef = useRef>({ + transitionMs: 500, + transition: 'smooth', + }) + const activeSequenceTransition = useRef<{ + target: Expression + index: number | null + settings: Pick + remainingMs: number + } | null>(null) + const pausedSequenceTransition = useRef(null) + const blinkControls = useRef | null>(null) + const blinkAnimating = useRef(false) + const blinkValue = useMotionValue(1) + const [renderedScene] = useState(() => createRenderedScene(initialGeometry)) + const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression)) + const bodyColorAnimation = useRef | null>(null) + const eyeColorAnimation = useRef | null>(null) + + const paintPose = (pose: AvatarPose, blink?: number, frameTimeMs?: number) => { + displayedPose.current = pose + paintRenderedRotationGizmo(renderedRotationGizmo, pose.expression) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + const signature = `${pose.expression.eyeMotion}:${pose.expression.bodyMotion}` + if (signature !== ambientSignature.current) { + ambientSignature.current = signature + ambientStartedAt.current = -1 + lastAmbientElapsed.current = 0 + } + if (frameTimeMs !== undefined) { + if (ambientStartedAt.current < 0) ambientStartedAt.current = frameTimeMs + lastAmbientElapsed.current = frameTimeMs - ambientStartedAt.current + } + const renderedExpression = + !reduceMotion && hasAmbientMotion(pose.expression) + ? applyAmbientMotion(pose.expression, lastAmbientElapsed.current) + : pose.expression + const renderPose = avatar + ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes) + : poseFromExpression(renderedExpression) + const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), { + includeWire: showWireRef.current || highlightRef.current === 'head', + bodyNodes: bodyNodesRef.current, + }) + paintRenderedScene(renderedScene, geometry) + paintRenderedOffset( + renderedScene, + ambientBodyOffset(pose.expression, lastAmbientElapsed.current) + ) + } + + useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest)) + + const paintAmbientFrame = useEffectEvent((time: number) => { + if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) { + lastAmbientFrame.current = time + paintPose(displayedPose.current, undefined, time) + } + }) + + const ambientLoopActive = !reduceMotion && hasAmbientMotion(editing?.draft ?? expression) + useEffect(() => { + if (!ambientLoopActive) return + const tick = (time: number) => { + paintAmbientFrame(time) + ambientFrame.current = requestAnimationFrame(tick) + } + ambientFrame.current = requestAnimationFrame(tick) + return () => { + if (ambientFrame.current !== null) cancelAnimationFrame(ambientFrame.current) + } + }, [ambientLoopActive]) + + useEffect( + () => () => { + if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) + blinkControls.current?.stop() + bodyColorAnimation.current?.stop() + eyeColorAnimation.current?.stop() + if (stateTimer.current) clearTimeout(stateTimer.current) + if (blinkTimer.current) clearTimeout(blinkTimer.current) + }, + [] + ) + + const stopTransition = (resetVelocity: boolean) => { + if (transitionFrame.current !== null) cancelAnimationFrame(transitionFrame.current) + transitionFrame.current = null + lastTransitionTime.current = null + retargetFrom.current = null + retargetTo.current = null + retargetStartedAt.current = null + lastInspectorFrame.current = 0 + if (resetVelocity) { + expressionFields.forEach(field => { + transitionVelocity[field] = 0 + }) + } + } + + const stopColorTransitions = () => { + bodyColorAnimation.current?.stop() + eyeColorAnimation.current?.stop() + bodyColorAnimation.current = null + eyeColorAnimation.current = null + } + + const freezeLivePreviewForManipulation = () => { + if (statePlaying) pauseState() + const renderedExpression = { ...displayedPose.current.expression } + stopTransition(true) + stopColorTransitions() + activeSequenceTransition.current = null + pausedSequenceTransition.current = null + transitionTarget.current = renderedExpression + canonicalTarget.current = renderedExpression + setExpression(renderedExpression) + setActiveExpression(null) + return renderedExpression + } + + const updateImmediate = (next: Expression, preservePlayback = false) => { + if (!preservePlayback && statePlaying) pauseState() + stopTransition(true) + stopColorTransitions() + const pose = poseFromExpression(next) + transitionTarget.current = next + canonicalTarget.current = next + setExpression(next) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) + setActiveExpression(null) + paintPose(pose) + } + + const transitionToExpression = ( + next: Expression, + index: number | null = null, + transitionSettings?: Pick + ) => { + if (!transitionSettings && statePlaying) pauseState() + sequenceTransitionRef.current = transitionSettings ?? { + transitionMs: 500, + transition: 'smooth', + } + setActiveExpression(index) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (reduceMotion || transitionSettings?.transitionMs === 0) { + updateImmediate(next, Boolean(transitionSettings)) + setActiveExpression(index) + return + } + const current = displayedPose.current.expression + const nearestAngle = (target: number, from: number) => { + let resolved = target + while (resolved - from > 180) resolved -= 360 + while (resolved - from < -180) resolved += 360 + return resolved + } + canonicalTarget.current = next + const resolvedTarget = { + ...next, + headX: nearestAngle(next.headX, current.headX), + headY: nearestAngle(next.headY, current.headY), + headZ: nearestAngle(next.headZ, current.headZ), + leftAngle: nearestAngle(next.leftAngle, current.leftAngle), + rightAngle: nearestAngle(next.rightAngle, current.rightAngle), + } + + if (transitionSettings) { + stopTransition(true) + stopColorTransitions() + const durationMs = transitionSettings.transitionMs + const from = { ...current } + const fromColors = { + body: renderedColors.body.get(), + eyes: renderedColors.eyes.get(), + } + const targetColors = avatar ? resolveColors(next, avatar.colors) : fromColors + let startedAt: number | null = null + activeSequenceTransition.current = { + target: next, + index, + settings: transitionSettings, + remainingMs: durationMs, + } + const tickSequenceTransition = (time: number) => { + if (startedAt === null) startedAt = time + const elapsed = time - startedAt + const progress = Math.min(elapsed / durationMs, 1) + const eased = + transitionSettings.transition === 'smooth' + ? progress * progress * (3 - 2 * progress) + : transitionSettings.transition === 'snappy' + ? 1 - (1 - progress) ** 3 + : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) + const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion } + expressionFields.forEach(field => { + animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased + }) + activeSequenceTransition.current = { + target: next, + index, + settings: transitionSettings, + remainingMs: Math.max(durationMs - elapsed, 0), + } + setDisplayColors({ + body: interpolateHexColor(fromColors.body, targetColors.body, bounded(eased, 0, 1)), + eyes: interpolateHexColor(fromColors.eyes, targetColors.eyes, bounded(eased, 0, 1)), + }) + paintPose(poseFromExpression(animated), undefined, time) + if ( + modeRef.current === 'manual' && + time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS + ) { + lastInspectorFrame.current = time + setExpression(animated) + } + if (progress < 1) { + transitionFrame.current = requestAnimationFrame(tickSequenceTransition) + return + } + transitionFrame.current = null + activeSequenceTransition.current = null + canonicalTarget.current = next + transitionTarget.current = next + setExpression(next) + setDisplayColors(targetColors) + paintPose(poseFromExpression(next)) + } + transitionFrame.current = requestAnimationFrame(tickSequenceTransition) + return + } + + if (avatar) { + const targetColors = resolveColors(next, avatar.colors) + bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, { + duration: 0.35, + ease: 'easeInOut', + }) + eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, { + duration: 0.35, + ease: 'easeInOut', + }) + } + + if (transitionFrame.current !== null) { + retargetFrom.current = { ...transitionTarget.current } + retargetTo.current = resolvedTarget + retargetStartedAt.current = -1 + return + } + transitionTarget.current = resolvedTarget + const tick = (time: number) => { + const previousTime = lastTransitionTime.current ?? time + const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30) + lastTransitionTime.current = time + const { stiffness, damping } = getSequenceSpring( + sequenceTransitionRef.current.transition, + sequenceTransitionRef.current.transitionMs, + springSpeedRef.current + ) + const mass = 0.85 + const currentExpression = displayedPose.current.expression + if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) { + if (retargetStartedAt.current < 0) retargetStartedAt.current = time + const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1) + const smoothProgress = + linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10) + const blendedTarget = { ...retargetTo.current } + expressionFields.forEach(field => { + blendedTarget[field] = + retargetFrom.current![field] + + (retargetTo.current![field] - retargetFrom.current![field]) * smoothProgress + }) + transitionTarget.current = blendedTarget + if (linearProgress === 1) { + transitionTarget.current = retargetTo.current + retargetFrom.current = null + retargetTo.current = null + retargetStartedAt.current = null + } + } + const target = transitionTarget.current + let settled = true + const animated = { ...currentExpression } + animated.eyeMotion = target.eyeMotion + animated.bodyMotion = target.bodyMotion + + expressionFields.forEach(field => { + const displacement = target[field] - currentExpression[field] + const acceleration = (stiffness * displacement - damping * transitionVelocity[field]) / mass + const velocity = transitionVelocity[field] + acceleration * deltaTime + const value = currentExpression[field] + velocity * deltaTime + transitionVelocity[field] = velocity + animated[field] = value + const tolerance = field === 'perspective' ? 0.0001 : 0.005 + if (Math.abs(displacement) > tolerance || Math.abs(velocity) > tolerance) settled = false + }) + + if (settled) { + const finalExpression = canonicalTarget.current + stopTransition(true) + setExpression(finalExpression) + paintPose(poseFromExpression(finalExpression)) + return + } + + paintPose(poseFromExpression(animated), undefined, time) + if (modeRef.current === 'manual' && time - lastInspectorFrame.current >= INSPECTOR_FRAME_MS) { + lastInspectorFrame.current = time + setExpression(animated) + } + transitionFrame.current = requestAnimationFrame(tick) + } + transitionFrame.current = requestAnimationFrame(tick) + } + + const blink = (durationMs?: number) => { + blinkControls.current?.stop() + blinkValue.jump(1) + blinkAnimating.current = true + const sequence = sequences.find(item => item.id === (activeState ?? selectedState)) + const blinkDuration = durationMs ?? sequence?.blink.durationMs ?? 280 + blinkControls.current = animate(blinkValue, [1, 0, 1], { + duration: reduceMotion ? 0 : blinkDuration / 1000, + times: [0, 0.42, 1], + ease: ['easeIn', 'easeOut'], + onComplete: () => { + blinkAnimating.current = false + }, + }) + } + + const updateDimension = (side: Side, dimension: 'width' | 'height', value: number) => { + updateImmediate(updateEyeDimension(expression, side, dimension, value, linked[dimension])) + } + + const updateSize = (side: Side, value: number) => { + updateImmediate(scaleEye(expression, side, value, linked.size)) + } + + const updateSpacing = (value: number) => { + updateImmediate({ ...expression, spacing: value }) + } + + const updateActiveAvatar = (update: (avatar: StudioAvatar) => StudioAvatar) => { + const next = avatarsRef.current.map(avatar => + avatar.id === activeAvatarIdRef.current ? update(avatar) : avatar + ) + avatarsRef.current = next + setAvatars(next) + if (!avatarEditSnapshot.current) { + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) + } + } + + const selectBodyNode = (id: 'primary' | string | null) => { + setSelectedBodyNodeId(id) + if (id) setSelectedEyeSide(null) + } + + const updateSurface = (next: SurfaceConfig) => { + surfaceRef.current = next + setSurface(next) + updateActiveAvatar(avatar => ({ + ...avatar, + body: { primary: next, nodes: bodyNodesRef.current }, + })) + paintPose(displayedPose.current) + } + + const updateBodyNodes = (next: BodyNode[]) => { + bodyNodesRef.current = next + setBodyNodes(next) + updateActiveAvatar(avatar => ({ + ...avatar, + body: { primary: surfaceRef.current, nodes: next }, + })) + paintPose(displayedPose.current) + } + + const updateAvatarColors = (changes: Partial) => { + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (!avatar) return + const colors = { ...avatar.colors, ...changes } + updateActiveAvatar(current => ({ ...current, colors })) + setDisplayColors(resolveColors(expression, colors)) + } + + const updateAvatarEyes = (changes: Partial) => { + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (!avatar) return + const eyes = { ...(avatar.eyes ?? defaultAvatarEyes), ...changes } + updateActiveAvatar(current => ({ ...current, eyes })) + paintPose(displayedPose.current) + } + + const activateAvatar = (id: string, editBody = false, preserveMode = false) => { + const avatar = avatarsRef.current.find(item => item.id === id) + if (!avatar) return + const resumeActiveSequence = statePlaying + if (resumeActiveSequence) pauseState(false) + if (editBody) suspendStateForEditor() + if (editBody && !avatarEditSnapshot.current) { + avatarEditSnapshot.current = { + avatars: avatarsRef.current, + activeAvatarId: activeAvatarIdRef.current, + } + } + const currentStateExpression = displayedPose.current.expression + const nextBehavior = resolveAvatarBehavior(avatar, baseBehavior) + const nextExpressions = nextBehavior.expressions + const nextSequences = nextBehavior.sequences + stopTransition(true) + activeAvatarIdRef.current = id + surfaceRef.current = avatar.body.primary + bodyNodesRef.current = avatar.body.nodes + setActiveAvatarId(id) + setSurface(avatar.body.primary) + setBodyNodes(avatar.body.nodes) + expressionsRef.current = nextExpressions + sequencesRef.current = nextSequences + expressionDragPreview.current = nextExpressions + stateDragPreview.current = nextSequences + setExpressions(nextExpressions) + setSequences(nextSequences) + setExportAnimationIds(nextSequences.map(animation => animation.id)) + const nextActiveSequence = activeState + ? (nextSequences.find(sequence => sequence.id === activeState) ?? null) + : null + const nextSelectedState = + nextActiveSequence?.id ?? + (nextSequences.some(sequence => sequence.id === 'idle') + ? 'idle' + : (nextSequences[0]?.id ?? '')) + activeSequenceRef.current = nextActiveSequence + setActiveState(nextActiveSequence?.id ?? null) + setSelectedState(nextSelectedState) + setPlaybackVisual(current => ({ ...current, position: null })) + selectBodyNode('primary') + setActiveExpression(null) + setEditing(null) + setBodyEditing(editBody) + if (!preserveMode || editBody) setMode('manual') + const nextExpression = nextActiveSequence + ? currentStateExpression + : { ...(nextExpressions[0] ?? defaultExpression) } + setExpression(nextExpression) + setDisplayColors(resolveColors(nextExpression, avatar.colors)) + canonicalTarget.current = nextExpression + transitionTarget.current = nextExpression + paintPose(poseFromExpression(nextExpression)) + if (nextActiveSequence && resumeActiveSequence) { + pausedSequenceTransition.current = null + launchSequence(nextActiveSequence, true, false) + } + if (!avatarEditSnapshot.current) { + updateStudioLibrary({ activeAvatarId: id, avatars: avatarsRef.current }) + } + } + + const createNewAvatar = () => { + avatarEditSnapshot.current = { + avatars: avatarsRef.current, + activeAvatarId: activeAvatarIdRef.current, + } + const avatar = createAvatar('Unknown') + const next = [...avatarsRef.current, avatar] + avatarsRef.current = next + setAvatars(next) + setFocusAvatarName(true) + activateAvatar(avatar.id, true) + } + + const duplicateAvatar = (source: StudioAvatar, editDuplicate = false) => { + const snapshotAvatars = avatarEditSnapshot.current?.avatars ?? avatarsRef.current + const sourceIndex = snapshotAvatars.findIndex(avatar => avatar.id === source.id) + const baseAvatars = sourceIndex < 0 ? [...snapshotAvatars, source] : snapshotAvatars + const duplicate: StudioAvatar = { + ...structuredClone(source), + id: `avatar-${crypto.randomUUID()}`, + name: `${source.name} ${t('copie')}`, + } + const next = [...baseAvatars, duplicate] + avatarEditSnapshot.current = null + avatarsRef.current = next + setAvatars(next) + updateStudioLibrary({ activeAvatarId: duplicate.id, avatars: next }) + activateAvatar(duplicate.id, editDuplicate) + } + + const previewAvatarMove = (targetId: string) => { + const draggedId = draggedAvatarId.current + if (!draggedId || draggedId === targetId) return + const current = avatarDragPreview.current + const dragged = current.find(avatar => avatar.id === draggedId) + const targetIndex = current.findIndex(avatar => avatar.id === targetId) + if (!dragged || targetIndex < 0) return + const next = current.filter(avatar => avatar.id !== draggedId) + next.splice(targetIndex, 0, dragged) + avatarDragPreview.current = next + setAvatars(next) + } + + const commitAvatarMove = (targetId: string) => { + previewAvatarMove(targetId) + const next = avatarDragPreview.current + avatarsRef.current = next + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: next }) + avatarDragOrigin.current = null + draggedAvatarId.current = null + setDraggingAvatarId(null) + } + + const cancelAvatarMove = () => { + if (draggedAvatarId.current && avatarDragOrigin.current) { + avatarDragPreview.current = avatarDragOrigin.current + setAvatars(avatarDragOrigin.current) + } + avatarDragOrigin.current = null + draggedAvatarId.current = null + setDraggingAvatarId(null) + } + + const cancelAvatarEditing = () => { + const snapshot = avatarEditSnapshot.current + if (!snapshot) { + setBodyEditing(false) + restoreStateAfterEditor() + return + } + avatarEditSnapshot.current = null + avatarsRef.current = snapshot.avatars + setAvatars(snapshot.avatars) + activateAvatar(snapshot.activeAvatarId, false, true) + restoreStateAfterEditor() + } + + const saveAvatarEditing = () => { + avatarEditSnapshot.current = null + updateStudioLibrary({ activeAvatarId: activeAvatarIdRef.current, avatars: avatarsRef.current }) + setBodyEditing(false) + restoreStateAfterEditor() + } + + const renameActiveAvatar = (name: string) => { + updateActiveAvatar(avatar => ({ ...avatar, name })) + } + + const deleteActiveAvatar = () => { + if (avatarsRef.current.length <= 1) return + avatarEditSnapshot.current = null + const remaining = avatarsRef.current.filter(avatar => avatar.id !== activeAvatarIdRef.current) + avatarsRef.current = remaining + setAvatars(remaining) + updateStudioLibrary({ activeAvatarId: remaining[0].id, avatars: remaining }) + setDeleteAvatarOpen(false) + activateAvatar(remaining[0].id) + restoreStateAfterEditor() + } + + const addBodyNode = (type: (typeof bodyPrimitiveTypes)[number]) => { + if (bodyNodesRef.current.length >= MAX_BODY_NODES) return + const node = createBodyNode(type, bodyNodesRef.current.length) + updateBodyNodes([...bodyNodesRef.current, node]) + selectBodyNode(node.id) + } + + const updateSelectedBodyNode = (update: (node: BodyNode) => BodyNode) => { + if (selectedBodyNodeId === 'primary') return + updateBodyNodes( + bodyNodesRef.current.map(node => (node.id === selectedBodyNodeId ? update(node) : node)) + ) + } + + const commitBodyNode = (nextNode: BodyNode) => { + updateBodyNodes(bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node))) + } + + const previewSelectedBodyNode = (nextNode: BodyNode) => { + const next = bodyNodesRef.current.map(node => (node.id === nextNode.id ? nextNode : node)) + bodyNodesRef.current = next + setBodyNodes(next) + paintPose(displayedPose.current) + } + + const deleteSelectedBodyNode = () => { + if (selectedBodyNodeId === 'primary') return + updateBodyNodes(bodyNodesRef.current.filter(node => node.id !== selectedBodyNodeId)) + selectBodyNode('primary') + } + + const duplicateSelectedBodyNode = () => { + if (selectedBodyNodeId === 'primary' || bodyNodesRef.current.length >= MAX_BODY_NODES) return + const source = bodyNodesRef.current.find(node => node.id === selectedBodyNodeId) + if (!source) return + const duplicate = duplicateBodyNode(source) + updateBodyNodes([...bodyNodesRef.current, duplicate]) + selectBodyNode(duplicate.id) + } + + const updateHighlight = (next: Highlight) => { + if (next && statePlaying) pauseState() + highlightRef.current = next + setHighlight(next) + if (next === 'head') paintPose(displayedPose.current) + } + + const updateWireVisibility = (next: boolean) => { + showWireRef.current = next + setShowWire(next) + if (next) paintPose(displayedPose.current) + } + + const clearStateTimers = () => { + if (stateTimer.current) clearTimeout(stateTimer.current) + if (blinkTimer.current) clearTimeout(blinkTimer.current) + stateTimer.current = null + blinkTimer.current = null + playbackTimeline.current = { + ...playbackTimeline.current, + stepDueAt: null, + blinkDueAt: null, + } + } + + const pauseState = (persist = true) => { + playbackTimeline.current = pausePlaybackTimeline(playbackTimeline.current, readSequenceClock()) + if (transitionFrame.current !== null && activeSequenceTransition.current) { + cancelAnimationFrame(transitionFrame.current) + transitionFrame.current = null + pausedSequenceTransition.current = activeSequenceTransition.current + activeSequenceTransition.current = null + } + if (blinkAnimating.current) blinkControls.current?.pause() + clearStateTimers() + setStatePlaying(false) + setPlaybackStatus('paused') + if (persist && activeState) persistStatePlayback({ stateId: activeState, playing: false }) + } + + const stopState = (persist = true) => { + clearStateTimers() + playbackTimeline.current = stopPlaybackTimeline(playbackTimeline.current) + activeSequenceTransition.current = null + pausedSequenceTransition.current = null + stopTransition(true) + blinkControls.current?.stop() + blinkAnimating.current = false + blinkValue.jump(1) + paintPose(displayedPose.current, 1) + setStatePlaying(false) + setPlaybackStatus('stopped') + setPlaybackVisual(current => ({ ...current, position: null })) + if (persist) persistStatePlayback({ stateId: null, playing: false }) + } + + const launchSequence = (sequence: AvatarSequence, resume = false, persist = true) => { + clearStateTimers() + if (!sequence.steps.length) { + stopState(persist) + return + } + const id = sequence.id + playbackTimeline.current = beginPlayback(playbackTimeline.current, resume) + setSelectedState(id) + setActiveState(id) + activeSequenceRef.current = sequence + setStatePlaying(true) + setPlaybackStatus('playing') + if (persist) persistStatePlayback({ stateId: id, playing: true }) + const scheduleAdvance = (delay: number) => { + playbackTimeline.current = schedulePlaybackStep( + playbackTimeline.current, + readSequenceClock(), + delay + ) + stateTimer.current = setTimeout(advance, delay) + } + const playCurrentStep = () => { + playbackTimeline.current = { ...playbackTimeline.current, stepDueAt: null } + const step = sequence.steps[playbackTimeline.current.position] + const availableExpressions = expressionsRef.current + const expressionIndex = findExpressionIndex(availableExpressions, step.expressionId) + const preset = availableExpressions[expressionIndex] + const durationMs = (reduceMotion ? 0 : step.transitionMs) + step.holdMs + setPlaybackVisual(current => ({ + position: playbackTimeline.current.position, + run: current.run + 1, + durationMs, + })) + if (preset) { + transitionToExpression(preset, expressionIndex, step) + } + scheduleAdvance(durationMs) + } + const advance = () => { + const advanced = advancePlaybackTimeline(playbackTimeline.current, sequence) + playbackTimeline.current = advanced.timeline + const { cursor } = advanced + if (cursor.complete) { + if (blinkTimer.current) clearTimeout(blinkTimer.current) + blinkTimer.current = null + playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } + setStatePlaying(false) + setPlaybackStatus('stopped') + setPlaybackVisual(current => ({ ...current, position: null })) + if (persist) persistStatePlayback({ stateId: null, playing: false }) + return + } + playCurrentStep() + } + const scheduleBlink = (delay: number) => { + playbackTimeline.current = schedulePlaybackBlink( + playbackTimeline.current, + readSequenceClock(), + delay + ) + blinkTimer.current = setTimeout(blinkLoop, delay) + } + const blinkLoop = () => { + playbackTimeline.current = { ...playbackTimeline.current, blinkDueAt: null } + blink(sequence.blink.durationMs) + const { minIntervalMs, maxIntervalMs } = sequence.blink + scheduleBlink( + sequence.blink.durationMs + minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs) + ) + } + if (resume) { + const pausedTransition = pausedSequenceTransition.current + if (pausedTransition) { + transitionToExpression(pausedTransition.target, pausedTransition.index, { + ...pausedTransition.settings, + transitionMs: pausedTransition.remainingMs, + }) + pausedSequenceTransition.current = null + } + if (blinkAnimating.current) blinkControls.current?.play() + const currentStep = sequence.steps[playbackTimeline.current.position] + scheduleAdvance( + playbackTimeline.current.stepRemainingMs || + (reduceMotion ? 0 : currentStep.transitionMs) + currentStep.holdMs + ) + } else { + playCurrentStep() + } + if (sequence.blink.enabled) { + scheduleBlink( + resume + ? playbackTimeline.current.blinkRemainingMs || sequence.blink.initialDelayMs + : sequence.blink.initialDelayMs + ) + } + } + + const toggleStatePlayback = () => { + if (!activeState || !activeSequenceRef.current) return + if (statePlaying) pauseState() + else launchSequence(activeSequenceRef.current, playbackStatus === 'paused') + } + + const suspendStateForEditor = () => { + if (editorStateSnapshot.current || !activeState) return + editorStateSnapshot.current = { + stateId: activeState, + playing: statePlaying, + expression: { ...displayedPose.current.expression }, + } + if (statePlaying) pauseState(false) + setActiveState(null) + } + + const restoreStateAfterEditor = (availableSequences = sequences) => { + const snapshot = editorStateSnapshot.current + editorStateSnapshot.current = null + if (!snapshot) return + const sequence = availableSequences.find(item => item.id === snapshot.stateId) + if (!sequence) { + stopState(false) + persistStatePlayback({ stateId: null, playing: false }) + return + } + activeSequenceRef.current = sequence + setSelectedState(sequence.id) + setActiveState(sequence.id) + if (snapshot.playing) { + launchSequence(sequence, true, false) + return + } + setStatePlaying(false) + transitionToExpression(snapshot.expression) + } + + useEffect(() => { + if (initialStatePlaybackApplied.current) return + initialStatePlaybackApplied.current = true + const sequence = sequences.find(item => item.id === selectedState) + if (sequence && initialStatePlayback.stateId !== null) { + activeSequenceRef.current = sequence + setActiveState(sequence.id) + if (initialStatePlayback.playing) launchSequence(sequence, false, false) + else setPlaybackStatus('paused') + } + return () => { + initialStatePlaybackApplied.current = false + } + }, []) + + const saveEditing = () => { + if (!editing) return + const index = editing.index ?? expressions.length + const savedDraft = + editing.index === null ? { ...editing.draft, id: createExpressionId() } : editing.draft + const next = + editing.index === null + ? [...expressions, savedDraft] + : expressions.map((item, itemIndex) => + itemIndex === editing.index ? { ...savedDraft } : item + ) + setExpressions(next) + updateStudioExpressions(next) + setEditing(null) + transitionToExpression(savedDraft, index) + if (!sequenceEditing) restoreStateAfterEditor() + } + + const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => { + const duplicate = { ...draft, id: createExpressionId() } + const next = [...expressions, duplicate] + const duplicateIndex = next.length - 1 + setExpressions(next) + updateStudioExpressions(next) + if (editDuplicate) openExpressionEditor(duplicateIndex, duplicate) + else transitionToExpression(duplicate, duplicateIndex) + } + + const previewExpressionMove = (targetId: string | null) => { + const draggedId = draggedExpressionId.current + if (!draggedId || draggedId === targetId) return + const current = expressionDragPreview.current + const dragged = current.find(item => item.id === draggedId) + if (!dragged) return + const activeId = activeExpression === null ? null : current[activeExpression]?.id + const next = current.filter(item => item.id !== draggedId) + const targetIndex = targetId ? next.findIndex(item => item.id === targetId) : next.length + next.splice(targetIndex < 0 ? next.length : targetIndex, 0, dragged) + expressionDragPreview.current = next + setExpressions(next) + if (activeId) setActiveExpression(next.findIndex(item => item.id === activeId)) + } + + const commitExpressionMove = (targetId: string | null) => { + previewExpressionMove(targetId) + updateStudioExpressions(expressionDragPreview.current) + expressionDragOrigin.current = null + draggedExpressionId.current = null + setDraggingExpressionId(null) + } + + const cancelExpressionMove = () => { + if (draggedExpressionId.current && expressionDragOrigin.current) { + expressionDragPreview.current = expressionDragOrigin.current + setExpressions(expressionDragOrigin.current) + } + expressionDragOrigin.current = null + draggedExpressionId.current = null + setDraggingExpressionId(null) + } + + const previewExpressionDraft = (draft: Expression) => { + setEditing(current => (current ? { ...current, draft } : current)) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) + paintPose(poseFromExpression(draft)) + } + + const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { + const now = performance.now() + const updateInspector = now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS + if (updateInspector) { + lastInspectorFrame.current = now + if (editing) { + setEditing(current => (current ? { ...current, draft: next } : current)) + } else { + setExpression(next) + } + } + if (bodyEditing) { + paintRenderedRotationGizmo(renderedRotationGizmo, next) + paintRenderedScene( + renderedScene, + renderAvatar( + poseFromExpression( + resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target) + ), + surfaceRef.current, + blinkValue.get(), + { + includeWire: showWireRef.current || highlightRef.current === 'head', + bodyNodes: bodyNodesRef.current, + } + ) + ) + return + } + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) + paintPose(poseFromExpression(next)) + } + + const openExpressionEditor = (index: number | null, draft: Expression) => { + suspendStateForEditor() + setBodyEditing(false) + setMode('expressions') + setEditing({ + index, + draft: { ...draft, id: index === null ? createExpressionId() : draft.id }, + }) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(draft, avatar.colors)) + paintPose(poseFromExpression(draft)) + } + + const cancelExpressionEditing = () => { + setEditing(null) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(expression, avatar.colors)) + paintPose(poseFromExpression(expression)) + if (!sequenceEditing) restoreStateAfterEditor() + } + + const deleteEditing = () => { + if (editing?.index === null || editing?.index === undefined) return + const next = + expressions.length <= 1 + ? [{ ...defaultExpression }] + : expressions.filter((_, index) => index !== editing.index) + const fallback = next[Math.min(editing.index, next.length - 1)] ?? defaultExpression + const deletedExpressionId = expressions[editing.index]?.id ?? editing.draft.id + const fallbackExpressionId = fallback.id + const nextSequences = remapSequencesAfterExpressionDelete( + sequences, + deletedExpressionId, + fallbackExpressionId + ) + setExpressions(next) + setSequences(nextSequences) + if (sequenceEditing) { + const draft = remapSequencesAfterExpressionDelete( + [sequenceEditing.draft], + deletedExpressionId, + fallbackExpressionId + )[0] + setSequenceEditing({ ...sequenceEditing, draft }) + } + updateStudioExpressions(next) + updateStudioSequences(nextSequences) + setActiveExpression(null) + setEditing(null) + setDeleteExpressionOpen(false) + setExpression(fallback) + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) + if (avatar) setDisplayColors(resolveColors(fallback, avatar.colors)) + paintPose(poseFromExpression(fallback)) + if (!sequenceEditing) restoreStateAfterEditor() + } + + const openSequenceEditor = (sequence?: AvatarSequence) => { + suspendStateForEditor() + setEditing(null) + setBodyEditing(false) + setMode('states') + const draft = sequence + ? { + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, + } + : createSequence(expressions[activeExpression ?? 0]?.id ?? expressions[0]?.id) + setSequenceEditing({ sourceId: sequence?.id ?? null, draft }) + setSelectedSequenceStepId(draft.steps[0]?.id ?? null) + const firstStep = draft.steps[0] + const expressionIndex = firstStep + ? findExpressionIndex(expressions, firstStep.expressionId) + : -1 + const preset = expressions[expressionIndex] + if (preset) transitionToExpression(preset, expressionIndex, firstStep) + } + + const cancelSequenceEditing = () => { + if (activeState === sequenceEditing?.draft.id) stopState(false) + setSequenceEditing(null) + setSelectedSequenceStepId(null) + restoreStateAfterEditor() + } + + const saveSequenceEditing = () => { + if (!sequenceEditing?.draft.steps.length || !sequenceEditing.draft.name.trim()) return + const saved = { + ...sequenceEditing.draft, + name: sequenceEditing.draft.name.trim(), + group: sequenceEditing.draft.group.trim() || 'Custom', + } + const next = sequenceEditing.sourceId + ? sequences.map(sequence => (sequence.id === sequenceEditing.sourceId ? saved : sequence)) + : [...sequences, saved] + setSequences(next) + updateStudioSequences(next) + setSelectedState(saved.id) + if (activeState === saved.id) stopState(false) + setSequenceEditing(null) + setSelectedSequenceStepId(null) + restoreStateAfterEditor(next) + } + + const duplicateSequenceEditing = () => { + if (!sequenceEditing) return + if (activeState === sequenceEditing.draft.id) stopState(false) + const duplicate = duplicateSequence(sequenceEditing.draft) + const next = [...sequences, duplicate] + setSequences(next) + updateStudioSequences(next) + setSelectedState(duplicate.id) + setSequenceEditing({ sourceId: duplicate.id, draft: duplicate }) + setSelectedSequenceStepId(duplicate.steps[0]?.id ?? null) + } + + const duplicateState = (sequence: AvatarSequence) => { + if (activeState === sequence.id) stopState(false) + const duplicate = duplicateSequence(sequence) + const next = [...sequences, duplicate] + setSequences(next) + updateStudioSequences(next) + setSelectedState(duplicate.id) + } + + const previewStateMove = (targetId: string | null, targetGroup: string) => { + const draggedId = draggedStateId.current + if (!draggedId || draggedId === targetId) return + const current = stateDragPreview.current + const dragged = current.find(sequence => sequence.id === draggedId) + if (!dragged) return + const next = current.filter(sequence => sequence.id !== draggedId) + const moved = { ...dragged, group: targetGroup } + if (targetId) { + const targetIndex = next.findIndex(sequence => sequence.id === targetId) + next.splice(targetIndex < 0 ? next.length : targetIndex, 0, moved) + } else { + let lastGroupIndex = -1 + for (let index = next.length - 1; index >= 0; index -= 1) { + if (next[index].group !== targetGroup) continue + lastGroupIndex = index + break + } + next.splice(lastGroupIndex + 1, 0, moved) + } + stateDragPreview.current = next + setSequences(next) + } + + const commitStateMove = (targetId: string | null, targetGroup: string) => { + previewStateMove(targetId, targetGroup) + updateStudioSequences(stateDragPreview.current) + stateDragOrigin.current = null + draggedStateId.current = null + setDraggingStateId(null) + } + + const cancelStateMove = () => { + if (draggedStateId.current && stateDragOrigin.current) { + stateDragPreview.current = stateDragOrigin.current + setSequences(stateDragOrigin.current) + } + stateDragOrigin.current = null + draggedStateId.current = null + setDraggingStateId(null) + } + + const deleteSequenceEditing = () => { + if (!sequenceEditing?.sourceId) return + const next = sequences.filter(sequence => sequence.id !== sequenceEditing.sourceId) + const fallback = next[0] + setSequences(next) + updateStudioSequences(next) + if (activeState === sequenceEditing.sourceId) stopState(false) + setSelectedState(fallback?.id ?? '') + setSequenceEditing(null) + setSelectedSequenceStepId(null) + setDeleteSequenceOpen(false) + restoreStateAfterEditor(next) + } + + const selectedBodyNode = + selectedBodyNodeId === 'primary' + ? null + : (bodyNodes.find(node => node.id === selectedBodyNodeId) ?? null) + + const updateNodeVector = (property: 'position' | 'rotation', index: 0 | 1 | 2, value: number) => { + updateSelectedBodyNode(node => { + const vector = [...node[property]] as [number, number, number] + vector[index] = value + return { ...node, [property]: vector } + }) + } + const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0] + const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes + const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null + const activeSequenceLabel = activeSequence + ? activeSequence.builtIn + ? t(activeSequence.name) + : activeSequence.name + : null + const expressionById = new Map(expressions.map(item => [item.id, item])) + const exportAnimationIdSet = new Set(exportAnimationIds) + const selectedExportAnimations = sequences.filter(animation => + exportAnimationIdSet.has(animation.id) + ) + const toggleExportAnimation = (animationId: string) => { + setExportAnimationIds(current => + current.includes(animationId) + ? current.filter(id => id !== animationId) + : [...current, animationId] + ) + } + const downloadAvatarExport = () => { + if (!selectedExportAnimations.length) return + const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations) + const isReact = exportFormat === 'react' + const extension = 'zip' + const blob = isReact + ? generateReactAvatarPackage(payload) + : generateJavaScriptAvatarPackage(payload, language) + downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension)) + } + const currentStudioDocument = (): StudioDocument => ({ + version: 2, + library: { activeAvatarId, avatars }, + expressions: baseBehavior.expressions, + sequences: baseBehavior.sequences, + playback: { + stateId: playbackStatus === 'stopped' ? null : (activeState ?? (selectedState || null)), + playing: statePlaying, + }, + }) + const downloadStudioProject = () => { + const blob = new Blob([serializeStudioDocument(currentStudioDocument())], { + type: 'application/json', + }) + downloadBlob(blob, 'avatar-studio-project.json') + } + const currentSnapshotSvg = () => + serializeAvatarSnapshot( + activeAvatar.name, + renderedScene, + { + body: renderedColors.body.get(), + eyes: renderedColors.eyes.get(), + }, + { + background: snapshotBackground, + colorFrom: snapshotColorFrom, + colorTo: snapshotColorTo, + size: Number(snapshotSize), + } + ) + + const downloadSnapshotSvg = () => { + downloadBlob( + new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }), + snapshotFileName(activeAvatar.name) + ) + } + const downloadSnapshotPng = () => { + const size = Number(snapshotSize) + const source = new Blob([currentSnapshotSvg()], { type: 'image/svg+xml;charset=utf-8' }) + const sourceUrl = URL.createObjectURL(source) + const image = new Image() + image.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + canvas.getContext('2d')?.drawImage(image, 0, 0, size, size) + URL.revokeObjectURL(sourceUrl) + canvas.toBlob(blob => { + if (blob) downloadBlob(blob, snapshotFileName(activeAvatar.name, 'png')) + }, 'image/png') + } + image.onerror = () => URL.revokeObjectURL(sourceUrl) + image.src = sourceUrl + } + const takePicture = () => { + setPhotoFlash(current => current + 1) + requestAnimationFrame(() => { + if (snapshotFormat === 'png') downloadSnapshotPng() + else downloadSnapshotSvg() + }) + } + const prepareStudioProjectImport = (file: File | undefined) => { + if (!file) return + setProjectImportError(null) + if (file.size > 10_000_000) { + setProjectImportError( + t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') + ) + return + } + file + .text() + .then(source => { + const imported = parseImportedStudioDocument(source, currentStudioDocument()) + setPendingProjectImport({ document: imported, fileName: file.name }) + }) + .catch(() => { + setProjectImportError( + t('Ce fichier ne contient pas un projet Avatar Studio valide et compatible.') + ) + }) + } + const confirmStudioProjectImport = () => { + if (!pendingProjectImport) return + if (!persistStudioDocument(pendingProjectImport.document)) { + setProjectImportError( + t( + 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.' + ) + ) + setPendingProjectImport(null) + return + } + window.location.reload() + } + const canvasExpression = editing?.draft ?? expression + const editorPageOpen = bodyEditing || editing !== null || sequenceEditing !== null + const expressionPendingDeletionId = + editing?.index !== null && editing?.index !== undefined + ? (expressions[editing.index]?.id ?? editing.draft.id) + : null + const animationsAffectedByExpressionDeletion = expressionPendingDeletionId + ? sequences.filter(sequence => + sequence.steps.some(step => step.expressionId === expressionPendingDeletionId) + ) + : [] + + useEffect(() => { + if (!editorPageOpen || focusAvatarName) return + const frame = requestAnimationFrame(() => workspaceBackButtonRef.current?.focus()) + return () => cancelAnimationFrame(frame) + }, [editorPageOpen, focusAvatarName]) + + const updateAvatarEyeDimension = (side: Side, dimension: 'width' | 'height', value: number) => { + const next = updateEyeDimension( + { ...defaultExpression, ...activeAvatarEyes }, + side, + dimension, + value, + linked[dimension] + ) + updateAvatarEyes({ + [`${dimension}Left`]: next[`${dimension}Left`], + [`${dimension}Right`]: next[`${dimension}Right`], + }) + } + const updateAvatarEyeSize = (side: Side, value: number) => { + const next = scaleEye({ ...defaultExpression, ...activeAvatarEyes }, side, value, linked.size) + updateAvatarEyes({ + widthLeft: next.widthLeft, + widthRight: next.widthRight, + heightLeft: next.heightLeft, + heightRight: next.heightRight, + }) + } + const updateAvatarEyePosition = (side: Side, axis: 'X' | 'Y', value: number) => { + const next = updateEyePosition( + { ...defaultExpression, ...activeAvatarEyes }, + side, + axis, + value, + linked.position + ) + updateAvatarEyes({ + [`position${axis}Left`]: next[`position${axis}Left`], + [`position${axis}Right`]: next[`position${axis}Right`], + }) + } + const persistEditedEyeExpression = (next: Expression) => { + updateAvatarEyes({ + widthLeft: next.widthLeft, + widthRight: next.widthRight, + heightLeft: next.heightLeft, + heightRight: next.heightRight, + spacing: next.spacing, + positionXLeft: next.positionXLeft, + positionXRight: next.positionXRight, + positionYLeft: next.positionYLeft, + positionYRight: next.positionYRight, + leftAngle: next.leftAngle, + rightAngle: next.rightAngle, + }) + } + + return ( +
+ +
+ + Bible Strong Avatar Lab +
+
+ + +
+ { + if (editing) { + setEditing(current => (current ? { ...current, draft: next } : current)) + } + transitionToExpression(next) + }} + onEyeChange={ + editing ? previewExpressionDraft : bodyEditing ? persistEditedEyeExpression : undefined + } + playback={ + activeSequenceLabel && playbackStatus !== 'stopped' + ? { name: activeSequenceLabel, status: playbackStatus } + : null + } + onManipulationStart={freezeLivePreviewForManipulation} + /> + {photoFlash > 0 && ( +
+ +
+ {sequenceEditing && !editing && ( + + + setSequenceEditing(current => (current ? { ...current, draft } : current)) + } + onPreviewStep={step => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (preset) transitionToExpression(preset, expressionIndex, step) + }} + onEditExpression={openExpressionEditor} + onPlay={() => launchSequence(sequenceEditing.draft, false, false)} + onPause={() => pauseState(false)} + onStop={() => stopState(false)} + playing={statePlaying} + active={activeState === sequenceEditing.draft.id} + onCancel={cancelSequenceEditing} + onSave={saveSequenceEditing} + onDuplicate={duplicateSequenceEditing} + onDelete={() => setDeleteSequenceOpen(true)} + /> + + )} + {editing && ( + + duplicateExpression(editing.index, editing.draft, true)} + onDelete={() => setDeleteExpressionOpen(true)} + /> + + )} + {!sequenceEditing && + !editing && + (bodyEditing ? ( +
+ +
+

{t('Construction du corps')}

+ renameActiveAvatar(event.currentTarget.value)} + onFocus={event => { + if (focusAvatarName) event.currentTarget.select() + }} + onBlur={event => { + if (!event.currentTarget.value.trim()) renameActiveAvatar('Unknown') + setFocusAvatarName(false) + }} + /> +

+ {t('Choisis la forme principale puis assemble les primitives autour d’elle.')} +

+
+
+ ) : ( + <> +
+
+ Avatars + {t('Double-clic pour modifier')} +
+
+ {avatars.map(avatar => ( + + + { + avatarDragOrigin.current = avatarsRef.current + avatarDragPreview.current = avatarsRef.current + draggedAvatarId.current = avatar.id + setDraggingAvatarId(avatar.id) + event.dataTransfer.effectAllowed = 'move' + }} + onDragEnter={() => previewAvatarMove(avatar.id)} + onDragOver={event => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }} + onDrop={event => { + event.preventDefault() + commitAvatarMove(avatar.id) + }} + onDragEnd={cancelAvatarMove} + onClick={() => activateAvatar(avatar.id, false, true)} + onDoubleClick={() => { + setFocusAvatarName(false) + activateAvatar(avatar.id, true) + }} + > + + {avatar.name} + + } + /> + + { + setFocusAvatarName(false) + activateAvatar(avatar.id, true) + }} + > + {t('Modifier')} + + duplicateAvatar(avatar)}> + {t('Dupliquer')} + + + { + activateAvatar(avatar.id, false, true) + setDeleteAvatarOpen(true) + }} + > + {t('Supprimer')} + + + + + ))} + +
+
+ setMode(value as Mode)}> + + {t('Pose')} + {t('Expressions')} + {t('Animations')} + {t('Exporter')} + + + + ))} + + {!editing && mode === 'manual' && ( +
+ {bodyEditing && ( + <> + + + +
+ + {bodyNodes.map(node => ( + + ))} +
+
+ + {t('Ajouter une forme')} · {bodyNodes.length}/{MAX_BODY_NODES} + +
+ {bodyPrimitiveTypes.map(type => ( + + ))} +
+
+ {selectedBodyNode && ( +
+
+ {selectedBodyNode.name} +
+ + +
+
+

+ {t('Gizmo local')} + {t( + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.' + )} +

+
+ + updateSelectedBodyNode(node => ({ + ...node, + surface: scaleSurface(node.surface, size, { + width: 10, + height: 10, + depth: 10, + }), + })) + } + /> + {(['width', 'height', 'depth'] as const).map(dimension => ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, [dimension]: value }, + })) + } + /> + ))} + {(selectedBodyNode.surface.type === 'cube' || + selectedBodyNode.surface.type === 'diamond' || + selectedBodyNode.surface.type === 'cylinder') && ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, roundness }, + })) + } + /> + )} + {(selectedBodyNode.surface.type === 'cylinder' || + selectedBodyNode.surface.type === 'cone') && ( + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, morphRoundness }, + })) + } + /> + )} + {selectedBodyNode.surface.type === 'cone' && ( + <> + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, tipRoundness }, + })) + } + /> + + updateSelectedBodyNode(node => ({ + ...node, + surface: { ...node.surface, baseRoundness }, + })) + } + /> + + )} +
+
+
+

{t('Position locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('position', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+

{t('Rotation locale')}

+ {(['X', 'Y', 'Z'] as const).map((axis, index) => ( + + updateNodeVector('rotation', index as 0 | 1 | 2, value) + } + /> + ))} +
+
+
+ )} +
+ + +
+ {bodyPrimitiveTypes.map(type => { + const previewSurface = + type === surface.type ? surface : surfacePresets[type] + return ( + + ) + })} +
+
+ + updateSurface( + scaleSurface(surface, size, { width: 120, height: 120, depth: 100 }) + ) + } + /> + updateSurface({ ...surface, width })} + /> + updateSurface({ ...surface, height })} + /> + updateSurface({ ...surface, depth })} + /> + {(surface.type === 'cube' || surface.type === 'diamond') && ( + updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {surface.type === 'cylinder' && ( + updateHighlight(active ? 'head' : null)} + onChange={roundness => updateSurface({ ...surface, roundness })} + /> + )} + {(surface.type === 'cylinder' || surface.type === 'cone') && ( + updateHighlight(active ? 'head' : null)} + onChange={morphRoundness => updateSurface({ ...surface, morphRoundness })} + /> + )} + {surface.type === 'cone' && ( + <> + updateHighlight(active ? 'head' : null)} + onChange={tipRoundness => updateSurface({ ...surface, tipRoundness })} + /> + updateHighlight(active ? 'head' : null)} + onChange={baseRoundness => updateSurface({ ...surface, baseRoundness })} + /> + + )} +
+
+ + + updateAvatarColors({ body })} + /> + +
+ +

+ {t( + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.' + )} +

+ {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = activeAvatarEyes[`width${side}`] + const height = activeAvatarEyes[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateAvatarEyeSize(side, next) + : updateAvatarEyeDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + +
+
+

{t('Position et espacement')}

+

+ {t('Coordonnées propres à l’avatar, indépendantes des poses.')} +

+
+ + setLinked(current => ({ ...current, position: !current.position })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => ( +
+

{t(side === 'Left' ? 'Œil gauche' : 'Œil droit')}

+ + updateHighlight( + active + ? linked.position + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={value => updateAvatarEyePosition(side, 'X', value)} + /> + + updateHighlight( + active + ? linked.position + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={value => updateAvatarEyePosition(side, 'Y', value)} + /> +
+ ))} +
+
+ updateHighlight(active ? 'both' : null)} + onChange={spacing => updateAvatarEyes({ spacing })} + /> +
+
+ +
+ + + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={leftAngle => + updateAvatarEyes({ + leftAngle, + ...(linked.rotation ? { rightAngle: -leftAngle } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={rightAngle => + updateAvatarEyes({ + rightAngle, + ...(linked.rotation ? { leftAngle: -rightAngle } : {}), + }) + } + /> +
+
+ + + updateAvatarColors({ eyes })} + /> + +
+ + )} + {!bodyEditing && ( + <> + + + + updateImmediate({ ...expression, bodyColor })} + /> + {expression.bodyColor && ( + + )} + + + + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headX: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headY: value })} + /> + updateHighlight(active ? 'head' : null)} + onChange={value => updateImmediate({ ...expression, headZ: value })} + /> + + + + + + updateImmediate({ ...expression, eyeColor })} + /> + {expression.eyeColor && ( + + )} + + {(['width', 'height', 'size'] as const).map(dimension => ( + +
+

+ {t( + { + width: 'Largeur', + height: 'Hauteur', + size: 'Taille proportionnelle', + }[dimension] + )} +

+ + setLinked(current => ({ + ...current, + [dimension]: !current[dimension], + })) + } + /> +
+
+ {(['Left', 'Right'] as Side[]).map(side => { + const width = expression[`width${side}`] + const height = expression[`height${side}`] + const value = + dimension === 'width' + ? width + : dimension === 'height' + ? height + : Math.max(width, height) + return ( + + updateHighlight( + active + ? linked[dimension] + ? 'both' + : side === 'Left' + ? 'left' + : 'right' + : null + ) + } + onChange={next => + dimension === 'size' + ? updateSize(side, next) + : updateDimension(side, dimension, next) + } + /> + ) + })} +
+
+ ))} + + +
+
+

{t('Œil gauche')}

+ updateHighlight(active ? 'left' : null)} + onChange={value => + updateImmediate({ ...expression, positionXLeft: value }) + } + /> + updateHighlight(active ? 'left' : null)} + onChange={value => + updateImmediate({ ...expression, positionYLeft: value }) + } + /> +
+
+

{t('Œil droit')}

+ updateHighlight(active ? 'right' : null)} + onChange={value => + updateImmediate({ ...expression, positionXRight: value }) + } + /> + updateHighlight(active ? 'right' : null)} + onChange={value => + updateImmediate({ ...expression, positionYRight: value }) + } + /> +
+
+
+ updateHighlight(active ? 'both' : null)} + onChange={updateSpacing} + /> +
+
+ +
+ + + setLinked(current => ({ ...current, rotation: !current.rotation })) + } + /> +
+
+ + updateHighlight(active ? (linked.rotation ? 'both' : 'left') : null) + } + onChange={value => + updateImmediate({ + ...expression, + leftAngle: value, + ...(linked.rotation ? { rightAngle: -value } : {}), + }) + } + /> + + updateHighlight(active ? (linked.rotation ? 'both' : 'right') : null) + } + onChange={value => + updateImmediate({ + ...expression, + rightAngle: value, + ...(linked.rotation ? { leftAngle: -value } : {}), + }) + } + /> +
+
+
+ + + + updateImmediate({ ...expression, perspective: value })} + /> +
+ {t('Afficher le maillage')} + +
+ +
+
+ + )} +
+ )} + + {!sequenceEditing && !editing && bodyEditing && ( +
+
+ + +
+ +
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'expressions' && ( +
+ +
+
+

{expressions.length} presets

+

{t('Expressions')}

+
+ {t('Double-clic pour modifier')} +
+
+ {expressions.map((preset, index) => ( + + transitionToExpression(preset, index)} + onEdit={() => openExpressionEditor(index, preset)} + onDuplicate={() => duplicateExpression(index, preset)} + onDelete={() => { + openExpressionEditor(index, preset) + setDeleteExpressionOpen(true) + }} + draggable + onDragStart={event => { + expressionDragOrigin.current = expressions + expressionDragPreview.current = expressions + draggedExpressionId.current = preset.id + setDraggingExpressionId(preset.id) + event.dataTransfer.effectAllowed = 'move' + }} + onDragEnter={() => previewExpressionMove(preset.id)} + onDragOver={event => { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + }} + onDrop={event => { + event.preventDefault() + commitExpressionMove(preset.id) + }} + onDragEnd={cancelExpressionMove} + /> + + ))} + +
+
+ + + { + springSpeedRef.current = value + setSpringSpeed(value) + }} + /> +
+ + +
+
+
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'states' && ( +
+ +
+
+

+ {sequences.length} {t('animations')} +

+

{t('Animations')}

+
+
+
+ {groupSequences(sequences).map(group => ( +
event.preventDefault()} + onDrop={event => { + event.preventDefault() + commitStateMove(null, group.name) + }} + > + + {group.sequences.every(sequence => sequence.builtIn) + ? t(group.name) + : group.name} + +
+ {group.sequences.map(sequence => { + const firstStep = sequence.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + const card = ( + + ) + return ( + + + + + openSequenceEditor(sequence)}> + + {t('Modifier')} + + duplicateState(sequence)}> + + {t('Dupliquer')} + + + { + openSequenceEditor(sequence) + setDeleteSequenceOpen(true) + }} + > + + {t('Supprimer')} + + + + + ) + })} +
+
+ ))} +
+ +
+
+
+
+ )} + + {!sequenceEditing && !editing && !bodyEditing && mode === 'export' && ( + + + +
+ +
+ {t('Avatar sélectionné')} + {activeAvatar.name} +
+
+
+ + + +
+ + +
+
+ + +
+
+

+ {selectedExportAnimations.length}/{sequences.length} {t('sélectionnées')} +

+

{t('Animations à exporter')}

+
+ +
+
+ {sequences.map(animation => { + const firstStep = animation.steps[0] + const firstExpression = firstStep + ? expressionById.get(firstStep.expressionId) + : undefined + return ( + + ) + })} +
+
+ + +
+ + + + +
+ {t('Aperçu du mode photo')} + {activeAvatar.name} + + {snapshotSize} × {snapshotSize} px · {snapshotFormat.toUpperCase()} + +
+
+ + + + + {t('Style')} + + + {snapshotBackground !== 'transparent' && ( +
+ + {(snapshotBackground === 'linear' || snapshotBackground === 'radial') && ( + + )} +
+ )} +
+ + + +
+ {t('Format d’export')} + {t('Choisis le type de fichier généré par le mode photo.')} +
+ +
+ + +
+ {t('Définition')} + {t('Dimensions du fichier exporté.')} +
+ +
+
+
+ + +
+ + + { + prepareStudioProjectImport(event.currentTarget.files?.[0]) + event.currentTarget.value = '' + }} + /> +
+ {projectImportError && ( +

+ {projectImportError} +

+ )} +
+
+ )} + {activeSequence && !editorPageOpen && ( + + + {statePlayerExpanded && ( + +
+
+

{t('Détails de l’animation')}

+

{activeSequenceLabel}

+

+ {activeSequence.builtIn + ? t(activeSequence.description) + : activeSequence.description} +

+
+ + {t('Mode de lecture')} · {t(activeSequence.playbackMode)} + +
+
+
+ {t('Expressions')} + {activeSequence.steps.length} + + {activeSequence.steps + .map(step => formatSeconds(step.holdMs, language)) + .join(' · ')} + +
+
+ {t('Premier clignement')} + + {activeSequence.blink.enabled + ? formatSeconds(activeSequence.blink.initialDelayMs, language) + : t('Désactivé')} + + {t('après le lancement')} +
+
+ {t('Intervalle du clignement')} + + {formatSeconds(activeSequence.blink.minIntervalMs, language)}– + {formatSeconds(activeSequence.blink.maxIntervalMs, language)} + + {t('tirage aléatoire')} +
+
+ {t('Durée du clignement')} + {activeSequence.blink.durationMs} ms + {t('fermeture et ouverture')} +
+
+
+ )} +
+
+ {activeSequence.steps.map((step, position) => { + const expressionIndex = findExpressionIndex(expressions, step.expressionId) + const preset = expressions[expressionIndex] + if (!preset) return null + return ( + + ) + })} +
+
+ +
+
+
+ )} +
+ + + + {t(`Supprimer ${activeAvatar.name} ?`)} + + {t( + 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.' + )} + + + + {t('Annuler')} + {t('Supprimer')} + + + + + + + {t('Supprimer cette expression ?')} + + {t( + 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.' + )} + + {animationsAffectedByExpressionDeletion.length > 0 && ( +
+

+ {t('Cette expression sera aussi retirée des animations suivantes :')} +

+
+ {animationsAffectedByExpressionDeletion.map(sequence => ( + + {sequence.name} + + ))} +
+

+ {t( + 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.' + )} +

+
+ )} +
+ + {t('Annuler')} + {t('Supprimer')} + +
+
+ + + + {t('Supprimer cette animation ?')} + + {t('Cette action supprimera définitivement cette animation.')} + + + + {t('Annuler')} + {t('Supprimer')} + + + + { + if (!open) setPendingProjectImport(null) + }} + > + + + {t('Importer ce projet ?')} + + {t( + 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.' + )}{' '} + {pendingProjectImport?.fileName} + + + + {t('Annuler')} + + {t('Importer')} + + + + +
+ ) +} + +function ControlSection({ + title, + subtitle, + compact = false, + children, +}: { + title: string + subtitle: string + compact?: boolean + children: React.ReactNode +}) { + const { t } = useStudioLanguage() + return ( +
+
+

{t(title)}

+

{t(subtitle)}

+
+ +
{children}
+
+ ) +} + +function SnapshotPreview({ + scene, + colors, + background, + colorFrom, + colorTo, +}: { + scene: RenderedScene + colors: RenderedColors + background: SnapshotBackground + colorFrom: string + colorTo: string +}) { + const id = useId().replace(/:/g, '') + const clipId = `${id}-clip` + const linearId = `${id}-linear` + const radialId = `${id}-radial` + const backgroundFill = + background === 'solid' + ? colorFrom + : background === 'linear' + ? `url(#${linearId})` + : `url(#${radialId})` + + return ( +
+ +
+ ) +} + +function ExportSection({ + value, + title, + subtitle, + children, +}: { + value: string + title: string + subtitle: string + children: React.ReactNode +}) { + const { t } = useStudioLanguage() + return ( + + + + {t(title)} + {t(subtitle)} + + + +
{children}
+
+
+ ) +} + +function InspectorCard({ className, ...props }: React.ComponentProps) { + return +} + +function StatePlayer({ + name, + status, + onToggle, + onStop, +}: { + name: string | null + status: PlaybackStatus + onToggle: () => void + onStop: () => void +}) { + const { t } = useStudioLanguage() + if (!name) return null + const statusLabel = + status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté' + return ( +
+ + + +
+ ) +} + +function PlaybackIdentity({ name, status }: { name: string; status: PlaybackStatus }) { + const { t } = useStudioLanguage() + const statusLabel = + status === 'playing' ? 'En lecture' : status === 'paused' ? 'En pause' : 'Arrêté' + return ( + + + + {t(statusLabel)} + {name} + + + ) +} + +function PanelTitle({ + title, + subtitle, + level = 2, +}: { + title: string + subtitle: string + level?: 2 | 3 +}) { + const { t } = useStudioLanguage() + const Heading = level === 3 ? 'h3' : 'h2' + return ( + + {t(title)} + {t(subtitle)} + + ) +} + +export default function App() { + return ( + + + + ) +} diff --git a/src/__tests__/ambient-motion-test.ts b/src/__tests__/ambient-motion-test.ts new file mode 100644 index 0000000..8ebdcdc --- /dev/null +++ b/src/__tests__/ambient-motion-test.ts @@ -0,0 +1,46 @@ +import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../ambientMotion' +import { defaultExpression } from '../presets' + +describe('perpetual expression motion', () => { + it('leaves a motionless expression unchanged', () => { + const expression = { ...defaultExpression } + + expect(hasAmbientMotion(expression)).toBe(false) + expect(applyAmbientMotion(expression, 500)).toEqual(expression) + }) + + it('moves both eyes together without mutating the saved expression', () => { + const expression = { ...defaultExpression, eyeMotion: 'microSaccades' as const } + const animated = applyAmbientMotion(expression, 500) + + expect(animated.positionXLeft - expression.positionXLeft).toBeCloseTo( + animated.positionXRight - expression.positionXRight + ) + expect(animated.positionYLeft - expression.positionYLeft).toBeCloseTo( + animated.positionYRight - expression.positionYRight + ) + expect(animated.positionXLeft).not.toBe(expression.positionXLeft) + expect(expression.positionXLeft).toBe(defaultExpression.positionXLeft) + }) + + it('adds body motion without changing eye placement', () => { + const expression = { ...defaultExpression, bodyMotion: 'shake' as const } + const animated = applyAmbientMotion(expression, 500) + + expect(animated.headY).not.toBe(expression.headY) + expect(animated.positionXLeft).toBe(expression.positionXLeft) + expect(animated.positionYRight).toBe(expression.positionYRight) + }) + + it('visibly offsets a symmetric body when it shakes', () => { + const expression = { ...defaultExpression, bodyMotion: 'shake' as const } + + expect(ambientBodyOffset(expression, 500)).not.toEqual({ x: 0, y: 0 }) + }) + + it('visibly offsets a symmetric body during slow drift', () => { + const expression = { ...defaultExpression, bodyMotion: 'slowDrift' as const } + + expect(ambientBodyOffset(expression, 1500)).not.toEqual({ x: 0, y: 0 }) + }) +}) diff --git a/src/__tests__/avatars-test.ts b/src/__tests__/avatars-test.ts new file mode 100644 index 0000000..612bd9a --- /dev/null +++ b/src/__tests__/avatars-test.ts @@ -0,0 +1,59 @@ +import { defaultExpression } from '../presets' +import { createInitialSequences } from '../sequences' +import { + applyAvatarEyeDefaults, + cloneAvatarBehavior, + createAvatar, + defaultAvatarEyes, + parseAvatarEyeDefaults, + resolveAvatarBehavior, +} from '../avatars' +import { initialExpressions } from '../presets' + +describe('avatar eye defaults', () => { + it('keeps the historical rendering when using default values', () => { + expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression) + }) + + it('composes avatar defaults as variations around the neutral expression', () => { + const expression = { ...defaultExpression, widthLeft: 28, positionYLeft: 5 } + const eyes = { ...defaultAvatarEyes, widthLeft: 30, positionYLeft: -12 } + + const result = applyAvatarEyeDefaults(expression, eyes) + + expect(result.widthLeft).toBe(38) + expect(result.positionYLeft).toBe(0) + expect(expression.widthLeft).toBe(28) + }) + + it('sanitizes partial persisted values', () => { + const result = parseAvatarEyeDefaults({ widthLeft: 42, heightRight: Number.NaN }) + + expect(result.widthLeft).toBe(42) + expect(result.heightRight).toBe(defaultAvatarEyes.heightRight) + expect(result.spacing).toBe(defaultAvatarEyes.spacing) + }) +}) + +describe('avatar behavior library', () => { + const base = { + expressions: initialExpressions, + sequences: createInitialSequences(), + } + + it('inherits the base library until the avatar owns a customization', () => { + const avatar = createAvatar('Strobi') + + expect(resolveAvatarBehavior(avatar, base)).toBe(base) + }) + + it('clones expressions, animations and nested steps as one independent library', () => { + const behavior = cloneAvatarBehavior(base) + + expect(behavior).not.toBe(base) + expect(behavior.expressions).not.toBe(base.expressions) + expect(behavior.sequences).not.toBe(base.sequences) + expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps) + expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink) + }) +}) diff --git a/src/__tests__/canvas-preview-test.ts b/src/__tests__/canvas-preview-test.ts new file mode 100644 index 0000000..8b37226 --- /dev/null +++ b/src/__tests__/canvas-preview-test.ts @@ -0,0 +1,37 @@ +import { applyAvatarEyeDefaults, defaultAvatarEyes } from '../avatars' +import { resolveCanvasPreviewExpression } from '../canvasPreview' +import { defaultExpression } from '../presets' + +describe('canvas preview expression', () => { + it('keeps customized neutral eyes applied while rotating the body preview', () => { + const eyes = { + ...defaultAvatarEyes, + widthLeft: 37, + positionYLeft: -18, + leftAngle: 21, + rightAngle: -21, + } + const draggedExpression = { ...defaultExpression, headX: 24, headY: 31 } + + expect(resolveCanvasPreviewExpression(draggedExpression, eyes, true, 'head')).toEqual( + applyAvatarEyeDefaults(draggedExpression, eyes) + ) + }) + + it('does not compose neutral eyes twice outside body editing', () => { + const eyes = { ...defaultAvatarEyes, widthLeft: 37 } + + expect(resolveCanvasPreviewExpression(defaultExpression, eyes, false, 'head')).toBe( + defaultExpression + ) + }) + + it('does not compose customized eyes twice while editing an eye handle', () => { + const eyes = { ...defaultAvatarEyes, widthLeft: 37 } + const customizedExpression = applyAvatarEyeDefaults(defaultExpression, eyes) + + expect(resolveCanvasPreviewExpression(customizedExpression, eyes, true, 'eyes')).toBe( + customizedExpression + ) + }) +}) diff --git a/src/__tests__/exporter-test.ts b/src/__tests__/exporter-test.ts new file mode 100644 index 0000000..d6cf933 --- /dev/null +++ b/src/__tests__/exporter-test.ts @@ -0,0 +1,154 @@ +import { createAvatar } from '../avatars' +import { parse } from '@babel/parser' +import { + createAvatarExportPayload, + generateJavaScriptAvatarHtml, + generateJavaScriptAvatarModule, + generateJavaScriptAvatarPackage, + generateReactAvatarComponent, + generateReactAvatarPackage, + generateReactAvatarRuntime, +} from '../exporter' +import { initialExpressions } from '../presets' +import { createInitialSequences } from '../sequences' + +describe('avatar export', () => { + const avatar = createAvatar('Strobi') + const animations = createInitialSequences().filter(item => + ['idle', 'listening'].includes(item.id) + ) + const payload = createAvatarExportPayload(avatar, initialExpressions, animations) + + it('includes only the selected animations and their referenced expressions', () => { + expect(Object.keys(payload.animations)).toEqual(['idle', 'listening']) + expect(Object.keys(payload.expressions).sort()).toEqual( + [ + ...new Set(animations.flatMap(animation => animation.steps.map(step => step.expressionId))), + ].sort() + ) + expect(payload).not.toHaveProperty('frames') + expect(payload.avatar.name).toBe('Strobi') + }) + + it('generates a standalone JavaScript module without a Web Component', () => { + const source = generateJavaScriptAvatarModule(payload) + + expect(source).toContain('export function createAvatar') + expect(source).toContain('play(animationName)') + expect(source).toContain('export const availableAnimations') + expect(source).toContain('pausedRemainingMs') + expect(source).toContain('pausedBlinkDelay') + expect(source).toContain('pausedBlink') + expect(source).toContain('step.transition') + expect(source).toContain('stepIndex = 0') + expect(source).not.toContain('customElements.define') + expect(source).not.toContain("from '") + expect(() => parse(source, { sourceType: 'module' })).not.toThrow() + }) + + it('uses the Studio geometry engine to render interpolated transition frames', () => { + const source = generateJavaScriptAvatarModule(payload) + + expect(source).toContain('AvatarProceduralEngine') + expect(source).toContain('expressionFields') + expect(source).toContain('requestAnimationFrame') + expect(source).not.toContain('avatarFrameIn') + }) + + it('generates an HTML demo that works when opened directly from the filesystem', () => { + const source = generateJavaScriptAvatarHtml(payload) + + expect(source).not.toContain("import { createAvatar } from './avatar.js'") + expect(source).toContain('function createAvatar') + expect(source).toContain('avatar.play("idle")') + }) + + it('generates an interactive playground for the exported animations', () => { + const source = generateJavaScriptAvatarHtml(payload) + + expect(source).toContain('id="animation-list"') + expect(source).toContain('id="play-animation"') + expect(source).toContain('id="pause-animation"') + expect(source).toContain('id="stop-animation"') + expect(source).toContain('availableAnimations.forEach') + expect(source).toContain('avatar.play(activeAnimation)') + expect(source).toContain('avatar.pause()') + expect(source).toContain('avatar.stop()') + expect(source).toContain("createAvatar('#avatar'") + }) + + it('localizes the exported playground', () => { + const source = generateJavaScriptAvatarHtml(payload, 'fr') + + expect(source).toContain('') + expect(source).toContain('Choisissez une animation exportée') + expect(source).toContain('▶ Lire') + expect(source).toContain('En pause') + }) + + it('keeps user-authored names and descriptions inside the generated script', () => { + const source = generateJavaScriptAvatarHtml({ + ...payload, + avatar: { ...payload.avatar, name: '' }, + }) + + expect(source).not.toContain('') + expect(source).toContain('</title>') + expect(source).toContain('\\u003c/script>') + }) + + it('packages the direct-open demo and reusable module together', async () => { + const archive = new Uint8Array(await generateJavaScriptAvatarPackage(payload).arrayBuffer()) + const contents = new TextDecoder().decode(archive) + + expect(contents).toContain('index.html') + expect(contents).toContain('avatar.js') + }) + + it('generates a typed React component backed by the local runtime', () => { + const source = generateReactAvatarComponent(payload) + + expect(source).toContain("from 'react'") + expect(source).toContain("from './avatar-runtime'") + expect(source).toContain("from './strobi.avatar'") + expect(source).toContain('export type { AnimationName }') + expect(source).toContain('animation?: AnimationName') + expect(source).toContain('forwardRef') + expect(source).not.toContain('AvatarProceduralEngine') + expect(source).toContain('runtime.createAvatar(host.current') + expect(source).not.toContain('@ts-nocheck') + expect(source).not.toContain('avatarFrameIn') + expect(source).not.toContain("from 'motion") + expect(() => + parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] }) + ).not.toThrow() + }) + + it('packages the React runtime, avatar data and component separately', async () => { + const archive = new Uint8Array(await generateReactAvatarPackage(payload).arrayBuffer()) + const contents = new TextDecoder().decode(archive) + + expect(contents).toContain('avatar-runtime.ts') + expect(contents).toContain('strobi.avatar.ts') + expect(contents).toContain('Strobi.tsx') + expect(contents).toContain('strobi.index.ts') + expect(contents).toContain("from './avatar-runtime'") + }) + + it('generates one avatar-independent and versioned React runtime', () => { + const runtime = generateReactAvatarRuntime() + + expect(runtime).toContain('AVATAR_RUNTIME_VERSION = 1') + expect(runtime).toContain('data.version !== AVATAR_RUNTIME_VERSION') + expect(runtime).not.toContain(payload.avatar.name) + expect(runtime).toContain('RuntimeAvatar') + expect(() => parse(runtime, { sourceType: 'module', plugins: ['typescript'] })).not.toThrow() + }) + + it('uses globally unique SVG identifiers across avatar runtimes', () => { + const source = generateJavaScriptAvatarModule(payload) + + expect(source).toContain('globalThis.crypto.randomUUID()') + expect(source).not.toContain('avatarInstanceCount') + }) +}) diff --git a/src/__tests__/expression-editing-test.ts b/src/__tests__/expression-editing-test.ts new file mode 100644 index 0000000..88c3567 --- /dev/null +++ b/src/__tests__/expression-editing-test.ts @@ -0,0 +1,24 @@ +import { updateEyeDimension, scaleEye, updateEyePosition } from '../expressionEditing' +import { defaultExpression } from '../presets' + +describe('Expression eye editing', () => { + it('applies linked dimensions through one invariant-preserving operation', () => { + const next = updateEyeDimension(defaultExpression, 'Left', 'width', 140, true) + expect(next.widthLeft).toBe(100) + expect(next.widthRight).toBe(100) + }) + + it('scales both eyes proportionally without exceeding eye bounds', () => { + const next = scaleEye(defaultExpression, 'Left', 100, true) + expect(next.heightLeft).toBe(100) + expect(next.widthLeft).toBe(40) + expect(next.heightRight).toBe(100) + expect(next.widthRight).toBe(40) + }) + + it('keeps linked eye positions symmetrical as an editing rule', () => { + const next = updateEyePosition(defaultExpression, 'Right', 'Y', 12, true) + expect(next.positionYLeft).toBe(12) + expect(next.positionYRight).toBe(12) + }) +}) diff --git a/src/__tests__/geometry-editor-test.ts b/src/__tests__/geometry-editor-test.ts new file mode 100644 index 0000000..dbe65e9 --- /dev/null +++ b/src/__tests__/geometry-editor-test.ts @@ -0,0 +1,91 @@ +import type { BodyNode } from '../body' +import { + poseFromExpression, + renderAvatar, + renderBodyNodeEditor, + rotateBodyNodeAroundLocalAxis, + translateBodyNodeInCameraPlane, + translateBodyNodeAlongLocalAxis, +} from '../geometry' +import { defaultExpression } from '../presets' +import { surfacePresets } from '../surfaces' + +const node: BodyNode = { + id: 'shape-test', + name: 'Forme test', + surface: surfacePresets.cube, + position: [0, 0, -20], + rotation: [0, 0, 0], +} + +describe('body node editor geometry', () => { + it('keeps all translation axes manipulable when one points at the camera', () => { + const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), node) + + expect( + Math.hypot(editor.axes.z[0] - editor.center[0], editor.axes.z[1] - editor.center[1]) + ).toBeGreaterThanOrEqual(12) + expect(Object.values(editor.axes).flat().every(Number.isFinite)).toBe(true) + }) + + it('projects complete local rotation rings around the selected node', () => { + const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), { + ...node, + rotation: [20, -15, 35], + }) + + expect(editor.rings.x).toHaveLength(65) + expect(editor.rings.y).toHaveLength(65) + expect(editor.rings.z).toHaveLength(65) + expect(editor.rings.z[0]).not.toEqual(editor.rings.z[16]) + }) + + it('moves along the rotated local axis', () => { + const translated = translateBodyNodeAlongLocalAxis({ ...node, rotation: [0, 0, 90] }, 'x', 20) + + expect(translated.position[0]).toBeCloseTo(0) + expect(translated.position[1]).toBeCloseTo(20) + expect(translated.position[2]).toBeCloseTo(-20) + }) + + it('moves freely in the camera plane regardless of head rotation', () => { + const pose = poseFromExpression({ ...defaultExpression, headX: 24, headY: -31, headZ: 18 }) + const before = renderBodyNodeEditor(pose, node).center + const translated = translateBodyNodeInCameraPlane(node, pose, 18, -9) + const after = renderBodyNodeEditor(pose, translated).center + + expect(after[0] - before[0]).toBeCloseTo(18) + expect(after[1] - before[1]).toBeCloseTo(-9) + expect(translated.position[2]).not.toBe(node.position[2]) + }) + + it('composes a local rotation instead of incrementing a raw Euler field', () => { + const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [25, 30, -20] }, 'x', 15) + + expect(rotated.rotation.every(Number.isFinite)).toBe(true) + expect(rotated.rotation).not.toEqual([40, 30, -20]) + }) + + it('keeps equivalent Euler values near their current free rotation', () => { + const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [0, 0, 350] }, 'x', 1) + + expect(rotated.rotation[2]).toBeGreaterThan(300) + }) + + it('keeps a spherical silhouette path stable across head rotations', () => { + const neutralPath = renderAvatar( + poseFromExpression(defaultExpression), + surfacePresets.sphere, + 1, + { includeWire: false } + ).headPath + const rotatedPath = renderAvatar( + poseFromExpression({ ...defaultExpression, headX: 17, headY: 34, headZ: -21 }), + surfacePresets.sphere, + 1, + { includeWire: false } + ).headPath + + expect(rotatedPath).toBe(neutralPath) + }) +}) diff --git a/src/__tests__/i18n-test.ts b/src/__tests__/i18n-test.ts new file mode 100644 index 0000000..f68e0cf --- /dev/null +++ b/src/__tests__/i18n-test.ts @@ -0,0 +1,45 @@ +import { translateStudioText } from '../i18n' + +describe('avatar studio translations', () => { + it('uses English for static interface copy', () => { + expect(translateStudioText('Couleur des yeux', 'en')).toBe('Eye color') + }) + + it('translates dynamic editor labels', () => { + expect(translateStudioText('Modifier l’expression 08', 'en')).toBe('Edit expression 08') + expect(translateStudioText('Sphère · porte les yeux', 'en')).toBe('Sphere · carries the eyes') + expect(translateStudioText('Cône 1 copie', 'en')).toBe('Cone 1 copy') + }) + + it('keeps the original French copy in French', () => { + expect(translateStudioText('Nouvel avatar', 'fr')).toBe('Nouvel avatar') + expect(translateStudioText('sleeping', 'fr')).toBe('sommeil') + }) + + it('translates the studio interface and dynamic labels to Simplified Chinese', () => { + expect(translateStudioText('Couleur des yeux', 'zh-CN')).toBe('眼睛颜色') + expect(translateStudioText('Modifier l’expression 08', 'zh-CN')).toBe('编辑表情 08') + expect(translateStudioText('sleeping', 'zh-CN')).toBe('睡眠') + }) + + it('translates every Photo Mode control to Simplified Chinese', () => { + expect(translateStudioText('Mode photo', 'zh-CN')).toBe('照片模式') + expect(translateStudioText('Dégradé radial', 'zh-CN')).toBe('径向渐变') + expect(translateStudioText('Définition du mode photo', 'zh-CN')).toBe('照片模式分辨率') + expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG') + expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照') + expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式') + }) + + it('covers every configured state description in English', () => { + expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe( + 'Steady rhythm and focused expressions.' + ) + expect(translateStudioText('Grandes expressions et transitions rapides.', 'en')).toBe( + 'Big expressions and fast transitions.' + ) + expect(translateStudioText('Inclinaisons et forte asymétrie.', 'en')).toBe( + 'Tilts and strong asymmetry.' + ) + }) +}) diff --git a/src/__tests__/manipulation-session-test.ts b/src/__tests__/manipulation-session-test.ts new file mode 100644 index 0000000..7b8d4d4 --- /dev/null +++ b/src/__tests__/manipulation-session-test.ts @@ -0,0 +1,47 @@ +import { + beginManipulation, + beginManipulationFromRenderedValue, + finishManipulation, + previewManipulation, +} from '../manipulationSession' + +describe('pointer manipulation session', () => { + it('previews many values and commits exactly once', () => { + const previews: number[] = [] + const commits: number[] = [] + const session = beginManipulation(10) + previewManipulation(session, 11, value => previews.push(value)) + previewManipulation(session, 12, value => previews.push(value)) + + finishManipulation(session, 'commit', { + preview: value => previews.push(value), + commit: value => commits.push(value), + }) + + expect(previews).toEqual([11, 12]) + expect(commits).toEqual([12]) + }) + + it('restores the initial value when pointer capture is cancelled', () => { + const previews: number[] = [] + const session = beginManipulation(10) + previewManipulation(session, 20, () => undefined) + + finishManipulation(session, 'cancel', { + preview: value => previews.push(value), + commit: () => undefined, + }) + + expect(previews).toEqual([10]) + }) + + it('starts from the live rendered value while an interpolation is running', () => { + const staleInspectorValue = { headX: 0, headY: 0 } + const renderedMidTransition = { headX: 18, headY: 27 } + + const session = beginManipulationFromRenderedValue(() => renderedMidTransition) + + expect(session.initial).toBe(renderedMidTransition) + expect(session.initial).not.toBe(staleInspectorValue) + }) +}) diff --git a/src/__tests__/playback-test.ts b/src/__tests__/playback-test.ts new file mode 100644 index 0000000..a6fe56a --- /dev/null +++ b/src/__tests__/playback-test.ts @@ -0,0 +1,43 @@ +import { createInitialSequences } from '../sequences' +import { + advancePlaybackTimeline, + beginPlayback, + createPlaybackTimeline, + pausePlaybackTimeline, + schedulePlaybackBlink, + schedulePlaybackStep, +} from '../playback' + +describe('State playback timeline', () => { + it('preserves the current sequence position when the avatar changes', () => { + const timeline = createPlaybackTimeline() + timeline.position = 1 + timeline.direction = -1 + + const preserved = beginPlayback(timeline, true) + + expect(preserved.position).toBe(1) + expect(preserved.direction).toBe(-1) + }) + + it('pauses and resumes from the exact remaining delays', () => { + const timeline = createPlaybackTimeline() + const scheduledStep = schedulePlaybackStep(timeline, 1_000, 500) + const scheduled = schedulePlaybackBlink(scheduledStep, 1_000, 900) + + const paused = pausePlaybackTimeline(scheduled, 1_200) + + expect(paused.stepRemainingMs).toBe(300) + expect(paused.blinkRemainingMs).toBe(700) + }) + + it('owns cursor advancement for the active State', () => { + const idle = createInitialSequences().find(sequence => sequence.id === 'idle')! + const timeline = createPlaybackTimeline() + + const result = advancePlaybackTimeline(timeline, idle) + + expect(result.cursor.complete).toBe(false) + expect(result.timeline.position).toBe(1) + }) +}) diff --git a/src/__tests__/presets-test.ts b/src/__tests__/presets-test.ts new file mode 100644 index 0000000..563884e --- /dev/null +++ b/src/__tests__/presets-test.ts @@ -0,0 +1,26 @@ +import { getStatePlaybackConfig } from '../presets' + +describe('state playback configuration', () => { + it('keeps idle slower than an active sequence', () => { + expect(getStatePlaybackConfig('idle').expressionIntervalMs).toBe(5200) + expect(getStatePlaybackConfig('listening').expressionIntervalMs).toBe(2300) + }) + + it('describes a valid randomized blink interval', () => { + const { blink } = getStatePlaybackConfig('idle') + + expect(blink.initialDelayMs).toBeGreaterThan(0) + expect(blink.minIntervalMs).toBeLessThan(blink.maxIntervalMs) + expect(blink.durationMs).toBe(280) + }) + + it('uses a blink rhythm adapted to each sequence family', () => { + const sleeping = getStatePlaybackConfig('sleeping').blink + const listening = getStatePlaybackConfig('listening').blink + const excited = getStatePlaybackConfig('excited').blink + + expect(sleeping.minIntervalMs).toBeGreaterThan(listening.minIntervalMs) + expect(listening.minIntervalMs).toBeGreaterThan(excited.minIntervalMs) + expect(sleeping.durationMs).toBeGreaterThan(excited.durationMs) + }) +}) diff --git a/src/__tests__/rendered-rotation-gizmo-test.ts b/src/__tests__/rendered-rotation-gizmo-test.ts new file mode 100644 index 0000000..5ca3274 --- /dev/null +++ b/src/__tests__/rendered-rotation-gizmo-test.ts @@ -0,0 +1,20 @@ +import { defaultExpression } from '../presets' +import { createRenderedRotationGizmo, paintRenderedRotationGizmo } from '../renderedRotationGizmo' + +describe('rendered rotation gizmo', () => { + it('updates its rings without requiring a React render', () => { + const gizmo = createRenderedRotationGizmo(defaultExpression) + const initialXPath = gizmo.xPath.get() + const initialYPath = gizmo.yPath.get() + + paintRenderedRotationGizmo(gizmo, { + ...defaultExpression, + headX: 28, + headY: -36, + headZ: 17, + }) + + expect(gizmo.xPath.get()).not.toBe(initialXPath) + expect(gizmo.yPath.get()).not.toBe(initialYPath) + }) +}) diff --git a/src/__tests__/rendered-scene-test.ts b/src/__tests__/rendered-scene-test.ts new file mode 100644 index 0000000..8bc3525 --- /dev/null +++ b/src/__tests__/rendered-scene-test.ts @@ -0,0 +1,72 @@ +import { createBodyNode } from '../body' +import { renderAvatar, poseFromExpression } from '../geometry' +import { defaultExpression } from '../presets' +import { + createRenderedColors, + createRenderedScene, + findBodyNodePath, + paintRenderedColors, + paintRenderedScene, +} from '../renderedScene' +import { surfacePresets } from '../surfaces' +import defaultStudioDocument from '../defaultStudioDocument.json' +import type { BodyNode } from '../body' +import type { Expression } from '../geometry' +import type { SurfaceConfig } from '../surfaces' + +describe('rendered avatar scene', () => { + it('keeps layer identity and hit mapping behind the scene seam', () => { + const node = createBodyNode('sphere', 0) + const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, { + bodyNodes: [node], + }) + const scene = createRenderedScene(first) + const rotated = renderAvatar( + poseFromExpression({ ...defaultExpression, headY: 35 }), + surfacePresets.sphere, + 1, + { bodyNodes: [node] } + ) + + paintRenderedScene(scene, rotated) + + expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath) + expect(findBodyNodePath(scene, node.id)).not.toBeNull() + expect(scene.headPath.get()).toBe(rotated.headPath) + }) + + it('updates animated colors without replacing their motion values', () => { + const colors = createRenderedColors({ body: '#5b7fe5', eyes: '#111316' }) + const body = colors.body + const eyes = colors.eyes + + paintRenderedColors(colors, { body: '#c53b47', eyes: '#ffffff' }) + + expect(colors.body).toBe(body) + expect(colors.eyes).toBe(eyes) + expect(colors.body.get()).toBe('#c53b47') + expect(colors.eyes.get()).toBe('#ffffff') + }) + + it('keeps Cloudee accessories behind the eyes at expression position 05', () => { + const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')! + const expression = defaultStudioDocument.expressions[5] as Expression + + const geometry = renderAvatar( + poseFromExpression(expression), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + + expect(geometry.frontNodeIds).toEqual([]) + + const clearlyTurned = renderAvatar( + poseFromExpression({ ...expression, headY: -35 }), + avatar.body.primary as SurfaceConfig, + 1, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f') + }) +}) diff --git a/src/__tests__/sequences-test.ts b/src/__tests__/sequences-test.ts new file mode 100644 index 0000000..6c5b694 --- /dev/null +++ b/src/__tests__/sequences-test.ts @@ -0,0 +1,97 @@ +import { + advanceSequenceCursor, + createInitialSequences, + getSequenceSpring, + normalizeSequencesForExpressions, + parseSequences, + remapSequencesAfterExpressionDelete, +} from '../sequences' +import { initialExpressions } from '../presets' + +describe('editable avatar sequences', () => { + it('creates the idle animation with editable steps and blink settings', () => { + const idle = createInitialSequences().find(sequence => sequence.id === 'idle') + + expect(idle?.steps.map(step => step.expressionId)).toEqual([ + initialExpressions[0].id, + initialExpressions[8].id, + ]) + expect(idle?.steps[0].holdMs).toBe(5200) + expect(idle?.blink.durationMs).toBe(280) + }) + + it('supports loop, once and ping-pong playback cursors', () => { + const base = createInitialSequences().find(sequence => sequence.id === 'listening')! + + expect(advanceSequenceCursor({ ...base, playbackMode: 'loop' }, 2, 1)).toEqual({ + index: 0, + direction: 1, + complete: false, + }) + expect(advanceSequenceCursor({ ...base, playbackMode: 'once' }, 2, 1).complete).toBe(true) + expect(advanceSequenceCursor({ ...base, playbackMode: 'pingPong' }, 2, 1)).toEqual({ + index: 1, + direction: -1, + complete: false, + }) + }) + + it('keeps sequence references stable when expressions are reordered', () => { + const sequence = createInitialSequences().find(item => item.id === 'idle')! + const reordered = [...initialExpressions].reverse() + const [normalized] = normalizeSequencesForExpressions([sequence], reordered) + + expect(normalized.steps.map(step => step.expressionId)).toEqual( + sequence.steps.map(step => step.expressionId) + ) + }) + + it('keeps a sequence playable when its only referenced expression is deleted', () => { + const sequence = createInitialSequences().find(item => item.id === 'waking')! + const fallbackId = initialExpressions[12].id + const [remapped] = remapSequencesAfterExpressionDelete( + [sequence], + initialExpressions[13].id, + fallbackId + ) + + expect(remapped.steps).toHaveLength(1) + expect(remapped.steps[0].expressionId).toBe(fallbackId) + }) + + it('sanitizes persisted timing values and invalid playback values', () => { + const [sequence] = parseSequences([ + { + id: 'custom', + name: 'Custom', + playbackMode: 'invalid', + steps: [{ expressionId: initialExpressions[2].id, holdMs: -5, transitionMs: 99999 }], + blink: { minIntervalMs: 9000, maxIntervalMs: 1000, durationMs: 2 }, + }, + ]) + + expect(sequence.playbackMode).toBe('loop') + expect(sequence.steps[0].holdMs).toBe(100) + expect(sequence.steps[0].transitionMs).toBe(5000) + expect(sequence.blink.maxIntervalMs).toBe(sequence.blink.minIntervalMs) + expect(sequence.blink.durationMs).toBe(40) + }) + + it('repairs missing and out-of-range expression references on load', () => { + const sequence = createInitialSequences().find(item => item.id === 'idle')! + const [normalized] = normalizeSequencesForExpressions( + [{ ...sequence, steps: [{ ...sequence.steps[0], expressionId: 'missing' }] }], + initialExpressions.slice(0, 4) + ) + + expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id) + }) + + it('maps transition styles and durations to distinct spring dynamics', () => { + const smooth = getSequenceSpring('smooth', 900, 7) + const snappy = getSequenceSpring('snappy', 250, 7) + + expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness) + expect(smooth.damping).toBeGreaterThan(0) + }) +}) diff --git a/src/__tests__/snapshot-exporter-test.ts b/src/__tests__/snapshot-exporter-test.ts new file mode 100644 index 0000000..ba2fb8c --- /dev/null +++ b/src/__tests__/snapshot-exporter-test.ts @@ -0,0 +1,41 @@ +import { poseFromExpression, renderAvatar } from '../geometry' +import { defaultExpression } from '../presets' +import { createRenderedScene, paintRenderedOffset } from '../renderedScene' +import { serializeAvatarSnapshot, snapshotFileName } from '../snapshotExporter' +import { surfacePresets } from '../surfaces' + +describe('avatar snapshot export', () => { + const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1) + const scene = createRenderedScene(geometry) + const colors = { body: '#5b7fe5', eyes: '#111316' } + + it('exports the currently rendered scene as a transparent SVG', () => { + paintRenderedOffset(scene, { x: 3, y: -2 }) + const svg = serializeAvatarSnapshot('Strobi', scene, colors, { + background: 'transparent', + colorFrom: '#ffffff', + colorTo: '#000000', + size: 1024, + }) + + expect(svg).toContain('width="1024" height="1024"') + expect(svg).toContain('transform="translate(3 -2)"') + expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`) + expect(svg).toContain('fill="#111316"') + expect(svg).not.toContain(' { + const svg = serializeAvatarSnapshot('Strobi', scene, colors, { + background: 'radial', + colorFrom: '#ffffff', + colorTo: '#8899aa', + size: 512, + }) + + expect(svg).toContain(' { + const avatar = createAvatar('Strobi') + return { + version: 2, + library: { + activeAvatarId: avatar.id, + avatars: [avatar], + }, + expressions: initialExpressions, + sequences: createInitialSequences(), + playback: { stateId: 'idle', playing: true }, + } +} + +describe('Studio document', () => { + const storage = (value: string | null = null) => ({ getItem: () => value }) + + it('loads the bundled Studio snapshot when no local project exists', () => { + const document = loadStudioDocument(storage()) + + expect(document.library.avatars).toHaveLength(10) + expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id) + expect(document.library.avatars[0].name).toBe('Strobi') + expect(document.expressions).toHaveLength(27) + expect(document.sequences).toHaveLength(23) + expect(document.playback).toEqual({ stateId: 'proud', playing: true }) + }) + + it('keeps a locally saved project authoritative over the bundled snapshot', () => { + const localDocument = documentFixture() + + expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument) + }) + + it('persists one coherent document after a mutation', () => { + const persisted: StudioDocument[] = [] + const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value)) + + store.update({ playback: { stateId: 'idle', playing: false } }) + + expect(persisted).toHaveLength(1) + expect(persisted[0].playback).toEqual({ stateId: 'idle', playing: false }) + expect(persisted[0].expressions).toHaveLength(initialExpressions.length) + }) + + it('repairs sequence references in the same transaction as expression deletion', () => { + const store = createStudioDocumentStore(documentFixture(), () => undefined) + const remainingExpressions = initialExpressions.slice(1) + + const next = store.update({ expressions: remainingExpressions }) + + expect( + next.sequences.every(sequence => + sequence.steps.every(step => + remainingExpressions.some(item => item.id === step.expressionId) + ) + ) + ).toBe(true) + }) + + it('round-trips a complete project document as portable JSON', () => { + const document = documentFixture() + const expression = { ...initialExpressions[0], widthLeft: 42 } + const sequence = { + ...createInitialSequences()[0], + steps: createInitialSequences()[0].steps.map(step => ({ + ...step, + expressionId: expression.id, + })), + } + document.library.avatars[0].behavior = { + expressions: [expression], + sequences: [sequence], + } + + const imported = parseImportedStudioDocument(serializeStudioDocument(document), document) + + expect(imported).toEqual(document) + expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42) + }) + + it('keeps the base library unchanged when an avatar owns customized behavior', () => { + const document = documentFixture() + const avatar = document.library.avatars[0] + const customized = { + ...avatar, + behavior: { + expressions: [{ ...initialExpressions[0], widthLeft: 47 }], + sequences: createInitialSequences().slice(0, 1), + }, + } + const store = createStudioDocumentStore(document, () => undefined) + + const next = store.update({ + library: { activeAvatarId: avatar.id, avatars: [customized] }, + }) + + expect(next.expressions[0].widthLeft).toBe(initialExpressions[0].widthLeft) + expect(next.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(47) + }) + + it('rejects files that are not versioned Studio projects', () => { + const fallback = documentFixture() + + expect(() => parseImportedStudioDocument('{"version":1}', fallback)).toThrow( + 'Unsupported Avatar Studio project' + ) + expect(() => parseImportedStudioDocument('{broken', fallback)).toThrow( + 'Invalid Avatar Studio project' + ) + }) + + it('repairs an imported active avatar and missing expression references', () => { + const fallback = documentFixture() + const avatar = createAvatar('Portable') + const imported = parseImportedStudioDocument( + JSON.stringify({ + ...fallback, + library: { activeAvatarId: 'missing', avatars: [avatar] }, + sequences: [ + { + ...createInitialSequences()[0], + steps: [{ ...createInitialSequences()[0].steps[0], expressionId: 'missing' }], + }, + ], + }), + fallback + ) + + expect(imported.library.activeAvatarId).toBe(avatar.id) + expect(imported.sequences[0].steps[0].expressionId).toBe(imported.expressions[0].id) + }) + + it('sanitizes imported animation timing and playback values', () => { + const fallback = documentFixture() + const imported = parseImportedStudioDocument( + JSON.stringify({ + ...fallback, + sequences: [ + { + ...fallback.sequences[0], + playbackMode: 'unsupported', + steps: [ + { + ...fallback.sequences[0].steps[0], + holdMs: -500, + transitionMs: Number.POSITIVE_INFINITY, + }, + ], + }, + ], + }), + fallback + ) + + expect(imported.sequences[0].playbackMode).toBe('loop') + expect(imported.sequences[0].steps[0].holdMs).toBeGreaterThanOrEqual(100) + expect(Number.isFinite(imported.sequences[0].steps[0].transitionMs)).toBe(true) + }) +}) diff --git a/src/ambientMotion.ts b/src/ambientMotion.ts new file mode 100644 index 0000000..2e392a0 --- /dev/null +++ b/src/ambientMotion.ts @@ -0,0 +1,96 @@ +import type { BodyMotion, Expression, EyeMotion } from './geometry' + +export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const +export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const +const eyeMotionSet = new Set(eyeMotionModes) +const bodyMotionSet = new Set(bodyMotionModes) +export const isEyeMotion = (value: unknown): value is EyeMotion => + typeof value === 'string' && eyeMotionSet.has(value) +export const isBodyMotion = (value: unknown): value is BodyMotion => + typeof value === 'string' && bodyMotionSet.has(value) + +const smoothstep = (value: number) => value * value * (3 - 2 * value) +const hash = (value: number) => { + const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453 + return (raw - Math.floor(raw)) * 2 - 1 +} + +const expressionSeed = (expression: Expression) => + expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37 + +const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => { + const progress = elapsedMs / interval + const step = Math.floor(progress) + const blend = smoothstep(progress - step) + const previous = hash(step * 3 + axis + seed) + const next = hash((step + 1) * 3 + axis + seed) + return previous + (next - previous) * blend +} + +const saccade = (elapsedMs: number, axis: number, seed: number) => { + const interval = 1100 + const warpedTime = elapsedMs + Math.sin(elapsedMs / 1700 + seed) * 280 + const step = Math.floor(warpedTime / interval) + const progress = (((warpedTime % interval) + interval) % interval) / interval + const blend = smoothstep(Math.min(progress / 0.09, 1)) + const previous = hash((step - 1) * 2 + axis + seed) + const next = hash(step * 2 + axis + seed) + const fadeIn = Math.min(elapsedMs / 240, 1) + return (previous + (next - previous) * blend) * fadeIn +} + +export const hasAmbientMotion = (expression: Expression) => + expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none' + +export const ambientBodyOffset = (expression: Expression, elapsedMs: number) => { + const seed = expressionSeed(expression) + if (expression.bodyMotion === 'slowDrift') { + return { + x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45, + y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1, + } + } + if (expression.bodyMotion === 'shake') { + const time = elapsedMs / 1000 + return { + x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35, + y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1, + } + } + return { x: 0, y: 0 } +} + +export const applyAmbientMotion = (expression: Expression, elapsedMs: number): Expression => { + const next = { ...expression } + const seed = expressionSeed(expression) + + if (expression.eyeMotion === 'microSaccades') { + const x = saccade(elapsedMs, 0, seed) * 1.5 + const y = saccade(elapsedMs, 1, seed) * 0.9 + next.positionXLeft += x + next.positionXRight += x + next.positionYLeft += y + next.positionYRight += y + } else if (expression.eyeMotion === 'shake') { + const time = elapsedMs / 1000 + const x = (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 + const y = (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 + next.positionXLeft += x + next.positionXRight += x + next.positionYLeft += y + next.positionYRight += y + } + + if (expression.bodyMotion === 'slowDrift') { + next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 + next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 + next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 + } else if (expression.bodyMotion === 'shake') { + const time = elapsedMs / 1000 + next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 + next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 + next.headZ += Math.sin(time * 43) * 0.7 + } + + return next +} diff --git a/src/avatars.ts b/src/avatars.ts new file mode 100644 index 0000000..4d1ff7f --- /dev/null +++ b/src/avatars.ts @@ -0,0 +1,211 @@ +import { parseAvatarBody, type AvatarBody } from './body' +import { defaultExpression, initialExpressions } from './presets' +import { surfacePresets } from './surfaces' +import type { Expression } from './geometry' +import { isBodyMotion, isEyeMotion } from './ambientMotion' +import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences' + +export type AvatarBehaviorLibrary = { + expressions: Expression[] + sequences: AvatarSequence[] +} + +export type StudioAvatar = { + id: string + name: string + body: AvatarBody + colors: AvatarColors + eyes: AvatarEyeDefaults + behavior?: AvatarBehaviorLibrary +} + +export type AvatarColors = { body: string; eyes: string } +export type AvatarEyeDefaults = Pick< + Expression, + | 'widthLeft' + | 'widthRight' + | 'heightLeft' + | 'heightRight' + | 'spacing' + | 'positionXLeft' + | 'positionXRight' + | 'positionYLeft' + | 'positionYRight' + | 'leftAngle' + | 'rightAngle' +> +export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' } +export const defaultAvatarEyes: AvatarEyeDefaults = { + widthLeft: defaultExpression.widthLeft, + widthRight: defaultExpression.widthRight, + heightLeft: defaultExpression.heightLeft, + heightRight: defaultExpression.heightRight, + spacing: defaultExpression.spacing, + positionXLeft: defaultExpression.positionXLeft, + positionXRight: defaultExpression.positionXRight, + positionYLeft: defaultExpression.positionYLeft, + positionYRight: defaultExpression.positionYRight, + leftAngle: defaultExpression.leftAngle, + rightAngle: defaultExpression.rightAngle, +} +const hexColor = /^#[0-9a-f]{6}$/i +const parseColors = (value: unknown): AvatarColors => { + const candidate = value as Partial | null + return { + body: + typeof candidate?.body === 'string' && hexColor.test(candidate.body) + ? candidate.body + : defaultAvatarColors.body, + eyes: + typeof candidate?.eyes === 'string' && hexColor.test(candidate.eyes) + ? candidate.eyes + : defaultAvatarColors.eyes, + } +} + +const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[] +export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => { + const candidate = value as Partial | null + const parsed = { ...defaultAvatarEyes } + eyeDefaultFields.forEach(field => { + const stored = candidate?.[field] + if (typeof stored === 'number' && Number.isFinite(stored)) parsed[field] = stored + }) + return parsed +} + +export const applyAvatarEyeDefaults = ( + expression: Expression, + eyes: AvatarEyeDefaults = defaultAvatarEyes +): Expression => { + const result = { ...expression } + eyeDefaultFields.forEach(field => { + result[field] = expression[field] + eyes[field] - defaultAvatarEyes[field] + }) + result.widthLeft = Math.max(10, result.widthLeft) + result.widthRight = Math.max(10, result.widthRight) + result.heightLeft = Math.max(10, result.heightLeft) + result.heightRight = Math.max(10, result.heightRight) + return result +} + +export type AvatarLibrary = { + activeAvatarId: string + avatars: StudioAvatar[] +} + +const cloneExpressions = (expressions: Expression[]) => expressions.map(item => ({ ...item })) +export const parseExpressions = (value: unknown): Expression[] => { + if (!Array.isArray(value) || !value.length) return cloneExpressions(initialExpressions) + return value.map((item, index) => { + if (!item || typeof item !== 'object') { + return { ...defaultExpression, id: `expression-${String(index).padStart(2, '0')}` } + } + const candidate = item as Partial + const storedEyeMotion = (item as { eyeMotion?: unknown }).eyeMotion + const storedBodyMotion = (item as { bodyMotion?: unknown }).bodyMotion + const parsed = Object.fromEntries( + Object.entries(defaultExpression).map(([field, fallback]) => { + if (field === 'id') { + return [ + field, + typeof candidate.id === 'string' && candidate.id + ? candidate.id + : `expression-${String(index).padStart(2, '0')}`, + ] + } + const stored = candidate[field as keyof Expression] + return [field, typeof stored === 'number' && Number.isFinite(stored) ? stored : fallback] + }) + ) as Expression + if (typeof candidate.bodyColor === 'string' && hexColor.test(candidate.bodyColor)) + parsed.bodyColor = candidate.bodyColor + if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor)) + parsed.eyeColor = candidate.eyeColor + parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion + parsed.bodyMotion = isBodyMotion(storedBodyMotion) + ? storedBodyMotion + : defaultExpression.bodyMotion + return parsed + }) +} + +const cloneSequences = (sequences: AvatarSequence[]) => + sequences.map(sequence => ({ + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, + })) + +export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({ + expressions: cloneExpressions(behavior.expressions), + sequences: cloneSequences(behavior.sequences), +}) + +export const resolveAvatarBehavior = ( + avatar: StudioAvatar, + base: AvatarBehaviorLibrary +): AvatarBehaviorLibrary => avatar.behavior ?? base + +const parseAvatarBehavior = ( + value: unknown, + base: AvatarBehaviorLibrary +): AvatarBehaviorLibrary | undefined => { + if (!value || typeof value !== 'object') return undefined + const candidate = value as Partial + if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined + const expressions = parseExpressions(candidate.expressions) + const sequences = normalizeSequencesForExpressions( + Array.isArray(candidate.sequences) + ? parseSequences(candidate.sequences) + : cloneSequences(base.sequences), + expressions + ) + return { expressions, sequences } +} + +export const createAvatar = (name: string): StudioAvatar => ({ + id: `avatar-${crypto.randomUUID()}`, + name: name.trim() || 'Nouvel avatar', + body: { primary: { ...surfacePresets.sphere }, nodes: [] }, + colors: { ...defaultAvatarColors }, + eyes: { ...defaultAvatarEyes }, +}) + +export const parseAvatarLibrary = ( + value: unknown, + fallback: AvatarLibrary, + baseBehavior: AvatarBehaviorLibrary +): AvatarLibrary => { + try { + const parsed = value as Partial | null + if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback + const seenIds = new Set() + const avatars = parsed.avatars + .filter(avatar => { + if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string') + return false + if (seenIds.has(avatar.id)) return false + seenIds.add(avatar.id) + return true + }) + .map(avatar => { + const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior) + return { + id: avatar.id, + name: avatar.name, + body: parseAvatarBody(avatar.body, surfacePresets.sphere), + colors: parseColors(avatar.colors), + eyes: parseAvatarEyeDefaults(avatar.eyes), + ...(behavior ? { behavior } : {}), + } + }) + if (!avatars.length) return fallback + const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId) + ? parsed.activeAvatarId! + : avatars[0].id + return { activeAvatarId, avatars } + } catch { + return fallback + } +} diff --git a/src/body.ts b/src/body.ts new file mode 100644 index 0000000..99db4c7 --- /dev/null +++ b/src/body.ts @@ -0,0 +1,117 @@ +import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces' + +export type BodyVector = readonly [number, number, number] + +export type BodyNode = { + id: string + name: string + surface: SurfaceConfig + position: BodyVector + rotation: BodyVector +} + +export type AvatarBody = { + primary: SurfaceConfig + nodes: BodyNode[] +} + +export const bodyPrimitiveTypes = [ + 'sphere', + 'cube', + 'capsule', + 'cylinder', + 'cone', + 'diamond', +] as const + +export const MAX_BODY_NODES = 16 + +const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[] +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) +const vector = (value: unknown): value is BodyVector => + Array.isArray(value) && value.length === 3 && value.every(finite) + +export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => { + if (!value || typeof value !== 'object') return { ...fallback } + const candidate = value as Partial + const type = + candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type + const preset = surfacePresets[type] + const numericFields = ['width', 'height', 'depth', 'roundness'] as const + if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback } + if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness)) + return { ...fallback } + if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness)) + return { ...fallback } + if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness)) + return { ...fallback } + return { ...preset, ...candidate, type } +} + +export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => { + if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] } + const candidate = value as Partial + const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary) + const seenIds = new Set() + const nodes = Array.isArray(candidate.nodes) + ? candidate.nodes + .filter((node): node is BodyNode => { + if (!node || typeof node !== 'object') return false + const surface = (node as BodyNode).surface + const id = (node as BodyNode).id + if (id === 'primary' || seenIds.has(id)) return false + const valid = Boolean( + typeof (node as BodyNode).id === 'string' && + id && + typeof (node as BodyNode).name === 'string' && + surface && + bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) && + finite(surface.width) && + finite(surface.height) && + finite(surface.depth) && + finite(surface.roundness) && + vector((node as BodyNode).position) && + vector((node as BodyNode).rotation) + ) + if (valid) seenIds.add(id) + return valid + }) + .slice(0, MAX_BODY_NODES) + .map(node => ({ + ...node, + surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]), + })) + : [] + return { primary, nodes } +} + +export const createBodyNode = ( + type: (typeof bodyPrimitiveTypes)[number], + index: number +): BodyNode => { + const preset = surfacePresets[type] + const scale = 0.34 + const side = index % 2 === 0 ? -1 : 1 + return { + id: `shape-${crypto.randomUUID()}`, + name: `${surfaceLabels[type]} ${index + 1}`, + surface: { + ...preset, + width: preset.width * scale, + height: preset.height * scale, + depth: preset.depth * scale, + }, + position: [side * 82, -72, -18], + rotation: [0, 0, 0], + } +} + +export const duplicateBodyNode = (source: BodyNode): BodyNode => ({ + ...source, + id: `shape-${crypto.randomUUID()}`, + name: `${source.name} copie`, + surface: { ...source.surface }, + position: [source.position[0] + 14, source.position[1] + 14, source.position[2]], + rotation: [...source.rotation], +}) diff --git a/src/canvasPreview.ts b/src/canvasPreview.ts new file mode 100644 index 0000000..ddd213b --- /dev/null +++ b/src/canvasPreview.ts @@ -0,0 +1,12 @@ +import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from './avatars' +import { type Expression } from './geometry' + +export type CanvasPreviewTarget = 'head' | 'eyes' + +export const resolveCanvasPreviewExpression = ( + expression: Expression, + avatarEyes: AvatarEyeDefaults, + bodyEditing: boolean, + target: CanvasPreviewTarget +) => + bodyEditing && target === 'head' ? applyAvatarEyeDefaults(expression, avatarEyes) : expression diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..2b4967d --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion' +import { ChevronDown } from 'lucide-react' +import type * as React from 'react' + +import { cn } from '../../lib/utils' + +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ) +} + +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ) +} + +function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + ) +} + +function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..444f239 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,71 @@ +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog' +import type * as React from 'react' + +import { cn } from '../../lib/utils' +import { Button } from './button' + +function AlertDialog(props: AlertDialogPrimitive.Root.Props) { + return +} + +function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) { + return +} + +function AlertDialogContent({ className, ...props }: AlertDialogPrimitive.Popup.Props) { + return ( + + + + + ) +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) { + return ( + + ) +} + +function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) { + return ( + + ) +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return
+} + +function AlertDialogCancel(props: AlertDialogPrimitive.Close.Props) { + return } {...props} /> +} + +function AlertDialogAction(props: React.ComponentProps) { + return + + +
+ + +
+
+
+

${copy.api}

+

${copy.apiHelp}

+
+
+
import { createAvatar } from './avatar.js'
+
+const avatar = createAvatar('#avatar', {
+  animation: ${JSON.stringify(firstAnimation)},
+  size: 320,
+})
+
+avatar.play(${JSON.stringify(firstAnimation)})
+avatar.pause()
+avatar.stop()
+

${copy.apiNote}

+
+ +
+ + + + +` +} + +export const generateJavaScriptAvatarPackage = ( + payload: AvatarExportPayload, + language: StudioLanguage = 'en' +) => { + return createStoredZip([ + { name: 'index.html', content: generateJavaScriptAvatarHtml(payload, language) }, + { name: 'avatar.js', content: generateJavaScriptAvatarModule(payload) }, + ]) +} + +const avatarExportSlug = (name: string) => + name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'avatar' + +const avatarComponentName = (name: string) => { + const identifier = avatarExportSlug(name) + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}` +} + +export const generateReactAvatarRuntime = () => ` +export type AvatarData = Readonly<{ + version: number + avatar: Readonly<{ name: string } & Record> + expressions: Readonly> + animations: Readonly> +}> + +export type RuntimeAvatar = { + play: (animation?: AnimationName) => RuntimeAvatar + pause: () => RuntimeAvatar + stop: () => RuntimeAvatar + destroy: () => void +} + +export type AvatarRuntimeModule = { + createAvatar: ( + target: HTMLElement, + options: { + animation: AnimationName + autoplay: boolean + loop?: boolean + size: string + onAnimationEnd: (animation: AnimationName) => void + } + ) => RuntimeAvatar +} + +export const AVATAR_RUNTIME_VERSION = 1 +const ENGINE_SOURCE = ${JSON.stringify(standaloneEngineSource)} +const BROWSER_RUNTIME_SOURCE = ${JSON.stringify(proceduralBrowserRuntime)} +const runtimes = new WeakMap>() + +const serializeData = (data: AvatarData) => + JSON.stringify(data) + .replace(/( + data: AvatarData +): Promise> => { + if (data.version !== AVATAR_RUNTIME_VERSION) { + throw new Error( + 'Avatar data version ' + data.version + ' requires a compatible avatar-runtime.ts' + ) + } + const cached = runtimes.get(data) + if (cached) return cached as Promise> + const source = + ENGINE_SOURCE + + '\\nconst DATA = ' + + serializeData(data) + + ';\\n' + + BROWSER_RUNTIME_SOURCE + + '\\nexport function createAvatar(target, options = {}) { return mountAvatar(target, options); }' + const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' })) + const runtime = import(/* @vite-ignore */ url).then(module => { + URL.revokeObjectURL(url) + return module as AvatarRuntimeModule + }) + runtimes.set(data, runtime) + return runtime +} +` + +export const generateReactAvatarData = (payload: AvatarExportPayload) => ` +import type { AvatarData } from './avatar-runtime' + +export const avatarData = ${serializedPayload(payload)} as const satisfies AvatarData +export type AnimationName = keyof typeof avatarData.animations +` + +export const generateReactAvatarComponent = (payload: AvatarExportPayload) => { + const animationNames = Object.keys(payload.animations) + const slug = avatarExportSlug(payload.avatar.name) + const componentName = avatarComponentName(payload.avatar.name) + return ` +import { forwardRef, useEffect, useImperativeHandle, useRef, type CSSProperties } from 'react' +import { loadAvatarRuntime, type RuntimeAvatar } from './avatar-runtime' +import { avatarData, type AnimationName } from './${slug}.avatar' + +export type { AnimationName } from './${slug}.avatar' +export type AvatarHandle = { + play: (animation?: AnimationName) => void + pause: () => void + stop: () => void +} +export type AvatarProps = { + animation?: AnimationName + playing?: boolean + loop?: boolean + size?: number | string + className?: string + style?: CSSProperties + onAnimationEnd?: (animation: AnimationName) => void +} + +export const ${componentName} = forwardRef(function ${componentName}( + { + animation = ${JSON.stringify(animationNames[0])}, + playing = true, + loop, + size = 240, + className, + style, + onAnimationEnd, + }, + ref +) { + const host = useRef(null) + const controller = useRef | null>(null) + const animationRef = useRef(animation) + const playingRef = useRef(playing) + const onAnimationEndRef = useRef(onAnimationEnd) + animationRef.current = animation + playingRef.current = playing + onAnimationEndRef.current = onAnimationEnd + + useEffect(() => { + if (!host.current) return + let disposed = false + let avatar: RuntimeAvatar | null = null + void loadAvatarRuntime(avatarData).then(runtime => { + if (disposed || !host.current) return + avatar = runtime.createAvatar(host.current, { + animation: animationRef.current, + autoplay: playingRef.current, + loop, + size: '100%', + onAnimationEnd: next => onAnimationEndRef.current?.(next), + }) + controller.current = avatar + }) + return () => { + disposed = true + avatar?.destroy() + controller.current = null + } + }, [loop]) + + useEffect(() => { + const avatar = controller.current + if (!avatar) return + if (playing) avatar.play(animation) + else avatar.pause() + }, [animation, playing]) + + useImperativeHandle(ref, () => ({ + play(next = animation) { controller.current?.play(next) }, + pause() { controller.current?.pause() }, + stop() { controller.current?.stop() }, + }), [animation]) + + const dimension = typeof size === 'number' ? size + 'px' : size + return +}) + +export default ${componentName} +` +} + +export const generateReactAvatarPackage = (payload: AvatarExportPayload) => { + const slug = avatarExportSlug(payload.avatar.name) + const componentName = avatarComponentName(payload.avatar.name) + const avatarEntry = `export { default, ${componentName} } from './${componentName}'\nexport type { AnimationName, AvatarHandle, AvatarProps } from './${componentName}'\n` + return createStoredZip([ + { name: 'avatar-runtime.ts', content: generateReactAvatarRuntime() }, + { name: `${slug}.avatar.ts`, content: generateReactAvatarData(payload) }, + { name: `${componentName}.tsx`, content: generateReactAvatarComponent(payload) }, + { name: `${slug}.index.ts`, content: avatarEntry }, + ]) +} + +export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => { + return `${avatarExportSlug(name)}-avatar.${extension}` +} diff --git a/src/expressionEditing.ts b/src/expressionEditing.ts new file mode 100644 index 0000000..c3dad76 --- /dev/null +++ b/src/expressionEditing.ts @@ -0,0 +1,60 @@ +import type { Expression } from './geometry' + +export type EyeSide = 'Left' | 'Right' +export type EyeLinks = { width: boolean; height: boolean; size: boolean; position?: boolean } + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)) +const opposite = (side: EyeSide): EyeSide => (side === 'Left' ? 'Right' : 'Left') + +export const updateEyeDimension = ( + expression: Expression, + side: EyeSide, + dimension: 'width' | 'height', + value: number, + linked: boolean +) => { + const field = `${dimension}${side}` as 'widthLeft' | 'widthRight' | 'heightLeft' | 'heightRight' + const otherField = `${dimension}${opposite(side)}` as typeof field + const nextValue = clamp(value, 10, 100) + return { + ...expression, + [field]: nextValue, + ...(linked ? { [otherField]: nextValue } : {}), + } +} + +export const scaleEye = ( + expression: Expression, + side: EyeSide, + targetSize: number, + linked: boolean +) => { + const widthField = `width${side}` as 'widthLeft' | 'widthRight' + const heightField = `height${side}` as 'heightLeft' | 'heightRight' + const factor = targetSize / Math.max(expression[widthField], expression[heightField], 1) + const next: Expression = { + ...expression, + [widthField]: clamp(expression[widthField] * factor, 10, 110), + [heightField]: clamp(expression[heightField] * factor, 10, 110), + } + if (!linked) return next + const otherSide = opposite(side) + const otherWidth = `width${otherSide}` as 'widthLeft' | 'widthRight' + const otherHeight = `height${otherSide}` as 'heightLeft' | 'heightRight' + next[otherWidth] = clamp(expression[otherWidth] * factor, 10, 110) + next[otherHeight] = clamp(expression[otherHeight] * factor, 10, 110) + return next +} + +export const updateEyePosition = ( + expression: Expression, + side: EyeSide, + axis: 'X' | 'Y', + value: number, + linked: boolean +) => { + const field = `position${axis}${side}` as + 'positionXLeft' | 'positionXRight' | 'positionYLeft' | 'positionYRight' + const otherField = `position${axis}${opposite(side)}` as typeof field + return { ...expression, [field]: value, ...(linked ? { [otherField]: value } : {}) } +} diff --git a/src/geometry.ts b/src/geometry.ts new file mode 100644 index 0000000..f2cf98c --- /dev/null +++ b/src/geometry.ts @@ -0,0 +1,1251 @@ +import { + cursorLayout, + surfaceFrontSampleAt, + surfacePointAt, + surfaceSampleAt, + type SurfaceConfig, +} from './surfaces' +import type { BodyNode } from './body' + +export type Quaternion = readonly [number, number, number, number] +export type Point3 = readonly [number, number, number] +export type EyeMotion = 'none' | 'microSaccades' | 'shake' +export type BodyMotion = 'none' | 'slowDrift' | 'shake' + +export type Expression = { + id: string + headX: number + headY: number + headZ: number + widthLeft: number + widthRight: number + heightLeft: number + heightRight: number + spacing: number + positionXLeft: number + positionXRight: number + positionYLeft: number + positionYRight: number + leftAngle: number + rightAngle: number + perspective: number + eyeMotion: EyeMotion + bodyMotion: BodyMotion + bodyColor?: string + eyeColor?: string +} + +export type ExpressionNumericField = Exclude< + keyof Expression, + 'id' | 'bodyColor' | 'eyeColor' | 'eyeMotion' | 'bodyMotion' +> + +export type AvatarPose = { + expression: Expression + orientation: Quaternion +} + +export type AvatarGeometry = { + backPaths: string[] + frontPaths: string[] + backNodeIds: (string | null)[] + frontNodeIds: (string | null)[] + headPath: string + leftPath: string + rightPath: string + leftVisible: boolean + rightVisible: boolean + wirePaths: string[] +} + +export type RenderAvatarOptions = { + includeWire?: boolean + bodyNodes?: BodyNode[] +} + +export type EyeEditorGeometry = { + visible: boolean + selectionPath: string + widthGuide: string + heightGuide: string + rotationGuide: string + spacingGuide: string + center: Point3 + widthHandle: Point3 + heightHandle: Point3 + rotateHandle: Point3 + sizeHandle: Point3 + spacingHandle: Point3 +} + +export type BodyNodeEditorGeometry = { + center: Point3 + axes: Record<'x' | 'y' | 'z', Point3> + rings: Record<'x' | 'y' | 'z', Point3[]> +} + +export const RADIUS = 120 +const FOCAL_LENGTH = 620 +const QUARTER_ARC_SAMPLES = 14 + +export const expressionFields: ExpressionNumericField[] = [ + 'headX', + 'headY', + 'headZ', + 'widthLeft', + 'widthRight', + 'heightLeft', + 'heightRight', + 'spacing', + 'positionXLeft', + 'positionXRight', + 'positionYLeft', + 'positionYRight', + 'leftAngle', + 'rightAngle', + 'perspective', +] + +export const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)) + +export const radians = (degrees: number) => (degrees * Math.PI) / 180 + +export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => { + const length = Math.hypot(w, x, y, z) || 1 + return [w / length, x / length, y / length, z / length] +} + +export const multiplyQuaternions = ( + [aw, ax, ay, az]: Quaternion, + [bw, bx, by, bz]: Quaternion +): Quaternion => + normalizeQuaternion([ + aw * bw - ax * bx - ay * by - az * bz, + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + ]) + +export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => { + const halfAngle = angle / 2 + const sine = Math.sin(halfAngle) + return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine]) +} + +export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => { + const xRotation = quaternionFromAxisAngle([1, 0, 0], x) + const yRotation = quaternionFromAxisAngle([0, 1, 0], y) + const zRotation = quaternionFromAxisAngle([0, 0, 1], z) + return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation) +} + +export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => { + const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2] + const cross: Point3 = [ + from[1] * to[2] - from[2] * to[1], + from[2] * to[0] - from[0] * to[2], + from[0] * to[1] - from[1] * to[0], + ] + return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]]) +} + +export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => { + const matrix00 = 1 - 2 * (y * y + z * z) + const matrix01 = 2 * (x * y - z * w) + const matrix10 = 2 * (x * y + z * w) + const matrix11 = 1 - 2 * (x * x + z * z) + const matrix20 = 2 * (x * z - y * w) + const matrix21 = 2 * (y * z + x * w) + const matrix22 = 1 - 2 * (x * x + y * y) + const headX = Math.asin(clamp(matrix21, -1, 1)) + if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)] + return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)] +} + +const nearestEquivalentAngle = (angle: number, current: number) => { + let result = angle + while (result - current > 180) result -= 360 + while (result - current < -180) result += 360 + return clamp(result, -365, 365) +} + +export const expressionWithOrientation = ( + expression: Expression, + orientation: Quaternion +): Expression => { + const [radiansX, radiansY, radiansZ] = quaternionToEuler(orientation) + const x = (radiansX * 180) / Math.PI + const y = (radiansY * 180) / Math.PI + const z = (radiansZ * 180) / Math.PI + return { + ...expression, + headX: nearestEquivalentAngle(x, expression.headX), + headY: nearestEquivalentAngle(y, expression.headY), + headZ: nearestEquivalentAngle(z, expression.headZ), + } +} + +export const slerpQuaternion = ( + start: Quaternion, + end: Quaternion, + progress: number +): Quaternion => { + let target = end + let dot = start.reduce((total, value, index) => total + value * target[index], 0) + if (dot < 0) { + target = target.map(value => -value) as unknown as Quaternion + dot = -dot + } + if (dot > 0.9995) { + return normalizeQuaternion( + start.map( + (value, index) => value + (target[index] - value) * progress + ) as unknown as Quaternion + ) + } + const angle = Math.acos(clamp(dot, -1, 1)) + const sine = Math.sin(angle) + const startWeight = Math.sin((1 - progress) * angle) / sine + const targetWeight = Math.sin(progress * angle) / sine + return normalizeQuaternion( + start.map( + (value, index) => value * startWeight + target[index] * targetWeight + ) as unknown as Quaternion + ) +} + +export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => { + const tx = 2 * (y * pz - z * py) + const ty = 2 * (z * px - x * pz) + const tz = 2 * (x * py - y * px) + return [ + px + w * tx + (y * tz - z * ty), + py + w * ty + (z * tx - x * tz), + pz + w * tz + (x * ty - y * tx), + ] +} + +const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => { + const halfWidth = width / 2 + const halfHeight = height / 2 + const cornerRadius = Math.min(halfHeight, halfWidth) + const points: (readonly [number, number])[] = [] + const addLine = (start: readonly [number, number], end: readonly [number, number]) => { + const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5)) + for (let index = 0; index < samples; index += 1) { + const progress = index / samples + points.push([ + start[0] + (end[0] - start[0]) * progress, + start[1] + (end[1] - start[1]) * progress, + ]) + } + } + const addArc = (centerX: number, centerY: number, startAngle: number) => { + for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) { + const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2) + points.push([ + centerX + Math.cos(angle) * cornerRadius, + centerY + Math.sin(angle) * cornerRadius, + ]) + } + } + addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight]) + addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2) + addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius]) + addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0) + addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight]) + addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2) + addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius]) + addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI) + return points +} + +const project = (point: Point3, perspective: number): Point3 => { + const denominator = FOCAL_LENGTH - point[2] * perspective + const scale = Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator + return [point[0] * scale, point[1] * scale, point[2]] +} + +export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 => + axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1] + +export const rotateExpressionAroundAxis = ( + expression: Expression, + axis: 'x' | 'y' | 'z', + deltaDegrees: number +) => { + const startOrientation = poseFromExpression(expression).orientation + const worldAxis = rotateWithQuaternion(startOrientation, axisVector(axis)) + const orientation = multiplyQuaternions( + quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)), + startOrientation + ) + return expressionWithOrientation(expression, orientation) +} + +export const rotateExpressionAroundCamera = (expression: Expression, deltaRadians: number) => { + const startOrientation = poseFromExpression(expression).orientation + return expressionWithOrientation( + expression, + multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), startOrientation) + ) +} + +const arcballVector = ([xValue, yValue]: readonly [number, number]): Point3 => { + const x = xValue / RADIUS + const y = yValue / RADIUS + const squaredLength = x * x + y * y + if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)] + const length = Math.sqrt(squaredLength) + return [x / length, y / length, 0] +} + +export const rotateExpressionWithArcball = ( + expression: Expression, + startPoint: readonly [number, number], + currentPoint: readonly [number, number] +) => { + const startOrientation = poseFromExpression(expression).orientation + const delta = quaternionFromVectors(arcballVector(startPoint), arcballVector(currentPoint)) + return expressionWithOrientation(expression, multiplyQuaternions(delta, startOrientation)) +} + +export const rotationRing = (pose: AvatarPose, axis: 'x' | 'y' | 'z', radius = 30): Point3[] => + Array.from({ length: 97 }, (_, index) => { + const angle = (index / 96) * Math.PI * 2 + const cosine = Math.cos(angle) + const sine = Math.sin(angle) + const point: Point3 = + axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0] + const rotated = rotateWithQuaternion(pose.orientation, point) + return [rotated[0] * radius, rotated[1] * radius, rotated[2]] + }) + +export const renderBodyNodeEditor = ( + pose: AvatarPose, + node: BodyNode, + axisLength = 34, + ringRadius = 26 +): BodyNodeEditorGeometry => { + const projectInHeadSpace = (point: Point3) => + project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective) + const center = projectInHeadSpace(node.position) + const localOrientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const axes = Object.fromEntries( + (['x', 'y', 'z'] as const).map(axis => { + const vector = rotateWithQuaternion(localOrientation, axisVector(axis)) + return [ + axis, + projectInHeadSpace([ + node.position[0] + vector[0] * axisLength, + node.position[1] + vector[1] * axisLength, + node.position[2] + vector[2] * axisLength, + ]), + ] + }) + ) as BodyNodeEditorGeometry['axes'] + ;(['x', 'y', 'z'] as const).forEach(axis => { + const endpoint = axes[axis] + if (Math.hypot(endpoint[0] - center[0], endpoint[1] - center[1]) >= 12) return + const fallback: Point3 = + axis === 'x' + ? [center[0] + 18, center[1], endpoint[2]] + : axis === 'y' + ? [center[0], center[1] + 18, endpoint[2]] + : [center[0] + 14, center[1] + 14, endpoint[2]] + axes[axis] = fallback + }) + const rings = Object.fromEntries( + (['x', 'y', 'z'] as const).map(axis => [ + axis, + Array.from({ length: 65 }, (_, index) => { + const angle = (index / 64) * Math.PI * 2 + const cosine = Math.cos(angle) * ringRadius + const sine = Math.sin(angle) * ringRadius + const localPoint: Point3 = + axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0] + const rotated = rotateWithQuaternion(localOrientation, localPoint) + return projectInHeadSpace([ + node.position[0] + rotated[0], + node.position[1] + rotated[1], + node.position[2] + rotated[2], + ]) + }), + ]) + ) as BodyNodeEditorGeometry['rings'] + return { center, axes, rings } +} + +export const translateBodyNodeAlongLocalAxis = ( + node: BodyNode, + axis: 'x' | 'y' | 'z', + distance: number +): BodyNode => { + const orientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const direction = rotateWithQuaternion(orientation, axisVector(axis)) + return { + ...node, + position: [ + node.position[0] + direction[0] * distance, + node.position[1] + direction[1] * distance, + node.position[2] + direction[2] * distance, + ], + } +} + +export const translateBodyNodeInCameraPlane = ( + node: BodyNode, + pose: AvatarPose, + screenDeltaX: number, + screenDeltaY: number +): BodyNode => { + const cameraPosition = rotateWithQuaternion(pose.orientation, node.position) + const denominator = FOCAL_LENGTH - cameraPosition[2] * pose.expression.perspective + const perspectiveScale = + Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator + const [w, x, y, z] = pose.orientation + const headDelta = rotateWithQuaternion( + [w, -x, -y, -z], + [screenDeltaX / perspectiveScale, screenDeltaY / perspectiveScale, 0] + ) + return { + ...node, + position: [ + node.position[0] + headDelta[0], + node.position[1] + headDelta[1], + node.position[2] + headDelta[2], + ], + } +} + +export const rotateBodyNodeAroundLocalAxis = ( + node: BodyNode, + axis: 'x' | 'y' | 'z', + deltaDegrees: number +): BodyNode => { + const orientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const rotated = multiplyQuaternions( + orientation, + quaternionFromAxisAngle(axisVector(axis), radians(deltaDegrees)) + ) + const next = quaternionToEuler(rotated).map(value => (value * 180) / Math.PI) as [ + number, + number, + number, + ] + return { + ...node, + rotation: next.map((value, index) => nearestEquivalentAngle(value, node.rotation[index])) as [ + number, + number, + number, + ], + } +} + +const path = (points: Point3[], close = true) => { + if (!points.length) return '' + return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points + .slice(1) + .map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`) + .join('')}${close ? 'Z' : ''}` +} + +export const poseFromExpression = (expression: Expression): AvatarPose => ({ + expression, + orientation: quaternionFromEuler( + radians(expression.headX), + radians(expression.headY), + radians(expression.headZ) + ), +}) + +export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => { + const expression: Expression = { ...from.expression } + expressionFields.forEach(field => { + expression[field] = + from.expression[field] + (to.expression[field] - from.expression[field]) * progress + }) + return { + expression, + orientation: slerpQuaternion(from.orientation, to.orientation, progress), + } +} + +type ProjectedSurfacePoint = { point: Point3; normal: Point3 } +type LocalSurfacePoint = ProjectedSurfacePoint + +const MAX_SURFACE_CACHE_ENTRIES = 24 +const HEAD_LATITUDE_SAMPLES = 25 +const HEAD_LONGITUDE_SAMPLES = 73 +const PRIMITIVE_RING_SAMPLES = 144 +const ROUNDED_PRIMITIVE_LATITUDE_SAMPLES = 33 +const ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES = 73 +const headSamplesCache = new Map() +const accessorySamplesCache = new Map() +const wireSamplesCache = new Map() + +const surfaceCacheKey = (surface: SurfaceConfig) => + [ + surface.type, + surface.width, + surface.height, + surface.depth, + surface.roundness, + surface.morphRoundness, + surface.tipRoundness, + surface.baseRoundness, + ] + .map(value => (typeof value === 'number' ? value.toFixed(4) : value)) + .join(':') + +const cacheSurfaceValue = (cache: Map, key: string, value: Value) => { + if (cache.size >= MAX_SURFACE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!) + cache.set(key, value) + return value +} + +const localSurfacePoint = ( + surface: SurfaceConfig, + longitude: number, + latitude: number +): LocalSurfacePoint => surfaceSampleAt(surface, longitude, latitude) + +const projectLocalSurfacePoint = ( + pose: AvatarPose, + sample: LocalSurfacePoint +): ProjectedSurfacePoint => ({ + point: project(rotateWithQuaternion(pose.orientation, sample.point), pose.expression.perspective), + normal: rotateWithQuaternion(pose.orientation, sample.normal), +}) + +const canonicalFaceCoordinates = (x: number, y: number): readonly [number, number] => { + const longitude = x / RADIUS + const latitude = y / RADIUS + return [RADIUS * Math.cos(latitude) * Math.sin(longitude), RADIUS * Math.sin(latitude)] +} + +const projectFacePoint = ( + pose: AvatarPose, + surface: SurfaceConfig, + x: number, + y: number +): ProjectedSurfacePoint => { + const [faceX, faceY] = canonicalFaceCoordinates(x, y) + return projectLocalSurfacePoint(pose, surfaceFrontSampleAt(surface, faceX, faceY)) +} + +const eyePoints = ( + pose: AvatarPose, + surface: SurfaceConfig, + side: -1 | 1, + blink: number +): ProjectedSurfacePoint[] => { + const expression = pose.expression + const suffix = side < 0 ? 'Left' : 'Right' + const width = expression[`width${suffix}`] + const restingHeight = expression[`height${suffix}`] + const height = 5 + (restingHeight - 5) * blink + const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + const centerY = expression[`positionY${suffix}`] + const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle) + return roundedRectangle(width, height).map(([localX, localY]) => { + const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle) + const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle) + return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY) + }) +} + +const visiblePath = (points: ProjectedSurfacePoint[]) => { + const segments: Point3[][] = [] + let segment: Point3[] = [] + points.forEach(({ point, normal }) => { + if (normal[2] > 0) segment.push(point) + else if (segment.length) { + segments.push(segment) + segment = [] + } + }) + if (segment.length) segments.push(segment) + return segments + .filter(item => item.length > 1) + .map(item => path(item, false)) + .join('') +} + +const wirePaths = (pose: AvatarPose, surface: SurfaceConfig): string[] => { + const key = surfaceCacheKey(surface) + let samples = wireSamplesCache.get(key) + if (!samples) { + const parallels = [-60, -30, 0, 30, 60].map(latitude => + Array.from({ length: 73 }, (_, index) => + localSurfacePoint(surface, radians(-180 + index * 5), radians(latitude)) + ) + ) + const meridians = Array.from( + { length: 12 }, + (_, longitudeIndex) => -150 + longitudeIndex * 30 + ).map(longitude => + Array.from({ length: 37 }, (_, index) => + localSurfacePoint(surface, radians(longitude), radians(-90 + index * 5)) + ) + ) + samples = cacheSurfaceValue(wireSamplesCache, key, [...parallels, ...meridians]) + } + return samples.map(curve => + visiblePath(curve.map(sample => projectLocalSurfacePoint(pose, sample))) + ) +} + +const projectEyePoint = ( + pose: AvatarPose, + surface: SurfaceConfig, + side: -1 | 1, + localX: number, + localY: number +): Point3 => { + const expression = pose.expression + const suffix = side < 0 ? 'Left' : 'Right' + const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle) + const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle) + const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle) + return projectFacePoint( + pose, + surface, + (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + rotatedX, + expression[`positionY${suffix}`] + rotatedY + ).point +} + +export const renderEyeEditor = ( + pose: AvatarPose, + surface: SurfaceConfig, + side: -1 | 1 +): EyeEditorGeometry => { + const expression = pose.expression + const suffix = side < 0 ? 'Left' : 'Right' + const width = expression[`width${suffix}`] + const height = expression[`height${suffix}`] + const selectedSamples = eyePoints(pose, surface, side, 1) + const selectedPoints = selectedSamples.map(sample => sample.point) + const center = projectEyePoint(pose, surface, side, 0, 0) + const widthHandle = projectEyePoint(pose, surface, side, width / 2 + 9, 0) + const heightHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 9) + const rotateHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 30) + const sizeHandle = projectEyePoint(pose, surface, side, width / 2 + 11, height / 2 + 11) + const leftCenter = projectEyePoint(pose, surface, -1, 0, 0) + const rightCenter = projectEyePoint(pose, surface, 1, 0, 0) + const spacingCenterX = (expression.positionXLeft + expression.positionXRight) / 2 + const spacingCenterY = (expression.positionYLeft + expression.positionYRight) / 2 + const spacingHandle = projectFacePoint( + pose, + surface, + spacingCenterX, + spacingCenterY + height / 2 + 34 + ).point + const spacingMiddle: Point3 = [ + (leftCenter[0] + rightCenter[0]) / 2, + (leftCenter[1] + rightCenter[1]) / 2, + (leftCenter[2] + rightCenter[2]) / 2, + ] + const line = (from: Point3, to: Point3) => path([from, to], false) + return { + visible: selectedSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0, + selectionPath: path(selectedPoints), + widthGuide: line(center, widthHandle), + heightGuide: line(center, heightHandle), + rotationGuide: line(heightHandle, rotateHandle), + spacingGuide: `${line(leftCenter, rightCenter)}${line(spacingMiddle, spacingHandle)}`, + center, + widthHandle, + heightHandle, + rotateHandle, + sizeHandle, + spacingHandle, + } +} + +const convexHull = (points: Point3[]): Point3[] => { + const sorted = [...points].sort((left, right) => left[0] - right[0] || left[1] - right[1]) + const cross = (origin: Point3, first: Point3, second: Point3) => + (first[0] - origin[0]) * (second[1] - origin[1]) - + (first[1] - origin[1]) * (second[0] - origin[0]) + const half = (source: Point3[]) => { + const result: Point3[] = [] + source.forEach(point => { + while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop() + result.push(point) + }) + return result + } + return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)] +} + +const smoothClosedPath = (points: Point3[]) => { + if (points.length < 3) return path(points) + const pointAt = (index: number) => points[(index + points.length) % points.length] + return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points + .map((point, index) => { + const previous = pointAt(index - 1) + const next = pointAt(index + 1) + const afterNext = pointAt(index + 2) + const firstControl: Point3 = [ + point[0] + (next[0] - previous[0]) / 6, + point[1] + (next[1] - previous[1]) / 6, + point[2], + ] + const secondControl: Point3 = [ + next[0] - (afterNext[0] - point[0]) / 6, + next[1] - (afterNext[1] - point[1]) / 6, + next[2], + ] + return `C${firstControl[0].toFixed(2)} ${firstControl[1].toFixed(2)} ${secondControl[0].toFixed(2)} ${secondControl[1].toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}` + }) + .join('')}Z` +} + +const densifyClosedPoints = (points: Point3[], maximumDistance = 7) => + points.flatMap((point, index) => { + const next = points[(index + 1) % points.length] + const steps = Math.max( + 1, + Math.ceil(Math.hypot(next[0] - point[0], next[1] - point[1]) / maximumDistance) + ) + return Array.from({ length: steps }, (_, step) => { + const progress = step / steps + return [ + point[0] + (next[0] - point[0]) * progress, + point[1] + (next[1] - point[1]) * progress, + point[2] + (next[2] - point[2]) * progress, + ] as Point3 + }) + }) + +const smoothOpenPath = (points: Point3[]) => { + if (!points.length) return '' + if (points.length === 1) return `${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}` + return points + .slice(0, -1) + .map((point, index) => { + const previous = points[Math.max(0, index - 1)] + const next = points[index + 1] + const afterNext = points[Math.min(points.length - 1, index + 2)] + const firstControlX = point[0] + (next[0] - previous[0]) / 6 + const firstControlY = point[1] + (next[1] - previous[1]) / 6 + const secondControlX = next[0] - (afterNext[0] - point[0]) / 6 + const secondControlY = next[1] - (afterNext[1] - point[1]) / 6 + return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}` + }) + .join('') +} + +const projectLocalPoint = (pose: AvatarPose, point: Point3) => + project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective) + +const ringPoints = (width: number, depth: number, y: number) => + Array.from({ length: PRIMITIVE_RING_SAMPLES + 1 }, (_, index) => { + const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2 + return [(width / 2) * Math.sin(angle), y, (depth / 2) * Math.cos(angle)] as Point3 + }) + +const projectedRoundedPrimitivePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const key = surfaceCacheKey(surface) + let localSamples = headSamplesCache.get(key) + if (!localSamples) { + localSamples = Array.from( + { length: ROUNDED_PRIMITIVE_LATITUDE_SAMPLES }, + (_, latitudeIndex) => { + const latitude = + -Math.PI / 2 + (latitudeIndex / (ROUNDED_PRIMITIVE_LATITUDE_SAMPLES - 1)) * Math.PI + return Array.from({ length: ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES }, (_, longitudeIndex) => { + const longitude = + -Math.PI + (longitudeIndex / (ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES - 1)) * Math.PI * 2 + return surfacePointAt(surface, longitude, latitude) + }) + } + ).flat() + cacheSurfaceValue(headSamplesCache, key, localSamples) + } + const projected = localSamples.map(point => projectLocalPoint(pose, point)) + return smoothClosedPath(densifyClosedPoints(convexHull(projected))) +} + +const projectedCylinderPath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.roundness > 0 || (surface.morphRoundness ?? 0) > 0) { + return projectedRoundedPrimitivePath(pose, surface) + } + + const halfHeight = surface.height / 2 + const projected = [ + ...ringPoints(surface.width, surface.depth, -halfHeight), + ...ringPoints(surface.width, surface.depth, halfHeight), + ].map(point => projectLocalPoint(pose, point)) + return smoothClosedPath(densifyClosedPoints(convexHull(projected))) +} + +const projectedCursorBodyPath = (pose: AvatarPose, surface: SurfaceConfig) => { + const layout = cursorLayout(surface) + const halfHeight = layout.bodyHeight / 2 + const projected = [ + ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY - halfHeight), + ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY + halfHeight), + ].map(point => projectLocalPoint(pose, point)) + return smoothClosedPath(densifyClosedPoints(convexHull(projected))) +} + +const projectedCursorConePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const layout = cursorLayout(surface) + const apex = projectLocalPoint(pose, [0, layout.coneApexY, 0]) + const base = ringPoints(surface.width, surface.depth, layout.coneBaseY).map(point => + projectLocalPoint(pose, point) + ) + return smoothClosedPath(densifyClosedPoints(convexHull([...base, apex]))) +} + +const projectedConePath = (pose: AvatarPose, surface: SurfaceConfig) => { + if ( + (surface.morphRoundness ?? 0) > 0 || + (surface.tipRoundness ?? 0) > 0 || + (surface.baseRoundness ?? 0) > 0 + ) { + return projectedRoundedPrimitivePath(pose, surface) + } + + const apex = projectLocalPoint(pose, [0, -surface.height / 2, 0]) + const base = ringPoints(surface.width, surface.depth, surface.height / 2).map(point => + projectLocalPoint(pose, point) + ) + const hull = convexHull([...base, apex]) + const apexIndex = hull.findIndex( + point => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < 0.01 + ) + if (apexIndex < 0) return smoothClosedPath(hull) + + const ordered = [...hull.slice(apexIndex), ...hull.slice(0, apexIndex)] + const baseArc = ordered.slice(1) + if (baseArc.length < 2) return path(hull) + return `M${apex[0].toFixed(2)} ${apex[1].toFixed(2)}L${baseArc[0][0].toFixed(2)} ${baseArc[0][1].toFixed(2)}${smoothOpenPath(baseArc)}L${apex[0].toFixed(2)} ${apex[1].toFixed(2)}Z` +} + +const projectedCubePath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface) + + const halfWidth = surface.width / 2 + const halfHeight = surface.height / 2 + const halfDepth = surface.depth / 2 + const vertices = [-1, 1].flatMap(x => + [-1, 1].flatMap(y => [-1, 1].map(z => [x * halfWidth, y * halfHeight, z * halfDepth] as Point3)) + ) + return path(convexHull(vertices.map(point => projectLocalPoint(pose, point)))) +} + +const projectedDiamondPath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface) + + const halfWidth = surface.width / 2 + const halfHeight = surface.height / 2 + const halfDepth = surface.depth / 2 + const vertices: Point3[] = [ + [-halfWidth, 0, 0], + [halfWidth, 0, 0], + [0, -halfHeight, 0], + [0, halfHeight, 0], + [0, 0, -halfDepth], + [0, 0, halfDepth], + ] + return path(convexHull(vertices.map(point => projectLocalPoint(pose, point)))) +} + +type ProjectedEllipse = { + centerX: number + centerY: number + majorRadius: number + minorRadius: number + rotation: number +} + +const ellipseProjection = ( + centerX: number, + centerY: number, + covarianceXX: number, + covarianceXY: number, + covarianceYY: number +): ProjectedEllipse | null => { + const trace = covarianceXX + covarianceYY + const difference = Math.hypot(covarianceXX - covarianceYY, covarianceXY * 2) + const majorSquared = (trace + difference) / 2 + const minorSquared = (trace - difference) / 2 + if (majorSquared <= 0 || minorSquared <= 0) return null + + return { + centerX, + centerY, + majorRadius: Math.sqrt(majorSquared), + minorRadius: Math.sqrt(minorSquared), + rotation: Math.atan2(covarianceXY * 2, covarianceXX - covarianceYY) / 2, + } +} + +const ellipsePath = ({ + centerX, + centerY, + majorRadius, + minorRadius, + rotation, +}: ProjectedEllipse) => { + const rotationDegrees = (rotation * 180) / Math.PI + const offsetX = Math.cos(rotation) * majorRadius + const offsetY = Math.sin(rotation) * majorRadius + const startX = centerX + offsetX + const startY = centerY + offsetY + const endX = centerX - offsetX + const endY = centerY - offsetY + + return `M${startX.toFixed(2)} ${startY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${endX.toFixed(2)} ${endY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${startX.toFixed(2)} ${startY.toFixed(2)}Z` +} + +const projectedEllipsoid = ( + pose: AvatarPose, + axes: Point3, + localCenter: Point3 = [0, 0, 0] +): ProjectedEllipse | null => { + const rotatedAxes = [ + rotateWithQuaternion(pose.orientation, [1, 0, 0]), + rotateWithQuaternion(pose.orientation, [0, 1, 0]), + rotateWithQuaternion(pose.orientation, [0, 0, 1]), + ] + const center = rotateWithQuaternion(pose.orientation, localCenter) + + if (Math.abs(pose.expression.perspective) < 0.0001) { + const covarianceXX = rotatedAxes.reduce( + (total, axis, index) => total + axis[0] * axis[0] * axes[index] * axes[index], + 0 + ) + const covarianceXY = rotatedAxes.reduce( + (total, axis, index) => total + axis[0] * axis[1] * axes[index] * axes[index], + 0 + ) + const covarianceYY = rotatedAxes.reduce( + (total, axis, index) => total + axis[1] * axis[1] * axes[index] * axes[index], + 0 + ) + return ellipseProjection(center[0], center[1], covarianceXX, covarianceXY, covarianceYY) + } + + const inverseAxesSquared = axes.map(axis => 1 / (axis * axis)) + const quadratic = Array.from({ length: 3 }, (_, row) => + Array.from({ length: 3 }, (_, column) => + rotatedAxes.reduce( + (total, axis, index) => total + axis[row] * inverseAxesSquared[index] * axis[column], + 0 + ) + ) + ) + const focalLength = FOCAL_LENGTH / pose.expression.perspective + const cameraOffset: Point3 = [-center[0], -center[1], focalLength - center[2]] + const cameraNormal: Point3 = [ + quadratic[0][0] * cameraOffset[0] + + quadratic[0][1] * cameraOffset[1] + + quadratic[0][2] * cameraOffset[2], + quadratic[1][0] * cameraOffset[0] + + quadratic[1][1] * cameraOffset[1] + + quadratic[1][2] * cameraOffset[2], + quadratic[2][0] * cameraOffset[0] + + quadratic[2][1] * cameraOffset[1] + + quadratic[2][2] * cameraOffset[2], + ] + const cameraTerm = + cameraOffset[0] * cameraNormal[0] + + cameraOffset[1] * cameraNormal[1] + + cameraOffset[2] * cameraNormal[2] - + 1 + const tangentLinear = [cameraNormal[0], cameraNormal[1], -focalLength * cameraNormal[2]] + const rayQuadratic = [ + [quadratic[0][0], quadratic[0][1], -focalLength * quadratic[0][2]], + [quadratic[1][0], quadratic[1][1], -focalLength * quadratic[1][2]], + [ + -focalLength * quadratic[2][0], + -focalLength * quadratic[2][1], + focalLength * focalLength * quadratic[2][2], + ], + ] + const conic = Array.from({ length: 3 }, (_, row) => + Array.from( + { length: 3 }, + (_, column) => + tangentLinear[row] * tangentLinear[column] - cameraTerm * rayQuadratic[row][column] + ) + ) + const determinant = conic[0][0] * conic[1][1] - conic[0][1] * conic[0][1] + if (Math.abs(determinant) < 1e-12) return null + + const centerX = -(conic[1][1] * conic[0][2] - conic[0][1] * conic[1][2]) / determinant + const centerY = (conic[0][1] * conic[0][2] - conic[0][0] * conic[1][2]) / determinant + const centeredConstant = conic[2][2] + conic[0][2] * centerX + conic[1][2] * centerY + const scale = -centeredConstant + if (Math.abs(scale) < 1e-12) return null + + const shapeXX = conic[0][0] / scale + const shapeXY = conic[0][1] / scale + const shapeYY = conic[1][1] / scale + const shapeDeterminant = shapeXX * shapeYY - shapeXY * shapeXY + if (shapeDeterminant <= 0) return null + + return ellipseProjection( + centerX, + centerY, + shapeYY / shapeDeterminant, + -shapeXY / shapeDeterminant, + shapeXX / shapeDeterminant + ) +} + +const projectedEllipsoidPath = (pose: AvatarPose, surface: SurfaceConfig) => { + const ellipse = projectedEllipsoid(pose, [ + surface.width / 2, + surface.height / 2, + surface.depth / 2, + ]) + const isSphere = surface.width === surface.height && surface.height === surface.depth + if (ellipse && isSphere) { + const radius = (ellipse.majorRadius + ellipse.minorRadius) / 2 + return ellipsePath({ + centerX: 0, + centerY: 0, + majorRadius: radius, + minorRadius: radius, + rotation: 0, + }) + } + return ellipse ? ellipsePath(ellipse) : null +} + +const mickeyEarPaths = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.type !== 'mickey') return [] + + const radius = Math.min(surface.width, surface.height) * 0.23 + const depthRadius = Math.min(radius, surface.depth * 0.29) + const centerX = surface.width * 0.37 + const centerY = -surface.height * 0.39 + const centerZ = -surface.depth * 0.12 + const axes: Point3 = [radius, radius, depthRadius] + return [-1, 1] + .map(side => projectedEllipsoid(pose, axes, [side * centerX, centerY, centerZ])) + .filter((ear): ear is ProjectedEllipse => ear !== null) + .map(ellipsePath) +} + +const compositeBackPaths = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.type === 'mickey') return mickeyEarPaths(pose, surface) + if (surface.type === 'cursor') return [projectedCursorConePath(pose, surface)] + return [] +} + +const ellipsePoints = (ellipse: ProjectedEllipse) => + Array.from({ length: PRIMITIVE_RING_SAMPLES }, (_, index) => { + const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2 + const major = Math.cos(angle) * ellipse.majorRadius + const minor = Math.sin(angle) * ellipse.minorRadius + return [ + ellipse.centerX + major * Math.cos(ellipse.rotation) - minor * Math.sin(ellipse.rotation), + ellipse.centerY + major * Math.sin(ellipse.rotation) + minor * Math.cos(ellipse.rotation), + 0, + ] as Point3 + }) + +const smoothHullPath = (points: Point3[]) => { + if (points.length < 3) return path(points) + const distances = points.map((point, index) => { + const next = points[(index + 1) % points.length] + return Math.hypot(next[0] - point[0], next[1] - point[1]) + }) + const sortedDistances = [...distances].sort((left, right) => left - right) + const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] || 1 + const straightThreshold = Math.max(8, medianDistance * 3.5) + const straightEdges = distances.map(distance => distance > straightThreshold) + + return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points + .map((point, index) => { + const nextIndex = (index + 1) % points.length + const next = points[nextIndex] + if (straightEdges[index]) return `L${next[0].toFixed(2)} ${next[1].toFixed(2)}` + const previous = straightEdges[(index - 1 + points.length) % points.length] + ? point + : points[(index - 1 + points.length) % points.length] + const afterNext = straightEdges[nextIndex] ? next : points[(index + 2) % points.length] + const firstControlX = point[0] + (next[0] - previous[0]) / 6 + const firstControlY = point[1] + (next[1] - previous[1]) / 6 + const secondControlX = next[0] - (afterNext[0] - point[0]) / 6 + const secondControlY = next[1] - (afterNext[1] - point[1]) / 6 + return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}` + }) + .join('')}Z` +} + +const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => { + const radiusX = surface.width / 2 + const radiusY = Math.min(radiusX, surface.height / 2) + const radiusZ = surface.depth / 2 + const straightHalf = Math.max(0, (surface.height - radiusY * 2) / 2) + const axes: Point3 = [radiusX, radiusY, radiusZ] + const top = projectedEllipsoid(pose, axes, [0, straightHalf, 0]) + const bottom = projectedEllipsoid(pose, axes, [0, -straightHalf, 0]) + if (!top || !bottom) return null + return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)])) +} + +const headPath = (pose: AvatarPose, surface: SurfaceConfig) => { + if (surface.type === 'sphere' || surface.type === 'mickey') { + const exactPath = projectedEllipsoidPath(pose, surface) + if (exactPath) return exactPath + } + + if (surface.type === 'capsule') { + const exactPath = projectedCapsulePath(pose, surface) + if (exactPath) return exactPath + } + + if (surface.type === 'cylinder') return projectedCylinderPath(pose, surface) + if (surface.type === 'cursor') return projectedCursorBodyPath(pose, surface) + if (surface.type === 'cone') return projectedConePath(pose, surface) + if (surface.type === 'cube') return projectedCubePath(pose, surface) + if (surface.type === 'diamond') return projectedDiamondPath(pose, surface) + + const key = surfaceCacheKey(surface) + let localSamples = headSamplesCache.get(key) + if (!localSamples) { + localSamples = Array.from({ length: HEAD_LATITUDE_SAMPLES }, (_, latitudeIndex) => { + const latitude = -Math.PI / 2 + (latitudeIndex / (HEAD_LATITUDE_SAMPLES - 1)) * Math.PI + return Array.from({ length: HEAD_LONGITUDE_SAMPLES }, (_, longitudeIndex) => { + const longitude = -Math.PI + (longitudeIndex / (HEAD_LONGITUDE_SAMPLES - 1)) * Math.PI * 2 + return surfacePointAt(surface, longitude, latitude) + }) + }).flat() + cacheSurfaceValue(headSamplesCache, key, localSamples) + } + const projectedSamples = localSamples.map(sample => + project(rotateWithQuaternion(pose.orientation, sample), pose.expression.perspective) + ) + return path(convexHull(projectedSamples)) +} + +const accessoryPath = (pose: AvatarPose, node: BodyNode) => { + const key = surfaceCacheKey(node.surface) + let localSamples = accessorySamplesCache.get(key) + if (!localSamples) { + localSamples = Array.from({ length: 17 }, (_, latitudeIndex) => { + const latitude = -Math.PI / 2 + (latitudeIndex / 16) * Math.PI + return Array.from({ length: 49 }, (_, longitudeIndex) => { + const longitude = -Math.PI + (longitudeIndex / 48) * Math.PI * 2 + return surfacePointAt(node.surface, longitude, latitude) + }) + }).flat() + cacheSurfaceValue(accessorySamplesCache, key, localSamples) + } + + const localOrientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const projected = localSamples.map(point => { + const locallyRotated = rotateWithQuaternion(localOrientation, point) + const positioned: Point3 = [ + locallyRotated[0] + node.position[0], + locallyRotated[1] + node.position[1], + locallyRotated[2] + node.position[2], + ] + return project(rotateWithQuaternion(pose.orientation, positioned), pose.expression.perspective) + }) + const hull = convexHull(projected) + if ( + (node.surface.type === 'cube' || node.surface.type === 'diamond') && + node.surface.roundness <= 0 + ) { + return path(hull) + } + return smoothClosedPath(densifyClosedPoints(hull)) +} + +const ACCESSORY_FRONT_CROSSING_RATIO = 0.1 + +const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => { + const localOrientation = quaternionFromEuler( + radians(node.rotation[0]), + radians(node.rotation[1]), + radians(node.rotation[2]) + ) + const cameraDepthByAxis = ( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] as Point3[] + ).map( + axis => rotateWithQuaternion(pose.orientation, rotateWithQuaternion(localOrientation, axis))[2] + ) + return Math.hypot( + cameraDepthByAxis[0] * (node.surface.width / 2), + cameraDepthByAxis[1] * (node.surface.height / 2), + cameraDepthByAxis[2] * (node.surface.depth / 2) + ) +} + +const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => { + const layers = nodes + .map(node => { + const depth = rotateWithQuaternion(pose.orientation, node.position)[2] + return { + id: node.id, + path: accessoryPath(pose, node), + depth, + front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO, + } + }) + .sort((left, right) => left.depth - right.depth) + return { + backPaths: layers.filter(layer => !layer.front).map(layer => layer.path), + frontPaths: layers.filter(layer => layer.front).map(layer => layer.path), + backNodeIds: layers.filter(layer => !layer.front).map(layer => layer.id), + frontNodeIds: layers.filter(layer => layer.front).map(layer => layer.id), + } +} + +export const renderAvatar = ( + pose: AvatarPose, + surface: SurfaceConfig, + blink = 1, + options: RenderAvatarOptions = {} +): AvatarGeometry => { + const leftSamples = eyePoints(pose, surface, -1, blink) + const rightSamples = eyePoints(pose, surface, 1, blink) + const left = leftSamples.map(sample => sample.point) + const right = rightSamples.map(sample => sample.point) + const accessories = accessoryLayers(pose, options.bodyNodes ?? []) + const compositePaths = compositeBackPaths(pose, surface) + return { + backPaths: [...compositePaths, ...accessories.backPaths], + frontPaths: accessories.frontPaths, + backNodeIds: [...compositePaths.map(() => null), ...accessories.backNodeIds], + frontNodeIds: accessories.frontNodeIds, + headPath: headPath(pose, surface), + leftPath: path(left), + rightPath: path(right), + leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0, + rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0, + wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface), + } +} diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..7abab67 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,514 @@ +import { + createContext, + createElement, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react' +import { chinese, translateChineseDynamicText } from './i18n.zh' + +export type StudioLanguage = 'en' | 'fr' | 'zh-CN' + +const STUDIO_LANGUAGE_STORAGE_KEY = 'avatar-studio-language' + +const isStudioLanguage = (value: string | null): value is StudioLanguage => + value === 'en' || value === 'fr' || value === 'zh-CN' + +const readStoredStudioLanguage = (): StudioLanguage => { + if (typeof window === 'undefined') return 'en' + try { + const storedLanguage = window.localStorage.getItem(STUDIO_LANGUAGE_STORAGE_KEY) + return isStudioLanguage(storedLanguage) ? storedLanguage : 'en' + } catch { + return 'en' + } +} + +const english: Record = { + 'Playground de l’avatar': 'Avatar playground', + Arrêté: 'Stopped', + 'Aperçu procédural en direct': 'Live procedural preview', + 'Aperçu de l’avatar': 'Avatar preview', + 'Choisissez une animation exportée pour la lancer.': 'Choose an exported animation to play it.', + Lecture: 'Playback', + 'Contrôlez l’animation active.': 'Control the active animation.', + Lire: 'Play', + Arrêter: 'Stop', + 'API JavaScript': 'JavaScript API', + 'Le module réutilisable est disponible dans avatar.js.': + 'The reusable module is available as avatar.js.', + 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.': + 'Open this demo directly, or serve the folder locally to import avatar.js from another module.', + 'Langue de l’interface': 'Interface language', + 'Nom de l’avatar': 'Avatar name', + sélecteur: 'picker', + hexadécimal: 'hex value', + 'Lier width': 'Link widths', + 'Lier height': 'Link heights', + 'Lier size': 'Link sizes', + 'Lier la position des yeux': 'Link eye positions', + 'Glisser horizontalement pour modifier': 'Drag horizontally to adjust', + 'En lecture': 'Playing', + 'En pause': 'Paused', + 'Réinitialiser la rotation de la tête': 'Reset head rotation', + 'Réinitialiser la position et la rotation de': 'Reset position and rotation of', + 'Avatar procédural': 'Procedural avatar', + 'Gizmo de rotation': 'Rotation gizmo', + 'Retour aux expressions': 'Back to expressions', + 'Retour aux animations': 'Back to animations', + 'Retour au studio': 'Back to studio', + 'Preset en mémoire': 'Saved preset', + 'Nouvelle expression': 'New expression', + 'L’avatar à gauche affiche cette expression en direct.': + 'The avatar on the left previews this expression live.', + Corps: 'Body', + 'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.', + 'Couleur du corps': 'Body color', + 'Rotation de la tête': 'Head rotation', + Yeux: 'Eyes', + 'Forme, placement et orientation propres au regard.': 'Eye shape, placement and orientation.', + 'Couleur des yeux': 'Eye color', + Largeur: 'Width', + Hauteur: 'Height', + Échelle: 'Scale', + 'Taille proportionnelle': 'Proportional size', + 'Œil gauche': 'Left eye', + 'Œil droit': 'Right eye', + 'Position et espacement': 'Position and spacing', + Horizontale: 'Horizontal', + Verticale: 'Vertical', + Espacement: 'Spacing', + 'Rotation locale': 'Local rotation', + Projection: 'Projection', + 'Perspective appliquée à la surface active.': 'Perspective applied to the active surface.', + Perspective: 'Perspective', + Supprimer: 'Delete', + Enregistrer: 'Save', + 'Construction du corps': 'Body construction', + 'Choisis la forme principale puis assemble les primitives autour d’elle.': + 'Choose the primary shape, then assemble primitives around it.', + 'Choisir un avatar': 'Choose an avatar', + 'Double-clic pour modifier': 'Double-click to edit', + 'Nouvel avatar': 'New avatar', + Modifier: 'Edit', + 'Mode d’édition': 'Editing mode', + Pose: 'Pose', + Expressions: 'Expressions', + Animations: 'Animations', + Exporter: 'Export', + 'Exporter l’avatar': 'Export avatar', + 'Télécharge un composant autonome avec les animations de ton choix.': + 'Download a standalone component with the animations you choose.', + 'Avatar sélectionné': 'Selected avatar', + Format: 'Format', + 'Choisis l’intégration correspondant à ton projet.': + 'Choose the integration that matches your project.', + 'Composant TSX autonome': 'Standalone TSX component', + 'Package React local (.zip)': 'Local React package (.zip)', + 'Module JavaScript': 'JavaScript module', + 'Module ES autonome': 'Standalone ES module', + 'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)', + sélectionnées: 'selected', + 'Animations à exporter': 'Animations to export', + 'Tout sélectionner': 'Select all', + 'Tout désélectionner': 'Deselect all', + 'Télécharger le composant TSX': 'Download TSX component', + 'Télécharger le package React': 'Download React package', + 'Télécharger le module': 'Download module', + Snapshot: 'Snapshot', + 'Mode photo': 'Photo Mode', + 'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.', + 'Les options de capture seront configurées ici.': 'Snapshot options will be configured here.', + 'Aperçu du Snapshot': 'Snapshot preview', + 'Aperçu du mode photo': 'Photo Mode preview', + 'Arrière-plan': 'Background', + 'Choisis un fond transparent, uni ou en dégradé.': + 'Choose a transparent, solid or gradient background.', + Style: 'Style', + Transparent: 'Transparent', + Uni: 'Solid', + 'Dégradé linéaire': 'Linear gradient', + 'Dégradé radial': 'Radial gradient', + 'Style d’arrière-plan': 'Background style', + Couleur: 'Color', + Départ: 'Start', + Arrivée: 'End', + Définition: 'Resolution', + 'Dimensions inscrites dans le fichier SVG.': 'Dimensions embedded in the SVG file.', + 'Dimensions du fichier exporté.': 'Dimensions of the exported file.', + 'Définition du Snapshot': 'Snapshot resolution', + 'Définition du mode photo': 'Photo Mode resolution', + 'Télécharger le Snapshot SVG': 'Download SVG snapshot', + 'Télécharger en SVG': 'Download SVG', + 'Télécharger en PNG': 'Download PNG', + 'Format d’export': 'Export format', + 'Format d’export du mode photo': 'Photo Mode export format', + 'Choisis le type de fichier généré par le mode photo.': + 'Choose the file type generated by Photo Mode.', + 'Prendre une photo': 'Take a picture', + 'Informations sur le mode photo': 'Photo Mode information', + 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.': + 'You can change the format, background and resolution for Photo Mode in Export.', + 'Projet du Studio': 'Studio project', + 'Transfère tous les avatars, expressions et animations vers un autre navigateur.': + 'Transfer every avatar, expression and animation to another browser.', + 'Télécharger le projet JSON': 'Download JSON project', + 'Importer un projet JSON': 'Import JSON project', + 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.': + 'This file does not contain a valid, compatible Avatar Studio project.', + 'Importer ce projet ?': 'Import this project?', + 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.': + 'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.', + 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.': + 'The project could not be saved in this browser. Free up some space and try again.', + Importer: 'Import', + 'Construction, forme et couleur de la tête de l’avatar.': + 'Build, shape and color of the avatar head.', + 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': + 'One primary shape carries the eyes. Other primitives are placed around it.', + 'Forme principale': 'Primary shape', + 'porte les yeux': 'carries the eyes', + 'Ajouter une forme': 'Add a shape', + Dupliquer: 'Duplicate', + 'Gizmo local': 'Local gizmo', + 'Déplacer dans le plan de la caméra': 'Move in camera plane', + Transformer: 'Transform', + copie: 'copy', + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.': + 'Drag an axis to move the shape, or a ring to rotate it.', + Profondeur: 'Depth', + 'Position locale': 'Local position', + 'Cette surface est la référence du visage et porte les yeux.': + 'This surface is the face reference and carries the eyes.', + 'Rondeur des arêtes': 'Edge roundness', + Rondeur: 'Roundness', + 'Rondeur morphologique': 'Morph roundness', + 'Rondeur de la pointe': 'Tip roundness', + 'Rondeur de la base': 'Base roundness', + 'Rondeur globale': 'Global roundness', + 'Rondeur pointe': 'Tip roundness', + 'Rondeur base': 'Base roundness', + 'Couleur de base utilisée par les poses et les expressions.': + 'Base color used by poses and expressions.', + 'Forme, placement, orientation et couleur du regard par défaut.': + 'Default eye shape, placement, orientation and color.', + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.': + 'Define this avatar’s default eyes. Poses are then applied on top of this base.', + 'Coordonnées propres à l’avatar, indépendantes des poses.': + 'Avatar-specific coordinates, independent from poses.', + 'Inclinaison par défaut propre à chaque œil.': 'Default tilt for each eye.', + 'Orientation et apparence générale de la pose.': 'General pose orientation and appearance.', + 'La pose peut remplacer temporairement la couleur de l’avatar.': + 'The pose can temporarily override the avatar color.', + 'Reprendre la couleur de l’avatar': 'Use avatar color', + 'Les libellés ↔ sont scrubbables, comme dans Figma.': + 'Labels marked ↔ can be scrubbed, like in Figma.', + 'Forme, placement, orientation et couleur du regard.': + 'Eye shape, placement, orientation and color.', + 'Coordonnées communes projetées sur la forme choisie.': + 'Shared coordinates projected onto the selected shape.', + 'Inclinaison propre à chaque œil.': 'Tilt applied to each eye.', + 'Perspective et repères appliqués à la surface active.': + 'Perspective and guides applied to the active surface.', + 'Profondeur simulée du visage.': 'Simulated face depth.', + 'Afficher le maillage': 'Show wireframe', + Réinitialiser: 'Reset', + Mouvement: 'Motion', + 'Mouvement perpétuel': 'Perpetual motion', + 'Aucun mouvement': 'No motion', + 'Dérive lente': 'Slow drift', + 'Micro-ajustements': 'Micro-adjustments', + Tremblement: 'Shake', + 'Ajoute une légère présence ou un tremblement continu au corps.': + 'Adds a subtle living presence or a continuous shake to the body.', + 'Anime le regard par petites saccades naturelles ou par tremblement.': + 'Animates the gaze with natural micro-saccades or a continuous shake.', + 'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.': + 'Motion interpolates values while the engine performs quaternion slerp.', + 'Vitesse du ressort': 'Spring speed', + 'Une animation est en cours de lecture': 'An animation is currently playing', + 'Mettez l’animation en pause avant de manipuler les paramètres de pose.': + 'Pause the animation before adjusting pose parameters.', + 'Mettez l’animation en pause avant de choisir ou personnaliser une expression.': + 'Pause the animation before selecting or customizing an expression.', + Cligner: 'Blink', + 'Expression aléatoire': 'Random expression', + 'Cycle de vie': 'Life cycle', + Réactions: 'Reactions', + 'Éditeur d’animation': 'Animation editor', + 'Modifier l’animation': 'Edit animation', + 'Nouvelle animation': 'New animation', + 'Compose les expressions, leur cadence et les clignements de cette animation.': + 'Compose the expressions, timing and blinks for this animation.', + 'Prévisualiser l’animation': 'Preview animation', + 'Arrêter l’animation': 'Stop animation', + Identité: 'Identity', + 'Nom, catégorie et comportement de lecture de l’animation.': + 'Animation name, category and playback behavior.', + Nom: 'Name', + Catégorie: 'Category', + Description: 'Description', + 'Mode de lecture': 'Playback mode', + Boucle: 'Loop', + 'Une fois': 'Once', + 'Aller-retour': 'Ping-pong', + Timeline: 'Timeline', + 'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.': + 'Drag steps to reorder them, then select one to adjust its timing.', + 'Ajoute une expression pour commencer.': 'Add an expression to get started.', + 'Étape sélectionnée': 'Selected step', + 'Durée visible avant de passer à l’expression suivante.': + 'Visible duration before moving to the next expression.', + 'Supprimer cette étape': 'Delete this step', + 'Temps d’affichage': 'Display time', + 'Durée de transition': 'Transition duration', + Transition: 'Transition', + Ressort: 'Spring', + Douce: 'Smooth', + Rapide: 'Snappy', + 'Ajouter une expression': 'Add expression', + 'Sélectionne un preset pour l’ajouter à la fin de la timeline.': + 'Select a preset to append it to the timeline.', + Clignements: 'Blinks', + 'Le blink fonctionne indépendamment des changements d’expression.': + 'Blinking runs independently from expression changes.', + 'Activer les clignements': 'Enable blinking', + 'Intervalle minimum': 'Minimum interval', + 'Intervalle maximum': 'Maximum interval', + 'Intervalle du clignement': 'Blink interval', + 'Durée du clignement': 'Blink duration', + animations: 'animations', + 'Chaque étape possède sa propre durée et sa propre transition.': + 'Each step has its own duration and transition.', + Désactivé: 'Disabled', + 'comportement de la timeline': 'timeline behavior', + 'Supprimer cette animation ?': 'Delete this animation?', + 'Cette action supprimera définitivement cette animation.': + 'This permanently deletes this animation.', + 'Cette animation enchaîne un pool de presets et des clignements.': + 'This animation cycles through presets and blinks.', + 'Cet état enchaîne un pool de presets et des clignements.': + 'This animation cycles through presets and blinks.', + 'Expressions de l’animation': 'Animation expressions', + 'Les presets sont joués dans cet ordre, puis la boucle recommence.': + 'Presets play in this order, then the loop starts again.', + 'Logique de clignement': 'Blink behavior', + 'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.': + 'A slightly randomized interval keeps the rhythm natural.', + 'Premier clignement': 'First blink', + 'après le lancement': 'after launch', + Intervalle: 'Interval', + 'tirage aléatoire': 'randomized', + Durée: 'Duration', + 'fermeture et ouverture': 'close and open', + 'Changement d’expression': 'Expression change', + 'cadence de l’animation': 'animation tempo', + 'Détails de l’animation': 'Animation details', + 'Afficher les détails de l’animation': 'Show animation details', + 'Masquer les détails de l’animation': 'Hide animation details', + Relancer: 'Restart', + Lancer: 'Play', + Pause: 'Pause', + Reprendre: 'Resume', + Annuler: 'Cancel', + 'Supprimer cette expression ?': 'Delete this expression?', + 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.': + 'This permanently removes the preset from this avatar’s library.', + 'Cette expression sera aussi retirée des animations suivantes :': + 'This expression will also be removed from the following animations:', + 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.': + 'If an animation only contains this expression, the fallback expression will be assigned so it remains playable.', + 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.': + 'This avatar’s body, expressions and animations will be permanently deleted. The base library will be preserved.', + 'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.': + 'Drag on the surface to orient the head. The gizmo rings control X, Y and Z.', + Sphère: 'Sphere', + Curseur: 'Cursor', + Cylindre: 'Cylinder', + Cône: 'Cone', + Diamant: 'Diamond', + 'Yeux presque fermés, respiration lente et expression de sommeil.': + 'Nearly closed eyes, slow breathing and a sleepy expression.', + 'Animation courte de réveil avant retour vers une expression neutre.': + 'Short waking animation before returning to a neutral expression.', + 'Séquence courte de réveil avant retour vers une expression neutre.': + 'Short waking animation before returning to a neutral expression.', + 'Micro-mouvements lents, expressions 00 et 08, clignement rare.': + 'Slow micro-movements, expressions 00 and 08, infrequent blinking.', + 'Expressions 10, 01 et 19, regard stable et clignement attentif.': + 'Expressions 10, 01 and 19, steady gaze and attentive blinking.', + 'Regard haut et latéral, expressions asymétriques et changements fréquents.': + 'Upward side gaze, asymmetric expressions and frequent changes.', + 'Balayage rapide et changements très fréquents.': 'Fast scanning and very frequent changes.', + 'Rythme régulier, regard concentré et micro-variations.': + 'Steady rhythm, focused gaze and subtle variations.', + 'Grandes expressions positives et mouvements rapides.': + 'Big positive expressions and fast movements.', + 'Inclinaisons et rotations rapides pour simuler un agent en mouvement.': + 'Fast tilts and rotations to simulate a moving agent.', + 'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.', + 'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.', + 'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.', + sleeping: 'sleeping', + waking: 'waking', + idle: 'idle', + listening: 'listening', + thinking: 'thinking', + searching: 'searching', + working: 'working', + excited: 'excited', + surprised: 'surprised', + suspicious: 'suspicious', + angry: 'angry', + drowsy: 'drowsy', + happy: 'happy', + curious: 'curious', + confused: 'confused', + bored: 'bored', + proud: 'proud', + shy: 'shy', + sad: 'sad', + laughing: 'laughing', + scared: 'scared', + playful: 'playful', + celebrate: 'celebrate', + orbit: 'orbit', + radar: 'radar', + progress: 'progress', + spawning: 'spawning', + humming: 'humming', + loading: 'loading', + dictating: 'dictating', + writing: 'writing', + sending: 'sending', + receiving: 'receiving', + uploading: 'uploading', + notifying: 'notifying', + alerting: 'alerting', + dragging: 'dragging', + bouncing: 'bouncing', + 'powering-down': 'powering down', +} + +const frenchStates: Record = { + loop: 'boucle', + once: 'une fois', + pingPong: 'aller-retour', + Custom: 'Personnalisé', + 'Untitled animation': 'Animation sans titre', + // Compatibilité avec les animations intégrées déjà persistées avant le renommage. + 'Untitled sequence': 'Animation sans titre', + 'Cet état enchaîne un pool de presets et des clignements.': + 'Cette animation enchaîne un pool de presets et des clignements.', + 'Séquence courte de réveil avant retour vers une expression neutre.': + 'Animation courte de réveil avant retour vers une expression neutre.', + sleeping: 'sommeil', + waking: 'réveil', + idle: 'au repos', + listening: 'écoute', + thinking: 'réflexion', + searching: 'recherche', + working: 'travail', + excited: 'enthousiaste', + surprised: 'surpris', + suspicious: 'méfiant', + angry: 'en colère', + drowsy: 'somnolent', + happy: 'heureux', + curious: 'curieux', + confused: 'confus', + bored: 'ennuyé', + proud: 'fier', + shy: 'timide', + sad: 'triste', + laughing: 'rire', + scared: 'effrayé', + playful: 'joueur', + celebrate: 'célébration', + orbit: 'orbite', + radar: 'radar', + progress: 'progression', + spawning: 'apparition', + humming: 'fredonnement', + loading: 'chargement', + dictating: 'dictée', + writing: 'écriture', + sending: 'envoi', + receiving: 'réception', + uploading: 'téléversement', + notifying: 'notification', + alerting: 'alerte', + dragging: 'glissement', + bouncing: 'rebond', + 'powering-down': 'extinction', +} + +const dynamicTranslations: [RegExp, string][] = [ + [/^Modifier l’expression (.+)$/, 'Edit expression $1'], + [/^Modifier (.+)$/, 'Edit $1'], + [/^Supprimer (.+) \?$/, 'Delete $1?'], + [/^Ajouter une forme · (.+)$/, 'Add a shape · $1'], + [/^Réinitialiser la position et la rotation de (.+)$/, 'Reset position and rotation of $1'], + [/^Animation en cours : (.+)$/, 'Active animation: $1'], + [/^Mettre (.+) en pause$/, 'Pause $1'], + [/^Reprendre (.+)$/, 'Resume $1'], + [/^Arrêter (.+)$/, 'Stop $1'], + [/^(.+) expressions$/, '$1 expressions'], +] + +export const translateStudioText = (text: string, language: StudioLanguage) => { + if (language === 'fr') return frenchStates[text] ?? text + if (language === 'zh-CN') { + const exact = chinese[text] + if (exact) return exact + const dynamic = translateChineseDynamicText(text) + if (dynamic) return dynamic + return Object.entries(chinese) + .sort(([left], [right]) => right.length - left.length) + .reduce( + (translated, [source, replacement]) => translated.replaceAll(source, replacement), + text + ) + } + const exact = english[text] + if (exact) return exact + for (const [pattern, replacement] of dynamicTranslations) { + if (pattern.test(text)) return text.replace(pattern, replacement) + } + return Object.entries(english) + .sort(([left], [right]) => right.length - left.length) + .reduce((translated, [source, replacement]) => translated.replaceAll(source, replacement), text) +} + +type StudioLanguageContextValue = { + language: StudioLanguage + setLanguage: (language: StudioLanguage) => void + t: (text: string) => string +} + +const StudioLanguageContext = createContext(null) + +export function StudioLanguageProvider({ children }: { children: ReactNode }) { + const [language, setLanguage] = useState(readStoredStudioLanguage) + + useEffect(() => { + try { + window.localStorage.setItem(STUDIO_LANGUAGE_STORAGE_KEY, language) + } catch { + // The studio remains usable when browser storage is unavailable. + } + }, [language]) + + return createElement( + StudioLanguageContext.Provider, + { value: { language, setLanguage, t: text => translateStudioText(text, language) } }, + children + ) +} + +export const useStudioLanguage = () => { + const context = useContext(StudioLanguageContext) + if (!context) throw new Error('useStudioLanguage must be used inside StudioLanguageProvider') + return context +} diff --git a/src/i18n.zh.ts b/src/i18n.zh.ts new file mode 100644 index 0000000..fba2909 --- /dev/null +++ b/src/i18n.zh.ts @@ -0,0 +1,377 @@ +export const chinese: Record = { + 'Playground de l’avatar': '头像游乐场', + Arrêté: '已停止', + 'Aperçu procédural en direct': '实时程序化预览', + 'Aperçu de l’avatar': '头像预览', + 'Choisissez une animation exportée pour la lancer。': '选择一个已导出的动画以播放。', + 'Choisissez une animation exportée pour la lancer.': '选择一个已导出的动画以播放。', + Lecture: '播放', + 'Contrôlez l’animation active.': '控制当前动画。', + Lire: '播放', + Arrêter: '停止', + 'API JavaScript': 'JavaScript API', + 'Le module réutilisable est disponible dans avatar.js.': '可复用模块位于 avatar.js。', + 'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.': + '可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。', + 'Langue de l’interface': '界面语言', + 'Nom de l’avatar': '头像名称', + sélecteur: '选择器', + hexadécimal: '十六进制值', + 'Lier width': '关联宽度', + 'Lier height': '关联高度', + 'Lier size': '关联尺寸', + 'Lier la position des yeux': '关联眼睛位置', + 'Glisser horizontalement pour modifier': '水平拖动以调整', + 'En lecture': '播放中', + 'En pause': '已暂停', + 'Réinitialiser la rotation de la tête': '重置头部旋转', + 'Réinitialiser la position et la rotation de': '重置位置和旋转:', + 'Avatar procédural': '程序化头像', + 'Gizmo de rotation': '旋转控制器', + 'Retour aux expressions': '返回表情', + 'Retour aux animations': '返回动画', + 'Retour au studio': '返回工作室', + 'Preset en mémoire': '已保存预设', + 'Nouvelle expression': '新建表情', + 'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。', + Corps: '身体', + 'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。', + 'Couleur du corps': '身体颜色', + 'Rotation de la tête': '头部旋转', + Yeux: '眼睛', + 'Forme, placement et orientation propres au regard.': '眼睛的形状、位置和方向。', + 'Couleur des yeux': '眼睛颜色', + Largeur: '宽度', + Hauteur: '高度', + Échelle: '缩放', + 'Taille proportionnelle': '等比尺寸', + 'Œil gauche': '左眼', + 'Œil droit': '右眼', + 'Position et espacement': '位置和间距', + Horizontale: '水平', + Verticale: '垂直', + Espacement: '间距', + 'Rotation locale': '局部旋转', + Projection: '投影', + 'Perspective appliquée à la surface active.': '应用于当前表面的透视。', + Perspective: '透视', + Supprimer: '删除', + Enregistrer: '保存', + 'Construction du corps': '身体构建', + 'Choisis la forme principale puis assemble les primitives autour d’elle.': + '选择主形状,然后在其周围组合基础形状。', + 'Choisir un avatar': '选择头像', + 'Double-clic pour modifier': '双击编辑', + 'Nouvel avatar': '新建头像', + Modifier: '编辑', + 'Mode d’édition': '编辑模式', + Pose: '姿势', + Expressions: '表情', + Animations: '动画', + Exporter: '导出', + Export: '导出', + 'Exporter l’avatar': '导出头像', + 'Télécharge un composant autonome avec les animations de ton choix.': + '下载包含所选动画的独立组件。', + 'Avatar sélectionné': '已选头像', + Format: '格式', + 'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。', + 'Composant TSX autonome': '独立 TSX 组件', + 'Package React local (.zip)': '本地 React 包(.zip)', + 'Module JavaScript': 'JavaScript 模块', + 'Module ES autonome': '独立 ES 模块', + 'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)', + sélectionnées: '已选择', + 'Animations à exporter': '要导出的动画', + 'Tout sélectionner': '全选', + 'Tout désélectionner': '取消全选', + 'Télécharger le composant TSX': '下载 TSX 组件', + 'Télécharger le package React': '下载 React 包', + 'Télécharger le module': '下载模块', + Snapshot: '快照', + 'Mode photo': '照片模式', + 'Capture une image statique de l’avatar.': '捕获头像的静态图像。', + 'Les options de capture seront configurées ici.': '快照选项将在此配置。', + 'Aperçu du Snapshot': '快照预览', + 'Aperçu du mode photo': '照片模式预览', + 'Arrière-plan': '背景', + 'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。', + Style: '样式', + Transparent: '透明', + Uni: '纯色', + 'Dégradé linéaire': '线性渐变', + 'Dégradé radial': '径向渐变', + 'Style d’arrière-plan': '背景样式', + Couleur: '颜色', + Départ: '起始颜色', + Arrivée: '结束颜色', + Définition: '分辨率', + 'Dimensions inscrites dans le fichier SVG.': '写入 SVG 文件的尺寸。', + 'Dimensions du fichier exporté.': '导出文件的尺寸。', + 'Définition du Snapshot': '快照分辨率', + 'Définition du mode photo': '照片模式分辨率', + 'Télécharger le Snapshot SVG': '下载 SVG 快照', + 'Télécharger en SVG': '下载 SVG', + 'Télécharger en PNG': '下载 PNG', + 'Format d’export': '导出格式', + 'Format d’export du mode photo': '照片模式导出格式', + 'Choisis le type de fichier généré par le mode photo.': '选择照片模式生成的文件类型。', + 'Prendre une photo': '拍照', + 'Informations sur le mode photo': '照片模式信息', + 'Tu peux modifier le format, le fond et la définition du mode photo dans Export.': + '你可以在“导出”中更改照片模式的格式、背景和分辨率。', + 'Projet du Studio': '工作室项目', + 'Transfère tous les avatars, expressions et animations vers un autre navigateur.': + '将所有头像、表情和动画传输到另一个浏览器。', + 'Télécharger le projet JSON': '下载 JSON 项目', + 'Importer un projet JSON': '导入 JSON 项目', + 'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.': + '此文件不包含有效且兼容的头像工作室项目。', + 'Importer ce projet ?': '导入此项目?', + 'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.': + '当前本地项目将被此文件中的头像、表情、动画和播放状态替换。', + 'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.': + '无法在此浏览器中保存项目。请释放空间后重试。', + Importer: '导入', + 'Construction, forme et couleur de la tête de l’avatar.': '头像头部的构建、形状和颜色。', + 'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.': + '主形状承载眼睛,其他基础形状围绕它放置。', + 'Forme principale': '主形状', + 'porte les yeux': '承载眼睛', + 'Ajouter une forme': '添加形状', + Dupliquer: '复制', + 'Gizmo local': '局部控制器', + 'Déplacer dans le plan de la caméra': '在相机平面中移动', + Transformer: '变换', + copie: '副本', + 'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.': + '拖动轴来移动形状,或拖动圆环来旋转形状。', + Profondeur: '深度', + 'Position locale': '局部位置', + 'Cette surface est la référence du visage et porte les yeux.': '此表面是面部基准并承载眼睛。', + 'Rondeur des arêtes': '边缘圆度', + Rondeur: '圆度', + 'Rondeur morphologique': '形态圆度', + 'Rondeur de la pointe': '尖端圆度', + 'Rondeur de la base': '底部圆度', + 'Rondeur globale': '整体圆度', + 'Rondeur pointe': '尖端圆度', + 'Rondeur base': '底部圆度', + 'Couleur de base utilisée par les poses et les expressions.': '姿势和表情使用的基础颜色。', + 'Forme, placement, orientation et couleur du regard par défaut.': + '默认眼睛的形状、位置、方向和颜色。', + 'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.': + '定义此头像的默认眼睛,姿势将在此基础上叠加。', + 'Coordonnées propres à l’avatar, indépendantes des poses.': '头像自身的坐标,与姿势无关。', + 'Inclinaison par défaut propre à chaque œil.': '每只眼睛的默认倾斜角度。', + 'Orientation et apparence générale de la pose.': '姿势的整体方向和外观。', + 'La pose peut remplacer temporairement la couleur de l’avatar.': '姿势可以暂时覆盖头像颜色。', + 'Reprendre la couleur de l’avatar': '恢复头像颜色', + 'Les libellés ↔ sont scrubbables, comme dans Figma.': '带 ↔ 的标签可像 Figma 一样拖动调整。', + 'Forme, placement, orientation et couleur du regard.': '眼睛的形状、位置、方向和颜色。', + 'Coordonnées communes projetées sur la forme choisie.': '投影到所选形状上的共享坐标。', + 'Inclinaison propre à chaque œil.': '每只眼睛各自的倾斜角度。', + 'Perspective et repères appliqués à la surface active.': '应用于当前表面的透视和参考线。', + 'Profondeur simulée du visage.': '模拟的面部深度。', + 'Afficher le maillage': '显示网格', + Réinitialiser: '重置', + Mouvement: '运动', + Motion: '运动', + 'Mouvement perpétuel': '持续运动', + 'Aucun mouvement': '无运动', + 'Dérive lente': '缓慢漂移', + 'Micro-ajustements': '微调', + Tremblement: '抖动', + 'Ajoute une légère présence ou un tremblement continu au corps.': + '为身体添加轻微的生命感或持续抖动。', + 'Anime le regard par petites saccades naturelles ou par tremblement.': + '通过自然的微小跳动或抖动让眼神动起来。', + 'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.': + 'Motion 对数值进行插值,引擎执行四元数球面插值。', + 'Vitesse du ressort': '弹簧速度', + 'Une animation est en cours de lecture': '动画正在播放', + 'Mettez l’animation en pause avant de manipuler les paramètres de pose.': + '调整姿势参数前请暂停动画。', + 'Mettez l’animation en pause avant de choisir ou personnaliser une expression.': + '选择或自定义表情前请暂停动画。', + Cligner: '眨眼', + 'Expression aléatoire': '随机表情', + 'Cycle de vie': '生命周期', + Réactions: '反应', + 'Éditeur d’animation': '动画编辑器', + 'Modifier l’animation': '编辑动画', + 'Nouvelle animation': '新建动画', + 'Compose les expressions, leur cadence et les clignements de cette animation.': + '组合此动画的表情、节奏和眨眼。', + 'Prévisualiser l’animation': '预览动画', + 'Arrêter l’animation': '停止动画', + Identité: '基本信息', + 'Nom, catégorie et comportement de lecture de l’animation.': '动画的名称、分类和播放行为。', + Nom: '名称', + Catégorie: '分类', + Description: '描述', + 'Mode de lecture': '播放模式', + Boucle: '循环', + 'Une fois': '一次', + 'Aller-retour': '往返', + Timeline: '时间轴', + 'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.': + '拖动步骤以重新排序,然后选择一个步骤调整时间。', + 'Ajoute une expression pour commencer.': '添加一个表情以开始。', + 'Étape sélectionnée': '已选步骤', + 'Durée visible avant de passer à l’expression suivante.': '切换到下一个表情前的显示时长。', + 'Supprimer cette étape': '删除此步骤', + 'Temps d’affichage': '显示时间', + 'Durée de transition': '过渡时长', + Transition: '过渡', + Ressort: '弹簧', + Douce: '平滑', + Rapide: '迅速', + 'Ajouter une expression': '添加表情', + 'Sélectionne un preset pour l’ajouter à la fin de la timeline.': + '选择一个预设并添加到时间轴末尾。', + Clignements: '眨眼', + 'Le blink fonctionne indépendamment des changements d’expression.': '眨眼独立于表情变化运行。', + 'Activer les clignements': '启用眨眼', + 'Intervalle minimum': '最小间隔', + 'Intervalle maximum': '最大间隔', + 'Intervalle du clignement': '眨眼间隔', + 'Durée du clignement': '眨眼时长', + animations: '动画', + 'Chaque étape possède sa propre durée et sa propre transition.': + '每个步骤都有自己的持续时间和过渡。', + Désactivé: '已禁用', + 'comportement de la timeline': '时间轴行为', + 'Supprimer cette animation ?': '删除此动画?', + 'Cette action supprimera définitivement cette animation.': '此操作将永久删除该动画。', + 'Cette animation enchaîne un pool de presets et des clignements.': + '此动画依次播放一组预设并执行眨眼。', + 'Cet état enchaîne un pool de presets et des clignements.': '此动画依次播放一组预设并执行眨眼。', + 'Expressions de l’animation': '动画表情', + 'Les presets sont joués dans cet ordre, puis la boucle recommence.': + '预设按此顺序播放,然后重新开始循环。', + 'Logique de clignement': '眨眼行为', + 'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.': + '略带随机性的间隔使节奏保持自然。', + 'Premier clignement': '首次眨眼', + 'après le lancement': '启动后', + Intervalle: '间隔', + 'tirage aléatoire': '随机', + Durée: '时长', + 'fermeture et ouverture': '闭合与睁开', + 'Changement d’expression': '表情变化', + 'cadence de l’animation': '动画节奏', + 'Détails de l’animation': '动画详情', + 'Afficher les détails de l’animation': '显示动画详情', + 'Masquer les détails de l’animation': '隐藏动画详情', + Relancer: '重新开始', + Lancer: '播放', + Pause: '暂停', + Reprendre: '继续', + Annuler: '取消', + 'Supprimer cette expression ?': '删除此表情?', + 'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.': + '此操作将从该头像的库中永久删除该预设。', + 'Cette expression sera aussi retirée des animations suivantes :': '此表情也会从以下动画中移除:', + 'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.': + '如果动画只包含此表情,将为其分配备用表情以保持可播放。', + 'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.': + '此头像的身体、表情和动画将被永久删除,基础库将被保留。', + 'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.': + '在表面上拖动以调整头部方向。控制器圆环分别控制 X、Y 和 Z。', + Sphère: '球体', + Cube: '立方体', + Capsule: '胶囊体', + Curseur: '光标', + Cylindre: '圆柱体', + Cône: '圆锥体', + Diamant: '菱形体', + 'Yeux presque fermés, respiration lente et expression de sommeil.': + '双眼几乎闭合,呼吸缓慢,呈现睡眠表情。', + 'Animation courte de réveil avant retour vers une expression neutre.': + '短暂的醒来动画,然后恢复中性表情。', + 'Séquence courte de réveil avant retour vers une expression neutre.': + '短暂的醒来动画,然后恢复中性表情。', + 'Micro-mouvements lents, expressions 00 et 08, clignement rare.': + '缓慢微动,使用表情 00 和 08,偶尔眨眼。', + 'Expressions 10, 01 et 19, regard stable et clignement attentif.': + '使用表情 10、01 和 19,视线稳定并专注眨眼。', + 'Regard haut et latéral, expressions asymétriques et changements fréquents.': + '向上侧视,不对称表情并频繁变化。', + 'Balayage rapide et changements très fréquents.': '快速扫视并非常频繁地变化。', + 'Rythme régulier, regard concentré et micro-variations.': '节奏稳定,目光专注并有细微变化。', + 'Grandes expressions positives et mouvements rapides.': '夸张的积极表情和快速运动。', + 'Inclinaisons et rotations rapides pour simuler un agent en mouvement.': + '通过快速倾斜和旋转模拟运动中的角色。', + 'Rythme régulier et expressions concentrées.': '节奏稳定且表情专注。', + 'Grandes expressions et transitions rapides.': '夸张表情和快速过渡。', + 'Inclinaisons et forte asymétrie.': '明显倾斜和强烈不对称。', + loop: '循环', + once: '一次', + pingPong: '往返', + Custom: '自定义', + 'Untitled animation': '未命名动画', + 'Untitled sequence': '未命名动画', + sleeping: '睡眠', + waking: '醒来', + idle: '待机', + listening: '聆听', + thinking: '思考', + searching: '搜索', + working: '工作', + excited: '兴奋', + surprised: '惊讶', + suspicious: '怀疑', + angry: '生气', + drowsy: '困倦', + happy: '开心', + curious: '好奇', + confused: '困惑', + bored: '无聊', + proud: '自豪', + shy: '害羞', + sad: '悲伤', + laughing: '大笑', + scared: '害怕', + playful: '顽皮', + celebrate: '庆祝', + orbit: '环绕', + radar: '雷达', + progress: '进度', + spawning: '出现', + humming: '哼唱', + loading: '加载', + dictating: '听写', + writing: '书写', + sending: '发送', + receiving: '接收', + uploading: '上传', + notifying: '通知', + alerting: '警报', + dragging: '拖动', + bouncing: '弹跳', + 'powering-down': '关机', + Avatars: '头像', + 'React / TypeScript': 'React / TypeScript', +} + +export const translateChineseDynamicText = (text: string) => { + const rules: Array<[RegExp, (...matches: string[]) => string]> = [ + [/^Modifier l’expression (.+)$/, value => `编辑表情 ${value}`], + [/^Modifier (.+)$/, value => `编辑${value}`], + [/^Supprimer (.+) \?$/, value => `删除${value}?`], + [/^Ajouter une forme · (.+)$/, value => `添加形状 · ${value}`], + [/^Réinitialiser la position et la rotation de (.+)$/, value => `重置${value}的位置和旋转`], + [/^Animation en cours : (.+)$/, value => `当前动画:${value}`], + [/^Mettre (.+) en pause$/, value => `暂停${value}`], + [/^Reprendre (.+)$/, value => `继续${value}`], + [/^Arrêter (.+)$/, value => `停止${value}`], + [/^(.+) expressions$/, value => `${value} 个表情`], + ] + + for (const [pattern, replacement] of rules) { + const match = text.match(pattern) + if (match) return replacement(...match.slice(1)) + } + return null +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..d1d7f94 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,4 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)) diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..5bae583 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/src/manipulationSession.ts b/src/manipulationSession.ts new file mode 100644 index 0000000..978af20 --- /dev/null +++ b/src/manipulationSession.ts @@ -0,0 +1,35 @@ +export type ManipulationSession = { + initial: Value + latest: Value +} + +export const beginManipulation = (initial: Value): ManipulationSession => ({ + initial, + latest: initial, +}) + +export const beginManipulationFromRenderedValue = ( + readRenderedValue: () => Value +): ManipulationSession => beginManipulation(readRenderedValue()) + +export const previewManipulation = ( + session: ManipulationSession, + next: Value, + preview?: (value: Value) => void +) => { + session.latest = next + preview?.(next) +} + +export const finishManipulation = ( + session: ManipulationSession, + outcome: 'commit' | 'cancel', + handlers: { preview: (value: Value) => void; commit: (value: Value) => void } +) => { + if (outcome === 'cancel') { + handlers.preview(session.initial) + return session.initial + } + handlers.commit(session.latest) + return session.latest +} diff --git a/src/playback.ts b/src/playback.ts new file mode 100644 index 0000000..652dc79 --- /dev/null +++ b/src/playback.ts @@ -0,0 +1,68 @@ +import { advanceSequenceCursor, type AvatarSequence } from './sequences' + +export type PlaybackTimeline = { + position: number + direction: 1 | -1 + stepDueAt: number | null + stepRemainingMs: number + blinkDueAt: number | null + blinkRemainingMs: number +} + +export const createPlaybackTimeline = (): PlaybackTimeline => ({ + position: 0, + direction: 1, + stepDueAt: null, + stepRemainingMs: 0, + blinkDueAt: null, + blinkRemainingMs: 0, +}) + +export const beginPlayback = (timeline: PlaybackTimeline, preservePosition = false) => { + return { + ...timeline, + position: preservePosition ? timeline.position : 0, + direction: preservePosition ? timeline.direction : 1, + stepDueAt: null, + blinkDueAt: null, + } satisfies PlaybackTimeline +} + +export const schedulePlaybackStep = (timeline: PlaybackTimeline, now: number, delayMs: number) => { + return { ...timeline, stepRemainingMs: delayMs, stepDueAt: now + delayMs } +} + +export const schedulePlaybackBlink = (timeline: PlaybackTimeline, now: number, delayMs: number) => { + return { ...timeline, blinkRemainingMs: delayMs, blinkDueAt: now + delayMs } +} + +export const pausePlaybackTimeline = (timeline: PlaybackTimeline, now: number) => { + return { + ...timeline, + stepRemainingMs: + timeline.stepDueAt === null + ? timeline.stepRemainingMs + : Math.max(timeline.stepDueAt - now, 0), + blinkRemainingMs: + timeline.blinkDueAt === null + ? timeline.blinkRemainingMs + : Math.max(timeline.blinkDueAt - now, 0), + stepDueAt: null, + blinkDueAt: null, + } +} + +export const advancePlaybackTimeline = (timeline: PlaybackTimeline, sequence: AvatarSequence) => { + const cursor = advanceSequenceCursor(sequence, timeline.position, timeline.direction) + return { + cursor, + timeline: { + ...timeline, + position: cursor.index, + direction: cursor.direction, + stepDueAt: null, + }, + } +} + +export const stopPlaybackTimeline = (_timeline: PlaybackTimeline) => createPlaybackTimeline() diff --git a/src/presets.ts b/src/presets.ts new file mode 100644 index 0000000..3b3a416 --- /dev/null +++ b/src/presets.ts @@ -0,0 +1,219 @@ +import type { Expression } from './geometry' + +const calibrated: number[][] = [ + [7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0], + [-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0], + [-36.2, 13.1, 15.5, 44.3, 51.3, 74.2, 76, 68.7, -40.7, 0, 0], + [15.6, -16.5, -11.3, 54, 51, 49.6, 48.5, 70.9, 30.1, 0, 0], + [3.4, 13, 8.9, 42.6, 44, 17.3, 16, 57.9, 4.9, 0, 0], + [-17.7, -1.4, -8.8, 29.5, 19.2, 51.6, 41.9, 56.3, 0, 0, 90], + [14.8, 14.5, 5.5, 22.9, 22.2, 32.4, 33.4, 50.9, 39.2, 0, 0], + [25.7, 16.5, -13.5, 48.5, 48.3, 33.5, 33, 53.3, 41.3, 61.3, -80], + [-22.8, -15.9, 6.2, 44.5, 43.9, 32.3, 24.6, 54.9, -42, -60.9, 69.2], + [-11.6, 8.3, -12.7, 42.5, 22.1, 41.8, 22.2, 61.7, 12.3, 0, 0], + [20.3, 7, 8.7, 30.2, 28.1, 48.8, 49.2, 56.8, 39.9, 0, 0], + [17.5, -15.2, -8.7, 51, 49.2, 75.3, 73.4, 70.2, 41.6, 0, 0], + [-10.4, 15.2, 11.8, 50.6, 51.6, 50, 50.7, 69.5, 16.7, 0, 0], + [-6, -7.7, -9.4, 43.6, 42.8, 15.5, 18.1, 57.9, 3.5, 0, 0], + [0.2, -3.1, 9, 29.6, 16.8, 51.5, 41.3, 56.4, -7.8, 0, 90], + [-16.2, 38.4, 2.4, 23.7, 26.2, 32.7, 34.6, 53.9, -41.1, 0, 0], + [3.5, -16.1, 15.8, 51, 48.5, 34.9, 33, 55.1, 41.9, 80, -62.2], + [-17.3, 11.2, -9.1, 24.2, 44.5, 44.5, 32.2, 55, -36.5, 18.5, 67.9], + [-0.7, 3.6, 12.2, 42.1, 22.2, 41.7, 22.1, 60.4, -9.1, 0, 0], + [-25.3, -12.4, -13.3, 30.5, 26.8, 49.9, 48.8, 56.2, -35.8, 0, 0], + [-41.1, 20.2, 18.8, 44.6, 53, 74.9, 77.8, 70.8, -40.6, 0, 0], + [-14.6, -12.5, -16.1, 51.4, 50.5, 50.1, 49.4, 69, -20, 0, 0], + [10, 2.7, 8.8, 42.9, 43.3, 16.4, 17.8, 57.9, 2.7, 0, 0], + [-17.8, 10, -6.3, 28.8, 17.3, 51.4, 42.7, 56.6, -9.8, 0, 90], + [-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0], +] + +export const initialExpressions: Expression[] = calibrated.map( + ( + [ + headX, + headY, + headZ, + widthLeft, + widthRight, + heightLeft, + heightRight, + spacing, + latitude, + leftAngle, + rightAngle, + ], + index + ) => ({ + id: `expression-${String(index).padStart(2, '0')}`, + headX, + headY, + headZ, + widthLeft, + widthRight, + heightLeft, + heightRight, + spacing, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: latitude, + positionYRight: latitude, + leftAngle, + rightAngle, + perspective: 1, + eyeMotion: 'none', + bodyMotion: 'none', + }) +) + +export const defaultExpression: Expression = { + id: 'expression-neutral', + headX: 0, + headY: 0, + headZ: 0, + widthLeft: 20, + widthRight: 20, + heightLeft: 50, + heightRight: 50, + spacing: 35, + positionXLeft: 0, + positionXRight: 0, + positionYLeft: -7, + positionYRight: -7, + leftAngle: 0, + rightAngle: 0, + perspective: 1, + eyeMotion: 'none', + bodyMotion: 'none', +} + +export const stateGroups = { + 'Cycle de vie': ['sleeping', 'waking', 'idle', 'listening', 'thinking', 'searching', 'working'], + Réactions: [ + 'excited', + 'surprised', + 'suspicious', + 'angry', + 'drowsy', + 'happy', + 'curious', + 'confused', + 'bored', + 'proud', + 'shy', + 'sad', + 'laughing', + 'scared', + 'playful', + 'celebrate', + ], +} as const + +export const statePools: Record = { + sleeping: [13, 22, 4], + waking: [13], + idle: [0, 8], + listening: [10, 1, 19], + thinking: [8, 16, 14, 17, 5], + searching: [15, 9, 3, 20, 12, 18], + working: [7, 16, 11, 10], + excited: [2, 17, 21, 3, 11], + surprised: [3, 21], + suspicious: [14, 5, 23], + angry: [7, 16], + drowsy: [4, 22, 13], + happy: [2, 11, 17, 19], + curious: [3, 21, 0, 15], + confused: [14, 5, 8], + bored: [4, 22, 0], + proud: [15, 8, 2], + shy: [0, 24, 13], + sad: [4, 13, 22], + laughing: [2, 11, 17], + scared: [3, 21], + playful: [2, 17, 11, 8], + celebrate: [2, 8, 17], +} + +export type StatePlaybackConfig = { + expressionIntervalMs: number + blink: { + initialDelayMs: number + minIntervalMs: number + maxIntervalMs: number + durationMs: number + } +} + +const blinkProfiles = { + natural: { + initialDelayMs: 2600, + minIntervalMs: 3400, + maxIntervalMs: 6200, + durationMs: 280, + }, + calm: { + initialDelayMs: 4800, + minIntervalMs: 6500, + maxIntervalMs: 9500, + durationMs: 420, + }, + attentive: { + initialDelayMs: 3200, + minIntervalMs: 4800, + maxIntervalMs: 7200, + durationMs: 240, + }, + active: { + initialDelayMs: 2100, + minIntervalMs: 2800, + maxIntervalMs: 5000, + durationMs: 260, + }, + reactive: { + initialDelayMs: 1200, + minIntervalMs: 1800, + maxIntervalMs: 3600, + durationMs: 220, + }, +} as const + +const calmStates = new Set(['sleeping', 'drowsy', 'bored', 'sad']) +const attentiveStates = new Set(['listening']) +const reactiveStates = new Set([ + 'waking', + 'excited', + 'surprised', + 'laughing', + 'scared', + 'celebrate', +]) + +export const getStatePlaybackConfig = (name: string): StatePlaybackConfig => { + const blink = + name === 'idle' + ? blinkProfiles.natural + : calmStates.has(name) + ? blinkProfiles.calm + : attentiveStates.has(name) + ? blinkProfiles.attentive + : reactiveStates.has(name) + ? blinkProfiles.reactive + : blinkProfiles.active + return { + expressionIntervalMs: name === 'idle' ? 5200 : calmStates.has(name) ? 3600 : 2300, + blink: { ...blink }, + } +} + +export const stateNotes: Record = { + sleeping: 'Yeux presque fermés, respiration lente et expression de sommeil.', + waking: 'Animation courte de réveil avant retour vers une expression neutre.', + idle: 'Micro-mouvements lents, expressions 00 et 08, clignement rare.', + listening: 'Expressions 10, 01 et 19, regard stable et clignement attentif.', + thinking: 'Regard haut et latéral, expressions asymétriques et changements fréquents.', + searching: 'Balayage rapide et changements très fréquents.', + working: 'Rythme régulier et expressions concentrées.', + excited: 'Grandes expressions et transitions rapides.', + curious: 'Inclinaisons et forte asymétrie.', +} diff --git a/src/proceduralBrowserRuntime.ts b/src/proceduralBrowserRuntime.ts new file mode 100644 index 0000000..2d88c4d --- /dev/null +++ b/src/proceduralBrowserRuntime.ts @@ -0,0 +1,351 @@ +export const proceduralBrowserRuntime = ` +const SVG_NS = 'http://www.w3.org/2000/svg'; +const avatarInstanceId = () => typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : Date.now().toString(36) + '-' + Math.random().toString(36).slice(2); +const clamp01 = value => Math.max(0, Math.min(1, value)); +const easeProgress = (progress, transition) => transition === 'smooth' + ? progress * progress * (3 - 2 * progress) + : transition === 'snappy' + ? 1 - (1 - progress) ** 3 + : 1 - Math.exp(-6 * progress) * Math.cos(8 * progress); +const nearestAngle = (target, current) => { + let resolved = target; + while (resolved - current > 180) resolved -= 360; + while (resolved - current < -180) resolved += 360; + return resolved; +}; +const resolvedTargetExpression = (target, current) => ({ + ...target, + headX: nearestAngle(target.headX, current.headX), + headY: nearestAngle(target.headY, current.headY), + headZ: nearestAngle(target.headZ, current.headZ), + leftAngle: nearestAngle(target.leftAngle, current.leftAngle), + rightAngle: nearestAngle(target.rightAngle, current.rightAngle), +}); +const colorChannels = color => { + const value = color.replace('#', ''); + const hex = value.length === 3 ? value.split('').map(channel => channel + channel).join('') : value; + const numeric = Number.parseInt(hex, 16); + return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255]; +}; +const interpolateColor = (from, to, progress) => { + const left = colorChannels(from); + const right = colorChannels(to); + const value = left.map((channel, index) => Math.round(channel + (right[index] - channel) * progress)); + return '#' + value.map(channel => channel.toString(16).padStart(2, '0')).join(''); +}; +const resolveColors = expression => ({ + body: expression.bodyColor || DATA.avatar.colors.body, + eyes: expression.eyeColor || DATA.avatar.colors.eyes, +}); +const svgElement = name => document.createElementNS(SVG_NS, name); + +function mountAvatar(target, options = {}) { + const host = typeof target === 'string' ? document.querySelector(target) : target; + if (!host) throw new Error('Avatar target was not found.'); + const animationNames = Object.keys(DATA.animations); + if (!animationNames.length) throw new Error('The avatar export contains no animations.'); + const instanceId = avatarInstanceId(); + const clipId = 'avatar-procedural-clip-' + instanceId; + const svg = svgElement('svg'); + svg.setAttribute('viewBox', '-150 -150 300 300'); + svg.setAttribute('role', 'img'); + svg.setAttribute('aria-label', DATA.avatar.name); + svg.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%'; + svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%'; + svg.style.display = 'block'; + svg.style.overflow = 'visible'; + const defs = svgElement('defs'); + const clipPath = svgElement('clipPath'); + const clipHead = svgElement('path'); + clipPath.id = clipId; + clipPath.append(clipHead); + defs.append(clipPath); + svg.append(defs); + const motionLayer = svgElement('g'); + const backLayer = svgElement('g'); + const head = svgElement('path'); + const eyesLayer = svgElement('g'); + const leftEye = svgElement('path'); + const rightEye = svgElement('path'); + const frontLayer = svgElement('g'); + eyesLayer.setAttribute('clip-path', 'url(#' + clipId + ')'); + eyesLayer.append(leftEye, rightEye); + motionLayer.append(backLayer, head, eyesLayer, frontLayer); + svg.append(motionLayer); + host.replaceChildren(svg); + + const ensurePaths = (group, paths, fill) => { + while (group.children.length < paths.length) group.append(svgElement('path')); + while (group.children.length > paths.length) group.lastElementChild.remove(); + paths.forEach((path, index) => { + group.children[index].setAttribute('d', path); + group.children[index].setAttribute('fill', fill); + }); + }; + let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0]; + const initialStep = DATA.animations[currentAnimation].steps[0]; + const initialExpression = DATA.expressions[initialStep.expressionId]; + let currentPose = AvatarProceduralEngine.poseFromExpression(initialExpression); + let currentColors = resolveColors(initialExpression); + let blinkAmount = 1; + let transitionState = null; + let blinkState = null; + let frameRequest = null; + let stepTimer = null; + let blinkTimer = null; + let blinkDueAt = null; + let stepIndex = 0; + let direction = 1; + let playing = false; + let paused = false; + let pausedRemainingMs = 0; + let pausedTransition = null; + let pausedBlink = null; + let pausedBlinkDelay = 0; + let stepDueAt = null; + let ambientStartedAt = performance.now(); + let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion; + let lastAmbientFrame = 0; + + const applyMotion = expression => { + const signature = expression.eyeMotion + ':' + expression.bodyMotion; + if (signature !== ambientSignature) { + ambientSignature = signature; + ambientStartedAt = performance.now(); + } + }; + const render = (time = performance.now()) => { + const elapsed = time - ambientStartedAt; + const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression) + ? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed) + : currentPose.expression; + const renderedPose = AvatarProceduralEngine.poseFromExpression(expression); + const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, { + includeWire: false, + bodyNodes: DATA.avatar.bodyNodes, + }); + const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed); + motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')'); + ensurePaths(backLayer, geometry.backPaths, currentColors.body); + ensurePaths(frontLayer, geometry.frontPaths, currentColors.body); + head.setAttribute('d', geometry.headPath); + head.setAttribute('fill', currentColors.body); + clipHead.setAttribute('d', geometry.headPath); + leftEye.setAttribute('d', geometry.leftPath); + rightEye.setAttribute('d', geometry.rightPath); + leftEye.setAttribute('fill', currentColors.eyes); + rightEye.setAttribute('fill', currentColors.eyes); + leftEye.style.display = geometry.leftVisible ? '' : 'none'; + rightEye.style.display = geometry.rightVisible ? '' : 'none'; + }; + const tick = time => { + frameRequest = null; + if (transitionState) { + const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs); + const eased = easeProgress(linear, transitionState.transition); + const expression = { ...transitionState.fromPose.expression }; + AvatarProceduralEngine.expressionFields.forEach(field => { + expression[field] = transitionState.fromPose.expression[field] + + (transitionState.toPose.expression[field] - transitionState.fromPose.expression[field]) * eased; + }); + expression.eyeMotion = transitionState.toPose.expression.eyeMotion; + expression.bodyMotion = transitionState.toPose.expression.bodyMotion; + currentPose = AvatarProceduralEngine.poseFromExpression(expression); + currentColors = { + body: interpolateColor(transitionState.fromColors.body, transitionState.toColors.body, clamp01(eased)), + eyes: interpolateColor(transitionState.fromColors.eyes, transitionState.toColors.eyes, clamp01(eased)), + }; + if (linear >= 1) { + currentPose = transitionState.toPose; + currentColors = transitionState.toColors; + transitionState = null; + } + } + if (blinkState) { + const progress = clamp01((time - blinkState.startedAt) / blinkState.durationMs); + if (progress <= 0.42) { + const closeProgress = progress / 0.42; + blinkAmount = 1 - closeProgress * closeProgress; + } else { + const openProgress = (progress - 0.42) / 0.58; + blinkAmount = 1 - (1 - openProgress) ** 2; + } + if (progress >= 1) { + blinkAmount = 1; + blinkState = null; + } + } + const ambientActive = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression); + if (transitionState || blinkState || !ambientActive || time - lastAmbientFrame >= 1000 / 30) { + render(time); + if (ambientActive) lastAmbientFrame = time; + } + if (transitionState || blinkState || ambientActive) frameRequest = requestAnimationFrame(tick); + }; + const requestTick = () => { + if (frameRequest === null) frameRequest = requestAnimationFrame(tick); + }; + const animateTo = (expressionId, durationMs, transition) => { + const target = DATA.expressions[expressionId]; + if (!target) return; + applyMotion(target); + const resolved = resolvedTargetExpression(target, currentPose.expression); + const targetPose = AvatarProceduralEngine.poseFromExpression(resolved); + const targetColors = resolveColors(target); + if (durationMs <= 0) { + transitionState = null; + currentPose = targetPose; + currentColors = targetColors; + render(); + if (AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)) requestTick(); + return; + } + transitionState = { + fromPose: currentPose, + toPose: targetPose, + fromColors: currentColors, + toColors: targetColors, + startedAt: performance.now(), + durationMs, + transition, + expressionId, + }; + requestTick(); + }; + const clearSchedule = () => { + if (stepTimer !== null) clearTimeout(stepTimer); + if (blinkTimer !== null) clearTimeout(blinkTimer); + stepTimer = null; + blinkTimer = null; + blinkDueAt = null; + stepDueAt = null; + }; + const scheduleBlink = (animation, delay) => { + if (!animation.blink.enabled) return; + blinkDueAt = performance.now() + delay; + blinkTimer = setTimeout(() => { + blinkDueAt = null; + blinkState = { startedAt: performance.now(), durationMs: animation.blink.durationMs }; + requestTick(); + const range = animation.blink.maxIntervalMs - animation.blink.minIntervalMs; + scheduleBlink(animation, animation.blink.durationMs + animation.blink.minIntervalMs + Math.random() * range); + }, delay); + }; + const advance = animation => { + const last = animation.steps.length - 1; + const playbackMode = options.loop === true ? 'loop' : options.loop === false ? 'once' : animation.playbackMode; + if (playbackMode === 'once' && stepIndex >= last) { + playing = false; + options.onAnimationEnd?.(currentAnimation); + return; + } + if (playbackMode === 'pingPong' && last > 0) { + if (stepIndex >= last) direction = -1; + else if (stepIndex <= 0) direction = 1; + stepIndex += direction; + } else stepIndex = (stepIndex + 1) % (last + 1); + runStep(animation); + }; + const runStep = animation => { + if (!playing || !animation.steps.length) return; + const step = animation.steps[stepIndex]; + animateTo(step.expressionId, step.transitionMs, step.transition); + const duration = step.transitionMs + step.holdMs; + stepDueAt = performance.now() + duration; + stepTimer = setTimeout(() => advance(animation), duration); + }; + const api = { + element: svg, + get animation() { return currentAnimation; }, + get playing() { return playing; }, + play(animationName) { + animationName = animationName || currentAnimation; + if (!DATA.animations[animationName]) throw new Error('Unknown animation: ' + animationName); + clearSchedule(); + if (animationName === currentAnimation && paused) { + paused = false; + playing = true; + if (pausedTransition) animateTo(pausedTransition.expressionId, pausedTransition.durationMs, pausedTransition.transition); + if (pausedBlink) { + blinkState = { + startedAt: performance.now() - pausedBlink.progress * pausedBlink.durationMs, + durationMs: pausedBlink.durationMs, + }; + requestTick(); + } + stepDueAt = performance.now() + pausedRemainingMs; + stepTimer = setTimeout(() => advance(DATA.animations[currentAnimation]), pausedRemainingMs); + scheduleBlink( + DATA.animations[currentAnimation], + pausedBlinkDelay || DATA.animations[currentAnimation].blink.minIntervalMs + ); + pausedTransition = null; + pausedBlink = null; + pausedBlinkDelay = 0; + return api; + } + currentAnimation = animationName; + stepIndex = 0; + direction = 1; + paused = false; + playing = true; + runStep(DATA.animations[currentAnimation]); + scheduleBlink(DATA.animations[currentAnimation], DATA.animations[currentAnimation].blink.initialDelayMs); + return api; + }, + pause() { + const now = performance.now(); + if (playing && stepDueAt !== null) pausedRemainingMs = Math.max(stepDueAt - now, 0); + pausedBlinkDelay = blinkDueAt === null ? 0 : Math.max(blinkDueAt - now, 0); + if (transitionState) { + const elapsed = now - transitionState.startedAt; + pausedTransition = { + expressionId: transitionState.expressionId, + durationMs: Math.max(transitionState.durationMs - elapsed, 0), + transition: transitionState.transition, + }; + } + if (blinkState) { + pausedBlink = { + progress: clamp01((now - blinkState.startedAt) / blinkState.durationMs), + durationMs: blinkState.durationMs, + }; + } + clearSchedule(); + transitionState = null; + blinkState = null; + paused = true; + playing = false; + render(); + return api; + }, + stop() { + clearSchedule(); + transitionState = null; + blinkState = null; + blinkAmount = 1; + pausedBlink = null; + pausedBlinkDelay = 0; + paused = false; + playing = false; + stepIndex = 0; + direction = 1; + const first = DATA.animations[currentAnimation].steps[0]; + if (first) animateTo(first.expressionId, 0, first.transition); + return api; + }, + destroy() { + clearSchedule(); + if (frameRequest !== null) cancelAnimationFrame(frameRequest); + svg.remove(); + }, + }; + applyMotion(initialExpression); + render(); + if (AvatarProceduralEngine.hasAmbientMotion(initialExpression)) requestTick(); + if (options.autoplay !== false) api.play(currentAnimation); + return api; +} +` diff --git a/src/renderedRotationGizmo.ts b/src/renderedRotationGizmo.ts new file mode 100644 index 0000000..ab5cf32 --- /dev/null +++ b/src/renderedRotationGizmo.ts @@ -0,0 +1,43 @@ +import { motionValue, type MotionValue } from 'motion' + +import { poseFromExpression, rotationRing, type Expression, type Point3 } from './geometry' + +export type RenderedRotationGizmo = { + xPath: MotionValue + yPath: MotionValue + zPath: MotionValue +} + +export const rotationRingPath = (points: Point3[]) => + `M${points[0][0]} ${points[0][1]}${points + .slice(1) + .map(point => `L${point[0]} ${point[1]}`) + .join('')}Z` + +const rotationPaths = (expression: Expression) => { + const pose = poseFromExpression(expression) + return { + x: rotationRingPath(rotationRing(pose, 'x')), + y: rotationRingPath(rotationRing(pose, 'y')), + z: rotationRingPath(rotationRing(pose, 'z')), + } +} + +export const createRenderedRotationGizmo = (expression: Expression): RenderedRotationGizmo => { + const paths = rotationPaths(expression) + return { + xPath: motionValue(paths.x), + yPath: motionValue(paths.y), + zPath: motionValue(paths.z), + } +} + +export const paintRenderedRotationGizmo = ( + gizmo: RenderedRotationGizmo, + expression: Expression +) => { + const paths = rotationPaths(expression) + gizmo.xPath.jump(paths.x) + gizmo.yPath.jump(paths.y) + gizmo.zPath.jump(paths.z) +} diff --git a/src/renderedScene.ts b/src/renderedScene.ts new file mode 100644 index 0000000..952b929 --- /dev/null +++ b/src/renderedScene.ts @@ -0,0 +1,82 @@ +import { motionValue, type MotionValue } from 'motion' + +import type { AvatarColors } from './avatars' +import { MAX_BODY_NODES } from './body' +import type { AvatarGeometry } from './geometry' + +export type RenderedScene = { + headPath: MotionValue + backPaths: MotionValue[] + frontPaths: MotionValue[] + backNodeIds: { current: (string | null)[] } + frontNodeIds: { current: (string | null)[] } + leftPath: MotionValue + rightPath: MotionValue + leftOpacity: MotionValue + rightOpacity: MotionValue + offsetX: MotionValue + offsetY: MotionValue + wirePaths: MotionValue[] +} + +export type RenderedColors = { + body: MotionValue + eyes: MotionValue +} + +const bodyPathSlots = MAX_BODY_NODES + 2 + +export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({ + headPath: motionValue(geometry.headPath), + backPaths: Array.from({ length: bodyPathSlots }, (_, index) => + motionValue(geometry.backPaths[index] ?? '') + ), + frontPaths: Array.from({ length: bodyPathSlots }, (_, index) => + motionValue(geometry.frontPaths[index] ?? '') + ), + backNodeIds: { current: geometry.backNodeIds }, + frontNodeIds: { current: geometry.frontNodeIds }, + leftPath: motionValue(geometry.leftPath), + rightPath: motionValue(geometry.rightPath), + leftOpacity: motionValue(geometry.leftVisible ? 1 : 0), + rightOpacity: motionValue(geometry.rightVisible ? 1 : 0), + offsetX: motionValue(0), + offsetY: motionValue(0), + wirePaths: geometry.wirePaths.map(path => motionValue(path)), +}) + +export const createRenderedColors = (colors: AvatarColors): RenderedColors => ({ + body: motionValue(colors.body), + eyes: motionValue(colors.eyes), +}) + +export const paintRenderedColors = (rendered: RenderedColors, colors: AvatarColors) => { + rendered.body.set(colors.body) + rendered.eyes.set(colors.eyes) +} + +export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y: number }) => { + scene.offsetX.set(offset.x) + scene.offsetY.set(offset.y) +} + +export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => { + scene.headPath.set(geometry.headPath) + scene.backNodeIds.current = geometry.backNodeIds + scene.frontNodeIds.current = geometry.frontNodeIds + scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? '')) + scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? '')) + scene.leftPath.set(geometry.leftPath) + scene.rightPath.set(geometry.rightPath) + scene.leftOpacity.set(geometry.leftVisible ? 1 : 0) + scene.rightOpacity.set(geometry.rightVisible ? 1 : 0) + scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? '')) +} + +export const findBodyNodePath = (scene: RenderedScene, selectedBodyNodeId: 'primary' | string) => { + if (selectedBodyNodeId === 'primary') return scene.headPath + const backIndex = scene.backNodeIds.current.indexOf(selectedBodyNodeId) + if (backIndex >= 0) return scene.backPaths[backIndex] + const frontIndex = scene.frontNodeIds.current.indexOf(selectedBodyNodeId) + return frontIndex >= 0 ? scene.frontPaths[frontIndex] : null +} diff --git a/src/sequences.ts b/src/sequences.ts new file mode 100644 index 0000000..d02b75a --- /dev/null +++ b/src/sequences.ts @@ -0,0 +1,288 @@ +import { + getStatePlaybackConfig, + initialExpressions, + stateGroups, + stateNotes, + statePools, +} from './presets' +import type { Expression } from './geometry' + +export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong' +export type SequenceTransition = 'spring' | 'smooth' | 'snappy' + +export type SequenceStep = { + id: string + expressionId: string + holdMs: number + transitionMs: number + transition: SequenceTransition +} + +export type BlinkSettings = { + enabled: boolean + initialDelayMs: number + minIntervalMs: number + maxIntervalMs: number + durationMs: number +} + +export type AvatarSequence = { + id: string + name: string + group: string + description: string + builtIn: boolean + playbackMode: SequencePlaybackMode + steps: SequenceStep[] + blink: BlinkSettings +} + +export type SequenceCursor = { + index: number + direction: 1 | -1 + complete: boolean +} + +const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong'] +const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy'] +const builtInSequenceIds = new Set(Object.values(stateGroups).flat()) + +const finite = (value: unknown, fallback: number, min: number, max: number) => + typeof value === 'number' && Number.isFinite(value) + ? Math.min(Math.max(value, min), max) + : fallback + +const createId = (prefix: string) => + `${prefix}-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`}` + +const cloneSequence = (sequence: AvatarSequence): AvatarSequence => ({ + ...sequence, + steps: sequence.steps.map(step => ({ ...step })), + blink: { ...sequence.blink }, +}) + +export const createInitialSequences = (): AvatarSequence[] => + Object.entries(stateGroups).flatMap(([group, stateIds]) => + stateIds.map(id => { + const playback = getStatePlaybackConfig(id) + return { + id, + name: id, + group, + description: + stateNotes[id] ?? 'Cette animation enchaîne un pool de presets et des clignements.', + builtIn: true, + playbackMode: 'loop', + steps: (statePools[id] ?? [0]).map((expressionIndex, index) => ({ + id: `${id}-step-${index}`, + expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id, + holdMs: playback.expressionIntervalMs, + transitionMs: 500, + transition: 'smooth', + })), + blink: { enabled: true, ...playback.blink }, + } + }) + ) + +const parseStep = (value: unknown, fallback: SequenceStep): SequenceStep => { + const candidate = value as Partial | null + return { + id: typeof candidate?.id === 'string' ? candidate.id : createId('step'), + expressionId: + typeof candidate?.expressionId === 'string' && candidate.expressionId + ? candidate.expressionId + : fallback.expressionId, + holdMs: finite(candidate?.holdMs, fallback.holdMs, 100, 60000), + transitionMs: finite(candidate?.transitionMs, fallback.transitionMs, 0, 5000), + transition: transitions.includes(candidate?.transition as SequenceTransition) + ? (candidate?.transition as SequenceTransition) + : fallback.transition, + } +} + +const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence => { + const candidate = value as Partial | null + const fallbackStep = fallback.steps[0] ?? { + id: 'fallback-step', + expressionId: initialExpressions[0].id, + holdMs: 3000, + transitionMs: 500, + transition: 'smooth' as const, + } + const steps = Array.isArray(candidate?.steps) + ? candidate.steps.map(step => parseStep(step, fallbackStep)) + : fallback.steps.map(step => ({ ...step })) + const storedBlink = candidate?.blink as Partial | undefined + const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000) + return { + id: typeof candidate?.id === 'string' ? candidate.id : fallback.id, + name: + typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name, + group: + typeof candidate?.group === 'string' && candidate.group.trim() + ? candidate.group + : fallback.group, + description: + typeof candidate?.description === 'string' ? candidate.description : fallback.description, + builtIn: + typeof candidate?.builtIn === 'boolean' + ? candidate.builtIn + : typeof candidate?.id === 'string' && builtInSequenceIds.has(candidate.id), + playbackMode: playbackModes.includes(candidate?.playbackMode as SequencePlaybackMode) + ? (candidate?.playbackMode as SequencePlaybackMode) + : fallback.playbackMode, + steps, + blink: { + enabled: + typeof storedBlink?.enabled === 'boolean' ? storedBlink.enabled : fallback.blink.enabled, + initialDelayMs: finite(storedBlink?.initialDelayMs, fallback.blink.initialDelayMs, 0, 60000), + minIntervalMs, + maxIntervalMs: Math.max( + minIntervalMs, + finite(storedBlink?.maxIntervalMs, fallback.blink.maxIntervalMs, 100, 60000) + ), + durationMs: finite(storedBlink?.durationMs, fallback.blink.durationMs, 40, 3000), + }, + } +} + +export const parseSequences = (value: unknown): AvatarSequence[] => { + const defaults = createInitialSequences() + if (!Array.isArray(value)) return defaults + if (!value.length) return [] + const fallback = defaults.find(sequence => sequence.id === 'idle') ?? defaults[0] + const seen = new Set() + return value + .map(sequence => parseSequence(sequence, fallback)) + .filter(sequence => { + if (seen.has(sequence.id)) return false + seen.add(sequence.id) + return true + }) +} + +export const normalizeSequencesForExpressions = ( + sequences: AvatarSequence[], + expressions: Expression[] +) => { + const availableIds = new Set(expressions.map(expression => expression.id)) + const fallbackId = expressions[0]?.id ?? initialExpressions[0].id + return sequences.map(sequence => ({ + ...sequence, + steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map( + step => ({ + ...step, + expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId, + }) + ), + })) +} + +export const findExpressionIndex = (expressions: Expression[], expressionId: string) => + expressions.findIndex(expression => expression.id === expressionId) + +export const readSequenceClock = () => performance.now() + +export const groupSequences = (sequences: AvatarSequence[]) => { + const groups: { name: string; sequences: AvatarSequence[] }[] = [] + const indexes = new Map() + sequences.forEach(sequence => { + const existingIndex = indexes.get(sequence.group) + if (existingIndex !== undefined) { + groups[existingIndex].sequences.push(sequence) + return + } + indexes.set(sequence.group, groups.length) + groups.push({ name: sequence.group, sequences: [sequence] }) + }) + return groups +} + +export const createSequence = (expressionId = initialExpressions[0].id): AvatarSequence => ({ + id: createId('sequence'), + name: 'Untitled animation', + group: 'Custom', + description: '', + builtIn: false, + playbackMode: 'loop', + steps: [ + { + id: createId('step'), + expressionId, + holdMs: 3000, + transitionMs: 500, + transition: 'smooth', + }, + ], + blink: { + enabled: true, + initialDelayMs: 2600, + minIntervalMs: 3400, + maxIntervalMs: 6200, + durationMs: 280, + }, +}) + +export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({ + ...cloneSequence(source), + id: createId('sequence'), + name: `${source.name} copy`, + builtIn: false, + steps: source.steps.map(step => ({ ...step, id: createId('step') })), +}) + +export const createSequenceStep = (expressionId: string): SequenceStep => ({ + id: createId('step'), + expressionId, + holdMs: 3000, + transitionMs: 500, + transition: 'smooth', +}) + +export const advanceSequenceCursor = ( + sequence: AvatarSequence, + index: number, + direction: 1 | -1 +): SequenceCursor => { + const lastIndex = sequence.steps.length - 1 + if (lastIndex < 0) return { index: 0, direction: 1, complete: true } + if (sequence.playbackMode === 'once') { + if (index >= lastIndex) return { index: lastIndex, direction: 1, complete: true } + return { index: index + 1, direction: 1, complete: false } + } + if (sequence.playbackMode === 'pingPong' && lastIndex > 0) { + if (direction === 1 && index >= lastIndex) + return { index: lastIndex - 1, direction: -1, complete: false } + if (direction === -1 && index <= 0) return { index: 1, direction: 1, complete: false } + return { index: index + direction, direction, complete: false } + } + return { index: index >= lastIndex ? 0 : index + 1, direction: 1, complete: false } +} + +export const remapSequencesAfterExpressionDelete = ( + sequences: AvatarSequence[], + deletedExpressionId: string, + fallbackExpressionId: string +) => + sequences.map(sequence => { + const steps = sequence.steps.filter(step => step.expressionId !== deletedExpressionId) + return { + ...sequence, + steps: steps.length ? steps : [createSequenceStep(fallbackExpressionId)], + } + }) + +export const getSequenceSpring = ( + transition: SequenceTransition, + durationMs: number, + baseSpeed: number +) => { + const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3) + const styleFactor = transition === 'smooth' ? 0.72 : transition === 'snappy' ? 1.45 : 1 + const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5) + return { + stiffness: 70 + speed * 24, + damping: (17 + speed * 1.7) * (transition === 'smooth' ? 1.18 : 1), + } +} diff --git a/src/snapshotExporter.ts b/src/snapshotExporter.ts new file mode 100644 index 0000000..7a373d7 --- /dev/null +++ b/src/snapshotExporter.ts @@ -0,0 +1,84 @@ +import type { AvatarColors } from './avatars' +import type { RenderedScene } from './renderedScene' + +export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial' + +export type SnapshotOptions = { + background: SnapshotBackground + colorFrom: string + colorTo: string + size: number +} + +const escapeXml = (value: string) => + value.replace(/[&<>"]/g, character => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + } + return entities[character] + }) + +const path = (value: string, fill: string, opacity = 1) => + value ? `` : '' + +const backgroundMarkup = (options: SnapshotOptions) => { + if (options.background === 'transparent') return '' + const fill = + options.background === 'solid' + ? options.colorFrom + : options.background === 'linear' + ? 'url(#snapshot-linear)' + : 'url(#snapshot-radial)' + return `` +} + +const gradientMarkup = (options: SnapshotOptions) => { + if (options.background === 'linear') { + return `` + } + if (options.background === 'radial') { + return `` + } + return '' +} + +export const serializeAvatarSnapshot = ( + name: string, + scene: RenderedScene, + colors: AvatarColors, + options: SnapshotOptions +) => { + const headPath = scene.headPath.get() + const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean) + const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean) + const offsetX = scene.offsetX.get() + const offsetY = scene.offsetY.get() + const body = [ + ...backPaths.map(value => path(value, colors.body)), + path(headPath, colors.body), + `${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}`, + ...frontPaths.map(value => path(value, colors.body)), + ].join('') + + return ` + + ${gradientMarkup(options)} + ${backgroundMarkup(options)} + ${body} +` +} + +export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => { + const slug = + name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'avatar' + return `${slug}-snapshot.${extension}` +} diff --git a/src/standaloneEngine.entry.ts b/src/standaloneEngine.entry.ts new file mode 100644 index 0000000..3c09894 --- /dev/null +++ b/src/standaloneEngine.entry.ts @@ -0,0 +1,2 @@ +export { expressionFields, poseFromExpression, renderAvatar } from './geometry' +export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion' diff --git a/src/standaloneEngine.generated.ts b/src/standaloneEngine.generated.ts new file mode 100644 index 0000000..e7f91ec --- /dev/null +++ b/src/standaloneEngine.generated.ts @@ -0,0 +1,2 @@ +// Generated by scripts/generate-standalone-engine.mjs. Do not edit. +export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=(e,t)=>Math.sign(e)*Math.abs(e)**t,n=(e,n,r,i,a,o,s)=>{let c=t(Math.cos(n),o);return[r/2*c*t(Math.sin(e),s),i/2*t(Math.sin(n),o),a/2*c*t(Math.cos(e),s)]},r=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),a=e=>1+i(e.roundness)/2,o=.04,s=e=>e.roundness<=0?1/0:2/(o+i(e.roundness)/2*.96),c=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},l=(e,t,n)=>c(e,t,n,a(e)),u=(e,t,n)=>c(e,t,n,s(e)),d=.24,f=.2,p=.22,m=e=>i(e.morphRoundness)/2,h=(e,t,n)=>{let r=m(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},g=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},_=e=>({tipFraction:(e.tipRoundness??0)*d,baseFraction:(e.baseRoundness??0)*f}),v=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*p;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},y=(e,t)=>h(e,t,v(e,t)),b=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=_(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:g(r,r/2,r/4,0,e),verticalProgress:g(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},S=(e,t)=>h(e,t,x(e,t)),C=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},w=(e,t,i)=>{let{width:a,height:o,depth:s}=e;switch(e.type){case`sphere`:case`mickey`:return n(t,i,a,o,s,1,1);case`cube`:return u(e,t,i);case`cylinder`:{let n=y(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),-o/2+o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}case`cursor`:{let n=C(e),r=(i+Math.PI/2)/Math.PI,a=v({...e,width:n.bodyWidth,height:n.bodyHeight,depth:n.bodyDepth},r);return[n.bodyWidth/2*a.radiusScale*Math.sin(t),n.bodyCenterY-n.bodyHeight/2+n.bodyHeight*a.verticalProgress,n.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return l(e,t,i);case`capsule`:return r(e,t,i);case`cone`:{let n=S(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),o/2-o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}}},ee=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],T=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},te=(e,t,n)=>{let r=e.type===`cone`?-1:1;return T([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},ne=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=w(e,t-r,n),a=w(e,t+r,n),o=w(e,t,Math.max(-Math.PI/2,n-r)),s=w(e,t,Math.min(Math.PI/2,n+r));return te(e,ee(a,i),ee(s,o))},E=(e,t)=>Math.sign(e)*Math.abs(e)**t,re=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return T([E(t[0]/r,n-1)/r,E(t[1]/i,n-1)/i,E(t[2]/a,n-1)/a])},ie=(e,t)=>re(e,t,a(e)),ae=(e,t)=>{let n=s(e);if(Number.isFinite(n))return re(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},oe=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},se=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:T([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},D=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=b(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=b(e,h,r),v=(b(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),x=-(o/a)*d/(l*y||1),S=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:T([-x,-S,1])}},ce=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,o=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return se(t,n,r,i,o);case`cube`:return oe(e,t,n,s(e),ae);case`capsule`:{let e=Math.min(r,i),a=Math.max(0,i-e);return se(t,n,r,e,o,n<-a?-a:n>a?a:n)}case`cylinder`:return D(e,t,n,y,1);case`cursor`:{let r=C(e),i=D({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,v,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return D(e,t,n,S,-1);case`diamond`:return oe(e,t,n,a(e),ie)}},le=(e,t,n)=>{let r=w(e,t,n);if(e.type===`sphere`||e.type===`mickey`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:T([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:T([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:ie(e,r)}:e.type===`cube`?{point:r,normal:ae(e,r)}:{point:r,normal:ne(e,t,n)}},O=620,ue=14,de=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],k=e=>e*Math.PI/180,fe=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},pe=([e,t,n,r],[i,a,o,s])=>fe([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),A=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return fe([Math.cos(i),e*a,t*a,n*a])},j=(e,t,n)=>{let r=A([1,0,0],e),i=A([0,1,0],t);return pe(pe(A([0,0,1],n),r),i)},M=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},me=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r{let n=O-e[2]*t,r=Math.abs(n)<1e-4?O/1e-4:O/n;return[e[0]*r,e[1]*r,e[2]]},P=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,he=e=>({expression:e,orientation:j(k(e.headX),k(e.headY),k(e.headZ))}),ge=24,_e=25,ve=73,F=144,ye=33,be=73,I=new Map,L=new Map,R=new Map,z=e=>[e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness].map(e=>typeof e==`number`?e.toFixed(4):e).join(`:`),B=(e,t,n)=>(e.size>=ge&&e.delete(e.keys().next().value),e.set(t,n),n),V=(e,t,n)=>le(e,t,n),xe=(e,t)=>({point:N(M(e.orientation,t.point),e.expression.perspective),normal:M(e.orientation,t.normal)}),Se=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},Ce=(e,t,n,r)=>{let[i,a]=Se(n,r);return xe(e,ce(t,i,a))},we=(e,t,n,r)=>{let i=e.expression,a=n<0?`Left`:`Right`,o=i[`width${a}`],s=5+(i[`height${a}`]-5)*r,c=n*i.spacing/2+i[`positionX${a}`],l=i[`positionY${a}`],u=k(n<0?i.leftAngle:i.rightAngle);return me(o,s).map(([n,r])=>{let i=n*Math.cos(u)-r*Math.sin(u),a=n*Math.sin(u)+r*Math.cos(u);return Ce(e,t,c+i,l+a)})},Te=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>P(e,!1)).join(``)},Ee=(e,t)=>{let n=z(t),r=R.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>V(t,k(-180+r*5),k(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>V(t,k(e),k(-90+r*5))));r=B(R,n,[...e,...i])}return r.map(t=>Te(t.map(t=>xe(e,t))))},H=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},U=e=>{if(e.length<3)return P(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},W=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),De=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,G=(e,t)=>N(M(e.orientation,t),e.expression.perspective),K=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/F*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),q=(e,t)=>{let n=z(t),r=I.get(n);return r||(r=Array.from({length:ye},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:be},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),B(I,n,r)),U(W(H(r.map(t=>G(e,t)))))},Oe=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return q(e,t);let n=t.height/2;return U(W(H([...K(t.width,t.depth,-n),...K(t.width,t.depth,n)].map(t=>G(e,t)))))},ke=(e,t)=>{let n=C(t),r=n.bodyHeight/2;return U(W(H([...K(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...K(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>G(e,t)))))},Ae=(e,t)=>{let n=C(t),r=G(e,[0,n.coneApexY,0]);return U(W(H([...K(t.width,t.depth,n.coneBaseY).map(t=>G(e,t)),r])))},je=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return q(e,t);let n=G(e,[0,-t.height/2,0]),r=H([...K(t.width,t.depth,t.height/2).map(t=>G(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return U(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?P(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${De(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Me=(e,t)=>{if(t.roundness>0)return q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return P(H([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>G(e,t))))},Ne=(e,t)=>{if(t.roundness>0)return q(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return P(H([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>G(e,t))))},Pe=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},J=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},Y=(e,t,n=[0,0,0])=>{let r=[M(e.orientation,[1,0,0]),M(e.orientation,[0,1,0]),M(e.orientation,[0,0,1])],i=M(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return Pe(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=O/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:Pe(h,g,b/x,-y/x,v/x)},Fe=(e,t)=>{let n=Y(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return J({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?J(n):null},Ie=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>Y(e,s,[t*i,a,o])).filter(e=>e!==null).map(J)},Le=(e,t)=>t.type===`mickey`?Ie(e,t):t.type===`cursor`?[Ae(e,t)]:[],X=e=>Array.from({length:F},(t,n)=>{let r=n/F*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),Re=e=>{if(e.length<3)return P(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},ze=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=Y(e,o,[0,a,0]),c=Y(e,o,[0,-a,0]);return!s||!c?null:Re(H([...X(s),...X(c)]))},Be=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Fe(e,t);if(n)return n}if(t.type===`capsule`){let n=ze(e,t);if(n)return n}if(t.type===`cylinder`)return Oe(e,t);if(t.type===`cursor`)return ke(e,t);if(t.type===`cone`)return je(e,t);if(t.type===`cube`)return Me(e,t);if(t.type===`diamond`)return Ne(e,t);let n=z(t),r=I.get(n);return r||(r=Array.from({length:_e},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:ve},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),B(I,n,r)),P(H(r.map(t=>N(M(e.orientation,t),e.expression.perspective))))},Ve=(e,t)=>{let n=z(t.surface),r=L.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return w(t.surface,i,r)})}).flat(),B(L,n,r));let i=j(k(t.rotation[0]),k(t.rotation[1]),k(t.rotation[2])),a=H(r.map(n=>{let r=M(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return N(M(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?P(a):U(W(a))},He=.1,Ue=(e,t)=>{let n=j(k(t.rotation[0]),k(t.rotation[1]),k(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>M(e.orientation,M(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},We=(e,t)=>{let n=t.map(t=>{let n=M(e.orientation,t.position)[2];return{id:t.id,path:Ve(e,t),depth:n,front:n>Ue(e,t)*He}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}},Ge=(e,t,n=1,r={})=>{let i=we(e,t,-1,n),a=we(e,t,1,n),o=i.map(e=>e.point),s=a.map(e=>e.point),c=We(e,r.bodyNodes??[]),l=Le(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:Be(e,t),leftPath:P(o),rightPath:P(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:Ee(e,t)}},Ke=e=>e*e*(3-2*e),Z=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},qe=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,Q=(e,t,n,r)=>{let i=e/r,a=Math.floor(i),o=Ke(i-a),s=Z(a*3+t+n);return s+(Z((a+1)*3+t+n)-s)*o},$=(e,t,n)=>{let r=1100,i=e+Math.sin(e/1700+n)*280,a=Math.floor(i/r),o=(i%r+r)%r/r,s=Ke(Math.min(o/.09,1)),c=Z((a-1)*2+t+n),l=Z(a*2+t+n),u=Math.min(e/240,1);return(c+(l-c)*s)*u};return e.ambientBodyOffset=(e,t)=>{let n=qe(e);if(e.bodyMotion===`slowDrift`)return{x:Q(t,3,n,2900)*1.45,y:Q(t,4,n,3700)*1.1};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1}}return{x:0,y:0}},e.applyAmbientMotion=(e,t)=>{let n={...e},r=qe(e);if(e.eyeMotion===`microSaccades`){let e=$(t,0,r)*1.5,i=$(t,1,r)*.9;n.positionXLeft+=e,n.positionXRight+=e,n.positionYLeft+=i,n.positionYRight+=i}else if(e.eyeMotion===`shake`){let e=t/1e3,r=(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2,i=(Math.sin(e*59)+Math.sin(e*83)*.4)*.8;n.positionXLeft+=r,n.positionXRight+=r,n.positionYLeft+=i,n.positionYRight+=i}if(e.bodyMotion===`slowDrift`)n.headX+=Q(t,0,r,2600)*.8,n.headY+=Q(t,1,r,3300)*1.15,n.headZ+=Q(t,2,r,4100)*.45;else if(e.bodyMotion===`shake`){let e=t/1e3;n.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15,n.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35,n.headZ+=Math.sin(e*43)*.7}return n},e.expressionFields=de,e.hasAmbientMotion=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,e.poseFromExpression=he,e.renderAvatar=Ge,e})({});" diff --git a/src/storedZip.ts b/src/storedZip.ts new file mode 100644 index 0000000..fd75d5c --- /dev/null +++ b/src/storedZip.ts @@ -0,0 +1,61 @@ +type StoredZipFile = { name: string; content: string } + +const crc32 = (bytes: Uint8Array) => { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + return (crc ^ 0xffffffff) >>> 0 +} + +export const createStoredZip = (files: StoredZipFile[]) => { + const encoder = new TextEncoder() + const chunks: Uint8Array[] = [] + const centralEntries: Uint8Array[] = [] + let offset = 0 + const header = (size: number) => { + const bytes = new Uint8Array(size) + return { bytes, view: new DataView(bytes.buffer) } + } + + for (const file of files) { + const name = encoder.encode(file.name) + const content = encoder.encode(file.content) + const checksum = crc32(content) + const local = header(30 + name.length) + local.view.setUint32(0, 0x04034b50, true) + local.view.setUint16(4, 20, true) + local.view.setUint32(14, checksum, true) + local.view.setUint32(18, content.length, true) + local.view.setUint32(22, content.length, true) + local.view.setUint16(26, name.length, true) + local.bytes.set(name, 30) + chunks.push(local.bytes, content) + + const central = header(46 + name.length) + central.view.setUint32(0, 0x02014b50, true) + central.view.setUint16(4, 20, true) + central.view.setUint16(6, 20, true) + central.view.setUint32(16, checksum, true) + central.view.setUint32(20, content.length, true) + central.view.setUint32(24, content.length, true) + central.view.setUint16(28, name.length, true) + central.view.setUint32(42, offset, true) + central.bytes.set(name, 46) + centralEntries.push(central.bytes) + offset += local.bytes.length + content.length + } + + const centralSize = centralEntries.reduce((size, entry) => size + entry.length, 0) + const end = header(22) + end.view.setUint32(0, 0x06054b50, true) + end.view.setUint16(8, files.length, true) + end.view.setUint16(10, files.length, true) + end.view.setUint32(12, centralSize, true) + end.view.setUint32(16, offset, true) + const parts = [...chunks, ...centralEntries, end.bytes].map( + bytes => Uint8Array.from(bytes).buffer as ArrayBuffer + ) + return new Blob(parts, { type: 'application/zip' }) +} diff --git a/src/studioDocument.ts b/src/studioDocument.ts new file mode 100644 index 0000000..4ce9572 --- /dev/null +++ b/src/studioDocument.ts @@ -0,0 +1,140 @@ +import { + parseAvatarLibrary, + parseExpressions, + type AvatarBehaviorLibrary, + type AvatarLibrary, +} from './avatars' +import type { Expression } from './geometry' +import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences' +import defaultStudioDocument from './defaultStudioDocument.json' + +export type StatePlaybackSelection = { stateId: string | null; playing: boolean } + +export type StudioDocument = { + version: 2 + library: AvatarLibrary + expressions: Expression[] + sequences: AvatarSequence[] + playback: StatePlaybackSelection +} + +export type StudioDocumentPatch = Partial> + +const DOCUMENT_STORAGE_KEY = 'bible-strong-avatar-studio-v2' + +const defaultPlayback: StatePlaybackSelection = { stateId: 'idle', playing: true } + +const parsePlayback = ( + value: unknown, + fallback: StatePlaybackSelection = defaultPlayback +): StatePlaybackSelection => { + const candidate = value as Partial | null + if (!candidate || (typeof candidate.stateId !== 'string' && candidate.stateId !== null)) { + return { ...fallback } + } + return { stateId: candidate.stateId, playing: candidate.playing === true } +} + +export const parseStudioDocument = (value: unknown, fallback: StudioDocument): StudioDocument => { + const candidate = value as Partial | null + if (!candidate || candidate.version !== 2) return fallback + const expressions = + Array.isArray(candidate.expressions) && candidate.expressions.length + ? parseExpressions(candidate.expressions) + : fallback.expressions + const sequences = Array.isArray(candidate.sequences) + ? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions) + : fallback.sequences + const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences } + const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior) + return { + version: 2, + library, + expressions, + sequences, + playback: parsePlayback(candidate.playback, fallback.playback), + } +} + +export const serializeStudioDocument = (document: StudioDocument) => + JSON.stringify(document, null, 2) + +export const parseImportedStudioDocument = ( + source: string, + fallback: StudioDocument +): StudioDocument => { + let value: unknown + try { + value = JSON.parse(source) + } catch { + throw new Error('Invalid Avatar Studio project') + } + const candidate = value as Partial | null + if (!candidate || candidate.version !== 2) { + throw new Error('Unsupported Avatar Studio project') + } + if ( + !candidate.library || + !Array.isArray(candidate.library.avatars) || + !candidate.library.avatars.length || + !Array.isArray(candidate.expressions) || + !candidate.expressions.length || + !Array.isArray(candidate.sequences) + ) { + throw new Error('Invalid Avatar Studio project') + } + return parseStudioDocument(candidate, fallback) +} + +const createBundledStudioDocument = () => { + const snapshot = JSON.parse(JSON.stringify(defaultStudioDocument)) as StudioDocument + return parseStudioDocument(snapshot, snapshot) +} + +export const loadStudioDocument = ( + storage: Pick = window.localStorage +): StudioDocument => { + const fallback = createBundledStudioDocument() + try { + return parseStudioDocument( + JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'), + fallback + ) + } catch { + return fallback + } +} + +export const persistStudioDocument = (document: StudioDocument) => { + try { + window.localStorage.setItem(DOCUMENT_STORAGE_KEY, JSON.stringify(document)) + return true + } catch { + // The in-memory document remains authoritative when storage is unavailable. + return false + } +} + +export const createStudioDocumentStore = ( + initial: StudioDocument, + persist: (document: StudioDocument) => void = persistStudioDocument +) => { + let current = initial + return { + update: (patch: StudioDocumentPatch) => { + const expressions = patch.expressions ?? current.expressions + current = { + ...current, + ...patch, + version: 2, + expressions, + sequences: normalizeSequencesForExpressions( + patch.sequences ?? current.sequences, + expressions + ), + } + persist(current) + return current + }, + } +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..06cc349 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,2177 @@ +@import 'tailwindcss'; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} + +:root { + --background: #ffffff; + --foreground: #17191d; + --card: #ffffff; + --card-foreground: #17191d; + --popover: #ffffff; + --popover-foreground: #17191d; + --primary: #17191d; + --primary-foreground: #ffffff; + --secondary: #edf1ff; + --secondary-foreground: #4168d5; + --muted: #f0f1f4; + --muted-foreground: #767d88; + --destructive: #c83737; + --border: #dcdfe6; + --input: #d9dce3; + --ring: #5b7fe5; + color: #17191d; + background: #eef0f4; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} +button, +input { + font: inherit; +} +.studio { + display: grid; + min-height: 100vh; + grid-template-columns: minmax(460px, 1fr) minmax(440px, 520px); +} + +.stage-column { + position: sticky; + top: 0; + display: flex; + height: 100vh; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: hidden; + padding: 28px; + background: #101316; + color: white; +} + +.stage-column::before { + position: absolute; + width: 70vw; + height: 70vw; + border-radius: 50%; + background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%); + content: ''; + pointer-events: none; +} +.photo-flash { + position: absolute; + z-index: 20; + inset: 0; + background: white; + pointer-events: none; +} +.photo-capture-bar { + position: absolute; + z-index: 8; + bottom: 58px; + left: 50%; + display: flex; + align-items: center; + gap: 8px; + transform: translateX(-50%); +} +.photo-capture-button { + min-width: 190px; + height: 44px; + padding-inline: 15px 10px; + border: 1px solid rgb(255 255 255 / 14%); + border-radius: 13px; + background: rgb(246 248 252 / 96%); + box-shadow: + 0 12px 32px rgb(0 0 0 / 28%), + inset 0 1px 0 rgb(255 255 255 / 78%); + color: #17191d; + transition-property: transform, background-color, box-shadow; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} +.photo-capture-button:hover { + background: white; + box-shadow: + 0 15px 36px rgb(0 0 0 / 34%), + inset 0 1px 0 white; +} +.photo-capture-button:active { + transform: scale(0.96); +} +.photo-capture-button > svg { + width: 17px; + height: 17px; +} +.photo-format-badge { + margin-left: auto; + padding: 3px 6px; + border-radius: 6px; + background: #e9edfa; + color: #4168d5; + font-size: 9px; + font-weight: 850; + letter-spacing: 0.08em; +} +.photo-help-button { + width: 40px; + height: 40px; + border: 1px solid rgb(255 255 255 / 13%); + border-radius: 12px; + background: rgb(26 30 34 / 88%); + box-shadow: 0 10px 28px rgb(0 0 0 / 24%); + color: #f6f7f8; + backdrop-filter: blur(12px); +} +.photo-help-button:hover { + background: rgb(40 45 51 / 96%); + color: white; +} +.photo-help-tooltip { + width: min(280px, calc(100vw - 32px)); + padding: 9px 11px; + line-height: 1.45; + text-wrap: pretty; +} + +.brand { + position: absolute; + top: 28px; + left: 32px; + display: flex; + align-items: center; + gap: 9px; + font-size: 13px; + font-weight: 760; + letter-spacing: -0.01em; +} + +.brand em { + color: #77808c; + font-style: normal; + font-weight: 600; +} +.brand-mark { + width: 20px; + height: 20px; + border-radius: 50%; + background: #5b7fe5; + box-shadow: 0 0 0 3px rgb(91 127 229 / 15%); +} +.language-picker { + position: absolute; + z-index: 5; + top: 24px; + right: 28px; + display: flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 4px 8px 4px 10px; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 10px; + background: rgb(26 30 34 / 86%); + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + backdrop-filter: blur(12px); +} +.language-picker > span { + font-size: 15px; + line-height: 1; +} +.language-picker [data-slot='select-trigger'] { + width: auto; + height: 24px; + min-width: 82px; + padding: 0 4px; + border: 0; + background: transparent; + box-shadow: none; + color: #f6f7f8; + font-size: 10px; + font-weight: 750; +} +.language-picker [data-slot='select-trigger']:focus-visible { + border-radius: 5px; + box-shadow: 0 0 0 2px #7895ee; +} + +.avatar-wrap { + position: relative; + z-index: 1; + width: min(72vh, 72vw, 680px); + aspect-ratio: 1; +} +.stage-playback-status { + position: absolute; + z-index: 6; + top: 3%; + left: 3%; + padding: 9px 11px; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 12px; + background: rgb(26 30 34 / 86%); + box-shadow: 0 8px 24px rgb(0 0 0 / 18%); + backdrop-filter: blur(12px); + pointer-events: none; +} +.stage-playback-status .playback-identity { + min-width: 104px; + padding: 0; +} +.stage-playback-status .playback-identity strong { + color: #f6f7f8; +} +.stage-playback-status .playback-identity small { + color: #8d96a2; +} + +.avatar { + display: block; + width: 100%; + height: 100%; + overflow: visible; + cursor: grab; + touch-action: none; +} +.avatar:active { + cursor: grabbing; +} + +.export-avatar-summary { + display: flex; + align-items: center; + gap: 14px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--muted); +} + +.export-avatar-summary > svg { + width: 62px; + height: 62px; + flex: none; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--card); +} + +.export-avatar-summary > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.export-avatar-summary small, +.export-format-grid small { + color: var(--muted-foreground); + font-size: 11px; + font-weight: 500; +} + +.export-avatar-summary strong { + overflow: hidden; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.export-format-grid { + display: grid; + min-width: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.export-format-grid > button { + justify-content: flex-start; + min-width: 0; + height: auto; + min-height: 68px; + padding: 10px; + text-align: left; +} + +.export-format-grid > button[aria-pressed='true'] { + border-color: #6f8ff0; + background: #edf1ff; + box-shadow: 0 0 0 2px rgb(91 127 229 / 14%); + color: #3159cb; +} + +.export-format-grid > button > svg { + width: 16px; + height: 16px; +} + +.export-format-grid > button > span { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.export-format-grid strong, +.export-format-grid small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.export-format-grid strong { + font-size: 11px; +} +.export-format-grid small { + font-size: 9px; +} + +.export-animation-header { + align-items: center; +} + +.export-animation-header > button { + color: #4168d5; + font-size: 11px; +} + +.export-animation-grid { + align-items: flex-start; +} + +.export-download { + width: 100%; + min-height: 44px; + box-shadow: 0 12px 30px rgb(23 25 29 / 18%); +} +.export-panel { + width: 100%; + min-width: 0; +} +.export-accordion-item { + min-width: 0; + overflow: hidden; + border-color: #dcdfe6; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.export-accordion-trigger > span { + display: grid; + min-width: 0; + gap: 5px; +} +.export-accordion-trigger strong { + color: #343b49; + font-size: 12px; + font-weight: 850; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.export-accordion-trigger small { + color: #858c99; + font-size: 10px; + font-weight: 500; + line-height: 1.45; + white-space: normal; +} +.export-accordion-trigger[aria-expanded='true'] > svg { + transform: rotate(180deg); +} +.export-accordion-content { + min-width: 0; + transition: height 200ms ease; +} +.export-accordion-inner { + display: grid; + min-width: 0; + gap: 10px; +} +.export-accordion-inner > * { + min-width: 0; +} +.export-placeholder { + margin: 0; + padding: 12px; + color: var(--muted-foreground); + border: 1px dashed var(--border); + border-radius: 12px; + background: var(--muted); + font-size: 12px; + line-height: 1.5; +} +.project-transfer-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.project-transfer-actions > button { + min-height: 42px; + font-size: 11px; +} +.project-import-input { + display: none; +} +.project-transfer-error { + margin: 10px 0 0; + color: var(--destructive); + font-size: 12px; + line-height: 1.45; +} +.snapshot-preview-card { + display: flex; + align-items: center; + gap: 14px; +} +.snapshot-preview-card > div:last-child { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.snapshot-preview-card small, +.snapshot-preview-card span, +.snapshot-background-field small { + color: var(--muted-foreground); + font-size: 10px; +} +.snapshot-preview-card strong { + overflow: hidden; + font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} +.snapshot-preview { + width: 112px; + height: 112px; + flex: none; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: white; +} +.snapshot-preview.is-transparent { + background-color: white; + background-image: + linear-gradient(45deg, #e7e9ee 25%, transparent 25%), + linear-gradient(-45deg, #e7e9ee 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e7e9ee 75%), + linear-gradient(-45deg, transparent 75%, #e7e9ee 75%); + background-position: + 0 0, + 0 6px, + 6px -6px, + -6px 0; + background-size: 12px 12px; +} +.snapshot-preview svg { + display: block; + width: 100%; + height: 100%; +} +.snapshot-background-field { + align-items: center; +} +.snapshot-background-field > [data-slot='select-trigger'] { + width: 190px; +} +.snapshot-background-field > div:first-child { + display: grid; + gap: 3px; +} +.snapshot-colors { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} +.snapshot-settings-separator { + margin-block: 14px; +} +.avatar-head, +.preview-head { + fill: var(--avatar-body-color, #5b7fe5); +} +.avatar-eye, +.preview-eye { + fill: var(--avatar-eye-color, #111316); +} +.avatar-head, +.avatar-eye { + transition: fill 420ms cubic-bezier(0.22, 1, 0.36, 1); +} +.avatar-eye { + cursor: pointer; +} +.cyan-outline { + stroke: #51ddff; + stroke-width: 2.6; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; +} +.selection-outline { + fill: none; + stroke: #51ddff; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} +.body-selection-outline { + stroke-width: 3; + filter: drop-shadow(0 0 3px rgb(81 221 255 / 52%)); +} +.body-node-gizmo { + isolation: isolate; +} +.body-gizmo-ring, +.body-gizmo-axis { + fill: none; + vector-effect: non-scaling-stroke; + touch-action: none; + transition: + opacity 120ms ease, + stroke-width 120ms ease, + filter 120ms ease; +} +.body-gizmo-hitbox { + fill: none; + stroke: transparent; + stroke-width: 14; + vector-effect: non-scaling-stroke; + cursor: grab; + touch-action: none; +} +.body-gizmo-ring { + stroke-width: 1.5; + opacity: 0.42; + cursor: grab; +} +.body-gizmo-axis { + stroke-width: 2.4; + cursor: move; +} +.body-gizmo-ring-hitbox:hover + .body-gizmo-ring, +.body-gizmo-ring.is-active, +.body-gizmo-axis-hitbox:hover + .body-gizmo-axis, +.body-gizmo-axis.is-active { + stroke-width: 4; + opacity: 1; + filter: drop-shadow(0 0 2px currentColor); +} +.body-gizmo-handle { + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + cursor: move; + touch-action: none; +} +.body-gizmo-handle.gizmo-x { + fill: #46d477; +} +.body-gizmo-handle.gizmo-y { + fill: #ef5f5f; +} +.body-gizmo-handle.gizmo-z { + fill: #557bea; +} +.body-gizmo-origin { + fill: #f8fafc; + stroke: #11161d; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + transition: + r 120ms ease, + stroke-width 120ms ease, + filter 120ms ease; +} +.body-gizmo-plane-hitbox { + fill: transparent; + cursor: move; + touch-action: none; +} +.body-gizmo-plane-hitbox:hover + .body-gizmo-origin, +.body-gizmo-origin.is-active { + stroke: #51ddff; + stroke-width: 3; + filter: drop-shadow(0 0 3px rgb(81 221 255 / 72%)); +} +.body-gizmo-reset { + cursor: pointer; + outline: none; +} +.body-gizmo-reset circle { + fill: rgb(17 22 29 / 92%); + stroke: #8da7ef; + stroke-width: 1.3; + vector-effect: non-scaling-stroke; +} +.body-gizmo-reset text { + fill: #f8fafc; + font-size: 8px; + font-weight: 800; + pointer-events: none; + text-anchor: middle; +} +.body-gizmo-reset:hover circle, +.body-gizmo-reset:focus-visible circle { + fill: #2c4d9d; + stroke: #c6d5ff; + stroke-width: 2.5; +} +.body-gizmo-label { + fill: #f8fafc; + font-size: 7px; + font-weight: 900; + paint-order: stroke; + pointer-events: none; + stroke: #11161d; + stroke-width: 2px; + text-anchor: middle; +} +.editor-guide { + fill: none; + stroke: rgb(81 221 255 / 58%); + stroke-width: 1.15; + stroke-dasharray: 3 3; + pointer-events: none; +} +.editor-control { + cursor: grab; + touch-action: none; +} +.editor-control:active { + cursor: grabbing; +} +.editor-handle { + fill: #111316; + stroke: #51ddff; + stroke-width: 2; +} +.editor-label { + fill: #fff; + font-size: 6px; + font-weight: 900; + text-anchor: middle; + pointer-events: none; + user-select: none; +} +.wire { + fill: none; + stroke: rgb(255 255 255 / 28%); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.gizmo-cluster { + position: absolute; + top: 2%; + right: 2%; + display: flex; + flex-direction: row-reverse; + align-items: flex-start; + gap: 5px; +} +.gizmo { + width: 94px; + height: 94px; + overflow: visible; +} +.gizmo-reset { + width: 26px; + height: 26px; + border-color: rgb(255 255 255 / 12%); + background: rgb(24 29 36 / 82%); + color: #dbe4f3; + backdrop-filter: blur(8px); +} +.gizmo-reset:hover { + background: rgb(46 54 65 / 92%); + color: white; +} +.gizmo-orbit { + fill: none; + stroke-width: 2; + vector-effect: non-scaling-stroke; + cursor: ew-resize; + transition: + stroke-width 140ms ease, + filter 140ms ease; + touch-action: none; +} +.gizmo-orbit:hover, +.gizmo-orbit:active { + stroke-width: 5; + filter: brightness(1.25); +} +.gizmo-camera { + stroke: #f0ce45; +} +.gizmo-x { + stroke: #46d477; +} +.gizmo-y { + stroke: #ef5f5f; +} +.gizmo-z { + stroke: #557bea; +} +.axis-key { + position: absolute; + right: 4%; + bottom: 3%; + display: flex; + align-items: center; + gap: 6px; + color: #77808c; + font-size: 10px; + font-weight: 800; +} +.axis-key i { + display: block; + width: 6px; + height: 6px; + margin-left: 4px; + border-radius: 50%; +} +.axis-key .x { + background: #46d477; +} +.axis-key .y { + background: #ef5f5f; +} +.axis-key .z { + background: #557bea; +} + +.stage-help { + position: absolute; + bottom: 28px; + width: min(520px, 82%); + margin: 0; + color: #77808c; + font-size: 12px; + line-height: 1.5; + text-align: center; +} + +.inspector { + height: 100vh; + overflow-x: hidden; + overflow-y: auto; + scrollbar-gutter: stable; + padding: 28px 26px 48px; + border-left: 1px solid #d9dce3; + background: #f5f6f8; +} +.inspector.expression-workspace-active, +.inspector.sequence-workspace-active, +.inspector.body-workspace { + padding-top: 0; +} +.inspector.state-player-active { + padding-bottom: 128px; +} +@keyframes studio-workspace-in { + from { + opacity: 0; + transform: translateX(-10px); + } +} +@keyframes body-workspace-in { + from { + opacity: 0; + transform: translateX(18px); + } +} +.studio-workspace { + animation: studio-workspace-in 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.body-workspace { + animation: body-workspace-in 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +@media (prefers-reduced-motion: reduce) { + .studio-workspace, + .body-workspace { + animation: none; + } +} +.workspace-page { + display: flex; + min-height: calc(100vh - 76px); + flex-direction: column; + padding-bottom: 74px; +} +.workspace-header { + position: sticky; + z-index: 8; + top: 0; + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 18px; + padding: 31px 2px 16px; + border-bottom: 1px solid #d9dce3; + background: linear-gradient(to bottom, #f5f6f8 78%, rgb(245 246 248 / 88%)); + backdrop-filter: blur(10px); +} +.workspace-header > [data-slot='button']:first-child { + flex: 0 0 auto; + margin-top: 4px; + border: 1px solid #dfe2e8; + border-radius: 50%; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.workspace-heading { + min-width: 0; + flex: 1; +} +.workspace-heading h1 { + font-size: 25px; +} +.workspace-heading > p:last-child { + max-width: 360px; + margin: 6px 0 0; + color: #818792; + font-size: 11px; + line-height: 1.45; +} +.workspace-header-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; +} +.workspace-scroll { + display: grid; + gap: 12px; +} +.workspace-footer { + position: fixed; + z-index: 8; + right: 0; + bottom: 0; + display: flex; + width: min(520px, 100vw); + box-sizing: border-box; + align-items: center; + justify-content: space-between; + gap: 12px; + margin: 0; + padding: 20px 28px 18px; + border-top: 1px solid #d9dce3; + background: linear-gradient(to bottom, rgb(245 246 248 / 86%), #f5f6f8 30%); + backdrop-filter: blur(10px); +} +.workspace-footer > [data-slot='button']:last-child, +.workspace-footer .dialog-actions-main [data-slot='button'] { + min-width: 120px; +} +.workspace-footer-secondary { + display: flex; + align-items: center; + gap: 8px; +} +.inspector-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 22px; +} +.eyebrow { + margin: 0 0 6px; + color: #7185c4; + font-size: 10px; + font-weight: 850; + letter-spacing: 0.11em; + text-transform: uppercase; +} +h1 { + margin: 0; + font-size: 30px; + letter-spacing: -0.045em; +} +h2, +h3, +p { + margin-top: 0; +} +.state-player { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid #dde1e9; + border-radius: 12px; + background: white; + box-shadow: 0 5px 18px rgb(24 28 35 / 5%); +} +.playback-identity { + display: inline-flex; + align-items: center; + gap: 8px; + color: #33834b; +} +.state-player .playback-identity { + min-width: 102px; + padding: 2px 7px; +} +.playback-identity.is-paused { + color: #8a6a24; +} +.playback-identity.is-stopped { + color: #7f8692; +} +.playback-identity i { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 10%, transparent); +} +.playback-identity > span { + display: grid; + gap: 1px; +} +.playback-identity small { + color: #9297a1; + font-size: 8px; + font-weight: 750; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; +} +.playback-identity strong { + max-width: 92px; + overflow: hidden; + color: #343943; + font-size: 10px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-player [data-slot='button'] { + width: 48px; + height: 48px; +} +.state-player svg { + width: 32px; + height: 32px; +} +.state-playback-footer { + position: fixed; + z-index: 9; + right: 0; + bottom: 0; + width: min(520px, 100vw); + box-sizing: border-box; + padding: 10px 18px; + border-top: 1px solid #d9dce3; + background: rgb(250 251 253 / 96%); + box-shadow: 0 -10px 30px rgb(24 28 35 / 8%); + backdrop-filter: blur(14px); +} +.state-playback-expand { + position: absolute; + top: 0; + left: 50%; + border-radius: 999px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 9%); + transform: translate(-50%, -50%); +} +.state-playback-bar { + display: flex; + min-width: 0; + align-items: center; + gap: 14px; +} +.state-playback-details { + display: grid; + gap: 14px; + padding: 14px 2px 16px; + border-bottom: 1px solid #dfe3eb; +} +.state-playback-details-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} +.state-playback-details-header > div { + min-width: 0; +} +.state-playback-details-header .eyebrow { + margin-bottom: 4px; +} +.state-playback-details-header h2 { + margin: 0; + font-size: 17px; +} +.state-playback-details-header p:last-child { + margin: 5px 0 0; + color: #7f8692; + font-size: 10px; + line-height: 1.45; +} +.state-playback-detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} +.state-playback-detail-grid > div { + display: grid; + min-width: 0; + gap: 3px; + padding: 10px; + border: 1px solid #e1e5ec; + border-radius: 11px; + background: white; +} +.state-playback-detail-grid span, +.state-playback-detail-grid small { + overflow: hidden; + color: #858b96; + font-size: 8px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-playback-detail-grid span { + letter-spacing: 0.04em; + text-transform: uppercase; +} +.state-playback-detail-grid strong { + font-size: 12px; +} +.state-playback-footer.is-expanded .state-playback-bar { + padding-top: 10px; +} +.state-playback-controls { + display: flex; + flex: 0 0 auto; + align-items: center; + padding-left: 14px; + border-left: 1px solid #dfe3eb; +} +.state-playback-footer .state-player { + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} +.state-playback-timeline { + display: flex; + min-width: 0; + flex: 1; + gap: 6px; + overflow-x: auto; + padding: 5px 3px 7px; + scroll-behavior: smooth; + scrollbar-color: #cbd2df transparent; + scrollbar-width: thin; +} +.state-playback-step { + position: relative; + width: 58px; + height: 58px; + flex: 0 0 58px; + overflow: hidden; + padding: 2px; + border-radius: 11px; + background: white; + box-shadow: 0 3px 10px rgb(24 28 35 / 5%); +} +.state-playback-step svg { + width: 100%; + height: 100%; +} +.state-playback-step[aria-pressed='true'] { + border-color: #aebff0; + background: #edf1ff; + box-shadow: 0 0 0 2px rgb(91 127 229 / 16%); +} +@property --state-countdown-angle { + syntax: ''; + inherits: false; + initial-value: 360deg; +} +@keyframes state-playback-countdown { + to { + --state-countdown-angle: 0deg; + } +} +.state-playback-progress { + position: absolute; + bottom: 5px; + left: 5px; + width: 16px; + height: 16px; + border: 2px solid white; + border-radius: 50%; + background: conic-gradient(#4f72d8 var(--state-countdown-angle), white 0); + box-shadow: 0 1px 4px rgb(24 28 35 / 22%); + animation-name: state-playback-countdown; + animation-timing-function: linear; + animation-fill-mode: forwards; + pointer-events: none; +} +.body-workspace-header [data-slot='button'] { + align-self: center; + cursor: pointer; +} +.body-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 18px; + padding: 31px 2px 16px; + border-bottom: 1px solid #d9dce3; +} +.avatar-name-input { + width: min(330px, 100%); + margin: 0; + padding: 1px 5px; + border: 1px solid transparent; + border-radius: 7px; + outline: 0; + background: transparent; + color: #17191d; + font-size: 26px; + font-weight: 800; + letter-spacing: -0.045em; +} +.avatar-name-input:hover { + border-color: #d9dce3; + background: white; +} +.avatar-name-input:focus { + border-color: #7895ee; + background: white; + box-shadow: 0 0 0 3px rgb(95 131 232 / 12%); +} +.body-workspace-header p:not(.eyebrow) { + margin: 6px 0 0; + color: #818792; + font-size: 11px; + line-height: 1.45; +} +.body-workspace-header [data-slot='button']:disabled { + cursor: not-allowed; + opacity: 0.4; +} +.avatar-shelf { + position: relative; + margin-bottom: 12px; + padding: 20px; + border: 1px solid #dcdfe6; + border-radius: 16px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.avatar-shelf-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.avatar-shelf-heading strong { + font-size: 11px; +} +.avatar-shelf-heading span { + color: #8d929c; + font-size: 8px; +} +.avatar-shelf .avatar-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; +} +.avatar-shelf .avatar-card, +.avatar-shelf .avatar-add { + height: 74px; + min-height: 74px; + border-radius: 10px; +} +.avatar-sort-item { + min-width: 0; + will-change: transform; +} +.avatar-sort-item .avatar-card { + width: 100%; +} +.avatar-sort-item[data-dragging='true'] .avatar-card { + border-style: dashed; + box-shadow: none; +} +.avatar-shelf .avatar-card { + flex-direction: column; + align-items: stretch; + cursor: grab; +} +.avatar-shelf .avatar-card:active { + cursor: grabbing; +} +.avatar-shelf .avatar-card svg { + height: 48px; + flex: 0 1 48px; +} +.avatar-shelf .avatar-card > span { + min-height: 12px; + padding: 0 3px; + font-size: 8px; +} +.tabs { + position: sticky; + z-index: 5; + top: 12px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 5px; + margin-bottom: 16px; + padding: 5px; + backdrop-filter: blur(14px); +} +.state-buttons button, +.button-row button, +.reset { + min-height: 36px; + font-size: 11px; +} +.color-field { + display: grid; + gap: 8px; + color: #3d424b; + font-size: 10px; + font-weight: 780; +} +.color-control { + display: flex; + align-items: center; + gap: 9px; + min-height: 42px; + padding: 5px 9px 5px 5px; + border: 1px solid #dcdfe6; + border-radius: 10px; + background: #f8f9fb; +} +.color-swatch { + width: 38px; + height: 30px; + padding: 2px; + border-radius: 7px; + cursor: pointer; +} +.color-control .color-value { + width: 86px; + height: 28px; + padding: 0 6px; + border: 0; + background: transparent; + box-shadow: none; + color: #656c77; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + letter-spacing: 0.04em; +} +.ambient-motion-field { + grid-template-columns: minmax(0, 1fr) 170px; +} +.ambient-motion-field [data-slot='select-trigger'] { + height: 34px; + padding: 0 30px 0 10px; + border: 1px solid #d8dce5; + border-radius: 8px; + outline: none; + background: #fff; + color: #272b33; + font-size: 12px; +} +.ambient-motion-field [data-slot='select-trigger']:focus-visible { + border-color: #7897f4; + box-shadow: 0 0 0 3px rgb(91 127 229 / 14%); +} +.field-help { + margin: 9px 0 0; + color: #858a95; + font-size: 11px; + line-height: 1.45; +} +.color-panel { + position: relative; +} +.inherit-colors { + position: absolute; + top: 14px; + right: 14px; +} +.position-spacing { + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid #eceef2; +} +.panel-inline-subtitle { + margin: 4px 0 0; + color: #858a95; + font-size: 10px; + font-weight: 500; + line-height: 1.45; +} + +.panel-stack { + display: grid; + gap: 14px; +} +.control-section { + display: grid; + gap: 14px; + padding: 20px; + border: 1px solid #dcdfe6; + border-radius: 16px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.control-section-header { + padding: 4px 5px 2px; +} +.control-section-header h2 { + margin: 0; + color: #343b49; + font-size: 12px; + font-weight: 850; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.control-section-header p { + margin: 5px 0 0; + color: #858c99; + font-size: 10px; + line-height: 1.45; +} +.control-section-separator { + opacity: 0.7; +} +.control-section-content { + display: grid; + gap: 10px; +} +.control-section .panel { + border-color: #e0e3e9; +} +.control-section-compact { + padding: 20px; + border-radius: 16px; +} +.control-section-compact .control-section-header { + padding-inline: 3px; +} +.body-tree { + display: grid; + gap: 6px; +} +.body-tree > button { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: auto; + min-height: 46px; + padding: 10px 12px; + text-align: left; + white-space: normal; +} +.body-tree > button[aria-pressed='true'] { + border-color: #7796ec; + background: #edf1ff; + color: #3f63c6; +} +.body-tree > button span:last-child { + display: grid; + min-width: 0; + gap: 2px; +} +.body-tree strong, +.body-tree small { + line-height: 1.25; +} +.body-tree small { + color: #858a95; + font-size: 9px; +} +.body-node-icon { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 34px; + place-items: center; + color: #7c8492; +} +.body-node-icon-primary { + width: 40px; + height: 40px; + flex-basis: 40px; +} +.body-node-icon svg { + width: 100%; + height: 100%; +} +.body-node-icon svg path { + fill: currentColor; +} +.body-add { + display: grid; + gap: 7px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #eceef2; + color: #858a95; + font-size: 10px; + font-weight: 750; +} +.body-add > div { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px; +} +.body-node-actions button { + font-size: 9px; +} +.body-add .body-add-card { + height: 62px; + min-height: 62px; + padding: 4px 2px; + border-radius: 10px; + font-size: 8px; +} +.body-add .body-add-card svg { + width: 30px; + height: 30px; +} +.body-node-editor { + margin-top: 14px; + padding: 13px; + border: 1px solid #dde3f2; + border-radius: 13px; + background: #fafbff; +} +.body-node-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.body-node-actions > div { + display: flex; + gap: 6px; +} +.body-gizmo-help { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 0; + color: #747c89; + font-size: 9px; + line-height: 1.4; +} +.body-transform-grid { + display: grid; + margin-top: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.body-transform-grid h3 { + margin: 0 0 5px; + font-size: 10px; +} +.body-transform-grid .numeric-field { + grid-template-columns: 42px minmax(0, 1fr); +} +.body-transform-grid .scrub-label { + grid-column: 1; +} +.panel { + gap: 14px; + padding: 20px; + border: 1px solid #dcdfe6; + border-radius: 16px; + background: white; + box-shadow: 0 4px 14px rgb(24 28 35 / 5%); +} +.section-description { + margin: -2px 3px 2px; + color: #747c89; + font-size: 10px; + line-height: 1.55; +} +.panel.compact { + padding-top: 16px; +} +.surface-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 7px; +} +.surface-card { + display: grid; + min-width: 0; + min-height: 82px; + padding: 6px 4px 7px; + place-items: center; + color: #767c87; + cursor: pointer; + font-size: 9px; + font-weight: 780; + transition: + border-color 140ms ease, + background 140ms ease, + color 140ms ease, + transform 140ms ease; +} +.surface-card:hover { + border-color: #c7cfdf; + transform: translateY(-1px); +} +.surface-card[aria-pressed='true'] { + border-color: #7796ec; + background: #edf1ff; + color: #3f63c6; + box-shadow: 0 0 0 2px rgb(91 127 229 / 12%); +} +.surface-card svg { + width: 48px; + height: 48px; +} +.surface-card svg path { + fill: currentColor; +} +.surface-fields { + display: grid; + margin-top: 14px; + padding-top: 10px; + border-top: 1px solid #eceef2; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 14px; +} +.surface-fields .numeric-field { + grid-template-columns: minmax(0, 1fr) 72px; +} +.surface-fields .numeric-field + .numeric-field { + border-top: 0; +} +.panel-title h2, +.panel-title h3, +.panel-inline-title h2, +.panel-inline-title h3, +.preset-header h2, +.state-detail h2 { + margin: 0; + font-size: 15px; + letter-spacing: -0.015em; +} +.panel-title { + gap: 5px; + margin-bottom: 17px; + padding: 0; +} +.panel-title [data-slot='card-title'] { + line-height: inherit; +} +.panel-title [data-slot='card-description'] { + font-size: 11px; + line-height: 1.45; +} +.panel-title p { + margin: 5px 0 17px; + color: #858a95; + font-size: 11px; + line-height: 1.45; +} +.panel-inline-title, +.preset-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.eye-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.eye-column h3 { + margin: 0 0 5px 4px; + color: #858a95; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.eye-columns .numeric-field { + grid-template-columns: minmax(0, 1fr) 72px; +} +.eye-columns .numeric-field + .numeric-field { + border-top: 0; +} +.preset-header > span { + color: #8a8f99; + font-size: 10px; +} + +.numeric-field { + display: grid; + align-items: center; + grid-template-columns: minmax(112px, 0.85fr) minmax(100px, 1fr) 72px; + gap: 10px; + min-height: 39px; +} +.numeric-field + .numeric-field { + border-top: 1px solid #f0f1f4; +} +.scrub-label { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 5px; + padding: 8px 4px; + cursor: ew-resize; + touch-action: none; + font-size: 11px; + font-weight: 720; + text-align: left; +} +.scrub-label span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.scrub-icon { + color: #7891dc; + font-size: 14px; + opacity: 0.65; +} +.scrub-label:hover .scrub-icon, +.scrub-label:active .scrub-icon { + opacity: 1; +} +.range { + width: 100%; + accent-color: #5b7fe5; + cursor: pointer; +} +.number-shell { + display: flex; + align-items: center; + overflow: hidden; + border: 1px solid #dcdfe6; + border-radius: 8px; + background: #f7f8fa; +} +.number-shell:focus-within { + border-color: #7592e9; + box-shadow: 0 0 0 3px rgb(91 127 229 / 12%); +} +.number-shell input { + width: 100%; + min-width: 0; + padding: 7px 0 7px 8px; + border: 0; + outline: 0; + background: transparent; + font-size: 10px; + font-variant-numeric: tabular-nums; +} +.number-shell span { + padding-right: 7px; + color: #8a8f99; + font-size: 9px; +} +.number-shell input::-webkit-inner-spin-button { + display: none; +} + +.link-button, +.icon-button { + display: grid; + width: 32px; + height: 32px; + padding: 7px; + place-items: center; +} +.link-button svg { + width: 16px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-width: 1.8; +} +.link-button[aria-pressed='true'] { + border-color: #b8c7f3; + background: #edf1ff; + color: #5075df; +} +.switch { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 12px; + padding-top: 13px; + border-top: 1px solid #eceef2; + font-size: 11px; + font-weight: 720; +} +.switch input { + accent-color: #5b7fe5; +} +.reset { + width: 100%; + margin-top: 14px; +} + +.expression-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.expression-sort-item, +.state-sort-item { + min-width: 0; +} +.expression-sort-item > *, +.state-sort-item > * { + width: 100%; +} +.expression-card, +.expression-add { + position: relative; + min-height: 76px; + overflow: hidden; + padding: 3px; + cursor: pointer; +} +.avatar-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} +.avatar-card, +.avatar-add { + position: relative; + min-height: 132px; + overflow: hidden; + padding: 7px; + cursor: pointer; +} +.avatar-card svg { + display: block; + width: 100%; + height: 105px; +} +.avatar-card > span { + display: block; + overflow: hidden; + padding: 5px 5px 3px; + font-size: 10px; + font-weight: 850; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.avatar-card[aria-pressed='true'] { + border-color: #5b7fe5; + box-shadow: 0 0 0 2px rgb(91 127 229 / 18%); +} +.creation-card { + display: grid; + place-items: center; + border-style: dashed; + color: #5b7fe5; +} +.creation-card svg { + width: 22px; + height: 22px; +} +.expression-card svg { + display: block; + width: 100%; + height: 64px; +} +.expression-card > span { + position: absolute; + right: 5px; + bottom: 4px; + padding: 2px 4px; + border-radius: 5px; + background: rgb(255 255 255 / 78%); + font-size: 8px; + font-weight: 850; +} +.expression-card[aria-pressed='true'] { + border-color: #5b7fe5; + box-shadow: 0 0 0 2px rgb(91 127 229 / 18%); +} +.expression-add { + border-radius: 10px; +} +.preview-head { + fill: #5b7fe5; +} +.preview-eye { + fill: #111316; +} +.button-row { + display: flex; + gap: 8px; + margin-top: 14px; +} +.button-row button { + flex: 1; +} +.state-groups { + display: grid; + gap: 15px; +} +.state-groups strong { + display: block; + margin-bottom: 7px; + color: #888d97; + font-size: 9px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.state-buttons { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} +.state-buttons .state-card > span { + max-width: calc(100% - 10px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.state-buttons .state-card { + height: 76px; + min-height: 76px; +} +.state-buttons .state-card[aria-pressed='true'] { + border-color: #aebff0; + background: #edf1ff; + color: #4f72d8; +} +.state-detail p { + margin: 7px 0 14px; + color: #7f848e; + font-size: 11px; + line-height: 1.5; +} +.state-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.state-detail-header > div { + min-width: 0; +} +.state-step-preview { + display: grid; + gap: 4px; +} +.state-step-preview > small { + color: #8a909b; + font-size: 8px; + font-weight: 750; + text-align: center; +} +.state-expression-section, +.state-blink-section { + padding-top: 16px; + border-top: 1px solid #eceef2; +} +.state-blink-section { + margin-top: 18px; +} +.state-section-heading { + display: flex; + align-items: start; + justify-content: space-between; + gap: 12px; + margin-bottom: 11px; +} +.state-section-heading h3 { + margin: 0; + font-size: 12px; +} +.state-section-heading p { + margin: 4px 0 0; + font-size: 10px; +} +.state-section-heading > span { + flex: 0 0 auto; + padding: 4px 7px; + border-radius: 999px; + background: #eef1f7; + color: #6f7785; + font-size: 8px; + font-weight: 800; + text-transform: uppercase; +} +.state-expression-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} +.state-logic-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} +.state-logic-grid > div { + display: grid; + gap: 3px; + padding: 11px; + border: 1px solid #e1e4ea; + border-radius: 11px; + background: #f7f8fa; +} +.state-logic-grid span, +.state-logic-grid small { + color: #858b96; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.sequence-workspace-scroll { + padding-bottom: 6px; +} +.sequence-workspace [data-slot='card'] { + gap: 14px; +} +.sequence-timeline { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 3px; +} +.sequence-step { + position: relative; + flex: 0 0 90px; + cursor: grab; +} +.sequence-step:active { + cursor: grabbing; +} +.sequence-step > [data-slot='button'] { + position: relative; + display: block; + width: 100%; + height: 94px; + overflow: hidden; + padding: 4px; +} +.sequence-step > [data-slot='button'][aria-pressed='true'] { + border-color: #5b7fe5; + box-shadow: 0 0 0 2px rgb(91 127 229 / 18%); +} +.sequence-step svg:not(.sequence-grip) { + display: block; + width: 100%; + height: 76px; +} +.sequence-step .sequence-grip { + position: absolute; + z-index: 2; + top: 6px; + left: 5px; + width: 13px; + height: 13px; + padding: 2px; + border-radius: 4px; + background: rgb(255 255 255 / 82%); + color: #7f8794; +} +.sequence-step span, +.sequence-step small { + position: absolute; + z-index: 2; + bottom: 5px; + padding: 2px 4px; + border-radius: 5px; + background: rgb(255 255 255 / 82%); + font-size: 8px; + font-weight: 850; +} +.sequence-step span { + right: 5px; +} +.sequence-step small { + left: 5px; + color: #727a87; +} +.sequence-empty { + margin: 0; + padding: 24px 12px; + border: 1px dashed #d6dae3; + border-radius: 10px; + color: #858b96; + font-size: 11px; + text-align: center; +} +.sequence-step-settings { + display: grid; + gap: 10px; + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid #eceef2; +} + +.avatar-card[aria-pressed='true'], +.expression-card[aria-pressed='true'], +.sequence-step > [data-slot='button'][aria-pressed='true'], +.surface-card[aria-pressed='true'], +.body-tree > button[aria-pressed='true'] { + border-color: #aebff0; + background: #edf1ff; + color: #4f72d8; + box-shadow: 0 0 0 2px rgb(91 127 229 / 16%); +} +.sequence-transition-field { + display: grid; + grid-template-columns: minmax(0, 1fr) 170px; +} +.sequence-expression-library { + max-height: 264px; + overflow-y: auto; + padding: 2px; +} +.state-logic-grid strong { + color: #303640; + font-size: 13px; +} +.state-logic-grid small { + font-size: 7px; + font-weight: 650; + letter-spacing: 0; + text-transform: none; +} + +.dialog-fields { + display: grid; + gap: 12px; +} +.dialog-group { + padding: 13px; + border: 1px solid #e0e2e7; + border-radius: 14px; + background: white; +} +.dialog-group h3 { + margin: 0 0 7px; + font-size: 11px; +} +.dialog-group .panel-inline-title { + margin-bottom: 7px; +} +.dialog-group .panel-inline-title h3 { + margin: 0; +} +.dialog-actions-main { + display: flex; + margin-left: auto; + gap: 8px; +} + +@media (max-width: 980px) { + .studio { + grid-template-columns: 1fr; + } + .stage-column { + position: relative; + height: min(72vh, 680px); + min-height: 480px; + } + .inspector { + border-top: 1px solid #d9dce3; + border-left: 0; + } + .avatar-wrap { + width: min(62vh, 82vw); + } +} + +@media (max-width: 600px) { + .stage-column { + min-height: 420px; + padding: 18px; + } + .photo-capture-bar { + bottom: 48px; + } + .photo-capture-button { + min-width: 168px; + } + .brand { + top: 18px; + left: 20px; + } + .inspector { + padding: 22px 14px 40px; + } + .numeric-field { + grid-template-columns: 100px minmax(76px, 1fr) 66px; + gap: 7px; + } + .expression-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .avatar-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .avatar-shelf .avatar-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + padding-right: 0; + } + .avatar-edit { + position: static; + width: 100%; + min-height: 36px; + margin-top: 8px; + } + .surface-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .workspace-header-actions .state-player > span { + display: none; + } +} diff --git a/src/surfaces.ts b/src/surfaces.ts new file mode 100644 index 0000000..98295fd --- /dev/null +++ b/src/surfaces.ts @@ -0,0 +1,665 @@ +import type { Point3 } from './geometry' + +export type SurfaceType = + 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' + +export type SurfaceConfig = { + type: SurfaceType + width: number + height: number + depth: number + roundness: number + morphRoundness?: number + tipRoundness?: number + baseRoundness?: number +} + +export type SurfaceSample = { + point: Point3 + normal: Point3 +} + +export const surfacePresets: Record = { + sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 }, + mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 }, + cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 }, + cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 }, + capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 }, + cylinder: { + type: 'cylinder', + width: 235, + height: 250, + depth: 215, + roundness: 0.45, + morphRoundness: 0, + }, + cone: { + type: 'cone', + width: 250, + height: 265, + depth: 225, + roundness: 0, + morphRoundness: 0, + tipRoundness: 0.55, + baseRoundness: 0.45, + }, + diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 }, +} + +export const surfaceLabels: Record = { + sphere: 'Sphère', + mickey: 'Mickey', + cursor: 'Curseur', + cube: 'Cube', + capsule: 'Capsule', + cylinder: 'Cylindre', + cone: 'Cône', + diamond: 'Diamant', +} + +const signedPower = (value: number, exponent: number) => + Math.sign(value) * Math.abs(value) ** exponent + +const superellipsoid = ( + longitude: number, + latitude: number, + width: number, + height: number, + depth: number, + verticalExponent: number, + horizontalExponent: number +): Point3 => { + const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent) + return [ + (width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent), + (height / 2) * signedPower(Math.sin(latitude), verticalExponent), + (depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent), + ] +} + +const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => { + const radiusX = config.width / 2 + const radiusZ = config.depth / 2 + const capRadius = Math.min(radiusX, config.height / 2) + const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2) + const meridianLength = straightHalf * 2 + Math.PI * capRadius + const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength + let radial = radiusX + let y = 0 + + if (distance < (Math.PI * capRadius) / 2) { + const angle = -Math.PI / 2 + distance / capRadius + radial = radiusX * Math.cos(angle) + y = -straightHalf + capRadius * Math.sin(angle) + } else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) { + y = -straightHalf + distance - (Math.PI * capRadius) / 2 + } else { + const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius + radial = radiusX * Math.cos(angle) + y = straightHalf + capRadius * Math.sin(angle) + } + + const depthScale = radiusX ? radiusZ / radiusX : 1 + return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)] +} + +const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0)) + +const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2 + +const MIN_CUBE_SURFACE_POWER = 0.04 + +const cubeExponent = (config: SurfaceConfig) => { + if (config.roundness <= 0) return Infinity + // The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid. + const surfacePower = + MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER) + return 2 / surfacePower +} + +const lpSurface = ( + config: SurfaceConfig, + longitude: number, + latitude: number, + exponent: number +): Point3 => { + const sphereX = Math.cos(latitude) * Math.sin(longitude) + const sphereY = Math.sin(latitude) + const sphereZ = Math.cos(latitude) * Math.cos(longitude) + const length = Number.isFinite(exponent) + ? (Math.abs(sphereX) ** exponent + + Math.abs(sphereY) ** exponent + + Math.abs(sphereZ) ** exponent) ** + (1 / exponent) || 1 + : Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1 + return [ + (config.width / 2) * (sphereX / length), + (config.height / 2) * (sphereY / length), + (config.depth / 2) * (sphereZ / length), + ] +} + +const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => { + return lpSurface(config, longitude, latitude, diamondExponent(config)) +} + +const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => + lpSurface(config, longitude, latitude, cubeExponent(config)) + +const MAX_CONE_TIP_FRACTION = 0.24 +const MAX_CONE_BASE_FRACTION = 0.2 +const MAX_CYLINDER_EDGE_FRACTION = 0.22 + +type RadialProfile = { + radiusScale: number + verticalProgress: number +} + +const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2 + +const morphProfileToEllipsoid = ( + config: SurfaceConfig, + progress: number, + profile: RadialProfile +): RadialProfile => { + const amount = morphProgress(config) + const clampedProgress = Math.max(0, Math.min(1, progress)) + const ellipsoidRadius = Math.sin(clampedProgress * Math.PI) + const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2 + return { + radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount, + verticalProgress: + profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount, + } +} + +const cubic = ( + start: number, + firstControl: number, + secondControl: number, + end: number, + progress: number +) => { + const inverse = 1 - progress + return ( + inverse ** 3 * start + + 3 * inverse * inverse * progress * firstControl + + 3 * inverse * progress * progress * secondControl + + progress ** 3 * end + ) +} + +const coneRounding = (config: SurfaceConfig) => ({ + tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION, + baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION, +}) + +/** Cylinder half-profile with a quarter-round transition at both caps. */ +const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => { + const clampedProgress = Math.max(0, Math.min(1, progress)) + const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION + if (edgeFraction <= 0) { + return { + radiusScale: 1, + verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2, + } + } + + if (clampedProgress < edgeFraction) { + const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2) + return { + radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle), + verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2, + } + } + + if (clampedProgress > 1 - edgeFraction) { + const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2) + return { + radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle), + verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2, + } + } + + const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2) + return { + radiusScale: 1, + verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction), + } +} + +const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) => + morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress)) + +const radiusScaleAtVerticalProgress = ( + config: SurfaceConfig, + verticalProgress: number, + profileAt: (config: SurfaceConfig, progress: number) => RadialProfile +) => { + const progress = Math.max(0, Math.min(1, verticalProgress)) + let lower = 0 + let upper = 1 + for (let iteration = 0; iteration < 14; iteration += 1) { + const candidate = (lower + upper) / 2 + if (profileAt(config, candidate).verticalProgress < progress) lower = candidate + else upper = candidate + } + return profileAt(config, (lower + upper) / 2).radiusScale +} + +/** Rounded half-profile revolved around the cone's vertical axis. */ +const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => { + const clampedProgress = Math.max(0, Math.min(1, progress)) + const { tipFraction, baseFraction } = coneRounding(config) + + if (baseFraction > 0 && clampedProgress < baseFraction) { + const curveProgress = clampedProgress / baseFraction + return { + radiusScale: cubic( + 1 - baseFraction, + 1, + 1 - baseFraction / 2, + 1 - baseFraction, + curveProgress + ), + verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress), + } + } + + if (tipFraction > 0 && clampedProgress > 1 - tipFraction) { + const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction + return { + radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress), + verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress), + } + } + + return { + radiusScale: 1 - clampedProgress, + verticalProgress: clampedProgress, + } +} + +const morphedConeProfileAt = (config: SurfaceConfig, progress: number) => + morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress)) + +export const cursorLayout = (config: SurfaceConfig) => { + const coneHeight = config.height * 0.36 + const bodyHeight = config.height - coneHeight + return { + coneApexY: -config.height / 2, + coneBaseY: -config.height / 2 + coneHeight, + bodyHeight, + bodyCenterY: config.height / 2 - bodyHeight / 2, + bodyWidth: config.width * 0.54, + bodyDepth: config.depth * 0.62, + } +} + +export const surfacePointAt = ( + config: SurfaceConfig, + longitude: number, + latitude: number +): Point3 => { + const { width, height, depth } = config + switch (config.type) { + case 'sphere': + case 'mickey': + return superellipsoid(longitude, latitude, width, height, depth, 1, 1) + case 'cube': + return cube(config, longitude, latitude) + case 'cylinder': { + const progress = (latitude + Math.PI / 2) / Math.PI + const profile = morphedCylinderProfileAt(config, progress) + return [ + (width / 2) * profile.radiusScale * Math.sin(longitude), + -height / 2 + height * profile.verticalProgress, + (depth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + case 'cursor': { + const layout = cursorLayout(config) + const progress = (latitude + Math.PI / 2) / Math.PI + const bodyConfig = { + ...config, + width: layout.bodyWidth, + height: layout.bodyHeight, + depth: layout.bodyDepth, + } + const profile = cylinderProfileAt(bodyConfig, progress) + return [ + (layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude), + layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress, + (layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + case 'diamond': + return diamond(config, longitude, latitude) + case 'capsule': + return capsule(config, longitude, latitude) + case 'cone': { + const progress = (latitude + Math.PI / 2) / Math.PI + const profile = morphedConeProfileAt(config, progress) + return [ + (width / 2) * profile.radiusScale * Math.sin(longitude), + height / 2 - height * profile.verticalProgress, + (depth / 2) * profile.radiusScale * Math.cos(longitude), + ] + } + } +} + +const subtract = (left: Point3, right: Point3): Point3 => [ + left[0] - right[0], + left[1] - right[1], + left[2] - right[2], +] + +const normalize = ([x, y, z]: Point3): Point3 => { + const length = Math.hypot(x, y, z) || 1 + return [x / length, y / length, z / length] +} + +const normalFromTangents = ( + config: SurfaceConfig, + longitudeTangent: Point3, + latitudeTangent: Point3 +) => { + const orientation = config.type === 'cone' ? -1 : 1 + return normalize([ + orientation * + (longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]), + orientation * + (longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]), + orientation * + (longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]), + ]) +} + +const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => { + const epsilon = 0.0005 + if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3 + const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude) + const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude) + const latitudeBefore = surfacePointAt( + config, + longitude, + Math.max(-Math.PI / 2, latitude - epsilon) + ) + const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon)) + return normalFromTangents( + config, + subtract(longitudeAfter, longitudeBefore), + subtract(latitudeAfter, latitudeBefore) + ) +} + +const signedMagnitude = (value: number, exponent: number) => + Math.sign(value) * Math.abs(value) ** exponent + +const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + return normalize([ + signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX, + signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY, + signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ, + ]) +} + +const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 => + lpNormal(config, point, diamondExponent(config)) + +const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => { + const exponent = cubeExponent(config) + if (Number.isFinite(exponent)) return lpNormal(config, point, exponent) + + const normalized = [ + point[0] / (config.width / 2 || 1), + point[1] / (config.height / 2 || 1), + point[2] / (config.depth / 2 || 1), + ] as Point3 + const dominantAxis = normalized.reduce( + (largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest), + 0 + ) + const normal: Point3 = [ + dominantAxis === 0 ? Math.sign(normalized[0]) : 0, + dominantAxis === 1 ? Math.sign(normalized[1]) : 0, + dominantAxis === 2 ? Math.sign(normalized[2]) : 0, + ] + return normal +} + +const lpFrontSample = ( + config: SurfaceConfig, + x: number, + y: number, + exponent: number, + normalAt: (config: SurfaceConfig, point: Point3) => Point3 +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + if (!Number.isFinite(exponent)) { + const point: Point3 = [ + Math.max(-radiusX, Math.min(radiusX, x)), + Math.max(-radiusY, Math.min(radiusY, y)), + radiusZ, + ] + return { point, normal: normalAt(config, point) } + } + + const normalizedY = Math.max(-1, Math.min(1, y / radiusY)) + const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent) + const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x)) + const normalizedX = surfaceX / radiusX + const normalizedZ = + Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) ** + (1 / exponent) + const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ] + return { point, normal: normalAt(config, point) } +} + +const ellipsoidFrontSample = ( + x: number, + y: number, + radiusX: number, + radiusY: number, + radiusZ: number, + centerY = 0 +): SurfaceSample => { + const localY = y - centerY + const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2) + const z = radiusZ * Math.sqrt(remaining) + return { + point: [x, y, z], + normal: normalize([ + x / (radiusX * radiusX || 1), + localY / (radiusY * radiusY || 1), + z / (radiusZ * radiusZ || 1), + ]), + } +} + +const radialProfileFrontSample = ( + config: SurfaceConfig, + x: number, + y: number, + profileAt: (config: SurfaceConfig, progress: number) => RadialProfile, + verticalDirection: -1 | 1 +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusZ = config.depth / 2 || 1 + const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height))) + const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt) + const sectionRadiusX = radiusX * radialScale + const sectionRadiusZ = radiusZ * radialScale + const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x)) + const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0 + const z = sectionRadiusZ * Math.sqrt(remaining) + const derivativeStep = 0.0001 + const previousProgress = Math.max(0, verticalProgress - derivativeStep) + const nextProgress = Math.min(1, verticalProgress + derivativeStep) + const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt) + const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt) + const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1) + const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001) + const depthRatio = radiusZ / radiusX + const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1) + const depthYDerivative = + (verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1) + return { + point: [surfaceX, y, z], + normal: normalize([-depthXDerivative, -depthYDerivative, 1]), + } +} + +/** Project canonical face coordinates onto a primitive's front-facing sheet. */ +export const surfaceFrontSampleAt = ( + config: SurfaceConfig, + x: number, + y: number +): SurfaceSample => { + const radiusX = config.width / 2 || 1 + const radiusY = config.height / 2 || 1 + const radiusZ = config.depth / 2 || 1 + + switch (config.type) { + case 'sphere': + case 'mickey': + return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ) + + case 'cube': + return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal) + + case 'capsule': { + const capRadiusY = Math.min(radiusX, radiusY) + const straightHalf = Math.max(0, radiusY - capRadiusY) + const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y + return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY) + } + + case 'cylinder': + return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1) + + case 'cursor': { + const layout = cursorLayout(config) + const bodyConfig = { + ...config, + width: layout.bodyWidth, + height: layout.bodyHeight, + depth: layout.bodyDepth, + } + const sample = radialProfileFrontSample( + bodyConfig, + x, + y - layout.bodyCenterY, + cylinderProfileAt, + 1 + ) + return { + point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]], + normal: sample.normal, + } + } + + case 'cone': + return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1) + + case 'diamond': + return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal) + } +} + +export const surfaceNormalAt = ( + config: SurfaceConfig, + longitude: number, + latitude: number +): Point3 => { + const point = surfacePointAt(config, longitude, latitude) + + // An ellipsoid has a cheap exact normal. This is also the overwhelmingly + // common path for the default spherical head. + if (config.type === 'sphere' || config.type === 'mickey') { + const halfWidth = config.width / 2 || 1 + const halfHeight = config.height / 2 || 1 + const halfDepth = config.depth / 2 || 1 + return normalize([ + point[0] / (halfWidth * halfWidth), + point[1] / (halfHeight * halfHeight), + point[2] / (halfDepth * halfDepth), + ]) + } + + if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) { + return normalize([ + Math.sin(longitude) / (config.width / 2 || 1), + 0, + Math.cos(longitude) / (config.depth / 2 || 1), + ]) + } + + if (config.type === 'diamond') { + return diamondNormal(config, point) + } + + if (config.type === 'cube') { + return cubeNormal(config, point) + } + + return tangentNormalAt(config, longitude, latitude) +} + +export const surfaceSampleAt = ( + config: SurfaceConfig, + longitude: number, + latitude: number +): SurfaceSample => { + const point = surfacePointAt(config, longitude, latitude) + + if (config.type === 'sphere' || config.type === 'mickey') { + const halfWidth = config.width / 2 || 1 + const halfHeight = config.height / 2 || 1 + const halfDepth = config.depth / 2 || 1 + return { + point, + normal: normalize([ + point[0] / (halfWidth * halfWidth), + point[1] / (halfHeight * halfHeight), + point[2] / (halfDepth * halfDepth), + ]), + } + } + + if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) { + return { + point, + normal: normalize([ + Math.sin(longitude) / (config.width / 2 || 1), + 0, + Math.cos(longitude) / (config.depth / 2 || 1), + ]), + } + } + + if (config.type === 'diamond') { + return { + point, + normal: diamondNormal(config, point), + } + } + + if (config.type === 'cube') { + return { + point, + normal: cubeNormal(config, point), + } + } + + return { + point, + normal: tangentNormalAt(config, longitude, latitude), + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e443020 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "paths": { + "@/*": ["./src/*"] + }, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src", "vite.config.ts", "vitest.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..153172a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,23 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import babel from '@rolldown/plugin-babel' +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' + +const root = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + root, + plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], + resolve: { + alias: { + '@': path.join(root, 'src'), + }, + }, + build: { + outDir: path.join(root, 'dist'), + emptyOutDir: true, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e505796 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/__tests__/**/*-test.ts'], + }, +}) diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..c90e4f3 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,1992 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.29.7" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/compat-data@npm:7.29.7" + checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e + languageName: node + linkType: hard + +"@babel/core@npm:^7.29.0": + version: 7.29.7 + resolution: "@babel/core@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helpers": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b + languageName: node + linkType: hard + +"@babel/generator@npm:^7.29.7, @babel/generator@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/generator@npm:7.29.8" + dependencies: + "@babel/parser": "npm:^7.29.8" + "@babel/types": "npm:^7.29.8" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/7b896696314a659652393b76d78276e236acd0f7fae40a9a1af7f01c76aeafc630dd0966aad6f9386d35d7c674a8e6e2d8e217c44d25fb11460e68afa9ba8441 + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-compilation-targets@npm:7.29.7" + dependencies: + "@babel/compat-data": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/4c15fd4c69a0a7047799a28a88460c19cede0a0ee8af994ea169114986f4af48b92c7393a4a3fee0456c11a656eece3448a6ed06354453d6c27cccf17195453b + languageName: node + linkType: hard + +"@babel/helper-globals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-globals@npm:7.29.7" + checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-imports@npm:7.29.7" + dependencies: + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-transforms@npm:7.29.7" + dependencies: + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/ee5a2172c24a42be696836f4b0d947489c9729d8adf5821885cf77d1ad5333e3c447368e9a71f67df1099570490553dccf9f888ef0a92a48aa63cb086bd8c7e1 + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-option@npm:7.29.7" + checksum: 10c0/d2a06c6d0ac40ba4a2f219fc2cab249c7a94bacdb2686273b7f9598571c908809b48468ff588915a346e6cc7296f60b581023d1d498b747fed06f779d335c2cc + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helpers@npm:7.29.7" + dependencies: + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/218e8d10953647c9f44775f5a022b227a182674853b5ea8631889deb7e1a3e4bc870388aaecf59bb8bd92a87f9a96220ed3f70a35bffec6bcf9169ecb67891ac + languageName: node + linkType: hard + +"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.7, @babel/parser@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 + languageName: node + linkType: hard + +"@babel/runtime@npm:^7.29.2": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10c0/ca11572f7146b21e0bde6a9ed4bb6a89eafbee5f0944c7eb54d0d8a2dac962c33638a1d611e14faa71dfbb92b4b5f9236232208568a6b7d5c6f3f39ddb91771e + languageName: node + linkType: hard + +"@babel/template@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/template@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58 + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.29.7": + version: 7.29.8 + resolution: "@babel/traverse@npm:7.29.8" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.8" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.8" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.8" + debug: "npm:^4.3.1" + checksum: 10c0/87a28989c434add26d787776ac6d30f749b89cb030f2a605c89f671a516a6fa165ac0476f07e2b69feed70128b2734cae1cbbf41dfe95ccf22081bd8f8b91923 + languageName: node + linkType: hard + +"@babel/types@npm:^7.26.0, @babel/types@npm:^7.29.7, @babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + +"@base-ui/react@npm:^1.7.0": + version: 1.7.0 + resolution: "@base-ui/react@npm:1.7.0" + dependencies: + "@babel/runtime": "npm:^7.29.2" + "@base-ui/utils": "npm:0.3.2" + "@floating-ui/react-dom": "npm:^2.1.9" + "@floating-ui/utils": "npm:^0.2.12" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + "@date-fns/tz": ^1.2.0 + "@types/react": ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + "@date-fns/tz": + optional: true + "@types/react": + optional: true + date-fns: + optional: true + checksum: 10c0/05bc69f68431ff30a5fac98d8e75c77af7c27749d10aeac10ab7f26843a0a8b49ca5cff32654aad1a107cb7f44aeb1705c15138c9c7f7ddc19d6321fbb263e4d + languageName: node + linkType: hard + +"@base-ui/utils@npm:0.3.2": + version: 0.3.2 + resolution: "@base-ui/utils@npm:0.3.2" + dependencies: + "@babel/runtime": "npm:^7.29.2" + "@floating-ui/utils": "npm:^0.2.12" + reselect: "npm:^5.2.0" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + "@types/react": ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/b523339f350679915550605e59754b5db9806a41a3b390d6f6adad29fedc0caf084c8ad1bad6bb62a463360593382defb7d3ea941a94404ed9db0142fdae5800 + languageName: node + linkType: hard + +"@emnapi/core@npm:^1.11.1": + version: 1.11.3 + resolution: "@emnapi/core@npm:1.11.3" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.3" + tslib: "npm:^2.4.0" + checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.11.1": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.2.3, @emnapi/wasi-threads@npm:^1.2.2": + version: 1.2.3 + resolution: "@emnapi/wasi-threads@npm:1.2.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/5aed84bc5dbe867973b20406ca1ed95661a4892ecaef80378fdf2d37424b3f7b9c428df8a3e1d82b783a3345a530032bf049e699e1f113ea52203c3e9b4e6ce5 + languageName: node + linkType: hard + +"@floating-ui/core@npm:^1.8.0": + version: 1.8.0 + resolution: "@floating-ui/core@npm:1.8.0" + dependencies: + "@floating-ui/utils": "npm:^0.2.12" + checksum: 10c0/cece958f6fab0f8cd7740cd57988c4f8bc5356dca39dd4c093433d44c91952c3a02c7a54d93d7ed6c078b6544d531a29f66621d2839c8bfbb4467dca6aed7df0 + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.8.0": + version: 1.8.0 + resolution: "@floating-ui/dom@npm:1.8.0" + dependencies: + "@floating-ui/core": "npm:^1.8.0" + "@floating-ui/utils": "npm:^0.2.12" + checksum: 10c0/c32b2de7a596b69cfffa909c7028825e14f6319be1b45da529f8943c89fe16b75a49c214e800c87ded3f172353d3b99163d50072f11410488e756961b7b629cc + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.1.9": + version: 2.1.9 + resolution: "@floating-ui/react-dom@npm:2.1.9" + dependencies: + "@floating-ui/dom": "npm:^1.8.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/9fa9851069738cb9591a1ab2da16e257f193eae5fd2d6d48ad0b1e73f3c049929b6805c5ad43d4af3c1da63c60b215b0ee4651a1fe5caeb80e66d9d423744348 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.12": + version: 0.2.12 + resolution: "@floating-ui/utils@npm:0.2.12" + checksum: 10c0/bb910b90d56991a62011afaf5f8e0c4b7fb6dab69403f4dd17fbd6eb25560b28d533dff9791f270b4f459852e9006a1077b5d73cbedb5e34d9424afc6a828314 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.2.3 + resolution: "@napi-rs/wasm-runtime@npm:1.2.3" + dependencies: + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4 + checksum: 10c0/6da0a4bf9df79e9abfb3e3d462f6a5d42889be39426040038c9dd5925865585d7dbd06dd439c2ffc20f49ef2751412da5bd748cfb3c5b049142d72c0550f6f79 + languageName: node + linkType: hard + +"@oxc-project/types@npm:=0.144.0": + version: 0.144.0 + resolution: "@oxc-project/types@npm:0.144.0" + checksum: 10c0/997c6c33f09706af604ece0e99c698965757887aca4b42c5fb5ddcb11c39876c1e824b188b1e6582276355468a9b13f955d3b822d3b532cd6bedbeb64b603ff0 + languageName: node + linkType: hard + +"@rolldown/binding-android-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-android-arm64@npm:1.2.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.4" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.4" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.2.4": + version: 1.2.4 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/plugin-babel@npm:^0.2.3": + version: 0.2.3 + resolution: "@rolldown/plugin-babel@npm:0.2.3" + dependencies: + picomatch: "npm:^4.0.4" + peerDependencies: + "@babel/core": ^7.29.0 || ^8.0.0-rc.1 + "@babel/plugin-transform-runtime": ^7.29.0 || ^8.0.0-rc.1 + "@babel/runtime": ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + "@babel/plugin-transform-runtime": + optional: true + "@babel/runtime": + optional: true + vite: + optional: true + checksum: 10c0/84afe9854137b576f963f4fd11080dc0b8afe6243d565513d33ddfed9ea9149652110def881a3911b5e889f5fa505df036c6839f4d0fd01d4222297d7f2baa21 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0, @rolldown/pluginutils@npm:^1.0.1": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + languageName: node + linkType: hard + +"@tailwindcss/node@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/node@npm:4.3.3" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + enhanced-resolve: "npm:^5.24.1" + jiti: "npm:^2.7.0" + lightningcss: "npm:1.32.0" + magic-string: "npm:^0.30.21" + source-map-js: "npm:^1.2.1" + tailwindcss: "npm:4.3.3" + checksum: 10c0/0123669be5b3e78d42b0e10cb34d547fb8574c8e7dc7a97a141c8a4ab4215852b5b1b99b97763e898e1da1493ee8424cb72e28225cc4876828c95156f94d7f0e + languageName: node + linkType: hard + +"@tailwindcss/oxide-android-arm64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-arm64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-x64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-freebsd-x64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-musl@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-wasm32-wasi@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.3" + dependencies: + "@emnapi/core": "npm:^1.11.1" + "@emnapi/runtime": "npm:^1.11.1" + "@emnapi/wasi-threads": "npm:^1.2.2" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + "@tybys/wasm-util": "npm:^0.10.2" + tslib: "npm:^2.8.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide@npm:4.3.3" + dependencies: + "@tailwindcss/oxide-android-arm64": "npm:4.3.3" + "@tailwindcss/oxide-darwin-arm64": "npm:4.3.3" + "@tailwindcss/oxide-darwin-x64": "npm:4.3.3" + "@tailwindcss/oxide-freebsd-x64": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.3" + "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.3" + "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.3" + "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.3" + "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.3" + "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.3" + dependenciesMeta: + "@tailwindcss/oxide-android-arm64": + optional: true + "@tailwindcss/oxide-darwin-arm64": + optional: true + "@tailwindcss/oxide-darwin-x64": + optional: true + "@tailwindcss/oxide-freebsd-x64": + optional: true + "@tailwindcss/oxide-linux-arm-gnueabihf": + optional: true + "@tailwindcss/oxide-linux-arm64-gnu": + optional: true + "@tailwindcss/oxide-linux-arm64-musl": + optional: true + "@tailwindcss/oxide-linux-x64-gnu": + optional: true + "@tailwindcss/oxide-linux-x64-musl": + optional: true + "@tailwindcss/oxide-wasm32-wasi": + optional: true + "@tailwindcss/oxide-win32-arm64-msvc": + optional: true + "@tailwindcss/oxide-win32-x64-msvc": + optional: true + checksum: 10c0/6c38b25226dad252347451ed8953a998432b51f1e068b25e021215b886ccf8bcf3af58faf5f4ab50368091e657ca2fa341df1deca6101651eddb567122f2acb5 + languageName: node + linkType: hard + +"@tailwindcss/vite@npm:^4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/vite@npm:4.3.3" + dependencies: + "@tailwindcss/node": "npm:4.3.3" + "@tailwindcss/oxide": "npm:4.3.3" + tailwindcss: "npm:4.3.3" + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + checksum: 10c0/bb1fe12f7a0bb8f6d6142f6bb2e96060ee853902314ef838e8d7f50cf30cc4b07adb7c2c86be2164e0d62eb581b8cc4df2d9aa13b4449f597a67f73c5772d549 + languageName: node + linkType: hard + +"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3": + version: 0.10.3 + resolution: "@tybys/wasm-util@npm:0.10.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3 + languageName: node + linkType: hard + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + +"@types/node@npm:^24.10.1": + version: 24.13.3 + resolution: "@types/node@npm:24.13.3" + dependencies: + undici-types: "npm:~7.18.0" + checksum: 10c0/a5bc08f49b9581dcdca90e02cd77197a3c799840807664942e5cd5161a553d4d2ae8064f7e3294410d748d7d098b5c1848be7fa50c06f29c4193ab16be4e59eb + languageName: node + linkType: hard + +"@types/react-dom@npm:^19.2.3": + version: 19.2.4 + resolution: "@types/react-dom@npm:19.2.4" + peerDependencies: + "@types/react": ^19.2.0 + checksum: 10c0/b7d854ce17bb51a3a067168268a90f0123d10dc490f63f1ff5409f07ac715febeb8f5b7b473404a9d437106571e0312fc2937a945634d590abfc9aa46a14b01b + languageName: node + linkType: hard + +"@types/react@npm:^19.2.14": + version: 19.2.18 + resolution: "@types/react@npm:19.2.18" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/d04216172b4b4362b310017c210dfbe019fb4f4e7dffd0313e70b3acb3051d5c3e76e7e84e3cf4c6a3c824993ffadfe3949e589a6398a09d4200e7670e2962de + languageName: node + linkType: hard + +"@vitejs/plugin-react@npm:^6.0.2": + version: 6.0.5 + resolution: "@vitejs/plugin-react@npm:6.0.5" + dependencies: + "@rolldown/pluginutils": "npm:^1.0.1" + peerDependencies: + "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + "@rolldown/plugin-babel": + optional: true + babel-plugin-react-compiler: + optional: true + checksum: 10c0/fb02246fe3652d7fb746190bdd098b9e29918dfcf8c67b9c0c37300ce789e82331b2ba761530ac6ac9a61390b774d44f401787bd1c87a6c866d1e74a745cc1a0 + languageName: node + linkType: hard + +"@vitest/expect@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/expect@npm:4.1.10" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/a817ad0d9bd6a039776a7228d54fb8319c17e4af15917407f5566ac61781a8511f591d302519d6999217399915bc3c0290028189fc73f5c38f80cb01b6f19c8d + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/mocker@npm:4.1.10" + dependencies: + "@vitest/spy": "npm:4.1.10" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/4aa70b0df58681652e2e28093437fb2e8f4d02a6d03f5619abc266ac1c5ae5f43326148061d13ae6e071e0f6cfcf7634659af63644de8ce098a7c98949a3d1ad + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/pretty-format@npm:4.1.10" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918 + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/runner@npm:4.1.10" + dependencies: + "@vitest/utils": "npm:4.1.10" + pathe: "npm:^2.0.3" + checksum: 10c0/554b72639de9694271b99be8ae273fe12ec793093ec91cce143816cd1187d40b7138a4d9d4de4f456cfca9567de986825bff97e107c05b9eb4abc130e854286d + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/snapshot@npm:4.1.10" + dependencies: + "@vitest/pretty-format": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f + languageName: node + linkType: hard + +"@vitest/spy@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/spy@npm:4.1.10" + checksum: 10c0/e5c08012560af6727fd66741c5cda25560d7c5442103d0c83e4276a9b0dd90b9da6cdf823a461195229a16c6ff87768ce788a68d0fa29dea73ee285618668178 + languageName: node + linkType: hard + +"@vitest/utils@npm:4.1.10": + version: 4.1.10 + resolution: "@vitest/utils@npm:4.1.10" + dependencies: + "@vitest/pretty-format": "npm:4.1.10" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d + languageName: node + linkType: hard + +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"babel-plugin-react-compiler@npm:^1.0.0": + version: 1.0.0 + resolution: "babel-plugin-react-compiler@npm:1.0.0" + dependencies: + "@babel/types": "npm:^7.26.0" + checksum: 10c0/9406267ada8d7dbdfe8906b40ecadb816a5f4cee2922bee23f7729293b369624ee135b5a9b0f263851c263c9787522ac5d97016c9a2b82d1668300e42b18aff8 + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.11.12": + version: 2.11.14 + resolution: "baseline-browser-mapping@npm:2.11.14" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/b0d248660ed8a09b0738fb9ca383668e8e445ad23b3efa8a5c41ee91fdf09399766513f97e5effd77e2e5027c1eec225f84bbfdf01586871e5ba2bb4cce8ea83 + languageName: node + linkType: hard + +"bible-strong-avatar-lab@workspace:.": + version: 0.0.0-use.local + resolution: "bible-strong-avatar-lab@workspace:." + dependencies: + "@babel/core": "npm:^7.29.0" + "@babel/parser": "npm:^7.28.6" + "@base-ui/react": "npm:^1.7.0" + "@rolldown/plugin-babel": "npm:^0.2.3" + "@tailwindcss/vite": "npm:^4.3.3" + "@types/node": "npm:^24.10.1" + "@types/react": "npm:^19.2.14" + "@types/react-dom": "npm:^19.2.3" + "@vitejs/plugin-react": "npm:^6.0.2" + babel-plugin-react-compiler: "npm:^1.0.0" + class-variance-authority: "npm:^0.7.1" + clsx: "npm:^2.1.1" + lucide-react: "npm:^1.31.0" + motion: "npm:^13.0.0" + prettier: "npm:^3.8.1" + react: "npm:19.2.3" + react-dom: "npm:19.2.3" + rolldown: "npm:^1.0.0-rc.5" + tailwind-merge: "npm:^3.6.0" + tailwindcss: "npm:^4.3.3" + typescript: "npm:~6.0.3" + vite: "npm:^8.0.13" + vitest: "npm:^4.1.10" + languageName: unknown + linkType: soft + +"browserslist@npm:^4.24.0": + version: 4.28.8 + resolution: "browserslist@npm:4.28.8" + dependencies: + baseline-browser-mapping: "npm:^2.11.12" + caniuse-lite: "npm:^1.0.30001809" + electron-to-chromium: "npm:^1.5.402" + node-releases: "npm:^2.0.53" + update-browserslist-db: "npm:^1.3.0" + bin: + browserslist: cli.js + checksum: 10c0/047dd517c8d1f1f56a253d752c3127b8ad01c58e4cbb7e21cb5cd07ebd13e083a2237c3afb13eb60674282c9132bd4534aea2c3ff7c6f7d29be0687a00cbd537 + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001809": + version: 1.0.30001809 + resolution: "caniuse-lite@npm:1.0.30001809" + checksum: 10c0/cac2ed4e66cc6c4cbf126b94d2c02012566eb92f93c89949290f99edcd2bdc4ed1290b5c537ccb9b42c773936a479ed468e5d4e9b8938b126a0947090e42e008 + languageName: node + linkType: hard + +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"class-variance-authority@npm:^0.7.1": + version: 0.7.1 + resolution: "class-variance-authority@npm:0.7.1" + dependencies: + clsx: "npm:^2.1.1" + checksum: 10c0/0f438cea22131808b99272de0fa933c2532d5659773bfec0c583de7b3f038378996d3350683426b8e9c74a6286699382106d71fbec52f0dd5fbb191792cccb5b + languageName: node + linkType: hard + +"clsx@npm:^2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"debug@npm:^4.1.0, debug@npm:^4.3.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.402": + version: 1.5.405 + resolution: "electron-to-chromium@npm:1.5.405" + checksum: 10c0/6e85abde0e3c77945c3cdd6f28741cba268474a62bfbe8c59c5728438a03e689d1df74fd4526a73064f452350ef7fd0b8edb721fa979a791813b81ec2b1a0d8c + languageName: node + linkType: hard + +"enhanced-resolve@npm:^5.24.1": + version: 5.24.5 + resolution: "enhanced-resolve@npm:5.24.5" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.3.3" + checksum: 10c0/76c32ce5485ecea0ef808c9289bc6f7c49ce85142943598d89a23a846f2909556d3dc6db97a007442a48c185baad5514c1c76d20a2656497f9511d13ccfab73b + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"es-module-lexer@npm:^2.0.0": + version: 2.3.1 + resolution: "es-module-lexer@npm:2.3.1" + checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9 + languageName: node + linkType: hard + +"escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"expect-type@npm:^1.3.0": + version: 1.4.0 + resolution: "expect-type@npm:1.4.0" + checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"framer-motion@npm:^13.1.0": + version: 13.1.0 + resolution: "framer-motion@npm:13.1.0" + dependencies: + motion-dom: "npm:^13.0.0" + motion-utils: "npm:^13.0.0" + tslib: "npm:^2.4.0" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + checksum: 10c0/0fa4c5d17369f288bfff434191fbd112d44ae06f7a99370e691128db1e302804332e5ec5e980c7a59ffcdb3248fe68f96bce5931d0c066eff55ec6d493fc197c + languageName: node + linkType: hard + +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"jiti@npm:^2.7.0": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 + languageName: node + linkType: hard + +"json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-android-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-android-arm64@npm:1.33.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-arm64@npm:1.33.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-darwin-x64@npm:1.33.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-freebsd-x64@npm:1.33.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.33.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-linux-x64-musl@npm:1.33.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.33.0": + version: 1.33.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.33.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + +"lightningcss@npm:^1.33.0": + version: 1.33.0 + resolution: "lightningcss@npm:1.33.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.33.0" + lightningcss-darwin-arm64: "npm:1.33.0" + lightningcss-darwin-x64: "npm:1.33.0" + lightningcss-freebsd-x64: "npm:1.33.0" + lightningcss-linux-arm-gnueabihf: "npm:1.33.0" + lightningcss-linux-arm64-gnu: "npm:1.33.0" + lightningcss-linux-arm64-musl: "npm:1.33.0" + lightningcss-linux-x64-gnu: "npm:1.33.0" + lightningcss-linux-x64-musl: "npm:1.33.0" + lightningcss-win32-arm64-msvc: "npm:1.33.0" + lightningcss-win32-x64-msvc: "npm:1.33.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3 + languageName: node + linkType: hard + +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: "npm:^3.0.2" + checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 + languageName: node + linkType: hard + +"lucide-react@npm:^1.31.0": + version: 1.31.0 + resolution: "lucide-react@npm:1.31.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/c49e451279b46250e117ebe112c7b0b60712912571b7139ce9ea004e7d9125a2684d545a16e62706b2bc5e074cf557c9cc0471b437e0c868f59369324f9a6d5e + languageName: node + linkType: hard + +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"motion-dom@npm:^13.0.0": + version: 13.0.0 + resolution: "motion-dom@npm:13.0.0" + dependencies: + motion-utils: "npm:^13.0.0" + checksum: 10c0/3029e249515976f1eea67b26c88cfc6037e3ed3a821400e9e10a211dafad7b70f1d0e9243878dcc3011249e8fd84bc0c587a667fe750c74ad41418a85365f61e + languageName: node + linkType: hard + +"motion-utils@npm:^13.0.0": + version: 13.0.0 + resolution: "motion-utils@npm:13.0.0" + checksum: 10c0/4b279ce85f71926d668ff5966feb898b174541d16952a61c7150982388553266602ecd2a76e057530076ff8f38d06672e585639ddc73c41dd1a67d04aaa3662d + languageName: node + linkType: hard + +"motion@npm:^13.0.0": + version: 13.1.0 + resolution: "motion@npm:13.1.0" + dependencies: + framer-motion: "npm:^13.1.0" + tslib: "npm:^2.4.0" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + checksum: 10c0/2f974b22929945cf96d0164916fb04c470d993ab81ca9006bd590638edb35d04e202dc3f4e61fede4fd81bc9ef9d8a47555d73c776e428a357e09097e35b2750 + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.17": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 13.0.1 + resolution: "node-gyp@npm:13.0.1" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^8.4.1" + which: "npm:^7.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/424077bc9e9bbe953a8e86db473ba818cbc6a121714008c977fd589e21e5f0c811fbf22faac730dc7182450b5e52df301811d01ae3373898658d999b7710f4e6 + languageName: node + linkType: hard + +"node-releases@npm:^2.0.53": + version: 2.0.53 + resolution: "node-releases@npm:2.0.53" + checksum: 10c0/db9fb29b21ec12e223ead8bd9406100ff14fce01678a9a4865e288098456b84869431421a739f216aa520b6f57d9425f6537662501ae8a2d9e1771bfa87751bc + languageName: node + linkType: hard + +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" + dependencies: + abbrev: "npm:^5.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a + languageName: node + linkType: hard + +"obug@npm:^2.1.1": + version: 2.1.4 + resolution: "obug@npm:2.1.4" + checksum: 10c0/34a0ee97cd88573cfd97d384c2a79f07118ae5680d7e45d1de6e99c74eddefe145e8ca27a2db02195a1ee5fded5aa22b924869c842728c201b9f109a27d0ef19 + languageName: node + linkType: hard + +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4, picomatch@npm:^4.0.5": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd + languageName: node + linkType: hard + +"postcss@npm:^8.5.25": + version: 8.5.26 + resolution: "postcss@npm:8.5.26" + dependencies: + nanoid: "npm:^3.3.17" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43 + languageName: node + linkType: hard + +"prettier@npm:^3.8.1": + version: 3.9.6 + resolution: "prettier@npm:3.9.6" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/9f7ddae234035868f3daab3dc34e6de7e54622185afb017378029f0c09a9c023248ccf6f34def0b17a0538e18c47aa1b3188bab72965d1bf735919baa0747e99 + languageName: node + linkType: hard + +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b + languageName: node + linkType: hard + +"react-dom@npm:19.2.3": + version: 19.2.3 + resolution: "react-dom@npm:19.2.3" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.3 + checksum: 10c0/dc43f7ede06f46f3acc16ee83107c925530de9b91d1d0b3824583814746ff4c498ea64fd65cd83aba363205268adff52e2827c582634ae7b15069deaeabc4892 + languageName: node + linkType: hard + +"react@npm:19.2.3": + version: 19.2.3 + resolution: "react@npm:19.2.3" + checksum: 10c0/094220b3ba3a76c1b668f972ace1dd15509b157aead1b40391d1c8e657e720c201d9719537375eff08f5e0514748c0319063392a6f000e31303aafc4471f1436 + languageName: node + linkType: hard + +"reselect@npm:^5.2.0": + version: 5.2.0 + resolution: "reselect@npm:5.2.0" + checksum: 10c0/d0a8497e404b25a769fe792eacae88b9b576c30870450cd243d76ec9427d91a59c4a2cc00d824d283448b444c4c698a8f961d771c8ae39c759313afb31950e2e + languageName: node + linkType: hard + +"rolldown@npm:^1.0.0-rc.5, rolldown@npm:~1.2.1": + version: 1.2.4 + resolution: "rolldown@npm:1.2.4" + dependencies: + "@oxc-project/types": "npm:=0.144.0" + "@rolldown/binding-android-arm64": "npm:1.2.4" + "@rolldown/binding-darwin-arm64": "npm:1.2.4" + "@rolldown/binding-darwin-x64": "npm:1.2.4" + "@rolldown/binding-freebsd-x64": "npm:1.2.4" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.4" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.4" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.4" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.4" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.4" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.4" + "@rolldown/binding-linux-x64-musl": "npm:1.2.4" + "@rolldown/binding-openharmony-arm64": "npm:1.2.4" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.4" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.4" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/438c2222db940fab6e2db1f1cf84ab27c83310959e056fa389d7e2208bcbf58929f970f4dca5bd9c255f319bf657ef1a4a7ea6725213491d28764d867e5407a4 + languageName: node + linkType: hard + +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^4.0.0-rc.1": + version: 4.2.0 + resolution: "std-env@npm:4.2.0" + checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69 + languageName: node + linkType: hard + +"tailwind-merge@npm:^3.6.0": + version: 3.6.0 + resolution: "tailwind-merge@npm:3.6.0" + checksum: 10c0/a728d929f4786bcb48641db4228cc8c006a7545491783a658fb4d24d177dab960620991d17805a047309228de50ab949ca8d9e112c30183da0432367796ae186 + languageName: node + linkType: hard + +"tailwindcss@npm:4.3.3, tailwindcss@npm:^4.3.3": + version: 4.3.3 + resolution: "tailwindcss@npm:4.3.3" + checksum: 10c0/7e9cd7553cd890ffa5350efb0ca8971ba5435257d0f98bf0714994ac8f1fee5c7cdf9a9da47fe83e8ac995d728410837ac49d3ef5ea66b5afe1db312086ffae2 + languageName: node + linkType: hard + +"tapable@npm:^2.3.3": + version: 2.3.3 + resolution: "tapable@npm:2.3.3" + checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.22 + resolution: "tar@npm:7.5.22" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea + languageName: node + linkType: hard + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^1.0.2": + version: 1.3.0 + resolution: "tinyexec@npm:1.3.0" + checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"tinyrainbow@npm:^3.1.0": + version: 3.1.1 + resolution: "tinyrainbow@npm:3.1.1" + checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d + languageName: node + linkType: hard + +"tslib@npm:^2.4.0, tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"typescript@npm:~6.0.3": + version: 6.0.3 + resolution: "typescript@npm:6.0.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A~6.0.3#optional!builtin": + version: 6.0.3 + resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df + languageName: node + linkType: hard + +"undici-types@npm:~7.18.0": + version: 7.18.2 + resolution: "undici-types@npm:7.18.2" + checksum: 10c0/85a79189113a238959d7a647368e4f7c5559c3a404ebdb8fc4488145ce9426fcd82252a844a302798dfc0e37e6fb178ff481ed03bc4caf634c5757d9ef43521d + languageName: node + linkType: hard + +"undici@npm:^8.4.1": + version: 8.10.0 + resolution: "undici@npm:8.10.0" + checksum: 10c0/37ae2b1db8f65c3a003504e2040e96887b99f9db16ba07dcf49c37ed8b7f8c72f4452f2d46f52f7c9e49518fe8325e3b5e7e02ac3a2424886bd67d4b62a0ecab + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.3.0": + version: 1.3.1 + resolution: "update-browserslist-db@npm:1.3.1" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/2a924f5aa283af2d918e5cd941c7e8d77b05adf2ffbe5ca461506b1ff05fee6e8bca74c0f1ba37eb28f00ec93b15e80de24c7222825835aec2dd7bf0a0f6a417 + languageName: node + linkType: hard + +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b + languageName: node + linkType: hard + +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.13": + version: 8.2.1 + resolution: "vite@npm:8.2.1" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.33.0" + picomatch: "npm:^4.0.5" + postcss: "npm:^8.5.25" + rolldown: "npm:~1.2.1" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/e958dd07502deeb552f04dba59418ff1eb63183add416fd7a3e3badabf5d36edc6834b94c916f9f9233f976bb1671e3e9989d90e869059af07b0d43f7fc078c2 + languageName: node + linkType: hard + +"vitest@npm:^4.1.10": + version: 4.1.10 + resolution: "vitest@npm:4.1.10" + dependencies: + "@vitest/expect": "npm:4.1.10" + "@vitest/mocker": "npm:4.1.10" + "@vitest/pretty-format": "npm:4.1.10" + "@vitest/runner": "npm:4.1.10" + "@vitest/snapshot": "npm:4.1.10" + "@vitest/spy": "npm:4.1.10" + "@vitest/utils": "npm:4.1.10" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.10 + "@vitest/browser-preview": 4.1.10 + "@vitest/browser-webdriverio": 4.1.10 + "@vitest/coverage-istanbul": 4.1.10 + "@vitest/coverage-v8": 4.1.10 + "@vitest/ui": 4.1.10 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/ff07294a57f9c62f3b503f7cf88a52ee0753ed26389a49cda430387a3898f39d80af47180b0af19e27acab5bd11ae95706bd4b44ce8befc97d3ae49af6ca4fc1 + languageName: node + linkType: hard + +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard