feat: expand avatar skins and expressions
This commit is contained in:
parent
be77e778d1
commit
9cca5fa079
28
THIRD_PARTY_NOTICES.md
Normal file
28
THIRD_PARTY_NOTICES.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Third-party notices
|
||||
|
||||
## OneWorks Avatar
|
||||
|
||||
The `oneworks-*` body shapes and bundled character silhouettes are adapted from
|
||||
[OneWorks Avatar](https://github.com/oneworks-ai/avatar).
|
||||
|
||||
Copyright (c) 2026-present One Works contributors
|
||||
|
||||
Licensed under the MIT License:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@ -13,8 +13,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#5b7fe5",
|
||||
"eyes": "#111316"
|
||||
"body": "#7c89ff",
|
||||
"eyes": "#50d7fe"
|
||||
},
|
||||
"expressions": {
|
||||
"neutral": {
|
||||
@ -27,6 +27,7 @@
|
||||
"left": {
|
||||
"width": 20,
|
||||
"height": 50,
|
||||
"curvature": 0,
|
||||
"x": 0,
|
||||
"y": -7,
|
||||
"angle": 0
|
||||
@ -34,6 +35,7 @@
|
||||
"right": {
|
||||
"width": 20,
|
||||
"height": 50,
|
||||
"curvature": 0,
|
||||
"x": 0,
|
||||
"y": -7,
|
||||
"angle": 0
|
||||
@ -835,6 +837,37 @@
|
||||
"colors": {
|
||||
"body": "#adc3ff"
|
||||
}
|
||||
},
|
||||
"joyful-arc": {
|
||||
"head": {
|
||||
"x": -8.75234375,
|
||||
"y": -8.743359375,
|
||||
"z": -10.773828125
|
||||
},
|
||||
"eyes": {
|
||||
"left": {
|
||||
"width": 54,
|
||||
"height": 15,
|
||||
"curvature": 1,
|
||||
"x": 0,
|
||||
"y": -18,
|
||||
"angle": 0
|
||||
},
|
||||
"right": {
|
||||
"width": 54,
|
||||
"height": 15,
|
||||
"curvature": 1,
|
||||
"x": 0,
|
||||
"y": -18,
|
||||
"angle": 0
|
||||
},
|
||||
"spacing": 69.276171875
|
||||
},
|
||||
"perspective": 1,
|
||||
"motion": {
|
||||
"eyes": "none",
|
||||
"body": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expressionOrder": [
|
||||
@ -865,7 +898,8 @@
|
||||
"suspicious-right",
|
||||
"shy-downward",
|
||||
"angry-brows",
|
||||
"uneasy-left"
|
||||
"uneasy-left",
|
||||
"joyful-arc"
|
||||
],
|
||||
"animations": {
|
||||
"sleeping": {
|
||||
@ -1258,7 +1292,7 @@
|
||||
"transition": "smooth"
|
||||
},
|
||||
{
|
||||
"expression": "angry-left",
|
||||
"expression": "angry-brows",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 500,
|
||||
"transition": "smooth"
|
||||
@ -1353,6 +1387,35 @@
|
||||
"group": "Réactions"
|
||||
}
|
||||
},
|
||||
"joyful": {
|
||||
"playbackMode": "loop",
|
||||
"steps": [
|
||||
{
|
||||
"expression": "joyful-wide",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 700,
|
||||
"transition": "gentleSpring"
|
||||
},
|
||||
{
|
||||
"expression": "joyful-arc",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 700,
|
||||
"transition": "gentleSpring"
|
||||
}
|
||||
],
|
||||
"blink": {
|
||||
"enabled": false,
|
||||
"initialDelayMs": 2100,
|
||||
"minIntervalMs": 2800,
|
||||
"maxIntervalMs": 5000,
|
||||
"durationMs": 260
|
||||
},
|
||||
"metadata": {
|
||||
"label": "joyful",
|
||||
"description": "Morphing fluide entre les expressions 11 et 27.",
|
||||
"group": "Réactions"
|
||||
}
|
||||
},
|
||||
"curious": {
|
||||
"playbackMode": "loop",
|
||||
"steps": [
|
||||
@ -1608,7 +1671,7 @@
|
||||
"transition": "smooth"
|
||||
},
|
||||
{
|
||||
"expression": "surprised-wide-left",
|
||||
"expression": "uneasy-left",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 500,
|
||||
"transition": "smooth"
|
||||
@ -1718,6 +1781,7 @@
|
||||
"angry",
|
||||
"drowsy",
|
||||
"happy",
|
||||
"joyful",
|
||||
"curious",
|
||||
"confused",
|
||||
"surprised",
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
bodyFromDefinition,
|
||||
createAvatarPlaybackState,
|
||||
expressionFromDefinition,
|
||||
interpolatePose,
|
||||
parseAvatarDefinition,
|
||||
playAvatarAnimation,
|
||||
pauseAvatarPlayback,
|
||||
@ -13,7 +14,11 @@ import {
|
||||
resumeAvatarPlayback,
|
||||
resolveAnimation,
|
||||
sampleAvatarFrame,
|
||||
surfaceFrontSampleAt,
|
||||
surfacePresets,
|
||||
surfaceSampleAt,
|
||||
type AvatarDefinition,
|
||||
type SurfaceType,
|
||||
} from '../index'
|
||||
|
||||
const expression = {
|
||||
@ -66,7 +71,64 @@ const definition: AvatarDefinition = {
|
||||
animationOrder: ['idle'],
|
||||
}
|
||||
|
||||
const pathPoints = (value: string): [number, number][] =>
|
||||
Array.from(value.matchAll(/[ML](-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g), match => [
|
||||
Number(match[1]),
|
||||
Number(match[2]),
|
||||
])
|
||||
|
||||
const pathSelfIntersects = (value: string) => {
|
||||
const points = pathPoints(value)
|
||||
const orientation = (
|
||||
[ax, ay]: [number, number],
|
||||
[bx, by]: [number, number],
|
||||
[cx, cy]: [number, number]
|
||||
) => (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
|
||||
|
||||
for (let first = 0; first < points.length; first += 1) {
|
||||
const firstNext = (first + 1) % points.length
|
||||
for (let second = first + 2; second < points.length; second += 1) {
|
||||
const secondNext = (second + 1) % points.length
|
||||
if (secondNext === first) continue
|
||||
const abC = orientation(points[first], points[firstNext], points[second])
|
||||
const abD = orientation(points[first], points[firstNext], points[secondNext])
|
||||
const cdA = orientation(points[second], points[secondNext], points[first])
|
||||
const cdB = orientation(points[second], points[secondNext], points[firstNext])
|
||||
if (abC * abD < -0.000001 && cdA * cdB < -0.000001) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
describe('@bible-strong/avatar-core', () => {
|
||||
it('renders every adapted OneWorks surface with finite body and eye geometry', () => {
|
||||
const types: SurfaceType[] = [
|
||||
'ellipse',
|
||||
'square',
|
||||
'rounded',
|
||||
'teardrop',
|
||||
'trapezoid',
|
||||
'frustum',
|
||||
'half-cone',
|
||||
]
|
||||
const neutral = poseFromExpression(expressionFromDefinition('neutral', expression))
|
||||
|
||||
types.forEach(type => {
|
||||
const surface = surfacePresets[type]
|
||||
const sample = surfaceSampleAt(surface, 0.4, -0.25)
|
||||
const face = surfaceFrontSampleAt(surface, 18, -12)
|
||||
const geometry = renderAvatar(neutral, surface)
|
||||
|
||||
expect(
|
||||
[...sample.point, ...sample.normal, ...face.point, ...face.normal].every(Number.isFinite)
|
||||
).toBe(true)
|
||||
expect(geometry.headPath).toContain('M')
|
||||
expect(geometry.leftPath).toContain('M')
|
||||
expect(geometry.rightPath).toContain('M')
|
||||
expect(JSON.stringify(geometry)).not.toContain('NaN')
|
||||
})
|
||||
})
|
||||
|
||||
it('loads a JSON definition and resolves an explicit semantic animation', () => {
|
||||
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
||||
expect(parsed.ok).toBe(true)
|
||||
@ -241,6 +303,74 @@ describe('@bible-strong/avatar-core', () => {
|
||||
expect(scene.colors).toEqual(definition.colors)
|
||||
})
|
||||
|
||||
it('interpolates joyful eye curvature with the standard expression fields', () => {
|
||||
const regular = poseFromExpression(expressionFromDefinition('neutral', expression))
|
||||
const joyful = poseFromExpression(
|
||||
expressionFromDefinition('joyful', {
|
||||
...expression,
|
||||
eyes: {
|
||||
...expression.eyes,
|
||||
left: { ...expression.eyes.left, curvature: 1 },
|
||||
right: { ...expression.eyes.right, curvature: 1 },
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const halfway = interpolatePose(regular, joyful, 0.5)
|
||||
|
||||
expect(halfway.expression.curvatureLeft).toBe(0.5)
|
||||
expect(halfway.expression.curvatureRight).toBe(0.5)
|
||||
})
|
||||
|
||||
it('keeps curved eyes from folding over themselves while morphing into tall eyes', () => {
|
||||
const joyful = poseFromExpression(
|
||||
expressionFromDefinition('joyful', {
|
||||
...expression,
|
||||
eyes: {
|
||||
left: { width: 54, height: 15, x: 0, y: -18, angle: 0, curvature: 1 },
|
||||
right: { width: 54, height: 15, x: 0, y: -18, angle: 0, curvature: 1 },
|
||||
spacing: 69.276,
|
||||
},
|
||||
})
|
||||
)
|
||||
const tallTargets = [
|
||||
{ left: [51.4, 50.1, 0], right: [50.5, 49.4, 0], spacing: 69 },
|
||||
{ left: [28.8, 51.4, 0], right: [17.3, 42.7, 90], spacing: 56.6 },
|
||||
] as const
|
||||
const surface = bodyFromDefinition(definition.body).primary
|
||||
|
||||
tallTargets.forEach(target => {
|
||||
const targetPose = poseFromExpression(
|
||||
expressionFromDefinition('target', {
|
||||
...expression,
|
||||
eyes: {
|
||||
left: {
|
||||
width: target.left[0],
|
||||
height: target.left[1],
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: target.left[2],
|
||||
},
|
||||
right: {
|
||||
width: target.right[0],
|
||||
height: target.right[1],
|
||||
x: 0,
|
||||
y: 0,
|
||||
angle: target.right[2],
|
||||
},
|
||||
spacing: target.spacing,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
for (let step = 0; step <= 100; step += 1) {
|
||||
const geometry = renderAvatar(interpolatePose(joyful, targetPose, step / 100), surface, 1)
|
||||
expect(pathSelfIntersects(geometry.leftPath)).toBe(false)
|
||||
expect(pathSelfIntersects(geometry.rightPath)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
|
||||
const narrow: AvatarDefinition = {
|
||||
...definition,
|
||||
|
||||
@ -71,11 +71,19 @@
|
||||
"mickey",
|
||||
"cursor",
|
||||
"cube",
|
||||
"ellipse",
|
||||
"square",
|
||||
"rounded",
|
||||
"capsule",
|
||||
"cylinder",
|
||||
"cone",
|
||||
"teardrop",
|
||||
"trapezoid",
|
||||
"frustum",
|
||||
"half-cone",
|
||||
"diamond",
|
||||
"ghost"
|
||||
"ghost",
|
||||
"apple-bite"
|
||||
]
|
||||
},
|
||||
"width": { "$ref": "#/$defs/dimension" },
|
||||
@ -84,7 +92,9 @@
|
||||
"roundness": { "$ref": "#/$defs/roundness" },
|
||||
"morphRoundness": { "$ref": "#/$defs/roundness" },
|
||||
"tipRoundness": { "$ref": "#/$defs/roundness" },
|
||||
"baseRoundness": { "$ref": "#/$defs/roundness" }
|
||||
"baseRoundness": { "$ref": "#/$defs/roundness" },
|
||||
"topScale": { "type": "number", "minimum": 0.05, "maximum": 2 },
|
||||
"cutAngle": { "type": "number", "minimum": -360, "maximum": 360 }
|
||||
}
|
||||
},
|
||||
"nodeSurface": {
|
||||
@ -93,7 +103,23 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] }
|
||||
"type": {
|
||||
"enum": [
|
||||
"sphere",
|
||||
"cube",
|
||||
"ellipse",
|
||||
"square",
|
||||
"rounded",
|
||||
"capsule",
|
||||
"cylinder",
|
||||
"cone",
|
||||
"teardrop",
|
||||
"trapezoid",
|
||||
"frustum",
|
||||
"half-cone",
|
||||
"diamond"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -133,6 +159,7 @@
|
||||
"required": ["surface", "position", "rotation"],
|
||||
"properties": {
|
||||
"layer": { "enum": ["auto", "front", "back"] },
|
||||
"color": { "$ref": "#/$defs/hexColor" },
|
||||
"surface": { "$ref": "#/$defs/nodeSurface" },
|
||||
"position": { "$ref": "#/$defs/position" },
|
||||
"rotation": { "$ref": "#/$defs/rotation" }
|
||||
@ -159,7 +186,8 @@
|
||||
"height": { "$ref": "#/$defs/boundedNumber" },
|
||||
"x": { "$ref": "#/$defs/boundedNumber" },
|
||||
"y": { "$ref": "#/$defs/boundedNumber" },
|
||||
"angle": { "$ref": "#/$defs/boundedNumber" }
|
||||
"angle": { "$ref": "#/$defs/boundedNumber" },
|
||||
"curvature": { "type": "number", "minimum": 0, "maximum": 1 }
|
||||
}
|
||||
},
|
||||
"expression": {
|
||||
@ -238,7 +266,7 @@
|
||||
"expression": { "$ref": "#/$defs/semanticKey" },
|
||||
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
|
||||
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
|
||||
"transition": { "enum": ["spring", "smooth", "snappy"] }
|
||||
"transition": { "enum": ["spring", "gentleSpring", "smooth", "snappy"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -41,14 +41,17 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
|
||||
morphRoundness?: number
|
||||
tipRoundness?: number
|
||||
baseRoundness?: number
|
||||
topScale?: number
|
||||
cutAngle?: number
|
||||
}
|
||||
|
||||
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost'>
|
||||
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost' | 'apple-bite'>
|
||||
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
|
||||
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
|
||||
|
||||
export type AvatarBodyNodeDefinition = {
|
||||
layer?: 'auto' | 'front' | 'back'
|
||||
color?: HexColor
|
||||
surface: BodyNodeSurfaceDefinition
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
@ -62,8 +65,15 @@ export type AvatarBodyDefinition = {
|
||||
export type AvatarExpressionDefinition = {
|
||||
head: { x: number; y: number; z: number }
|
||||
eyes: {
|
||||
left: { width: number; height: number; x: number; y: number; angle: number }
|
||||
right: { width: number; height: number; x: number; y: number; angle: number }
|
||||
left: { width: number; height: number; x: number; y: number; angle: number; curvature?: number }
|
||||
right: {
|
||||
width: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
angle: number
|
||||
curvature?: number
|
||||
}
|
||||
spacing: number
|
||||
}
|
||||
perspective: number
|
||||
@ -78,7 +88,7 @@ export type AvatarAnimationStepDefinition = {
|
||||
expression: ExpressionKey
|
||||
holdMs: number
|
||||
transitionMs: number
|
||||
transition: 'spring' | 'smooth' | 'snappy'
|
||||
transition: 'spring' | 'gentleSpring' | 'smooth' | 'snappy'
|
||||
}
|
||||
|
||||
export type AvatarAnimationDefinition = {
|
||||
|
||||
@ -5,6 +5,8 @@ export type BodyVector = readonly [number, number, number]
|
||||
export type BodyNode = {
|
||||
id: string
|
||||
name: string
|
||||
layer?: 'auto' | 'front' | 'back'
|
||||
color?: string
|
||||
surface: SurfaceConfig
|
||||
position: BodyVector
|
||||
rotation: BodyVector
|
||||
@ -18,9 +20,16 @@ export type AvatarBody = {
|
||||
export const bodyPrimitiveTypes = [
|
||||
'sphere',
|
||||
'cube',
|
||||
'ellipse',
|
||||
'square',
|
||||
'rounded',
|
||||
'capsule',
|
||||
'cylinder',
|
||||
'cone',
|
||||
'teardrop',
|
||||
'trapezoid',
|
||||
'frustum',
|
||||
'half-cone',
|
||||
'diamond',
|
||||
] as const
|
||||
|
||||
@ -31,6 +40,8 @@ const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
const vector = (value: unknown): value is BodyVector =>
|
||||
Array.isArray(value) && value.length === 3 && value.every(finite)
|
||||
const hexColor = (value: unknown): value is string =>
|
||||
typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
||||
|
||||
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||
if (!value || typeof value !== 'object') return { ...fallback }
|
||||
@ -46,6 +57,8 @@ export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): Sur
|
||||
return { ...fallback }
|
||||
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
|
||||
return { ...fallback }
|
||||
if (candidate.topScale !== undefined && !finite(candidate.topScale)) return { ...fallback }
|
||||
if (candidate.cutAngle !== undefined && !finite(candidate.cutAngle)) return { ...fallback }
|
||||
return { ...preset, ...candidate, type }
|
||||
}
|
||||
|
||||
@ -80,6 +93,11 @@ export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig):
|
||||
.slice(0, MAX_BODY_NODES)
|
||||
.map(node => ({
|
||||
...node,
|
||||
layer:
|
||||
node.layer === 'front' || node.layer === 'back' || node.layer === 'auto'
|
||||
? node.layer
|
||||
: undefined,
|
||||
color: hexColor(node.color) ? node.color : undefined,
|
||||
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||
}))
|
||||
: []
|
||||
|
||||
@ -22,6 +22,8 @@ export type Expression = {
|
||||
widthRight: number
|
||||
heightLeft: number
|
||||
heightRight: number
|
||||
curvatureLeft: number
|
||||
curvatureRight: number
|
||||
spacing: number
|
||||
positionXLeft: number
|
||||
positionXRight: number
|
||||
@ -98,6 +100,8 @@ export const expressionFields: ExpressionNumericField[] = [
|
||||
'widthRight',
|
||||
'heightLeft',
|
||||
'heightRight',
|
||||
'curvatureLeft',
|
||||
'curvatureRight',
|
||||
'spacing',
|
||||
'positionXLeft',
|
||||
'positionXRight',
|
||||
@ -519,6 +523,8 @@ const surfaceCacheKey = (surface: SurfaceConfig) =>
|
||||
surface.morphRoundness,
|
||||
surface.tipRoundness,
|
||||
surface.baseRoundness,
|
||||
surface.topScale,
|
||||
surface.cutAngle,
|
||||
])
|
||||
|
||||
const cacheSurfaceValue = <Value>(cache: Map<string, Value>, key: string, value: Value) => {
|
||||
@ -569,12 +575,33 @@ const eyePoints = (
|
||||
const width = expression[`width${suffix}`]
|
||||
const restingHeight = expression[`height${suffix}`]
|
||||
const height = 5 + (restingHeight - 5) * blink
|
||||
const curvature = expression[`curvature${suffix}`]
|
||||
const cornerRadius = Math.min(width / 2, height / 2)
|
||||
const segmentHalf = Math.max(width / 2 - cornerRadius, 0)
|
||||
const desiredCurveDepth = Math.max(9, width * 0.38) * curvature * blink
|
||||
// Keep the offset capsule below the cusp limit of the curved centerline.
|
||||
// Without this bound, morphing a curved horizontal eye into a tall eye makes
|
||||
// segmentHalf approach zero while its slope tends to infinity, folding the
|
||||
// outline over itself for a few frames.
|
||||
const maximumSafeCurveDepth =
|
||||
segmentHalf > 0 ? (0.5 * segmentHalf ** 2) / (2 * Math.max(cornerRadius, Number.EPSILON)) : 0
|
||||
const curveDepth = Math.min(desiredCurveDepth, maximumSafeCurveDepth)
|
||||
const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
|
||||
const centerY = expression[`positionY${suffix}`] + offset.y
|
||||
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
|
||||
return roundedRectangle(width, height).map(([localX, localY]) => {
|
||||
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
|
||||
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
|
||||
const curveX = segmentHalf ? clamp(localX, -segmentHalf, segmentHalf) : 0
|
||||
const normalizedX = segmentHalf ? curveX / segmentHalf : 0
|
||||
const curveY = -curveDepth * (1 - normalizedX ** 2)
|
||||
const slope = segmentHalf ? (2 * curveDepth * curveX) / segmentHalf ** 2 : 0
|
||||
const tangentLength = Math.hypot(1, slope)
|
||||
const tangentX = 1 / tangentLength
|
||||
const tangentY = slope / tangentLength
|
||||
const offsetAlongCurve = localX - curveX
|
||||
const curvedX = curveX + tangentX * offsetAlongCurve - tangentY * localY
|
||||
const curvedY = curveY + tangentY * offsetAlongCurve + tangentX * localY
|
||||
const rotatedX = curvedX * Math.cos(angle) - curvedY * Math.sin(angle)
|
||||
const rotatedY = curvedX * Math.sin(angle) + curvedY * Math.cos(angle)
|
||||
return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
|
||||
})
|
||||
}
|
||||
@ -1198,6 +1225,126 @@ const projectedGhostPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
].join('')
|
||||
}
|
||||
|
||||
const projectedAppleBitePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
const halfWidth = surface.width / 2
|
||||
const halfHeight = surface.height / 2
|
||||
const point = (x: number, y: number) => projectLocalPoint(pose, [x, y, 0])
|
||||
const format = ([x, y]: Point3) => `${x.toFixed(2)} ${y.toFixed(2)}`
|
||||
const move = (x: number, y: number) => `M${format(point(x, y))}`
|
||||
const line = (x: number, y: number) => `L${format(point(x, y))}`
|
||||
const cubicTo = (
|
||||
firstX: number,
|
||||
firstY: number,
|
||||
secondX: number,
|
||||
secondY: number,
|
||||
endX: number,
|
||||
endY: number
|
||||
) =>
|
||||
`C${format(point(firstX, firstY))} ${format(point(secondX, secondY))} ${format(point(endX, endY))}`
|
||||
|
||||
return [
|
||||
move(-halfWidth * 0.46, -halfHeight),
|
||||
cubicTo(
|
||||
-halfWidth * 0.08,
|
||||
-halfHeight,
|
||||
halfWidth * 0.15,
|
||||
-halfHeight * 0.78,
|
||||
halfWidth * 0.12,
|
||||
-halfHeight * 0.54
|
||||
),
|
||||
cubicTo(
|
||||
halfWidth * 0.4,
|
||||
-halfHeight * 0.72,
|
||||
halfWidth * 0.74,
|
||||
-halfHeight * 0.75,
|
||||
halfWidth,
|
||||
-halfHeight * 0.56
|
||||
),
|
||||
cubicTo(
|
||||
halfWidth * 0.8,
|
||||
-halfHeight * 0.43,
|
||||
halfWidth * 0.68,
|
||||
-halfHeight * 0.24,
|
||||
halfWidth * 0.68,
|
||||
-halfHeight * 0.03
|
||||
),
|
||||
cubicTo(
|
||||
halfWidth * 0.68,
|
||||
halfHeight * 0.2,
|
||||
halfWidth * 0.8,
|
||||
halfHeight * 0.4,
|
||||
halfWidth,
|
||||
halfHeight * 0.5
|
||||
),
|
||||
cubicTo(
|
||||
halfWidth * 0.82,
|
||||
halfHeight * 0.83,
|
||||
halfWidth * 0.54,
|
||||
halfHeight,
|
||||
halfWidth * 0.26,
|
||||
halfHeight
|
||||
),
|
||||
cubicTo(
|
||||
halfWidth * 0.08,
|
||||
halfHeight,
|
||||
-halfWidth * 0.02,
|
||||
halfHeight * 0.91,
|
||||
-halfWidth * 0.16,
|
||||
halfHeight * 0.91
|
||||
),
|
||||
cubicTo(
|
||||
-halfWidth * 0.31,
|
||||
halfHeight * 0.91,
|
||||
-halfWidth * 0.4,
|
||||
halfHeight,
|
||||
-halfWidth * 0.57,
|
||||
halfHeight
|
||||
),
|
||||
cubicTo(
|
||||
-halfWidth * 0.89,
|
||||
halfHeight,
|
||||
-halfWidth,
|
||||
halfHeight * 0.67,
|
||||
-halfWidth,
|
||||
halfHeight * 0.3
|
||||
),
|
||||
cubicTo(
|
||||
-halfWidth,
|
||||
-halfHeight * 0.16,
|
||||
-halfWidth * 0.8,
|
||||
-halfHeight * 0.58,
|
||||
-halfWidth * 0.49,
|
||||
-halfHeight * 0.7
|
||||
),
|
||||
cubicTo(
|
||||
-halfWidth * 0.28,
|
||||
-halfHeight * 0.78,
|
||||
-halfWidth * 0.03,
|
||||
-halfHeight * 0.69,
|
||||
halfWidth * 0.08,
|
||||
-halfHeight * 0.55
|
||||
),
|
||||
line(-halfWidth * 0.1, -halfHeight * 0.66),
|
||||
cubicTo(
|
||||
-halfWidth * 0.12,
|
||||
-halfHeight * 0.82,
|
||||
-halfWidth * 0.22,
|
||||
-halfHeight * 0.88,
|
||||
-halfWidth * 0.35,
|
||||
-halfHeight * 0.84
|
||||
),
|
||||
cubicTo(
|
||||
-halfWidth * 0.41,
|
||||
-halfHeight * 0.84,
|
||||
-halfWidth * 0.45,
|
||||
-halfHeight * 0.93,
|
||||
-halfWidth * 0.46,
|
||||
-halfHeight
|
||||
),
|
||||
'Z',
|
||||
].join('')
|
||||
}
|
||||
|
||||
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
if (surface.type === 'sphere' || surface.type === 'mickey') {
|
||||
const exactPath = projectedEllipsoidPath(pose, surface)
|
||||
@ -1215,6 +1362,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||
if (surface.type === 'cube') return projectedCubePath(pose, surface)
|
||||
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
|
||||
if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
|
||||
if (surface.type === 'apple-bite') return projectedAppleBitePath(pose, surface)
|
||||
|
||||
const key = surfaceCacheKey(surface)
|
||||
let localSamples = headSamplesCache.get(key)
|
||||
|
||||
@ -224,6 +224,10 @@ const easing = (
|
||||
const progress = Math.max(0, Math.min(1, value))
|
||||
if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
|
||||
if (transition === 'snappy') return 1 - (1 - progress) ** 3
|
||||
if (transition === 'gentleSpring') {
|
||||
const end = 1 - Math.exp(-9) * Math.cos(5)
|
||||
return (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) / end
|
||||
}
|
||||
const end = 1 - Math.exp(-6) * Math.cos(8)
|
||||
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ export const expressionFromDefinition = (
|
||||
widthRight: expression.eyes.right.width,
|
||||
heightLeft: expression.eyes.left.height,
|
||||
heightRight: expression.eyes.right.height,
|
||||
curvatureLeft: expression.eyes.left.curvature ?? 0,
|
||||
curvatureRight: expression.eyes.right.curvature ?? 0,
|
||||
spacing: expression.eyes.spacing,
|
||||
positionXLeft: expression.eyes.left.x,
|
||||
positionXRight: expression.eyes.right.x,
|
||||
@ -39,6 +41,8 @@ export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
|
||||
nodes: body.nodes.map((node, index) => ({
|
||||
id: `runtime-node-${index}`,
|
||||
name: `Runtime node ${index + 1}`,
|
||||
...(node.layer ? { layer: node.layer } : {}),
|
||||
...(node.color ? { color: node.color } : {}),
|
||||
surface: { ...node.surface },
|
||||
position: [...node.position],
|
||||
rotation: [...node.rotation],
|
||||
|
||||
@ -1,7 +1,23 @@
|
||||
import type { Point3 } from './geometry'
|
||||
|
||||
export type SurfaceType =
|
||||
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond' | 'ghost'
|
||||
| 'sphere'
|
||||
| 'mickey'
|
||||
| 'cursor'
|
||||
| 'cube'
|
||||
| 'ellipse'
|
||||
| 'square'
|
||||
| 'rounded'
|
||||
| 'capsule'
|
||||
| 'cylinder'
|
||||
| 'cone'
|
||||
| 'teardrop'
|
||||
| 'trapezoid'
|
||||
| 'frustum'
|
||||
| 'half-cone'
|
||||
| 'diamond'
|
||||
| 'ghost'
|
||||
| 'apple-bite'
|
||||
|
||||
export type SurfaceConfig = {
|
||||
type: SurfaceType
|
||||
@ -12,6 +28,8 @@ export type SurfaceConfig = {
|
||||
morphRoundness?: number
|
||||
tipRoundness?: number
|
||||
baseRoundness?: number
|
||||
topScale?: number
|
||||
cutAngle?: number
|
||||
}
|
||||
|
||||
export type SurfaceSample = {
|
||||
@ -24,6 +42,9 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
||||
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
||||
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
||||
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
|
||||
ellipse: { type: 'ellipse', width: 245, height: 189, depth: 195, roundness: 1 },
|
||||
square: { type: 'square', width: 240, height: 240, depth: 196, roundness: 0.6 },
|
||||
rounded: { type: 'rounded', width: 240, height: 240, depth: 211, roundness: 1 },
|
||||
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
|
||||
cylinder: {
|
||||
type: 'cylinder',
|
||||
@ -43,8 +64,33 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
||||
tipRoundness: 0.55,
|
||||
baseRoundness: 0.45,
|
||||
},
|
||||
teardrop: { type: 'teardrop', width: 230, height: 258, depth: 195, roundness: 0.78 },
|
||||
trapezoid: {
|
||||
type: 'trapezoid',
|
||||
width: 250,
|
||||
height: 232,
|
||||
depth: 204,
|
||||
roundness: 1.44,
|
||||
topScale: 0.82,
|
||||
},
|
||||
frustum: {
|
||||
type: 'frustum',
|
||||
width: 250,
|
||||
height: 250,
|
||||
depth: 223,
|
||||
roundness: 0.48,
|
||||
},
|
||||
'half-cone': {
|
||||
type: 'half-cone',
|
||||
width: 250,
|
||||
height: 250,
|
||||
depth: 223,
|
||||
roundness: 0.48,
|
||||
cutAngle: 0,
|
||||
},
|
||||
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
||||
ghost: { type: 'ghost', width: 250, height: 245, depth: 190, roundness: 1 },
|
||||
'apple-bite': { type: 'apple-bite', width: 240, height: 250, depth: 190, roundness: 1 },
|
||||
}
|
||||
|
||||
export const surfaceLabels: Record<SurfaceType, string> = {
|
||||
@ -52,16 +98,80 @@ export const surfaceLabels: Record<SurfaceType, string> = {
|
||||
mickey: 'Mickey',
|
||||
cursor: 'Curseur',
|
||||
cube: 'Cube',
|
||||
ellipse: 'Ellipse',
|
||||
square: 'Carré',
|
||||
rounded: 'Arrondi',
|
||||
capsule: 'Capsule',
|
||||
cylinder: 'Cylindre',
|
||||
cone: 'Cône',
|
||||
teardrop: 'Goutte',
|
||||
trapezoid: 'Trapèze',
|
||||
frustum: 'Tronc de cône',
|
||||
'half-cone': 'Demi-cône',
|
||||
diamond: 'Diamant',
|
||||
ghost: 'Ghost',
|
||||
'apple-bite': 'Apple Bite',
|
||||
}
|
||||
|
||||
const signedPower = (value: number, exponent: number) =>
|
||||
Math.sign(value) * Math.abs(value) ** exponent
|
||||
|
||||
const interpolate = (from: number, to: number, progress: number) => from + (to - from) * progress
|
||||
|
||||
const ONEWORKS_SHAPE_EXPONENTS = {
|
||||
rounded: 0.5,
|
||||
square: 0.3,
|
||||
teardrop: 0.78,
|
||||
} as const
|
||||
|
||||
const oneworksSuperellipsoid = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
exponent: number
|
||||
): Point3 => {
|
||||
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
|
||||
return [
|
||||
(config.width / 2) * latitudeFactor * signedPower(Math.sin(longitude), exponent),
|
||||
(config.height / 2) * signedPower(Math.sin(latitude), exponent),
|
||||
(config.depth / 2) * latitudeFactor * signedPower(Math.cos(longitude), exponent),
|
||||
]
|
||||
}
|
||||
|
||||
const oneworksTeardrop = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||
const exponent = ONEWORKS_SHAPE_EXPONENTS.teardrop
|
||||
const vertical = signedPower(Math.sin(latitude), exponent)
|
||||
const progress = Math.max(0, Math.min(1, (vertical + 1) / 2))
|
||||
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
|
||||
const widthTaper = interpolate(0.46, 1.24, progress)
|
||||
const depthTaper = interpolate(0.7, 1.12, progress)
|
||||
return [
|
||||
(config.width / 2) * widthTaper * latitudeFactor * signedPower(Math.sin(longitude), exponent),
|
||||
(config.height / 2) * vertical,
|
||||
(config.depth / 2) * depthTaper * latitudeFactor * signedPower(Math.cos(longitude), exponent),
|
||||
]
|
||||
}
|
||||
|
||||
const trapezoidExponent = (config: SurfaceConfig) =>
|
||||
interpolate(0.34, 0.76, clampRoundness(config.roundness) / 2)
|
||||
|
||||
const oneworksTrapezoid = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||
const exponent = trapezoidExponent(config)
|
||||
const vertical = signedPower(Math.sin(latitude), exponent)
|
||||
const progress = Math.max(0, Math.min(1, (vertical + 1) / 2))
|
||||
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
|
||||
const horizontalTaper = interpolate(config.topScale ?? 0.82, 1.08, progress)
|
||||
const depthTaper = interpolate(0.92, 1.04, progress)
|
||||
return [
|
||||
(config.width / 2) *
|
||||
horizontalTaper *
|
||||
latitudeFactor *
|
||||
signedPower(Math.sin(longitude), exponent),
|
||||
(config.height / 2) * vertical,
|
||||
(config.depth / 2) * depthTaper * latitudeFactor * signedPower(Math.cos(longitude), exponent),
|
||||
]
|
||||
}
|
||||
|
||||
const superellipsoid = (
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
@ -285,6 +395,28 @@ const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile =
|
||||
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
|
||||
|
||||
const oneworksFrustumProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const easedProgress = clampedProgress * clampedProgress * (3 - 2 * clampedProgress)
|
||||
const roundedProgress = interpolate(
|
||||
clampedProgress,
|
||||
easedProgress,
|
||||
(clampRoundness(config.roundness) / 2) * 0.55
|
||||
)
|
||||
return {
|
||||
radiusScale: interpolate(0.46, 1, roundedProgress),
|
||||
verticalProgress: clampedProgress,
|
||||
}
|
||||
}
|
||||
|
||||
const oneworksHalfConeProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
return {
|
||||
radiusScale: clampedProgress ** interpolate(1, 0.56, clampRoundness(config.roundness) / 2),
|
||||
verticalProgress: clampedProgress,
|
||||
}
|
||||
}
|
||||
|
||||
export const cursorLayout = (config: SurfaceConfig) => {
|
||||
const coneHeight = config.height * 0.36
|
||||
const bodyHeight = config.height - coneHeight
|
||||
@ -308,6 +440,12 @@ export const surfacePointAt = (
|
||||
case 'sphere':
|
||||
case 'mickey':
|
||||
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
|
||||
case 'ellipse':
|
||||
return oneworksSuperellipsoid(config, longitude, latitude, 1)
|
||||
case 'square':
|
||||
return oneworksSuperellipsoid(config, longitude, latitude, ONEWORKS_SHAPE_EXPONENTS.square)
|
||||
case 'rounded':
|
||||
return oneworksSuperellipsoid(config, longitude, latitude, ONEWORKS_SHAPE_EXPONENTS.rounded)
|
||||
case 'cube':
|
||||
return cube(config, longitude, latitude)
|
||||
case 'cylinder': {
|
||||
@ -337,8 +475,13 @@ export const surfacePointAt = (
|
||||
}
|
||||
case 'diamond':
|
||||
return diamond(config, longitude, latitude)
|
||||
case 'teardrop':
|
||||
return oneworksTeardrop(config, longitude, latitude)
|
||||
case 'trapezoid':
|
||||
return oneworksTrapezoid(config, longitude, latitude)
|
||||
case 'capsule':
|
||||
case 'ghost':
|
||||
case 'apple-bite':
|
||||
return capsule(config, longitude, latitude)
|
||||
case 'cone': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
@ -349,6 +492,25 @@ export const surfacePointAt = (
|
||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
case 'frustum': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const profile = oneworksFrustumProfileAt(config, progress)
|
||||
return [
|
||||
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
-height / 2 + height * profile.verticalProgress,
|
||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
case 'half-cone': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const profile = oneworksHalfConeProfileAt(config, progress)
|
||||
const sampledLongitude = ((config.cutAngle ?? 0) * Math.PI) / 180 + longitude / 2
|
||||
return [
|
||||
(width / 2) * profile.radiusScale * Math.sin(sampledLongitude),
|
||||
-height / 2 + height * profile.verticalProgress,
|
||||
(depth / 2) * profile.radiusScale * Math.cos(sampledLongitude),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -435,6 +597,12 @@ const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
|
||||
return normal
|
||||
}
|
||||
|
||||
const oneworksSuperellipsoidNormal = (
|
||||
config: SurfaceConfig,
|
||||
point: Point3,
|
||||
parameterExponent: number
|
||||
) => lpNormal(config, point, 2 / parameterExponent)
|
||||
|
||||
const lpFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
@ -486,6 +654,39 @@ const ellipsoidFrontSample = (
|
||||
}
|
||||
}
|
||||
|
||||
const oneworksProfileFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number,
|
||||
profile: 'teardrop' | 'trapezoid'
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
const exponent =
|
||||
profile === 'teardrop' ? ONEWORKS_SHAPE_EXPONENTS.teardrop : trapezoidExponent(config)
|
||||
const vertical = Math.max(-1, Math.min(1, y / radiusY))
|
||||
const sineLatitude = signedPower(vertical, 1 / exponent)
|
||||
const latitude = Math.asin(Math.max(-1, Math.min(1, sineLatitude)))
|
||||
const progress = Math.max(0, Math.min(1, (vertical + 1) / 2))
|
||||
const latitudeFactor = Math.max(Math.cos(latitude), 0) ** exponent
|
||||
const widthTaper =
|
||||
profile === 'teardrop'
|
||||
? interpolate(0.46, 1.24, progress)
|
||||
: interpolate(config.topScale ?? 0.82, 1.08, progress)
|
||||
const depthTaper =
|
||||
profile === 'teardrop' ? interpolate(0.7, 1.12, progress) : interpolate(0.92, 1.04, progress)
|
||||
const sectionRadiusX = radiusX * widthTaper * latitudeFactor
|
||||
const sectionRadiusZ = radiusZ * depthTaper * latitudeFactor
|
||||
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
|
||||
const normalizedX = sectionRadiusX > 0 ? surfaceX / sectionRadiusX : 0
|
||||
const sineLongitude = signedPower(Math.max(-1, Math.min(1, normalizedX)), 1 / exponent)
|
||||
const longitude = Math.asin(Math.max(-1, Math.min(1, sineLongitude)))
|
||||
const z = sectionRadiusZ * signedPower(Math.max(Math.cos(longitude), 0), exponent)
|
||||
const point: Point3 = [surfaceX, vertical * radiusY, z]
|
||||
return { point, normal: tangentNormalAt(config, longitude, latitude) }
|
||||
}
|
||||
|
||||
const radialProfileFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
@ -532,13 +733,25 @@ export const surfaceFrontSampleAt = (
|
||||
switch (config.type) {
|
||||
case 'sphere':
|
||||
case 'mickey':
|
||||
case 'ellipse':
|
||||
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
|
||||
|
||||
case 'square':
|
||||
return lpFrontSample(config, x, y, 2 / ONEWORKS_SHAPE_EXPONENTS.square, (surface, point) =>
|
||||
oneworksSuperellipsoidNormal(surface, point, ONEWORKS_SHAPE_EXPONENTS.square)
|
||||
)
|
||||
|
||||
case 'rounded':
|
||||
return lpFrontSample(config, x, y, 2 / ONEWORKS_SHAPE_EXPONENTS.rounded, (surface, point) =>
|
||||
oneworksSuperellipsoidNormal(surface, point, ONEWORKS_SHAPE_EXPONENTS.rounded)
|
||||
)
|
||||
|
||||
case 'cube':
|
||||
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
||||
|
||||
case 'capsule':
|
||||
case 'ghost': {
|
||||
case 'ghost':
|
||||
case 'apple-bite': {
|
||||
const capRadiusY = Math.min(radiusX, radiusY)
|
||||
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
||||
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
||||
@ -572,6 +785,18 @@ export const surfaceFrontSampleAt = (
|
||||
case 'cone':
|
||||
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
|
||||
|
||||
case 'frustum':
|
||||
return radialProfileFrontSample(config, x, y, oneworksFrustumProfileAt, 1)
|
||||
|
||||
case 'half-cone':
|
||||
return radialProfileFrontSample(config, x, y, oneworksHalfConeProfileAt, 1)
|
||||
|
||||
case 'teardrop':
|
||||
return oneworksProfileFrontSample(config, x, y, 'teardrop')
|
||||
|
||||
case 'trapezoid':
|
||||
return oneworksProfileFrontSample(config, x, y, 'trapezoid')
|
||||
|
||||
case 'diamond':
|
||||
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
|
||||
}
|
||||
@ -586,7 +811,7 @@ export const surfaceNormalAt = (
|
||||
|
||||
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
|
||||
// common path for the default spherical head.
|
||||
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||
if (config.type === 'sphere' || config.type === 'mickey' || config.type === 'ellipse') {
|
||||
const halfWidth = config.width / 2 || 1
|
||||
const halfHeight = config.height / 2 || 1
|
||||
const halfDepth = config.depth / 2 || 1
|
||||
@ -613,6 +838,14 @@ export const surfaceNormalAt = (
|
||||
return cubeNormal(config, point)
|
||||
}
|
||||
|
||||
if (config.type === 'square') {
|
||||
return oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.square)
|
||||
}
|
||||
|
||||
if (config.type === 'rounded') {
|
||||
return oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.rounded)
|
||||
}
|
||||
|
||||
return tangentNormalAt(config, longitude, latitude)
|
||||
}
|
||||
|
||||
@ -623,7 +856,7 @@ export const surfaceSampleAt = (
|
||||
): SurfaceSample => {
|
||||
const point = surfacePointAt(config, longitude, latitude)
|
||||
|
||||
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||
if (config.type === 'sphere' || config.type === 'mickey' || config.type === 'ellipse') {
|
||||
const halfWidth = config.width / 2 || 1
|
||||
const halfHeight = config.height / 2 || 1
|
||||
const halfDepth = config.depth / 2 || 1
|
||||
@ -662,6 +895,20 @@ export const surfaceSampleAt = (
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'square') {
|
||||
return {
|
||||
point,
|
||||
normal: oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.square),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'rounded') {
|
||||
return {
|
||||
point,
|
||||
normal: oneworksSuperellipsoidNormal(config, point, ONEWORKS_SHAPE_EXPONENTS.rounded),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point,
|
||||
normal: tangentNormalAt(config, longitude, latitude),
|
||||
|
||||
@ -34,6 +34,46 @@ describe('@bible-strong/avatar-web', () => {
|
||||
expect(document.querySelector('#avatar svg')).toBeNull()
|
||||
})
|
||||
|
||||
it('tints fixed-color body parts when an expression overrides the body color', () => {
|
||||
const tintedDefinition = {
|
||||
...definitionJson,
|
||||
body: {
|
||||
...definitionJson.body,
|
||||
nodes: [
|
||||
{
|
||||
layer: 'back',
|
||||
color: '#a95f47',
|
||||
surface: {
|
||||
type: 'ellipse',
|
||||
width: 40,
|
||||
height: 68,
|
||||
depth: 36,
|
||||
roundness: 1,
|
||||
},
|
||||
position: [-64, -80, -18],
|
||||
rotation: [-4, -7, 8],
|
||||
},
|
||||
],
|
||||
},
|
||||
expressions: {
|
||||
...definitionJson.expressions,
|
||||
neutral: {
|
||||
...definitionJson.expressions.neutral,
|
||||
colors: { body: '#ba3636' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const avatar = createAvatar('#avatar', {
|
||||
definition: tintedDefinition,
|
||||
defaultExpression: 'neutral',
|
||||
})
|
||||
const firstBodyPart = document.querySelector('#avatar svg > path')
|
||||
|
||||
expect(firstBodyPart?.getAttribute('fill')).toBe('#ba3636')
|
||||
avatar.destroy()
|
||||
})
|
||||
|
||||
it('returns typed errors for unknown targets', () => {
|
||||
const avatar = createAvatar('#avatar', { definition: definitionJson })
|
||||
|
||||
|
||||
@ -145,7 +145,19 @@ export function createAvatar(
|
||||
if (onError) onError(error)
|
||||
else console.error(`[Avatar] ${error.message}`)
|
||||
}
|
||||
const paint = (scene: ReturnType<typeof renderAvatarDefinition>) => {
|
||||
const colorForNodeId = (
|
||||
id: string | null | undefined,
|
||||
fallback: string,
|
||||
bodyColorOverride: boolean
|
||||
) => {
|
||||
if (bodyColorOverride) return fallback
|
||||
if (!id?.startsWith('runtime-node-')) return fallback
|
||||
const index = Number(id.slice('runtime-node-'.length))
|
||||
return definition.body.nodes[index]?.color ?? fallback
|
||||
}
|
||||
const paint = (scene: ReturnType<typeof renderAvatarDefinition>, bodyColorOverride = false) => {
|
||||
host.style.setProperty('--bs-avatar-body-color', scene.colors.body)
|
||||
host.style.setProperty('--bs-avatar-eye-color', scene.colors.eyes)
|
||||
clipHeadPath.setAttribute('d', scene.geometry.headPath)
|
||||
headPath.setAttribute('d', scene.geometry.headPath)
|
||||
headPath.setAttribute('fill', scene.colors.body)
|
||||
@ -157,11 +169,23 @@ export function createAvatar(
|
||||
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
||||
backPaths.forEach((element, index) => {
|
||||
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
|
||||
element.setAttribute('fill', scene.colors.body)
|
||||
const color = colorForNodeId(
|
||||
scene.geometry.backNodeIds[index],
|
||||
scene.colors.body,
|
||||
bodyColorOverride
|
||||
)
|
||||
element.setAttribute('fill', color)
|
||||
element.style.setProperty('--bs-avatar-node-color', color)
|
||||
})
|
||||
frontPaths.forEach((element, index) => {
|
||||
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
|
||||
element.setAttribute('fill', scene.colors.body)
|
||||
const color = colorForNodeId(
|
||||
scene.geometry.frontNodeIds[index],
|
||||
scene.colors.body,
|
||||
bodyColorOverride
|
||||
)
|
||||
element.setAttribute('fill', color)
|
||||
element.style.setProperty('--bs-avatar-node-color', color)
|
||||
})
|
||||
}
|
||||
|
||||
@ -180,7 +204,10 @@ export function createAvatar(
|
||||
const renderCurrent = (now: number) => {
|
||||
const environment = runtimeEnvironment()
|
||||
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
|
||||
paint(renderAvatarFrame(definition, playback, now, environment))
|
||||
paint(
|
||||
renderAvatarFrame(definition, playback, now, environment),
|
||||
Boolean(definition.expressions[playback.activeExpression]?.colors?.body)
|
||||
)
|
||||
notifyExpression()
|
||||
}
|
||||
const tick = (now: number) => {
|
||||
@ -260,7 +287,10 @@ export function createAvatar(
|
||||
playback = createAvatarPlaybackState()
|
||||
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||
frameRequest = null
|
||||
paint(renderAvatarDefinition(definition))
|
||||
paint(
|
||||
renderAvatarDefinition(definition),
|
||||
Boolean(definition.expressions.neutral.colors?.body)
|
||||
)
|
||||
notifyExpression()
|
||||
},
|
||||
getState() {
|
||||
@ -293,7 +323,7 @@ export function createAvatar(
|
||||
renderCurrent(performance.now())
|
||||
}
|
||||
} else {
|
||||
paint(initialScene)
|
||||
paint(initialScene, Boolean(definition.expressions.neutral.colors?.body))
|
||||
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
|
||||
notifyExpression()
|
||||
}
|
||||
|
||||
@ -70,8 +70,43 @@ input {
|
||||
min-height: 100vh;
|
||||
overflow-x: clip;
|
||||
}
|
||||
.studio-catalog-only {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.studio-catalog-only .inspector {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
border-left: 0;
|
||||
}
|
||||
.studio-catalog-only .inspector-identity {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
margin: -28px -26px 24px;
|
||||
padding: 0 28px;
|
||||
border-bottom: 1px solid #d9dce3;
|
||||
background: rgb(245 246 248 / 94%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.studio-catalog-only .inspector-identity .brand {
|
||||
top: 0;
|
||||
margin-left: 0;
|
||||
color: #17191d;
|
||||
}
|
||||
.studio-catalog-only .inspector-identity .brand em {
|
||||
color: #767d88;
|
||||
}
|
||||
.studio-catalog-only .inspector-identity .language-picker {
|
||||
top: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.stage-column {
|
||||
--avatar-interior-color: #000000;
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
@ -81,7 +116,7 @@ input {
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
padding: 28px;
|
||||
background: #101316;
|
||||
background: #000000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@ -90,14 +125,14 @@ input {
|
||||
width: 70vw;
|
||||
height: 70vw;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgb(91 127 229 / 18%), transparent 68%);
|
||||
background: none;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.photo-mode-active::before {
|
||||
width: 82vw;
|
||||
height: 82vw;
|
||||
background: radial-gradient(circle, rgb(91 127 229 / 24%), transparent 66%);
|
||||
background: none;
|
||||
}
|
||||
.photo-live-frame {
|
||||
position: relative;
|
||||
@ -448,6 +483,7 @@ input {
|
||||
.is-pixel-rendered .avatar-head,
|
||||
.is-pixel-rendered .avatar-eye {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
transition: none;
|
||||
}
|
||||
.is-pixel-rendered .avatar-head.cyan-outline,
|
||||
@ -1177,19 +1213,35 @@ input {
|
||||
}
|
||||
.avatar-head,
|
||||
.preview-head {
|
||||
fill: var(--avatar-body-color, #5b7fe5);
|
||||
fill: var(--avatar-interior-color, #101316);
|
||||
stroke: var(--avatar-node-color, var(--avatar-body-color, #5b7fe5));
|
||||
stroke-width: var(--avatar-outline-width, 10);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.avatar-eye,
|
||||
.preview-eye {
|
||||
fill: var(--avatar-eye-color, #111316);
|
||||
fill: var(--avatar-eye-color, #ffffff);
|
||||
}
|
||||
.avatar-head,
|
||||
.avatar-eye {
|
||||
transition: fill 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition:
|
||||
fill 420ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
stroke 420ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
stroke-width 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.avatar-eye {
|
||||
cursor: pointer;
|
||||
}
|
||||
.is-filled-vector .avatar-head {
|
||||
fill: var(--avatar-node-color, var(--avatar-body-color, #5b7fe5));
|
||||
}
|
||||
.is-filled-vector .avatar-eye {
|
||||
fill: var(--avatar-eye-color, #101316);
|
||||
}
|
||||
.avatar-preview {
|
||||
--avatar-interior-color: #ffffff;
|
||||
}
|
||||
.cyan-outline {
|
||||
stroke: #51ddff;
|
||||
stroke-width: 2.6;
|
||||
@ -1935,9 +1987,18 @@ p {
|
||||
font-size: 8px;
|
||||
}
|
||||
.avatar-shelf .avatar-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.studio-catalog-only .mode-page-transition,
|
||||
.studio-catalog-only .avatar-page,
|
||||
.studio-catalog-only .avatar-shelf {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.studio-catalog-only .avatar-shelf .avatar-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.avatar-shelf .avatar-card,
|
||||
.avatar-shelf .avatar-add {
|
||||
height: auto;
|
||||
@ -1969,6 +2030,9 @@ p {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.avatar-preview-live {
|
||||
pointer-events: none;
|
||||
}
|
||||
.avatar-shelf .avatar-card > span {
|
||||
min-height: 12px;
|
||||
padding: 2px 4px;
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
createInitialSequences,
|
||||
duplicateSequence,
|
||||
getSequenceSpring,
|
||||
scaleSpringDynamics,
|
||||
normalizeSequencesForExpressions,
|
||||
parseSequences,
|
||||
remapSequencesAfterExpressionDelete,
|
||||
@ -11,7 +12,8 @@ import { initialExpressions } from '@/features/avatar/presets'
|
||||
|
||||
describe('editable avatar sequences', () => {
|
||||
it('creates the idle animation with editable steps and blink settings', () => {
|
||||
const idle = createInitialSequences().find(sequence => sequence.id === 'idle')
|
||||
const sequences = createInitialSequences()
|
||||
const idle = sequences.find(sequence => sequence.id === 'idle')
|
||||
|
||||
expect(idle?.steps.map(step => step.expressionId)).toEqual([
|
||||
initialExpressions[0].id,
|
||||
@ -19,6 +21,41 @@ describe('editable avatar sequences', () => {
|
||||
])
|
||||
expect(idle?.steps[0].holdMs).toBe(5200)
|
||||
expect(idle?.blink.durationMs).toBe(280)
|
||||
expect(sequences).toHaveLength(24)
|
||||
})
|
||||
|
||||
it('creates a saved joyful animation that morphs between expressions 11 and 27', () => {
|
||||
const joyful = createInitialSequences().find(sequence => sequence.id === 'joyful')
|
||||
|
||||
expect(joyful?.steps).toEqual([
|
||||
{
|
||||
id: 'joyful-step-0',
|
||||
expressionId: 'expression-11',
|
||||
holdMs: 2300,
|
||||
transitionMs: 700,
|
||||
transition: 'gentleSpring',
|
||||
},
|
||||
{
|
||||
id: 'joyful-step-1',
|
||||
expressionId: 'expression-joyful-arc',
|
||||
holdMs: 2300,
|
||||
transitionMs: 700,
|
||||
transition: 'gentleSpring',
|
||||
},
|
||||
])
|
||||
expect(joyful?.playbackMode).toBe('loop')
|
||||
expect(joyful?.blink.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the motion expressions as the second Angry and Scared steps', () => {
|
||||
const sequences = createInitialSequences()
|
||||
|
||||
expect(
|
||||
sequences.find(sequence => sequence.id === 'angry')?.steps.map(step => step.expressionId)
|
||||
).toEqual(['expression-07', 'expression-3d2bed26-f97c-477d-922f-77600cb10e92'])
|
||||
expect(
|
||||
sequences.find(sequence => sequence.id === 'scared')?.steps.map(step => step.expressionId)
|
||||
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
|
||||
})
|
||||
|
||||
it('supports loop, once and ping-pong playback cursors', () => {
|
||||
@ -96,6 +133,16 @@ describe('editable avatar sequences', () => {
|
||||
expect(smooth.damping).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('time-scales spring dynamics without changing their motion character', () => {
|
||||
const spring = getSequenceSpring('smooth', 500, 7)
|
||||
|
||||
expect(scaleSpringDynamics(spring, 1)).toEqual(spring)
|
||||
expect(scaleSpringDynamics(spring, 2)).toEqual({
|
||||
stiffness: spring.stiffness * 4,
|
||||
damping: spring.damping * 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the public semantic key when an animation is duplicated', () => {
|
||||
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
|
||||
|
||||
|
||||
@ -380,6 +380,7 @@ export function SequenceWorkspace({
|
||||
value={selectedStep.transition}
|
||||
items={[
|
||||
{ value: 'spring', label: t('Ressort') },
|
||||
{ value: 'gentleSpring', label: t('Ressort léger') },
|
||||
{ value: 'smooth', label: t('Douce') },
|
||||
{ value: 'snappy', label: t('Rapide') },
|
||||
]}
|
||||
@ -392,6 +393,7 @@ export function SequenceWorkspace({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="spring">{t('Ressort')}</SelectItem>
|
||||
<SelectItem value="gentleSpring">{t('Ressort léger')}</SelectItem>
|
||||
<SelectItem value="smooth">{t('Douce')}</SelectItem>
|
||||
<SelectItem value="snappy">{t('Rapide')}</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
import type { Expression } from '../avatar/geometry'
|
||||
|
||||
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
||||
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
||||
export type SequenceTransition = 'spring' | 'gentleSpring' | 'smooth' | 'snappy'
|
||||
|
||||
export type SequenceStep = {
|
||||
id: string
|
||||
@ -45,7 +45,7 @@ export type SequenceCursor = {
|
||||
}
|
||||
|
||||
const playbackModes: SequencePlaybackMode[] = ['loop', 'once', 'pingPong']
|
||||
const transitions: SequenceTransition[] = ['spring', 'smooth', 'snappy']
|
||||
const transitions: SequenceTransition[] = ['spring', 'gentleSpring', 'smooth', 'snappy']
|
||||
const builtInSequenceIds = new Set<string>(Object.values(stateGroups).flat())
|
||||
|
||||
const finite = (value: unknown, fallback: number, min: number, max: number) =>
|
||||
@ -79,10 +79,10 @@ export const createInitialSequences = (): AvatarSequence[] =>
|
||||
id: `${id}-step-${index}`,
|
||||
expressionId: initialExpressions[expressionIndex]?.id ?? initialExpressions[0].id,
|
||||
holdMs: playback.expressionIntervalMs,
|
||||
transitionMs: 500,
|
||||
transition: 'smooth',
|
||||
transitionMs: id === 'joyful' ? 700 : 500,
|
||||
transition: id === 'joyful' ? 'gentleSpring' : 'smooth',
|
||||
})),
|
||||
blink: { enabled: true, ...playback.blink },
|
||||
blink: { enabled: id !== 'joyful', ...playback.blink },
|
||||
}
|
||||
})
|
||||
)
|
||||
@ -283,10 +283,30 @@ export const getSequenceSpring = (
|
||||
baseSpeed: number
|
||||
) => {
|
||||
const durationFactor = Math.min(Math.max(500 / Math.max(durationMs, 100), 0.35), 3)
|
||||
const styleFactor = transition === 'smooth' ? 0.72 : transition === 'snappy' ? 1.45 : 1
|
||||
const styleFactor =
|
||||
transition === 'smooth'
|
||||
? 0.72
|
||||
: transition === 'snappy'
|
||||
? 1.45
|
||||
: transition === 'gentleSpring'
|
||||
? 0.9
|
||||
: 1
|
||||
const speed = Math.max(baseSpeed * durationFactor * styleFactor, 0.5)
|
||||
return {
|
||||
stiffness: 70 + speed * 24,
|
||||
damping: (17 + speed * 1.7) * (transition === 'smooth' ? 1.18 : 1),
|
||||
damping:
|
||||
(17 + speed * 1.7) *
|
||||
(transition === 'smooth' ? 1.18 : transition === 'gentleSpring' ? 1.2 : 1),
|
||||
}
|
||||
}
|
||||
|
||||
export const scaleSpringDynamics = (
|
||||
spring: Readonly<{ stiffness: number; damping: number }>,
|
||||
timeScale: number
|
||||
) => {
|
||||
const boundedTimeScale = Math.max(timeScale, 0.01)
|
||||
return {
|
||||
stiffness: spring.stiffness * boundedTimeScale ** 2,
|
||||
damping: spring.damping * boundedTimeScale,
|
||||
}
|
||||
}
|
||||
|
||||
@ -486,9 +486,9 @@ describe('Studio to avatar definition conversion', () => {
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.expressionOrder).toHaveLength(28)
|
||||
expect(result.value.expressionOrder).toHaveLength(31)
|
||||
expect(result.value.expressionOrder[0]).toBe('neutral')
|
||||
expect(result.value.animationOrder).toHaveLength(23)
|
||||
expect(result.value.animationOrder).toHaveLength(25)
|
||||
expect(result.value.animationOrder).toContain('idle')
|
||||
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
|
||||
'upward-side-glance',
|
||||
@ -624,7 +624,7 @@ describe('Studio to avatar definition conversion', () => {
|
||||
createAvatarDefinition({ avatar, behavior })
|
||||
|
||||
expect(JSON.stringify({ avatar, behavior })).toBe(before)
|
||||
expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
|
||||
expect(defaultAvatarColors).toEqual({ body: '#7c89ff', eyes: '#7c89ff' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,11 +6,13 @@ import {
|
||||
createAvatar,
|
||||
createUnkeyedExpressionCopy,
|
||||
defaultAvatarEyes,
|
||||
ensurePrimitiveBundledAvatars,
|
||||
parseAvatarEyeDefaults,
|
||||
parseAvatarRenderStyle,
|
||||
resolveAvatarBehavior,
|
||||
} from '@/features/avatar/avatars'
|
||||
import { initialExpressions } from '@/features/avatar/presets'
|
||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||
|
||||
describe('avatar eye defaults', () => {
|
||||
it('clears the public semantic key when creating custom content from a preset', () => {
|
||||
@ -61,6 +63,135 @@ describe('avatar render style', () => {
|
||||
).toEqual({ type: 'vector' })
|
||||
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
|
||||
})
|
||||
|
||||
it('preserves the filled treatment for imported vector skins', () => {
|
||||
expect(parseAvatarRenderStyle({ type: 'vector', filled: true })).toEqual({
|
||||
type: 'vector',
|
||||
filled: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('OneWorks animal construction', () => {
|
||||
const createBundledLibrary = () => {
|
||||
const seed = createAvatar('Seed')
|
||||
seed.id = 'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e'
|
||||
return ensurePrimitiveBundledAvatars({ activeAvatarId: seed.id, avatars: [seed] })
|
||||
}
|
||||
|
||||
it('keeps the source occlusion order for every imported animal', () => {
|
||||
const library = createBundledLibrary()
|
||||
const layers = (id: string) =>
|
||||
library.avatars.find(avatar => avatar.id === id)?.body.nodes.map(node => node.layer)
|
||||
|
||||
expect(layers('oneworks-cat')).toBeUndefined()
|
||||
expect(layers('oneworks-dog')).toEqual(['front', 'front'])
|
||||
expect(layers('oneworks-bear')).toEqual(['back', 'back'])
|
||||
expect(layers('oneworks-rabbit')).toEqual(['back', 'back'])
|
||||
expect(layers('oneworks-bun')).toEqual(['front'])
|
||||
})
|
||||
|
||||
it('keeps the unfinished piglet hidden and removes a persisted copy', () => {
|
||||
const library = createBundledLibrary()
|
||||
const stored = {
|
||||
...library,
|
||||
activeAvatarId: 'memento-piglet',
|
||||
avatars: [
|
||||
...library.avatars,
|
||||
{
|
||||
...createAvatar('Порося'),
|
||||
id: 'memento-piglet',
|
||||
body: {
|
||||
primary: { ...surfacePresets.sphere },
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const restored = ensurePrimitiveBundledAvatars(stored)
|
||||
|
||||
expect(restored.avatars.some(avatar => avatar.id === 'memento-piglet')).toBe(false)
|
||||
expect(restored.activeAvatarId).not.toBe('memento-piglet')
|
||||
})
|
||||
|
||||
it('removes Cow Cat from a previously persisted library', () => {
|
||||
const library = createBundledLibrary()
|
||||
const cowCat = { ...createAvatar('Cow Cat'), id: 'oneworks-cat-cow' }
|
||||
const restored = ensurePrimitiveBundledAvatars({
|
||||
...library,
|
||||
activeAvatarId: cowCat.id,
|
||||
avatars: [...library.avatars, cowCat],
|
||||
})
|
||||
|
||||
expect(restored.avatars.some(avatar => avatar.id === cowCat.id)).toBe(false)
|
||||
expect(restored.activeAvatarId).not.toBe(cowCat.id)
|
||||
})
|
||||
|
||||
it('repairs the previous persisted dog-ear layer and placement', () => {
|
||||
const library = createBundledLibrary()
|
||||
const stored = {
|
||||
...library,
|
||||
avatars: library.avatars.map(avatar =>
|
||||
avatar.id !== 'oneworks-dog'
|
||||
? avatar
|
||||
: {
|
||||
...avatar,
|
||||
body: {
|
||||
...avatar.body,
|
||||
nodes: avatar.body.nodes.map(node => ({
|
||||
...node,
|
||||
layer: 'back' as const,
|
||||
position: [node.position[0] < 0 ? -72 : 72, -52, 0] as const,
|
||||
})),
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
const dog = ensurePrimitiveBundledAvatars(stored).avatars.find(
|
||||
avatar => avatar.id === 'oneworks-dog'
|
||||
)!
|
||||
|
||||
expect(dog.body.nodes.map(node => node.layer)).toEqual(['front', 'front'])
|
||||
expect(dog.body.nodes.map(node => node.position)).toEqual([
|
||||
[-82, -65, 0],
|
||||
[82, -65, 0],
|
||||
])
|
||||
})
|
||||
|
||||
it('moves previously persisted bear ears farther outside the head', () => {
|
||||
const library = createBundledLibrary()
|
||||
const stored = {
|
||||
...library,
|
||||
avatars: library.avatars.map(avatar =>
|
||||
avatar.id !== 'oneworks-bear'
|
||||
? avatar
|
||||
: {
|
||||
...avatar,
|
||||
body: {
|
||||
...avatar.body,
|
||||
nodes: avatar.body.nodes.map(node => ({
|
||||
...node,
|
||||
position:
|
||||
node.id === 'bear-ear-left'
|
||||
? ([-58, -72, -18] as const)
|
||||
: ([58, -70, -20] as const),
|
||||
})),
|
||||
},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
const bear = ensurePrimitiveBundledAvatars(stored).avatars.find(
|
||||
avatar => avatar.id === 'oneworks-bear'
|
||||
)!
|
||||
|
||||
expect(bear.body.nodes.map(node => node.position)).toEqual([
|
||||
[-64, -80, -18],
|
||||
[64, -78, -20],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('avatar behavior library', () => {
|
||||
|
||||
@ -3,10 +3,11 @@ import { resolve } from 'node:path'
|
||||
|
||||
import { createAvatarDefinition } from '../avatarDefinition'
|
||||
import { resolveAvatarBehavior } from '../avatars'
|
||||
import { loadStudioDocument } from '../../studio/studioDocument'
|
||||
import defaultStudioDocument from '../../studio/defaultStudioDocument.json'
|
||||
import type { StudioDocument } from '../../studio/studioDocument'
|
||||
|
||||
it('keeps the consumer fixture synchronized with the bundled Strobi Studio document', async () => {
|
||||
const document = loadStudioDocument({ getItem: () => null })
|
||||
const document = defaultStudioDocument as unknown as StudioDocument
|
||||
const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi')
|
||||
if (!avatar) throw new Error('Bundled Strobi avatar not found')
|
||||
const behavior = resolveAvatarBehavior(avatar, {
|
||||
|
||||
@ -30,6 +30,8 @@ const mapSurface = <TType extends SurfaceType>(
|
||||
...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
|
||||
...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
|
||||
...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
|
||||
...(surface.topScale === undefined ? {} : { topScale: surface.topScale }),
|
||||
...(surface.cutAngle === undefined ? {} : { cutAngle: surface.cutAngle }),
|
||||
})
|
||||
|
||||
const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
||||
@ -38,6 +40,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
||||
left: {
|
||||
width: expression.widthLeft,
|
||||
height: expression.heightLeft,
|
||||
curvature: expression.curvatureLeft,
|
||||
x: expression.positionXLeft,
|
||||
y: expression.positionYLeft,
|
||||
angle: expression.leftAngle,
|
||||
@ -45,6 +48,7 @@ const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
||||
right: {
|
||||
width: expression.widthRight,
|
||||
height: expression.heightRight,
|
||||
curvature: expression.curvatureRight,
|
||||
x: expression.positionXRight,
|
||||
y: expression.positionYRight,
|
||||
angle: expression.rightAngle,
|
||||
@ -170,6 +174,8 @@ export const createAvatarDefinition = ({
|
||||
body: {
|
||||
primary: mapSurface(avatar.body.primary),
|
||||
nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({
|
||||
...(node.layer ? { layer: node.layer } : {}),
|
||||
...(node.color ? { color: node.color as HexColor } : {}),
|
||||
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
|
||||
position: [...node.position],
|
||||
rotation: [...node.rotation],
|
||||
|
||||
@ -31,7 +31,8 @@ export type PixelRenderStyle = {
|
||||
type: 'pixel'
|
||||
resolution: number
|
||||
}
|
||||
export type AvatarRenderStyle = { type: 'vector' } | PixelRenderStyle
|
||||
export type VectorRenderStyle = { type: 'vector'; filled?: boolean }
|
||||
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
|
||||
export type AvatarEyeDefaults = Pick<
|
||||
Expression,
|
||||
| 'widthLeft'
|
||||
@ -66,6 +67,8 @@ export const defaultAvatarColors: AvatarColors = {
|
||||
eyes: deslopAccentColors.indigo,
|
||||
}
|
||||
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
||||
export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true }
|
||||
const filledAvatarEyeColor = '#111316'
|
||||
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
||||
type: 'pixel',
|
||||
resolution: 64,
|
||||
@ -150,6 +153,19 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
|
||||
}
|
||||
|
||||
export const simpleBundledAvatarIds = [
|
||||
'primitive-ghost',
|
||||
'primitive-apple-bite',
|
||||
'oneworks-cat',
|
||||
'oneworks-cat-siamese',
|
||||
'oneworks-cat-british-shorthair',
|
||||
'oneworks-cat-russian-blue',
|
||||
'oneworks-cat-orange-tabby',
|
||||
'oneworks-cat-black',
|
||||
'oneworks-dog',
|
||||
'oneworks-dog-brown',
|
||||
'oneworks-bear',
|
||||
'oneworks-rabbit',
|
||||
'oneworks-bun',
|
||||
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
|
||||
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
|
||||
'avatar-1786600724626',
|
||||
@ -160,13 +176,33 @@ export const simpleBundledAvatarIds = [
|
||||
'primitive-cylinder',
|
||||
'primitive-cone',
|
||||
'primitive-diamond',
|
||||
'primitive-ghost',
|
||||
'primitive-willy',
|
||||
'oneworks-sphere',
|
||||
'oneworks-ellipse',
|
||||
'oneworks-square',
|
||||
'oneworks-rounded',
|
||||
'oneworks-capsule',
|
||||
'oneworks-teardrop',
|
||||
'oneworks-diamond',
|
||||
'oneworks-trapezoid',
|
||||
'oneworks-cone',
|
||||
'oneworks-frustum',
|
||||
'oneworks-half-cone',
|
||||
'oneworks-cloud',
|
||||
'oneworks-sun',
|
||||
] as const
|
||||
|
||||
const simpleBundledAvatarOrder = new Map(simpleBundledAvatarIds.map((id, index) => [id, index]))
|
||||
|
||||
// Remove hidden bundled characters from both fresh and previously persisted local libraries.
|
||||
const hiddenBundledAvatarIds = new Set([
|
||||
'memento-piglet',
|
||||
'oneworks-cat',
|
||||
'oneworks-cat-cow',
|
||||
])
|
||||
|
||||
const retiredBundledAvatarIds = new Set([
|
||||
'memento-arctic-fox',
|
||||
'strobi',
|
||||
'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed',
|
||||
'avatar-7874f78a-93ec-4536-a3b6-bb53ed744efd',
|
||||
@ -175,6 +211,14 @@ const retiredBundledAvatarIds = new Set([
|
||||
'avatar-fafdaf4d-2071-41d6-9d42-7d34670956f0',
|
||||
])
|
||||
|
||||
const legacyBundledAvatarNames: Record<string, string> = {
|
||||
'oneworks-cat': 'OneWorks Cat',
|
||||
'oneworks-dog': 'OneWorks Dog',
|
||||
'oneworks-bear': 'OneWorks Bear',
|
||||
'oneworks-rabbit': 'OneWorks Rabbit',
|
||||
'oneworks-bun': 'OneWorks Bun',
|
||||
}
|
||||
|
||||
const primitiveBundledAvatarSpecs = [
|
||||
['primitive-sphere', 'Sphere', 'sphere', deslopAccentColors.indigo, 1],
|
||||
['primitive-cube', 'Cube', 'cube', deslopAccentColors.brown, 0.78],
|
||||
@ -182,23 +226,584 @@ const primitiveBundledAvatarSpecs = [
|
||||
['primitive-cylinder', 'Cylinder', 'cylinder', deslopAccentColors.mint, 0.82],
|
||||
['primitive-cone', 'Cone', 'cone', deslopAccentColors.purple, 0.82],
|
||||
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
|
||||
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 1],
|
||||
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
|
||||
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
|
||||
] as const
|
||||
|
||||
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => ({
|
||||
expressions: (behavior?.expressions ?? initialExpressions).map(expression => {
|
||||
const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
|
||||
const storedExpressions = behavior?.expressions ?? initialExpressions
|
||||
const baseExpressions = storedExpressions.filter(
|
||||
expression => !expression.semanticKey?.startsWith('scared-state-')
|
||||
)
|
||||
const expressions = baseExpressions.map((expression, index) => {
|
||||
const fixedColorExpression = { ...expression }
|
||||
const bundledExpression =
|
||||
initialExpressions.find(candidate => candidate.semanticKey === expression.semanticKey) ??
|
||||
initialExpressions[index]
|
||||
if (expression.semanticKey === 'angry-brows' || expression.semanticKey === 'uneasy-left') {
|
||||
if (bundledExpression?.bodyColor) {
|
||||
fixedColorExpression.bodyColor = bundledExpression.bodyColor
|
||||
} else {
|
||||
delete fixedColorExpression.bodyColor
|
||||
}
|
||||
if (bundledExpression?.eyeColor) {
|
||||
fixedColorExpression.eyeColor = bundledExpression.eyeColor
|
||||
} else {
|
||||
delete fixedColorExpression.eyeColor
|
||||
}
|
||||
} else {
|
||||
delete fixedColorExpression.bodyColor
|
||||
delete fixedColorExpression.eyeColor
|
||||
}
|
||||
return fixedColorExpression
|
||||
}),
|
||||
sequences: (behavior?.sequences ?? createInitialSequences()).map(sequence => ({
|
||||
})
|
||||
const expressionById = new Map(expressions.map(expression => [expression.id, expression]))
|
||||
const sourceSequences = behavior?.sequences ?? createInitialSequences()
|
||||
const scaredSequence = sourceSequences.find(sequence => sequence.semanticKey === 'scared')
|
||||
const scaredBodyColor =
|
||||
initialExpressions.find(expression => expression.semanticKey === 'uneasy-left')?.bodyColor ??
|
||||
'#adc3ff'
|
||||
const scaredExpressions = (scaredSequence?.steps ?? []).flatMap(step => {
|
||||
const baseId = step.expressionId.replace(/-scared-state$/, '')
|
||||
const source = expressionById.get(baseId)
|
||||
if (!source) return []
|
||||
return [
|
||||
{
|
||||
...source,
|
||||
id: `${baseId}-scared-state`,
|
||||
semanticKey: `scared-state-${(source.semanticKey ?? source.id).replace(/^scared-state-/, '')}`,
|
||||
bodyColor: scaredBodyColor,
|
||||
},
|
||||
]
|
||||
})
|
||||
const sequences = sourceSequences.map(sequence => ({
|
||||
...sequence,
|
||||
steps: sequence.steps.map(step => ({
|
||||
...step,
|
||||
expressionId:
|
||||
sequence.semanticKey === 'scared'
|
||||
? `${step.expressionId.replace(/-scared-state$/, '')}-scared-state`
|
||||
: step.expressionId,
|
||||
})),
|
||||
blink: { ...sequence.blink },
|
||||
}))
|
||||
|
||||
return { expressions: [...expressions, ...scaredExpressions], sequences }
|
||||
}
|
||||
|
||||
const mementoBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
|
||||
const sourceExpressions = (behavior?.expressions ?? initialExpressions).filter(
|
||||
expression =>
|
||||
expression.semanticKey !== 'onboarding-listening' &&
|
||||
expression.semanticKey !== 'onboarding-curious'
|
||||
)
|
||||
const expressions = sourceExpressions.map(expression => {
|
||||
const fixedColorExpression = { ...expression }
|
||||
const bundledExpression = initialExpressions.find(
|
||||
candidate => candidate.semanticKey === expression.semanticKey
|
||||
)
|
||||
if (expression.semanticKey === 'angry-brows') {
|
||||
fixedColorExpression.bodyColor = bundledExpression?.bodyColor
|
||||
fixedColorExpression.eyeColor = bundledExpression?.eyeColor
|
||||
} else {
|
||||
delete fixedColorExpression.bodyColor
|
||||
delete fixedColorExpression.eyeColor
|
||||
}
|
||||
return fixedColorExpression
|
||||
})
|
||||
const listeningSource = expressions.find(expression => expression.semanticKey === 'downward-gaze')
|
||||
const curiousSource = expressions.find(expression => expression.semanticKey === 'small-attentive')
|
||||
const onboardingExpressions: Expression[] =
|
||||
listeningSource && curiousSource
|
||||
? [
|
||||
{
|
||||
...listeningSource,
|
||||
id: 'memento-onboarding-listening',
|
||||
semanticKey: 'onboarding-listening',
|
||||
headX: -22,
|
||||
positionYLeft: -50,
|
||||
positionYRight: -50,
|
||||
},
|
||||
{
|
||||
...curiousSource,
|
||||
id: 'memento-onboarding-curious',
|
||||
semanticKey: 'onboarding-curious',
|
||||
headX: -12,
|
||||
positionYLeft: -22,
|
||||
positionYRight: -22,
|
||||
},
|
||||
]
|
||||
: []
|
||||
const sequences = (behavior?.sequences ?? createInitialSequences()).map(sequence => ({
|
||||
...sequence,
|
||||
steps: sequence.steps.map(step => ({ ...step })),
|
||||
blink: { ...sequence.blink },
|
||||
})),
|
||||
}))
|
||||
return {
|
||||
expressions: [...expressions, ...onboardingExpressions],
|
||||
sequences: [
|
||||
...sequences.filter(sequence => sequence.semanticKey !== 'onboarding'),
|
||||
...(onboardingExpressions.length === 2
|
||||
? [
|
||||
{
|
||||
id: 'memento-onboarding',
|
||||
semanticKey: 'onboarding',
|
||||
name: 'onboarding',
|
||||
group: 'Memento',
|
||||
description: 'Alternates attentive listening with curiosity.',
|
||||
builtIn: true,
|
||||
playbackMode: 'loop' as const,
|
||||
steps: [
|
||||
{
|
||||
id: 'memento-onboarding-listening-step',
|
||||
expressionId: 'memento-onboarding-listening',
|
||||
holdMs: 1200,
|
||||
transitionMs: 350,
|
||||
transition: 'smooth' as const,
|
||||
},
|
||||
{
|
||||
id: 'memento-onboarding-curious-step',
|
||||
expressionId: 'memento-onboarding-curious',
|
||||
holdMs: 1200,
|
||||
transitionMs: 350,
|
||||
transition: 'smooth' as const,
|
||||
},
|
||||
],
|
||||
blink: {
|
||||
enabled: true,
|
||||
initialDelayMs: 2600,
|
||||
minIntervalMs: 3400,
|
||||
maxIntervalMs: 6200,
|
||||
durationMs: 280,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
type OneWorksShape =
|
||||
| 'sphere'
|
||||
| 'ellipse'
|
||||
| 'square'
|
||||
| 'rounded'
|
||||
| 'capsule'
|
||||
| 'teardrop'
|
||||
| 'diamond'
|
||||
| 'trapezoid'
|
||||
| 'cone'
|
||||
| 'frustum'
|
||||
| 'half-cone'
|
||||
|
||||
// Adapted from OneWorks Avatar's MIT-licensed parametric shape catalog.
|
||||
// See avatar-lab/THIRD_PARTY_NOTICES.md for attribution and source details.
|
||||
const oneWorksShapeDimensions: Record<
|
||||
OneWorksShape,
|
||||
{ width: number; height: number; depth: number }
|
||||
> = {
|
||||
sphere: { width: 278, height: 278, depth: 278 },
|
||||
ellipse: { width: 306, height: 236, depth: 244 },
|
||||
square: { width: 264, height: 264, depth: 216 },
|
||||
rounded: { width: 264, height: 264, depth: 232 },
|
||||
capsule: { width: 300, height: 218, depth: 218 },
|
||||
teardrop: { width: 264, height: 296, depth: 224 },
|
||||
diamond: { width: 278, height: 278, depth: 212 },
|
||||
trapezoid: { width: 284, height: 264, depth: 232 },
|
||||
cone: { width: 278, height: 278, depth: 248 },
|
||||
frustum: { width: 278, height: 278, depth: 248 },
|
||||
'half-cone': { width: 278, height: 278, depth: 248 },
|
||||
}
|
||||
|
||||
const oneWorksSurfaceType: Record<OneWorksShape, keyof typeof surfacePresets> = {
|
||||
sphere: 'sphere',
|
||||
ellipse: 'ellipse',
|
||||
square: 'square',
|
||||
rounded: 'rounded',
|
||||
// OneWorks' capsule is a wide superellipsoid, not a vertical stadium.
|
||||
capsule: 'rounded',
|
||||
teardrop: 'teardrop',
|
||||
diamond: 'diamond',
|
||||
trapezoid: 'trapezoid',
|
||||
cone: 'cone',
|
||||
frustum: 'frustum',
|
||||
'half-cone': 'half-cone',
|
||||
}
|
||||
|
||||
const oneWorksSurface = (
|
||||
shape: OneWorksShape,
|
||||
scaleX = 1,
|
||||
scaleY = scaleX,
|
||||
scaleZ = Math.min(scaleX, scaleY),
|
||||
options: Partial<(typeof surfacePresets)[keyof typeof surfacePresets]> = {}
|
||||
) => {
|
||||
const type = oneWorksSurfaceType[shape]
|
||||
const dimensions = oneWorksShapeDimensions[shape]
|
||||
return {
|
||||
...surfacePresets[type],
|
||||
width: dimensions.width * scaleX,
|
||||
height: dimensions.height * scaleY,
|
||||
depth: dimensions.depth * scaleZ,
|
||||
...(shape === 'diamond' ? { roundness: 1.3 } : {}),
|
||||
...(shape === 'trapezoid' ? { roundness: 1.44, topScale: 0.82 } : {}),
|
||||
...(shape === 'cone'
|
||||
? { roundness: 0.48, morphRoundness: 0, tipRoundness: 1, baseRoundness: 0.35 }
|
||||
: {}),
|
||||
...options,
|
||||
type,
|
||||
}
|
||||
}
|
||||
|
||||
const oneWorksNode = (
|
||||
id: string,
|
||||
name: string,
|
||||
shape: OneWorksShape,
|
||||
position: readonly [number, number, number],
|
||||
scaleX: number,
|
||||
scaleY: number,
|
||||
options: {
|
||||
scaleZ?: number
|
||||
rotation?: readonly [number, number, number]
|
||||
surface?: Partial<(typeof surfacePresets)[keyof typeof surfacePresets]>
|
||||
layer?: 'auto' | 'front' | 'back'
|
||||
color?: string
|
||||
} = {}
|
||||
) => ({
|
||||
id,
|
||||
name,
|
||||
layer: options.layer ?? ('back' as const),
|
||||
...(options.color ? { color: options.color } : {}),
|
||||
surface: oneWorksSurface(shape, scaleX, scaleY, options.scaleZ, options.surface),
|
||||
position: [...position] as [number, number, number],
|
||||
rotation: [...(options.rotation ?? [0, 0, 0])] as [number, number, number],
|
||||
})
|
||||
|
||||
const oneWorksAvatar = (
|
||||
id: string,
|
||||
name: string,
|
||||
shape: OneWorksShape,
|
||||
color: string
|
||||
): StudioAvatar => ({
|
||||
id,
|
||||
name,
|
||||
body: { primary: oneWorksSurface(shape, 0.84), nodes: [] },
|
||||
colors: { body: color, eyes: color },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
})
|
||||
|
||||
const oneWorksOriginalAnimalColors: Record<string, AvatarColors> = {
|
||||
'oneworks-cat': { body: '#f7f7f4', eyes: '#050608' },
|
||||
'oneworks-cat-siamese': { body: '#ead7b8', eyes: '#281913' },
|
||||
'oneworks-cat-british-shorthair': { body: '#b89a6b', eyes: '#35291f' },
|
||||
'oneworks-cat-russian-blue': { body: '#718493', eyes: '#17232c' },
|
||||
'oneworks-cat-orange-tabby': { body: '#d98a35', eyes: '#3b2416' },
|
||||
'oneworks-cat-black': { body: '#111419', eyes: '#eef2f5' },
|
||||
'oneworks-dog': { body: '#d4d0c8', eyes: '#211f1d' },
|
||||
'oneworks-bear': { body: '#a95f47', eyes: '#2b1d18' },
|
||||
'oneworks-rabbit': { body: '#eee9df', eyes: '#292724' },
|
||||
'oneworks-bun': { body: '#fff3d9', eyes: '#241915' },
|
||||
}
|
||||
|
||||
const oneWorksBrownDogColors: AvatarColors = {
|
||||
body: '#e3b17f',
|
||||
eyes: '#2b1d18',
|
||||
}
|
||||
|
||||
const oneWorksBundledAnimalColors: Record<string, AvatarColors> = {
|
||||
...oneWorksOriginalAnimalColors,
|
||||
'oneworks-dog-brown': oneWorksBrownDogColors,
|
||||
}
|
||||
|
||||
const mementoAnimalColors: Record<string, AvatarColors> = {
|
||||
'memento-piglet': { body: deslopAccentColors.pink, eyes: filledAvatarEyeColor },
|
||||
}
|
||||
|
||||
const oneWorksCatAvatar = (
|
||||
id: string,
|
||||
name: string,
|
||||
colors: AvatarColors,
|
||||
earWidth: number,
|
||||
earHeight: number,
|
||||
earColor = colors.body
|
||||
): StudioAvatar => {
|
||||
const earScaleX = 0.24 * (earWidth / 104)
|
||||
const earScaleY = 0.29 * (earHeight / 114)
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
body: {
|
||||
primary: oneWorksSurface('ellipse', 0.73, 0.68),
|
||||
nodes: [
|
||||
oneWorksNode('cat-ear-left', 'Left ear', 'cone', [-56, -78, -8], earScaleX, earScaleY, {
|
||||
rotation: [-7, -13, -9],
|
||||
surface: { roundness: 0.96 },
|
||||
color: earColor,
|
||||
}),
|
||||
oneWorksNode(
|
||||
'cat-ear-right',
|
||||
'Right ear',
|
||||
'cone',
|
||||
[56, -78, -10],
|
||||
earScaleX * 0.96,
|
||||
earScaleY * 0.97,
|
||||
{
|
||||
rotation: [-6, 13, 9],
|
||||
surface: { roundness: 1.04 },
|
||||
color: earColor,
|
||||
}
|
||||
),
|
||||
],
|
||||
},
|
||||
colors: { ...colors },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...filledAvatarRenderStyle },
|
||||
behavior: fixedSkinColorBehavior(),
|
||||
}
|
||||
}
|
||||
|
||||
const oneWorksDogAvatar = (
|
||||
id: string,
|
||||
name: string,
|
||||
colors: AvatarColors,
|
||||
earColor: string
|
||||
): StudioAvatar => ({
|
||||
id,
|
||||
name,
|
||||
body: {
|
||||
primary: oneWorksSurface('trapezoid', 0.72, 0.8, 0.72, {
|
||||
roundness: 1.68,
|
||||
topScale: 0.68,
|
||||
}),
|
||||
nodes: [
|
||||
oneWorksNode('dog-ear-left', 'Left ear', 'teardrop', [-82, -65, 0], 0.18, 0.34, {
|
||||
scaleZ: 0.18,
|
||||
rotation: [-4, -10, 22],
|
||||
layer: 'front',
|
||||
color: earColor,
|
||||
}),
|
||||
oneWorksNode('dog-ear-right', 'Right ear', 'teardrop', [82, -65, 0], 0.18, 0.34, {
|
||||
scaleZ: 0.18,
|
||||
rotation: [-4, 10, -22],
|
||||
layer: 'front',
|
||||
color: earColor,
|
||||
}),
|
||||
],
|
||||
},
|
||||
colors: { ...colors },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...filledAvatarRenderStyle },
|
||||
behavior: fixedSkinColorBehavior(),
|
||||
})
|
||||
|
||||
const oneWorksBundledAvatars: StudioAvatar[] = [
|
||||
oneWorksAvatar('oneworks-sphere', 'OneWorks Sphere', 'sphere', deslopAccentColors.cyan),
|
||||
oneWorksAvatar('oneworks-ellipse', 'OneWorks Ellipse', 'ellipse', deslopAccentColors.teal),
|
||||
oneWorksAvatar('oneworks-square', 'OneWorks Square', 'square', deslopAccentColors.brown),
|
||||
oneWorksAvatar('oneworks-rounded', 'OneWorks Rounded', 'rounded', deslopAccentColors.blue),
|
||||
oneWorksAvatar('oneworks-capsule', 'OneWorks Capsule', 'capsule', deslopAccentColors.green),
|
||||
oneWorksAvatar('oneworks-teardrop', 'OneWorks Teardrop', 'teardrop', deslopAccentColors.pink),
|
||||
oneWorksAvatar('oneworks-diamond', 'OneWorks Diamond', 'diamond', deslopAccentColors.purple),
|
||||
oneWorksAvatar('oneworks-trapezoid', 'OneWorks Trapezoid', 'trapezoid', deslopAccentColors.mint),
|
||||
oneWorksAvatar('oneworks-cone', 'OneWorks Cone', 'cone', deslopAccentColors.red),
|
||||
oneWorksAvatar('oneworks-frustum', 'OneWorks Frustum', 'frustum', deslopAccentColors.yellow),
|
||||
oneWorksAvatar(
|
||||
'oneworks-half-cone',
|
||||
'OneWorks Half Cone',
|
||||
'half-cone',
|
||||
deslopAccentColors.indigo
|
||||
),
|
||||
{
|
||||
id: 'oneworks-cloud',
|
||||
name: 'OneWorks Cloud',
|
||||
body: {
|
||||
primary: oneWorksSurface('ellipse', 0.83, 0.66),
|
||||
nodes: [
|
||||
oneWorksNode('cloud-crown-left', 'Crown left', 'sphere', [-91, -48, -42], 0.38, 0.38),
|
||||
oneWorksNode('cloud-crown-center', 'Crown center', 'rounded', [-20, -79, -34], 0.48, 0.46),
|
||||
oneWorksNode('cloud-crown-right', 'Crown right', 'sphere', [65, -55, -47], 0.34, 0.37),
|
||||
oneWorksNode('cloud-edge-left', 'Left edge', 'rounded', [-122, 13, -26], 0.41, 0.36),
|
||||
oneWorksNode('cloud-edge-right', 'Right edge', 'sphere', [119, 20, -29], 0.37, 0.34),
|
||||
oneWorksNode('cloud-base-left', 'Base left', 'capsule', [-65, 72, -38], 0.47, 0.27),
|
||||
oneWorksNode('cloud-base-right', 'Base right', 'capsule', [52, 70, -36], 0.52, 0.29),
|
||||
],
|
||||
},
|
||||
colors: { body: deslopAccentColors.cyan, eyes: deslopAccentColors.cyan },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
},
|
||||
{
|
||||
id: 'oneworks-sun',
|
||||
name: 'OneWorks Sun',
|
||||
body: {
|
||||
primary: oneWorksSurface('sphere', 0.72),
|
||||
nodes: Array.from({ length: 8 }, (_, index) => {
|
||||
const angle = (index * Math.PI) / 4
|
||||
return oneWorksNode(
|
||||
`sun-ray-${index + 1}`,
|
||||
`Ray ${index + 1}`,
|
||||
index % 2 === 0 ? 'diamond' : 'sphere',
|
||||
[Math.cos(angle) * 125, Math.sin(angle) * 125, -18],
|
||||
0.14,
|
||||
0.14
|
||||
)
|
||||
}),
|
||||
},
|
||||
colors: { body: deslopAccentColors.yellow, eyes: deslopAccentColors.yellow },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
},
|
||||
oneWorksCatAvatar('oneworks-cat', 'Cat', oneWorksOriginalAnimalColors['oneworks-cat'], 104, 114),
|
||||
oneWorksCatAvatar(
|
||||
'oneworks-cat-siamese',
|
||||
'Siamese',
|
||||
oneWorksOriginalAnimalColors['oneworks-cat-siamese'],
|
||||
104,
|
||||
114,
|
||||
'#3c2118'
|
||||
),
|
||||
oneWorksCatAvatar(
|
||||
'oneworks-cat-british-shorthair',
|
||||
'British Shorthair',
|
||||
oneWorksOriginalAnimalColors['oneworks-cat-british-shorthair'],
|
||||
90,
|
||||
86
|
||||
),
|
||||
oneWorksCatAvatar(
|
||||
'oneworks-cat-russian-blue',
|
||||
'Russian Blue',
|
||||
oneWorksOriginalAnimalColors['oneworks-cat-russian-blue'],
|
||||
104,
|
||||
116
|
||||
),
|
||||
oneWorksCatAvatar(
|
||||
'oneworks-cat-orange-tabby',
|
||||
'Orange Tabby',
|
||||
oneWorksOriginalAnimalColors['oneworks-cat-orange-tabby'],
|
||||
100,
|
||||
100
|
||||
),
|
||||
oneWorksCatAvatar(
|
||||
'oneworks-cat-black',
|
||||
'Black Cat',
|
||||
oneWorksOriginalAnimalColors['oneworks-cat-black'],
|
||||
102,
|
||||
106
|
||||
),
|
||||
oneWorksDogAvatar(
|
||||
'oneworks-dog',
|
||||
'Dog',
|
||||
oneWorksOriginalAnimalColors['oneworks-dog'],
|
||||
deslopAccentColors.brown
|
||||
),
|
||||
oneWorksDogAvatar('oneworks-dog-brown', 'Brown Dog', oneWorksBrownDogColors, '#a95f47'),
|
||||
{
|
||||
id: 'oneworks-bear',
|
||||
name: 'Bear',
|
||||
body: {
|
||||
primary: oneWorksSurface('trapezoid', 0.74, 0.72, 0.72, { roundness: 1.56 }),
|
||||
nodes: [
|
||||
oneWorksNode('bear-ear-left', 'Left ear', 'ellipse', [-64, -80, -18], 0.2, 0.34, {
|
||||
rotation: [-4, -7, 8],
|
||||
color: '#a95f47',
|
||||
}),
|
||||
oneWorksNode('bear-ear-right', 'Right ear', 'ellipse', [64, -78, -20], 0.19, 0.33, {
|
||||
rotation: [-3, 8, -7],
|
||||
color: '#a95f47',
|
||||
}),
|
||||
],
|
||||
},
|
||||
colors: { ...oneWorksOriginalAnimalColors['oneworks-bear'] },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...filledAvatarRenderStyle },
|
||||
behavior: fixedSkinColorBehavior(),
|
||||
},
|
||||
{
|
||||
id: 'oneworks-rabbit',
|
||||
name: 'Rabbit',
|
||||
body: {
|
||||
primary: oneWorksSurface('trapezoid', 0.72, 0.74, 0.72, {
|
||||
roundness: 2,
|
||||
topScale: 0.94,
|
||||
}),
|
||||
nodes: [
|
||||
oneWorksNode('rabbit-ear-left', 'Left ear', 'trapezoid', [-50, -76, -22], 0.18, 0.6, {
|
||||
rotation: [-3, -5, -8],
|
||||
surface: { roundness: 2, topScale: 0.9 },
|
||||
}),
|
||||
oneWorksNode('rabbit-ear-right', 'Right ear', 'trapezoid', [48, -79, -24], 0.17, 0.62, {
|
||||
rotation: [-4, 7, 11],
|
||||
surface: { roundness: 2, topScale: 0.9 },
|
||||
}),
|
||||
],
|
||||
},
|
||||
colors: { ...oneWorksOriginalAnimalColors['oneworks-rabbit'] },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...filledAvatarRenderStyle },
|
||||
behavior: fixedSkinColorBehavior(),
|
||||
},
|
||||
{
|
||||
id: 'oneworks-bun',
|
||||
name: 'Bun',
|
||||
body: {
|
||||
primary: oneWorksSurface('sphere', 0.7, 0.5, 0.7),
|
||||
nodes: [
|
||||
oneWorksNode('bun-crown', 'Rounded bun crown', 'cone', [0, -46, -14], 0.5, 0.23, {
|
||||
scaleZ: 0.5,
|
||||
layer: 'front',
|
||||
surface: { roundness: 0.92, topScale: 0.82 },
|
||||
}),
|
||||
],
|
||||
},
|
||||
colors: { ...oneWorksOriginalAnimalColors['oneworks-bun'] },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...filledAvatarRenderStyle },
|
||||
behavior: fixedSkinColorBehavior(),
|
||||
},
|
||||
]
|
||||
|
||||
const mementoPigletAvatar: StudioAvatar = {
|
||||
id: 'memento-piglet',
|
||||
name: 'Порося',
|
||||
body: {
|
||||
primary: oneWorksSurface('trapezoid', 0.72, 0.8, 0.72, {
|
||||
roundness: 1.68,
|
||||
topScale: 0.68,
|
||||
}),
|
||||
nodes: [
|
||||
oneWorksNode('piglet-ear-left', 'Left ear', 'ellipse', [-57, -76, -18], 0.18, 0.29, {
|
||||
rotation: [-4, -7, 8],
|
||||
color: deslopAccentColors.red,
|
||||
}),
|
||||
oneWorksNode('piglet-ear-right', 'Right ear', 'ellipse', [57, -75, -20], 0.18, 0.29, {
|
||||
rotation: [-3, 8, -7],
|
||||
color: deslopAccentColors.red,
|
||||
}),
|
||||
oneWorksNode('piglet-snout', 'Snout', 'ellipse', [0, 44, 76], 0.25, 0.16, {
|
||||
scaleZ: 0.1,
|
||||
layer: 'front',
|
||||
color: deslopAccentColors.red,
|
||||
}),
|
||||
oneWorksNode('piglet-nostril-left', 'Left nostril', 'ellipse', [-14, 44, 90], 0.035, 0.048, {
|
||||
scaleZ: 0.025,
|
||||
rotation: [0, 0, -5],
|
||||
layer: 'front',
|
||||
color: filledAvatarEyeColor,
|
||||
}),
|
||||
oneWorksNode('piglet-nostril-right', 'Right nostril', 'ellipse', [14, 44, 90], 0.035, 0.048, {
|
||||
scaleZ: 0.025,
|
||||
rotation: [0, 0, 5],
|
||||
layer: 'front',
|
||||
color: filledAvatarEyeColor,
|
||||
}),
|
||||
],
|
||||
},
|
||||
colors: { ...mementoAnimalColors['memento-piglet'] },
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...filledAvatarRenderStyle },
|
||||
behavior: fixedSkinColorBehavior(),
|
||||
}
|
||||
|
||||
const primitiveBundledAvatars: StudioAvatar[] = [
|
||||
...primitiveBundledAvatarSpecs.map(([id, name, surface, color, scale]) => ({
|
||||
id,
|
||||
@ -212,7 +817,10 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
||||
},
|
||||
nodes: [],
|
||||
},
|
||||
colors: { body: color, eyes: color },
|
||||
colors: {
|
||||
body: color,
|
||||
eyes: surface === 'ghost' || surface === 'apple-bite' ? filledAvatarEyeColor : color,
|
||||
},
|
||||
eyes:
|
||||
surface === 'ghost'
|
||||
? {
|
||||
@ -220,10 +828,24 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
||||
positionYLeft: defaultAvatarEyes.positionYLeft - 20,
|
||||
positionYRight: defaultAvatarEyes.positionYRight - 20,
|
||||
}
|
||||
: surface === 'apple-bite'
|
||||
? {
|
||||
...defaultAvatarEyes,
|
||||
positionXLeft: defaultAvatarEyes.positionXLeft - 18,
|
||||
positionXRight: defaultAvatarEyes.positionXRight - 18,
|
||||
}
|
||||
: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
...(surface === 'ghost' ? { behavior: mementoBehavior() } : {}),
|
||||
renderStyle:
|
||||
surface === 'ghost' || surface === 'apple-bite'
|
||||
? { ...filledAvatarRenderStyle }
|
||||
: { ...defaultAvatarRenderStyle },
|
||||
...(surface === 'ghost'
|
||||
? { behavior: mementoBehavior() }
|
||||
: surface === 'apple-bite'
|
||||
? { behavior: fixedSkinColorBehavior() }
|
||||
: {}),
|
||||
})),
|
||||
mementoPigletAvatar,
|
||||
{
|
||||
id: 'primitive-willy',
|
||||
name: 'Willy',
|
||||
@ -287,9 +909,26 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
||||
eyes: { ...defaultAvatarEyes },
|
||||
renderStyle: { ...defaultAvatarRenderStyle },
|
||||
},
|
||||
...oneWorksBundledAvatars,
|
||||
]
|
||||
|
||||
const originalSimpleBundledAvatarIds = new Set<string>(simpleBundledAvatarIds.slice(0, 4))
|
||||
const originalSimpleBundledAvatarIds = new Set<string>([
|
||||
'avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e',
|
||||
'avatar-2739f2c2-a5b4-45d9-8915-c9d6101d4d3b',
|
||||
'avatar-1786600724626',
|
||||
'avatar-295e74a7-5d70-4d61-83d4-7beebb22bdd8',
|
||||
])
|
||||
const filledBundledAvatarIds = new Set([
|
||||
...Object.keys(oneWorksBundledAnimalColors),
|
||||
...Object.keys(mementoAnimalColors),
|
||||
'primitive-ghost',
|
||||
'primitive-apple-bite',
|
||||
])
|
||||
const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors))
|
||||
const filledPrimitiveAvatarColors: Record<string, AvatarColors> = {
|
||||
'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
|
||||
'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
|
||||
}
|
||||
|
||||
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
|
||||
...avatar,
|
||||
@ -305,17 +944,107 @@ const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({
|
||||
colors: { ...avatar.colors },
|
||||
eyes: { ...avatar.eyes },
|
||||
renderStyle: { ...avatar.renderStyle },
|
||||
...(avatar.behavior ? { behavior: mementoBehavior(avatar.behavior) } : {}),
|
||||
...(avatar.behavior
|
||||
? {
|
||||
behavior: {
|
||||
expressions: avatar.behavior.expressions.map(expression => ({ ...expression })),
|
||||
sequences: avatar.behavior.sequences.map(sequence => ({
|
||||
...sequence,
|
||||
steps: sequence.steps.map(step => ({ ...step })),
|
||||
blink: { ...sequence.blink },
|
||||
})),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLibrary => {
|
||||
if (!library.avatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) return library
|
||||
const resizedAvatars = library.avatars.map(avatar => {
|
||||
const visibleLibraryAvatars = library.avatars.filter(
|
||||
avatar => !hiddenBundledAvatarIds.has(avatar.id)
|
||||
)
|
||||
if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) {
|
||||
const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => {
|
||||
if (avatar.id === 'primitive-ghost') {
|
||||
return { ...avatar, behavior: mementoBehavior(avatar.behavior) }
|
||||
}
|
||||
if (filledBundledAvatarIds.has(avatar.id)) {
|
||||
return { ...avatar, behavior: fixedSkinColorBehavior(avatar.behavior) }
|
||||
}
|
||||
return avatar
|
||||
})
|
||||
return {
|
||||
...library,
|
||||
activeAvatarId: avatarsWithCurrentColorBehavior.some(
|
||||
avatar => avatar.id === library.activeAvatarId
|
||||
)
|
||||
? library.activeAvatarId
|
||||
: (avatarsWithCurrentColorBehavior[0]?.id ?? library.activeAvatarId),
|
||||
avatars: avatarsWithCurrentColorBehavior,
|
||||
}
|
||||
}
|
||||
const resizedAvatars = visibleLibraryAvatars.map(avatar => {
|
||||
const bundled = primitiveBundledAvatars.find(candidate => candidate.id === avatar.id)
|
||||
if (!bundled) return avatar
|
||||
const migratedNodes = avatar.body.nodes.map(node => {
|
||||
const bundledNode = bundled.body.nodes.find(candidate => candidate.id === node.id)
|
||||
if (!bundledNode || avatar.id !== 'primitive-willy') return node
|
||||
if (!bundledNode) return node
|
||||
const nodeWithOriginalColor = bundledNode.color ? { ...node, color: bundledNode.color } : node
|
||||
const nodeWithOriginalLayer = oneWorksOriginalAnimalIds.has(avatar.id)
|
||||
? { ...nodeWithOriginalColor, layer: bundledNode.layer }
|
||||
: nodeWithOriginalColor
|
||||
const isDogEar =
|
||||
['oneworks-dog', 'oneworks-dog-brown'].includes(avatar.id) &&
|
||||
(node.id === 'dog-ear-left' || node.id === 'dog-ear-right')
|
||||
const hasLegacyDogEarPosition =
|
||||
node.position[1] === -52 ||
|
||||
(node.position[1] === -65 &&
|
||||
((node.id === 'dog-ear-left' && node.position[0] === -92) ||
|
||||
(node.id === 'dog-ear-right' && node.position[0] === 92))) ||
|
||||
(node.position[1] === -78 && [72, 88].includes(Math.abs(node.position[0]))) ||
|
||||
(node.position[1] === -62 &&
|
||||
((node.id === 'dog-ear-left' && node.position[0] === -88) ||
|
||||
(node.id === 'dog-ear-right' && [88, 132, 142, 146].includes(node.position[0])))) ||
|
||||
(node.position[1] === -42 &&
|
||||
((node.id === 'dog-ear-left' && node.position[0] === -100) ||
|
||||
(node.id === 'dog-ear-right' && node.position[0] === 146))) ||
|
||||
(node.position[1] === -30 &&
|
||||
((node.id === 'dog-ear-left' && node.position[0] === -100) ||
|
||||
(node.id === 'dog-ear-right' && [140, 152].includes(node.position[0]))))
|
||||
const hasLegacyDogEarGeometry = isDogEar && hasLegacyDogEarPosition
|
||||
const isBearEar =
|
||||
avatar.id === 'oneworks-bear' &&
|
||||
(node.id === 'bear-ear-left' || node.id === 'bear-ear-right')
|
||||
const hasLegacyBearEarPosition =
|
||||
(node.id === 'bear-ear-left' &&
|
||||
node.position[0] === -58 &&
|
||||
node.position[1] === -72 &&
|
||||
node.position[2] === -18) ||
|
||||
(node.id === 'bear-ear-right' &&
|
||||
node.position[0] === 58 &&
|
||||
node.position[1] === -70 &&
|
||||
node.position[2] === -20)
|
||||
const hasLegacyBearEarGeometry = isBearEar && hasLegacyBearEarPosition
|
||||
const isPigletFaceFeature =
|
||||
avatar.id === 'memento-piglet' &&
|
||||
['piglet-snout', 'piglet-nostril-left', 'piglet-nostril-right'].includes(node.id)
|
||||
const hasLegacyPigletFaceGeometry =
|
||||
isPigletFaceFeature &&
|
||||
((node.id === 'piglet-snout' &&
|
||||
([8, 154].includes(node.position[2]) ||
|
||||
(node.position[1] === 57 && node.position[2] === 76))) ||
|
||||
((node.id === 'piglet-nostril-left' || node.id === 'piglet-nostril-right') &&
|
||||
([20, 176].includes(node.position[2]) ||
|
||||
(node.position[1] === 57 && node.position[2] === 90))))
|
||||
const nodeWithCurrentAnimalGeometry =
|
||||
hasLegacyDogEarGeometry || hasLegacyBearEarGeometry || hasLegacyPigletFaceGeometry
|
||||
? {
|
||||
...nodeWithOriginalLayer,
|
||||
surface: { ...bundledNode.surface },
|
||||
position: [...bundledNode.position] as typeof bundledNode.position,
|
||||
rotation: [...bundledNode.rotation] as typeof bundledNode.rotation,
|
||||
}
|
||||
: nodeWithOriginalLayer
|
||||
if (avatar.id !== 'primitive-willy') return nodeWithCurrentAnimalGeometry
|
||||
const isLegacyLowerSphere =
|
||||
(node.id === 'willy-left-sphere' || node.id === 'willy-right-sphere') &&
|
||||
node.surface.type === 'sphere' &&
|
||||
@ -329,7 +1058,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
||||
node.surface.depth === 135 &&
|
||||
node.position[2] === 12) ||
|
||||
(node.surface.width === 166 && node.surface.height === 80 && node.surface.depth === 148))
|
||||
if (!isLegacyLowerSphere && !isLegacyTopCap) return node
|
||||
if (!isLegacyLowerSphere && !isLegacyTopCap) return nodeWithCurrentAnimalGeometry
|
||||
return {
|
||||
...bundledNode,
|
||||
surface: { ...bundledNode.surface },
|
||||
@ -358,8 +1087,11 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
||||
: migratedNodes.some((node, index) => node !== avatar.body.nodes[index])
|
||||
? { ...avatar, body: { ...avatar.body, nodes: migratedNodes } }
|
||||
: avatar
|
||||
const legacyBundledName = legacyBundledAvatarNames[avatarWithCurrentNodes.id]
|
||||
const avatarWithCurrentName =
|
||||
avatarWithCurrentNodes.id === 'primitive-ghost' && avatarWithCurrentNodes.name === 'Ghost'
|
||||
(avatarWithCurrentNodes.id === 'primitive-ghost' &&
|
||||
avatarWithCurrentNodes.name === 'Ghost') ||
|
||||
avatarWithCurrentNodes.name === legacyBundledName
|
||||
? { ...avatarWithCurrentNodes, name: bundled.name }
|
||||
: avatarWithCurrentNodes
|
||||
const avatarWithCurrentEyes =
|
||||
@ -372,12 +1104,36 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
||||
)
|
||||
? { ...avatarWithCurrentName, eyes: { ...bundled.eyes } }
|
||||
: avatarWithCurrentName
|
||||
const avatarWithFixedColorBehavior =
|
||||
avatarWithCurrentEyes.id === 'primitive-ghost'
|
||||
? { ...avatarWithCurrentEyes, behavior: mementoBehavior(avatarWithCurrentEyes.behavior) }
|
||||
const avatarWithAppleBiteEyes =
|
||||
avatarWithCurrentEyes.id === 'primitive-apple-bite' &&
|
||||
avatarWithCurrentEyes.eyes.positionXLeft === defaultAvatarEyes.positionXLeft &&
|
||||
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
|
||||
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
|
||||
: avatarWithCurrentEyes
|
||||
const avatarWithFixedColorBehavior =
|
||||
avatarWithAppleBiteEyes.id === 'primitive-ghost'
|
||||
? {
|
||||
...avatarWithAppleBiteEyes,
|
||||
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
|
||||
}
|
||||
: filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id)
|
||||
? {
|
||||
...avatarWithAppleBiteEyes,
|
||||
behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior),
|
||||
}
|
||||
: avatarWithAppleBiteEyes
|
||||
const avatarWithCurrentRenderStyle = filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
|
||||
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
|
||||
: avatarWithFixedColorBehavior
|
||||
const bundledFilledColors =
|
||||
mementoAnimalColors[avatarWithCurrentRenderStyle.id] ??
|
||||
oneWorksBundledAnimalColors[avatarWithCurrentRenderStyle.id] ??
|
||||
filledPrimitiveAvatarColors[avatarWithCurrentRenderStyle.id]
|
||||
const avatarWithOriginalColors = bundledFilledColors
|
||||
? { ...avatarWithCurrentRenderStyle, colors: { ...bundledFilledColors } }
|
||||
: avatarWithCurrentRenderStyle
|
||||
const original = surfacePresets[bundled.body.primary.type]
|
||||
const primary = avatarWithFixedColorBehavior.body.primary
|
||||
const primary = avatarWithOriginalColors.body.primary
|
||||
const stillUsesOriginalSize =
|
||||
primary.type === original.type &&
|
||||
primary.width === original.width &&
|
||||
@ -389,17 +1145,25 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
||||
primary.width === 122 &&
|
||||
primary.height === 210 &&
|
||||
primary.depth === 115
|
||||
if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody) return avatarWithFixedColorBehavior
|
||||
const stillUsesPreviousPigletBody =
|
||||
avatar.id === 'memento-piglet' &&
|
||||
primary.type === 'sphere' &&
|
||||
primary.width === 194.6 &&
|
||||
primary.height === 194.6 &&
|
||||
primary.depth === 200.16
|
||||
if (!stillUsesOriginalSize && !stillUsesPreviousWillyBody && !stillUsesPreviousPigletBody) {
|
||||
return avatarWithOriginalColors
|
||||
}
|
||||
return {
|
||||
...avatarWithFixedColorBehavior,
|
||||
body: { ...avatarWithFixedColorBehavior.body, primary: { ...bundled.body.primary } },
|
||||
...avatarWithOriginalColors,
|
||||
body: { ...avatarWithOriginalColors.body, primary: { ...bundled.body.primary } },
|
||||
}
|
||||
})
|
||||
const existingIds = new Set(resizedAvatars.map(avatar => avatar.id))
|
||||
const avatars = [
|
||||
...resizedAvatars,
|
||||
...primitiveBundledAvatars
|
||||
.filter(avatar => !existingIds.has(avatar.id))
|
||||
.filter(avatar => !hiddenBundledAvatarIds.has(avatar.id) && !existingIds.has(avatar.id))
|
||||
.map(clonePrimitiveBundledAvatar),
|
||||
].sort((left, right) => {
|
||||
const leftOrder = simpleBundledAvatarOrder.get(
|
||||
@ -413,7 +1177,13 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
||||
if (rightOrder === undefined) return -1
|
||||
return leftOrder - rightOrder
|
||||
})
|
||||
return { ...library, avatars }
|
||||
return {
|
||||
...library,
|
||||
activeAvatarId: avatars.some(avatar => avatar.id === library.activeAvatarId)
|
||||
? library.activeAvatarId
|
||||
: (avatars[0]?.id ?? library.activeAvatarId),
|
||||
avatars,
|
||||
}
|
||||
}
|
||||
|
||||
export const migrateBundledAvatarColors = (id: string, colors: AvatarColors): AvatarColors => {
|
||||
@ -434,7 +1204,10 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
|
||||
: fallback
|
||||
|
||||
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
||||
const candidate = value as Partial<PixelRenderStyle> | null
|
||||
const candidate = value as { type?: unknown; filled?: unknown; resolution?: unknown } | null
|
||||
if (candidate?.type === 'vector') {
|
||||
return candidate.filled === true ? { type: 'vector', filled: true } : { type: 'vector' }
|
||||
}
|
||||
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
||||
return { ...defaultAvatarRenderStyle }
|
||||
}
|
||||
@ -616,7 +1389,9 @@ export const parseAvatarLibrary = (
|
||||
.filter(avatar => {
|
||||
if (!avatar || typeof avatar.id !== 'string' || typeof avatar.name !== 'string')
|
||||
return false
|
||||
if (retiredBundledAvatarIds.has(avatar.id)) return false
|
||||
if (retiredBundledAvatarIds.has(avatar.id) || hiddenBundledAvatarIds.has(avatar.id)) {
|
||||
return false
|
||||
}
|
||||
if (seenIds.has(avatar.id)) return false
|
||||
seenIds.add(avatar.id)
|
||||
return true
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ArrowLeft, Copy, Pencil, RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useState, type RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
@ -27,15 +28,38 @@ import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditin
|
||||
import { type Expression } from '@/features/avatar/geometry'
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||
import { StaticPixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||
import {
|
||||
avatarBodyOutlineWidth,
|
||||
resolveAvatarNodeColor,
|
||||
useAvatarBodyColorOverride,
|
||||
} from '@/features/rendering/avatarAppearance'
|
||||
import {
|
||||
LivePixelAvatarCanvas,
|
||||
StaticPixelAvatarCanvas,
|
||||
} from '@/features/rendering/components/PixelAvatarCanvas'
|
||||
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
||||
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
|
||||
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
|
||||
const outlineWidth = avatarBodyOutlineWidth()
|
||||
return (
|
||||
<svg viewBox="-150 -150 300 300" aria-hidden="true">
|
||||
{geometry.backPaths.map((pathValue, index) => (
|
||||
<path d={pathValue} key={index} />
|
||||
<path
|
||||
d={pathValue}
|
||||
fill="var(--avatar-interior-color, #ffffff)"
|
||||
stroke="currentColor"
|
||||
strokeWidth={outlineWidth}
|
||||
strokeLinejoin="round"
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
<path d={geometry.headPath} />
|
||||
<path
|
||||
d={geometry.headPath}
|
||||
fill="var(--avatar-interior-color, #ffffff)"
|
||||
stroke="currentColor"
|
||||
strokeWidth={outlineWidth}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@ -59,6 +83,16 @@ export function ExpressionPreview({
|
||||
}) {
|
||||
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
|
||||
const resolvedColors = resolveColors(expression, colors)
|
||||
const outlineWidth = avatarBodyOutlineWidth()
|
||||
const filled = renderStyle.type === 'vector' && renderStyle.filled === true
|
||||
const bodyFill = filled ? resolvedColors.body : 'var(--avatar-interior-color, #ffffff)'
|
||||
const eyeFill = resolvedColors.eyes
|
||||
const nodeColor = (id: string | null | undefined) =>
|
||||
resolveAvatarNodeColor({
|
||||
nodeColor: bodyNodes.find(node => node.id === id)?.color,
|
||||
bodyColor: resolvedColors.body,
|
||||
expressionBodyColor: expression.bodyColor,
|
||||
})
|
||||
if (renderStyle.type === 'pixel') {
|
||||
return (
|
||||
<StaticPixelAvatarCanvas
|
||||
@ -88,37 +122,168 @@ export function ExpressionPreview({
|
||||
<path d={geometry.headPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{geometry.backPaths.map((pathValue, index) => (
|
||||
{geometry.backPaths.map((pathValue, index) => {
|
||||
const color = nodeColor(geometry.backNodeIds[index])
|
||||
return (
|
||||
<path
|
||||
className="preview-head"
|
||||
d={pathValue}
|
||||
key={index}
|
||||
style={{ fill: resolvedColors.body }}
|
||||
style={{
|
||||
fill: filled ? color : bodyFill,
|
||||
stroke: color,
|
||||
strokeWidth: outlineWidth,
|
||||
strokeLinejoin: 'round',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<path
|
||||
className="preview-head"
|
||||
d={geometry.headPath}
|
||||
style={{
|
||||
fill: bodyFill,
|
||||
stroke: resolvedColors.body,
|
||||
strokeWidth: outlineWidth,
|
||||
strokeLinejoin: 'round',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<path className="preview-head" d={geometry.headPath} style={{ fill: resolvedColors.body }} />
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<path
|
||||
className="preview-eye"
|
||||
d={geometry.leftPath}
|
||||
opacity={geometry.leftVisible ? 1 : 0}
|
||||
style={{ fill: resolvedColors.eyes }}
|
||||
style={{ fill: eyeFill }}
|
||||
/>
|
||||
<path
|
||||
className="preview-eye"
|
||||
d={geometry.rightPath}
|
||||
opacity={geometry.rightVisible ? 1 : 0}
|
||||
style={{ fill: resolvedColors.eyes }}
|
||||
style={{ fill: eyeFill }}
|
||||
/>
|
||||
</g>
|
||||
{geometry.frontPaths.map((pathValue, index) => (
|
||||
{geometry.frontPaths.map((pathValue, index) => {
|
||||
const color = nodeColor(geometry.frontNodeIds[index])
|
||||
return (
|
||||
<path
|
||||
className="preview-head"
|
||||
d={pathValue}
|
||||
key={`front-${index}`}
|
||||
style={{ fill: resolvedColors.body }}
|
||||
style={{
|
||||
fill: filled ? color : bodyFill,
|
||||
stroke: color,
|
||||
strokeWidth: outlineWidth,
|
||||
strokeLinejoin: 'round',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function LiveExpressionPreview({
|
||||
scene,
|
||||
colors,
|
||||
baseBodyColor,
|
||||
bodyNodes,
|
||||
renderStyle,
|
||||
expressionBodyColor,
|
||||
id,
|
||||
}: {
|
||||
scene: RenderedScene
|
||||
colors: RenderedColors
|
||||
baseBodyColor: string
|
||||
bodyNodes: BodyNode[]
|
||||
renderStyle: AvatarRenderStyle
|
||||
expressionBodyColor?: string
|
||||
id: string
|
||||
}) {
|
||||
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor)
|
||||
|
||||
if (renderStyle.type === 'pixel') {
|
||||
return (
|
||||
<LivePixelAvatarCanvas
|
||||
className="avatar-preview"
|
||||
scene={scene}
|
||||
colors={colors}
|
||||
style={renderStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const clipId = `live-preview-${id}`
|
||||
const filled = renderStyle.filled === true
|
||||
const outlineWidth = avatarBodyOutlineWidth()
|
||||
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
|
||||
const nodeColor = (nodeId: string | null | undefined) =>
|
||||
inheritBodyColor
|
||||
? colors.body
|
||||
: (bodyNodes.find(node => node.id === nodeId)?.color ?? colors.body)
|
||||
|
||||
return (
|
||||
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<motion.path d={scene.headPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||
{scene.backPaths.map((pathValue, index) => {
|
||||
const color = nodeColor(scene.backNodeIds.current[index])
|
||||
return (
|
||||
<motion.path
|
||||
d={pathValue}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)',
|
||||
stroke: color,
|
||||
strokeWidth: outlineWidth,
|
||||
}}
|
||||
key={`back-${index}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<motion.path
|
||||
d={scene.headPath}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
fill: filled ? colors.body : 'var(--avatar-interior-color, #ffffff)',
|
||||
stroke: colors.body,
|
||||
strokeWidth: outlineWidth,
|
||||
}}
|
||||
/>
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<motion.path
|
||||
d={scene.leftPath}
|
||||
opacity={scene.leftOpacity}
|
||||
style={{ fill: colors.eyes }}
|
||||
/>
|
||||
<motion.path
|
||||
d={scene.rightPath}
|
||||
opacity={scene.rightOpacity}
|
||||
style={{ fill: colors.eyes }}
|
||||
/>
|
||||
</g>
|
||||
{scene.frontPaths.map((pathValue, index) => {
|
||||
const color = nodeColor(scene.frontNodeIds.current[index])
|
||||
return (
|
||||
<motion.path
|
||||
d={pathValue}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)',
|
||||
stroke: color,
|
||||
strokeWidth: outlineWidth,
|
||||
}}
|
||||
key={`front-${index}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</motion.g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@ -56,7 +56,8 @@ const calibrated: number[][] = [
|
||||
[-29.6, 7.5, 10.1, 21.5, 23.2, 32, 33.5, 51.2, -37.4, 0, 0],
|
||||
]
|
||||
|
||||
export const initialExpressions: Expression[] = calibrated.map(
|
||||
export const initialExpressions: Expression[] = [
|
||||
...calibrated.map(
|
||||
(
|
||||
[
|
||||
headX,
|
||||
@ -82,6 +83,8 @@ export const initialExpressions: Expression[] = calibrated.map(
|
||||
widthRight,
|
||||
heightLeft,
|
||||
heightRight,
|
||||
curvatureLeft: 0,
|
||||
curvatureRight: 0,
|
||||
spacing,
|
||||
positionXLeft: 0,
|
||||
positionXRight: 0,
|
||||
@ -90,10 +93,83 @@ export const initialExpressions: Expression[] = calibrated.map(
|
||||
leftAngle,
|
||||
rightAngle,
|
||||
perspective: 1,
|
||||
eyeMotion: 'none',
|
||||
bodyMotion: 'none',
|
||||
eyeMotion: 'none' as const,
|
||||
bodyMotion: 'none' as const,
|
||||
})
|
||||
)
|
||||
),
|
||||
{
|
||||
id: 'expression-3d2bed26-f97c-477d-922f-77600cb10e92',
|
||||
semanticKey: 'angry-brows',
|
||||
headX: 10.473974503042374,
|
||||
headY: 5.087293619785961,
|
||||
headZ: 4.698252132317348,
|
||||
widthLeft: 27.126562499999995,
|
||||
widthRight: 27.126562499999995,
|
||||
heightLeft: 63.028125,
|
||||
heightRight: 63.028125,
|
||||
curvatureLeft: 0,
|
||||
curvatureRight: 0,
|
||||
spacing: 68.7,
|
||||
positionXLeft: 0,
|
||||
positionXRight: 0,
|
||||
positionYLeft: 0,
|
||||
positionYRight: 0,
|
||||
leftAngle: -36.244531249999994,
|
||||
rightAngle: 27.730078125000002,
|
||||
perspective: 1,
|
||||
eyeMotion: 'none' as const,
|
||||
bodyMotion: 'shake' as const,
|
||||
bodyColor: '#ba3636',
|
||||
eyeColor: '#610000',
|
||||
},
|
||||
{
|
||||
id: 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8',
|
||||
semanticKey: 'uneasy-left',
|
||||
headX: -12.303515625,
|
||||
headY: -17.601171875,
|
||||
headZ: 5.9109375,
|
||||
widthLeft: 20.605859374999994,
|
||||
widthRight: 20.605859374999994,
|
||||
heightLeft: 47.769921874999994,
|
||||
heightRight: 47.769921874999994,
|
||||
curvatureLeft: 0,
|
||||
curvatureRight: 0,
|
||||
spacing: 54.9,
|
||||
positionXLeft: 0,
|
||||
positionXRight: 0,
|
||||
positionYLeft: 0,
|
||||
positionYRight: 0,
|
||||
leftAngle: 23.523046875000002,
|
||||
rightAngle: -24.042578125000002,
|
||||
perspective: 1,
|
||||
eyeMotion: 'shake' as const,
|
||||
bodyMotion: 'slowDrift' as const,
|
||||
bodyColor: '#adc3ff',
|
||||
},
|
||||
{
|
||||
id: 'expression-joyful-arc',
|
||||
semanticKey: 'joyful-arc',
|
||||
headX: -8.75234375,
|
||||
headY: -8.743359375,
|
||||
headZ: -10.773828125,
|
||||
widthLeft: 54,
|
||||
widthRight: 54,
|
||||
heightLeft: 15,
|
||||
heightRight: 15,
|
||||
curvatureLeft: 1,
|
||||
curvatureRight: 1,
|
||||
spacing: 69.276171875,
|
||||
positionXLeft: 0,
|
||||
positionXRight: 0,
|
||||
positionYLeft: -18,
|
||||
positionYRight: -18,
|
||||
leftAngle: 0,
|
||||
rightAngle: 0,
|
||||
perspective: 1,
|
||||
eyeMotion: 'none' as const,
|
||||
bodyMotion: 'none' as const,
|
||||
},
|
||||
]
|
||||
|
||||
export const defaultExpression: Expression = {
|
||||
id: 'expression-neutral',
|
||||
@ -104,6 +180,8 @@ export const defaultExpression: Expression = {
|
||||
widthRight: 20,
|
||||
heightLeft: 50,
|
||||
heightRight: 50,
|
||||
curvatureLeft: 0,
|
||||
curvatureRight: 0,
|
||||
spacing: 35,
|
||||
positionXLeft: 0,
|
||||
positionXRight: 0,
|
||||
@ -125,6 +203,7 @@ export const stateGroups = {
|
||||
'angry',
|
||||
'drowsy',
|
||||
'happy',
|
||||
'joyful',
|
||||
'curious',
|
||||
'confused',
|
||||
'bored',
|
||||
@ -149,9 +228,10 @@ export const statePools: Record<string, number[]> = {
|
||||
excited: [2, 17, 21, 3, 11],
|
||||
surprised: [3, 21],
|
||||
suspicious: [14, 5, 23],
|
||||
angry: [7, 16],
|
||||
angry: [7, 25],
|
||||
drowsy: [4, 22, 13],
|
||||
happy: [2, 11, 17, 19],
|
||||
joyful: [11, 27],
|
||||
curious: [3, 21, 0, 15],
|
||||
confused: [14, 5, 8],
|
||||
bored: [4, 22, 0],
|
||||
@ -159,7 +239,7 @@ export const statePools: Record<string, number[]> = {
|
||||
shy: [0, 24, 13],
|
||||
sad: [4, 13, 22],
|
||||
laughing: [2, 11, 17],
|
||||
scared: [3, 21],
|
||||
scared: [3, 26],
|
||||
playful: [2, 17, 11, 8],
|
||||
celebrate: [2, 8, 17],
|
||||
}
|
||||
@ -244,5 +324,6 @@ export const stateNotes: Record<string, string> = {
|
||||
searching: 'Balayage rapide et changements très fréquents.',
|
||||
working: 'Rythme régulier et expressions concentrées.',
|
||||
excited: 'Grandes expressions et transitions rapides.',
|
||||
joyful: 'Morphing fluide entre les expressions 11 et 27.',
|
||||
curious: 'Inclinaisons et forte asymétrie.',
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ const easeProgress = (progress, transition) => transition === 'smooth'
|
||||
? progress * progress * (3 - 2 * progress)
|
||||
: transition === 'snappy'
|
||||
? 1 - (1 - progress) ** 3
|
||||
: transition === 'gentleSpring'
|
||||
? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress))
|
||||
/ (1 - Math.exp(-9) * Math.cos(5))
|
||||
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress);
|
||||
const nearestAngle = (target, current) => {
|
||||
let resolved = target;
|
||||
@ -134,12 +137,13 @@ function mountAvatar(target, options = {}) {
|
||||
const renderElement = pixelStyle ? canvas : svg;
|
||||
host.replaceChildren(renderElement);
|
||||
|
||||
const ensurePaths = (group, paths, fill) => {
|
||||
const ensurePaths = (group, paths, fill, nodeIds, bodyColorOverride) => {
|
||||
while (group.children.length < paths.length) group.append(svgElement('path'));
|
||||
while (group.children.length > paths.length) group.lastElementChild.remove();
|
||||
paths.forEach((path, index) => {
|
||||
group.children[index].setAttribute('d', path);
|
||||
group.children[index].setAttribute('fill', fill);
|
||||
const node = DATA.avatar.bodyNodes.find(candidate => candidate.id === nodeIds[index]);
|
||||
group.children[index].setAttribute('fill', bodyColorOverride ? fill : node?.color || fill);
|
||||
});
|
||||
};
|
||||
let currentAnimation = options.animation && DATA.animations[options.animation] ? options.animation : animationNames[0];
|
||||
@ -200,8 +204,23 @@ function mountAvatar(target, options = {}) {
|
||||
return;
|
||||
}
|
||||
motionLayer.setAttribute('transform', 'translate(' + offset.x + ' ' + offset.y + ')');
|
||||
ensurePaths(backLayer, geometry.backPaths, currentColors.body);
|
||||
ensurePaths(frontLayer, geometry.frontPaths, currentColors.body);
|
||||
const bodyColorOverride = Boolean(
|
||||
currentPose.expression.bodyColor || transitionState?.toPose.expression.bodyColor
|
||||
);
|
||||
ensurePaths(
|
||||
backLayer,
|
||||
geometry.backPaths,
|
||||
currentColors.body,
|
||||
geometry.backNodeIds,
|
||||
bodyColorOverride
|
||||
);
|
||||
ensurePaths(
|
||||
frontLayer,
|
||||
geometry.frontPaths,
|
||||
currentColors.body,
|
||||
geometry.frontNodeIds,
|
||||
bodyColorOverride
|
||||
);
|
||||
head.setAttribute('d', geometry.headPath);
|
||||
head.setAttribute('fill', currentColors.body);
|
||||
clipHead.setAttribute('d', geometry.headPath);
|
||||
|
||||
File diff suppressed because one or more lines are too long
36
src/features/rendering/__tests__/avatar-appearance-test.ts
Normal file
36
src/features/rendering/__tests__/avatar-appearance-test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { migrateBundledAvatarColors } from '@/features/avatar/avatars'
|
||||
import {
|
||||
avatarBodyOutlineWidth,
|
||||
resolveAvatarNodeColor,
|
||||
} from '@/features/rendering/avatarAppearance'
|
||||
|
||||
describe('outlined avatar appearance', () => {
|
||||
it('uses the same fixed body outline width for every avatar', () => {
|
||||
expect(avatarBodyOutlineWidth()).toBe(12)
|
||||
})
|
||||
|
||||
it('migrates bundled avatar colors to Deslop accent tokens', () => {
|
||||
expect(migrateBundledAvatarColors('strobi', { body: '#5b7fe5', eyes: '#111316' })).toEqual({
|
||||
body: '#7c89ff',
|
||||
eyes: '#50d7fe',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves user-customized colors', () => {
|
||||
expect(migrateBundledAvatarColors('strobi', { body: '#123456', eyes: '#654321' })).toEqual({
|
||||
body: '#123456',
|
||||
eyes: '#654321',
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an explicit expression color tint fixed-color body parts', () => {
|
||||
expect(
|
||||
resolveAvatarNodeColor({
|
||||
nodeColor: '#a95f47',
|
||||
bodyColor: '#ba3636',
|
||||
expressionBodyColor: '#ba3636',
|
||||
})
|
||||
).toBe('#ba3636')
|
||||
expect(resolveAvatarNodeColor({ nodeColor: '#a95f47', bodyColor: '#e3b17f' })).toBe('#a95f47')
|
||||
})
|
||||
})
|
||||
@ -9,11 +9,29 @@ import {
|
||||
paintRenderedScene,
|
||||
} from '@/features/rendering/renderedScene'
|
||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
|
||||
import type { BodyNode } from '@/features/avatar/body'
|
||||
import type { Expression } from '@/features/avatar/geometry'
|
||||
import type { SurfaceConfig } from '@/features/avatar/surfaces'
|
||||
|
||||
const pathBounds = (value: string) => {
|
||||
const numbers = (value.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number)
|
||||
const x = numbers.filter((_, index) => index % 2 === 0)
|
||||
const y = numbers.filter((_, index) => index % 2 === 1)
|
||||
return {
|
||||
left: Math.min(...x),
|
||||
top: Math.min(...y),
|
||||
right: Math.max(...x),
|
||||
bottom: Math.max(...y),
|
||||
}
|
||||
}
|
||||
|
||||
const overlap = (first: ReturnType<typeof pathBounds>, second: ReturnType<typeof pathBounds>) => ({
|
||||
x: Math.min(first.right, second.right) - Math.max(first.left, second.left),
|
||||
y: Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top),
|
||||
})
|
||||
|
||||
describe('rendered avatar scene', () => {
|
||||
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
||||
const node = createBodyNode('sphere', 0)
|
||||
@ -68,5 +86,85 @@ describe('rendered avatar scene', () => {
|
||||
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||
)
|
||||
expect(clearlyTurned.frontNodeIds).toContain('shape-d4b4e8ad-8625-488d-920c-c497da226f9f')
|
||||
|
||||
const forcedFront = renderAvatar(
|
||||
poseFromExpression({ ...expression, headY: 35 }),
|
||||
avatar.body.primary as SurfaceConfig,
|
||||
1,
|
||||
{
|
||||
bodyNodes: [{ ...(avatar.body.nodes[0] as unknown as BodyNode), layer: 'front' }],
|
||||
}
|
||||
)
|
||||
expect(forcedFront.frontNodeIds).toContain(avatar.body.nodes[0].id)
|
||||
})
|
||||
|
||||
it('anchors dog ears in front of the head across expressions', () => {
|
||||
const studio = loadStudioDocument({ getItem: () => null })
|
||||
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-dog')!
|
||||
const detachedEars: string[] = []
|
||||
|
||||
const neutral = renderAvatar(
|
||||
poseFromExpression(defaultExpression),
|
||||
avatar.body.primary as SurfaceConfig,
|
||||
1,
|
||||
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||
)
|
||||
expect(neutral.frontNodeIds).toEqual(['dog-ear-left', 'dog-ear-right'])
|
||||
|
||||
studio.expressions.forEach(expression => {
|
||||
const geometry = renderAvatar(
|
||||
poseFromExpression(expression as Expression),
|
||||
avatar.body.primary as SurfaceConfig,
|
||||
1,
|
||||
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||
)
|
||||
const head = pathBounds(geometry.headPath)
|
||||
|
||||
const earIds = ['dog-ear-left', 'dog-ear-right'] as const
|
||||
earIds.forEach(nodeId => {
|
||||
const frontIndex = geometry.frontNodeIds.indexOf(nodeId)
|
||||
expect(geometry.backNodeIds).not.toContain(nodeId)
|
||||
expect(frontIndex).toBeGreaterThanOrEqual(0)
|
||||
const earPath = geometry.frontPaths[frontIndex]
|
||||
const ear = pathBounds(earPath)
|
||||
const headOverlap = overlap(ear, head)
|
||||
if (headOverlap.x <= 0 || headOverlap.y <= 0) {
|
||||
detachedEars.push(
|
||||
`${expression.id}/${nodeId} (${headOverlap.x.toFixed(2)} × ${headOverlap.y.toFixed(2)})`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
expect(detachedEars, 'detached dog ears').toEqual([])
|
||||
})
|
||||
|
||||
it('keeps bear ears attached behind the head across expressions', () => {
|
||||
const studio = loadStudioDocument({ getItem: () => null })
|
||||
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-bear')!
|
||||
const detachedEars: string[] = []
|
||||
|
||||
studio.expressions.forEach(expression => {
|
||||
const geometry = renderAvatar(
|
||||
poseFromExpression(expression as Expression),
|
||||
avatar.body.primary as SurfaceConfig,
|
||||
1,
|
||||
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||
)
|
||||
const head = pathBounds(geometry.headPath)
|
||||
|
||||
;(['bear-ear-left', 'bear-ear-right'] as const).forEach(nodeId => {
|
||||
const earIndex = geometry.backNodeIds.indexOf(nodeId)
|
||||
expect(earIndex).toBeGreaterThanOrEqual(0)
|
||||
const ear = pathBounds(geometry.backPaths[earIndex])
|
||||
const headOverlap = overlap(ear, head)
|
||||
if (headOverlap.x <= 0 || headOverlap.y <= 0) {
|
||||
detachedEars.push(expression.id + '/' + nodeId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(detachedEars, 'detached bear ears').toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
37
src/features/rendering/avatarAppearance.ts
Normal file
37
src/features/rendering/avatarAppearance.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { useMotionValueEvent, type MotionValue } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const avatarBodyOutlineWidth = () => 12
|
||||
|
||||
const colorsMatch = (left: string, right: string) =>
|
||||
left.trim().toLowerCase() === right.trim().toLowerCase()
|
||||
|
||||
export const useAvatarBodyColorOverride = (
|
||||
renderedBodyColor: MotionValue<string>,
|
||||
baseBodyColor: string
|
||||
) => {
|
||||
const [overridden, setOverridden] = useState(
|
||||
() => !colorsMatch(renderedBodyColor.get(), baseBodyColor)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setOverridden(!colorsMatch(renderedBodyColor.get(), baseBodyColor))
|
||||
}, [baseBodyColor, renderedBodyColor])
|
||||
|
||||
useMotionValueEvent(renderedBodyColor, 'change', latest => {
|
||||
const next = !colorsMatch(latest, baseBodyColor)
|
||||
setOverridden(current => (current === next ? current : next))
|
||||
})
|
||||
|
||||
return overridden
|
||||
}
|
||||
|
||||
export const resolveAvatarNodeColor = ({
|
||||
nodeColor,
|
||||
bodyColor,
|
||||
expressionBodyColor,
|
||||
}: {
|
||||
nodeColor?: string
|
||||
bodyColor: string
|
||||
expressionBodyColor?: string
|
||||
}) => expressionBodyColor ?? nodeColor ?? bodyColor
|
||||
@ -1,6 +1,6 @@
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStudioLanguage } from '@/i18n'
|
||||
@ -496,8 +496,10 @@ export function AvatarCanvas({
|
||||
expression,
|
||||
avatarEyes,
|
||||
surface,
|
||||
bodyNodes,
|
||||
scene,
|
||||
colors,
|
||||
expressionBodyColor,
|
||||
renderStyle,
|
||||
rotationGizmo,
|
||||
showWire,
|
||||
@ -522,8 +524,10 @@ export function AvatarCanvas({
|
||||
expression: Expression
|
||||
avatarEyes: AvatarEyeDefaults
|
||||
surface: SurfaceConfig
|
||||
bodyNodes: BodyNode[]
|
||||
scene: RenderedScene
|
||||
colors: RenderedColors
|
||||
expressionBodyColor?: string
|
||||
renderStyle: AvatarRenderStyle
|
||||
rotationGizmo: RenderedRotationGizmo
|
||||
showWire: boolean
|
||||
@ -593,6 +597,11 @@ export function AvatarCanvas({
|
||||
if (!bodyEditing || !selectedBodyNodeId) return null
|
||||
return findBodyNodePath(scene, selectedBodyNodeId)
|
||||
})()
|
||||
const nodeColorStyle = (id: string | null | undefined) => {
|
||||
if (expressionBodyColor ?? expression.bodyColor) return undefined
|
||||
const color = bodyNodes.find(node => node.id === id)?.color
|
||||
return color ? ({ '--avatar-node-color': color } as CSSProperties) : undefined
|
||||
}
|
||||
|
||||
const toSvg = (event: React.PointerEvent<SVGElement>): readonly [number, number] => {
|
||||
const rectangle = svgRef.current!.getBoundingClientRect()
|
||||
@ -774,7 +783,9 @@ export function AvatarCanvas({
|
||||
}
|
||||
useEscapeToCancel(cancelDrag)
|
||||
return (
|
||||
<div className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}`}>
|
||||
<div
|
||||
className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}${renderStyle.type === 'vector' && renderStyle.filled ? ' is-filled-vector' : ''}`}
|
||||
>
|
||||
{playback && (
|
||||
<motion.div
|
||||
className="stage-playback-status"
|
||||
@ -814,6 +825,7 @@ export function AvatarCanvas({
|
||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||
d={pathValue}
|
||||
key={index}
|
||||
style={nodeColorStyle(backNodeIds.current[index])}
|
||||
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
||||
/>
|
||||
))}
|
||||
@ -848,6 +860,7 @@ export function AvatarCanvas({
|
||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||
d={pathValue}
|
||||
key={index}
|
||||
style={nodeColorStyle(frontNodeIds.current[index])}
|
||||
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -28,13 +28,19 @@ const documentFixture = (): StudioDocument => {
|
||||
|
||||
describe('Studio document', () => {
|
||||
const storage = (value: string | null = null) => ({ getItem: () => value })
|
||||
|
||||
it('loads the bundled Studio snapshot when no local project exists', () => {
|
||||
const document = loadStudioDocument(storage())
|
||||
|
||||
expect(document.library.avatars).toHaveLength(12)
|
||||
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
|
||||
const bundledAvatarNames = [
|
||||
'Memento',
|
||||
'Apple Bite',
|
||||
'Siamese',
|
||||
'British Shorthair',
|
||||
'Russian Blue',
|
||||
'Orange Tabby',
|
||||
'Black Cat',
|
||||
'Dog',
|
||||
'Brown Dog',
|
||||
'Bear',
|
||||
'Rabbit',
|
||||
'Bun',
|
||||
'Onee',
|
||||
'Cubee',
|
||||
'Nova',
|
||||
@ -45,25 +51,165 @@ describe('Studio document', () => {
|
||||
'Cylinder',
|
||||
'Cone',
|
||||
'Diamond',
|
||||
'Memento',
|
||||
'Willy',
|
||||
'OneWorks Sphere',
|
||||
'OneWorks Ellipse',
|
||||
'OneWorks Square',
|
||||
'OneWorks Rounded',
|
||||
'OneWorks Capsule',
|
||||
'OneWorks Teardrop',
|
||||
'OneWorks Diamond',
|
||||
'OneWorks Trapezoid',
|
||||
'OneWorks Cone',
|
||||
'OneWorks Frustum',
|
||||
'OneWorks Half Cone',
|
||||
'OneWorks Cloud',
|
||||
'OneWorks Sun',
|
||||
]
|
||||
|
||||
it('loads the bundled Studio snapshot when no local project exists', () => {
|
||||
const document = loadStudioDocument(storage())
|
||||
|
||||
expect(document.library.avatars).toHaveLength(bundledAvatarNames.length)
|
||||
expect(document.library.activeAvatarId).toBe('avatar-4b9ea0c1-286f-4aa1-b053-61fcc416ba7e')
|
||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames)
|
||||
expect(
|
||||
document.library.avatars
|
||||
.filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
|
||||
.every(avatar => avatar.colors.body === avatar.colors.eyes)
|
||||
).toBe(true)
|
||||
expect(document.library.avatars.slice(0, 12).map(avatar => avatar.body.primary.type)).toEqual([
|
||||
'ghost',
|
||||
'apple-bite',
|
||||
'ellipse',
|
||||
'ellipse',
|
||||
'ellipse',
|
||||
'ellipse',
|
||||
'ellipse',
|
||||
'trapezoid',
|
||||
'trapezoid',
|
||||
'trapezoid',
|
||||
'trapezoid',
|
||||
'sphere',
|
||||
])
|
||||
expect(document.library.avatars.some(avatar => avatar.id === 'memento-piglet')).toBe(false)
|
||||
expect(document.library.avatars.some(avatar => avatar.id === 'oneworks-cat')).toBe(false)
|
||||
expect(document.library.avatars.some(avatar => avatar.id === 'oneworks-cat-cow')).toBe(false)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-willy')?.body.nodes
|
||||
).toHaveLength(3)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'oneworks-cloud')?.body.nodes
|
||||
).toHaveLength(7)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'oneworks-sun')?.body.nodes
|
||||
).toHaveLength(8)
|
||||
expect(
|
||||
document.library.avatars
|
||||
.filter(avatar =>
|
||||
['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some(
|
||||
id => avatar.id === `oneworks-${id}`
|
||||
)
|
||||
)
|
||||
.every(avatar => avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true)
|
||||
).toBe(true)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.renderStyle
|
||||
).toEqual({
|
||||
type: 'vector',
|
||||
filled: true,
|
||||
})
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle
|
||||
).toEqual({ type: 'vector', filled: true })
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.body.primary
|
||||
).toMatchObject({ width: 225, height: 220.5, depth: 171 })
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.body.primary
|
||||
).toMatchObject({ width: 216, height: 225, depth: 171 })
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes
|
||||
.positionXLeft
|
||||
).toBe(defaultAvatarEyes.positionXLeft - 18)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes
|
||||
.positionXRight
|
||||
).toBe(defaultAvatarEyes.positionXRight - 18)
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'oneworks-cat-siamese')?.colors
|
||||
).toEqual({ body: '#ead7b8', eyes: '#281913' })
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'oneworks-cat-black')?.colors
|
||||
).toEqual({ body: '#111419', eyes: '#eef2f5' })
|
||||
expect(
|
||||
document.library.avatars
|
||||
.find(avatar => avatar.id === 'oneworks-cat-siamese')
|
||||
?.body.nodes.map(node => node.color)
|
||||
).toEqual(['#3c2118', '#3c2118'])
|
||||
expect(document.library.avatars.find(avatar => avatar.id === 'oneworks-dog')?.colors).toEqual({
|
||||
body: '#d4d0c8',
|
||||
eyes: '#211f1d',
|
||||
})
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'oneworks-dog-brown')?.colors
|
||||
).toEqual({
|
||||
body: '#e3b17f',
|
||||
eyes: '#2b1d18',
|
||||
})
|
||||
expect(document.library.avatars.find(avatar => avatar.id === 'oneworks-bear')?.colors).toEqual({
|
||||
body: '#a95f47',
|
||||
eyes: '#2b1d18',
|
||||
})
|
||||
expect(
|
||||
document.library.avatars
|
||||
.find(avatar => avatar.id === 'oneworks-dog')
|
||||
?.body.nodes.map(node => node.color)
|
||||
).toEqual(['#be9675', '#be9675'])
|
||||
expect(
|
||||
document.library.avatars
|
||||
.find(avatar => avatar.id === 'oneworks-dog-brown')
|
||||
?.body.nodes.map(node => node.color)
|
||||
).toEqual(['#a95f47', '#a95f47'])
|
||||
expect(
|
||||
document.library.avatars
|
||||
.filter(avatar =>
|
||||
['oneworks-dog', 'oneworks-dog-brown', 'oneworks-bear'].includes(avatar.id)
|
||||
)
|
||||
.map(avatar => ({
|
||||
id: avatar.id,
|
||||
hasBehavior: Boolean(avatar.behavior),
|
||||
coloredExpressions:
|
||||
avatar.behavior?.expressions.filter(
|
||||
expression => expression.bodyColor || expression.eyeColor
|
||||
).length ?? -1,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'oneworks-dog', hasBehavior: true, coloredExpressions: 4 },
|
||||
{ id: 'oneworks-dog-brown', hasBehavior: true, coloredExpressions: 4 },
|
||||
{ id: 'oneworks-bear', hasBehavior: true, coloredExpressions: 4 },
|
||||
])
|
||||
expect(
|
||||
document.library.avatars.every(avatar => avatar.colors.body === avatar.colors.eyes)
|
||||
).toBe(true)
|
||||
expect(document.library.avatars.slice(4).map(avatar => avatar.body.primary.type)).toEqual([
|
||||
'sphere',
|
||||
'cube',
|
||||
'capsule',
|
||||
'cylinder',
|
||||
'cone',
|
||||
'diamond',
|
||||
'ghost',
|
||||
'capsule',
|
||||
])
|
||||
expect(document.library.avatars.at(-1)?.body.nodes).toHaveLength(3)
|
||||
document.library.avatars
|
||||
.find(avatar => avatar.id === 'oneworks-bear')
|
||||
?.body.nodes.map(node => node.color)
|
||||
).toEqual(['#a95f47', '#a95f47'])
|
||||
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
|
||||
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
|
||||
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#111316' })
|
||||
expect(
|
||||
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors
|
||||
).toEqual({ body: '#ff5558', eyes: '#111316' })
|
||||
expect(
|
||||
document.library.avatars
|
||||
.find(avatar => avatar.id === 'primitive-apple-bite')
|
||||
?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor)
|
||||
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
|
||||
).toEqual([
|
||||
['angry-brows', '#ba3636', '#610000'],
|
||||
['uneasy-left', '#adc3ff', undefined],
|
||||
['scared-state-surprised-left', '#adc3ff', undefined],
|
||||
['scared-state-uneasy-left', '#adc3ff', undefined],
|
||||
])
|
||||
expect(
|
||||
document.library.avatars
|
||||
.filter(avatar => avatar.id !== 'primitive-ghost')
|
||||
@ -72,12 +218,20 @@ describe('Studio document', () => {
|
||||
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
|
||||
expect(memento?.eyes.positionYRight).toBe(defaultAvatarEyes.positionYRight - 20)
|
||||
expect(
|
||||
memento?.behavior?.expressions.every(
|
||||
expression => !expression.bodyColor && !expression.eyeColor
|
||||
)
|
||||
memento?.behavior?.expressions.find(
|
||||
expression => expression.semanticKey === 'onboarding-curious'
|
||||
)?.positionYLeft
|
||||
).toBe(-22)
|
||||
expect(
|
||||
memento?.behavior?.sequences.some(sequence => sequence.semanticKey === 'onboarding')
|
||||
).toBe(true)
|
||||
expect(document.expressions).toHaveLength(27)
|
||||
expect(document.sequences).toHaveLength(23)
|
||||
expect(
|
||||
memento?.behavior?.expressions
|
||||
.filter(expression => expression.bodyColor || expression.eyeColor)
|
||||
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
|
||||
).toEqual([['angry-brows', '#ba3636', '#610000']])
|
||||
expect(document.expressions).toHaveLength(28)
|
||||
expect(document.sequences).toHaveLength(24)
|
||||
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
|
||||
expect(
|
||||
document.sequences
|
||||
@ -90,11 +244,40 @@ describe('Studio document', () => {
|
||||
.find(sequence => sequence.id === 'scared')
|
||||
?.steps.map(step => step.expressionId)
|
||||
).toEqual(['expression-03', 'expression-5220eaee-32fe-4bd8-ad31-432189534cc8'])
|
||||
expect(
|
||||
document.sequences
|
||||
.find(sequence => sequence.id === 'joyful')
|
||||
?.steps.map(step => [step.expressionId, step.transitionMs, step.transition])
|
||||
).toEqual([
|
||||
['expression-11', 700, 'gentleSpring'],
|
||||
['expression-joyful-arc', 700, 'gentleSpring'],
|
||||
])
|
||||
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
|
||||
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
|
||||
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
|
||||
})
|
||||
|
||||
it('exports every imported OneWorks skin as a valid avatar definition', () => {
|
||||
const document = loadStudioDocument(storage())
|
||||
const importedAvatars = document.library.avatars.filter(avatar =>
|
||||
avatar.id.startsWith('oneworks-')
|
||||
)
|
||||
|
||||
expect(importedAvatars).toHaveLength(23)
|
||||
importedAvatars.forEach(avatar => {
|
||||
const result = createAvatarDefinition({
|
||||
avatar,
|
||||
behavior: avatar.behavior ?? {
|
||||
expressions: document.expressions,
|
||||
sequences: document.sequences,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.ok, avatar.name).toBe(true)
|
||||
if (result.ok) expect(JSON.stringify(result.value)).not.toContain('NaN')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears only the persisted Studio project', () => {
|
||||
const removeItem = vi.fn()
|
||||
|
||||
@ -108,6 +291,122 @@ describe('Studio document', () => {
|
||||
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
||||
})
|
||||
|
||||
it('migrates previously saved bundled filled avatars to the filled treatment', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const filledIds = new Set([
|
||||
'primitive-ghost',
|
||||
'primitive-apple-bite',
|
||||
...fallback.library.avatars
|
||||
.filter(avatar => avatar.id.startsWith('oneworks-'))
|
||||
.map(avatar => avatar.id),
|
||||
])
|
||||
const legacy = {
|
||||
...fallback,
|
||||
library: {
|
||||
...fallback.library,
|
||||
avatars: fallback.library.avatars.map(avatar =>
|
||||
filledIds.has(avatar.id)
|
||||
? {
|
||||
...avatar,
|
||||
renderStyle: { type: 'vector' },
|
||||
eyes:
|
||||
avatar.id === 'primitive-apple-bite'
|
||||
? { ...avatar.eyes, positionXLeft: 0, positionXRight: 0 }
|
||||
: avatar.eyes,
|
||||
body: {
|
||||
...avatar.body,
|
||||
nodes: avatar.body.nodes.map(node => ({ ...node, color: undefined })),
|
||||
},
|
||||
}
|
||||
: avatar
|
||||
),
|
||||
},
|
||||
} satisfies StudioDocument
|
||||
|
||||
const restored = loadStudioDocument(storage(JSON.stringify(legacy)))
|
||||
const restoredById = new Map(restored.library.avatars.map(avatar => [avatar.id, avatar]))
|
||||
|
||||
;['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].forEach(id => {
|
||||
expect(restoredById.get(`oneworks-${id}`)?.renderStyle).toEqual({
|
||||
type: 'vector',
|
||||
filled: true,
|
||||
})
|
||||
})
|
||||
expect(restoredById.get('primitive-ghost')?.renderStyle).toEqual({
|
||||
type: 'vector',
|
||||
filled: true,
|
||||
})
|
||||
expect(restoredById.get('primitive-apple-bite')?.renderStyle).toEqual({
|
||||
type: 'vector',
|
||||
filled: true,
|
||||
})
|
||||
expect(restoredById.get('primitive-ghost')?.colors).toEqual({
|
||||
body: '#ff9d45',
|
||||
eyes: '#111316',
|
||||
})
|
||||
expect(restoredById.get('primitive-apple-bite')?.colors).toEqual({
|
||||
body: '#ff5558',
|
||||
eyes: '#111316',
|
||||
})
|
||||
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXLeft).toBe(-18)
|
||||
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXRight).toBe(-18)
|
||||
expect(
|
||||
restoredById
|
||||
.get('primitive-apple-bite')
|
||||
?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor)
|
||||
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
|
||||
).toEqual([
|
||||
['angry-brows', '#ba3636', '#610000'],
|
||||
['uneasy-left', '#adc3ff', undefined],
|
||||
['scared-state-surprised-left', '#adc3ff', undefined],
|
||||
['scared-state-uneasy-left', '#adc3ff', undefined],
|
||||
])
|
||||
expect(restoredById.get('oneworks-rabbit')?.colors).toEqual({
|
||||
body: '#eee9df',
|
||||
eyes: '#292724',
|
||||
})
|
||||
expect(restoredById.get('oneworks-bun')?.colors).toEqual({
|
||||
body: '#fff3d9',
|
||||
eyes: '#241915',
|
||||
})
|
||||
expect(restoredById.get('oneworks-dog')?.colors).toEqual({
|
||||
body: '#d4d0c8',
|
||||
eyes: '#211f1d',
|
||||
})
|
||||
expect(restoredById.get('oneworks-dog')?.body.nodes.map(node => node.color)).toEqual([
|
||||
'#be9675',
|
||||
'#be9675',
|
||||
])
|
||||
expect(restoredById.get('oneworks-dog-brown')?.colors).toEqual({
|
||||
body: '#e3b17f',
|
||||
eyes: '#2b1d18',
|
||||
})
|
||||
expect(restoredById.get('oneworks-dog-brown')?.body.nodes.map(node => node.color)).toEqual([
|
||||
'#a95f47',
|
||||
'#a95f47',
|
||||
])
|
||||
expect(restoredById.get('oneworks-bear')?.colors).toEqual({
|
||||
body: '#a95f47',
|
||||
eyes: '#2b1d18',
|
||||
})
|
||||
expect(
|
||||
restoredById
|
||||
.get('oneworks-dog-brown')
|
||||
?.behavior?.expressions.filter(expression => expression.bodyColor || expression.eyeColor)
|
||||
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
|
||||
).toEqual([
|
||||
['angry-brows', '#ba3636', '#610000'],
|
||||
['uneasy-left', '#adc3ff', undefined],
|
||||
['scared-state-surprised-left', '#adc3ff', undefined],
|
||||
['scared-state-uneasy-left', '#adc3ff', undefined],
|
||||
])
|
||||
expect(restoredById.get('oneworks-bear')?.body.nodes.map(node => node.color)).toEqual([
|
||||
'#a95f47',
|
||||
'#a95f47',
|
||||
])
|
||||
expect(restoredById.get('oneworks-sun')?.renderStyle).toEqual({ type: 'vector' })
|
||||
})
|
||||
|
||||
it('restores standalone built-in animations from the temporary idle pairs', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const pairedDocument: StudioDocument = {
|
||||
@ -133,34 +432,94 @@ describe('Studio document', () => {
|
||||
expect(restored.playback).toEqual({ stateId: 'proud', playing: true })
|
||||
})
|
||||
|
||||
it('adds the joyful arc expression to an existing local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const localDocument = {
|
||||
...fallback,
|
||||
expressions: fallback.expressions.filter(
|
||||
expression => expression.semanticKey !== 'joyful-arc'
|
||||
),
|
||||
}
|
||||
|
||||
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||
|
||||
expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc')
|
||||
})
|
||||
|
||||
it('adds the joyful animation to an existing local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const localDocument = {
|
||||
...fallback,
|
||||
sequences: fallback.sequences.filter(sequence => sequence.id !== 'joyful'),
|
||||
}
|
||||
|
||||
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||
const joyful = restored.sequences.find(sequence => sequence.id === 'joyful')
|
||||
|
||||
expect(joyful?.steps.map(step => step.expressionId)).toEqual([
|
||||
'expression-11',
|
||||
'expression-joyful-arc',
|
||||
])
|
||||
expect(joyful?.blink.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('updates the bundled joyful arc thickness in an existing local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacyExpressions = fallback.expressions.map(expression =>
|
||||
expression.semanticKey === 'joyful-arc'
|
||||
? { ...expression, heightLeft: 11, heightRight: 11 }
|
||||
: expression
|
||||
)
|
||||
const localDocument = {
|
||||
...fallback,
|
||||
expressions: legacyExpressions,
|
||||
library: {
|
||||
...fallback.library,
|
||||
avatars: fallback.library.avatars.map((avatar, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...avatar,
|
||||
behavior: { expressions: legacyExpressions, sequences: fallback.sequences },
|
||||
}
|
||||
: avatar
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||
const joyfulArc = restored.expressions.find(
|
||||
expression => expression.semanticKey === 'joyful-arc'
|
||||
)
|
||||
|
||||
expect(joyfulArc?.heightLeft).toBe(15)
|
||||
expect(joyfulArc?.heightRight).toBe(15)
|
||||
const avatarJoyfulArc = restored.library.avatars[0].behavior?.expressions.find(
|
||||
expression => expression.semanticKey === 'joyful-arc'
|
||||
)
|
||||
expect(avatarJoyfulArc?.heightLeft).toBe(15)
|
||||
expect(avatarJoyfulArc?.heightRight).toBe(15)
|
||||
expect(
|
||||
restored.library.avatars[0].behavior?.sequences.some(sequence => sequence.id === 'joyful')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('removes retired bundled avatars from a locally saved project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const retired = createAvatar('Freddy')
|
||||
retired.id = 'avatar-4fe2d1bd-cf46-4e5e-a62d-d6b60be519ed'
|
||||
const retiredFox = createAvatar('Продовый песец')
|
||||
retiredFox.id = 'memento-arctic-fox'
|
||||
const localDocument: StudioDocument = {
|
||||
...fallback,
|
||||
library: {
|
||||
activeAvatarId: retired.id,
|
||||
avatars: [retired, ...fallback.library.avatars],
|
||||
activeAvatarId: retiredFox.id,
|
||||
avatars: [retired, retiredFox, ...fallback.library.avatars],
|
||||
},
|
||||
}
|
||||
|
||||
const document = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||
|
||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual([
|
||||
'Onee',
|
||||
'Cubee',
|
||||
'Nova',
|
||||
'Citrus',
|
||||
'Sphere',
|
||||
'Cube',
|
||||
'Capsule',
|
||||
'Cylinder',
|
||||
'Cone',
|
||||
'Diamond',
|
||||
'Memento',
|
||||
'Willy',
|
||||
])
|
||||
expect(document.library.avatars.map(avatar => avatar.name)).toEqual(bundledAvatarNames)
|
||||
expect(document.library.activeAvatarId).toBe(document.library.avatars[0].id)
|
||||
})
|
||||
|
||||
@ -183,6 +542,35 @@ describe('Studio document', () => {
|
||||
).toBe(defaultAvatarEyes.positionYLeft - 20)
|
||||
})
|
||||
|
||||
it('simplifies bundled animal names in an existing local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacyNames: Record<string, string> = {
|
||||
'oneworks-dog': 'OneWorks Dog',
|
||||
'oneworks-bear': 'OneWorks Bear',
|
||||
'oneworks-rabbit': 'OneWorks Rabbit',
|
||||
'oneworks-bun': 'OneWorks Bun',
|
||||
}
|
||||
const legacy = {
|
||||
...fallback,
|
||||
library: {
|
||||
...fallback.library,
|
||||
avatars: fallback.library.avatars.map(avatar => ({
|
||||
...avatar,
|
||||
name: legacyNames[avatar.id] ?? avatar.name,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
const restored = loadStudioDocument(storage(JSON.stringify(legacy)))
|
||||
const restoredById = new Map(restored.library.avatars.map(avatar => [avatar.id, avatar]))
|
||||
|
||||
expect(restoredById.has('oneworks-cat')).toBe(false)
|
||||
expect(restoredById.get('oneworks-dog')?.name).toBe('Dog')
|
||||
expect(restoredById.get('oneworks-bear')?.name).toBe('Bear')
|
||||
expect(restoredById.get('oneworks-rabbit')?.name).toBe('Rabbit')
|
||||
expect(restoredById.get('oneworks-bun')?.name).toBe('Bun')
|
||||
})
|
||||
|
||||
it('restores bundled semantic keys in a legacy local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacy = structuredClone(fallback)
|
||||
|
||||
@ -12,8 +12,115 @@ import {
|
||||
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import {
|
||||
avatarBodyOutlineWidth,
|
||||
useAvatarBodyColorOverride,
|
||||
} from '@/features/rendering/avatarAppearance'
|
||||
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
||||
import type { StudioController } from '@/features/studio/useStudioController'
|
||||
|
||||
function LiveAvatarPreview({
|
||||
avatar,
|
||||
colors,
|
||||
scene,
|
||||
expressionBodyColor,
|
||||
}: {
|
||||
avatar: StudioController['activeAvatar']
|
||||
colors: RenderedColors
|
||||
scene: RenderedScene
|
||||
expressionBodyColor?: string
|
||||
}) {
|
||||
const outlineWidth = avatarBodyOutlineWidth()
|
||||
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
|
||||
const fixedSkinColors =
|
||||
avatar.behavior?.expressions.every(
|
||||
expression => !expression.bodyColor && !expression.eyeColor
|
||||
) ?? false
|
||||
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, avatar.colors.body)
|
||||
const bodyColor = fixedSkinColors && !bodyColorIsOverridden ? avatar.colors.body : colors.body
|
||||
const eyeColor = fixedSkinColors ? avatar.colors.eyes : colors.eyes
|
||||
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
|
||||
const nodeColor = (id: string | null | undefined) =>
|
||||
inheritBodyColor
|
||||
? bodyColor
|
||||
: (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor)
|
||||
const bodyFill = filled ? bodyColor : '#ffffff'
|
||||
const clipId = `live-avatar-${avatar.id}`
|
||||
|
||||
if (avatar.renderStyle.type === 'pixel') {
|
||||
return (
|
||||
<LivePixelAvatarCanvas
|
||||
className="avatar-preview avatar-preview-live"
|
||||
scene={scene}
|
||||
colors={colors}
|
||||
style={avatar.renderStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="avatar-preview avatar-preview-live"
|
||||
viewBox="-150 -150 300 300"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<motion.path d={scene.headPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||
{scene.backPaths.map((pathValue, index) => {
|
||||
const color = nodeColor(scene.backNodeIds.current[index])
|
||||
return (
|
||||
<motion.path
|
||||
className="preview-head"
|
||||
d={pathValue}
|
||||
key={`back-${index}`}
|
||||
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<motion.path
|
||||
className="preview-head"
|
||||
d={scene.headPath}
|
||||
style={{
|
||||
fill: bodyFill,
|
||||
stroke: bodyColor,
|
||||
strokeWidth: outlineWidth,
|
||||
}}
|
||||
/>
|
||||
<g clipPath={`url(#${clipId})`}>
|
||||
<motion.path
|
||||
className="preview-eye"
|
||||
d={scene.leftPath}
|
||||
opacity={scene.leftOpacity}
|
||||
style={{ fill: eyeColor }}
|
||||
/>
|
||||
<motion.path
|
||||
className="preview-eye"
|
||||
d={scene.rightPath}
|
||||
opacity={scene.rightOpacity}
|
||||
style={{ fill: eyeColor }}
|
||||
/>
|
||||
</g>
|
||||
{scene.frontPaths.map((pathValue, index) => {
|
||||
const color = nodeColor(scene.frontNodeIds.current[index])
|
||||
return (
|
||||
<motion.path
|
||||
className="preview-head"
|
||||
d={pathValue}
|
||||
key={`front-${index}`}
|
||||
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</motion.g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||
const {
|
||||
activateAvatar,
|
||||
@ -29,8 +136,12 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||
draggingAvatarId,
|
||||
duplicateAvatar,
|
||||
expressions,
|
||||
playbackStatus,
|
||||
previewAvatarMove,
|
||||
reduceMotion,
|
||||
renderedColors,
|
||||
renderedExpressionBodyColor,
|
||||
renderedScene,
|
||||
setDeleteAvatarOpen,
|
||||
setDraggingAvatarId,
|
||||
setFocusAvatarName,
|
||||
@ -93,6 +204,14 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||
activateAvatar(avatar.id, true)
|
||||
}}
|
||||
>
|
||||
{activeAvatarId === avatar.id && playbackStatus !== 'stopped' ? (
|
||||
<LiveAvatarPreview
|
||||
avatar={avatar}
|
||||
colors={renderedColors}
|
||||
scene={renderedScene}
|
||||
expressionBodyColor={renderedExpressionBodyColor}
|
||||
/>
|
||||
) : (
|
||||
<ExpressionPreview
|
||||
expression={expressions[0] ?? defaultExpression}
|
||||
surface={avatar.body.primary}
|
||||
@ -102,6 +221,7 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
||||
renderStyle={avatar.renderStyle}
|
||||
id={`avatar-${avatar.id}`}
|
||||
/>
|
||||
)}
|
||||
<span>{avatar.name}</span>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@ -176,7 +176,11 @@ export function BodyConstructionAccordion({
|
||||
unit="u"
|
||||
onChange={depth => updateSurface({ ...surface, depth })}
|
||||
/>
|
||||
{(surface.type === 'cube' || surface.type === 'diamond') && (
|
||||
{(surface.type === 'cube' ||
|
||||
surface.type === 'diamond' ||
|
||||
surface.type === 'trapezoid' ||
|
||||
surface.type === 'frustum' ||
|
||||
surface.type === 'half-cone') && (
|
||||
<NumericField
|
||||
label="Rondeur"
|
||||
value={surface.roundness}
|
||||
@ -231,6 +235,29 @@ export function BodyConstructionAccordion({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{surface.type === 'trapezoid' && (
|
||||
<NumericField
|
||||
label="Échelle du sommet"
|
||||
value={surface.topScale ?? 0.82}
|
||||
min={0.05}
|
||||
max={2}
|
||||
step={0.01}
|
||||
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
|
||||
onChange={topScale => updateSurface({ ...surface, topScale })}
|
||||
/>
|
||||
)}
|
||||
{surface.type === 'half-cone' && (
|
||||
<NumericField
|
||||
label="Angle de coupe"
|
||||
value={surface.cutAngle ?? 0}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onActiveChange={active => controller.updateHighlight(active ? 'head' : null)}
|
||||
onChange={cutAngle => updateSurface({ ...surface, cutAngle })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@ -320,7 +347,10 @@ export function BodyConstructionAccordion({
|
||||
))}
|
||||
{(selectedBodyNode.surface.type === 'cube' ||
|
||||
selectedBodyNode.surface.type === 'diamond' ||
|
||||
selectedBodyNode.surface.type === 'cylinder') && (
|
||||
selectedBodyNode.surface.type === 'cylinder' ||
|
||||
selectedBodyNode.surface.type === 'trapezoid' ||
|
||||
selectedBodyNode.surface.type === 'frustum' ||
|
||||
selectedBodyNode.surface.type === 'half-cone') && (
|
||||
<NumericField
|
||||
label="Rondeur"
|
||||
value={selectedBodyNode.surface.roundness}
|
||||
@ -381,6 +411,37 @@ export function BodyConstructionAccordion({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{selectedBodyNode.surface.type === 'trapezoid' && (
|
||||
<NumericField
|
||||
label="Échelle du sommet"
|
||||
value={selectedBodyNode.surface.topScale ?? 0.82}
|
||||
min={0.05}
|
||||
max={2}
|
||||
step={0.01}
|
||||
onChange={topScale =>
|
||||
updateSelectedBodyNode(currentNode => ({
|
||||
...currentNode,
|
||||
surface: { ...currentNode.surface, topScale },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{selectedBodyNode.surface.type === 'half-cone' && (
|
||||
<NumericField
|
||||
label="Angle de coupe"
|
||||
value={selectedBodyNode.surface.cutAngle ?? 0}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
unit="°"
|
||||
onChange={cutAngle =>
|
||||
updateSelectedBodyNode(currentNode => ({
|
||||
...currentNode,
|
||||
surface: { ...currentNode.surface, cutAngle },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="body-transform-grid">
|
||||
<div>
|
||||
|
||||
@ -72,6 +72,7 @@ import {
|
||||
ExpressionCard,
|
||||
ExpressionPreview,
|
||||
ExpressionWorkspace,
|
||||
LiveExpressionPreview,
|
||||
} from '@/features/avatar/components/ExpressionWorkspace'
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import { randomSnapshotPalette } from '@/features/export/snapshotPalette'
|
||||
@ -488,6 +489,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
projectImportError,
|
||||
projectImportRef,
|
||||
reduceMotion,
|
||||
renderedColors,
|
||||
renderedExpressionBodyColor,
|
||||
renderedScene,
|
||||
renameActiveAvatar,
|
||||
runtimeDefinitionResult,
|
||||
runtimeCopyStatus,
|
||||
@ -1245,6 +1249,18 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
onClick={() => launchSequence(sequence)}
|
||||
onDoubleClick={() => openSequenceEditor(sequence)}
|
||||
>
|
||||
{activeSequence?.id === sequence.id &&
|
||||
playbackStatus !== 'stopped' ? (
|
||||
<LiveExpressionPreview
|
||||
scene={renderedScene}
|
||||
colors={renderedColors}
|
||||
baseBodyColor={activeAvatar.colors.body}
|
||||
bodyNodes={bodyNodes}
|
||||
renderStyle={activeAvatar.renderStyle}
|
||||
expressionBodyColor={renderedExpressionBodyColor}
|
||||
id={`state-card-${sequence.id}`}
|
||||
/>
|
||||
) : (
|
||||
<ExpressionPreview
|
||||
expression={
|
||||
firstExpression ?? expressions[0] ?? defaultExpression
|
||||
@ -1256,6 +1272,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
renderStyle={activeAvatar.renderStyle}
|
||||
id={`state-card-${sequence.id}`}
|
||||
/>
|
||||
)}
|
||||
{animationSemanticKeyError(sequence) && (
|
||||
<i
|
||||
className="runtime-key-missing"
|
||||
|
||||
@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import { avatarBodyOutlineWidth } from '@/features/rendering/avatarAppearance'
|
||||
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
|
||||
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
|
||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||
@ -35,6 +36,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
previewExpressionDraft,
|
||||
previewSelectedBodyNode,
|
||||
renderedColors,
|
||||
renderedExpressionBodyColor,
|
||||
renderedRotationGizmo,
|
||||
renderedScene,
|
||||
selectBodyNode,
|
||||
@ -72,8 +74,10 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
expression={canvasExpression}
|
||||
avatarEyes={activeAvatarEyes}
|
||||
surface={surface}
|
||||
bodyNodes={activeAvatar.body.nodes}
|
||||
scene={renderedScene}
|
||||
colors={renderedColors}
|
||||
expressionBodyColor={renderedExpressionBodyColor}
|
||||
renderStyle={activeAvatar.renderStyle}
|
||||
rotationGizmo={renderedRotationGizmo}
|
||||
showWire={showWire}
|
||||
@ -114,6 +118,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
{
|
||||
'--avatar-body-color': renderedColors.body,
|
||||
'--avatar-eye-color': renderedColors.eyes,
|
||||
'--avatar-outline-width': avatarBodyOutlineWidth(),
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
@ -1,56 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { StudioDialogs } from '@/features/studio/components/StudioDialogs'
|
||||
import { StudioInspector } from '@/features/studio/components/StudioInspector'
|
||||
import { StudioStage } from '@/features/studio/components/StudioStage'
|
||||
import type { StudioController } from '@/features/studio/useStudioController'
|
||||
|
||||
const STACKED_LAYOUT_QUERY = '(max-width: 980px)'
|
||||
|
||||
export function StudioView(controller: StudioController) {
|
||||
const [stackedLayout, setStackedLayout] = useState(
|
||||
() => window.matchMedia(STACKED_LAYOUT_QUERY).matches
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(STACKED_LAYOUT_QUERY)
|
||||
const updateStackedLayout = () => setStackedLayout(mediaQuery.matches)
|
||||
mediaQuery.addEventListener('change', updateStackedLayout)
|
||||
return () => mediaQuery.removeEventListener('change', updateStackedLayout)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="studio-root" lang={controller.language}>
|
||||
{stackedLayout ? (
|
||||
<ResizablePanelGroup
|
||||
className="studio studio-mobile-resizable"
|
||||
orientation="vertical"
|
||||
resizeTargetMinimumSize={{ coarse: 40, fine: 12 }}
|
||||
>
|
||||
<ResizablePanel
|
||||
id="preview"
|
||||
className="mobile-stage-panel"
|
||||
defaultSize="42%"
|
||||
minSize={180}
|
||||
>
|
||||
<StudioStage controller={controller} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
className="studio-resize-handle"
|
||||
withHandle
|
||||
aria-label={controller.t('Redimensionner l’aperçu et l’éditeur')}
|
||||
/>
|
||||
<ResizablePanel id="editor" className="mobile-inspector-panel" minSize={260}>
|
||||
<StudioInspector controller={controller} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<div className="studio">
|
||||
<StudioStage controller={controller} />
|
||||
<div className="studio studio-catalog-only">
|
||||
<StudioInspector controller={controller} />
|
||||
</div>
|
||||
)}
|
||||
<StudioDialogs controller={controller} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -17,8 +17,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#5b7fe5",
|
||||
"eyes": "#111316"
|
||||
"body": "#7c89ff",
|
||||
"eyes": "#50d7fe"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 20,
|
||||
@ -113,8 +113,8 @@
|
||||
]
|
||||
},
|
||||
"colors": {
|
||||
"body": "#e6855c",
|
||||
"eyes": "#ffffff"
|
||||
"body": "#ff9d45",
|
||||
"eyes": "#ffda1a"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 28.934879954481378,
|
||||
@ -147,8 +147,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#ffcf24",
|
||||
"eyes": "#000000"
|
||||
"body": "#ffda1a",
|
||||
"eyes": "#ffda1a"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 20,
|
||||
@ -178,8 +178,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#55b6c3",
|
||||
"eyes": "#111316"
|
||||
"body": "#1ad7e3",
|
||||
"eyes": "#1ad7e3"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 13.26171875,
|
||||
@ -209,8 +209,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#000000",
|
||||
"eyes": "#ffffff"
|
||||
"body": "#df48f3",
|
||||
"eyes": "#ff4b6f"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 20,
|
||||
@ -409,8 +409,8 @@
|
||||
]
|
||||
},
|
||||
"colors": {
|
||||
"body": "#e69a5c",
|
||||
"eyes": "#111316"
|
||||
"body": "#be9675",
|
||||
"eyes": "#ff9d45"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 20,
|
||||
@ -483,8 +483,8 @@
|
||||
]
|
||||
},
|
||||
"colors": {
|
||||
"body": "#ffc2e9",
|
||||
"eyes": "#3e4e65"
|
||||
"body": "#ff4b6f",
|
||||
"eyes": "#df48f3"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 20,
|
||||
@ -599,8 +599,8 @@
|
||||
]
|
||||
},
|
||||
"colors": {
|
||||
"body": "#c9cbcf",
|
||||
"eyes": "#111316"
|
||||
"body": "#50d7fe",
|
||||
"eyes": "#1a9cff"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 13.942812499999993,
|
||||
@ -630,8 +630,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#e65c5c",
|
||||
"eyes": "#111316"
|
||||
"body": "#ff5558",
|
||||
"eyes": "#ff5558"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 19.975390625,
|
||||
@ -664,8 +664,8 @@
|
||||
"nodes": []
|
||||
},
|
||||
"colors": {
|
||||
"body": "#dbe2f5",
|
||||
"eyes": "#111316"
|
||||
"body": "#1a9cff",
|
||||
"eyes": "#1a9cff"
|
||||
},
|
||||
"eyes": {
|
||||
"widthLeft": 13.389062500000001,
|
||||
@ -1253,6 +1253,29 @@
|
||||
"eyeMotion": "shake",
|
||||
"bodyMotion": "slowDrift",
|
||||
"bodyColor": "#adc3ff"
|
||||
},
|
||||
{
|
||||
"id": "expression-joyful-arc",
|
||||
"semanticKey": "joyful-arc",
|
||||
"headX": -8.75234375,
|
||||
"headY": -8.743359375,
|
||||
"headZ": -10.773828125,
|
||||
"widthLeft": 54,
|
||||
"widthRight": 54,
|
||||
"heightLeft": 15,
|
||||
"heightRight": 15,
|
||||
"curvatureLeft": 1,
|
||||
"curvatureRight": 1,
|
||||
"spacing": 69.276171875,
|
||||
"positionXLeft": 0,
|
||||
"positionXRight": 0,
|
||||
"positionYLeft": -18,
|
||||
"positionYRight": -18,
|
||||
"leftAngle": 0,
|
||||
"rightAngle": 0,
|
||||
"perspective": 1,
|
||||
"eyeMotion": "none",
|
||||
"bodyMotion": "none"
|
||||
}
|
||||
],
|
||||
"sequences": [
|
||||
@ -1699,7 +1722,7 @@
|
||||
},
|
||||
{
|
||||
"id": "angry-step-1",
|
||||
"expressionId": "expression-16",
|
||||
"expressionId": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 500,
|
||||
"transition": "smooth"
|
||||
@ -1798,6 +1821,38 @@
|
||||
"durationMs": 260
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "joyful",
|
||||
"semanticKey": "joyful",
|
||||
"name": "joyful",
|
||||
"group": "Réactions",
|
||||
"description": "Morphing fluide entre les expressions 11 et 27.",
|
||||
"builtIn": true,
|
||||
"playbackMode": "loop",
|
||||
"steps": [
|
||||
{
|
||||
"id": "joyful-step-0",
|
||||
"expressionId": "expression-11",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 700,
|
||||
"transition": "gentleSpring"
|
||||
},
|
||||
{
|
||||
"id": "joyful-step-1",
|
||||
"expressionId": "expression-joyful-arc",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 700,
|
||||
"transition": "gentleSpring"
|
||||
}
|
||||
],
|
||||
"blink": {
|
||||
"enabled": false,
|
||||
"initialDelayMs": 2100,
|
||||
"minIntervalMs": 2800,
|
||||
"maxIntervalMs": 5000,
|
||||
"durationMs": 260
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "curious",
|
||||
"semanticKey": "curious",
|
||||
@ -2089,7 +2144,7 @@
|
||||
},
|
||||
{
|
||||
"id": "scared-step-1",
|
||||
"expressionId": "expression-21",
|
||||
"expressionId": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
|
||||
"holdMs": 2300,
|
||||
"transitionMs": 500,
|
||||
"transition": "smooth"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
ensurePrimitiveBundledAvatars,
|
||||
parseAvatarLibrary,
|
||||
parseExpressions,
|
||||
restoreLegacyBehaviorSemanticKeys,
|
||||
@ -99,17 +100,181 @@ const createBundledStudioDocument = () => {
|
||||
return parseStudioDocument(snapshot, snapshot)
|
||||
}
|
||||
|
||||
const restoreStandaloneBundledAnimations = (
|
||||
document: StudioDocument,
|
||||
fallback: StudioDocument
|
||||
): Pick<StudioDocument, 'sequences' | 'playback'> => {
|
||||
const bundledIds = new Set(fallback.sequences.map(sequence => sequence.id))
|
||||
const targetByPairId = new Map(
|
||||
fallback.sequences
|
||||
.filter(sequence => sequence.id !== 'idle')
|
||||
.map(sequence => [`idle-${sequence.id}`, sequence.id])
|
||||
)
|
||||
if (!document.sequences.some(sequence => targetByPairId.has(sequence.id))) {
|
||||
return { sequences: document.sequences, playback: document.playback }
|
||||
}
|
||||
const custom = document.sequences.filter(
|
||||
sequence => !bundledIds.has(sequence.id) && !targetByPairId.has(sequence.id)
|
||||
)
|
||||
const stateId =
|
||||
document.playback.stateId === null
|
||||
? null
|
||||
: (targetByPairId.get(document.playback.stateId) ?? document.playback.stateId)
|
||||
return {
|
||||
sequences: [...fallback.sequences, ...custom],
|
||||
playback: { ...document.playback, stateId },
|
||||
}
|
||||
}
|
||||
|
||||
const restoreSignatureAnimationSteps = (
|
||||
sequences: AvatarSequence[],
|
||||
fallback: AvatarSequence[],
|
||||
signatureIds = new Set(['angry', 'scared', 'joyful'])
|
||||
) => {
|
||||
const fallbackById = new Map(fallback.map(sequence => [sequence.id, sequence]))
|
||||
const restored = sequences.map(sequence => {
|
||||
if (!signatureIds.has(sequence.id)) return sequence
|
||||
const canonical = fallbackById.get(sequence.id)
|
||||
if (!canonical) return sequence
|
||||
if (sequence.id === 'joyful') {
|
||||
return {
|
||||
...canonical,
|
||||
steps: canonical.steps.map(step => ({ ...step })),
|
||||
blink: { ...canonical.blink },
|
||||
}
|
||||
}
|
||||
const steps = canonical.steps.map((canonicalStep, index) => ({
|
||||
...(sequence.steps[index] ?? canonicalStep),
|
||||
id: canonicalStep.id,
|
||||
expressionId: canonicalStep.expressionId,
|
||||
}))
|
||||
const unchanged =
|
||||
steps.length === sequence.steps.length &&
|
||||
steps.every(
|
||||
(step, index) =>
|
||||
step.id === sequence.steps[index].id &&
|
||||
step.expressionId === sequence.steps[index].expressionId
|
||||
)
|
||||
return unchanged ? sequence : { ...sequence, steps }
|
||||
})
|
||||
const joyful = fallbackById.get('joyful')
|
||||
if (!joyful || restored.some(sequence => sequence.id === joyful.id)) return restored
|
||||
return [
|
||||
...restored,
|
||||
{
|
||||
...joyful,
|
||||
steps: joyful.steps.map(step => ({ ...step })),
|
||||
blink: { ...joyful.blink },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const restoreSignatureExpressions = (
|
||||
expressions: Expression[],
|
||||
fallback: Expression[],
|
||||
addMissing = true
|
||||
) => {
|
||||
const signatureKeys = new Set(['joyful-arc'])
|
||||
const fallbackByKey = new Map(
|
||||
fallback
|
||||
.filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey))
|
||||
.map(expression => [expression.semanticKey!, expression])
|
||||
)
|
||||
let changed = false
|
||||
const restored = expressions.map(expression => {
|
||||
const canonical = expression.semanticKey ? fallbackByKey.get(expression.semanticKey) : undefined
|
||||
if (
|
||||
!canonical ||
|
||||
(expression.curvatureLeft === canonical.curvatureLeft &&
|
||||
expression.curvatureRight === canonical.curvatureRight &&
|
||||
expression.heightLeft === canonical.heightLeft &&
|
||||
expression.heightRight === canonical.heightRight)
|
||||
) {
|
||||
return expression
|
||||
}
|
||||
changed = true
|
||||
return {
|
||||
...expression,
|
||||
curvatureLeft: canonical.curvatureLeft,
|
||||
curvatureRight: canonical.curvatureRight,
|
||||
heightLeft: canonical.heightLeft,
|
||||
heightRight: canonical.heightRight,
|
||||
}
|
||||
})
|
||||
const existingKeys = new Set(restored.map(expression => expression.semanticKey))
|
||||
const missing = addMissing
|
||||
? fallback.filter(
|
||||
expression =>
|
||||
expression.semanticKey &&
|
||||
signatureKeys.has(expression.semanticKey) &&
|
||||
!existingKeys.has(expression.semanticKey)
|
||||
)
|
||||
: []
|
||||
if (missing.length) return [...restored, ...missing.map(expression => ({ ...expression }))]
|
||||
return changed ? restored : expressions
|
||||
}
|
||||
|
||||
const restoreAvatarSignatureBehavior = (
|
||||
library: AvatarLibrary,
|
||||
fallbackExpressions: Expression[],
|
||||
fallbackSequences: AvatarSequence[]
|
||||
) => {
|
||||
let changed = false
|
||||
const avatars = library.avatars.map(avatar => {
|
||||
if (!avatar.behavior) return avatar
|
||||
const expressions = restoreSignatureExpressions(
|
||||
avatar.behavior.expressions,
|
||||
fallbackExpressions,
|
||||
false
|
||||
)
|
||||
const availableExpressionIds = new Set(expressions.map(expression => expression.id))
|
||||
const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful')
|
||||
const sequences =
|
||||
joyful && joyful.steps.every(step => availableExpressionIds.has(step.expressionId))
|
||||
? restoreSignatureAnimationSteps(
|
||||
avatar.behavior.sequences,
|
||||
fallbackSequences,
|
||||
new Set(['angry', 'joyful'])
|
||||
)
|
||||
: avatar.behavior.sequences
|
||||
if (expressions === avatar.behavior.expressions && sequences === avatar.behavior.sequences) {
|
||||
return avatar
|
||||
}
|
||||
changed = true
|
||||
return { ...avatar, behavior: { expressions, sequences } }
|
||||
})
|
||||
return changed ? { ...library, avatars } : library
|
||||
}
|
||||
|
||||
export const loadStudioDocument = (
|
||||
storage: Pick<Storage, 'getItem'> = window.localStorage
|
||||
): StudioDocument => {
|
||||
const fallback = createBundledStudioDocument()
|
||||
try {
|
||||
return parseStudioDocument(
|
||||
const document = parseStudioDocument(
|
||||
JSON.parse(storage.getItem(DOCUMENT_STORAGE_KEY) ?? 'null'),
|
||||
fallback
|
||||
)
|
||||
const expressions = restoreSignatureExpressions(document.expressions, fallback.expressions)
|
||||
const hydrated = expressions === document.expressions ? document : { ...document, expressions }
|
||||
const restored = restoreStandaloneBundledAnimations(hydrated, fallback)
|
||||
const sequences = restoreSignatureAnimationSteps(restored.sequences, fallback.sequences)
|
||||
const library = restoreAvatarSignatureBehavior(
|
||||
ensurePrimitiveBundledAvatars(hydrated.library),
|
||||
fallback.expressions,
|
||||
fallback.sequences
|
||||
)
|
||||
return {
|
||||
...hydrated,
|
||||
library,
|
||||
...restored,
|
||||
sequences,
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
return {
|
||||
...fallback,
|
||||
library: ensurePrimitiveBundledAvatars(fallback.library),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
getSequenceSpring,
|
||||
readSequenceClock,
|
||||
remapSequencesAfterExpressionDelete,
|
||||
scaleSpringDynamics,
|
||||
type AvatarSequence,
|
||||
type SequenceStep,
|
||||
} from '@/features/animation/sequences'
|
||||
@ -128,6 +129,9 @@ import {
|
||||
type StudioDocument,
|
||||
} from '@/features/studio/studioDocument'
|
||||
|
||||
const JOYFUL_ARC_SEMANTIC_KEY = 'joyful-arc'
|
||||
const JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER = 1.5
|
||||
|
||||
export function useStudioController() {
|
||||
const { language, setLanguage, t } = useStudioLanguage()
|
||||
const [mode, setMode] = useState<Mode>('avatars')
|
||||
@ -340,6 +344,7 @@ export function useStudioController() {
|
||||
const lastTransitionTime = useRef<number | null>(null)
|
||||
const lastInspectorFrame = useRef(0)
|
||||
const springSpeedRef = useRef(springSpeed)
|
||||
const expressionEntrySpeedMultiplier = useRef(1)
|
||||
const sequenceTransitionRef = useRef<Pick<SequenceStep, 'transitionMs' | 'transition'>>({
|
||||
transitionMs: 500,
|
||||
transition: 'smooth',
|
||||
@ -525,6 +530,8 @@ export function useStudioController() {
|
||||
transitionMs: 500,
|
||||
transition: 'smooth',
|
||||
}
|
||||
expressionEntrySpeedMultiplier.current =
|
||||
next.semanticKey === JOYFUL_ARC_SEMANTIC_KEY ? JOYFUL_ARC_ENTRY_SPEED_MULTIPLIER : 1
|
||||
setActiveExpression(index)
|
||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||
if (reduceMotion || transitionSettings?.transitionMs === 0) {
|
||||
@ -576,6 +583,9 @@ export function useStudioController() {
|
||||
? progress * progress * (3 - 2 * progress)
|
||||
: transitionSettings.transition === 'snappy'
|
||||
? 1 - (1 - progress) ** 3
|
||||
: transitionSettings.transition === 'gentleSpring'
|
||||
? (1 - Math.exp(-9 * progress) * Math.cos(5 * progress)) /
|
||||
(1 - Math.exp(-9) * Math.cos(5))
|
||||
: 1 - Math.exp(-6 * progress) * Math.cos(8 * progress)
|
||||
const animated = { ...from, eyeMotion: next.eyeMotion, bodyMotion: next.bodyMotion }
|
||||
expressionFields.forEach(field => {
|
||||
@ -618,11 +628,11 @@ export function useStudioController() {
|
||||
if (avatar) {
|
||||
const targetColors = resolveColors(next, avatar.colors)
|
||||
bodyColorAnimation.current = animate(renderedColors.body, targetColors.body, {
|
||||
duration: 0.35,
|
||||
duration: 0.35 / expressionEntrySpeedMultiplier.current,
|
||||
ease: 'easeInOut',
|
||||
})
|
||||
eyeColorAnimation.current = animate(renderedColors.eyes, targetColors.eyes, {
|
||||
duration: 0.35,
|
||||
duration: 0.35 / expressionEntrySpeedMultiplier.current,
|
||||
ease: 'easeInOut',
|
||||
})
|
||||
}
|
||||
@ -644,16 +654,23 @@ export function useStudioController() {
|
||||
const previousTime = lastTransitionTime.current ?? time
|
||||
const deltaTime = Math.min(Math.max((time - previousTime) / 1000, 1 / 240), 1 / 30)
|
||||
lastTransitionTime.current = time
|
||||
const { stiffness, damping } = getSequenceSpring(
|
||||
const { stiffness, damping } = scaleSpringDynamics(
|
||||
getSequenceSpring(
|
||||
sequenceTransitionRef.current.transition,
|
||||
sequenceTransitionRef.current.transitionMs,
|
||||
springSpeedRef.current
|
||||
),
|
||||
expressionEntrySpeedMultiplier.current
|
||||
)
|
||||
const mass = 0.85
|
||||
const currentExpression = displayedPose.current.expression
|
||||
if (retargetStartedAt.current !== null && retargetFrom.current && retargetTo.current) {
|
||||
if (retargetStartedAt.current < 0) retargetStartedAt.current = time
|
||||
const linearProgress = Math.min((time - retargetStartedAt.current) / RETARGET_BLEND_MS, 1)
|
||||
const linearProgress = Math.min(
|
||||
(time - retargetStartedAt.current) /
|
||||
(RETARGET_BLEND_MS / expressionEntrySpeedMultiplier.current),
|
||||
1
|
||||
)
|
||||
const smoothProgress =
|
||||
linearProgress ** 3 * (linearProgress * (linearProgress * 6 - 15) + 10)
|
||||
const blendedTarget = { ...retargetTo.current }
|
||||
@ -815,6 +832,7 @@ export function useStudioController() {
|
||||
const nextExpressions = nextBehavior.expressions
|
||||
const nextSequences = nextBehavior.sequences
|
||||
stopTransition(true)
|
||||
stopColorTransitions()
|
||||
activeAvatarIdRef.current = id
|
||||
surfaceRef.current = avatar.body.primary
|
||||
bodyNodesRef.current = avatar.body.nodes
|
||||
@ -849,9 +867,23 @@ export function useStudioController() {
|
||||
modeRef.current = 'manual'
|
||||
setMode('manual')
|
||||
}
|
||||
const avatarStateExpression =
|
||||
nextExpressions.find(expression => expression.id === currentStateExpression.id) ??
|
||||
nextExpressions.find(
|
||||
expression =>
|
||||
currentStateExpression.semanticKey &&
|
||||
expression.semanticKey === currentStateExpression.semanticKey
|
||||
)
|
||||
const selectedExpression = nextActiveSequence
|
||||
? currentStateExpression
|
||||
? { ...(avatarStateExpression ?? currentStateExpression) }
|
||||
: { ...(nextExpressions[0] ?? defaultExpression) }
|
||||
const usesFixedSkinColors = nextExpressions.every(
|
||||
expression => !expression.bodyColor && !expression.eyeColor
|
||||
)
|
||||
if (usesFixedSkinColors) {
|
||||
delete selectedExpression.bodyColor
|
||||
delete selectedExpression.eyeColor
|
||||
}
|
||||
const nextExpression = editBody ? resetBodyEditorView(selectedExpression) : selectedExpression
|
||||
setExpression(nextExpression)
|
||||
if (editBody) {
|
||||
@ -1537,6 +1569,16 @@ export function useStudioController() {
|
||||
: activeSequence.name
|
||||
: null
|
||||
const expressionById = new Map(expressions.map(item => [item.id, item]))
|
||||
const playbackExpression =
|
||||
activeSequence && playbackVisual.position !== null
|
||||
? expressionById.get(activeSequence.steps[playbackVisual.position]?.expressionId ?? '')
|
||||
: null
|
||||
const activeSequenceUsesBodyColor =
|
||||
activeSequence?.steps.some(step => expressionById.get(step.expressionId)?.bodyColor) ?? false
|
||||
const renderedExpressionBodyColor =
|
||||
playbackExpression?.bodyColor ??
|
||||
expression.bodyColor ??
|
||||
(activeSequenceUsesBodyColor ? renderedColors.body.get() : undefined)
|
||||
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
|
||||
t(
|
||||
issue === 'missing_semantic_key'
|
||||
@ -1999,6 +2041,7 @@ export function useStudioController() {
|
||||
reduceMotion,
|
||||
renameActiveAvatar,
|
||||
renderedColors,
|
||||
renderedExpressionBodyColor,
|
||||
renderedRotationGizmo,
|
||||
renderedScene,
|
||||
runtimeDefinitionResult,
|
||||
|
||||
@ -100,6 +100,15 @@ const english: Record<string, string> = {
|
||||
'Couleur des yeux': 'Eye color',
|
||||
Largeur: 'Width',
|
||||
Hauteur: 'Height',
|
||||
Ellipse: 'Ellipse',
|
||||
Carré: 'Square',
|
||||
Arrondi: 'Rounded',
|
||||
Goutte: 'Teardrop',
|
||||
Trapèze: 'Trapezoid',
|
||||
'Tronc de cône': 'Frustum',
|
||||
'Demi-cône': 'Half cone',
|
||||
'Échelle du sommet': 'Top scale',
|
||||
'Angle de coupe': 'Cut angle',
|
||||
Échelle: 'Scale',
|
||||
'Taille proportionnelle': 'Proportional size',
|
||||
'Œil gauche': 'Left eye',
|
||||
@ -504,6 +513,7 @@ const english: Record<string, string> = {
|
||||
'Durée de transition': 'Transition duration',
|
||||
Transition: 'Transition',
|
||||
Ressort: 'Spring',
|
||||
'Ressort léger': 'Gentle spring',
|
||||
Douce: 'Smooth',
|
||||
Rapide: 'Snappy',
|
||||
'Ajouter une expression': 'Add expression',
|
||||
@ -588,6 +598,7 @@ const english: Record<string, string> = {
|
||||
'Fast tilts and rotations to simulate a moving agent.',
|
||||
'Rythme régulier et expressions concentrées.': 'Steady rhythm and focused expressions.',
|
||||
'Grandes expressions et transitions rapides.': 'Big expressions and fast transitions.',
|
||||
'Morphing fluide entre les expressions 11 et 27.': 'Smooth morph between expressions 11 and 27.',
|
||||
'Inclinaisons et forte asymétrie.': 'Tilts and strong asymmetry.',
|
||||
sleeping: 'sleeping',
|
||||
waking: 'waking',
|
||||
@ -602,6 +613,7 @@ const english: Record<string, string> = {
|
||||
angry: 'angry',
|
||||
drowsy: 'drowsy',
|
||||
happy: 'happy',
|
||||
joyful: 'joyful',
|
||||
curious: 'curious',
|
||||
confused: 'confused',
|
||||
bored: 'bored',
|
||||
@ -655,6 +667,7 @@ const frenchStates: Record<string, string> = {
|
||||
angry: 'en colère',
|
||||
drowsy: 'somnolent',
|
||||
happy: 'heureux',
|
||||
joyful: 'joyeux',
|
||||
curious: 'curieux',
|
||||
confused: 'confus',
|
||||
bored: 'ennuyé',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user