diff --git a/TIMELOG.md b/TIMELOG.md
new file mode 100644
index 0000000..7bc02f2
--- /dev/null
+++ b/TIMELOG.md
@@ -0,0 +1,29 @@
+# TIMELOG - Bible Strong Avatar Lab
+
+> Engineering work log. Times are rounded from observed session timestamps and are provided for
+> project traceability, not billing.
+
+## 2026-08-14 - Avatar runtime React v1
+
+| Workstream | Start | End | Duration | Status | Evidence |
+| ------------------------------------------- | ----- | ----- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Public contract and core runtime | 13:53 | 14:10 | 17 min | Complete | v1 JSON Schema, bounded parser, Ajv validation, semantic catalog, geometry, scene generation and deterministic playback extracted to `@bible-strong/avatar-core`. |
+| React renderer and consumer hardening | 14:10 | 14:25 | 15 min | Complete | Embedded/floating rendering, React 19 controller, SSR portal handoff, drag/keyboard controls, resize constraints, callbacks and direct frame updates validated. |
+| Studio semantic export and reported blocker | 14:25 | 14:39 | 14 min | Complete | Restored approved bundled keys, removed cascading errors, added targeted local-project clearing and verified a schema-v1 Strobi export with no public opaque references. |
+| Export UX and developer guidance | 14:44 | 14:50 | 6 min | Complete | Flagged the new runtime menu, distinguished the historical ZIP export, added npm quick start, translations and accessible copy feedback. |
+| Runnable package preview and visual polish | 14:50 | 15:02 | 12 min | Complete | Added high-contrast syntax coloring and an inline preview rendered by `@bible-strong/avatar-react`, with restartable `idle` playback. |
+| Final audit, documentation and archive | 15:02 | 15:20 | 18 min | Complete | Updated the implementation status, archived completed specs, recorded verification evidence and prepared the detailed commit. |
+
+**Recorded total: approximately 1 h 22 min.**
+
+### Verification record
+
+- `pnpm check`: 19 test files and 162 tests passed; typecheck, generated engine, package builds and
+ production Studio build passed.
+- `npm pack --dry-run --json`: 30 allow-listed core files and 10 React files.
+- `pnpm packages:smoke`: actual tarballs installed, typechecked and built in a clean non-workspace
+ React/Vite consumer.
+- Browser checks: semantic playback, embedded/floating rendering, dragging, mobile overflow,
+ runtime export recovery, formatted JSON copy, syntax coloring and live package preview verified.
+- Known unrelated local-preview noise: Vercel Analytics and Speed Insights scripts return 404 when
+ served outside Vercel.
diff --git a/docs/specs/20260814-vite-dev-server-performance.md b/docs/specs/20260814-vite-dev-server-performance.md
new file mode 100644
index 0000000..d67b934
--- /dev/null
+++ b/docs/specs/20260814-vite-dev-server-performance.md
@@ -0,0 +1,198 @@
+# Vite Dev Server Performance Fix
+
+## Status
+
+Draft - impact review completed on 2026-08-14; corrections and Eric's approval are still required.
+
+## Impact review - 2026-08-14
+
+### Decision
+
+Do not implement this draft unchanged. The watcher and compiler-scope changes are isolated from the
+runtime packages, but parts of the diagnosis and validation procedure are inaccurate for the current
+workspace.
+
+### Verified current state
+
+- The installed workspace versions are Vite 8.2.1, `@vitejs/plugin-react` 6.0.5,
+ `@rolldown/plugin-babel` 0.2.3, and `@tailwindcss/vite` 4.3.3. The version sentence in the Problem
+ section reflects earlier manifest ranges, not the current lockfile.
+- `fsevents` 2.3.3 is installed through Vite. Chokidar uses FSEvents when it is available; polling on
+ macOS is the fallback when FSEvents cannot be used. The draft must not state that Vite
+ unconditionally polls on macOS.
+- Two already-running dev servers for this repository were sampled five times while idle on
+ 2026-08-14. They reported 0-0.1% CPU, so the reported ~200% condition was not reproduced during
+ this review. A before/after measurement under the condition that triggers the problem is required.
+- Vite 8.2.1 already ignores `.git`, `node_modules`, `test-results`, `cacheDir`, and every emptied
+ build `outDir`. Adding `node_modules` and root `dist` to `server.watch.ignored` is therefore
+ defensive and mostly redundant, not a fix for a missing default.
+- The installed React Compiler preset already has a code filter, but it still admits many pure files
+ containing capitalized identifiers or hook-like names. Restricting Babel to actual JSX/TSX source
+ remains a credible optimization.
+
+### Impact on the avatar-runtime work
+
+| Area | Impact |
+| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Root Studio dev server | Direct. `server.watch` and the root Babel plugin change startup, idle watching, and HMR behavior. |
+| `packages/avatar-core` | Source HMR must remain enabled through the root alias. Its own build config is unaffected. The Babel compiler can safely exclude this package because it contains no JSX. |
+| `packages/avatar-react` | Its library build config is unaffected. A generic `/src/...tsx/` regex also matches `packages/avatar-react/src/Avatar.tsx`, contrary to the draft's "app only" explanation; an app-root-anchored filter is required. |
+| React consumer fixture | No direct impact because it owns a separate Vite config. Its HMR needs a separate smoke check only if that config is changed later. |
+| Vitest, tarball smoke, production packages | No direct impact. Their processes/configs are separate, but the normal regression suite must still pass. |
+| Standalone engine generation | No idle impact. `pnpm dev` runs `pnpm engine` once before Vite, so startup CPU must be measured separately from steady-state Vite CPU. |
+
+### Required corrections before approval
+
+1. Reword cause 1 as a hypothesis about an FSEvents fallback and capture evidence that the affected
+ process is actually polling.
+2. Reword cause 2 to acknowledge Vite's existing ignore defaults. Add only project-specific paths if
+ measurements show that they are watched.
+3. Anchor the Babel include filter to the absolute root `src/` directory. The proposed
+ `/src\/.*\.[jt]sx$/` expression is not root-specific.
+4. Record both manifest ranges and installed lockfile versions, and repeat measurements after any
+ dependency install.
+5. Start a single strict-port dev server for measurement and resolve its exact PID. `pgrep -f vite`
+ is ambiguous on this machine because several unrelated Vite servers run concurrently.
+6. Measure startup separately from at least 30 seconds of idle CPU, an HMR edit under root `src/`,
+ and an HMR edit under `packages/avatar-core/src/`.
+7. Treat the Vite 7 downgrade as a separate decision that modifies `package.json` and
+ `pnpm-lock.yaml`; this contradicts the current "vite.config.ts only" file list.
+8. Re-evaluate the cited Vite 8/Rolldown memory reports against Vite 8.2.1 and the installed Rolldown
+ version before using an 800 MB downgrade threshold.
+
+## Problem
+
+The dev server (`pnpm dev`) consumes ~200% CPU on macOS. The project runs Vite 8.0.13 with
+`@vitejs/plugin-react` 6.0.2, `@rolldown/plugin-babel` (React Compiler preset),
+`@tailwindcss/vite` 4.3.3, and a pnpm workspace monorepo (`packages/avatar-core` linked via
+`workspace:*`).
+
+Four documented causes have been identified, ordered by expected impact.
+
+## Root causes
+
+### 1. File watcher defaults to polling on macOS
+
+Vite inherits a legacy default that sets `usePolling: true` on macOS. Instead of using the
+kernel's native `FSEvents`, chokidar scans every watched file on a timer. In a monorepo with
+`node_modules` symlinks this produces sustained CPU even when no file changes.
+
+**Source**: https://github.com/vitejs/vite/issues/21033
+
+### 2. Watcher scope includes node_modules and dist
+
+No `server.watch.ignored` is configured. The workspace alias
+`@bible-strong/avatar-core → packages/avatar-core/src/index.ts` causes the watcher to follow
+pnpm symlinks into `node_modules/.pnpm`, multiplying the number of watched paths.
+
+### 3. React Compiler runs on all files
+
+`@rolldown/plugin-babel` with `reactCompilerPreset()` is applied globally. It processes every
+`.ts`/`.tsx` file including `packages/avatar-core`, which contains zero React components — pure
+geometry, math, and schema validation. The compiler's analysis pass is expensive and wasted on
+non-component code.
+
+**Source**: https://github.com/vitejs/vite-plugin-react/discussions/1148
+
+### 4. Vite 8 (Rolldown) baseline memory regression
+
+Vite 8's Rolldown bundler uses ~1.1 GB within 37 seconds vs ~300 MB for Vite 5. Higher memory
+pressure triggers frequent GC cycles that manifest as CPU usage.
+
+**Sources**:
+
+- https://github.com/vitejs/rolldown-vite/issues/577
+- https://github.com/rolldown/rolldown/issues/9330
+
+## Changes
+
+All changes are in `/vite.config.ts` (root). No other files are modified.
+
+### Change 1 — Disable polling and scope the watcher
+
+Add a `server` block to the Vite config:
+
+```ts
+server: {
+ watch: {
+ usePolling: false,
+ ignored: ['**/node_modules/**', '**/dist/**'],
+ },
+},
+```
+
+**Why `usePolling: false`**: macOS FSEvents is reliable and near-zero CPU. The polling
+fallback exists for network filesystems (NFS/SMB) which do not apply here.
+
+**Why `ignored`**: prevents chokidar from traversing pnpm's `.pnpm` store and build output
+directories. These paths never contain source files that need HMR.
+
+### Change 2 — Scope the Babel React Compiler to app source only
+
+Current config (line 14):
+
+```ts
+plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
+```
+
+Add an `include` filter to the `babel()` call:
+
+```ts
+plugins: [
+ react(),
+ babel({
+ presets: [reactCompilerPreset()],
+ include: [/src\/.*\.[jt]sx$/],
+ }),
+ tailwindcss(),
+],
+```
+
+The pattern `src\/.*\.[jt]sx$` matches only `.jsx`/`.tsx` files under `src/` (the app).
+It excludes:
+
+- `packages/avatar-core/` (no React components)
+- `.ts` files that are pure logic (no JSX to compile)
+
+### Change 3 — Monitor and consider Vite downgrade (conditional)
+
+If changes 1–2 do not bring CPU below ~30% idle:
+
+1. Run `pnpm dev` and note RSS memory after 60 seconds (`ps -o rss -p $(pgrep -f vite)`).
+2. If RSS exceeds 800 MB, the Rolldown memory regression is contributing. Consider pinning
+ Vite 7 (`"vite": "^7.0.0"`) until Rolldown stabilises. This requires:
+ - Replacing `@rolldown/plugin-babel` with standard `@vitejs/plugin-react` Babel config
+ (v5 style), since `@rolldown/plugin-babel` is Vite 8–specific.
+ - Verifying the Tailwind v4 plugin remains compatible with Vite 7.
+
+This step is **not** part of the default changeset — only pursue it if the first two changes
+are insufficient.
+
+## Validation
+
+After applying changes 1–2:
+
+1. `pnpm dev` — verify the server starts and HMR works (edit a `.tsx` in `src/`, confirm
+ hot reload).
+2. Monitor CPU for 30 seconds idle: `top -pid $(pgrep -f vite) -l 5`. Expect < 10% idle CPU
+ (down from ~200%).
+3. Edit a file in `packages/avatar-core/src/` — verify HMR still picks up the change despite
+ the watcher `ignored` pattern (the alias resolves to source, which is under the project
+ root and not under `node_modules`).
+4. `pnpm build` — verify production build still succeeds (the `server` block does not affect
+ build).
+5. `pnpm test` — verify no test regressions.
+
+## Files modified
+
+| File | Nature of change |
+| ---------------- | ------------------------------------------------------------------- |
+| `vite.config.ts` | Add `server.watch` config, add `include` filter to `babel()` plugin |
+
+## Out of scope
+
+- Upgrading or downgrading Vite (unless change 3 is triggered).
+- Modifying `packages/avatar-core/vite.config.ts` (library build config, not the dev server).
+- Changing `vitest.config.ts` (test runner, separate process).
+- Tailwind v4 plugin tuning (4.3.3 has no known performance issues per
+ https://github.com/tailwindlabs/tailwindcss/issues/16911, fixed since 4.0.10).
diff --git a/docs/specs/20260814-avatar-runtime-npm-package-and-semantic-api.md b/docs/specs/archive/20260814-avatar-runtime-npm-package-and-semantic-api.md
similarity index 83%
rename from docs/specs/20260814-avatar-runtime-npm-package-and-semantic-api.md
rename to docs/specs/archive/20260814-avatar-runtime-npm-package-and-semantic-api.md
index 0d1042e..376835a 100644
--- a/docs/specs/20260814-avatar-runtime-npm-package-and-semantic-api.md
+++ b/docs/specs/archive/20260814-avatar-runtime-npm-package-and-semantic-api.md
@@ -2,7 +2,98 @@
## Status
-Implementation specification - product decisions 1 to 21 recorded on 2026-08-14.
+Complete and archived on 2026-08-14. React v1 phases A-E are implemented and verified. The packages
+remain private and must not be published until licensing and repository metadata are approved.
+Product decisions 1 to 21 were recorded on 2026-08-14.
+
+### Implementation progress - 2026-08-14
+
+| Phase | State | Evidence |
+| -------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| A - Contract and pure conversion | Complete | The v1 JSON Schema, bounded duplicate-detecting parser, Ajv validation, public types, Studio conversion and focused boundary tests are implemented. |
+| B - Semantic Studio authoring | Complete | The approved bundled catalog has semantic keys. The Studio validates keys, reports export readiness, downloads `.avatar.json`, copies formatted JSON accessibly, and keeps English, French and Simplified Chinese copy synchronized. |
+| C - Extract core package | Complete | `@bible-strong/avatar-core` contains the framework-independent contract, geometry, scene generation, semantic lookup and deterministic playback. ESM, declarations and documented entry points build successfully. |
+| D - React renderer package | Complete | `@bible-strong/avatar-react` implements React 19 refs, embedded/floating SVG, SSR portal handoff, controlled/uncontrolled playback, direct per-frame rendering, pointer/keyboard movement, bounds, resize behavior and accessible movement controls. |
+| E - Existing export integration and package verification | Complete | Both packages build and pack. The smoke script installs their actual tarballs in a clean non-workspace React/Vite consumer, typechecks and builds it. Browser checks covered semantic controls, embedded/floating rendering, drag, reduced motion, mobile overflow, console errors and network responses. |
+| F - Additional framework adapters | Deferred | Vue and Angular remain explicitly outside the React v1 scope and require a separate go-ahead. |
+
+### Archive state
+
+Implementation was completed on branch `avatar-runtime`. Do not publish either package and do not
+start Vue or Angular adapters without a separate product decision.
+
+#### Runtime export bug reported on 2026-08-14
+
+The Studio displayed one missing-key error for every bundled Expression and Animation, followed by
+many secondary unresolved-reference errors. This made the active Strobi avatar appear impossible to
+export.
+
+The fix is implemented:
+
+- when the known bundled catalog is loaded without semantic keys, its approved keys are restored by
+ durable internal ID;
+- custom Expressions and Animations are never assigned invented keys;
+- unresolved-reference consequences are hidden when an Expression key error already explains the
+ problem, and identical messages are deduplicated;
+- the incomplete-export card offers a destructive `Clear local project and reload` action that
+ removes only the Studio document storage key before loading the bundled catalog again;
+- the same repair applies to base behavior and Avatar-owned behavior;
+- focused persistence tests cover the repaired bundled catalog and prove that a custom item remains
+ unkeyed.
+
+The Export accordion flags `Export runtime JSON` as new and distinguishes it from the pre-existing
+`Export avatar` ZIP generator. The former exports runtime data for the npm packages; the latter
+continues to generate the historical standalone React or JavaScript ZIP.
+The runtime section also includes a compact `
` quick start with the npm install command and a
+minimal validated React integration. It explicitly notes that the commands become usable after the
+currently private packages are published. The code uses accessible high-contrast syntax coloring,
+and a `Run example` control renders the generated definition through the actual public React package
+with a restartable `idle` animation.
+
+A production-build browser regression loaded Strobi with all bundled semantic keys removed, opened
+Export and observed `Ready for runtime export` and `6/6 standard animations available`. The captured
+download was schema v1 with 28 Expressions (including synthesized `neutral`), 23 Animations and zero
+public `expression-*` references. The formatted-copy action produced valid JSON and its accessible
+success status.
+
+#### Implemented architecture
+
+- `pnpm-workspace.yaml` declares `packages/*` and `examples/*`.
+- `packages/avatar-core` owns the public schema/types/validator, bounded JSON parser, semantic
+ manifests, geometry, surfaces, body model, ambient motion, pure playback state, definition-to-scene
+ adapter, ESM build, declarations, README and package metadata.
+- `src/features/avatar/{geometry,surfaces,body,ambientMotion}.ts` are compatibility re-exports from
+ the shared core. `src/features/avatar/avatarDefinition.ts` keeps only the Studio-to-public adapter
+ and re-exports the public contract.
+- `packages/avatar-react` owns ` `, its typed controller, CSS hooks, embedded/floating
+ layouts, body portal, pointer capture, keyboard movement, position constraints/callbacks, SVG
+ rendering and direct per-frame path/transform updates.
+- `examples/react-vite-consumer` is the independent React fixture. `scripts/smoke-packages.mjs`
+ builds and packs both packages, copies the fixture outside the workspace, forces the core
+ dependency to the local tarball, installs, typechecks and builds.
+- Runtime packages remain `private: true` and AGPL-3.0-only. Apache-2.0 relicensing and any npm
+ publication remain blocked until every copyright holder and repository metadata are confirmed.
+- The existing Studio ZIP export still consumes compatibility re-exports and strips new
+ `semanticKey` fields from its legacy payload to avoid an unintended output change.
+
+#### Final verification recorded on 2026-08-14
+
+- Focused Studio/avatar regression suite: 3 files and 67 tests passed.
+- `pnpm check`: passed, including generated-engine freshness, formatting, TypeScript, 19 test files
+ and 162 tests, both package builds, and the production Studio build.
+- `npm pack --dry-run --json`: passed; core contains 30 allow-listed files and React contains 10.
+- `pnpm packages:smoke`: passed against newly built tarballs in a clean temporary consumer.
+- Browser verification of the tarball consumer observed two SVGs/eight paths, working
+ `play('idle')` and `setExpression('neutral')`, floating drag, no mobile horizontal overflow, and no
+ application console or page errors.
+- `git diff --check`: passed during final handoff.
+
+#### Post-implementation constraints
+
+- Decide licensing/repository metadata before removing `private: true` or publishing either package.
+- Phase F requires a separate product decision; it is not a blocker for React v1.
+- Local Vite preview still receives 404 responses for the pre-existing Vercel Analytics and Speed
+ Insights scripts; the runtime example itself renders without JavaScript errors.
## 1. Context and objective
@@ -349,7 +440,13 @@ Implementation steps:
4. Update `defaultStudioDocument.json` with the curated keys.
5. Update default constructors and tests so newly created custom content starts without a key and is visibly marked as not export-ready.
-Because the project is pre-release, extend the current v2 document parser, serializer, default document, and behavior-copy operations coherently instead of adding a legacy migration. Existing local documents without semantic keys remain editable, but cannot be exported as runtime definitions until keys are supplied. The parser must round-trip `semanticKey` for both base behavior and avatar-owned behavior; it must not drop it as an unknown field after reload.
+Because the project is pre-release, extend the current v2 document parser, serializer, default
+document, and behavior-copy operations coherently instead of introducing another schema version.
+When approved bundled items are loaded without their keys, restore the known keys by durable internal
+ID so the bundled avatar remains exportable. Never invent a key for custom content: custom items
+remain editable but cannot be exported until the user supplies a valid key. The parser must
+round-trip `semanticKey` for both base behavior and Avatar-owned behavior; it must not drop it as an
+unknown field after reload.
### 6.3 Expression resolution during export
diff --git a/docs/specs/archive/20260814-avatar-runtime-semantic-curation.md b/docs/specs/archive/20260814-avatar-runtime-semantic-curation.md
new file mode 100644
index 0000000..06bdbcd
--- /dev/null
+++ b/docs/specs/archive/20260814-avatar-runtime-semantic-curation.md
@@ -0,0 +1,89 @@
+# Avatar Runtime Semantic Curation
+
+## Status
+
+Approved by Éric on 2026-08-14. This document is the product/design source for the committed
+`STANDARD_ANIMATIONS_V1` runtime manifest.
+
+Implementation state: complete. The approved expression keys, explicit animation keys, and
+`STANDARD_ANIMATIONS_V1` manifest are present in the bundled Studio data and runtime contract. This
+approval gate no longer blocks phase C; overall implementation progress is tracked in
+`20260814-avatar-runtime-npm-package-and-semantic-api.md`.
+
+It implements the human-validation gate required by
+`20260814-avatar-runtime-npm-package-and-semantic-api.md`, phase B, step 1. No semantic key or
+standard-animation mapping below may be copied into bundled data or runtime code before explicit
+approval. That approval has now been recorded below.
+
+## Expression catalogue proposal
+
+The 27 bundled Studio expressions were rendered with the framework-independent SVG geometry engine
+and visually inventoried. Internal IDs remain editor-only identifiers and will not be exposed by the
+runtime.
+
+| Internal expression ID | Proposed semantic key | Visual reading |
+| ------------------------------------------------- | ----------------------- | ---------------------------------------- |
+| `expression-00` | `upward-side-glance` | Eyes raised toward one side |
+| `expression-01` | `downward-gaze` | Medium eyes looking downward |
+| `expression-05` | `skeptical-right` | One vertical eye and one horizontal eye |
+| `expression-06` | `small-attentive` | Small attentive eyes |
+| `expression-12` | `wide-downward-gaze` | Large round eyes looking downward |
+| `expression-03` | `surprised-left` | Large offset eyes with a tilted head |
+| `expression-04` | `sleepy-squint` | Narrow tired eyes |
+| `expression-07` | `angry-right` | Tense angular gaze |
+| `expression-08` | `curious-left` | Uneven sideways gaze |
+| `expression-09` | `asymmetric-down-right` | One large and one small eye |
+| `expression-10` | `attentive-left` | Slightly tilted attentive gaze |
+| `expression-11` | `joyful-wide` | Very large open eyes |
+| `expression-13` | `eyes-closed` | Closed, lowered eyes |
+| `expression-02` | `joyful-down-right` | Large eyes angled downward |
+| `expression-14` | `skeptical-left` | Opposite skeptical gaze |
+| `expression-15` | `far-right-glance` | Small eyes strongly offset to one side |
+| `expression-16` | `angry-left` | Opposite angular gaze |
+| `expression-17` | `playful-right` | Lively uneven gaze |
+| `expression-18` | `asymmetric-up-left` | One large and one small raised eye |
+| `expression-19` | `gentle-downward-gaze` | Soft downward gaze |
+| `expression-20` | `wide-down-left` | Very large lowered eyes |
+| `expression-21` | `surprised-wide-left` | Wide round eyes, alternate surprise pose |
+| `expression-22` | `drowsy-closed` | Nearly closed raised eyes |
+| `expression-23` | `suspicious-right` | Wary sideways gaze |
+| `expression-24` | `shy-downward` | Small lowered eyes |
+| `expression-3d2bed26-f97c-477d-922f-77600cb10e92` | `angry-brows` | Strongly inward-sloping eyes |
+| `expression-5220eaee-32fe-4bd8-ad31-432189534cc8` | `uneasy-left` | Uneven worried gaze |
+
+## Explicit animation-key proposal
+
+The 23 bundled animations already have unique English machine IDs. The proposal is to use each
+existing ID unchanged as its `semanticKey`:
+
+`sleeping`, `waking`, `idle`, `listening`, `thinking`, `searching`, `working`, `excited`, `bored`,
+`suspicious`, `angry`, `drowsy`, `happy`, `curious`, `confused`, `surprised`, `proud`, `shy`, `sad`,
+`laughing`, `scared`, `playful`, and `celebrate`.
+
+## `STANDARD_ANIMATIONS_V1` proposal
+
+This proposal retains the established Studio sequence order, timing, playback, and blink behavior.
+All steps use `transitionMs: 500` and `transition: "smooth"`.
+
+| Standard key | Ordered semantic-expression steps | `holdMs` per step | Playback mode | Blink: initial / min / max / duration (ms) |
+| ------------ | ---------------------------------------------------------------------------------------------------- | ----------------: | ------------- | ------------------------------------------ |
+| `idle` | `upward-side-glance` -> `curious-left` | 5200 | `loop` | 2600 / 3400 / 6200 / 280 |
+| `happy` | `joyful-down-right` -> `joyful-wide` -> `playful-right` -> `gentle-downward-gaze` | 2300 | `loop` | 2100 / 2800 / 5000 / 260 |
+| `sad` | `sleepy-squint` -> `eyes-closed` -> `drowsy-closed` | 3600 | `loop` | 4800 / 6500 / 9500 / 420 |
+| `thinking` | `curious-left` -> `angry-left` -> `skeptical-left` -> `playful-right` -> `skeptical-right` | 2300 | `loop` | 2100 / 2800 / 5000 / 260 |
+| `excited` | `joyful-down-right` -> `playful-right` -> `surprised-wide-left` -> `surprised-left` -> `joyful-wide` | 2300 | `loop` | 1200 / 1800 / 3600 / 220 |
+| `celebrate` | `joyful-down-right` -> `curious-left` -> `playful-right` | 2300 | `loop` | 1200 / 1800 / 3600 / 220 |
+
+For each row, the required expression keys are the unique keys appearing in its ordered steps. An
+explicit animation with the same key will override the corresponding standard animation, as required
+by the v1 runtime specification.
+
+## Approval record
+
+Approver: Éric
+
+Decision date: 2026-08-14
+
+Decision: approved as proposed
+
+Requested changes: none recorded
diff --git a/examples/react-vite-consumer/index.html b/examples/react-vite-consumer/index.html
new file mode 100644
index 0000000..3872ce8
--- /dev/null
+++ b/examples/react-vite-consumer/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Avatar React consumer
+
+
+
+
+
+
diff --git a/examples/react-vite-consumer/package.json b/examples/react-vite-consumer/package.json
new file mode 100644
index 0000000..5dbad17
--- /dev/null
+++ b/examples/react-vite-consumer/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "avatar-react-vite-consumer",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build"
+ },
+ "dependencies": {
+ "@bible-strong/avatar-core": "workspace:*",
+ "@bible-strong/avatar-react": "workspace:*",
+ "@vitejs/plugin-react": "^6.0.2",
+ "vite": "^8.0.13",
+ "typescript": "~6.0.3",
+ "react": "19.2.3",
+ "react-dom": "19.2.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3"
+ }
+}
diff --git a/examples/react-vite-consumer/src/main.tsx b/examples/react-vite-consumer/src/main.tsx
new file mode 100644
index 0000000..42adab9
--- /dev/null
+++ b/examples/react-vite-consumer/src/main.tsx
@@ -0,0 +1,60 @@
+import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
+import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
+import '@bible-strong/avatar-react/styles.css'
+import { StrictMode, useRef, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import definitionJson from './strobi.avatar.json'
+import './styles.css'
+
+const validation = validateAvatarDefinition(definitionJson)
+if (!validation.ok) throw new Error(validation.errors[0]?.message)
+const definition = validation.value as AvatarDefinition
+
+function Demo() {
+ const controller = useRef(null)
+ const [status, setStatus] = useState('neutral')
+ const play = () => {
+ const result = controller.current?.play('idle')
+ setStatus(result?.ok ? 'idle' : (result?.error.code ?? 'unavailable'))
+ }
+ const setNeutral = () => {
+ const result = controller.current?.setExpression('neutral')
+ setStatus(result?.ok ? 'neutral' : (result?.error.code ?? 'unavailable'))
+ }
+ return (
+
+ Avatar package consumer
+
+
+ Play idle
+
+
+ Set neutral
+
+ {status}
+
+
+ )
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/examples/react-vite-consumer/src/strobi.avatar.json b/examples/react-vite-consumer/src/strobi.avatar.json
new file mode 100644
index 0000000..a5f7a94
--- /dev/null
+++ b/examples/react-vite-consumer/src/strobi.avatar.json
@@ -0,0 +1,1733 @@
+{
+ "schema": "bible-strong/avatar-definition",
+ "schemaVersion": 1,
+ "name": "Strobi",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 240,
+ "height": 240,
+ "depth": 240.03671875,
+ "roundness": 1
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#5b7fe5",
+ "eyes": "#111316"
+ },
+ "expressions": {
+ "neutral": {
+ "head": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ },
+ "eyes": {
+ "left": {
+ "width": 20,
+ "height": 50,
+ "x": 0,
+ "y": -7,
+ "angle": 0
+ },
+ "right": {
+ "width": 20,
+ "height": 50,
+ "x": 0,
+ "y": -7,
+ "angle": 0
+ },
+ "spacing": 35
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "upward-side-glance": {
+ "head": {
+ "x": 7.3,
+ "y": 27.8,
+ "z": -16.1
+ },
+ "eyes": {
+ "left": {
+ "width": 22.501171874999997,
+ "height": 42.37773437499999,
+ "x": 0,
+ "y": -20.5,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.501171874999997,
+ "height": 42.37773437499999,
+ "x": 0,
+ "y": -20.5,
+ "angle": 0
+ },
+ "spacing": 54.3
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "downward-gaze": {
+ "head": {
+ "x": -15.057812500000004,
+ "y": 0.14296874999999964,
+ "z": -14.549218750000001
+ },
+ "eyes": {
+ "left": {
+ "width": 22.401171875000003,
+ "height": 54.5703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.401171875000003,
+ "height": 54.5703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 57.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "skeptical-right": {
+ "head": {
+ "x": -16.528515625,
+ "y": -3.7679687499999996,
+ "z": -13.7296875
+ },
+ "eyes": {
+ "left": {
+ "width": 23.090625000000003,
+ "height": 57.6796875,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 49.924609375,
+ "height": 12.431640625,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 56.3
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "small-attentive": {
+ "head": {
+ "x": -4.232421875000001,
+ "y": 14.362109375000003,
+ "z": 11.204296875
+ },
+ "eyes": {
+ "left": {
+ "width": 22.066796874999994,
+ "height": 39.599218750000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.066796874999994,
+ "height": 39.599218750000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 50.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "wide-downward-gaze": {
+ "head": {
+ "x": -19.20859375,
+ "y": 15.2,
+ "z": 11.8
+ },
+ "eyes": {
+ "left": {
+ "width": 52.084765625,
+ "height": 51.46715970849806,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 53.11410881916996,
+ "height": 52.18769994441695,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 69.5
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "surprised-left": {
+ "head": {
+ "x": 2.9468749999999986,
+ "y": -16.051171875,
+ "z": -20.916015625
+ },
+ "eyes": {
+ "left": {
+ "width": 51.68336723153048,
+ "height": 51.74054108796297,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 51.68336723153048,
+ "height": 51.74054108796297,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 70.9
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "sleepy-squint": {
+ "head": {
+ "x": 3.4,
+ "y": 13.22578125,
+ "z": 8.976953125
+ },
+ "eyes": {
+ "left": {
+ "width": 51.775000000000006,
+ "height": 13.02734375,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 51.775000000000006,
+ "height": 13.02734375,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 63.872265625
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "angry-right": {
+ "head": {
+ "x": 8.063671874999999,
+ "y": 17.626562500000002,
+ "z": -11.116796874999999
+ },
+ "eyes": {
+ "left": {
+ "width": 20.908203125,
+ "height": 40.400781249999994,
+ "x": 0,
+ "y": 0,
+ "angle": -30.865625
+ },
+ "right": {
+ "width": 20.908203125,
+ "height": 40.400781249999994,
+ "x": 0,
+ "y": 0,
+ "angle": 28.781640625
+ },
+ "spacing": 52.059765625
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "curious-left": {
+ "head": {
+ "x": -12.303515625,
+ "y": -17.601171875,
+ "z": 5.9109375
+ },
+ "eyes": {
+ "left": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": 23.523046875000002
+ },
+ "right": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": -24.042578125000002
+ },
+ "spacing": 54.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "asymmetric-down-right": {
+ "head": {
+ "x": -20.058203125,
+ "y": 12.607421875,
+ "z": -12.7
+ },
+ "eyes": {
+ "left": {
+ "width": 42.5,
+ "height": 41.8,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.1,
+ "height": 22.200000000000003,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 61.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "attentive-left": {
+ "head": {
+ "x": 1.43359375,
+ "y": 6.194140624999999,
+ "z": 10.56015625
+ },
+ "eyes": {
+ "left": {
+ "width": 23.836718749999996,
+ "height": 58.130078125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 23.836718749999996,
+ "height": 58.130078125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 56.8
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "joyful-wide": {
+ "head": {
+ "x": -2.092968750000001,
+ "y": -15.899609374999999,
+ "z": -14.469921875
+ },
+ "eyes": {
+ "left": {
+ "width": 34.20086765973213,
+ "height": 85.33085937499999,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 34.20086765973213,
+ "height": 83.17775668160692,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 59.414453124999994
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "eyes-closed": {
+ "head": {
+ "x": -8.752343750000001,
+ "y": -8.743359375,
+ "z": -10.773828125000001
+ },
+ "eyes": {
+ "left": {
+ "width": 56.133984375,
+ "height": 15.5,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 56.133984375,
+ "height": 15.155468749999997,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 69.276171875
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "joyful-down-right": {
+ "head": {
+ "x": -15.287109375000002,
+ "y": 15.006640625,
+ "z": 12.787890625
+ },
+ "eyes": {
+ "left": {
+ "width": 31.25390625,
+ "height": 76.720703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 31.25390625,
+ "height": 76.720703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 68.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "skeptical-left": {
+ "head": {
+ "x": 3.5292968750000004,
+ "y": -7.0765625,
+ "z": 9.830078125
+ },
+ "eyes": {
+ "left": {
+ "width": 24.306250000000006,
+ "height": 59.281640624999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 48.92421875000001,
+ "height": 13.408203125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 62.218359375000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "far-right-glance": {
+ "head": {
+ "x": 0.31914062500000184,
+ "y": 35.307421874999996,
+ "z": -10.904296875
+ },
+ "eyes": {
+ "left": {
+ "width": 22.4609375,
+ "height": 39.820703124999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.4609375,
+ "height": 39.820703124999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 53.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "angry-left": {
+ "head": {
+ "x": -14.750781250000001,
+ "y": -19.350000000000005,
+ "z": 5.631640624999998
+ },
+ "eyes": {
+ "left": {
+ "width": 19.602343750000003,
+ "height": 48.63984375,
+ "x": 0,
+ "y": 0,
+ "angle": -27.606640625
+ },
+ "right": {
+ "width": 19.602343750000003,
+ "height": 48.63984375,
+ "x": 0,
+ "y": 0,
+ "angle": 26.1484375
+ },
+ "spacing": 55.099999999999994
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "playful-right": {
+ "head": {
+ "x": -4.3953125,
+ "y": 14.07265625,
+ "z": -16.126171874999997
+ },
+ "eyes": {
+ "left": {
+ "width": 19.045145681988203,
+ "height": 43.370703125000006,
+ "x": 0,
+ "y": 0,
+ "angle": 26.2921875
+ },
+ "right": {
+ "width": 19.045145681988203,
+ "height": 43.370703125000006,
+ "x": 0,
+ "y": 0,
+ "angle": -20.249218750000004
+ },
+ "spacing": 51.73124999999999
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "asymmetric-up-left": {
+ "head": {
+ "x": 6.585546875,
+ "y": 4.737109375000001,
+ "z": 12.840234374999998
+ },
+ "eyes": {
+ "left": {
+ "width": 42.1,
+ "height": 41.7,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.200000000000003,
+ "height": 22.099999999999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 60.400000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "gentle-downward-gaze": {
+ "head": {
+ "x": -6.077734375000001,
+ "y": -11.03515625,
+ "z": -13.965625000000001
+ },
+ "eyes": {
+ "left": {
+ "width": 23.045703125000003,
+ "height": 58.685156250000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 23.045703125000003,
+ "height": 58.685156250000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 56.2
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "wide-down-left": {
+ "head": {
+ "x": -17.127734375000003,
+ "y": 18.070703124999998,
+ "z": 13.891796875
+ },
+ "eyes": {
+ "left": {
+ "width": 35.452734375,
+ "height": 79.10429687499999,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 35.452734375,
+ "height": 79.10429687499999,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 70.8
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "surprised-wide-left": {
+ "head": {
+ "x": -5.428125,
+ "y": -11.71328125,
+ "z": -13.472265625000002
+ },
+ "eyes": {
+ "left": {
+ "width": 51.400000000000006,
+ "height": 50.099999999999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 50.5,
+ "height": 49.400000000000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 69
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "drowsy-closed": {
+ "head": {
+ "x": 10.292578125,
+ "y": 3.39921875,
+ "z": 7.583203125
+ },
+ "eyes": {
+ "left": {
+ "width": 55.672265624999994,
+ "height": 14.616015625000003,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 55.672265624999994,
+ "height": 14.616015625000003,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 68.416796875
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "suspicious-right": {
+ "head": {
+ "x": -17.8,
+ "y": 10,
+ "z": -10.894921875
+ },
+ "eyes": {
+ "left": {
+ "width": 23.969921874999997,
+ "height": 55.892968749999994,
+ "x": 0,
+ "y": -9.8,
+ "angle": 0
+ },
+ "right": {
+ "width": 53.56328125,
+ "height": 13.333593750000006,
+ "x": 0,
+ "y": -9.8,
+ "angle": 0
+ },
+ "spacing": 59.943749999999994
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "shy-downward": {
+ "head": {
+ "x": 7.131640624999998,
+ "y": 7.7828124999999995,
+ "z": 3.935546874999999
+ },
+ "eyes": {
+ "left": {
+ "width": 21.5,
+ "height": 32,
+ "x": 0,
+ "y": 40,
+ "angle": 0
+ },
+ "right": {
+ "width": 23.200000000000003,
+ "height": 33.5,
+ "x": 0,
+ "y": 40,
+ "angle": 0
+ },
+ "spacing": 51.2
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "angry-brows": {
+ "head": {
+ "x": 10.473974503042374,
+ "y": 5.087293619785961,
+ "z": 4.698252132317348
+ },
+ "eyes": {
+ "left": {
+ "width": 27.12656249999999,
+ "height": 63.028125,
+ "x": 0,
+ "y": 0,
+ "angle": -36.244531249999994
+ },
+ "right": {
+ "width": 27.12656249999999,
+ "height": 63.028125,
+ "x": 0,
+ "y": 0,
+ "angle": 27.730078125000002
+ },
+ "spacing": 68.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "shake"
+ },
+ "colors": {
+ "body": "#ba3636",
+ "eyes": "#610000"
+ }
+ },
+ "uneasy-left": {
+ "head": {
+ "x": -12.303515625,
+ "y": -17.601171875,
+ "z": 5.9109375
+ },
+ "eyes": {
+ "left": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": 23.523046875000002
+ },
+ "right": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": -24.042578125000002
+ },
+ "spacing": 54.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "shake",
+ "body": "slowDrift"
+ },
+ "colors": {
+ "body": "#adc3ff"
+ }
+ }
+ },
+ "expressionOrder": [
+ "neutral",
+ "upward-side-glance",
+ "downward-gaze",
+ "skeptical-right",
+ "small-attentive",
+ "wide-downward-gaze",
+ "surprised-left",
+ "sleepy-squint",
+ "angry-right",
+ "curious-left",
+ "asymmetric-down-right",
+ "attentive-left",
+ "joyful-wide",
+ "eyes-closed",
+ "joyful-down-right",
+ "skeptical-left",
+ "far-right-glance",
+ "angry-left",
+ "playful-right",
+ "asymmetric-up-left",
+ "gentle-downward-gaze",
+ "wide-down-left",
+ "surprised-wide-left",
+ "drowsy-closed",
+ "suspicious-right",
+ "shy-downward",
+ "angry-brows",
+ "uneasy-left"
+ ],
+ "animations": {
+ "sleeping": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "eyes-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "sleeping",
+ "description": "Yeux presque fermés, respiration lente et expression de sommeil.",
+ "group": "Cycle de vie"
+ }
+ },
+ "waking": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "eyes-closed",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "waking",
+ "description": "Séquence courte de réveil avant retour vers une expression neutre.",
+ "group": "Cycle de vie"
+ }
+ },
+ "idle": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 5200,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 5200,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2600,
+ "minIntervalMs": 3400,
+ "maxIntervalMs": 6200,
+ "durationMs": 280
+ },
+ "metadata": {
+ "label": "idle",
+ "description": "Micro-mouvements lents, expressions 00 et 08, clignement rare.",
+ "group": "Cycle de vie"
+ }
+ },
+ "listening": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "attentive-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "gentle-downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 3200,
+ "minIntervalMs": 4800,
+ "maxIntervalMs": 7200,
+ "durationMs": 240
+ },
+ "metadata": {
+ "label": "listening",
+ "description": "Expressions 10, 01 et 19, regard stable et clignement attentif.",
+ "group": "Cycle de vie"
+ }
+ },
+ "thinking": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "angry-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "thinking",
+ "description": "Regard haut et latéral, expressions asymétriques et changements fréquents.",
+ "group": "Cycle de vie"
+ }
+ },
+ "searching": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "far-right-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "asymmetric-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "wide-down-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "wide-downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "asymmetric-up-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "searching",
+ "description": "Balayage rapide et changements très fréquents.",
+ "group": "Cycle de vie"
+ }
+ },
+ "working": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "angry-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "angry-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "attentive-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "working",
+ "description": "Rythme régulier et expressions concentrées.",
+ "group": "Cycle de vie"
+ }
+ },
+ "excited": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "excited",
+ "description": "Grandes expressions et transitions rapides.",
+ "group": "Réactions"
+ }
+ },
+ "bored": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "bored",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "suspicious": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "skeptical-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "suspicious-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "suspicious",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "angry": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "angry-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "angry-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "angry",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "drowsy": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "eyes-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "drowsy",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "happy": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "gentle-downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "happy",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "curious": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "far-right-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "curious",
+ "description": "Inclinaisons et forte asymétrie.",
+ "group": "Réactions"
+ }
+ },
+ "confused": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "skeptical-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "confused",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "surprised": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "surprised",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "proud": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "far-right-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "proud",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "shy": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "shy-downward",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "eyes-closed",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "shy",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "sad": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "eyes-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "sad",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "laughing": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "laughing",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "scared": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "scared",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "playful": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "playful",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "celebrate": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "celebrate",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ }
+ },
+ "animationOrder": [
+ "sleeping",
+ "waking",
+ "idle",
+ "listening",
+ "thinking",
+ "searching",
+ "working",
+ "excited",
+ "bored",
+ "suspicious",
+ "angry",
+ "drowsy",
+ "happy",
+ "curious",
+ "confused",
+ "surprised",
+ "proud",
+ "shy",
+ "sad",
+ "laughing",
+ "scared",
+ "playful",
+ "celebrate"
+ ],
+ "standardAnimationSet": 1
+}
diff --git a/examples/react-vite-consumer/src/styles.css b/examples/react-vite-consumer/src/styles.css
new file mode 100644
index 0000000..7cc3566
--- /dev/null
+++ b/examples/react-vite-consumer/src/styles.css
@@ -0,0 +1,44 @@
+:root {
+ font-family: system-ui, sans-serif;
+ color: #111827;
+ background: #f8fafc;
+}
+
+body {
+ margin: 0;
+}
+
+main {
+ display: grid;
+ justify-items: center;
+ gap: 24px;
+ min-height: 100dvh;
+ padding: 48px 16px;
+ box-sizing: border-box;
+}
+
+.avatar-host {
+ width: min(100%, 360px);
+ min-height: 280px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #cbd5e1;
+ border-radius: 24px;
+ background: white;
+}
+
+button {
+ min-height: 44px;
+ padding: 0 20px;
+ border: 0;
+ border-radius: 999px;
+ color: white;
+ background: #4338ca;
+ font: inherit;
+ cursor: pointer;
+}
+
+button:focus-visible {
+ outline: 3px solid #4338ca;
+ outline-offset: 3px;
+}
diff --git a/examples/react-vite-consumer/src/vite-env.d.ts b/examples/react-vite-consumer/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/examples/react-vite-consumer/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/react-vite-consumer/tsconfig.json b/examples/react-vite-consumer/tsconfig.json
new file mode 100644
index 0000000..56ce52e
--- /dev/null
+++ b/examples/react-vite-consumer/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "skipLibCheck": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/examples/react-vite-consumer/vite.config.ts b/examples/react-vite-consumer/vite.config.ts
new file mode 100644
index 0000000..80316f3
--- /dev/null
+++ b/examples/react-vite-consumer/vite.config.ts
@@ -0,0 +1,4 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({ plugins: [react()] })
diff --git a/package.json b/package.json
index 66df015..7f59a74 100644
--- a/package.json
+++ b/package.json
@@ -29,12 +29,17 @@
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check .",
- "check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm build"
+ "packages:build": "pnpm --filter './packages/*' build",
+ "packages:smoke": "node scripts/smoke-packages.mjs",
+ "check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm packages:build && pnpm build"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
+ "@bible-strong/avatar-core": "workspace:*",
+ "@bible-strong/avatar-react": "workspace:*",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
+ "ajv": "^8.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.31.0",
@@ -49,11 +54,13 @@
"@babel/parser": "^7.28.6",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
+ "@testing-library/react": "^16.3.2",
"@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",
+ "jsdom": "^30.0.1",
"prettier": "^3.8.1",
"rolldown": "^1.0.0-rc.5",
"tailwindcss": "^4.3.3",
diff --git a/packages/avatar-core/LICENSE b/packages/avatar-core/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/packages/avatar-core/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/packages/avatar-core/README.md b/packages/avatar-core/README.md
new file mode 100644
index 0000000..14b2730
--- /dev/null
+++ b/packages/avatar-core/README.md
@@ -0,0 +1,74 @@
+# @bible-strong/avatar-core
+
+Framework-independent validation, semantic playback and renderer-neutral SVG scene generation for
+Bible Strong procedural avatars. The package has no React, DOM, Motion or browser-storage
+dependency.
+
+## Install and validate
+
+```sh
+pnpm add @bible-strong/avatar-core
+```
+
+Use `parseAvatarDefinition` for untrusted JSON text. It enforces the 256 KiB/depth limits and
+detects duplicate object keys before validating against the v1 schema. Use
+`validateAvatarDefinition` when the value is already materialized.
+
+```ts
+import { parseAvatarDefinition } from '@bible-strong/avatar-core'
+
+const parsed = parseAvatarDefinition(jsonText)
+if (!parsed.ok) {
+ throw new Error(`${parsed.errors[0].path}: ${parsed.errors[0].message}`)
+}
+const definition = parsed.value
+```
+
+Both functions return a non-mutating discriminated result. Successful values are deeply frozen;
+errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
+`@bible-strong/avatar-core/schema`.
+
+## Semantic lookup and playback
+
+Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
+for unknown keys; standard animations additionally report
+`unavailable_standard_animation` when their required expressions are absent.
+
+```ts
+import {
+ advanceAvatarPlayback,
+ getStandardAnimationAvailabilityV1,
+ playAvatarAnimation,
+ renderAvatarFrame,
+} from '@bible-strong/avatar-core'
+
+const availability = getStandardAnimationAvailabilityV1(definition.expressions)
+const started = playAvatarAnimation(definition, 'idle', 0)
+if (!started.ok) throw new Error(started.error.message)
+
+const state = advanceAvatarPlayback(definition, started.value, 500, {
+ random: () => 0.5,
+})
+const scene = renderAvatarFrame(definition, state, 500, {
+ random: () => 0.5,
+ reduceMotion: false,
+})
+```
+
+`advanceAvatarPlayback` is a pure state transition driven by a monotonic timestamp and injected
+random source. The timeline for each step is transition then hold. `pauseAvatarPlayback` and
+`resumeAvatarPlayback` preserve exact progress. With `reduceMotion: true`, transitions and ambient
+motion jump deterministically to their target while configured blinks remain active.
+
+`renderAvatarDefinition` renders a static semantic expression. `renderAvatarFrame` renders an
+animated frame. Both return paths, visibility and resolved colors without creating DOM nodes.
+
+## Entry points
+
+- `@bible-strong/avatar-core`: contract, validation, semantic catalog, playback and scene APIs.
+- `@bible-strong/avatar-core/schema`: the v1 Draft 2020-12 JSON Schema.
+- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
+ primitives for renderer authors.
+
+This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
+are confirmed. Local tarballs are for verification only and must not be published.
diff --git a/packages/avatar-core/package.json b/packages/avatar-core/package.json
new file mode 100644
index 0000000..45bfb70
--- /dev/null
+++ b/packages/avatar-core/package.json
@@ -0,0 +1,51 @@
+{
+ "name": "@bible-strong/avatar-core",
+ "version": "0.1.0",
+ "description": "Framework-independent runtime for Bible Strong procedural avatars.",
+ "license": "AGPL-3.0-only",
+ "private": true,
+ "type": "module",
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./geometry": {
+ "types": "./dist/geometry.d.ts",
+ "import": "./dist/geometry.js"
+ },
+ "./body": {
+ "types": "./dist/body.d.ts",
+ "import": "./dist/body.js"
+ },
+ "./surfaces": {
+ "types": "./dist/surfaces.d.ts",
+ "import": "./dist/surfaces.js"
+ },
+ "./ambient-motion": {
+ "types": "./dist/ambientMotion.d.ts",
+ "import": "./dist/ambientMotion.js"
+ },
+ "./schema": "./dist/avatarDefinition.schema.json"
+ },
+ "scripts": {
+ "build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "ajv": "^8.20.0"
+ },
+ "devDependencies": {
+ "typescript": "~6.0.3",
+ "vite": "^8.0.13"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+}
diff --git a/packages/avatar-core/src/__tests__/core-test.ts b/packages/avatar-core/src/__tests__/core-test.ts
new file mode 100644
index 0000000..b0de46f
--- /dev/null
+++ b/packages/avatar-core/src/__tests__/core-test.ts
@@ -0,0 +1,247 @@
+import {
+ advanceAvatarPlayback,
+ bodyFromDefinition,
+ createAvatarPlaybackState,
+ expressionFromDefinition,
+ parseAvatarDefinition,
+ playAvatarAnimation,
+ pauseAvatarPlayback,
+ poseFromExpression,
+ renderAvatar,
+ renderAvatarDefinition,
+ renderAvatarFrame,
+ resumeAvatarPlayback,
+ resolveAnimation,
+ type AvatarDefinition,
+} from '../index'
+
+const expression = {
+ head: { x: 0, y: 0, z: 0 },
+ eyes: {
+ left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
+ right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
+ spacing: 54,
+ },
+ perspective: 1,
+ motion: { eyes: 'none', body: 'none' },
+} as const
+
+const definition: AvatarDefinition = {
+ schema: 'bible-strong/avatar-definition',
+ schemaVersion: 1,
+ name: 'Core fixture',
+ body: {
+ primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
+ nodes: [],
+ },
+ colors: { body: '#5b7fe5', eyes: '#111316' },
+ expressions: {
+ neutral: expression,
+ 'upward-side-glance': { ...expression, head: { x: -8, y: 18, z: -4 } },
+ 'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
+ },
+ expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
+ animations: {},
+ animationOrder: [],
+ standardAnimationSet: 1,
+}
+
+describe('@bible-strong/avatar-core', () => {
+ it('loads a JSON definition and resolves a standard semantic animation', () => {
+ const parsed = parseAvatarDefinition(JSON.stringify(definition))
+ expect(parsed.ok).toBe(true)
+ if (!parsed.ok) return
+
+ const idle = resolveAnimation(parsed.value, 'idle')
+ expect(idle.ok).toBe(true)
+ if (!idle.ok) return
+ expect(idle.value.steps.map(step => step.expression)).toEqual([
+ 'upward-side-glance',
+ 'curious-left',
+ ])
+ })
+
+ it('advances playback deterministically from transition to hold and the next step', () => {
+ const started = playAvatarAnimation(definition, 'idle', 1_000)
+ expect(started.ok).toBe(true)
+ if (!started.ok) return
+
+ const holding = advanceAvatarPlayback(definition, started.value, 1_500, {
+ random: () => 0.5,
+ })
+ expect(holding).toMatchObject({
+ activeAnimation: 'idle',
+ activeExpression: 'upward-side-glance',
+ phase: 'hold',
+ status: 'playing',
+ })
+
+ const next = advanceAvatarPlayback(definition, holding, 6_701, { random: () => 0.5 })
+ expect(next).toMatchObject({
+ activeExpression: 'curious-left',
+ phase: 'transition',
+ stepIndex: 1,
+ })
+ })
+
+ it('generates the same geometry through the public definition adapter', () => {
+ const scene = renderAvatarDefinition(definition, 'curious-left')
+ const body = bodyFromDefinition(definition.body)
+ const internalExpression = expressionFromDefinition(
+ 'curious-left',
+ definition.expressions['curious-left']
+ )
+ const direct = renderAvatar(poseFromExpression(internalExpression), body.primary, 1, {
+ bodyNodes: body.nodes,
+ })
+
+ expect(scene.geometry).toEqual(direct)
+ expect(scene.colors).toEqual(definition.colors)
+ })
+
+ it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
+ const narrow: AvatarDefinition = {
+ ...definition,
+ body: {
+ primary: {
+ type: 'cube',
+ width: 199.00018,
+ height: 200,
+ depth: 200,
+ roundness: 0.5,
+ },
+ nodes: [],
+ },
+ }
+ const wide: AvatarDefinition = {
+ ...narrow,
+ body: {
+ ...narrow.body,
+ primary: { ...narrow.body.primary, width: 199.00022 },
+ },
+ }
+
+ const narrowScene = renderAvatarDefinition(narrow)
+ const wideScene = renderAvatarDefinition(wide)
+
+ expect(wideScene.geometry.headPath).not.toBe(narrowScene.geometry.headPath)
+ expect(wideScene.geometry.wirePaths).not.toEqual(narrowScene.geometry.wirePaths)
+ })
+
+ it('starts from the documented neutral stopped state', () => {
+ expect(createAvatarPlaybackState()).toMatchObject({
+ activeExpression: 'neutral',
+ status: 'stopped',
+ })
+ })
+
+ it('interpolates a bounded transition and freezes its exact progress while paused', () => {
+ const started = playAvatarAnimation(definition, 'idle', 1_000)
+ if (!started.ok) throw new Error(started.error.message)
+ const neutral = renderAvatarDefinition(definition, 'neutral')
+ const target = renderAvatarDefinition(definition, 'upward-side-glance')
+ const halfway = renderAvatarFrame(definition, started.value, 1_250, {
+ random: () => 0.5,
+ })
+ expect(halfway.geometry.leftPath).not.toBe(neutral.geometry.leftPath)
+ expect(halfway.geometry.leftPath).not.toBe(target.geometry.leftPath)
+
+ const paused = pauseAvatarPlayback(started.value, 1_250)
+ const resumed = resumeAvatarPlayback(paused, 4_250)
+ expect(resumed.phaseStartedAt).toBe(4_000)
+ const resumedFrame = renderAvatarFrame(definition, resumed, 4_250, {
+ random: () => 0.5,
+ })
+ expect(resumedFrame.geometry.leftPath).toBe(halfway.geometry.leftPath)
+ })
+
+ it('uses the injectable random source for a deterministic blink timeline', () => {
+ const started = playAvatarAnimation(definition, 'idle', 1_000)
+ if (!started.ok) throw new Error(started.error.message)
+ const blinking = advanceAvatarPlayback(definition, started.value, 3_600, {
+ random: () => 0,
+ })
+ expect(blinking.blinkStartedAt).toBe(3_600)
+ expect(blinking.blinkDueAt).toBe(7_280)
+ const open = renderAvatarFrame(definition, blinking, 3_600, { random: () => 0 })
+ const closed = renderAvatarFrame(definition, blinking, 3_740, { random: () => 0 })
+ expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
+ })
+
+ it('returns typed errors for unknown and unavailable semantic animations', () => {
+ expect(resolveAnimation(definition, 'missing')).toMatchObject({
+ ok: false,
+ error: { code: 'unknown_animation', key: 'missing' },
+ })
+ expect(resolveAnimation(definition, 'happy')).toMatchObject({
+ ok: false,
+ error: { code: 'unavailable_standard_animation', key: 'happy' },
+ })
+ })
+
+ it('lets an explicit animation override the standard animation with the same key', () => {
+ const overridden: AvatarDefinition = {
+ ...definition,
+ animations: {
+ idle: {
+ playbackMode: 'once',
+ steps: [{ expression: 'neutral', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
+ blink: {
+ enabled: false,
+ initialDelayMs: 0,
+ minIntervalMs: 1_000,
+ maxIntervalMs: 1_000,
+ durationMs: 100,
+ },
+ },
+ },
+ animationOrder: ['idle'],
+ }
+ expect(resolveAnimation(overridden, 'idle')).toMatchObject({
+ ok: true,
+ value: { playbackMode: 'once', steps: [{ expression: 'neutral' }] },
+ })
+ })
+
+ it('completes once playback and deterministically removes transition motion', () => {
+ const onceDefinition: AvatarDefinition = {
+ ...definition,
+ animations: {
+ once: {
+ playbackMode: 'once',
+ steps: [
+ {
+ expression: 'curious-left',
+ holdMs: 100,
+ transitionMs: 100,
+ transition: 'smooth',
+ },
+ ],
+ blink: {
+ enabled: false,
+ initialDelayMs: 0,
+ minIntervalMs: 1_000,
+ maxIntervalMs: 1_000,
+ durationMs: 100,
+ },
+ },
+ },
+ animationOrder: ['once'],
+ }
+ const started = playAvatarAnimation(onceDefinition, 'once', 0)
+ if (!started.ok) throw new Error(started.error.message)
+ const reduced = renderAvatarFrame(onceDefinition, started.value, 50, {
+ random: () => 0.5,
+ reduceMotion: true,
+ })
+ expect(reduced.geometry.leftPath).toBe(
+ renderAvatarDefinition(onceDefinition, 'curious-left').geometry.leftPath
+ )
+ expect(
+ advanceAvatarPlayback(onceDefinition, started.value, 200, { random: () => 0.5 })
+ ).toMatchObject({
+ activeExpression: 'curious-left',
+ status: 'stopped',
+ })
+ })
+})
diff --git a/packages/avatar-core/src/ambientMotion.ts b/packages/avatar-core/src/ambientMotion.ts
new file mode 100644
index 0000000..2df3a01
--- /dev/null
+++ b/packages/avatar-core/src/ambientMotion.ts
@@ -0,0 +1,115 @@
+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 EYE_MOTION_SEED = 17.29
+
+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 duration = 140
+ if (elapsedMs <= 0) return 0
+ const step = Math.floor(elapsedMs / interval)
+ const progress = (elapsedMs - step * interval) / duration
+ const blend = smoothstep(Math.min(progress, 1))
+ const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
+ const next = hash(step * 2 + axis + seed)
+ return previous + (next - previous) * blend
+}
+
+export const hasAmbientMotion = (expression: Expression) =>
+ expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
+
+export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
+ const seed = expressionSeed(expression)
+ if (expression.bodyMotion === 'slowDrift') {
+ return {
+ x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
+ y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
+ }
+ }
+ if (expression.bodyMotion === 'shake') {
+ const time = elapsedMs / 1000
+ return {
+ x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
+ y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
+ }
+ }
+ return { x: 0, y: 0 }
+}
+
+export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
+ if (expression.eyeMotion === 'microSaccades') {
+ return {
+ x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
+ y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
+ }
+ }
+ if (expression.eyeMotion === 'shake') {
+ const time = elapsedMs / 1000
+ return {
+ x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
+ y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
+ }
+ }
+ return { x: 0, y: 0 }
+}
+
+export const applyAmbientBodyMotion = (
+ expression: Expression,
+ elapsedMs: number,
+ strength = 1
+): Expression => {
+ const next = { ...expression }
+ const seed = expressionSeed(expression)
+
+ if (expression.bodyMotion === 'slowDrift') {
+ next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
+ next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
+ next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
+ } else if (expression.bodyMotion === 'shake') {
+ const time = elapsedMs / 1000
+ next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
+ next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
+ next.headZ += Math.sin(time * 43) * 0.7 * strength
+ }
+
+ return next
+}
+
+export const applyAmbientMotion = (
+ expression: Expression,
+ elapsedMs: number,
+ strength = 1
+): Expression => {
+ const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
+ const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
+ next.positionXLeft += eyeOffset.x
+ next.positionXRight += eyeOffset.x
+ next.positionYLeft += eyeOffset.y
+ next.positionYRight += eyeOffset.y
+ return next
+}
diff --git a/packages/avatar-core/src/avatarDefinition.schema.json b/packages/avatar-core/src/avatarDefinition.schema.json
new file mode 100644
index 0000000..36e8962
--- /dev/null
+++ b/packages/avatar-core/src/avatarDefinition.schema.json
@@ -0,0 +1,249 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://avatars.bible-strong.app/schemas/avatar-definition-v1.json",
+ "title": "Bible Strong Avatar Definition v1",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "schema",
+ "schemaVersion",
+ "body",
+ "colors",
+ "expressions",
+ "expressionOrder",
+ "animations",
+ "animationOrder",
+ "standardAnimationSet"
+ ],
+ "properties": {
+ "schema": { "const": "bible-strong/avatar-definition" },
+ "schemaVersion": { "const": 1 },
+ "name": { "type": "string", "maxLength": 120 },
+ "body": { "$ref": "#/$defs/body" },
+ "colors": { "$ref": "#/$defs/colors" },
+ "expressions": {
+ "type": "object",
+ "minProperties": 1,
+ "maxProperties": 128,
+ "required": ["neutral"],
+ "propertyNames": { "$ref": "#/$defs/semanticKey" },
+ "properties": { "neutral": { "$ref": "#/$defs/expression" } },
+ "additionalProperties": { "$ref": "#/$defs/expression" }
+ },
+ "expressionOrder": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 128,
+ "uniqueItems": true,
+ "items": { "$ref": "#/$defs/semanticKey" }
+ },
+ "animations": {
+ "type": "object",
+ "maxProperties": 64,
+ "propertyNames": { "$ref": "#/$defs/semanticKey" },
+ "additionalProperties": { "$ref": "#/$defs/animation" }
+ },
+ "animationOrder": {
+ "type": "array",
+ "maxItems": 64,
+ "uniqueItems": true,
+ "items": { "$ref": "#/$defs/semanticKey" }
+ },
+ "standardAnimationSet": { "const": 1 }
+ },
+ "$defs": {
+ "semanticKey": {
+ "type": "string",
+ "maxLength": 64,
+ "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
+ },
+ "hexColor": { "type": "string", "pattern": "^#[0-9a-f]{6}$" },
+ "boundedNumber": { "type": "number", "minimum": -10000, "maximum": 10000 },
+ "dimension": { "type": "number", "minimum": 0.001, "maximum": 10000 },
+ "roundness": { "type": "number", "minimum": 0, "maximum": 1 },
+ "surface": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "width", "height", "depth", "roundness"],
+ "properties": {
+ "type": {
+ "enum": ["sphere", "mickey", "cursor", "cube", "capsule", "cylinder", "cone", "diamond"]
+ },
+ "width": { "$ref": "#/$defs/dimension" },
+ "height": { "$ref": "#/$defs/dimension" },
+ "depth": { "$ref": "#/$defs/dimension" },
+ "roundness": { "$ref": "#/$defs/roundness" },
+ "morphRoundness": { "$ref": "#/$defs/roundness" },
+ "tipRoundness": { "$ref": "#/$defs/roundness" },
+ "baseRoundness": { "$ref": "#/$defs/roundness" }
+ }
+ },
+ "nodeSurface": {
+ "allOf": [
+ { "$ref": "#/$defs/surface" },
+ {
+ "type": "object",
+ "properties": {
+ "type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] }
+ }
+ }
+ ]
+ },
+ "position": {
+ "type": "array",
+ "minItems": 3,
+ "maxItems": 3,
+ "prefixItems": [
+ { "$ref": "#/$defs/boundedNumber" },
+ { "$ref": "#/$defs/boundedNumber" },
+ { "$ref": "#/$defs/boundedNumber" }
+ ]
+ },
+ "rotation": {
+ "type": "array",
+ "minItems": 3,
+ "maxItems": 3,
+ "prefixItems": [
+ { "type": "number", "minimum": -360, "maximum": 360 },
+ { "type": "number", "minimum": -360, "maximum": 360 },
+ { "type": "number", "minimum": -360, "maximum": 360 }
+ ]
+ },
+ "body": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["primary", "nodes"],
+ "properties": {
+ "primary": { "$ref": "#/$defs/surface" },
+ "nodes": {
+ "type": "array",
+ "maxItems": 16,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["surface", "position", "rotation"],
+ "properties": {
+ "surface": { "$ref": "#/$defs/nodeSurface" },
+ "position": { "$ref": "#/$defs/position" },
+ "rotation": { "$ref": "#/$defs/rotation" }
+ }
+ }
+ }
+ }
+ },
+ "colors": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["body", "eyes"],
+ "properties": {
+ "body": { "$ref": "#/$defs/hexColor" },
+ "eyes": { "$ref": "#/$defs/hexColor" }
+ }
+ },
+ "eye": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["width", "height", "x", "y", "angle"],
+ "properties": {
+ "width": { "$ref": "#/$defs/boundedNumber" },
+ "height": { "$ref": "#/$defs/boundedNumber" },
+ "x": { "$ref": "#/$defs/boundedNumber" },
+ "y": { "$ref": "#/$defs/boundedNumber" },
+ "angle": { "$ref": "#/$defs/boundedNumber" }
+ }
+ },
+ "expression": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["head", "eyes", "perspective", "motion"],
+ "properties": {
+ "head": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["x", "y", "z"],
+ "properties": {
+ "x": { "$ref": "#/$defs/boundedNumber" },
+ "y": { "$ref": "#/$defs/boundedNumber" },
+ "z": { "$ref": "#/$defs/boundedNumber" }
+ }
+ },
+ "eyes": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["left", "right", "spacing"],
+ "properties": {
+ "left": { "$ref": "#/$defs/eye" },
+ "right": { "$ref": "#/$defs/eye" },
+ "spacing": { "$ref": "#/$defs/boundedNumber" }
+ }
+ },
+ "perspective": { "type": "number", "minimum": 0.1, "maximum": 10 },
+ "motion": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["eyes", "body"],
+ "properties": {
+ "eyes": { "enum": ["none", "microSaccades", "shake"] },
+ "body": { "enum": ["none", "slowDrift", "shake"] }
+ }
+ },
+ "colors": {
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "body": { "$ref": "#/$defs/hexColor" },
+ "eyes": { "$ref": "#/$defs/hexColor" }
+ }
+ }
+ }
+ },
+ "blink": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["enabled", "initialDelayMs", "minIntervalMs", "maxIntervalMs", "durationMs"],
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "initialDelayMs": { "type": "number", "minimum": 0, "maximum": 60000 },
+ "minIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
+ "maxIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
+ "durationMs": { "type": "number", "minimum": 50, "maximum": 2000 }
+ }
+ },
+ "animation": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["playbackMode", "steps", "blink"],
+ "properties": {
+ "playbackMode": { "enum": ["loop", "once", "pingPong"] },
+ "steps": {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 128,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["expression", "holdMs", "transitionMs", "transition"],
+ "properties": {
+ "expression": { "$ref": "#/$defs/semanticKey" },
+ "holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
+ "transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
+ "transition": { "enum": ["spring", "smooth", "snappy"] }
+ }
+ }
+ },
+ "blink": { "$ref": "#/$defs/blink" },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "label": { "type": "string", "maxLength": 120 },
+ "description": { "type": "string", "maxLength": 512 },
+ "group": { "type": "string", "maxLength": 64 }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/avatar-core/src/avatarDefinition.ts b/packages/avatar-core/src/avatarDefinition.ts
new file mode 100644
index 0000000..9f7ce5c
--- /dev/null
+++ b/packages/avatar-core/src/avatarDefinition.ts
@@ -0,0 +1,617 @@
+import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js'
+import avatarDefinitionSchema from './avatarDefinition.schema.json'
+import type { SurfaceType } from './surfaces'
+
+export const AVATAR_DEFINITION_MAX_BYTES = 262_144
+export const AVATAR_DEFINITION_MAX_DEPTH = 32
+const MAX_JSON_STRING_LENGTH = 512
+export const SEMANTIC_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
+
+export type SemanticKeyIssueCode =
+ 'missing_semantic_key' | 'invalid_semantic_key' | 'reserved_semantic_key'
+
+export const getSemanticKeyIssue = (
+ semanticKey: string | undefined,
+ kind: 'expression' | 'animation'
+): SemanticKeyIssueCode | undefined => {
+ if (!semanticKey) return 'missing_semantic_key'
+ if (!SEMANTIC_KEY_PATTERN.test(semanticKey) || semanticKey.length > 64) {
+ return 'invalid_semantic_key'
+ }
+ if (kind === 'expression' && semanticKey === 'neutral') return 'reserved_semantic_key'
+ return undefined
+}
+
+export type SemanticKey = string
+export type ExpressionKey = SemanticKey
+export type AnimationKey = SemanticKey
+export type HexColor = `#${string}`
+
+export type AvatarColorsDefinition = {
+ body: HexColor
+ eyes: HexColor
+}
+
+export type SurfaceDefinition = {
+ type: TType
+ width: number
+ height: number
+ depth: number
+ roundness: number
+ morphRoundness?: number
+ tipRoundness?: number
+ baseRoundness?: number
+}
+
+export type BodyNodeSurfaceType = Exclude
+export type PrimarySurfaceDefinition = SurfaceDefinition
+export type BodyNodeSurfaceDefinition = SurfaceDefinition
+
+export type AvatarBodyNodeDefinition = {
+ surface: BodyNodeSurfaceDefinition
+ position: [number, number, number]
+ rotation: [number, number, number]
+}
+
+export type AvatarBodyDefinition = {
+ primary: PrimarySurfaceDefinition
+ nodes: AvatarBodyNodeDefinition[]
+}
+
+export type AvatarExpressionDefinition = {
+ head: { x: number; y: number; z: number }
+ eyes: {
+ left: { width: number; height: number; x: number; y: number; angle: number }
+ right: { width: number; height: number; x: number; y: number; angle: number }
+ spacing: number
+ }
+ perspective: number
+ motion: {
+ eyes: 'none' | 'microSaccades' | 'shake'
+ body: 'none' | 'slowDrift' | 'shake'
+ }
+ colors?: Partial
+}
+
+export type AvatarAnimationStepDefinition = {
+ expression: ExpressionKey
+ holdMs: number
+ transitionMs: number
+ transition: 'spring' | 'smooth' | 'snappy'
+}
+
+export type AvatarAnimationDefinition = {
+ playbackMode: 'loop' | 'once' | 'pingPong'
+ steps: AvatarAnimationStepDefinition[]
+ blink: {
+ enabled: boolean
+ initialDelayMs: number
+ minIntervalMs: number
+ maxIntervalMs: number
+ durationMs: number
+ }
+ metadata?: {
+ label?: string
+ description?: string
+ group?: string
+ }
+}
+
+export type AvatarDefinition = {
+ schema: 'bible-strong/avatar-definition'
+ schemaVersion: 1
+ name?: string
+ body: AvatarBodyDefinition
+ colors: AvatarColorsDefinition
+ expressions: Record
+ expressionOrder: ExpressionKey[]
+ animations: Record
+ animationOrder: AnimationKey[]
+ standardAnimationSet: 1
+}
+
+export const STANDARD_ANIMATION_ORDER_V1 = [
+ 'idle',
+ 'happy',
+ 'sad',
+ 'thinking',
+ 'excited',
+ 'celebrate',
+] as const
+
+export type StandardAnimationKeyV1 = (typeof STANDARD_ANIMATION_ORDER_V1)[number]
+export type StandardAnimationManifestEntry = AvatarAnimationDefinition & {
+ requiredExpressions: ExpressionKey[]
+}
+
+const standardStep = (
+ expression: ExpressionKey,
+ holdMs: number
+): AvatarAnimationStepDefinition => ({
+ expression,
+ holdMs,
+ transitionMs: 500,
+ transition: 'smooth',
+})
+
+const standardBlink = (
+ initialDelayMs: number,
+ minIntervalMs: number,
+ maxIntervalMs: number,
+ durationMs: number
+) => ({ enabled: true, initialDelayMs, minIntervalMs, maxIntervalMs, durationMs })
+
+export const STANDARD_ANIMATIONS_V1 = {
+ idle: {
+ requiredExpressions: ['upward-side-glance', 'curious-left'],
+ playbackMode: 'loop',
+ steps: [standardStep('upward-side-glance', 5200), standardStep('curious-left', 5200)],
+ blink: standardBlink(2600, 3400, 6200, 280),
+ },
+ happy: {
+ requiredExpressions: [
+ 'joyful-down-right',
+ 'joyful-wide',
+ 'playful-right',
+ 'gentle-downward-gaze',
+ ],
+ playbackMode: 'loop',
+ steps: [
+ standardStep('joyful-down-right', 2300),
+ standardStep('joyful-wide', 2300),
+ standardStep('playful-right', 2300),
+ standardStep('gentle-downward-gaze', 2300),
+ ],
+ blink: standardBlink(2100, 2800, 5000, 260),
+ },
+ sad: {
+ requiredExpressions: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
+ playbackMode: 'loop',
+ steps: [
+ standardStep('sleepy-squint', 3600),
+ standardStep('eyes-closed', 3600),
+ standardStep('drowsy-closed', 3600),
+ ],
+ blink: standardBlink(4800, 6500, 9500, 420),
+ },
+ thinking: {
+ requiredExpressions: [
+ 'curious-left',
+ 'angry-left',
+ 'skeptical-left',
+ 'playful-right',
+ 'skeptical-right',
+ ],
+ playbackMode: 'loop',
+ steps: [
+ standardStep('curious-left', 2300),
+ standardStep('angry-left', 2300),
+ standardStep('skeptical-left', 2300),
+ standardStep('playful-right', 2300),
+ standardStep('skeptical-right', 2300),
+ ],
+ blink: standardBlink(2100, 2800, 5000, 260),
+ },
+ excited: {
+ requiredExpressions: [
+ 'joyful-down-right',
+ 'playful-right',
+ 'surprised-wide-left',
+ 'surprised-left',
+ 'joyful-wide',
+ ],
+ playbackMode: 'loop',
+ steps: [
+ standardStep('joyful-down-right', 2300),
+ standardStep('playful-right', 2300),
+ standardStep('surprised-wide-left', 2300),
+ standardStep('surprised-left', 2300),
+ standardStep('joyful-wide', 2300),
+ ],
+ blink: standardBlink(1200, 1800, 3600, 220),
+ },
+ celebrate: {
+ requiredExpressions: ['joyful-down-right', 'curious-left', 'playful-right'],
+ playbackMode: 'loop',
+ steps: [
+ standardStep('joyful-down-right', 2300),
+ standardStep('curious-left', 2300),
+ standardStep('playful-right', 2300),
+ ],
+ blink: standardBlink(1200, 1800, 3600, 220),
+ },
+} satisfies Record
+
+export type StandardAnimationAvailabilityV1 = {
+ available: StandardAnimationKeyV1[]
+ unavailable: { key: StandardAnimationKeyV1; missingExpressions: ExpressionKey[] }[]
+}
+
+export const getStandardAnimationAvailabilityV1 = (
+ expressions: Readonly>
+): StandardAnimationAvailabilityV1 => {
+ const available: StandardAnimationKeyV1[] = []
+ const unavailable: StandardAnimationAvailabilityV1['unavailable'] = []
+ STANDARD_ANIMATION_ORDER_V1.forEach(key => {
+ const missingExpressions = STANDARD_ANIMATIONS_V1[key].requiredExpressions.filter(
+ expression => !(expression in expressions)
+ )
+ if (missingExpressions.length) unavailable.push({ key, missingExpressions })
+ else available.push(key)
+ })
+ return { available, unavailable }
+}
+
+export type AvatarDefinitionError = {
+ path: string
+ code: string
+ message: string
+}
+
+export type ValidationResult =
+ { ok: true; value: Readonly } | { ok: false; errors: readonly AvatarDefinitionError[] }
+
+const ajv = new Ajv2020({ allErrors: true, strict: true })
+const validateSchema = ajv.compile(avatarDefinitionSchema)
+
+const escapePointer = (value: string) => value.replaceAll('~', '~0').replaceAll('/', '~1')
+const childPointer = (path: string, segment: string | number) =>
+ `${path}/${escapePointer(String(segment))}`
+const stringLength = (value: string) => [...value].length
+
+const stringLimitAt = (path: string) => {
+ if (
+ /^\/(?:expressionOrder|animationOrder)\/\d+$/.test(path) ||
+ /^\/animations\/[^/]+\/steps\/\d+\/expression$/.test(path) ||
+ /^\/animations\/[^/]+\/metadata\/group$/.test(path)
+ ) {
+ return 64
+ }
+ if (path === '/name' || /^\/animations\/[^/]+\/metadata\/label$/.test(path)) return 120
+ return MAX_JSON_STRING_LENGTH
+}
+
+const schemaErrorPath = (error: ErrorObject) => {
+ const propertyName = (error as ErrorObject & { propertyName?: string }).propertyName
+ if (propertyName !== undefined) return childPointer(error.instancePath, propertyName)
+ if (error.keyword === 'required') {
+ return childPointer(error.instancePath, String(error.params.missingProperty))
+ }
+ if (error.keyword === 'additionalProperties') {
+ return childPointer(error.instancePath, String(error.params.additionalProperty))
+ }
+ if (error.keyword === 'propertyNames') {
+ return childPointer(error.instancePath, String(error.params.propertyName))
+ }
+ return error.instancePath
+}
+
+const schemaErrorCode = (error: ErrorObject) => {
+ if (error.instancePath === '/schemaVersion' && error.keyword === 'const') {
+ return 'unsupported_version'
+ }
+ return error.keyword
+}
+
+const schemaErrors = (errors: ErrorObject[] | null | undefined): AvatarDefinitionError[] =>
+ (errors ?? []).map(error => ({
+ path: schemaErrorPath(error),
+ code: schemaErrorCode(error),
+ message: error.message ?? 'Invalid avatar definition',
+ }))
+
+const inspectMaterializedValue = (value: unknown): AvatarDefinitionError[] => {
+ const errors: AvatarDefinitionError[] = []
+ const ancestors = new WeakSet()
+
+ const visit = (current: unknown, path: string) => {
+ if (typeof current === 'number' && !Number.isFinite(current)) {
+ errors.push({ path, code: 'non_finite_number', message: 'Number must be finite' })
+ return
+ }
+ if (current === null || typeof current !== 'object') return
+ if (ancestors.has(current)) {
+ errors.push({ path, code: 'cyclic_value', message: 'Avatar definition must not be cyclic' })
+ return
+ }
+ ancestors.add(current)
+ if (!Array.isArray(current)) {
+ const prototype = Object.getPrototypeOf(current)
+ if (prototype !== Object.prototype && prototype !== null) {
+ errors.push({ path, code: 'non_plain_object', message: 'Expected a plain object' })
+ ancestors.delete(current)
+ return
+ }
+ }
+ Object.entries(current).forEach(([key, child]) => visit(child, childPointer(path, key)))
+ ancestors.delete(current)
+ }
+
+ visit(value, '')
+ return errors
+}
+
+const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] => {
+ const errors: AvatarDefinitionError[] = []
+ const expressionKeys = Object.keys(definition.expressions)
+ const animationKeys = Object.keys(definition.animations)
+
+ const checkCompleteOrder = (
+ order: string[],
+ keys: string[],
+ path: '/expressionOrder' | '/animationOrder'
+ ) => {
+ const ordered = new Set(order)
+ keys.forEach(key => {
+ if (!ordered.has(key)) {
+ errors.push({
+ path,
+ code: 'incomplete_order',
+ message: `Order is missing key '${key}'`,
+ })
+ }
+ })
+ order.forEach((key, index) => {
+ if (!keys.includes(key)) {
+ errors.push({
+ path: childPointer(path, index),
+ code: 'unknown_order_key',
+ message: `Unknown key '${key}'`,
+ })
+ }
+ })
+ }
+
+ checkCompleteOrder(definition.expressionOrder, expressionKeys, '/expressionOrder')
+ checkCompleteOrder(definition.animationOrder, animationKeys, '/animationOrder')
+ if (definition.expressionOrder[0] !== 'neutral') {
+ errors.push({
+ path: '/expressionOrder/0',
+ code: 'neutral_not_first',
+ message: "'neutral' must be the first expression-order entry",
+ })
+ }
+ Object.entries(definition.animations).forEach(([animationKey, animation]) => {
+ if (animation.blink.minIntervalMs > animation.blink.maxIntervalMs) {
+ errors.push({
+ path: `/animations/${escapePointer(animationKey)}/blink/minIntervalMs`,
+ code: 'invalid_interval_range',
+ message: 'minIntervalMs must be less than or equal to maxIntervalMs',
+ })
+ }
+ animation.steps.forEach((step, index) => {
+ if (!(step.expression in definition.expressions)) {
+ errors.push({
+ path: `/animations/${escapePointer(animationKey)}/steps/${index}/expression`,
+ code: 'unknown_expression',
+ message: `Unknown expression '${step.expression}'`,
+ })
+ }
+ })
+ })
+ return errors
+}
+
+const cloneAndFreeze = (value: T): Readonly => {
+ if (value === null || typeof value !== 'object') return value
+ const clone: unknown = Array.isArray(value)
+ ? value.map(item => cloneAndFreeze(item))
+ : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneAndFreeze(item)]))
+ return Object.freeze(clone) as Readonly
+}
+
+export const validateAvatarDefinition = (value: unknown): ValidationResult => {
+ const structuralErrors = inspectMaterializedValue(value)
+ if (structuralErrors.length) return { ok: false, errors: structuralErrors }
+ if (!validateSchema(value)) return { ok: false, errors: schemaErrors(validateSchema.errors) }
+ const errors = semanticErrors(value)
+ return errors.length ? { ok: false, errors } : { ok: true, value: cloneAndFreeze(value) }
+}
+
+class JsonTextError extends Error {
+ constructor(
+ readonly path: string,
+ readonly code: string,
+ message: string
+ ) {
+ super(message)
+ }
+}
+
+class BoundedJsonParser {
+ private index = 0
+
+ constructor(private readonly source: string) {}
+
+ parse(): unknown {
+ this.skipWhitespace()
+ const value = this.parseValue('', 1)
+ this.skipWhitespace()
+ if (this.index !== this.source.length)
+ this.fail('', 'invalid_json', 'Unexpected trailing input')
+ return value
+ }
+
+ private fail(path: string, code: string, message: string): never {
+ throw new JsonTextError(path, code, `${message} at character ${this.index}`)
+ }
+
+ private skipWhitespace() {
+ while (
+ (this.source[this.index] === ' ' ||
+ this.source[this.index] === '\n' ||
+ this.source[this.index] === '\r' ||
+ this.source[this.index] === '\t') &&
+ this.index < this.source.length
+ ) {
+ this.index += 1
+ }
+ }
+
+ private parseValue(path: string, depth: number): unknown {
+ this.skipWhitespace()
+ const character = this.source[this.index]
+ if (character === '{' || character === '[') {
+ if (depth > AVATAR_DEFINITION_MAX_DEPTH) {
+ this.fail(path, 'max_depth', `JSON nesting depth exceeds ${AVATAR_DEFINITION_MAX_DEPTH}`)
+ }
+ return character === '{' ? this.parseObject(path, depth) : this.parseArray(path, depth)
+ }
+ if (character === '"') return this.parseString(path)
+ if (character === '-' || (character >= '0' && character <= '9')) return this.parseNumber(path)
+ if (this.source.startsWith('true', this.index)) return this.parseLiteral('true', true)
+ if (this.source.startsWith('false', this.index)) return this.parseLiteral('false', false)
+ if (this.source.startsWith('null', this.index)) return this.parseLiteral('null', null)
+ this.fail(path, 'invalid_json', 'Expected a JSON value')
+ }
+
+ private parseObject(path: string, depth: number) {
+ this.index += 1
+ this.skipWhitespace()
+ const result: Record = Object.create(null) as Record
+ const keys = new Set()
+ if (this.source[this.index] === '}') {
+ this.index += 1
+ return result
+ }
+ while (this.index < this.source.length) {
+ if (this.source[this.index] !== '"') this.fail(path, 'invalid_json', 'Expected an object key')
+ const key = this.parseString(path)
+ const keyPath = childPointer(path, key)
+ if ((path === '/expressions' || path === '/animations') && stringLength(key) > 64) {
+ this.fail(keyPath, 'string_too_long', 'Semantic key exceeds 64 characters')
+ }
+ if (keys.has(key)) this.fail(keyPath, 'duplicate_key', `Duplicate object member '${key}'`)
+ keys.add(key)
+ this.skipWhitespace()
+ if (this.source[this.index] !== ':') this.fail(keyPath, 'invalid_json', "Expected ':'")
+ this.index += 1
+ result[key] = this.parseValue(keyPath, depth + 1)
+ this.skipWhitespace()
+ const separator = this.source[this.index]
+ if (separator === '}') {
+ this.index += 1
+ return result
+ }
+ if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or '}'")
+ this.index += 1
+ this.skipWhitespace()
+ }
+ this.fail(path, 'invalid_json', 'Unterminated object')
+ }
+
+ private parseArray(path: string, depth: number) {
+ this.index += 1
+ this.skipWhitespace()
+ const result: unknown[] = []
+ if (this.source[this.index] === ']') {
+ this.index += 1
+ return result
+ }
+ while (this.index < this.source.length) {
+ result.push(this.parseValue(childPointer(path, result.length), depth + 1))
+ this.skipWhitespace()
+ const separator = this.source[this.index]
+ if (separator === ']') {
+ this.index += 1
+ return result
+ }
+ if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or ']'")
+ this.index += 1
+ }
+ this.fail(path, 'invalid_json', 'Unterminated array')
+ }
+
+ private parseString(path: string): string {
+ const start = this.index
+ this.index += 1
+ let escaped = false
+ while (this.index < this.source.length) {
+ const character = this.source[this.index]
+ if (!escaped && character === '"') {
+ this.index += 1
+ let value: string
+ try {
+ value = JSON.parse(this.source.slice(start, this.index)) as string
+ } catch {
+ this.fail(path, 'invalid_json', 'Invalid JSON string')
+ }
+ const limit = stringLimitAt(path)
+ if (stringLength(value) > limit) {
+ this.fail(path, 'string_too_long', `JSON string exceeds ${limit} characters`)
+ }
+ return value
+ }
+ if (!escaped && character.charCodeAt(0) < 0x20) {
+ this.fail(path, 'invalid_json', 'Unescaped control character')
+ }
+ if (!escaped && character === '\\') escaped = true
+ else escaped = false
+ this.index += 1
+ if (this.index - start > MAX_JSON_STRING_LENGTH * 12 + 2) {
+ this.fail(
+ path,
+ 'string_too_long',
+ `JSON string exceeds ${MAX_JSON_STRING_LENGTH} characters`
+ )
+ }
+ }
+ this.fail(path, 'invalid_json', 'Unterminated string')
+ }
+
+ private parseNumber(path: string): number {
+ const remaining = this.source.slice(this.index)
+ const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(remaining)
+ if (!match) this.fail(path, 'invalid_json', 'Invalid number')
+ this.index += match[0].length
+ const number = Number(match[0])
+ if (!Number.isFinite(number)) this.fail(path, 'non_finite_number', 'Number must be finite')
+ return number
+ }
+
+ private parseLiteral(source: string, value: T): T {
+ this.index += source.length
+ return value
+ }
+}
+
+export const parseAvatarDefinition = (text: string): ValidationResult => {
+ if (new TextEncoder().encode(text).byteLength > AVATAR_DEFINITION_MAX_BYTES) {
+ return {
+ ok: false,
+ errors: [
+ {
+ path: '',
+ code: 'max_bytes',
+ message: `JSON input exceeds ${AVATAR_DEFINITION_MAX_BYTES} UTF-8 bytes`,
+ },
+ ],
+ }
+ }
+ try {
+ return validateAvatarDefinition(new BoundedJsonParser(text).parse())
+ } catch (error) {
+ if (error instanceof JsonTextError) {
+ return {
+ ok: false,
+ errors: [{ path: error.path, code: error.code, message: error.message }],
+ }
+ }
+ return {
+ ok: false,
+ errors: [{ path: '', code: 'invalid_json', message: 'Invalid JSON input' }],
+ }
+ }
+}
+
+export const avatarDefinitionFileName = (name: string) => {
+ const base =
+ name
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '') || 'avatar'
+ return `${base}.avatar.json`
+}
diff --git a/packages/avatar-core/src/body.ts b/packages/avatar-core/src/body.ts
new file mode 100644
index 0000000..99db4c7
--- /dev/null
+++ b/packages/avatar-core/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/packages/avatar-core/src/geometry.ts b/packages/avatar-core/src/geometry.ts
new file mode 100644
index 0000000..28c107e
--- /dev/null
+++ b/packages/avatar-core/src/geometry.ts
@@ -0,0 +1,1252 @@
+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
+ semanticKey?: 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' | 'semanticKey' | '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[]
+ eyeOffset?: Readonly<{ x: number; y: number }>
+}
+
+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) =>
+ JSON.stringify([
+ surface.type,
+ surface.width,
+ surface.height,
+ surface.depth,
+ surface.roundness,
+ surface.morphRoundness,
+ surface.tipRoundness,
+ surface.baseRoundness,
+ ])
+
+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,
+ offset: Readonly<{ x: number; y: number }> = { x: 0, y: 0 }
+): 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}`] + offset.x
+ const centerY = expression[`positionY${suffix}`] + offset.y
+ 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, options.eyeOffset)
+ const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
+ 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/packages/avatar-core/src/index.ts b/packages/avatar-core/src/index.ts
new file mode 100644
index 0000000..4886d86
--- /dev/null
+++ b/packages/avatar-core/src/index.ts
@@ -0,0 +1,7 @@
+export * from './ambientMotion'
+export * from './avatarDefinition'
+export * from './body'
+export * from './geometry'
+export * from './runtime'
+export * from './scene'
+export * from './surfaces'
diff --git a/packages/avatar-core/src/runtime.ts b/packages/avatar-core/src/runtime.ts
new file mode 100644
index 0000000..eade0e1
--- /dev/null
+++ b/packages/avatar-core/src/runtime.ts
@@ -0,0 +1,266 @@
+import {
+ getStandardAnimationAvailabilityV1,
+ STANDARD_ANIMATIONS_V1,
+ type AnimationKey,
+ type AvatarAnimationDefinition,
+ type AvatarDefinition,
+ type AvatarExpressionDefinition,
+ type ExpressionKey,
+} from './avatarDefinition'
+import { applyAmbientMotion } from './ambientMotion'
+import { interpolatePose, poseFromExpression } from './geometry'
+import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
+
+export type AvatarRuntimeError = {
+ code: 'unknown_animation' | 'unavailable_standard_animation' | 'unknown_expression'
+ key: string
+ message: string
+}
+
+export type AvatarCommandResult =
+ { ok: true; value: T } | { ok: false; error: AvatarRuntimeError }
+
+export const resolveExpression = (
+ definition: Readonly,
+ key: ExpressionKey
+): AvatarCommandResult> => {
+ const expression = definition.expressions[key]
+ return expression
+ ? { ok: true, value: expression }
+ : {
+ ok: false,
+ error: { code: 'unknown_expression', key, message: `Unknown expression '${key}'` },
+ }
+}
+
+export const resolveAnimation = (
+ definition: Readonly,
+ key: AnimationKey
+): AvatarCommandResult> => {
+ const explicit = definition.animations[key]
+ if (explicit) return { ok: true, value: explicit }
+ if (!(key in STANDARD_ANIMATIONS_V1)) {
+ return {
+ ok: false,
+ error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
+ }
+ }
+ const availability = getStandardAnimationAvailabilityV1(definition.expressions)
+ const unavailable = availability.unavailable.find(item => item.key === key)
+ if (unavailable) {
+ return {
+ ok: false,
+ error: {
+ code: 'unavailable_standard_animation',
+ key,
+ message: `Animation '${key}' requires: ${unavailable.missingExpressions.join(', ')}`,
+ },
+ }
+ }
+ const { requiredExpressions: _requiredExpressions, ...animation } =
+ STANDARD_ANIMATIONS_V1[key as keyof typeof STANDARD_ANIMATIONS_V1]
+ return { ok: true, value: animation }
+}
+
+export type AvatarPlaybackState = {
+ activeAnimation?: AnimationKey
+ activeExpression: ExpressionKey
+ status: 'playing' | 'paused' | 'stopped'
+ stepIndex: number
+ direction: 1 | -1
+ phase: 'transition' | 'hold'
+ phaseStartedAt: number
+ transitionFrom: ExpressionKey
+ pausedAt?: number
+ blinkDueAt?: number
+ blinkStartedAt?: number
+}
+
+export type AvatarRuntimeEnvironment = {
+ random: () => number
+ reduceMotion?: boolean
+}
+
+export const createAvatarPlaybackState = (): AvatarPlaybackState => ({
+ activeExpression: 'neutral',
+ status: 'stopped',
+ stepIndex: 0,
+ direction: 1,
+ phase: 'transition',
+ phaseStartedAt: 0,
+ transitionFrom: 'neutral',
+})
+
+export const playAvatarAnimation = (
+ definition: Readonly,
+ key: AnimationKey,
+ now: number
+): AvatarCommandResult => {
+ const result = resolveAnimation(definition, key)
+ if (!result.ok) return result
+ return {
+ ok: true,
+ value: {
+ activeAnimation: key,
+ activeExpression: result.value.steps[0]?.expression ?? 'neutral',
+ status: 'playing',
+ stepIndex: 0,
+ direction: 1,
+ phase: 'transition',
+ phaseStartedAt: now,
+ transitionFrom: 'neutral',
+ blinkDueAt: now + result.value.blink.initialDelayMs,
+ },
+ }
+}
+
+const nextCursor = (
+ animation: Readonly,
+ state: AvatarPlaybackState
+): { stepIndex: number; direction: 1 | -1; complete: boolean } => {
+ const last = animation.steps.length - 1
+ if (state.stepIndex < last && state.direction === 1) {
+ return { stepIndex: state.stepIndex + 1, direction: state.direction, complete: false }
+ }
+ if (state.stepIndex > 0 && state.direction === -1) {
+ return { stepIndex: state.stepIndex - 1, direction: state.direction, complete: false }
+ }
+ if (animation.playbackMode === 'once') {
+ return { stepIndex: state.stepIndex, direction: state.direction, complete: true }
+ }
+ if (animation.playbackMode === 'pingPong' && last > 0) {
+ const direction = state.direction === 1 ? -1 : 1
+ return { stepIndex: state.stepIndex + direction, direction, complete: false }
+ }
+ return { stepIndex: 0, direction: 1 as const, complete: false }
+}
+
+export const advanceAvatarPlayback = (
+ definition: Readonly,
+ state: Readonly,
+ now: number,
+ environment: AvatarRuntimeEnvironment
+): AvatarPlaybackState => {
+ if (state.status !== 'playing' || !state.activeAnimation) return { ...state }
+ const resolved = resolveAnimation(definition, state.activeAnimation)
+ if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() }
+ const animation = resolved.value
+ let next = { ...state }
+ if (animation.blink.enabled && next.blinkDueAt !== undefined && now >= next.blinkDueAt) {
+ const startedAt = next.blinkDueAt
+ const interval =
+ animation.blink.minIntervalMs +
+ Math.max(0, Math.min(1, environment.random())) *
+ (animation.blink.maxIntervalMs - animation.blink.minIntervalMs)
+ next.blinkStartedAt = startedAt
+ next.blinkDueAt = startedAt + animation.blink.durationMs + interval
+ }
+ let safety = animation.steps.length * 4 + 4
+ while (safety-- > 0) {
+ const step = animation.steps[next.stepIndex]
+ const duration = next.phase === 'transition' ? step.transitionMs : step.holdMs
+ if (now < next.phaseStartedAt + duration) break
+ next.phaseStartedAt += duration
+ if (next.phase === 'transition') {
+ next.phase = 'hold'
+ next.activeExpression = step.expression
+ continue
+ }
+ const cursor = nextCursor(animation, next)
+ if (cursor.complete) {
+ next.status = 'stopped'
+ delete next.activeAnimation
+ break
+ }
+ next.stepIndex = cursor.stepIndex
+ next.direction = cursor.direction
+ next.phase = 'transition'
+ next.transitionFrom = next.activeExpression
+ next.activeExpression = animation.steps[cursor.stepIndex].expression
+ }
+ return next
+}
+
+export const pauseAvatarPlayback = (
+ state: Readonly,
+ now: number
+): AvatarPlaybackState =>
+ state.status === 'playing' ? { ...state, status: 'paused', pausedAt: now } : { ...state }
+
+export const resumeAvatarPlayback = (
+ state: Readonly,
+ now: number
+): AvatarPlaybackState => {
+ if (state.status !== 'paused' || state.pausedAt === undefined) return { ...state }
+ const pauseDuration = now - state.pausedAt
+ return {
+ ...state,
+ status: 'playing',
+ phaseStartedAt: state.phaseStartedAt + pauseDuration,
+ ...(state.blinkDueAt === undefined ? {} : { blinkDueAt: state.blinkDueAt + pauseDuration }),
+ ...(state.blinkStartedAt === undefined
+ ? {}
+ : { blinkStartedAt: state.blinkStartedAt + pauseDuration }),
+ pausedAt: undefined,
+ }
+}
+
+const easing = (
+ transition: AvatarAnimationDefinition['steps'][number]['transition'],
+ value: number
+) => {
+ const progress = Math.max(0, Math.min(1, value))
+ if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
+ if (transition === 'snappy') return 1 - (1 - progress) ** 3
+ const end = 1 - Math.exp(-6) * Math.cos(8)
+ return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
+}
+
+export const blinkOpacityAt = (
+ animation: Readonly,
+ state: Readonly,
+ now: number
+) => {
+ if (!animation.blink.enabled || state.blinkStartedAt === undefined) return 1
+ const progress = (now - state.blinkStartedAt) / animation.blink.durationMs
+ if (progress < 0 || progress >= 1) return 1
+ return Math.abs(progress * 2 - 1)
+}
+
+export const renderAvatarFrame = (
+ definition: Readonly,
+ state: Readonly,
+ now: number,
+ environment: AvatarRuntimeEnvironment
+): AvatarScene => {
+ const targetDefinition = definition.expressions[state.activeExpression]
+ if (!targetDefinition)
+ return renderAvatarExpression(
+ definition,
+ expressionFromDefinition('neutral', definition.expressions.neutral)
+ )
+ let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
+ let blink = 1
+ if (state.activeAnimation) {
+ const resolved = resolveAnimation(definition, state.activeAnimation)
+ if (resolved.ok) {
+ const step = resolved.value.steps[state.stepIndex]
+ if (state.phase === 'transition' && step && !environment.reduceMotion) {
+ const fromDefinition = definition.expressions[state.transitionFrom]
+ if (fromDefinition) {
+ const from = expressionFromDefinition(state.transitionFrom, fromDefinition)
+ const duration = Math.max(step.transitionMs, 1)
+ const progress = easing(step.transition, (now - state.phaseStartedAt) / duration)
+ expression = interpolatePose(
+ poseFromExpression(from),
+ poseFromExpression(expression),
+ progress
+ ).expression
+ }
+ }
+ blink = blinkOpacityAt(resolved.value, state, now)
+ }
+ }
+ if (!environment.reduceMotion) expression = applyAmbientMotion(expression, now)
+ return renderAvatarExpression(definition, expression, targetDefinition.colors, blink)
+}
diff --git a/packages/avatar-core/src/scene.ts b/packages/avatar-core/src/scene.ts
new file mode 100644
index 0000000..f1746c1
--- /dev/null
+++ b/packages/avatar-core/src/scene.ts
@@ -0,0 +1,79 @@
+import type {
+ AvatarBodyDefinition,
+ AvatarDefinition,
+ AvatarExpressionDefinition,
+ ExpressionKey,
+} from './avatarDefinition'
+import type { AvatarBody } from './body'
+import { poseFromExpression, renderAvatar, type AvatarGeometry, type Expression } from './geometry'
+
+export const expressionFromDefinition = (
+ key: ExpressionKey,
+ expression: AvatarExpressionDefinition
+): Expression => ({
+ id: key,
+ semanticKey: key,
+ headX: expression.head.x,
+ headY: expression.head.y,
+ headZ: expression.head.z,
+ widthLeft: expression.eyes.left.width,
+ widthRight: expression.eyes.right.width,
+ heightLeft: expression.eyes.left.height,
+ heightRight: expression.eyes.right.height,
+ spacing: expression.eyes.spacing,
+ positionXLeft: expression.eyes.left.x,
+ positionXRight: expression.eyes.right.x,
+ positionYLeft: expression.eyes.left.y,
+ positionYRight: expression.eyes.right.y,
+ leftAngle: expression.eyes.left.angle,
+ rightAngle: expression.eyes.right.angle,
+ perspective: expression.perspective,
+ eyeMotion: expression.motion.eyes,
+ bodyMotion: expression.motion.body,
+ ...(expression.colors?.body ? { bodyColor: expression.colors.body } : {}),
+ ...(expression.colors?.eyes ? { eyeColor: expression.colors.eyes } : {}),
+})
+
+export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
+ primary: { ...body.primary },
+ nodes: body.nodes.map((node, index) => ({
+ id: `runtime-node-${index}`,
+ name: `Runtime node ${index + 1}`,
+ surface: { ...node.surface },
+ position: [...node.position],
+ rotation: [...node.rotation],
+ })),
+})
+
+export type AvatarScene = {
+ geometry: AvatarGeometry
+ colors: { body: string; eyes: string }
+}
+
+export const renderAvatarExpression = (
+ definition: Readonly,
+ expression: Expression,
+ colors: { body?: string; eyes?: string } = {},
+ blink = 1
+): AvatarScene => {
+ const body = bodyFromDefinition(definition.body)
+ return {
+ geometry: renderAvatar(poseFromExpression(expression), body.primary, blink, {
+ bodyNodes: body.nodes,
+ }),
+ colors: {
+ body: colors.body ?? expression.bodyColor ?? definition.colors.body,
+ eyes: colors.eyes ?? expression.eyeColor ?? definition.colors.eyes,
+ },
+ }
+}
+
+export const renderAvatarDefinition = (
+ definition: Readonly,
+ expressionKey: ExpressionKey = 'neutral'
+): AvatarScene => {
+ const publicExpression = definition.expressions[expressionKey]
+ if (!publicExpression) throw new Error(`Unknown expression '${expressionKey}'`)
+ const expression = expressionFromDefinition(expressionKey, publicExpression)
+ return renderAvatarExpression(definition, expression, publicExpression.colors)
+}
diff --git a/packages/avatar-core/src/surfaces.ts b/packages/avatar-core/src/surfaces.ts
new file mode 100644
index 0000000..98295fd
--- /dev/null
+++ b/packages/avatar-core/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/packages/avatar-core/tsconfig.build.json b/packages/avatar-core/tsconfig.build.json
new file mode 100644
index 0000000..738a64e
--- /dev/null
+++ b/packages/avatar-core/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"]
+}
diff --git a/packages/avatar-core/tsconfig.json b/packages/avatar-core/tsconfig.json
new file mode 100644
index 0000000..10fca9b
--- /dev/null
+++ b/packages/avatar-core/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src", "vite.config.ts"],
+ "exclude": ["src/**/__tests__/**"]
+}
diff --git a/packages/avatar-core/vite.config.ts b/packages/avatar-core/vite.config.ts
new file mode 100644
index 0000000..aa73982
--- /dev/null
+++ b/packages/avatar-core/vite.config.ts
@@ -0,0 +1,35 @@
+import { fileURLToPath } from 'node:url'
+
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: {
+ index: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
+ geometry: fileURLToPath(new URL('./src/geometry.ts', import.meta.url)),
+ body: fileURLToPath(new URL('./src/body.ts', import.meta.url)),
+ surfaces: fileURLToPath(new URL('./src/surfaces.ts', import.meta.url)),
+ ambientMotion: fileURLToPath(new URL('./src/ambientMotion.ts', import.meta.url)),
+ },
+ formats: ['es'],
+ },
+ sourcemap: true,
+ rollupOptions: {
+ external: ['ajv/dist/2020.js'],
+ output: { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' },
+ },
+ },
+ plugins: [
+ {
+ name: 'copy-avatar-schema',
+ closeBundle: async () => {
+ const { copyFile } = await import('node:fs/promises')
+ await copyFile(
+ fileURLToPath(new URL('./src/avatarDefinition.schema.json', import.meta.url)),
+ fileURLToPath(new URL('./dist/avatarDefinition.schema.json', import.meta.url))
+ )
+ },
+ },
+ ],
+})
diff --git a/packages/avatar-react/LICENSE b/packages/avatar-react/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/packages/avatar-react/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+ .
diff --git a/packages/avatar-react/README.md b/packages/avatar-react/README.md
new file mode 100644
index 0000000..979223b
--- /dev/null
+++ b/packages/avatar-react/README.md
@@ -0,0 +1,98 @@
+# @bible-strong/avatar-react
+
+React 19 renderer for a validated Bible Strong `AvatarDefinition`. React and React DOM 19 are peer
+dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
+
+## Install
+
+```sh
+pnpm add @bible-strong/avatar-react react react-dom
+```
+
+Import the package stylesheet once in the application entry point:
+
+```tsx
+import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
+import type { AvatarDefinition } from '@bible-strong/avatar-core'
+import '@bible-strong/avatar-react/styles.css'
+import { useRef } from 'react'
+
+export function Assistant({ definition }: { definition: AvatarDefinition }) {
+ const avatar = useRef(null)
+ return (
+ <>
+
+ avatar.current?.play('happy')}>Play happy
+ avatar.current?.setExpression('neutral')}>Neutral
+ >
+ )
+}
+```
+
+`play` and `setExpression` return `{ ok: true }` or a typed error with one of
+`unknown_animation`, `unavailable_standard_animation`, `unknown_expression` or
+`controlled_by_props`. `pause` freezes the exact timeline position, calling `play` with the paused
+key resumes it, and `stop` returns an uncontrolled avatar to `neutral`.
+
+## Playback props
+
+- `animation` and `expression` are mutually exclusive controlled targets. They take priority over
+ defaults and imperative target changes.
+- `defaultAnimation` and `defaultExpression` initialize uncontrolled use. Animation autoplay is on
+ by default when `defaultAnimation` is present; set `autoplay={false}` to show its first expression
+ statically.
+- `onAnimationEnd` fires once when a `once` animation completes naturally.
+- `onExpressionChange` reports semantic expression changes.
+- `size`, `className`, `style` and `ariaLabel` customize layout without changing the definition.
+
+The definition is validated once per immutable object reference and revalidated/reinitialized when
+that reference changes.
+
+## Embedded and floating layout
+
+Embedded mode is the default and stays in the caller's layout:
+
+```tsx
+
+```
+
+Floating mode uses fixed positioning and portals to `document.body` after hydration. Supply
+`portalContainer` for a dedicated overlay root. Server rendering emits a neutral fixed-size
+placeholder before the portal handoff.
+
+```tsx
+
+```
+
+`initialPosition` accepts `{ x, y }` or top/right/bottom/left anchors. A controlled `position` wins
+over it. `constrainTo` accepts `none`, `viewport` or `parent`; floating defaults to `viewport`, while
+embedded defaults to `none`. A constrained embedded parent needs a definite rendered size.
+
+Dragging uses Pointer Events and direct transforms. `onPositionPreview` is limited to one callback
+per animation frame and `onPositionCommit` reports the final clamped point. `onDragStart` and
+`onDragEnd` bracket pointer movement. In controlled position mode callbacks report suggestions but
+the supplied position remains authoritative. When resized bounds invalidate a controlled position,
+`onPositionChange` reports the clamped suggestion. Resize re-clamping never emits a movement commit.
+
+When `draggable` is enabled, arrow keys move by 10 px, Shift+Arrow by 1 px, and Escape cancels an
+active pointer drag. Accessible directional and reset buttons provide the same movement without a
+pointer.
+
+## Styling hooks
+
+The stylesheet exposes `.bs-avatar`, `.bs-avatar--embedded`, `.bs-avatar--floating`,
+`.bs-avatar--draggable`, `.bs-avatar--dragging`, `.bs-avatar__svg` and
+`.bs-avatar__move-controls`. Consumer `className` and `style` are applied to the outer wrapper.
+
+The public component never exposes Studio IDs or document types. This package is private while
+copyright ownership, Apache-2.0 relicensing and repository metadata are confirmed. Local tarballs
+are for verification only and must not be published.
diff --git a/packages/avatar-react/package.json b/packages/avatar-react/package.json
new file mode 100644
index 0000000..99168a8
--- /dev/null
+++ b/packages/avatar-react/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@bible-strong/avatar-react",
+ "version": "0.1.0",
+ "description": "React 19 renderer for Bible Strong procedural avatars.",
+ "license": "AGPL-3.0-only",
+ "private": true,
+ "type": "module",
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./styles.css": "./dist/styles.css"
+ },
+ "scripts": {
+ "build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@bible-strong/avatar-core": "workspace:^"
+ },
+ "peerDependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
+ "typescript": "~6.0.3",
+ "vite": "^8.0.13"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+}
diff --git a/packages/avatar-react/src/Avatar.tsx b/packages/avatar-react/src/Avatar.tsx
new file mode 100644
index 0000000..1dda4cd
--- /dev/null
+++ b/packages/avatar-react/src/Avatar.tsx
@@ -0,0 +1,646 @@
+import {
+ advanceAvatarPlayback,
+ createAvatarPlaybackState,
+ playAvatarAnimation,
+ pauseAvatarPlayback,
+ renderAvatarDefinition,
+ renderAvatarFrame,
+ resolveAnimation,
+ resolveExpression,
+ resumeAvatarPlayback,
+ validateAvatarDefinition,
+ type AnimationKey,
+ type AvatarDefinition,
+ type AvatarPlaybackState as CorePlaybackState,
+ type AvatarRuntimeError as CoreRuntimeError,
+ type ExpressionKey,
+} from '@bible-strong/avatar-core'
+import {
+ useEffect,
+ useId,
+ useImperativeHandle,
+ useRef,
+ useState,
+ type CSSProperties,
+ type PointerEvent as ReactPointerEvent,
+ type ReactElement,
+ type Ref,
+} from 'react'
+import { createPortal } from 'react-dom'
+
+import './styles.css'
+
+const validatedDefinitions = new WeakSet()
+
+const assertValidDefinition = (definition: AvatarDefinition) => {
+ if (validatedDefinitions.has(definition)) return
+ const result = validateAvatarDefinition(definition)
+ if (!result.ok) {
+ throw new Error(`Invalid avatar definition: ${result.errors[0]?.message}`)
+ }
+ validatedDefinitions.add(definition)
+}
+
+export type AvatarRuntimeError =
+ | CoreRuntimeError
+ | {
+ code: 'controlled_by_props'
+ key: string
+ message: string
+ }
+
+export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
+
+export type AvatarPlaybackState = Pick<
+ CorePlaybackState,
+ 'activeAnimation' | 'activeExpression' | 'status'
+>
+
+export type AvatarController = {
+ play(animation: AnimationKey): AvatarCommandResult
+ setExpression(expression: ExpressionKey): AvatarCommandResult
+ pause(): void
+ stop(): void
+ getState(): AvatarPlaybackState
+}
+
+export type AvatarPosition = { x: number; y: number }
+export type FloatingInitialPosition =
+ AvatarPosition | { top?: number; right?: number; bottom?: number; left?: number }
+
+export type AvatarProps = {
+ definition: AvatarDefinition
+ ref?: Ref
+ animation?: AnimationKey
+ expression?: ExpressionKey
+ defaultAnimation?: AnimationKey
+ defaultExpression?: ExpressionKey
+ autoplay?: boolean
+ size?: number | string
+ className?: string
+ style?: CSSProperties
+ mode?: 'embedded' | 'floating'
+ portalContainer?: HTMLElement
+ draggable?: boolean
+ constrainTo?: 'none' | 'viewport' | 'parent'
+ position?: AvatarPosition
+ initialPosition?: FloatingInitialPosition
+ zIndex?: number
+ ariaLabel?: string
+ onPositionPreview?: (position: AvatarPosition) => void
+ onPositionCommit?: (position: AvatarPosition) => void
+ /** Suggested clamped position when controlled bounds change. */
+ onPositionChange?: (position: AvatarPosition) => void
+ onDragStart?: () => void
+ onDragEnd?: (position: AvatarPosition) => void
+ onAnimationEnd?: (animation: AnimationKey) => void
+ onExpressionChange?: (expression: ExpressionKey) => void
+}
+
+const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
+ left.activeAnimation === right.activeAnimation &&
+ left.activeExpression === right.activeExpression &&
+ left.status === right.status &&
+ left.stepIndex === right.stepIndex &&
+ left.direction === right.direction &&
+ left.phase === right.phase &&
+ left.phaseStartedAt === right.phaseStartedAt &&
+ left.transitionFrom === right.transitionFrom &&
+ left.blinkDueAt === right.blinkDueAt &&
+ left.blinkStartedAt === right.blinkStartedAt
+
+const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
+ left.x === right.x && left.y === right.y
+
+const sizeInPixels = (size: number | string | undefined) => (typeof size === 'number' ? size : 240)
+
+const initialPoint = (
+ value: FloatingInitialPosition | undefined,
+ width: number,
+ height: number
+): AvatarPosition => {
+ if (!value) return { x: 32, y: 32 }
+ if ('x' in value && 'y' in value) return { x: value.x, y: value.y }
+ const x = value.left ?? Math.max(0, width - (value.right ?? 32))
+ const y = value.top ?? Math.max(0, height - (value.bottom ?? 32))
+ return { x, y }
+}
+
+export function Avatar({
+ definition,
+ ref,
+ animation,
+ expression,
+ defaultAnimation,
+ defaultExpression,
+ autoplay,
+ size = 240,
+ className,
+ style,
+ mode = 'embedded',
+ portalContainer,
+ draggable = false,
+ constrainTo,
+ position,
+ initialPosition,
+ zIndex = 1000,
+ ariaLabel = 'Procedural avatar',
+ onPositionPreview,
+ onPositionCommit,
+ onPositionChange,
+ onDragStart,
+ onDragEnd,
+ onAnimationEnd,
+ onExpressionChange,
+}: AvatarProps): ReactElement {
+ if (animation !== undefined && expression !== undefined) {
+ throw new Error('Avatar accepts either animation or expression, not both.')
+ }
+ assertValidDefinition(definition)
+
+ const clipId = `${useId().replaceAll(':', '')}-head`
+ const wrapperRef = useRef(null)
+ const clipPathRef = useRef(null)
+ const headPathRef = useRef(null)
+ const leftPathRef = useRef(null)
+ const rightPathRef = useRef(null)
+ const backPathRefs = useRef<(SVGPathElement | null)[]>([])
+ const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
+ const defaultPlaybackStarted = useRef(false)
+ const floatingPositionInitialized = useRef(false)
+ const previewFrame = useRef(undefined)
+ const completedAnimation = useRef(undefined)
+ const playbackRef = useRef(createAvatarPlaybackState())
+ const drag = useRef<
+ | {
+ pointerId: number
+ pointer: AvatarPosition
+ origin: AvatarPosition
+ current: AvatarPosition
+ }
+ | undefined
+ >(undefined)
+ const [mounted, setMounted] = useState(false)
+ const [internalPosition, setInternalPosition] = useState({ x: 0, y: 0 })
+ const [playback, setPlayback] = useState(() => {
+ const key = animation ?? defaultAnimation
+ if (key) {
+ const result = resolveAnimation(definition, key)
+ if (result.ok) {
+ return {
+ ...createAvatarPlaybackState(),
+ activeExpression: result.value.steps[0]?.expression ?? 'neutral',
+ }
+ }
+ }
+ const keyExpression = expression ?? defaultExpression ?? 'neutral'
+ return { ...createAvatarPlaybackState(), activeExpression: keyExpression }
+ })
+ playbackRef.current = playback
+ const authoritativePosition = position ?? internalPosition
+ const effectiveConstraint = constrainTo ?? (mode === 'floating' ? 'viewport' : 'none')
+
+ const applyTransform = (point: AvatarPosition) => {
+ if (wrapperRef.current) {
+ wrapperRef.current.style.transform = `translate3d(${point.x}px, ${point.y}px, 0)`
+ }
+ }
+
+ const clampPosition = (point: AvatarPosition): AvatarPosition => {
+ const element = wrapperRef.current
+ if (!element || effectiveConstraint === 'none') return point
+ const width = element.offsetWidth
+ const height = element.offsetHeight
+ if (effectiveConstraint === 'viewport') {
+ return {
+ x: Math.min(Math.max(point.x, 0), Math.max(window.innerWidth - width, 0)),
+ y: Math.min(Math.max(point.y, 0), Math.max(window.innerHeight - height, 0)),
+ }
+ }
+ const parent = element.parentElement
+ if (!parent) return point
+ return {
+ x: Math.min(Math.max(point.x, 0), Math.max(parent.clientWidth - width, 0)),
+ y: Math.min(Math.max(point.y, 0), Math.max(parent.clientHeight - height, 0)),
+ }
+ }
+
+ const commitPosition = (next: AvatarPosition) => {
+ const clamped = clampPosition(next)
+ applyTransform(position ?? clamped)
+ if (!position) setInternalPosition(clamped)
+ onPositionCommit?.(clamped)
+ return clamped
+ }
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ useEffect(() => {
+ defaultPlaybackStarted.current = false
+ completedAnimation.current = undefined
+ const key = animation ?? defaultAnimation
+ if (key) {
+ const result = resolveAnimation(definition, key)
+ if (result.ok) {
+ const next = {
+ ...createAvatarPlaybackState(),
+ activeExpression: result.value.steps[0]?.expression ?? 'neutral',
+ }
+ playbackRef.current = next
+ setPlayback(next)
+ return
+ }
+ }
+ const keyExpression = expression ?? defaultExpression ?? 'neutral'
+ const resolved = resolveExpression(definition, keyExpression)
+ const next = {
+ ...createAvatarPlaybackState(),
+ activeExpression: resolved.ok ? keyExpression : 'neutral',
+ }
+ playbackRef.current = next
+ setPlayback(next)
+ }, [definition])
+
+ useEffect(() => {
+ if (
+ defaultPlaybackStarted.current ||
+ animation !== undefined ||
+ expression !== undefined ||
+ defaultAnimation === undefined ||
+ autoplay === false
+ ) {
+ return
+ }
+ defaultPlaybackStarted.current = true
+ const result = playAvatarAnimation(definition, defaultAnimation, performance.now())
+ if (result.ok) setPlayback(result.value)
+ }, [animation, autoplay, defaultAnimation, definition, expression])
+
+ useEffect(() => {
+ if (mode === 'floating') {
+ if (floatingPositionInitialized.current) {
+ applyTransform(position ?? internalPosition)
+ return
+ }
+ floatingPositionInitialized.current = true
+ const fallbackPixels = sizeInPixels(size)
+ const measuredWidth = wrapperRef.current?.offsetWidth || fallbackPixels
+ const measuredHeight = wrapperRef.current?.offsetHeight || fallbackPixels
+ const point = initialPoint(
+ initialPosition,
+ window.innerWidth - measuredWidth,
+ window.innerHeight - measuredHeight
+ )
+ const clamped = clampPosition(position ?? point)
+ if (!position) setInternalPosition(clamped)
+ applyTransform(position ?? clamped)
+ } else {
+ floatingPositionInitialized.current = false
+ applyTransform(position ?? internalPosition)
+ }
+ }, [mode, position, size, internalPosition])
+
+ useEffect(() => {
+ if (!mounted) return
+ const reClamp = () => {
+ const current = position ?? internalPosition
+ const clamped = clampPosition(current)
+ if (samePosition(current, clamped)) return
+ if (position) {
+ applyTransform(position)
+ onPositionChange?.(clamped)
+ return
+ }
+ applyTransform(clamped)
+ setInternalPosition(clamped)
+ }
+ const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(reClamp)
+ if (wrapperRef.current) observer?.observe(wrapperRef.current)
+ if (effectiveConstraint === 'parent' && wrapperRef.current?.parentElement && observer) {
+ observer.observe(wrapperRef.current.parentElement)
+ }
+ window.addEventListener('resize', reClamp)
+ return () => {
+ observer?.disconnect()
+ window.removeEventListener('resize', reClamp)
+ }
+ }, [mounted, position, internalPosition, effectiveConstraint, onPositionChange])
+
+ useEffect(() => {
+ if (expression !== undefined) {
+ const resolved = resolveExpression(definition, expression)
+ if (resolved.ok) {
+ setPlayback({ ...createAvatarPlaybackState(), activeExpression: expression })
+ }
+ return
+ }
+ if (animation !== undefined) {
+ const result = playAvatarAnimation(definition, animation, performance.now())
+ if (result.ok) setPlayback(result.value)
+ }
+ }, [animation, definition, expression])
+
+ useEffect(() => {
+ onExpressionChange?.(playback.activeExpression)
+ }, [playback.activeExpression, onExpressionChange])
+
+ useEffect(() => {
+ if (playback.status !== 'playing') return
+ let frame = 0
+ const tick = (now: number) => {
+ const current = playbackRef.current
+ const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ const next = advanceAvatarPlayback(definition, current, now, {
+ random: Math.random,
+ reduceMotion,
+ })
+ playbackRef.current = next
+ if (!samePlayback(current, next)) setPlayback(next)
+ if (
+ current.status === 'playing' &&
+ next.status === 'stopped' &&
+ current.activeAnimation &&
+ completedAnimation.current !== current.activeAnimation
+ ) {
+ completedAnimation.current = current.activeAnimation
+ onAnimationEnd?.(current.activeAnimation)
+ }
+ const frameScene = renderAvatarFrame(definition, next, now, {
+ random: Math.random,
+ reduceMotion,
+ })
+ headPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
+ clipPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
+ headPathRef.current?.setAttribute('fill', frameScene.colors.body)
+ leftPathRef.current?.setAttribute('d', frameScene.geometry.leftPath)
+ leftPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
+ leftPathRef.current?.setAttribute('opacity', frameScene.geometry.leftVisible ? '1' : '0')
+ rightPathRef.current?.setAttribute('d', frameScene.geometry.rightPath)
+ rightPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
+ rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
+ frameScene.geometry.backPaths.forEach((path, index) => {
+ backPathRefs.current[index]?.setAttribute('d', path)
+ backPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
+ })
+ frameScene.geometry.frontPaths.forEach((path, index) => {
+ frontPathRefs.current[index]?.setAttribute('d', path)
+ frontPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
+ })
+ if (next.status === 'playing') frame = requestAnimationFrame(tick)
+ }
+ frame = requestAnimationFrame(tick)
+ return () => cancelAnimationFrame(frame)
+ }, [definition, playback.status, onAnimationEnd])
+
+ const controlled = animation !== undefined || expression !== undefined
+ useImperativeHandle(ref, () => ({
+ play(key) {
+ if (controlled) {
+ return {
+ ok: false,
+ error: {
+ code: 'controlled_by_props',
+ key,
+ message: 'Playback is controlled by Avatar props.',
+ },
+ }
+ }
+ const current = playbackRef.current
+ if (
+ current.status === 'paused' &&
+ current.activeAnimation === key &&
+ current.pausedAt !== undefined
+ ) {
+ const now = performance.now()
+ const resumed = resumeAvatarPlayback(current, now)
+ playbackRef.current = resumed
+ setPlayback(resumed)
+ return { ok: true }
+ }
+ const result = playAvatarAnimation(definition, key, performance.now())
+ if (!result.ok) return { ok: false, error: result.error }
+ completedAnimation.current = undefined
+ playbackRef.current = result.value
+ setPlayback(result.value)
+ return { ok: true }
+ },
+ setExpression(key) {
+ if (controlled) {
+ return {
+ ok: false,
+ error: {
+ code: 'controlled_by_props',
+ key,
+ message: 'Expression is controlled by Avatar props.',
+ },
+ }
+ }
+ const result = resolveExpression(definition, key)
+ if (!result.ok) return { ok: false, error: result.error }
+ const next = { ...createAvatarPlaybackState(), activeExpression: key }
+ playbackRef.current = next
+ setPlayback(next)
+ return { ok: true }
+ },
+ pause() {
+ const current = playbackRef.current
+ if (current.status !== 'playing') return
+ const next = pauseAvatarPlayback(current, performance.now())
+ playbackRef.current = next
+ setPlayback(next)
+ },
+ stop() {
+ if (!controlled) {
+ const next = createAvatarPlaybackState()
+ playbackRef.current = next
+ setPlayback(next)
+ }
+ },
+ getState() {
+ const current = playbackRef.current
+ return {
+ ...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
+ activeExpression: current.activeExpression,
+ status: current.status,
+ }
+ },
+ }))
+
+ const startDrag = (event: ReactPointerEvent) => {
+ if (!draggable || event.button !== 0) return
+ event.currentTarget.setPointerCapture(event.pointerId)
+ drag.current = {
+ pointerId: event.pointerId,
+ pointer: { x: event.clientX, y: event.clientY },
+ origin: authoritativePosition,
+ current: authoritativePosition,
+ }
+ event.currentTarget.classList.add('bs-avatar--dragging')
+ onDragStart?.()
+ }
+
+ const moveDrag = (event: ReactPointerEvent) => {
+ const active = drag.current
+ if (!active || active.pointerId !== event.pointerId) return
+ const next = clampPosition({
+ x: active.origin.x + event.clientX - active.pointer.x,
+ y: active.origin.y + event.clientY - active.pointer.y,
+ })
+ active.current = next
+ applyTransform(next)
+ if (previewFrame.current === undefined) {
+ previewFrame.current = requestAnimationFrame(() => {
+ previewFrame.current = undefined
+ if (drag.current) onPositionPreview?.(drag.current.current)
+ })
+ }
+ }
+
+ const endDrag = (cancelled: boolean) => {
+ const active = drag.current
+ if (!active) return
+ if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
+ previewFrame.current = undefined
+ const finalPosition = commitPosition(cancelled ? active.origin : active.current)
+ wrapperRef.current?.classList.remove('bs-avatar--dragging')
+ drag.current = undefined
+ if (
+ wrapperRef.current?.hasPointerCapture?.(active.pointerId) &&
+ wrapperRef.current.releasePointerCapture
+ ) {
+ wrapperRef.current.releasePointerCapture(active.pointerId)
+ }
+ onDragEnd?.(finalPosition)
+ }
+
+ const moveByKeyboard = (x: number, y: number) => {
+ const next = commitPosition({ x: authoritativePosition.x + x, y: authoritativePosition.y + y })
+ onPositionPreview?.(next)
+ }
+
+ const scene = renderAvatarDefinition(
+ definition,
+ mode === 'floating' && !mounted ? 'neutral' : playback.activeExpression
+ )
+ const wrapper = (
+ endDrag(false)}
+ onPointerCancel={() => endDrag(true)}
+ onLostPointerCapture={() => endDrag(false)}
+ onKeyDown={event => {
+ if (!draggable) return
+ const amount = event.shiftKey ? 1 : 10
+ if (event.key === 'ArrowLeft') moveByKeyboard(-amount, 0)
+ else if (event.key === 'ArrowRight') moveByKeyboard(amount, 0)
+ else if (event.key === 'ArrowUp') moveByKeyboard(0, -amount)
+ else if (event.key === 'ArrowDown') moveByKeyboard(0, amount)
+ else if (event.key === 'Escape') endDrag(true)
+ else return
+ event.preventDefault()
+ }}
+ >
+
+
+
+
+
+
+ {scene.geometry.backPaths.map((path, index) => (
+ {
+ backPathRefs.current[index] = element
+ }}
+ d={path}
+ fill={scene.colors.body}
+ key={`back-${index}`}
+ />
+ ))}
+
+
+
+
+
+ {scene.geometry.frontPaths.map((path, index) => (
+ {
+ frontPathRefs.current[index] = element
+ }}
+ d={path}
+ fill={scene.colors.body}
+ key={`front-${index}`}
+ />
+ ))}
+
+ {draggable && (
+
+ {(
+ [
+ ['Move avatar left', -10, 0, '\u2190'],
+ ['Move avatar right', 10, 0, '\u2192'],
+ ['Move avatar up', 0, -10, '\u2191'],
+ ['Move avatar down', 0, 10, '\u2193'],
+ ] as const
+ ).map(([label, x, y, symbol]) => (
+ event.stopPropagation()}
+ onClick={() => moveByKeyboard(x, y)}
+ key={label}
+ >
+ {symbol}
+
+ ))}
+ event.stopPropagation()}
+ onClick={() => commitPosition({ x: 0, y: 0 })}
+ >
+ \u21ba
+
+
+ )}
+
+ )
+
+ if (mode === 'floating' && mounted) {
+ return createPortal(wrapper, portalContainer ?? document.body)
+ }
+ return wrapper
+}
diff --git a/packages/avatar-react/src/__tests__/avatar-test.tsx b/packages/avatar-react/src/__tests__/avatar-test.tsx
new file mode 100644
index 0000000..b2ffbcd
--- /dev/null
+++ b/packages/avatar-react/src/__tests__/avatar-test.tsx
@@ -0,0 +1,538 @@
+// @vitest-environment jsdom
+
+import { renderAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
+import { act, createRef, Profiler, StrictMode } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { renderToString } from 'react-dom/server'
+import { fireEvent, render } from '@testing-library/react'
+import { vi } from 'vitest'
+
+import { Avatar, type AvatarController } from '../Avatar'
+
+let resizeCallbacks: ResizeObserverCallback[] = []
+
+class TestResizeObserver implements ResizeObserver {
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallbacks.push(callback)
+ }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+
+const expression = {
+ head: { x: 0, y: 0, z: 0 },
+ eyes: {
+ left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
+ right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
+ spacing: 54,
+ },
+ perspective: 1,
+ motion: { eyes: 'none', body: 'none' },
+} as const
+
+const definition: AvatarDefinition = {
+ schema: 'bible-strong/avatar-definition',
+ schemaVersion: 1,
+ name: 'React fixture',
+ body: {
+ primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
+ nodes: [],
+ },
+ colors: { body: '#5b7fe5', eyes: '#111316' },
+ expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } },
+ expressionOrder: ['neutral', 'smile'],
+ animations: {
+ greet: {
+ playbackMode: 'loop',
+ steps: [{ expression: 'smile', holdMs: 1_000, transitionMs: 100, transition: 'smooth' }],
+ blink: {
+ enabled: false,
+ initialDelayMs: 0,
+ minIntervalMs: 1_000,
+ maxIntervalMs: 1_000,
+ durationMs: 100,
+ },
+ },
+ 'wave-once': {
+ playbackMode: 'once',
+ steps: [{ expression: 'smile', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
+ blink: {
+ enabled: false,
+ initialDelayMs: 0,
+ minIntervalMs: 1_000,
+ maxIntervalMs: 1_000,
+ durationMs: 100,
+ },
+ },
+ },
+ animationOrder: ['greet', 'wave-once'],
+ standardAnimationSet: 1,
+}
+
+beforeAll(() => {
+ HTMLElement.prototype.setPointerCapture = () => undefined
+ HTMLElement.prototype.hasPointerCapture = () => true
+ HTMLElement.prototype.releasePointerCapture = () => undefined
+ window.matchMedia = () =>
+ ({
+ matches: false,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ }) as unknown as MediaQueryList
+ globalThis.ResizeObserver = TestResizeObserver
+})
+
+beforeEach(() => {
+ resizeCallbacks = []
+ Object.defineProperties(window, {
+ innerWidth: { configurable: true, value: 1_024 },
+ innerHeight: { configurable: true, value: 768 },
+ })
+})
+
+describe('@bible-strong/avatar-react', () => {
+ it('renders semantic SVG geometry in embedded mode', () => {
+ const view = render( )
+ const avatar = view.getByRole('img', { name: 'Assistant avatar' })
+ expect(avatar.classList.contains('bs-avatar--embedded')).toBe(true)
+ expect(avatar.querySelector('svg path')).not.toBeNull()
+ expect(document.body.querySelector('.bs-avatar--floating')).toBeNull()
+ })
+
+ it('exposes semantic imperative controls without Studio identifiers', () => {
+ const controller = createRef()
+ render( )
+
+ let result: ReturnType | undefined
+ act(() => {
+ result = controller.current?.setExpression('smile')
+ })
+ expect(result).toEqual({ ok: true })
+ expect(controller.current?.getState()).toMatchObject({
+ activeExpression: 'smile',
+ status: 'stopped',
+ })
+ expect(controller.current?.play('missing')).toMatchObject({
+ ok: false,
+ error: { code: 'unknown_animation', key: 'missing' },
+ })
+ act(() => {
+ result = controller.current?.play('greet')
+ })
+ expect(result).toEqual({ ok: true })
+ expect(controller.current?.getState()).toMatchObject({
+ activeAnimation: 'greet',
+ activeExpression: 'smile',
+ status: 'playing',
+ })
+ })
+
+ it('rejects imperative target changes when playback is controlled by props', () => {
+ const controller = createRef()
+ render( )
+ expect(controller.current?.setExpression('smile')).toMatchObject({
+ ok: false,
+ error: { code: 'controlled_by_props' },
+ })
+ })
+
+ it('rejects simultaneous controlled animation and expression props', () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ expect(() =>
+ render( )
+ ).toThrow('Avatar accepts either animation or expression, not both.')
+ errors.mockRestore()
+ })
+
+ it('honors uncontrolled defaults without autoplay when requested', () => {
+ const controller = createRef()
+ render(
+
+ )
+ expect(controller.current?.getState()).toEqual({
+ activeExpression: 'smile',
+ status: 'stopped',
+ })
+ })
+
+ it('portals floating mode to the document body after mount', () => {
+ const host = document.createElement('section')
+ document.body.append(host)
+ const view = render( , { container: host })
+ expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
+ view.unmount()
+ host.remove()
+ })
+
+ it('normalizes an uncontrolled floating initial position only once', () => {
+ const view = render(
+
+ )
+ expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
+ view.rerender(
+
+ )
+ expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
+ })
+
+ it('server-renders a neutral floating placeholder before portal handoff', () => {
+ const markup = renderToString(
+
+ )
+ const neutral = renderAvatarDefinition(definition, 'neutral')
+ expect(markup).toContain('bs-avatar--floating')
+ expect(markup).toContain('width:180px;height:180px')
+ expect(markup).toContain(`d="${neutral.geometry.leftPath}"`)
+ })
+
+ it('hydrates the floating placeholder before moving it to a portal without warnings', async () => {
+ const host = document.createElement('section')
+ const markup = renderToString( )
+ host.innerHTML = markup
+ document.body.append(host)
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ let root: ReturnType | undefined
+ await act(async () => {
+ root = hydrateRoot(host, )
+ await Promise.resolve()
+ })
+ expect(errors).not.toHaveBeenCalled()
+ expect(host.querySelector('.bs-avatar--floating')).toBeNull()
+ expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
+ act(() => root?.unmount())
+ errors.mockRestore()
+ host.remove()
+ })
+
+ it('moves by pointer without React state updates on every pointer move and commits once', () => {
+ const previews: { x: number; y: number }[] = []
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ previews.push(point)}
+ onPositionCommit={point => commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ fireEvent.pointerDown(avatar, { pointerId: 1, button: 0, clientX: 10, clientY: 10 })
+ fireEvent.pointerMove(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
+ expect(avatar.style.transform).toBe('translate3d(25px, 35px, 0)')
+ fireEvent.pointerUp(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
+ expect(commits).toEqual([{ x: 25, y: 35 }])
+ expect(previews.length).toBeLessThanOrEqual(1)
+ })
+
+ it('limits drag previews to one callback per animation frame with the latest position', () => {
+ let nextFrame = 0
+ const frames = new Map()
+ const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
+ frames.set(++nextFrame, callback)
+ return nextFrame
+ })
+ const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
+ frames.delete(id)
+ })
+ const previews: { x: number; y: number }[] = []
+ const view = render(
+ previews.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ fireEvent.pointerDown(avatar, { pointerId: 6, button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 10, clientY: 12 })
+ fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 20, clientY: 24 })
+ expect(previews).toEqual([])
+ act(() => {
+ const callbacks = [...frames.values()]
+ frames.clear()
+ callbacks.forEach(callback => callback(16))
+ })
+ expect(previews).toEqual([{ x: 20, y: 24 }])
+ fireEvent.pointerUp(avatar, { pointerId: 6 })
+ request.mockRestore()
+ cancel.mockRestore()
+ })
+
+ it('does not render React once per pointer movement', () => {
+ let renders = 0
+ const view = render(
+ renders++}>
+
+
+ )
+ const avatar = view.getByRole('group')
+ const beforeMoves = renders
+ fireEvent.pointerDown(avatar, { pointerId: 2, button: 0, clientX: 0, clientY: 0 })
+ for (let index = 1; index <= 20; index++) {
+ fireEvent.pointerMove(avatar, { pointerId: 2, clientX: index, clientY: index })
+ }
+ expect(renders).toBe(beforeMoves)
+ })
+
+ it('restores the drag origin on pointer cancel', () => {
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ fireEvent.pointerDown(avatar, { pointerId: 3, button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerMove(avatar, { pointerId: 3, clientX: 40, clientY: 50 })
+ fireEvent.pointerCancel(avatar, { pointerId: 3 })
+ expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
+ expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
+ })
+
+ it('restores the drag origin when Escape cancels an active drag', () => {
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ fireEvent.pointerDown(avatar, { pointerId: 7, button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerMove(avatar, { pointerId: 7, clientX: 30, clientY: 40 })
+ fireEvent.keyDown(avatar, { key: 'Escape' })
+ expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
+ expect(commits).toEqual([{ x: 0, y: 0 }])
+ })
+
+ it('clamps pointer movement to an embedded parent', () => {
+ const view = render( )
+ const avatar = view.getByRole('group')
+ Object.defineProperties(avatar, {
+ offsetWidth: { configurable: true, value: 40 },
+ offsetHeight: { configurable: true, value: 40 },
+ })
+ Object.defineProperties(avatar.parentElement!, {
+ clientWidth: { configurable: true, value: 100 },
+ clientHeight: { configurable: true, value: 100 },
+ })
+ fireEvent.pointerDown(avatar, { pointerId: 4, button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerMove(avatar, { pointerId: 4, clientX: 200, clientY: 200 })
+ fireEvent.pointerUp(avatar, { pointerId: 4 })
+ expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
+ })
+
+ it('keeps controlled position authoritative while reporting a constrained commit', () => {
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ Object.defineProperties(avatar, {
+ offsetWidth: { configurable: true, value: 40 },
+ offsetHeight: { configurable: true, value: 40 },
+ })
+ Object.defineProperties(avatar.parentElement!, {
+ clientWidth: { configurable: true, value: 100 },
+ clientHeight: { configurable: true, value: 100 },
+ })
+ fireEvent.pointerDown(avatar, { pointerId: 5, button: 0, clientX: 0, clientY: 0 })
+ fireEvent.pointerMove(avatar, { pointerId: 5, clientX: 200, clientY: 200 })
+ fireEvent.pointerUp(avatar, { pointerId: 5 })
+ expect(commits).toEqual([{ x: 60, y: 60 }])
+ expect(avatar.style.transform).toBe('translate3d(10px, 12px, 0)')
+ })
+
+ it('re-clamps on resize without emitting a movement commit', () => {
+ const changes: { x: number; y: number }[] = []
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ changes.push(point)}
+ onPositionCommit={point => commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ Object.defineProperties(avatar, {
+ offsetWidth: { configurable: true, value: 40 },
+ offsetHeight: { configurable: true, value: 40 },
+ })
+ Object.defineProperties(window, {
+ innerWidth: { configurable: true, value: 100 },
+ innerHeight: { configurable: true, value: 100 },
+ })
+ act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
+ expect(changes.at(-1)).toEqual({ x: 60, y: 60 })
+ expect(commits).toEqual([])
+ expect(avatar.style.transform).toBe('translate3d(90px, 90px, 0)')
+ })
+
+ it('re-clamps an uncontrolled floating position without emitting a movement commit', () => {
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ Object.defineProperties(avatar, {
+ offsetWidth: { configurable: true, value: 40 },
+ offsetHeight: { configurable: true, value: 40 },
+ })
+ Object.defineProperties(window, {
+ innerWidth: { configurable: true, value: 100 },
+ innerHeight: { configurable: true, value: 100 },
+ })
+ act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
+ expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
+ expect(commits).toEqual([])
+ })
+
+ it('re-clamps to the viewport on window resize without ResizeObserver support', () => {
+ const originalObserver = globalThis.ResizeObserver
+ // @ts-expect-error This test covers browsers without ResizeObserver.
+ globalThis.ResizeObserver = undefined
+ const view = render(
+
+ )
+ const avatar = view.getByRole('group')
+ Object.defineProperties(avatar, {
+ offsetWidth: { configurable: true, value: 40 },
+ offsetHeight: { configurable: true, value: 40 },
+ })
+ Object.defineProperties(window, {
+ innerWidth: { configurable: true, value: 100 },
+ innerHeight: { configurable: true, value: 100 },
+ })
+ act(() => window.dispatchEvent(new Event('resize')))
+ expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
+ globalThis.ResizeObserver = originalObserver
+ })
+
+ it('provides keyboard movement and an accessible reset control', () => {
+ const commits: { x: number; y: number }[] = []
+ const view = render(
+ commits.push(point)}
+ />
+ )
+ const avatar = view.getByRole('group')
+ fireEvent.keyDown(avatar, { key: 'ArrowRight' })
+ expect(commits.at(-1)).toEqual({ x: 10, y: 0 })
+ act(() => view.getByRole('button', { name: 'Move avatar down' }).click())
+ expect(commits.at(-1)).toEqual({ x: 10, y: 10 })
+ act(() => view.getByRole('button', { name: 'Reset avatar position' }).click())
+ expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
+ })
+
+ it('resumes the current paused animation instead of restarting it', () => {
+ const controller = createRef()
+ const clock = vi.spyOn(performance, 'now')
+ clock.mockReturnValueOnce(100).mockReturnValueOnce(250).mockReturnValueOnce(1_250)
+ render( )
+ act(() => {
+ controller.current?.play('greet')
+ controller.current?.pause()
+ controller.current?.play('greet')
+ })
+ expect(controller.current?.getState()).toMatchObject({
+ activeAnimation: 'greet',
+ activeExpression: 'smile',
+ status: 'playing',
+ })
+ clock.mockRestore()
+ })
+
+ it('updates SVG frames without rendering React once per animation frame', () => {
+ let nextFrame = 0
+ const frames = new Map()
+ const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
+ frames.set(++nextFrame, callback)
+ return nextFrame
+ })
+ const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
+ frames.delete(id)
+ })
+ const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
+ let renders = 0
+ render(
+ renders++}>
+
+
+ )
+ const beforeFrames = renders
+ act(() => {
+ for (let index = 1; index <= 20; index++) {
+ const callback = [...frames.values()].at(-1)
+ frames.clear()
+ callback?.(1_000 + index)
+ }
+ })
+ expect(renders).toBe(beforeFrames)
+ clock.mockRestore()
+ request.mockRestore()
+ cancel.mockRestore()
+ })
+
+ it('fires once-completion exactly once under Strict Mode', () => {
+ let nextFrame = 0
+ const frames = new Map()
+ const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
+ frames.set(++nextFrame, callback)
+ return nextFrame
+ })
+ const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
+ frames.delete(id)
+ })
+ const ended = vi.fn()
+ render(
+
+
+
+ )
+ act(() => {
+ const callbacks = [...frames.values()]
+ frames.clear()
+ callbacks.forEach(callback => callback(performance.now() + 1_000))
+ })
+ expect(ended).toHaveBeenCalledTimes(1)
+ expect(ended).toHaveBeenCalledWith('wave-once')
+ request.mockRestore()
+ cancel.mockRestore()
+ })
+})
diff --git a/packages/avatar-react/src/index.ts b/packages/avatar-react/src/index.ts
new file mode 100644
index 0000000..095d20e
--- /dev/null
+++ b/packages/avatar-react/src/index.ts
@@ -0,0 +1,10 @@
+export { Avatar } from './Avatar'
+export type {
+ AvatarCommandResult,
+ AvatarController,
+ AvatarPlaybackState,
+ AvatarPosition,
+ AvatarProps,
+ AvatarRuntimeError,
+ FloatingInitialPosition,
+} from './Avatar'
diff --git a/packages/avatar-react/src/styles.css b/packages/avatar-react/src/styles.css
new file mode 100644
index 0000000..86caa5f
--- /dev/null
+++ b/packages/avatar-react/src/styles.css
@@ -0,0 +1,98 @@
+.bs-avatar {
+ box-sizing: border-box;
+ display: inline-grid;
+ place-items: center;
+ max-width: 100%;
+ aspect-ratio: 1;
+ outline: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.bs-avatar--embedded {
+ position: relative;
+}
+
+.bs-avatar--floating {
+ position: fixed;
+ inset: 0 auto auto 0;
+}
+
+.bs-avatar--draggable {
+ cursor: grab;
+ touch-action: none;
+}
+
+.bs-avatar--dragging {
+ cursor: grabbing;
+}
+
+.bs-avatar:focus-visible {
+ outline: 3px solid currentColor;
+ outline-offset: 3px;
+}
+
+.bs-avatar__svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.bs-avatar__move-controls {
+ position: absolute;
+ inset: 0 0 auto auto;
+ display: grid;
+ grid-template-columns: repeat(3, 44px);
+ opacity: 0;
+}
+
+.bs-avatar__move-controls button {
+ width: 44px;
+ height: 44px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: currentColor;
+ cursor: pointer;
+}
+
+.bs-avatar__move-controls button:nth-child(1) {
+ grid-column: 1;
+ grid-row: 2;
+}
+
+.bs-avatar__move-controls button:nth-child(2) {
+ grid-column: 3;
+ grid-row: 2;
+}
+
+.bs-avatar__move-controls button:nth-child(3) {
+ grid-column: 2;
+ grid-row: 1;
+}
+
+.bs-avatar__move-controls button:nth-child(4) {
+ grid-column: 2;
+ grid-row: 3;
+}
+
+.bs-avatar__reset {
+ grid-column: 2;
+ grid-row: 2;
+}
+
+.bs-avatar:hover .bs-avatar__move-controls,
+.bs-avatar:focus-within .bs-avatar__move-controls,
+.bs-avatar__move-controls:focus-within {
+ opacity: 0.75;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bs-avatar,
+ .bs-avatar * {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/packages/avatar-react/src/styles.d.ts b/packages/avatar-react/src/styles.d.ts
new file mode 100644
index 0000000..5894ae0
--- /dev/null
+++ b/packages/avatar-react/src/styles.d.ts
@@ -0,0 +1 @@
+declare module '*.css'
diff --git a/packages/avatar-react/tsconfig.build.json b/packages/avatar-react/tsconfig.build.json
new file mode 100644
index 0000000..738a64e
--- /dev/null
+++ b/packages/avatar-react/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"]
+}
diff --git a/packages/avatar-react/tsconfig.json b/packages/avatar-react/tsconfig.json
new file mode 100644
index 0000000..586a83e
--- /dev/null
+++ b/packages/avatar-react/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src", "vite.config.ts"],
+ "exclude": ["src/**/__tests__/**"]
+}
diff --git a/packages/avatar-react/vite.config.ts b/packages/avatar-react/vite.config.ts
new file mode 100644
index 0000000..f758b9e
--- /dev/null
+++ b/packages/avatar-react/vite.config.ts
@@ -0,0 +1,18 @@
+import { fileURLToPath } from 'node:url'
+
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
+ formats: ['es'],
+ fileName: 'index',
+ cssFileName: 'styles',
+ },
+ sourcemap: true,
+ rollupOptions: {
+ external: ['react', 'react-dom', 'react/jsx-runtime', '@bible-strong/avatar-core'],
+ },
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c26efc..de38c0e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,12 +11,21 @@ importers:
'@base-ui/react':
specifier: ^1.7.0
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@bible-strong/avatar-core':
+ specifier: workspace:*
+ version: link:packages/avatar-core
+ '@bible-strong/avatar-react':
+ specifier: workspace:*
+ version: link:packages/avatar-react
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(react@19.2.3)
'@vercel/speed-insights':
specifier: ^2.0.0
version: 2.0.0(react@19.2.3)
+ ajv:
+ specifier: ^8.20.0
+ version: 8.20.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -54,6 +63,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@types/node':
specifier: ^24.10.1
version: 24.13.3
@@ -69,6 +81,9 @@ importers:
babel-plugin-react-compiler:
specifier: ^1.0.0
version: 1.0.0
+ jsdom:
+ specifier: ^30.0.1
+ version: 30.0.1
prettier:
specifier: ^3.8.1
version: 3.9.6
@@ -86,10 +101,87 @@ importers:
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
vitest:
specifier: ^4.1.10
- version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
+ version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
+
+ examples/react-vite-consumer:
+ dependencies:
+ '@bible-strong/avatar-core':
+ specifier: workspace:*
+ version: link:../../packages/avatar-core
+ '@bible-strong/avatar-react':
+ specifier: workspace:*
+ version: link:../../packages/avatar-react
+ '@vitejs/plugin-react':
+ specifier: ^6.0.2
+ version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
+ react:
+ specifier: 19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: 19.2.3
+ version: 19.2.3(react@19.2.3)
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^8.0.13
+ version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.18
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.4(@types/react@19.2.18)
+
+ packages/avatar-core:
+ dependencies:
+ ajv:
+ specifier: ^8.20.0
+ version: 8.20.0
+ devDependencies:
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^8.0.13
+ version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+
+ packages/avatar-react:
+ dependencies:
+ '@bible-strong/avatar-core':
+ specifier: workspace:^
+ version: link:../avatar-core
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.14
+ version: 19.2.18
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.4(@types/react@19.2.18)
+ react:
+ specifier: 19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: 19.2.3
+ version: 19.2.3(react@19.2.3)
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^8.0.13
+ version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
packages:
+ '@asamuzakjp/css-color@6.0.7':
+ resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
+ engines: {node: ^22.13.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
+ engines: {node: ^22.13.0 || >=24.0.0}
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -188,6 +280,55 @@ packages:
'@types/react':
optional: true
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.1.0':
+ resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.3.0':
+ resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.10':
+ resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.7':
+ resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@@ -429,6 +570,28 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -546,6 +709,20 @@ packages:
'@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -558,6 +735,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
browserslist@4.28.8:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -580,9 +760,17 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -592,10 +780,20 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
electron-to-chromium@1.5.405:
resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==}
@@ -603,6 +801,10 @@ packages:
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
engines: {node: '>=10.13.0'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
es-module-lexer@2.3.1:
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
@@ -617,6 +819,12 @@ packages:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
+ fast-uri@3.1.5:
+ resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -649,6 +857,13 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -656,11 +871,23 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ jsdom@30.0.1:
+ resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
+ engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
+ peerDependencies:
+ canvas: ^3.2.3
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -814,6 +1041,10 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -822,9 +1053,16 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
motion-dom@13.0.0:
resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==}
@@ -858,6 +1096,9 @@ packages:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -877,11 +1118,22 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-resizable-panels@4.12.2:
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
peerDependencies:
@@ -892,6 +1144,10 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
@@ -900,6 +1156,10 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -920,6 +1180,9 @@ packages:
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -945,6 +1208,21 @@ packages:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
+ tldts-core@7.4.10:
+ resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
+
+ tldts@7.4.10:
+ resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
+ hasBin: true
+
+ tough-cookie@6.0.2:
+ resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -956,6 +1234,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+ undici@8.10.0:
+ resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
+ engines: {node: '>=22.19.0'}
+
update-browserslist-db@1.3.1:
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
hasBin: true
@@ -1051,16 +1333,58 @@ packages:
jsdom:
optional: true
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ whatwg-url@17.1.0:
+ resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
+ engines: {node: ^22.14.0 || >=24.0.0}
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
snapshots:
+ '@asamuzakjp/css-color@6.0.7':
+ dependencies:
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.5.2
+
+ '@asamuzakjp/dom-selector@8.3.2':
+ dependencies:
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -1186,6 +1510,36 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.1.0': {}
+
+ '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.1.0
+ '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
+ '@exodus/bytes@1.15.1': {}
+
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
@@ -1347,6 +1701,29 @@ snapshots:
tailwindcss: 4.3.3
vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.18
+ '@types/react-dom': 19.2.4(@types/react@19.2.18)
+
+ '@types/aria-query@5.0.4': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -1425,6 +1802,21 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.1
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.5
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@5.2.0: {}
+
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
assertion-error@2.0.1: {}
babel-plugin-react-compiler@1.0.0:
@@ -1433,6 +1825,10 @@ snapshots:
baseline-browser-mapping@2.11.13: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
browserslist@4.28.8:
dependencies:
baseline-browser-mapping: 2.11.13
@@ -1453,14 +1849,32 @@ snapshots:
convert-source-map@2.0.0: {}
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
csstype@3.2.3: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
debug@4.4.3:
dependencies:
ms: 2.1.3
+ decimal.js@10.6.0: {}
+
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
+ dom-accessibility-api@0.5.16: {}
+
electron-to-chromium@1.5.405: {}
enhanced-resolve@5.24.5:
@@ -1468,6 +1882,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
+ entities@8.0.0: {}
+
es-module-lexer@2.3.1: {}
escalade@3.2.0: {}
@@ -1478,6 +1894,10 @@ snapshots:
expect-type@1.4.0: {}
+ fast-deep-equal@3.1.3: {}
+
+ fast-uri@3.1.5: {}
+
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@@ -1498,12 +1918,48 @@ snapshots:
graceful-fs@4.2.11: {}
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ is-potential-custom-element-name@1.0.1: {}
+
jiti@2.7.0: {}
js-tokens@4.0.0: {}
+ jsdom@30.0.1:
+ dependencies:
+ '@asamuzakjp/css-color': 6.0.7
+ '@asamuzakjp/dom-selector': 8.3.2
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.1
+ css-tree: 3.2.1
+ data-urls: 7.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.5.2
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.2
+ undici: 8.10.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 17.1.0
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
jsesc@3.1.0: {}
+ json-schema-traverse@1.0.0: {}
+
json5@2.2.3: {}
lightningcss-android-arm64@1.32.0:
@@ -1604,6 +2060,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
+ lru-cache@11.5.2: {}
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -1612,10 +2070,14 @@ snapshots:
dependencies:
react: 19.2.3
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ mdn-data@2.27.1: {}
+
motion-dom@13.0.0:
dependencies:
motion-utils: 13.0.0
@@ -1638,6 +2100,10 @@ snapshots:
obug@2.1.4: {}
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -1652,11 +2118,21 @@ snapshots:
prettier@3.9.6: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
+ punycode@2.3.1: {}
+
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
+ react-is@17.0.2: {}
+
react-resizable-panels@4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
@@ -1664,6 +2140,8 @@ snapshots:
react@19.2.3: {}
+ require-from-string@2.0.2: {}
+
reselect@5.2.0: {}
rolldown@1.2.4:
@@ -1686,6 +2164,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.4
'@rolldown/binding-win32-x64-msvc': 1.2.4
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -1698,6 +2180,8 @@ snapshots:
std-env@4.2.0: {}
+ symbol-tree@3.2.4: {}
+
tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {}
@@ -1715,12 +2199,28 @@ snapshots:
tinyrainbow@3.1.1: {}
+ tldts-core@7.4.10: {}
+
+ tldts@7.4.10:
+ dependencies:
+ tldts-core: 7.4.10
+
+ tough-cookie@6.0.2:
+ dependencies:
+ tldts: 7.4.10
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tslib@2.8.1: {}
typescript@6.0.3: {}
undici-types@7.18.2: {}
+ undici@8.10.0: {}
+
update-browserslist-db@1.3.1(browserslist@4.28.8):
dependencies:
browserslist: 4.28.8
@@ -1743,7 +2243,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
- vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)):
+ vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
@@ -1767,12 +2267,41 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
+ jsdom: 30.0.1
transitivePeerDependencies:
- msw
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ whatwg-url@17.1.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
yallist@3.1.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..e535fd3
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - packages/*
+ - examples/*
diff --git a/scripts/generate-standalone-engine.mjs b/scripts/generate-standalone-engine.mjs
index 8a13df9..158a06a 100644
--- a/scripts/generate-standalone-engine.mjs
+++ b/scripts/generate-standalone-engine.mjs
@@ -8,6 +8,11 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
const result = await build({
configFile: false,
logLevel: 'silent',
+ resolve: {
+ alias: {
+ '@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
+ },
+ },
build: {
write: false,
minify: true,
diff --git a/scripts/smoke-packages.mjs b/scripts/smoke-packages.mjs
new file mode 100644
index 0000000..464955e
--- /dev/null
+++ b/scripts/smoke-packages.mjs
@@ -0,0 +1,47 @@
+import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { spawnSync } from 'node:child_process'
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
+const workspace = await mkdtemp(path.join(tmpdir(), 'avatar-runtime-pack-smoke-'))
+const consumer = path.join(workspace, 'consumer')
+
+const run = (args, cwd = root, capture = false) => {
+ const result = spawnSync('pnpm', args, {
+ cwd,
+ encoding: 'utf8',
+ stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
+ })
+ if (result.status !== 0) process.exit(result.status ?? 1)
+ return result.stdout?.trim()
+}
+
+run(['packages:build'])
+const pack = packageName => {
+ const output = run(
+ ['--dir', path.join(root, 'packages', packageName), 'pack', '--pack-destination', workspace],
+ root,
+ true
+ )
+ return output.split('\n').at(-1)
+}
+const coreTarball = pack('avatar-core')
+const reactTarball = pack('avatar-react')
+
+await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
+ recursive: true,
+ filter: source =>
+ !source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
+})
+const packagePath = path.join(consumer, 'package.json')
+const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
+packageJson.dependencies['@bible-strong/avatar-core'] = `file:${coreTarball}`
+packageJson.dependencies['@bible-strong/avatar-react'] = `file:${reactTarball}`
+packageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
+await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
+
+run(['install', '--no-frozen-lockfile'], consumer)
+run(['build'], consumer)
+process.stdout.write(`Tarball consumer verified at ${consumer}\n`)
diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx
index b3381bd..1268775 100644
--- a/src/app/components/common.tsx
+++ b/src/app/components/common.tsx
@@ -124,11 +124,13 @@ export function ExportSection({
value,
title,
subtitle,
+ badge,
children,
}: {
value: string
title: string
subtitle: string
+ badge?: string
children: React.ReactNode
}) {
const { t } = useStudioLanguage()
@@ -136,7 +138,10 @@ export function ExportSection({
- {t(title)}
+
+ {t(title)}
+ {badge && {t(badge)} }
+
{t(subtitle)}
diff --git a/src/app/styles.css b/src/app/styles.css
index 814b77b..da014b4 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -382,12 +382,58 @@ input {
}
.export-avatar-summary small,
+.export-avatar-summary span,
.export-format-grid small {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
}
+.runtime-readiness {
+ display: grid;
+ gap: 10px;
+ border-color: color-mix(in srgb, var(--destructive) 32%, var(--border));
+ background: color-mix(in srgb, var(--destructive) 5%, var(--card));
+}
+.runtime-readiness[data-ready='true'] {
+ border-color: #b9d8c6;
+ background: #f1faf5;
+}
+.runtime-readiness-heading {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+.runtime-readiness-heading > svg {
+ width: 18px;
+ height: 18px;
+ flex: none;
+ color: var(--destructive);
+}
+.runtime-readiness[data-ready='true'] .runtime-readiness-heading > svg {
+ color: #278453;
+}
+.runtime-readiness-heading > div {
+ display: grid;
+ gap: 2px;
+}
+.runtime-readiness-heading strong {
+ font-size: 12px;
+}
+.runtime-readiness-heading small {
+ color: var(--muted-foreground);
+ font-size: 10px;
+}
+.runtime-error-list {
+ display: grid;
+ gap: 5px;
+ margin: 0;
+ padding-left: 18px;
+ color: var(--destructive);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
.export-avatar-summary strong {
overflow: hidden;
font-size: 16px;
@@ -460,6 +506,106 @@ input {
min-height: 44px;
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
}
+.runtime-export-actions {
+ display: grid;
+ gap: 8px;
+}
+.runtime-export-actions > button {
+ width: 100%;
+ min-height: 44px;
+}
+.runtime-copy-status {
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: 11px;
+ text-align: center;
+}
+.runtime-copy-status[role='alert'] {
+ color: var(--destructive);
+}
+.runtime-quickstart {
+ display: grid;
+ gap: 12px;
+}
+.runtime-quickstart > button {
+ min-height: 44px;
+}
+.runtime-code-example {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+}
+.runtime-code-example > small {
+ color: var(--muted-foreground);
+ font-size: 10px;
+ font-weight: 700;
+}
+.runtime-code-example pre {
+ max-width: 100%;
+ margin: 0;
+ overflow-x: auto;
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: #17191d;
+ color: #d8dee9;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 9px;
+ line-height: 1.55;
+ white-space: pre;
+}
+.runtime-token-comment {
+ color: #a6adbb;
+}
+.runtime-token-keyword {
+ color: #e9a6ff;
+}
+.runtime-token-string {
+ color: #b9e88c;
+}
+.runtime-token-tag {
+ color: #8fc7ff;
+}
+.runtime-live-example {
+ display: grid;
+ gap: 10px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+.runtime-live-example-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+.runtime-live-example-heading > div {
+ display: grid;
+ gap: 2px;
+}
+.runtime-live-example-heading small {
+ color: var(--muted-foreground);
+ font-size: 10px;
+}
+.runtime-live-example-heading strong {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 9px;
+}
+.runtime-live-example-heading button {
+ flex: none;
+}
+.runtime-live-example-stage {
+ display: grid;
+ min-height: 190px;
+ overflow: hidden;
+ place-items: center;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background-color: #f5f7fc;
+ background-image:
+ linear-gradient(rgb(91 127 229 / 6%) 1px, transparent 1px),
+ linear-gradient(90deg, rgb(91 127 229 / 6%) 1px, transparent 1px);
+ background-size: 16px 16px;
+}
.export-panel {
width: 100%;
min-width: 0;
@@ -478,6 +624,24 @@ input {
min-width: 0;
gap: 5px;
}
+.export-accordion-title {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+}
+.export-menu-badge {
+ flex: none;
+ padding: 2px 5px;
+ border-radius: 5px;
+ background: #e9edfa;
+ color: #4168d5;
+ font-size: 8px;
+ font-weight: 850;
+ letter-spacing: 0.08em;
+ line-height: 1.3;
+ text-transform: uppercase;
+}
.export-accordion-trigger strong {
color: #343b49;
font-size: 12px;
@@ -1622,6 +1786,24 @@ p {
font-size: 11px;
line-height: 1.45;
}
+.semantic-key-card input {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+.semantic-key-label {
+ color: var(--foreground);
+ font-size: 12px;
+ font-weight: 700;
+}
+.semantic-key-error {
+ margin: 0;
+ color: var(--destructive);
+ font-size: 11px;
+ line-height: 1.45;
+}
+.semantic-key-card input[aria-invalid='true'] {
+ border-color: var(--destructive);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--destructive) 14%, transparent);
+}
.color-panel {
position: relative;
}
@@ -2147,6 +2329,23 @@ p {
font-size: 8px;
font-weight: 850;
}
+.runtime-key-missing {
+ position: absolute;
+ top: 5px;
+ left: 5px;
+ display: grid;
+ width: 17px;
+ height: 17px;
+ place-items: center;
+ border: 1px solid #fff;
+ border-radius: 50%;
+ background: var(--destructive);
+ color: white;
+ font-size: 10px;
+ font-style: normal;
+ font-weight: 900;
+ line-height: 1;
+}
.expression-card[aria-pressed='true'] {
border-color: #5b7fe5;
box-shadow: 0 0 0 2px rgb(91 127 229 / 18%);
diff --git a/src/features/animation/__tests__/sequences-test.ts b/src/features/animation/__tests__/sequences-test.ts
index c7cb909..863ba33 100644
--- a/src/features/animation/__tests__/sequences-test.ts
+++ b/src/features/animation/__tests__/sequences-test.ts
@@ -1,6 +1,7 @@
import {
advanceSequenceCursor,
createInitialSequences,
+ duplicateSequence,
getSequenceSpring,
normalizeSequencesForExpressions,
parseSequences,
@@ -94,4 +95,13 @@ describe('editable avatar sequences', () => {
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
expect(smooth.damping).toBeGreaterThan(0)
})
+
+ it('clears the public semantic key when an animation is duplicated', () => {
+ const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
+
+ const duplicate = duplicateSequence(sequence)
+
+ expect(duplicate.semanticKey).toBeUndefined()
+ expect(duplicate.id).not.toBe(sequence.id)
+ })
})
diff --git a/src/features/animation/components/SequenceWorkspace.tsx b/src/features/animation/components/SequenceWorkspace.tsx
index 14f76bf..32c8cc1 100644
--- a/src/features/animation/components/SequenceWorkspace.tsx
+++ b/src/features/animation/components/SequenceWorkspace.tsx
@@ -62,6 +62,7 @@ export function SequenceWorkspace({
onSave,
onDuplicate,
onDelete,
+ semanticKeyError,
}: {
editing: { sourceId: string | null; draft: AvatarSequence }
expressions: Expression[]
@@ -86,6 +87,7 @@ export function SequenceWorkspace({
onSave: () => void
onDuplicate: () => void
onDelete: () => void
+ semanticKeyError: string | null
}) {
const { t } = useStudioLanguage()
const draggedStepId = useRef(null)
@@ -161,6 +163,35 @@ export function SequenceWorkspace({
compact
>
+
+
+ {t('Clé sémantique')}
+
+
+ onChange({
+ ...editing.draft,
+ semanticKey: event.currentTarget.value || undefined,
+ })
+ }
+ />
+
+ {semanticKeyError ??
+ t('Clé publique stable utilisée par l’API runtime, par exemple thinking.')}
+
+
{t('Nom')}
const playback = getStatePlaybackConfig(id)
return {
id,
+ semanticKey: id,
name: id,
group,
description:
@@ -117,6 +119,7 @@ const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence
const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
return {
id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
+ ...(typeof candidate?.semanticKey === 'string' ? { semanticKey: candidate.semanticKey } : {}),
name:
typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
group:
@@ -227,6 +230,7 @@ export const createSequence = (expressionId = initialExpressions[0].id): AvatarS
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
...cloneSequence(source),
id: createId('sequence'),
+ semanticKey: undefined,
name: `${source.name} copy`,
builtIn: false,
steps: source.steps.map(step => ({ ...step, id: createId('step') })),
diff --git a/src/features/avatar/__tests__/avatar-definition-test.ts b/src/features/avatar/__tests__/avatar-definition-test.ts
new file mode 100644
index 0000000..d3a5e3d
--- /dev/null
+++ b/src/features/avatar/__tests__/avatar-definition-test.ts
@@ -0,0 +1,715 @@
+import {
+ AVATAR_DEFINITION_MAX_BYTES,
+ avatarDefinitionFileName,
+ createAvatarDefinition,
+ getStandardAnimationAvailabilityV1,
+ parseAvatarDefinition,
+ STANDARD_ANIMATIONS_V1,
+ STANDARD_ANIMATION_ORDER_V1,
+ validateAvatarDefinition,
+ type AvatarDefinition,
+} from '@/features/avatar/avatarDefinition'
+import {
+ defaultAvatarColors,
+ defaultAvatarEyes,
+ resolveAvatarBehavior,
+ type AvatarBehaviorLibrary,
+ type StudioAvatar,
+} from '@/features/avatar/avatars'
+import { createInitialSequences, createSequence } from '@/features/animation/sequences'
+import { defaultExpression, initialExpressions } from '@/features/avatar/presets'
+import { surfacePresets } from '@/features/avatar/surfaces'
+import { loadStudioDocument } from '@/features/studio/studioDocument'
+
+const avatarFixture = (): StudioAvatar => ({
+ id: 'avatar-fixture',
+ name: 'Fixture',
+ body: {
+ primary: {
+ ...surfacePresets.cone,
+ width: 251.123456,
+ roundness: 0.25,
+ morphRoundness: 0.2,
+ tipRoundness: 0.3,
+ baseRoundness: 0.4,
+ },
+ nodes: [
+ {
+ id: 'shape-private',
+ name: 'Private label',
+ surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
+ position: [1.25, -2.5, 3.75],
+ rotation: [-10, 20, 30],
+ },
+ ],
+ },
+ colors: { body: '#abcdef', eyes: '#123456' },
+ eyes: {
+ ...defaultAvatarEyes,
+ widthLeft: defaultAvatarEyes.widthLeft + 3,
+ positionYRight: defaultAvatarEyes.positionYRight - 4,
+ },
+})
+
+const behaviorFixture = (): AvatarBehaviorLibrary => {
+ const expression = {
+ ...initialExpressions[0],
+ semanticKey: 'happy-smile',
+ bodyColor: '#fedcba',
+ eyeColor: '#654321',
+ eyeMotion: 'microSaccades' as const,
+ bodyMotion: 'slowDrift' as const,
+ }
+ const sequence = createInitialSequences()[0]
+ return {
+ expressions: [expression],
+ sequences: [
+ {
+ ...sequence,
+ semanticKey: 'happy',
+ playbackMode: 'once',
+ steps: [
+ {
+ ...sequence.steps[0],
+ expressionId: expression.id,
+ holdMs: 1234,
+ transitionMs: 432,
+ transition: 'snappy',
+ },
+ ],
+ blink: {
+ enabled: true,
+ initialDelayMs: 100,
+ minIntervalMs: 250,
+ maxIntervalMs: 120000,
+ durationMs: 50,
+ },
+ },
+ ],
+ }
+}
+
+const definitionFixture = (): AvatarDefinition => {
+ const result = createAvatarDefinition({ avatar: avatarFixture(), behavior: behaviorFixture() })
+ if (!result.ok) throw new Error(JSON.stringify(result.errors))
+ return structuredClone(result.value) as AvatarDefinition
+}
+
+const expectError = (value: unknown, code: string, path?: string) => {
+ const result = validateAvatarDefinition(value)
+ expect(result.ok).toBe(false)
+ if (result.ok) return
+ expect(result.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ code, ...(path === undefined ? {} : { path }) }),
+ ])
+ )
+}
+
+describe('avatar definition validation', () => {
+ it('validates, clones, deeply freezes, and JSON round-trips a v1 definition', () => {
+ const input = definitionFixture()
+ const result = validateAvatarDefinition(input)
+
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.value).not.toBe(input)
+ expect(Object.isFrozen(result.value)).toBe(true)
+ expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
+ expect(JSON.parse(JSON.stringify(result.value))).toEqual(input)
+ expect(validateAvatarDefinition(JSON.parse(JSON.stringify(result.value))).ok).toBe(true)
+ })
+
+ it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
+ expectError(
+ { ...definitionFixture(), schemaVersion: 2 },
+ 'unsupported_version',
+ '/schemaVersion'
+ )
+ expectError({ ...definitionFixture(), surprise: true }, 'additionalProperties', '/surprise')
+
+ const malformed = definitionFixture()
+ malformed.expressions['Happy Smile'] = malformed.expressions['happy-smile']
+ malformed.expressionOrder.push('Happy Smile')
+ expectError(malformed, 'pattern', '/expressions/Happy Smile')
+
+ const missingNeutral = definitionFixture()
+ delete missingNeutral.expressions.neutral
+ missingNeutral.expressionOrder = missingNeutral.expressionOrder.filter(key => key !== 'neutral')
+ expectError(missingNeutral, 'required', '/expressions/neutral')
+ })
+
+ it('rejects incomplete order lists and dangling animation references with JSON pointers', () => {
+ const incomplete = definitionFixture()
+ incomplete.expressionOrder = ['neutral']
+ expectError(incomplete, 'incomplete_order', '/expressionOrder')
+
+ const dangling = definitionFixture()
+ dangling.animations.happy.steps[0].expression = 'missing'
+ expectError(dangling, 'unknown_expression', '/animations/happy/steps/0/expression')
+ })
+
+ it('requires neutral to be the first expression-order entry', () => {
+ const definition = definitionFixture()
+ definition.expressionOrder = ['happy-smile', 'neutral']
+
+ expectError(definition, 'neutral_not_first', '/expressionOrder/0')
+ })
+
+ it('rejects non-finite numbers, non-plain objects, and invalid blink ranges', () => {
+ const nonFinite = definitionFixture()
+ nonFinite.body.primary.width = Number.POSITIVE_INFINITY
+ expectError(nonFinite, 'non_finite_number', '/body/primary/width')
+
+ const nonPlain = definitionFixture()
+ nonPlain.colors = new (class {
+ body = '#abcdef' as const
+ eyes = '#123456' as const
+ })()
+ expectError(nonPlain, 'non_plain_object', '/colors')
+
+ const interval = definitionFixture()
+ interval.animations.happy.blink.minIntervalMs = 500
+ interval.animations.happy.blink.maxIntervalMs = 499
+ expectError(interval, 'invalid_interval_range', '/animations/happy/blink/minIntervalMs')
+ })
+
+ it.each([
+ ['dimension minimum', (value: AvatarDefinition) => (value.body.primary.width = 0), 'minimum'],
+ [
+ 'dimension maximum',
+ (value: AvatarDefinition) => (value.body.primary.height = 10000.1),
+ 'maximum',
+ ],
+ [
+ 'roundness minimum',
+ (value: AvatarDefinition) => (value.body.primary.roundness = -0.1),
+ 'minimum',
+ ],
+ [
+ 'roundness maximum',
+ (value: AvatarDefinition) => (value.body.primary.roundness = 1.1),
+ 'maximum',
+ ],
+ [
+ 'position minimum',
+ (value: AvatarDefinition) => (value.body.nodes[0].position[0] = -10000.1),
+ 'minimum',
+ ],
+ [
+ 'position maximum',
+ (value: AvatarDefinition) => (value.body.nodes[0].position[1] = 10000.1),
+ 'maximum',
+ ],
+ [
+ 'rotation minimum',
+ (value: AvatarDefinition) => (value.body.nodes[0].rotation[0] = -360.1),
+ 'minimum',
+ ],
+ [
+ 'rotation maximum',
+ (value: AvatarDefinition) => (value.body.nodes[0].rotation[1] = 360.1),
+ 'maximum',
+ ],
+ [
+ 'perspective minimum',
+ (value: AvatarDefinition) => (value.expressions.neutral.perspective = 0.09),
+ 'minimum',
+ ],
+ [
+ 'perspective maximum',
+ (value: AvatarDefinition) => (value.expressions.neutral.perspective = 10.01),
+ 'maximum',
+ ],
+ [
+ 'eye bound',
+ (value: AvatarDefinition) => (value.expressions.neutral.eyes.left.x = 10001),
+ 'maximum',
+ ],
+ [
+ 'head bound',
+ (value: AvatarDefinition) => (value.expressions.neutral.head.z = -10001),
+ 'minimum',
+ ],
+ [
+ 'hold minimum',
+ (value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 99),
+ 'minimum',
+ ],
+ [
+ 'hold maximum',
+ (value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 60001),
+ 'maximum',
+ ],
+ [
+ 'transition minimum',
+ (value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = -1),
+ 'minimum',
+ ],
+ [
+ 'transition maximum',
+ (value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = 5001),
+ 'maximum',
+ ],
+ [
+ 'blink initial delay',
+ (value: AvatarDefinition) => (value.animations.happy.blink.initialDelayMs = 60001),
+ 'maximum',
+ ],
+ [
+ 'blink interval minimum',
+ (value: AvatarDefinition) => (value.animations.happy.blink.minIntervalMs = 249),
+ 'minimum',
+ ],
+ [
+ 'blink interval maximum',
+ (value: AvatarDefinition) => (value.animations.happy.blink.maxIntervalMs = 120001),
+ 'maximum',
+ ],
+ [
+ 'blink duration minimum',
+ (value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 49),
+ 'minimum',
+ ],
+ [
+ 'blink duration maximum',
+ (value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 2001),
+ 'maximum',
+ ],
+ ])('enforces the documented %s boundary', (_name, mutate, code) => {
+ const definition = definitionFixture()
+ mutate(definition)
+ expectError(definition, code)
+ })
+
+ it('accepts all inclusive numeric boundaries', () => {
+ const definition = definitionFixture()
+ definition.body.primary.width = 0.001
+ definition.body.primary.height = 10000
+ definition.body.primary.roundness = 0
+ definition.body.primary.morphRoundness = 1
+ definition.body.nodes[0].position = [-10000, 0, 10000]
+ definition.body.nodes[0].rotation = [-360, 0, 360]
+ definition.expressions.neutral.head = { x: -10000, y: 0, z: 10000 }
+ definition.expressions.neutral.perspective = 0.1
+ definition.expressions['happy-smile'].perspective = 10
+ definition.animations.happy.steps[0].holdMs = 60000
+ definition.animations.happy.steps[0].transitionMs = 5000
+ definition.animations.happy.blink = {
+ enabled: false,
+ initialDelayMs: 60000,
+ minIntervalMs: 250,
+ maxIntervalMs: 120000,
+ durationMs: 2000,
+ }
+
+ expect(validateAvatarDefinition(definition).ok).toBe(true)
+ })
+
+ it('enforces collection and text limits', () => {
+ const nodes = definitionFixture()
+ nodes.body.nodes = Array.from({ length: 17 }, () => structuredClone(nodes.body.nodes[0]))
+ expectError(nodes, 'maxItems', '/body/nodes')
+
+ const steps = definitionFixture()
+ steps.animations.happy.steps = Array.from({ length: 129 }, () => ({
+ ...steps.animations.happy.steps[0],
+ }))
+ expectError(steps, 'maxItems', '/animations/happy/steps')
+
+ const text = definitionFixture()
+ text.name = 'n'.repeat(121)
+ text.animations.happy.metadata!.label = 'l'.repeat(121)
+ text.animations.happy.metadata!.description = 'd'.repeat(513)
+ text.animations.happy.metadata!.group = 'g'.repeat(65)
+ expectError(text, 'maxLength')
+ })
+
+ it('enforces expression and animation collection limits', () => {
+ const expressions = definitionFixture()
+ const pose = expressions.expressions['happy-smile']
+ expressions.expressions = { neutral: expressions.expressions.neutral }
+ expressions.expressionOrder = ['neutral']
+ for (let index = 0; index < 128; index += 1) {
+ const key = `pose-${index}`
+ expressions.expressions[key] = structuredClone(pose)
+ expressions.expressionOrder.push(key)
+ }
+ expectError(expressions, 'maxProperties', '/expressions')
+
+ const animations = definitionFixture()
+ const animation = animations.animations.happy
+ animations.animations = {}
+ animations.animationOrder = []
+ for (let index = 0; index < 65; index += 1) {
+ const key = `animation-${index}`
+ animations.animations[key] = structuredClone(animation)
+ animations.animationOrder.push(key)
+ }
+ expectError(animations, 'maxProperties', '/animations')
+ })
+
+ it('accepts exact collection and text maxima', () => {
+ const definition = definitionFixture()
+ definition.name = 'n'.repeat(120)
+ definition.animations.happy.metadata = {
+ label: 'l'.repeat(120),
+ description: 'd'.repeat(512),
+ group: 'g'.repeat(64),
+ }
+ definition.body.nodes = Array.from({ length: 16 }, () =>
+ structuredClone(definition.body.nodes[0])
+ )
+ definition.animations.happy.steps = Array.from({ length: 128 }, () => ({
+ ...definition.animations.happy.steps[0],
+ }))
+
+ expect(validateAvatarDefinition(definition).ok).toBe(true)
+ })
+
+ it('enforces the 64-character semantic-key maximum', () => {
+ const definition = definitionFixture()
+ const expression = definition.expressions['happy-smile']
+ delete definition.expressions['happy-smile']
+ const key = `a${'b'.repeat(64)}`
+ definition.expressions[key] = expression
+ definition.expressionOrder = ['neutral', key]
+
+ expectError(definition, 'maxLength', `/expressions/${key}`)
+ })
+
+ it('requires lowercase six-digit colors and known secondary surface types', () => {
+ const color = definitionFixture()
+ color.colors.body = '#ABCDEF'
+ expectError(color, 'pattern', '/colors/body')
+
+ const surface = definitionFixture()
+ surface.body.nodes[0].surface.type = 'cursor' as 'sphere'
+ expectError(surface, 'enum', '/body/nodes/0/surface/type')
+ })
+})
+
+describe('bounded avatar JSON parser', () => {
+ it('rejects duplicate object members at the second member path', () => {
+ const result = parseAvatarDefinition('{"schema":1,"schema":2}')
+
+ expect(result).toEqual({
+ ok: false,
+ errors: [expect.objectContaining({ path: '/schema', code: 'duplicate_key' })],
+ })
+ })
+
+ it('rejects oversized UTF-8 input and excessive nesting', () => {
+ const oversized = parseAvatarDefinition(' '.repeat(AVATAR_DEFINITION_MAX_BYTES + 1))
+ expect(oversized).toEqual({
+ ok: false,
+ errors: [expect.objectContaining({ path: '', code: 'max_bytes' })],
+ })
+
+ const nested = parseAvatarDefinition(`${'['.repeat(33)}null${']'.repeat(33)}`)
+ expect(nested).toEqual({
+ ok: false,
+ errors: [expect.objectContaining({ code: 'max_depth' })],
+ })
+ })
+
+ it('accepts the exact byte limit and depth limit before schema validation', () => {
+ const json = JSON.stringify(definitionFixture())
+ const jsonBytes = new TextEncoder().encode(json).byteLength
+ const exactBytes = `${json}${' '.repeat(AVATAR_DEFINITION_MAX_BYTES - jsonBytes)}`
+ expect(new TextEncoder().encode(exactBytes)).toHaveLength(AVATAR_DEFINITION_MAX_BYTES)
+ expect(parseAvatarDefinition(exactBytes).ok).toBe(true)
+
+ const depth32 = `${'['.repeat(32)}null${']'.repeat(32)}`
+ const result = parseAvatarDefinition(depth32)
+ expect(result.ok).toBe(false)
+ if (!result.ok) expect(result.errors.some(error => error.code === 'max_depth')).toBe(false)
+ })
+
+ it('rejects overlong decoded strings before schema validation', () => {
+ const result = parseAvatarDefinition(`{"name":"${'a'.repeat(513)}"}`)
+ expect(result).toEqual({
+ ok: false,
+ errors: [expect.objectContaining({ path: '/name', code: 'string_too_long' })],
+ })
+ })
+
+ it('applies semantic-key limits while tokenizing and treats prototype keys as data', () => {
+ const longKey = `a${'b'.repeat(64)}`
+ const longKeyResult = parseAvatarDefinition(`{"expressions":{"${longKey}":null}}`)
+ expect(longKeyResult).toEqual({
+ ok: false,
+ errors: [
+ expect.objectContaining({
+ path: `/expressions/${longKey}`,
+ code: 'string_too_long',
+ }),
+ ],
+ })
+
+ const prototypeResult = parseAvatarDefinition('{"__proto__":{"polluted":true}}')
+ expect(prototypeResult.ok).toBe(false)
+ expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
+ if (!prototypeResult.ok) {
+ expect(prototypeResult.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: '/__proto__', code: 'additionalProperties' }),
+ ])
+ )
+ }
+ })
+
+ it('parses a valid definition and rejects malformed JSON', () => {
+ expect(parseAvatarDefinition(JSON.stringify(definitionFixture())).ok).toBe(true)
+ const malformed = parseAvatarDefinition('{"schema":]')
+ expect(malformed).toEqual({
+ ok: false,
+ errors: [expect.objectContaining({ code: 'invalid_json' })],
+ })
+ })
+})
+
+describe('Studio to avatar definition conversion', () => {
+ it('exports the complete bundled document with curated semantic keys', () => {
+ const document = loadStudioDocument({ getItem: () => null })
+ const avatar = document.library.avatars[0]
+ const behavior = resolveAvatarBehavior(avatar, {
+ expressions: document.expressions,
+ sequences: document.sequences,
+ })
+
+ const result = createAvatarDefinition({ avatar, behavior })
+
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.value.expressionOrder).toHaveLength(28)
+ expect(result.value.expressionOrder[0]).toBe('neutral')
+ expect(result.value.animationOrder).toHaveLength(23)
+ expect(result.value.animationOrder).toContain('idle')
+ expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
+ 'upward-side-glance',
+ 'curious-left',
+ ])
+ expect(getStandardAnimationAvailabilityV1(result.value.expressions).available).toEqual(
+ STANDARD_ANIMATION_ORDER_V1
+ )
+ expect(JSON.stringify(result.value)).not.toContain('expression-00')
+ })
+
+ it('rejects newly created custom content until semantic keys are supplied', () => {
+ const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
+ const sequence = createSequence(expression.id)
+
+ expect(expression.semanticKey).toBeUndefined()
+ expect(sequence.semanticKey).toBeUndefined()
+ const result = createAvatarDefinition({
+ avatar: avatarFixture(),
+ behavior: { expressions: [expression], sequences: [sequence] },
+ })
+ expect(result.ok).toBe(false)
+ if (!result.ok) {
+ expect(result.errors.map(error => error.code)).toEqual(
+ expect.arrayContaining(['missing_semantic_key'])
+ )
+ }
+ })
+
+ it('preserves geometry, colors, timing, motion, and resolves avatar eye defaults', () => {
+ const avatar = avatarFixture()
+ const behavior = behaviorFixture()
+ const result = createAvatarDefinition({ avatar, behavior })
+
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.value.body.primary).toEqual(avatar.body.primary)
+ expect(result.value.body.nodes).toEqual([
+ {
+ surface: avatar.body.nodes[0].surface,
+ position: avatar.body.nodes[0].position,
+ rotation: avatar.body.nodes[0].rotation,
+ },
+ ])
+ expect(result.value.body.nodes[0]).not.toHaveProperty('id')
+ expect(result.value.body.nodes[0]).not.toHaveProperty('name')
+ expect(result.value.colors).toEqual(avatar.colors)
+ expect(result.value.expressions.neutral.eyes.left.width).toBe(defaultExpression.widthLeft + 3)
+ expect(result.value.expressions.neutral.eyes.right.y).toBe(defaultExpression.positionYRight - 4)
+ expect(result.value.expressions['happy-smile'].eyes.left.width).toBeCloseTo(
+ behavior.expressions[0].widthLeft + 3
+ )
+ expect(result.value.expressions['happy-smile'].colors).toEqual({
+ body: '#fedcba',
+ eyes: '#654321',
+ })
+ expect(result.value.expressions['happy-smile'].motion).toEqual({
+ eyes: 'microSaccades',
+ body: 'slowDrift',
+ })
+ expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
+ expect(result.value.animations.happy).toMatchObject({
+ playbackMode: 'once',
+ steps: [
+ {
+ expression: 'happy-smile',
+ holdMs: 1234,
+ transitionMs: 432,
+ transition: 'snappy',
+ },
+ ],
+ blink: behavior.sequences[0].blink,
+ })
+ })
+
+ it.each([
+ [undefined, 'missing_semantic_key'],
+ ['Bad Key', 'invalid_semantic_key'],
+ ['neutral', 'reserved_semantic_key'],
+ ])('rejects an unexportable expression semantic key', (semanticKey, code) => {
+ const behavior = behaviorFixture()
+ behavior.expressions[0].semanticKey = semanticKey
+ const result = createAvatarDefinition({ avatar: avatarFixture(), behavior })
+
+ expect(result.ok).toBe(false)
+ if (!result.ok)
+ expect(result.errors).toEqual(expect.arrayContaining([expect.objectContaining({ code })]))
+ })
+
+ it('rejects duplicate keys and unresolved animation expression references', () => {
+ const duplicate = behaviorFixture()
+ duplicate.expressions.push({ ...duplicate.expressions[0], id: 'other-expression' })
+ const duplicateResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: duplicate })
+ expect(duplicateResult.ok).toBe(false)
+ if (!duplicateResult.ok) {
+ expect(duplicateResult.errors).toEqual(
+ expect.arrayContaining([expect.objectContaining({ code: 'duplicate_semantic_key' })])
+ )
+ }
+
+ const dangling = behaviorFixture()
+ dangling.sequences[0].steps[0].expressionId = 'missing-expression'
+ const danglingResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: dangling })
+ expect(danglingResult.ok).toBe(false)
+ if (!danglingResult.ok) {
+ expect(danglingResult.errors).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: '/studio/animations/0/steps/0/expressionId',
+ code: 'unresolved_expression_reference',
+ }),
+ ])
+ )
+ }
+ })
+
+ it('does not mutate Studio inputs', () => {
+ const avatar = avatarFixture()
+ const behavior = behaviorFixture()
+ const before = JSON.stringify({ avatar, behavior })
+
+ createAvatarDefinition({ avatar, behavior })
+
+ expect(JSON.stringify({ avatar, behavior })).toBe(before)
+ expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
+ })
+})
+
+describe('standard animation catalogue v1', () => {
+ it('commits the approved keys, order, steps, timing, and blink settings', () => {
+ expect(STANDARD_ANIMATION_ORDER_V1).toEqual([
+ 'idle',
+ 'happy',
+ 'sad',
+ 'thinking',
+ 'excited',
+ 'celebrate',
+ ])
+ expect(STANDARD_ANIMATIONS_V1.idle).toEqual({
+ requiredExpressions: ['upward-side-glance', 'curious-left'],
+ playbackMode: 'loop',
+ steps: [
+ {
+ expression: 'upward-side-glance',
+ holdMs: 5200,
+ transitionMs: 500,
+ transition: 'smooth',
+ },
+ {
+ expression: 'curious-left',
+ holdMs: 5200,
+ transitionMs: 500,
+ transition: 'smooth',
+ },
+ ],
+ blink: {
+ enabled: true,
+ initialDelayMs: 2600,
+ minIntervalMs: 3400,
+ maxIntervalMs: 6200,
+ durationMs: 280,
+ },
+ })
+ expect(Object.values(STANDARD_ANIMATIONS_V1).every(animation => animation.steps.length)).toBe(
+ true
+ )
+ expect(
+ Object.values(STANDARD_ANIMATIONS_V1).every(animation =>
+ animation.steps.every(
+ step =>
+ step.transitionMs === 500 &&
+ step.transition === 'smooth' &&
+ animation.requiredExpressions.includes(step.expression)
+ )
+ )
+ ).toBe(true)
+ expect(
+ Object.fromEntries(
+ STANDARD_ANIMATION_ORDER_V1.map(key => [
+ key,
+ STANDARD_ANIMATIONS_V1[key].steps.map(step => step.expression),
+ ])
+ )
+ ).toEqual({
+ idle: ['upward-side-glance', 'curious-left'],
+ happy: ['joyful-down-right', 'joyful-wide', 'playful-right', 'gentle-downward-gaze'],
+ sad: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
+ thinking: [
+ 'curious-left',
+ 'angry-left',
+ 'skeptical-left',
+ 'playful-right',
+ 'skeptical-right',
+ ],
+ excited: [
+ 'joyful-down-right',
+ 'playful-right',
+ 'surprised-wide-left',
+ 'surprised-left',
+ 'joyful-wide',
+ ],
+ celebrate: ['joyful-down-right', 'curious-left', 'playful-right'],
+ })
+ })
+
+ it('reports available standards and exact missing semantic expressions', () => {
+ const definition = definitionFixture()
+ const availability = getStandardAnimationAvailabilityV1(definition.expressions)
+
+ expect(availability.available).toEqual([])
+ expect(availability.unavailable.find(item => item.key === 'idle')).toEqual({
+ key: 'idle',
+ missingExpressions: ['upward-side-glance', 'curious-left'],
+ })
+
+ const idleExpressions = {
+ ...definition.expressions,
+ 'upward-side-glance': definition.expressions.neutral,
+ 'curious-left': definition.expressions.neutral,
+ }
+ expect(getStandardAnimationAvailabilityV1(idleExpressions).available).toEqual(['idle'])
+ })
+
+ it('creates a sanitized runtime-definition filename', () => {
+ expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
+ expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
+ })
+})
diff --git a/src/features/avatar/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts
index 69835a8..9fb380d 100644
--- a/src/features/avatar/__tests__/avatars-test.ts
+++ b/src/features/avatar/__tests__/avatars-test.ts
@@ -4,6 +4,7 @@ import {
applyAvatarEyeDefaults,
cloneAvatarBehavior,
createAvatar,
+ createUnkeyedExpressionCopy,
defaultAvatarEyes,
parseAvatarEyeDefaults,
parseAvatarRenderStyle,
@@ -12,6 +13,16 @@ import {
import { initialExpressions } from '@/features/avatar/presets'
describe('avatar eye defaults', () => {
+ it('clears the public semantic key when creating custom content from a preset', () => {
+ const source = { ...defaultExpression, semanticKey: 'attentive-left' }
+
+ const copy = createUnkeyedExpressionCopy(source, 'expression-copy')
+
+ expect(copy.id).toBe('expression-copy')
+ expect(copy.semanticKey).toBeUndefined()
+ expect(source.semanticKey).toBe('attentive-left')
+ })
+
it('keeps the historical rendering when using default values', () => {
expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
})
diff --git a/src/features/avatar/__tests__/generate-strobi-fixture-test.ts b/src/features/avatar/__tests__/generate-strobi-fixture-test.ts
new file mode 100644
index 0000000..7d29639
--- /dev/null
+++ b/src/features/avatar/__tests__/generate-strobi-fixture-test.ts
@@ -0,0 +1,23 @@
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+
+import { createAvatarDefinition } from '../avatarDefinition'
+import { resolveAvatarBehavior } from '../avatars'
+import { loadStudioDocument } from '../../studio/studioDocument'
+
+it('keeps the consumer fixture synchronized with the bundled Strobi Studio document', async () => {
+ const document = loadStudioDocument({ getItem: () => null })
+ const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi')
+ if (!avatar) throw new Error('Bundled Strobi avatar not found')
+ const behavior = resolveAvatarBehavior(avatar, {
+ expressions: document.expressions,
+ sequences: document.sequences,
+ })
+ const result = createAvatarDefinition({ avatar, behavior })
+ if (!result.ok) throw new Error(result.errors.map(error => error.message).join('\n'))
+ const fixture = await readFile(
+ resolve('examples/react-vite-consumer/src/strobi.avatar.json'),
+ 'utf8'
+ )
+ expect(JSON.parse(fixture)).toEqual(result.value)
+})
diff --git a/src/features/avatar/ambientMotion.ts b/src/features/avatar/ambientMotion.ts
index 2df3a01..11f5adb 100644
--- a/src/features/avatar/ambientMotion.ts
+++ b/src/features/avatar/ambientMotion.ts
@@ -1,115 +1 @@
-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 EYE_MOTION_SEED = 17.29
-
-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 duration = 140
- if (elapsedMs <= 0) return 0
- const step = Math.floor(elapsedMs / interval)
- const progress = (elapsedMs - step * interval) / duration
- const blend = smoothstep(Math.min(progress, 1))
- const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
- const next = hash(step * 2 + axis + seed)
- return previous + (next - previous) * blend
-}
-
-export const hasAmbientMotion = (expression: Expression) =>
- expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
-
-export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
- const seed = expressionSeed(expression)
- if (expression.bodyMotion === 'slowDrift') {
- return {
- x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
- y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
- }
- }
- if (expression.bodyMotion === 'shake') {
- const time = elapsedMs / 1000
- return {
- x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
- y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
- }
- }
- return { x: 0, y: 0 }
-}
-
-export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
- if (expression.eyeMotion === 'microSaccades') {
- return {
- x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
- y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
- }
- }
- if (expression.eyeMotion === 'shake') {
- const time = elapsedMs / 1000
- return {
- x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
- y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
- }
- }
- return { x: 0, y: 0 }
-}
-
-export const applyAmbientBodyMotion = (
- expression: Expression,
- elapsedMs: number,
- strength = 1
-): Expression => {
- const next = { ...expression }
- const seed = expressionSeed(expression)
-
- if (expression.bodyMotion === 'slowDrift') {
- next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
- next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
- next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
- } else if (expression.bodyMotion === 'shake') {
- const time = elapsedMs / 1000
- next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
- next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
- next.headZ += Math.sin(time * 43) * 0.7 * strength
- }
-
- return next
-}
-
-export const applyAmbientMotion = (
- expression: Expression,
- elapsedMs: number,
- strength = 1
-): Expression => {
- const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
- const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
- next.positionXLeft += eyeOffset.x
- next.positionXRight += eyeOffset.x
- next.positionYLeft += eyeOffset.y
- next.positionYRight += eyeOffset.y
- return next
-}
+export * from '@bible-strong/avatar-core'
diff --git a/src/features/avatar/avatarDefinition.ts b/src/features/avatar/avatarDefinition.ts
new file mode 100644
index 0000000..eef5aec
--- /dev/null
+++ b/src/features/avatar/avatarDefinition.ts
@@ -0,0 +1,192 @@
+import {
+ getSemanticKeyIssue,
+ validateAvatarDefinition,
+ type AvatarAnimationDefinition,
+ type AvatarBodyNodeDefinition,
+ type AvatarDefinition,
+ type AvatarDefinitionError,
+ type AvatarExpressionDefinition,
+ type BodyNodeSurfaceType,
+ type HexColor,
+ type SurfaceDefinition,
+ type ValidationResult,
+} from '@bible-strong/avatar-core'
+import type { SurfaceType } from '@bible-strong/avatar-core'
+
+import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
+import { defaultExpression } from './presets'
+import type { Expression } from './geometry'
+
+export * from '@bible-strong/avatar-core'
+
+const mapSurface = (
+ surface: SurfaceDefinition
+): SurfaceDefinition => ({
+ type: surface.type,
+ width: surface.width,
+ height: surface.height,
+ depth: surface.depth,
+ roundness: surface.roundness,
+ ...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
+ ...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
+ ...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
+})
+
+const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
+ head: { x: expression.headX, y: expression.headY, z: expression.headZ },
+ eyes: {
+ left: {
+ width: expression.widthLeft,
+ height: expression.heightLeft,
+ x: expression.positionXLeft,
+ y: expression.positionYLeft,
+ angle: expression.leftAngle,
+ },
+ right: {
+ width: expression.widthRight,
+ height: expression.heightRight,
+ x: expression.positionXRight,
+ y: expression.positionYRight,
+ angle: expression.rightAngle,
+ },
+ spacing: expression.spacing,
+ },
+ perspective: expression.perspective,
+ motion: { eyes: expression.eyeMotion, body: expression.bodyMotion },
+ ...(expression.bodyColor || expression.eyeColor
+ ? {
+ colors: {
+ ...(expression.bodyColor ? { body: expression.bodyColor as HexColor } : {}),
+ ...(expression.eyeColor ? { eyes: expression.eyeColor as HexColor } : {}),
+ },
+ }
+ : {}),
+})
+
+const semanticKeyError = (
+ path: string,
+ kind: 'expression' | 'animation',
+ semanticKey: string | undefined,
+ seen: Set
+): AvatarDefinitionError | undefined => {
+ const issue = getSemanticKeyIssue(semanticKey, kind)
+ if (issue === 'missing_semantic_key') {
+ return { path, code: 'missing_semantic_key', message: `${kind} semantic key is required` }
+ }
+ if (issue === 'invalid_semantic_key') {
+ return { path, code: 'invalid_semantic_key', message: `Invalid semantic key '${semanticKey}'` }
+ }
+ if (issue === 'reserved_semantic_key') {
+ return { path, code: 'reserved_semantic_key', message: "'neutral' is reserved" }
+ }
+ if (semanticKey === undefined) {
+ return { path, code: 'missing_semantic_key', message: `${kind} semantic key is required` }
+ }
+ if (seen.has(semanticKey)) {
+ return {
+ path,
+ code: 'duplicate_semantic_key',
+ message: `Duplicate semantic key '${semanticKey}'`,
+ }
+ }
+ seen.add(semanticKey)
+ return undefined
+}
+
+export const createAvatarDefinition = ({
+ avatar,
+ behavior,
+}: {
+ avatar: StudioAvatar
+ behavior: AvatarBehaviorLibrary
+}): ValidationResult => {
+ const errors: AvatarDefinitionError[] = []
+ const expressionKeys = new Set()
+ const animationKeys = new Set()
+ const expressionKeyById = new Map()
+
+ behavior.expressions.forEach((expression, index) => {
+ const error = semanticKeyError(
+ `/studio/expressions/${index}/semanticKey`,
+ 'expression',
+ expression.semanticKey,
+ expressionKeys
+ )
+ if (error) errors.push(error)
+ else expressionKeyById.set(expression.id, expression.semanticKey!)
+ })
+ behavior.sequences.forEach((sequence, index) => {
+ const error = semanticKeyError(
+ `/studio/animations/${index}/semanticKey`,
+ 'animation',
+ sequence.semanticKey,
+ animationKeys
+ )
+ if (error) errors.push(error)
+ sequence.steps.forEach((step, stepIndex) => {
+ if (!expressionKeyById.has(step.expressionId)) {
+ errors.push({
+ path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
+ code: 'unresolved_expression_reference',
+ message: `Animation step references unexportable expression '${step.expressionId}'`,
+ })
+ }
+ })
+ })
+ if (errors.length) return { ok: false, errors }
+
+ const expressions: Record = {
+ neutral: mapExpression(applyAvatarEyeDefaults(defaultExpression, avatar.eyes)),
+ }
+ behavior.expressions.forEach(expression => {
+ expressions[expression.semanticKey!] = mapExpression(
+ applyAvatarEyeDefaults(expression, avatar.eyes)
+ )
+ })
+ const animations: Record = Object.fromEntries(
+ behavior.sequences.map(sequence => [
+ sequence.semanticKey!,
+ {
+ playbackMode: sequence.playbackMode,
+ steps: sequence.steps.map(step => ({
+ expression: expressionKeyById.get(step.expressionId)!,
+ holdMs: step.holdMs,
+ transitionMs: step.transitionMs,
+ transition: step.transition,
+ })),
+ blink: { ...sequence.blink },
+ metadata: {
+ label: sequence.name,
+ description: sequence.description,
+ group: sequence.group,
+ },
+ },
+ ])
+ )
+ const definition: AvatarDefinition = {
+ schema: 'bible-strong/avatar-definition',
+ schemaVersion: 1,
+ ...(avatar.name ? { name: avatar.name } : {}),
+ body: {
+ primary: mapSurface(avatar.body.primary),
+ nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({
+ surface: mapSurface(node.surface as SurfaceDefinition),
+ position: [...node.position],
+ rotation: [...node.rotation],
+ })),
+ },
+ colors: {
+ body: avatar.colors.body as HexColor,
+ eyes: avatar.colors.eyes as HexColor,
+ },
+ expressions,
+ expressionOrder: [
+ 'neutral',
+ ...behavior.expressions.map(expression => expression.semanticKey!),
+ ],
+ animations,
+ animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!),
+ standardAnimationSet: 1,
+ }
+ return validateAvatarDefinition(definition)
+}
diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts
index 72e2375..9dfc08b 100644
--- a/src/features/avatar/avatars.ts
+++ b/src/features/avatar/avatars.ts
@@ -120,6 +120,12 @@ export const applyAvatarEyeDefaults = (
return result
}
+export const createUnkeyedExpressionCopy = (source: Expression, id: string): Expression => ({
+ ...source,
+ id,
+ semanticKey: undefined,
+})
+
export type AvatarLibrary = {
activeAvatarId: string
avatars: StudioAvatar[]
@@ -153,6 +159,7 @@ export const parseExpressions = (value: unknown): Expression[] => {
parsed.bodyColor = candidate.bodyColor
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
parsed.eyeColor = candidate.eyeColor
+ if (typeof candidate.semanticKey === 'string') parsed.semanticKey = candidate.semanticKey
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
? storedBodyMotion
@@ -173,6 +180,41 @@ export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBeha
sequences: cloneSequences(behavior.sequences),
})
+export const restoreLegacyBehaviorSemanticKeys = (
+ behavior: AvatarBehaviorLibrary,
+ reference: AvatarBehaviorLibrary
+): AvatarBehaviorLibrary => {
+ const expressionKeys = new Map(
+ reference.expressions.flatMap(expression =>
+ expression.semanticKey ? [[expression.id, expression.semanticKey] as const] : []
+ )
+ )
+ const sequenceKeys = new Map(
+ reference.sequences.flatMap(sequence =>
+ sequence.semanticKey ? [[sequence.id, sequence.semanticKey] as const] : []
+ )
+ )
+ const restoreExpressions = behavior.expressions.every(
+ expression => expression.semanticKey === undefined
+ )
+ const restoreSequences = behavior.sequences.every(sequence => sequence.semanticKey === undefined)
+
+ return {
+ expressions: restoreExpressions
+ ? behavior.expressions.map(expression => {
+ const semanticKey = expressionKeys.get(expression.id)
+ return semanticKey ? { ...expression, semanticKey } : expression
+ })
+ : behavior.expressions,
+ sequences: restoreSequences
+ ? behavior.sequences.map(sequence => {
+ const semanticKey = sequenceKeys.get(sequence.id)
+ return semanticKey ? { ...sequence, semanticKey } : sequence
+ })
+ : behavior.sequences,
+ }
+}
+
export const resolveAvatarBehavior = (
avatar: StudioAvatar,
base: AvatarBehaviorLibrary
@@ -186,13 +228,18 @@ const parseAvatarBehavior = (
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 restoreLegacyBehaviorSemanticKeys(
+ {
+ expressions,
+ sequences: normalizeSequencesForExpressions(
+ Array.isArray(candidate.sequences)
+ ? parseSequences(candidate.sequences)
+ : cloneSequences(base.sequences),
+ expressions
+ ),
+ },
+ base
)
- return { expressions, sequences }
}
export const createAvatar = (name: string): StudioAvatar => ({
diff --git a/src/features/avatar/body.ts b/src/features/avatar/body.ts
index 99db4c7..11f5adb 100644
--- a/src/features/avatar/body.ts
+++ b/src/features/avatar/body.ts
@@ -1,117 +1 @@
-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],
-})
+export * from '@bible-strong/avatar-core'
diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx
index dde381f..61ce31e 100644
--- a/src/features/avatar/components/ExpressionWorkspace.tsx
+++ b/src/features/avatar/components/ExpressionWorkspace.tsx
@@ -3,6 +3,8 @@ import { useState, type RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
+import { Field } from '@/components/ui/field'
+import { Input } from '@/components/ui/input'
import {
ContextMenu,
ContextMenuContent,
@@ -141,6 +143,7 @@ export function ExpressionCard({
onDragOver,
onDrop,
onDragEnd,
+ runtimeError,
}: {
expression: Expression
index: number
@@ -161,6 +164,7 @@ export function ExpressionCard({
onDragOver?: (event: React.DragEvent) => void
onDrop?: (event: React.DragEvent) => void
onDragEnd?: () => void
+ runtimeError: string | null
}) {
const { t } = useStudioLanguage()
const card = (
@@ -187,6 +191,16 @@ export function ExpressionCard({
renderStyle={renderStyle}
id={previewId}
/>
+ {runtimeError && (
+
+ !
+
+ )}
{String(index).padStart(2, '0')}
)
@@ -225,6 +239,7 @@ export function ExpressionWorkspace({
onSave,
onDuplicate,
onDelete,
+ semanticKeyError,
}: {
editing: { index: number | null; draft: Expression }
avatarColors: AvatarColors
@@ -234,6 +249,7 @@ export function ExpressionWorkspace({
onSave: () => void
onDuplicate: () => void
onDelete: () => void
+ semanticKeyError: string | null
}) {
const { t } = useStudioLanguage()
const [linked, setLinked] = useState({
@@ -293,6 +309,43 @@ export function ExpressionWorkspace({
+
+
+
+
+ {t('Clé sémantique')}
+
+
+ update({ semanticKey: event.currentTarget.value || undefined })
+ }
+ />
+
+ {semanticKeyError ??
+ t('Clé publique stable utilisée par l’API runtime, par exemple happy-smile.')}
+
+
+
+
-
-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[]
- eyeOffset?: Readonly<{ x: number; y: number }>
-}
-
-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,
- offset: Readonly<{ x: number; y: number }> = { x: 0, y: 0 }
-): 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}`] + offset.x
- const centerY = expression[`positionY${suffix}`] + offset.y
- 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, options.eyeOffset)
- const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
- 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),
- }
-}
+export * from '@bible-strong/avatar-core'
diff --git a/src/features/avatar/presets.ts b/src/features/avatar/presets.ts
index 3b3a416..b254081 100644
--- a/src/features/avatar/presets.ts
+++ b/src/features/avatar/presets.ts
@@ -1,5 +1,33 @@
import type { Expression } from './geometry'
+export const bundledExpressionSemanticKeys: Record = {
+ 'expression-00': 'upward-side-glance',
+ 'expression-01': 'downward-gaze',
+ 'expression-02': 'joyful-down-right',
+ 'expression-03': 'surprised-left',
+ 'expression-04': 'sleepy-squint',
+ 'expression-05': 'skeptical-right',
+ 'expression-06': 'small-attentive',
+ 'expression-07': 'angry-right',
+ 'expression-08': 'curious-left',
+ 'expression-09': 'asymmetric-down-right',
+ 'expression-10': 'attentive-left',
+ 'expression-11': 'joyful-wide',
+ 'expression-12': 'wide-downward-gaze',
+ 'expression-13': 'eyes-closed',
+ 'expression-14': 'skeptical-left',
+ 'expression-15': 'far-right-glance',
+ 'expression-16': 'angry-left',
+ 'expression-17': 'playful-right',
+ 'expression-18': 'asymmetric-up-left',
+ 'expression-19': 'gentle-downward-gaze',
+ 'expression-20': 'wide-down-left',
+ 'expression-21': 'surprised-wide-left',
+ 'expression-22': 'drowsy-closed',
+ 'expression-23': 'suspicious-right',
+ 'expression-24': 'shy-downward',
+}
+
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],
@@ -46,6 +74,7 @@ export const initialExpressions: Expression[] = calibrated.map(
index
) => ({
id: `expression-${String(index).padStart(2, '0')}`,
+ semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
headX,
headY,
headZ,
diff --git a/src/features/avatar/surfaces.ts b/src/features/avatar/surfaces.ts
index 98295fd..11f5adb 100644
--- a/src/features/avatar/surfaces.ts
+++ b/src/features/avatar/surfaces.ts
@@ -1,665 +1 @@
-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),
- }
-}
+export * from '@bible-strong/avatar-core'
diff --git a/src/features/export/__tests__/exporter-test.ts b/src/features/export/__tests__/exporter-test.ts
index c3808a6..931a3fa 100644
--- a/src/features/export/__tests__/exporter-test.ts
+++ b/src/features/export/__tests__/exporter-test.ts
@@ -29,6 +29,9 @@ describe('avatar export', () => {
expect(payload).not.toHaveProperty('frames')
expect(payload.avatar.name).toBe('Strobi')
expect(payload.avatar.renderStyle).toEqual({ type: 'vector' })
+ expect(Object.values(payload.expressions).every(expression => !expression.semanticKey)).toBe(
+ true
+ )
})
it('preserves pixel rendering in standalone exports', () => {
diff --git a/src/features/export/exporter.ts b/src/features/export/exporter.ts
index 9f3fc7a..41f04a3 100644
--- a/src/features/export/exporter.ts
+++ b/src/features/export/exporter.ts
@@ -58,9 +58,12 @@ export const createAvatarExportPayload = (
const exportedExpressions = Object.fromEntries(
[...referencedIds].flatMap(expressionId => {
const expression = expressionById.get(expressionId)
- return expression
- ? [[expressionId, applyAvatarEyeDefaults(expression, avatar.eyes)] as const]
- : []
+ if (!expression) return []
+ const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
+ expression,
+ avatar.eyes
+ )
+ return [[expressionId, legacyExpression] as const]
})
)
const usedKeys = new Set()
diff --git a/src/features/export/standaloneEngine.generated.ts b/src/features/export/standaloneEngine.generated.ts
index b1eac8c..38e0a7b 100644
--- a/src/features/export/standaloneEngine.generated.ts
+++ b/src/features/export/standaloneEngine.generated.ts
@@ -1,2 +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,D=(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])},re=(e,t)=>D(e,t,a(e)),ie=(e,t)=>{let n=s(e);if(Number.isFinite(n))return D(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]},ae=(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)}},oe=(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)])}},O=(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])}},se=(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 oe(t,n,r,i,o);case`cube`:return ae(e,t,n,s(e),ie);case`capsule`:{let e=Math.min(r,i),a=Math.max(0,i-e);return oe(t,n,r,e,o,n<-a?-a:n>a?a:n)}case`cylinder`:return O(e,t,n,y,1);case`cursor`:{let r=C(e),i=O({...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 O(e,t,n,S,-1);case`diamond`:return ae(e,t,n,a(e),re)}},ce=(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:re(e,r)}:e.type===`cube`?{point:r,normal:ie(e,r)}:{point:r,normal:ne(e,t,n)}},k=620,A=14,le=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],j=e=>e*Math.PI/180,M=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},ue=([e,t,n,r],[i,a,o,s])=>M([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]),N=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return M([Math.cos(i),e*a,t*a,n*a])},P=(e,t,n)=>{let r=N([1,0,0],e),i=N([0,1,0],t);return ue(ue(N([0,0,1],n),r),i)},F=([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)]},de=(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=k-e[2]*t,r=Math.abs(n)<1e-4?k/1e-4:k/n;return[e[0]*r,e[1]*r,e[2]]},L=(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`:``}`:``,fe=e=>({expression:e,orientation:P(j(e.headX),j(e.headY),j(e.headZ))}),pe=24,me=25,he=73,R=144,ge=33,_e=73,z=new Map,ve=new Map,ye=new Map,B=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(`:`),V=(e,t,n)=>(e.size>=pe&&e.delete(e.keys().next().value),e.set(t,n),n),be=(e,t,n)=>ce(e,t,n),H=(e,t)=>({point:I(F(e.orientation,t.point),e.expression.perspective),normal:F(e.orientation,t.normal)}),xe=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},Se=(e,t,n,r)=>{let[i,a]=xe(n,r);return H(e,se(t,i,a))},U=(e,t,n,r,i={x:0,y:0})=>{let a=e.expression,o=n<0?`Left`:`Right`,s=a[`width${o}`],c=5+(a[`height${o}`]-5)*r,l=n*a.spacing/2+a[`positionX${o}`]+i.x,u=a[`positionY${o}`]+i.y,d=j(n<0?a.leftAngle:a.rightAngle);return de(s,c).map(([n,r])=>{let i=n*Math.cos(d)-r*Math.sin(d),a=n*Math.sin(d)+r*Math.cos(d);return Se(e,t,l+i,u+a)})},Ce=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=>L(e,!1)).join(``)},we=(e,t)=>{let n=B(t),r=ye.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>be(t,j(-180+r*5),j(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>be(t,j(e),j(-90+r*5))));r=V(ye,n,[...e,...i])}return r.map(t=>Ce(t.map(t=>H(e,t))))},W=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)]},G=e=>{if(e.length<3)return L(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`},K=(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]})}),Te=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(``):``,q=(e,t)=>I(F(e.orientation,t),e.expression.perspective),J=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/R*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),Y=(e,t)=>{let n=B(t),r=z.get(n);return r||(r=Array.from({length:ge},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:_e},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),V(z,n,r)),G(K(W(r.map(t=>q(e,t)))))},Ee=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return Y(e,t);let n=t.height/2;return G(K(W([...J(t.width,t.depth,-n),...J(t.width,t.depth,n)].map(t=>q(e,t)))))},De=(e,t)=>{let n=C(t),r=n.bodyHeight/2;return G(K(W([...J(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...J(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>q(e,t)))))},Oe=(e,t)=>{let n=C(t),r=q(e,[0,n.coneApexY,0]);return G(K(W([...J(t.width,t.depth,n.coneBaseY).map(t=>q(e,t)),r])))},ke=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return Y(e,t);let n=q(e,[0,-t.height/2,0]),r=W([...J(t.width,t.depth,t.height/2).map(t=>q(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return G(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?L(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${Te(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Ae=(e,t)=>{if(t.roundness>0)return Y(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return L(W([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>q(e,t))))},je=(e,t)=>{if(t.roundness>0)return Y(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return L(W([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>q(e,t))))},Me=(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}},X=({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`},Z=(e,t,n=[0,0,0])=>{let r=[F(e.orientation,[1,0,0]),F(e.orientation,[0,1,0]),F(e.orientation,[0,0,1])],i=F(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 Me(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=k/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:Me(h,g,b/x,-y/x,v/x)},Ne=(e,t)=>{let n=Z(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 X({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?X(n):null},Pe=(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=>Z(e,s,[t*i,a,o])).filter(e=>e!==null).map(X)},Fe=(e,t)=>t.type===`mickey`?Pe(e,t):t.type===`cursor`?[Oe(e,t)]:[],Ie=e=>Array.from({length:R},(t,n)=>{let r=n/R*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]}),Le=e=>{if(e.length<3)return L(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`},Re=(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=Z(e,o,[0,a,0]),c=Z(e,o,[0,-a,0]);return!s||!c?null:Le(W([...Ie(s),...Ie(c)]))},ze=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Ne(e,t);if(n)return n}if(t.type===`capsule`){let n=Re(e,t);if(n)return n}if(t.type===`cylinder`)return Ee(e,t);if(t.type===`cursor`)return De(e,t);if(t.type===`cone`)return ke(e,t);if(t.type===`cube`)return Ae(e,t);if(t.type===`diamond`)return je(e,t);let n=B(t),r=z.get(n);return r||(r=Array.from({length:me},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:he},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),V(z,n,r)),L(W(r.map(t=>I(F(e.orientation,t),e.expression.perspective))))},Be=(e,t)=>{let n=B(t.surface),r=ve.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(),V(ve,n,r));let i=P(j(t.rotation[0]),j(t.rotation[1]),j(t.rotation[2])),a=W(r.map(n=>{let r=F(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return I(F(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?L(a):G(K(a))},Ve=.1,He=(e,t)=>{let n=P(j(t.rotation[0]),j(t.rotation[1]),j(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>F(e.orientation,F(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},Ue=(e,t)=>{let n=t.map(t=>{let n=F(e.orientation,t.position)[2];return{id:t.id,path:Be(e,t),depth:n,front:n>He(e,t)*Ve}}).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)}},We=(e,t,n=1,r={})=>{let i=U(e,t,-1,n,r.eyeOffset),a=U(e,t,1,n,r.eyeOffset),o=i.map(e=>e.point),s=a.map(e=>e.point),c=Ue(e,r.bodyNodes??[]),l=Fe(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:ze(e,t),leftPath:L(o),rightPath:L(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?[]:we(e,t)}},Ge=e=>e*e*(3-2*e),Q=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},Ke=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,qe=17.29,$=(e,t,n,r)=>{let i=e/r,a=Math.floor(i),o=Ge(i-a),s=Q(a*3+t+n);return s+(Q((a+1)*3+t+n)-s)*o},Je=(e,t,n)=>{let r=1100;if(e<=0)return 0;let i=Math.floor(e/r),a=(e-i*r)/140,o=Ge(Math.min(a,1)),s=i===0?0:Q((i-1)*2+t+n);return s+(Q(i*2+t+n)-s)*o},Ye=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,Xe=(e,t,n=1)=>{let r=Ke(e);if(e.bodyMotion===`slowDrift`)return{x:$(t,3,r,2900)*1.45*n,y:$(t,4,r,3700)*1.1*n};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35*n,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1*n}}return{x:0,y:0}},Ze=(e,t,n=1)=>{if(e.eyeMotion===`microSaccades`)return{x:Je(t,0,qe)*1.5*n,y:Je(t,1,qe)*.9*n};if(e.eyeMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2*n,y:(Math.sin(e*59)+Math.sin(e*83)*.4)*.8*n}}return{x:0,y:0}},Qe=(e,t,n=1)=>{let r={...e},i=Ke(e);if(e.bodyMotion===`slowDrift`)r.headX+=$(t,0,i,2600)*.8*n,r.headY+=$(t,1,i,3300)*1.15*n,r.headZ+=$(t,2,i,4100)*.45*n;else if(e.bodyMotion===`shake`){let e=t/1e3;r.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15*n,r.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35*n,r.headZ+=Math.sin(e*43)*.7*n}return r};return e.ambientBodyOffset=Xe,e.ambientEyeOffset=Ze,e.applyAmbientBodyMotion=Qe,e.applyAmbientMotion=(e,t,n=1)=>{let r=Qe(e,t,n),i=Ze(e,t,n);return r.positionXLeft+=i.x,r.positionXRight+=i.x,r.positionYLeft+=i.y,r.positionYRight+=i.y,r},e.expressionFields=le,e.hasAmbientMotion=Ye,e.poseFromExpression=fe,e.renderAvatar=We,e})({});"
+export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=e=>e*e*(3-2*e),n=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},r=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,i=17.29,a=(e,r,i,a)=>{let o=e/a,s=Math.floor(o),c=t(o-s),l=n(s*3+r+i);return l+(n((s+1)*3+r+i)-l)*c},o=(e,r,i)=>{let a=1100;if(e<=0)return 0;let o=Math.floor(e/a),s=(e-o*a)/140,c=t(Math.min(s,1)),l=o===0?0:n((o-1)*2+r+i);return l+(n(o*2+r+i)-l)*c},s=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,c=(e,t,n=1)=>{let i=r(e);if(e.bodyMotion===`slowDrift`)return{x:a(t,3,i,2900)*1.45*n,y:a(t,4,i,3700)*1.1*n};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35*n,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1*n}}return{x:0,y:0}},l=(e,t,n=1)=>{if(e.eyeMotion===`microSaccades`)return{x:o(t,0,i)*1.5*n,y:o(t,1,i)*.9*n};if(e.eyeMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2*n,y:(Math.sin(e*59)+Math.sin(e*83)*.4)*.8*n}}return{x:0,y:0}},u=(e,t,n=1)=>{let i={...e},o=r(e);if(e.bodyMotion===`slowDrift`)i.headX+=a(t,0,o,2600)*.8*n,i.headY+=a(t,1,o,3300)*1.15*n,i.headZ+=a(t,2,o,4100)*.45*n;else if(e.bodyMotion===`shake`){let e=t/1e3;i.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15*n,i.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35*n,i.headZ+=Math.sin(e*43)*.7*n}return i},d=(e,t,n=1)=>{let r=u(e,t,n),i=l(e,t,n);return r.positionXLeft+=i.x,r.positionXRight+=i.x,r.positionYLeft+=i.y,r.positionYRight+=i.y,r},f=(e,t)=>Math.sign(e)*Math.abs(e)**t,p=(e,t,n,r,i,a,o)=>{let s=f(Math.cos(t),a);return[n/2*s*f(Math.sin(e),o),r/2*f(Math.sin(t),a),i/2*s*f(Math.cos(e),o)]},m=(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)),g=e=>1+h(e.roundness)/2,_=.04,v=e=>e.roundness<=0?1/0:2/(_+h(e.roundness)/2*.96),y=(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)]},b=(e,t,n)=>y(e,t,n,g(e)),x=(e,t,n)=>y(e,t,n,v(e)),ee=.24,te=.2,ne=.22,re=e=>h(e.morphRoundness)/2,ie=(e,t,n)=>{let r=re(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}},S=(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},ae=e=>({tipFraction:(e.tipRoundness??0)*ee,baseFraction:(e.baseRoundness??0)*te}),C=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*ne;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)}},oe=(e,t)=>ie(e,t,C(e,t)),w=(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}=ae(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:S(r,r/2,r/4,0,e),verticalProgress:S(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},T=(e,t)=>ie(e,t,se(e,t)),E=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}},D=(e,t,n)=>{let{width:r,height:i,depth:a}=e;switch(e.type){case`sphere`:case`mickey`:return p(t,n,r,i,a,1,1);case`cube`:return x(e,t,n);case`cylinder`:{let o=oe(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),-i/2+i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}case`cursor`:{let r=E(e),i=(n+Math.PI/2)/Math.PI,a=C({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},i);return[r.bodyWidth/2*a.radiusScale*Math.sin(t),r.bodyCenterY-r.bodyHeight/2+r.bodyHeight*a.verticalProgress,r.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return b(e,t,n);case`capsule`:return m(e,t,n);case`cone`:{let o=T(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),i/2-i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}}},O=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],k=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},ce=(e,t,n)=>{let r=e.type===`cone`?-1:1;return k([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])])},le=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=D(e,t-r,n),a=D(e,t+r,n),o=D(e,t,Math.max(-Math.PI/2,n-r)),s=D(e,t,Math.min(Math.PI/2,n+r));return ce(e,O(a,i),O(s,o))},A=(e,t)=>Math.sign(e)*Math.abs(e)**t,ue=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return k([A(t[0]/r,n-1)/r,A(t[1]/i,n-1)/i,A(t[2]/a,n-1)/a])},de=(e,t)=>ue(e,t,g(e)),fe=(e,t)=>{let n=v(e);if(Number.isFinite(n))return ue(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]},pe=(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)}},me=(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:k([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},j=(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=w(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),_=w(e,h,r),v=(w(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),b=-(o/a)*d/(l*y||1),x=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:k([-b,-x,1])}},he=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return me(t,n,r,i,a);case`cube`:return pe(e,t,n,v(e),fe);case`capsule`:{let e=Math.min(r,i),o=Math.max(0,i-e);return me(t,n,r,e,a,n<-o?-o:n>o?o:n)}case`cylinder`:return j(e,t,n,oe,1);case`cursor`:{let r=E(e),i=j({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,C,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return j(e,t,n,T,-1);case`diamond`:return pe(e,t,n,g(e),de)}},ge=(e,t,n)=>{let r=D(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:k([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:k([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:de(e,r)}:e.type===`cube`?{point:r,normal:fe(e,r)}:{point:r,normal:le(e,t,n)}},M=620,_e=14,ve=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],N=e=>e*Math.PI/180,P=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},F=([e,t,n,r],[i,a,o,s])=>P([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]),I=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return P([Math.cos(i),e*a,t*a,n*a])},L=(e,t,n)=>{let r=I([1,0,0],e),i=I([0,1,0],t);return F(F(I([0,0,1],n),r),i)},R=([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)]},ye=(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<_e;r+=1){let o=n+r/_e*(Math.PI/2);a.push([e+Math.cos(o)*i,t+Math.sin(o)*i])}};return o([-n+i,-r],[n-i,-r]),s(n-i,-r+i,-Math.PI/2),o([n,-r+i],[n,r-i]),s(n-i,r-i,0),o([n-i,r],[-n+i,r]),s(-n+i,r-i,Math.PI/2),o([-n,r-i],[-n,-r+i]),s(-n+i,-r+i,Math.PI),a},z=(e,t)=>{let n=M-e[2]*t,r=Math.abs(n)<1e-4?M/1e-4:M/n;return[e[0]*r,e[1]*r,e[2]]},B=(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`:``}`:``,be=e=>({expression:e,orientation:L(N(e.headX),N(e.headY),N(e.headZ))}),xe=24,Se=25,Ce=73,V=144,we=33,Te=73,H=new Map,Ee=new Map,De=new Map,U=e=>JSON.stringify([e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness]),W=(e,t,n)=>(e.size>=xe&&e.delete(e.keys().next().value),e.set(t,n),n),Oe=(e,t,n)=>ge(e,t,n),ke=(e,t)=>({point:z(R(e.orientation,t.point),e.expression.perspective),normal:R(e.orientation,t.normal)}),Ae=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},je=(e,t,n,r)=>{let[i,a]=Ae(n,r);return ke(e,he(t,i,a))},Me=(e,t,n,r,i={x:0,y:0})=>{let a=e.expression,o=n<0?`Left`:`Right`,s=a[`width${o}`],c=5+(a[`height${o}`]-5)*r,l=n*a.spacing/2+a[`positionX${o}`]+i.x,u=a[`positionY${o}`]+i.y,d=N(n<0?a.leftAngle:a.rightAngle);return ye(s,c).map(([n,r])=>{let i=n*Math.cos(d)-r*Math.sin(d),a=n*Math.sin(d)+r*Math.cos(d);return je(e,t,l+i,u+a)})},Ne=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=>B(e,!1)).join(``)},Pe=(e,t)=>{let n=U(t),r=De.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>Oe(t,N(-180+r*5),N(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>Oe(t,N(e),N(-90+r*5))));r=W(De,n,[...e,...i])}return r.map(t=>Ne(t.map(t=>ke(e,t))))},G=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)]},K=e=>{if(e.length<3)return B(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`},q=(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]})}),Fe=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(``):``,J=(e,t)=>z(R(e.orientation,t),e.expression.perspective),Y=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/V*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),X=(e,t)=>{let n=U(t),r=H.get(n);return r||(r=Array.from({length:we},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:Te},(e,n)=>D(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),W(H,n,r)),K(q(G(r.map(t=>J(e,t)))))},Ie=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return X(e,t);let n=t.height/2;return K(q(G([...Y(t.width,t.depth,-n),...Y(t.width,t.depth,n)].map(t=>J(e,t)))))},Le=(e,t)=>{let n=E(t),r=n.bodyHeight/2;return K(q(G([...Y(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...Y(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>J(e,t)))))},Re=(e,t)=>{let n=E(t),r=J(e,[0,n.coneApexY,0]);return K(q(G([...Y(t.width,t.depth,n.coneBaseY).map(t=>J(e,t)),r])))},ze=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return X(e,t);let n=J(e,[0,-t.height/2,0]),r=G([...Y(t.width,t.depth,t.height/2).map(t=>J(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return K(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?B(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${Fe(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Be=(e,t)=>{if(t.roundness>0)return X(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return B(G([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>J(e,t))))},Ve=(e,t)=>{if(t.roundness>0)return X(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return B(G([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>J(e,t))))},He=(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}},Z=({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`},Q=(e,t,n=[0,0,0])=>{let r=[R(e.orientation,[1,0,0]),R(e.orientation,[0,1,0]),R(e.orientation,[0,0,1])],i=R(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 He(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=M/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:He(h,g,b/x,-y/x,v/x)},Ue=(e,t)=>{let n=Q(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 Z({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?Z(n):null},We=(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=>Q(e,s,[t*i,a,o])).filter(e=>e!==null).map(Z)},Ge=(e,t)=>t.type===`mickey`?We(e,t):t.type===`cursor`?[Re(e,t)]:[],$=e=>Array.from({length:V},(t,n)=>{let r=n/V*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]}),Ke=e=>{if(e.length<3)return B(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`},qe=(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=Q(e,o,[0,a,0]),c=Q(e,o,[0,-a,0]);return!s||!c?null:Ke(G([...$(s),...$(c)]))},Je=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Ue(e,t);if(n)return n}if(t.type===`capsule`){let n=qe(e,t);if(n)return n}if(t.type===`cylinder`)return Ie(e,t);if(t.type===`cursor`)return Le(e,t);if(t.type===`cone`)return ze(e,t);if(t.type===`cube`)return Be(e,t);if(t.type===`diamond`)return Ve(e,t);let n=U(t),r=H.get(n);return r||(r=Array.from({length:Se},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:Ce},(e,n)=>D(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),W(H,n,r)),B(G(r.map(t=>z(R(e.orientation,t),e.expression.perspective))))},Ye=(e,t)=>{let n=U(t.surface),r=Ee.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 D(t.surface,i,r)})}).flat(),W(Ee,n,r));let i=L(N(t.rotation[0]),N(t.rotation[1]),N(t.rotation[2])),a=G(r.map(n=>{let r=R(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return z(R(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?B(a):K(q(a))},Xe=.1,Ze=(e,t)=>{let n=L(N(t.rotation[0]),N(t.rotation[1]),N(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>R(e.orientation,R(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},Qe=(e,t)=>{let n=t.map(t=>{let n=R(e.orientation,t.position)[2];return{id:t.id,path:Ye(e,t),depth:n,front:n>Ze(e,t)*Xe}}).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)}};return e.ambientBodyOffset=c,e.ambientEyeOffset=l,e.applyAmbientBodyMotion=u,e.applyAmbientMotion=d,e.expressionFields=ve,e.hasAmbientMotion=s,e.poseFromExpression=be,e.renderAvatar=(e,t,n=1,r={})=>{let i=Me(e,t,-1,n,r.eyeOffset),a=Me(e,t,1,n,r.eyeOffset),o=i.map(e=>e.point),s=a.map(e=>e.point),c=Qe(e,r.bodyNodes??[]),l=Ge(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:Je(e,t),leftPath:B(o),rightPath:B(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?[]:Pe(e,t)}},e})({});"
diff --git a/src/features/studio/__tests__/studio-document-test.ts b/src/features/studio/__tests__/studio-document-test.ts
index ff2e146..9dcbf94 100644
--- a/src/features/studio/__tests__/studio-document-test.ts
+++ b/src/features/studio/__tests__/studio-document-test.ts
@@ -1,9 +1,12 @@
import { createAvatar } from '@/features/avatar/avatars'
import { createInitialSequences } from '@/features/animation/sequences'
import { initialExpressions } from '@/features/avatar/presets'
+import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
import {
+ clearPersistedStudioDocument,
createStudioDocumentStore,
loadStudioDocument,
+ parseStudioDocument,
parseImportedStudioDocument,
serializeStudioDocument,
type StudioDocument,
@@ -34,15 +37,71 @@ describe('Studio document', () => {
expect(document.library.avatars[0].name).toBe('Strobi')
expect(document.expressions).toHaveLength(27)
expect(document.sequences).toHaveLength(23)
+ expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
+ expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
})
+ it('clears only the persisted Studio project', () => {
+ const removeItem = vi.fn()
+
+ expect(clearPersistedStudioDocument({ removeItem })).toBe(true)
+ expect(removeItem).toHaveBeenCalledExactlyOnceWith('bible-strong-avatar-studio-v2')
+ })
+
it('keeps a locally saved project authoritative over the bundled snapshot', () => {
const localDocument = documentFixture()
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
})
+ it('restores bundled semantic keys in a legacy local project', () => {
+ const fallback = loadStudioDocument(storage())
+ const legacy = structuredClone(fallback)
+ legacy.expressions.forEach(expression => delete expression.semanticKey)
+ legacy.sequences.forEach(sequence => delete sequence.semanticKey)
+
+ const document = parseStudioDocument(legacy, fallback)
+
+ expect(document.expressions.map(expression => expression.semanticKey)).toEqual(
+ fallback.expressions.map(expression => expression.semanticKey)
+ )
+ expect(document.sequences.map(sequence => sequence.semanticKey)).toEqual(
+ fallback.sequences.map(sequence => sequence.semanticKey)
+ )
+ expect(
+ createAvatarDefinition({
+ avatar: document.library.avatars[0],
+ behavior: { expressions: document.expressions, sequences: document.sequences },
+ }).ok
+ ).toBe(true)
+ })
+
+ it('restores bundled keys in legacy avatar-owned behavior without naming custom items', () => {
+ const fallback = loadStudioDocument(storage())
+ const legacy = structuredClone(fallback)
+ const avatar = legacy.library.avatars[0]
+ avatar.behavior = {
+ expressions: legacy.expressions.map(expression => ({
+ ...expression,
+ semanticKey: undefined,
+ })),
+ sequences: legacy.sequences.map(sequence => ({ ...sequence, semanticKey: undefined })),
+ }
+ avatar.behavior.expressions.push({
+ ...avatar.behavior.expressions[0],
+ id: 'expression-custom',
+ semanticKey: undefined,
+ })
+
+ const document = parseStudioDocument(legacy, fallback)
+ const behavior = document.library.avatars[0].behavior!
+
+ expect(behavior.expressions[0].semanticKey).toBe(fallback.expressions[0].semanticKey)
+ expect(behavior.sequences[0].semanticKey).toBe(fallback.sequences[0].semanticKey)
+ expect(behavior.expressions.at(-1)?.semanticKey).toBeUndefined()
+ })
+
it('persists one coherent document after a mutation', () => {
const persisted: StudioDocument[] = []
const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
@@ -71,9 +130,10 @@ describe('Studio document', () => {
it('round-trips a complete project document as portable JSON', () => {
const document = documentFixture()
- const expression = { ...initialExpressions[0], widthLeft: 42 }
+ const expression = { ...initialExpressions[0], semanticKey: 'happy-smile', widthLeft: 42 }
const sequence = {
...createInitialSequences()[0],
+ semanticKey: 'happy',
steps: createInitialSequences()[0].steps.map(step => ({
...step,
expressionId: expression.id,
@@ -88,6 +148,26 @@ describe('Studio document', () => {
expect(imported).toEqual(document)
expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
+ expect(imported.expressions[0].semanticKey).toBe(initialExpressions[0].semanticKey)
+ expect(imported.library.avatars[0].behavior?.expressions[0].semanticKey).toBe('happy-smile')
+ expect(imported.library.avatars[0].behavior?.sequences[0].semanticKey).toBe('happy')
+ })
+
+ it('preserves semantic keys in the base behavior parser', () => {
+ const document = documentFixture()
+ document.expressions = document.expressions.map((expression, index) => ({
+ ...expression,
+ ...(index === 0 ? { semanticKey: 'attentive' } : {}),
+ }))
+ document.sequences = document.sequences.map((sequence, index) => ({
+ ...sequence,
+ ...(index === 0 ? { semanticKey: 'sleeping' } : {}),
+ }))
+
+ const imported = parseImportedStudioDocument(serializeStudioDocument(document), document)
+
+ expect(imported.expressions[0].semanticKey).toBe('attentive')
+ expect(imported.sequences[0].semanticKey).toBe('sleeping')
})
it('keeps the base library unchanged when an avatar owns customized behavior', () => {
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx
index 75ea493..06e6b56 100644
--- a/src/features/studio/components/StudioInspector.tsx
+++ b/src/features/studio/components/StudioInspector.tsx
@@ -1,5 +1,8 @@
import {
ArrowLeft,
+ CircleCheck,
+ ChevronDown,
+ ChevronUp,
Copy,
Download,
FileCode2,
@@ -10,10 +13,16 @@ import {
RotateCcw,
Smile,
Trash2,
+ TriangleAlert,
Upload,
} from 'lucide-react'
-import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
-import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
+import { AnimatePresence, motion } from 'motion/react'
+import { type CSSProperties, useRef, useState } from 'react'
+
+import {
+ Avatar as RuntimeAvatar,
+ type AvatarController as RuntimeAvatarController,
+} from '@bible-strong/avatar-react'
import { Accordion } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
@@ -65,11 +74,51 @@ import {
import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
-import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
+import { AvatarDrawer } from '@/features/studio/components/AvatarDrawer'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController'
+const runtimeInstallExample =
+ 'npm install @bible-strong/avatar-core @bible-strong/avatar-react react react-dom'
+
+const runtimeReactExample = `import { validateAvatarDefinition } from '@bible-strong/avatar-core'
+import { Avatar } from '@bible-strong/avatar-react'
+import '@bible-strong/avatar-react/styles.css'
+import avatarJson from './strobi.avatar.json'
+
+const avatar = validateAvatarDefinition(avatarJson)
+if (!avatar.ok) throw new Error(avatar.errors[0].message)
+
+export function Strobi() {
+ return
+}`
+
+const codeTokenPattern =
+ /(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return)\b)/g
+
+const highlightedCode = (source: string) =>
+ source.split(codeTokenPattern).map((token, index) => {
+ if (!token) return null
+ const kind = token.startsWith('//')
+ ? 'comment'
+ : token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
+ ? 'string'
+ : token.startsWith('<')
+ ? 'tag'
+ : /^(?:npm|install|import|from|const|if|throw|new|export|function|return)$/.test(token)
+ ? 'keyword'
+ : 'plain'
+ return (
+
+ {token}
+
+ )
+ })
+
export function StudioInspector({ controller }: { controller: StudioController }) {
+ const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false)
+ const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false)
+ const runtimeExampleRef = useRef(null)
const {
activateAvatar,
activeAvatar,
@@ -96,9 +145,12 @@ export function StudioInspector({ controller }: { controller: StudioController }
commitAvatarMove,
commitExpressionMove,
commitStateMove,
+ clearLocalStudioDocument,
+ copyAvatarRuntimeDefinition,
createNewAvatar,
deleteSelectedBodyNode,
downloadAvatarExport,
+ downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
@@ -117,6 +169,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
exportFormat,
expression,
expressionById,
+ expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
@@ -141,6 +194,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
renameActiveAvatar,
renderedColors,
renderedScene,
+ runtimeDefinitionResult,
+ runtimeCopyStatus,
+ runtimeExportErrors,
+ runtimeStandardAvailability,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@@ -151,6 +208,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
selectedSequenceStepId,
selectedState,
sequenceEditing,
+ animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
@@ -269,9 +327,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
duration: reduceMotion ? 0 : undefined,
})
}
-
return (
-
+
@@ -319,6 +376,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
onSave={saveSequenceEditing}
onDuplicate={duplicateSequenceEditing}
onDelete={() => setDeleteSequenceOpen(true)}
+ semanticKeyError={animationSemanticKeyError(sequenceEditing.draft)}
/>
)}
@@ -339,6 +397,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
onSave={saveEditing}
onDuplicate={() => duplicateExpression(editing.index, editing.draft, true)}
onDelete={() => setDeleteExpressionOpen(true)}
+ semanticKeyError={expressionSemanticKeyError(editing.draft)}
/>
)}
@@ -389,33 +448,18 @@ export function StudioInspector({ controller }: { controller: StudioController }
>
{!editorPageOpen && (
-
-
{activeAvatar.name}
-
- {t(
- mode === 'manual'
- ? 'Pose'
- : mode === 'avatars'
- ? 'Avatars'
- : mode === 'expressions'
- ? 'Expressions'
- : mode === 'states'
- ? 'Animations'
- : 'Exporter'
- )}
-
-
- {mode === 'manual' && (
- transitionToExpression({ ...defaultExpression })}
- >
-
-
- )}
+ {activeAvatar.name}
+
+ {t(
+ mode === 'manual'
+ ? 'Pose'
+ : mode === 'expressions'
+ ? 'Expressions'
+ : mode === 'states'
+ ? 'Animations'
+ : 'Exporter'
+ )}
+
)}
@@ -1350,13 +1394,20 @@ export function StudioInspector({ controller }: { controller: StudioController }
aria-label={t('Afficher le maillage')}
/>
+
transitionToExpression({ ...defaultExpression })}
+ >
+ {t('Réinitialiser')}
+
>
)}
)}
- {!editorPageOpen && mode === 'avatars' && }
{!sequenceEditing && !editing && bodyEditing && (
@@ -1422,6 +1473,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
openExpressionEditor(index, preset)
setDeleteExpressionOpen(true)
}}
+ runtimeError={expressionSemanticKeyError(preset)}
draggable
onDragStart={event => {
expressionDragOrigin.current = expressions
@@ -1563,6 +1615,16 @@ export function StudioInspector({ controller }: { controller: StudioController }
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
+ {animationSemanticKeyError(sequence) && (
+
+ !
+
+ )}
{sequence.builtIn ? t(sequence.name) : sequence.name}
)
@@ -1630,11 +1692,168 @@ export function StudioInspector({ controller }: { controller: StudioController }
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
-
+
+
+
+
+
+
+ {t('Définition runtime')}
+ {activeAvatar.name}
+ {t('Fichier .avatar.json portable')}
+
+
+
+
+
+
+ {runtimeDefinitionResult.ok ?
:
}
+
+
+ {t(
+ runtimeDefinitionResult.ok
+ ? 'Prêt pour l’export runtime'
+ : 'Export runtime incomplet'
+ )}
+
+ {runtimeDefinitionResult.ok && runtimeStandardAvailability && (
+
+ {runtimeStandardAvailability.available.length}/6{' '}
+ {t('animations standard disponibles')}
+
+ )}
+
+
+ {runtimeExportErrors.length > 0 && (
+ <>
+
+ {runtimeExportErrors.map((error, index) => (
+ {error}
+ ))}
+
+
+
+ {t('Effacer le projet local et recharger')}
+
+ >
+ )}
+
+
+
+
+
+
{t('Installation')}
+
+ {highlightedCode(runtimeInstallExample)}
+
+
+
+
{t('Exemple React minimal')}
+
+ {highlightedCode(runtimeReactExample)}
+
+
+ setRuntimeExampleOpen(open => !open)}
+ >
+
+ {t(runtimeExampleOpen ? 'Masquer l’aperçu' : 'Lancer l’exemple')}
+
+ {runtimeExampleOpen && runtimeDefinitionResult.ok && (
+
+
+
+ {t('Aperçu avec le package React')}
+ defaultAnimation="idle"
+
+
runtimeExampleRef.current?.play('idle')}
+ >
+
+ {t('Relancer')}
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ {t('Télécharger la définition .avatar.json')}
+
+ void copyAvatarRuntimeDefinition()}
+ >
+
+ {t('Copier le JSON formaté')}
+
+
+ {runtimeCopyStatus !== 'idle' && (
+
+ {t(
+ runtimeCopyStatus === 'success'
+ ? 'JSON runtime copié dans le presse-papiers.'
+ : 'Impossible de copier le JSON runtime.'
+ )}
+
+ )}
+
+
@@ -1924,44 +2143,79 @@ export function StudioInspector({ controller }: { controller: StudioController }
{activeSequence && !editorPageOpen && (
-
-
- snapPlaybackFooter(!statePlayerExpanded)}
- onDragStart={() => {
- playbackFooterDragOriginY.current = playbackFooterY.get()
- }}
- onDrag={(_, info) => {
- playbackFooterY.set(
- Math.min(
- playbackFooterCollapsedOffset,
- Math.max(0, playbackFooterDragOriginY.current + info.offset.y)
- )
- )
- }}
- onDragEnd={(_, info) => {
- playbackHandleY.set(0)
- const projectedY = playbackFooterY.get() + info.velocity.y * 0.16
- snapPlaybackFooter(projectedY < playbackFooterCollapsedOffset / 2)
- }}
- >
-
-
+ setStatePlayerExpanded(expanded => !expanded)}
+ >
+ {statePlayerExpanded ? : }
+
+ {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) => {
@@ -2012,62 +2266,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
/>
-
-
-
-
-
{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')}
-
-
-
-
)}
{!editorPageOpen && (
@@ -2076,9 +2274,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
className="mobile-mode-tab mobile-avatar-tab"
variant="ghost"
type="button"
- aria-pressed={mode === 'avatars'}
+ aria-pressed={avatarDrawerOpen}
aria-label={t('Choisir un avatar')}
- onClick={() => setMode('avatars')}
+ onClick={() => setAvatarDrawerOpen(true)}
>
)}
+
)
diff --git a/src/features/studio/defaultStudioDocument.json b/src/features/studio/defaultStudioDocument.json
index 49dc6c2..141295f 100644
--- a/src/features/studio/defaultStudioDocument.json
+++ b/src/features/studio/defaultStudioDocument.json
@@ -686,6 +686,7 @@
"expressions": [
{
"id": "expression-00",
+ "semanticKey": "upward-side-glance",
"headX": 7.3,
"headY": 27.8,
"headZ": -16.1,
@@ -706,6 +707,7 @@
},
{
"id": "expression-01",
+ "semanticKey": "downward-gaze",
"headX": -15.057812500000004,
"headY": 0.14296874999999964,
"headZ": -14.549218750000001,
@@ -726,6 +728,7 @@
},
{
"id": "expression-05",
+ "semanticKey": "skeptical-right",
"headX": -16.528515625,
"headY": -3.7679687499999996,
"headZ": -13.7296875,
@@ -746,6 +749,7 @@
},
{
"id": "expression-06",
+ "semanticKey": "small-attentive",
"headX": -4.232421875000001,
"headY": 14.362109375000003,
"headZ": 11.204296875,
@@ -766,6 +770,7 @@
},
{
"id": "expression-12",
+ "semanticKey": "wide-downward-gaze",
"headX": -19.20859375,
"headY": 15.2,
"headZ": 11.8,
@@ -786,6 +791,7 @@
},
{
"id": "expression-03",
+ "semanticKey": "surprised-left",
"headX": 2.9468749999999986,
"headY": -16.051171875,
"headZ": -20.916015625,
@@ -806,6 +812,7 @@
},
{
"id": "expression-04",
+ "semanticKey": "sleepy-squint",
"headX": 3.4,
"headY": 13.22578125,
"headZ": 8.976953125,
@@ -826,6 +833,7 @@
},
{
"id": "expression-07",
+ "semanticKey": "angry-right",
"headX": 8.063671874999999,
"headY": 17.626562500000002,
"headZ": -11.116796874999999,
@@ -846,6 +854,7 @@
},
{
"id": "expression-08",
+ "semanticKey": "curious-left",
"headX": -12.303515625,
"headY": -17.601171875,
"headZ": 5.9109375,
@@ -866,6 +875,7 @@
},
{
"id": "expression-09",
+ "semanticKey": "asymmetric-down-right",
"headX": -20.058203125,
"headY": 12.607421875,
"headZ": -12.7,
@@ -886,6 +896,7 @@
},
{
"id": "expression-10",
+ "semanticKey": "attentive-left",
"headX": 1.43359375,
"headY": 6.194140624999999,
"headZ": 10.56015625,
@@ -906,6 +917,7 @@
},
{
"id": "expression-11",
+ "semanticKey": "joyful-wide",
"headX": -2.092968750000001,
"headY": -15.899609374999999,
"headZ": -14.469921875,
@@ -926,6 +938,7 @@
},
{
"id": "expression-13",
+ "semanticKey": "eyes-closed",
"headX": -8.752343750000001,
"headY": -8.743359375,
"headZ": -10.773828125000001,
@@ -946,6 +959,7 @@
},
{
"id": "expression-02",
+ "semanticKey": "joyful-down-right",
"headX": -15.287109375000002,
"headY": 15.006640625,
"headZ": 12.787890625,
@@ -966,6 +980,7 @@
},
{
"id": "expression-14",
+ "semanticKey": "skeptical-left",
"headX": 3.5292968750000004,
"headY": -7.0765625,
"headZ": 9.830078125,
@@ -986,6 +1001,7 @@
},
{
"id": "expression-15",
+ "semanticKey": "far-right-glance",
"headX": 0.31914062500000184,
"headY": 35.307421874999996,
"headZ": -10.904296875,
@@ -1006,6 +1022,7 @@
},
{
"id": "expression-16",
+ "semanticKey": "angry-left",
"headX": -14.750781250000001,
"headY": -19.350000000000005,
"headZ": 5.631640624999998,
@@ -1026,6 +1043,7 @@
},
{
"id": "expression-17",
+ "semanticKey": "playful-right",
"headX": -4.3953125,
"headY": 14.07265625,
"headZ": -16.126171874999997,
@@ -1046,6 +1064,7 @@
},
{
"id": "expression-18",
+ "semanticKey": "asymmetric-up-left",
"headX": 6.585546875,
"headY": 4.737109375000001,
"headZ": 12.840234374999998,
@@ -1066,6 +1085,7 @@
},
{
"id": "expression-19",
+ "semanticKey": "gentle-downward-gaze",
"headX": -6.077734375000001,
"headY": -11.03515625,
"headZ": -13.965625000000001,
@@ -1086,6 +1106,7 @@
},
{
"id": "expression-20",
+ "semanticKey": "wide-down-left",
"headX": -17.127734375000003,
"headY": 18.070703124999998,
"headZ": 13.891796875,
@@ -1106,6 +1127,7 @@
},
{
"id": "expression-21",
+ "semanticKey": "surprised-wide-left",
"headX": -5.428125,
"headY": -11.71328125,
"headZ": -13.472265625000002,
@@ -1126,6 +1148,7 @@
},
{
"id": "expression-22",
+ "semanticKey": "drowsy-closed",
"headX": 10.292578125,
"headY": 3.39921875,
"headZ": 7.583203125,
@@ -1146,6 +1169,7 @@
},
{
"id": "expression-23",
+ "semanticKey": "suspicious-right",
"headX": -17.8,
"headY": 10,
"headZ": -10.894921875,
@@ -1166,6 +1190,7 @@
},
{
"id": "expression-24",
+ "semanticKey": "shy-downward",
"headX": 7.131640624999998,
"headY": 7.7828124999999995,
"headZ": 3.935546874999999,
@@ -1186,6 +1211,7 @@
},
{
"id": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
+ "semanticKey": "angry-brows",
"headX": 10.473974503042374,
"headY": 5.087293619785961,
"headZ": 4.698252132317348,
@@ -1208,6 +1234,7 @@
},
{
"id": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
+ "semanticKey": "uneasy-left",
"headX": -12.303515625,
"headY": -17.601171875,
"headZ": 5.9109375,
@@ -1231,6 +1258,7 @@
"sequences": [
{
"id": "sleeping",
+ "semanticKey": "sleeping",
"name": "sleeping",
"group": "Cycle de vie",
"description": "Yeux presque fermés, respiration lente et expression de sommeil.",
@@ -1269,6 +1297,7 @@
},
{
"id": "waking",
+ "semanticKey": "waking",
"name": "waking",
"group": "Cycle de vie",
"description": "Séquence courte de réveil avant retour vers une expression neutre.",
@@ -1293,6 +1322,7 @@
},
{
"id": "idle",
+ "semanticKey": "idle",
"name": "idle",
"group": "Cycle de vie",
"description": "Micro-mouvements lents, expressions 00 et 08, clignement rare.",
@@ -1324,6 +1354,7 @@
},
{
"id": "listening",
+ "semanticKey": "listening",
"name": "listening",
"group": "Cycle de vie",
"description": "Expressions 10, 01 et 19, regard stable et clignement attentif.",
@@ -1362,6 +1393,7 @@
},
{
"id": "thinking",
+ "semanticKey": "thinking",
"name": "thinking",
"group": "Cycle de vie",
"description": "Regard haut et latéral, expressions asymétriques et changements fréquents.",
@@ -1414,6 +1446,7 @@
},
{
"id": "searching",
+ "semanticKey": "searching",
"name": "searching",
"group": "Cycle de vie",
"description": "Balayage rapide et changements très fréquents.",
@@ -1473,6 +1506,7 @@
},
{
"id": "working",
+ "semanticKey": "working",
"name": "working",
"group": "Cycle de vie",
"description": "Rythme régulier et expressions concentrées.",
@@ -1518,6 +1552,7 @@
},
{
"id": "excited",
+ "semanticKey": "excited",
"name": "excited",
"group": "Réactions",
"description": "Grandes expressions et transitions rapides.",
@@ -1570,6 +1605,7 @@
},
{
"id": "bored",
+ "semanticKey": "bored",
"name": "bored",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1608,6 +1644,7 @@
},
{
"id": "suspicious",
+ "semanticKey": "suspicious",
"name": "suspicious",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1646,6 +1683,7 @@
},
{
"id": "angry",
+ "semanticKey": "angry",
"name": "angry",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1677,6 +1715,7 @@
},
{
"id": "drowsy",
+ "semanticKey": "drowsy",
"name": "drowsy",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1715,6 +1754,7 @@
},
{
"id": "happy",
+ "semanticKey": "happy",
"name": "happy",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1760,6 +1800,7 @@
},
{
"id": "curious",
+ "semanticKey": "curious",
"name": "curious",
"group": "Réactions",
"description": "Inclinaisons et forte asymétrie.",
@@ -1805,6 +1846,7 @@
},
{
"id": "confused",
+ "semanticKey": "confused",
"name": "confused",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1843,6 +1885,7 @@
},
{
"id": "surprised",
+ "semanticKey": "surprised",
"name": "surprised",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1874,6 +1917,7 @@
},
{
"id": "proud",
+ "semanticKey": "proud",
"name": "proud",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1912,6 +1956,7 @@
},
{
"id": "shy",
+ "semanticKey": "shy",
"name": "shy",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1950,6 +1995,7 @@
},
{
"id": "sad",
+ "semanticKey": "sad",
"name": "sad",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -1988,6 +2034,7 @@
},
{
"id": "laughing",
+ "semanticKey": "laughing",
"name": "laughing",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -2026,6 +2073,7 @@
},
{
"id": "scared",
+ "semanticKey": "scared",
"name": "scared",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -2057,6 +2105,7 @@
},
{
"id": "playful",
+ "semanticKey": "playful",
"name": "playful",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@@ -2102,6 +2151,7 @@
},
{
"id": "celebrate",
+ "semanticKey": "celebrate",
"name": "celebrate",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
diff --git a/src/features/studio/studioDocument.ts b/src/features/studio/studioDocument.ts
index 70b8f4b..84b374d 100644
--- a/src/features/studio/studioDocument.ts
+++ b/src/features/studio/studioDocument.ts
@@ -1,6 +1,7 @@
import {
parseAvatarLibrary,
parseExpressions,
+ restoreLegacyBehaviorSemanticKeys,
type AvatarBehaviorLibrary,
type AvatarLibrary,
} from '../avatar/avatars'
@@ -49,13 +50,16 @@ export const parseStudioDocument = (value: unknown, fallback: StudioDocument): S
const sequences = Array.isArray(candidate.sequences)
? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions)
: fallback.sequences
- const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences }
+ const baseBehavior = restoreLegacyBehaviorSemanticKeys(
+ { expressions, sequences },
+ { expressions: fallback.expressions, sequences: fallback.sequences }
+ )
const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior)
return {
version: 2,
library,
- expressions,
- sequences,
+ expressions: baseBehavior.expressions,
+ sequences: baseBehavior.sequences,
playback: parsePlayback(candidate.playback, fallback.playback),
}
}
@@ -119,6 +123,17 @@ export const persistStudioDocument = (document: StudioDocument) => {
}
}
+export const clearPersistedStudioDocument = (
+ storage: Pick = window.localStorage
+) => {
+ try {
+ storage.removeItem(DOCUMENT_STORAGE_KEY)
+ return true
+ } catch {
+ return false
+ }
+}
+
export const createStudioDocumentStore = (
initial: StudioDocument,
persist: (document: StudioDocument) => void = persistStudioDocument
diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts
index 352ac67..6800878 100644
--- a/src/features/studio/useStudioController.ts
+++ b/src/features/studio/useStudioController.ts
@@ -45,9 +45,17 @@ import {
applyAmbientBodyMotion,
hasAmbientMotion,
} from '@/features/avatar/ambientMotion'
+import {
+ avatarDefinitionFileName,
+ createAvatarDefinition,
+ getSemanticKeyIssue,
+ getStandardAnimationAvailabilityV1,
+ type SemanticKeyIssueCode,
+} from '@/features/avatar/avatarDefinition'
import {
cloneAvatarBehavior,
createAvatar,
+ createUnkeyedExpressionCopy,
defaultAvatarEyes,
resolveAvatarBehavior,
type AvatarBehaviorLibrary,
@@ -106,6 +114,7 @@ import {
} from '@/features/rendering/renderedScene'
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
import {
+ clearPersistedStudioDocument,
createStudioDocumentStore,
loadStudioDocument,
parseImportedStudioDocument,
@@ -147,6 +156,7 @@ export function useStudioController() {
const [snapshotSize, setSnapshotSize] = useState('1024')
const [snapshotFormat, setSnapshotFormat] = useState('png')
const [photoFlash, setPhotoFlash] = useState(0)
+ const [runtimeCopyStatus, setRuntimeCopyStatus] = useState<'idle' | 'success' | 'error'>('idle')
const initialStatePlayback = initialDocument.playback
const updateStudioLibrary = (library: typeof initialDocument.library) =>
documentStore.update({ library })
@@ -1192,7 +1202,7 @@ export function useStudioController() {
}
const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => {
- const duplicate = { ...draft, id: createExpressionId() }
+ const duplicate = createUnkeyedExpressionCopy(draft, createExpressionId())
const next = [...expressions, duplicate]
const duplicateIndex = next.length - 1
setExpressions(next)
@@ -1281,7 +1291,8 @@ export function useStudioController() {
setMode('expressions')
setEditing({
index,
- draft: { ...draft, id: index === null ? createExpressionId() : draft.id },
+ draft:
+ index === null ? createUnkeyedExpressionCopy(draft, createExpressionId()) : { ...draft },
})
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (avatar) setDisplayColors(resolveColors(draft, avatar.colors))
@@ -1478,6 +1489,79 @@ export function useStudioController() {
: activeSequence.name
: null
const expressionById = new Map(expressions.map(item => [item.id, item]))
+ const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
+ t(
+ issue === 'missing_semantic_key'
+ ? 'Ajoute une clé pour inclure cet élément dans l’export runtime.'
+ : issue === 'invalid_semantic_key'
+ ? 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.'
+ : issue === 'reserved_semantic_key'
+ ? 'neutral est réservé à l’apparence neutre de l’avatar.'
+ : 'Cette clé est déjà utilisée dans cette bibliothèque.'
+ )
+ const expressionSemanticKeyError = (draft: Expression) => {
+ const issue = getSemanticKeyIssue(draft.semanticKey, 'expression')
+ if (issue) return semanticKeyIssueMessage(issue)
+ if (
+ expressions.some(
+ expression => expression.id !== draft.id && expression.semanticKey === draft.semanticKey
+ )
+ ) {
+ return semanticKeyIssueMessage('duplicate_semantic_key')
+ }
+ return null
+ }
+ const animationSemanticKeyError = (draft: AvatarSequence) => {
+ const issue = getSemanticKeyIssue(draft.semanticKey, 'animation')
+ if (issue) return semanticKeyIssueMessage(issue)
+ if (
+ sequences.some(
+ sequence => sequence.id !== draft.id && sequence.semanticKey === draft.semanticKey
+ )
+ ) {
+ return semanticKeyIssueMessage('duplicate_semantic_key')
+ }
+ return null
+ }
+ const runtimeDefinitionResult = createAvatarDefinition({
+ avatar: activeAvatar,
+ behavior: { expressions, sequences },
+ })
+ const runtimeExportErrors = runtimeDefinitionResult.ok
+ ? []
+ : [
+ ...new Set(
+ runtimeDefinitionResult.errors
+ .filter(
+ error =>
+ error.code !== 'unresolved_expression_reference' ||
+ !runtimeDefinitionResult.errors.some(candidate =>
+ candidate.path.startsWith('/studio/expressions/')
+ )
+ )
+ .map(error => {
+ const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
+ if (expressionMatch) {
+ const index = Number(expressionMatch[1])
+ const item = expressions[index]
+ return `${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
+ }
+ const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
+ if (animationMatch) {
+ const index = Number(animationMatch[1])
+ const item = sequences[index]
+ if (error.code === 'unresolved_expression_reference') {
+ return `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
+ }
+ return `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
+ }
+ return `${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`
+ })
+ ),
+ ]
+ const runtimeStandardAvailability = runtimeDefinitionResult.ok
+ ? getStandardAnimationAvailabilityV1(runtimeDefinitionResult.value.expressions)
+ : null
const exportAnimationIdSet = new Set(exportAnimationIds)
const selectedExportAnimations = sequences.filter(animation =>
exportAnimationIdSet.has(animation.id)
@@ -1499,6 +1583,28 @@ export function useStudioController() {
: generateJavaScriptAvatarPackage(payload, language)
downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
}
+ const downloadAvatarRuntimeDefinition = () => {
+ if (!runtimeDefinitionResult.ok) return
+ downloadBlob(
+ new Blob([JSON.stringify(runtimeDefinitionResult.value, null, 2)], {
+ type: 'application/json;charset=utf-8',
+ }),
+ avatarDefinitionFileName(activeAvatar.name)
+ )
+ }
+ const copyAvatarRuntimeDefinition = async () => {
+ if (!runtimeDefinitionResult.ok) return
+ try {
+ if (!navigator.clipboard) throw new Error('Clipboard API unavailable')
+ await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
+ setRuntimeCopyStatus('success')
+ } catch {
+ setRuntimeCopyStatus('error')
+ }
+ }
+ const clearLocalStudioDocument = () => {
+ if (clearPersistedStudioDocument()) window.location.reload()
+ }
const currentStudioDocument = (): StudioDocument => ({
version: 2,
library: { activeAvatarId, avatars },
@@ -1772,7 +1878,9 @@ export function useStudioController() {
commitBodyNode,
commitExpressionMove,
commitStateMove,
+ clearLocalStudioDocument,
confirmStudioProjectImport,
+ copyAvatarRuntimeDefinition,
createNewAvatar,
deleteActiveAvatar,
deleteAvatarOpen,
@@ -1782,6 +1890,7 @@ export function useStudioController() {
deleteSequenceEditing,
deleteSequenceOpen,
downloadAvatarExport,
+ downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
@@ -1800,6 +1909,7 @@ export function useStudioController() {
exportFormat,
expression,
expressionById,
+ expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
@@ -1832,6 +1942,10 @@ export function useStudioController() {
renderedColors,
renderedRotationGizmo,
renderedScene,
+ runtimeDefinitionResult,
+ runtimeCopyStatus,
+ runtimeExportErrors,
+ runtimeStandardAvailability,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@@ -1843,6 +1957,7 @@ export function useStudioController() {
selectedSequenceStepId,
selectedState,
sequenceEditing,
+ animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
diff --git a/src/i18n/__tests__/i18n-test.ts b/src/i18n/__tests__/i18n-test.ts
index e64a8c3..078fa75 100644
--- a/src/i18n/__tests__/i18n-test.ts
+++ b/src/i18n/__tests__/i18n-test.ts
@@ -31,6 +31,50 @@ describe('avatar studio translations', () => {
expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式')
})
+ it('keeps runtime-export authoring copy synchronized in all three languages', () => {
+ expect(translateStudioText('Clé sémantique', 'fr')).toBe('Clé sémantique')
+ expect(translateStudioText('Clé sémantique', 'en')).toBe('Semantic key')
+ expect(translateStudioText('Clé sémantique', 'zh-CN')).toBe('语义键')
+ expect(translateStudioText('Exporter le JSON runtime', 'en')).toBe('Export runtime JSON')
+ expect(translateStudioText('Exporter le JSON runtime', 'zh-CN')).toBe('导出运行时 JSON')
+ expect(translateStudioText('Nouveau', 'en')).toBe('New')
+ expect(translateStudioText('Nouveau', 'zh-CN')).toBe('新增')
+ expect(translateStudioText('Démarrage rapide npm', 'en')).toBe('npm quick start')
+ expect(translateStudioText('Démarrage rapide npm', 'zh-CN')).toBe('npm 快速开始')
+ expect(translateStudioText('Lancer l’exemple', 'en')).toBe('Run example')
+ expect(translateStudioText('Lancer l’exemple', 'zh-CN')).toBe('运行示例')
+ expect(
+ translateStudioText(
+ 'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.',
+ 'en'
+ )
+ ).toBe('Export the .avatar.json file used by the new npm packages.')
+ expect(
+ translateStudioText(
+ 'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.',
+ 'zh-CN'
+ )
+ ).toBe('生成原有的 React 或 JavaScript 独立 ZIP 导出。')
+ expect(translateStudioText('Export runtime incomplet', 'en')).toBe(
+ 'Runtime export is incomplete'
+ )
+ expect(translateStudioText('Export runtime incomplet', 'zh-CN')).toBe('运行时导出不完整')
+ expect(translateStudioText('Effacer le projet local et recharger', 'en')).toBe(
+ 'Clear local project and reload'
+ )
+ expect(translateStudioText('Effacer le projet local et recharger', 'zh-CN')).toBe(
+ '清除本地项目并重新加载'
+ )
+ expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
+ expect(translateStudioText('Copier le JSON formaté', 'zh-CN')).toBe('复制格式化的 JSON')
+ expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'en')).toBe(
+ 'Runtime JSON copied to the clipboard.'
+ )
+ expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'zh-CN')).toBe(
+ '运行时 JSON 已复制到剪贴板。'
+ )
+ })
+
it('covers every configured state description in English', () => {
expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe(
'Steady rhythm and focused expressions.'
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index b51513c..4909446 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -61,6 +61,23 @@ const english: Record = {
'Retour au studio': 'Back to studio',
'Preset en mémoire': 'Saved preset',
'Nouvelle expression': 'New expression',
+ 'Identité runtime': 'Runtime identity',
+ 'Nom public stable utilisé par les applications qui chargent cet avatar.':
+ 'Stable public name used by applications that load this avatar.',
+ 'Clé sémantique': 'Semantic key',
+ 'Clé sémantique manquante': 'Missing semantic key',
+ 'Clé publique stable utilisée par l’API runtime, par exemple happy-smile.':
+ 'Stable public key used by the runtime API, for example happy-smile.',
+ 'Clé publique stable utilisée par l’API runtime, par exemple thinking.':
+ 'Stable public key used by the runtime API, for example thinking.',
+ 'Ajoute une clé pour inclure cet élément dans l’export runtime.':
+ 'Add a key to include this item in the runtime export.',
+ 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
+ 'Use lowercase letters, numbers, and hyphens, for example happy-smile.',
+ 'neutral est réservé à l’apparence neutre de l’avatar.':
+ 'neutral is reserved for the avatar neutral appearance.',
+ 'Cette clé est déjà utilisée dans cette bibliothèque.':
+ 'This key is already used in this library.',
'L’avatar à gauche affiche cette expression en direct.':
'The avatar on the left previews this expression live.',
Corps: 'Body',
@@ -108,8 +125,40 @@ const english: Record = {
Animations: 'Animations',
Exporter: 'Export',
'Exporter l’avatar': 'Export avatar',
+ 'Exporter le JSON runtime': 'Export runtime JSON',
+ Nouveau: 'New',
+ 'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
+ 'Export the .avatar.json file used by the new npm packages.',
+ 'Télécharge la définition portable complète de l’avatar actif.':
+ 'Download the complete portable definition for the active avatar.',
+ 'Définition runtime': 'Runtime definition',
+ 'Fichier .avatar.json portable': 'Portable .avatar.json file',
+ 'Prêt pour l’export runtime': 'Ready for runtime export',
+ 'Export runtime incomplet': 'Runtime export is incomplete',
+ 'Effacer le projet local et recharger': 'Clear local project and reload',
+ 'animations standard disponibles': 'standard animations available',
+ 'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
+ 'Copier le JSON formaté': 'Copy formatted JSON',
+ 'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.',
+ 'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
+ 'Démarrage rapide npm': 'npm quick start',
+ 'Ces commandes fonctionneront après la publication des packages actuellement privés.':
+ 'These commands will work after the currently private packages are published.',
+ Installation: 'Installation',
+ 'Exemple React minimal': 'Minimal React example',
+ 'Lancer l’exemple': 'Run example',
+ 'Masquer l’aperçu': 'Hide preview',
+ 'Aperçu avec le package React': 'Preview using the React package',
+ 'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
+ Expression: 'Expression',
+ Animation: 'Animation',
+ 'Une étape référence une expression qui ne peut pas être exportée.':
+ 'A step references an expression that cannot be exported.',
+ 'Valeur incompatible avec le format runtime': 'Value incompatible with the runtime format',
'Télécharge un composant autonome avec les animations de ton choix.':
'Download a standalone component with the animations you choose.',
+ 'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
+ 'Generate the existing standalone React or JavaScript ZIP export.',
'Avatar sélectionné': 'Selected avatar',
Format: 'Format',
'Choisis l’intégration correspondant à ton projet.':
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index f5ddb9f..20af3de 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -34,6 +34,21 @@ export const chinese: Record = {
'Retour au studio': '返回工作室',
'Preset en mémoire': '已保存预设',
'Nouvelle expression': '新建表情',
+ 'Identité runtime': '运行时标识',
+ 'Nom public stable utilisé par les applications qui chargent cet avatar.':
+ '供加载此头像的应用使用的稳定公开名称。',
+ 'Clé sémantique': '语义键',
+ 'Clé sémantique manquante': '缺少语义键',
+ 'Clé publique stable utilisée par l’API runtime, par exemple happy-smile.':
+ '运行时 API 使用的稳定公开键,例如 happy-smile。',
+ 'Clé publique stable utilisée par l’API runtime, par exemple thinking.':
+ '运行时 API 使用的稳定公开键,例如 thinking。',
+ 'Ajoute une clé pour inclure cet élément dans l’export runtime.':
+ '添加键以在运行时导出中包含此项目。',
+ 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
+ '请使用小写字母、数字和连字符,例如 happy-smile。',
+ 'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
+ 'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
Corps: '身体',
Rendu: '渲染',
@@ -80,8 +95,39 @@ export const chinese: Record = {
Exporter: '导出',
Export: '导出',
'Exporter l’avatar': '导出头像',
+ 'Exporter le JSON runtime': '导出运行时 JSON',
+ Nouveau: '新增',
+ 'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
+ '导出新 npm 包使用的 .avatar.json 文件。',
+ 'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
+ 'Définition runtime': '运行时定义',
+ 'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
+ 'Prêt pour l’export runtime': '可以导出运行时定义',
+ 'Export runtime incomplet': '运行时导出不完整',
+ 'Effacer le projet local et recharger': '清除本地项目并重新加载',
+ 'animations standard disponibles': '个标准动画可用',
+ 'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
+ 'Copier le JSON formaté': '复制格式化的 JSON',
+ 'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
+ 'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
+ 'Démarrage rapide npm': 'npm 快速开始',
+ 'Ces commandes fonctionneront après la publication des packages actuellement privés.':
+ '这些命令将在当前私有包发布后可用。',
+ Installation: '安装',
+ 'Exemple React minimal': '最简 React 示例',
+ 'Lancer l’exemple': '运行示例',
+ 'Masquer l’aperçu': '隐藏预览',
+ 'Aperçu avec le package React': '使用 React 包预览',
+ 'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
+ Expression: '表情',
+ Animation: '动画',
+ 'Une étape référence une expression qui ne peut pas être exportée.':
+ '某个步骤引用了无法导出的表情。',
+ 'Valeur incompatible avec le format runtime': '值与运行时格式不兼容',
'Télécharge un composant autonome avec les animations de ton choix.':
'下载包含所选动画的独立组件。',
+ 'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
+ '生成原有的 React 或 JavaScript 独立 ZIP 导出。',
'Avatar sélectionné': '已选头像',
Format: '格式',
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
diff --git a/tsconfig.json b/tsconfig.json
index e443020..799b398 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,7 +12,10 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
- "@/*": ["./src/*"]
+ "@/*": ["./src/*"],
+ "@bible-strong/avatar-core": ["./packages/avatar-core/src/index.ts"],
+ "@bible-strong/avatar-core/*": ["./packages/avatar-core/src/*"],
+ "@bible-strong/avatar-react": ["./packages/avatar-react/src/index.ts"]
},
"resolveJsonModule": true,
"isolatedModules": true,
@@ -20,5 +23,5 @@
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals"]
},
- "include": ["src", "vite.config.ts", "vitest.config.ts"]
+ "include": ["src", "packages/*/src", "vite.config.ts", "vitest.config.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index b8015e9..6b0dc17 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -15,10 +15,16 @@ export default defineConfig({
resolve: {
alias: {
'@': path.join(root, 'src'),
+ '@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
+ '@bible-strong/avatar-react': path.join(root, 'packages/avatar-react/src/index.ts'),
},
},
build: {
outDir: path.join(root, 'dist'),
emptyOutDir: true,
+ chunkSizeWarningLimit: 1_100,
+ rolldownOptions: {
+ checks: { pluginTimings: false },
+ },
},
})
diff --git a/vitest.config.ts b/vitest.config.ts
index 799596f..7c74e8c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -9,11 +9,12 @@ export default defineConfig({
resolve: {
alias: {
'@': path.join(root, 'src'),
+ '@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
},
},
test: {
environment: 'node',
globals: true,
- include: ['src/**/__tests__/**/*-test.ts'],
+ include: ['src/**/__tests__/**/*-test.{ts,tsx}', 'packages/**/__tests__/**/*-test.{ts,tsx}'],
},
})