feat: expand avatar skins and expressions

This commit is contained in:
Наер Михаил Александрович 2026-08-25 19:11:07 +03:00
parent be77e778d1
commit 9cca5fa079
39 changed files with 3357 additions and 287 deletions

28
THIRD_PARTY_NOTICES.md Normal file
View File

@ -0,0 +1,28 @@
# Third-party notices
## OneWorks Avatar
The `oneworks-*` body shapes and bundled character silhouettes are adapted from
[OneWorks Avatar](https://github.com/oneworks-ai/avatar).
Copyright (c) 2026-present One Works contributors
Licensed under the MIT License:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -13,8 +13,8 @@
"nodes": []
},
"colors": {
"body": "#5b7fe5",
"eyes": "#111316"
"body": "#7c89ff",
"eyes": "#50d7fe"
},
"expressions": {
"neutral": {
@ -27,6 +27,7 @@
"left": {
"width": 20,
"height": 50,
"curvature": 0,
"x": 0,
"y": -7,
"angle": 0
@ -34,6 +35,7 @@
"right": {
"width": 20,
"height": 50,
"curvature": 0,
"x": 0,
"y": -7,
"angle": 0
@ -835,6 +837,37 @@
"colors": {
"body": "#adc3ff"
}
},
"joyful-arc": {
"head": {
"x": -8.75234375,
"y": -8.743359375,
"z": -10.773828125
},
"eyes": {
"left": {
"width": 54,
"height": 15,
"curvature": 1,
"x": 0,
"y": -18,
"angle": 0
},
"right": {
"width": 54,
"height": 15,
"curvature": 1,
"x": 0,
"y": -18,
"angle": 0
},
"spacing": 69.276171875
},
"perspective": 1,
"motion": {
"eyes": "none",
"body": "none"
}
}
},
"expressionOrder": [
@ -865,7 +898,8 @@
"suspicious-right",
"shy-downward",
"angry-brows",
"uneasy-left"
"uneasy-left",
"joyful-arc"
],
"animations": {
"sleeping": {
@ -1258,7 +1292,7 @@
"transition": "smooth"
},
{
"expression": "angry-left",
"expression": "angry-brows",
"holdMs": 2300,
"transitionMs": 500,
"transition": "smooth"
@ -1353,6 +1387,35 @@
"group": "Réactions"
}
},
"joyful": {
"playbackMode": "loop",
"steps": [
{
"expression": "joyful-wide",
"holdMs": 2300,
"transitionMs": 700,
"transition": "gentleSpring"
},
{
"expression": "joyful-arc",
"holdMs": 2300,
"transitionMs": 700,
"transition": "gentleSpring"
}
],
"blink": {
"enabled": false,
"initialDelayMs": 2100,
"minIntervalMs": 2800,
"maxIntervalMs": 5000,
"durationMs": 260
},
"metadata": {
"label": "joyful",
"description": "Morphing fluide entre les expressions 11 et 27.",
"group": "Réactions"
}
},
"curious": {
"playbackMode": "loop",
"steps": [
@ -1608,7 +1671,7 @@
"transition": "smooth"
},
{
"expression": "surprised-wide-left",
"expression": "uneasy-left",
"holdMs": 2300,
"transitionMs": 500,
"transition": "smooth"
@ -1718,6 +1781,7 @@
"angry",
"drowsy",
"happy",
"joyful",
"curious",
"confused",
"surprised",

View File

@ -3,6 +3,7 @@ import {
bodyFromDefinition,
createAvatarPlaybackState,
expressionFromDefinition,
interpolatePose,
parseAvatarDefinition,
playAvatarAnimation,
pauseAvatarPlayback,
@ -13,7 +14,11 @@ import {
resumeAvatarPlayback,
resolveAnimation,
sampleAvatarFrame,
surfaceFrontSampleAt,
surfacePresets,
surfaceSampleAt,
type AvatarDefinition,
type SurfaceType,
} from '../index'
const expression = {
@ -66,7 +71,64 @@ const definition: AvatarDefinition = {
animationOrder: ['idle'],
}
const pathPoints = (value: string): [number, number][] =>
Array.from(value.matchAll(/[ML](-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g), match => [
Number(match[1]),
Number(match[2]),
])
const pathSelfIntersects = (value: string) => {
const points = pathPoints(value)
const orientation = (
[ax, ay]: [number, number],
[bx, by]: [number, number],
[cx, cy]: [number, number]
) => (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
for (let first = 0; first < points.length; first += 1) {
const firstNext = (first + 1) % points.length
for (let second = first + 2; second < points.length; second += 1) {
const secondNext = (second + 1) % points.length
if (secondNext === first) continue
const abC = orientation(points[first], points[firstNext], points[second])
const abD = orientation(points[first], points[firstNext], points[secondNext])
const cdA = orientation(points[second], points[secondNext], points[first])
const cdB = orientation(points[second], points[secondNext], points[firstNext])
if (abC * abD < -0.000001 && cdA * cdB < -0.000001) return true
}
}
return false
}
describe('@bible-strong/avatar-core', () => {
it('renders every adapted OneWorks surface with finite body and eye geometry', () => {
const types: SurfaceType[] = [
'ellipse',
'square',
'rounded',
'teardrop',
'trapezoid',
'frustum',
'half-cone',
]
const neutral = poseFromExpression(expressionFromDefinition('neutral', expression))
types.forEach(type => {
const surface = surfacePresets[type]
const sample = surfaceSampleAt(surface, 0.4, -0.25)
const face = surfaceFrontSampleAt(surface, 18, -12)
const geometry = renderAvatar(neutral, surface)
expect(
[...sample.point, ...sample.normal, ...face.point, ...face.normal].every(Number.isFinite)
).toBe(true)
expect(geometry.headPath).toContain('M')
expect(geometry.leftPath).toContain('M')
expect(geometry.rightPath).toContain('M')
expect(JSON.stringify(geometry)).not.toContain('NaN')
})
})
it('loads a JSON definition and resolves an explicit semantic animation', () => {
const parsed = parseAvatarDefinition(JSON.stringify(definition))
expect(parsed.ok).toBe(true)
@ -241,6 +303,74 @@ describe('@bible-strong/avatar-core', () => {
expect(scene.colors).toEqual(definition.colors)
})
it('interpolates joyful eye curvature with the standard expression fields', () => {
const regular = poseFromExpression(expressionFromDefinition('neutral', expression))
const joyful = poseFromExpression(
expressionFromDefinition('joyful', {
...expression,
eyes: {
...expression.eyes,
left: { ...expression.eyes.left, curvature: 1 },
right: { ...expression.eyes.right, curvature: 1 },
},
})
)
const halfway = interpolatePose(regular, joyful, 0.5)
expect(halfway.expression.curvatureLeft).toBe(0.5)
expect(halfway.expression.curvatureRight).toBe(0.5)
})
it('keeps curved eyes from folding over themselves while morphing into tall eyes', () => {
const joyful = poseFromExpression(
expressionFromDefinition('joyful', {
...expression,
eyes: {
left: { width: 54, height: 15, x: 0, y: -18, angle: 0, curvature: 1 },
right: { width: 54, height: 15, x: 0, y: -18, angle: 0, curvature: 1 },
spacing: 69.276,
},
})
)
const tallTargets = [
{ left: [51.4, 50.1, 0], right: [50.5, 49.4, 0], spacing: 69 },
{ left: [28.8, 51.4, 0], right: [17.3, 42.7, 90], spacing: 56.6 },
] as const
const surface = bodyFromDefinition(definition.body).primary
tallTargets.forEach(target => {
const targetPose = poseFromExpression(
expressionFromDefinition('target', {
...expression,
eyes: {
left: {
width: target.left[0],
height: target.left[1],
x: 0,
y: 0,
angle: target.left[2],
},
right: {
width: target.right[0],
height: target.right[1],
x: 0,
y: 0,
angle: target.right[2],
},
spacing: target.spacing,
},
})
)
for (let step = 0; step <= 100; step += 1) {
const geometry = renderAvatar(interpolatePose(joyful, targetPose, step / 100), surface, 1)
expect(pathSelfIntersects(geometry.leftPath)).toBe(false)
expect(pathSelfIntersects(geometry.rightPath)).toBe(false)
}
})
})
it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
const narrow: AvatarDefinition = {
...definition,

View File

@ -71,11 +71,19 @@
"mickey",
"cursor",
"cube",
"ellipse",
"square",
"rounded",
"capsule",
"cylinder",
"cone",
"teardrop",
"trapezoid",
"frustum",
"half-cone",
"diamond",
"ghost"
"ghost",
"apple-bite"
]
},
"width": { "$ref": "#/$defs/dimension" },
@ -84,7 +92,9 @@
"roundness": { "$ref": "#/$defs/roundness" },
"morphRoundness": { "$ref": "#/$defs/roundness" },
"tipRoundness": { "$ref": "#/$defs/roundness" },
"baseRoundness": { "$ref": "#/$defs/roundness" }
"baseRoundness": { "$ref": "#/$defs/roundness" },
"topScale": { "type": "number", "minimum": 0.05, "maximum": 2 },
"cutAngle": { "type": "number", "minimum": -360, "maximum": 360 }
}
},
"nodeSurface": {
@ -93,7 +103,23 @@
{
"type": "object",
"properties": {
"type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] }
"type": {
"enum": [
"sphere",
"cube",
"ellipse",
"square",
"rounded",
"capsule",
"cylinder",
"cone",
"teardrop",
"trapezoid",
"frustum",
"half-cone",
"diamond"
]
}
}
}
]
@ -133,6 +159,7 @@
"required": ["surface", "position", "rotation"],
"properties": {
"layer": { "enum": ["auto", "front", "back"] },
"color": { "$ref": "#/$defs/hexColor" },
"surface": { "$ref": "#/$defs/nodeSurface" },
"position": { "$ref": "#/$defs/position" },
"rotation": { "$ref": "#/$defs/rotation" }
@ -159,7 +186,8 @@
"height": { "$ref": "#/$defs/boundedNumber" },
"x": { "$ref": "#/$defs/boundedNumber" },
"y": { "$ref": "#/$defs/boundedNumber" },
"angle": { "$ref": "#/$defs/boundedNumber" }
"angle": { "$ref": "#/$defs/boundedNumber" },
"curvature": { "type": "number", "minimum": 0, "maximum": 1 }
}
},
"expression": {
@ -238,7 +266,7 @@
"expression": { "$ref": "#/$defs/semanticKey" },
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
"transition": { "enum": ["spring", "smooth", "snappy"] }
"transition": { "enum": ["spring", "gentleSpring", "smooth", "snappy"] }
}
}
},

View File

@ -41,14 +41,17 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
topScale?: number
cutAngle?: number
}
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost'>
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost' | 'apple-bite'>
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
export type AvatarBodyNodeDefinition = {
layer?: 'auto' | 'front' | 'back'
color?: HexColor
surface: BodyNodeSurfaceDefinition
position: [number, number, number]
rotation: [number, number, number]
@ -62,8 +65,15 @@ export type AvatarBodyDefinition = {
export type AvatarExpressionDefinition = {
head: { x: number; y: number; z: number }
eyes: {
left: { width: number; height: number; x: number; y: number; angle: number }
right: { width: number; height: number; x: number; y: number; angle: number }
left: { width: number; height: number; x: number; y: number; angle: number; curvature?: number }
right: {
width: number
height: number
x: number
y: number
angle: number
curvature?: number
}
spacing: number
}
perspective: number
@ -78,7 +88,7 @@ export type AvatarAnimationStepDefinition = {
expression: ExpressionKey
holdMs: number
transitionMs: number
transition: 'spring' | 'smooth' | 'snappy'
transition: 'spring' | 'gentleSpring' | 'smooth' | 'snappy'
}
export type AvatarAnimationDefinition = {

View File

@ -5,6 +5,8 @@ export type BodyVector = readonly [number, number, number]
export type BodyNode = {
id: string
name: string
layer?: 'auto' | 'front' | 'back'
color?: string
surface: SurfaceConfig
position: BodyVector
rotation: BodyVector
@ -18,9 +20,16 @@ export type AvatarBody = {
export const bodyPrimitiveTypes = [
'sphere',
'cube',
'ellipse',
'square',
'rounded',
'capsule',
'cylinder',
'cone',
'teardrop',
'trapezoid',
'frustum',
'half-cone',
'diamond',
] as const
@ -31,6 +40,8 @@ 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)
const hexColor = (value: unknown): value is string =>
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
if (!value || typeof value !== 'object') return { ...fallback }
@ -46,6 +57,8 @@ export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): Sur
return { ...fallback }
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
return { ...fallback }
if (candidate.topScale !== undefined && !finite(candidate.topScale)) return { ...fallback }
if (candidate.cutAngle !== undefined && !finite(candidate.cutAngle)) return { ...fallback }
return { ...preset, ...candidate, type }
}
@ -80,6 +93,11 @@ export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig):
.slice(0, MAX_BODY_NODES)
.map(node => ({
...node,
layer:
node.layer === 'front' || node.layer === 'back' || node.layer === 'auto'
? node.layer
: undefined,
color: hexColor(node.color) ? node.color : undefined,
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
}))
: []

View File

@ -22,6 +22,8 @@ export type Expression = {
widthRight: number
heightLeft: number
heightRight: number
curvatureLeft: number
curvatureRight: number
spacing: number
positionXLeft: number
positionXRight: number
@ -98,6 +100,8 @@ export const expressionFields: ExpressionNumericField[] = [
'widthRight',
'heightLeft',
'heightRight',
'curvatureLeft',
'curvatureRight',
'spacing',
'positionXLeft',
'positionXRight',
@ -519,6 +523,8 @@ const surfaceCacheKey = (surface: SurfaceConfig) =>
surface.morphRoundness,
surface.tipRoundness,
surface.baseRoundness,
surface.topScale,
surface.cutAngle,
])
const cacheSurfaceValue = <Value>(cache: Map<string, Value>, key: string, value: Value) => {
@ -569,12 +575,33 @@ const eyePoints = (
const width = expression[`width${suffix}`]
const restingHeight = expression[`height${suffix}`]
const height = 5 + (restingHeight - 5) * blink
const curvature = expression[`curvature${suffix}`]
const cornerRadius = Math.min(width / 2, height / 2)
const segmentHalf = Math.max(width / 2 - cornerRadius, 0)
const desiredCurveDepth = Math.max(9, width * 0.38) * curvature * blink
// Keep the offset capsule below the cusp limit of the curved centerline.
// Without this bound, morphing a curved horizontal eye into a tall eye makes
// segmentHalf approach zero while its slope tends to infinity, folding the
// outline over itself for a few frames.
const maximumSafeCurveDepth =
segmentHalf > 0 ? (0.5 * segmentHalf ** 2) / (2 * Math.max(cornerRadius, Number.EPSILON)) : 0
const curveDepth = Math.min(desiredCurveDepth, maximumSafeCurveDepth)
const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
const centerY = expression[`positionY${suffix}`] + offset.y
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
return roundedRectangle(width, height).map(([localX, localY]) => {
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
const curveX = segmentHalf ? clamp(localX, -segmentHalf, segmentHalf) : 0
const normalizedX = segmentHalf ? curveX / segmentHalf : 0
const curveY = -curveDepth * (1 - normalizedX ** 2)
const slope = segmentHalf ? (2 * curveDepth * curveX) / segmentHalf ** 2 : 0
const tangentLength = Math.hypot(1, slope)
const tangentX = 1 / tangentLength
const tangentY = slope / tangentLength
const offsetAlongCurve = localX - curveX
const curvedX = curveX + tangentX * offsetAlongCurve - tangentY * localY
const curvedY = curveY + tangentY * offsetAlongCurve + tangentX * localY
const rotatedX = curvedX * Math.cos(angle) - curvedY * Math.sin(angle)
const rotatedY = curvedX * Math.sin(angle) + curvedY * Math.cos(angle)
return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
})
}
@ -1198,6 +1225,126 @@ const projectedGhostPath = (pose: AvatarPose, surface: SurfaceConfig) => {
].join('')
}
const projectedAppleBitePath = (pose: AvatarPose, surface: SurfaceConfig) => {
const halfWidth = surface.width / 2
const halfHeight = surface.height / 2
const point = (x: number, y: number) => projectLocalPoint(pose, [x, y, 0])
const format = ([x, y]: Point3) => `${x.toFixed(2)} ${y.toFixed(2)}`
const move = (x: number, y: number) => `M${format(point(x, y))}`
const line = (x: number, y: number) => `L${format(point(x, y))}`
const cubicTo = (
firstX: number,
firstY: number,
secondX: number,
secondY: number,
endX: number,
endY: number
) =>
`C${format(point(firstX, firstY))} ${format(point(secondX, secondY))} ${format(point(endX, endY))}`
return [
move(-halfWidth * 0.46, -halfHeight),
cubicTo(
-halfWidth * 0.08,
-halfHeight,
halfWidth * 0.15,
-halfHeight * 0.78,
halfWidth * 0.12,
-halfHeight * 0.54
),
cubicTo(
halfWidth * 0.4,
-halfHeight * 0.72,
halfWidth * 0.74,
-halfHeight * 0.75,
halfWidth,
-halfHeight * 0.56
),
cubicTo(
halfWidth * 0.8,
-halfHeight * 0.43,
halfWidth * 0.68,
-halfHeight * 0.24,
halfWidth * 0.68,
-halfHeight * 0.03
),
cubicTo(
halfWidth * 0.68,
halfHeight * 0.2,
halfWidth * 0.8,
halfHeight * 0.4,
halfWidth,
halfHeight * 0.5
),
cubicTo(
halfWidth * 0.82,
halfHeight * 0.83,
halfWidth * 0.54,
halfHeight,
halfWidth * 0.26,
halfHeight
),
cubicTo(
halfWidth * 0.08,
halfHeight,
-halfWidth * 0.02,
halfHeight * 0.91,
-halfWidth * 0.16,
halfHeight * 0.91
),
cubicTo(
-halfWidth * 0.31,
halfHeight * 0.91,
-halfWidth * 0.4,
halfHeight,
-halfWidth * 0.57,
halfHeight
),
cubicTo(
-halfWidth * 0.89,
halfHeight,
-halfWidth,
halfHeight * 0.67,
-halfWidth,
halfHeight * 0.3
),
cubicTo(
-halfWidth,
-halfHeight * 0.16,
-halfWidth * 0.8,
-halfHeight * 0.58,
-halfWidth * 0.49,
-halfHeight * 0.7
),
cubicTo(
-halfWidth * 0.28,
-halfHeight * 0.78,
-halfWidth * 0.03,
-halfHeight * 0.69,
halfWidth * 0.08,
-halfHeight * 0.55
),
line(-halfWidth * 0.1, -halfHeight * 0.66),
cubicTo(
-halfWidth * 0.12,
-halfHeight * 0.82,
-halfWidth * 0.22,
-halfHeight * 0.88,
-halfWidth * 0.35,
-halfHeight * 0.84
),
cubicTo(
-halfWidth * 0.41,
-halfHeight * 0.84,
-halfWidth * 0.45,
-halfHeight * 0.93,
-halfWidth * 0.46,
-halfHeight
),
'Z',
].join('')
}
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'sphere' || surface.type === 'mickey') {
const exactPath = projectedEllipsoidPath(pose, surface)
@ -1215,6 +1362,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'cube') return projectedCubePath(pose, surface)
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
if (surface.type === 'apple-bite') return projectedAppleBitePath(pose, surface)
const key = surfaceCacheKey(surface)
let localSamples = headSamplesCache.get(key)
@ -1306,8 +1454,8 @@ const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
depth,
front:
node.layer === 'front' ||
(node.layer !== 'back' &&
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
(node.layer !== 'back' &&
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
}
})
.sort((left, right) => left.depth - right.depth)

View File

@ -224,6 +224,10 @@ const easing = (
const progress = Math.max(0, Math.min(1, value))
if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
if (transition === 'snappy') return 1 - (1 - progress) ** 3
if (transition === 'gentleSpring') {
const end = 1 - Math.exp(-9) * Math.cos(5)
return (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) / end
}
const end = 1 - Math.exp(-6) * Math.cos(8)
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
}

View File

@ -20,6 +20,8 @@ export const expressionFromDefinition = (
widthRight: expression.eyes.right.width,
heightLeft: expression.eyes.left.height,
heightRight: expression.eyes.right.height,
curvatureLeft: expression.eyes.left.curvature ?? 0,
curvatureRight: expression.eyes.right.curvature ?? 0,
spacing: expression.eyes.spacing,
positionXLeft: expression.eyes.left.x,
positionXRight: expression.eyes.right.x,
@ -39,6 +41,8 @@ export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
nodes: body.nodes.map((node, index) => ({
id: `runtime-node-${index}`,
name: `Runtime node ${index + 1}`,
...(node.layer ? { layer: node.layer } : {}),
...(node.color ? { color: node.color } : {}),
surface: { ...node.surface },
position: [...node.position],
rotation: [...node.rotation],

View File

@ -1,7 +1,23 @@
import type { Point3 } from './geometry'
export type SurfaceType =
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' | 'ghost'
| 'sphere'
| 'mickey'
| 'cursor'
| 'cube'
| 'ellipse'
| 'square'
| 'rounded'
| 'capsule'
| 'cylinder'
| 'cone'
| 'teardrop'
| 'trapezoid'
| 'frustum'
| 'half-cone'
| 'diamond'
| 'ghost'
| 'apple-bite'
export type SurfaceConfig = {
type: SurfaceType
@ -12,6 +28,8 @@ export type SurfaceConfig = {
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
topScale?: number
cutAngle?: number
}
export type SurfaceSample = {
@ -24,6 +42,9 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
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 },
ellipse: { type: 'ellipse', width: 245, height: 189, depth: 195, roundness: 1 },
square: { type: 'square', width: 240, height: 240, depth: 196, roundness: 0.6 },
rounded: { type: 'rounded', width: 240, height: 240, depth: 211, roundness: 1 },
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
cylinder: {
type: 'cylinder',
@ -43,8 +64,33 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
tipRoundness: 0.55,
baseRoundness: 0.45,
},
teardrop: { type: 'teardrop', width: 230, height: 258, depth: 195, roundness: 0.78 },
trapezoid: {
type: 'trapezoid',
width: 250,
height: 232,
depth: 204,
roundness: 1.44,
topScale: 0.82,
},
frustum: {
type: 'frustum',
width: 250,
height: 250,
depth: 223,
roundness: 0.48,
},
'half-cone': {
type: 'half-cone',
width: 250,
height: 250,
depth: 223,
roundness: 0.48,
cutAngle: 0,
},
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
ghost: { type: 'ghost', width: 250, height: 245, depth: 190, roundness: 1 },
'apple-bite': { type: 'apple-bite', width: 240, height: 250, depth: 190, roundness: 1 },
}
export const surfaceLabels: Record<SurfaceType, string> = {
@ -52,16 +98,80 @@ export const surfaceLabels: Record<SurfaceType, string> = {
mickey: 'Mickey',
cursor: 'Curseur',
cube: 'Cube',
ellipse: 'Ellipse',
square: 'Carré',
rounded: 'Arrondi',
capsule: 'Capsule',
cylinder: 'Cylindre',
cone: 'Cône',
teardrop: 'Goutte',
trapezoid: 'Trapèze',
frustum: 'Tronc de cône',
'half-cone': 'Demi-cône',
diamond: 'Diamant',
ghost: 'Ghost',
'apple-bite': 'Apple Bite',
}
const signedPower = (value: number, exponent: number) =>
Math.sign(value) * Math.abs(value) ** exponent
const interpolate = (from: number, to: number, progress: number) => from + (to - from) * progress
const ONEWORKS_SHAPE_EXPONENTS = {
rounded: 0.5,
square: 0.3,
teardrop: 0.78,
} as const
const oneworksSuperellipsoid = (
config: SurfaceConfig,
longitude: number,
latitude: number,
exponent: number
): Point3 => {
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
return [
(config.width / 2) * latitudeFactor * signedPower(Math.sin(longitude), exponent),
(config.height / 2) * signedPower(Math.sin(latitude), exponent),
(config.depth / 2) * latitudeFactor * signedPower(Math.cos(longitude), exponent),
]
}
const oneworksTeardrop = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
const exponent = ONEWORKS_SHAPE_EXPONENTS.teardrop
const vertical = signedPower(Math.sin(latitude), exponent)
const progress = Math.max(0, Math.min(1, (vertical + 1) / 2))
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
const widthTaper = interpolate(0.46, 1.24, progress)
const depthTaper = interpolate(0.7, 1.12, progress)
return [
(config.width / 2) * widthTaper * latitudeFactor * signedPower(Math.sin(longitude), exponent),
(config.height / 2) * vertical,
(config.depth / 2) * depthTaper * latitudeFactor * signedPower(Math.cos(longitude), exponent),
]
}
const trapezoidExponent = (config: SurfaceConfig) =>
interpolate(0.34, 0.76, clampRoundness(config.roundness) / 2)
const oneworksTrapezoid = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
const exponent = trapezoidExponent(config)
const vertical = signedPower(Math.sin(latitude), exponent)
const progress = Math.max(0, Math.min(1, (vertical + 1) / 2))
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
const horizontalTaper = interpolate(config.topScale ?? 0.82, 1.08, progress)
const depthTaper = interpolate(0.92, 1.04, progress)
return [
(config.width / 2) *
horizontalTaper *
latitudeFactor *
signedPower(Math.sin(longitude), exponent),
(config.height / 2) * vertical,
(config.depth / 2) * depthTaper * latitudeFactor * signedPower(Math.cos(longitude), exponent),
]
}
const superellipsoid = (
longitude: number,
latitude: number,
@ -285,6 +395,28 @@ const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile =
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
const oneworksFrustumProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
const clampedProgress = Math.max(0, Math.min(1, progress))
const easedProgress = clampedProgress * clampedProgress * (3 - 2 * clampedProgress)
const roundedProgress = interpolate(
clampedProgress,
easedProgress,
(clampRoundness(config.roundness) / 2) * 0.55
)
return {
radiusScale: interpolate(0.46, 1, roundedProgress),
verticalProgress: clampedProgress,
}
}
const oneworksHalfConeProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
const clampedProgress = Math.max(0, Math.min(1, progress))
return {
radiusScale: clampedProgress ** interpolate(1, 0.56, clampRoundness(config.roundness) / 2),
verticalProgress: clampedProgress,
}
}
export const cursorLayout = (config: SurfaceConfig) => {
const coneHeight = config.height * 0.36
const bodyHeight = config.height - coneHeight
@ -308,6 +440,12 @@ export const surfacePointAt = (
case 'sphere':
case 'mickey':
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
case 'ellipse':
return oneworksSuperellipsoid(config, longitude, latitude, 1)
case 'square':
return oneworksSuperellipsoid(config, longitude, latitude, ONEWORKS_SHAPE_EXPONENTS.square)
case 'rounded':
return oneworksSuperellipsoid(config, longitude, latitude, ONEWORKS_SHAPE_EXPONENTS.rounded)
case 'cube':
return cube(config, longitude, latitude)
case 'cylinder': {
@ -337,8 +475,13 @@ export const surfacePointAt = (
}
case 'diamond':
return diamond(config, longitude, latitude)
case 'teardrop':
return oneworksTeardrop(config, longitude, latitude)
case 'trapezoid':
return oneworksTrapezoid(config, longitude, latitude)
case 'capsule':
case 'ghost':
case 'apple-bite':
return capsule(config, longitude, latitude)
case 'cone': {
const progress = (latitude + Math.PI / 2) / Math.PI
@ -349,6 +492,25 @@ export const surfacePointAt = (
(depth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
case 'frustum': {
const progress = (latitude + Math.PI / 2) / Math.PI
const profile = oneworksFrustumProfileAt(config, progress)
return [
(width / 2) * profile.radiusScale * Math.sin(longitude),
-height / 2 + height * profile.verticalProgress,
(depth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
case 'half-cone': {
const progress = (latitude + Math.PI / 2) / Math.PI
const profile = oneworksHalfConeProfileAt(config, progress)
const sampledLongitude = ((config.cutAngle ?? 0) * Math.PI) / 180 + longitude / 2
return [
(width / 2) * profile.radiusScale * Math.sin(sampledLongitude),
-height / 2 + height * profile.verticalProgress,
(depth / 2) * profile.radiusScale * Math.cos(sampledLongitude),
]
}
}
}
@ -435,6 +597,12 @@ const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
return normal
}
const oneworksSuperellipsoidNormal = (
config: SurfaceConfig,
point: Point3,
parameterExponent: number
) => lpNormal(config, point, 2 / parameterExponent)
const lpFrontSample = (
config: SurfaceConfig,
x: number,
@ -486,6 +654,39 @@ const ellipsoidFrontSample = (
}
}
const oneworksProfileFrontSample = (
config: SurfaceConfig,
x: number,
y: number,
profile: 'teardrop' | 'trapezoid'
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
const exponent =
profile === 'teardrop' ? ONEWORKS_SHAPE_EXPONENTS.teardrop : trapezoidExponent(config)
const vertical = Math.max(-1, Math.min(1, y / radiusY))
const sineLatitude = signedPower(vertical, 1 / exponent)
const latitude = Math.asin(Math.max(-1, Math.min(1, sineLatitude)))
const progress = Math.max(0, Math.min(1, (vertical + 1) / 2))
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
const widthTaper =
profile === 'teardrop'
? interpolate(0.46, 1.24, progress)
: interpolate(config.topScale ?? 0.82, 1.08, progress)
const depthTaper =
profile === 'teardrop' ? interpolate(0.7, 1.12, progress) : interpolate(0.92, 1.04, progress)
const sectionRadiusX = radiusX * widthTaper * latitudeFactor
const sectionRadiusZ = radiusZ * depthTaper * latitudeFactor
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
const normalizedX = sectionRadiusX > 0 ? surfaceX / sectionRadiusX : 0
const sineLongitude = signedPower(Math.max(-1, Math.min(1, normalizedX)), 1 / exponent)
const longitude = Math.asin(Math.max(-1, Math.min(1, sineLongitude)))
const z = sectionRadiusZ * signedPower(Math.max(Math.cos(longitude), 0), exponent)
const point: Point3 = [surfaceX, vertical * radiusY, z]
return { point, normal: tangentNormalAt(config, longitude, latitude) }
}
const radialProfileFrontSample = (
config: SurfaceConfig,
x: number,
@ -532,13 +733,25 @@ export const surfaceFrontSampleAt = (
switch (config.type) {
case 'sphere':
case 'mickey':
case 'ellipse':
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
case 'square':
return lpFrontSample(config, x, y, 2 / ONEWORKS_SHAPE_EXPONENTS.square, (surface, point) =>
oneworksSuperellipsoidNormal(surface, point, ONEWORKS_SHAPE_EXPONENTS.square)
)
case 'rounded':
return lpFrontSample(config, x, y, 2 / ONEWORKS_SHAPE_EXPONENTS.rounded, (surface, point) =>
oneworksSuperellipsoidNormal(surface, point, ONEWORKS_SHAPE_EXPONENTS.rounded)
)
case 'cube':
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
case 'capsule':
case 'ghost': {
case 'ghost':
case 'apple-bite': {
const capRadiusY = Math.min(radiusX, radiusY)
const straightHalf = Math.max(0, radiusY - capRadiusY)
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
@ -572,6 +785,18 @@ export const surfaceFrontSampleAt = (
case 'cone':
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
case 'frustum':
return radialProfileFrontSample(config, x, y, oneworksFrustumProfileAt, 1)
case 'half-cone':
return radialProfileFrontSample(config, x, y, oneworksHalfConeProfileAt, 1)
case 'teardrop':
return oneworksProfileFrontSample(config, x, y, 'teardrop')
case 'trapezoid':
return oneworksProfileFrontSample(config, x, y, 'trapezoid')
case 'diamond':
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
}
@ -586,7 +811,7 @@ export const surfaceNormalAt = (
// 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') {
if (config.type === 'sphere' || config.type === 'mickey' || config.type === 'ellipse') {
const halfWidth = config.width / 2 || 1
const halfHeight = config.height / 2 || 1
const halfDepth = config.depth / 2 || 1
@ -613,6 +838,14 @@ export const surfaceNormalAt = (
return cubeNormal(config, point)
}
if (config.type === 'square') {
return oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.square)
}
if (config.type === 'rounded') {
return oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.rounded)
}
return tangentNormalAt(config, longitude, latitude)
}
@ -623,7 +856,7 @@ export const surfaceSampleAt = (
): SurfaceSample => {
const point = surfacePointAt(config, longitude, latitude)
if (config.type === 'sphere' || config.type === 'mickey') {
if (config.type === 'sphere' || config.type === 'mickey' || config.type === 'ellipse') {
const halfWidth = config.width / 2 || 1
const halfHeight = config.height / 2 || 1
const halfDepth = config.depth / 2 || 1
@ -662,6 +895,20 @@ export const surfaceSampleAt = (
}
}
if (config.type === 'square') {
return {
point,
normal: oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.square),
}
}
if (config.type === 'rounded') {
return {
point,
normal: oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.rounded),
}
}
return {
point,
normal: tangentNormalAt(config, longitude, latitude),

View File

@ -34,6 +34,46 @@ describe('@bible-strong/avatar-web', () => {
expect(document.querySelector('#avatar svg')).toBeNull()
})
it('tints fixed-color body parts when an expression overrides the body color', () => {
const tintedDefinition = {
...definitionJson,
body: {
...definitionJson.body,
nodes: [
{
layer: 'back',
color: '#a95f47',
surface: {
type: 'ellipse',
width: 40,
height: 68,
depth: 36,
roundness: 1,
},
position: [-64, -80, -18],
rotation: [-4, -7, 8],
},
],
},
expressions: {
...definitionJson.expressions,
neutral: {
...definitionJson.expressions.neutral,
colors: { body: '#ba3636' },
},
},
}
const avatar = createAvatar('#avatar', {
definition: tintedDefinition,
defaultExpression: 'neutral',
})
const firstBodyPart = document.querySelector('#avatar svg > path')
expect(firstBodyPart?.getAttribute('fill')).toBe('#ba3636')
avatar.destroy()
})
it('returns typed errors for unknown targets', () => {
const avatar = createAvatar('#avatar', { definition: definitionJson })

View File

@ -145,7 +145,19 @@ export function createAvatar(
if (onError) onError(error)
else console.error(`[Avatar] ${error.message}`)
}
const paint = (scene: ReturnType<typeof renderAvatarDefinition>) => {
const colorForNodeId = (
id: string | null | undefined,
fallback: string,
bodyColorOverride: boolean
) => {
if (bodyColorOverride) return fallback
if (!id?.startsWith('runtime-node-')) return fallback
const index = Number(id.slice('runtime-node-'.length))
return definition.body.nodes[index]?.color ?? fallback
}
const paint = (scene: ReturnType<typeof renderAvatarDefinition>, bodyColorOverride = false) => {
host.style.setProperty('--bs-avatar-body-color', scene.colors.body)
host.style.setProperty('--bs-avatar-eye-color', scene.colors.eyes)
clipHeadPath.setAttribute('d', scene.geometry.headPath)
headPath.setAttribute('d', scene.geometry.headPath)
headPath.setAttribute('fill', scene.colors.body)
@ -157,11 +169,23 @@ export function createAvatar(
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
backPaths.forEach((element, index) => {
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
element.setAttribute('fill', scene.colors.body)
const color = colorForNodeId(
scene.geometry.backNodeIds[index],
scene.colors.body,
bodyColorOverride
)
element.setAttribute('fill', color)
element.style.setProperty('--bs-avatar-node-color', color)
})
frontPaths.forEach((element, index) => {
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
element.setAttribute('fill', scene.colors.body)
const color = colorForNodeId(
scene.geometry.frontNodeIds[index],
scene.colors.body,
bodyColorOverride
)
element.setAttribute('fill', color)
element.style.setProperty('--bs-avatar-node-color', color)
})
}
@ -180,7 +204,10 @@ export function createAvatar(
const renderCurrent = (now: number) => {
const environment = runtimeEnvironment()
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
paint(renderAvatarFrame(definition, playback, now, environment))
paint(
renderAvatarFrame(definition, playback, now, environment),
Boolean(definition.expressions[playback.activeExpression]?.colors?.body)
)
notifyExpression()
}
const tick = (now: number) => {
@ -260,7 +287,10 @@ export function createAvatar(
playback = createAvatarPlaybackState()
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
paint(renderAvatarDefinition(definition))
paint(
renderAvatarDefinition(definition),
Boolean(definition.expressions.neutral.colors?.body)
)
notifyExpression()
},
getState() {
@ -293,7 +323,7 @@ export function createAvatar(
renderCurrent(performance.now())
}
} else {
paint(initialScene)
paint(initialScene, Boolean(definition.expressions.neutral.colors?.body))
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
notifyExpression()
}

View File

@ -70,8 +70,43 @@ input {
min-height: 100vh;
overflow-x: clip;
}
.studio-catalog-only {
display: block;
min-height: 100vh;
}
.studio-catalog-only .inspector {
width: 100%;
min-height: 100vh;
border-left: 0;
}
.studio-catalog-only .inspector-identity {
position: sticky;
z-index: 20;
top: 0;
display: flex;
min-height: 64px;
margin: -28px -26px 24px;
padding: 0 28px;
border-bottom: 1px solid #d9dce3;
background: rgb(245 246 248 / 94%);
backdrop-filter: blur(14px);
}
.studio-catalog-only .inspector-identity .brand {
top: 0;
margin-left: 0;
color: #17191d;
}
.studio-catalog-only .inspector-identity .brand em {
color: #767d88;
}
.studio-catalog-only .inspector-identity .language-picker {
top: 0;
margin-right: 0;
}
.stage-column {
--avatar-interior-color: #000000;
position: sticky;
top: 0;
display: flex;
@ -81,7 +116,7 @@ input {
justify-content: center;
overflow: hidden;
padding: 28px;
background: #101316;
background: #000000;
color: white;
}
@ -90,14 +125,14 @@ input {
width: 70vw;
height: 70vw;
border-radius: 50%;
background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%);
background: none;
content: '';
pointer-events: none;
}
.photo-mode-active::before {
width: 82vw;
height: 82vw;
background: radial-gradient(circle, rgb(91 127 229 / 24%), transparent 66%);
background: none;
}
.photo-live-frame {
position: relative;
@ -448,6 +483,7 @@ input {
.is-pixel-rendered .avatar-head,
.is-pixel-rendered .avatar-eye {
fill: transparent;
stroke: transparent;
transition: none;
}
.is-pixel-rendered .avatar-head.cyan-outline,
@ -1177,19 +1213,35 @@ input {
}
.avatar-head,
.preview-head {
fill: var(--avatar-body-color, #5b7fe5);
fill: var(--avatar-interior-color, #101316);
stroke: var(--avatar-node-color, var(--avatar-body-color, #5b7fe5));
stroke-width: var(--avatar-outline-width, 10);
stroke-linecap: round;
stroke-linejoin: round;
}
.avatar-eye,
.preview-eye {
fill: var(--avatar-eye-color, #111316);
fill: var(--avatar-eye-color, #ffffff);
}
.avatar-head,
.avatar-eye {
transition: fill 420ms cubic-bezier(0.22, 1, 0.36, 1);
transition:
fill 420ms cubic-bezier(0.22, 1, 0.36, 1),
stroke 420ms cubic-bezier(0.22, 1, 0.36, 1),
stroke-width 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.avatar-eye {
cursor: pointer;
}
.is-filled-vector .avatar-head {
fill: var(--avatar-node-color, var(--avatar-body-color, #5b7fe5));
}
.is-filled-vector .avatar-eye {
fill: var(--avatar-eye-color, #101316);
}
.avatar-preview {
--avatar-interior-color: #ffffff;
}
.cyan-outline {
stroke: #51ddff;
stroke-width: 2.6;
@ -1935,9 +1987,18 @@ p {
font-size: 8px;
}
.avatar-shelf .avatar-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.studio-catalog-only .mode-page-transition,
.studio-catalog-only .avatar-page,
.studio-catalog-only .avatar-shelf {
width: 100%;
max-width: none;
}
.studio-catalog-only .avatar-shelf .avatar-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.avatar-shelf .avatar-card,
.avatar-shelf .avatar-add {
height: auto;
@ -1969,6 +2030,9 @@ p {
min-height: 0;
flex: 1 1 auto;
}
.avatar-preview-live {
pointer-events: none;
}
.avatar-shelf .avatar-card > span {
min-height: 12px;
padding: 2px 4px;

View File

@ -3,6 +3,7 @@ import {
createInitialSequences,
duplicateSequence,
getSequenceSpring,
scaleSpringDynamics,
normalizeSequencesForExpressions,
parseSequences,
remapSequencesAfterExpressionDelete,
@ -11,7 +12,8 @@ import { initialExpressions } from '@/features/avatar/presets'
describe('editable avatar sequences', () => {
it('creates the idle animation with editable steps and blink settings', () => {
const idle = createInitialSequences().find(sequence => sequence.id === 'idle')
const sequences = createInitialSequences()
const idle = sequences.find(sequence => sequence.id === 'idle')
expect(idle?.steps.map(step => step.expressionId)).toEqual([
initialExpressions[0].id,
@ -19,6 +21,41 @@ describe('editable avatar sequences', () => {
])
expect(idle?.steps[0].holdMs).toBe(5200)
expect(idle?.blink.durationMs).toBe(280)
expect(sequences).toHaveLength(24)
})
it('creates a saved joyful animation that morphs between expressions 11 and 27', () => {
const joyful = createInitialSequences().find(sequence => sequence.id === 'joyful')
expect(joyful?.steps).toEqual([
{
id: 'joyful-step-0',
expressionId: 'expression-11',
holdMs: 2300,
transitionMs: 700,
transition: 'gentleSpring',
},
{
id: 'joyful-step-1',
expressionId: 'expression-joyful-arc',
holdMs: 2300,
transitionMs: 700,
transition: 'gentleSpring',
},
])
expect(joyful?.playbackMode).toBe('loop')
expect(joyful?.blink.enabled).toBe(false)
})
it('uses the motion expressions as the second Angry and Scared steps', () => {
const sequences = createInitialSequences()
expect(
sequences.find(sequence => sequence.id === 'angry')?.steps.map(step => step.expressionId)
).toEqual(['expression-07', 'expression-3d2bed26-f97c-477d-922f-77600cb10e92'])
expect(
sequences.find(sequence => sequence.id === 'scared')?.steps.map(step => step.expressionId)
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
})
it('supports loop, once and ping-pong playback cursors', () => {
@ -96,6 +133,16 @@ describe('editable avatar sequences', () => {
expect(smooth.damping).toBeGreaterThan(0)
})
it('time-scales spring dynamics without changing their motion character', () => {
const spring = getSequenceSpring('smooth', 500, 7)
expect(scaleSpringDynamics(spring, 1)).toEqual(spring)
expect(scaleSpringDynamics(spring, 2)).toEqual({
stiffness: spring.stiffness * 4,
damping: spring.damping * 2,
})
})
it('clears the public semantic key when an animation is duplicated', () => {
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }

View File

@ -380,6 +380,7 @@ export function SequenceWorkspace({
value={selectedStep.transition}
items={[
{ value: 'spring', label: t('Ressort') },
{ value: 'gentleSpring', label: t('Ressort léger') },
{ value: 'smooth', label: t('Douce') },
{ value: 'snappy', label: t('Rapide') },
]}
@ -392,6 +393,7 @@ export function SequenceWorkspace({
</SelectTrigger>
<SelectContent>
<SelectItem value="spring">{t('Ressort')}</SelectItem>
<SelectItem value="gentleSpring">{t('Ressort léger')}</SelectItem>
<SelectItem value="smooth">{t('Douce')}</SelectItem>
<SelectItem value="snappy">{t('Rapide')}</SelectItem>
</SelectContent>

View File

@ -8,7 +8,7 @@ import {
import type { Expression } from '../avatar/geometry'
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
export type SequenceTransition = 'spring' | 'gentleSpring' | 'smooth' | 'snappy'
export type SequenceStep = {
id: string
@ -45,7 +45,7 @@ export type SequenceCursor = {
}
const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong']
const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy']
const transitions: SequenceTransition[] = ['spring', 'gentleSpring', 'smooth', 'snappy']
const builtInSequenceIds = new Set<string>(Object.values(stateGroups).flat())
const finite = (value: unknown, fallback: number, min: number, max: number) =>
@ -79,10 +79,10 @@ export const createInitialSequences = (): AvatarSequence[] =>
id: `${id}-step-${index}`,
expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id,
holdMs: playback.expressionIntervalMs,
transitionMs: 500,
transition: 'smooth',
transitionMs: id === 'joyful' ? 700 : 500,
transition: id === 'joyful' ? 'gentleSpring' : 'smooth',
})),
blink: { enabled: true, ...playback.blink },
blink: { enabled: id !== 'joyful', ...playback.blink },
}
})
)
@ -283,10 +283,30 @@ export const getSequenceSpring = (
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 styleFactor =
transition === 'smooth'
? 0.72
: transition === 'snappy'
? 1.45
: transition === 'gentleSpring'
? 0.9
: 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),
damping:
(17 + speed * 1.7) *
(transition === 'smooth' ? 1.18 : transition === 'gentleSpring' ? 1.2 : 1),
}
}
export const scaleSpringDynamics = (
spring: Readonly<{ stiffness: number; damping: number }>,
timeScale: number
) => {
const boundedTimeScale = Math.max(timeScale, 0.01)
return {
stiffness: spring.stiffness * boundedTimeScale ** 2,
damping: spring.damping * boundedTimeScale,
}
}

View File

@ -486,9 +486,9 @@ describe('Studio to avatar definition conversion', () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressionOrder).toHaveLength(28)
expect(result.value.expressionOrder).toHaveLength(31)
expect(result.value.expressionOrder[0]).toBe('neutral')
expect(result.value.animationOrder).toHaveLength(23)
expect(result.value.animationOrder).toHaveLength(25)
expect(result.value.animationOrder).toContain('idle')
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
'upward-side-glance',
@ -624,7 +624,7 @@ describe('Studio to avatar definition conversion', () => {
createAvatarDefinition({ avatar, behavior })
expect(JSON.stringify({ avatar, behavior })).toBe(before)
expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
expect(defaultAvatarColors).toEqual({ body: '#7c89ff', eyes: '#7c89ff' })
})
})

View File

@ -6,11 +6,13 @@ import {
createAvatar,
createUnkeyedExpressionCopy,
defaultAvatarEyes,
ensurePrimitiveBundledAvatars,
parseAvatarEyeDefaults,
parseAvatarRenderStyle,
resolveAvatarBehavior,
} from '@/features/avatar/avatars'
import { initialExpressions } from '@/features/avatar/presets'
import { surfacePresets } from '@/features/avatar/surfaces'
describe('avatar eye defaults', () => {
it('clears the public semantic key when creating custom content from a preset', () => {
@ -61,6 +63,135 @@ describe('avatar render style', () => {
).toEqual({ type: 'vector' })
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
})
it('preserves the filled treatment for imported vector skins', () => {
expect(parseAvatarRenderStyle({ type: 'vector', filled: true })).toEqual({
type: 'vector',
filled: true,
})
})
})
describe('OneWorks animal construction', () => {
const createBundledLibrary = () => {
const seed = createAvatar('Seed')
seed.id = 'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e'
return ensurePrimitiveBundledAvatars({ activeAvatarId: seed.id, avatars: [seed] })
}
it('keeps the source occlusion order for every imported animal', () => {
const library = createBundledLibrary()
const layers = (id: string) =>
library.avatars.find(avatar => avatar.id === id)?.body.nodes.map(node => node.layer)
expect(layers('oneworks-cat')).toBeUndefined()
expect(layers('oneworks-dog')).toEqual(['front', 'front'])
expect(layers('oneworks-bear')).toEqual(['back', 'back'])
expect(layers('oneworks-rabbit')).toEqual(['back', 'back'])
expect(layers('oneworks-bun')).toEqual(['front'])
})
it('keeps the unfinished piglet hidden and removes a persisted copy', () => {
const library = createBundledLibrary()
const stored = {
...library,
activeAvatarId: 'memento-piglet',
avatars: [
...library.avatars,
{
...createAvatar('Порося'),
id: 'memento-piglet',
body: {
primary: { ...surfacePresets.sphere },
nodes: [],
},
},
],
}
const restored = ensurePrimitiveBundledAvatars(stored)
expect(restored.avatars.some(avatar => avatar.id === 'memento-piglet')).toBe(false)
expect(restored.activeAvatarId).not.toBe('memento-piglet')
})
it('removes Cow Cat from a previously persisted library', () => {
const library = createBundledLibrary()
const cowCat = { ...createAvatar('Cow Cat'), id: 'oneworks-cat-cow' }
const restored = ensurePrimitiveBundledAvatars({
...library,
activeAvatarId: cowCat.id,
avatars: [...library.avatars, cowCat],
})
expect(restored.avatars.some(avatar => avatar.id === cowCat.id)).toBe(false)
expect(restored.activeAvatarId).not.toBe(cowCat.id)
})
it('repairs the previous persisted dog-ear layer and placement', () => {
const library = createBundledLibrary()
const stored = {
...library,
avatars: library.avatars.map(avatar =>
avatar.id !== 'oneworks-dog'
? avatar
: {
...avatar,
body: {
...avatar.body,
nodes: avatar.body.nodes.map(node => ({
...node,
layer: 'back' as const,
position: [node.position[0] < 0 ? -72 : 72, -52, 0] as const,
})),
},
}
),
}
const dog = ensurePrimitiveBundledAvatars(stored).avatars.find(
avatar => avatar.id === 'oneworks-dog'
)!
expect(dog.body.nodes.map(node => node.layer)).toEqual(['front', 'front'])
expect(dog.body.nodes.map(node => node.position)).toEqual([
[-82, -65, 0],
[82, -65, 0],
])
})
it('moves previously persisted bear ears farther outside the head', () => {
const library = createBundledLibrary()
const stored = {
...library,
avatars: library.avatars.map(avatar =>
avatar.id !== 'oneworks-bear'
? avatar
: {
...avatar,
body: {
...avatar.body,
nodes: avatar.body.nodes.map(node => ({
...node,
position:
node.id === 'bear-ear-left'
? ([-58, -72, -18] as const)
: ([58, -70, -20] as const),
})),
},
}
),
}
const bear = ensurePrimitiveBundledAvatars(stored).avatars.find(
avatar => avatar.id === 'oneworks-bear'
)!
expect(bear.body.nodes.map(node => node.position)).toEqual([
[-64, -80, -18],
[64, -78, -20],
])
})
})
describe('avatar behavior library', () => {

View File

@ -3,10 +3,11 @@ import { resolve } from 'node:path'
import { createAvatarDefinition } from '../avatarDefinition'
import { resolveAvatarBehavior } from '../avatars'
import { loadStudioDocument } from '../../studio/studioDocument'
import defaultStudioDocument from '../../studio/defaultStudioDocument.json'
import type { StudioDocument } from '../../studio/studioDocument'
it('keeps the consumer fixture synchronized with the bundled Strobi Studio document', async () => {
const document = loadStudioDocument({ getItem: () => null })
const document = defaultStudioDocument as unknown as StudioDocument
const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi')
if (!avatar) throw new Error('Bundled Strobi avatar not found')
const behavior = resolveAvatarBehavior(avatar, {

View File

@ -30,6 +30,8 @@ const mapSurface = <TType extends SurfaceType>(
...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
...(surface.topScale === undefined ? {} : { topScale: surface.topScale }),
...(surface.cutAngle === undefined ? {} : { cutAngle: surface.cutAngle }),
})
const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
@ -38,6 +40,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
left: {
width: expression.widthLeft,
height: expression.heightLeft,
curvature: expression.curvatureLeft,
x: expression.positionXLeft,
y: expression.positionYLeft,
angle: expression.leftAngle,
@ -45,6 +48,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
right: {
width: expression.widthRight,
height: expression.heightRight,
curvature: expression.curvatureRight,
x: expression.positionXRight,
y: expression.positionYRight,
angle: expression.rightAngle,
@ -170,6 +174,8 @@ export const createAvatarDefinition = ({
body: {
primary: mapSurface(avatar.body.primary),
nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({
...(node.layer ? { layer: node.layer } : {}),
...(node.color ? { color: node.color as HexColor } : {}),
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
position: [...node.position],
rotation: [...node.rotation],

View File

@ -31,7 +31,8 @@ export type PixelRenderStyle = {
type: 'pixel'
resolution: number
}
export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle
export type VectorRenderStyle = { type: 'vector'; filled?: boolean }
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
export type AvatarEyeDefaults = Pick<
Expression,
| 'widthLeft'
@ -66,6 +67,8 @@ export const defaultAvatarColors: AvatarColors = {
eyes: deslopAccentColors.indigo,
}
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true }
const filledAvatarEyeColor = '#111316'
export const defaultPixelRenderStyle: PixelRenderStyle = {
type: 'pixel',
resolution: 64,
@ -150,6 +153,19 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
}
export const simpleBundledAvatarIds = [
'primitive-ghost',
'primitive-apple-bite',
'oneworks-cat',
'oneworks-cat-siamese',
'oneworks-cat-british-shorthair',
'oneworks-cat-russian-blue',
'oneworks-cat-orange-tabby',
'oneworks-cat-black',
'oneworks-dog',
'oneworks-dog-brown',
'oneworks-bear',
'oneworks-rabbit',
'oneworks-bun',
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
'avatar-1786600724626',
@ -160,13 +176,33 @@ export const simpleBundledAvatarIds = [
'primitive-cylinder',
'primitive-cone',
'primitive-diamond',
'primitive-ghost',
'primitive-willy',
'oneworks-sphere',
'oneworks-ellipse',
'oneworks-square',
'oneworks-rounded',
'oneworks-capsule',
'oneworks-teardrop',
'oneworks-diamond',
'oneworks-trapezoid',
'oneworks-cone',
'oneworks-frustum',
'oneworks-half-cone',
'oneworks-cloud',
'oneworks-sun',
] as const
const simpleBundledAvatarOrder = new Map(simpleBundledAvatarIds.map((id, index) => [id, index]))
// Remove hidden bundled characters from both fresh and previously persisted local libraries.
const hiddenBundledAvatarIds = new Set([
'memento-piglet',
'oneworks-cat',
'oneworks-cat-cow',
])
const retiredBundledAvatarIds = new Set([
'memento-arctic-fox',
'strobi',
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed',
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd',
@ -175,6 +211,14 @@ const retiredBundledAvatarIds = new Set([
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0',
])
const legacyBundledAvatarNames: Record<string, string> = {
'oneworks-cat': 'OneWorks Cat',
'oneworks-dog': 'OneWorks Dog',
'oneworks-bear': 'OneWorks Bear',
'oneworks-rabbit': 'OneWorks Rabbit',
'oneworks-bun': 'OneWorks Bun',
}
const primitiveBundledAvatarSpecs = [
['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1],
['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78],
@ -182,23 +226,584 @@ const primitiveBundledAvatarSpecs = [
['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82],
['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82],
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 1],
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
] as const
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
expressions: (behavior?.expressions ?? initialExpressions).map(expression => {
const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
const storedExpressions = behavior?.expressions ?? initialExpressions
const baseExpressions = storedExpressions.filter(
expression => !expression.semanticKey?.startsWith('scared-state-')
)
const expressions = baseExpressions.map((expression, index) => {
const fixedColorExpression = { ...expression }
delete fixedColorExpression.bodyColor
delete fixedColorExpression.eyeColor
const bundledExpression =
initialExpressions.find(candidate => candidate.semanticKey === expression.semanticKey) ??
initialExpressions[index]
if (expression.semanticKey === 'angry-brows' || expression.semanticKey === 'uneasy-left') {
if (bundledExpression?.bodyColor) {
fixedColorExpression.bodyColor = bundledExpression.bodyColor
} else {
delete fixedColorExpression.bodyColor
}
if (bundledExpression?.eyeColor) {
fixedColorExpression.eyeColor = bundledExpression.eyeColor
} else {
delete fixedColorExpression.eyeColor
}
} else {
delete fixedColorExpression.bodyColor
delete fixedColorExpression.eyeColor
}
return fixedColorExpression
}),
sequences: (behavior?.sequences ?? createInitialSequences()).map(sequence => ({
})
const expressionById = new Map(expressions.map(expression => [expression.id, expression]))
const sourceSequences = behavior?.sequences ?? createInitialSequences()
const scaredSequence = sourceSequences.find(sequence => sequence.semanticKey === 'scared')
const scaredBodyColor =
initialExpressions.find(expression => expression.semanticKey === 'uneasy-left')?.bodyColor ??
'#adc3ff'
const scaredExpressions = (scaredSequence?.steps ?? []).flatMap(step => {
const baseId = step.expressionId.replace(/-scared-state$/, '')
const source = expressionById.get(baseId)
if (!source) return []
return [
{
...source,
id: `${baseId}-scared-state`,
semanticKey: `scared-state-${(source.semanticKey ?? source.id).replace(/^scared-state-/, '')}`,
bodyColor: scaredBodyColor,
},
]
})
const sequences = sourceSequences.map(sequence => ({
...sequence,
steps: sequence.steps.map(step => ({
...step,
expressionId:
sequence.semanticKey === 'scared'
? `${step.expressionId.replace(/-scared-state$/, '')}-scared-state`
: step.expressionId,
})),
blink: { ...sequence.blink },
}))
return { expressions: [...expressions, ...scaredExpressions], sequences }
}
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
const sourceExpressions = (behavior?.expressions ?? initialExpressions).filter(
expression =>
expression.semanticKey !== 'onboarding-listening' &&
expression.semanticKey !== 'onboarding-curious'
)
const expressions = sourceExpressions.map(expression => {
const fixedColorExpression = { ...expression }
const bundledExpression = initialExpressions.find(
candidate => candidate.semanticKey === expression.semanticKey
)
if (expression.semanticKey === 'angry-brows') {
fixedColorExpression.bodyColor = bundledExpression?.bodyColor
fixedColorExpression.eyeColor = bundledExpression?.eyeColor
} else {
delete fixedColorExpression.bodyColor
delete fixedColorExpression.eyeColor
}
return fixedColorExpression
})
const listeningSource = expressions.find(expression => expression.semanticKey === 'downward-gaze')
const curiousSource = expressions.find(expression => expression.semanticKey === 'small-attentive')
const onboardingExpressions: Expression[] =
listeningSource && curiousSource
? [
{
...listeningSource,
id: 'memento-onboarding-listening',
semanticKey: 'onboarding-listening',
headX: -22,
positionYLeft: -50,
positionYRight: -50,
},
{
...curiousSource,
id: 'memento-onboarding-curious',
semanticKey: 'onboarding-curious',
headX: -12,
positionYLeft: -22,
positionYRight: -22,
},
]
: []
const sequences = (behavior?.sequences ?? createInitialSequences()).map(sequence => ({
...sequence,
steps: sequence.steps.map(step => ({ ...step })),
blink: { ...sequence.blink },
})),
}))
return {
expressions: [...expressions, ...onboardingExpressions],
sequences: [
...sequences.filter(sequence => sequence.semanticKey !== 'onboarding'),
...(onboardingExpressions.length === 2
? [
{
id: 'memento-onboarding',
semanticKey: 'onboarding',
name: 'onboarding',
group: 'Memento',
description: 'Alternates attentive listening with curiosity.',
builtIn: true,
playbackMode: 'loop' as const,
steps: [
{
id: 'memento-onboarding-listening-step',
expressionId: 'memento-onboarding-listening',
holdMs: 1200,
transitionMs: 350,
transition: 'smooth' as const,
},
{
id: 'memento-onboarding-curious-step',
expressionId: 'memento-onboarding-curious',
holdMs: 1200,
transitionMs: 350,
transition: 'smooth' as const,
},
],
blink: {
enabled: true,
initialDelayMs: 2600,
minIntervalMs: 3400,
maxIntervalMs: 6200,
durationMs: 280,
},
},
]
: []),
],
}
}
type OneWorksShape =
| 'sphere'
| 'ellipse'
| 'square'
| 'rounded'
| 'capsule'
| 'teardrop'
| 'diamond'
| 'trapezoid'
| 'cone'
| 'frustum'
| 'half-cone'
// Adapted from OneWorks Avatar's MIT-licensed parametric shape catalog.
// See avatar-lab/THIRD_PARTY_NOTICES.md for attribution and source details.
const oneWorksShapeDimensions: Record<
OneWorksShape,
{ width: number; height: number; depth: number }
> = {
sphere: { width: 278, height: 278, depth: 278 },
ellipse: { width: 306, height: 236, depth: 244 },
square: { width: 264, height: 264, depth: 216 },
rounded: { width: 264, height: 264, depth: 232 },
capsule: { width: 300, height: 218, depth: 218 },
teardrop: { width: 264, height: 296, depth: 224 },
diamond: { width: 278, height: 278, depth: 212 },
trapezoid: { width: 284, height: 264, depth: 232 },
cone: { width: 278, height: 278, depth: 248 },
frustum: { width: 278, height: 278, depth: 248 },
'half-cone': { width: 278, height: 278, depth: 248 },
}
const oneWorksSurfaceType: Record<OneWorksShape, keyof typeof surfacePresets> = {
sphere: 'sphere',
ellipse: 'ellipse',
square: 'square',
rounded: 'rounded',
// OneWorks' capsule is a wide superellipsoid, not a vertical stadium.
capsule: 'rounded',
teardrop: 'teardrop',
diamond: 'diamond',
trapezoid: 'trapezoid',
cone: 'cone',
frustum: 'frustum',
'half-cone': 'half-cone',
}
const oneWorksSurface = (
shape: OneWorksShape,
scaleX = 1,
scaleY = scaleX,
scaleZ = Math.min(scaleX, scaleY),
options: Partial<(typeof surfacePresets)[keyof typeof surfacePresets]> = {}
) => {
const type = oneWorksSurfaceType[shape]
const dimensions = oneWorksShapeDimensions[shape]
return {
...surfacePresets[type],
width: dimensions.width * scaleX,
height: dimensions.height * scaleY,
depth: dimensions.depth * scaleZ,
...(shape === 'diamond' ? { roundness: 1.3 } : {}),
...(shape === 'trapezoid' ? { roundness: 1.44, topScale: 0.82 } : {}),
...(shape === 'cone'
? { roundness: 0.48, morphRoundness: 0, tipRoundness: 1, baseRoundness: 0.35 }
: {}),
...options,
type,
}
}
const oneWorksNode = (
id: string,
name: string,
shape: OneWorksShape,
position: readonly [number, number, number],
scaleX: number,
scaleY: number,
options: {
scaleZ?: number
rotation?: readonly [number, number, number]
surface?: Partial<(typeof surfacePresets)[keyof typeof surfacePresets]>
layer?: 'auto' | 'front' | 'back'
color?: string
} = {}
) => ({
id,
name,
layer: options.layer ?? ('back' as const),
...(options.color ? { color: options.color } : {}),
surface: oneWorksSurface(shape, scaleX, scaleY, options.scaleZ, options.surface),
position: [...position] as [number, number, number],
rotation: [...(options.rotation ?? [0, 0, 0])] as [number, number, number],
})
const oneWorksAvatar = (
id: string,
name: string,
shape: OneWorksShape,
color: string
): StudioAvatar => ({
id,
name,
body: { primary: oneWorksSurface(shape, 0.84), nodes: [] },
colors: { body: color, eyes: color },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
})
const oneWorksOriginalAnimalColors: Record<string, AvatarColors> = {
'oneworks-cat': { body: '#f7f7f4', eyes: '#050608' },
'oneworks-cat-siamese': { body: '#ead7b8', eyes: '#281913' },
'oneworks-cat-british-shorthair': { body: '#b89a6b', eyes: '#35291f' },
'oneworks-cat-russian-blue': { body: '#718493', eyes: '#17232c' },
'oneworks-cat-orange-tabby': { body: '#d98a35', eyes: '#3b2416' },
'oneworks-cat-black': { body: '#111419', eyes: '#eef2f5' },
'oneworks-dog': { body: '#d4d0c8', eyes: '#211f1d' },
'oneworks-bear': { body: '#a95f47', eyes: '#2b1d18' },
'oneworks-rabbit': { body: '#eee9df', eyes: '#292724' },
'oneworks-bun': { body: '#fff3d9', eyes: '#241915' },
}
const oneWorksBrownDogColors: AvatarColors = {
body: '#e3b17f',
eyes: '#2b1d18',
}
const oneWorksBundledAnimalColors: Record<string, AvatarColors> = {
...oneWorksOriginalAnimalColors,
'oneworks-dog-brown': oneWorksBrownDogColors,
}
const mementoAnimalColors: Record<string, AvatarColors> = {
'memento-piglet': { body: deslopAccentColors.pink, eyes: filledAvatarEyeColor },
}
const oneWorksCatAvatar = (
id: string,
name: string,
colors: AvatarColors,
earWidth: number,
earHeight: number,
earColor = colors.body
): StudioAvatar => {
const earScaleX = 0.24 * (earWidth / 104)
const earScaleY = 0.29 * (earHeight / 114)
return {
id,
name,
body: {
primary: oneWorksSurface('ellipse', 0.73, 0.68),
nodes: [
oneWorksNode('cat-ear-left', 'Left ear', 'cone', [-56, -78, -8], earScaleX, earScaleY, {
rotation: [-7, -13, -9],
surface: { roundness: 0.96 },
color: earColor,
}),
oneWorksNode(
'cat-ear-right',
'Right ear',
'cone',
[56, -78, -10],
earScaleX * 0.96,
earScaleY * 0.97,
{
rotation: [-6, 13, 9],
surface: { roundness: 1.04 },
color: earColor,
}
),
],
},
colors: { ...colors },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...filledAvatarRenderStyle },
behavior: fixedSkinColorBehavior(),
}
}
const oneWorksDogAvatar = (
id: string,
name: string,
colors: AvatarColors,
earColor: string
): StudioAvatar => ({
id,
name,
body: {
primary: oneWorksSurface('trapezoid', 0.72, 0.8, 0.72, {
roundness: 1.68,
topScale: 0.68,
}),
nodes: [
oneWorksNode('dog-ear-left', 'Left ear', 'teardrop', [-82, -65, 0], 0.18, 0.34, {
scaleZ: 0.18,
rotation: [-4, -10, 22],
layer: 'front',
color: earColor,
}),
oneWorksNode('dog-ear-right', 'Right ear', 'teardrop', [82, -65, 0], 0.18, 0.34, {
scaleZ: 0.18,
rotation: [-4, 10, -22],
layer: 'front',
color: earColor,
}),
],
},
colors: { ...colors },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...filledAvatarRenderStyle },
behavior: fixedSkinColorBehavior(),
})
const oneWorksBundledAvatars: StudioAvatar[] = [
oneWorksAvatar('oneworks-sphere', 'OneWorks Sphere', 'sphere', deslopAccentColors.cyan),
oneWorksAvatar('oneworks-ellipse', 'OneWorks Ellipse', 'ellipse', deslopAccentColors.teal),
oneWorksAvatar('oneworks-square', 'OneWorks Square', 'square', deslopAccentColors.brown),
oneWorksAvatar('oneworks-rounded', 'OneWorks Rounded', 'rounded', deslopAccentColors.blue),
oneWorksAvatar('oneworks-capsule', 'OneWorks Capsule', 'capsule', deslopAccentColors.green),
oneWorksAvatar('oneworks-teardrop', 'OneWorks Teardrop', 'teardrop', deslopAccentColors.pink),
oneWorksAvatar('oneworks-diamond', 'OneWorks Diamond', 'diamond', deslopAccentColors.purple),
oneWorksAvatar('oneworks-trapezoid', 'OneWorks Trapezoid', 'trapezoid', deslopAccentColors.mint),
oneWorksAvatar('oneworks-cone', 'OneWorks Cone', 'cone', deslopAccentColors.red),
oneWorksAvatar('oneworks-frustum', 'OneWorks Frustum', 'frustum', deslopAccentColors.yellow),
oneWorksAvatar(
'oneworks-half-cone',
'OneWorks Half Cone',
'half-cone',
deslopAccentColors.indigo
),
{
id: 'oneworks-cloud',
name: 'OneWorks Cloud',
body: {
primary: oneWorksSurface('ellipse', 0.83, 0.66),
nodes: [
oneWorksNode('cloud-crown-left', 'Crown left', 'sphere', [-91, -48, -42], 0.38, 0.38),
oneWorksNode('cloud-crown-center', 'Crown center', 'rounded', [-20, -79, -34], 0.48, 0.46),
oneWorksNode('cloud-crown-right', 'Crown right', 'sphere', [65, -55, -47], 0.34, 0.37),
oneWorksNode('cloud-edge-left', 'Left edge', 'rounded', [-122, 13, -26], 0.41, 0.36),
oneWorksNode('cloud-edge-right', 'Right edge', 'sphere', [119, 20, -29], 0.37, 0.34),
oneWorksNode('cloud-base-left', 'Base left', 'capsule', [-65, 72, -38], 0.47, 0.27),
oneWorksNode('cloud-base-right', 'Base right', 'capsule', [52, 70, -36], 0.52, 0.29),
],
},
colors: { body: deslopAccentColors.cyan, eyes: deslopAccentColors.cyan },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
},
{
id: 'oneworks-sun',
name: 'OneWorks Sun',
body: {
primary: oneWorksSurface('sphere', 0.72),
nodes: Array.from({ length: 8 }, (_, index) => {
const angle = (index * Math.PI) / 4
return oneWorksNode(
`sun-ray-${index + 1}`,
`Ray ${index + 1}`,
index % 2 === 0 ? 'diamond' : 'sphere',
[Math.cos(angle) * 125, Math.sin(angle) * 125, -18],
0.14,
0.14
)
}),
},
colors: { body: deslopAccentColors.yellow, eyes: deslopAccentColors.yellow },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
},
oneWorksCatAvatar('oneworks-cat', 'Cat', oneWorksOriginalAnimalColors['oneworks-cat'], 104, 114),
oneWorksCatAvatar(
'oneworks-cat-siamese',
'Siamese',
oneWorksOriginalAnimalColors['oneworks-cat-siamese'],
104,
114,
'#3c2118'
),
oneWorksCatAvatar(
'oneworks-cat-british-shorthair',
'British Shorthair',
oneWorksOriginalAnimalColors['oneworks-cat-british-shorthair'],
90,
86
),
oneWorksCatAvatar(
'oneworks-cat-russian-blue',
'Russian Blue',
oneWorksOriginalAnimalColors['oneworks-cat-russian-blue'],
104,
116
),
oneWorksCatAvatar(
'oneworks-cat-orange-tabby',
'Orange Tabby',
oneWorksOriginalAnimalColors['oneworks-cat-orange-tabby'],
100,
100
),
oneWorksCatAvatar(
'oneworks-cat-black',
'Black Cat',
oneWorksOriginalAnimalColors['oneworks-cat-black'],
102,
106
),
oneWorksDogAvatar(
'oneworks-dog',
'Dog',
oneWorksOriginalAnimalColors['oneworks-dog'],
deslopAccentColors.brown
),
oneWorksDogAvatar('oneworks-dog-brown', 'Brown Dog', oneWorksBrownDogColors, '#a95f47'),
{
id: 'oneworks-bear',
name: 'Bear',
body: {
primary: oneWorksSurface('trapezoid', 0.74, 0.72, 0.72, { roundness: 1.56 }),
nodes: [
oneWorksNode('bear-ear-left', 'Left ear', 'ellipse', [-64, -80, -18], 0.2, 0.34, {
rotation: [-4, -7, 8],
color: '#a95f47',
}),
oneWorksNode('bear-ear-right', 'Right ear', 'ellipse', [64, -78, -20], 0.19, 0.33, {
rotation: [-3, 8, -7],
color: '#a95f47',
}),
],
},
colors: { ...oneWorksOriginalAnimalColors['oneworks-bear'] },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...filledAvatarRenderStyle },
behavior: fixedSkinColorBehavior(),
},
{
id: 'oneworks-rabbit',
name: 'Rabbit',
body: {
primary: oneWorksSurface('trapezoid', 0.72, 0.74, 0.72, {
roundness: 2,
topScale: 0.94,
}),
nodes: [
oneWorksNode('rabbit-ear-left', 'Left ear', 'trapezoid', [-50, -76, -22], 0.18, 0.6, {
rotation: [-3, -5, -8],
surface: { roundness: 2, topScale: 0.9 },
}),
oneWorksNode('rabbit-ear-right', 'Right ear', 'trapezoid', [48, -79, -24], 0.17, 0.62, {
rotation: [-4, 7, 11],
surface: { roundness: 2, topScale: 0.9 },
}),
],
},
colors: { ...oneWorksOriginalAnimalColors['oneworks-rabbit'] },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...filledAvatarRenderStyle },
behavior: fixedSkinColorBehavior(),
},
{
id: 'oneworks-bun',
name: 'Bun',
body: {
primary: oneWorksSurface('sphere', 0.7, 0.5, 0.7),
nodes: [
oneWorksNode('bun-crown', 'Rounded bun crown', 'cone', [0, -46, -14], 0.5, 0.23, {
scaleZ: 0.5,
layer: 'front',
surface: { roundness: 0.92, topScale: 0.82 },
}),
],
},
colors: { ...oneWorksOriginalAnimalColors['oneworks-bun'] },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...filledAvatarRenderStyle },
behavior: fixedSkinColorBehavior(),
},
]
const mementoPigletAvatar: StudioAvatar = {
id: 'memento-piglet',
name: 'Порося',
body: {
primary: oneWorksSurface('trapezoid', 0.72, 0.8, 0.72, {
roundness: 1.68,
topScale: 0.68,
}),
nodes: [
oneWorksNode('piglet-ear-left', 'Left ear', 'ellipse', [-57, -76, -18], 0.18, 0.29, {
rotation: [-4, -7, 8],
color: deslopAccentColors.red,
}),
oneWorksNode('piglet-ear-right', 'Right ear', 'ellipse', [57, -75, -20], 0.18, 0.29, {
rotation: [-3, 8, -7],
color: deslopAccentColors.red,
}),
oneWorksNode('piglet-snout', 'Snout', 'ellipse', [0, 44, 76], 0.25, 0.16, {
scaleZ: 0.1,
layer: 'front',
color: deslopAccentColors.red,
}),
oneWorksNode('piglet-nostril-left', 'Left nostril', 'ellipse', [-14, 44, 90], 0.035, 0.048, {
scaleZ: 0.025,
rotation: [0, 0, -5],
layer: 'front',
color: filledAvatarEyeColor,
}),
oneWorksNode('piglet-nostril-right', 'Right nostril', 'ellipse', [14, 44, 90], 0.035, 0.048, {
scaleZ: 0.025,
rotation: [0, 0, 5],
layer: 'front',
color: filledAvatarEyeColor,
}),
],
},
colors: { ...mementoAnimalColors['memento-piglet'] },
eyes: { ...defaultAvatarEyes },
renderStyle: { ...filledAvatarRenderStyle },
behavior: fixedSkinColorBehavior(),
}
const primitiveBundledAvatars: StudioAvatar[] = [
...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({
id,
@ -212,7 +817,10 @@ const primitiveBundledAvatars: StudioAvatar[] = [
},
nodes: [],
},
colors: { body: color, eyes: color },
colors: {
body: color,
eyes: surface === 'ghost' || surface === 'apple-bite' ? filledAvatarEyeColor : color,
},
eyes:
surface === 'ghost'
? {
@ -220,10 +828,24 @@ const primitiveBundledAvatars: StudioAvatar[] = [
positionYLeft: defaultAvatarEyes.positionYLeft - 20,
positionYRight: defaultAvatarEyes.positionYRight - 20,
}
: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}),
: surface === 'apple-bite'
? {
...defaultAvatarEyes,
positionXLeft: defaultAvatarEyes.positionXLeft - 18,
positionXRight: defaultAvatarEyes.positionXRight - 18,
}
: { ...defaultAvatarEyes },
renderStyle:
surface === 'ghost' || surface === 'apple-bite'
? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle },
...(surface === 'ghost'
? { behavior: mementoBehavior() }
: surface === 'apple-bite'
? { behavior: fixedSkinColorBehavior() }
: {}),
})),
mementoPigletAvatar,
{
id: 'primitive-willy',
name: 'Willy',
@ -287,9 +909,26 @@ const primitiveBundledAvatars: StudioAvatar[] = [
eyes: { ...defaultAvatarEyes },
renderStyle: { ...defaultAvatarRenderStyle },
},
...oneWorksBundledAvatars,
]
const originalSimpleBundledAvatarIds = new Set<string>(simpleBundledAvatarIds.slice(0, 4))
const originalSimpleBundledAvatarIds = new Set<string>([
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
'avatar-1786600724626',
'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8',
])
const filledBundledAvatarIds = new Set([
...Object.keys(oneWorksBundledAnimalColors),
...Object.keys(mementoAnimalColors),
'primitive-ghost',
'primitive-apple-bite',
])
const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors))
const filledPrimitiveAvatarColors: Record<string, AvatarColors> = {
'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
}
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
...avatar,
@ -305,17 +944,107 @@ const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
colors: { ...avatar.colors },
eyes: { ...avatar.eyes },
renderStyle: { ...avatar.renderStyle },
...(avatar.behavior ? { behavior: mementoBehavior(avatar.behavior) } : {}),
...(avatar.behavior
? {
behavior: {
expressions: avatar.behavior.expressions.map(expression => ({ ...expression })),
sequences: avatar.behavior.sequences.map(sequence => ({
...sequence,
steps: sequence.steps.map(step => ({ ...step })),
blink: { ...sequence.blink },
})),
},
}
: {}),
})
export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLibrary => {
if (!library.avatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) return library
const resizedAvatars = library.avatars.map(avatar => {
const visibleLibraryAvatars = library.avatars.filter(
avatar => !hiddenBundledAvatarIds.has(avatar.id)
)
if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) {
const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => {
if (avatar.id === 'primitive-ghost') {
return { ...avatar, behavior: mementoBehavior(avatar.behavior) }
}
if (filledBundledAvatarIds.has(avatar.id)) {
return { ...avatar, behavior: fixedSkinColorBehavior(avatar.behavior) }
}
return avatar
})
return {
...library,
activeAvatarId: avatarsWithCurrentColorBehavior.some(
avatar => avatar.id === library.activeAvatarId
)
? library.activeAvatarId
: (avatarsWithCurrentColorBehavior[0]?.id ?? library.activeAvatarId),
avatars: avatarsWithCurrentColorBehavior,
}
}
const resizedAvatars = visibleLibraryAvatars.map(avatar => {
const bundled = primitiveBundledAvatars.find(candidate => candidate.id === avatar.id)
if (!bundled) return avatar
const migratedNodes = avatar.body.nodes.map(node => {
const bundledNode = bundled.body.nodes.find(candidate => candidate.id === node.id)
if (!bundledNode || avatar.id !== 'primitive-willy') return node
if (!bundledNode) return node
const nodeWithOriginalColor = bundledNode.color ? { ...node, color: bundledNode.color } : node
const nodeWithOriginalLayer = oneWorksOriginalAnimalIds.has(avatar.id)
? { ...nodeWithOriginalColor, layer: bundledNode.layer }
: nodeWithOriginalColor
const isDogEar =
['oneworks-dog', 'oneworks-dog-brown'].includes(avatar.id) &&
(node.id === 'dog-ear-left' || node.id === 'dog-ear-right')
const hasLegacyDogEarPosition =
node.position[1] === -52 ||
(node.position[1] === -65 &&
((node.id === 'dog-ear-left' && node.position[0] === -92) ||
(node.id === 'dog-ear-right' && node.position[0] === 92))) ||
(node.position[1] === -78 && [72, 88].includes(Math.abs(node.position[0]))) ||
(node.position[1] === -62 &&
((node.id === 'dog-ear-left' && node.position[0] === -88) ||
(node.id === 'dog-ear-right' && [88, 132, 142, 146].includes(node.position[0])))) ||
(node.position[1] === -42 &&
((node.id === 'dog-ear-left' && node.position[0] === -100) ||
(node.id === 'dog-ear-right' && node.position[0] === 146))) ||
(node.position[1] === -30 &&
((node.id === 'dog-ear-left' && node.position[0] === -100) ||
(node.id === 'dog-ear-right' && [140, 152].includes(node.position[0]))))
const hasLegacyDogEarGeometry = isDogEar && hasLegacyDogEarPosition
const isBearEar =
avatar.id === 'oneworks-bear' &&
(node.id === 'bear-ear-left' || node.id === 'bear-ear-right')
const hasLegacyBearEarPosition =
(node.id === 'bear-ear-left' &&
node.position[0] === -58 &&
node.position[1] === -72 &&
node.position[2] === -18) ||
(node.id === 'bear-ear-right' &&
node.position[0] === 58 &&
node.position[1] === -70 &&
node.position[2] === -20)
const hasLegacyBearEarGeometry = isBearEar && hasLegacyBearEarPosition
const isPigletFaceFeature =
avatar.id === 'memento-piglet' &&
['piglet-snout', 'piglet-nostril-left', 'piglet-nostril-right'].includes(node.id)
const hasLegacyPigletFaceGeometry =
isPigletFaceFeature &&
((node.id === 'piglet-snout' &&
([8, 154].includes(node.position[2]) ||
(node.position[1] === 57 && node.position[2] === 76))) ||
((node.id === 'piglet-nostril-left' || node.id === 'piglet-nostril-right') &&
([20, 176].includes(node.position[2]) ||
(node.position[1] === 57 && node.position[2] === 90))))
const nodeWithCurrentAnimalGeometry =
hasLegacyDogEarGeometry || hasLegacyBearEarGeometry || hasLegacyPigletFaceGeometry
? {
...nodeWithOriginalLayer,
surface: { ...bundledNode.surface },
position: [...bundledNode.position] as typeof bundledNode.position,
rotation: [...bundledNode.rotation] as typeof bundledNode.rotation,
}
: nodeWithOriginalLayer
if (avatar.id !== 'primitive-willy') return nodeWithCurrentAnimalGeometry
const isLegacyLowerSphere =
(node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') &&
node.surface.type === 'sphere' &&
@ -329,7 +1058,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
node.surface.depth === 135 &&
node.position[2] === 12) ||
(node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148))
if (!isLegacyLowerSphere && !isLegacyTopCap) return node
if (!isLegacyLowerSphere && !isLegacyTopCap) return nodeWithCurrentAnimalGeometry
return {
...bundledNode,
surface: { ...bundledNode.surface },
@ -358,8 +1087,11 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
: migratedNodes.some((node, index) => node !== avatar.body.nodes[index])
? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } }
: avatar
const legacyBundledName = legacyBundledAvatarNames[avatarWithCurrentNodes.id]
const avatarWithCurrentName =
avatarWithCurrentNodes.id === 'primitive-ghost' && avatarWithCurrentNodes.name === 'Ghost'
(avatarWithCurrentNodes.id === 'primitive-ghost' &&
avatarWithCurrentNodes.name === 'Ghost') ||
avatarWithCurrentNodes.name === legacyBundledName
? { ...avatarWithCurrentNodes, name: bundled.name }
: avatarWithCurrentNodes
const avatarWithCurrentEyes =
@ -372,12 +1104,36 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
)
? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } }
: avatarWithCurrentName
const avatarWithFixedColorBehavior =
avatarWithCurrentEyes.id === 'primitive-ghost'
? { ...avatarWithCurrentEyes, behavior: mementoBehavior(avatarWithCurrentEyes.behavior) }
const avatarWithAppleBiteEyes =
avatarWithCurrentEyes.id === 'primitive-apple-bite' &&
avatarWithCurrentEyes.eyes.positionXLeft === defaultAvatarEyes.positionXLeft &&
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
: avatarWithCurrentEyes
const avatarWithFixedColorBehavior =
avatarWithAppleBiteEyes.id === 'primitive-ghost'
? {
...avatarWithAppleBiteEyes,
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
}
: filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id)
? {
...avatarWithAppleBiteEyes,
behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior),
}
: avatarWithAppleBiteEyes
const avatarWithCurrentRenderStyle = filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
: avatarWithFixedColorBehavior
const bundledFilledColors =
mementoAnimalColors[avatarWithCurrentRenderStyle.id] ??
oneWorksBundledAnimalColors[avatarWithCurrentRenderStyle.id] ??
filledPrimitiveAvatarColors[avatarWithCurrentRenderStyle.id]
const avatarWithOriginalColors = bundledFilledColors
? { ...avatarWithCurrentRenderStyle, colors: { ...bundledFilledColors } }
: avatarWithCurrentRenderStyle
const original = surfacePresets[bundled.body.primary.type]
const primary = avatarWithFixedColorBehavior.body.primary
const primary = avatarWithOriginalColors.body.primary
const stillUsesOriginalSize =
primary.type === original.type &&
primary.width === original.width &&
@ -389,17 +1145,25 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
primary.width === 122 &&
primary.height === 210 &&
primary.depth === 115
if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody) return avatarWithFixedColorBehavior
const stillUsesPreviousPigletBody =
avatar.id === 'memento-piglet' &&
primary.type === 'sphere' &&
primary.width === 194.6 &&
primary.height === 194.6 &&
primary.depth === 200.16
if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody && !stillUsesPreviousPigletBody) {
return avatarWithOriginalColors
}
return {
...avatarWithFixedColorBehavior,
body: { ...avatarWithFixedColorBehavior.body, primary: { ...bundled.body.primary } },
...avatarWithOriginalColors,
body: { ...avatarWithOriginalColors.body, primary: { ...bundled.body.primary } },
}
})
const existingIds = new Set(resizedAvatars.map(avatar => avatar.id))
const avatars = [
...resizedAvatars,
...primitiveBundledAvatars
.filter(avatar => !existingIds.has(avatar.id))
.filter(avatar => !hiddenBundledAvatarIds.has(avatar.id) && !existingIds.has(avatar.id))
.map(clonePrimitiveBundledAvatar),
].sort((left, right) => {
const leftOrder = simpleBundledAvatarOrder.get(
@ -413,7 +1177,13 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
if (rightOrder === undefined) return -1
return leftOrder - rightOrder
})
return { ...library, avatars }
return {
...library,
activeAvatarId: avatars.some(avatar => avatar.id === library.activeAvatarId)
? library.activeAvatarId
: (avatars[0]?.id ?? library.activeAvatarId),
avatars,
}
}
export const migrateBundledAvatarColors = (id: string, colors: AvatarColors): AvatarColors => {
@ -434,7 +1204,10 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
: fallback
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as Partial<PixelRenderStyle> | null
const candidate = value as { type?: unknown; filled?: unknown; resolution?: unknown } | null
if (candidate?.type === 'vector') {
return candidate.filled === true ? { type: 'vector', filled: true } : { type: 'vector' }
}
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
return { ...defaultAvatarRenderStyle }
}
@ -616,7 +1389,9 @@ export const parseAvatarLibrary = (
.filter(avatar => {
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
return false
if (retiredBundledAvatarIds.has(avatar.id)) return false
if (retiredBundledAvatarIds.has(avatar.id) || hiddenBundledAvatarIds.has(avatar.id)) {
return false
}
if (seenIds.has(avatar.id)) return false
seenIds.add(avatar.id)
return true

View File

@ -1,4 +1,5 @@
import { ArrowLeft, Copy, Pencil, RotateCcw, Trash2 } from 'lucide-react'
import { motion } from 'motion/react'
import { useState, type RefObject } from 'react'
import { Button } from '@/components/ui/button'
@ -27,15 +28,38 @@ import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditin
import { type Expression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
import { StaticPixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import {
avatarBodyOutlineWidth,
resolveAvatarNodeColor,
useAvatarBodyColorOverride,
} from '@/features/rendering/avatarAppearance'
import {
LivePixelAvatarCanvas,
StaticPixelAvatarCanvas,
} from '@/features/rendering/components/PixelAvatarCanvas'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
const outlineWidth = avatarBodyOutlineWidth()
return (
<svg viewBox="-150 -150 300 300" aria-hidden="true">
{geometry.backPaths.map((pathValue, index) => (
<path d={pathValue} key={index} />
<path
d={pathValue}
fill="var(--avatar-interior-color, #ffffff)"
stroke="currentColor"
strokeWidth={outlineWidth}
strokeLinejoin="round"
key={index}
/>
))}
<path d={geometry.headPath} />
<path
d={geometry.headPath}
fill="var(--avatar-interior-color, #ffffff)"
stroke="currentColor"
strokeWidth={outlineWidth}
strokeLinejoin="round"
/>
</svg>
)
}
@ -59,6 +83,16 @@ export function ExpressionPreview({
}) {
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
const resolvedColors = resolveColors(expression, colors)
const outlineWidth = avatarBodyOutlineWidth()
const filled = renderStyle.type === 'vector' && renderStyle.filled === true
const bodyFill = filled ? resolvedColors.body : 'var(--avatar-interior-color, #ffffff)'
const eyeFill = resolvedColors.eyes
const nodeColor = (id: string | null | undefined) =>
resolveAvatarNodeColor({
nodeColor: bodyNodes.find(node => node.id === id)?.color,
bodyColor: resolvedColors.body,
expressionBodyColor: expression.bodyColor,
})
if (renderStyle.type === 'pixel') {
return (
<StaticPixelAvatarCanvas
@ -88,37 +122,168 @@ export function ExpressionPreview({
<path d={geometry.headPath} />
</clipPath>
</defs>
{geometry.backPaths.map((pathValue, index) => (
<path
className="preview-head"
d={pathValue}
key={index}
style={{ fill: resolvedColors.body }}
/>
))}
<path className="preview-head" d={geometry.headPath} style={{ fill: resolvedColors.body }} />
{geometry.backPaths.map((pathValue, index) => {
const color = nodeColor(geometry.backNodeIds[index])
return (
<path
className="preview-head"
d={pathValue}
key={index}
style={{
fill: filled ? color : bodyFill,
stroke: color,
strokeWidth: outlineWidth,
strokeLinejoin: 'round',
}}
/>
)
})}
<path
className="preview-head"
d={geometry.headPath}
style={{
fill: bodyFill,
stroke: resolvedColors.body,
strokeWidth: outlineWidth,
strokeLinejoin: 'round',
}}
/>
<g clipPath={`url(#${clipId})`}>
<path
className="preview-eye"
d={geometry.leftPath}
opacity={geometry.leftVisible ? 1 : 0}
style={{ fill: resolvedColors.eyes }}
style={{ fill: eyeFill }}
/>
<path
className="preview-eye"
d={geometry.rightPath}
opacity={geometry.rightVisible ? 1 : 0}
style={{ fill: resolvedColors.eyes }}
style={{ fill: eyeFill }}
/>
</g>
{geometry.frontPaths.map((pathValue, index) => (
<path
className="preview-head"
d={pathValue}
key={`front-${index}`}
style={{ fill: resolvedColors.body }}
{geometry.frontPaths.map((pathValue, index) => {
const color = nodeColor(geometry.frontNodeIds[index])
return (
<path
className="preview-head"
d={pathValue}
key={`front-${index}`}
style={{
fill: filled ? color : bodyFill,
stroke: color,
strokeWidth: outlineWidth,
strokeLinejoin: 'round',
}}
/>
)
})}
</svg>
)
}
export function LiveExpressionPreview({
scene,
colors,
baseBodyColor,
bodyNodes,
renderStyle,
expressionBodyColor,
id,
}: {
scene: RenderedScene
colors: RenderedColors
baseBodyColor: string
bodyNodes: BodyNode[]
renderStyle: AvatarRenderStyle
expressionBodyColor?: string
id: string
}) {
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor)
if (renderStyle.type === 'pixel') {
return (
<LivePixelAvatarCanvas
className="avatar-preview"
scene={scene}
colors={colors}
style={renderStyle}
/>
)
}
const clipId = `live-preview-${id}`
const filled = renderStyle.filled === true
const outlineWidth = avatarBodyOutlineWidth()
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
const nodeColor = (nodeId: string | null | undefined) =>
inheritBodyColor
? colors.body
: (bodyNodes.find(node => node.id === nodeId)?.color ?? colors.body)
return (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<motion.path d={scene.headPath} />
</clipPath>
</defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => {
const color = nodeColor(scene.backNodeIds.current[index])
return (
<motion.path
d={pathValue}
strokeLinecap="round"
strokeLinejoin="round"
style={{
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)',
stroke: color,
strokeWidth: outlineWidth,
}}
key={`back-${index}`}
/>
)
})}
<motion.path
d={scene.headPath}
strokeLinecap="round"
strokeLinejoin="round"
style={{
fill: filled ? colors.body : 'var(--avatar-interior-color, #ffffff)',
stroke: colors.body,
strokeWidth: outlineWidth,
}}
/>
))}
<g clipPath={`url(#${clipId})`}>
<motion.path
d={scene.leftPath}
opacity={scene.leftOpacity}
style={{ fill: colors.eyes }}
/>
<motion.path
d={scene.rightPath}
opacity={scene.rightOpacity}
style={{ fill: colors.eyes }}
/>
</g>
{scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index])
return (
<motion.path
d={pathValue}
strokeLinecap="round"
strokeLinejoin="round"
style={{
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)',
stroke: color,
strokeWidth: outlineWidth,
}}
key={`front-${index}`}
/>
)
})}
</motion.g>
</svg>
)
}

View File

@ -56,9 +56,26 @@ const calibrated: number[][] = [
[-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(
(
[
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')}`,
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
headX,
headY,
headZ,
@ -66,34 +83,93 @@ export const initialExpressions: Expression[] = calibrated.map(
widthRight,
heightLeft,
heightRight,
curvatureLeft: 0,
curvatureRight: 0,
spacing,
latitude,
positionXLeft: 0,
positionXRight: 0,
positionYLeft: latitude,
positionYRight: latitude,
leftAngle,
rightAngle,
],
index
) => ({
id: `expression-${String(index).padStart(2, '0')}`,
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
headX,
headY,
headZ,
widthLeft,
widthRight,
heightLeft,
heightRight,
spacing,
perspective: 1,
eyeMotion: 'none' as const,
bodyMotion: 'none' as const,
})
),
{
id: 'expression-3d2bed26-f97c-477d-922f-77600cb10e92',
semanticKey: 'angry-brows',
headX: 10.473974503042374,
headY: 5.087293619785961,
headZ: 4.698252132317348,
widthLeft: 27.126562499999995,
widthRight: 27.126562499999995,
heightLeft: 63.028125,
heightRight: 63.028125,
curvatureLeft: 0,
curvatureRight: 0,
spacing: 68.7,
positionXLeft: 0,
positionXRight: 0,
positionYLeft: latitude,
positionYRight: latitude,
leftAngle,
rightAngle,
positionYLeft: 0,
positionYRight: 0,
leftAngle: -36.244531249999994,
rightAngle: 27.730078125000002,
perspective: 1,
eyeMotion: 'none',
bodyMotion: 'none',
})
)
eyeMotion: 'none' as const,
bodyMotion: 'shake' as const,
bodyColor: '#ba3636',
eyeColor: '#610000',
},
{
id: 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8',
semanticKey: 'uneasy-left',
headX: -12.303515625,
headY: -17.601171875,
headZ: 5.9109375,
widthLeft: 20.605859374999994,
widthRight: 20.605859374999994,
heightLeft: 47.769921874999994,
heightRight: 47.769921874999994,
curvatureLeft: 0,
curvatureRight: 0,
spacing: 54.9,
positionXLeft: 0,
positionXRight: 0,
positionYLeft: 0,
positionYRight: 0,
leftAngle: 23.523046875000002,
rightAngle: -24.042578125000002,
perspective: 1,
eyeMotion: 'shake' as const,
bodyMotion: 'slowDrift' as const,
bodyColor: '#adc3ff',
},
{
id: 'expression-joyful-arc',
semanticKey: 'joyful-arc',
headX: -8.75234375,
headY: -8.743359375,
headZ: -10.773828125,
widthLeft: 54,
widthRight: 54,
heightLeft: 15,
heightRight: 15,
curvatureLeft: 1,
curvatureRight: 1,
spacing: 69.276171875,
positionXLeft: 0,
positionXRight: 0,
positionYLeft: -18,
positionYRight: -18,
leftAngle: 0,
rightAngle: 0,
perspective: 1,
eyeMotion: 'none' as const,
bodyMotion: 'none' as const,
},
]
export const defaultExpression: Expression = {
id: 'expression-neutral',
@ -104,6 +180,8 @@ export const defaultExpression: Expression = {
widthRight: 20,
heightLeft: 50,
heightRight: 50,
curvatureLeft: 0,
curvatureRight: 0,
spacing: 35,
positionXLeft: 0,
positionXRight: 0,
@ -125,6 +203,7 @@ export const stateGroups = {
'angry',
'drowsy',
'happy',
'joyful',
'curious',
'confused',
'bored',
@ -149,9 +228,10 @@ export const statePools: Record<string, number[]> = {
excited: [2, 17, 21, 3, 11],
surprised: [3, 21],
suspicious: [14, 5, 23],
angry: [7, 16],
angry: [7, 25],
drowsy: [4, 22, 13],
happy: [2, 11, 17, 19],
joyful: [11, 27],
curious: [3, 21, 0, 15],
confused: [14, 5, 8],
bored: [4, 22, 0],
@ -159,7 +239,7 @@ export const statePools: Record<string, number[]> = {
shy: [0, 24, 13],
sad: [4, 13, 22],
laughing: [2, 11, 17],
scared: [3, 21],
scared: [3, 26],
playful: [2, 17, 11, 8],
celebrate: [2, 8, 17],
}
@ -244,5 +324,6 @@ export const stateNotes: Record<string, string> = {
searching: 'Balayage rapide et changements très fréquents.',
working: 'Rythme régulier et expressions concentrées.',
excited: 'Grandes expressions et transitions rapides.',
joyful: 'Morphing fluide entre les expressions 11 et 27.',
curious: 'Inclinaisons et forte asymétrie.',
}

View File

@ -8,7 +8,10 @@ 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);
: transition === 'gentleSpring'
? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress))
/ (1 - Math.exp(-9) * Math.cos(5))
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
const nearestAngle = (target, current) => {
let resolved = target;
while (resolved - current > 180) resolved -= 360;
@ -134,12 +137,13 @@ function mountAvatar(target, options = {}) {
const renderElement = pixelStyle ? canvas : svg;
host.replaceChildren(renderElement);
const ensurePaths = (group, paths, fill) => {
const ensurePaths = (group, paths, fill, nodeIds, bodyColorOverride) => {
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);
const node = DATA.avatar.bodyNodes.find(candidate => candidate.id === nodeIds[index]);
group.children[index].setAttribute('fill', bodyColorOverride ? fill : node?.color || fill);
});
};
let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
@ -200,8 +204,23 @@ function mountAvatar(target, options = {}) {
return;
}
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
const bodyColorOverride = Boolean(
currentPose.expression.bodyColor || transitionState?.toPose.expression.bodyColor
);
ensurePaths(
backLayer,
geometry.backPaths,
currentColors.body,
geometry.backNodeIds,
bodyColorOverride
);
ensurePaths(
frontLayer,
geometry.frontPaths,
currentColors.body,
geometry.frontNodeIds,
bodyColorOverride
);
head.setAttribute('d', geometry.headPath);
head.setAttribute('fill', currentColors.body);
clipHead.setAttribute('d', geometry.headPath);

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,36 @@
import { migrateBundledAvatarColors } from '@/features/avatar/avatars'
import {
avatarBodyOutlineWidth,
resolveAvatarNodeColor,
} from '@/features/rendering/avatarAppearance'
describe('outlined avatar appearance', () => {
it('uses the same fixed body outline width for every avatar', () => {
expect(avatarBodyOutlineWidth()).toBe(12)
})
it('migrates bundled avatar colors to Deslop accent tokens', () => {
expect(migrateBundledAvatarColors('strobi', { body: '#5b7fe5', eyes: '#111316' })).toEqual({
body: '#7c89ff',
eyes: '#50d7fe',
})
})
it('preserves user-customized colors', () => {
expect(migrateBundledAvatarColors('strobi', { body: '#123456', eyes: '#654321' })).toEqual({
body: '#123456',
eyes: '#654321',
})
})
it('lets an explicit expression color tint fixed-color body parts', () => {
expect(
resolveAvatarNodeColor({
nodeColor: '#a95f47',
bodyColor: '#ba3636',
expressionBodyColor: '#ba3636',
})
).toBe('#ba3636')
expect(resolveAvatarNodeColor({ nodeColor: '#a95f47', bodyColor: '#e3b17f' })).toBe('#a95f47')
})
})

View File

@ -9,11 +9,29 @@ import {
paintRenderedScene,
} from '@/features/rendering/renderedScene'
import { surfacePresets } from '@/features/avatar/surfaces'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
import type { BodyNode } from '@/features/avatar/body'
import type { Expression } from '@/features/avatar/geometry'
import type { SurfaceConfig } from '@/features/avatar/surfaces'
const pathBounds = (value: string) => {
const numbers = (value.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number)
const x = numbers.filter((_, index) => index % 2 === 0)
const y = numbers.filter((_, index) => index % 2 === 1)
return {
left: Math.min(...x),
top: Math.min(...y),
right: Math.max(...x),
bottom: Math.max(...y),
}
}
const overlap = (first: ReturnType<typeof pathBounds>, second: ReturnType<typeof pathBounds>) => ({
x: Math.min(first.right, second.right) - Math.max(first.left, second.left),
y: Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top),
})
describe('rendered avatar scene', () => {
it('keeps layer identity and hit mapping behind the scene seam', () => {
const node = createBodyNode('sphere', 0)
@ -68,5 +86,85 @@ describe('rendered avatar scene', () => {
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f')
const forcedFront = renderAvatar(
poseFromExpression({ ...expression, headY: 35 }),
avatar.body.primary as SurfaceConfig,
1,
{
bodyNodes: [{ ...(avatar.body.nodes[0] as unknown as BodyNode), layer: 'front' }],
}
)
expect(forcedFront.frontNodeIds).toContain(avatar.body.nodes[0].id)
})
it('anchors dog ears in front of the head across expressions', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-dog')!
const detachedEars: string[] = []
const neutral = renderAvatar(
poseFromExpression(defaultExpression),
avatar.body.primary as SurfaceConfig,
1,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
expect(neutral.frontNodeIds).toEqual(['dog-ear-left', 'dog-ear-right'])
studio.expressions.forEach(expression => {
const geometry = renderAvatar(
poseFromExpression(expression as Expression),
avatar.body.primary as SurfaceConfig,
1,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
const head = pathBounds(geometry.headPath)
const earIds = ['dog-ear-left', 'dog-ear-right'] as const
earIds.forEach(nodeId => {
const frontIndex = geometry.frontNodeIds.indexOf(nodeId)
expect(geometry.backNodeIds).not.toContain(nodeId)
expect(frontIndex).toBeGreaterThanOrEqual(0)
const earPath = geometry.frontPaths[frontIndex]
const ear = pathBounds(earPath)
const headOverlap = overlap(ear, head)
if (headOverlap.x <= 0 || headOverlap.y <= 0) {
detachedEars.push(
`${expression.id}/${nodeId} (${headOverlap.x.toFixed(2)} × ${headOverlap.y.toFixed(2)})`
)
}
})
})
expect(detachedEars, 'detached dog ears').toEqual([])
})
it('keeps bear ears attached behind the head across expressions', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-bear')!
const detachedEars: string[] = []
studio.expressions.forEach(expression => {
const geometry = renderAvatar(
poseFromExpression(expression as Expression),
avatar.body.primary as SurfaceConfig,
1,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
const head = pathBounds(geometry.headPath)
;(['bear-ear-left', 'bear-ear-right'] as const).forEach(nodeId => {
const earIndex = geometry.backNodeIds.indexOf(nodeId)
expect(earIndex).toBeGreaterThanOrEqual(0)
const ear = pathBounds(geometry.backPaths[earIndex])
const headOverlap = overlap(ear, head)
if (headOverlap.x <= 0 || headOverlap.y <= 0) {
detachedEars.push(expression.id + '/' + nodeId)
}
})
})
expect(detachedEars, 'detached bear ears').toEqual([])
})
})

View File

@ -0,0 +1,37 @@
import { useMotionValueEvent, type MotionValue } from 'motion/react'
import { useEffect, useState } from 'react'
export const avatarBodyOutlineWidth = () => 12
const colorsMatch = (left: string, right: string) =>
left.trim().toLowerCase() === right.trim().toLowerCase()
export const useAvatarBodyColorOverride = (
renderedBodyColor: MotionValue<string>,
baseBodyColor: string
) => {
const [overridden, setOverridden] = useState(
() => !colorsMatch(renderedBodyColor.get(), baseBodyColor)
)
useEffect(() => {
setOverridden(!colorsMatch(renderedBodyColor.get(), baseBodyColor))
}, [baseBodyColor, renderedBodyColor])
useMotionValueEvent(renderedBodyColor, 'change', latest => {
const next = !colorsMatch(latest, baseBodyColor)
setOverridden(current => (current === next ? current : next))
})
return overridden
}
export const resolveAvatarNodeColor = ({
nodeColor,
bodyColor,
expressionBodyColor,
}: {
nodeColor?: string
bodyColor: string
expressionBodyColor?: string
}) => expressionBodyColor ?? nodeColor ?? bodyColor

View File

@ -1,6 +1,6 @@
import { RotateCcw } from 'lucide-react'
import { motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button } from '@/components/ui/button'
import { useStudioLanguage } from '@/i18n'
@ -496,8 +496,10 @@ export function AvatarCanvas({
expression,
avatarEyes,
surface,
bodyNodes,
scene,
colors,
expressionBodyColor,
renderStyle,
rotationGizmo,
showWire,
@ -522,8 +524,10 @@ export function AvatarCanvas({
expression: Expression
avatarEyes: AvatarEyeDefaults
surface: SurfaceConfig
bodyNodes: BodyNode[]
scene: RenderedScene
colors: RenderedColors
expressionBodyColor?: string
renderStyle: AvatarRenderStyle
rotationGizmo: RenderedRotationGizmo
showWire: boolean
@ -593,6 +597,11 @@ export function AvatarCanvas({
if (!bodyEditing || !selectedBodyNodeId) return null
return findBodyNodePath(scene, selectedBodyNodeId)
})()
const nodeColorStyle = (id: string | null | undefined) => {
if (expressionBodyColor ?? expression.bodyColor) return undefined
const color = bodyNodes.find(node => node.id === id)?.color
return color ? ({ '--avatar-node-color': color } as CSSProperties) : undefined
}
const toSvg = (event: React.PointerEvent<SVGElement>): readonly [number, number] => {
const rectangle = svgRef.current!.getBoundingClientRect()
@ -774,7 +783,9 @@ export function AvatarCanvas({
}
useEscapeToCancel(cancelDrag)
return (
<div className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}`}>
<div
className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}${renderStyle.type === 'vector' && renderStyle.filled ? ' is-filled-vector' : ''}`}
>
{playback && (
<motion.div
className="stage-playback-status"
@ -814,6 +825,7 @@ export function AvatarCanvas({
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={pathValue}
key={index}
style={nodeColorStyle(backNodeIds.current[index])}
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
/>
))}
@ -848,6 +860,7 @@ export function AvatarCanvas({
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={pathValue}
key={index}
style={nodeColorStyle(frontNodeIds.current[index])}
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
/>
))}

View File

@ -28,42 +28,188 @@ const documentFixture = (): StudioDocument => {
describe('Studio document', () => {
const storage = (value: string | null = null) => ({ getItem: () => value })
const bundledAvatarNames = [
'Memento',
'Apple Bite',
'Siamese',
'British Shorthair',
'Russian Blue',
'Orange Tabby',
'Black Cat',
'Dog',
'Brown Dog',
'Bear',
'Rabbit',
'Bun',
'Onee',
'Cubee',
'Nova',
'Citrus',
'Sphere',
'Cube',
'Capsule',
'Cylinder',
'Cone',
'Diamond',
'Willy',
'OneWorks Sphere',
'OneWorks Ellipse',
'OneWorks Square',
'OneWorks Rounded',
'OneWorks Capsule',
'OneWorks Teardrop',
'OneWorks Diamond',
'OneWorks Trapezoid',
'OneWorks Cone',
'OneWorks Frustum',
'OneWorks Half Cone',
'OneWorks Cloud',
'OneWorks Sun',
]
it('loads the bundled Studio snapshot when no local project exists', () => {
const document = loadStudioDocument(storage())
expect(document.library.avatars).toHaveLength(12)
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
'Onee',
'Cubee',
'Nova',
'Citrus',
'Sphere',
'Cube',
'Capsule',
'Cylinder',
'Cone',
'Diamond',
'Memento',
'Willy',
expect(document.library.avatars).toHaveLength(bundledAvatarNames.length)
expect(document.library.activeAvatarId).toBe('avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e')
expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames)
expect(
document.library.avatars
.filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
.every(avatar => avatar.colors.body === avatar.colors.eyes)
).toBe(true)
expect(document.library.avatars.slice(0, 12).map(avatar => avatar.body.primary.type)).toEqual([
'ghost',
'apple-bite',
'ellipse',
'ellipse',
'ellipse',
'ellipse',
'ellipse',
'trapezoid',
'trapezoid',
'trapezoid',
'trapezoid',
'sphere',
])
expect(document.library.avatars.some(avatar => avatar.id === 'memento-piglet')).toBe(false)
expect(document.library.avatars.some(avatar => avatar.id === 'oneworks-cat')).toBe(false)
expect(document.library.avatars.some(avatar => avatar.id === 'oneworks-cat-cow')).toBe(false)
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-willy')?.body.nodes
).toHaveLength(3)
expect(
document.library.avatars.find(avatar => avatar.id === 'oneworks-cloud')?.body.nodes
).toHaveLength(7)
expect(
document.library.avatars.find(avatar => avatar.id === 'oneworks-sun')?.body.nodes
).toHaveLength(8)
expect(
document.library.avatars
.filter(avatar =>
['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some(
id => avatar.id === `oneworks-${id}`
)
)
.every(avatar => avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true)
).toBe(true)
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.renderStyle
).toEqual({
type: 'vector',
filled: true,
})
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle
).toEqual({ type: 'vector', filled: true })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.body.primary
).toMatchObject({ width: 225, height: 220.5, depth: 171 })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.body.primary
).toMatchObject({ width: 216, height: 225, depth: 171 })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes
.positionXLeft
).toBe(defaultAvatarEyes.positionXLeft - 18)
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes
.positionXRight
).toBe(defaultAvatarEyes.positionXRight - 18)
expect(
document.library.avatars.find(avatar => avatar.id === 'oneworks-cat-siamese')?.colors
).toEqual({ body: '#ead7b8', eyes: '#281913' })
expect(
document.library.avatars.find(avatar => avatar.id === 'oneworks-cat-black')?.colors
).toEqual({ body: '#111419', eyes: '#eef2f5' })
expect(
document.library.avatars
.find(avatar => avatar.id === 'oneworks-cat-siamese')
?.body.nodes.map(node => node.color)
).toEqual(['#3c2118', '#3c2118'])
expect(document.library.avatars.find(avatar => avatar.id === 'oneworks-dog')?.colors).toEqual({
body: '#d4d0c8',
eyes: '#211f1d',
})
expect(
document.library.avatars.find(avatar => avatar.id === 'oneworks-dog-brown')?.colors
).toEqual({
body: '#e3b17f',
eyes: '#2b1d18',
})
expect(document.library.avatars.find(avatar => avatar.id === 'oneworks-bear')?.colors).toEqual({
body: '#a95f47',
eyes: '#2b1d18',
})
expect(
document.library.avatars
.find(avatar => avatar.id === 'oneworks-dog')
?.body.nodes.map(node => node.color)
).toEqual(['#be9675', '#be9675'])
expect(
document.library.avatars
.find(avatar => avatar.id === 'oneworks-dog-brown')
?.body.nodes.map(node => node.color)
).toEqual(['#a95f47', '#a95f47'])
expect(
document.library.avatars
.filter(avatar =>
['oneworks-dog', 'oneworks-dog-brown', 'oneworks-bear'].includes(avatar.id)
)
.map(avatar => ({
id: avatar.id,
hasBehavior: Boolean(avatar.behavior),
coloredExpressions:
avatar.behavior?.expressions.filter(
expression => expression.bodyColor || expression.eyeColor
).length ?? -1,
}))
).toEqual([
{ id: 'oneworks-dog', hasBehavior: true, coloredExpressions: 4 },
{ id: 'oneworks-dog-brown', hasBehavior: true, coloredExpressions: 4 },
{ id: 'oneworks-bear', hasBehavior: true, coloredExpressions: 4 },
])
expect(
document.library.avatars.every(avatar => avatar.colors.body === avatar.colors.eyes)
).toBe(true)
expect(document.library.avatars.slice(4).map(avatar => avatar.body.primary.type)).toEqual([
'sphere',
'cube',
'capsule',
'cylinder',
'cone',
'diamond',
'ghost',
'capsule',
])
expect(document.library.avatars.at(-1)?.body.nodes).toHaveLength(3)
document.library.avatars
.find(avatar => avatar.id === 'oneworks-bear')
?.body.nodes.map(node => node.color)
).toEqual(['#a95f47', '#a95f47'])
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#111316' })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors
).toEqual({ body: '#ff5558', eyes: '#111316' })
expect(
document.library.avatars
.find(avatar => avatar.id === 'primitive-apple-bite')
?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor)
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
).toEqual([
['angry-brows', '#ba3636', '#610000'],
['uneasy-left', '#adc3ff', undefined],
['scared-state-surprised-left', '#adc3ff', undefined],
['scared-state-uneasy-left', '#adc3ff', undefined],
])
expect(
document.library.avatars
.filter(avatar => avatar.id !== 'primitive-ghost')
@ -72,12 +218,20 @@ describe('Studio document', () => {
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20)
expect(
memento?.behavior?.expressions.every(
expression => !expression.bodyColor && !expression.eyeColor
)
memento?.behavior?.expressions.find(
expression => expression.semanticKey === 'onboarding-curious'
)?.positionYLeft
).toBe(-22)
expect(
memento?.behavior?.sequences.some(sequence => sequence.semanticKey === 'onboarding')
).toBe(true)
expect(document.expressions).toHaveLength(27)
expect(document.sequences).toHaveLength(23)
expect(
memento?.behavior?.expressions
.filter(expression => expression.bodyColor || expression.eyeColor)
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
).toEqual([['angry-brows', '#ba3636', '#610000']])
expect(document.expressions).toHaveLength(28)
expect(document.sequences).toHaveLength(24)
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
expect(
document.sequences
@ -90,11 +244,40 @@ describe('Studio document', () => {
.find(sequence => sequence.id === 'scared')
?.steps.map(step => step.expressionId)
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
expect(
document.sequences
.find(sequence => sequence.id === 'joyful')
?.steps.map(step => [step.expressionId, step.transitionMs, step.transition])
).toEqual([
['expression-11', 700, 'gentleSpring'],
['expression-joyful-arc', 700, 'gentleSpring'],
])
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
})
it('exports every imported OneWorks skin as a valid avatar definition', () => {
const document = loadStudioDocument(storage())
const importedAvatars = document.library.avatars.filter(avatar =>
avatar.id.startsWith('oneworks-')
)
expect(importedAvatars).toHaveLength(23)
importedAvatars.forEach(avatar => {
const result = createAvatarDefinition({
avatar,
behavior: avatar.behavior ?? {
expressions: document.expressions,
sequences: document.sequences,
},
})
expect(result.ok, avatar.name).toBe(true)
if (result.ok) expect(JSON.stringify(result.value)).not.toContain('NaN')
})
})
it('clears only the persisted Studio project', () => {
const removeItem = vi.fn()
@ -108,6 +291,122 @@ describe('Studio document', () => {
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
})
it('migrates previously saved bundled filled avatars to the filled treatment', () => {
const fallback = loadStudioDocument(storage())
const filledIds = new Set([
'primitive-ghost',
'primitive-apple-bite',
...fallback.library.avatars
.filter(avatar => avatar.id.startsWith('oneworks-'))
.map(avatar => avatar.id),
])
const legacy = {
...fallback,
library: {
...fallback.library,
avatars: fallback.library.avatars.map(avatar =>
filledIds.has(avatar.id)
? {
...avatar,
renderStyle: { type: 'vector' },
eyes:
avatar.id === 'primitive-apple-bite'
? { ...avatar.eyes, positionXLeft: 0, positionXRight: 0 }
: avatar.eyes,
body: {
...avatar.body,
nodes: avatar.body.nodes.map(node => ({ ...node, color: undefined })),
},
}
: avatar
),
},
} satisfies StudioDocument
const restored = loadStudioDocument(storage(JSON.stringify(legacy)))
const restoredById = new Map(restored.library.avatars.map(avatar => [avatar.id, avatar]))
;['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].forEach(id => {
expect(restoredById.get(`oneworks-${id}`)?.renderStyle).toEqual({
type: 'vector',
filled: true,
})
})
expect(restoredById.get('primitive-ghost')?.renderStyle).toEqual({
type: 'vector',
filled: true,
})
expect(restoredById.get('primitive-apple-bite')?.renderStyle).toEqual({
type: 'vector',
filled: true,
})
expect(restoredById.get('primitive-ghost')?.colors).toEqual({
body: '#ff9d45',
eyes: '#111316',
})
expect(restoredById.get('primitive-apple-bite')?.colors).toEqual({
body: '#ff5558',
eyes: '#111316',
})
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXLeft).toBe(-18)
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXRight).toBe(-18)
expect(
restoredById
.get('primitive-apple-bite')
?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor)
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
).toEqual([
['angry-brows', '#ba3636', '#610000'],
['uneasy-left', '#adc3ff', undefined],
['scared-state-surprised-left', '#adc3ff', undefined],
['scared-state-uneasy-left', '#adc3ff', undefined],
])
expect(restoredById.get('oneworks-rabbit')?.colors).toEqual({
body: '#eee9df',
eyes: '#292724',
})
expect(restoredById.get('oneworks-bun')?.colors).toEqual({
body: '#fff3d9',
eyes: '#241915',
})
expect(restoredById.get('oneworks-dog')?.colors).toEqual({
body: '#d4d0c8',
eyes: '#211f1d',
})
expect(restoredById.get('oneworks-dog')?.body.nodes.map(node => node.color)).toEqual([
'#be9675',
'#be9675',
])
expect(restoredById.get('oneworks-dog-brown')?.colors).toEqual({
body: '#e3b17f',
eyes: '#2b1d18',
})
expect(restoredById.get('oneworks-dog-brown')?.body.nodes.map(node => node.color)).toEqual([
'#a95f47',
'#a95f47',
])
expect(restoredById.get('oneworks-bear')?.colors).toEqual({
body: '#a95f47',
eyes: '#2b1d18',
})
expect(
restoredById
.get('oneworks-dog-brown')
?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor)
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
).toEqual([
['angry-brows', '#ba3636', '#610000'],
['uneasy-left', '#adc3ff', undefined],
['scared-state-surprised-left', '#adc3ff', undefined],
['scared-state-uneasy-left', '#adc3ff', undefined],
])
expect(restoredById.get('oneworks-bear')?.body.nodes.map(node => node.color)).toEqual([
'#a95f47',
'#a95f47',
])
expect(restoredById.get('oneworks-sun')?.renderStyle).toEqual({ type: 'vector' })
})
it('restores standalone built-in animations from the temporary idle pairs', () => {
const fallback = loadStudioDocument(storage())
const pairedDocument: StudioDocument = {
@ -133,34 +432,94 @@ describe('Studio document', () => {
expect(restored.playback).toEqual({ stateId: 'proud', playing: true })
})
it('adds the joyful arc expression to an existing local project', () => {
const fallback = loadStudioDocument(storage())
const localDocument = {
...fallback,
expressions: fallback.expressions.filter(
expression => expression.semanticKey !== 'joyful-arc'
),
}
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc')
})
it('adds the joyful animation to an existing local project', () => {
const fallback = loadStudioDocument(storage())
const localDocument = {
...fallback,
sequences: fallback.sequences.filter(sequence => sequence.id !== 'joyful'),
}
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
const joyful = restored.sequences.find(sequence => sequence.id === 'joyful')
expect(joyful?.steps.map(step => step.expressionId)).toEqual([
'expression-11',
'expression-joyful-arc',
])
expect(joyful?.blink.enabled).toBe(false)
})
it('updates the bundled joyful arc thickness in an existing local project', () => {
const fallback = loadStudioDocument(storage())
const legacyExpressions = fallback.expressions.map(expression =>
expression.semanticKey === 'joyful-arc'
? { ...expression, heightLeft: 11, heightRight: 11 }
: expression
)
const localDocument = {
...fallback,
expressions: legacyExpressions,
library: {
...fallback.library,
avatars: fallback.library.avatars.map((avatar, index) =>
index === 0
? {
...avatar,
behavior: { expressions: legacyExpressions, sequences: fallback.sequences },
}
: avatar
),
},
}
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
const joyfulArc = restored.expressions.find(
expression => expression.semanticKey === 'joyful-arc'
)
expect(joyfulArc?.heightLeft).toBe(15)
expect(joyfulArc?.heightRight).toBe(15)
const avatarJoyfulArc = restored.library.avatars[0].behavior?.expressions.find(
expression => expression.semanticKey === 'joyful-arc'
)
expect(avatarJoyfulArc?.heightLeft).toBe(15)
expect(avatarJoyfulArc?.heightRight).toBe(15)
expect(
restored.library.avatars[0].behavior?.sequences.some(sequence => sequence.id === 'joyful')
).toBe(true)
})
it('removes retired bundled avatars from a locally saved project', () => {
const fallback = loadStudioDocument(storage())
const retired = createAvatar('Freddy')
retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed'
const retiredFox = createAvatar('Продовый песец')
retiredFox.id = 'memento-arctic-fox'
const localDocument: StudioDocument = {
...fallback,
library: {
activeAvatarId: retired.id,
avatars: [retired, ...fallback.library.avatars],
activeAvatarId: retiredFox.id,
avatars: [retired, retiredFox, ...fallback.library.avatars],
},
}
const document = loadStudioDocument(storage(JSON.stringify(localDocument)))
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
'Onee',
'Cubee',
'Nova',
'Citrus',
'Sphere',
'Cube',
'Capsule',
'Cylinder',
'Cone',
'Diamond',
'Memento',
'Willy',
])
expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames)
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
})
@ -183,6 +542,35 @@ describe('Studio document', () => {
).toBe(defaultAvatarEyes.positionYLeft - 20)
})
it('simplifies bundled animal names in an existing local project', () => {
const fallback = loadStudioDocument(storage())
const legacyNames: Record<string, string> = {
'oneworks-dog': 'OneWorks Dog',
'oneworks-bear': 'OneWorks Bear',
'oneworks-rabbit': 'OneWorks Rabbit',
'oneworks-bun': 'OneWorks Bun',
}
const legacy = {
...fallback,
library: {
...fallback.library,
avatars: fallback.library.avatars.map(avatar => ({
...avatar,
name: legacyNames[avatar.id] ?? avatar.name,
})),
},
}
const restored = loadStudioDocument(storage(JSON.stringify(legacy)))
const restoredById = new Map(restored.library.avatars.map(avatar => [avatar.id, avatar]))
expect(restoredById.has('oneworks-cat')).toBe(false)
expect(restoredById.get('oneworks-dog')?.name).toBe('Dog')
expect(restoredById.get('oneworks-bear')?.name).toBe('Bear')
expect(restoredById.get('oneworks-rabbit')?.name).toBe('Rabbit')
expect(restoredById.get('oneworks-bun')?.name).toBe('Bun')
})
it('restores bundled semantic keys in a legacy local project', () => {
const fallback = loadStudioDocument(storage())
const legacy = structuredClone(fallback)

View File

@ -12,8 +12,115 @@ import {
import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import {
avatarBodyOutlineWidth,
useAvatarBodyColorOverride,
} from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
import type { StudioController } from '@/features/studio/useStudioController'
function LiveAvatarPreview({
avatar,
colors,
scene,
expressionBodyColor,
}: {
avatar: StudioController['activeAvatar']
colors: RenderedColors
scene: RenderedScene
expressionBodyColor?: string
}) {
const outlineWidth = avatarBodyOutlineWidth()
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
const fixedSkinColors =
avatar.behavior?.expressions.every(
expression => !expression.bodyColor && !expression.eyeColor
) ?? false
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, avatar.colors.body)
const bodyColor = fixedSkinColors && !bodyColorIsOverridden ? avatar.colors.body : colors.body
const eyeColor = fixedSkinColors ? avatar.colors.eyes : colors.eyes
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
const nodeColor = (id: string | null | undefined) =>
inheritBodyColor
? bodyColor
: (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor)
const bodyFill = filled ? bodyColor : '#ffffff'
const clipId = `live-avatar-${avatar.id}`
if (avatar.renderStyle.type === 'pixel') {
return (
<LivePixelAvatarCanvas
className="avatar-preview avatar-preview-live"
scene={scene}
colors={colors}
style={avatar.renderStyle}
/>
)
}
return (
<svg
className="avatar-preview avatar-preview-live"
viewBox="-150 -150 300 300"
aria-hidden="true"
>
<defs>
<clipPath id={clipId}>
<motion.path d={scene.headPath} />
</clipPath>
</defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => {
const color = nodeColor(scene.backNodeIds.current[index])
return (
<motion.path
className="preview-head"
d={pathValue}
key={`back-${index}`}
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
/>
)
})}
<motion.path
className="preview-head"
d={scene.headPath}
style={{
fill: bodyFill,
stroke: bodyColor,
strokeWidth: outlineWidth,
}}
/>
<g clipPath={`url(#${clipId})`}>
<motion.path
className="preview-eye"
d={scene.leftPath}
opacity={scene.leftOpacity}
style={{ fill: eyeColor }}
/>
<motion.path
className="preview-eye"
d={scene.rightPath}
opacity={scene.rightOpacity}
style={{ fill: eyeColor }}
/>
</g>
{scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index])
return (
<motion.path
className="preview-head"
d={pathValue}
key={`front-${index}`}
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
/>
)
})}
</motion.g>
</svg>
)
}
export function AvatarPage({ controller }: { controller: StudioController }) {
const {
activateAvatar,
@ -29,8 +136,12 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
draggingAvatarId,
duplicateAvatar,
expressions,
playbackStatus,
previewAvatarMove,
reduceMotion,
renderedColors,
renderedExpressionBodyColor,
renderedScene,
setDeleteAvatarOpen,
setDraggingAvatarId,
setFocusAvatarName,
@ -93,15 +204,24 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
activateAvatar(avatar.id, true)
}}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
renderStyle={avatar.renderStyle}
id={`avatar-${avatar.id}`}
/>
{activeAvatarId === avatar.id && playbackStatus !== 'stopped' ? (
<LiveAvatarPreview
avatar={avatar}
colors={renderedColors}
scene={renderedScene}
expressionBodyColor={renderedExpressionBodyColor}
/>
) : (
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
renderStyle={avatar.renderStyle}
id={`avatar-${avatar.id}`}
/>
)}
<span>{avatar.name}</span>
</Button>
}

View File

@ -176,7 +176,11 @@ export function BodyConstructionAccordion({
unit="u"
onChange={depth => updateSurface({ ...surface, depth })}
/>
{(surface.type === 'cube' || surface.type === 'diamond') && (
{(surface.type === 'cube' ||
surface.type === 'diamond' ||
surface.type === 'trapezoid' ||
surface.type === 'frustum' ||
surface.type === 'half-cone') && (
<NumericField
label="Rondeur"
value={surface.roundness}
@ -231,6 +235,29 @@ export function BodyConstructionAccordion({
/>
</>
)}
{surface.type === 'trapezoid' && (
<NumericField
label="Échelle du sommet"
value={surface.topScale ?? 0.82}
min={0.05}
max={2}
step={0.01}
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={topScale => updateSurface({ ...surface, topScale })}
/>
)}
{surface.type === 'half-cone' && (
<NumericField
label="Angle de coupe"
value={surface.cutAngle ?? 0}
min={-180}
max={180}
step={1}
unit="°"
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
onChange={cutAngle => updateSurface({ ...surface, cutAngle })}
/>
)}
</div>
</AccordionContent>
</AccordionItem>
@ -320,7 +347,10 @@ export function BodyConstructionAccordion({
))}
{(selectedBodyNode.surface.type === 'cube' ||
selectedBodyNode.surface.type === 'diamond' ||
selectedBodyNode.surface.type === 'cylinder') && (
selectedBodyNode.surface.type === 'cylinder' ||
selectedBodyNode.surface.type === 'trapezoid' ||
selectedBodyNode.surface.type === 'frustum' ||
selectedBodyNode.surface.type === 'half-cone') && (
<NumericField
label="Rondeur"
value={selectedBodyNode.surface.roundness}
@ -381,6 +411,37 @@ export function BodyConstructionAccordion({
/>
</>
)}
{selectedBodyNode.surface.type === 'trapezoid' && (
<NumericField
label="Échelle du sommet"
value={selectedBodyNode.surface.topScale ?? 0.82}
min={0.05}
max={2}
step={0.01}
onChange={topScale =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, topScale },
}))
}
/>
)}
{selectedBodyNode.surface.type === 'half-cone' && (
<NumericField
label="Angle de coupe"
value={selectedBodyNode.surface.cutAngle ?? 0}
min={-180}
max={180}
step={1}
unit="°"
onChange={cutAngle =>
updateSelectedBodyNode(currentNode => ({
...currentNode,
surface: { ...currentNode.surface, cutAngle },
}))
}
/>
)}
</div>
<div className="body-transform-grid">
<div>

View File

@ -72,6 +72,7 @@ import {
ExpressionCard,
ExpressionPreview,
ExpressionWorkspace,
LiveExpressionPreview,
} from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import { randomSnapshotPalette } from '@/features/export/snapshotPalette'
@ -488,6 +489,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
projectImportError,
projectImportRef,
reduceMotion,
renderedColors,
renderedExpressionBodyColor,
renderedScene,
renameActiveAvatar,
runtimeDefinitionResult,
runtimeCopyStatus,
@ -1245,17 +1249,30 @@ export function StudioInspector({ controller }: { controller: StudioController }
onClick={() => launchSequence(sequence)}
onDoubleClick={() => openSequenceEditor(sequence)}
>
<ExpressionPreview
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
{activeSequence?.id === sequence.id &&
playbackStatus !== 'stopped' ? (
<LiveExpressionPreview
scene={renderedScene}
colors={renderedColors}
baseBodyColor={activeAvatar.colors.body}
bodyNodes={bodyNodes}
renderStyle={activeAvatar.renderStyle}
expressionBodyColor={renderedExpressionBodyColor}
id={`state-card-${sequence.id}`}
/>
) : (
<ExpressionPreview
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
)}
{animationSemanticKeyError(sequence) && (
<i
className="runtime-key-missing"

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { defaultExpression } from '@/features/avatar/presets'
import { avatarBodyOutlineWidth } from '@/features/rendering/avatarAppearance'
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
@ -35,6 +36,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
previewExpressionDraft,
previewSelectedBodyNode,
renderedColors,
renderedExpressionBodyColor,
renderedRotationGizmo,
renderedScene,
selectBodyNode,
@ -72,8 +74,10 @@ export function StudioStage({ controller }: { controller: StudioController }) {
expression={canvasExpression}
avatarEyes={activeAvatarEyes}
surface={surface}
bodyNodes={activeAvatar.body.nodes}
scene={renderedScene}
colors={renderedColors}
expressionBodyColor={renderedExpressionBodyColor}
renderStyle={activeAvatar.renderStyle}
rotationGizmo={renderedRotationGizmo}
showWire={showWire}
@ -114,6 +118,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
{
'--avatar-body-color': renderedColors.body,
'--avatar-eye-color': renderedColors.eyes,
'--avatar-outline-width': avatarBodyOutlineWidth(),
} as CSSProperties
}
>

View File

@ -1,56 +1,13 @@
import { useEffect, useState } from 'react'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { StudioDialogs } from '@/features/studio/components/StudioDialogs'
import { StudioInspector } from '@/features/studio/components/StudioInspector'
import { StudioStage } from '@/features/studio/components/StudioStage'
import type { StudioController } from '@/features/studio/useStudioController'
const STACKED_LAYOUT_QUERY = '(max-width: 980px)'
export function StudioView(controller: StudioController) {
const [stackedLayout, setStackedLayout] = useState(
() => window.matchMedia(STACKED_LAYOUT_QUERY).matches
)
useEffect(() => {
const mediaQuery = window.matchMedia(STACKED_LAYOUT_QUERY)
const updateStackedLayout = () => setStackedLayout(mediaQuery.matches)
mediaQuery.addEventListener('change', updateStackedLayout)
return () => mediaQuery.removeEventListener('change', updateStackedLayout)
}, [])
return (
<div className="studio-root" lang={controller.language}>
{stackedLayout ? (
<ResizablePanelGroup
className="studio studio-mobile-resizable"
orientation="vertical"
resizeTargetMinimumSize={{ coarse: 40, fine: 12 }}
>
<ResizablePanel
id="preview"
className="mobile-stage-panel"
defaultSize="42%"
minSize={180}
>
<StudioStage controller={controller} />
</ResizablePanel>
<ResizableHandle
className="studio-resize-handle"
withHandle
aria-label={controller.t('Redimensionner l’aperçu et l’éditeur')}
/>
<ResizablePanel id="editor" className="mobile-inspector-panel" minSize={260}>
<StudioInspector controller={controller} />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="studio">
<StudioStage controller={controller} />
<StudioInspector controller={controller} />
</div>
)}
<div className="studio studio-catalog-only">
<StudioInspector controller={controller} />
</div>
<StudioDialogs controller={controller} />
</div>
)

View File

@ -17,8 +17,8 @@
"nodes": []
},
"colors": {
"body": "#5b7fe5",
"eyes": "#111316"
"body": "#7c89ff",
"eyes": "#50d7fe"
},
"eyes": {
"widthLeft": 20,
@ -113,8 +113,8 @@
]
},
"colors": {
"body": "#e6855c",
"eyes": "#ffffff"
"body": "#ff9d45",
"eyes": "#ffda1a"
},
"eyes": {
"widthLeft": 28.934879954481378,
@ -147,8 +147,8 @@
"nodes": []
},
"colors": {
"body": "#ffcf24",
"eyes": "#000000"
"body": "#ffda1a",
"eyes": "#ffda1a"
},
"eyes": {
"widthLeft": 20,
@ -178,8 +178,8 @@
"nodes": []
},
"colors": {
"body": "#55b6c3",
"eyes": "#111316"
"body": "#1ad7e3",
"eyes": "#1ad7e3"
},
"eyes": {
"widthLeft": 13.26171875,
@ -209,8 +209,8 @@
"nodes": []
},
"colors": {
"body": "#000000",
"eyes": "#ffffff"
"body": "#df48f3",
"eyes": "#ff4b6f"
},
"eyes": {
"widthLeft": 20,
@ -409,8 +409,8 @@
]
},
"colors": {
"body": "#e69a5c",
"eyes": "#111316"
"body": "#be9675",
"eyes": "#ff9d45"
},
"eyes": {
"widthLeft": 20,
@ -483,8 +483,8 @@
]
},
"colors": {
"body": "#ffc2e9",
"eyes": "#3e4e65"
"body": "#ff4b6f",
"eyes": "#df48f3"
},
"eyes": {
"widthLeft": 20,
@ -599,8 +599,8 @@
]
},
"colors": {
"body": "#c9cbcf",
"eyes": "#111316"
"body": "#50d7fe",
"eyes": "#1a9cff"
},
"eyes": {
"widthLeft": 13.942812499999993,
@ -630,8 +630,8 @@
"nodes": []
},
"colors": {
"body": "#e65c5c",
"eyes": "#111316"
"body": "#ff5558",
"eyes": "#ff5558"
},
"eyes": {
"widthLeft": 19.975390625,
@ -664,8 +664,8 @@
"nodes": []
},
"colors": {
"body": "#dbe2f5",
"eyes": "#111316"
"body": "#1a9cff",
"eyes": "#1a9cff"
},
"eyes": {
"widthLeft": 13.389062500000001,
@ -1253,6 +1253,29 @@
"eyeMotion": "shake",
"bodyMotion": "slowDrift",
"bodyColor": "#adc3ff"
},
{
"id": "expression-joyful-arc",
"semanticKey": "joyful-arc",
"headX": -8.75234375,
"headY": -8.743359375,
"headZ": -10.773828125,
"widthLeft": 54,
"widthRight": 54,
"heightLeft": 15,
"heightRight": 15,
"curvatureLeft": 1,
"curvatureRight": 1,
"spacing": 69.276171875,
"positionXLeft": 0,
"positionXRight": 0,
"positionYLeft": -18,
"positionYRight": -18,
"leftAngle": 0,
"rightAngle": 0,
"perspective": 1,
"eyeMotion": "none",
"bodyMotion": "none"
}
],
"sequences": [
@ -1699,7 +1722,7 @@
},
{
"id": "angry-step-1",
"expressionId": "expression-16",
"expressionId": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
"holdMs": 2300,
"transitionMs": 500,
"transition": "smooth"
@ -1798,6 +1821,38 @@
"durationMs": 260
}
},
{
"id": "joyful",
"semanticKey": "joyful",
"name": "joyful",
"group": "Réactions",
"description": "Morphing fluide entre les expressions 11 et 27.",
"builtIn": true,
"playbackMode": "loop",
"steps": [
{
"id": "joyful-step-0",
"expressionId": "expression-11",
"holdMs": 2300,
"transitionMs": 700,
"transition": "gentleSpring"
},
{
"id": "joyful-step-1",
"expressionId": "expression-joyful-arc",
"holdMs": 2300,
"transitionMs": 700,
"transition": "gentleSpring"
}
],
"blink": {
"enabled": false,
"initialDelayMs": 2100,
"minIntervalMs": 2800,
"maxIntervalMs": 5000,
"durationMs": 260
}
},
{
"id": "curious",
"semanticKey": "curious",
@ -2089,7 +2144,7 @@
},
{
"id": "scared-step-1",
"expressionId": "expression-21",
"expressionId": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
"holdMs": 2300,
"transitionMs": 500,
"transition": "smooth"

View File

@ -1,4 +1,5 @@
import {
ensurePrimitiveBundledAvatars,
parseAvatarLibrary,
parseExpressions,
restoreLegacyBehaviorSemanticKeys,
@ -99,17 +100,181 @@ const createBundledStudioDocument = () => {
return parseStudioDocument(snapshot, snapshot)
}
const restoreStandaloneBundledAnimations = (
document: StudioDocument,
fallback: StudioDocument
): Pick<StudioDocument, 'sequences' | 'playback'> => {
const bundledIds = new Set(fallback.sequences.map(sequence => sequence.id))
const targetByPairId = new Map(
fallback.sequences
.filter(sequence => sequence.id !== 'idle')
.map(sequence => [`idle-${sequence.id}`, sequence.id])
)
if (!document.sequences.some(sequence => targetByPairId.has(sequence.id))) {
return { sequences: document.sequences, playback: document.playback }
}
const custom = document.sequences.filter(
sequence => !bundledIds.has(sequence.id) && !targetByPairId.has(sequence.id)
)
const stateId =
document.playback.stateId === null
? null
: (targetByPairId.get(document.playback.stateId) ?? document.playback.stateId)
return {
sequences: [...fallback.sequences, ...custom],
playback: { ...document.playback, stateId },
}
}
const restoreSignatureAnimationSteps = (
sequences: AvatarSequence[],
fallback: AvatarSequence[],
signatureIds = new Set(['angry', 'scared', 'joyful'])
) => {
const fallbackById = new Map(fallback.map(sequence => [sequence.id, sequence]))
const restored = sequences.map(sequence => {
if (!signatureIds.has(sequence.id)) return sequence
const canonical = fallbackById.get(sequence.id)
if (!canonical) return sequence
if (sequence.id === 'joyful') {
return {
...canonical,
steps: canonical.steps.map(step => ({ ...step })),
blink: { ...canonical.blink },
}
}
const steps = canonical.steps.map((canonicalStep, index) => ({
...(sequence.steps[index] ?? canonicalStep),
id: canonicalStep.id,
expressionId: canonicalStep.expressionId,
}))
const unchanged =
steps.length === sequence.steps.length &&
steps.every(
(step, index) =>
step.id === sequence.steps[index].id &&
step.expressionId === sequence.steps[index].expressionId
)
return unchanged ? sequence : { ...sequence, steps }
})
const joyful = fallbackById.get('joyful')
if (!joyful || restored.some(sequence => sequence.id === joyful.id)) return restored
return [
...restored,
{
...joyful,
steps: joyful.steps.map(step => ({ ...step })),
blink: { ...joyful.blink },
},
]
}
const restoreSignatureExpressions = (
expressions: Expression[],
fallback: Expression[],
addMissing = true
) => {
const signatureKeys = new Set(['joyful-arc'])
const fallbackByKey = new Map(
fallback
.filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey))
.map(expression => [expression.semanticKey!, expression])
)
let changed = false
const restored = expressions.map(expression => {
const canonical = expression.semanticKey ? fallbackByKey.get(expression.semanticKey) : undefined
if (
!canonical ||
(expression.curvatureLeft === canonical.curvatureLeft &&
expression.curvatureRight === canonical.curvatureRight &&
expression.heightLeft === canonical.heightLeft &&
expression.heightRight === canonical.heightRight)
) {
return expression
}
changed = true
return {
...expression,
curvatureLeft: canonical.curvatureLeft,
curvatureRight: canonical.curvatureRight,
heightLeft: canonical.heightLeft,
heightRight: canonical.heightRight,
}
})
const existingKeys = new Set(restored.map(expression => expression.semanticKey))
const missing = addMissing
? fallback.filter(
expression =>
expression.semanticKey &&
signatureKeys.has(expression.semanticKey) &&
!existingKeys.has(expression.semanticKey)
)
: []
if (missing.length) return [...restored, ...missing.map(expression => ({ ...expression }))]
return changed ? restored : expressions
}
const restoreAvatarSignatureBehavior = (
library: AvatarLibrary,
fallbackExpressions: Expression[],
fallbackSequences: AvatarSequence[]
) => {
let changed = false
const avatars = library.avatars.map(avatar => {
if (!avatar.behavior) return avatar
const expressions = restoreSignatureExpressions(
avatar.behavior.expressions,
fallbackExpressions,
false
)
const availableExpressionIds = new Set(expressions.map(expression => expression.id))
const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful')
const sequences =
joyful && joyful.steps.every(step => availableExpressionIds.has(step.expressionId))
? restoreSignatureAnimationSteps(
avatar.behavior.sequences,
fallbackSequences,
new Set(['angry', 'joyful'])
)
: avatar.behavior.sequences
if (expressions === avatar.behavior.expressions && sequences === avatar.behavior.sequences) {
return avatar
}
changed = true
return { ...avatar, behavior: { expressions, sequences } }
})
return changed ? { ...library, avatars } : library
}
export const loadStudioDocument = (
storage: Pick<Storage, 'getItem'> = window.localStorage
): StudioDocument => {
const fallback = createBundledStudioDocument()
try {
return parseStudioDocument(
const document = parseStudioDocument(
JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'),
fallback
)
const expressions = restoreSignatureExpressions(document.expressions, fallback.expressions)
const hydrated = expressions === document.expressions ? document : { ...document, expressions }
const restored = restoreStandaloneBundledAnimations(hydrated, fallback)
const sequences = restoreSignatureAnimationSteps(restored.sequences, fallback.sequences)
const library = restoreAvatarSignatureBehavior(
ensurePrimitiveBundledAvatars(hydrated.library),
fallback.expressions,
fallback.sequences
)
return {
...hydrated,
library,
...restored,
sequences,
}
} catch {
return fallback
return {
...fallback,
library: ensurePrimitiveBundledAvatars(fallback.library),
}
}
}

View File

@ -38,6 +38,7 @@ import {
getSequenceSpring,
readSequenceClock,
remapSequencesAfterExpressionDelete,
scaleSpringDynamics,
type AvatarSequence,
type SequenceStep,
} from '@/features/animation/sequences'
@ -128,6 +129,9 @@ import {
type StudioDocument,
} from '@/features/studio/studioDocument'
const JOYFUL_ARC_SEMANTIC_KEY = 'joyful-arc'
const JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER = 1.5
export function useStudioController() {
const { language, setLanguage, t } = useStudioLanguage()
const [mode, setMode] = useState<Mode>('avatars')
@ -340,6 +344,7 @@ export function useStudioController() {
const lastTransitionTime = useRef<number | null>(null)
const lastInspectorFrame = useRef(0)
const springSpeedRef = useRef(springSpeed)
const expressionEntrySpeedMultiplier = useRef(1)
const sequenceTransitionRef = useRef<Pick<SequenceStep, 'transitionMs' | 'transition'>>({
transitionMs: 500,
transition: 'smooth',
@ -525,6 +530,8 @@ export function useStudioController() {
transitionMs: 500,
transition: 'smooth',
}
expressionEntrySpeedMultiplier.current =
next.semanticKey === JOYFUL_ARC_SEMANTIC_KEY ? JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER : 1
setActiveExpression(index)
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (reduceMotion || transitionSettings?.transitionMs === 0) {
@ -576,7 +583,10 @@ export function useStudioController() {
? progress * progress * (3 - 2 * progress)
: transitionSettings.transition === 'snappy'
? 1 - (1 - progress) ** 3
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress)
: transitionSettings.transition === 'gentleSpring'
? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) /
(1 - Math.exp(-9) * Math.cos(5))
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress)
const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion }
expressionFields.forEach(field => {
animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased
@ -618,11 +628,11 @@ export function useStudioController() {
if (avatar) {
const targetColors = resolveColors(next, avatar.colors)
bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, {
duration: 0.35,
duration: 0.35 / expressionEntrySpeedMultiplier.current,
ease: 'easeInOut',
})
eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, {
duration: 0.35,
duration: 0.35 / expressionEntrySpeedMultiplier.current,
ease: 'easeInOut',
})
}
@ -644,16 +654,23 @@ export function useStudioController() {
const previousTime = lastTransitionTime.current ?? time
const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30)
lastTransitionTime.current = time
const { stiffness, damping } = getSequenceSpring(
sequenceTransitionRef.current.transition,
sequenceTransitionRef.current.transitionMs,
springSpeedRef.current
const { stiffness, damping } = scaleSpringDynamics(
getSequenceSpring(
sequenceTransitionRef.current.transition,
sequenceTransitionRef.current.transitionMs,
springSpeedRef.current
),
expressionEntrySpeedMultiplier.current
)
const mass = 0.85
const currentExpression = displayedPose.current.expression
if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) {
if (retargetStartedAt.current < 0) retargetStartedAt.current = time
const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1)
const linearProgress = Math.min(
(time - retargetStartedAt.current) /
(RETARGET_BLEND_MS / expressionEntrySpeedMultiplier.current),
1
)
const smoothProgress =
linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10)
const blendedTarget = { ...retargetTo.current }
@ -815,6 +832,7 @@ export function useStudioController() {
const nextExpressions = nextBehavior.expressions
const nextSequences = nextBehavior.sequences
stopTransition(true)
stopColorTransitions()
activeAvatarIdRef.current = id
surfaceRef.current = avatar.body.primary
bodyNodesRef.current = avatar.body.nodes
@ -849,9 +867,23 @@ export function useStudioController() {
modeRef.current = 'manual'
setMode('manual')
}
const avatarStateExpression =
nextExpressions.find(expression => expression.id === currentStateExpression.id) ??
nextExpressions.find(
expression =>
currentStateExpression.semanticKey &&
expression.semanticKey === currentStateExpression.semanticKey
)
const selectedExpression = nextActiveSequence
? currentStateExpression
? { ...(avatarStateExpression ?? currentStateExpression) }
: { ...(nextExpressions[0] ?? defaultExpression) }
const usesFixedSkinColors = nextExpressions.every(
expression => !expression.bodyColor && !expression.eyeColor
)
if (usesFixedSkinColors) {
delete selectedExpression.bodyColor
delete selectedExpression.eyeColor
}
const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression
setExpression(nextExpression)
if (editBody) {
@ -1537,6 +1569,16 @@ export function useStudioController() {
: activeSequence.name
: null
const expressionById = new Map(expressions.map(item => [item.id, item]))
const playbackExpression =
activeSequence && playbackVisual.position !== null
? expressionById.get(activeSequence.steps[playbackVisual.position]?.expressionId ?? '')
: null
const activeSequenceUsesBodyColor =
activeSequence?.steps.some(step => expressionById.get(step.expressionId)?.bodyColor) ?? false
const renderedExpressionBodyColor =
playbackExpression?.bodyColor ??
expression.bodyColor ??
(activeSequenceUsesBodyColor ? renderedColors.body.get() : undefined)
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
t(
issue === 'missing_semantic_key'
@ -1999,6 +2041,7 @@ export function useStudioController() {
reduceMotion,
renameActiveAvatar,
renderedColors,
renderedExpressionBodyColor,
renderedRotationGizmo,
renderedScene,
runtimeDefinitionResult,

View File

@ -100,6 +100,15 @@ const english: Record<string, string> = {
'Couleur des yeux': 'Eye color',
Largeur: 'Width',
Hauteur: 'Height',
Ellipse: 'Ellipse',
Carré: 'Square',
Arrondi: 'Rounded',
Goutte: 'Teardrop',
Trapèze: 'Trapezoid',
'Tronc de cône': 'Frustum',
'Demi-cône': 'Half cone',
'Échelle du sommet': 'Top scale',
'Angle de coupe': 'Cut angle',
Échelle: 'Scale',
'Taille proportionnelle': 'Proportional size',
'Œil gauche': 'Left eye',
@ -504,6 +513,7 @@ const english: Record<string, string> = {
'Durée de transition': 'Transition duration',
Transition: 'Transition',
Ressort: 'Spring',
'Ressort léger': 'Gentle spring',
Douce: 'Smooth',
Rapide: 'Snappy',
'Ajouter une expression': 'Add expression',
@ -588,6 +598,7 @@ const english: Record<string, string> = {
'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.',
'Morphing fluide entre les expressions 11 et 27.': 'Smooth morph between expressions 11 and 27.',
'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.',
sleeping: 'sleeping',
waking: 'waking',
@ -602,6 +613,7 @@ const english: Record<string, string> = {
angry: 'angry',
drowsy: 'drowsy',
happy: 'happy',
joyful: 'joyful',
curious: 'curious',
confused: 'confused',
bored: 'bored',
@ -655,6 +667,7 @@ const frenchStates: Record<string, string> = {
angry: 'en colère',
drowsy: 'somnolent',
happy: 'heureux',
joyful: 'joyeux',
curious: 'curieux',
confused: 'confus',
bored: 'ennuyé',