diff --git a/CONTEXT.md b/CONTEXT.md index 6581ef5..7edf77b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/packages/avatar-core/src/__tests__/core-test.ts b/packages/avatar-core/src/__tests__/core-test.ts index 6e8caa4..9d80716 100644 --- a/packages/avatar-core/src/__tests__/core-test.ts +++ b/packages/avatar-core/src/__tests__/core-test.ts @@ -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() + }) }) diff --git a/packages/avatar-core/src/avatarDefinition.schema.json b/packages/avatar-core/src/avatarDefinition.schema.json index b14d50b..a1ebfd7 100644 --- a/packages/avatar-core/src/avatarDefinition.schema.json +++ b/packages/avatar-core/src/avatarDefinition.schema.json @@ -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" } } } } diff --git a/packages/avatar-core/src/avatarDefinition.ts b/packages/avatar-core/src/avatarDefinition.ts index 8713c0e..70a5e43 100644 --- a/packages/avatar-core/src/avatarDefinition.ts +++ b/packages/avatar-core/src/avatarDefinition.ts @@ -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 = { diff --git a/packages/avatar-core/src/body.ts b/packages/avatar-core/src/body.ts index 99db4c7..c7686c2 100644 --- a/packages/avatar-core/src/body.ts +++ b/packages/avatar-core/src/body.ts @@ -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 } } diff --git a/packages/avatar-core/src/scene.ts b/packages/avatar-core/src/scene.ts index f1746c1..f8a5987 100644 --- a/packages/avatar-core/src/scene.ts +++ b/packages/avatar-core/src/scene.ts @@ -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 } export const renderAvatarExpression = ( @@ -57,6 +60,10 @@ export const renderAvatarExpression = ( blink = 1 ): AvatarScene => { const body = bodyFromDefinition(definition.body) + const nodeColors: Record = {} + 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, } } diff --git a/packages/avatar-react/src/Avatar.tsx b/packages/avatar-react/src/Avatar.tsx index e3a21ab..39703f7 100644 --- a/packages/avatar-react/src/Avatar.tsx +++ b/packages/avatar-react/src/Avatar.tsx @@ -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}`} /> ))} diff --git a/packages/avatar-web/src/index.ts b/packages/avatar-web/src/index.ts index d14ceed..f0e2d3c 100644 --- a/packages/avatar-web/src/index.ts +++ b/packages/avatar-web/src/index.ts @@ -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 + ) }) } diff --git a/src/app/styles.css b/src/app/styles.css index 808fe80..4734780 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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; diff --git a/src/features/avatar/__tests__/avatar-definition-test.ts b/src/features/avatar/__tests__/avatar-definition-test.ts index 4e17616..086c76e 100644 --- a/src/features/avatar/__tests__/avatar-definition-test.ts +++ b/src/features/avatar/__tests__/avatar-definition-test.ts @@ -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') diff --git a/src/features/avatar/__tests__/import-avatar-definition-test.ts b/src/features/avatar/__tests__/import-avatar-definition-test.ts index e5ac1ee..0e3c60e 100644 --- a/src/features/avatar/__tests__/import-avatar-definition-test.ts +++ b/src/features/avatar/__tests__/import-avatar-definition-test.ts @@ -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() + }) }) diff --git a/src/features/avatar/avatarDefinition.ts b/src/features/avatar/avatarDefinition.ts index a125e6c..dd4e8da 100644 --- a/src/features/avatar/avatarDefinition.ts +++ b/src/features/avatar/avatarDefinition.ts @@ -180,6 +180,7 @@ export const createAvatarDefinition = ({ surface: mapSurface(node.surface as SurfaceDefinition), position: [...node.position], rotation: [...node.rotation], + ...(node.color ? { color: node.color as HexColor } : {}), })), }, colors: { diff --git a/src/features/avatar/importAvatarDefinition.ts b/src/features/avatar/importAvatarDefinition.ts index 341d869..688a447 100644 --- a/src/features/avatar/importAvatarDefinition.ts +++ b/src/features/avatar/importAvatarDefinition.ts @@ -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, diff --git a/src/features/export/__tests__/snapshot-exporter-test.ts b/src/features/export/__tests__/snapshot-exporter-test.ts index 975bc3e..b852386 100644 --- a/src/features/export/__tests__/snapshot-exporter-test.ts +++ b/src/features/export/__tests__/snapshot-exporter-test.ts @@ -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) diff --git a/src/features/export/snapshotExporter.ts b/src/features/export/snapshotExporter.ts index 16f5e73..9c1d757 100644 --- a/src/features/export/snapshotExporter.ts +++ b/src/features/export/snapshotExporter.ts @@ -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), `${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get())}`, - ...frontPaths.map(value => path(value, colors.body)), + ...frontPaths, ].join('') return ` diff --git a/src/features/rendering/__tests__/rendered-scene-test.ts b/src/features/rendering/__tests__/rendered-scene-test.ts index f4dcdb7..fd17e31 100644 --- a/src/features/rendering/__tests__/rendered-scene-test.ts +++ b/src/features/rendering/__tests__/rendered-scene-test.ts @@ -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 diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx index 6ab25f4..1f6a0ab 100644 --- a/src/features/rendering/components/AvatarCanvas.tsx +++ b/src/features/rendering/components/AvatarCanvas.tsx @@ -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 } | 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} > @@ -811,8 +816,9 @@ export function AvatarCanvas({ {backPaths.map((pathValue, index) => ( selectBodyPath(event, backNodeIds.current[index])} /> @@ -845,8 +851,9 @@ export function AvatarCanvas({ {frontPaths.map((pathValue, index) => ( selectBodyPath(event, frontNodeIds.current[index])} /> diff --git a/src/features/rendering/renderedScene.ts b/src/features/rendering/renderedScene.ts index 39faa5c..7f4ca73 100644 --- a/src/features/rendering/renderedScene.ts +++ b/src/features/rendering/renderedScene.ts @@ -10,6 +10,8 @@ export type RenderedScene = { frontPaths: MotionValue[] backNodeIds: { current: (string | null)[] } frontNodeIds: { current: (string | null)[] } + backNodeFills: MotionValue[] + frontNodeFills: MotionValue[] leftPath: MotionValue rightPath: MotionValue leftOpacity: MotionValue @@ -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 + +/** + * 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) diff --git a/src/features/studio/components/BodyConstructionAccordion.tsx b/src/features/studio/components/BodyConstructionAccordion.tsx index 7c34421..dfb6891 100644 --- a/src/features/studio/components/BodyConstructionAccordion.tsx +++ b/src/features/studio/components/BodyConstructionAccordion.tsx @@ -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({ )} +
+ + + updateSelectedBodyNode(currentNode => ({ ...currentNode, color })) + } + /> + {selectedBodyNode.color && ( + + )} +

{t('Position locale')}

diff --git a/src/features/studio/components/StudioStage.tsx b/src/features/studio/components/StudioStage.tsx index 51578d8..bb445b3 100644 --- a/src/features/studio/components/StudioStage.tsx +++ b/src/features/studio/components/StudioStage.tsx @@ -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 ( diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 58b6633..e6f7fd0 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -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 | 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 | null>(null) const eyeColorAnimation = useRef | 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)