feat: add Memento headset avatar
This commit is contained in:
parent
e73ce55fb6
commit
734a99e4fb
@ -868,6 +868,37 @@
|
|||||||
"eyes": "none",
|
"eyes": "none",
|
||||||
"body": "none"
|
"body": "none"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"front-facing": {
|
||||||
|
"head": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 0,
|
||||||
|
"z": 0
|
||||||
|
},
|
||||||
|
"eyes": {
|
||||||
|
"left": {
|
||||||
|
"width": 20,
|
||||||
|
"height": 50,
|
||||||
|
"curvature": 0,
|
||||||
|
"x": 0,
|
||||||
|
"y": -7,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
"right": {
|
||||||
|
"width": 20,
|
||||||
|
"height": 50,
|
||||||
|
"curvature": 0,
|
||||||
|
"x": 0,
|
||||||
|
"y": -7,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
"spacing": 35
|
||||||
|
},
|
||||||
|
"perspective": 1,
|
||||||
|
"motion": {
|
||||||
|
"eyes": "none",
|
||||||
|
"body": "none"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"expressionOrder": [
|
"expressionOrder": [
|
||||||
@ -899,7 +930,8 @@
|
|||||||
"shy-downward",
|
"shy-downward",
|
||||||
"angry-brows",
|
"angry-brows",
|
||||||
"uneasy-left",
|
"uneasy-left",
|
||||||
"joyful-arc"
|
"joyful-arc",
|
||||||
|
"front-facing"
|
||||||
],
|
],
|
||||||
"animations": {
|
"animations": {
|
||||||
"sleeping": {
|
"sleeping": {
|
||||||
|
|||||||
1
public/headset-filled.svg
Normal file
1
public/headset-filled.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="24" fill="none" viewBox="-1 0 26 24"><path fill="currentColor" fill-rule="nonzero" d="M12 1.875C17.7 1.875 22.45 6.4 22.45 12.5h-2C20.45 7.44 16.73 3.875 12 3.875S3.55 7.44 3.55 12.5h-2C1.55 6.4 6.3 1.875 12 1.875ZM3.5 8.25v8.5a4.25 4.25 0 0 1 0-8.5ZM20.5 8.25a4.25 4.25 0 0 1 0 8.5v-8.5ZM22.5 13.5C22.5 18.8 19 22 13.5 22a1 1 0 1 1 0-2c4.25 0 7-2.25 7-5.5 0-1.1.9-2 2-2Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@ -7,9 +7,11 @@ import {
|
|||||||
defaultAvatarEyes,
|
defaultAvatarEyes,
|
||||||
type AvatarColors,
|
type AvatarColors,
|
||||||
type AvatarEyeDefaults,
|
type AvatarEyeDefaults,
|
||||||
|
type AvatarRenderStyle,
|
||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
import { type BodyNode } from '@/features/avatar/body'
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
|
import { poseFromExpression, type Expression } from '@/features/avatar/geometry'
|
||||||
|
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo'
|
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo'
|
||||||
@ -51,7 +53,7 @@ const previewGeometryCache = new WeakMap<
|
|||||||
Expression,
|
Expression,
|
||||||
WeakMap<
|
WeakMap<
|
||||||
SurfaceConfig,
|
SurfaceConfig,
|
||||||
WeakMap<BodyNode[], { positionKey: string; geometry: ReturnType<typeof renderAvatar> }>
|
WeakMap<BodyNode[], { positionKey: string; geometry: ReturnType<typeof renderAvatarByStyle> }>
|
||||||
>
|
>
|
||||||
>()
|
>()
|
||||||
|
|
||||||
@ -62,7 +64,8 @@ export const getPreviewGeometry = (
|
|||||||
expression: Expression,
|
expression: Expression,
|
||||||
surface: SurfaceConfig,
|
surface: SurfaceConfig,
|
||||||
bodyNodes: BodyNode[],
|
bodyNodes: BodyNode[],
|
||||||
eyes: AvatarEyeDefaults = defaultAvatarEyes
|
eyes: AvatarEyeDefaults = defaultAvatarEyes,
|
||||||
|
renderStyle: AvatarRenderStyle = { type: 'vector' }
|
||||||
) => {
|
) => {
|
||||||
let surfaceCache = previewGeometryCache.get(expression)
|
let surfaceCache = previewGeometryCache.get(expression)
|
||||||
if (!surfaceCache) {
|
if (!surfaceCache) {
|
||||||
@ -74,13 +77,19 @@ export const getPreviewGeometry = (
|
|||||||
bodyCache = new WeakMap()
|
bodyCache = new WeakMap()
|
||||||
surfaceCache.set(surface, bodyCache)
|
surfaceCache.set(surface, bodyCache)
|
||||||
}
|
}
|
||||||
const positionKey = JSON.stringify(eyes)
|
const positionKey = JSON.stringify([eyes, renderStyle])
|
||||||
const cached = bodyCache.get(bodyNodes)
|
const cached = bodyCache.get(bodyNodes)
|
||||||
if (cached?.positionKey === positionKey) return cached.geometry
|
if (cached?.positionKey === positionKey) return cached.geometry
|
||||||
const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, {
|
const geometry = renderAvatarByStyle(
|
||||||
|
poseWithAvatarEyes(expression, eyes),
|
||||||
|
surface,
|
||||||
|
1,
|
||||||
|
renderStyle,
|
||||||
|
{
|
||||||
includeWire: false,
|
includeWire: false,
|
||||||
bodyNodes,
|
bodyNodes,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
bodyCache.set(bodyNodes, { positionKey, geometry })
|
bodyCache.set(bodyNodes, { positionKey, geometry })
|
||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1219,6 +1219,11 @@ input {
|
|||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
.avatar-headset {
|
||||||
|
fill: #111316 !important;
|
||||||
|
opacity: 1;
|
||||||
|
stroke: none !important;
|
||||||
|
}
|
||||||
.avatar-eye,
|
.avatar-eye,
|
||||||
.preview-eye {
|
.preview-eye {
|
||||||
fill: var(--avatar-eye-color, #ffffff);
|
fill: var(--avatar-eye-color, #ffffff);
|
||||||
|
|||||||
1
src/assets/headset-filled.svg
Normal file
1
src/assets/headset-filled.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="26" height="24" fill="none" viewBox="-1 0 26 24"><path fill="currentColor" fill-rule="nonzero" d="M12 1.875C17.7 1.875 22.45 6.4 22.45 12.5h-2C20.45 7.44 16.73 3.875 12 3.875S3.55 7.44 3.55 12.5h-2C1.55 6.4 6.3 1.875 12 1.875ZM3.5 8.25v8.5a4.25 4.25 0 0 1 0-8.5ZM20.5 8.25a4.25 4.25 0 0 1 0 8.5v-8.5ZM22.5 13.5C22.5 18.8 19 22 13.5 22a1 1 0 1 1 0-2c4.25 0 7-2.25 7-5.5 0-1.1.9-2 2-2Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 440 B |
@ -486,8 +486,9 @@ describe('Studio to avatar definition conversion', () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true)
|
expect(result.ok).toBe(true)
|
||||||
if (!result.ok) return
|
if (!result.ok) return
|
||||||
expect(result.value.expressionOrder).toHaveLength(31)
|
expect(result.value.expressionOrder).toHaveLength(32)
|
||||||
expect(result.value.expressionOrder[0]).toBe('neutral')
|
expect(result.value.expressionOrder[0]).toBe('neutral')
|
||||||
|
expect(result.value.expressionOrder).toContain('front-facing')
|
||||||
expect(result.value.animationOrder).toHaveLength(25)
|
expect(result.value.animationOrder).toHaveLength(25)
|
||||||
expect(result.value.animationOrder).toContain('idle')
|
expect(result.value.animationOrder).toContain('idle')
|
||||||
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
|
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
|
||||||
|
import { LiveExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
|
import { poseFromExpression } from '@/features/avatar/geometry'
|
||||||
|
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
|
||||||
|
import {
|
||||||
|
createRenderedColors,
|
||||||
|
createRenderedScene,
|
||||||
|
} from '@/features/rendering/renderedScene'
|
||||||
|
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||||
|
|
||||||
|
it('keeps the headset visible and transformed in a live animation preview', () => {
|
||||||
|
const studio = loadStudioDocument({ getItem: () => null })
|
||||||
|
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')!
|
||||||
|
const expression = studio.expressions.find(item => item.semanticKey === 'front-facing')!
|
||||||
|
const geometry = renderAvatarByStyle(
|
||||||
|
poseFromExpression(expression),
|
||||||
|
avatar.body.primary,
|
||||||
|
1,
|
||||||
|
avatar.renderStyle,
|
||||||
|
{ bodyNodes: avatar.body.nodes }
|
||||||
|
)
|
||||||
|
const scene = createRenderedScene(geometry)
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<LiveExpressionPreview
|
||||||
|
scene={scene}
|
||||||
|
colors={createRenderedColors(avatar.colors)}
|
||||||
|
baseBodyColor={avatar.colors.body}
|
||||||
|
bodyNodes={avatar.body.nodes}
|
||||||
|
renderStyle={avatar.renderStyle}
|
||||||
|
id="animated-headset"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
const headset = view.container.querySelector('.avatar-headset')
|
||||||
|
expect(headset).not.toBeNull()
|
||||||
|
expect(headset?.getAttribute('d')).toBe(geometry.frontPaths.at(-1))
|
||||||
|
expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1))
|
||||||
|
})
|
||||||
@ -31,7 +31,12 @@ export type PixelRenderStyle = {
|
|||||||
type: 'pixel'
|
type: 'pixel'
|
||||||
resolution: number
|
resolution: number
|
||||||
}
|
}
|
||||||
export type VectorRenderStyle = { type: 'vector'; filled?: boolean }
|
export type VectorAccessory = 'headset'
|
||||||
|
export type VectorRenderStyle = {
|
||||||
|
type: 'vector'
|
||||||
|
filled?: boolean
|
||||||
|
accessory?: VectorAccessory
|
||||||
|
}
|
||||||
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
|
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
|
||||||
export type AvatarEyeDefaults = Pick<
|
export type AvatarEyeDefaults = Pick<
|
||||||
Expression,
|
Expression,
|
||||||
@ -68,6 +73,11 @@ export const defaultAvatarColors: AvatarColors = {
|
|||||||
}
|
}
|
||||||
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' }
|
||||||
export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true }
|
export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true }
|
||||||
|
export const headsetAvatarRenderStyle: VectorRenderStyle = {
|
||||||
|
type: 'vector',
|
||||||
|
filled: true,
|
||||||
|
accessory: 'headset',
|
||||||
|
}
|
||||||
const filledAvatarEyeColor = '#111316'
|
const filledAvatarEyeColor = '#111316'
|
||||||
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
export const defaultPixelRenderStyle: PixelRenderStyle = {
|
||||||
type: 'pixel',
|
type: 'pixel',
|
||||||
@ -154,6 +164,7 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
|
|||||||
|
|
||||||
export const simpleBundledAvatarIds = [
|
export const simpleBundledAvatarIds = [
|
||||||
'primitive-ghost',
|
'primitive-ghost',
|
||||||
|
'primitive-ghost-headset',
|
||||||
'primitive-apple-bite',
|
'primitive-apple-bite',
|
||||||
'primitive-lock',
|
'primitive-lock',
|
||||||
'oneworks-cat',
|
'oneworks-cat',
|
||||||
@ -225,6 +236,7 @@ const primitiveBundledAvatarSpecs = [
|
|||||||
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
|
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
|
||||||
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
|
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
|
||||||
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
|
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
|
||||||
|
['primitive-ghost-headset', 'Memento · Headset', 'ghost', deslopAccentColors.orange, 0.9],
|
||||||
['primitive-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9],
|
['primitive-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@ -843,7 +855,9 @@ const primitiveBundledAvatars: StudioAvatar[] = [
|
|||||||
}
|
}
|
||||||
: { ...defaultAvatarEyes },
|
: { ...defaultAvatarEyes },
|
||||||
renderStyle:
|
renderStyle:
|
||||||
surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
|
id === 'primitive-ghost-headset'
|
||||||
|
? { ...headsetAvatarRenderStyle }
|
||||||
|
: surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
|
||||||
? { ...filledAvatarRenderStyle }
|
? { ...filledAvatarRenderStyle }
|
||||||
: { ...defaultAvatarRenderStyle },
|
: { ...defaultAvatarRenderStyle },
|
||||||
...(surface === 'ghost'
|
...(surface === 'ghost'
|
||||||
@ -931,12 +945,14 @@ const filledBundledAvatarIds = new Set([
|
|||||||
...Object.keys(oneWorksBundledAnimalColors),
|
...Object.keys(oneWorksBundledAnimalColors),
|
||||||
...Object.keys(mementoAnimalColors),
|
...Object.keys(mementoAnimalColors),
|
||||||
'primitive-ghost',
|
'primitive-ghost',
|
||||||
|
'primitive-ghost-headset',
|
||||||
'primitive-apple-bite',
|
'primitive-apple-bite',
|
||||||
'primitive-lock',
|
'primitive-lock',
|
||||||
])
|
])
|
||||||
const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors))
|
const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors))
|
||||||
const filledPrimitiveAvatarColors: Record<string, AvatarColors> = {
|
const filledPrimitiveAvatarColors: Record<string, AvatarColors> = {
|
||||||
'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
|
'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
|
||||||
|
'primitive-ghost-headset': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
|
||||||
'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
|
'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
|
||||||
'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor },
|
'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor },
|
||||||
}
|
}
|
||||||
@ -975,7 +991,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
)
|
)
|
||||||
if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) {
|
if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) {
|
||||||
const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => {
|
const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => {
|
||||||
if (avatar.id === 'primitive-ghost') {
|
if (['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id)) {
|
||||||
return { ...avatar, behavior: mementoBehavior(avatar.behavior) }
|
return { ...avatar, behavior: mementoBehavior(avatar.behavior) }
|
||||||
}
|
}
|
||||||
if (filledBundledAvatarIds.has(avatar.id)) {
|
if (filledBundledAvatarIds.has(avatar.id)) {
|
||||||
@ -1106,7 +1122,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
? { ...avatarWithCurrentNodes, name: bundled.name }
|
? { ...avatarWithCurrentNodes, name: bundled.name }
|
||||||
: avatarWithCurrentNodes
|
: avatarWithCurrentNodes
|
||||||
const avatarWithCurrentEyes =
|
const avatarWithCurrentEyes =
|
||||||
avatarWithCurrentName.id === 'primitive-ghost' &&
|
['primitive-ghost', 'primitive-ghost-headset'].includes(avatarWithCurrentName.id) &&
|
||||||
[defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes(
|
[defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes(
|
||||||
avatarWithCurrentName.eyes.positionYLeft
|
avatarWithCurrentName.eyes.positionYLeft
|
||||||
) &&
|
) &&
|
||||||
@ -1121,8 +1137,9 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
|
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
|
||||||
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
|
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
|
||||||
: avatarWithCurrentEyes
|
: avatarWithCurrentEyes
|
||||||
const avatarWithFixedColorBehavior =
|
const avatarWithFixedColorBehavior = ['primitive-ghost', 'primitive-ghost-headset'].includes(
|
||||||
avatarWithAppleBiteEyes.id === 'primitive-ghost'
|
avatarWithAppleBiteEyes.id
|
||||||
|
)
|
||||||
? {
|
? {
|
||||||
...avatarWithAppleBiteEyes,
|
...avatarWithAppleBiteEyes,
|
||||||
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
|
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
|
||||||
@ -1133,7 +1150,10 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
|
|||||||
behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior),
|
behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior),
|
||||||
}
|
}
|
||||||
: avatarWithAppleBiteEyes
|
: avatarWithAppleBiteEyes
|
||||||
const avatarWithCurrentRenderStyle = filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
|
const avatarWithCurrentRenderStyle =
|
||||||
|
avatarWithFixedColorBehavior.id === 'primitive-ghost-headset'
|
||||||
|
? { ...avatarWithFixedColorBehavior, renderStyle: { ...headsetAvatarRenderStyle } }
|
||||||
|
: filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
|
||||||
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
|
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
|
||||||
: avatarWithFixedColorBehavior
|
: avatarWithFixedColorBehavior
|
||||||
const bundledFilledColors =
|
const bundledFilledColors =
|
||||||
@ -1215,9 +1235,18 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
|
|||||||
: fallback
|
: fallback
|
||||||
|
|
||||||
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
||||||
const candidate = value as { type?: unknown; filled?: unknown; resolution?: unknown } | null
|
const candidate = value as {
|
||||||
|
type?: unknown
|
||||||
|
filled?: unknown
|
||||||
|
accessory?: unknown
|
||||||
|
resolution?: unknown
|
||||||
|
} | null
|
||||||
if (candidate?.type === 'vector') {
|
if (candidate?.type === 'vector') {
|
||||||
return candidate.filled === true ? { type: 'vector', filled: true } : { type: 'vector' }
|
return {
|
||||||
|
type: 'vector',
|
||||||
|
...(candidate.filled === true ? { filled: true } : {}),
|
||||||
|
...(candidate.accessory === 'headset' ? { accessory: 'headset' as const } : {}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
||||||
return { ...defaultAvatarRenderStyle }
|
return { ...defaultAvatarRenderStyle }
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
LivePixelAvatarCanvas,
|
LivePixelAvatarCanvas,
|
||||||
StaticPixelAvatarCanvas,
|
StaticPixelAvatarCanvas,
|
||||||
} from '@/features/rendering/components/PixelAvatarCanvas'
|
} from '@/features/rendering/components/PixelAvatarCanvas'
|
||||||
|
import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas'
|
||||||
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
|
export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) {
|
||||||
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
|
const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes)
|
||||||
@ -82,7 +83,7 @@ export function ExpressionPreview({
|
|||||||
renderStyle: AvatarRenderStyle
|
renderStyle: AvatarRenderStyle
|
||||||
id: string
|
id: string
|
||||||
}) {
|
}) {
|
||||||
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes)
|
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes, renderStyle)
|
||||||
const resolvedColors = resolveColors(expression, colors)
|
const resolvedColors = resolveColors(expression, colors)
|
||||||
const outlineWidth = avatarBodyOutlineWidth()
|
const outlineWidth = avatarBodyOutlineWidth()
|
||||||
const filled = renderStyle.type === 'vector' && renderStyle.filled === true
|
const filled = renderStyle.type === 'vector' && renderStyle.filled === true
|
||||||
@ -129,6 +130,7 @@ export function ExpressionPreview({
|
|||||||
<path
|
<path
|
||||||
className="preview-head"
|
className="preview-head"
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
transform={geometry.pathTransforms?.back[index]}
|
||||||
key={index}
|
key={index}
|
||||||
style={{
|
style={{
|
||||||
fill: filled ? color : bodyFill,
|
fill: filled ? color : bodyFill,
|
||||||
@ -167,8 +169,9 @@ export function ExpressionPreview({
|
|||||||
const color = nodeColor(geometry.frontNodeIds[index])
|
const color = nodeColor(geometry.frontNodeIds[index])
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
className="preview-head"
|
className={`preview-head${geometry.headsetFrontIndex === index ? ' avatar-headset' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
transform={geometry.pathTransforms?.front[index]}
|
||||||
key={`front-${index}`}
|
key={`front-${index}`}
|
||||||
style={{
|
style={{
|
||||||
fill: filled ? color : bodyFill,
|
fill: filled ? color : bodyFill,
|
||||||
@ -234,8 +237,10 @@ export function LiveExpressionPreview({
|
|||||||
{scene.backPaths.map((pathValue, index) => {
|
{scene.backPaths.map((pathValue, index) => {
|
||||||
const color = nodeColor(scene.backNodeIds.current[index])
|
const color = nodeColor(scene.backNodeIds.current[index])
|
||||||
return (
|
return (
|
||||||
<motion.path
|
<SvgTransformPath
|
||||||
|
className="preview-head"
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
svgTransform={scene.backTransforms[index]}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
style={{
|
style={{
|
||||||
@ -272,8 +277,10 @@ export function LiveExpressionPreview({
|
|||||||
{scene.frontPaths.map((pathValue, index) => {
|
{scene.frontPaths.map((pathValue, index) => {
|
||||||
const color = nodeColor(scene.frontNodeIds.current[index])
|
const color = nodeColor(scene.frontNodeIds.current[index])
|
||||||
return (
|
return (
|
||||||
<motion.path
|
<SvgTransformPath
|
||||||
|
className={`preview-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
svgTransform={scene.frontTransforms[index]}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -169,6 +169,29 @@ export const initialExpressions: Expression[] = [
|
|||||||
eyeMotion: 'none' as const,
|
eyeMotion: 'none' as const,
|
||||||
bodyMotion: 'none' as const,
|
bodyMotion: 'none' as const,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'expression-front-facing',
|
||||||
|
semanticKey: 'front-facing',
|
||||||
|
headX: 0,
|
||||||
|
headY: 0,
|
||||||
|
headZ: 0,
|
||||||
|
widthLeft: 20,
|
||||||
|
widthRight: 20,
|
||||||
|
heightLeft: 50,
|
||||||
|
heightRight: 50,
|
||||||
|
curvatureLeft: 0,
|
||||||
|
curvatureRight: 0,
|
||||||
|
spacing: 35,
|
||||||
|
positionXLeft: 0,
|
||||||
|
positionXRight: 0,
|
||||||
|
positionYLeft: -7,
|
||||||
|
positionYRight: -7,
|
||||||
|
leftAngle: 0,
|
||||||
|
rightAngle: 0,
|
||||||
|
perspective: 1,
|
||||||
|
eyeMotion: 'none' as const,
|
||||||
|
bodyMotion: 'none' as const,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const defaultExpression: Expression = {
|
export const defaultExpression: Expression = {
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import {
|
|||||||
snapshotFileName,
|
snapshotFileName,
|
||||||
} from '@/features/export/snapshotExporter'
|
} from '@/features/export/snapshotExporter'
|
||||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
|
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
|
||||||
|
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||||
|
|
||||||
describe('avatar snapshot export', () => {
|
describe('avatar snapshot export', () => {
|
||||||
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
|
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1)
|
||||||
@ -29,6 +31,29 @@ describe('avatar snapshot export', () => {
|
|||||||
expect(svg).not.toContain('width="300" height="300" fill=')
|
expect(svg).not.toContain('width="300" height="300" fill=')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exports the Memento headset as a transformed translucent front layer', () => {
|
||||||
|
const studio = loadStudioDocument({ getItem: () => null })
|
||||||
|
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')!
|
||||||
|
const headsetGeometry = renderAvatarByStyle(
|
||||||
|
poseFromExpression({ ...defaultExpression, headY: 18, headZ: 6 }),
|
||||||
|
avatar.body.primary,
|
||||||
|
1,
|
||||||
|
avatar.renderStyle,
|
||||||
|
{ bodyNodes: avatar.body.nodes }
|
||||||
|
)
|
||||||
|
const headsetScene = createRenderedScene(headsetGeometry)
|
||||||
|
const svg = serializeAvatarSnapshot('Memento · Headset', headsetScene, avatar.colors, {
|
||||||
|
background: 'transparent',
|
||||||
|
colorFrom: '#ffffff',
|
||||||
|
colorTo: '#000000',
|
||||||
|
size: 1024,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(svg).toContain('fill="#111316" opacity="1"')
|
||||||
|
expect(svg).toContain(`transform="${headsetGeometry.pathTransforms?.front.at(-1)}"`)
|
||||||
|
expect(svg).not.toContain('stroke=')
|
||||||
|
})
|
||||||
|
|
||||||
it('applies logo framing without changing the rendered avatar scene', () => {
|
it('applies logo framing without changing the rendered avatar scene', () => {
|
||||||
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
|
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
|
||||||
background: 'solid',
|
background: 'solid',
|
||||||
|
|||||||
@ -28,8 +28,10 @@ const escapeXml = (value: string) =>
|
|||||||
return entities[character]
|
return entities[character]
|
||||||
})
|
})
|
||||||
|
|
||||||
const path = (value: string, fill: string, opacity = 1) =>
|
const path = (value: string, fill: string, opacity = 1, transform?: string) =>
|
||||||
value ? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"/>` : ''
|
value
|
||||||
|
? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"${transform ? ` transform="${escapeXml(transform)}"` : ''}/>`
|
||||||
|
: ''
|
||||||
|
|
||||||
const backgroundMarkup = (options: SnapshotOptions) => {
|
const backgroundMarkup = (options: SnapshotOptions) => {
|
||||||
if (options.background === 'transparent') return ''
|
if (options.background === 'transparent') return ''
|
||||||
@ -62,21 +64,30 @@ export const serializeAvatarSnapshot = (
|
|||||||
options.composition ?? defaultSnapshotComposition
|
options.composition ?? defaultSnapshotComposition
|
||||||
)
|
)
|
||||||
const headPath = scene.headPath.get()
|
const headPath = scene.headPath.get()
|
||||||
const backPaths = scene.backPaths.flatMap(item => {
|
const backPaths = scene.backPaths.flatMap((item, index) => {
|
||||||
const value = item.get()
|
const value = item.get()
|
||||||
return value ? [value] : []
|
return value ? [{ value, index }] : []
|
||||||
})
|
})
|
||||||
const frontPaths = scene.frontPaths.flatMap(item => {
|
const frontPaths = scene.frontPaths.flatMap((item, index) => {
|
||||||
const value = item.get()
|
const value = item.get()
|
||||||
return value ? [value] : []
|
return value ? [{ value, index }] : []
|
||||||
})
|
})
|
||||||
const offsetX = scene.offsetX.get()
|
const offsetX = scene.offsetX.get()
|
||||||
const offsetY = scene.offsetY.get()
|
const offsetY = scene.offsetY.get()
|
||||||
const body = [
|
const body = [
|
||||||
...backPaths.map(value => path(value, colors.body)),
|
...backPaths.map(({ value, index }) =>
|
||||||
path(headPath, colors.body),
|
path(value, colors.body, 1, scene.backTransforms[index].get())
|
||||||
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}</g>`,
|
),
|
||||||
...frontPaths.map(value => path(value, colors.body)),
|
path(headPath, colors.body, 1, scene.headTransform.get()),
|
||||||
|
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get(), scene.leftTransform.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get(), scene.rightTransform.get())}</g>`,
|
||||||
|
...frontPaths.map(({ value, index }) =>
|
||||||
|
path(
|
||||||
|
value,
|
||||||
|
scene.headsetFrontIndex.current === index ? '#111316' : colors.body,
|
||||||
|
1,
|
||||||
|
scene.frontTransforms[index].get()
|
||||||
|
)
|
||||||
|
),
|
||||||
].join('')
|
].join('')
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
paintRenderedScene,
|
paintRenderedScene,
|
||||||
} from '@/features/rendering/renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
|
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
|
||||||
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||||
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
|
import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json'
|
||||||
import type { BodyNode } from '@/features/avatar/body'
|
import type { BodyNode } from '@/features/avatar/body'
|
||||||
@ -33,6 +34,41 @@ const overlap = (first: ReturnType<typeof pathBounds>, second: ReturnType<typeof
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('rendered avatar scene', () => {
|
describe('rendered avatar scene', () => {
|
||||||
|
it('keeps the Memento headset in front of the face and moves it with the pose', () => {
|
||||||
|
const studio = loadStudioDocument({ getItem: () => null })
|
||||||
|
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')!
|
||||||
|
const neutralPose = poseFromExpression(defaultExpression)
|
||||||
|
const turnedPose = poseFromExpression({ ...defaultExpression, headY: 22, headZ: -8 })
|
||||||
|
const neutral = renderAvatarByStyle(
|
||||||
|
neutralPose,
|
||||||
|
avatar.body.primary as SurfaceConfig,
|
||||||
|
1,
|
||||||
|
avatar.renderStyle,
|
||||||
|
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||||
|
)
|
||||||
|
const turned = renderAvatarByStyle(
|
||||||
|
turnedPose,
|
||||||
|
avatar.body.primary as SurfaceConfig,
|
||||||
|
1,
|
||||||
|
avatar.renderStyle,
|
||||||
|
{ bodyNodes: avatar.body.nodes as BodyNode[] }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(avatar.name).toBe('Memento · Headset')
|
||||||
|
expect(avatar.colors).toEqual({ body: '#ff9d45', eyes: '#111316' })
|
||||||
|
expect(avatar.renderStyle).toEqual({ type: 'vector', filled: true, accessory: 'headset' })
|
||||||
|
expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1)
|
||||||
|
expect(neutral.frontPaths.at(-1)).toContain('M12 1.875C17.7 1.875')
|
||||||
|
expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1))
|
||||||
|
|
||||||
|
const scene = createRenderedScene(neutral)
|
||||||
|
paintRenderedScene(scene, turned)
|
||||||
|
expect(scene.headsetFrontIndex.current).toBe(turned.frontPaths.length - 1)
|
||||||
|
expect(scene.frontTransforms[scene.headsetFrontIndex.current!].get()).toBe(
|
||||||
|
turned.pathTransforms?.front.at(-1)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
it('keeps layer identity and hit mapping behind the scene seam', () => {
|
||||||
const node = createBodyNode('sphere', 0)
|
const node = createBodyNode('sphere', 0)
|
||||||
const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
|
const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
|
||||||
|
|||||||
106
src/features/rendering/avatarAccessories.ts
Normal file
106
src/features/rendering/avatarAccessories.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import headsetSvg from '@/assets/headset-filled.svg?raw'
|
||||||
|
|
||||||
|
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
|
||||||
|
import {
|
||||||
|
renderAvatar,
|
||||||
|
type AvatarGeometry,
|
||||||
|
type AvatarPose,
|
||||||
|
type RenderAvatarOptions,
|
||||||
|
} from '@/features/avatar/geometry'
|
||||||
|
import type { SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
|
type Matrix = readonly [number, number, number, number, number, number]
|
||||||
|
|
||||||
|
export type AvatarPathTransforms = {
|
||||||
|
back: string[]
|
||||||
|
head: string
|
||||||
|
left: string
|
||||||
|
right: string
|
||||||
|
front: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StudioAvatarGeometry = AvatarGeometry & {
|
||||||
|
pathTransforms?: AvatarPathTransforms
|
||||||
|
headsetFrontIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathValues = [...headsetSvg.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)].map(match => match[1])
|
||||||
|
|
||||||
|
if (pathValues.length !== 1) {
|
||||||
|
throw new Error(`Expected one path in headset-filled.svg, received ${pathValues.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [headsetPath] = pathValues
|
||||||
|
export const identityTransform = 'matrix(1 0 0 1 0 0)'
|
||||||
|
|
||||||
|
const identity: Matrix = [1, 0, 0, 1, 0, 0]
|
||||||
|
const translate = (x: number, y: number): Matrix => [1, 0, 0, 1, x, y]
|
||||||
|
const scale = (x: number, y: number): Matrix => [x, 0, 0, y, 0, 0]
|
||||||
|
const rotate = (degrees: number): Matrix => {
|
||||||
|
const angle = (degrees * Math.PI) / 180
|
||||||
|
const cosine = Math.cos(angle)
|
||||||
|
const sine = Math.sin(angle)
|
||||||
|
return [cosine, sine, -sine, cosine, 0, 0]
|
||||||
|
}
|
||||||
|
const skewX = (degrees: number): Matrix => [1, 0, Math.tan((degrees * Math.PI) / 180), 1, 0, 0]
|
||||||
|
const skewY = (degrees: number): Matrix => [1, Math.tan((degrees * Math.PI) / 180), 0, 1, 0, 0]
|
||||||
|
|
||||||
|
const multiply = (left: Matrix, right: Matrix): Matrix => [
|
||||||
|
left[0] * right[0] + left[2] * right[1],
|
||||||
|
left[1] * right[0] + left[3] * right[1],
|
||||||
|
left[0] * right[2] + left[2] * right[3],
|
||||||
|
left[1] * right[2] + left[3] * right[3],
|
||||||
|
left[0] * right[4] + left[2] * right[5] + left[4],
|
||||||
|
left[1] * right[4] + left[3] * right[5] + left[5],
|
||||||
|
]
|
||||||
|
|
||||||
|
const compose = (...matrices: Matrix[]) => matrices.reduce(multiply, identity)
|
||||||
|
const clamp = (value: number, minimum: number, maximum: number) =>
|
||||||
|
Math.min(maximum, Math.max(minimum, value))
|
||||||
|
const matrixValue = (matrix: Matrix) =>
|
||||||
|
`matrix(${matrix.map(value => Number(value.toFixed(5))).join(' ')})`
|
||||||
|
|
||||||
|
const headsetTransform = (pose: AvatarPose, surface: SurfaceConfig) => {
|
||||||
|
const expression = pose.expression
|
||||||
|
const horizontalTurn = clamp(expression.headY / 25, -1, 1)
|
||||||
|
const verticalTurn = clamp(expression.headX / 25, -1, 1)
|
||||||
|
const perspective = clamp(expression.perspective, 0.6, 1.4)
|
||||||
|
const horizontalScale = Math.max(0.38, Math.cos((expression.headY * Math.PI) / 180))
|
||||||
|
const verticalScale = Math.max(0.46, Math.cos((expression.headX * Math.PI) / 180))
|
||||||
|
|
||||||
|
return matrixValue(
|
||||||
|
compose(
|
||||||
|
rotate(expression.headZ),
|
||||||
|
skewY(verticalTurn * 3.2),
|
||||||
|
skewX(-horizontalTurn * 4.2),
|
||||||
|
scale(horizontalScale * perspective, verticalScale * perspective),
|
||||||
|
scale((surface.width * 1.42) / 24, (surface.height * 1.28) / 24),
|
||||||
|
translate(-12, -13.25)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderAvatarByStyle = (
|
||||||
|
pose: AvatarPose,
|
||||||
|
surface: SurfaceConfig,
|
||||||
|
blink: number,
|
||||||
|
renderStyle: AvatarRenderStyle,
|
||||||
|
options: RenderAvatarOptions = {}
|
||||||
|
): StudioAvatarGeometry => {
|
||||||
|
const geometry = renderAvatar(pose, surface, blink, options)
|
||||||
|
if (renderStyle.type !== 'vector' || renderStyle.accessory !== 'headset') return geometry
|
||||||
|
|
||||||
|
return {
|
||||||
|
...geometry,
|
||||||
|
frontPaths: [...geometry.frontPaths, headsetPath],
|
||||||
|
frontNodeIds: [...geometry.frontNodeIds, null],
|
||||||
|
headsetFrontIndex: geometry.frontPaths.length,
|
||||||
|
pathTransforms: {
|
||||||
|
back: geometry.backPaths.map(() => identityTransform),
|
||||||
|
head: identityTransform,
|
||||||
|
left: identityTransform,
|
||||||
|
right: identityTransform,
|
||||||
|
front: [...geometry.frontPaths.map(() => identityTransform), headsetTransform(pose, surface)],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||||
|
import type { MotionValue } from 'motion'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useStudioLanguage } from '@/i18n'
|
import { useStudioLanguage } from '@/i18n'
|
||||||
@ -226,6 +227,23 @@ export function RotationGizmo({
|
|||||||
|
|
||||||
type TransformAxis = 'x' | 'y' | 'z'
|
type TransformAxis = 'x' | 'y' | 'z'
|
||||||
|
|
||||||
|
export function SvgTransformPath({
|
||||||
|
svgTransform,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof motion.path>, 'transform'> & {
|
||||||
|
svgTransform: MotionValue<string>
|
||||||
|
}) {
|
||||||
|
const ref = useRef<SVGPathElement>(null)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const applyTransform = (value: string) => ref.current?.setAttribute('transform', value)
|
||||||
|
applyTransform(svgTransform.get())
|
||||||
|
return svgTransform.on('change', applyTransform)
|
||||||
|
}, [svgTransform])
|
||||||
|
|
||||||
|
return <motion.path {...props} ref={ref} />
|
||||||
|
}
|
||||||
|
|
||||||
export function BodyNodeGizmo({
|
export function BodyNodeGizmo({
|
||||||
svgRef,
|
svgRef,
|
||||||
pose,
|
pose,
|
||||||
@ -555,7 +573,9 @@ export function AvatarCanvas({
|
|||||||
const {
|
const {
|
||||||
wirePaths,
|
wirePaths,
|
||||||
backPaths,
|
backPaths,
|
||||||
|
backTransforms,
|
||||||
frontPaths,
|
frontPaths,
|
||||||
|
frontTransforms,
|
||||||
backNodeIds,
|
backNodeIds,
|
||||||
frontNodeIds,
|
frontNodeIds,
|
||||||
headPath,
|
headPath,
|
||||||
@ -823,9 +843,10 @@ export function AvatarCanvas({
|
|||||||
</defs>
|
</defs>
|
||||||
<motion.g style={{ x: offsetX, y: offsetY }}>
|
<motion.g style={{ x: offsetX, y: offsetY }}>
|
||||||
{backPaths.map((pathValue, index) => (
|
{backPaths.map((pathValue, index) => (
|
||||||
<motion.path
|
<SvgTransformPath
|
||||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
svgTransform={backTransforms[index]}
|
||||||
key={index}
|
key={index}
|
||||||
style={nodeColorStyle(backNodeIds.current[index])}
|
style={nodeColorStyle(backNodeIds.current[index])}
|
||||||
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
||||||
@ -858,9 +879,10 @@ export function AvatarCanvas({
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
{frontPaths.map((pathValue, index) => (
|
{frontPaths.map((pathValue, index) => (
|
||||||
<motion.path
|
<SvgTransformPath
|
||||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
className={`avatar-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''} ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
svgTransform={frontTransforms[index]}
|
||||||
key={index}
|
key={index}
|
||||||
style={nodeColorStyle(frontNodeIds.current[index])}
|
style={nodeColorStyle(frontNodeIds.current[index])}
|
||||||
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
||||||
|
|||||||
@ -2,17 +2,23 @@ import { motionValue, type MotionValue } from 'motion'
|
|||||||
|
|
||||||
import type { AvatarColors } from '../avatar/avatars'
|
import type { AvatarColors } from '../avatar/avatars'
|
||||||
import { MAX_BODY_NODES } from '../avatar/body'
|
import { MAX_BODY_NODES } from '../avatar/body'
|
||||||
import type { AvatarGeometry } from '../avatar/geometry'
|
import { identityTransform, type StudioAvatarGeometry } from './avatarAccessories'
|
||||||
|
|
||||||
export type RenderedScene = {
|
export type RenderedScene = {
|
||||||
headPath: MotionValue<string>
|
headPath: MotionValue<string>
|
||||||
|
headTransform: MotionValue<string>
|
||||||
backPaths: MotionValue<string>[]
|
backPaths: MotionValue<string>[]
|
||||||
|
backTransforms: MotionValue<string>[]
|
||||||
frontPaths: MotionValue<string>[]
|
frontPaths: MotionValue<string>[]
|
||||||
|
frontTransforms: MotionValue<string>[]
|
||||||
|
headsetFrontIndex: { current: number | null }
|
||||||
backNodeIds: { current: (string | null)[] }
|
backNodeIds: { current: (string | null)[] }
|
||||||
frontNodeIds: { current: (string | null)[] }
|
frontNodeIds: { current: (string | null)[] }
|
||||||
nodeOrderVersion: MotionValue<number>
|
nodeOrderVersion: MotionValue<number>
|
||||||
leftPath: MotionValue<string>
|
leftPath: MotionValue<string>
|
||||||
rightPath: MotionValue<string>
|
rightPath: MotionValue<string>
|
||||||
|
leftTransform: MotionValue<string>
|
||||||
|
rightTransform: MotionValue<string>
|
||||||
leftOpacity: MotionValue<number>
|
leftOpacity: MotionValue<number>
|
||||||
rightOpacity: MotionValue<number>
|
rightOpacity: MotionValue<number>
|
||||||
offsetX: MotionValue<number>
|
offsetX: MotionValue<number>
|
||||||
@ -25,21 +31,31 @@ export type RenderedColors = {
|
|||||||
eyes: MotionValue<string>
|
eyes: MotionValue<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyPathSlots = MAX_BODY_NODES + 2
|
const bodyPathSlots = MAX_BODY_NODES + 3
|
||||||
|
|
||||||
export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({
|
export const createRenderedScene = (geometry: StudioAvatarGeometry): RenderedScene => ({
|
||||||
headPath: motionValue(geometry.headPath),
|
headPath: motionValue(geometry.headPath),
|
||||||
|
headTransform: motionValue(geometry.pathTransforms?.head ?? identityTransform),
|
||||||
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
motionValue(geometry.backPaths[index] ?? '')
|
motionValue(geometry.backPaths[index] ?? '')
|
||||||
),
|
),
|
||||||
|
backTransforms: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
|
motionValue(geometry.pathTransforms?.back[index] ?? identityTransform)
|
||||||
|
),
|
||||||
frontPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
frontPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
motionValue(geometry.frontPaths[index] ?? '')
|
motionValue(geometry.frontPaths[index] ?? '')
|
||||||
),
|
),
|
||||||
|
frontTransforms: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
|
motionValue(geometry.pathTransforms?.front[index] ?? identityTransform)
|
||||||
|
),
|
||||||
|
headsetFrontIndex: { current: geometry.headsetFrontIndex ?? null },
|
||||||
backNodeIds: { current: geometry.backNodeIds },
|
backNodeIds: { current: geometry.backNodeIds },
|
||||||
frontNodeIds: { current: geometry.frontNodeIds },
|
frontNodeIds: { current: geometry.frontNodeIds },
|
||||||
nodeOrderVersion: motionValue(0),
|
nodeOrderVersion: motionValue(0),
|
||||||
leftPath: motionValue(geometry.leftPath),
|
leftPath: motionValue(geometry.leftPath),
|
||||||
rightPath: motionValue(geometry.rightPath),
|
rightPath: motionValue(geometry.rightPath),
|
||||||
|
leftTransform: motionValue(geometry.pathTransforms?.left ?? identityTransform),
|
||||||
|
rightTransform: motionValue(geometry.pathTransforms?.right ?? identityTransform),
|
||||||
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
||||||
rightOpacity: motionValue(geometry.rightVisible ? 1 : 0),
|
rightOpacity: motionValue(geometry.rightVisible ? 1 : 0),
|
||||||
offsetX: motionValue(0),
|
offsetX: motionValue(0),
|
||||||
@ -62,8 +78,9 @@ export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y
|
|||||||
scene.offsetY.set(offset.y)
|
scene.offsetY.set(offset.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
|
export const paintRenderedScene = (scene: RenderedScene, geometry: StudioAvatarGeometry) => {
|
||||||
scene.headPath.set(geometry.headPath)
|
scene.headPath.set(geometry.headPath)
|
||||||
|
scene.headTransform.set(geometry.pathTransforms?.head ?? identityTransform)
|
||||||
const nodeOrderChanged =
|
const nodeOrderChanged =
|
||||||
scene.backNodeIds.current.length !== geometry.backNodeIds.length ||
|
scene.backNodeIds.current.length !== geometry.backNodeIds.length ||
|
||||||
scene.frontNodeIds.current.length !== geometry.frontNodeIds.length ||
|
scene.frontNodeIds.current.length !== geometry.frontNodeIds.length ||
|
||||||
@ -71,11 +88,20 @@ export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometr
|
|||||||
scene.frontNodeIds.current.some((id, index) => id !== geometry.frontNodeIds[index])
|
scene.frontNodeIds.current.some((id, index) => id !== geometry.frontNodeIds[index])
|
||||||
scene.backNodeIds.current = geometry.backNodeIds
|
scene.backNodeIds.current = geometry.backNodeIds
|
||||||
scene.frontNodeIds.current = geometry.frontNodeIds
|
scene.frontNodeIds.current = geometry.frontNodeIds
|
||||||
|
scene.headsetFrontIndex.current = geometry.headsetFrontIndex ?? null
|
||||||
if (nodeOrderChanged) scene.nodeOrderVersion.set(scene.nodeOrderVersion.get() + 1)
|
if (nodeOrderChanged) scene.nodeOrderVersion.set(scene.nodeOrderVersion.get() + 1)
|
||||||
scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
|
scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
|
||||||
|
scene.backTransforms.forEach((transform, index) =>
|
||||||
|
transform.set(geometry.pathTransforms?.back[index] ?? identityTransform)
|
||||||
|
)
|
||||||
scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
|
scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
|
||||||
|
scene.frontTransforms.forEach((transform, index) =>
|
||||||
|
transform.set(geometry.pathTransforms?.front[index] ?? identityTransform)
|
||||||
|
)
|
||||||
scene.leftPath.set(geometry.leftPath)
|
scene.leftPath.set(geometry.leftPath)
|
||||||
scene.rightPath.set(geometry.rightPath)
|
scene.rightPath.set(geometry.rightPath)
|
||||||
|
scene.leftTransform.set(geometry.pathTransforms?.left ?? identityTransform)
|
||||||
|
scene.rightTransform.set(geometry.pathTransforms?.right ?? identityTransform)
|
||||||
scene.leftOpacity.set(geometry.leftVisible ? 1 : 0)
|
scene.leftOpacity.set(geometry.leftVisible ? 1 : 0)
|
||||||
scene.rightOpacity.set(geometry.rightVisible ? 1 : 0)
|
scene.rightOpacity.set(geometry.rightVisible ? 1 : 0)
|
||||||
scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))
|
scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))
|
||||||
|
|||||||
@ -30,6 +30,7 @@ describe('Studio document', () => {
|
|||||||
const storage = (value: string | null = null) => ({ getItem: () => value })
|
const storage = (value: string | null = null) => ({ getItem: () => value })
|
||||||
const bundledAvatarNames = [
|
const bundledAvatarNames = [
|
||||||
'Memento',
|
'Memento',
|
||||||
|
'Memento · Headset',
|
||||||
'Apple Bite',
|
'Apple Bite',
|
||||||
'Lock',
|
'Lock',
|
||||||
'Siamese',
|
'Siamese',
|
||||||
@ -79,7 +80,8 @@ describe('Studio document', () => {
|
|||||||
.filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
|
.filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
|
||||||
.every(avatar => avatar.colors.body === avatar.colors.eyes)
|
.every(avatar => avatar.colors.body === avatar.colors.eyes)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(document.library.avatars.slice(0, 13).map(avatar => avatar.body.primary.type)).toEqual([
|
expect(document.library.avatars.slice(0, 14).map(avatar => avatar.body.primary.type)).toEqual([
|
||||||
|
'ghost',
|
||||||
'ghost',
|
'ghost',
|
||||||
'apple-bite',
|
'apple-bite',
|
||||||
'lock',
|
'lock',
|
||||||
@ -224,7 +226,7 @@ describe('Studio document', () => {
|
|||||||
])
|
])
|
||||||
expect(
|
expect(
|
||||||
document.library.avatars
|
document.library.avatars
|
||||||
.filter(avatar => avatar.id !== 'primitive-ghost')
|
.filter(avatar => !['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id))
|
||||||
.every(avatar => avatar.colors.body !== '#ff9d45')
|
.every(avatar => avatar.colors.body !== '#ff9d45')
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
|
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
|
||||||
@ -242,7 +244,7 @@ describe('Studio document', () => {
|
|||||||
.filter(expression => expression.bodyColor || expression.eyeColor)
|
.filter(expression => expression.bodyColor || expression.eyeColor)
|
||||||
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
|
.map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor])
|
||||||
).toEqual([['angry-brows', '#ba3636', '#610000']])
|
).toEqual([['angry-brows', '#ba3636', '#610000']])
|
||||||
expect(document.expressions).toHaveLength(28)
|
expect(document.expressions).toHaveLength(29)
|
||||||
expect(document.sequences).toHaveLength(24)
|
expect(document.sequences).toHaveLength(24)
|
||||||
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
|
expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2)
|
||||||
expect(
|
expect(
|
||||||
@ -467,6 +469,39 @@ describe('Studio document', () => {
|
|||||||
expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc')
|
expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('adds the front-facing expression to existing base and avatar libraries', () => {
|
||||||
|
const fallback = loadStudioDocument(storage())
|
||||||
|
const withoutFront = fallback.expressions.filter(
|
||||||
|
expression => expression.semanticKey !== 'front-facing'
|
||||||
|
)
|
||||||
|
const localDocument = {
|
||||||
|
...fallback,
|
||||||
|
expressions: withoutFront,
|
||||||
|
library: {
|
||||||
|
...fallback.library,
|
||||||
|
avatars: fallback.library.avatars.map(avatar =>
|
||||||
|
avatar.behavior
|
||||||
|
? { ...avatar, behavior: { ...avatar.behavior, expressions: withoutFront } }
|
||||||
|
: avatar
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const restored = loadStudioDocument(storage(JSON.stringify(localDocument)))
|
||||||
|
const front = restored.expressions.find(expression => expression.semanticKey === 'front-facing')
|
||||||
|
|
||||||
|
expect(front).toMatchObject({ headX: 0, headY: 0, headZ: 0 })
|
||||||
|
expect(front?.widthLeft).toBe(front?.widthRight)
|
||||||
|
expect(front?.heightLeft).toBe(front?.heightRight)
|
||||||
|
expect(
|
||||||
|
restored.library.avatars
|
||||||
|
.filter(avatar => avatar.behavior)
|
||||||
|
.every(avatar =>
|
||||||
|
avatar.behavior?.expressions.some(expression => expression.semanticKey === 'front-facing')
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('adds the joyful animation to an existing local project', () => {
|
it('adds the joyful animation to an existing local project', () => {
|
||||||
const fallback = loadStudioDocument(storage())
|
const fallback = loadStudioDocument(storage())
|
||||||
const localDocument = {
|
const localDocument = {
|
||||||
|
|||||||
@ -14,10 +14,10 @@ import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorksp
|
|||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import {
|
import {
|
||||||
avatarBodyOutlineWidth,
|
avatarBodyOutlineWidth,
|
||||||
useAvatarBodyColorOverride,
|
|
||||||
useRenderedSceneNodeOrder,
|
useRenderedSceneNodeOrder,
|
||||||
} from '@/features/rendering/avatarAppearance'
|
} from '@/features/rendering/avatarAppearance'
|
||||||
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||||
|
import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas'
|
||||||
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
import type { StudioController } from '@/features/studio/useStudioController'
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
@ -35,14 +35,9 @@ function LiveAvatarPreview({
|
|||||||
useRenderedSceneNodeOrder(scene)
|
useRenderedSceneNodeOrder(scene)
|
||||||
const outlineWidth = avatarBodyOutlineWidth()
|
const outlineWidth = avatarBodyOutlineWidth()
|
||||||
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
|
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
|
||||||
const fixedSkinColors =
|
const bodyColor = expressionBodyColor ? colors.body : avatar.colors.body
|
||||||
avatar.behavior?.expressions.every(
|
const eyeColor = expressionBodyColor ? colors.eyes : avatar.colors.eyes
|
||||||
expression => !expression.bodyColor && !expression.eyeColor
|
const inheritBodyColor = Boolean(expressionBodyColor)
|
||||||
) ?? 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) =>
|
const nodeColor = (id: string | null | undefined) =>
|
||||||
inheritBodyColor
|
inheritBodyColor
|
||||||
? bodyColor
|
? bodyColor
|
||||||
@ -76,9 +71,10 @@ function LiveAvatarPreview({
|
|||||||
{scene.backPaths.map((pathValue, index) => {
|
{scene.backPaths.map((pathValue, index) => {
|
||||||
const color = nodeColor(scene.backNodeIds.current[index])
|
const color = nodeColor(scene.backNodeIds.current[index])
|
||||||
return (
|
return (
|
||||||
<motion.path
|
<SvgTransformPath
|
||||||
className="preview-head"
|
className="preview-head"
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
svgTransform={scene.backTransforms[index]}
|
||||||
key={`back-${index}`}
|
key={`back-${index}`}
|
||||||
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
|
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
|
||||||
/>
|
/>
|
||||||
@ -110,9 +106,10 @@ function LiveAvatarPreview({
|
|||||||
{scene.frontPaths.map((pathValue, index) => {
|
{scene.frontPaths.map((pathValue, index) => {
|
||||||
const color = nodeColor(scene.frontNodeIds.current[index])
|
const color = nodeColor(scene.frontNodeIds.current[index])
|
||||||
return (
|
return (
|
||||||
<motion.path
|
<SvgTransformPath
|
||||||
className="preview-head"
|
className={`preview-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''}`}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
svgTransform={scene.frontTransforms[index]}
|
||||||
key={`front-${index}`}
|
key={`front-${index}`}
|
||||||
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
|
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1276,6 +1276,29 @@
|
|||||||
"perspective": 1,
|
"perspective": 1,
|
||||||
"eyeMotion": "none",
|
"eyeMotion": "none",
|
||||||
"bodyMotion": "none"
|
"bodyMotion": "none"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "expression-front-facing",
|
||||||
|
"semanticKey": "front-facing",
|
||||||
|
"headX": 0,
|
||||||
|
"headY": 0,
|
||||||
|
"headZ": 0,
|
||||||
|
"widthLeft": 20,
|
||||||
|
"widthRight": 20,
|
||||||
|
"heightLeft": 50,
|
||||||
|
"heightRight": 50,
|
||||||
|
"curvatureLeft": 0,
|
||||||
|
"curvatureRight": 0,
|
||||||
|
"spacing": 35,
|
||||||
|
"positionXLeft": 0,
|
||||||
|
"positionXRight": 0,
|
||||||
|
"positionYLeft": -7,
|
||||||
|
"positionYRight": -7,
|
||||||
|
"leftAngle": 0,
|
||||||
|
"rightAngle": 0,
|
||||||
|
"perspective": 1,
|
||||||
|
"eyeMotion": "none",
|
||||||
|
"bodyMotion": "none"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sequences": [
|
"sequences": [
|
||||||
|
|||||||
@ -175,6 +175,7 @@ const restoreSignatureExpressions = (
|
|||||||
addMissing = true
|
addMissing = true
|
||||||
) => {
|
) => {
|
||||||
const signatureKeys = new Set(['joyful-arc'])
|
const signatureKeys = new Set(['joyful-arc'])
|
||||||
|
const additiveKeys = new Set(['joyful-arc', 'front-facing'])
|
||||||
const fallbackByKey = new Map(
|
const fallbackByKey = new Map(
|
||||||
fallback
|
fallback
|
||||||
.filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey))
|
.filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey))
|
||||||
@ -206,7 +207,7 @@ const restoreSignatureExpressions = (
|
|||||||
? fallback.filter(
|
? fallback.filter(
|
||||||
expression =>
|
expression =>
|
||||||
expression.semanticKey &&
|
expression.semanticKey &&
|
||||||
signatureKeys.has(expression.semanticKey) &&
|
additiveKeys.has(expression.semanticKey) &&
|
||||||
!existingKeys.has(expression.semanticKey)
|
!existingKeys.has(expression.semanticKey)
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
@ -214,6 +215,14 @@ const restoreSignatureExpressions = (
|
|||||||
return changed ? restored : expressions
|
return changed ? restored : expressions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoreFrontFacingExpression = (expressions: Expression[], fallback: Expression[]) => {
|
||||||
|
if (expressions.some(expression => expression.semanticKey === 'front-facing')) {
|
||||||
|
return expressions
|
||||||
|
}
|
||||||
|
const frontFacing = fallback.find(expression => expression.semanticKey === 'front-facing')
|
||||||
|
return frontFacing ? [...expressions, { ...frontFacing }] : expressions
|
||||||
|
}
|
||||||
|
|
||||||
const restoreAvatarSignatureBehavior = (
|
const restoreAvatarSignatureBehavior = (
|
||||||
library: AvatarLibrary,
|
library: AvatarLibrary,
|
||||||
fallbackExpressions: Expression[],
|
fallbackExpressions: Expression[],
|
||||||
@ -222,10 +231,9 @@ const restoreAvatarSignatureBehavior = (
|
|||||||
let changed = false
|
let changed = false
|
||||||
const avatars = library.avatars.map(avatar => {
|
const avatars = library.avatars.map(avatar => {
|
||||||
if (!avatar.behavior) return avatar
|
if (!avatar.behavior) return avatar
|
||||||
const expressions = restoreSignatureExpressions(
|
const expressions = restoreFrontFacingExpression(
|
||||||
avatar.behavior.expressions,
|
restoreSignatureExpressions(avatar.behavior.expressions, fallbackExpressions, false),
|
||||||
fallbackExpressions,
|
fallbackExpressions
|
||||||
false
|
|
||||||
)
|
)
|
||||||
const availableExpressionIds = new Set(expressions.map(expression => expression.id))
|
const availableExpressionIds = new Set(expressions.map(expression => expression.id))
|
||||||
const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful')
|
const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful')
|
||||||
|
|||||||
@ -80,7 +80,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
expressionFields,
|
expressionFields,
|
||||||
poseFromExpression,
|
poseFromExpression,
|
||||||
renderAvatar,
|
|
||||||
type AvatarPose,
|
type AvatarPose,
|
||||||
type Expression,
|
type Expression,
|
||||||
} from '@/features/avatar/geometry'
|
} from '@/features/avatar/geometry'
|
||||||
@ -119,6 +118,7 @@ import {
|
|||||||
paintRenderedScene,
|
paintRenderedScene,
|
||||||
} from '@/features/rendering/renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
|
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
|
||||||
|
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
|
||||||
import {
|
import {
|
||||||
createStudioDocumentStore,
|
createStudioDocumentStore,
|
||||||
loadStudioDocument,
|
loadStudioDocument,
|
||||||
@ -309,10 +309,11 @@ export function useStudioController() {
|
|||||||
const pose = poseFromExpression(initialExpression)
|
const pose = poseFromExpression(initialExpression)
|
||||||
return {
|
return {
|
||||||
pose,
|
pose,
|
||||||
geometry: renderAvatar(
|
geometry: renderAvatarByStyle(
|
||||||
poseWithAvatarEyes(initialExpression, initialAvatar.eyes),
|
poseWithAvatarEyes(initialExpression, initialAvatar.eyes),
|
||||||
surface,
|
surface,
|
||||||
1,
|
1,
|
||||||
|
initialAvatar.renderStyle,
|
||||||
{ bodyNodes }
|
{ bodyNodes }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@ -411,11 +412,17 @@ export function useStudioController() {
|
|||||||
const renderPose = avatar
|
const renderPose = avatar
|
||||||
? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes)
|
? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes)
|
||||||
: poseFromExpression(renderedExpression)
|
: poseFromExpression(renderedExpression)
|
||||||
const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), {
|
const geometry = renderAvatarByStyle(
|
||||||
|
renderPose,
|
||||||
|
surfaceRef.current,
|
||||||
|
blink ?? blinkValue.get(),
|
||||||
|
avatar?.renderStyle ?? { type: 'vector' },
|
||||||
|
{
|
||||||
includeWire: showWireRef.current || highlightRef.current === 'head',
|
includeWire: showWireRef.current || highlightRef.current === 'head',
|
||||||
bodyNodes: bodyNodesRef.current,
|
bodyNodes: bodyNodesRef.current,
|
||||||
eyeOffset,
|
eyeOffset,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
paintRenderedScene(renderedScene, geometry)
|
paintRenderedScene(renderedScene, geometry)
|
||||||
paintRenderedOffset(
|
paintRenderedOffset(
|
||||||
renderedScene,
|
renderedScene,
|
||||||
@ -1325,6 +1332,7 @@ export function useStudioController() {
|
|||||||
|
|
||||||
const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => {
|
const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => {
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
|
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||||
const updateInspector =
|
const updateInspector =
|
||||||
shouldSyncCanvasPreviewToReact(bodyEditing, target) &&
|
shouldSyncCanvasPreviewToReact(bodyEditing, target) &&
|
||||||
now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS
|
now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS
|
||||||
@ -1340,12 +1348,13 @@ export function useStudioController() {
|
|||||||
paintRenderedRotationGizmo(renderedRotationGizmo, next)
|
paintRenderedRotationGizmo(renderedRotationGizmo, next)
|
||||||
paintRenderedScene(
|
paintRenderedScene(
|
||||||
renderedScene,
|
renderedScene,
|
||||||
renderAvatar(
|
renderAvatarByStyle(
|
||||||
poseFromExpression(
|
poseFromExpression(
|
||||||
resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target)
|
resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target)
|
||||||
),
|
),
|
||||||
surfaceRef.current,
|
surfaceRef.current,
|
||||||
blinkValue.get(),
|
blinkValue.get(),
|
||||||
|
avatar?.renderStyle ?? { type: 'vector' },
|
||||||
{
|
{
|
||||||
includeWire: showWireRef.current || highlightRef.current === 'head',
|
includeWire: showWireRef.current || highlightRef.current === 'head',
|
||||||
bodyNodes: bodyNodesRef.current,
|
bodyNodes: bodyNodesRef.current,
|
||||||
@ -1354,7 +1363,6 @@ export function useStudioController() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
|
||||||
if (avatar) setDisplayColors(resolveColors(next, avatar.colors))
|
if (avatar) setDisplayColors(resolveColors(next, avatar.colors))
|
||||||
paintPose(poseFromExpression(next))
|
paintPose(poseFromExpression(next))
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user