feat: expand avatar skins and expressions

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

28
THIRD_PARTY_NOTICES.md Normal file
View File

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

View File

@ -13,8 +13,8 @@
"nodes": [] "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",

View File

@ -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,

View File

@ -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"] }
} }
} }
}, },

View File

@ -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 = {

View File

@ -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]),
})) }))
: [] : []

View File

@ -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)
@ -1306,8 +1454,8 @@ const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
depth, depth,
front: front:
node.layer === 'front' || node.layer === 'front' ||
(node.layer !== 'back' && (node.layer !== 'back' &&
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO), depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
} }
}) })
.sort((left, right) => left.depth - right.depth) .sort((left, right) => left.depth - right.depth)

View File

@ -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))
} }

View File

@ -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],

View File

@ -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),

View File

@ -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 })

View File

@ -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()
} }

View File

@ -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;

View File

@ -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' }

View File

@ -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>

View File

@ -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,
} }
} }

View File

@ -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' })
}) })
}) })

View File

@ -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', () => {

View File

@ -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, {

View File

@ -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],

View File

@ -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 }
delete fixedColorExpression.bodyColor const bundledExpression =
delete fixedColorExpression.eyeColor initialExpressions.find(candidate => candidate.semanticKey === expression.semanticKey) ??
initialExpressions[index]
if (expression.semanticKey === 'angry-brows' || expression.semanticKey === 'uneasy-left') {
if (bundledExpression?.bodyColor) {
fixedColorExpression.bodyColor = bundledExpression.bodyColor
} else {
delete fixedColorExpression.bodyColor
}
if (bundledExpression?.eyeColor) {
fixedColorExpression.eyeColor = bundledExpression.eyeColor
} else {
delete fixedColorExpression.eyeColor
}
} else {
delete fixedColorExpression.bodyColor
delete fixedColorExpression.eyeColor
}
return fixedColorExpression 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,
} }
: { ...defaultAvatarEyes }, : surface === 'apple-bite'
renderStyle: { ...defaultAvatarRenderStyle }, ? {
...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}), ...defaultAvatarEyes,
positionXLeft: defaultAvatarEyes.positionXLeft - 18,
positionXRight: defaultAvatarEyes.positionXRight - 18,
}
: { ...defaultAvatarEyes },
renderStyle:
surface === 'ghost' || surface === 'apple-bite'
? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle },
...(surface === 'ghost'
? { behavior: mementoBehavior() }
: surface === 'apple-bite'
? { behavior: fixedSkinColorBehavior() }
: {}),
})), })),
mementoPigletAvatar,
{ {
id: 'primitive-willy', 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

View File

@ -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) => {
<path const color = nodeColor(geometry.backNodeIds[index])
className="preview-head" return (
d={pathValue} <path
key={index} className="preview-head"
style={{ fill: resolvedColors.body }} d={pathValue}
/> key={index}
))} style={{
<path className="preview-head" d={geometry.headPath} style={{ fill: resolvedColors.body }} /> fill: filled ? color : bodyFill,
stroke: color,
strokeWidth: outlineWidth,
strokeLinejoin: 'round',
}}
/>
)
})}
<path
className="preview-head"
d={geometry.headPath}
style={{
fill: bodyFill,
stroke: resolvedColors.body,
strokeWidth: outlineWidth,
strokeLinejoin: 'round',
}}
/>
<g clipPath={`url(#${clipId})`}> <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) => {
<path const color = nodeColor(geometry.frontNodeIds[index])
className="preview-head" return (
d={pathValue} <path
key={`front-${index}`} className="preview-head"
style={{ fill: resolvedColors.body }} d={pathValue}
key={`front-${index}`}
style={{
fill: filled ? color : bodyFill,
stroke: color,
strokeWidth: outlineWidth,
strokeLinejoin: 'round',
}}
/>
)
})}
</svg>
)
}
export function LiveExpressionPreview({
scene,
colors,
baseBodyColor,
bodyNodes,
renderStyle,
expressionBodyColor,
id,
}: {
scene: RenderedScene
colors: RenderedColors
baseBodyColor: string
bodyNodes: BodyNode[]
renderStyle: AvatarRenderStyle
expressionBodyColor?: string
id: string
}) {
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor)
if (renderStyle.type === 'pixel') {
return (
<LivePixelAvatarCanvas
className="avatar-preview"
scene={scene}
colors={colors}
style={renderStyle}
/>
)
}
const clipId = `live-preview-${id}`
const filled = renderStyle.filled === true
const outlineWidth = avatarBodyOutlineWidth()
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
const nodeColor = (nodeId: string | null | undefined) =>
inheritBodyColor
? colors.body
: (bodyNodes.find(node => node.id === nodeId)?.color ?? colors.body)
return (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<motion.path d={scene.headPath} />
</clipPath>
</defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => {
const color = nodeColor(scene.backNodeIds.current[index])
return (
<motion.path
d={pathValue}
strokeLinecap="round"
strokeLinejoin="round"
style={{
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)',
stroke: color,
strokeWidth: outlineWidth,
}}
key={`back-${index}`}
/>
)
})}
<motion.path
d={scene.headPath}
strokeLinecap="round"
strokeLinejoin="round"
style={{
fill: filled ? colors.body : 'var(--avatar-interior-color, #ffffff)',
stroke: colors.body,
strokeWidth: outlineWidth,
}}
/> />
))} <g clipPath={`url(#${clipId})`}>
<motion.path
d={scene.leftPath}
opacity={scene.leftOpacity}
style={{ fill: colors.eyes }}
/>
<motion.path
d={scene.rightPath}
opacity={scene.rightOpacity}
style={{ fill: colors.eyes }}
/>
</g>
{scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index])
return (
<motion.path
d={pathValue}
strokeLinecap="round"
strokeLinejoin="round"
style={{
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)',
stroke: color,
strokeWidth: outlineWidth,
}}
key={`front-${index}`}
/>
)
})}
</motion.g>
</svg> </svg>
) )
} }

