feat: import standalone Avatar Lab
This commit is contained in:
parent
8913438acd
commit
e547bc9f7b
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.yarn/
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
legacy/
|
||||||
|
src/defaultStudioDocument.json
|
||||||
|
src/standaloneEngine.generated.ts
|
||||||
|
yarn.lock
|
||||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
34
AGENTS.md
Normal file
34
AGENTS.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Bible Strong Avatar Lab is a standalone React 19 + Vite 8 application for procedural SVG avatars.
|
||||||
|
Read `CONTEXT.md` before changing behavior or persistence semantics.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
yarn typecheck
|
||||||
|
yarn test
|
||||||
|
yarn build
|
||||||
|
yarn check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Engineering rules
|
||||||
|
|
||||||
|
- Use TypeScript in strict mode.
|
||||||
|
- Keep geometry, playback and document operations framework-independent.
|
||||||
|
- Preserve the split between durable React state and high-frequency Motion values.
|
||||||
|
- Do not use `useMemo`, `useCallback` or `memo`; React Compiler handles memoization.
|
||||||
|
- Add or update focused tests for domain behavior.
|
||||||
|
- Run `yarn typecheck` and the relevant test file while working, then `yarn check` before committing.
|
||||||
|
- `src/standaloneEngine.generated.ts` is generated. Update it with `yarn engine`, never by hand.
|
||||||
|
- Keep English, French and Simplified Chinese copy synchronized across `src/i18n.ts` and `src/i18n.zh.ts`.
|
||||||
|
- Preserve project JSON compatibility only with the current pre-release schema unless a migration is explicitly requested.
|
||||||
|
|
||||||
|
## UI conventions
|
||||||
|
|
||||||
|
- Reuse components under `src/components/ui/` rather than adding one-off controls.
|
||||||
|
- Keep selection, hover, focus and destructive actions visually consistent across avatars, expressions and animations.
|
||||||
|
- Prefer transform/opacity animation over layout animation for continuous interactions.
|
||||||
68
CONTEXT.md
Normal file
68
CONTEXT.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Avatar Lab Context
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Bible Strong Avatar Lab is a browser-based authoring tool for procedural 2D avatars. It combines
|
||||||
|
3D-inspired geometry with SVG rendering so creators can build a body from primitives, define a
|
||||||
|
neutral face, author expressions and compose reusable animations. Generated packages do not depend
|
||||||
|
on the Studio UI.
|
||||||
|
|
||||||
|
## Ubiquitous language
|
||||||
|
|
||||||
|
**Avatar**
|
||||||
|
A reusable character whose body, colors and neutral eyes define its persistent visual identity.
|
||||||
|
|
||||||
|
**Neutral appearance**
|
||||||
|
The persistent visual base of an Avatar before an Expression is applied. Avoid “default pose”.
|
||||||
|
|
||||||
|
**Pose**
|
||||||
|
The Avatar's current temporary visual configuration. A Pose is not persisted directly.
|
||||||
|
|
||||||
|
**Base behavior library**
|
||||||
|
The immutable bundled collection of Expressions and Animations inherited by an Avatar that has not
|
||||||
|
customized its behavior.
|
||||||
|
|
||||||
|
**Avatar behavior library**
|
||||||
|
The Avatar-owned collection of Expressions and Animations created atomically from the Base behavior
|
||||||
|
library on that Avatar's first behavior mutation.
|
||||||
|
|
||||||
|
**Expression**
|
||||||
|
A saved visual preset within the Base behavior library or one Avatar behavior library. Eye values
|
||||||
|
are relative to the owning Avatar's Neutral appearance. Optional color overrides are temporary.
|
||||||
|
|
||||||
|
**Animation**
|
||||||
|
A saved sequence of Expressions from the same behavior library, including transitions and blink
|
||||||
|
behavior. Avoid “state” in user-facing copy.
|
||||||
|
|
||||||
|
**Playback**
|
||||||
|
The active execution of one Animation. It exclusively controls the animated Pose until direct user
|
||||||
|
manipulation pauses it.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- An Avatar inherits the Base behavior library until its first Expression or Animation mutation.
|
||||||
|
- The first behavior mutation copies Expressions and Animations together so references stay valid.
|
||||||
|
- Subsequent behavior mutations affect only the owning Avatar.
|
||||||
|
- Duplicating an Avatar duplicates its Avatar behavior library when one exists.
|
||||||
|
- Transferring an Animation must also transfer every Expression it references.
|
||||||
|
- The primary body shape carries the facial coordinate system and eyes.
|
||||||
|
- Secondary primitives have independent local dimensions, position and rotation.
|
||||||
|
- Expressions remain compatible across body surfaces because they operate in the common facial frame.
|
||||||
|
- `geometry.ts` and the exported procedural engine stay independent from React.
|
||||||
|
- `defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required.
|
||||||
|
- Live editing may update the preview, but unsaved avatar/expression edits must remain reversible.
|
||||||
|
|
||||||
|
## Architecture boundaries
|
||||||
|
|
||||||
|
- Pure geometry and document logic live in framework-independent TypeScript modules.
|
||||||
|
- `App.tsx` coordinates UI and application state; avoid moving domain calculations into components.
|
||||||
|
- Motion values own frame-by-frame visual updates; React state owns durable editor state.
|
||||||
|
- Exports receive explicit avatar, expression and animation data rather than reading browser storage.
|
||||||
|
- The generated standalone engine is committed and checked for freshness in local validation.
|
||||||
|
|
||||||
|
## Current persistence
|
||||||
|
|
||||||
|
The complete Studio document is persisted in browser local storage. JSON project export/import is the
|
||||||
|
portable backup mechanism. There is no remote backend in this repository.
|
||||||
|
|
||||||
|
Accepted architecture decisions live under `docs/adr/`.
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Bible Strong Avatar Lab
|
||||||
|
|
||||||
|
Standalone React/Vite studio for creating procedural 2D avatars, expressions and animations.
|
||||||
|
The project was extracted from Bible Strong so it can evolve independently.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Requirements: Node.js 22.12+ and Corepack/Yarn 4.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack enable
|
||||||
|
yarn install
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:5173/`.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn typecheck
|
||||||
|
yarn test
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
`yarn check` runs the generated-engine freshness check and the full validation suite.
|
||||||
|
|
||||||
|
## Project map
|
||||||
|
|
||||||
|
- `src/App.tsx`: Studio interface and orchestration.
|
||||||
|
- `src/geometry.ts`: framework-independent procedural projection.
|
||||||
|
- `src/surfaces.ts`: supported head surfaces and primitive definitions.
|
||||||
|
- `src/renderedScene.ts`: stable render layer backed by Motion values.
|
||||||
|
- `src/playback.ts`: expression and animation playback.
|
||||||
|
- `src/studioDocument.ts`: versioned project document and browser persistence.
|
||||||
|
- `src/exporter.ts`: React/TypeScript and standalone JavaScript exports.
|
||||||
|
- `src/snapshotExporter.ts`: static SVG and PNG photo-mode exports.
|
||||||
|
- `src/defaultStudioDocument.json`: bundled avatars, expressions and animations.
|
||||||
|
- `scripts/generate-standalone-engine.mjs`: embeds the procedural runtime used by exports.
|
||||||
|
- `legacy/`: the two self-contained HTML prototypes that preceded the React app.
|
||||||
|
- `docs/adr/`: accepted architecture decisions inherited from the prototype.
|
||||||
|
|
||||||
|
## Product model
|
||||||
|
|
||||||
|
- An avatar owns its body and neutral eye appearance.
|
||||||
|
- The bundled behavior library supplies the initial expressions and animations.
|
||||||
|
- On the first behavior edit, an avatar receives its own independent copy of both.
|
||||||
|
- An animation references expressions from the same behavior library.
|
||||||
|
- The Studio document can be exported/imported as JSON for portability.
|
||||||
|
|
||||||
|
See [CONTEXT.md](./CONTEXT.md) for the domain language and architecture constraints.
|
||||||
22
components.json
Normal file
22
components.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
26
docs/adr/0022-model-avatar-studio-as-a-versioned-document.md
Normal file
26
docs/adr/0022-model-avatar-studio-as-a-versioned-document.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# ADR-0022: Model Avatar Studio as a versioned document
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Avatar Studio previously persisted Avatars, Expressions, Animations, and Playback separately. Animation
|
||||||
|
steps referenced Expressions by their current array position, so inserting or deleting an
|
||||||
|
Expression required coordinated remapping and could leave partially persisted data.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Avatar Studio persists one versioned Studio document. Every Expression has a durable identity and
|
||||||
|
State steps reference that identity. Existing local data is migrated automatically by resolving
|
||||||
|
legacy expression indexes against the migrated Expression catalog. An Avatar owns its Neutral
|
||||||
|
appearance; global Expressions describe relative changes, and optional Expression colors remain
|
||||||
|
temporary overrides. Playback exclusively owns the animated Pose until a direct user manipulation
|
||||||
|
pauses it. Changing Avatar preserves the current Playback position.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Studio mutations preserve cross-object invariants in one transaction. Reordering Expressions no
|
||||||
|
longer changes Animation meaning. The project is pre-release and the bundled version-2 document is
|
||||||
|
the current baseline; no compatibility migration for earlier prototypes is maintained.
|
||||||
26
docs/adr/0023-name-avatar-sequences-animations.md
Normal file
26
docs/adr/0023-name-avatar-sequences-animations.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# ADR-0023: Name Avatar Studio sequences Animations
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0022 introduced the technical `State` model for a persisted sequence of Expressions. In the
|
||||||
|
Studio interface, “State” did not clearly communicate that the object is a complete, playable
|
||||||
|
animation. “Sequence” was also used for the same object in editor copy, creating three competing
|
||||||
|
names.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The product term is **Animation**. An Animation contains ordered Expression steps, transitions,
|
||||||
|
playback behavior and blink settings. **Playback** remains the running execution of an Animation.
|
||||||
|
|
||||||
|
Existing internal identifiers such as `AvatarSequence`, `stateId` and legacy storage keys may remain
|
||||||
|
until a dedicated migration is justified. They must not leak into user-facing copy or exported APIs.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Avatar Studio uses one consistent user-facing term in its tabs, editor, player and export flow.
|
||||||
|
The current versioned document remains unchanged because this decision changes terminology rather
|
||||||
|
than its internal shape.
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>Bible Strong Avatar Lab</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4015
legacy/index.html
Normal file
4015
legacy/index.html
Normal file
File diff suppressed because it is too large
Load Diff
2894
legacy/spherical-eyes.html
Normal file
2894
legacy/spherical-eyes.html
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "bible-strong-avatar-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "yarn@4.12.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "yarn engine && vite",
|
||||||
|
"build": "yarn engine && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"engine": "node scripts/generate-standalone-engine.mjs",
|
||||||
|
"engine:check": "node scripts/generate-standalone-engine.mjs --check",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"check": "yarn engine:check && yarn format:check && yarn typecheck && yarn test && yarn build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.7.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
|
"motion": "^13.0.0",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.29.0",
|
||||||
|
"@babel/parser": "^7.28.6",
|
||||||
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"rolldown": "^1.0.0-rc.5",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
scripts/generate-standalone-engine.mjs
Normal file
33
scripts/generate-standalone-engine.mjs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { build } from 'vite'
|
||||||
|
|
||||||
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
||||||
|
const result = await build({
|
||||||
|
configFile: false,
|
||||||
|
logLevel: 'silent',
|
||||||
|
build: {
|
||||||
|
write: false,
|
||||||
|
minify: true,
|
||||||
|
lib: {
|
||||||
|
entry: path.join(root, 'src/standaloneEngine.entry.ts'),
|
||||||
|
formats: ['iife'],
|
||||||
|
name: 'AvatarProceduralEngine',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const outputs = Array.isArray(result) ? result.flatMap(item => item.output) : result.output
|
||||||
|
const chunk = outputs.find(item => item.type === 'chunk')
|
||||||
|
if (!chunk) throw new Error('Standalone avatar engine bundle was not generated.')
|
||||||
|
|
||||||
|
const outputPath = path.join(root, 'src/standaloneEngine.generated.ts')
|
||||||
|
const source = `// Generated by scripts/generate-standalone-engine.mjs. Do not edit.\nexport const standaloneEngineSource = ${JSON.stringify(chunk.code)}\n`
|
||||||
|
|
||||||
|
if (process.argv.includes('--check')) {
|
||||||
|
const current = await readFile(outputPath, 'utf8').catch(() => '')
|
||||||
|
if (current !== source) throw new Error('Standalone avatar engine is stale. Run yarn engine.')
|
||||||
|
} else {
|
||||||
|
await writeFile(outputPath, source)
|
||||||
|
}
|
||||||
6015
src/App.tsx
Normal file
6015
src/App.tsx
Normal file
File diff suppressed because it is too large
Load Diff
46
src/__tests__/ambient-motion-test.ts
Normal file
46
src/__tests__/ambient-motion-test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from '../ambientMotion'
|
||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
|
||||||
|
describe('perpetual expression motion', () => {
|
||||||
|
it('leaves a motionless expression unchanged', () => {
|
||||||
|
const expression = { ...defaultExpression }
|
||||||
|
|
||||||
|
expect(hasAmbientMotion(expression)).toBe(false)
|
||||||
|
expect(applyAmbientMotion(expression, 500)).toEqual(expression)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves both eyes together without mutating the saved expression', () => {
|
||||||
|
const expression = { ...defaultExpression, eyeMotion: 'microSaccades' as const }
|
||||||
|
const animated = applyAmbientMotion(expression, 500)
|
||||||
|
|
||||||
|
expect(animated.positionXLeft - expression.positionXLeft).toBeCloseTo(
|
||||||
|
animated.positionXRight - expression.positionXRight
|
||||||
|
)
|
||||||
|
expect(animated.positionYLeft - expression.positionYLeft).toBeCloseTo(
|
||||||
|
animated.positionYRight - expression.positionYRight
|
||||||
|
)
|
||||||
|
expect(animated.positionXLeft).not.toBe(expression.positionXLeft)
|
||||||
|
expect(expression.positionXLeft).toBe(defaultExpression.positionXLeft)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds body motion without changing eye placement', () => {
|
||||||
|
const expression = { ...defaultExpression, bodyMotion: 'shake' as const }
|
||||||
|
const animated = applyAmbientMotion(expression, 500)
|
||||||
|
|
||||||
|
expect(animated.headY).not.toBe(expression.headY)
|
||||||
|
expect(animated.positionXLeft).toBe(expression.positionXLeft)
|
||||||
|
expect(animated.positionYRight).toBe(expression.positionYRight)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('visibly offsets a symmetric body when it shakes', () => {
|
||||||
|
const expression = { ...defaultExpression, bodyMotion: 'shake' as const }
|
||||||
|
|
||||||
|
expect(ambientBodyOffset(expression, 500)).not.toEqual({ x: 0, y: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('visibly offsets a symmetric body during slow drift', () => {
|
||||||
|
const expression = { ...defaultExpression, bodyMotion: 'slowDrift' as const }
|
||||||
|
|
||||||
|
expect(ambientBodyOffset(expression, 1500)).not.toEqual({ x: 0, y: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
59
src/__tests__/avatars-test.ts
Normal file
59
src/__tests__/avatars-test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
import { createInitialSequences } from '../sequences'
|
||||||
|
import {
|
||||||
|
applyAvatarEyeDefaults,
|
||||||
|
cloneAvatarBehavior,
|
||||||
|
createAvatar,
|
||||||
|
defaultAvatarEyes,
|
||||||
|
parseAvatarEyeDefaults,
|
||||||
|
resolveAvatarBehavior,
|
||||||
|
} from '../avatars'
|
||||||
|
import { initialExpressions } from '../presets'
|
||||||
|
|
||||||
|
describe('avatar eye defaults', () => {
|
||||||
|
it('keeps the historical rendering when using default values', () => {
|
||||||
|
expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('composes avatar defaults as variations around the neutral expression', () => {
|
||||||
|
const expression = { ...defaultExpression, widthLeft: 28, positionYLeft: 5 }
|
||||||
|
const eyes = { ...defaultAvatarEyes, widthLeft: 30, positionYLeft: -12 }
|
||||||
|
|
||||||
|
const result = applyAvatarEyeDefaults(expression, eyes)
|
||||||
|
|
||||||
|
expect(result.widthLeft).toBe(38)
|
||||||
|
expect(result.positionYLeft).toBe(0)
|
||||||
|
expect(expression.widthLeft).toBe(28)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes partial persisted values', () => {
|
||||||
|
const result = parseAvatarEyeDefaults({ widthLeft: 42, heightRight: Number.NaN })
|
||||||
|
|
||||||
|
expect(result.widthLeft).toBe(42)
|
||||||
|
expect(result.heightRight).toBe(defaultAvatarEyes.heightRight)
|
||||||
|
expect(result.spacing).toBe(defaultAvatarEyes.spacing)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('avatar behavior library', () => {
|
||||||
|
const base = {
|
||||||
|
expressions: initialExpressions,
|
||||||
|
sequences: createInitialSequences(),
|
||||||
|
}
|
||||||
|
|
||||||
|
it('inherits the base library until the avatar owns a customization', () => {
|
||||||
|
const avatar = createAvatar('Strobi')
|
||||||
|
|
||||||
|
expect(resolveAvatarBehavior(avatar, base)).toBe(base)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clones expressions, animations and nested steps as one independent library', () => {
|
||||||
|
const behavior = cloneAvatarBehavior(base)
|
||||||
|
|
||||||
|
expect(behavior).not.toBe(base)
|
||||||
|
expect(behavior.expressions).not.toBe(base.expressions)
|
||||||
|
expect(behavior.sequences).not.toBe(base.sequences)
|
||||||
|
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
|
||||||
|
expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
|
||||||
|
})
|
||||||
|
})
|
||||||
37
src/__tests__/canvas-preview-test.ts
Normal file
37
src/__tests__/canvas-preview-test.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { applyAvatarEyeDefaults, defaultAvatarEyes } from '../avatars'
|
||||||
|
import { resolveCanvasPreviewExpression } from '../canvasPreview'
|
||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
|
||||||
|
describe('canvas preview expression', () => {
|
||||||
|
it('keeps customized neutral eyes applied while rotating the body preview', () => {
|
||||||
|
const eyes = {
|
||||||
|
...defaultAvatarEyes,
|
||||||
|
widthLeft: 37,
|
||||||
|
positionYLeft: -18,
|
||||||
|
leftAngle: 21,
|
||||||
|
rightAngle: -21,
|
||||||
|
}
|
||||||
|
const draggedExpression = { ...defaultExpression, headX: 24, headY: 31 }
|
||||||
|
|
||||||
|
expect(resolveCanvasPreviewExpression(draggedExpression, eyes, true, 'head')).toEqual(
|
||||||
|
applyAvatarEyeDefaults(draggedExpression, eyes)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not compose neutral eyes twice outside body editing', () => {
|
||||||
|
const eyes = { ...defaultAvatarEyes, widthLeft: 37 }
|
||||||
|
|
||||||
|
expect(resolveCanvasPreviewExpression(defaultExpression, eyes, false, 'head')).toBe(
|
||||||
|
defaultExpression
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not compose customized eyes twice while editing an eye handle', () => {
|
||||||
|
const eyes = { ...defaultAvatarEyes, widthLeft: 37 }
|
||||||
|
const customizedExpression = applyAvatarEyeDefaults(defaultExpression, eyes)
|
||||||
|
|
||||||
|
expect(resolveCanvasPreviewExpression(customizedExpression, eyes, true, 'eyes')).toBe(
|
||||||
|
customizedExpression
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
154
src/__tests__/exporter-test.ts
Normal file
154
src/__tests__/exporter-test.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { createAvatar } from '../avatars'
|
||||||
|
import { parse } from '@babel/parser'
|
||||||
|
import {
|
||||||
|
createAvatarExportPayload,
|
||||||
|
generateJavaScriptAvatarHtml,
|
||||||
|
generateJavaScriptAvatarModule,
|
||||||
|
generateJavaScriptAvatarPackage,
|
||||||
|
generateReactAvatarComponent,
|
||||||
|
generateReactAvatarPackage,
|
||||||
|
generateReactAvatarRuntime,
|
||||||
|
} from '../exporter'
|
||||||
|
import { initialExpressions } from '../presets'
|
||||||
|
import { createInitialSequences } from '../sequences'
|
||||||
|
|
||||||
|
describe('avatar export', () => {
|
||||||
|
const avatar = createAvatar('Strobi')
|
||||||
|
const animations = createInitialSequences().filter(item =>
|
||||||
|
['idle', 'listening'].includes(item.id)
|
||||||
|
)
|
||||||
|
const payload = createAvatarExportPayload(avatar, initialExpressions, animations)
|
||||||
|
|
||||||
|
it('includes only the selected animations and their referenced expressions', () => {
|
||||||
|
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
|
||||||
|
expect(Object.keys(payload.expressions).sort()).toEqual(
|
||||||
|
[
|
||||||
|
...new Set(animations.flatMap(animation => animation.steps.map(step => step.expressionId))),
|
||||||
|
].sort()
|
||||||
|
)
|
||||||
|
expect(payload).not.toHaveProperty('frames')
|
||||||
|
expect(payload.avatar.name).toBe('Strobi')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates a standalone JavaScript module without a Web Component', () => {
|
||||||
|
const source = generateJavaScriptAvatarModule(payload)
|
||||||
|
|
||||||
|
expect(source).toContain('export function createAvatar')
|
||||||
|
expect(source).toContain('play(animationName)')
|
||||||
|
expect(source).toContain('export const availableAnimations')
|
||||||
|
expect(source).toContain('pausedRemainingMs')
|
||||||
|
expect(source).toContain('pausedBlinkDelay')
|
||||||
|
expect(source).toContain('pausedBlink')
|
||||||
|
expect(source).toContain('step.transition')
|
||||||
|
expect(source).toContain('stepIndex = 0')
|
||||||
|
expect(source).not.toContain('customElements.define')
|
||||||
|
expect(source).not.toContain("from '")
|
||||||
|
expect(() => parse(source, { sourceType: 'module' })).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the Studio geometry engine to render interpolated transition frames', () => {
|
||||||
|
const source = generateJavaScriptAvatarModule(payload)
|
||||||
|
|
||||||
|
expect(source).toContain('AvatarProceduralEngine')
|
||||||
|
expect(source).toContain('expressionFields')
|
||||||
|
expect(source).toContain('requestAnimationFrame')
|
||||||
|
expect(source).not.toContain('avatarFrameIn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an HTML demo that works when opened directly from the filesystem', () => {
|
||||||
|
const source = generateJavaScriptAvatarHtml(payload)
|
||||||
|
|
||||||
|
expect(source).not.toContain("import { createAvatar } from './avatar.js'")
|
||||||
|
expect(source).toContain('function createAvatar')
|
||||||
|
expect(source).toContain('avatar.play("idle")')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates an interactive playground for the exported animations', () => {
|
||||||
|
const source = generateJavaScriptAvatarHtml(payload)
|
||||||
|
|
||||||
|
expect(source).toContain('id="animation-list"')
|
||||||
|
expect(source).toContain('id="play-animation"')
|
||||||
|
expect(source).toContain('id="pause-animation"')
|
||||||
|
expect(source).toContain('id="stop-animation"')
|
||||||
|
expect(source).toContain('availableAnimations.forEach')
|
||||||
|
expect(source).toContain('avatar.play(activeAnimation)')
|
||||||
|
expect(source).toContain('avatar.pause()')
|
||||||
|
expect(source).toContain('avatar.stop()')
|
||||||
|
expect(source).toContain("createAvatar('#avatar'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('localizes the exported playground', () => {
|
||||||
|
const source = generateJavaScriptAvatarHtml(payload, 'fr')
|
||||||
|
|
||||||
|
expect(source).toContain('<html lang="fr">')
|
||||||
|
expect(source).toContain('Choisissez une animation exportée')
|
||||||
|
expect(source).toContain('▶ Lire')
|
||||||
|
expect(source).toContain('En pause')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps user-authored names and descriptions inside the generated script', () => {
|
||||||
|
const source = generateJavaScriptAvatarHtml({
|
||||||
|
...payload,
|
||||||
|
avatar: { ...payload.avatar, name: '</title><script>alert(1)</script>' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(source).not.toContain('</title><script>alert(1)</script>')
|
||||||
|
expect(source).toContain('</title>')
|
||||||
|
expect(source).toContain('\\u003c/script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('packages the direct-open demo and reusable module together', async () => {
|
||||||
|
const archive = new Uint8Array(await generateJavaScriptAvatarPackage(payload).arrayBuffer())
|
||||||
|
const contents = new TextDecoder().decode(archive)
|
||||||
|
|
||||||
|
expect(contents).toContain('index.html')
|
||||||
|
expect(contents).toContain('avatar.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates a typed React component backed by the local runtime', () => {
|
||||||
|
const source = generateReactAvatarComponent(payload)
|
||||||
|
|
||||||
|
expect(source).toContain("from 'react'")
|
||||||
|
expect(source).toContain("from './avatar-runtime'")
|
||||||
|
expect(source).toContain("from './strobi.avatar'")
|
||||||
|
expect(source).toContain('export type { AnimationName }')
|
||||||
|
expect(source).toContain('animation?: AnimationName')
|
||||||
|
expect(source).toContain('forwardRef<AvatarHandle, AvatarProps>')
|
||||||
|
expect(source).not.toContain('AvatarProceduralEngine')
|
||||||
|
expect(source).toContain('runtime.createAvatar(host.current')
|
||||||
|
expect(source).not.toContain('@ts-nocheck')
|
||||||
|
expect(source).not.toContain('avatarFrameIn')
|
||||||
|
expect(source).not.toContain("from 'motion")
|
||||||
|
expect(() =>
|
||||||
|
parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] })
|
||||||
|
).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('packages the React runtime, avatar data and component separately', async () => {
|
||||||
|
const archive = new Uint8Array(await generateReactAvatarPackage(payload).arrayBuffer())
|
||||||
|
const contents = new TextDecoder().decode(archive)
|
||||||
|
|
||||||
|
expect(contents).toContain('avatar-runtime.ts')
|
||||||
|
expect(contents).toContain('strobi.avatar.ts')
|
||||||
|
expect(contents).toContain('Strobi.tsx')
|
||||||
|
expect(contents).toContain('strobi.index.ts')
|
||||||
|
expect(contents).toContain("from './avatar-runtime'")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates one avatar-independent and versioned React runtime', () => {
|
||||||
|
const runtime = generateReactAvatarRuntime()
|
||||||
|
|
||||||
|
expect(runtime).toContain('AVATAR_RUNTIME_VERSION = 1')
|
||||||
|
expect(runtime).toContain('data.version !== AVATAR_RUNTIME_VERSION')
|
||||||
|
expect(runtime).not.toContain(payload.avatar.name)
|
||||||
|
expect(runtime).toContain('RuntimeAvatar<AnimationName>')
|
||||||
|
expect(() => parse(runtime, { sourceType: 'module', plugins: ['typescript'] })).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses globally unique SVG identifiers across avatar runtimes', () => {
|
||||||
|
const source = generateJavaScriptAvatarModule(payload)
|
||||||
|
|
||||||
|
expect(source).toContain('globalThis.crypto.randomUUID()')
|
||||||
|
expect(source).not.toContain('avatarInstanceCount')
|
||||||
|
})
|
||||||
|
})
|
||||||
24
src/__tests__/expression-editing-test.ts
Normal file
24
src/__tests__/expression-editing-test.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { updateEyeDimension, scaleEye, updateEyePosition } from '../expressionEditing'
|
||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
|
||||||
|
describe('Expression eye editing', () => {
|
||||||
|
it('applies linked dimensions through one invariant-preserving operation', () => {
|
||||||
|
const next = updateEyeDimension(defaultExpression, 'Left', 'width', 140, true)
|
||||||
|
expect(next.widthLeft).toBe(100)
|
||||||
|
expect(next.widthRight).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales both eyes proportionally without exceeding eye bounds', () => {
|
||||||
|
const next = scaleEye(defaultExpression, 'Left', 100, true)
|
||||||
|
expect(next.heightLeft).toBe(100)
|
||||||
|
expect(next.widthLeft).toBe(40)
|
||||||
|
expect(next.heightRight).toBe(100)
|
||||||
|
expect(next.widthRight).toBe(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps linked eye positions symmetrical as an editing rule', () => {
|
||||||
|
const next = updateEyePosition(defaultExpression, 'Right', 'Y', 12, true)
|
||||||
|
expect(next.positionYLeft).toBe(12)
|
||||||
|
expect(next.positionYRight).toBe(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
91
src/__tests__/geometry-editor-test.ts
Normal file
91
src/__tests__/geometry-editor-test.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import type { BodyNode } from '../body'
|
||||||
|
import {
|
||||||
|
poseFromExpression,
|
||||||
|
renderAvatar,
|
||||||
|
renderBodyNodeEditor,
|
||||||
|
rotateBodyNodeAroundLocalAxis,
|
||||||
|
translateBodyNodeInCameraPlane,
|
||||||
|
translateBodyNodeAlongLocalAxis,
|
||||||
|
} from '../geometry'
|
||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
import { surfacePresets } from '../surfaces'
|
||||||
|
|
||||||
|
const node: BodyNode = {
|
||||||
|
id: 'shape-test',
|
||||||
|
name: 'Forme test',
|
||||||
|
surface: surfacePresets.cube,
|
||||||
|
position: [0, 0, -20],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('body node editor geometry', () => {
|
||||||
|
it('keeps all translation axes manipulable when one points at the camera', () => {
|
||||||
|
const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), node)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Math.hypot(editor.axes.z[0] - editor.center[0], editor.axes.z[1] - editor.center[1])
|
||||||
|
).toBeGreaterThanOrEqual(12)
|
||||||
|
expect(Object.values(editor.axes).flat().every(Number.isFinite)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects complete local rotation rings around the selected node', () => {
|
||||||
|
const editor = renderBodyNodeEditor(poseFromExpression(defaultExpression), {
|
||||||
|
...node,
|
||||||
|
rotation: [20, -15, 35],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(editor.rings.x).toHaveLength(65)
|
||||||
|
expect(editor.rings.y).toHaveLength(65)
|
||||||
|
expect(editor.rings.z).toHaveLength(65)
|
||||||
|
expect(editor.rings.z[0]).not.toEqual(editor.rings.z[16])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves along the rotated local axis', () => {
|
||||||
|
const translated = translateBodyNodeAlongLocalAxis({ ...node, rotation: [0, 0, 90] }, 'x', 20)
|
||||||
|
|
||||||
|
expect(translated.position[0]).toBeCloseTo(0)
|
||||||
|
expect(translated.position[1]).toBeCloseTo(20)
|
||||||
|
expect(translated.position[2]).toBeCloseTo(-20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves freely in the camera plane regardless of head rotation', () => {
|
||||||
|
const pose = poseFromExpression({ ...defaultExpression, headX: 24, headY: -31, headZ: 18 })
|
||||||
|
const before = renderBodyNodeEditor(pose, node).center
|
||||||
|
const translated = translateBodyNodeInCameraPlane(node, pose, 18, -9)
|
||||||
|
const after = renderBodyNodeEditor(pose, translated).center
|
||||||
|
|
||||||
|
expect(after[0] - before[0]).toBeCloseTo(18)
|
||||||
|
expect(after[1] - before[1]).toBeCloseTo(-9)
|
||||||
|
expect(translated.position[2]).not.toBe(node.position[2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('composes a local rotation instead of incrementing a raw Euler field', () => {
|
||||||
|
const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [25, 30, -20] }, 'x', 15)
|
||||||
|
|
||||||
|
expect(rotated.rotation.every(Number.isFinite)).toBe(true)
|
||||||
|
expect(rotated.rotation).not.toEqual([40, 30, -20])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps equivalent Euler values near their current free rotation', () => {
|
||||||
|
const rotated = rotateBodyNodeAroundLocalAxis({ ...node, rotation: [0, 0, 350] }, 'x', 1)
|
||||||
|
|
||||||
|
expect(rotated.rotation[2]).toBeGreaterThan(300)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a spherical silhouette path stable across head rotations', () => {
|
||||||
|
const neutralPath = renderAvatar(
|
||||||
|
poseFromExpression(defaultExpression),
|
||||||
|
surfacePresets.sphere,
|
||||||
|
1,
|
||||||
|
{ includeWire: false }
|
||||||
|
).headPath
|
||||||
|
const rotatedPath = renderAvatar(
|
||||||
|
poseFromExpression({ ...defaultExpression, headX: 17, headY: 34, headZ: -21 }),
|
||||||
|
surfacePresets.sphere,
|
||||||
|
1,
|
||||||
|
{ includeWire: false }
|
||||||
|
).headPath
|
||||||
|
|
||||||
|
expect(rotatedPath).toBe(neutralPath)
|
||||||
|
})
|
||||||
|
})
|
||||||
45
src/__tests__/i18n-test.ts
Normal file
45
src/__tests__/i18n-test.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { translateStudioText } from '../i18n'
|
||||||
|
|
||||||
|
describe('avatar studio translations', () => {
|
||||||
|
it('uses English for static interface copy', () => {
|
||||||
|
expect(translateStudioText('Couleur des yeux', 'en')).toBe('Eye color')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates dynamic editor labels', () => {
|
||||||
|
expect(translateStudioText('Modifier l’expression 08', 'en')).toBe('Edit expression 08')
|
||||||
|
expect(translateStudioText('Sphère · porte les yeux', 'en')).toBe('Sphere · carries the eyes')
|
||||||
|
expect(translateStudioText('Cône 1 copie', 'en')).toBe('Cone 1 copy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the original French copy in French', () => {
|
||||||
|
expect(translateStudioText('Nouvel avatar', 'fr')).toBe('Nouvel avatar')
|
||||||
|
expect(translateStudioText('sleeping', 'fr')).toBe('sommeil')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates the studio interface and dynamic labels to Simplified Chinese', () => {
|
||||||
|
expect(translateStudioText('Couleur des yeux', 'zh-CN')).toBe('眼睛颜色')
|
||||||
|
expect(translateStudioText('Modifier l’expression 08', 'zh-CN')).toBe('编辑表情 08')
|
||||||
|
expect(translateStudioText('sleeping', 'zh-CN')).toBe('睡眠')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('translates every Photo Mode control to Simplified Chinese', () => {
|
||||||
|
expect(translateStudioText('Mode photo', 'zh-CN')).toBe('照片模式')
|
||||||
|
expect(translateStudioText('Dégradé radial', 'zh-CN')).toBe('径向渐变')
|
||||||
|
expect(translateStudioText('Définition du mode photo', 'zh-CN')).toBe('照片模式分辨率')
|
||||||
|
expect(translateStudioText('Télécharger en PNG', 'zh-CN')).toBe('下载 PNG')
|
||||||
|
expect(translateStudioText('Prendre une photo', 'zh-CN')).toBe('拍照')
|
||||||
|
expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('covers every configured state description in English', () => {
|
||||||
|
expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe(
|
||||||
|
'Steady rhythm and focused expressions.'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Grandes expressions et transitions rapides.', 'en')).toBe(
|
||||||
|
'Big expressions and fast transitions.'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Inclinaisons et forte asymétrie.', 'en')).toBe(
|
||||||
|
'Tilts and strong asymmetry.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
47
src/__tests__/manipulation-session-test.ts
Normal file
47
src/__tests__/manipulation-session-test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
beginManipulation,
|
||||||
|
beginManipulationFromRenderedValue,
|
||||||
|
finishManipulation,
|
||||||
|
previewManipulation,
|
||||||
|
} from '../manipulationSession'
|
||||||
|
|
||||||
|
describe('pointer manipulation session', () => {
|
||||||
|
it('previews many values and commits exactly once', () => {
|
||||||
|
const previews: number[] = []
|
||||||
|
const commits: number[] = []
|
||||||
|
const session = beginManipulation(10)
|
||||||
|
previewManipulation(session, 11, value => previews.push(value))
|
||||||
|
previewManipulation(session, 12, value => previews.push(value))
|
||||||
|
|
||||||
|
finishManipulation(session, 'commit', {
|
||||||
|
preview: value => previews.push(value),
|
||||||
|
commit: value => commits.push(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(previews).toEqual([11, 12])
|
||||||
|
expect(commits).toEqual([12])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the initial value when pointer capture is cancelled', () => {
|
||||||
|
const previews: number[] = []
|
||||||
|
const session = beginManipulation(10)
|
||||||
|
previewManipulation(session, 20, () => undefined)
|
||||||
|
|
||||||
|
finishManipulation(session, 'cancel', {
|
||||||
|
preview: value => previews.push(value),
|
||||||
|
commit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(previews).toEqual([10])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts from the live rendered value while an interpolation is running', () => {
|
||||||
|
const staleInspectorValue = { headX: 0, headY: 0 }
|
||||||
|
const renderedMidTransition = { headX: 18, headY: 27 }
|
||||||
|
|
||||||
|
const session = beginManipulationFromRenderedValue(() => renderedMidTransition)
|
||||||
|
|
||||||
|
expect(session.initial).toBe(renderedMidTransition)
|
||||||
|
expect(session.initial).not.toBe(staleInspectorValue)
|
||||||
|
})
|
||||||
|
})
|
||||||
43
src/__tests__/playback-test.ts
Normal file
43
src/__tests__/playback-test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { createInitialSequences } from '../sequences'
|
||||||
|
import {
|
||||||
|
advancePlaybackTimeline,
|
||||||
|
beginPlayback,
|
||||||
|
createPlaybackTimeline,
|
||||||
|
pausePlaybackTimeline,
|
||||||
|
schedulePlaybackBlink,
|
||||||
|
schedulePlaybackStep,
|
||||||
|
} from '../playback'
|
||||||
|
|
||||||
|
describe('State playback timeline', () => {
|
||||||
|
it('preserves the current sequence position when the avatar changes', () => {
|
||||||
|
const timeline = createPlaybackTimeline()
|
||||||
|
timeline.position = 1
|
||||||
|
timeline.direction = -1
|
||||||
|
|
||||||
|
const preserved = beginPlayback(timeline, true)
|
||||||
|
|
||||||
|
expect(preserved.position).toBe(1)
|
||||||
|
expect(preserved.direction).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pauses and resumes from the exact remaining delays', () => {
|
||||||
|
const timeline = createPlaybackTimeline()
|
||||||
|
const scheduledStep = schedulePlaybackStep(timeline, 1_000, 500)
|
||||||
|
const scheduled = schedulePlaybackBlink(scheduledStep, 1_000, 900)
|
||||||
|
|
||||||
|
const paused = pausePlaybackTimeline(scheduled, 1_200)
|
||||||
|
|
||||||
|
expect(paused.stepRemainingMs).toBe(300)
|
||||||
|
expect(paused.blinkRemainingMs).toBe(700)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('owns cursor advancement for the active State', () => {
|
||||||
|
const idle = createInitialSequences().find(sequence => sequence.id === 'idle')!
|
||||||
|
const timeline = createPlaybackTimeline()
|
||||||
|
|
||||||
|
const result = advancePlaybackTimeline(timeline, idle)
|
||||||
|
|
||||||
|
expect(result.cursor.complete).toBe(false)
|
||||||
|
expect(result.timeline.position).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
26
src/__tests__/presets-test.ts
Normal file
26
src/__tests__/presets-test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { getStatePlaybackConfig } from '../presets'
|
||||||
|
|
||||||
|
describe('state playback configuration', () => {
|
||||||
|
it('keeps idle slower than an active sequence', () => {
|
||||||
|
expect(getStatePlaybackConfig('idle').expressionIntervalMs).toBe(5200)
|
||||||
|
expect(getStatePlaybackConfig('listening').expressionIntervalMs).toBe(2300)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes a valid randomized blink interval', () => {
|
||||||
|
const { blink } = getStatePlaybackConfig('idle')
|
||||||
|
|
||||||
|
expect(blink.initialDelayMs).toBeGreaterThan(0)
|
||||||
|
expect(blink.minIntervalMs).toBeLessThan(blink.maxIntervalMs)
|
||||||
|
expect(blink.durationMs).toBe(280)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a blink rhythm adapted to each sequence family', () => {
|
||||||
|
const sleeping = getStatePlaybackConfig('sleeping').blink
|
||||||
|
const listening = getStatePlaybackConfig('listening').blink
|
||||||
|
const excited = getStatePlaybackConfig('excited').blink
|
||||||
|
|
||||||
|
expect(sleeping.minIntervalMs).toBeGreaterThan(listening.minIntervalMs)
|
||||||
|
expect(listening.minIntervalMs).toBeGreaterThan(excited.minIntervalMs)
|
||||||
|
expect(sleeping.durationMs).toBeGreaterThan(excited.durationMs)
|
||||||
|
})
|
||||||
|
})
|
||||||
20
src/__tests__/rendered-rotation-gizmo-test.ts
Normal file
20
src/__tests__/rendered-rotation-gizmo-test.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
import { createRenderedRotationGizmo, paintRenderedRotationGizmo } from '../renderedRotationGizmo'
|
||||||
|
|
||||||
|
describe('rendered rotation gizmo', () => {
|
||||||
|
it('updates its rings without requiring a React render', () => {
|
||||||
|
const gizmo = createRenderedRotationGizmo(defaultExpression)
|
||||||
|
const initialXPath = gizmo.xPath.get()
|
||||||
|
const initialYPath = gizmo.yPath.get()
|
||||||
|
|
||||||
|
paintRenderedRotationGizmo(gizmo, {
|
||||||
|
...defaultExpression,
|
||||||
|
headX: 28,
|
||||||
|
headY: -36,
|
||||||
|
headZ: 17,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(gizmo.xPath.get()).not.toBe(initialXPath)
|
||||||
|
expect(gizmo.yPath.get()).not.toBe(initialYPath)
|
||||||
|
})
|
||||||
|
})
|
||||||
72
src/__tests__/rendered-scene-test.ts
Normal file
72
src/__tests__/rendered-scene-test.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { createBodyNode } from '../body'
|
||||||
|
import { renderAvatar, poseFromExpression } from '../geometry'
|
||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
import {
|
||||||
|
createRenderedColors,
|
||||||
|
createRenderedScene,
|
||||||
|
findBodyNodePath,
|
||||||
|
paintRenderedColors,
|
||||||
|
paintRenderedScene,
|
||||||
|
} from '../renderedScene'
|
||||||
|
import { surfacePresets } from '../surfaces'
|
||||||
|
import defaultStudioDocument from '../defaultStudioDocument.json'
|
||||||
|
import type { BodyNode } from '../body'
|
||||||
|
import type { Expression } from '../geometry'
|
||||||
|
import type { SurfaceConfig } from '../surfaces'
|
||||||
|
|
||||||
|
describe('rendered avatar scene', () => {
|
||||||
|
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
||||||
|
const node = createBodyNode('sphere', 0)
|
||||||
|
const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
|
||||||
|
bodyNodes: [node],
|
||||||
|
})
|
||||||
|
const scene = createRenderedScene(first)
|
||||||
|
const rotated = renderAvatar(
|
||||||
|
poseFromExpression({ ...defaultExpression, headY: 35 }),
|
||||||
|
surfacePresets.sphere,
|
||||||
|
1,
|
||||||
|
{ bodyNodes: [node] }
|
||||||
|
)
|
||||||
|
|
||||||
|
paintRenderedScene(scene, rotated)
|
||||||
|
|
||||||
|
expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath)
|
||||||
|
expect(findBodyNodePath(scene, node.id)).not.toBeNull()
|
||||||
|
expect(scene.headPath.get()).toBe(rotated.headPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates animated colors without replacing their motion values', () => {
|
||||||
|
const colors = createRenderedColors({ body: '#5b7fe5', eyes: '#111316' })
|
||||||
|
const body = colors.body
|
||||||
|
const eyes = colors.eyes
|
||||||
|
|
||||||
|
paintRenderedColors(colors, { body: '#c53b47', eyes: '#ffffff' })
|
||||||
|
|
||||||
|
expect(colors.body).toBe(body)
|
||||||
|
expect(colors.eyes).toBe(eyes)
|
||||||
|
expect(colors.body.get()).toBe('#c53b47')
|
||||||
|
expect(colors.eyes.get()).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Cloudee accessories behind the eyes at expression position 05', () => {
|
||||||
|
const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')!
|
||||||
|
const expression = defaultStudioDocument.expressions[5] as Expression
|
||||||
|
|
||||||
|
const geometry = renderAvatar(
|
||||||
|
poseFromExpression(expression),
|
||||||
|
avatar.body.primary as SurfaceConfig,
|
||||||
|
1,
|
||||||
|
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(geometry.frontNodeIds).toEqual([])
|
||||||
|
|
||||||
|
const clearlyTurned = renderAvatar(
|
||||||
|
poseFromExpression({ ...expression, headY: -35 }),
|
||||||
|
avatar.body.primary as SurfaceConfig,
|
||||||
|
1,
|
||||||
|
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||||
|
)
|
||||||
|
expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f')
|
||||||
|
})
|
||||||
|
})
|
||||||
97
src/__tests__/sequences-test.ts
Normal file
97
src/__tests__/sequences-test.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
advanceSequenceCursor,
|
||||||
|
createInitialSequences,
|
||||||
|
getSequenceSpring,
|
||||||
|
normalizeSequencesForExpressions,
|
||||||
|
parseSequences,
|
||||||
|
remapSequencesAfterExpressionDelete,
|
||||||
|
} from '../sequences'
|
||||||
|
import { initialExpressions } from '../presets'
|
||||||
|
|
||||||
|
describe('editable avatar sequences', () => {
|
||||||
|
it('creates the idle animation with editable steps and blink settings', () => {
|
||||||
|
const idle = createInitialSequences().find(sequence => sequence.id === 'idle')
|
||||||
|
|
||||||
|
expect(idle?.steps.map(step => step.expressionId)).toEqual([
|
||||||
|
initialExpressions[0].id,
|
||||||
|
initialExpressions[8].id,
|
||||||
|
])
|
||||||
|
expect(idle?.steps[0].holdMs).toBe(5200)
|
||||||
|
expect(idle?.blink.durationMs).toBe(280)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports loop, once and ping-pong playback cursors', () => {
|
||||||
|
const base = createInitialSequences().find(sequence => sequence.id === 'listening')!
|
||||||
|
|
||||||
|
expect(advanceSequenceCursor({ ...base, playbackMode: 'loop' }, 2, 1)).toEqual({
|
||||||
|
index: 0,
|
||||||
|
direction: 1,
|
||||||
|
complete: false,
|
||||||
|
})
|
||||||
|
expect(advanceSequenceCursor({ ...base, playbackMode: 'once' }, 2, 1).complete).toBe(true)
|
||||||
|
expect(advanceSequenceCursor({ ...base, playbackMode: 'pingPong' }, 2, 1)).toEqual({
|
||||||
|
index: 1,
|
||||||
|
direction: -1,
|
||||||
|
complete: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps sequence references stable when expressions are reordered', () => {
|
||||||
|
const sequence = createInitialSequences().find(item => item.id === 'idle')!
|
||||||
|
const reordered = [...initialExpressions].reverse()
|
||||||
|
const [normalized] = normalizeSequencesForExpressions([sequence], reordered)
|
||||||
|
|
||||||
|
expect(normalized.steps.map(step => step.expressionId)).toEqual(
|
||||||
|
sequence.steps.map(step => step.expressionId)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a sequence playable when its only referenced expression is deleted', () => {
|
||||||
|
const sequence = createInitialSequences().find(item => item.id === 'waking')!
|
||||||
|
const fallbackId = initialExpressions[12].id
|
||||||
|
const [remapped] = remapSequencesAfterExpressionDelete(
|
||||||
|
[sequence],
|
||||||
|
initialExpressions[13].id,
|
||||||
|
fallbackId
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(remapped.steps).toHaveLength(1)
|
||||||
|
expect(remapped.steps[0].expressionId).toBe(fallbackId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes persisted timing values and invalid playback values', () => {
|
||||||
|
const [sequence] = parseSequences([
|
||||||
|
{
|
||||||
|
id: 'custom',
|
||||||
|
name: 'Custom',
|
||||||
|
playbackMode: 'invalid',
|
||||||
|
steps: [{ expressionId: initialExpressions[2].id, holdMs: -5, transitionMs: 99999 }],
|
||||||
|
blink: { minIntervalMs: 9000, maxIntervalMs: 1000, durationMs: 2 },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(sequence.playbackMode).toBe('loop')
|
||||||
|
expect(sequence.steps[0].holdMs).toBe(100)
|
||||||
|
expect(sequence.steps[0].transitionMs).toBe(5000)
|
||||||
|
expect(sequence.blink.maxIntervalMs).toBe(sequence.blink.minIntervalMs)
|
||||||
|
expect(sequence.blink.durationMs).toBe(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repairs missing and out-of-range expression references on load', () => {
|
||||||
|
const sequence = createInitialSequences().find(item => item.id === 'idle')!
|
||||||
|
const [normalized] = normalizeSequencesForExpressions(
|
||||||
|
[{ ...sequence, steps: [{ ...sequence.steps[0], expressionId: 'missing' }] }],
|
||||||
|
initialExpressions.slice(0, 4)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps transition styles and durations to distinct spring dynamics', () => {
|
||||||
|
const smooth = getSequenceSpring('smooth', 900, 7)
|
||||||
|
const snappy = getSequenceSpring('snappy', 250, 7)
|
||||||
|
|
||||||
|
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
|
||||||
|
expect(smooth.damping).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/__tests__/snapshot-exporter-test.ts
Normal file
41
src/__tests__/snapshot-exporter-test.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { poseFromExpression, renderAvatar } from '../geometry'
|
||||||
|
import { defaultExpression } from '../presets'
|
||||||
|
import { createRenderedScene, paintRenderedOffset } from '../renderedScene'
|
||||||
|
import { serializeAvatarSnapshot, snapshotFileName } from '../snapshotExporter'
|
||||||
|
import { surfacePresets } from '../surfaces'
|
||||||
|
|
||||||
|
describe('avatar snapshot export', () => {
|
||||||
|
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
|
||||||
|
const scene = createRenderedScene(geometry)
|
||||||
|
const colors = { body: '#5b7fe5', eyes: '#111316' }
|
||||||
|
|
||||||
|
it('exports the currently rendered scene as a transparent SVG', () => {
|
||||||
|
paintRenderedOffset(scene, { x: 3, y: -2 })
|
||||||
|
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
|
||||||
|
background: 'transparent',
|
||||||
|
colorFrom: '#ffffff',
|
||||||
|
colorTo: '#000000',
|
||||||
|
size: 1024,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(svg).toContain('width="1024" height="1024"')
|
||||||
|
expect(svg).toContain('transform="translate(3 -2)"')
|
||||||
|
expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
|
||||||
|
expect(svg).toContain('fill="#111316"')
|
||||||
|
expect(svg).not.toContain('<rect')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('embeds a radial background without external dependencies', () => {
|
||||||
|
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
|
||||||
|
background: 'radial',
|
||||||
|
colorFrom: '#ffffff',
|
||||||
|
colorTo: '#8899aa',
|
||||||
|
size: 512,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(svg).toContain('<radialGradient id="snapshot-radial"')
|
||||||
|
expect(svg).toContain('fill="url(#snapshot-radial)"')
|
||||||
|
expect(snapshotFileName('Étoile du soir')).toBe('etoile-du-soir-snapshot.svg')
|
||||||
|
expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
171
src/__tests__/studio-document-test.ts
Normal file
171
src/__tests__/studio-document-test.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { createAvatar } from '../avatars'
|
||||||
|
import { createInitialSequences } from '../sequences'
|
||||||
|
import { initialExpressions } from '../presets'
|
||||||
|
import {
|
||||||
|
createStudioDocumentStore,
|
||||||
|
loadStudioDocument,
|
||||||
|
parseImportedStudioDocument,
|
||||||
|
serializeStudioDocument,
|
||||||
|
type StudioDocument,
|
||||||
|
} from '../studioDocument'
|
||||||
|
|
||||||
|
const documentFixture = (): StudioDocument => {
|
||||||
|
const avatar = createAvatar('Strobi')
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
library: {
|
||||||
|
activeAvatarId: avatar.id,
|
||||||
|
avatars: [avatar],
|
||||||
|
},
|
||||||
|
expressions: initialExpressions,
|
||||||
|
sequences: createInitialSequences(),
|
||||||
|
playback: { stateId: 'idle', playing: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Studio document', () => {
|
||||||
|
const storage = (value: string | null = null) => ({ getItem: () => value })
|
||||||
|
|
||||||
|
it('loads the bundled Studio snapshot when no local project exists', () => {
|
||||||
|
const document = loadStudioDocument(storage())
|
||||||
|
|
||||||
|
expect(document.library.avatars).toHaveLength(10)
|
||||||
|
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
||||||
|
expect(document.library.avatars[0].name).toBe('Strobi')
|
||||||
|
expect(document.expressions).toHaveLength(27)
|
||||||
|
expect(document.sequences).toHaveLength(23)
|
||||||
|
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a locally saved project authoritative over the bundled snapshot', () => {
|
||||||
|
const localDocument = documentFixture()
|
||||||
|
|
||||||
|
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists one coherent document after a mutation', () => {
|
||||||
|
const persisted: StudioDocument[] = []
|
||||||
|
const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
|
||||||
|
|
||||||
|
store.update({ playback: { stateId: 'idle', playing: false } })
|
||||||
|
|
||||||
|
expect(persisted).toHaveLength(1)
|
||||||
|
expect(persisted[0].playback).toEqual({ stateId: 'idle', playing: false })
|
||||||
|
expect(persisted[0].expressions).toHaveLength(initialExpressions.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repairs sequence references in the same transaction as expression deletion', () => {
|
||||||
|
const store = createStudioDocumentStore(documentFixture(), () => undefined)
|
||||||
|
const remainingExpressions = initialExpressions.slice(1)
|
||||||
|
|
||||||
|
const next = store.update({ expressions: remainingExpressions })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
next.sequences.every(sequence =>
|
||||||
|
sequence.steps.every(step =>
|
||||||
|
remainingExpressions.some(item => item.id === step.expressionId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips a complete project document as portable JSON', () => {
|
||||||
|
const document = documentFixture()
|
||||||
|
const expression = { ...initialExpressions[0], widthLeft: 42 }
|
||||||
|
const sequence = {
|
||||||
|
...createInitialSequences()[0],
|
||||||
|
steps: createInitialSequences()[0].steps.map(step => ({
|
||||||
|
...step,
|
||||||
|
expressionId: expression.id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
document.library.avatars[0].behavior = {
|
||||||
|
expressions: [expression],
|
||||||
|
sequences: [sequence],
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = parseImportedStudioDocument(serializeStudioDocument(document), document)
|
||||||
|
|
||||||
|
expect(imported).toEqual(document)
|
||||||
|
expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the base library unchanged when an avatar owns customized behavior', () => {
|
||||||
|
const document = documentFixture()
|
||||||
|
const avatar = document.library.avatars[0]
|
||||||
|
const customized = {
|
||||||
|
...avatar,
|
||||||
|
behavior: {
|
||||||
|
expressions: [{ ...initialExpressions[0], widthLeft: 47 }],
|
||||||
|
sequences: createInitialSequences().slice(0, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const store = createStudioDocumentStore(document, () => undefined)
|
||||||
|
|
||||||
|
const next = store.update({
|
||||||
|
library: { activeAvatarId: avatar.id, avatars: [customized] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(next.expressions[0].widthLeft).toBe(initialExpressions[0].widthLeft)
|
||||||
|
expect(next.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(47)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects files that are not versioned Studio projects', () => {
|
||||||
|
const fallback = documentFixture()
|
||||||
|
|
||||||
|
expect(() => parseImportedStudioDocument('{"version":1}', fallback)).toThrow(
|
||||||
|
'Unsupported Avatar Studio project'
|
||||||
|
)
|
||||||
|
expect(() => parseImportedStudioDocument('{broken', fallback)).toThrow(
|
||||||
|
'Invalid Avatar Studio project'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('repairs an imported active avatar and missing expression references', () => {
|
||||||
|
const fallback = documentFixture()
|
||||||
|
const avatar = createAvatar('Portable')
|
||||||
|
const imported = parseImportedStudioDocument(
|
||||||
|
JSON.stringify({
|
||||||
|
...fallback,
|
||||||
|
library: { activeAvatarId: 'missing', avatars: [avatar] },
|
||||||
|
sequences: [
|
||||||
|
{
|
||||||
|
...createInitialSequences()[0],
|
||||||
|
steps: [{ ...createInitialSequences()[0].steps[0], expressionId: 'missing' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(imported.library.activeAvatarId).toBe(avatar.id)
|
||||||
|
expect(imported.sequences[0].steps[0].expressionId).toBe(imported.expressions[0].id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sanitizes imported animation timing and playback values', () => {
|
||||||
|
const fallback = documentFixture()
|
||||||
|
const imported = parseImportedStudioDocument(
|
||||||
|
JSON.stringify({
|
||||||
|
...fallback,
|
||||||
|
sequences: [
|
||||||
|
{
|
||||||
|
...fallback.sequences[0],
|
||||||
|
playbackMode: 'unsupported',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
...fallback.sequences[0].steps[0],
|
||||||
|
holdMs: -500,
|
||||||
|
transitionMs: Number.POSITIVE_INFINITY,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(imported.sequences[0].playbackMode).toBe('loop')
|
||||||
|
expect(imported.sequences[0].steps[0].holdMs).toBeGreaterThanOrEqual(100)
|
||||||
|
expect(Number.isFinite(imported.sequences[0].steps[0].transitionMs)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
96
src/ambientMotion.ts
Normal file
96
src/ambientMotion.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import type { BodyMotion, Expression, EyeMotion } from './geometry'
|
||||||
|
|
||||||
|
export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const
|
||||||
|
export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const
|
||||||
|
const eyeMotionSet = new Set<string>(eyeMotionModes)
|
||||||
|
const bodyMotionSet = new Set<string>(bodyMotionModes)
|
||||||
|
export const isEyeMotion = (value: unknown): value is EyeMotion =>
|
||||||
|
typeof value === 'string' && eyeMotionSet.has(value)
|
||||||
|
export const isBodyMotion = (value: unknown): value is BodyMotion =>
|
||||||
|
typeof value === 'string' && bodyMotionSet.has(value)
|
||||||
|
|
||||||
|
const smoothstep = (value: number) => value * value * (3 - 2 * value)
|
||||||
|
const hash = (value: number) => {
|
||||||
|
const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
|
||||||
|
return (raw - Math.floor(raw)) * 2 - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressionSeed = (expression: Expression) =>
|
||||||
|
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
|
||||||
|
|
||||||
|
const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => {
|
||||||
|
const progress = elapsedMs / interval
|
||||||
|
const step = Math.floor(progress)
|
||||||
|
const blend = smoothstep(progress - step)
|
||||||
|
const previous = hash(step * 3 + axis + seed)
|
||||||
|
const next = hash((step + 1) * 3 + axis + seed)
|
||||||
|
return previous + (next - previous) * blend
|
||||||
|
}
|
||||||
|
|
||||||
|
const saccade = (elapsedMs: number, axis: number, seed: number) => {
|
||||||
|
const interval = 1100
|
||||||
|
const warpedTime = elapsedMs + Math.sin(elapsedMs / 1700 + seed) * 280
|
||||||
|
const step = Math.floor(warpedTime / interval)
|
||||||
|
const progress = (((warpedTime % interval) + interval) % interval) / interval
|
||||||
|
const blend = smoothstep(Math.min(progress / 0.09, 1))
|
||||||
|
const previous = hash((step - 1) * 2 + axis + seed)
|
||||||
|
const next = hash(step * 2 + axis + seed)
|
||||||
|
const fadeIn = Math.min(elapsedMs / 240, 1)
|
||||||
|
return (previous + (next - previous) * blend) * fadeIn
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasAmbientMotion = (expression: Expression) =>
|
||||||
|
expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
|
||||||
|
|
||||||
|
export const ambientBodyOffset = (expression: Expression, elapsedMs: number) => {
|
||||||
|
const seed = expressionSeed(expression)
|
||||||
|
if (expression.bodyMotion === 'slowDrift') {
|
||||||
|
return {
|
||||||
|
x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45,
|
||||||
|
y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expression.bodyMotion === 'shake') {
|
||||||
|
const time = elapsedMs / 1000
|
||||||
|
return {
|
||||||
|
x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35,
|
||||||
|
y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyAmbientMotion = (expression: Expression, elapsedMs: number): Expression => {
|
||||||
|
const next = { ...expression }
|
||||||
|
const seed = expressionSeed(expression)
|
||||||
|
|
||||||
|
if (expression.eyeMotion === 'microSaccades') {
|
||||||
|
const x = saccade(elapsedMs, 0, seed) * 1.5
|
||||||
|
const y = saccade(elapsedMs, 1, seed) * 0.9
|
||||||
|
next.positionXLeft += x
|
||||||
|
next.positionXRight += x
|
||||||
|
next.positionYLeft += y
|
||||||
|
next.positionYRight += y
|
||||||
|
} else if (expression.eyeMotion === 'shake') {
|
||||||
|
const time = elapsedMs / 1000
|
||||||
|
const x = (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2
|
||||||
|
const y = (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8
|
||||||
|
next.positionXLeft += x
|
||||||
|
next.positionXRight += x
|
||||||
|
next.positionYLeft += y
|
||||||
|
next.positionYRight += y
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expression.bodyMotion === 'slowDrift') {
|
||||||
|
next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8
|
||||||
|
next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15
|
||||||
|
next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45
|
||||||
|
} else if (expression.bodyMotion === 'shake') {
|
||||||
|
const time = elapsedMs / 1000
|
||||||
|
next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15
|
||||||
|
next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35
|
||||||
|
next.headZ += Math.sin(time * 43) * 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
211
src/avatars.ts
Normal file
211
src/avatars.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { parseAvatarBody, type AvatarBody } from './body'
|
||||||
|
import { defaultExpression, initialExpressions } from './presets'
|
||||||
|
import { surfacePresets } from './surfaces'
|
||||||
|
import type { Expression } from './geometry'
|
||||||
|
import { isBodyMotion, isEyeMotion } from './ambientMotion'
|
||||||
|
import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences'
|
||||||
|
|
||||||
|
export type AvatarBehaviorLibrary = {
|
||||||
|
expressions: Expression[]
|
||||||
|
sequences: AvatarSequence[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudioAvatar = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
body: AvatarBody
|
||||||
|
colors: AvatarColors
|
||||||
|
eyes: AvatarEyeDefaults
|
||||||
|
behavior?: AvatarBehaviorLibrary
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarColors = { body: string; eyes: string }
|
||||||
|
export type AvatarEyeDefaults = Pick<
|
||||||
|
Expression,
|
||||||
|
| 'widthLeft'
|
||||||
|
| 'widthRight'
|
||||||
|
| 'heightLeft'
|
||||||
|
| 'heightRight'
|
||||||
|
| 'spacing'
|
||||||
|
| 'positionXLeft'
|
||||||
|
| 'positionXRight'
|
||||||
|
| 'positionYLeft'
|
||||||
|
| 'positionYRight'
|
||||||
|
| 'leftAngle'
|
||||||
|
| 'rightAngle'
|
||||||
|
>
|
||||||
|
export const defaultAvatarColors: AvatarColors = { body: '#5b7fe5', eyes: '#111316' }
|
||||||
|
export const defaultAvatarEyes: AvatarEyeDefaults = {
|
||||||
|
widthLeft: defaultExpression.widthLeft,
|
||||||
|
widthRight: defaultExpression.widthRight,
|
||||||
|
heightLeft: defaultExpression.heightLeft,
|
||||||
|
heightRight: defaultExpression.heightRight,
|
||||||
|
spacing: defaultExpression.spacing,
|
||||||
|
positionXLeft: defaultExpression.positionXLeft,
|
||||||
|
positionXRight: defaultExpression.positionXRight,
|
||||||
|
positionYLeft: defaultExpression.positionYLeft,
|
||||||
|
positionYRight: defaultExpression.positionYRight,
|
||||||
|
leftAngle: defaultExpression.leftAngle,
|
||||||
|
rightAngle: defaultExpression.rightAngle,
|
||||||
|
}
|
||||||
|
const hexColor = /^#[0-9a-f]{6}$/i
|
||||||
|
const parseColors = (value: unknown): AvatarColors => {
|
||||||
|
const candidate = value as Partial<AvatarColors> | null
|
||||||
|
return {
|
||||||
|
body:
|
||||||
|
typeof candidate?.body === 'string' && hexColor.test(candidate.body)
|
||||||
|
? candidate.body
|
||||||
|
: defaultAvatarColors.body,
|
||||||
|
eyes:
|
||||||
|
typeof candidate?.eyes === 'string' && hexColor.test(candidate.eyes)
|
||||||
|
? candidate.eyes
|
||||||
|
: defaultAvatarColors.eyes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eyeDefaultFields = Object.keys(defaultAvatarEyes) as (keyof AvatarEyeDefaults)[]
|
||||||
|
export const parseAvatarEyeDefaults = (value: unknown): AvatarEyeDefaults => {
|
||||||
|
const candidate = value as Partial<AvatarEyeDefaults> | null
|
||||||
|
const parsed = { ...defaultAvatarEyes }
|
||||||
|
eyeDefaultFields.forEach(field => {
|
||||||
|
const stored = candidate?.[field]
|
||||||
|
if (typeof stored === 'number' && Number.isFinite(stored)) parsed[field] = stored
|
||||||
|
})
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyAvatarEyeDefaults = (
|
||||||
|
expression: Expression,
|
||||||
|
eyes: AvatarEyeDefaults = defaultAvatarEyes
|
||||||
|
): Expression => {
|
||||||
|
const result = { ...expression }
|
||||||
|
eyeDefaultFields.forEach(field => {
|
||||||
|
result[field] = expression[field] + eyes[field] - defaultAvatarEyes[field]
|
||||||
|
})
|
||||||
|
result.widthLeft = Math.max(10, result.widthLeft)
|
||||||
|
result.widthRight = Math.max(10, result.widthRight)
|
||||||
|
result.heightLeft = Math.max(10, result.heightLeft)
|
||||||
|
result.heightRight = Math.max(10, result.heightRight)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarLibrary = {
|
||||||
|
activeAvatarId: string
|
||||||
|
avatars: StudioAvatar[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneExpressions = (expressions: Expression[]) => expressions.map(item => ({ ...item }))
|
||||||
|
export const parseExpressions = (value: unknown): Expression[] => {
|
||||||
|
if (!Array.isArray(value) || !value.length) return cloneExpressions(initialExpressions)
|
||||||
|
return value.map((item, index) => {
|
||||||
|
if (!item || typeof item !== 'object') {
|
||||||
|
return { ...defaultExpression, id: `expression-${String(index).padStart(2, '0')}` }
|
||||||
|
}
|
||||||
|
const candidate = item as Partial<Expression>
|
||||||
|
const storedEyeMotion = (item as { eyeMotion?: unknown }).eyeMotion
|
||||||
|
const storedBodyMotion = (item as { bodyMotion?: unknown }).bodyMotion
|
||||||
|
const parsed = Object.fromEntries(
|
||||||
|
Object.entries(defaultExpression).map(([field, fallback]) => {
|
||||||
|
if (field === 'id') {
|
||||||
|
return [
|
||||||
|
field,
|
||||||
|
typeof candidate.id === 'string' && candidate.id
|
||||||
|
? candidate.id
|
||||||
|
: `expression-${String(index).padStart(2, '0')}`,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const stored = candidate[field as keyof Expression]
|
||||||
|
return [field, typeof stored === 'number' && Number.isFinite(stored) ? stored : fallback]
|
||||||
|
})
|
||||||
|
) as Expression
|
||||||
|
if (typeof candidate.bodyColor === 'string' && hexColor.test(candidate.bodyColor))
|
||||||
|
parsed.bodyColor = candidate.bodyColor
|
||||||
|
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
|
||||||
|
parsed.eyeColor = candidate.eyeColor
|
||||||
|
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
|
||||||
|
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
|
||||||
|
? storedBodyMotion
|
||||||
|
: defaultExpression.bodyMotion
|
||||||
|
return parsed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneSequences = (sequences: AvatarSequence[]) =>
|
||||||
|
sequences.map(sequence => ({
|
||||||
|
...sequence,
|
||||||
|
steps: sequence.steps.map(step => ({ ...step })),
|
||||||
|
blink: { ...sequence.blink },
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
|
||||||
|
expressions: cloneExpressions(behavior.expressions),
|
||||||
|
sequences: cloneSequences(behavior.sequences),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveAvatarBehavior = (
|
||||||
|
avatar: StudioAvatar,
|
||||||
|
base: AvatarBehaviorLibrary
|
||||||
|
): AvatarBehaviorLibrary => avatar.behavior ?? base
|
||||||
|
|
||||||
|
const parseAvatarBehavior = (
|
||||||
|
value: unknown,
|
||||||
|
base: AvatarBehaviorLibrary
|
||||||
|
): AvatarBehaviorLibrary | undefined => {
|
||||||
|
if (!value || typeof value !== 'object') return undefined
|
||||||
|
const candidate = value as Partial<AvatarBehaviorLibrary>
|
||||||
|
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
|
||||||
|
const expressions = parseExpressions(candidate.expressions)
|
||||||
|
const sequences = normalizeSequencesForExpressions(
|
||||||
|
Array.isArray(candidate.sequences)
|
||||||
|
? parseSequences(candidate.sequences)
|
||||||
|
: cloneSequences(base.sequences),
|
||||||
|
expressions
|
||||||
|
)
|
||||||
|
return { expressions, sequences }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAvatar = (name: string): StudioAvatar => ({
|
||||||
|
id: `avatar-${crypto.randomUUID()}`,
|
||||||
|
name: name.trim() || 'Nouvel avatar',
|
||||||
|
body: { primary: { ...surfacePresets.sphere }, nodes: [] },
|
||||||
|
colors: { ...defaultAvatarColors },
|
||||||
|
eyes: { ...defaultAvatarEyes },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const parseAvatarLibrary = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: AvatarLibrary,
|
||||||
|
baseBehavior: AvatarBehaviorLibrary
|
||||||
|
): AvatarLibrary => {
|
||||||
|
try {
|
||||||
|
const parsed = value as Partial<AvatarLibrary> | null
|
||||||
|
if (!parsed || !Array.isArray(parsed.avatars) || !parsed.avatars.length) return fallback
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const avatars = parsed.avatars
|
||||||
|
.filter(avatar => {
|
||||||
|
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
|
||||||
|
return false
|
||||||
|
if (seenIds.has(avatar.id)) return false
|
||||||
|
seenIds.add(avatar.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(avatar => {
|
||||||
|
const behavior = parseAvatarBehavior(avatar.behavior, baseBehavior)
|
||||||
|
return {
|
||||||
|
id: avatar.id,
|
||||||
|
name: avatar.name,
|
||||||
|
body: parseAvatarBody(avatar.body, surfacePresets.sphere),
|
||||||
|
colors: parseColors(avatar.colors),
|
||||||
|
eyes: parseAvatarEyeDefaults(avatar.eyes),
|
||||||
|
...(behavior ? { behavior } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!avatars.length) return fallback
|
||||||
|
const activeAvatarId = avatars.some(avatar => avatar.id === parsed.activeAvatarId)
|
||||||
|
? parsed.activeAvatarId!
|
||||||
|
: avatars[0].id
|
||||||
|
return { activeAvatarId, avatars }
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
117
src/body.ts
Normal file
117
src/body.ts
Normal file
@ -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<SurfaceConfig>
|
||||||
|
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<AvatarBody>
|
||||||
|
const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
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],
|
||||||
|
})
|
||||||
12
src/canvasPreview.ts
Normal file
12
src/canvasPreview.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from './avatars'
|
||||||
|
import { type Expression } from './geometry'
|
||||||
|
|
||||||
|
export type CanvasPreviewTarget = 'head' | 'eyes'
|
||||||
|
|
||||||
|
export const resolveCanvasPreviewExpression = (
|
||||||
|
expression: Expression,
|
||||||
|
avatarEyes: AvatarEyeDefaults,
|
||||||
|
bodyEditing: boolean,
|
||||||
|
target: CanvasPreviewTarget
|
||||||
|
) =>
|
||||||
|
bodyEditing && target === 'head' ? applyAvatarEyeDefaults(expression, avatarEyes) : expression
|
||||||
57
src/components/ui/accordion.tsx
Normal file
57
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
data-slot="accordion"
|
||||||
|
className={cn('grid gap-3', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn('overflow-hidden rounded-2xl border bg-card', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header data-slot="accordion-header">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between gap-4 p-5 text-left outline-none transition-colors hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDown className="size-4 shrink-0 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Panel
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className={cn('h-[var(--accordion-panel-height)] overflow-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="px-5 pb-5">{children}</div>
|
||||||
|
</AccordionPrimitive.Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
|
||||||
71
src/components/ui/alert-dialog.tsx
Normal file
71
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
function AlertDialog(props: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return <AlertDialogPrimitive.Trigger {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({ className, ...props }: AlertDialogPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal>
|
||||||
|
<AlertDialogPrimitive.Backdrop className="fixed inset-0 z-50 bg-black/30 backdrop-blur-sm" />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
className={cn(
|
||||||
|
'fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-5 text-popover-foreground shadow-2xl ring-1 ring-foreground/10 outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('grid gap-1.5', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({ className, ...props }: AlertDialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title className={cn('text-base font-semibold', className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({ className, ...props }: AlertDialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('flex justify-end gap-2', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel(props: AlertDialogPrimitive.Close.Props) {
|
||||||
|
return <AlertDialogPrimitive.Close render={<Button variant="outline" />} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction(props: React.ComponentProps<typeof Button>) {
|
||||||
|
return <Button variant="destructive" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex w-fit shrink-0 items-center justify-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
outline: 'border-border text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
44
src/components/ui/button.tsx
Normal file
44
src/components/ui/button.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
outline: 'border-border bg-background hover:bg-muted hover:text-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-muted',
|
||||||
|
ghost: 'hover:bg-muted hover:text-foreground',
|
||||||
|
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-8 gap-1.5 px-2.5',
|
||||||
|
sm: 'h-7 gap-1 rounded-md px-2.5 text-xs',
|
||||||
|
lg: 'h-9 gap-1.5 px-3',
|
||||||
|
icon: 'size-8',
|
||||||
|
'icon-sm': 'size-7 rounded-md',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default', size: 'default' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
56
src/components/ui/card.tsx
Normal file
56
src/components/ui/card.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-header" className={cn('grid gap-2 px-6', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({
|
||||||
|
as: Component = 'div',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & { as?: 'div' | 'h2' | 'h3' }) {
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn('font-semibold leading-none', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="card-footer" className={cn('flex items-center px-6', className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||||
62
src/components/ui/context-menu.tsx
Normal file
62
src/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react/context-menu'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function ContextMenu(props: ContextMenuPrimitive.Root.Props) {
|
||||||
|
return <ContextMenuPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger(props: ContextMenuPrimitive.Trigger.Props) {
|
||||||
|
return <ContextMenuPrimitive.Trigger {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({ className, ...props }: ContextMenuPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Positioner className="z-50">
|
||||||
|
<ContextMenuPrimitive.Popup
|
||||||
|
className={cn(
|
||||||
|
'min-w-40 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Positioner>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: ContextMenuPrimitive.Item.Props & { variant?: 'default' | 'destructive' }) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
|
||||||
|
variant === 'destructive' && 'text-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
}
|
||||||
63
src/components/ui/field.tsx
Normal file
63
src/components/ui/field.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
const fieldVariants = cva('group/field flex w-full gap-3', {
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: 'flex-col [&>*]:w-full',
|
||||||
|
horizontal: 'flex-row items-center',
|
||||||
|
responsive: 'flex-col @md:flex-row @md:items-center',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { orientation: 'vertical' },
|
||||||
|
})
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn('flex w-full flex-col gap-7', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn('flex items-center gap-2 text-sm font-medium leading-snug', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn('text-sm font-normal leading-normal text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Field, FieldDescription, FieldGroup, FieldTitle }
|
||||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Input as InputPrimitive } from '@base-ui/react/input'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
70
src/components/ui/select.tsx
Normal file
70
src/components/ui/select.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Select as SelectPrimitive } from '@base-ui/react/select'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Select<Value, Multiple extends boolean | undefined = false>(
|
||||||
|
props: SelectPrimitive.Root.Props<Value, Multiple>
|
||||||
|
) {
|
||||||
|
return <SelectPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon render={<ChevronDown className="size-4 shrink-0 opacity-50" />} />
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue(props: SelectPrimitive.Value.Props) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({ className, children, ...props }: SelectPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner className="z-50" sideOffset={4} alignItemWithTrigger={false}>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'min-w-[var(--anchor-width)] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator className="absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<Check className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
|
||||||
24
src/components/ui/separator.tsx
Normal file
24
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive> & { orientation?: 'horizontal' | 'vertical' }) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
21
src/components/ui/switch.tsx
Normal file
21
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Switch as SwitchPrimitive } from '@base-ui/react/switch'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
'group/switch inline-flex h-5 w-8 shrink-0 items-center rounded-full border border-transparent bg-input outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked]:bg-primary disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb className="block size-4 translate-x-0 rounded-full bg-background shadow-sm transition-transform group-data-[checked]/switch:translate-x-3" />
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
42
src/components/ui/tabs.tsx
Normal file
42
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn('flex flex-col gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({ className, ...props }: TabsPrimitive.List.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-7 flex-1 items-center justify-center rounded-md px-3 text-sm font-medium text-muted-foreground transition-all outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-active:bg-background data-active:text-foreground data-active:shadow-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger }
|
||||||
41
src/components/ui/tooltip.tsx
Normal file
41
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
|
||||||
|
function TooltipProvider(props: TooltipPrimitive.Provider.Props) {
|
||||||
|
return <TooltipPrimitive.Provider delay={250} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip(props: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger(props: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'top',
|
||||||
|
sideOffset = 6,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props & Pick<TooltipPrimitive.Positioner.Props, 'side' | 'sideOffset'>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner side={side} sideOffset={sideOffset} className="z-50">
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
className={cn(
|
||||||
|
'rounded-md bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||||
2146
src/defaultStudioDocument.json
Normal file
2146
src/defaultStudioDocument.json
Normal file
File diff suppressed because it is too large
Load Diff
547
src/exporter.ts
Normal file
547
src/exporter.ts
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
import { applyAvatarEyeDefaults, type StudioAvatar } from './avatars'
|
||||||
|
import type { Expression } from './geometry'
|
||||||
|
import { translateStudioText, type StudioLanguage } from './i18n'
|
||||||
|
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
||||||
|
import type { AvatarSequence } from './sequences'
|
||||||
|
import { standaloneEngineSource } from './standaloneEngine.generated'
|
||||||
|
import { createStoredZip } from './storedZip'
|
||||||
|
|
||||||
|
export type AvatarExportAnimation = Pick<
|
||||||
|
AvatarSequence,
|
||||||
|
'name' | 'description' | 'playbackMode' | 'blink'
|
||||||
|
> & {
|
||||||
|
steps: Pick<
|
||||||
|
AvatarSequence['steps'][number],
|
||||||
|
'expressionId' | 'holdMs' | 'transitionMs' | 'transition'
|
||||||
|
>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarExportPayload = {
|
||||||
|
version: 1
|
||||||
|
avatar: {
|
||||||
|
name: string
|
||||||
|
surface: StudioAvatar['body']['primary']
|
||||||
|
bodyNodes: StudioAvatar['body']['nodes']
|
||||||
|
colors: StudioAvatar['colors']
|
||||||
|
}
|
||||||
|
expressions: Record<string, Expression>
|
||||||
|
animations: Record<string, AvatarExportAnimation>
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationKey = (animation: AvatarSequence, used: Set<string>) => {
|
||||||
|
const source = animation.builtIn ? animation.id : animation.name
|
||||||
|
const base =
|
||||||
|
source
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'animation'
|
||||||
|
let key = base
|
||||||
|
let suffix = 2
|
||||||
|
while (used.has(key)) key = `${base}-${suffix++}`
|
||||||
|
used.add(key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAvatarExportPayload = (
|
||||||
|
avatar: StudioAvatar,
|
||||||
|
expressions: Expression[],
|
||||||
|
selectedAnimations: AvatarSequence[]
|
||||||
|
): AvatarExportPayload => {
|
||||||
|
const expressionById = new Map(expressions.map(expression => [expression.id, expression]))
|
||||||
|
const referencedIds = new Set(
|
||||||
|
selectedAnimations.flatMap(animation => animation.steps.map(step => step.expressionId))
|
||||||
|
)
|
||||||
|
const exportedExpressions = Object.fromEntries(
|
||||||
|
[...referencedIds].flatMap(expressionId => {
|
||||||
|
const expression = expressionById.get(expressionId)
|
||||||
|
return expression
|
||||||
|
? [[expressionId, applyAvatarEyeDefaults(expression, avatar.eyes)] as const]
|
||||||
|
: []
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const usedKeys = new Set<string>()
|
||||||
|
const animations = Object.fromEntries(
|
||||||
|
selectedAnimations.map(animation => {
|
||||||
|
const key = animationKey(animation, usedKeys)
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
name: animation.name,
|
||||||
|
description: animation.description,
|
||||||
|
playbackMode: animation.playbackMode,
|
||||||
|
blink: { ...animation.blink },
|
||||||
|
steps: animation.steps
|
||||||
|
.filter(step => exportedExpressions[step.expressionId])
|
||||||
|
.map(({ expressionId, holdMs, transitionMs, transition }) => ({
|
||||||
|
expressionId,
|
||||||
|
holdMs,
|
||||||
|
transitionMs,
|
||||||
|
transition,
|
||||||
|
})),
|
||||||
|
} satisfies AvatarExportAnimation,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
avatar: {
|
||||||
|
name: avatar.name,
|
||||||
|
surface: avatar.body.primary,
|
||||||
|
bodyNodes: avatar.body.nodes,
|
||||||
|
colors: avatar.colors,
|
||||||
|
},
|
||||||
|
expressions: exportedExpressions,
|
||||||
|
animations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializedPayload = (payload: AvatarExportPayload) =>
|
||||||
|
JSON.stringify(payload)
|
||||||
|
.replace(/</g, '\\u003c')
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029')
|
||||||
|
|
||||||
|
const escapedHtml = (value: string) =>
|
||||||
|
value.replace(/[&<>"']/g, character => {
|
||||||
|
const entities: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}
|
||||||
|
return entities[character]
|
||||||
|
})
|
||||||
|
|
||||||
|
const localizedPlaygroundCopy = (language: StudioLanguage) => {
|
||||||
|
const t = (text: string) => translateStudioText(text, language)
|
||||||
|
return {
|
||||||
|
title: t('Playground de l’avatar'),
|
||||||
|
eyebrow: t('Playground de l’avatar'),
|
||||||
|
playing: t('En lecture'),
|
||||||
|
paused: t('En pause'),
|
||||||
|
stopped: t('Arrêté'),
|
||||||
|
previewLabel: t('Aperçu procédural en direct'),
|
||||||
|
previewAriaLabel: t('Aperçu de l’avatar'),
|
||||||
|
animations: t('Animations'),
|
||||||
|
animationsHelp: t('Choisissez une animation exportée pour la lancer.'),
|
||||||
|
playback: t('Lecture'),
|
||||||
|
playbackHelp: t('Contrôlez l’animation active.'),
|
||||||
|
play: t('Lire'),
|
||||||
|
pause: t('Pause'),
|
||||||
|
stop: t('Arrêter'),
|
||||||
|
api: t('API JavaScript'),
|
||||||
|
apiHelp: t('Le module réutilisable est disponible dans avatar.js.'),
|
||||||
|
apiNote: t(
|
||||||
|
'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateJavaScriptAvatarModule = (
|
||||||
|
payload: AvatarExportPayload
|
||||||
|
) => `${standaloneEngineSource}
|
||||||
|
${`const DATA = ${serializedPayload(payload)};`}
|
||||||
|
${proceduralBrowserRuntime}
|
||||||
|
export const availableAnimations = Object.freeze(Object.keys(DATA.animations));
|
||||||
|
export function createAvatar(target, options = {}) { return mountAvatar(target, options); }
|
||||||
|
export default createAvatar;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateJavaScriptAvatarHtml = (
|
||||||
|
payload: AvatarExportPayload,
|
||||||
|
language: StudioLanguage = 'en'
|
||||||
|
) => {
|
||||||
|
const firstAnimation = Object.keys(payload.animations)[0]
|
||||||
|
const copy = localizedPlaygroundCopy(language)
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="${language}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${escapedHtml(payload.avatar.name)} · ${copy.title}</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||||
|
body { margin: 0; min-height: 100vh; color: #f7f8fa; background: #0d1117; }
|
||||||
|
button { font: inherit; }
|
||||||
|
.playground { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0; }
|
||||||
|
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
|
||||||
|
.identity { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.brand-mark { width: 13px; height: 13px; border-radius: 50%; background: #5b7fe5; box-shadow: 0 0 0 7px rgb(91 127 229 / 12%); }
|
||||||
|
.eyebrow { margin: 0 0 3px; color: #7f9cff; font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||||
|
h1 { margin: 0; font-size: clamp(20px, 3vw, 28px); letter-spacing: -.035em; }
|
||||||
|
.status { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 12px; border: 1px solid #293140; border-radius: 999px; color: #aeb8c7; background: #141a22; font-size: 12px; font-weight: 700; }
|
||||||
|
.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #47cf8a; box-shadow: 0 0 0 4px rgb(71 207 138 / 10%); }
|
||||||
|
.status[data-state="paused"] .status-dot { background: #f4b860; box-shadow: 0 0 0 4px rgb(244 184 96 / 10%); }
|
||||||
|
.status[data-state="stopped"] .status-dot { background: #788395; box-shadow: none; }
|
||||||
|
.workspace { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .65fr); gap: 18px; }
|
||||||
|
.preview-card, .control-card { border: 1px solid #252d39; border-radius: 24px; overflow: hidden; box-shadow: 0 24px 80px rgb(0 0 0 / 22%); }
|
||||||
|
.preview-card { min-height: min(680px, calc(100vh - 130px)); display: grid; place-items: center; position: relative; background: radial-gradient(circle at 50% 42%, #1a2432 0, #111821 54%, #0b1016 100%); }
|
||||||
|
.preview-card::after { content: "${copy.previewLabel}"; position: absolute; left: 20px; bottom: 18px; color: #667285; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
#avatar { width: min(72%, 520px); aspect-ratio: 1; }
|
||||||
|
.control-card { align-self: stretch; padding: 22px; color: #171a20; background: #f7f8fa; }
|
||||||
|
.section + .section { margin-top: 22px; padding-top: 22px; border-top: 1px solid #dde1e8; }
|
||||||
|
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 12px; margin-bottom: 13px; }
|
||||||
|
h2 { margin: 0; font-size: 15px; letter-spacing: -.015em; }
|
||||||
|
.section-copy { margin: 4px 0 0; color: #717a89; font-size: 12px; line-height: 1.45; }
|
||||||
|
#active-animation { color: #5277dc; font-size: 12px; font-weight: 800; }
|
||||||
|
.animation-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; max-height: 250px; overflow: auto; padding: 2px; }
|
||||||
|
.animation-button { min-width: 0; padding: 10px 11px; overflow: hidden; color: #424a57; border: 1px solid #d8dde6; border-radius: 11px; background: #fff; text-align: left; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; transition: border-color 150ms ease, box-shadow 150ms ease, color 150ms ease, background 150ms ease; }
|
||||||
|
.animation-button:hover { border-color: #9db2f4; }
|
||||||
|
.animation-button.is-active { color: #315bc8; border-color: #7392ed; background: #edf2ff; box-shadow: 0 0 0 3px rgb(91 127 229 / 12%); }
|
||||||
|
.animation-button:focus-visible, .transport-button:focus-visible { outline: 3px solid rgb(91 127 229 / 30%); outline-offset: 2px; }
|
||||||
|
.transport { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||||
|
.transport-button { min-height: 42px; border: 1px solid #d6dbe4; border-radius: 11px; color: #343a45; background: #fff; font-weight: 750; cursor: pointer; }
|
||||||
|
.transport-button:hover { background: #f0f3f8; }
|
||||||
|
.transport-button.primary { color: #fff; border-color: #171a20; background: #171a20; }
|
||||||
|
.transport-button.primary:hover { background: #2a2e36; }
|
||||||
|
.api-example { margin: 0; padding: 14px; overflow-x: auto; color: #cfdbed; border-radius: 12px; background: #171c24; font: 11px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; }
|
||||||
|
.api-note { margin: 10px 0 0; color: #7d8694; font-size: 11px; line-height: 1.5; }
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.playground { width: min(100% - 20px, 620px); padding: 18px 0; }
|
||||||
|
.workspace { grid-template-columns: 1fr; }
|
||||||
|
.preview-card { min-height: 52vh; }
|
||||||
|
#avatar { width: min(72vw, 430px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="playground">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="identity">
|
||||||
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">${copy.eyebrow}</p>
|
||||||
|
<h1>${escapedHtml(payload.avatar.name)}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="playback-status" data-state="playing">
|
||||||
|
<span class="status-dot" aria-hidden="true"></span>
|
||||||
|
<span id="playback-status-label">${copy.playing}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<section class="preview-card" aria-label="${copy.previewAriaLabel}">
|
||||||
|
<div id="avatar"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="control-card">
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>${copy.animations}</h2>
|
||||||
|
<p class="section-copy">${copy.animationsHelp}</p>
|
||||||
|
</div>
|
||||||
|
<span id="active-animation"></span>
|
||||||
|
</div>
|
||||||
|
<div class="animation-list" id="animation-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>${copy.playback}</h2>
|
||||||
|
<p class="section-copy">${copy.playbackHelp}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="transport">
|
||||||
|
<button class="transport-button primary" id="play-animation" type="button">▶ ${copy.play}</button>
|
||||||
|
<button class="transport-button" id="pause-animation" type="button">Ⅱ ${copy.pause}</button>
|
||||||
|
<button class="transport-button" id="stop-animation" type="button">■ ${copy.stop}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2>${copy.api}</h2>
|
||||||
|
<p class="section-copy">${copy.apiHelp}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="api-example"><code>import { createAvatar } from './avatar.js'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
animation: ${JSON.stringify(firstAnimation)},
|
||||||
|
size: 320,
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.play(${JSON.stringify(firstAnimation)})
|
||||||
|
avatar.pause()
|
||||||
|
avatar.stop()</code></pre>
|
||||||
|
<p class="api-note">${copy.apiNote}</p>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module">
|
||||||
|
${generateJavaScriptAvatarModule(payload)}
|
||||||
|
let activeAnimation = ${JSON.stringify(firstAnimation)}
|
||||||
|
const animationList = document.querySelector('#animation-list')
|
||||||
|
const activeLabel = document.querySelector('#active-animation')
|
||||||
|
const status = document.querySelector('#playback-status')
|
||||||
|
const statusLabel = document.querySelector('#playback-status-label')
|
||||||
|
const animationButtons = new Map()
|
||||||
|
|
||||||
|
const setStatus = state => {
|
||||||
|
status.dataset.state = state
|
||||||
|
statusLabel.textContent = state === 'playing' ? ${JSON.stringify(copy.playing)} : state === 'paused' ? ${JSON.stringify(copy.paused)} : ${JSON.stringify(copy.stopped)}
|
||||||
|
}
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
size: '100%',
|
||||||
|
autoplay: false,
|
||||||
|
onAnimationEnd: () => setStatus('stopped'),
|
||||||
|
})
|
||||||
|
const selectAnimation = name => {
|
||||||
|
activeAnimation = name
|
||||||
|
activeLabel.textContent = DATA.animations[name]?.name || name
|
||||||
|
animationButtons.forEach((button, animationName) => {
|
||||||
|
button.classList.toggle('is-active', animationName === name)
|
||||||
|
button.setAttribute('aria-pressed', String(animationName === name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
availableAnimations.forEach(name => {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.type = 'button'
|
||||||
|
button.className = 'animation-button'
|
||||||
|
button.textContent = DATA.animations[name]?.name || name
|
||||||
|
button.title = name
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
selectAnimation(name)
|
||||||
|
avatar.play(name)
|
||||||
|
setStatus('playing')
|
||||||
|
})
|
||||||
|
animationButtons.set(name, button)
|
||||||
|
animationList.append(button)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelector('#play-animation').addEventListener('click', () => {
|
||||||
|
avatar.play(activeAnimation)
|
||||||
|
setStatus('playing')
|
||||||
|
})
|
||||||
|
document.querySelector('#pause-animation').addEventListener('click', () => {
|
||||||
|
avatar.pause()
|
||||||
|
setStatus('paused')
|
||||||
|
})
|
||||||
|
document.querySelector('#stop-animation').addEventListener('click', () => {
|
||||||
|
avatar.stop()
|
||||||
|
setStatus('stopped')
|
||||||
|
})
|
||||||
|
|
||||||
|
selectAnimation(activeAnimation)
|
||||||
|
avatar.play(activeAnimation)
|
||||||
|
window.avatar = avatar
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateJavaScriptAvatarPackage = (
|
||||||
|
payload: AvatarExportPayload,
|
||||||
|
language: StudioLanguage = 'en'
|
||||||
|
) => {
|
||||||
|
return createStoredZip([
|
||||||
|
{ name: 'index.html', content: generateJavaScriptAvatarHtml(payload, language) },
|
||||||
|
{ name: 'avatar.js', content: generateJavaScriptAvatarModule(payload) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarExportSlug = (name: string) =>
|
||||||
|
name
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'avatar'
|
||||||
|
|
||||||
|
const avatarComponentName = (name: string) => {
|
||||||
|
const identifier = avatarExportSlug(name)
|
||||||
|
.split('-')
|
||||||
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join('')
|
||||||
|
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateReactAvatarRuntime = () => `
|
||||||
|
export type AvatarData<AnimationName extends string = string> = Readonly<{
|
||||||
|
version: number
|
||||||
|
avatar: Readonly<{ name: string } & Record<string, unknown>>
|
||||||
|
expressions: Readonly<Record<string, unknown>>
|
||||||
|
animations: Readonly<Record<AnimationName, unknown>>
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type RuntimeAvatar<AnimationName extends string = string> = {
|
||||||
|
play: (animation?: AnimationName) => RuntimeAvatar<AnimationName>
|
||||||
|
pause: () => RuntimeAvatar<AnimationName>
|
||||||
|
stop: () => RuntimeAvatar<AnimationName>
|
||||||
|
destroy: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarRuntimeModule<AnimationName extends string = string> = {
|
||||||
|
createAvatar: (
|
||||||
|
target: HTMLElement,
|
||||||
|
options: {
|
||||||
|
animation: AnimationName
|
||||||
|
autoplay: boolean
|
||||||
|
loop?: boolean
|
||||||
|
size: string
|
||||||
|
onAnimationEnd: (animation: AnimationName) => void
|
||||||
|
}
|
||||||
|
) => RuntimeAvatar<AnimationName>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_RUNTIME_VERSION = 1
|
||||||
|
const ENGINE_SOURCE = ${JSON.stringify(standaloneEngineSource)}
|
||||||
|
const BROWSER_RUNTIME_SOURCE = ${JSON.stringify(proceduralBrowserRuntime)}
|
||||||
|
const runtimes = new WeakMap<object, Promise<unknown>>()
|
||||||
|
|
||||||
|
const serializeData = (data: AvatarData) =>
|
||||||
|
JSON.stringify(data)
|
||||||
|
.replace(/</g, '\\\\u003c')
|
||||||
|
.replace(/\\u2028/g, '\\\\u2028')
|
||||||
|
.replace(/\\u2029/g, '\\\\u2029')
|
||||||
|
|
||||||
|
export const loadAvatarRuntime = <AnimationName extends string>(
|
||||||
|
data: AvatarData<AnimationName>
|
||||||
|
): Promise<AvatarRuntimeModule<AnimationName>> => {
|
||||||
|
if (data.version !== AVATAR_RUNTIME_VERSION) {
|
||||||
|
throw new Error(
|
||||||
|
'Avatar data version ' + data.version + ' requires a compatible avatar-runtime.ts'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const cached = runtimes.get(data)
|
||||||
|
if (cached) return cached as Promise<AvatarRuntimeModule<AnimationName>>
|
||||||
|
const source =
|
||||||
|
ENGINE_SOURCE +
|
||||||
|
'\\nconst DATA = ' +
|
||||||
|
serializeData(data) +
|
||||||
|
';\\n' +
|
||||||
|
BROWSER_RUNTIME_SOURCE +
|
||||||
|
'\\nexport function createAvatar(target, options = {}) { return mountAvatar(target, options); }'
|
||||||
|
const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
|
||||||
|
const runtime = import(/* @vite-ignore */ url).then(module => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
return module as AvatarRuntimeModule<AnimationName>
|
||||||
|
})
|
||||||
|
runtimes.set(data, runtime)
|
||||||
|
return runtime
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateReactAvatarData = (payload: AvatarExportPayload) => `
|
||||||
|
import type { AvatarData } from './avatar-runtime'
|
||||||
|
|
||||||
|
export const avatarData = ${serializedPayload(payload)} as const satisfies AvatarData
|
||||||
|
export type AnimationName = keyof typeof avatarData.animations
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateReactAvatarComponent = (payload: AvatarExportPayload) => {
|
||||||
|
const animationNames = Object.keys(payload.animations)
|
||||||
|
const slug = avatarExportSlug(payload.avatar.name)
|
||||||
|
const componentName = avatarComponentName(payload.avatar.name)
|
||||||
|
return `
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef, type CSSProperties } from 'react'
|
||||||
|
import { loadAvatarRuntime, type RuntimeAvatar } from './avatar-runtime'
|
||||||
|
import { avatarData, type AnimationName } from './${slug}.avatar'
|
||||||
|
|
||||||
|
export type { AnimationName } from './${slug}.avatar'
|
||||||
|
export type AvatarHandle = {
|
||||||
|
play: (animation?: AnimationName) => void
|
||||||
|
pause: () => void
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
export type AvatarProps = {
|
||||||
|
animation?: AnimationName
|
||||||
|
playing?: boolean
|
||||||
|
loop?: boolean
|
||||||
|
size?: number | string
|
||||||
|
className?: string
|
||||||
|
style?: CSSProperties
|
||||||
|
onAnimationEnd?: (animation: AnimationName) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ${componentName} = forwardRef<AvatarHandle, AvatarProps>(function ${componentName}(
|
||||||
|
{
|
||||||
|
animation = ${JSON.stringify(animationNames[0])},
|
||||||
|
playing = true,
|
||||||
|
loop,
|
||||||
|
size = 240,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
onAnimationEnd,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const host = useRef<HTMLSpanElement>(null)
|
||||||
|
const controller = useRef<RuntimeAvatar<AnimationName> | null>(null)
|
||||||
|
const animationRef = useRef(animation)
|
||||||
|
const playingRef = useRef(playing)
|
||||||
|
const onAnimationEndRef = useRef(onAnimationEnd)
|
||||||
|
animationRef.current = animation
|
||||||
|
playingRef.current = playing
|
||||||
|
onAnimationEndRef.current = onAnimationEnd
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!host.current) return
|
||||||
|
let disposed = false
|
||||||
|
let avatar: RuntimeAvatar<AnimationName> | null = null
|
||||||
|
void loadAvatarRuntime<AnimationName>(avatarData).then(runtime => {
|
||||||
|
if (disposed || !host.current) return
|
||||||
|
avatar = runtime.createAvatar(host.current, {
|
||||||
|
animation: animationRef.current,
|
||||||
|
autoplay: playingRef.current,
|
||||||
|
loop,
|
||||||
|
size: '100%',
|
||||||
|
onAnimationEnd: next => onAnimationEndRef.current?.(next),
|
||||||
|
})
|
||||||
|
controller.current = avatar
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
avatar?.destroy()
|
||||||
|
controller.current = null
|
||||||
|
}
|
||||||
|
}, [loop])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const avatar = controller.current
|
||||||
|
if (!avatar) return
|
||||||
|
if (playing) avatar.play(animation)
|
||||||
|
else avatar.pause()
|
||||||
|
}, [animation, playing])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
play(next = animation) { controller.current?.play(next) },
|
||||||
|
pause() { controller.current?.pause() },
|
||||||
|
stop() { controller.current?.stop() },
|
||||||
|
}), [animation])
|
||||||
|
|
||||||
|
const dimension = typeof size === 'number' ? size + 'px' : size
|
||||||
|
return <span ref={host} className={className} style={{ display: 'inline-block', width: dimension, height: dimension, ...style }} />
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ${componentName}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateReactAvatarPackage = (payload: AvatarExportPayload) => {
|
||||||
|
const slug = avatarExportSlug(payload.avatar.name)
|
||||||
|
const componentName = avatarComponentName(payload.avatar.name)
|
||||||
|
const avatarEntry = `export { default, ${componentName} } from './${componentName}'\nexport type { AnimationName, AvatarHandle, AvatarProps } from './${componentName}'\n`
|
||||||
|
return createStoredZip([
|
||||||
|
{ name: 'avatar-runtime.ts', content: generateReactAvatarRuntime() },
|
||||||
|
{ name: `${slug}.avatar.ts`, content: generateReactAvatarData(payload) },
|
||||||
|
{ name: `${componentName}.tsx`, content: generateReactAvatarComponent(payload) },
|
||||||
|
{ name: `${slug}.index.ts`, content: avatarEntry },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
|
||||||
|
return `${avatarExportSlug(name)}-avatar.${extension}`
|
||||||
|
}
|
||||||
60
src/expressionEditing.ts
Normal file
60
src/expressionEditing.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import type { Expression } from './geometry'
|
||||||
|
|
||||||
|
export type EyeSide = 'Left' | 'Right'
|
||||||
|
export type EyeLinks = { width: boolean; height: boolean; size: boolean; position?: boolean }
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
|
||||||
|
const opposite = (side: EyeSide): EyeSide => (side === 'Left' ? 'Right' : 'Left')
|
||||||
|
|
||||||
|
export const updateEyeDimension = (
|
||||||
|
expression: Expression,
|
||||||
|
side: EyeSide,
|
||||||
|
dimension: 'width' | 'height',
|
||||||
|
value: number,
|
||||||
|
linked: boolean
|
||||||
|
) => {
|
||||||
|
const field = `${dimension}${side}` as 'widthLeft' | 'widthRight' | 'heightLeft' | 'heightRight'
|
||||||
|
const otherField = `${dimension}${opposite(side)}` as typeof field
|
||||||
|
const nextValue = clamp(value, 10, 100)
|
||||||
|
return {
|
||||||
|
...expression,
|
||||||
|
[field]: nextValue,
|
||||||
|
...(linked ? { [otherField]: nextValue } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const scaleEye = (
|
||||||
|
expression: Expression,
|
||||||
|
side: EyeSide,
|
||||||
|
targetSize: number,
|
||||||
|
linked: boolean
|
||||||
|
) => {
|
||||||
|
const widthField = `width${side}` as 'widthLeft' | 'widthRight'
|
||||||
|
const heightField = `height${side}` as 'heightLeft' | 'heightRight'
|
||||||
|
const factor = targetSize / Math.max(expression[widthField], expression[heightField], 1)
|
||||||
|
const next: Expression = {
|
||||||
|
...expression,
|
||||||
|
[widthField]: clamp(expression[widthField] * factor, 10, 110),
|
||||||
|
[heightField]: clamp(expression[heightField] * factor, 10, 110),
|
||||||
|
}
|
||||||
|
if (!linked) return next
|
||||||
|
const otherSide = opposite(side)
|
||||||
|
const otherWidth = `width${otherSide}` as 'widthLeft' | 'widthRight'
|
||||||
|
const otherHeight = `height${otherSide}` as 'heightLeft' | 'heightRight'
|
||||||
|
next[otherWidth] = clamp(expression[otherWidth] * factor, 10, 110)
|
||||||
|
next[otherHeight] = clamp(expression[otherHeight] * factor, 10, 110)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateEyePosition = (
|
||||||
|
expression: Expression,
|
||||||
|
side: EyeSide,
|
||||||
|
axis: 'X' | 'Y',
|
||||||
|
value: number,
|
||||||
|
linked: boolean
|
||||||
|
) => {
|
||||||
|
const field = `position${axis}${side}` as
|
||||||
|
'positionXLeft' | 'positionXRight' | 'positionYLeft' | 'positionYRight'
|
||||||
|
const otherField = `position${axis}${opposite(side)}` as typeof field
|
||||||
|
return { ...expression, [field]: value, ...(linked ? { [otherField]: value } : {}) }
|
||||||
|
}
|
||||||
1251
src/geometry.ts
Normal file
1251
src/geometry.ts
Normal file
File diff suppressed because it is too large
Load Diff
514
src/i18n.ts
Normal file
514
src/i18n.ts
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
createElement,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { chinese, translateChineseDynamicText } from './i18n.zh'
|
||||||
|
|
||||||
|
export type StudioLanguage = 'en' | 'fr' | 'zh-CN'
|
||||||
|
|
||||||
|
const STUDIO_LANGUAGE_STORAGE_KEY = 'avatar-studio-language'
|
||||||
|
|
||||||
|
const isStudioLanguage = (value: string | null): value is StudioLanguage =>
|
||||||
|
value === 'en' || value === 'fr' || value === 'zh-CN'
|
||||||
|
|
||||||
|
const readStoredStudioLanguage = (): StudioLanguage => {
|
||||||
|
if (typeof window === 'undefined') return 'en'
|
||||||
|
try {
|
||||||
|
const storedLanguage = window.localStorage.getItem(STUDIO_LANGUAGE_STORAGE_KEY)
|
||||||
|
return isStudioLanguage(storedLanguage) ? storedLanguage : 'en'
|
||||||
|
} catch {
|
||||||
|
return 'en'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const english: Record<string, string> = {
|
||||||
|
'Playground de l’avatar': 'Avatar playground',
|
||||||
|
Arrêté: 'Stopped',
|
||||||
|
'Aperçu procédural en direct': 'Live procedural preview',
|
||||||
|
'Aperçu de l’avatar': 'Avatar preview',
|
||||||
|
'Choisissez une animation exportée pour la lancer.': 'Choose an exported animation to play it.',
|
||||||
|
Lecture: 'Playback',
|
||||||
|
'Contrôlez l’animation active.': 'Control the active animation.',
|
||||||
|
Lire: 'Play',
|
||||||
|
Arrêter: 'Stop',
|
||||||
|
'API JavaScript': 'JavaScript API',
|
||||||
|
'Le module réutilisable est disponible dans avatar.js.':
|
||||||
|
'The reusable module is available as avatar.js.',
|
||||||
|
'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.':
|
||||||
|
'Open this demo directly, or serve the folder locally to import avatar.js from another module.',
|
||||||
|
'Langue de l’interface': 'Interface language',
|
||||||
|
'Nom de l’avatar': 'Avatar name',
|
||||||
|
sélecteur: 'picker',
|
||||||
|
hexadécimal: 'hex value',
|
||||||
|
'Lier width': 'Link widths',
|
||||||
|
'Lier height': 'Link heights',
|
||||||
|
'Lier size': 'Link sizes',
|
||||||
|
'Lier la position des yeux': 'Link eye positions',
|
||||||
|
'Glisser horizontalement pour modifier': 'Drag horizontally to adjust',
|
||||||
|
'En lecture': 'Playing',
|
||||||
|
'En pause': 'Paused',
|
||||||
|
'Réinitialiser la rotation de la tête': 'Reset head rotation',
|
||||||
|
'Réinitialiser la position et la rotation de': 'Reset position and rotation of',
|
||||||
|
'Avatar procédural': 'Procedural avatar',
|
||||||
|
'Gizmo de rotation': 'Rotation gizmo',
|
||||||
|
'Retour aux expressions': 'Back to expressions',
|
||||||
|
'Retour aux animations': 'Back to animations',
|
||||||
|
'Retour au studio': 'Back to studio',
|
||||||
|
'Preset en mémoire': 'Saved preset',
|
||||||
|
'Nouvelle expression': 'New expression',
|
||||||
|
'L’avatar à gauche affiche cette expression en direct.':
|
||||||
|
'The avatar on the left previews this expression live.',
|
||||||
|
Corps: 'Body',
|
||||||
|
'Apparence et orientation générale de l’avatar.': 'General avatar appearance and orientation.',
|
||||||
|
'Couleur du corps': 'Body color',
|
||||||
|
'Rotation de la tête': 'Head rotation',
|
||||||
|
Yeux: 'Eyes',
|
||||||
|
'Forme, placement et orientation propres au regard.': 'Eye shape, placement and orientation.',
|
||||||
|
'Couleur des yeux': 'Eye color',
|
||||||
|
Largeur: 'Width',
|
||||||
|
Hauteur: 'Height',
|
||||||
|
Échelle: 'Scale',
|
||||||
|
'Taille proportionnelle': 'Proportional size',
|
||||||
|
'Œil gauche': 'Left eye',
|
||||||
|
'Œil droit': 'Right eye',
|
||||||
|
'Position et espacement': 'Position and spacing',
|
||||||
|
Horizontale: 'Horizontal',
|
||||||
|
Verticale: 'Vertical',
|
||||||
|
Espacement: 'Spacing',
|
||||||
|
'Rotation locale': 'Local rotation',
|
||||||
|
Projection: 'Projection',
|
||||||
|
'Perspective appliquée à la surface active.': 'Perspective applied to the active surface.',
|
||||||
|
Perspective: 'Perspective',
|
||||||
|
Supprimer: 'Delete',
|
||||||
|
Enregistrer: 'Save',
|
||||||
|
'Construction du corps': 'Body construction',
|
||||||
|
'Choisis la forme principale puis assemble les primitives autour d’elle.':
|
||||||
|
'Choose the primary shape, then assemble primitives around it.',
|
||||||
|
'Choisir un avatar': 'Choose an avatar',
|
||||||
|
'Double-clic pour modifier': 'Double-click to edit',
|
||||||
|
'Nouvel avatar': 'New avatar',
|
||||||
|
Modifier: 'Edit',
|
||||||
|
'Mode d’édition': 'Editing mode',
|
||||||
|
Pose: 'Pose',
|
||||||
|
Expressions: 'Expressions',
|
||||||
|
Animations: 'Animations',
|
||||||
|
Exporter: 'Export',
|
||||||
|
'Exporter l’avatar': 'Export avatar',
|
||||||
|
'Télécharge un composant autonome avec les animations de ton choix.':
|
||||||
|
'Download a standalone component with the animations you choose.',
|
||||||
|
'Avatar sélectionné': 'Selected avatar',
|
||||||
|
Format: 'Format',
|
||||||
|
'Choisis l’intégration correspondant à ton projet.':
|
||||||
|
'Choose the integration that matches your project.',
|
||||||
|
'Composant TSX autonome': 'Standalone TSX component',
|
||||||
|
'Package React local (.zip)': 'Local React package (.zip)',
|
||||||
|
'Module JavaScript': 'JavaScript module',
|
||||||
|
'Module ES autonome': 'Standalone ES module',
|
||||||
|
'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)',
|
||||||
|
sélectionnées: 'selected',
|
||||||
|
'Animations à exporter': 'Animations to export',
|
||||||
|
'Tout sélectionner': 'Select all',
|
||||||
|
'Tout désélectionner': 'Deselect all',
|
||||||
|
'Télécharger le composant TSX': 'Download TSX component',
|
||||||
|
'Télécharger le package React': 'Download React package',
|
||||||
|
'Télécharger le module': 'Download module',
|
||||||
|
Snapshot: 'Snapshot',
|
||||||
|
'Mode photo': 'Photo Mode',
|
||||||
|
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
|
||||||
|
'Les options de capture seront configurées ici.': 'Snapshot options will be configured here.',
|
||||||
|
'Aperçu du Snapshot': 'Snapshot preview',
|
||||||
|
'Aperçu du mode photo': 'Photo Mode preview',
|
||||||
|
'Arrière-plan': 'Background',
|
||||||
|
'Choisis un fond transparent, uni ou en dégradé.':
|
||||||
|
'Choose a transparent, solid or gradient background.',
|
||||||
|
Style: 'Style',
|
||||||
|
Transparent: 'Transparent',
|
||||||
|
Uni: 'Solid',
|
||||||
|
'Dégradé linéaire': 'Linear gradient',
|
||||||
|
'Dégradé radial': 'Radial gradient',
|
||||||
|
'Style d’arrière-plan': 'Background style',
|
||||||
|
Couleur: 'Color',
|
||||||
|
Départ: 'Start',
|
||||||
|
Arrivée: 'End',
|
||||||
|
Définition: 'Resolution',
|
||||||
|
'Dimensions inscrites dans le fichier SVG.': 'Dimensions embedded in the SVG file.',
|
||||||
|
'Dimensions du fichier exporté.': 'Dimensions of the exported file.',
|
||||||
|
'Définition du Snapshot': 'Snapshot resolution',
|
||||||
|
'Définition du mode photo': 'Photo Mode resolution',
|
||||||
|
'Télécharger le Snapshot SVG': 'Download SVG snapshot',
|
||||||
|
'Télécharger en SVG': 'Download SVG',
|
||||||
|
'Télécharger en PNG': 'Download PNG',
|
||||||
|
'Format d’export': 'Export format',
|
||||||
|
'Format d’export du mode photo': 'Photo Mode export format',
|
||||||
|
'Choisis le type de fichier généré par le mode photo.':
|
||||||
|
'Choose the file type generated by Photo Mode.',
|
||||||
|
'Prendre une photo': 'Take a picture',
|
||||||
|
'Informations sur le mode photo': 'Photo Mode information',
|
||||||
|
'Tu peux modifier le format, le fond et la définition du mode photo dans Export.':
|
||||||
|
'You can change the format, background and resolution for Photo Mode in Export.',
|
||||||
|
'Projet du Studio': 'Studio project',
|
||||||
|
'Transfère tous les avatars, expressions et animations vers un autre navigateur.':
|
||||||
|
'Transfer every avatar, expression and animation to another browser.',
|
||||||
|
'Télécharger le projet JSON': 'Download JSON project',
|
||||||
|
'Importer un projet JSON': 'Import JSON project',
|
||||||
|
'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
|
||||||
|
'This file does not contain a valid, compatible Avatar Studio project.',
|
||||||
|
'Importer ce projet ?': 'Import this project?',
|
||||||
|
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
|
||||||
|
'The current local project will be replaced with the avatars, expressions, animations and playback state from this file.',
|
||||||
|
'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.':
|
||||||
|
'The project could not be saved in this browser. Free up some space and try again.',
|
||||||
|
Importer: 'Import',
|
||||||
|
'Construction, forme et couleur de la tête de l’avatar.':
|
||||||
|
'Build, shape and color of the avatar head.',
|
||||||
|
'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.':
|
||||||
|
'One primary shape carries the eyes. Other primitives are placed around it.',
|
||||||
|
'Forme principale': 'Primary shape',
|
||||||
|
'porte les yeux': 'carries the eyes',
|
||||||
|
'Ajouter une forme': 'Add a shape',
|
||||||
|
Dupliquer: 'Duplicate',
|
||||||
|
'Gizmo local': 'Local gizmo',
|
||||||
|
'Déplacer dans le plan de la caméra': 'Move in camera plane',
|
||||||
|
Transformer: 'Transform',
|
||||||
|
copie: 'copy',
|
||||||
|
'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.':
|
||||||
|
'Drag an axis to move the shape, or a ring to rotate it.',
|
||||||
|
Profondeur: 'Depth',
|
||||||
|
'Position locale': 'Local position',
|
||||||
|
'Cette surface est la référence du visage et porte les yeux.':
|
||||||
|
'This surface is the face reference and carries the eyes.',
|
||||||
|
'Rondeur des arêtes': 'Edge roundness',
|
||||||
|
Rondeur: 'Roundness',
|
||||||
|
'Rondeur morphologique': 'Morph roundness',
|
||||||
|
'Rondeur de la pointe': 'Tip roundness',
|
||||||
|
'Rondeur de la base': 'Base roundness',
|
||||||
|
'Rondeur globale': 'Global roundness',
|
||||||
|
'Rondeur pointe': 'Tip roundness',
|
||||||
|
'Rondeur base': 'Base roundness',
|
||||||
|
'Couleur de base utilisée par les poses et les expressions.':
|
||||||
|
'Base color used by poses and expressions.',
|
||||||
|
'Forme, placement, orientation et couleur du regard par défaut.':
|
||||||
|
'Default eye shape, placement, orientation and color.',
|
||||||
|
'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.':
|
||||||
|
'Define this avatar’s default eyes. Poses are then applied on top of this base.',
|
||||||
|
'Coordonnées propres à l’avatar, indépendantes des poses.':
|
||||||
|
'Avatar-specific coordinates, independent from poses.',
|
||||||
|
'Inclinaison par défaut propre à chaque œil.': 'Default tilt for each eye.',
|
||||||
|
'Orientation et apparence générale de la pose.': 'General pose orientation and appearance.',
|
||||||
|
'La pose peut remplacer temporairement la couleur de l’avatar.':
|
||||||
|
'The pose can temporarily override the avatar color.',
|
||||||
|
'Reprendre la couleur de l’avatar': 'Use avatar color',
|
||||||
|
'Les libellés ↔ sont scrubbables, comme dans Figma.':
|
||||||
|
'Labels marked ↔ can be scrubbed, like in Figma.',
|
||||||
|
'Forme, placement, orientation et couleur du regard.':
|
||||||
|
'Eye shape, placement, orientation and color.',
|
||||||
|
'Coordonnées communes projetées sur la forme choisie.':
|
||||||
|
'Shared coordinates projected onto the selected shape.',
|
||||||
|
'Inclinaison propre à chaque œil.': 'Tilt applied to each eye.',
|
||||||
|
'Perspective et repères appliqués à la surface active.':
|
||||||
|
'Perspective and guides applied to the active surface.',
|
||||||
|
'Profondeur simulée du visage.': 'Simulated face depth.',
|
||||||
|
'Afficher le maillage': 'Show wireframe',
|
||||||
|
Réinitialiser: 'Reset',
|
||||||
|
Mouvement: 'Motion',
|
||||||
|
'Mouvement perpétuel': 'Perpetual motion',
|
||||||
|
'Aucun mouvement': 'No motion',
|
||||||
|
'Dérive lente': 'Slow drift',
|
||||||
|
'Micro-ajustements': 'Micro-adjustments',
|
||||||
|
Tremblement: 'Shake',
|
||||||
|
'Ajoute une légère présence ou un tremblement continu au corps.':
|
||||||
|
'Adds a subtle living presence or a continuous shake to the body.',
|
||||||
|
'Anime le regard par petites saccades naturelles ou par tremblement.':
|
||||||
|
'Animates the gaze with natural micro-saccades or a continuous shake.',
|
||||||
|
'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.':
|
||||||
|
'Motion interpolates values while the engine performs quaternion slerp.',
|
||||||
|
'Vitesse du ressort': 'Spring speed',
|
||||||
|
'Une animation est en cours de lecture': 'An animation is currently playing',
|
||||||
|
'Mettez l’animation en pause avant de manipuler les paramètres de pose.':
|
||||||
|
'Pause the animation before adjusting pose parameters.',
|
||||||
|
'Mettez l’animation en pause avant de choisir ou personnaliser une expression.':
|
||||||
|
'Pause the animation before selecting or customizing an expression.',
|
||||||
|
Cligner: 'Blink',
|
||||||
|
'Expression aléatoire': 'Random expression',
|
||||||
|
'Cycle de vie': 'Life cycle',
|
||||||
|
Réactions: 'Reactions',
|
||||||
|
'Éditeur d’animation': 'Animation editor',
|
||||||
|
'Modifier l’animation': 'Edit animation',
|
||||||
|
'Nouvelle animation': 'New animation',
|
||||||
|
'Compose les expressions, leur cadence et les clignements de cette animation.':
|
||||||
|
'Compose the expressions, timing and blinks for this animation.',
|
||||||
|
'Prévisualiser l’animation': 'Preview animation',
|
||||||
|
'Arrêter l’animation': 'Stop animation',
|
||||||
|
Identité: 'Identity',
|
||||||
|
'Nom, catégorie et comportement de lecture de l’animation.':
|
||||||
|
'Animation name, category and playback behavior.',
|
||||||
|
Nom: 'Name',
|
||||||
|
Catégorie: 'Category',
|
||||||
|
Description: 'Description',
|
||||||
|
'Mode de lecture': 'Playback mode',
|
||||||
|
Boucle: 'Loop',
|
||||||
|
'Une fois': 'Once',
|
||||||
|
'Aller-retour': 'Ping-pong',
|
||||||
|
Timeline: 'Timeline',
|
||||||
|
'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.':
|
||||||
|
'Drag steps to reorder them, then select one to adjust its timing.',
|
||||||
|
'Ajoute une expression pour commencer.': 'Add an expression to get started.',
|
||||||
|
'Étape sélectionnée': 'Selected step',
|
||||||
|
'Durée visible avant de passer à l’expression suivante.':
|
||||||
|
'Visible duration before moving to the next expression.',
|
||||||
|
'Supprimer cette étape': 'Delete this step',
|
||||||
|
'Temps d’affichage': 'Display time',
|
||||||
|
'Durée de transition': 'Transition duration',
|
||||||
|
Transition: 'Transition',
|
||||||
|
Ressort: 'Spring',
|
||||||
|
Douce: 'Smooth',
|
||||||
|
Rapide: 'Snappy',
|
||||||
|
'Ajouter une expression': 'Add expression',
|
||||||
|
'Sélectionne un preset pour l’ajouter à la fin de la timeline.':
|
||||||
|
'Select a preset to append it to the timeline.',
|
||||||
|
Clignements: 'Blinks',
|
||||||
|
'Le blink fonctionne indépendamment des changements d’expression.':
|
||||||
|
'Blinking runs independently from expression changes.',
|
||||||
|
'Activer les clignements': 'Enable blinking',
|
||||||
|
'Intervalle minimum': 'Minimum interval',
|
||||||
|
'Intervalle maximum': 'Maximum interval',
|
||||||
|
'Intervalle du clignement': 'Blink interval',
|
||||||
|
'Durée du clignement': 'Blink duration',
|
||||||
|
animations: 'animations',
|
||||||
|
'Chaque étape possède sa propre durée et sa propre transition.':
|
||||||
|
'Each step has its own duration and transition.',
|
||||||
|
Désactivé: 'Disabled',
|
||||||
|
'comportement de la timeline': 'timeline behavior',
|
||||||
|
'Supprimer cette animation ?': 'Delete this animation?',
|
||||||
|
'Cette action supprimera définitivement cette animation.':
|
||||||
|
'This permanently deletes this animation.',
|
||||||
|
'Cette animation enchaîne un pool de presets et des clignements.':
|
||||||
|
'This animation cycles through presets and blinks.',
|
||||||
|
'Cet état enchaîne un pool de presets et des clignements.':
|
||||||
|
'This animation cycles through presets and blinks.',
|
||||||
|
'Expressions de l’animation': 'Animation expressions',
|
||||||
|
'Les presets sont joués dans cet ordre, puis la boucle recommence.':
|
||||||
|
'Presets play in this order, then the loop starts again.',
|
||||||
|
'Logique de clignement': 'Blink behavior',
|
||||||
|
'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.':
|
||||||
|
'A slightly randomized interval keeps the rhythm natural.',
|
||||||
|
'Premier clignement': 'First blink',
|
||||||
|
'après le lancement': 'after launch',
|
||||||
|
Intervalle: 'Interval',
|
||||||
|
'tirage aléatoire': 'randomized',
|
||||||
|
Durée: 'Duration',
|
||||||
|
'fermeture et ouverture': 'close and open',
|
||||||
|
'Changement d’expression': 'Expression change',
|
||||||
|
'cadence de l’animation': 'animation tempo',
|
||||||
|
'Détails de l’animation': 'Animation details',
|
||||||
|
'Afficher les détails de l’animation': 'Show animation details',
|
||||||
|
'Masquer les détails de l’animation': 'Hide animation details',
|
||||||
|
Relancer: 'Restart',
|
||||||
|
Lancer: 'Play',
|
||||||
|
Pause: 'Pause',
|
||||||
|
Reprendre: 'Resume',
|
||||||
|
Annuler: 'Cancel',
|
||||||
|
'Supprimer cette expression ?': 'Delete this expression?',
|
||||||
|
'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.':
|
||||||
|
'This permanently removes the preset from this avatar’s library.',
|
||||||
|
'Cette expression sera aussi retirée des animations suivantes :':
|
||||||
|
'This expression will also be removed from the following animations:',
|
||||||
|
'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.':
|
||||||
|
'If an animation only contains this expression, the fallback expression will be assigned so it remains playable.',
|
||||||
|
'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.':
|
||||||
|
'This avatar’s body, expressions and animations will be permanently deleted. The base library will be preserved.',
|
||||||
|
'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.':
|
||||||
|
'Drag on the surface to orient the head. The gizmo rings control X, Y and Z.',
|
||||||
|
Sphère: 'Sphere',
|
||||||
|
Curseur: 'Cursor',
|
||||||
|
Cylindre: 'Cylinder',
|
||||||
|
Cône: 'Cone',
|
||||||
|
Diamant: 'Diamond',
|
||||||
|
'Yeux presque fermés, respiration lente et expression de sommeil.':
|
||||||
|
'Nearly closed eyes, slow breathing and a sleepy expression.',
|
||||||
|
'Animation courte de réveil avant retour vers une expression neutre.':
|
||||||
|
'Short waking animation before returning to a neutral expression.',
|
||||||
|
'Séquence courte de réveil avant retour vers une expression neutre.':
|
||||||
|
'Short waking animation before returning to a neutral expression.',
|
||||||
|
'Micro-mouvements lents, expressions 00 et 08, clignement rare.':
|
||||||
|
'Slow micro-movements, expressions 00 and 08, infrequent blinking.',
|
||||||
|
'Expressions 10, 01 et 19, regard stable et clignement attentif.':
|
||||||
|
'Expressions 10, 01 and 19, steady gaze and attentive blinking.',
|
||||||
|
'Regard haut et latéral, expressions asymétriques et changements fréquents.':
|
||||||
|
'Upward side gaze, asymmetric expressions and frequent changes.',
|
||||||
|
'Balayage rapide et changements très fréquents.': 'Fast scanning and very frequent changes.',
|
||||||
|
'Rythme régulier, regard concentré et micro-variations.':
|
||||||
|
'Steady rhythm, focused gaze and subtle variations.',
|
||||||
|
'Grandes expressions positives et mouvements rapides.':
|
||||||
|
'Big positive expressions and fast movements.',
|
||||||
|
'Inclinaisons et rotations rapides pour simuler un agent en mouvement.':
|
||||||
|
'Fast tilts and rotations to simulate a moving agent.',
|
||||||
|
'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.',
|
||||||
|
'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.',
|
||||||
|
'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.',
|
||||||
|
sleeping: 'sleeping',
|
||||||
|
waking: 'waking',
|
||||||
|
idle: 'idle',
|
||||||
|
listening: 'listening',
|
||||||
|
thinking: 'thinking',
|
||||||
|
searching: 'searching',
|
||||||
|
working: 'working',
|
||||||
|
excited: 'excited',
|
||||||
|
surprised: 'surprised',
|
||||||
|
suspicious: 'suspicious',
|
||||||
|
angry: 'angry',
|
||||||
|
drowsy: 'drowsy',
|
||||||
|
happy: 'happy',
|
||||||
|
curious: 'curious',
|
||||||
|
confused: 'confused',
|
||||||
|
bored: 'bored',
|
||||||
|
proud: 'proud',
|
||||||
|
shy: 'shy',
|
||||||
|
sad: 'sad',
|
||||||
|
laughing: 'laughing',
|
||||||
|
scared: 'scared',
|
||||||
|
playful: 'playful',
|
||||||
|
celebrate: 'celebrate',
|
||||||
|
orbit: 'orbit',
|
||||||
|
radar: 'radar',
|
||||||
|
progress: 'progress',
|
||||||
|
spawning: 'spawning',
|
||||||
|
humming: 'humming',
|
||||||
|
loading: 'loading',
|
||||||
|
dictating: 'dictating',
|
||||||
|
writing: 'writing',
|
||||||
|
sending: 'sending',
|
||||||
|
receiving: 'receiving',
|
||||||
|
uploading: 'uploading',
|
||||||
|
notifying: 'notifying',
|
||||||
|
alerting: 'alerting',
|
||||||
|
dragging: 'dragging',
|
||||||
|
bouncing: 'bouncing',
|
||||||
|
'powering-down': 'powering down',
|
||||||
|
}
|
||||||
|
|
||||||
|
const frenchStates: Record<string, string> = {
|
||||||
|
loop: 'boucle',
|
||||||
|
once: 'une fois',
|
||||||
|
pingPong: 'aller-retour',
|
||||||
|
Custom: 'Personnalisé',
|
||||||
|
'Untitled animation': 'Animation sans titre',
|
||||||
|
// Compatibilité avec les animations intégrées déjà persistées avant le renommage.
|
||||||
|
'Untitled sequence': 'Animation sans titre',
|
||||||
|
'Cet état enchaîne un pool de presets et des clignements.':
|
||||||
|
'Cette animation enchaîne un pool de presets et des clignements.',
|
||||||
|
'Séquence courte de réveil avant retour vers une expression neutre.':
|
||||||
|
'Animation courte de réveil avant retour vers une expression neutre.',
|
||||||
|
sleeping: 'sommeil',
|
||||||
|
waking: 'réveil',
|
||||||
|
idle: 'au repos',
|
||||||
|
listening: 'écoute',
|
||||||
|
thinking: 'réflexion',
|
||||||
|
searching: 'recherche',
|
||||||
|
working: 'travail',
|
||||||
|
excited: 'enthousiaste',
|
||||||
|
surprised: 'surpris',
|
||||||
|
suspicious: 'méfiant',
|
||||||
|
angry: 'en colère',
|
||||||
|
drowsy: 'somnolent',
|
||||||
|
happy: 'heureux',
|
||||||
|
curious: 'curieux',
|
||||||
|
confused: 'confus',
|
||||||
|
bored: 'ennuyé',
|
||||||
|
proud: 'fier',
|
||||||
|
shy: 'timide',
|
||||||
|
sad: 'triste',
|
||||||
|
laughing: 'rire',
|
||||||
|
scared: 'effrayé',
|
||||||
|
playful: 'joueur',
|
||||||
|
celebrate: 'célébration',
|
||||||
|
orbit: 'orbite',
|
||||||
|
radar: 'radar',
|
||||||
|
progress: 'progression',
|
||||||
|
spawning: 'apparition',
|
||||||
|
humming: 'fredonnement',
|
||||||
|
loading: 'chargement',
|
||||||
|
dictating: 'dictée',
|
||||||
|
writing: 'écriture',
|
||||||
|
sending: 'envoi',
|
||||||
|
receiving: 'réception',
|
||||||
|
uploading: 'téléversement',
|
||||||
|
notifying: 'notification',
|
||||||
|
alerting: 'alerte',
|
||||||
|
dragging: 'glissement',
|
||||||
|
bouncing: 'rebond',
|
||||||
|
'powering-down': 'extinction',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dynamicTranslations: [RegExp, string][] = [
|
||||||
|
[/^Modifier l’expression (.+)$/, 'Edit expression $1'],
|
||||||
|
[/^Modifier (.+)$/, 'Edit $1'],
|
||||||
|
[/^Supprimer (.+) \?$/, 'Delete $1?'],
|
||||||
|
[/^Ajouter une forme · (.+)$/, 'Add a shape · $1'],
|
||||||
|
[/^Réinitialiser la position et la rotation de (.+)$/, 'Reset position and rotation of $1'],
|
||||||
|
[/^Animation en cours : (.+)$/, 'Active animation: $1'],
|
||||||
|
[/^Mettre (.+) en pause$/, 'Pause $1'],
|
||||||
|
[/^Reprendre (.+)$/, 'Resume $1'],
|
||||||
|
[/^Arrêter (.+)$/, 'Stop $1'],
|
||||||
|
[/^(.+) expressions$/, '$1 expressions'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export const translateStudioText = (text: string, language: StudioLanguage) => {
|
||||||
|
if (language === 'fr') return frenchStates[text] ?? text
|
||||||
|
if (language === 'zh-CN') {
|
||||||
|
const exact = chinese[text]
|
||||||
|
if (exact) return exact
|
||||||
|
const dynamic = translateChineseDynamicText(text)
|
||||||
|
if (dynamic) return dynamic
|
||||||
|
return Object.entries(chinese)
|
||||||
|
.sort(([left], [right]) => right.length - left.length)
|
||||||
|
.reduce(
|
||||||
|
(translated, [source, replacement]) => translated.replaceAll(source, replacement),
|
||||||
|
text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const exact = english[text]
|
||||||
|
if (exact) return exact
|
||||||
|
for (const [pattern, replacement] of dynamicTranslations) {
|
||||||
|
if (pattern.test(text)) return text.replace(pattern, replacement)
|
||||||
|
}
|
||||||
|
return Object.entries(english)
|
||||||
|
.sort(([left], [right]) => right.length - left.length)
|
||||||
|
.reduce((translated, [source, replacement]) => translated.replaceAll(source, replacement), text)
|
||||||
|
}
|
||||||
|
|
||||||
|
type StudioLanguageContextValue = {
|
||||||
|
language: StudioLanguage
|
||||||
|
setLanguage: (language: StudioLanguage) => void
|
||||||
|
t: (text: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudioLanguageContext = createContext<StudioLanguageContextValue | null>(null)
|
||||||
|
|
||||||
|
export function StudioLanguageProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [language, setLanguage] = useState<StudioLanguage>(readStoredStudioLanguage)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STUDIO_LANGUAGE_STORAGE_KEY, language)
|
||||||
|
} catch {
|
||||||
|
// The studio remains usable when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
return createElement(
|
||||||
|
StudioLanguageContext.Provider,
|
||||||
|
{ value: { language, setLanguage, t: text => translateStudioText(text, language) } },
|
||||||
|
children
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStudioLanguage = () => {
|
||||||
|
const context = useContext(StudioLanguageContext)
|
||||||
|
if (!context) throw new Error('useStudioLanguage must be used inside StudioLanguageProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
377
src/i18n.zh.ts
Normal file
377
src/i18n.zh.ts
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
export const chinese: Record<string, string> = {
|
||||||
|
'Playground de l’avatar': '头像游乐场',
|
||||||
|
Arrêté: '已停止',
|
||||||
|
'Aperçu procédural en direct': '实时程序化预览',
|
||||||
|
'Aperçu de l’avatar': '头像预览',
|
||||||
|
'Choisissez une animation exportée pour la lancer。': '选择一个已导出的动画以播放。',
|
||||||
|
'Choisissez une animation exportée pour la lancer.': '选择一个已导出的动画以播放。',
|
||||||
|
Lecture: '播放',
|
||||||
|
'Contrôlez l’animation active.': '控制当前动画。',
|
||||||
|
Lire: '播放',
|
||||||
|
Arrêter: '停止',
|
||||||
|
'API JavaScript': 'JavaScript API',
|
||||||
|
'Le module réutilisable est disponible dans avatar.js.': '可复用模块位于 avatar.js。',
|
||||||
|
'Ouvrez cette démo directement, ou servez le dossier localement pour importer avatar.js depuis un autre module.':
|
||||||
|
'可直接打开此演示,或在本地启动该目录,以便从其他模块导入 avatar.js。',
|
||||||
|
'Langue de l’interface': '界面语言',
|
||||||
|
'Nom de l’avatar': '头像名称',
|
||||||
|
sélecteur: '选择器',
|
||||||
|
hexadécimal: '十六进制值',
|
||||||
|
'Lier width': '关联宽度',
|
||||||
|
'Lier height': '关联高度',
|
||||||
|
'Lier size': '关联尺寸',
|
||||||
|
'Lier la position des yeux': '关联眼睛位置',
|
||||||
|
'Glisser horizontalement pour modifier': '水平拖动以调整',
|
||||||
|
'En lecture': '播放中',
|
||||||
|
'En pause': '已暂停',
|
||||||
|
'Réinitialiser la rotation de la tête': '重置头部旋转',
|
||||||
|
'Réinitialiser la position et la rotation de': '重置位置和旋转:',
|
||||||
|
'Avatar procédural': '程序化头像',
|
||||||
|
'Gizmo de rotation': '旋转控制器',
|
||||||
|
'Retour aux expressions': '返回表情',
|
||||||
|
'Retour aux animations': '返回动画',
|
||||||
|
'Retour au studio': '返回工作室',
|
||||||
|
'Preset en mémoire': '已保存预设',
|
||||||
|
'Nouvelle expression': '新建表情',
|
||||||
|
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
|
||||||
|
Corps: '身体',
|
||||||
|
'Apparence et orientation générale de l’avatar.': '头像的整体外观和方向。',
|
||||||
|
'Couleur du corps': '身体颜色',
|
||||||
|
'Rotation de la tête': '头部旋转',
|
||||||
|
Yeux: '眼睛',
|
||||||
|
'Forme, placement et orientation propres au regard.': '眼睛的形状、位置和方向。',
|
||||||
|
'Couleur des yeux': '眼睛颜色',
|
||||||
|
Largeur: '宽度',
|
||||||
|
Hauteur: '高度',
|
||||||
|
Échelle: '缩放',
|
||||||
|
'Taille proportionnelle': '等比尺寸',
|
||||||
|
'Œil gauche': '左眼',
|
||||||
|
'Œil droit': '右眼',
|
||||||
|
'Position et espacement': '位置和间距',
|
||||||
|
Horizontale: '水平',
|
||||||
|
Verticale: '垂直',
|
||||||
|
Espacement: '间距',
|
||||||
|
'Rotation locale': '局部旋转',
|
||||||
|
Projection: '投影',
|
||||||
|
'Perspective appliquée à la surface active.': '应用于当前表面的透视。',
|
||||||
|
Perspective: '透视',
|
||||||
|
Supprimer: '删除',
|
||||||
|
Enregistrer: '保存',
|
||||||
|
'Construction du corps': '身体构建',
|
||||||
|
'Choisis la forme principale puis assemble les primitives autour d’elle.':
|
||||||
|
'选择主形状,然后在其周围组合基础形状。',
|
||||||
|
'Choisir un avatar': '选择头像',
|
||||||
|
'Double-clic pour modifier': '双击编辑',
|
||||||
|
'Nouvel avatar': '新建头像',
|
||||||
|
Modifier: '编辑',
|
||||||
|
'Mode d’édition': '编辑模式',
|
||||||
|
Pose: '姿势',
|
||||||
|
Expressions: '表情',
|
||||||
|
Animations: '动画',
|
||||||
|
Exporter: '导出',
|
||||||
|
Export: '导出',
|
||||||
|
'Exporter l’avatar': '导出头像',
|
||||||
|
'Télécharge un composant autonome avec les animations de ton choix.':
|
||||||
|
'下载包含所选动画的独立组件。',
|
||||||
|
'Avatar sélectionné': '已选头像',
|
||||||
|
Format: '格式',
|
||||||
|
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
|
||||||
|
'Composant TSX autonome': '独立 TSX 组件',
|
||||||
|
'Package React local (.zip)': '本地 React 包(.zip)',
|
||||||
|
'Module JavaScript': 'JavaScript 模块',
|
||||||
|
'Module ES autonome': '独立 ES 模块',
|
||||||
|
'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)',
|
||||||
|
sélectionnées: '已选择',
|
||||||
|
'Animations à exporter': '要导出的动画',
|
||||||
|
'Tout sélectionner': '全选',
|
||||||
|
'Tout désélectionner': '取消全选',
|
||||||
|
'Télécharger le composant TSX': '下载 TSX 组件',
|
||||||
|
'Télécharger le package React': '下载 React 包',
|
||||||
|
'Télécharger le module': '下载模块',
|
||||||
|
Snapshot: '快照',
|
||||||
|
'Mode photo': '照片模式',
|
||||||
|
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
||||||
|
'Les options de capture seront configurées ici.': '快照选项将在此配置。',
|
||||||
|
'Aperçu du Snapshot': '快照预览',
|
||||||
|
'Aperçu du mode photo': '照片模式预览',
|
||||||
|
'Arrière-plan': '背景',
|
||||||
|
'Choisis un fond transparent, uni ou en dégradé.': '选择透明、纯色或渐变背景。',
|
||||||
|
Style: '样式',
|
||||||
|
Transparent: '透明',
|
||||||
|
Uni: '纯色',
|
||||||
|
'Dégradé linéaire': '线性渐变',
|
||||||
|
'Dégradé radial': '径向渐变',
|
||||||
|
'Style d’arrière-plan': '背景样式',
|
||||||
|
Couleur: '颜色',
|
||||||
|
Départ: '起始颜色',
|
||||||
|
Arrivée: '结束颜色',
|
||||||
|
Définition: '分辨率',
|
||||||
|
'Dimensions inscrites dans le fichier SVG.': '写入 SVG 文件的尺寸。',
|
||||||
|
'Dimensions du fichier exporté.': '导出文件的尺寸。',
|
||||||
|
'Définition du Snapshot': '快照分辨率',
|
||||||
|
'Définition du mode photo': '照片模式分辨率',
|
||||||
|
'Télécharger le Snapshot SVG': '下载 SVG 快照',
|
||||||
|
'Télécharger en SVG': '下载 SVG',
|
||||||
|
'Télécharger en PNG': '下载 PNG',
|
||||||
|
'Format d’export': '导出格式',
|
||||||
|
'Format d’export du mode photo': '照片模式导出格式',
|
||||||
|
'Choisis le type de fichier généré par le mode photo.': '选择照片模式生成的文件类型。',
|
||||||
|
'Prendre une photo': '拍照',
|
||||||
|
'Informations sur le mode photo': '照片模式信息',
|
||||||
|
'Tu peux modifier le format, le fond et la définition du mode photo dans Export.':
|
||||||
|
'你可以在“导出”中更改照片模式的格式、背景和分辨率。',
|
||||||
|
'Projet du Studio': '工作室项目',
|
||||||
|
'Transfère tous les avatars, expressions et animations vers un autre navigateur.':
|
||||||
|
'将所有头像、表情和动画传输到另一个浏览器。',
|
||||||
|
'Télécharger le projet JSON': '下载 JSON 项目',
|
||||||
|
'Importer un projet JSON': '导入 JSON 项目',
|
||||||
|
'Ce fichier ne contient pas un projet Avatar Studio valide et compatible.':
|
||||||
|
'此文件不包含有效且兼容的头像工作室项目。',
|
||||||
|
'Importer ce projet ?': '导入此项目?',
|
||||||
|
'Le projet local actuel sera remplacé par les avatars, expressions, animations et état de lecture de ce fichier.':
|
||||||
|
'当前本地项目将被此文件中的头像、表情、动画和播放状态替换。',
|
||||||
|
'Le projet n’a pas pu être enregistré dans ce navigateur. Libère de l’espace puis réessaie.':
|
||||||
|
'无法在此浏览器中保存项目。请释放空间后重试。',
|
||||||
|
Importer: '导入',
|
||||||
|
'Construction, forme et couleur de la tête de l’avatar.': '头像头部的构建、形状和颜色。',
|
||||||
|
'Une forme principale porte les yeux. Les autres primitives se placent autour d’elle.':
|
||||||
|
'主形状承载眼睛,其他基础形状围绕它放置。',
|
||||||
|
'Forme principale': '主形状',
|
||||||
|
'porte les yeux': '承载眼睛',
|
||||||
|
'Ajouter une forme': '添加形状',
|
||||||
|
Dupliquer: '复制',
|
||||||
|
'Gizmo local': '局部控制器',
|
||||||
|
'Déplacer dans le plan de la caméra': '在相机平面中移动',
|
||||||
|
Transformer: '变换',
|
||||||
|
copie: '副本',
|
||||||
|
'Glisse un axe pour déplacer la forme, ou un anneau pour la faire tourner.':
|
||||||
|
'拖动轴来移动形状,或拖动圆环来旋转形状。',
|
||||||
|
Profondeur: '深度',
|
||||||
|
'Position locale': '局部位置',
|
||||||
|
'Cette surface est la référence du visage et porte les yeux.': '此表面是面部基准并承载眼睛。',
|
||||||
|
'Rondeur des arêtes': '边缘圆度',
|
||||||
|
Rondeur: '圆度',
|
||||||
|
'Rondeur morphologique': '形态圆度',
|
||||||
|
'Rondeur de la pointe': '尖端圆度',
|
||||||
|
'Rondeur de la base': '底部圆度',
|
||||||
|
'Rondeur globale': '整体圆度',
|
||||||
|
'Rondeur pointe': '尖端圆度',
|
||||||
|
'Rondeur base': '底部圆度',
|
||||||
|
'Couleur de base utilisée par les poses et les expressions.': '姿势和表情使用的基础颜色。',
|
||||||
|
'Forme, placement, orientation et couleur du regard par défaut.':
|
||||||
|
'默认眼睛的形状、位置、方向和颜色。',
|
||||||
|
'Définis l’identité du regard de cet avatar. Les poses s’ajoutent ensuite à cette base.':
|
||||||
|
'定义此头像的默认眼睛,姿势将在此基础上叠加。',
|
||||||
|
'Coordonnées propres à l’avatar, indépendantes des poses.': '头像自身的坐标,与姿势无关。',
|
||||||
|
'Inclinaison par défaut propre à chaque œil.': '每只眼睛的默认倾斜角度。',
|
||||||
|
'Orientation et apparence générale de la pose.': '姿势的整体方向和外观。',
|
||||||
|
'La pose peut remplacer temporairement la couleur de l’avatar.': '姿势可以暂时覆盖头像颜色。',
|
||||||
|
'Reprendre la couleur de l’avatar': '恢复头像颜色',
|
||||||
|
'Les libellés ↔ sont scrubbables, comme dans Figma.': '带 ↔ 的标签可像 Figma 一样拖动调整。',
|
||||||
|
'Forme, placement, orientation et couleur du regard.': '眼睛的形状、位置、方向和颜色。',
|
||||||
|
'Coordonnées communes projetées sur la forme choisie.': '投影到所选形状上的共享坐标。',
|
||||||
|
'Inclinaison propre à chaque œil.': '每只眼睛各自的倾斜角度。',
|
||||||
|
'Perspective et repères appliqués à la surface active.': '应用于当前表面的透视和参考线。',
|
||||||
|
'Profondeur simulée du visage.': '模拟的面部深度。',
|
||||||
|
'Afficher le maillage': '显示网格',
|
||||||
|
Réinitialiser: '重置',
|
||||||
|
Mouvement: '运动',
|
||||||
|
Motion: '运动',
|
||||||
|
'Mouvement perpétuel': '持续运动',
|
||||||
|
'Aucun mouvement': '无运动',
|
||||||
|
'Dérive lente': '缓慢漂移',
|
||||||
|
'Micro-ajustements': '微调',
|
||||||
|
Tremblement: '抖动',
|
||||||
|
'Ajoute une légère présence ou un tremblement continu au corps.':
|
||||||
|
'为身体添加轻微的生命感或持续抖动。',
|
||||||
|
'Anime le regard par petites saccades naturelles ou par tremblement.':
|
||||||
|
'通过自然的微小跳动或抖动让眼神动起来。',
|
||||||
|
'Motion interpole les valeurs et notre moteur effectue le slerp quaternion.':
|
||||||
|
'Motion 对数值进行插值,引擎执行四元数球面插值。',
|
||||||
|
'Vitesse du ressort': '弹簧速度',
|
||||||
|
'Une animation est en cours de lecture': '动画正在播放',
|
||||||
|
'Mettez l’animation en pause avant de manipuler les paramètres de pose.':
|
||||||
|
'调整姿势参数前请暂停动画。',
|
||||||
|
'Mettez l’animation en pause avant de choisir ou personnaliser une expression.':
|
||||||
|
'选择或自定义表情前请暂停动画。',
|
||||||
|
Cligner: '眨眼',
|
||||||
|
'Expression aléatoire': '随机表情',
|
||||||
|
'Cycle de vie': '生命周期',
|
||||||
|
Réactions: '反应',
|
||||||
|
'Éditeur d’animation': '动画编辑器',
|
||||||
|
'Modifier l’animation': '编辑动画',
|
||||||
|
'Nouvelle animation': '新建动画',
|
||||||
|
'Compose les expressions, leur cadence et les clignements de cette animation.':
|
||||||
|
'组合此动画的表情、节奏和眨眼。',
|
||||||
|
'Prévisualiser l’animation': '预览动画',
|
||||||
|
'Arrêter l’animation': '停止动画',
|
||||||
|
Identité: '基本信息',
|
||||||
|
'Nom, catégorie et comportement de lecture de l’animation.': '动画的名称、分类和播放行为。',
|
||||||
|
Nom: '名称',
|
||||||
|
Catégorie: '分类',
|
||||||
|
Description: '描述',
|
||||||
|
'Mode de lecture': '播放模式',
|
||||||
|
Boucle: '循环',
|
||||||
|
'Une fois': '一次',
|
||||||
|
'Aller-retour': '往返',
|
||||||
|
Timeline: '时间轴',
|
||||||
|
'Glisse les étapes pour les réordonner, puis sélectionne-en une pour régler sa cadence.':
|
||||||
|
'拖动步骤以重新排序,然后选择一个步骤调整时间。',
|
||||||
|
'Ajoute une expression pour commencer.': '添加一个表情以开始。',
|
||||||
|
'Étape sélectionnée': '已选步骤',
|
||||||
|
'Durée visible avant de passer à l’expression suivante.': '切换到下一个表情前的显示时长。',
|
||||||
|
'Supprimer cette étape': '删除此步骤',
|
||||||
|
'Temps d’affichage': '显示时间',
|
||||||
|
'Durée de transition': '过渡时长',
|
||||||
|
Transition: '过渡',
|
||||||
|
Ressort: '弹簧',
|
||||||
|
Douce: '平滑',
|
||||||
|
Rapide: '迅速',
|
||||||
|
'Ajouter une expression': '添加表情',
|
||||||
|
'Sélectionne un preset pour l’ajouter à la fin de la timeline.':
|
||||||
|
'选择一个预设并添加到时间轴末尾。',
|
||||||
|
Clignements: '眨眼',
|
||||||
|
'Le blink fonctionne indépendamment des changements d’expression.': '眨眼独立于表情变化运行。',
|
||||||
|
'Activer les clignements': '启用眨眼',
|
||||||
|
'Intervalle minimum': '最小间隔',
|
||||||
|
'Intervalle maximum': '最大间隔',
|
||||||
|
'Intervalle du clignement': '眨眼间隔',
|
||||||
|
'Durée du clignement': '眨眼时长',
|
||||||
|
animations: '动画',
|
||||||
|
'Chaque étape possède sa propre durée et sa propre transition.':
|
||||||
|
'每个步骤都有自己的持续时间和过渡。',
|
||||||
|
Désactivé: '已禁用',
|
||||||
|
'comportement de la timeline': '时间轴行为',
|
||||||
|
'Supprimer cette animation ?': '删除此动画?',
|
||||||
|
'Cette action supprimera définitivement cette animation.': '此操作将永久删除该动画。',
|
||||||
|
'Cette animation enchaîne un pool de presets et des clignements.':
|
||||||
|
'此动画依次播放一组预设并执行眨眼。',
|
||||||
|
'Cet état enchaîne un pool de presets et des clignements.': '此动画依次播放一组预设并执行眨眼。',
|
||||||
|
'Expressions de l’animation': '动画表情',
|
||||||
|
'Les presets sont joués dans cet ordre, puis la boucle recommence.':
|
||||||
|
'预设按此顺序播放,然后重新开始循环。',
|
||||||
|
'Logique de clignement': '眨眼行为',
|
||||||
|
'Le rythme reste naturel grâce à un intervalle légèrement aléatoire.':
|
||||||
|
'略带随机性的间隔使节奏保持自然。',
|
||||||
|
'Premier clignement': '首次眨眼',
|
||||||
|
'après le lancement': '启动后',
|
||||||
|
Intervalle: '间隔',
|
||||||
|
'tirage aléatoire': '随机',
|
||||||
|
Durée: '时长',
|
||||||
|
'fermeture et ouverture': '闭合与睁开',
|
||||||
|
'Changement d’expression': '表情变化',
|
||||||
|
'cadence de l’animation': '动画节奏',
|
||||||
|
'Détails de l’animation': '动画详情',
|
||||||
|
'Afficher les détails de l’animation': '显示动画详情',
|
||||||
|
'Masquer les détails de l’animation': '隐藏动画详情',
|
||||||
|
Relancer: '重新开始',
|
||||||
|
Lancer: '播放',
|
||||||
|
Pause: '暂停',
|
||||||
|
Reprendre: '继续',
|
||||||
|
Annuler: '取消',
|
||||||
|
'Supprimer cette expression ?': '删除此表情?',
|
||||||
|
'Cette action retirera définitivement le preset de la bibliothèque de cet avatar.':
|
||||||
|
'此操作将从该头像的库中永久删除该预设。',
|
||||||
|
'Cette expression sera aussi retirée des animations suivantes :': '此表情也会从以下动画中移除:',
|
||||||
|
'Si une animation ne contient que cette expression, l’expression de repli lui sera assignée pour qu’elle reste jouable.':
|
||||||
|
'如果动画只包含此表情,将为其分配备用表情以保持可播放。',
|
||||||
|
'Le corps, les expressions et les animations propres à cet avatar seront définitivement supprimés. La bibliothèque de base sera conservée.':
|
||||||
|
'此头像的身体、表情和动画将被永久删除,基础库将被保留。',
|
||||||
|
'Glisse sur la surface pour orienter la tête. Les anneaux du gizmo contrôlent X, Y et Z.':
|
||||||
|
'在表面上拖动以调整头部方向。控制器圆环分别控制 X、Y 和 Z。',
|
||||||
|
Sphère: '球体',
|
||||||
|
Cube: '立方体',
|
||||||
|
Capsule: '胶囊体',
|
||||||
|
Curseur: '光标',
|
||||||
|
Cylindre: '圆柱体',
|
||||||
|
Cône: '圆锥体',
|
||||||
|
Diamant: '菱形体',
|
||||||
|
'Yeux presque fermés, respiration lente et expression de sommeil.':
|
||||||
|
'双眼几乎闭合,呼吸缓慢,呈现睡眠表情。',
|
||||||
|
'Animation courte de réveil avant retour vers une expression neutre.':
|
||||||
|
'短暂的醒来动画,然后恢复中性表情。',
|
||||||
|
'Séquence courte de réveil avant retour vers une expression neutre.':
|
||||||
|
'短暂的醒来动画,然后恢复中性表情。',
|
||||||
|
'Micro-mouvements lents, expressions 00 et 08, clignement rare.':
|
||||||
|
'缓慢微动,使用表情 00 和 08,偶尔眨眼。',
|
||||||
|
'Expressions 10, 01 et 19, regard stable et clignement attentif.':
|
||||||
|
'使用表情 10、01 和 19,视线稳定并专注眨眼。',
|
||||||
|
'Regard haut et latéral, expressions asymétriques et changements fréquents.':
|
||||||
|
'向上侧视,不对称表情并频繁变化。',
|
||||||
|
'Balayage rapide et changements très fréquents.': '快速扫视并非常频繁地变化。',
|
||||||
|
'Rythme régulier, regard concentré et micro-variations.': '节奏稳定,目光专注并有细微变化。',
|
||||||
|
'Grandes expressions positives et mouvements rapides.': '夸张的积极表情和快速运动。',
|
||||||
|
'Inclinaisons et rotations rapides pour simuler un agent en mouvement.':
|
||||||
|
'通过快速倾斜和旋转模拟运动中的角色。',
|
||||||
|
'Rythme régulier et expressions concentrées.': '节奏稳定且表情专注。',
|
||||||
|
'Grandes expressions et transitions rapides.': '夸张表情和快速过渡。',
|
||||||
|
'Inclinaisons et forte asymétrie.': '明显倾斜和强烈不对称。',
|
||||||
|
loop: '循环',
|
||||||
|
once: '一次',
|
||||||
|
pingPong: '往返',
|
||||||
|
Custom: '自定义',
|
||||||
|
'Untitled animation': '未命名动画',
|
||||||
|
'Untitled sequence': '未命名动画',
|
||||||
|
sleeping: '睡眠',
|
||||||
|
waking: '醒来',
|
||||||
|
idle: '待机',
|
||||||
|
listening: '聆听',
|
||||||
|
thinking: '思考',
|
||||||
|
searching: '搜索',
|
||||||
|
working: '工作',
|
||||||
|
excited: '兴奋',
|
||||||
|
surprised: '惊讶',
|
||||||
|
suspicious: '怀疑',
|
||||||
|
angry: '生气',
|
||||||
|
drowsy: '困倦',
|
||||||
|
happy: '开心',
|
||||||
|
curious: '好奇',
|
||||||
|
confused: '困惑',
|
||||||
|
bored: '无聊',
|
||||||
|
proud: '自豪',
|
||||||
|
shy: '害羞',
|
||||||
|
sad: '悲伤',
|
||||||
|
laughing: '大笑',
|
||||||
|
scared: '害怕',
|
||||||
|
playful: '顽皮',
|
||||||
|
celebrate: '庆祝',
|
||||||
|
orbit: '环绕',
|
||||||
|
radar: '雷达',
|
||||||
|
progress: '进度',
|
||||||
|
spawning: '出现',
|
||||||
|
humming: '哼唱',
|
||||||
|
loading: '加载',
|
||||||
|
dictating: '听写',
|
||||||
|
writing: '书写',
|
||||||
|
sending: '发送',
|
||||||
|
receiving: '接收',
|
||||||
|
uploading: '上传',
|
||||||
|
notifying: '通知',
|
||||||
|
alerting: '警报',
|
||||||
|
dragging: '拖动',
|
||||||
|
bouncing: '弹跳',
|
||||||
|
'powering-down': '关机',
|
||||||
|
Avatars: '头像',
|
||||||
|
'React / TypeScript': 'React / TypeScript',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const translateChineseDynamicText = (text: string) => {
|
||||||
|
const rules: Array<[RegExp, (...matches: string[]) => string]> = [
|
||||||
|
[/^Modifier l’expression (.+)$/, value => `编辑表情 ${value}`],
|
||||||
|
[/^Modifier (.+)$/, value => `编辑${value}`],
|
||||||
|
[/^Supprimer (.+) \?$/, value => `删除${value}?`],
|
||||||
|
[/^Ajouter une forme · (.+)$/, value => `添加形状 · ${value}`],
|
||||||
|
[/^Réinitialiser la position et la rotation de (.+)$/, value => `重置${value}的位置和旋转`],
|
||||||
|
[/^Animation en cours : (.+)$/, value => `当前动画:${value}`],
|
||||||
|
[/^Mettre (.+) en pause$/, value => `暂停${value}`],
|
||||||
|
[/^Reprendre (.+)$/, value => `继续${value}`],
|
||||||
|
[/^Arrêter (.+)$/, value => `停止${value}`],
|
||||||
|
[/^(.+) expressions$/, value => `${value} 个表情`],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [pattern, replacement] of rules) {
|
||||||
|
const match = text.match(pattern)
|
||||||
|
if (match) return replacement(...match.slice(1))
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
4
src/lib/utils.ts
Normal file
4
src/lib/utils.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
|
||||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import App from './App'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
35
src/manipulationSession.ts
Normal file
35
src/manipulationSession.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export type ManipulationSession<Value> = {
|
||||||
|
initial: Value
|
||||||
|
latest: Value
|
||||||
|
}
|
||||||
|
|
||||||
|
export const beginManipulation = <Value>(initial: Value): ManipulationSession<Value> => ({
|
||||||
|
initial,
|
||||||
|
latest: initial,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const beginManipulationFromRenderedValue = <Value>(
|
||||||
|
readRenderedValue: () => Value
|
||||||
|
): ManipulationSession<Value> => beginManipulation(readRenderedValue())
|
||||||
|
|
||||||
|
export const previewManipulation = <Value>(
|
||||||
|
session: ManipulationSession<Value>,
|
||||||
|
next: Value,
|
||||||
|
preview?: (value: Value) => void
|
||||||
|
) => {
|
||||||
|
session.latest = next
|
||||||
|
preview?.(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const finishManipulation = <Value>(
|
||||||
|
session: ManipulationSession<Value>,
|
||||||
|
outcome: 'commit' | 'cancel',
|
||||||
|
handlers: { preview: (value: Value) => void; commit: (value: Value) => void }
|
||||||
|
) => {
|
||||||
|
if (outcome === 'cancel') {
|
||||||
|
handlers.preview(session.initial)
|
||||||
|
return session.initial
|
||||||
|
}
|
||||||
|
handlers.commit(session.latest)
|
||||||
|
return session.latest
|
||||||
|
}
|
||||||
68
src/playback.ts
Normal file
68
src/playback.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { advanceSequenceCursor, type AvatarSequence } from './sequences'
|
||||||
|
|
||||||
|
export type PlaybackTimeline = {
|
||||||
|
position: number
|
||||||
|
direction: 1 | -1
|
||||||
|
stepDueAt: number | null
|
||||||
|
stepRemainingMs: number
|
||||||
|
blinkDueAt: number | null
|
||||||
|
blinkRemainingMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPlaybackTimeline = (): PlaybackTimeline => ({
|
||||||
|
position: 0,
|
||||||
|
direction: 1,
|
||||||
|
stepDueAt: null,
|
||||||
|
stepRemainingMs: 0,
|
||||||
|
blinkDueAt: null,
|
||||||
|
blinkRemainingMs: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const beginPlayback = (timeline: PlaybackTimeline, preservePosition = false) => {
|
||||||
|
return {
|
||||||
|
...timeline,
|
||||||
|
position: preservePosition ? timeline.position : 0,
|
||||||
|
direction: preservePosition ? timeline.direction : 1,
|
||||||
|
stepDueAt: null,
|
||||||
|
blinkDueAt: null,
|
||||||
|
} satisfies PlaybackTimeline
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schedulePlaybackStep = (timeline: PlaybackTimeline, now: number, delayMs: number) => {
|
||||||
|
return { ...timeline, stepRemainingMs: delayMs, stepDueAt: now + delayMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schedulePlaybackBlink = (timeline: PlaybackTimeline, now: number, delayMs: number) => {
|
||||||
|
return { ...timeline, blinkRemainingMs: delayMs, blinkDueAt: now + delayMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pausePlaybackTimeline = (timeline: PlaybackTimeline, now: number) => {
|
||||||
|
return {
|
||||||
|
...timeline,
|
||||||
|
stepRemainingMs:
|
||||||
|
timeline.stepDueAt === null
|
||||||
|
? timeline.stepRemainingMs
|
||||||
|
: Math.max(timeline.stepDueAt - now, 0),
|
||||||
|
blinkRemainingMs:
|
||||||
|
timeline.blinkDueAt === null
|
||||||
|
? timeline.blinkRemainingMs
|
||||||
|
: Math.max(timeline.blinkDueAt - now, 0),
|
||||||
|
stepDueAt: null,
|
||||||
|
blinkDueAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const advancePlaybackTimeline = (timeline: PlaybackTimeline, sequence: AvatarSequence) => {
|
||||||
|
const cursor = advanceSequenceCursor(sequence, timeline.position, timeline.direction)
|
||||||
|
return {
|
||||||
|
cursor,
|
||||||
|
timeline: {
|
||||||
|
...timeline,
|
||||||
|
position: cursor.index,
|
||||||
|
direction: cursor.direction,
|
||||||
|
stepDueAt: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stopPlaybackTimeline = (_timeline: PlaybackTimeline) => createPlaybackTimeline()
|
||||||
219
src/presets.ts
Normal file
219
src/presets.ts
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import type { Expression } from './geometry'
|
||||||
|
|
||||||
|
const calibrated: number[][] = [
|
||||||
|
[7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0],
|
||||||
|
[-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0],
|
||||||
|
[-36.2, 13.1, 15.5, 44.3, 51.3, 74.2, 76, 68.7, -40.7, 0, 0],
|
||||||
|
[15.6, -16.5, -11.3, 54, 51, 49.6, 48.5, 70.9, 30.1, 0, 0],
|
||||||
|
[3.4, 13, 8.9, 42.6, 44, 17.3, 16, 57.9, 4.9, 0, 0],
|
||||||
|
[-17.7, -1.4, -8.8, 29.5, 19.2, 51.6, 41.9, 56.3, 0, 0, 90],
|
||||||
|
[14.8, 14.5, 5.5, 22.9, 22.2, 32.4, 33.4, 50.9, 39.2, 0, 0],
|
||||||
|
[25.7, 16.5, -13.5, 48.5, 48.3, 33.5, 33, 53.3, 41.3, 61.3, -80],
|
||||||
|
[-22.8, -15.9, 6.2, 44.5, 43.9, 32.3, 24.6, 54.9, -42, -60.9, 69.2],
|
||||||
|
[-11.6, 8.3, -12.7, 42.5, 22.1, 41.8, 22.2, 61.7, 12.3, 0, 0],
|
||||||
|
[20.3, 7, 8.7, 30.2, 28.1, 48.8, 49.2, 56.8, 39.9, 0, 0],
|
||||||
|
[17.5, -15.2, -8.7, 51, 49.2, 75.3, 73.4, 70.2, 41.6, 0, 0],
|
||||||
|
[-10.4, 15.2, 11.8, 50.6, 51.6, 50, 50.7, 69.5, 16.7, 0, 0],
|
||||||
|
[-6, -7.7, -9.4, 43.6, 42.8, 15.5, 18.1, 57.9, 3.5, 0, 0],
|
||||||
|
[0.2, -3.1, 9, 29.6, 16.8, 51.5, 41.3, 56.4, -7.8, 0, 90],
|
||||||
|
[-16.2, 38.4, 2.4, 23.7, 26.2, 32.7, 34.6, 53.9, -41.1, 0, 0],
|
||||||
|
[3.5, -16.1, 15.8, 51, 48.5, 34.9, 33, 55.1, 41.9, 80, -62.2],
|
||||||
|
[-17.3, 11.2, -9.1, 24.2, 44.5, 44.5, 32.2, 55, -36.5, 18.5, 67.9],
|
||||||
|
[-0.7, 3.6, 12.2, 42.1, 22.2, 41.7, 22.1, 60.4, -9.1, 0, 0],
|
||||||
|
[-25.3, -12.4, -13.3, 30.5, 26.8, 49.9, 48.8, 56.2, -35.8, 0, 0],
|
||||||
|
[-41.1, 20.2, 18.8, 44.6, 53, 74.9, 77.8, 70.8, -40.6, 0, 0],
|
||||||
|
[-14.6, -12.5, -16.1, 51.4, 50.5, 50.1, 49.4, 69, -20, 0, 0],
|
||||||
|
[10, 2.7, 8.8, 42.9, 43.3, 16.4, 17.8, 57.9, 2.7, 0, 0],
|
||||||
|
[-17.8, 10, -6.3, 28.8, 17.3, 51.4, 42.7, 56.6, -9.8, 0, 90],
|
||||||
|
[-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0],
|
||||||
|
]
|
||||||
|
|
||||||
|
export const initialExpressions: Expression[] = calibrated.map(
|
||||||
|
(
|
||||||
|
[
|
||||||
|
headX,
|
||||||
|
headY,
|
||||||
|
headZ,
|
||||||
|
widthLeft,
|
||||||
|
widthRight,
|
||||||
|
heightLeft,
|
||||||
|
heightRight,
|
||||||
|
spacing,
|
||||||
|
latitude,
|
||||||
|
leftAngle,
|
||||||
|
rightAngle,
|
||||||
|
],
|
||||||
|
index
|
||||||
|
) => ({
|
||||||
|
id: `expression-${String(index).padStart(2, '0')}`,
|
||||||
|
headX,
|
||||||
|
headY,
|
||||||
|
headZ,
|
||||||
|
widthLeft,
|
||||||
|
widthRight,
|
||||||
|
heightLeft,
|
||||||
|
heightRight,
|
||||||
|
spacing,
|
||||||
|
positionXLeft: 0,
|
||||||
|
positionXRight: 0,
|
||||||
|
positionYLeft: latitude,
|
||||||
|
positionYRight: latitude,
|
||||||
|
leftAngle,
|
||||||
|
rightAngle,
|
||||||
|
perspective: 1,
|
||||||
|
eyeMotion: 'none',
|
||||||
|
bodyMotion: 'none',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultExpression: Expression = {
|
||||||
|
id: 'expression-neutral',
|
||||||
|
headX: 0,
|
||||||
|
headY: 0,
|
||||||
|
headZ: 0,
|
||||||
|
widthLeft: 20,
|
||||||
|
widthRight: 20,
|
||||||
|
heightLeft: 50,
|
||||||
|
heightRight: 50,
|
||||||
|
spacing: 35,
|
||||||
|
positionXLeft: 0,
|
||||||
|
positionXRight: 0,
|
||||||
|
positionYLeft: -7,
|
||||||
|
positionYRight: -7,
|
||||||
|
leftAngle: 0,
|
||||||
|
rightAngle: 0,
|
||||||
|
perspective: 1,
|
||||||
|
eyeMotion: 'none',
|
||||||
|
bodyMotion: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stateGroups = {
|
||||||
|
'Cycle de vie': ['sleeping', 'waking', 'idle', 'listening', 'thinking', 'searching', 'working'],
|
||||||
|
Réactions: [
|
||||||
|
'excited',
|
||||||
|
'surprised',
|
||||||
|
'suspicious',
|
||||||
|
'angry',
|
||||||
|
'drowsy',
|
||||||
|
'happy',
|
||||||
|
'curious',
|
||||||
|
'confused',
|
||||||
|
'bored',
|
||||||
|
'proud',
|
||||||
|
'shy',
|
||||||
|
'sad',
|
||||||
|
'laughing',
|
||||||
|
'scared',
|
||||||
|
'playful',
|
||||||
|
'celebrate',
|
||||||
|
],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const statePools: Record<string, number[]> = {
|
||||||
|
sleeping: [13, 22, 4],
|
||||||
|
waking: [13],
|
||||||
|
idle: [0, 8],
|
||||||
|
listening: [10, 1, 19],
|
||||||
|
thinking: [8, 16, 14, 17, 5],
|
||||||
|
searching: [15, 9, 3, 20, 12, 18],
|
||||||
|
working: [7, 16, 11, 10],
|
||||||
|
excited: [2, 17, 21, 3, 11],
|
||||||
|
surprised: [3, 21],
|
||||||
|
suspicious: [14, 5, 23],
|
||||||
|
angry: [7, 16],
|
||||||
|
drowsy: [4, 22, 13],
|
||||||
|
happy: [2, 11, 17, 19],
|
||||||
|
curious: [3, 21, 0, 15],
|
||||||
|
confused: [14, 5, 8],
|
||||||
|
bored: [4, 22, 0],
|
||||||
|
proud: [15, 8, 2],
|
||||||
|
shy: [0, 24, 13],
|
||||||
|
sad: [4, 13, 22],
|
||||||
|
laughing: [2, 11, 17],
|
||||||
|
scared: [3, 21],
|
||||||
|
playful: [2, 17, 11, 8],
|
||||||
|
celebrate: [2, 8, 17],
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatePlaybackConfig = {
|
||||||
|
expressionIntervalMs: number
|
||||||
|
blink: {
|
||||||
|
initialDelayMs: number
|
||||||
|
minIntervalMs: number
|
||||||
|
maxIntervalMs: number
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blinkProfiles = {
|
||||||
|
natural: {
|
||||||
|
initialDelayMs: 2600,
|
||||||
|
minIntervalMs: 3400,
|
||||||
|
maxIntervalMs: 6200,
|
||||||
|
durationMs: 280,
|
||||||
|
},
|
||||||
|
calm: {
|
||||||
|
initialDelayMs: 4800,
|
||||||
|
minIntervalMs: 6500,
|
||||||
|
maxIntervalMs: 9500,
|
||||||
|
durationMs: 420,
|
||||||
|
},
|
||||||
|
attentive: {
|
||||||
|
initialDelayMs: 3200,
|
||||||
|
minIntervalMs: 4800,
|
||||||
|
maxIntervalMs: 7200,
|
||||||
|
durationMs: 240,
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
initialDelayMs: 2100,
|
||||||
|
minIntervalMs: 2800,
|
||||||
|
maxIntervalMs: 5000,
|
||||||
|
durationMs: 260,
|
||||||
|
},
|
||||||
|
reactive: {
|
||||||
|
initialDelayMs: 1200,
|
||||||
|
minIntervalMs: 1800,
|
||||||
|
maxIntervalMs: 3600,
|
||||||
|
durationMs: 220,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const calmStates = new Set(['sleeping', 'drowsy', 'bored', 'sad'])
|
||||||
|
const attentiveStates = new Set(['listening'])
|
||||||
|
const reactiveStates = new Set([
|
||||||
|
'waking',
|
||||||
|
'excited',
|
||||||
|
'surprised',
|
||||||
|
'laughing',
|
||||||
|
'scared',
|
||||||
|
'celebrate',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const getStatePlaybackConfig = (name: string): StatePlaybackConfig => {
|
||||||
|
const blink =
|
||||||
|
name === 'idle'
|
||||||
|
? blinkProfiles.natural
|
||||||
|
: calmStates.has(name)
|
||||||
|
? blinkProfiles.calm
|
||||||
|
: attentiveStates.has(name)
|
||||||
|
? blinkProfiles.attentive
|
||||||
|
: reactiveStates.has(name)
|
||||||
|
? blinkProfiles.reactive
|
||||||
|
: blinkProfiles.active
|
||||||
|
return {
|
||||||
|
expressionIntervalMs: name === 'idle' ? 5200 : calmStates.has(name) ? 3600 : 2300,
|
||||||
|
blink: { ...blink },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stateNotes: Record<string, string> = {
|
||||||
|
sleeping: 'Yeux presque fermés, respiration lente et expression de sommeil.',
|
||||||
|
waking: 'Animation courte de réveil avant retour vers une expression neutre.',
|
||||||
|
idle: 'Micro-mouvements lents, expressions 00 et 08, clignement rare.',
|
||||||
|
listening: 'Expressions 10, 01 et 19, regard stable et clignement attentif.',
|
||||||
|
thinking: 'Regard haut et latéral, expressions asymétriques et changements fréquents.',
|
||||||
|
searching: 'Balayage rapide et changements très fréquents.',
|
||||||
|
working: 'Rythme régulier et expressions concentrées.',
|
||||||
|
excited: 'Grandes expressions et transitions rapides.',
|
||||||
|
curious: 'Inclinaisons et forte asymétrie.',
|
||||||
|
}
|
||||||
351
src/proceduralBrowserRuntime.ts
Normal file
351
src/proceduralBrowserRuntime.ts
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
export const proceduralBrowserRuntime = `
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const avatarInstanceId = () => typeof globalThis.crypto?.randomUUID === 'function'
|
||||||
|
? globalThis.crypto.randomUUID()
|
||||||
|
: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||||
|
const clamp01 = value => Math.max(0, Math.min(1, value));
|
||||||
|
const easeProgress = (progress, transition) => transition === 'smooth'
|
||||||
|
? progress * progress * (3 - 2 * progress)
|
||||||
|
: transition === 'snappy'
|
||||||
|
? 1 - (1 - progress) ** 3
|
||||||
|
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
|
||||||
|
const nearestAngle = (target, current) => {
|
||||||
|
let resolved = target;
|
||||||
|
while (resolved - current > 180) resolved -= 360;
|
||||||
|
while (resolved - current < -180) resolved += 360;
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
const resolvedTargetExpression = (target, current) => ({
|
||||||
|
...target,
|
||||||
|
headX: nearestAngle(target.headX, current.headX),
|
||||||
|
headY: nearestAngle(target.headY, current.headY),
|
||||||
|
headZ: nearestAngle(target.headZ, current.headZ),
|
||||||
|
leftAngle: nearestAngle(target.leftAngle, current.leftAngle),
|
||||||
|
rightAngle: nearestAngle(target.rightAngle, current.rightAngle),
|
||||||
|
});
|
||||||
|
const colorChannels = color => {
|
||||||
|
const value = color.replace('#', '');
|
||||||
|
const hex = value.length === 3 ? value.split('').map(channel => channel + channel).join('') : value;
|
||||||
|
const numeric = Number.parseInt(hex, 16);
|
||||||
|
return [(numeric >> 16) & 255, (numeric >> 8) & 255, numeric & 255];
|
||||||
|
};
|
||||||
|
const interpolateColor = (from, to, progress) => {
|
||||||
|
const left = colorChannels(from);
|
||||||
|
const right = colorChannels(to);
|
||||||
|
const value = left.map((channel, index) => Math.round(channel + (right[index] - channel) * progress));
|
||||||
|
return '#' + value.map(channel => channel.toString(16).padStart(2, '0')).join('');
|
||||||
|
};
|
||||||
|
const resolveColors = expression => ({
|
||||||
|
body: expression.bodyColor || DATA.avatar.colors.body,
|
||||||
|
eyes: expression.eyeColor || DATA.avatar.colors.eyes,
|
||||||
|
});
|
||||||
|
const svgElement = name => document.createElementNS(SVG_NS, name);
|
||||||
|
|
||||||
|
function mountAvatar(target, options = {}) {
|
||||||
|
const host = typeof target === 'string' ? document.querySelector(target) : target;
|
||||||
|
if (!host) throw new Error('Avatar target was not found.');
|
||||||
|
const animationNames = Object.keys(DATA.animations);
|
||||||
|
if (!animationNames.length) throw new Error('The avatar export contains no animations.');
|
||||||
|
const instanceId = avatarInstanceId();
|
||||||
|
const clipId = 'avatar-procedural-clip-' + instanceId;
|
||||||
|
const svg = svgElement('svg');
|
||||||
|
svg.setAttribute('viewBox', '-150 -150 300 300');
|
||||||
|
svg.setAttribute('role', 'img');
|
||||||
|
svg.setAttribute('aria-label', DATA.avatar.name);
|
||||||
|
svg.style.width = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
|
||||||
|
svg.style.height = typeof options.size === 'number' ? options.size + 'px' : options.size || '100%';
|
||||||
|
svg.style.display = 'block';
|
||||||
|
svg.style.overflow = 'visible';
|
||||||
|
const defs = svgElement('defs');
|
||||||
|
const clipPath = svgElement('clipPath');
|
||||||
|
const clipHead = svgElement('path');
|
||||||
|
clipPath.id = clipId;
|
||||||
|
clipPath.append(clipHead);
|
||||||
|
defs.append(clipPath);
|
||||||
|
svg.append(defs);
|
||||||
|
const motionLayer = svgElement('g');
|
||||||
|
const backLayer = svgElement('g');
|
||||||
|
const head = svgElement('path');
|
||||||
|
const eyesLayer = svgElement('g');
|
||||||
|
const leftEye = svgElement('path');
|
||||||
|
const rightEye = svgElement('path');
|
||||||
|
const frontLayer = svgElement('g');
|
||||||
|
eyesLayer.setAttribute('clip-path', 'url(#' + clipId + ')');
|
||||||
|
eyesLayer.append(leftEye, rightEye);
|
||||||
|
motionLayer.append(backLayer, head, eyesLayer, frontLayer);
|
||||||
|
svg.append(motionLayer);
|
||||||
|
host.replaceChildren(svg);
|
||||||
|
|
||||||
|
const ensurePaths = (group, paths, fill) => {
|
||||||
|
while (group.children.length < paths.length) group.append(svgElement('path'));
|
||||||
|
while (group.children.length > paths.length) group.lastElementChild.remove();
|
||||||
|
paths.forEach((path, index) => {
|
||||||
|
group.children[index].setAttribute('d', path);
|
||||||
|
group.children[index].setAttribute('fill', fill);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
|
||||||
|
const initialStep = DATA.animations[currentAnimation].steps[0];
|
||||||
|
const initialExpression = DATA.expressions[initialStep.expressionId];
|
||||||
|
let currentPose = AvatarProceduralEngine.poseFromExpression(initialExpression);
|
||||||
|
let currentColors = resolveColors(initialExpression);
|
||||||
|
let blinkAmount = 1;
|
||||||
|
let transitionState = null;
|
||||||
|
let blinkState = null;
|
||||||
|
let frameRequest = null;
|
||||||
|
let stepTimer = null;
|
||||||
|
let blinkTimer = null;
|
||||||
|
let blinkDueAt = null;
|
||||||
|
let stepIndex = 0;
|
||||||
|
let direction = 1;
|
||||||
|
let playing = false;
|
||||||
|
let paused = false;
|
||||||
|
let pausedRemainingMs = 0;
|
||||||
|
let pausedTransition = null;
|
||||||
|
let pausedBlink = null;
|
||||||
|
let pausedBlinkDelay = 0;
|
||||||
|
let stepDueAt = null;
|
||||||
|
let ambientStartedAt = performance.now();
|
||||||
|
let ambientSignature = initialExpression.eyeMotion + ':' + initialExpression.bodyMotion;
|
||||||
|
let lastAmbientFrame = 0;
|
||||||
|
|
||||||
|
const applyMotion = expression => {
|
||||||
|
const signature = expression.eyeMotion + ':' + expression.bodyMotion;
|
||||||
|
if (signature !== ambientSignature) {
|
||||||
|
ambientSignature = signature;
|
||||||
|
ambientStartedAt = performance.now();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const render = (time = performance.now()) => {
|
||||||
|
const elapsed = time - ambientStartedAt;
|
||||||
|
const expression = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)
|
||||||
|
? AvatarProceduralEngine.applyAmbientMotion(currentPose.expression, elapsed)
|
||||||
|
: currentPose.expression;
|
||||||
|
const renderedPose = AvatarProceduralEngine.poseFromExpression(expression);
|
||||||
|
const geometry = AvatarProceduralEngine.renderAvatar(renderedPose, DATA.avatar.surface, blinkAmount, {
|
||||||
|
includeWire: false,
|
||||||
|
bodyNodes: DATA.avatar.bodyNodes,
|
||||||
|
});
|
||||||
|
const offset = AvatarProceduralEngine.ambientBodyOffset(currentPose.expression, elapsed);
|
||||||
|
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
|
||||||
|
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
|
||||||
|
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
|
||||||
|
head.setAttribute('d', geometry.headPath);
|
||||||
|
head.setAttribute('fill', currentColors.body);
|
||||||
|
clipHead.setAttribute('d', geometry.headPath);
|
||||||
|
leftEye.setAttribute('d', geometry.leftPath);
|
||||||
|
rightEye.setAttribute('d', geometry.rightPath);
|
||||||
|
leftEye.setAttribute('fill', currentColors.eyes);
|
||||||
|
rightEye.setAttribute('fill', currentColors.eyes);
|
||||||
|
leftEye.style.display = geometry.leftVisible ? '' : 'none';
|
||||||
|
rightEye.style.display = geometry.rightVisible ? '' : 'none';
|
||||||
|
};
|
||||||
|
const tick = time => {
|
||||||
|
frameRequest = null;
|
||||||
|
if (transitionState) {
|
||||||
|
const linear = clamp01((time - transitionState.startedAt) / transitionState.durationMs);
|
||||||
|
const eased = easeProgress(linear, transitionState.transition);
|
||||||
|
const expression = { ...transitionState.fromPose.expression };
|
||||||
|
AvatarProceduralEngine.expressionFields.forEach(field => {
|
||||||
|
expression[field] = transitionState.fromPose.expression[field] +
|
||||||
|
(transitionState.toPose.expression[field] - transitionState.fromPose.expression[field]) * eased;
|
||||||
|
});
|
||||||
|
expression.eyeMotion = transitionState.toPose.expression.eyeMotion;
|
||||||
|
expression.bodyMotion = transitionState.toPose.expression.bodyMotion;
|
||||||
|
currentPose = AvatarProceduralEngine.poseFromExpression(expression);
|
||||||
|
currentColors = {
|
||||||
|
body: interpolateColor(transitionState.fromColors.body, transitionState.toColors.body, clamp01(eased)),
|
||||||
|
eyes: interpolateColor(transitionState.fromColors.eyes, transitionState.toColors.eyes, clamp01(eased)),
|
||||||
|
};
|
||||||
|
if (linear >= 1) {
|
||||||
|
currentPose = transitionState.toPose;
|
||||||
|
currentColors = transitionState.toColors;
|
||||||
|
transitionState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (blinkState) {
|
||||||
|
const progress = clamp01((time - blinkState.startedAt) / blinkState.durationMs);
|
||||||
|
if (progress <= 0.42) {
|
||||||
|
const closeProgress = progress / 0.42;
|
||||||
|
blinkAmount = 1 - closeProgress * closeProgress;
|
||||||
|
} else {
|
||||||
|
const openProgress = (progress - 0.42) / 0.58;
|
||||||
|
blinkAmount = 1 - (1 - openProgress) ** 2;
|
||||||
|
}
|
||||||
|
if (progress >= 1) {
|
||||||
|
blinkAmount = 1;
|
||||||
|
blinkState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ambientActive = AvatarProceduralEngine.hasAmbientMotion(currentPose.expression);
|
||||||
|
if (transitionState || blinkState || !ambientActive || time - lastAmbientFrame >= 1000 / 30) {
|
||||||
|
render(time);
|
||||||
|
if (ambientActive) lastAmbientFrame = time;
|
||||||
|
}
|
||||||
|
if (transitionState || blinkState || ambientActive) frameRequest = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
const requestTick = () => {
|
||||||
|
if (frameRequest === null) frameRequest = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
const animateTo = (expressionId, durationMs, transition) => {
|
||||||
|
const target = DATA.expressions[expressionId];
|
||||||
|
if (!target) return;
|
||||||
|
applyMotion(target);
|
||||||
|
const resolved = resolvedTargetExpression(target, currentPose.expression);
|
||||||
|
const targetPose = AvatarProceduralEngine.poseFromExpression(resolved);
|
||||||
|
const targetColors = resolveColors(target);
|
||||||
|
if (durationMs <= 0) {
|
||||||
|
transitionState = null;
|
||||||
|
currentPose = targetPose;
|
||||||
|
currentColors = targetColors;
|
||||||
|
render();
|
||||||
|
if (AvatarProceduralEngine.hasAmbientMotion(currentPose.expression)) requestTick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transitionState = {
|
||||||
|
fromPose: currentPose,
|
||||||
|
toPose: targetPose,
|
||||||
|
fromColors: currentColors,
|
||||||
|
toColors: targetColors,
|
||||||
|
startedAt: performance.now(),
|
||||||
|
durationMs,
|
||||||
|
transition,
|
||||||
|
expressionId,
|
||||||
|
};
|
||||||
|
requestTick();
|
||||||
|
};
|
||||||
|
const clearSchedule = () => {
|
||||||
|
if (stepTimer !== null) clearTimeout(stepTimer);
|
||||||
|
if (blinkTimer !== null) clearTimeout(blinkTimer);
|
||||||
|
stepTimer = null;
|
||||||
|
blinkTimer = null;
|
||||||
|
blinkDueAt = null;
|
||||||
|
stepDueAt = null;
|
||||||
|
};
|
||||||
|
const scheduleBlink = (animation, delay) => {
|
||||||
|
if (!animation.blink.enabled) return;
|
||||||
|
blinkDueAt = performance.now() + delay;
|
||||||
|
blinkTimer = setTimeout(() => {
|
||||||
|
blinkDueAt = null;
|
||||||
|
blinkState = { startedAt: performance.now(), durationMs: animation.blink.durationMs };
|
||||||
|
requestTick();
|
||||||
|
const range = animation.blink.maxIntervalMs - animation.blink.minIntervalMs;
|
||||||
|
scheduleBlink(animation, animation.blink.durationMs + animation.blink.minIntervalMs + Math.random() * range);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
const advance = animation => {
|
||||||
|
const last = animation.steps.length - 1;
|
||||||
|
const playbackMode = options.loop === true ? 'loop' : options.loop === false ? 'once' : animation.playbackMode;
|
||||||
|
if (playbackMode === 'once' && stepIndex >= last) {
|
||||||
|
playing = false;
|
||||||
|
options.onAnimationEnd?.(currentAnimation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (playbackMode === 'pingPong' && last > 0) {
|
||||||
|
if (stepIndex >= last) direction = -1;
|
||||||
|
else if (stepIndex <= 0) direction = 1;
|
||||||
|
stepIndex += direction;
|
||||||
|
} else stepIndex = (stepIndex + 1) % (last + 1);
|
||||||
|
runStep(animation);
|
||||||
|
};
|
||||||
|
const runStep = animation => {
|
||||||
|
if (!playing || !animation.steps.length) return;
|
||||||
|
const step = animation.steps[stepIndex];
|
||||||
|
animateTo(step.expressionId, step.transitionMs, step.transition);
|
||||||
|
const duration = step.transitionMs + step.holdMs;
|
||||||
|
stepDueAt = performance.now() + duration;
|
||||||
|
stepTimer = setTimeout(() => advance(animation), duration);
|
||||||
|
};
|
||||||
|
const api = {
|
||||||
|
element: svg,
|
||||||
|
get animation() { return currentAnimation; },
|
||||||
|
get playing() { return playing; },
|
||||||
|
play(animationName) {
|
||||||
|
animationName = animationName || currentAnimation;
|
||||||
|
if (!DATA.animations[animationName]) throw new Error('Unknown animation: ' + animationName);
|
||||||
|
clearSchedule();
|
||||||
|
if (animationName === currentAnimation && paused) {
|
||||||
|
paused = false;
|
||||||
|
playing = true;
|
||||||
|
if (pausedTransition) animateTo(pausedTransition.expressionId, pausedTransition.durationMs, pausedTransition.transition);
|
||||||
|
if (pausedBlink) {
|
||||||
|
blinkState = {
|
||||||
|
startedAt: performance.now() - pausedBlink.progress * pausedBlink.durationMs,
|
||||||
|
durationMs: pausedBlink.durationMs,
|
||||||
|
};
|
||||||
|
requestTick();
|
||||||
|
}
|
||||||
|
stepDueAt = performance.now() + pausedRemainingMs;
|
||||||
|
stepTimer = setTimeout(() => advance(DATA.animations[currentAnimation]), pausedRemainingMs);
|
||||||
|
scheduleBlink(
|
||||||
|
DATA.animations[currentAnimation],
|
||||||
|
pausedBlinkDelay || DATA.animations[currentAnimation].blink.minIntervalMs
|
||||||
|
);
|
||||||
|
pausedTransition = null;
|
||||||
|
pausedBlink = null;
|
||||||
|
pausedBlinkDelay = 0;
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
currentAnimation = animationName;
|
||||||
|
stepIndex = 0;
|
||||||
|
direction = 1;
|
||||||
|
paused = false;
|
||||||
|
playing = true;
|
||||||
|
runStep(DATA.animations[currentAnimation]);
|
||||||
|
scheduleBlink(DATA.animations[currentAnimation], DATA.animations[currentAnimation].blink.initialDelayMs);
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
const now = performance.now();
|
||||||
|
if (playing && stepDueAt !== null) pausedRemainingMs = Math.max(stepDueAt - now, 0);
|
||||||
|
pausedBlinkDelay = blinkDueAt === null ? 0 : Math.max(blinkDueAt - now, 0);
|
||||||
|
if (transitionState) {
|
||||||
|
const elapsed = now - transitionState.startedAt;
|
||||||
|
pausedTransition = {
|
||||||
|
expressionId: transitionState.expressionId,
|
||||||
|
durationMs: Math.max(transitionState.durationMs - elapsed, 0),
|
||||||
|
transition: transitionState.transition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (blinkState) {
|
||||||
|
pausedBlink = {
|
||||||
|
progress: clamp01((now - blinkState.startedAt) / blinkState.durationMs),
|
||||||
|
durationMs: blinkState.durationMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
clearSchedule();
|
||||||
|
transitionState = null;
|
||||||
|
blinkState = null;
|
||||||
|
paused = true;
|
||||||
|
playing = false;
|
||||||
|
render();
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
clearSchedule();
|
||||||
|
transitionState = null;
|
||||||
|
blinkState = null;
|
||||||
|
blinkAmount = 1;
|
||||||
|
pausedBlink = null;
|
||||||
|
pausedBlinkDelay = 0;
|
||||||
|
paused = false;
|
||||||
|
playing = false;
|
||||||
|
stepIndex = 0;
|
||||||
|
direction = 1;
|
||||||
|
const first = DATA.animations[currentAnimation].steps[0];
|
||||||
|
if (first) animateTo(first.expressionId, 0, first.transition);
|
||||||
|
return api;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
clearSchedule();
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest);
|
||||||
|
svg.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
applyMotion(initialExpression);
|
||||||
|
render();
|
||||||
|
if (AvatarProceduralEngine.hasAmbientMotion(initialExpression)) requestTick();
|
||||||
|
if (options.autoplay !== false) api.play(currentAnimation);
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
`
|
||||||
43
src/renderedRotationGizmo.ts
Normal file
43
src/renderedRotationGizmo.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { motionValue, type MotionValue } from 'motion'
|
||||||
|
|
||||||
|
import { poseFromExpression, rotationRing, type Expression, type Point3 } from './geometry'
|
||||||
|
|
||||||
|
export type RenderedRotationGizmo = {
|
||||||
|
xPath: MotionValue<string>
|
||||||
|
yPath: MotionValue<string>
|
||||||
|
zPath: MotionValue<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotationRingPath = (points: Point3[]) =>
|
||||||
|
`M${points[0][0]} ${points[0][1]}${points
|
||||||
|
.slice(1)
|
||||||
|
.map(point => `L${point[0]} ${point[1]}`)
|
||||||
|
.join('')}Z`
|
||||||
|
|
||||||
|
const rotationPaths = (expression: Expression) => {
|
||||||
|
const pose = poseFromExpression(expression)
|
||||||
|
return {
|
||||||
|
x: rotationRingPath(rotationRing(pose, 'x')),
|
||||||
|
y: rotationRingPath(rotationRing(pose, 'y')),
|
||||||
|
z: rotationRingPath(rotationRing(pose, 'z')),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRenderedRotationGizmo = (expression: Expression): RenderedRotationGizmo => {
|
||||||
|
const paths = rotationPaths(expression)
|
||||||
|
return {
|
||||||
|
xPath: motionValue(paths.x),
|
||||||
|
yPath: motionValue(paths.y),
|
||||||
|
zPath: motionValue(paths.z),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paintRenderedRotationGizmo = (
|
||||||
|
gizmo: RenderedRotationGizmo,
|
||||||
|
expression: Expression
|
||||||
|
) => {
|
||||||
|
const paths = rotationPaths(expression)
|
||||||
|
gizmo.xPath.jump(paths.x)
|
||||||
|
gizmo.yPath.jump(paths.y)
|
||||||
|
gizmo.zPath.jump(paths.z)
|
||||||
|
}
|
||||||
82
src/renderedScene.ts
Normal file
82
src/renderedScene.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { motionValue, type MotionValue } from 'motion'
|
||||||
|
|
||||||
|
import type { AvatarColors } from './avatars'
|
||||||
|
import { MAX_BODY_NODES } from './body'
|
||||||
|
import type { AvatarGeometry } from './geometry'
|
||||||
|
|
||||||
|
export type RenderedScene = {
|
||||||
|
headPath: MotionValue<string>
|
||||||
|
backPaths: MotionValue<string>[]
|
||||||
|
frontPaths: MotionValue<string>[]
|
||||||
|
backNodeIds: { current: (string | null)[] }
|
||||||
|
frontNodeIds: { current: (string | null)[] }
|
||||||
|
leftPath: MotionValue<string>
|
||||||
|
rightPath: MotionValue<string>
|
||||||
|
leftOpacity: MotionValue<number>
|
||||||
|
rightOpacity: MotionValue<number>
|
||||||
|
offsetX: MotionValue<number>
|
||||||
|
offsetY: MotionValue<number>
|
||||||
|
wirePaths: MotionValue<string>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderedColors = {
|
||||||
|
body: MotionValue<string>
|
||||||
|
eyes: MotionValue<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyPathSlots = MAX_BODY_NODES + 2
|
||||||
|
|
||||||
|
export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({
|
||||||
|
headPath: motionValue(geometry.headPath),
|
||||||
|
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
|
motionValue(geometry.backPaths[index] ?? '')
|
||||||
|
),
|
||||||
|
frontPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
|
motionValue(geometry.frontPaths[index] ?? '')
|
||||||
|
),
|
||||||
|
backNodeIds: { current: geometry.backNodeIds },
|
||||||
|
frontNodeIds: { current: geometry.frontNodeIds },
|
||||||
|
leftPath: motionValue(geometry.leftPath),
|
||||||
|
rightPath: motionValue(geometry.rightPath),
|
||||||
|
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
||||||
|
rightOpacity: motionValue(geometry.rightVisible ? 1 : 0),
|
||||||
|
offsetX: motionValue(0),
|
||||||
|
offsetY: motionValue(0),
|
||||||
|
wirePaths: geometry.wirePaths.map(path => motionValue(path)),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createRenderedColors = (colors: AvatarColors): RenderedColors => ({
|
||||||
|
body: motionValue(colors.body),
|
||||||
|
eyes: motionValue(colors.eyes),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const paintRenderedColors = (rendered: RenderedColors, colors: AvatarColors) => {
|
||||||
|
rendered.body.set(colors.body)
|
||||||
|
rendered.eyes.set(colors.eyes)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y: number }) => {
|
||||||
|
scene.offsetX.set(offset.x)
|
||||||
|
scene.offsetY.set(offset.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
|
||||||
|
scene.headPath.set(geometry.headPath)
|
||||||
|
scene.backNodeIds.current = geometry.backNodeIds
|
||||||
|
scene.frontNodeIds.current = geometry.frontNodeIds
|
||||||
|
scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
|
||||||
|
scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
|
||||||
|
scene.leftPath.set(geometry.leftPath)
|
||||||
|
scene.rightPath.set(geometry.rightPath)
|
||||||
|
scene.leftOpacity.set(geometry.leftVisible ? 1 : 0)
|
||||||
|
scene.rightOpacity.set(geometry.rightVisible ? 1 : 0)
|
||||||
|
scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findBodyNodePath = (scene: RenderedScene, selectedBodyNodeId: 'primary' | string) => {
|
||||||
|
if (selectedBodyNodeId === 'primary') return scene.headPath
|
||||||
|
const backIndex = scene.backNodeIds.current.indexOf(selectedBodyNodeId)
|
||||||
|
if (backIndex >= 0) return scene.backPaths[backIndex]
|
||||||
|
const frontIndex = scene.frontNodeIds.current.indexOf(selectedBodyNodeId)
|
||||||
|
return frontIndex >= 0 ? scene.frontPaths[frontIndex] : null
|
||||||
|
}
|
||||||
288
src/sequences.ts
Normal file
288
src/sequences.ts
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
import {
|
||||||
|
getStatePlaybackConfig,
|
||||||
|
initialExpressions,
|
||||||
|
stateGroups,
|
||||||
|
stateNotes,
|
||||||
|
statePools,
|
||||||
|
} from './presets'
|
||||||
|
import type { Expression } from './geometry'
|
||||||
|
|
||||||
|
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
||||||
|
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
||||||
|
|
||||||
|
export type SequenceStep = {
|
||||||
|
id: string
|
||||||
|
expressionId: string
|
||||||
|
holdMs: number
|
||||||
|
transitionMs: number
|
||||||
|
transition: SequenceTransition
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlinkSettings = {
|
||||||
|
enabled: boolean
|
||||||
|
initialDelayMs: number
|
||||||
|
minIntervalMs: number
|
||||||
|
maxIntervalMs: number
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarSequence = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
group: string
|
||||||
|
description: string
|
||||||
|
builtIn: boolean
|
||||||
|
playbackMode: SequencePlaybackMode
|
||||||
|
steps: SequenceStep[]
|
||||||
|
blink: BlinkSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SequenceCursor = {
|
||||||
|
index: number
|
||||||
|
direction: 1 | -1
|
||||||
|
complete: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong']
|
||||||
|
const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy']
|
||||||
|
const builtInSequenceIds = new Set<string>(Object.values(stateGroups).flat())
|
||||||
|
|
||||||
|
const finite = (value: unknown, fallback: number, min: number, max: number) =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? Math.min(Math.max(value, min), max)
|
||||||
|
: fallback
|
||||||
|
|
||||||
|
const createId = (prefix: string) =>
|
||||||
|
`${prefix}-${typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`}`
|
||||||
|
|
||||||
|
const cloneSequence = (sequence: AvatarSequence): AvatarSequence => ({
|
||||||
|
...sequence,
|
||||||
|
steps: sequence.steps.map(step => ({ ...step })),
|
||||||
|
blink: { ...sequence.blink },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createInitialSequences = (): AvatarSequence[] =>
|
||||||
|
Object.entries(stateGroups).flatMap(([group, stateIds]) =>
|
||||||
|
stateIds.map(id => {
|
||||||
|
const playback = getStatePlaybackConfig(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
group,
|
||||||
|
description:
|
||||||
|
stateNotes[id] ?? 'Cette animation enchaîne un pool de presets et des clignements.',
|
||||||
|
builtIn: true,
|
||||||
|
playbackMode: 'loop',
|
||||||
|
steps: (statePools[id] ?? [0]).map((expressionIndex, index) => ({
|
||||||
|
id: `${id}-step-${index}`,
|
||||||
|
expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id,
|
||||||
|
holdMs: playback.expressionIntervalMs,
|
||||||
|
transitionMs: 500,
|
||||||
|
transition: 'smooth',
|
||||||
|
})),
|
||||||
|
blink: { enabled: true, ...playback.blink },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const parseStep = (value: unknown, fallback: SequenceStep): SequenceStep => {
|
||||||
|
const candidate = value as Partial<SequenceStep> | null
|
||||||
|
return {
|
||||||
|
id: typeof candidate?.id === 'string' ? candidate.id : createId('step'),
|
||||||
|
expressionId:
|
||||||
|
typeof candidate?.expressionId === 'string' && candidate.expressionId
|
||||||
|
? candidate.expressionId
|
||||||
|
: fallback.expressionId,
|
||||||
|
holdMs: finite(candidate?.holdMs, fallback.holdMs, 100, 60000),
|
||||||
|
transitionMs: finite(candidate?.transitionMs, fallback.transitionMs, 0, 5000),
|
||||||
|
transition: transitions.includes(candidate?.transition as SequenceTransition)
|
||||||
|
? (candidate?.transition as SequenceTransition)
|
||||||
|
: fallback.transition,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence => {
|
||||||
|
const candidate = value as Partial<AvatarSequence> | null
|
||||||
|
const fallbackStep = fallback.steps[0] ?? {
|
||||||
|
id: 'fallback-step',
|
||||||
|
expressionId: initialExpressions[0].id,
|
||||||
|
holdMs: 3000,
|
||||||
|
transitionMs: 500,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
}
|
||||||
|
const steps = Array.isArray(candidate?.steps)
|
||||||
|
? candidate.steps.map(step => parseStep(step, fallbackStep))
|
||||||
|
: fallback.steps.map(step => ({ ...step }))
|
||||||
|
const storedBlink = candidate?.blink as Partial<BlinkSettings> | undefined
|
||||||
|
const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
|
||||||
|
return {
|
||||||
|
id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
|
||||||
|
name:
|
||||||
|
typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
|
||||||
|
group:
|
||||||
|
typeof candidate?.group === 'string' && candidate.group.trim()
|
||||||
|
? candidate.group
|
||||||
|
: fallback.group,
|
||||||
|
description:
|
||||||
|
typeof candidate?.description === 'string' ? candidate.description : fallback.description,
|
||||||
|
builtIn:
|
||||||
|
typeof candidate?.builtIn === 'boolean'
|
||||||
|
? candidate.builtIn
|
||||||
|
: typeof candidate?.id === 'string' && builtInSequenceIds.has(candidate.id),
|
||||||
|
playbackMode: playbackModes.includes(candidate?.playbackMode as SequencePlaybackMode)
|
||||||
|
? (candidate?.playbackMode as SequencePlaybackMode)
|
||||||
|
: fallback.playbackMode,
|
||||||
|
steps,
|
||||||
|
blink: {
|
||||||
|
enabled:
|
||||||
|
typeof storedBlink?.enabled === 'boolean' ? storedBlink.enabled : fallback.blink.enabled,
|
||||||
|
initialDelayMs: finite(storedBlink?.initialDelayMs, fallback.blink.initialDelayMs, 0, 60000),
|
||||||
|
minIntervalMs,
|
||||||
|
maxIntervalMs: Math.max(
|
||||||
|
minIntervalMs,
|
||||||
|
finite(storedBlink?.maxIntervalMs, fallback.blink.maxIntervalMs, 100, 60000)
|
||||||
|
),
|
||||||
|
durationMs: finite(storedBlink?.durationMs, fallback.blink.durationMs, 40, 3000),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseSequences = (value: unknown): AvatarSequence[] => {
|
||||||
|
const defaults = createInitialSequences()
|
||||||
|
if (!Array.isArray(value)) return defaults
|
||||||
|
if (!value.length) return []
|
||||||
|
const fallback = defaults.find(sequence => sequence.id === 'idle') ?? defaults[0]
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return value
|
||||||
|
.map(sequence => parseSequence(sequence, fallback))
|
||||||
|
.filter(sequence => {
|
||||||
|
if (seen.has(sequence.id)) return false
|
||||||
|
seen.add(sequence.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeSequencesForExpressions = (
|
||||||
|
sequences: AvatarSequence[],
|
||||||
|
expressions: Expression[]
|
||||||
|
) => {
|
||||||
|
const availableIds = new Set(expressions.map(expression => expression.id))
|
||||||
|
const fallbackId = expressions[0]?.id ?? initialExpressions[0].id
|
||||||
|
return sequences.map(sequence => ({
|
||||||
|
...sequence,
|
||||||
|
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
|
||||||
|
step => ({
|
||||||
|
...step,
|
||||||
|
expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
|
||||||
|
expressions.findIndex(expression => expression.id === expressionId)
|
||||||
|
|
||||||
|
export const readSequenceClock = () => performance.now()
|
||||||
|
|
||||||
|
export const groupSequences = (sequences: AvatarSequence[]) => {
|
||||||
|
const groups: { name: string; sequences: AvatarSequence[] }[] = []
|
||||||
|
const indexes = new Map<string, number>()
|
||||||
|
sequences.forEach(sequence => {
|
||||||
|
const existingIndex = indexes.get(sequence.group)
|
||||||
|
if (existingIndex !== undefined) {
|
||||||
|
groups[existingIndex].sequences.push(sequence)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indexes.set(sequence.group, groups.length)
|
||||||
|
groups.push({ name: sequence.group, sequences: [sequence] })
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSequence = (expressionId = initialExpressions[0].id): AvatarSequence => ({
|
||||||
|
id: createId('sequence'),
|
||||||
|
name: 'Untitled animation',
|
||||||
|
group: 'Custom',
|
||||||
|
description: '',
|
||||||
|
builtIn: false,
|
||||||
|
playbackMode: 'loop',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: createId('step'),
|
||||||
|
expressionId,
|
||||||
|
holdMs: 3000,
|
||||||
|
transitionMs: 500,
|
||||||
|
transition: 'smooth',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blink: {
|
||||||
|
enabled: true,
|
||||||
|
initialDelayMs: 2600,
|
||||||
|
minIntervalMs: 3400,
|
||||||
|
maxIntervalMs: 6200,
|
||||||
|
durationMs: 280,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
|
||||||
|
...cloneSequence(source),
|
||||||
|
id: createId('sequence'),
|
||||||
|
name: `${source.name} copy`,
|
||||||
|
builtIn: false,
|
||||||
|
steps: source.steps.map(step => ({ ...step, id: createId('step') })),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createSequenceStep = (expressionId: string): SequenceStep => ({
|
||||||
|
id: createId('step'),
|
||||||
|
expressionId,
|
||||||
|
holdMs: 3000,
|
||||||
|
transitionMs: 500,
|
||||||
|
transition: 'smooth',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const advanceSequenceCursor = (
|
||||||
|
sequence: AvatarSequence,
|
||||||
|
index: number,
|
||||||
|
direction: 1 | -1
|
||||||
|
): SequenceCursor => {
|
||||||
|
const lastIndex = sequence.steps.length - 1
|
||||||
|
if (lastIndex < 0) return { index: 0, direction: 1, complete: true }
|
||||||
|
if (sequence.playbackMode === 'once') {
|
||||||
|
if (index >= lastIndex) return { index: lastIndex, direction: 1, complete: true }
|
||||||
|
return { index: index + 1, direction: 1, complete: false }
|
||||||
|
}
|
||||||
|
if (sequence.playbackMode === 'pingPong' && lastIndex > 0) {
|
||||||
|
if (direction === 1 && index >= lastIndex)
|
||||||
|
return { index: lastIndex - 1, direction: -1, complete: false }
|
||||||
|
if (direction === -1 && index <= 0) return { index: 1, direction: 1, complete: false }
|
||||||
|
return { index: index + direction, direction, complete: false }
|
||||||
|
}
|
||||||
|
return { index: index >= lastIndex ? 0 : index + 1, direction: 1, complete: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const remapSequencesAfterExpressionDelete = (
|
||||||
|
sequences: AvatarSequence[],
|
||||||
|
deletedExpressionId: string,
|
||||||
|
fallbackExpressionId: string
|
||||||
|
) =>
|
||||||
|
sequences.map(sequence => {
|
||||||
|
const steps = sequence.steps.filter(step => step.expressionId !== deletedExpressionId)
|
||||||
|
return {
|
||||||
|
...sequence,
|
||||||
|
steps: steps.length ? steps : [createSequenceStep(fallbackExpressionId)],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getSequenceSpring = (
|
||||||
|
transition: SequenceTransition,
|
||||||
|
durationMs: number,
|
||||||
|
baseSpeed: number
|
||||||
|
) => {
|
||||||
|
const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3)
|
||||||
|
const styleFactor = transition === 'smooth' ? 0.72 : transition === 'snappy' ? 1.45 : 1
|
||||||
|
const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5)
|
||||||
|
return {
|
||||||
|
stiffness: 70 + speed * 24,
|
||||||
|
damping: (17 + speed * 1.7) * (transition === 'smooth' ? 1.18 : 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/snapshotExporter.ts
Normal file
84
src/snapshotExporter.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import type { AvatarColors } from './avatars'
|
||||||
|
import type { RenderedScene } from './renderedScene'
|
||||||
|
|
||||||
|
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
||||||
|
|
||||||
|
export type SnapshotOptions = {
|
||||||
|
background: SnapshotBackground
|
||||||
|
colorFrom: string
|
||||||
|
colorTo: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeXml = (value: string) =>
|
||||||
|
value.replace(/[&<>"]/g, character => {
|
||||||
|
const entities: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
}
|
||||||
|
return entities[character]
|
||||||
|
})
|
||||||
|
|
||||||
|
const path = (value: string, fill: string, opacity = 1) =>
|
||||||
|
value ? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"/>` : ''
|
||||||
|
|
||||||
|
const backgroundMarkup = (options: SnapshotOptions) => {
|
||||||
|
if (options.background === 'transparent') return ''
|
||||||
|
const fill =
|
||||||
|
options.background === 'solid'
|
||||||
|
? options.colorFrom
|
||||||
|
: options.background === 'linear'
|
||||||
|
? 'url(#snapshot-linear)'
|
||||||
|
: 'url(#snapshot-radial)'
|
||||||
|
return `<rect x="-150" y="-150" width="300" height="300" fill="${fill}"/>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradientMarkup = (options: SnapshotOptions) => {
|
||||||
|
if (options.background === 'linear') {
|
||||||
|
return `<linearGradient id="snapshot-linear" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${options.colorFrom}"/><stop offset="1" stop-color="${options.colorTo}"/></linearGradient>`
|
||||||
|
}
|
||||||
|
if (options.background === 'radial') {
|
||||||
|
return `<radialGradient id="snapshot-radial" cx="50%" cy="42%" r="70%"><stop offset="0" stop-color="${options.colorFrom}"/><stop offset="1" stop-color="${options.colorTo}"/></radialGradient>`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeAvatarSnapshot = (
|
||||||
|
name: string,
|
||||||
|
scene: RenderedScene,
|
||||||
|
colors: AvatarColors,
|
||||||
|
options: SnapshotOptions
|
||||||
|
) => {
|
||||||
|
const headPath = scene.headPath.get()
|
||||||
|
const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean)
|
||||||
|
const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean)
|
||||||
|
const offsetX = scene.offsetX.get()
|
||||||
|
const offsetY = scene.offsetY.get()
|
||||||
|
const body = [
|
||||||
|
...backPaths.map(value => path(value, colors.body)),
|
||||||
|
path(headPath, colors.body),
|
||||||
|
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}</g>`,
|
||||||
|
...frontPaths.map(value => path(value, colors.body)),
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${options.size}" height="${options.size}" role="img" aria-label="${escapeXml(name)}">
|
||||||
|
<defs>${gradientMarkup(options)}<clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
|
||||||
|
${backgroundMarkup(options)}
|
||||||
|
<g transform="translate(${offsetX} ${offsetY})">${body}</g>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapshotFileName = (name: string, extension: 'svg' | 'png' = 'svg') => {
|
||||||
|
const slug =
|
||||||
|
name
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'avatar'
|
||||||
|
return `${slug}-snapshot.${extension}`
|
||||||
|
}
|
||||||
2
src/standaloneEngine.entry.ts
Normal file
2
src/standaloneEngine.entry.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { expressionFields, poseFromExpression, renderAvatar } from './geometry'
|
||||||
|
export { ambientBodyOffset, applyAmbientMotion, hasAmbientMotion } from './ambientMotion'
|
||||||
2
src/standaloneEngine.generated.ts
Normal file
2
src/standaloneEngine.generated.ts
Normal file
File diff suppressed because one or more lines are too long
61
src/storedZip.ts
Normal file
61
src/storedZip.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
type StoredZipFile = { name: string; content: string }
|
||||||
|
|
||||||
|
const crc32 = (bytes: Uint8Array) => {
|
||||||
|
let crc = 0xffffffff
|
||||||
|
for (const byte of bytes) {
|
||||||
|
crc ^= byte
|
||||||
|
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createStoredZip = (files: StoredZipFile[]) => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
const centralEntries: Uint8Array[] = []
|
||||||
|
let offset = 0
|
||||||
|
const header = (size: number) => {
|
||||||
|
const bytes = new Uint8Array(size)
|
||||||
|
return { bytes, view: new DataView(bytes.buffer) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const name = encoder.encode(file.name)
|
||||||
|
const content = encoder.encode(file.content)
|
||||||
|
const checksum = crc32(content)
|
||||||
|
const local = header(30 + name.length)
|
||||||
|
local.view.setUint32(0, 0x04034b50, true)
|
||||||
|
local.view.setUint16(4, 20, true)
|
||||||
|
local.view.setUint32(14, checksum, true)
|
||||||
|
local.view.setUint32(18, content.length, true)
|
||||||
|
local.view.setUint32(22, content.length, true)
|
||||||
|
local.view.setUint16(26, name.length, true)
|
||||||
|
local.bytes.set(name, 30)
|
||||||
|
chunks.push(local.bytes, content)
|
||||||
|
|
||||||
|
const central = header(46 + name.length)
|
||||||
|
central.view.setUint32(0, 0x02014b50, true)
|
||||||
|
central.view.setUint16(4, 20, true)
|
||||||
|
central.view.setUint16(6, 20, true)
|
||||||
|
central.view.setUint32(16, checksum, true)
|
||||||
|
central.view.setUint32(20, content.length, true)
|
||||||
|
central.view.setUint32(24, content.length, true)
|
||||||
|
central.view.setUint16(28, name.length, true)
|
||||||
|
central.view.setUint32(42, offset, true)
|
||||||
|
central.bytes.set(name, 46)
|
||||||
|
centralEntries.push(central.bytes)
|
||||||
|
offset += local.bytes.length + content.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralSize = centralEntries.reduce((size, entry) => size + entry.length, 0)
|
||||||
|
const end = header(22)
|
||||||
|
end.view.setUint32(0, 0x06054b50, true)
|
||||||
|
end.view.setUint16(8, files.length, true)
|
||||||
|
end.view.setUint16(10, files.length, true)
|
||||||
|
end.view.setUint32(12, centralSize, true)
|
||||||
|
end.view.setUint32(16, offset, true)
|
||||||
|
const parts = [...chunks, ...centralEntries, end.bytes].map(
|
||||||
|
bytes => Uint8Array.from(bytes).buffer as ArrayBuffer
|
||||||
|
)
|
||||||
|
return new Blob(parts, { type: 'application/zip' })
|
||||||
|
}
|
||||||
140
src/studioDocument.ts
Normal file
140
src/studioDocument.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
parseAvatarLibrary,
|
||||||
|
parseExpressions,
|
||||||
|
type AvatarBehaviorLibrary,
|
||||||
|
type AvatarLibrary,
|
||||||
|
} from './avatars'
|
||||||
|
import type { Expression } from './geometry'
|
||||||
|
import { normalizeSequencesForExpressions, parseSequences, type AvatarSequence } from './sequences'
|
||||||
|
import defaultStudioDocument from './defaultStudioDocument.json'
|
||||||
|
|
||||||
|
export type StatePlaybackSelection = { stateId: string | null; playing: boolean }
|
||||||
|
|
||||||
|
export type StudioDocument = {
|
||||||
|
version: 2
|
||||||
|
library: AvatarLibrary
|
||||||
|
expressions: Expression[]
|
||||||
|
sequences: AvatarSequence[]
|
||||||
|
playback: StatePlaybackSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudioDocumentPatch = Partial<Omit<StudioDocument, 'version'>>
|
||||||
|
|
||||||
|
const DOCUMENT_STORAGE_KEY = 'bible-strong-avatar-studio-v2'
|
||||||
|
|
||||||
|
const defaultPlayback: StatePlaybackSelection = { stateId: 'idle', playing: true }
|
||||||
|
|
||||||
|
const parsePlayback = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: StatePlaybackSelection = defaultPlayback
|
||||||
|
): StatePlaybackSelection => {
|
||||||
|
const candidate = value as Partial<StatePlaybackSelection> | null
|
||||||
|
if (!candidate || (typeof candidate.stateId !== 'string' && candidate.stateId !== null)) {
|
||||||
|
return { ...fallback }
|
||||||
|
}
|
||||||
|
return { stateId: candidate.stateId, playing: candidate.playing === true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseStudioDocument = (value: unknown, fallback: StudioDocument): StudioDocument => {
|
||||||
|
const candidate = value as Partial<StudioDocument> | null
|
||||||
|
if (!candidate || candidate.version !== 2) return fallback
|
||||||
|
const expressions =
|
||||||
|
Array.isArray(candidate.expressions) && candidate.expressions.length
|
||||||
|
? parseExpressions(candidate.expressions)
|
||||||
|
: fallback.expressions
|
||||||
|
const sequences = Array.isArray(candidate.sequences)
|
||||||
|
? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions)
|
||||||
|
: fallback.sequences
|
||||||
|
const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences }
|
||||||
|
const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior)
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
library,
|
||||||
|
expressions,
|
||||||
|
sequences,
|
||||||
|
playback: parsePlayback(candidate.playback, fallback.playback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serializeStudioDocument = (document: StudioDocument) =>
|
||||||
|
JSON.stringify(document, null, 2)
|
||||||
|
|
||||||
|
export const parseImportedStudioDocument = (
|
||||||
|
source: string,
|
||||||
|
fallback: StudioDocument
|
||||||
|
): StudioDocument => {
|
||||||
|
let value: unknown
|
||||||
|
try {
|
||||||
|
value = JSON.parse(source)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid Avatar Studio project')
|
||||||
|
}
|
||||||
|
const candidate = value as Partial<StudioDocument> | null
|
||||||
|
if (!candidate || candidate.version !== 2) {
|
||||||
|
throw new Error('Unsupported Avatar Studio project')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!candidate.library ||
|
||||||
|
!Array.isArray(candidate.library.avatars) ||
|
||||||
|
!candidate.library.avatars.length ||
|
||||||
|
!Array.isArray(candidate.expressions) ||
|
||||||
|
!candidate.expressions.length ||
|
||||||
|
!Array.isArray(candidate.sequences)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid Avatar Studio project')
|
||||||
|
}
|
||||||
|
return parseStudioDocument(candidate, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBundledStudioDocument = () => {
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(defaultStudioDocument)) as StudioDocument
|
||||||
|
return parseStudioDocument(snapshot, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadStudioDocument = (
|
||||||
|
storage: Pick<Storage, 'getItem'> = window.localStorage
|
||||||
|
): StudioDocument => {
|
||||||
|
const fallback = createBundledStudioDocument()
|
||||||
|
try {
|
||||||
|
return parseStudioDocument(
|
||||||
|
JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'),
|
||||||
|
fallback
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const persistStudioDocument = (document: StudioDocument) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DOCUMENT_STORAGE_KEY, JSON.stringify(document))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// The in-memory document remains authoritative when storage is unavailable.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createStudioDocumentStore = (
|
||||||
|
initial: StudioDocument,
|
||||||
|
persist: (document: StudioDocument) => void = persistStudioDocument
|
||||||
|
) => {
|
||||||
|
let current = initial
|
||||||
|
return {
|
||||||
|
update: (patch: StudioDocumentPatch) => {
|
||||||
|
const expressions = patch.expressions ?? current.expressions
|
||||||
|
current = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
version: 2,
|
||||||
|
expressions,
|
||||||
|
sequences: normalizeSequencesForExpressions(
|
||||||
|
patch.sequences ?? current.sequences,
|
||||||
|
expressions
|
||||||
|
),
|
||||||
|
}
|
||||||
|
persist(current)
|
||||||
|
return current
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
2177
src/styles.css
Normal file
2177
src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
665
src/surfaces.ts
Normal file
665
src/surfaces.ts
Normal file
@ -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<SurfaceType, SurfaceConfig> = {
|
||||||
|
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<SurfaceType, string> = {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import babel from '@rolldown/plugin-babel'
|
||||||
|
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const root = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root,
|
||||||
|
plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.join(root, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: path.join(root, 'dist'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/__tests__/**/*-test.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user