feat: expand avatar skins and expressions
This commit is contained in:
parent
be77e778d1
commit
9cca5fa079
28
THIRD_PARTY_NOTICES.md
Normal file
28
THIRD_PARTY_NOTICES.md
Normal 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.
|
||||||
@ -13,8 +13,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#5b7fe5",
|
"body": "#7c89ff",
|
||||||
"eyes": "#111316"
|
"eyes": "#50d7fe"
|
||||||
},
|
},
|
||||||
"expressions": {
|
"expressions": {
|
||||||
"neutral": {
|
"neutral": {
|
||||||
@ -27,6 +27,7 @@
|
|||||||
"left": {
|
"left": {
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"height": 50,
|
"height": 50,
|
||||||
|
"curvature": 0,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": -7,
|
"y": -7,
|
||||||
"angle": 0
|
"angle": 0
|
||||||
@ -34,6 +35,7 @@
|
|||||||
"right": {
|
"right": {
|
||||||
"width": 20,
|
"width": 20,
|
||||||
"height": 50,
|
"height": 50,
|
||||||
|
"curvature": 0,
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": -7,
|
"y": -7,
|
||||||
"angle": 0
|
"angle": 0
|
||||||
@ -835,6 +837,37 @@
|
|||||||
"colors": {
|
"colors": {
|
||||||
"body": "#adc3ff"
|
"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": [
|
"expressionOrder": [
|
||||||
@ -865,7 +898,8 @@
|
|||||||
"suspicious-right",
|
"suspicious-right",
|
||||||
"shy-downward",
|
"shy-downward",
|
||||||
"angry-brows",
|
"angry-brows",
|
||||||
"uneasy-left"
|
"uneasy-left",
|
||||||
|
"joyful-arc"
|
||||||
],
|
],
|
||||||
"animations": {
|
"animations": {
|
||||||
"sleeping": {
|
"sleeping": {
|
||||||
@ -1258,7 +1292,7 @@
|
|||||||
"transition": "smooth"
|
"transition": "smooth"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expression": "angry-left",
|
"expression": "angry-brows",
|
||||||
"holdMs": 2300,
|
"holdMs": 2300,
|
||||||
"transitionMs": 500,
|
"transitionMs": 500,
|
||||||
"transition": "smooth"
|
"transition": "smooth"
|
||||||
@ -1353,6 +1387,35 @@
|
|||||||
"group": "Réactions"
|
"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": {
|
"curious": {
|
||||||
"playbackMode": "loop",
|
"playbackMode": "loop",
|
||||||
"steps": [
|
"steps": [
|
||||||
@ -1608,7 +1671,7 @@
|
|||||||
"transition": "smooth"
|
"transition": "smooth"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expression": "surprised-wide-left",
|
"expression": "uneasy-left",
|
||||||
"holdMs": 2300,
|
"holdMs": 2300,
|
||||||
"transitionMs": 500,
|
"transitionMs": 500,
|
||||||
"transition": "smooth"
|
"transition": "smooth"
|
||||||
@ -1718,6 +1781,7 @@
|
|||||||
"angry",
|
"angry",
|
||||||
"drowsy",
|
"drowsy",
|
||||||
"happy",
|
"happy",
|
||||||
|
"joyful",
|
||||||
"curious",
|
"curious",
|
||||||
"confused",
|
"confused",
|
||||||
"surprised",
|
"surprised",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
bodyFromDefinition,
|
bodyFromDefinition,
|
||||||
createAvatarPlaybackState,
|
createAvatarPlaybackState,
|
||||||
expressionFromDefinition,
|
expressionFromDefinition,
|
||||||
|
interpolatePose,
|
||||||
parseAvatarDefinition,
|
parseAvatarDefinition,
|
||||||
playAvatarAnimation,
|
playAvatarAnimation,
|
||||||
pauseAvatarPlayback,
|
pauseAvatarPlayback,
|
||||||
@ -13,7 +14,11 @@ import {
|
|||||||
resumeAvatarPlayback,
|
resumeAvatarPlayback,
|
||||||
resolveAnimation,
|
resolveAnimation,
|
||||||
sampleAvatarFrame,
|
sampleAvatarFrame,
|
||||||
|
surfaceFrontSampleAt,
|
||||||
|
surfacePresets,
|
||||||
|
surfaceSampleAt,
|
||||||
type AvatarDefinition,
|
type AvatarDefinition,
|
||||||
|
type SurfaceType,
|
||||||
} from '../index'
|
} from '../index'
|
||||||
|
|
||||||
const expression = {
|
const expression = {
|
||||||
@ -66,7 +71,64 @@ const definition: AvatarDefinition = {
|
|||||||
animationOrder: ['idle'],
|
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', () => {
|
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', () => {
|
it('loads a JSON definition and resolves an explicit semantic animation', () => {
|
||||||
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
||||||
expect(parsed.ok).toBe(true)
|
expect(parsed.ok).toBe(true)
|
||||||
@ -241,6 +303,74 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
expect(scene.colors).toEqual(definition.colors)
|
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', () => {
|
it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
|
||||||
const narrow: AvatarDefinition = {
|
const narrow: AvatarDefinition = {
|
||||||
...definition,
|
...definition,
|
||||||
|
|||||||
@ -71,11 +71,19 @@
|
|||||||
"mickey",
|
"mickey",
|
||||||
"cursor",
|
"cursor",
|
||||||
"cube",
|
"cube",
|
||||||
|
"ellipse",
|
||||||
|
"square",
|
||||||
|
"rounded",
|
||||||
"capsule",
|
"capsule",
|
||||||
"cylinder",
|
"cylinder",
|
||||||
"cone",
|
"cone",
|
||||||
|
"teardrop",
|
||||||
|
"trapezoid",
|
||||||
|
"frustum",
|
||||||
|
"half-cone",
|
||||||
"diamond",
|
"diamond",
|
||||||
"ghost"
|
"ghost",
|
||||||
|
"apple-bite"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"width": { "$ref": "#/$defs/dimension" },
|
"width": { "$ref": "#/$defs/dimension" },
|
||||||
@ -84,7 +92,9 @@
|
|||||||
"roundness": { "$ref": "#/$defs/roundness" },
|
"roundness": { "$ref": "#/$defs/roundness" },
|
||||||
"morphRoundness": { "$ref": "#/$defs/roundness" },
|
"morphRoundness": { "$ref": "#/$defs/roundness" },
|
||||||
"tipRoundness": { "$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": {
|
"nodeSurface": {
|
||||||
@ -93,7 +103,23 @@
|
|||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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"],
|
"required": ["surface", "position", "rotation"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"layer": { "enum": ["auto", "front", "back"] },
|
"layer": { "enum": ["auto", "front", "back"] },
|
||||||
|
"color": { "$ref": "#/$defs/hexColor" },
|
||||||
"surface": { "$ref": "#/$defs/nodeSurface" },
|
"surface": { "$ref": "#/$defs/nodeSurface" },
|
||||||
"position": { "$ref": "#/$defs/position" },
|
"position": { "$ref": "#/$defs/position" },
|
||||||
"rotation": { "$ref": "#/$defs/rotation" }
|
"rotation": { "$ref": "#/$defs/rotation" }
|
||||||
@ -159,7 +186,8 @@
|
|||||||
"height": { "$ref": "#/$defs/boundedNumber" },
|
"height": { "$ref": "#/$defs/boundedNumber" },
|
||||||
"x": { "$ref": "#/$defs/boundedNumber" },
|
"x": { "$ref": "#/$defs/boundedNumber" },
|
||||||
"y": { "$ref": "#/$defs/boundedNumber" },
|
"y": { "$ref": "#/$defs/boundedNumber" },
|
||||||
"angle": { "$ref": "#/$defs/boundedNumber" }
|
"angle": { "$ref": "#/$defs/boundedNumber" },
|
||||||
|
"curvature": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"expression": {
|
"expression": {
|
||||||
@ -238,7 +266,7 @@
|
|||||||
"expression": { "$ref": "#/$defs/semanticKey" },
|
"expression": { "$ref": "#/$defs/semanticKey" },
|
||||||
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
|
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
|
||||||
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
|
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
|
||||||
"transition": { "enum": ["spring", "smooth", "snappy"] }
|
"transition": { "enum": ["spring", "gentleSpring", "smooth", "snappy"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -41,14 +41,17 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
|
|||||||
morphRoundness?: number
|
morphRoundness?: number
|
||||||
tipRoundness?: number
|
tipRoundness?: number
|
||||||
baseRoundness?: 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 PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
|
||||||
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
|
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
|
||||||
|
|
||||||
export type AvatarBodyNodeDefinition = {
|
export type AvatarBodyNodeDefinition = {
|
||||||
layer?: 'auto' | 'front' | 'back'
|
layer?: 'auto' | 'front' | 'back'
|
||||||
|
color?: HexColor
|
||||||
surface: BodyNodeSurfaceDefinition
|
surface: BodyNodeSurfaceDefinition
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
rotation: [number, number, number]
|
rotation: [number, number, number]
|
||||||
@ -62,8 +65,15 @@ export type AvatarBodyDefinition = {
|
|||||||
export type AvatarExpressionDefinition = {
|
export type AvatarExpressionDefinition = {
|
||||||
head: { x: number; y: number; z: number }
|
head: { x: number; y: number; z: number }
|
||||||
eyes: {
|
eyes: {
|
||||||
left: { 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 }
|
right: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
angle: number
|
||||||
|
curvature?: number
|
||||||
|
}
|
||||||
spacing: number
|
spacing: number
|
||||||
}
|
}
|
||||||
perspective: number
|
perspective: number
|
||||||
@ -78,7 +88,7 @@ export type AvatarAnimationStepDefinition = {
|
|||||||
expression: ExpressionKey
|
expression: ExpressionKey
|
||||||
holdMs: number
|
holdMs: number
|
||||||
transitionMs: number
|
transitionMs: number
|
||||||
transition: 'spring' | 'smooth' | 'snappy'
|
transition: 'spring' | 'gentleSpring' | 'smooth' | 'snappy'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarAnimationDefinition = {
|
export type AvatarAnimationDefinition = {
|
||||||
|
|||||||
@ -5,6 +5,8 @@ export type BodyVector = readonly [number, number, number]
|
|||||||
export type BodyNode = {
|
export type BodyNode = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
layer?: 'auto' | 'front' | 'back'
|
||||||
|
color?: string
|
||||||
surface: SurfaceConfig
|
surface: SurfaceConfig
|
||||||
position: BodyVector
|
position: BodyVector
|
||||||
rotation: BodyVector
|
rotation: BodyVector
|
||||||
@ -18,9 +20,16 @@ export type AvatarBody = {
|
|||||||
export const bodyPrimitiveTypes = [
|
export const bodyPrimitiveTypes = [
|
||||||
'sphere',
|
'sphere',
|
||||||
'cube',
|
'cube',
|
||||||
|
'ellipse',
|
||||||
|
'square',
|
||||||
|
'rounded',
|
||||||
'capsule',
|
'capsule',
|
||||||
'cylinder',
|
'cylinder',
|
||||||
'cone',
|
'cone',
|
||||||
|
'teardrop',
|
||||||
|
'trapezoid',
|
||||||
|
'frustum',
|
||||||
|
'half-cone',
|
||||||
'diamond',
|
'diamond',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@ -31,6 +40,8 @@ const finite = (value: unknown): value is number =>
|
|||||||
typeof value === 'number' && Number.isFinite(value)
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
const vector = (value: unknown): value is BodyVector =>
|
const vector = (value: unknown): value is BodyVector =>
|
||||||
Array.isArray(value) && value.length === 3 && value.every(finite)
|
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 => {
|
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||||
if (!value || typeof value !== 'object') return { ...fallback }
|
if (!value || typeof value !== 'object') return { ...fallback }
|
||||||
@ -46,6 +57,8 @@ export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): Sur
|
|||||||
return { ...fallback }
|
return { ...fallback }
|
||||||
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
|
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
|
||||||
return { ...fallback }
|
return { ...fallback }
|
||||||
|
if (candidate.topScale !== undefined && !finite(candidate.topScale)) return { ...fallback }
|
||||||
|
if (candidate.cutAngle !== undefined && !finite(candidate.cutAngle)) return { ...fallback }
|
||||||
return { ...preset, ...candidate, type }
|
return { ...preset, ...candidate, type }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +93,11 @@ export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig):
|
|||||||
.slice(0, MAX_BODY_NODES)
|
.slice(0, MAX_BODY_NODES)
|
||||||
.map(node => ({
|
.map(node => ({
|
||||||
...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]),
|
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||||
}))
|
}))
|
||||||
: []
|
: []
|
||||||
|
|||||||
@ -22,6 +22,8 @@ export type Expression = {
|
|||||||
widthRight: number
|
widthRight: number
|
||||||
heightLeft: number
|
heightLeft: number
|
||||||
heightRight: number
|
heightRight: number
|
||||||
|
curvatureLeft: number
|
||||||
|
curvatureRight: number
|
||||||
spacing: number
|
spacing: number
|
||||||
positionXLeft: number
|
positionXLeft: number
|
||||||
positionXRight: number
|
positionXRight: number
|
||||||
@ -98,6 +100,8 @@ export const expressionFields: ExpressionNumericField[] = [
|
|||||||
'widthRight',
|
'widthRight',
|
||||||
'heightLeft',
|
'heightLeft',
|
||||||
'heightRight',
|
'heightRight',
|
||||||
|
'curvatureLeft',
|
||||||
|
'curvatureRight',
|
||||||
'spacing',
|
'spacing',
|
||||||
'positionXLeft',
|
'positionXLeft',
|
||||||
'positionXRight',
|
'positionXRight',
|
||||||
@ -519,6 +523,8 @@ const surfaceCacheKey = (surface: SurfaceConfig) =>
|
|||||||
surface.morphRoundness,
|
surface.morphRoundness,
|
||||||
surface.tipRoundness,
|
surface.tipRoundness,
|
||||||
surface.baseRoundness,
|
surface.baseRoundness,
|
||||||
|
surface.topScale,
|
||||||
|
surface.cutAngle,
|
||||||
])
|
])
|
||||||
|
|
||||||
const cacheSurfaceValue = <Value>(cache: Map<string, Value>, key: string, value: Value) => {
|
const cacheSurfaceValue = <Value>(cache: Map<string, Value>, key: string, value: Value) => {
|
||||||
@ -569,12 +575,33 @@ const eyePoints = (
|
|||||||
const width = expression[`width${suffix}`]
|
const width = expression[`width${suffix}`]
|
||||||
const restingHeight = expression[`height${suffix}`]
|
const restingHeight = expression[`height${suffix}`]
|
||||||
const height = 5 + (restingHeight - 5) * blink
|
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 centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
|
||||||
const centerY = expression[`positionY${suffix}`] + offset.y
|
const centerY = expression[`positionY${suffix}`] + offset.y
|
||||||
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
|
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
|
||||||
return roundedRectangle(width, height).map(([localX, localY]) => {
|
return roundedRectangle(width, height).map(([localX, localY]) => {
|
||||||
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
|
const curveX = segmentHalf ? clamp(localX, -segmentHalf, segmentHalf) : 0
|
||||||
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
|
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)
|
return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1198,6 +1225,126 @@ const projectedGhostPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|||||||
].join('')
|
].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) => {
|
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||||
if (surface.type === 'sphere' || surface.type === 'mickey') {
|
if (surface.type === 'sphere' || surface.type === 'mickey') {
|
||||||
const exactPath = projectedEllipsoidPath(pose, surface)
|
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 === 'cube') return projectedCubePath(pose, surface)
|
||||||
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
|
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
|
||||||
if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
|
if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
|
||||||
|
if (surface.type === 'apple-bite') return projectedAppleBitePath(pose, surface)
|
||||||
|
|
||||||
const key = surfaceCacheKey(surface)
|
const key = surfaceCacheKey(surface)
|
||||||
let localSamples = headSamplesCache.get(key)
|
let localSamples = headSamplesCache.get(key)
|
||||||
|
|||||||
@ -224,6 +224,10 @@ const easing = (
|
|||||||
const progress = Math.max(0, Math.min(1, value))
|
const progress = Math.max(0, Math.min(1, value))
|
||||||
if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
|
if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
|
||||||
if (transition === 'snappy') return 1 - (1 - progress) ** 3
|
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)
|
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))
|
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export const expressionFromDefinition = (
|
|||||||
widthRight: expression.eyes.right.width,
|
widthRight: expression.eyes.right.width,
|
||||||
heightLeft: expression.eyes.left.height,
|
heightLeft: expression.eyes.left.height,
|
||||||
heightRight: expression.eyes.right.height,
|
heightRight: expression.eyes.right.height,
|
||||||
|
curvatureLeft: expression.eyes.left.curvature ?? 0,
|
||||||
|
curvatureRight: expression.eyes.right.curvature ?? 0,
|
||||||
spacing: expression.eyes.spacing,
|
spacing: expression.eyes.spacing,
|
||||||
positionXLeft: expression.eyes.left.x,
|
positionXLeft: expression.eyes.left.x,
|
||||||
positionXRight: expression.eyes.right.x,
|
positionXRight: expression.eyes.right.x,
|
||||||
@ -39,6 +41,8 @@ export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
|
|||||||
nodes: body.nodes.map((node, index) => ({
|
nodes: body.nodes.map((node, index) => ({
|
||||||
id: `runtime-node-${index}`,
|
id: `runtime-node-${index}`,
|
||||||
name: `Runtime node ${index + 1}`,
|
name: `Runtime node ${index + 1}`,
|
||||||
|
...(node.layer ? { layer: node.layer } : {}),
|
||||||
|
...(node.color ? { color: node.color } : {}),
|
||||||
surface: { ...node.surface },
|
surface: { ...node.surface },
|
||||||
position: [...node.position],
|
position: [...node.position],
|
||||||
rotation: [...node.rotation],
|
rotation: [...node.rotation],
|
||||||
|
|||||||
@ -1,7 +1,23 @@
|
|||||||
import type { Point3 } from './geometry'
|
import type { Point3 } from './geometry'
|
||||||
|
|
||||||
export type SurfaceType =
|
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 = {
|
export type SurfaceConfig = {
|
||||||
type: SurfaceType
|
type: SurfaceType
|
||||||
@ -12,6 +28,8 @@ export type SurfaceConfig = {
|
|||||||
morphRoundness?: number
|
morphRoundness?: number
|
||||||
tipRoundness?: number
|
tipRoundness?: number
|
||||||
baseRoundness?: number
|
baseRoundness?: number
|
||||||
|
topScale?: number
|
||||||
|
cutAngle?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SurfaceSample = {
|
export type SurfaceSample = {
|
||||||
@ -24,6 +42,9 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
|||||||
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
||||||
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
||||||
cube: { type: 'cube', width: 245, height: 245, depth: 220, 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 },
|
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
|
||||||
cylinder: {
|
cylinder: {
|
||||||
type: 'cylinder',
|
type: 'cylinder',
|
||||||
@ -43,8 +64,33 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
|||||||
tipRoundness: 0.55,
|
tipRoundness: 0.55,
|
||||||
baseRoundness: 0.45,
|
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 },
|
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
||||||
ghost: { type: 'ghost', width: 250, height: 245, depth: 190, roundness: 1 },
|
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> = {
|
export const surfaceLabels: Record<SurfaceType, string> = {
|
||||||
@ -52,16 +98,80 @@ export const surfaceLabels: Record<SurfaceType, string> = {
|
|||||||
mickey: 'Mickey',
|
mickey: 'Mickey',
|
||||||
cursor: 'Curseur',
|
cursor: 'Curseur',
|
||||||
cube: 'Cube',
|
cube: 'Cube',
|
||||||
|
ellipse: 'Ellipse',
|
||||||
|
square: 'Carré',
|
||||||
|
rounded: 'Arrondi',
|
||||||
capsule: 'Capsule',
|
capsule: 'Capsule',
|
||||||
cylinder: 'Cylindre',
|
cylinder: 'Cylindre',
|
||||||
cone: 'Cône',
|
cone: 'Cône',
|
||||||
|
teardrop: 'Goutte',
|
||||||
|
trapezoid: 'Trapèze',
|
||||||
|
frustum: 'Tronc de cône',
|
||||||
|
'half-cone': 'Demi-cône',
|
||||||
diamond: 'Diamant',
|
diamond: 'Diamant',
|
||||||
ghost: 'Ghost',
|
ghost: 'Ghost',
|
||||||
|
'apple-bite': 'Apple Bite',
|
||||||
}
|
}
|
||||||
|
|
||||||
const signedPower = (value: number, exponent: number) =>
|
const signedPower = (value: number, exponent: number) =>
|
||||||
Math.sign(value) * Math.abs(value) ** exponent
|
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 = (
|
const superellipsoid = (
|
||||||
longitude: number,
|
longitude: number,
|
||||||
latitude: number,
|
latitude: number,
|
||||||
@ -285,6 +395,28 @@ const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile =
|
|||||||
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||||
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
|
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) => {
|
export const cursorLayout = (config: SurfaceConfig) => {
|
||||||
const coneHeight = config.height * 0.36
|
const coneHeight = config.height * 0.36
|
||||||
const bodyHeight = config.height - coneHeight
|
const bodyHeight = config.height - coneHeight
|
||||||
@ -308,6 +440,12 @@ export const surfacePointAt = (
|
|||||||
case 'sphere':
|
case 'sphere':
|
||||||
case 'mickey':
|
case 'mickey':
|
||||||
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
|
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':
|
case 'cube':
|
||||||
return cube(config, longitude, latitude)
|
return cube(config, longitude, latitude)
|
||||||
case 'cylinder': {
|
case 'cylinder': {
|
||||||
@ -337,8 +475,13 @@ export const surfacePointAt = (
|
|||||||
}
|
}
|
||||||
case 'diamond':
|
case 'diamond':
|
||||||
return diamond(config, longitude, latitude)
|
return diamond(config, longitude, latitude)
|
||||||
|
case 'teardrop':
|
||||||
|
return oneworksTeardrop(config, longitude, latitude)
|
||||||
|
case 'trapezoid':
|
||||||
|
return oneworksTrapezoid(config, longitude, latitude)
|
||||||
case 'capsule':
|
case 'capsule':
|
||||||
case 'ghost':
|
case 'ghost':
|
||||||
|
case 'apple-bite':
|
||||||
return capsule(config, longitude, latitude)
|
return capsule(config, longitude, latitude)
|
||||||
case 'cone': {
|
case 'cone': {
|
||||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||||
@ -349,6 +492,25 @@ export const surfacePointAt = (
|
|||||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
(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
|
return normal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oneworksSuperellipsoidNormal = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
point: Point3,
|
||||||
|
parameterExponent: number
|
||||||
|
) => lpNormal(config, point, 2 / parameterExponent)
|
||||||
|
|
||||||
const lpFrontSample = (
|
const lpFrontSample = (
|
||||||
config: SurfaceConfig,
|
config: SurfaceConfig,
|
||||||
x: number,
|
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 = (
|
const radialProfileFrontSample = (
|
||||||
config: SurfaceConfig,
|
config: SurfaceConfig,
|
||||||
x: number,
|
x: number,
|
||||||
@ -532,13 +733,25 @@ export const surfaceFrontSampleAt = (
|
|||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case 'sphere':
|
case 'sphere':
|
||||||
case 'mickey':
|
case 'mickey':
|
||||||
|
case 'ellipse':
|
||||||
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
|
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':
|
case 'cube':
|
||||||
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
||||||
|
|
||||||
case 'capsule':
|
case 'capsule':
|
||||||
case 'ghost': {
|
case 'ghost':
|
||||||
|
case 'apple-bite': {
|
||||||
const capRadiusY = Math.min(radiusX, radiusY)
|
const capRadiusY = Math.min(radiusX, radiusY)
|
||||||
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
||||||
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
||||||
@ -572,6 +785,18 @@ export const surfaceFrontSampleAt = (
|
|||||||
case 'cone':
|
case 'cone':
|
||||||
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
|
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':
|
case 'diamond':
|
||||||
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
|
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
|
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
|
||||||
// common path for the default spherical head.
|
// 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 halfWidth = config.width / 2 || 1
|
||||||
const halfHeight = config.height / 2 || 1
|
const halfHeight = config.height / 2 || 1
|
||||||
const halfDepth = config.depth / 2 || 1
|
const halfDepth = config.depth / 2 || 1
|
||||||
@ -613,6 +838,14 @@ export const surfaceNormalAt = (
|
|||||||
return cubeNormal(config, point)
|
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)
|
return tangentNormalAt(config, longitude, latitude)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -623,7 +856,7 @@ export const surfaceSampleAt = (
|
|||||||
): SurfaceSample => {
|
): SurfaceSample => {
|
||||||
const point = surfacePointAt(config, longitude, latitude)
|
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 halfWidth = config.width / 2 || 1
|
||||||
const halfHeight = config.height / 2 || 1
|
const halfHeight = config.height / 2 || 1
|
||||||
const halfDepth = config.depth / 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 {
|
return {
|
||||||
point,
|
point,
|
||||||
normal: tangentNormalAt(config, longitude, latitude),
|
normal: tangentNormalAt(config, longitude, latitude),
|
||||||
|
|||||||
@ -34,6 +34,46 @@ describe('@bible-strong/avatar-web', () => {
|
|||||||
expect(document.querySelector('#avatar svg')).toBeNull()
|
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', () => {
|
it('returns typed errors for unknown targets', () => {
|
||||||
const avatar = createAvatar('#avatar', { definition: definitionJson })
|
const avatar = createAvatar('#avatar', { definition: definitionJson })
|
||||||
|
|
||||||
|
|||||||
@ -145,7 +145,19 @@ export function createAvatar(
|
|||||||
if (onError) onError(error)
|
if (onError) onError(error)
|
||||||
else console.error(`[Avatar] ${error.message}`)
|
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)
|
clipHeadPath.setAttribute('d', scene.geometry.headPath)
|
||||||
headPath.setAttribute('d', scene.geometry.headPath)
|
headPath.setAttribute('d', scene.geometry.headPath)
|
||||||
headPath.setAttribute('fill', scene.colors.body)
|
headPath.setAttribute('fill', scene.colors.body)
|
||||||
@ -157,11 +169,23 @@ export function createAvatar(
|
|||||||
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
||||||
backPaths.forEach((element, index) => {
|
backPaths.forEach((element, index) => {
|
||||||
element.setAttribute('d', scene.geometry.backPaths[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) => {
|
frontPaths.forEach((element, index) => {
|
||||||
element.setAttribute('d', scene.geometry.frontPaths[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 renderCurrent = (now: number) => {
|
||||||
const environment = runtimeEnvironment()
|
const environment = runtimeEnvironment()
|
||||||
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
|
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()
|
notifyExpression()
|
||||||
}
|
}
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
@ -260,7 +287,10 @@ export function createAvatar(
|
|||||||
playback = createAvatarPlaybackState()
|
playback = createAvatarPlaybackState()
|
||||||
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
frameRequest = null
|
frameRequest = null
|
||||||
paint(renderAvatarDefinition(definition))
|
paint(
|
||||||
|
renderAvatarDefinition(definition),
|
||||||
|
Boolean(definition.expressions.neutral.colors?.body)
|
||||||
|
)
|
||||||
notifyExpression()
|
notifyExpression()
|
||||||
},
|
},
|
||||||
getState() {
|
getState() {
|
||||||
@ -293,7 +323,7 @@ export function createAvatar(
|
|||||||
renderCurrent(performance.now())
|
renderCurrent(performance.now())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
paint(initialScene)
|
paint(initialScene, Boolean(definition.expressions.neutral.colors?.body))
|
||||||
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
|
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
|
||||||
notifyExpression()
|
notifyExpression()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,8 +70,43 @@ input {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: clip;
|
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 {
|
.stage-column {
|
||||||
|
--avatar-interior-color: #000000;
|
||||||
|
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -81,7 +116,7 @@ input {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
background: #101316;
|
background: #000000;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,14 +125,14 @@ input {
|
|||||||
width: 70vw;
|
width: 70vw;
|
||||||
height: 70vw;
|
height: 70vw;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%);
|
background: none;
|
||||||
content: '';
|
content: '';
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.photo-mode-active::before {
|
.photo-mode-active::before {
|
||||||
width: 82vw;
|
width: 82vw;
|
||||||
height: 82vw;
|
height: 82vw;
|
||||||
background: radial-gradient(circle, rgb(91 127 229 / 24%), transparent 66%);
|
background: none;
|
||||||
}
|
}
|
||||||
.photo-live-frame {
|
.photo-live-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -448,6 +483,7 @@ input {
|
|||||||
.is-pixel-rendered .avatar-head,
|
.is-pixel-rendered .avatar-head,
|
||||||
.is-pixel-rendered .avatar-eye {
|
.is-pixel-rendered .avatar-eye {
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
|
stroke: transparent;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
.is-pixel-rendered .avatar-head.cyan-outline,
|
.is-pixel-rendered .avatar-head.cyan-outline,
|
||||||
@ -1177,19 +1213,35 @@ input {
|
|||||||
}
|
}
|
||||||
.avatar-head,
|
.avatar-head,
|
||||||
.preview-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,
|
.avatar-eye,
|
||||||
.preview-eye {
|
.preview-eye {
|
||||||
fill: var(--avatar-eye-color, #111316);
|
fill: var(--avatar-eye-color, #ffffff);
|
||||||
}
|
}
|
||||||
.avatar-head,
|
.avatar-head,
|
||||||
.avatar-eye {
|
.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 {
|
.avatar-eye {
|
||||||
cursor: pointer;
|
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 {
|
.cyan-outline {
|
||||||
stroke: #51ddff;
|
stroke: #51ddff;
|
||||||
stroke-width: 2.6;
|
stroke-width: 2.6;
|
||||||
@ -1935,9 +1987,18 @@ p {
|
|||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
.avatar-shelf .avatar-grid {
|
.avatar-shelf .avatar-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 10px;
|
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-card,
|
||||||
.avatar-shelf .avatar-add {
|
.avatar-shelf .avatar-add {
|
||||||
height: auto;
|
height: auto;
|
||||||
@ -1969,6 +2030,9 @@ p {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
.avatar-preview-live {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.avatar-shelf .avatar-card > span {
|
.avatar-shelf .avatar-card > span {
|
||||||
min-height: 12px;
|
min-height: 12px;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
createInitialSequences,
|
createInitialSequences,
|
||||||
duplicateSequence,
|
duplicateSequence,
|
||||||
getSequenceSpring,
|
getSequenceSpring,
|
||||||
|
scaleSpringDynamics,
|
||||||
normalizeSequencesForExpressions,
|
normalizeSequencesForExpressions,
|
||||||
parseSequences,
|
parseSequences,
|
||||||
remapSequencesAfterExpressionDelete,
|
remapSequencesAfterExpressionDelete,
|
||||||
@ -11,7 +12,8 @@ import { initialExpressions } from '@/features/avatar/presets'
|
|||||||
|
|
||||||
describe('editable avatar sequences', () => {
|
describe('editable avatar sequences', () => {
|
||||||
it('creates the idle animation with editable steps and blink settings', () => {
|
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([
|
expect(idle?.steps.map(step => step.expressionId)).toEqual([
|
||||||
initialExpressions[0].id,
|
initialExpressions[0].id,
|
||||||
@ -19,6 +21,41 @@ describe('editable avatar sequences', () => {
|
|||||||
])
|
])
|
||||||
expect(idle?.steps[0].holdMs).toBe(5200)
|
expect(idle?.steps[0].holdMs).toBe(5200)
|
||||||
expect(idle?.blink.durationMs).toBe(280)
|
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', () => {
|
it('supports loop, once and ping-pong playback cursors', () => {
|
||||||
@ -96,6 +133,16 @@ describe('editable avatar sequences', () => {
|
|||||||
expect(smooth.damping).toBeGreaterThan(0)
|
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', () => {
|
it('clears the public semantic key when an animation is duplicated', () => {
|
||||||
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
|
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
|
||||||
|
|
||||||
|
|||||||
@ -380,6 +380,7 @@ export function SequenceWorkspace({
|
|||||||
value={selectedStep.transition}
|
value={selectedStep.transition}
|
||||||
items={[
|
items={[
|
||||||
{ value: 'spring', label: t('Ressort') },
|
{ value: 'spring', label: t('Ressort') },
|
||||||
|
{ value: 'gentleSpring', label: t('Ressort léger') },
|
||||||
{ value: 'smooth', label: t('Douce') },
|
{ value: 'smooth', label: t('Douce') },
|
||||||
{ value: 'snappy', label: t('Rapide') },
|
{ value: 'snappy', label: t('Rapide') },
|
||||||
]}
|
]}
|
||||||
@ -392,6 +393,7 @@ export function SequenceWorkspace({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="spring">{t('Ressort')}</SelectItem>
|
<SelectItem value="spring">{t('Ressort')}</SelectItem>
|
||||||
|
<SelectItem value="gentleSpring">{t('Ressort léger')}</SelectItem>
|
||||||
<SelectItem value="smooth">{t('Douce')}</SelectItem>
|
<SelectItem value="smooth">{t('Douce')}</SelectItem>
|
||||||
<SelectItem value="snappy">{t('Rapide')}</SelectItem>
|
<SelectItem value="snappy">{t('Rapide')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
import type { Expression } from '../avatar/geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
|
|
||||||
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
||||||
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
export type SequenceTransition = 'spring' | 'gentleSpring' | 'smooth' | 'snappy'
|
||||||
|
|
||||||
export type SequenceStep = {
|
export type SequenceStep = {
|
||||||
id: string
|
id: string
|
||||||
@ -45,7 +45,7 @@ export type SequenceCursor = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong']
|
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 builtInSequenceIds = new Set<string>(Object.values(stateGroups).flat())
|
||||||
|
|
||||||
const finite = (value: unknown, fallback: number, min: number, max: number) =>
|
const finite = (value: unknown, fallback: number, min: number, max: number) =>
|
||||||
@ -79,10 +79,10 @@ export const createInitialSequences = (): AvatarSequence[] =>
|
|||||||
id: `${id}-step-${index}`,
|
id: `${id}-step-${index}`,
|
||||||
expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id,
|
expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id,
|
||||||
holdMs: playback.expressionIntervalMs,
|
holdMs: playback.expressionIntervalMs,
|
||||||
transitionMs: 500,
|
transitionMs: id === 'joyful' ? 700 : 500,
|
||||||
transition: 'smooth',
|
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
|
baseSpeed: number
|
||||||
) => {
|
) => {
|
||||||
const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3)
|
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)
|
const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5)
|
||||||
return {
|
return {
|
||||||
stiffness: 70 + speed * 24,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -486,9 +486,9 @@ describe('Studio to avatar definition conversion', () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (!result.ok) return
|
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.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.animationOrder).toContain('idle')
|
||||||
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
|
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
|
||||||
'upward-side-glance',
|
'upward-side-glance',
|
||||||
@ -624,7 +624,7 @@ describe('Studio to avatar definition conversion', () => {
|
|||||||
createAvatarDefinition({ avatar, behavior })
|
createAvatarDefinition({ avatar, behavior })
|
||||||
|
|
||||||
expect(JSON.stringify({ avatar, behavior })).toBe(before)
|
expect(JSON.stringify({ avatar, behavior })).toBe(before)
|
||||||
expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
|
expect(defaultAvatarColors).toEqual({ body: '#7c89ff', eyes: '#7c89ff' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -6,11 +6,13 @@ import {
|
|||||||
createAvatar,
|
createAvatar,
|
||||||
createUnkeyedExpressionCopy,
|
createUnkeyedExpressionCopy,
|
||||||
defaultAvatarEyes,
|
defaultAvatarEyes,
|
||||||
|
ensurePrimitiveBundledAvatars,
|
||||||
parseAvatarEyeDefaults,
|
parseAvatarEyeDefaults,
|
||||||
parseAvatarRenderStyle,
|
parseAvatarRenderStyle,
|
||||||
resolveAvatarBehavior,
|
resolveAvatarBehavior,
|
||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
import { initialExpressions } from '@/features/avatar/presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
describe('avatar eye defaults', () => {
|
describe('avatar eye defaults', () => {
|
||||||
it('clears the public semantic key when creating custom content from a preset', () => {
|
it('clears the public semantic key when creating custom content from a preset', () => {
|
||||||
@ -61,6 +63,135 @@ describe('avatar render style', () => {
|
|||||||
).toEqual({ type: 'vector' })
|
).toEqual({ type: 'vector' })
|
||||||
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).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', () => {
|
describe('avatar behavior library', () => {
|
||||||
|
|||||||
@ -3,10 +3,11 @@ import { resolve } from 'node:path'
|
|||||||
|
|
||||||
import { createAvatarDefinition } from '../avatarDefinition'
|
import { createAvatarDefinition } from '../avatarDefinition'
|
||||||
import { resolveAvatarBehavior } from '../avatars'
|
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 () => {
|
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')
|
const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi')
|
||||||
if (!avatar) throw new Error('Bundled Strobi avatar not found')
|
if (!avatar) throw new Error('Bundled Strobi avatar not found')
|
||||||
const behavior = resolveAvatarBehavior(avatar, {
|
const behavior = resolveAvatarBehavior(avatar, {
|
||||||
|
|||||||
@ -30,6 +30,8 @@ const mapSurface = <TType extends SurfaceType>(
|
|||||||
...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
|
...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
|
||||||
...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
|
...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
|
||||||
...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
|
...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
|
||||||
|
...(surface.topScale === undefined ? {} : { topScale: surface.topScale }),
|
||||||
|
...(surface.cutAngle === undefined ? {} : { cutAngle: surface.cutAngle }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
||||||
@ -38,6 +40,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
|||||||
left: {
|
left: {
|
||||||
width: expression.widthLeft,
|
width: expression.widthLeft,
|
||||||
height: expression.heightLeft,
|
height: expression.heightLeft,
|
||||||
|
curvature: expression.curvatureLeft,
|
||||||
x: expression.positionXLeft,
|
x: expression.positionXLeft,
|
||||||
y: expression.positionYLeft,
|
y: expression.positionYLeft,
|
||||||
angle: expression.leftAngle,
|
angle: expression.leftAngle,
|
||||||
@ -45,6 +48,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
|||||||
right: {
|
right: {
|
||||||
width: expression.widthRight,
|
width: expression.widthRight,
|
||||||
height: expression.heightRight,
|
height: expression.heightRight,
|
||||||
|
curvature: expression.curvatureRight,
|
||||||
x: expression.positionXRight,
|
x: expression.positionXRight,
|
||||||
y: expression.positionYRight,
|
y: expression.positionYRight,
|
||||||
angle: expression.rightAngle,
|
angle: expression.rightAngle,
|
||||||
@ -170,6 +174,8 @@ export const createAvatarDefinition = ({
|
|||||||
body: {
|
body: {
|
||||||
primary: mapSurface(avatar.body.primary),
|
primary: mapSurface(avatar.body.primary),
|
||||||
nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({
|
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>),
|
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
|
||||||
position: [...node.position],
|
position: [...node.position],
|
||||||
rotation: [...node.rotation],
|
rotation: [...node.rotation],
|
||||||
|
|||||||
@ -31,7 +31,8 @@ export type PixelRenderStyle = {
|
|||||||
type: 'pixel'
|
type: 'pixel'
|
||||||
resolution: number
|
resolution: number
|
||||||
}
|
}
|
||||||
export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle
|
export type VectorRenderStyle = { type: 'vector'; filled?: boolean }
|
||||||
|
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
|
||||||
export type AvatarEyeDefaults = Pick<
|
export type AvatarEyeDefaults = Pick<
|
||||||
Expression,
|
Expression,
|
||||||
| 'widthLeft'
|
| 'widthLeft'
|
||||||
@ -66,6 +67,8 @@ export const defaultAvatarColors: AvatarColors = {
|
|||||||
eyes: deslopAccentColors.indigo,
|
eyes: deslopAccentColors.indigo,
|
||||||
}
|
}
|
||||||
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
||||||
|
export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true }
|
||||||
|
const filledAvatarEyeColor = '#111316'
|
||||||
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
||||||
type: 'pixel',
|
type: 'pixel',
|
||||||
resolution: 64,
|
resolution: 64,
|
||||||
@ -150,6 +153,19 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const simpleBundledAvatarIds = [
|
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-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
|
||||||
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
|
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
|
||||||
'avatar-1786600724626',
|
'avatar-1786600724626',
|
||||||
@ -160,13 +176,33 @@ export const simpleBundledAvatarIds = [
|
|||||||
'primitive-cylinder',
|
'primitive-cylinder',
|
||||||
'primitive-cone',
|
'primitive-cone',
|
||||||
'primitive-diamond',
|
'primitive-diamond',
|
||||||
'primitive-ghost',
|
|
||||||
'primitive-willy',
|
'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
|
] as const
|
||||||
|
|
||||||
const simpleBundledAvatarOrder = new Map(simpleBundledAvatarIds.map((id, index) => [id, index]))
|
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([
|
const retiredBundledAvatarIds = new Set([
|
||||||
|
'memento-arctic-fox',
|
||||||
'strobi',
|
'strobi',
|
||||||
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed',
|
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed',
|
||||||
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd',
|
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd',
|
||||||
@ -175,6 +211,14 @@ const retiredBundledAvatarIds = new Set([
|
|||||||
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0',
|
'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 = [
|
const primitiveBundledAvatarSpecs = [
|
||||||
['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1],
|
['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1],
|
||||||
['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78],
|
['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78],
|
||||||
@ -182,23 +226,584 @@ const primitiveBundledAvatarSpecs = [
|
|||||||
['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82],
|
['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82],
|
||||||
['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82],
|
['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82],
|
||||||
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
|
['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
|
] as const
|
||||||
|
|
||||||
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
|
const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
|
||||||
expressions: (behavior?.expressions ?? initialExpressions).map(expression => {
|
const storedExpressions = behavior?.expressions ?? initialExpressions
|
||||||
|
const baseExpressions = storedExpressions.filter(
|
||||||
|
expression => !expression.semanticKey?.startsWith('scared-state-')
|
||||||
|
)
|
||||||
|
const expressions = baseExpressions.map((expression, index) => {
|
||||||
const fixedColorExpression = { ...expression }
|
const fixedColorExpression = { ...expression }
|
||||||
|
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.bodyColor
|
||||||
delete fixedColorExpression.eyeColor
|
delete fixedColorExpression.eyeColor
|
||||||
|
}
|
||||||
return fixedColorExpression
|
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,
|
...sequence,
|
||||||
steps: sequence.steps.map(step => ({ ...step })),
|
steps: sequence.steps.map(step => ({ ...step })),
|
||||||
blink: { ...sequence.blink },
|
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[] = [
|
const primitiveBundledAvatars: StudioAvatar[] = [
|
||||||
...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({
|
...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({
|
||||||
id,
|
id,
|
||||||
@ -212,7 +817,10 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
|||||||
},
|
},
|
||||||
nodes: [],
|
nodes: [],
|
||||||
},
|
},
|
||||||
colors: { body: color, eyes: color },
|
colors: {
|
||||||
|
body: color,
|
||||||
|
eyes: surface === 'ghost' || surface === 'apple-bite' ? filledAvatarEyeColor : color,
|
||||||
|
},
|
||||||
eyes:
|
eyes:
|
||||||
surface === 'ghost'
|
surface === 'ghost'
|
||||||
? {
|
? {
|
||||||
@ -220,10 +828,24 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
|||||||
positionYLeft: defaultAvatarEyes.positionYLeft - 20,
|
positionYLeft: defaultAvatarEyes.positionYLeft - 20,
|
||||||
positionYRight: defaultAvatarEyes.positionYRight - 20,
|
positionYRight: defaultAvatarEyes.positionYRight - 20,
|
||||||
}
|
}
|
||||||
|
: surface === 'apple-bite'
|
||||||
|
? {
|
||||||
|
...defaultAvatarEyes,
|
||||||
|
positionXLeft: defaultAvatarEyes.positionXLeft - 18,
|
||||||
|
positionXRight: defaultAvatarEyes.positionXRight - 18,
|
||||||
|
}
|
||||||
: { ...defaultAvatarEyes },
|
: { ...defaultAvatarEyes },
|
||||||
renderStyle: { ...defaultAvatarRenderStyle },
|
renderStyle:
|
||||||
...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}),
|
surface === 'ghost' || surface === 'apple-bite'
|
||||||
|
? { ...filledAvatarRenderStyle }
|
||||||
|
: { ...defaultAvatarRenderStyle },
|
||||||
|
...(surface === 'ghost'
|
||||||
|
? { behavior: mementoBehavior() }
|
||||||
|
: surface === 'apple-bite'
|
||||||
|
? { behavior: fixedSkinColorBehavior() }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
|
mementoPigletAvatar,
|
||||||
{
|
{
|
||||||
id: 'primitive-willy',
|
id: 'primitive-willy',
|
||||||
name: 'Willy',
|
name: 'Willy',
|
||||||
@ -287,9 +909,26 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
|||||||
eyes: { ...defaultAvatarEyes },
|
eyes: { ...defaultAvatarEyes },
|
||||||
renderStyle: { ...defaultAvatarRenderStyle },
|
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 => ({
|
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
|
||||||
...avatar,
|
...avatar,
|
||||||
@ -305,17 +944,107 @@ const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
|
|||||||
colors: { ...avatar.colors },
|
colors: { ...avatar.colors },
|
||||||
eyes: { ...avatar.eyes },
|
eyes: { ...avatar.eyes },
|
||||||
renderStyle: { ...avatar.renderStyle },
|
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 => {
|
export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLibrary => {
|
||||||
if (!library.avatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) return library
|
const visibleLibraryAvatars = library.avatars.filter(
|
||||||
const resizedAvatars = library.avatars.map(avatar => {
|
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)
|
const bundled = primitiveBundledAvatars.find(candidate => candidate.id === avatar.id)
|
||||||
if (!bundled) return avatar
|
if (!bundled) return avatar
|
||||||
const migratedNodes = avatar.body.nodes.map(node => {
|
const migratedNodes = avatar.body.nodes.map(node => {
|
||||||
const bundledNode = bundled.body.nodes.find(candidate => candidate.id === node.id)
|
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 =
|
const isLegacyLowerSphere =
|
||||||
(node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') &&
|
(node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') &&
|
||||||
node.surface.type === 'sphere' &&
|
node.surface.type === 'sphere' &&
|
||||||
@ -329,7 +1058,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
node.surface.depth === 135 &&
|
node.surface.depth === 135 &&
|
||||||
node.position[2] === 12) ||
|
node.position[2] === 12) ||
|
||||||
(node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148))
|
(node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148))
|
||||||
if (!isLegacyLowerSphere && !isLegacyTopCap) return node
|
if (!isLegacyLowerSphere && !isLegacyTopCap) return nodeWithCurrentAnimalGeometry
|
||||||
return {
|
return {
|
||||||
...bundledNode,
|
...bundledNode,
|
||||||
surface: { ...bundledNode.surface },
|
surface: { ...bundledNode.surface },
|
||||||
@ -358,8 +1087,11 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
: migratedNodes.some((node, index) => node !== avatar.body.nodes[index])
|
: migratedNodes.some((node, index) => node !== avatar.body.nodes[index])
|
||||||
? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } }
|
? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } }
|
||||||
: avatar
|
: avatar
|
||||||
|
const legacyBundledName = legacyBundledAvatarNames[avatarWithCurrentNodes.id]
|
||||||
const avatarWithCurrentName =
|
const avatarWithCurrentName =
|
||||||
avatarWithCurrentNodes.id === 'primitive-ghost' && avatarWithCurrentNodes.name === 'Ghost'
|
(avatarWithCurrentNodes.id === 'primitive-ghost' &&
|
||||||
|
avatarWithCurrentNodes.name === 'Ghost') ||
|
||||||
|
avatarWithCurrentNodes.name === legacyBundledName
|
||||||
? { ...avatarWithCurrentNodes, name: bundled.name }
|
? { ...avatarWithCurrentNodes, name: bundled.name }
|
||||||
: avatarWithCurrentNodes
|
: avatarWithCurrentNodes
|
||||||
const avatarWithCurrentEyes =
|
const avatarWithCurrentEyes =
|
||||||
@ -372,12 +1104,36 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
)
|
)
|
||||||
? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } }
|
? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } }
|
||||||
: avatarWithCurrentName
|
: avatarWithCurrentName
|
||||||
const avatarWithFixedColorBehavior =
|
const avatarWithAppleBiteEyes =
|
||||||
avatarWithCurrentEyes.id === 'primitive-ghost'
|
avatarWithCurrentEyes.id === 'primitive-apple-bite' &&
|
||||||
? { ...avatarWithCurrentEyes, behavior: mementoBehavior(avatarWithCurrentEyes.behavior) }
|
avatarWithCurrentEyes.eyes.positionXLeft === defaultAvatarEyes.positionXLeft &&
|
||||||
|
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
|
||||||
|
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
|
||||||
: avatarWithCurrentEyes
|
: 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 original = surfacePresets[bundled.body.primary.type]
|
||||||
const primary = avatarWithFixedColorBehavior.body.primary
|
const primary = avatarWithOriginalColors.body.primary
|
||||||
const stillUsesOriginalSize =
|
const stillUsesOriginalSize =
|
||||||
primary.type === original.type &&
|
primary.type === original.type &&
|
||||||
primary.width === original.width &&
|
primary.width === original.width &&
|
||||||
@ -389,17 +1145,25 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
primary.width === 122 &&
|
primary.width === 122 &&
|
||||||
primary.height === 210 &&
|
primary.height === 210 &&
|
||||||
primary.depth === 115
|
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 {
|
return {
|
||||||
...avatarWithFixedColorBehavior,
|
...avatarWithOriginalColors,
|
||||||
body: { ...avatarWithFixedColorBehavior.body, primary: { ...bundled.body.primary } },
|
body: { ...avatarWithOriginalColors.body, primary: { ...bundled.body.primary } },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const existingIds = new Set(resizedAvatars.map(avatar => avatar.id))
|
const existingIds = new Set(resizedAvatars.map(avatar => avatar.id))
|
||||||
const avatars = [
|
const avatars = [
|
||||||
...resizedAvatars,
|
...resizedAvatars,
|
||||||
...primitiveBundledAvatars
|
...primitiveBundledAvatars
|
||||||
.filter(avatar => !existingIds.has(avatar.id))
|
.filter(avatar => !hiddenBundledAvatarIds.has(avatar.id) && !existingIds.has(avatar.id))
|
||||||
.map(clonePrimitiveBundledAvatar),
|
.map(clonePrimitiveBundledAvatar),
|
||||||
].sort((left, right) => {
|
].sort((left, right) => {
|
||||||
const leftOrder = simpleBundledAvatarOrder.get(
|
const leftOrder = simpleBundledAvatarOrder.get(
|
||||||
@ -413,7 +1177,13 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
if (rightOrder === undefined) return -1
|
if (rightOrder === undefined) return -1
|
||||||
return leftOrder - rightOrder
|
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 => {
|
export const migrateBundledAvatarColors = (id: string, colors: AvatarColors): AvatarColors => {
|
||||||
@ -434,7 +1204,10 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
|
|||||||
: fallback
|
: fallback
|
||||||
|
|
||||||
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
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') {
|
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
||||||
return { ...defaultAvatarRenderStyle }
|
return { ...defaultAvatarRenderStyle }
|
||||||
}
|
}
|
||||||
@ -616,7 +1389,9 @@ export const parseAvatarLibrary = (
|
|||||||
.filter(avatar => {
|
.filter(avatar => {
|
||||||
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
|
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
|
||||||
return false
|
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
|
if (seenIds.has(avatar.id)) return false
|
||||||
seenIds.add(avatar.id)
|
seenIds.add(avatar.id)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { ArrowLeft, Copy, Pencil, RotateCcw, Trash2 } from 'lucide-react'
|
import { ArrowLeft, Copy, Pencil, RotateCcw, Trash2 } from 'lucide-react'
|
||||||
|
import { motion } from 'motion/react'
|
||||||
import { useState, type RefObject } from 'react'
|
import { useState, type RefObject } from 'react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
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 { type Expression } from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
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 }) {
|
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
|
||||||
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
|
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
|
||||||
|
const outlineWidth = avatarBodyOutlineWidth()
|
||||||
return (
|
return (
|
||||||
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
{geometry.backPaths.map((pathValue, index) => (
|
{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>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -59,6 +83,16 @@ export function ExpressionPreview({
|
|||||||
}) {
|
}) {
|
||||||
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
|
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
|
||||||
const resolvedColors = resolveColors(expression, colors)
|
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') {
|
if (renderStyle.type === 'pixel') {
|
||||||
return (
|
return (
|
||||||
<StaticPixelAvatarCanvas
|
<StaticPixelAvatarCanvas
|
||||||
@ -88,37 +122,168 @@ export function ExpressionPreview({
|
|||||||
<path d={geometry.headPath} />
|
<path d={geometry.headPath} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
{geometry.backPaths.map((pathValue, index) => (
|
{geometry.backPaths.map((pathValue, index) => {
|
||||||
|
const color = nodeColor(geometry.backNodeIds[index])
|
||||||
|
return (
|
||||||
<path
|
<path
|
||||||
className="preview-head"
|
className="preview-head"
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
key={index}
|
key={index}
|
||||||
style={{ fill: resolvedColors.body }}
|
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',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
<path className="preview-head" d={geometry.headPath} style={{ fill: resolvedColors.body }} />
|
|
||||||
<g clipPath={`url(#${clipId})`}>
|
<g clipPath={`url(#${clipId})`}>
|
||||||
<path
|
<path
|
||||||
className="preview-eye"
|
className="preview-eye"
|
||||||
d={geometry.leftPath}
|
d={geometry.leftPath}
|
||||||
opacity={geometry.leftVisible ? 1 : 0}
|
opacity={geometry.leftVisible ? 1 : 0}
|
||||||
style={{ fill: resolvedColors.eyes }}
|
style={{ fill: eyeFill }}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
className="preview-eye"
|
className="preview-eye"
|
||||||
d={geometry.rightPath}
|
d={geometry.rightPath}
|
||||||
opacity={geometry.rightVisible ? 1 : 0}
|
opacity={geometry.rightVisible ? 1 : 0}
|
||||||
style={{ fill: resolvedColors.eyes }}
|
style={{ fill: eyeFill }}
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
{geometry.frontPaths.map((pathValue, index) => (
|
{geometry.frontPaths.map((pathValue, index) => {
|
||||||
|
const color = nodeColor(geometry.frontNodeIds[index])
|
||||||
|
return (
|
||||||
<path
|
<path
|
||||||
className="preview-head"
|
className="preview-head"
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
key={`front-${index}`}
|
key={`front-${index}`}
|
||||||
style={{ fill: resolvedColors.body }}
|
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>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,7 +56,8 @@ const calibrated: number[][] = [
|
|||||||
[-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0],
|
[-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,
|
headX,
|
||||||
@ -82,6 +83,8 @@ export const initialExpressions: Expression[] = calibrated.map(
|
|||||||
widthRight,
|
widthRight,
|
||||||
heightLeft,
|
heightLeft,
|
||||||
heightRight,
|
heightRight,
|
||||||
|
curvatureLeft: 0,
|
||||||
|
curvatureRight: 0,
|
||||||
spacing,
|
spacing,
|
||||||
positionXLeft: 0,
|
positionXLeft: 0,
|
||||||
positionXRight: 0,
|
positionXRight: 0,
|
||||||
@ -90,10 +93,83 @@ export const initialExpressions: Expression[] = calibrated.map(
|
|||||||
leftAngle,
|
leftAngle,
|
||||||
rightAngle,
|
rightAngle,
|
||||||
perspective: 1,
|
perspective: 1,
|
||||||
eyeMotion: 'none',
|
eyeMotion: 'none' as const,
|
||||||
bodyMotion: 'none',
|
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: 0,
|
||||||
|
positionYRight: 0,
|
||||||
|
leftAngle: -36.244531249999994,
|
||||||
|
rightAngle: 27.730078125000002,
|
||||||
|
perspective: 1,
|
||||||
|
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 = {
|
export const defaultExpression: Expression = {
|
||||||
id: 'expression-neutral',
|
id: 'expression-neutral',
|
||||||
@ -104,6 +180,8 @@ export const defaultExpression: Expression = {
|
|||||||
widthRight: 20,
|
widthRight: 20,
|
||||||
heightLeft: 50,
|
heightLeft: 50,
|
||||||
heightRight: 50,
|
heightRight: 50,
|
||||||
|
curvatureLeft: 0,
|
||||||
|
curvatureRight: 0,
|
||||||
spacing: 35,
|
spacing: 35,
|
||||||
positionXLeft: 0,
|
positionXLeft: 0,
|
||||||
positionXRight: 0,
|
positionXRight: 0,
|
||||||
@ -125,6 +203,7 @@ export const stateGroups = {
|
|||||||
'angry',
|
'angry',
|
||||||
'drowsy',
|
'drowsy',
|
||||||
'happy',
|
'happy',
|
||||||
|
'joyful',
|
||||||
'curious',
|
'curious',
|
||||||
'confused',
|
'confused',
|
||||||
'bored',
|
'bored',
|
||||||
@ -149,9 +228,10 @@ export const statePools: Record<string, number[]> = {
|
|||||||
excited: [2, 17, 21, 3, 11],
|
excited: [2, 17, 21, 3, 11],
|
||||||
surprised: [3, 21],
|
surprised: [3, 21],
|
||||||
suspicious: [14, 5, 23],
|
suspicious: [14, 5, 23],
|
||||||
angry: [7, 16],
|
angry: [7, 25],
|
||||||
drowsy: [4, 22, 13],
|
drowsy: [4, 22, 13],
|
||||||
happy: [2, 11, 17, 19],
|
happy: [2, 11, 17, 19],
|
||||||
|
joyful: [11, 27],
|
||||||
curious: [3, 21, 0, 15],
|
curious: [3, 21, 0, 15],
|
||||||
confused: [14, 5, 8],
|
confused: [14, 5, 8],
|
||||||
bored: [4, 22, 0],
|
bored: [4, 22, 0],
|
||||||
@ -159,7 +239,7 @@ export const statePools: Record<string, number[]> = {
|
|||||||
shy: [0, 24, 13],
|
shy: [0, 24, 13],
|
||||||
sad: [4, 13, 22],
|
sad: [4, 13, 22],
|
||||||
laughing: [2, 11, 17],
|
laughing: [2, 11, 17],
|
||||||
scared: [3, 21],
|
scared: [3, 26],
|
||||||
playful: [2, 17, 11, 8],
|
playful: [2, 17, 11, 8],
|
||||||
celebrate: [2, 8, 17],
|
celebrate: [2, 8, 17],
|
||||||
}
|
}
|
||||||
@ -244,5 +324,6 @@ export const stateNotes: Record<string, string> = {
|
|||||||
searching: 'Balayage rapide et changements très fréquents.',
|
searching: 'Balayage rapide et changements très fréquents.',
|
||||||
working: 'Rythme régulier et expressions concentrées.',
|
working: 'Rythme régulier et expressions concentrées.',
|
||||||
excited: 'Grandes expressions et transitions rapides.',
|
excited: 'Grandes expressions et transitions rapides.',
|
||||||
|
joyful: 'Morphing fluide entre les expressions 11 et 27.',
|
||||||
curious: 'Inclinaisons et forte asymétrie.',
|
curious: 'Inclinaisons et forte asymétrie.',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,9 @@ const easeProgress = (progress, transition) => transition === 'smooth'
|
|||||||
? progress * progress * (3 - 2 * progress)
|
? progress * progress * (3 - 2 * progress)
|
||||||
: transition === 'snappy'
|
: transition === 'snappy'
|
||||||
? 1 - (1 - progress) ** 3
|
? 1 - (1 - progress) ** 3
|
||||||
|
: 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);
|
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
|
||||||
const nearestAngle = (target, current) => {
|
const nearestAngle = (target, current) => {
|
||||||
let resolved = target;
|
let resolved = target;
|
||||||
@ -134,12 +137,13 @@ function mountAvatar(target, options = {}) {
|
|||||||
const renderElement = pixelStyle ? canvas : svg;
|
const renderElement = pixelStyle ? canvas : svg;
|
||||||
host.replaceChildren(renderElement);
|
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.append(svgElement('path'));
|
||||||
while (group.children.length > paths.length) group.lastElementChild.remove();
|
while (group.children.length > paths.length) group.lastElementChild.remove();
|
||||||
paths.forEach((path, index) => {
|
paths.forEach((path, index) => {
|
||||||
group.children[index].setAttribute('d', path);
|
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];
|
let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
|
||||||
@ -200,8 +204,23 @@ function mountAvatar(target, options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
|
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
|
||||||
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
|
const bodyColorOverride = Boolean(
|
||||||
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
|
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('d', geometry.headPath);
|
||||||
head.setAttribute('fill', currentColors.body);
|
head.setAttribute('fill', currentColors.body);
|
||||||
clipHead.setAttribute('d', geometry.headPath);
|
clipHead.setAttribute('d', geometry.headPath);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
36
src/features/rendering/__tests__/avatar-appearance-test.ts
Normal file
36
src/features/rendering/__tests__/avatar-appearance-test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -9,11 +9,29 @@ import {
|
|||||||
paintRenderedScene,
|
paintRenderedScene,
|
||||||
} from '@/features/rendering/renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
|
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||||
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
|
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
|
||||||
import type { BodyNode } from '@/features/avatar/body'
|
import type { BodyNode } from '@/features/avatar/body'
|
||||||
import type { Expression } from '@/features/avatar/geometry'
|
import type { Expression } from '@/features/avatar/geometry'
|
||||||
import type { SurfaceConfig } from '@/features/avatar/surfaces'
|
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', () => {
|
describe('rendered avatar scene', () => {
|
||||||
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
||||||
const node = createBodyNode('sphere', 0)
|
const node = createBodyNode('sphere', 0)
|
||||||
@ -68,5 +86,85 @@ describe('rendered avatar scene', () => {
|
|||||||
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||||
)
|
)
|
||||||
expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f')
|
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([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
37
src/features/rendering/avatarAppearance.ts
Normal file
37
src/features/rendering/avatarAppearance.ts
Normal 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
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { motion } from 'motion/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 { Button } from '@/components/ui/button'
|
||||||
import { useStudioLanguage } from '@/i18n'
|
import { useStudioLanguage } from '@/i18n'
|
||||||
@ -496,8 +496,10 @@ export function AvatarCanvas({
|
|||||||
expression,
|
expression,
|
||||||
avatarEyes,
|
avatarEyes,
|
||||||
surface,
|
surface,
|
||||||
|
bodyNodes,
|
||||||
scene,
|
scene,
|
||||||
colors,
|
colors,
|
||||||
|
expressionBodyColor,
|
||||||
renderStyle,
|
renderStyle,
|
||||||
rotationGizmo,
|
rotationGizmo,
|
||||||
showWire,
|
showWire,
|
||||||
@ -522,8 +524,10 @@ export function AvatarCanvas({
|
|||||||
expression: Expression
|
expression: Expression
|
||||||
avatarEyes: AvatarEyeDefaults
|
avatarEyes: AvatarEyeDefaults
|
||||||
surface: SurfaceConfig
|
surface: SurfaceConfig
|
||||||
|
bodyNodes: BodyNode[]
|
||||||
scene: RenderedScene
|
scene: RenderedScene
|
||||||
colors: RenderedColors
|
colors: RenderedColors
|
||||||
|
expressionBodyColor?: string
|
||||||
renderStyle: AvatarRenderStyle
|
renderStyle: AvatarRenderStyle
|
||||||
rotationGizmo: RenderedRotationGizmo
|
rotationGizmo: RenderedRotationGizmo
|
||||||
showWire: boolean
|
showWire: boolean
|
||||||
@ -593,6 +597,11 @@ export function AvatarCanvas({
|
|||||||
if (!bodyEditing || !selectedBodyNodeId) return null
|
if (!bodyEditing || !selectedBodyNodeId) return null
|
||||||
return findBodyNodePath(scene, selectedBodyNodeId)
|
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 toSvg = (event: React.PointerEvent<SVGElement>): readonly [number, number] => {
|
||||||
const rectangle = svgRef.current!.getBoundingClientRect()
|
const rectangle = svgRef.current!.getBoundingClientRect()
|
||||||
@ -774,7 +783,9 @@ export function AvatarCanvas({
|
|||||||
}
|
}
|
||||||
useEscapeToCancel(cancelDrag)
|
useEscapeToCancel(cancelDrag)
|
||||||
return (
|
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 && (
|
{playback && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="stage-playback-status"
|
className="stage-playback-status"
|
||||||
@ -814,6 +825,7 @@ export function AvatarCanvas({
|
|||||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
key={index}
|
key={index}
|
||||||
|
style={nodeColorStyle(backNodeIds.current[index])}
|
||||||
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -848,6 +860,7 @@ export function AvatarCanvas({
|
|||||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
key={index}
|
key={index}
|
||||||
|
style={nodeColorStyle(frontNodeIds.current[index])}
|
||||||
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -28,13 +28,19 @@ const documentFixture = (): StudioDocument => {
|
|||||||
|
|
||||||
describe('Studio document', () => {
|
describe('Studio document', () => {
|
||||||
const storage = (value: string | null = null) => ({ getItem: () => value })
|
const storage = (value: string | null = null) => ({ getItem: () => value })
|
||||||
|
const bundledAvatarNames = [
|
||||||
it('loads the bundled Studio snapshot when no local project exists', () => {
|
'Memento',
|
||||||
const document = loadStudioDocument(storage())
|
'Apple Bite',
|
||||||
|
'Siamese',
|
||||||
expect(document.library.avatars).toHaveLength(12)
|
'British Shorthair',
|
||||||
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
'Russian Blue',
|
||||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
|
'Orange Tabby',
|
||||||
|
'Black Cat',
|
||||||
|
'Dog',
|
||||||
|
'Brown Dog',
|
||||||
|
'Bear',
|
||||||
|
'Rabbit',
|
||||||
|
'Bun',
|
||||||
'Onee',
|
'Onee',
|
||||||
'Cubee',
|
'Cubee',
|
||||||
'Nova',
|
'Nova',
|
||||||
@ -45,25 +51,165 @@ describe('Studio document', () => {
|
|||||||
'Cylinder',
|
'Cylinder',
|
||||||
'Cone',
|
'Cone',
|
||||||
'Diamond',
|
'Diamond',
|
||||||
'Memento',
|
|
||||||
'Willy',
|
'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(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(
|
expect(
|
||||||
document.library.avatars.every(avatar => avatar.colors.body === avatar.colors.eyes)
|
document.library.avatars
|
||||||
).toBe(true)
|
.find(avatar => avatar.id === 'oneworks-bear')
|
||||||
expect(document.library.avatars.slice(4).map(avatar => avatar.body.primary.type)).toEqual([
|
?.body.nodes.map(node => node.color)
|
||||||
'sphere',
|
).toEqual(['#a95f47', '#a95f47'])
|
||||||
'cube',
|
|
||||||
'capsule',
|
|
||||||
'cylinder',
|
|
||||||
'cone',
|
|
||||||
'diamond',
|
|
||||||
'ghost',
|
|
||||||
'capsule',
|
|
||||||
])
|
|
||||||
expect(document.library.avatars.at(-1)?.body.nodes).toHaveLength(3)
|
|
||||||
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
|
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(
|
expect(
|
||||||
document.library.avatars
|
document.library.avatars
|
||||||
.filter(avatar => avatar.id !== 'primitive-ghost')
|
.filter(avatar => avatar.id !== 'primitive-ghost')
|
||||||
@ -72,12 +218,20 @@ describe('Studio document', () => {
|
|||||||
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
|
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
|
||||||
expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20)
|
expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20)
|
||||||
expect(
|
expect(
|
||||||
memento?.behavior?.expressions.every(
|
memento?.behavior?.expressions.find(
|
||||||
expression => !expression.bodyColor && !expression.eyeColor
|
expression => expression.semanticKey === 'onboarding-curious'
|
||||||
)
|
)?.positionYLeft
|
||||||
|
).toBe(-22)
|
||||||
|
expect(
|
||||||
|
memento?.behavior?.sequences.some(sequence => sequence.semanticKey === 'onboarding')
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(document.expressions).toHaveLength(27)
|
expect(
|
||||||
expect(document.sequences).toHaveLength(23)
|
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.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
document.sequences
|
document.sequences
|
||||||
@ -90,11 +244,40 @@ describe('Studio document', () => {
|
|||||||
.find(sequence => sequence.id === 'scared')
|
.find(sequence => sequence.id === 'scared')
|
||||||
?.steps.map(step => step.expressionId)
|
?.steps.map(step => step.expressionId)
|
||||||
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
|
).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.expressions.every(expression => expression.semanticKey)).toBe(true)
|
||||||
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
|
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
|
||||||
expect(document.playback).toEqual({ stateId: 'proud', playing: 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', () => {
|
it('clears only the persisted Studio project', () => {
|
||||||
const removeItem = vi.fn()
|
const removeItem = vi.fn()
|
||||||
|
|
||||||
@ -108,6 +291,122 @@ describe('Studio document', () => {
|
|||||||
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
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', () => {
|
it('restores standalone built-in animations from the temporary idle pairs', () => {
|
||||||
const fallback = loadStudioDocument(storage())
|
const fallback = loadStudioDocument(storage())
|
||||||
const pairedDocument: StudioDocument = {
|
const pairedDocument: StudioDocument = {
|
||||||
@ -133,34 +432,94 @@ describe('Studio document', () => {
|
|||||||
expect(restored.playback).toEqual({ stateId: 'proud', playing: true })
|
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', () => {
|
it('removes retired bundled avatars from a locally saved project', () => {
|
||||||
const fallback = loadStudioDocument(storage())
|
const fallback = loadStudioDocument(storage())
|
||||||
const retired = createAvatar('Freddy')
|
const retired = createAvatar('Freddy')
|
||||||
retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed'
|
retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed'
|
||||||
|
const retiredFox = createAvatar('Продовый песец')
|
||||||
|
retiredFox.id = 'memento-arctic-fox'
|
||||||
const localDocument: StudioDocument = {
|
const localDocument: StudioDocument = {
|
||||||
...fallback,
|
...fallback,
|
||||||
library: {
|
library: {
|
||||||
activeAvatarId: retired.id,
|
activeAvatarId: retiredFox.id,
|
||||||
avatars: [retired, ...fallback.library.avatars],
|
avatars: [retired, retiredFox, ...fallback.library.avatars],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const document = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
const document = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||||
|
|
||||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
|
expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames)
|
||||||
'Onee',
|
|
||||||
'Cubee',
|
|
||||||
'Nova',
|
|
||||||
'Citrus',
|
|
||||||
'Sphere',
|
|
||||||
'Cube',
|
|
||||||
'Capsule',
|
|
||||||
'Cylinder',
|
|
||||||
'Cone',
|
|
||||||
'Diamond',
|
|
||||||
'Memento',
|
|
||||||
'Willy',
|
|
||||||
])
|
|
||||||
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -183,6 +542,35 @@ describe('Studio document', () => {
|
|||||||
).toBe(defaultAvatarEyes.positionYLeft - 20)
|
).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', () => {
|
it('restores bundled semantic keys in a legacy local project', () => {
|
||||||
const fallback = loadStudioDocument(storage())
|
const fallback = loadStudioDocument(storage())
|
||||||
const legacy = structuredClone(fallback)
|
const legacy = structuredClone(fallback)
|
||||||
|
|||||||
@ -12,8 +12,115 @@ import {
|
|||||||
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||||
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
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'
|
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 }) {
|
export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||||
const {
|
const {
|
||||||
activateAvatar,
|
activateAvatar,
|
||||||
@ -29,8 +136,12 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
draggingAvatarId,
|
draggingAvatarId,
|
||||||
duplicateAvatar,
|
duplicateAvatar,
|
||||||
expressions,
|
expressions,
|
||||||
|
playbackStatus,
|
||||||
previewAvatarMove,
|
previewAvatarMove,
|
||||||
reduceMotion,
|
reduceMotion,
|
||||||
|
renderedColors,
|
||||||
|
renderedExpressionBodyColor,
|
||||||
|
renderedScene,
|
||||||
setDeleteAvatarOpen,
|
setDeleteAvatarOpen,
|
||||||
setDraggingAvatarId,
|
setDraggingAvatarId,
|
||||||
setFocusAvatarName,
|
setFocusAvatarName,
|
||||||
@ -93,6 +204,14 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
activateAvatar(avatar.id, true)
|
activateAvatar(avatar.id, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{activeAvatarId === avatar.id && playbackStatus !== 'stopped' ? (
|
||||||
|
<LiveAvatarPreview
|
||||||
|
avatar={avatar}
|
||||||
|
colors={renderedColors}
|
||||||
|
scene={renderedScene}
|
||||||
|
expressionBodyColor={renderedExpressionBodyColor}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ExpressionPreview
|
<ExpressionPreview
|
||||||
expression={expressions[0] ?? defaultExpression}
|
expression={expressions[0] ?? defaultExpression}
|
||||||
surface={avatar.body.primary}
|
surface={avatar.body.primary}
|
||||||
@ -102,6 +221,7 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
renderStyle={avatar.renderStyle}
|
renderStyle={avatar.renderStyle}
|
||||||
id={`avatar-${avatar.id}`}
|
id={`avatar-${avatar.id}`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<span>{avatar.name}</span>
|
<span>{avatar.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -176,7 +176,11 @@ export function BodyConstructionAccordion({
|
|||||||
unit="u"
|
unit="u"
|
||||||
onChange={depth => updateSurface({ ...surface, depth })}
|
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
|
<NumericField
|
||||||
label="Rondeur"
|
label="Rondeur"
|
||||||
value={surface.roundness}
|
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>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@ -320,7 +347,10 @@ export function BodyConstructionAccordion({
|
|||||||
))}
|
))}
|
||||||
{(selectedBodyNode.surface.type === 'cube' ||
|
{(selectedBodyNode.surface.type === 'cube' ||
|
||||||
selectedBodyNode.surface.type === 'diamond' ||
|
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
|
<NumericField
|
||||||
label="Rondeur"
|
label="Rondeur"
|
||||||
value={selectedBodyNode.surface.roundness}
|
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>
|
||||||
<div className="body-transform-grid">
|
<div className="body-transform-grid">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -72,6 +72,7 @@ import {
|
|||||||
ExpressionCard,
|
ExpressionCard,
|
||||||
ExpressionPreview,
|
ExpressionPreview,
|
||||||
ExpressionWorkspace,
|
ExpressionWorkspace,
|
||||||
|
LiveExpressionPreview,
|
||||||
} from '@/features/avatar/components/ExpressionWorkspace'
|
} from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { randomSnapshotPalette } from '@/features/export/snapshotPalette'
|
import { randomSnapshotPalette } from '@/features/export/snapshotPalette'
|
||||||
@ -488,6 +489,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
projectImportError,
|
projectImportError,
|
||||||
projectImportRef,
|
projectImportRef,
|
||||||
reduceMotion,
|
reduceMotion,
|
||||||
|
renderedColors,
|
||||||
|
renderedExpressionBodyColor,
|
||||||
|
renderedScene,
|
||||||
renameActiveAvatar,
|
renameActiveAvatar,
|
||||||
runtimeDefinitionResult,
|
runtimeDefinitionResult,
|
||||||
runtimeCopyStatus,
|
runtimeCopyStatus,
|
||||||
@ -1245,6 +1249,18 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
onClick={() => launchSequence(sequence)}
|
onClick={() => launchSequence(sequence)}
|
||||||
onDoubleClick={() => openSequenceEditor(sequence)}
|
onDoubleClick={() => openSequenceEditor(sequence)}
|
||||||
>
|
>
|
||||||
|
{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
|
<ExpressionPreview
|
||||||
expression={
|
expression={
|
||||||
firstExpression ?? expressions[0] ?? defaultExpression
|
firstExpression ?? expressions[0] ?? defaultExpression
|
||||||
@ -1256,6 +1272,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
renderStyle={activeAvatar.renderStyle}
|
renderStyle={activeAvatar.renderStyle}
|
||||||
id={`state-card-${sequence.id}`}
|
id={`state-card-${sequence.id}`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{animationSemanticKeyError(sequence) && (
|
{animationSemanticKeyError(sequence) && (
|
||||||
<i
|
<i
|
||||||
className="runtime-key-missing"
|
className="runtime-key-missing"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
|
import { avatarBodyOutlineWidth } from '@/features/rendering/avatarAppearance'
|
||||||
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
|
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
|
||||||
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
|
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
|
||||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||||
@ -35,6 +36,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
previewExpressionDraft,
|
previewExpressionDraft,
|
||||||
previewSelectedBodyNode,
|
previewSelectedBodyNode,
|
||||||
renderedColors,
|
renderedColors,
|
||||||
|
renderedExpressionBodyColor,
|
||||||
renderedRotationGizmo,
|
renderedRotationGizmo,
|
||||||
renderedScene,
|
renderedScene,
|
||||||
selectBodyNode,
|
selectBodyNode,
|
||||||
@ -72,8 +74,10 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
expression={canvasExpression}
|
expression={canvasExpression}
|
||||||
avatarEyes={activeAvatarEyes}
|
avatarEyes={activeAvatarEyes}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
|
bodyNodes={activeAvatar.body.nodes}
|
||||||
scene={renderedScene}
|
scene={renderedScene}
|
||||||
colors={renderedColors}
|
colors={renderedColors}
|
||||||
|
expressionBodyColor={renderedExpressionBodyColor}
|
||||||
renderStyle={activeAvatar.renderStyle}
|
renderStyle={activeAvatar.renderStyle}
|
||||||
rotationGizmo={renderedRotationGizmo}
|
rotationGizmo={renderedRotationGizmo}
|
||||||
showWire={showWire}
|
showWire={showWire}
|
||||||
@ -114,6 +118,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
{
|
{
|
||||||
'--avatar-body-color': renderedColors.body,
|
'--avatar-body-color': renderedColors.body,
|
||||||
'--avatar-eye-color': renderedColors.eyes,
|
'--avatar-eye-color': renderedColors.eyes,
|
||||||
|
'--avatar-outline-width': avatarBodyOutlineWidth(),
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 { StudioDialogs } from '@/features/studio/components/StudioDialogs'
|
||||||
import { StudioInspector } from '@/features/studio/components/StudioInspector'
|
import { StudioInspector } from '@/features/studio/components/StudioInspector'
|
||||||
import { StudioStage } from '@/features/studio/components/StudioStage'
|
|
||||||
import type { StudioController } from '@/features/studio/useStudioController'
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
const STACKED_LAYOUT_QUERY = '(max-width: 980px)'
|
|
||||||
|
|
||||||
export function StudioView(controller: StudioController) {
|
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 (
|
return (
|
||||||
<div className="studio-root" lang={controller.language}>
|
<div className="studio-root" lang={controller.language}>
|
||||||
{stackedLayout ? (
|
<div className="studio studio-catalog-only">
|
||||||
<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} />
|
<StudioInspector controller={controller} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<StudioDialogs controller={controller} />
|
<StudioDialogs controller={controller} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -17,8 +17,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#5b7fe5",
|
"body": "#7c89ff",
|
||||||
"eyes": "#111316"
|
"eyes": "#50d7fe"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 20,
|
"widthLeft": 20,
|
||||||
@ -113,8 +113,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#e6855c",
|
"body": "#ff9d45",
|
||||||
"eyes": "#ffffff"
|
"eyes": "#ffda1a"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 28.934879954481378,
|
"widthLeft": 28.934879954481378,
|
||||||
@ -147,8 +147,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#ffcf24",
|
"body": "#ffda1a",
|
||||||
"eyes": "#000000"
|
"eyes": "#ffda1a"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 20,
|
"widthLeft": 20,
|
||||||
@ -178,8 +178,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#55b6c3",
|
"body": "#1ad7e3",
|
||||||
"eyes": "#111316"
|
"eyes": "#1ad7e3"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 13.26171875,
|
"widthLeft": 13.26171875,
|
||||||
@ -209,8 +209,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#000000",
|
"body": "#df48f3",
|
||||||
"eyes": "#ffffff"
|
"eyes": "#ff4b6f"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 20,
|
"widthLeft": 20,
|
||||||
@ -409,8 +409,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#e69a5c",
|
"body": "#be9675",
|
||||||
"eyes": "#111316"
|
"eyes": "#ff9d45"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 20,
|
"widthLeft": 20,
|
||||||
@ -483,8 +483,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#ffc2e9",
|
"body": "#ff4b6f",
|
||||||
"eyes": "#3e4e65"
|
"eyes": "#df48f3"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 20,
|
"widthLeft": 20,
|
||||||
@ -599,8 +599,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#c9cbcf",
|
"body": "#50d7fe",
|
||||||
"eyes": "#111316"
|
"eyes": "#1a9cff"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 13.942812499999993,
|
"widthLeft": 13.942812499999993,
|
||||||
@ -630,8 +630,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#e65c5c",
|
"body": "#ff5558",
|
||||||
"eyes": "#111316"
|
"eyes": "#ff5558"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 19.975390625,
|
"widthLeft": 19.975390625,
|
||||||
@ -664,8 +664,8 @@
|
|||||||
"nodes": []
|
"nodes": []
|
||||||
},
|
},
|
||||||
"colors": {
|
"colors": {
|
||||||
"body": "#dbe2f5",
|
"body": "#1a9cff",
|
||||||
"eyes": "#111316"
|
"eyes": "#1a9cff"
|
||||||
},
|
},
|
||||||
"eyes": {
|
"eyes": {
|
||||||
"widthLeft": 13.389062500000001,
|
"widthLeft": 13.389062500000001,
|
||||||
@ -1253,6 +1253,29 @@
|
|||||||
"eyeMotion": "shake",
|
"eyeMotion": "shake",
|
||||||
"bodyMotion": "slowDrift",
|
"bodyMotion": "slowDrift",
|
||||||
"bodyColor": "#adc3ff"
|
"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": [
|
"sequences": [
|
||||||
@ -1699,7 +1722,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "angry-step-1",
|
"id": "angry-step-1",
|
||||||
"expressionId": "expression-16",
|
"expressionId": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
|
||||||
"holdMs": 2300,
|
"holdMs": 2300,
|
||||||
"transitionMs": 500,
|
"transitionMs": 500,
|
||||||
"transition": "smooth"
|
"transition": "smooth"
|
||||||
@ -1798,6 +1821,38 @@
|
|||||||
"durationMs": 260
|
"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",
|
"id": "curious",
|
||||||
"semanticKey": "curious",
|
"semanticKey": "curious",
|
||||||
@ -2089,7 +2144,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "scared-step-1",
|
"id": "scared-step-1",
|
||||||
"expressionId": "expression-21",
|
"expressionId": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
|
||||||
"holdMs": 2300,
|
"holdMs": 2300,
|
||||||
"transitionMs": 500,
|
"transitionMs": 500,
|
||||||
"transition": "smooth"
|
"transition": "smooth"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ensurePrimitiveBundledAvatars,
|
||||||
parseAvatarLibrary,
|
parseAvatarLibrary,
|
||||||
parseExpressions,
|
parseExpressions,
|
||||||
restoreLegacyBehaviorSemanticKeys,
|
restoreLegacyBehaviorSemanticKeys,
|
||||||
@ -99,17 +100,181 @@ const createBundledStudioDocument = () => {
|
|||||||
return parseStudioDocument(snapshot, snapshot)
|
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 = (
|
export const loadStudioDocument = (
|
||||||
storage: Pick<Storage, 'getItem'> = window.localStorage
|
storage: Pick<Storage, 'getItem'> = window.localStorage
|
||||||
): StudioDocument => {
|
): StudioDocument => {
|
||||||
const fallback = createBundledStudioDocument()
|
const fallback = createBundledStudioDocument()
|
||||||
try {
|
try {
|
||||||
return parseStudioDocument(
|
const document = parseStudioDocument(
|
||||||
JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'),
|
JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'),
|
||||||
fallback
|
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 {
|
} catch {
|
||||||
return fallback
|
return {
|
||||||
|
...fallback,
|
||||||
|
library: ensurePrimitiveBundledAvatars(fallback.library),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
getSequenceSpring,
|
getSequenceSpring,
|
||||||
readSequenceClock,
|
readSequenceClock,
|
||||||
remapSequencesAfterExpressionDelete,
|
remapSequencesAfterExpressionDelete,
|
||||||
|
scaleSpringDynamics,
|
||||||
type AvatarSequence,
|
type AvatarSequence,
|
||||||
type SequenceStep,
|
type SequenceStep,
|
||||||
} from '@/features/animation/sequences'
|
} from '@/features/animation/sequences'
|
||||||
@ -128,6 +129,9 @@ import {
|
|||||||
type StudioDocument,
|
type StudioDocument,
|
||||||
} from '@/features/studio/studioDocument'
|
} from '@/features/studio/studioDocument'
|
||||||
|
|
||||||
|
const JOYFUL_ARC_SEMANTIC_KEY = 'joyful-arc'
|
||||||
|
const JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER = 1.5
|
||||||
|
|
||||||
export function useStudioController() {
|
export function useStudioController() {
|
||||||
const { language, setLanguage, t } = useStudioLanguage()
|
const { language, setLanguage, t } = useStudioLanguage()
|
||||||
const [mode, setMode] = useState<Mode>('avatars')
|
const [mode, setMode] = useState<Mode>('avatars')
|
||||||
@ -340,6 +344,7 @@ export function useStudioController() {
|
|||||||
const lastTransitionTime = useRef<number | null>(null)
|
const lastTransitionTime = useRef<number | null>(null)
|
||||||
const lastInspectorFrame = useRef(0)
|
const lastInspectorFrame = useRef(0)
|
||||||
const springSpeedRef = useRef(springSpeed)
|
const springSpeedRef = useRef(springSpeed)
|
||||||
|
const expressionEntrySpeedMultiplier = useRef(1)
|
||||||
const sequenceTransitionRef = useRef<Pick<SequenceStep, 'transitionMs' | 'transition'>>({
|
const sequenceTransitionRef = useRef<Pick<SequenceStep, 'transitionMs' | 'transition'>>({
|
||||||
transitionMs: 500,
|
transitionMs: 500,
|
||||||
transition: 'smooth',
|
transition: 'smooth',
|
||||||
@ -525,6 +530,8 @@ export function useStudioController() {
|
|||||||
transitionMs: 500,
|
transitionMs: 500,
|
||||||
transition: 'smooth',
|
transition: 'smooth',
|
||||||
}
|
}
|
||||||
|
expressionEntrySpeedMultiplier.current =
|
||||||
|
next.semanticKey === JOYFUL_ARC_SEMANTIC_KEY ? JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER : 1
|
||||||
setActiveExpression(index)
|
setActiveExpression(index)
|
||||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||||
if (reduceMotion || transitionSettings?.transitionMs === 0) {
|
if (reduceMotion || transitionSettings?.transitionMs === 0) {
|
||||||
@ -576,6 +583,9 @@ export function useStudioController() {
|
|||||||
? progress * progress * (3 - 2 * progress)
|
? progress * progress * (3 - 2 * progress)
|
||||||
: transitionSettings.transition === 'snappy'
|
: transitionSettings.transition === 'snappy'
|
||||||
? 1 - (1 - progress) ** 3
|
? 1 - (1 - progress) ** 3
|
||||||
|
: 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)
|
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress)
|
||||||
const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion }
|
const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion }
|
||||||
expressionFields.forEach(field => {
|
expressionFields.forEach(field => {
|
||||||
@ -618,11 +628,11 @@ export function useStudioController() {
|
|||||||
if (avatar) {
|
if (avatar) {
|
||||||
const targetColors = resolveColors(next, avatar.colors)
|
const targetColors = resolveColors(next, avatar.colors)
|
||||||
bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, {
|
bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, {
|
||||||
duration: 0.35,
|
duration: 0.35 / expressionEntrySpeedMultiplier.current,
|
||||||
ease: 'easeInOut',
|
ease: 'easeInOut',
|
||||||
})
|
})
|
||||||
eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, {
|
eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, {
|
||||||
duration: 0.35,
|
duration: 0.35 / expressionEntrySpeedMultiplier.current,
|
||||||
ease: 'easeInOut',
|
ease: 'easeInOut',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -644,16 +654,23 @@ export function useStudioController() {
|
|||||||
const previousTime = lastTransitionTime.current ?? time
|
const previousTime = lastTransitionTime.current ?? time
|
||||||
const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30)
|
const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30)
|
||||||
lastTransitionTime.current = time
|
lastTransitionTime.current = time
|
||||||
const { stiffness, damping } = getSequenceSpring(
|
const { stiffness, damping } = scaleSpringDynamics(
|
||||||
|
getSequenceSpring(
|
||||||
sequenceTransitionRef.current.transition,
|
sequenceTransitionRef.current.transition,
|
||||||
sequenceTransitionRef.current.transitionMs,
|
sequenceTransitionRef.current.transitionMs,
|
||||||
springSpeedRef.current
|
springSpeedRef.current
|
||||||
|
),
|
||||||
|
expressionEntrySpeedMultiplier.current
|
||||||
)
|
)
|
||||||
const mass = 0.85
|
const mass = 0.85
|
||||||
const currentExpression = displayedPose.current.expression
|
const currentExpression = displayedPose.current.expression
|
||||||
if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) {
|
if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) {
|
||||||
if (retargetStartedAt.current < 0) retargetStartedAt.current = time
|
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 =
|
const smoothProgress =
|
||||||
linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10)
|
linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10)
|
||||||
const blendedTarget = { ...retargetTo.current }
|
const blendedTarget = { ...retargetTo.current }
|
||||||
@ -815,6 +832,7 @@ export function useStudioController() {
|
|||||||
const nextExpressions = nextBehavior.expressions
|
const nextExpressions = nextBehavior.expressions
|
||||||
const nextSequences = nextBehavior.sequences
|
const nextSequences = nextBehavior.sequences
|
||||||
stopTransition(true)
|
stopTransition(true)
|
||||||
|
stopColorTransitions()
|
||||||
activeAvatarIdRef.current = id
|
activeAvatarIdRef.current = id
|
||||||
surfaceRef.current = avatar.body.primary
|
surfaceRef.current = avatar.body.primary
|
||||||
bodyNodesRef.current = avatar.body.nodes
|
bodyNodesRef.current = avatar.body.nodes
|
||||||
@ -849,9 +867,23 @@ export function useStudioController() {
|
|||||||
modeRef.current = 'manual'
|
modeRef.current = 'manual'
|
||||||
setMode('manual')
|
setMode('manual')
|
||||||
}
|
}
|
||||||
|
const avatarStateExpression =
|
||||||
|
nextExpressions.find(expression => expression.id === currentStateExpression.id) ??
|
||||||
|
nextExpressions.find(
|
||||||
|
expression =>
|
||||||
|
currentStateExpression.semanticKey &&
|
||||||
|
expression.semanticKey === currentStateExpression.semanticKey
|
||||||
|
)
|
||||||
const selectedExpression = nextActiveSequence
|
const selectedExpression = nextActiveSequence
|
||||||
? currentStateExpression
|
? { ...(avatarStateExpression ?? currentStateExpression) }
|
||||||
: { ...(nextExpressions[0] ?? defaultExpression) }
|
: { ...(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
|
const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression
|
||||||
setExpression(nextExpression)
|
setExpression(nextExpression)
|
||||||
if (editBody) {
|
if (editBody) {
|
||||||
@ -1537,6 +1569,16 @@ export function useStudioController() {
|
|||||||
: activeSequence.name
|
: activeSequence.name
|
||||||
: null
|
: null
|
||||||
const expressionById = new Map(expressions.map(item => [item.id, item]))
|
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') =>
|
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
|
||||||
t(
|
t(
|
||||||
issue === 'missing_semantic_key'
|
issue === 'missing_semantic_key'
|
||||||
@ -1999,6 +2041,7 @@ export function useStudioController() {
|
|||||||
reduceMotion,
|
reduceMotion,
|
||||||
renameActiveAvatar,
|
renameActiveAvatar,
|
||||||
renderedColors,
|
renderedColors,
|
||||||
|
renderedExpressionBodyColor,
|
||||||
renderedRotationGizmo,
|
renderedRotationGizmo,
|
||||||
renderedScene,
|
renderedScene,
|
||||||
runtimeDefinitionResult,
|
runtimeDefinitionResult,
|
||||||
|
|||||||
@ -100,6 +100,15 @@ const english: Record<string, string> = {
|
|||||||
'Couleur des yeux': 'Eye color',
|
'Couleur des yeux': 'Eye color',
|
||||||
Largeur: 'Width',
|
Largeur: 'Width',
|
||||||
Hauteur: 'Height',
|
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',
|
Échelle: 'Scale',
|
||||||
'Taille proportionnelle': 'Proportional size',
|
'Taille proportionnelle': 'Proportional size',
|
||||||
'Œil gauche': 'Left eye',
|
'Œil gauche': 'Left eye',
|
||||||
@ -504,6 +513,7 @@ const english: Record<string, string> = {
|
|||||||
'Durée de transition': 'Transition duration',
|
'Durée de transition': 'Transition duration',
|
||||||
Transition: 'Transition',
|
Transition: 'Transition',
|
||||||
Ressort: 'Spring',
|
Ressort: 'Spring',
|
||||||
|
'Ressort léger': 'Gentle spring',
|
||||||
Douce: 'Smooth',
|
Douce: 'Smooth',
|
||||||
Rapide: 'Snappy',
|
Rapide: 'Snappy',
|
||||||
'Ajouter une expression': 'Add expression',
|
'Ajouter une expression': 'Add expression',
|
||||||
@ -588,6 +598,7 @@ const english: Record<string, string> = {
|
|||||||
'Fast tilts and rotations to simulate a moving agent.',
|
'Fast tilts and rotations to simulate a moving agent.',
|
||||||
'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.',
|
'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.',
|
||||||
'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.',
|
'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.',
|
'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.',
|
||||||
sleeping: 'sleeping',
|
sleeping: 'sleeping',
|
||||||
waking: 'waking',
|
waking: 'waking',
|
||||||
@ -602,6 +613,7 @@ const english: Record<string, string> = {
|
|||||||
angry: 'angry',
|
angry: 'angry',
|
||||||
drowsy: 'drowsy',
|
drowsy: 'drowsy',
|
||||||
happy: 'happy',
|
happy: 'happy',
|
||||||
|
joyful: 'joyful',
|
||||||
curious: 'curious',
|
curious: 'curious',
|
||||||
confused: 'confused',
|
confused: 'confused',
|
||||||
bored: 'bored',
|
bored: 'bored',
|
||||||
@ -655,6 +667,7 @@ const frenchStates: Record<string, string> = {
|
|||||||
angry: 'en colère',
|
angry: 'en colère',
|
||||||
drowsy: 'somnolent',
|
drowsy: 'somnolent',
|
||||||
happy: 'heureux',
|
happy: 'heureux',
|
||||||
|
joyful: 'joyeux',
|
||||||
curious: 'curieux',
|
curious: 'curieux',
|
||||||
confused: 'confus',
|
confused: 'confus',
|
||||||
bored: 'ennuyé',
|
bored: 'ennuyé',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user