bible-strong-avatar-lab/src/features/rendering/__tests__/rendered-scene-test.ts
Наер Михаил Александрович 9cca5fa079 feat: expand avatar skins and expressions
2026-08-25 19:11:07 +03:00

171 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createBodyNode } from '@/features/avatar/body'
import { renderAvatar, poseFromExpression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import {
createRenderedColors,
createRenderedScene,
findBodyNodePath,
paintRenderedColors,
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)
const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
bodyNodes: [node],
})
const scene = createRenderedScene(first)
const rotated = renderAvatar(
poseFromExpression({ ...defaultExpression, headY: 35 }),
surfacePresets.sphere,
1,
{ bodyNodes: [node] }
)
paintRenderedScene(scene, rotated)
expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath)
expect(findBodyNodePath(scene, node.id)).not.toBeNull()
expect(scene.headPath.get()).toBe(rotated.headPath)
})
it('updates animated colors without replacing their motion values', () => {
const colors = createRenderedColors({ body: '#5b7fe5', eyes: '#111316' })
const body = colors.body
const eyes = colors.eyes
paintRenderedColors(colors, { body: '#c53b47', eyes: '#ffffff' })
expect(colors.body).toBe(body)
expect(colors.eyes).toBe(eyes)
expect(colors.body.get()).toBe('#c53b47')
expect(colors.eyes.get()).toBe('#ffffff')
})
it('keeps Cloudee accessories behind the eyes at expression position 05', () => {
const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')!
const expression = defaultStudioDocument.expressions[5] as Expression
const geometry = renderAvatar(
poseFromExpression(expression),
avatar.body.primary as SurfaceConfig,
1,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
expect(geometry.frontNodeIds).toEqual([])
const clearlyTurned = renderAvatar(
poseFromExpression({ ...expression, headY: -35 }),
avatar.body.primary as SurfaceConfig,
1,
{ 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([])
})
})