Merge 767f5191e9 into 79fe9ba06e
This commit is contained in:
commit
211a34e91d
@ -54,7 +54,7 @@ manipulation pauses it.
|
||||
- Duplicating an Avatar duplicates its Avatar behavior library when one exists.
|
||||
- Transferring an Animation must also transfer every Expression it references.
|
||||
- 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.
|
||||
- `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.
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
bodyFromDefinition,
|
||||
createAvatarPlaybackState,
|
||||
expressionFromDefinition,
|
||||
parseAvatarBody,
|
||||
parseAvatarDefinition,
|
||||
playAvatarAnimation,
|
||||
pauseAvatarPlayback,
|
||||
@ -386,4 +387,54 @@ describe('@bible-strong/avatar-core', () => {
|
||||
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": {
|
||||
"surface": { "$ref": "#/$defs/nodeSurface" },
|
||||
"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
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
/** Optional independent color; when omitted the node inherits the body color. */
|
||||
color?: HexColor
|
||||
}
|
||||
|
||||
export type AvatarBodyDefinition = {
|
||||
|
||||
@ -8,6 +8,8 @@ export type BodyNode = {
|
||||
surface: SurfaceConfig
|
||||
position: BodyVector
|
||||
rotation: BodyVector
|
||||
/** Optional independent color; when omitted the node inherits the body color. */
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type AvatarBody = {
|
||||
@ -31,6 +33,7 @@ const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
const vector = (value: unknown): value is BodyVector =>
|
||||
Array.isArray(value) && value.length === 3 && value.every(finite)
|
||||
const hexColor = /^#[0-9a-f]{6}$/i
|
||||
|
||||
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||
if (!value || typeof value !== 'object') return { ...fallback }
|
||||
@ -78,10 +81,16 @@ export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig):
|
||||
return valid
|
||||
})
|
||||
.slice(0, MAX_BODY_NODES)
|
||||
.map(node => ({
|
||||
...node,
|
||||
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||
}))
|
||||
.map(node => {
|
||||
const parsed: BodyNode = {
|
||||
...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 }
|
||||
}
|
||||
|
||||
@ -42,12 +42,15 @@ export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
|
||||
surface: { ...node.surface },
|
||||
position: [...node.position],
|
||||
rotation: [...node.rotation],
|
||||
...(node.color ? { color: node.color } : {}),
|
||||
})),
|
||||
})
|
||||
|
||||
export type AvatarScene = {
|
||||
geometry: AvatarGeometry
|
||||
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 = (
|
||||
@ -57,6 +60,10 @@ export const renderAvatarExpression = (
|
||||
blink = 1
|
||||
): AvatarScene => {
|
||||
const body = bodyFromDefinition(definition.body)
|
||||
const nodeColors: Record<string, string> = {}
|
||||
body.nodes.forEach(node => {
|
||||
if (node.color) nodeColors[node.id] = node.color
|
||||
})
|
||||
return {
|
||||
geometry: renderAvatar(poseFromExpression(expression), body.primary, blink, {
|
||||
bodyNodes: body.nodes,
|
||||
@ -65,6 +72,7 @@ export const renderAvatarExpression = (
|
||||
body: colors.body ?? expression.bodyColor ?? definition.colors.body,
|
||||
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')
|
||||
backPathRefs.current.forEach((element, 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) => {
|
||||
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
|
||||
}}
|
||||
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}`}
|
||||
/>
|
||||
))}
|
||||
@ -516,7 +528,11 @@ export function Avatar({
|
||||
frontPathRefs.current[index] = element
|
||||
}}
|
||||
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}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
@ -157,11 +157,19 @@ export function createAvatar(
|
||||
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
||||
backPaths.forEach((element, 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) => {
|
||||
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;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.body-node-color-panel {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #eceef2;
|
||||
}
|
||||
.body-transform-grid {
|
||||
display: grid;
|
||||
margin-top: 12px;
|
||||
|
||||
@ -37,6 +37,7 @@ const avatarFixture = (): StudioAvatar => ({
|
||||
surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
|
||||
position: [1.25, -2.5, 3.75],
|
||||
rotation: [-10, 20, 30],
|
||||
color: '#ff0000',
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -542,6 +543,7 @@ describe('Studio to avatar definition conversion', () => {
|
||||
surface: avatar.body.nodes[0].surface,
|
||||
position: avatar.body.nodes[0].position,
|
||||
rotation: avatar.body.nodes[0].rotation,
|
||||
color: avatar.body.nodes[0].color,
|
||||
},
|
||||
])
|
||||
expect(result.value.body.nodes[0]).not.toHaveProperty('id')
|
||||
|
||||
@ -142,4 +142,31 @@ describe('studioAvatarFromDefinition', () => {
|
||||
'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>),
|
||||
position: [...node.position],
|
||||
rotation: [...node.rotation],
|
||||
...(node.color ? { color: node.color as HexColor } : {}),
|
||||
})),
|
||||
},
|
||||
colors: {
|
||||
|
||||
@ -236,6 +236,7 @@ export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefini
|
||||
surface: node.surface,
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
...(node.color ? { color: node.color } : {}),
|
||||
})),
|
||||
},
|
||||
colors: definition.colors,
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import { poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import { createRenderedScene, paintRenderedOffset } from '@/features/rendering/renderedScene'
|
||||
import {
|
||||
createRenderedScene,
|
||||
paintRenderedNodeColors,
|
||||
paintRenderedOffset,
|
||||
} from '@/features/rendering/renderedScene'
|
||||
import {
|
||||
serializeAvatarSnapshot,
|
||||
serializePixelSnapshot,
|
||||
@ -59,6 +63,30 @@ describe('avatar snapshot export', () => {
|
||||
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', () => {
|
||||
const svg = serializePixelSnapshot('Pixel & Co', 'data:image/png;base64,AAAA', 256)
|
||||
|
||||
|
||||
@ -62,21 +62,21 @@ export const serializeAvatarSnapshot = (
|
||||
options.composition ?? defaultSnapshotComposition
|
||||
)
|
||||
const headPath = scene.headPath.get()
|
||||
const backPaths = scene.backPaths.flatMap(item => {
|
||||
const backPaths = scene.backPaths.flatMap((item, index) => {
|
||||
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()
|
||||
return value ? [value] : []
|
||||
return value ? [path(value, scene.frontNodeFills[index].get())] : []
|
||||
})
|
||||
const offsetX = scene.offsetX.get()
|
||||
const offsetY = scene.offsetY.get()
|
||||
const body = [
|
||||
...backPaths.map(value => path(value, colors.body)),
|
||||
...backPaths,
|
||||
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>`,
|
||||
...frontPaths.map(value => path(value, colors.body)),
|
||||
...frontPaths,
|
||||
].join('')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
@ -5,7 +5,9 @@ import {
|
||||
createRenderedColors,
|
||||
createRenderedScene,
|
||||
findBodyNodePath,
|
||||
nodeColorMap,
|
||||
paintRenderedColors,
|
||||
paintRenderedNodeColors,
|
||||
paintRenderedScene,
|
||||
} from '@/features/rendering/renderedScene'
|
||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||
@ -48,6 +50,21 @@ describe('rendered avatar scene', () => {
|
||||
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', () => {
|
||||
const avatar = defaultStudioDocument.library.avatars.find(item => item.name === 'Cloudee')!
|
||||
const expression = defaultStudioDocument.expressions[5] as Expression
|
||||
|
||||
@ -518,6 +518,7 @@ export function AvatarCanvas({
|
||||
onEyeChange,
|
||||
playback,
|
||||
onManipulationStart,
|
||||
onEnterBodyEdit,
|
||||
}: {
|
||||
expression: Expression
|
||||
avatarEyes: AvatarEyeDefaults
|
||||
@ -544,6 +545,7 @@ export function AvatarCanvas({
|
||||
onEyeChange?: (next: Expression) => void
|
||||
playback: { name: string; status: Exclude<PlaybackStatus, 'stopped'> } | null
|
||||
onManipulationStart: () => Expression
|
||||
onEnterBodyEdit?: () => void
|
||||
}) {
|
||||
const { t } = useStudioLanguage()
|
||||
const {
|
||||
@ -552,6 +554,8 @@ export function AvatarCanvas({
|
||||
frontPaths,
|
||||
backNodeIds,
|
||||
frontNodeIds,
|
||||
backNodeFills,
|
||||
frontNodeFills,
|
||||
headPath,
|
||||
leftPath,
|
||||
rightPath,
|
||||
@ -802,6 +806,7 @@ export function AvatarCanvas({
|
||||
onPointerMove={move}
|
||||
onPointerUp={commitDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
onDoubleClick={onEnterBodyEdit}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="avatar-head-clip">
|
||||
@ -811,8 +816,9 @@ export function AvatarCanvas({
|
||||
<motion.g style={{ x: offsetX, y: offsetY }}>
|
||||
{backPaths.map((pathValue, index) => (
|
||||
<motion.path
|
||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||
className={highlight === 'head' ? 'cyan-outline' : undefined}
|
||||
d={pathValue}
|
||||
fill={backNodeFills[index]}
|
||||
key={index}
|
||||
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
|
||||
/>
|
||||
@ -845,8 +851,9 @@ export function AvatarCanvas({
|
||||
</g>
|
||||
{frontPaths.map((pathValue, index) => (
|
||||
<motion.path
|
||||
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
|
||||
className={highlight === 'head' ? 'cyan-outline' : undefined}
|
||||
d={pathValue}
|
||||
fill={frontNodeFills[index]}
|
||||
key={index}
|
||||
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}
|
||||
/>
|
||||
|
||||
@ -10,6 +10,8 @@ export type RenderedScene = {
|
||||
frontPaths: MotionValue<string>[]
|
||||
backNodeIds: { current: (string | null)[] }
|
||||
frontNodeIds: { current: (string | null)[] }
|
||||
backNodeFills: MotionValue<string>[]
|
||||
frontNodeFills: MotionValue<string>[]
|
||||
leftPath: MotionValue<string>
|
||||
rightPath: MotionValue<string>
|
||||
leftOpacity: MotionValue<number>
|
||||
@ -25,8 +27,12 @@ export type RenderedColors = {
|
||||
}
|
||||
|
||||
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),
|
||||
backPaths: Array.from({ length: bodyPathSlots }, (_, index) =>
|
||||
motionValue(geometry.backPaths[index] ?? '')
|
||||
@ -36,6 +42,8 @@ export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene =>
|
||||
),
|
||||
backNodeIds: { current: geometry.backNodeIds },
|
||||
frontNodeIds: { current: geometry.frontNodeIds },
|
||||
backNodeFills: Array.from({ length: bodyPathSlots }, () => motionValue(initialBodyColor)),
|
||||
frontNodeFills: Array.from({ length: bodyPathSlots }, () => motionValue(initialBodyColor)),
|
||||
leftPath: motionValue(geometry.leftPath),
|
||||
rightPath: motionValue(geometry.rightPath),
|
||||
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] ?? ''))
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (selectedBodyNodeId === 'primary') return scene.headPath
|
||||
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 { useState } from 'react'
|
||||
|
||||
@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
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 { bodyPrimitiveTypes, MAX_BODY_NODES, type BodyNode } from '@/features/avatar/body'
|
||||
import { SurfaceThumbnail } from '@/features/avatar/components/ExpressionWorkspace'
|
||||
@ -72,6 +72,7 @@ export function BodyConstructionAccordion({
|
||||
}) {
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const {
|
||||
activeAvatar,
|
||||
addBodyNode,
|
||||
bodyNodes,
|
||||
deleteSelectedBodyNode,
|
||||
@ -382,6 +383,37 @@ export function BodyConstructionAccordion({
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
<h3>{t('Position locale')}</h3>
|
||||
|
||||
@ -14,8 +14,10 @@ import type { StudioController } from '@/features/studio/useStudioController'
|
||||
export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
const [photoHelpOpen, setPhotoHelpOpen] = useState(false)
|
||||
const {
|
||||
activateAvatar,
|
||||
activeAvatar,
|
||||
activeAvatarEyes,
|
||||
activeAvatarId,
|
||||
activeSequenceLabel,
|
||||
bodyEditing,
|
||||
canvasExpression,
|
||||
@ -41,6 +43,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
selectedBodyNode,
|
||||
selectedBodyNodeId,
|
||||
selectedEyeSide,
|
||||
sequenceEditing,
|
||||
setEditing,
|
||||
setPhotoPanelSections,
|
||||
setPhotoTool,
|
||||
@ -59,6 +62,11 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
updateImmediate,
|
||||
} = controller
|
||||
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) => {
|
||||
setPhotoTool(tool)
|
||||
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
|
||||
@ -105,6 +113,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
|
||||
: null
|
||||
}
|
||||
onManipulationStart={freezeLivePreviewForManipulation}
|
||||
onEnterBodyEdit={enterBodyEdit}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
|
||||
@ -119,7 +119,9 @@ import {
|
||||
import {
|
||||
createRenderedColors,
|
||||
createRenderedScene,
|
||||
nodeColorMap,
|
||||
paintRenderedColors,
|
||||
paintRenderedNodeColors,
|
||||
paintRenderedOffset,
|
||||
paintRenderedScene,
|
||||
} from '@/features/rendering/renderedScene'
|
||||
@ -362,7 +364,9 @@ export function useStudioController() {
|
||||
const blinkControls = useRef<ReturnType<typeof animate> | null>(null)
|
||||
const blinkAnimating = useRef(false)
|
||||
const blinkValue = useMotionValue(1)
|
||||
const [renderedScene] = useState(() => createRenderedScene(initialGeometry))
|
||||
const [renderedScene] = useState(() =>
|
||||
createRenderedScene(initialGeometry, initialDisplayColors.body)
|
||||
)
|
||||
const [renderedRotationGizmo] = useState(() => createRenderedRotationGizmo(initialExpression))
|
||||
const bodyColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
|
||||
const eyeColorAnimation = useRef<ReturnType<typeof animate> | null>(null)
|
||||
@ -420,6 +424,11 @@ export function useStudioController() {
|
||||
eyeOffset,
|
||||
})
|
||||
paintRenderedScene(renderedScene, geometry)
|
||||
paintRenderedNodeColors(
|
||||
renderedScene,
|
||||
renderedColors.body.get(),
|
||||
nodeColorMap(bodyNodesRef.current)
|
||||
)
|
||||
paintRenderedOffset(
|
||||
renderedScene,
|
||||
bodyAmbientEnabled
|
||||
@ -433,6 +442,23 @@ export function useStudioController() {
|
||||
}
|
||||
|
||||
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) => {
|
||||
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
|
||||
}
|
||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user