This commit is contained in:
PhilippeMaillot 2026-09-08 16:54:52 +02:00 committed by GitHub
commit 211a34e91d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 314 additions and 25 deletions

View File

@ -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.

View File

@ -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()
})
}) })

View File

@ -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" }
} }
} }
} }

View File

@ -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 = {

View File

@ -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 }
} }

View File

@ -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,
} }
} }

View File

@ -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}`}
/> />
))} ))}

View File

@ -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
)
}) })
} }

View File

@ -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;

View File

@ -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')

View File

@ -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()
})
}) })

View File

@ -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: {

View File

@ -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,

View File

@ -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)

View File

@ -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"?>

View File

@ -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

View File

@ -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])}
/> />

View File

@ -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)

View File

@ -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>

View File

@ -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 (

View File

@ -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)