From 99300b3b30cb4cfb11a35f99a49a8477d1e5fb01 Mon Sep 17 00:00:00 2001 From: Eric Cappannelli Date: Fri, 14 Aug 2026 15:14:49 -0400 Subject: [PATCH] feat(runtime): ship semantic avatar packages and Studio export - add the v1 AvatarDefinition schema, bounded parser, strict validation, semantic catalog, pure geometry, scene generation, and deterministic playback to @bible-strong/avatar-core - add the React 19 renderer with semantic controls, SSR-safe embedded/floating layouts, direct frame updates, pointer and keyboard movement, constraints, callbacks, and accessible controls - add real tarball packaging and a clean React/Vite consumer using the exported Strobi definition - add Studio semantic-key authoring, runtime readiness, JSON download/copy, bundled-key recovery, concise errors, targeted local-project clearing, npm guidance, syntax highlighting, and a runnable package preview - preserve the historical ZIP and Studio project exports, regenerate the standalone engine, and synchronize English, French, and Simplified Chinese copy - add focused contract, playback, renderer, interaction, persistence, export, and localization coverage - archive the completed runtime and semantic-curation specs, retain the Vite performance draft, and record the engineering session in TIMELOG.md Validation: - pnpm check: 19 test files, 162 tests, typecheck, engine freshness, package builds, and Studio production build passed - npm pack dry runs: 30 core files and 10 React files - package smoke: real tarballs installed, typechecked, and built outside the workspace - browser checks: semantic playback, embedded/floating render, drag, mobile overflow, runtime export recovery, formatted copy, syntax colors, and live package preview Publication remains disabled: both packages stay private and AGPL-3.0-only pending licensing and repository metadata approval. Vue and Angular adapters remain deferred. --- TIMELOG.md | 29 + .../20260814-vite-dev-server-performance.md | 198 ++ ...ar-runtime-npm-package-and-semantic-api.md | 101 +- ...260814-avatar-runtime-semantic-curation.md | 89 + examples/react-vite-consumer/index.html | 13 + examples/react-vite-consumer/package.json | 23 + examples/react-vite-consumer/src/main.tsx | 60 + .../src/strobi.avatar.json | 1733 +++++++++++++++++ examples/react-vite-consumer/src/styles.css | 44 + .../react-vite-consumer/src/vite-env.d.ts | 1 + examples/react-vite-consumer/tsconfig.json | 15 + examples/react-vite-consumer/vite.config.ts | 4 + package.json | 9 +- packages/avatar-core/LICENSE | 661 +++++++ packages/avatar-core/README.md | 74 + packages/avatar-core/package.json | 51 + .../avatar-core/src/__tests__/core-test.ts | 247 +++ packages/avatar-core/src/ambientMotion.ts | 115 ++ .../src/avatarDefinition.schema.json | 249 +++ packages/avatar-core/src/avatarDefinition.ts | 617 ++++++ packages/avatar-core/src/body.ts | 117 ++ packages/avatar-core/src/geometry.ts | 1252 ++++++++++++ packages/avatar-core/src/index.ts | 7 + packages/avatar-core/src/runtime.ts | 266 +++ packages/avatar-core/src/scene.ts | 79 + packages/avatar-core/src/surfaces.ts | 665 +++++++ packages/avatar-core/tsconfig.build.json | 12 + packages/avatar-core/tsconfig.json | 15 + packages/avatar-core/vite.config.ts | 35 + packages/avatar-react/LICENSE | 661 +++++++ packages/avatar-react/README.md | 98 + packages/avatar-react/package.json | 45 + packages/avatar-react/src/Avatar.tsx | 646 ++++++ .../src/__tests__/avatar-test.tsx | 538 +++++ packages/avatar-react/src/index.ts | 10 + packages/avatar-react/src/styles.css | 98 + packages/avatar-react/src/styles.d.ts | 1 + packages/avatar-react/tsconfig.build.json | 12 + packages/avatar-react/tsconfig.json | 15 + packages/avatar-react/vite.config.ts | 18 + pnpm-lock.yaml | 533 ++++- pnpm-workspace.yaml | 3 + scripts/generate-standalone-engine.mjs | 5 + scripts/smoke-packages.mjs | 47 + src/app/components/common.tsx | 7 +- src/app/styles.css | 199 ++ .../animation/__tests__/sequences-test.ts | 10 + .../components/SequenceWorkspace.tsx | 31 + src/features/animation/sequences.ts | 4 + .../__tests__/avatar-definition-test.ts | 715 +++++++ src/features/avatar/__tests__/avatars-test.ts | 11 + .../__tests__/generate-strobi-fixture-test.ts | 23 + src/features/avatar/ambientMotion.ts | 116 +- src/features/avatar/avatarDefinition.ts | 192 ++ src/features/avatar/avatars.ts | 59 +- src/features/avatar/body.ts | 118 +- .../avatar/components/ExpressionWorkspace.tsx | 53 + src/features/avatar/geometry.ts | 1254 +----------- src/features/avatar/presets.ts | 29 + src/features/avatar/surfaces.ts | 666 +------ .../export/__tests__/exporter-test.ts | 3 + src/features/export/exporter.ts | 9 +- .../export/standaloneEngine.generated.ts | 2 +- .../studio/__tests__/studio-document-test.ts | 82 +- .../studio/components/StudioInspector.tsx | 457 +++-- .../studio/defaultStudioDocument.json | 50 + src/features/studio/studioDocument.ts | 21 +- src/features/studio/useStudioController.ts | 119 +- src/i18n/__tests__/i18n-test.ts | 44 + src/i18n/index.ts | 49 + src/i18n/zh.ts | 46 + tsconfig.json | 7 +- vite.config.ts | 6 + vitest.config.ts | 3 +- 74 files changed, 11592 insertions(+), 2304 deletions(-) create mode 100644 TIMELOG.md create mode 100644 docs/specs/20260814-vite-dev-server-performance.md rename docs/specs/{ => archive}/20260814-avatar-runtime-npm-package-and-semantic-api.md (83%) create mode 100644 docs/specs/archive/20260814-avatar-runtime-semantic-curation.md create mode 100644 examples/react-vite-consumer/index.html create mode 100644 examples/react-vite-consumer/package.json create mode 100644 examples/react-vite-consumer/src/main.tsx create mode 100644 examples/react-vite-consumer/src/strobi.avatar.json create mode 100644 examples/react-vite-consumer/src/styles.css create mode 100644 examples/react-vite-consumer/src/vite-env.d.ts create mode 100644 examples/react-vite-consumer/tsconfig.json create mode 100644 examples/react-vite-consumer/vite.config.ts create mode 100644 packages/avatar-core/LICENSE create mode 100644 packages/avatar-core/README.md create mode 100644 packages/avatar-core/package.json create mode 100644 packages/avatar-core/src/__tests__/core-test.ts create mode 100644 packages/avatar-core/src/ambientMotion.ts create mode 100644 packages/avatar-core/src/avatarDefinition.schema.json create mode 100644 packages/avatar-core/src/avatarDefinition.ts create mode 100644 packages/avatar-core/src/body.ts create mode 100644 packages/avatar-core/src/geometry.ts create mode 100644 packages/avatar-core/src/index.ts create mode 100644 packages/avatar-core/src/runtime.ts create mode 100644 packages/avatar-core/src/scene.ts create mode 100644 packages/avatar-core/src/surfaces.ts create mode 100644 packages/avatar-core/tsconfig.build.json create mode 100644 packages/avatar-core/tsconfig.json create mode 100644 packages/avatar-core/vite.config.ts create mode 100644 packages/avatar-react/LICENSE create mode 100644 packages/avatar-react/README.md create mode 100644 packages/avatar-react/package.json create mode 100644 packages/avatar-react/src/Avatar.tsx create mode 100644 packages/avatar-react/src/__tests__/avatar-test.tsx create mode 100644 packages/avatar-react/src/index.ts create mode 100644 packages/avatar-react/src/styles.css create mode 100644 packages/avatar-react/src/styles.d.ts create mode 100644 packages/avatar-react/tsconfig.build.json create mode 100644 packages/avatar-react/tsconfig.json create mode 100644 packages/avatar-react/vite.config.ts create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/smoke-packages.mjs create mode 100644 src/features/avatar/__tests__/avatar-definition-test.ts create mode 100644 src/features/avatar/__tests__/generate-strobi-fixture-test.ts create mode 100644 src/features/avatar/avatarDefinition.ts 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

+
+ +
+ + + {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 ( + <> + + + + + ) +} +``` + +`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() + }} + > + + {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]) => ( + + ))} + +
+ )} +
+ ) + + 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 > + + + + 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({
+ + + + + + 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' && ( - - )} +

{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')} />
+ )}
)} - {!editorPageOpen && mode === 'avatars' && } {!sequenceEditing && !editing && bodyEditing && (