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', 'trapezoid',
'frustum', 'frustum',
'half-cone', 'half-cone',
'lock',
] ]
const neutral = poseFromExpression(expressionFromDefinition('neutral', expression)) const neutral = poseFromExpression(expressionFromDefinition('neutral', expression))

View File

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

View File

@ -45,7 +45,10 @@ export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
cutAngle?: number 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 PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType> export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>

View File

@ -1345,6 +1345,56 @@ const projectedAppleBitePath = (pose: AvatarPose, surface: SurfaceConfig) => {
].join('') ].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) => { const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'sphere' || surface.type === 'mickey') { if (surface.type === 'sphere' || surface.type === 'mickey') {
const exactPath = projectedEllipsoidPath(pose, surface) const exactPath = projectedEllipsoidPath(pose, surface)
@ -1363,6 +1413,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface) if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
if (surface.type === 'ghost') return projectedGhostPath(pose, surface) if (surface.type === 'ghost') return projectedGhostPath(pose, surface)
if (surface.type === 'apple-bite') return projectedAppleBitePath(pose, surface) if (surface.type === 'apple-bite') return projectedAppleBitePath(pose, surface)
if (surface.type === 'lock') return projectedLockPath(pose, surface)
const key = surfaceCacheKey(surface) const key = surfaceCacheKey(surface)
let localSamples = headSamplesCache.get(key) let localSamples = headSamplesCache.get(key)

View File

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

View File

@ -155,6 +155,7 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
export const simpleBundledAvatarIds = [ export const simpleBundledAvatarIds = [
'primitive-ghost', 'primitive-ghost',
'primitive-apple-bite', 'primitive-apple-bite',
'primitive-lock',
'oneworks-cat', 'oneworks-cat',
'oneworks-cat-siamese', 'oneworks-cat-siamese',
'oneworks-cat-british-shorthair', 'oneworks-cat-british-shorthair',
@ -224,6 +225,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-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9],
] as const ] as const
const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => { const fixedSkinColorBehavior = (behavior?: AvatarBehaviorLibrary): AvatarBehaviorLibrary => {
@ -815,7 +817,10 @@ const primitiveBundledAvatars: StudioAvatar[] = [
}, },
colors: { colors: {
body: color, body: color,
eyes: surface === 'ghost' || surface === 'apple-bite' ? filledAvatarEyeColor : color, eyes:
surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? filledAvatarEyeColor
: color,
}, },
eyes: eyes:
surface === 'ghost' surface === 'ghost'
@ -830,14 +835,22 @@ const primitiveBundledAvatars: StudioAvatar[] = [
positionXLeft: defaultAvatarEyes.positionXLeft - 18, positionXLeft: defaultAvatarEyes.positionXLeft - 18,
positionXRight: defaultAvatarEyes.positionXRight - 18, positionXRight: defaultAvatarEyes.positionXRight - 18,
} }
: surface === 'lock'
? {
...defaultAvatarEyes,
positionYLeft: defaultAvatarEyes.positionYLeft + 22,
positionYRight: defaultAvatarEyes.positionYRight + 22,
}
: { ...defaultAvatarEyes }, : { ...defaultAvatarEyes },
renderStyle: renderStyle:
surface === 'ghost' || surface === 'apple-bite' surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? { ...filledAvatarRenderStyle } ? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle }, : { ...defaultAvatarRenderStyle },
...(surface === 'ghost' ...(surface === 'ghost'
? { behavior: mementoBehavior() } ? { behavior: mementoBehavior() }
: surface === 'apple-bite' : surface === 'apple-bite'
? { behavior: fixedSkinColorBehavior() }
: surface === 'lock'
? { behavior: fixedSkinColorBehavior() } ? { behavior: fixedSkinColorBehavior() }
: {}), : {}),
})), })),
@ -919,11 +932,13 @@ const filledBundledAvatarIds = new Set([
...Object.keys(mementoAnimalColors), ...Object.keys(mementoAnimalColors),
'primitive-ghost', 'primitive-ghost',
'primitive-apple-bite', 'primitive-apple-bite',
'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-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor }, 'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor },
} }
const clonePrimitiveBundledAvatar = (avatar: StudioAvatar): StudioAvatar => ({ 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 = [ const bundledAvatarNames = [
'Memento', 'Memento',
'Apple Bite', 'Apple Bite',
'Lock',
'Siamese', 'Siamese',
'British Shorthair', 'British Shorthair',
'Russian Blue', 'Russian Blue',
@ -78,9 +79,10 @@ 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, 12).map(avatar => avatar.body.primary.type)).toEqual([ expect(document.library.avatars.slice(0, 13).map(avatar => avatar.body.primary.type)).toEqual([
'ghost', 'ghost',
'apple-bite', 'apple-bite',
'lock',
'ellipse', 'ellipse',
'ellipse', 'ellipse',
'ellipse', 'ellipse',
@ -107,9 +109,7 @@ describe('Studio document', () => {
expect( expect(
document.library.avatars document.library.avatars
.filter(avatar => .filter(avatar =>
['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some( ['dog', 'dog-brown', 'bear', 'rabbit', 'bun'].some(id => avatar.id === `oneworks-${id}`)
id => avatar.id === `oneworks-${id}`
)
) )
.every(avatar => avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true) .every(avatar => avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true)
).toBe(true) ).toBe(true)
@ -122,12 +122,18 @@ describe('Studio document', () => {
expect( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle
).toEqual({ type: 'vector', filled: true }) ).toEqual({ type: 'vector', filled: true })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-lock')?.renderStyle
).toEqual({ type: 'vector', filled: true })
expect( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.body.primary document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')?.body.primary
).toMatchObject({ width: 225, height: 220.5, depth: 171 }) ).toMatchObject({ width: 225, height: 220.5, depth: 171 })
expect( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.body.primary document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.body.primary
).toMatchObject({ width: 216, height: 225, depth: 171 }) ).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( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.eyes
.positionXLeft .positionXLeft
@ -199,6 +205,12 @@ describe('Studio document', () => {
expect( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors
).toEqual({ body: '#ff5558', eyes: '#111316' }) ).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( expect(
document.library.avatars document.library.avatars
.find(avatar => avatar.id === 'primitive-apple-bite') .find(avatar => avatar.id === 'primitive-apple-bite')
@ -296,6 +308,7 @@ describe('Studio document', () => {
const filledIds = new Set([ const filledIds = new Set([
'primitive-ghost', 'primitive-ghost',
'primitive-apple-bite', 'primitive-apple-bite',
'primitive-lock',
...fallback.library.avatars ...fallback.library.avatars
.filter(avatar => avatar.id.startsWith('oneworks-')) .filter(avatar => avatar.id.startsWith('oneworks-'))
.map(avatar => avatar.id), .map(avatar => avatar.id),
@ -340,6 +353,10 @@ describe('Studio document', () => {
type: 'vector', type: 'vector',
filled: true, filled: true,
}) })
expect(restoredById.get('primitive-lock')?.renderStyle).toEqual({
type: 'vector',
filled: true,
})
expect(restoredById.get('primitive-ghost')?.colors).toEqual({ expect(restoredById.get('primitive-ghost')?.colors).toEqual({
body: '#ff9d45', body: '#ff9d45',
eyes: '#111316', eyes: '#111316',
@ -348,6 +365,10 @@ describe('Studio document', () => {
body: '#ff5558', body: '#ff5558',
eyes: '#111316', 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.positionXLeft).toBe(-18)
expect(restoredById.get('primitive-apple-bite')?.eyes.positionXRight).toBe(-18) expect(restoredById.get('primitive-apple-bite')?.eyes.positionXRight).toBe(-18)
expect( expect(