feat(studio): independent body-part colors and double-click to edit body
- Add an optional per-node color so each shape added to the body can be colored independently from the body color. - Surface the color picker in the body construction panel, with a reset to inherit the body color. - Paint secondary primitive fills as motion values so they stay in sync with the geometry while the camera rotates, across avatar switches and on the exported snapshot / runtime renderers. - Preserve the node color through export and import of .avatar.json. - Double-click the 3D model to enter body construction editing (when not already editing).
This commit is contained in:
parent
79fe9ba06e
commit
767f5191e9
@ -54,7 +54,7 @@ manipulation pauses it.
|
|||||||
- Duplicating an Avatar duplicates its Avatar behavior library when one exists.
|
- Duplicating an Avatar duplicates its Avatar behavior library when one exists.
|
||||||
- Transferring an Animation must also transfer every Expression it references.
|
- Transferring an Animation must also transfer every Expression it references.
|
||||||
- The primary body shape carries the facial coordinate system and eyes.
|
- The primary body shape carries the facial coordinate system and eyes.
|
||||||
- Secondary primitives have independent local dimensions, position and rotation.
|
- Secondary primitives have independent local dimensions, position, rotation and color.
|
||||||
- Expressions remain compatible across body surfaces because they operate in the common facial frame.
|
- Expressions remain compatible across body surfaces because they operate in the common facial frame.
|
||||||
- `features/avatar/geometry.ts` and the exported procedural engine stay independent from React.
|
- `features/avatar/geometry.ts` and the exported procedural engine stay independent from React.
|
||||||
- `features/studio/defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required.
|
- `features/studio/defaultStudioDocument.json` is the current schema baseline; pre-release legacy migrations are not required.
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
bodyFromDefinition,
|
bodyFromDefinition,
|
||||||
createAvatarPlaybackState,
|
createAvatarPlaybackState,
|
||||||
expressionFromDefinition,
|
expressionFromDefinition,
|
||||||
|
parseAvatarBody,
|
||||||
parseAvatarDefinition,
|
parseAvatarDefinition,
|
||||||
playAvatarAnimation,
|
playAvatarAnimation,
|
||||||
pauseAvatarPlayback,
|
pauseAvatarPlayback,
|
||||||
@ -386,4 +387,54 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes a node color independently from the body color on the scene', () => {
|
||||||
|
const withNode: AvatarDefinition = {
|
||||||
|
...definition,
|
||||||
|
body: {
|
||||||
|
primary: definition.body.primary,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
surface: { type: 'sphere', width: 80, height: 80, depth: 80, roundness: 1 },
|
||||||
|
position: [90, -70, -20],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
color: '#ff0000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: { type: 'cube', width: 60, height: 60, depth: 60, roundness: 0 },
|
||||||
|
position: [-90, -70, -20],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const parsed = parseAvatarDefinition(JSON.stringify(withNode))
|
||||||
|
expect(parsed.ok).toBe(true)
|
||||||
|
if (!parsed.ok) return
|
||||||
|
const scene = renderAvatarDefinition(parsed.value, 'neutral')
|
||||||
|
expect(scene.colors.body).toBe(definition.colors.body)
|
||||||
|
expect(scene.nodeColors['runtime-node-0']).toBe('#ff0000')
|
||||||
|
// A node without a color stays on the body color.
|
||||||
|
expect(scene.nodeColors['runtime-node-1']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a node color that is not a valid hex color', () => {
|
||||||
|
const body = parseAvatarBody(
|
||||||
|
{
|
||||||
|
primary: definition.body.primary,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'shape-1',
|
||||||
|
name: 'Shape',
|
||||||
|
surface: { type: 'sphere', width: 80, height: 80, depth: 80, roundness: 1 },
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
color: 'not-a-color',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
definition.body.primary
|
||||||
|
)
|
||||||
|
expect(body.nodes[0].color).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -124,7 +124,8 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"surface": { "$ref": "#/$defs/nodeSurface" },
|
"surface": { "$ref": "#/$defs/nodeSurface" },
|
||||||
"position": { "$ref": "#/$defs/position" },
|
"position": { "$ref": "#/$defs/position" },
|
||||||
"rotation": { "$ref": "#/$defs/rotation" }
|
"rotation": { "$ref": "#/$defs/rotation" },
|
||||||
|
"color": { "$ref": "#/$defs/hexColor" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,8 @@ export type AvatarBodyNodeDefinition = {
|
|||||||
surface: BodyNodeSurfaceDefinition
|
surface: BodyNodeSurfaceDefinition
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
rotation: [number, number, number]
|
rotation: [number, number, number]
|
||||||
|
/** Optional independent color; when omitted the node inherits the body color. */
|
||||||
|
color?: HexColor
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarBodyDefinition = {
|
export type AvatarBodyDefinition = {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ export type BodyNode = {
|
|||||||
surface: SurfaceConfig
|
surface: SurfaceConfig
|
||||||
position: BodyVector
|
position: BodyVector
|
||||||
rotation: BodyVector
|
rotation: BodyVector
|
||||||
|
/** Optional independent color; when omitted the node inherits the body color. */
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarBody = {
|
export type AvatarBody = {
|
||||||
@ -31,6 +33,7 @@ const finite = (value: unknown): value is number =>
|
|||||||
typeof value === 'number' && Number.isFinite(value)
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
const vector = (value: unknown): value is BodyVector =>
|
const vector = (value: unknown): value is BodyVector =>
|
||||||
Array.isArray(value) && value.length === 3 && value.every(finite)
|
Array.isArray(value) && value.length === 3 && value.every(finite)
|
||||||
|
const hexColor = /^#[0-9a-f]{6}$/i
|
||||||
|
|
||||||
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||||
if (!value || typeof value !== 'object') return { ...fallback }
|
if (!value || typeof value !== 'object') return { ...fallback }
|
||||||
@ -78,10 +81,16 @@ export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig):
|
|||||||
return valid
|
return valid
|
||||||
})
|
})
|
||||||
.slice(0, MAX_BODY_NODES)
|
.slice(0, MAX_BODY_NODES)
|
||||||
.map(node => ({
|
.map(node => {
|
||||||
...node,
|
const parsed: BodyNode = {
|
||||||
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
...node,
|
||||||
}))
|
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||||
|
}
|
||||||
|
const color = (node as BodyNode).color
|
||||||
|
if (typeof color === 'string' && hexColor.test(color)) parsed.color = color
|
||||||
|
else delete parsed.color
|
||||||
|
return parsed
|
||||||
|
})
|
||||||
: []
|
: []
|
||||||
return { primary, nodes }
|
return { primary, nodes }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,12 +42,15 @@ export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
|
|||||||
surface: { ...node.surface },
|
surface: { ...node.surface },
|
||||||
position: [...node.position],
|
position: [...node.position],
|
||||||
rotation: [...node.rotation],
|
rotation: [...node.rotation],
|
||||||
|
...(node.color ? { color: node.color } : {}),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AvatarScene = {
|
export type AvatarScene = {
|
||||||
geometry: AvatarGeometry
|
geometry: AvatarGeometry
|
||||||
colors: { body: string; eyes: string }
|
colors: { body: string; eyes: string }
|
||||||
|
/** Custom colors keyed by runtime node id; absent nodes inherit the body color. */
|
||||||
|
nodeColors: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderAvatarExpression = (
|
export const renderAvatarExpression = (
|
||||||
@ -57,6 +60,10 @@ export const renderAvatarExpression = (
|
|||||||
blink = 1
|
blink = 1
|
||||||
): AvatarScene => {
|
): AvatarScene => {
|
||||||
const body = bodyFromDefinition(definition.body)
|
const body = bodyFromDefinition(definition.body)
|
||||||
|
const nodeColors: Record<string, string> = {}
|
||||||
|
body.nodes.forEach(node => {
|
||||||
|
if (node.color) nodeColors[node.id] = node.color
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
geometry: renderAvatar(poseFromExpression(expression), body.primary, blink, {
|
geometry: renderAvatar(poseFromExpression(expression), body.primary, blink, {
|
||||||
bodyNodes: body.nodes,
|
bodyNodes: body.nodes,
|
||||||
@ -65,6 +72,7 @@ export const renderAvatarExpression = (
|
|||||||
body: colors.body ?? expression.bodyColor ?? definition.colors.body,
|
body: colors.body ?? expression.bodyColor ?? definition.colors.body,
|
||||||
eyes: colors.eyes ?? expression.eyeColor ?? definition.colors.eyes,
|
eyes: colors.eyes ?? expression.eyeColor ?? definition.colors.eyes,
|
||||||
},
|
},
|
||||||
|
nodeColors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -221,11 +221,19 @@ export function Avatar({
|
|||||||
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
|
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
|
||||||
backPathRefs.current.forEach((element, index) => {
|
backPathRefs.current.forEach((element, index) => {
|
||||||
element?.setAttribute('d', frameScene.geometry.backPaths[index] ?? '')
|
element?.setAttribute('d', frameScene.geometry.backPaths[index] ?? '')
|
||||||
element?.setAttribute('fill', frameScene.colors.body)
|
const nodeId = frameScene.geometry.backNodeIds[index]
|
||||||
|
element?.setAttribute(
|
||||||
|
'fill',
|
||||||
|
nodeId ? (frameScene.nodeColors[nodeId] ?? frameScene.colors.body) : frameScene.colors.body
|
||||||
|
)
|
||||||
})
|
})
|
||||||
frontPathRefs.current.forEach((element, index) => {
|
frontPathRefs.current.forEach((element, index) => {
|
||||||
element?.setAttribute('d', frameScene.geometry.frontPaths[index] ?? '')
|
element?.setAttribute('d', frameScene.geometry.frontPaths[index] ?? '')
|
||||||
element?.setAttribute('fill', frameScene.colors.body)
|
const nodeId = frameScene.geometry.frontNodeIds[index]
|
||||||
|
element?.setAttribute(
|
||||||
|
'fill',
|
||||||
|
nodeId ? (frameScene.nodeColors[nodeId] ?? frameScene.colors.body) : frameScene.colors.body
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -493,7 +501,11 @@ export function Avatar({
|
|||||||
backPathRefs.current[index] = element
|
backPathRefs.current[index] = element
|
||||||
}}
|
}}
|
||||||
d={scene.geometry.backPaths[index] ?? ''}
|
d={scene.geometry.backPaths[index] ?? ''}
|
||||||
fill={scene.colors.body}
|
fill={
|
||||||
|
scene.geometry.backNodeIds[index]
|
||||||
|
? (scene.nodeColors[scene.geometry.backNodeIds[index]!] ?? scene.colors.body)
|
||||||
|
: scene.colors.body
|
||||||
|
}
|
||||||
key={`back-${index}`}
|
key={`back-${index}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -516,7 +528,11 @@ export function Avatar({
|
|||||||
frontPathRefs.current[index] = element
|
frontPathRefs.current[index] = element
|
||||||
}}
|
}}
|
||||||
d={scene.geometry.frontPaths[index] ?? ''}
|
d={scene.geometry.frontPaths[index] ?? ''}
|
||||||
fill={scene.colors.body}
|
fill={
|
||||||
|
scene.geometry.frontNodeIds[index]
|
||||||
|
? (scene.nodeColors[scene.geometry.frontNodeIds[index]!] ?? scene.colors.body)
|
||||||
|
: scene.colors.body
|
||||||
|
}
|
||||||
key={`front-${index}`}
|
key={`front-${index}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -157,11 +157,19 @@ export function createAvatar(
|
|||||||
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
||||||
backPaths.forEach((element, index) => {
|
backPaths.forEach((element, index) => {
|
||||||
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
|
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
|
||||||
element.setAttribute('fill', scene.colors.body)
|
const nodeId = scene.geometry.backNodeIds[index]
|
||||||
|
element.setAttribute(
|
||||||
|
'fill',
|
||||||
|
nodeId ? (scene.nodeColors[nodeId] ?? scene.colors.body) : scene.colors.body
|
||||||
|
)
|
||||||
})
|
})
|
||||||
frontPaths.forEach((element, index) => {
|
frontPaths.forEach((element, index) => {
|
||||||
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
|
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
|
||||||
element.setAttribute('fill', scene.colors.body)
|
const nodeId = scene.geometry.frontNodeIds[index]
|
||||||
|
element.setAttribute(
|
||||||
|
'fill',
|
||||||
|
nodeId ? (scene.nodeColors[nodeId] ?? scene.colors.body) : scene.colors.body
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2902,6 +2902,11 @@ p {
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
.body-node-color-panel {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #eceef2;
|
||||||
|
}
|
||||||
.body-transform-grid {
|
.body-transform-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const avatarFixture = (): StudioAvatar => ({
|
|||||||
surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
|
surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
|
||||||
position: [1.25, -2.5, 3.75],
|
position: [1.25, -2.5, 3.75],
|
||||||
rotation: [-10, 20, 30],
|
rotation: [-10, 20, 30],
|
||||||
|
color: '#ff0000',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -542,6 +543,7 @@ describe('Studio to avatar definition conversion', () => {
|
|||||||
surface: avatar.body.nodes[0].surface,
|
surface: avatar.body.nodes[0].surface,
|
||||||
position: avatar.body.nodes[0].position,
|
position: avatar.body.nodes[0].position,
|
||||||
rotation: avatar.body.nodes[0].rotation,
|
rotation: avatar.body.nodes[0].rotation,
|
||||||
|
color: avatar.body.nodes[0].color,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
expect(result.value.body.nodes[0]).not.toHaveProperty('id')
|
expect(result.value.body.nodes[0]).not.toHaveProperty('id')
|
||||||
|
|||||||
@ -142,4 +142,31 @@ describe('studioAvatarFromDefinition', () => {
|
|||||||
'JSON input exceeds'
|
'JSON input exceeds'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves an independent node color through import and re-export', () => {
|
||||||
|
const withColor = structuredClone(strobi) as AvatarDefinition
|
||||||
|
if (withColor.body.nodes.length === 0) {
|
||||||
|
withColor.body.nodes = [
|
||||||
|
{
|
||||||
|
surface: { type: 'sphere', width: 40, height: 40, depth: 40, roundness: 1 },
|
||||||
|
position: [80, -70, -20],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
color: '#ff0000',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
withColor.body.nodes[0].color = '#ff0000'
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = studioAvatarFromDefinition(withColor)
|
||||||
|
expect(imported.avatar.body.nodes.some(node => node.color === '#ff0000')).toBe(true)
|
||||||
|
|
||||||
|
const result = createAvatarDefinition({
|
||||||
|
avatar: imported.avatar,
|
||||||
|
behavior: { expressions: imported.expressions, sequences: imported.sequences },
|
||||||
|
})
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
expect(result.value.body.nodes.find(node => node.color === '#ff0000')).toBeDefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -180,6 +180,7 @@ export const createAvatarDefinition = ({
|
|||||||
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
|
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
|
||||||
position: [...node.position],
|
position: [...node.position],
|
||||||
rotation: [...node.rotation],
|
rotation: [...node.rotation],
|
||||||
|
...(node.color ? { color: node.color as HexColor } : {}),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@ -236,6 +236,7 @@ export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefini
|
|||||||
surface: node.surface,
|
surface: node.surface,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
rotation: node.rotation,
|
rotation: node.rotation,
|
||||||
|
...(node.color ? { color: node.color } : {}),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
colors: definition.colors,
|
colors: definition.colors,
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
|
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene'
|
import {
|
||||||
|
createRenderedScene,
|
||||||
|
paintRenderedNodeColors,
|
||||||
|
paintRenderedOffset,
|
||||||
|
} from '@/features/rendering/renderedScene'
|
||||||
import {
|
import {
|
||||||
serializeAvatarSnapshot,
|
serializeAvatarSnapshot,
|
||||||
serializePixelSnapshot,
|
serializePixelSnapshot,
|
||||||
@ -59,6 +63,30 @@ describe('avatar snapshot export', () => {
|
|||||||
expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png')
|
expect(snapshotFileName('Étoile du soir', 'png')).toBe('etoile-du-soir-snapshot.png')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('applies an independent node color to the exported shape', () => {
|
||||||
|
const node = {
|
||||||
|
id: 'shape-1',
|
||||||
|
name: 'Shape',
|
||||||
|
surface: { ...surfacePresets.sphere, width: 80, height: 80, depth: 80 },
|
||||||
|
position: [90, -70, 40] as [number, number, number],
|
||||||
|
rotation: [0, 0, 0] as [number, number, number],
|
||||||
|
}
|
||||||
|
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
|
||||||
|
bodyNodes: [node],
|
||||||
|
})
|
||||||
|
const scene = createRenderedScene(geometry)
|
||||||
|
paintRenderedNodeColors(scene, colors.body, { 'shape-1': '#ff0000' })
|
||||||
|
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
|
||||||
|
background: 'transparent',
|
||||||
|
colorFrom: '#ffffff',
|
||||||
|
colorTo: '#000000',
|
||||||
|
size: 512,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(geometry.frontNodeIds).toContain('shape-1')
|
||||||
|
expect(svg).toContain('fill="#ff0000"')
|
||||||
|
})
|
||||||
|
|
||||||
it('embeds a pixel snapshot as a self-contained raster SVG', () => {
|
it('embeds a pixel snapshot as a self-contained raster SVG', () => {
|
||||||
const svg = serializePixelSnapshot('Pixel & Co', 'data:image/png;base64,AAAA', 256)
|
const svg = serializePixelSnapshot('Pixel & Co', 'data:image/png;base64,AAAA', 256)
|
||||||
|
|
||||||
|
|||||||
@ -62,21 +62,21 @@ 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 ? [path(value, scene.backNodeFills[index].get())] : []
|
||||||
})
|
})
|
||||||
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 ? [path(value, scene.frontNodeFills[index].get())] : []
|
||||||
})
|
})
|
||||||
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,
|
||||||
path(headPath, colors.body),
|
path(headPath, colors.body),
|
||||||
`<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>`,
|
`<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)),
|
...frontPaths,
|
||||||
].join('')
|
].join('')
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import {
|
|||||||
createRenderedColors,
|
createRenderedColors,
|
||||||
createRenderedScene,
|
createRenderedScene,
|
||||||
findBodyNodePath,
|
findBodyNodePath,
|
||||||
|
nodeColorMap,
|
||||||
paintRenderedColors,
|
paintRenderedColors,
|
||||||
|
paintRenderedNodeColors,
|
||||||
paintRenderedScene,
|
paintRenderedScene,
|
||||||
} from '@/features/rendering/renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||||
@ -48,6 +50,21 @@ describe('rendered avatar scene', () => {
|
|||||||
expect(colors.eyes.get()).toBe('#ffffff')
|
expect(colors.eyes.get()).toBe('#ffffff')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('paints an independent node color into the matching node fill motion value', () => {
|
||||||
|
const node: BodyNode = { ...createBodyNode('sphere', 0), color: '#ff0000' }
|
||||||
|
const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {
|
||||||
|
bodyNodes: [node],
|
||||||
|
})
|
||||||
|
const scene = createRenderedScene(geometry)
|
||||||
|
paintRenderedScene(scene, geometry)
|
||||||
|
paintRenderedNodeColors(scene, '#5b7fe5', nodeColorMap([node]))
|
||||||
|
|
||||||
|
const slot = scene.frontNodeIds.current.includes(node.id)
|
||||||
|
? scene.frontNodeFills[scene.frontNodeIds.current.indexOf(node.id)]
|
||||||
|
: scene.backNodeFills[scene.backNodeIds.current.indexOf(node.id)]
|
||||||
|
expect(slot.get()).toBe('#ff0000')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps Cloudee accessories behind the eyes at expression position 05', () => {
|
it('keeps Cloudee accessories behind the eyes at expression position 05', () => {
|
||||||
const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')!
|
const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')!
|
||||||
const expression = defaultStudioDocument.expressions[5] as Expression
|
const expression = defaultStudioDocument.expressions[5] as Expression
|
||||||
|
|||||||
@ -518,6 +518,7 @@ export function AvatarCanvas({
|
|||||||
onEyeChange,
|
onEyeChange,
|
||||||
playback,
|
playback,
|
||||||
onManipulationStart,
|
onManipulationStart,
|
||||||
|
onEnterBodyEdit,
|
||||||
}: {
|
}: {
|
||||||
expression: Expression
|
expression: Expression
|
||||||
avatarEyes: AvatarEyeDefaults
|
avatarEyes: AvatarEyeDefaults
|
||||||
@ -544,6 +545,7 @@ export function AvatarCanvas({
|
|||||||
onEyeChange?: (next: Expression) => void
|
onEyeChange?: (next: Expression) => void
|
||||||
playback: { name: string; status: Exclude<PlaybackStatus, 'stopped'> } | null
|
playback: { name: string; status: Exclude<PlaybackStatus, 'stopped'> } | null
|
||||||
onManipulationStart: () => Expression
|
onManipulationStart: () => Expression
|
||||||
|
onEnterBodyEdit?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useStudioLanguage()
|
const { t } = useStudioLanguage()
|
||||||
const {
|
const {
|
||||||
@ -552,6 +554,8 @@ export function AvatarCanvas({
|
|||||||
frontPaths,
|
frontPaths,
|
||||||
backNodeIds,
|
backNodeIds,
|
||||||
frontNodeIds,
|
frontNodeIds,
|
||||||
|
backNodeFills,
|
||||||
|
frontNodeFills,
|
||||||
headPath,
|
headPath,
|
||||||
leftPath,
|
leftPath,
|
||||||
rightPath,
|
rightPath,
|
||||||
@ -802,6 +806,7 @@ export function AvatarCanvas({
|
|||||||
onPointerMove={move}
|
onPointerMove={move}
|
||||||
onPointerUp={commitDrag}
|
onPointerUp={commitDrag}
|
||||||
onPointerCancel={cancelDrag}
|
onPointerCancel={cancelDrag}
|
||||||
|
onDoubleClick={onEnterBodyEdit}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="avatar-head-clip">
|
<clipPath id="avatar-head-clip">
|
||||||
@ -811,8 +816,9 @@ export function AvatarCanvas({
|
|||||||
<motion.g style={{ x: offsetX, y: offsetY }}>
|
<motion.g style={{ x: offsetX, y: offsetY }}>
|
||||||
{backPaths.map((pathValue, index) => (
|
{backPaths.map((pathValue, index) => (
|
||||||
<motion.path
|
<motion.path
|
||||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
className={highlight === 'head' ? 'cyan-outline' : undefined}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
fill={backNodeFills[index]}
|
||||||
key={index}
|
key={index}
|
||||||
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
||||||
/>
|
/>
|
||||||
@ -845,8 +851,9 @@ export function AvatarCanvas({
|
|||||||
</g>
|
</g>
|
||||||
{frontPaths.map((pathValue, index) => (
|
{frontPaths.map((pathValue, index) => (
|
||||||
<motion.path
|
<motion.path
|
||||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
className={highlight === 'head' ? 'cyan-outline' : undefined}
|
||||||
d={pathValue}
|
d={pathValue}
|
||||||
|
fill={frontNodeFills[index]}
|
||||||
key={index}
|
key={index}
|
||||||
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export type RenderedScene = {
|
|||||||
frontPaths: MotionValue<string>[]
|
frontPaths: MotionValue<string>[]
|
||||||
backNodeIds: { current: (string | null)[] }
|
backNodeIds: { current: (string | null)[] }
|
||||||
frontNodeIds: { current: (string | null)[] }
|
frontNodeIds: { current: (string | null)[] }
|
||||||
|
backNodeFills: MotionValue<string>[]
|
||||||
|
frontNodeFills: MotionValue<string>[]
|
||||||
leftPath: MotionValue<string>
|
leftPath: MotionValue<string>
|
||||||
rightPath: MotionValue<string>
|
rightPath: MotionValue<string>
|
||||||
leftOpacity: MotionValue<number>
|
leftOpacity: MotionValue<number>
|
||||||
@ -25,8 +27,12 @@ export type RenderedColors = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bodyPathSlots = MAX_BODY_NODES + 2
|
const bodyPathSlots = MAX_BODY_NODES + 2
|
||||||
|
const DEFAULT_BODY_COLOR = '#5b7fe5'
|
||||||
|
|
||||||
export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({
|
export const createRenderedScene = (
|
||||||
|
geometry: AvatarGeometry,
|
||||||
|
initialBodyColor = DEFAULT_BODY_COLOR
|
||||||
|
): RenderedScene => ({
|
||||||
headPath: motionValue(geometry.headPath),
|
headPath: motionValue(geometry.headPath),
|
||||||
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||||
motionValue(geometry.backPaths[index] ?? '')
|
motionValue(geometry.backPaths[index] ?? '')
|
||||||
@ -36,6 +42,8 @@ export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene =>
|
|||||||
),
|
),
|
||||||
backNodeIds: { current: geometry.backNodeIds },
|
backNodeIds: { current: geometry.backNodeIds },
|
||||||
frontNodeIds: { current: geometry.frontNodeIds },
|
frontNodeIds: { current: geometry.frontNodeIds },
|
||||||
|
backNodeFills: Array.from({ length: bodyPathSlots }, () => motionValue(initialBodyColor)),
|
||||||
|
frontNodeFills: Array.from({ length: bodyPathSlots }, () => motionValue(initialBodyColor)),
|
||||||
leftPath: motionValue(geometry.leftPath),
|
leftPath: motionValue(geometry.leftPath),
|
||||||
rightPath: motionValue(geometry.rightPath),
|
rightPath: motionValue(geometry.rightPath),
|
||||||
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
||||||
@ -73,6 +81,32 @@ export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometr
|
|||||||
scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))
|
scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeColorMap = Record<string, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints the fill of each secondary primitive slot from its current node id.
|
||||||
|
* Reads `scene.backNodeIds`/`frontNodeIds` (kept in sync by `paintRenderedScene`)
|
||||||
|
* so the fills stay aligned with the geometry even when nodes reorder or move
|
||||||
|
* between the back and front layers while the camera rotates.
|
||||||
|
*/
|
||||||
|
export const paintRenderedNodeColors = (
|
||||||
|
scene: RenderedScene,
|
||||||
|
bodyColor: string,
|
||||||
|
nodeColors: NodeColorMap = {}
|
||||||
|
) => {
|
||||||
|
scene.backNodeFills.forEach((fill, index) => {
|
||||||
|
const nodeId = scene.backNodeIds.current[index]
|
||||||
|
fill.set(nodeId ? (nodeColors[nodeId] ?? bodyColor) : bodyColor)
|
||||||
|
})
|
||||||
|
scene.frontNodeFills.forEach((fill, index) => {
|
||||||
|
const nodeId = scene.frontNodeIds.current[index]
|
||||||
|
fill.set(nodeId ? (nodeColors[nodeId] ?? bodyColor) : bodyColor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeColorMap = (nodes: { id: string; color?: string }[]): NodeColorMap =>
|
||||||
|
Object.fromEntries(nodes.filter(node => node.color).map(node => [node.id, node.color!]))
|
||||||
|
|
||||||
export const findBodyNodePath = (scene: RenderedScene, selectedBodyNodeId: 'primary' | string) => {
|
export const findBodyNodePath = (scene: RenderedScene, selectedBodyNodeId: 'primary' | string) => {
|
||||||
if (selectedBodyNodeId === 'primary') return scene.headPath
|
if (selectedBodyNodeId === 'primary') return scene.headPath
|
||||||
const backIndex = scene.backNodeIds.current.indexOf(selectedBodyNodeId)
|
const backIndex = scene.backNodeIds.current.indexOf(selectedBodyNodeId)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ChevronDown, Plus } from 'lucide-react'
|
import { ChevronDown, Plus, RotateCcw } from 'lucide-react'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
import { InspectorCard, PanelTitle } from '@/app/components/common'
|
import { InspectorCard, PanelTitle } from '@/app/components/common'
|
||||||
import { NumericField } from '@/app/components/controls'
|
import { ColorField, NumericField } from '@/app/components/controls'
|
||||||
import { getPreviewGeometry, scaleSurface } from '@/app/studio-utils'
|
import { getPreviewGeometry, scaleSurface } from '@/app/studio-utils'
|
||||||
import { bodyPrimitiveTypes, MAX_BODY_NODES, type BodyNode } from '@/features/avatar/body'
|
import { bodyPrimitiveTypes, MAX_BODY_NODES, type BodyNode } from '@/features/avatar/body'
|
||||||
import { SurfaceThumbnail } from '@/features/avatar/components/ExpressionWorkspace'
|
import { SurfaceThumbnail } from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
@ -72,6 +72,7 @@ export function BodyConstructionAccordion({
|
|||||||
}) {
|
}) {
|
||||||
const [addOpen, setAddOpen] = useState(false)
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
const {
|
const {
|
||||||
|
activeAvatar,
|
||||||
addBodyNode,
|
addBodyNode,
|
||||||
bodyNodes,
|
bodyNodes,
|
||||||
deleteSelectedBodyNode,
|
deleteSelectedBodyNode,
|
||||||
@ -382,6 +383,37 @@ export function BodyConstructionAccordion({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="color-panel body-node-color-panel">
|
||||||
|
<PanelTitle
|
||||||
|
level={3}
|
||||||
|
title="Couleur de la forme"
|
||||||
|
subtitle="Indépendante de la couleur du corps."
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Forme"
|
||||||
|
value={selectedBodyNode.color ?? activeAvatar.colors.body}
|
||||||
|
onChange={color =>
|
||||||
|
updateSelectedBodyNode(currentNode => ({ ...currentNode, color }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{selectedBodyNode.color && (
|
||||||
|
<Button
|
||||||
|
className="inherit-colors"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t('Reprendre la couleur du corps')}
|
||||||
|
onClick={() =>
|
||||||
|
updateSelectedBodyNode(currentNode => {
|
||||||
|
const next = { ...currentNode }
|
||||||
|
delete next.color
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="body-transform-grid">
|
<div className="body-transform-grid">
|
||||||
<div>
|
<div>
|
||||||
<h3>{t('Position locale')}</h3>
|
<h3>{t('Position locale')}</h3>
|
||||||
|
|||||||
@ -14,8 +14,10 @@ import type { StudioController } from '@/features/studio/useStudioController'
|
|||||||
export function StudioStage({ controller }: { controller: StudioController }) {
|
export function StudioStage({ controller }: { controller: StudioController }) {
|
||||||
const [photoHelpOpen, setPhotoHelpOpen] = useState(false)
|
const [photoHelpOpen, setPhotoHelpOpen] = useState(false)
|
||||||
const {
|
const {
|
||||||
|
activateAvatar,
|
||||||
activeAvatar,
|
activeAvatar,
|
||||||
activeAvatarEyes,
|
activeAvatarEyes,
|
||||||
|
activeAvatarId,
|
||||||
activeSequenceLabel,
|
activeSequenceLabel,
|
||||||
bodyEditing,
|
bodyEditing,
|
||||||
canvasExpression,
|
canvasExpression,
|
||||||
@ -41,6 +43,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
selectedBodyNode,
|
selectedBodyNode,
|
||||||
selectedBodyNodeId,
|
selectedBodyNodeId,
|
||||||
selectedEyeSide,
|
selectedEyeSide,
|
||||||
|
sequenceEditing,
|
||||||
setEditing,
|
setEditing,
|
||||||
setPhotoPanelSections,
|
setPhotoPanelSections,
|
||||||
setPhotoTool,
|
setPhotoTool,
|
||||||
@ -59,6 +62,11 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
updateImmediate,
|
updateImmediate,
|
||||||
} = controller
|
} = controller
|
||||||
const photoMode = mode === 'photo'
|
const photoMode = mode === 'photo'
|
||||||
|
const enterBodyEdit = () => {
|
||||||
|
if (bodyEditing || editing !== null || sequenceEditing !== null) return
|
||||||
|
if (mode === 'photo' || mode === 'export') return
|
||||||
|
activateAvatar(activeAvatarId, true)
|
||||||
|
}
|
||||||
const activatePhotoTool = (tool: typeof photoTool) => {
|
const activatePhotoTool = (tool: typeof photoTool) => {
|
||||||
setPhotoTool(tool)
|
setPhotoTool(tool)
|
||||||
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
|
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
|
||||||
@ -105,6 +113,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
onManipulationStart={freezeLivePreviewForManipulation}
|
onManipulationStart={freezeLivePreviewForManipulation}
|
||||||
|
onEnterBodyEdit={enterBodyEdit}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -119,7 +119,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
createRenderedColors,
|
createRenderedColors,
|
||||||
createRenderedScene,
|
createRenderedScene,
|
||||||
|
nodeColorMap,
|
||||||
paintRenderedColors,
|
paintRenderedColors,
|
||||||
|
paintRenderedNodeColors,
|
||||||
paintRenderedOffset,
|
paintRenderedOffset,
|
||||||
paintRenderedScene,
|
paintRenderedScene,
|
||||||
} from '@/features/rendering/renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
@ -362,7 +364,9 @@ export function useStudioController() {
|
|||||||
const blinkControls = useRef<ReturnType<typeof animate> | null>(null)
|
const blinkControls = useRef<ReturnType<typeof animate> | null>(null)
|
||||||
const blinkAnimating = useRef(false)
|
const blinkAnimating = useRef(false)
|
||||||
const blinkValue = useMotionValue(1)
|
const blinkValue = useMotionValue(1)
|
||||||
const [renderedScene] = useState(() => createRenderedScene(initialGeometry))
|
const [renderedScene] = useState(() =>
|
||||||
|
createRenderedScene(initialGeometry, initialDisplayColors.body)
|
||||||
|
)
|
||||||
const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression))
|
const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression))
|
||||||
const bodyColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
|
const bodyColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
|
||||||
const eyeColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
|
const eyeColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
|
||||||
@ -420,6 +424,11 @@ export function useStudioController() {
|
|||||||
eyeOffset,
|
eyeOffset,
|
||||||
})
|
})
|
||||||
paintRenderedScene(renderedScene, geometry)
|
paintRenderedScene(renderedScene, geometry)
|
||||||
|
paintRenderedNodeColors(
|
||||||
|
renderedScene,
|
||||||
|
renderedColors.body.get(),
|
||||||
|
nodeColorMap(bodyNodesRef.current)
|
||||||
|
)
|
||||||
paintRenderedOffset(
|
paintRenderedOffset(
|
||||||
renderedScene,
|
renderedScene,
|
||||||
bodyAmbientEnabled
|
bodyAmbientEnabled
|
||||||
@ -433,6 +442,23 @@ export function useStudioController() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest))
|
useMotionValueEvent(blinkValue, 'change', latest => paintPose(displayedPose.current, latest))
|
||||||
|
useMotionValueEvent(renderedColors.body, 'change', () =>
|
||||||
|
paintRenderedNodeColors(
|
||||||
|
renderedScene,
|
||||||
|
renderedColors.body.get(),
|
||||||
|
nodeColorMap(bodyNodesRef.current)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Keep the secondary-primitive fills aligned with the initial geometry.
|
||||||
|
useEffect(() => {
|
||||||
|
paintRenderedNodeColors(
|
||||||
|
renderedScene,
|
||||||
|
renderedColors.body.get(),
|
||||||
|
nodeColorMap(bodyNodesRef.current)
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
const paintAmbientFrame = useEffectEvent((time: number) => {
|
const paintAmbientFrame = useEffectEvent((time: number) => {
|
||||||
if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) {
|
if (transitionFrame.current === null && time - lastAmbientFrame.current >= AMBIENT_FRAME_MS) {
|
||||||
@ -1327,6 +1353,11 @@ export function useStudioController() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
paintRenderedNodeColors(
|
||||||
|
renderedScene,
|
||||||
|
renderedColors.body.get(),
|
||||||
|
nodeColorMap(bodyNodesRef.current)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user