View File

@ -56,9 +56,26 @@ const calibrated: number[][] = [
[-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0], [-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0],
] ]
export const initialExpressions: Expression[] = calibrated.map( export const initialExpressions: Expression[] = [
( ...calibrated.map(
[ (
[
headX,
headY,
headZ,
widthLeft,
widthRight,
heightLeft,
heightRight,
spacing,
latitude,
leftAngle,
rightAngle,
],
index
) => ({
id: `expression-${String(index).padStart(2, '0')}`,
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
headX, headX,
headY, headY,
headZ, headZ,
@ -66,34 +83,93 @@ export const initialExpressions: Expression[] = calibrated.map(
widthRight, widthRight,
heightLeft, heightLeft,
heightRight, heightRight,
curvatureLeft: 0,
curvatureRight: 0,
spacing, spacing,
latitude, positionXLeft: 0,
positionXRight: 0,
positionYLeft: latitude,
positionYRight: latitude,
leftAngle, leftAngle,
rightAngle, rightAngle,
], perspective: 1,
index eyeMotion: 'none' as const,
) => ({ bodyMotion: 'none' as const,
id: `expression-${String(index).padStart(2, '0')}`, })
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`], ),
headX, {
headY, id: 'expression-3d2bed26-f97c-477d-922f-77600cb10e92',
headZ, semanticKey: 'angry-brows',
widthLeft, headX: 10.473974503042374,
widthRight, headY: 5.087293619785961,
heightLeft, headZ: 4.698252132317348,
heightRight, widthLeft: 27.126562499999995,
spacing, widthRight: 27.126562499999995,
heightLeft: 63.028125,
heightRight: 63.028125,
curvatureLeft: 0,
curvatureRight: 0,
spacing: 68.7,
positionXLeft: 0, positionXLeft: 0,
positionXRight: 0, positionXRight: 0,
positionYLeft: latitude, positionYLeft: 0,
positionYRight: latitude, positionYRight: 0,
leftAngle, leftAngle: -36.244531249999994,
rightAngle, rightAngle: 27.730078125000002,
perspective: 1, perspective: 1,
eyeMotion: 'none', eyeMotion: 'none' as const,
bodyMotion: 'none', 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.',
} }

View File

@ -8,7 +8,10 @@ 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
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress); : transition === 'gentleSpring'
? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress))
/ (1 - Math.exp(-9) * Math.cos(5))
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
const nearestAngle = (target, current) => { const nearestAngle = (target, current) => {
let resolved = target; let resolved = target;
while (resolved - current > 180) resolved -= 360; while (resolved - current > 180) resolved -= 360;
@ -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

View File

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

View File

@ -9,11 +9,29 @@ import {
paintRenderedScene, 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([])
}) })
}) })

View File

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

View File

@ -1,6 +1,6 @@
import { RotateCcw } from 'lucide-react' import { 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])}
/> />
))} ))}

View File

@ -28,42 +28,188 @@ 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 = [
'Memento',
'Apple Bite',
'Siamese',
'British Shorthair',
'Russian Blue',
'Orange Tabby',
'Black Cat',
'Dog',
'Brown Dog',
'Bear',
'Rabbit',
'Bun',
'Onee',
'Cubee',
'Nova',
'Citrus',
'Sphere',
'Cube',
'Capsule',
'Cylinder',
'Cone',
'Diamond',
'Willy',
'OneWorks Sphere',
'OneWorks Ellipse',
'OneWorks Square',
'OneWorks Rounded',
'OneWorks Capsule',
'OneWorks Teardrop',
'OneWorks Diamond',
'OneWorks Trapezoid',
'OneWorks Cone',
'OneWorks Frustum',
'OneWorks Half Cone',
'OneWorks Cloud',
'OneWorks Sun',
]
it('loads the bundled Studio snapshot when no local project exists', () => { it('loads the bundled Studio snapshot when no local project exists', () => {
const document = loadStudioDocument(storage()) const document = loadStudioDocument(storage())
expect(document.library.avatars).toHaveLength(12) expect(document.library.avatars).toHaveLength(bundledAvatarNames.length)
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id) expect(document.library.activeAvatarId).toBe('avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e')
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([ expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames)
'Onee', expect(
'Cubee', document.library.avatars
'Nova', .filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
'Citrus', .every(avatar => avatar.colors.body === avatar.colors.eyes)
'Sphere', ).toBe(true)
'Cube', expect(document.library.avatars.slice(0, 12).map(avatar => avatar.body.primary.type)).toEqual([
'Capsule', 'ghost',
'Cylinder', 'apple-bite',
'Cone', 'ellipse',
'Diamond', 'ellipse',
'Memento', 'ellipse',
'Willy', '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)

View File

@ -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,15 +204,24 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
activateAvatar(avatar.id, true) activateAvatar(avatar.id, true)
}} }}
> >
<ExpressionPreview {activeAvatarId === avatar.id && playbackStatus !== 'stopped' ? (
expression={expressions[0] ?? defaultExpression} <LiveAvatarPreview
surface={avatar.body.primary} avatar={avatar}
bodyNodes={avatar.body.nodes} colors={renderedColors}
colors={avatar.colors} scene={renderedScene}
avatarEyes={avatar.eyes ?? defaultAvatarEyes} expressionBodyColor={renderedExpressionBodyColor}
renderStyle={avatar.renderStyle} />
id={`avatar-${avatar.id}`} ) : (
/> <ExpressionPreview
expression={expressions[0] ?? defaultExpression}
surface={avatar.body.primary}
bodyNodes={avatar.body.nodes}
colors={avatar.colors}
avatarEyes={avatar.eyes ?? defaultAvatarEyes}
renderStyle={avatar.renderStyle}
id={`avatar-${avatar.id}`}
/>
)}
<span>{avatar.name}</span> <span>{avatar.name}</span>
</Button> </Button>
} }

View File

@ -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>

View File

@ -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,17 +1249,30 @@ export function StudioInspector({ controller }: { controller: StudioController }
onClick={() => launchSequence(sequence)} onClick={() => launchSequence(sequence)}
onDoubleClick={() => openSequenceEditor(sequence)} onDoubleClick={() => openSequenceEditor(sequence)}
> >
<ExpressionPreview {activeSequence?.id === sequence.id &&
expression={ playbackStatus !== 'stopped' ? (
firstExpression ?? expressions[0] ?? defaultExpression <LiveExpressionPreview
} scene={renderedScene}
surface={surface} colors={renderedColors}
bodyNodes={bodyNodes} baseBodyColor={activeAvatar.colors.body}
colors={activeAvatar.colors} bodyNodes={bodyNodes}
avatarEyes={activeAvatarEyes} renderStyle={activeAvatar.renderStyle}
renderStyle={activeAvatar.renderStyle} expressionBodyColor={renderedExpressionBodyColor}
id={`state-card-${sequence.id}`} id={`state-card-${sequence.id}`}
/> />
) : (
<ExpressionPreview
expression={
firstExpression ?? expressions[0] ?? defaultExpression
}
surface={surface}
bodyNodes={bodyNodes}
colors={activeAvatar.colors}
avatarEyes={activeAvatarEyes}
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
)}
{animationSemanticKeyError(sequence) && ( {animationSemanticKeyError(sequence) && (
<i <i
className="runtime-key-missing" className="runtime-key-missing"

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { 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
} }
> >

View File

@ -1,56 +1,13 @@
import { useEffect, useState } from 'react'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
import { StudioDialogs } from '@/features/studio/components/StudioDialogs' import { 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 <StudioInspector controller={controller} />
className="studio studio-mobile-resizable" </div>
orientation="vertical"
resizeTargetMinimumSize={{ coarse: 40, fine: 12 }}
>
<ResizablePanel
id="preview"
className="mobile-stage-panel"
defaultSize="42%"
minSize={180}
>
<StudioStage controller={controller} />
</ResizablePanel>
<ResizableHandle
className="studio-resize-handle"
withHandle
aria-label={controller.t('Redimensionner l’aperçu et l’éditeur')}
/>
<ResizablePanel id="editor" className="mobile-inspector-panel" minSize={260}>
<StudioInspector controller={controller} />
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="studio">
<StudioStage controller={controller} />
<StudioInspector controller={controller} />
</div>
)}
<StudioDialogs controller={controller} /> <StudioDialogs controller={controller} />
</div> </div>
) )

View File

@ -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"

View File

@ -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),
}
} }
} }

View File

@ -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,7 +583,10 @@ 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
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress) : transitionSettings.transition === 'gentleSpring'
? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) /
(1 - Math.exp(-9) * Math.cos(5))
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress)
const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion } const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion }
expressionFields.forEach(field => { expressionFields.forEach(field => {
animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased animated[field] = from[field] + (resolvedTarget[field] - from[field]) * eased
@ -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(
sequenceTransitionRef.current.transition, getSequenceSpring(
sequenceTransitionRef.current.transitionMs, sequenceTransitionRef.current.transition,
springSpeedRef.current sequenceTransitionRef.current.transitionMs,
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,

View File

@ -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é',