feat: add filled lock avatar

This commit is contained in:
Наер Михаил Александрович 2026-08-26 17:15:21 +03:00
parent edc7a6004d
commit e73ce55fb6
8 changed files with 109 additions and 12 deletions

View File

@ -110,6 +110,7 @@ describe('@bible-strong/avatar-core', () => {
'trapezoid',
'frustum',
'half-cone',
'lock',
]
const neutral = poseFromExpression(expressionFromDefinition('neutral', expression))

View File

@ -83,7 +83,8 @@
"half-cone",
"diamond",
"ghost",
"apple-bite"
"apple-bite",
"lock"
]
},
"width": { "$ref": "#/$defs/dimension" },

View File

@ -45,7 +45,10 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
cutAngle?: number
}
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor' | 'ghost' | 'apple-bite'>
export type BodyNodeSurfaceType = Exclude<
SurfaceType,
'mickey' | 'cursor' | 'ghost' | 'apple-bite' | 'lock'
>
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>

View File

@ -1345,6 +1345,56 @@ const projectedAppleBitePath = (pose: AvatarPose, surface: SurfaceConfig) => {
].join('')
}
// Adapted from the supplied 24px lock icon. The second, reverse-wound subpath
// keeps the shackle opening transparent while the character body stays filled.
const projectedLockPath = (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))}`
const x = (iconX: number) => ((iconX - 12) / 10) * halfWidth
const y = (iconY: number) => ((iconY - 12) / 10) * halfHeight
return [
// Outer silhouette: shackle and rounded lock body as one continuous shape.
move(x(7), y(9)),
line(x(7), y(7)),
cubicTo(x(7), y(4.24), x(9.24), y(2), x(12), y(2)),
cubicTo(x(14.76), y(2), x(17), y(4.24), x(17), y(7)),
line(x(17), y(9)),
line(x(18), y(9)),
cubicTo(x(20.21), y(9), x(22), y(10.79), x(22), y(13)),
line(x(22), y(18)),
cubicTo(x(22), y(20.21), x(20.21), y(22), x(18), y(22)),
line(x(6), y(22)),
cubicTo(x(3.79), y(22), x(2), y(20.21), x(2), y(18)),
line(x(2), y(13)),
cubicTo(x(2), y(10.79), x(3.79), y(9), x(6), y(9)),
line(x(7), y(9)),
'Z',
// Inner shackle opening, intentionally wound in the opposite direction.
move(x(9), y(9)),
line(x(15), y(9)),
line(x(15), y(7)),
cubicTo(x(15), y(5.34), x(13.66), y(4), x(12), y(4)),
cubicTo(x(10.34), y(4), x(9), y(5.34), x(9), y(7)),
line(x(9), y(9)),
'Z',
].join('')
}
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'sphere' || surface.type === 'mickey') {
const exactPath = projectedEllipsoidPath(pose, surface)
@ -1363,6 +1413,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
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)
if (surface.type === 'lock') return projectedLockPath(pose, surface)
const key = surfaceCacheKey(surface)
let localSamples = headSamplesCache.get(key)

View File

@ -18,6 +18,7 @@ export type SurfaceType =
| 'diamond'
| 'ghost'
| 'apple-bite'
| 'lock'
export type SurfaceConfig = {
type: SurfaceType
@ -91,6 +92,7 @@ export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
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 },
lock: { type: 'lock', width: 250, height: 250, depth: 190, roundness: 1 },
}
export const surfaceLabels: Record<SurfaceType, string> = {
@ -111,6 +113,7 @@ export const surfaceLabels: Record<SurfaceType, string> = {
diamond: 'Diamant',
ghost: 'Ghost',
'apple-bite': 'Apple Bite',
lock: 'Lock',
}
const signedPower = (value: number, exponent: number) =>
@ -482,6 +485,7 @@ export const surfacePointAt = (
case 'capsule':
case 'ghost':
case 'apple-bite':
case 'lock':
return capsule(config, longitude, latitude)
case 'cone': {
const progress = (latitude + Math.PI / 2) / Math.PI
@ -751,7 +755,8 @@ export const surfaceFrontSampleAt = (
case 'capsule':
case 'ghost':
case 'apple-bite': {
case 'apple-bite':
case 'lock': {
const capRadiusY = Math.min(radiusX, radiusY)
const straightHalf = Math.max(0, radiusY - capRadiusY)
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y

View File

@ -155,6 +155,7 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
export const simpleBundledAvatarIds = [
'primitive-ghost',
'primitive-apple-bite',
'primitive-lock',
'oneworks-cat',
'oneworks-cat-siamese',
'oneworks-cat-british-shorthair',
@ -224,6 +225,7 @@ const primitiveBundledAvatarSpecs = [
['primitive-diamond', 'Diamond', 'diamond', deslopAccentColors.pink, 1],
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
['primitive-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9],
] as const
const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
@ -815,7 +817,10 @@ const primitiveBundledAvatars: StudioAvatar[] = [
},
colors: {
body: color,
eyes: surface === 'ghost' || surface === 'apple-bite' ? filledAvatarEyeColor : color,
eyes:
surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? filledAvatarEyeColor
: color,
},
eyes:
surface === 'ghost'
@ -830,16 +835,24 @@ const primitiveBundledAvatars: StudioAvatar[] = [
positionXLeft: defaultAvatarEyes.positionXLeft - 18,
positionXRight: defaultAvatarEyes.positionXRight - 18,
}
: { ...defaultAvatarEyes },
: surface === 'lock'
? {
...defaultAvatarEyes,
positionYLeft: defaultAvatarEyes.positionYLeft + 22,
positionYRight: defaultAvatarEyes.positionYRight + 22,
}
: { ...defaultAvatarEyes },
renderStyle:
surface === 'ghost' || surface === 'apple-bite'
surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle },
...(surface === 'ghost'
? { behavior: mementoBehavior() }
: surface === 'apple-bite'
? { behavior: fixedSkinColorBehavior() }
: {}),
: surface === 'lock'
? { behavior: fixedSkinColorBehavior() }
: {}),
})),
mementoPigletAvatar,
{
@ -919,11 +932,13 @@ const filledBundledAvatarIds = new Set([
...Object.keys(mementoAnimalColors),
'primitive-ghost',
'primitive-apple-bite',
'primitive-lock',
])
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 },
'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor },
}
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,7 @@ describe('Studio document', () => {
const bundledAvatarNames = [
'Memento',
'Apple Bite',
'Lock',
'Siamese',
'British Shorthair',
'Russian Blue',
@ -78,9 +79,10 @@ describe('Studio document', () => {
.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([
expect(document.library.avatars.slice(0, 13).map(avatar => avatar.body.primary.type)).toEqual([
'ghost',
'apple-bite',
'lock',
'ellipse',
'ellipse',
'ellipse',
@ -107,9 +109,7 @@ describe('Studio document', () => {
expect(
document.library.avatars
.filter(avatar =>
['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some(
id => avatar.id === `oneworks-${id}`
)
['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some(id => avatar.id === `oneworks-${id}`)
)
.every(avatar => avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true)
).toBe(true)
@ -122,12 +122,18 @@ describe('Studio document', () => {
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-lock')?.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-lock')?.body.primary
).toMatchObject({ type: 'lock', width: 225, height: 225, depth: 171 })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes
.positionXLeft
@ -199,6 +205,12 @@ describe('Studio document', () => {
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-lock')?.colors).toEqual(
{ body: '#1a9cff', eyes: '#111316' }
)
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-lock')?.eyes.positionYLeft
).toBe(defaultAvatarEyes.positionYLeft + 22)
expect(
document.library.avatars
.find(avatar => avatar.id === 'primitive-apple-bite')
@ -296,6 +308,7 @@ describe('Studio document', () => {
const filledIds = new Set([
'primitive-ghost',
'primitive-apple-bite',
'primitive-lock',
...fallback.library.avatars
.filter(avatar => avatar.id.startsWith('oneworks-'))
.map(avatar => avatar.id),
@ -340,6 +353,10 @@ describe('Studio document', () => {
type: 'vector',
filled: true,
})
expect(restoredById.get('primitive-lock')?.renderStyle).toEqual({
type: 'vector',
filled: true,
})
expect(restoredById.get('primitive-ghost')?.colors).toEqual({
body: '#ff9d45',
eyes: '#111316',
@ -348,6 +365,10 @@ describe('Studio document', () => {
body: '#ff5558',
eyes: '#111316',
})
expect(restoredById.get('primitive-lock')?.colors).toEqual({
body: '#1a9cff',
eyes: '#111316',
})
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXLeft).toBe(-18)
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXRight).toBe(-18)
expect(