diff --git a/examples/react-vite-consumer/src/strobi.avatar.json b/examples/react-vite-consumer/src/strobi.avatar.json index f9818ca..3f754e6 100644 --- a/examples/react-vite-consumer/src/strobi.avatar.json +++ b/examples/react-vite-consumer/src/strobi.avatar.json @@ -868,6 +868,37 @@ "eyes": "none", "body": "none" } + }, + "front-facing": { + "head": { + "x": 0, + "y": 0, + "z": 0 + }, + "eyes": { + "left": { + "width": 20, + "height": 50, + "curvature": 0, + "x": 0, + "y": -7, + "angle": 0 + }, + "right": { + "width": 20, + "height": 50, + "curvature": 0, + "x": 0, + "y": -7, + "angle": 0 + }, + "spacing": 35 + }, + "perspective": 1, + "motion": { + "eyes": "none", + "body": "none" + } } }, "expressionOrder": [ @@ -899,7 +930,8 @@ "shy-downward", "angry-brows", "uneasy-left", - "joyful-arc" + "joyful-arc", + "front-facing" ], "animations": { "sleeping": { diff --git a/public/headset-filled.svg b/public/headset-filled.svg new file mode 100644 index 0000000..0674a73 --- /dev/null +++ b/public/headset-filled.svg @@ -0,0 +1 @@ + diff --git a/src/app/studio-utils.ts b/src/app/studio-utils.ts index 0a9c24a..4f34851 100644 --- a/src/app/studio-utils.ts +++ b/src/app/studio-utils.ts @@ -7,9 +7,11 @@ import { defaultAvatarEyes, type AvatarColors, type AvatarEyeDefaults, + type AvatarRenderStyle, } from '@/features/avatar/avatars' import { type BodyNode } from '@/features/avatar/body' -import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry' +import { poseFromExpression, type Expression } from '@/features/avatar/geometry' +import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories' import { type SurfaceConfig } from '@/features/avatar/surfaces' export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo' @@ -51,7 +53,7 @@ const previewGeometryCache = new WeakMap< Expression, WeakMap< SurfaceConfig, - WeakMap }> + WeakMap }> > >() @@ -62,7 +64,8 @@ export const getPreviewGeometry = ( expression: Expression, surface: SurfaceConfig, bodyNodes: BodyNode[], - eyes: AvatarEyeDefaults = defaultAvatarEyes + eyes: AvatarEyeDefaults = defaultAvatarEyes, + renderStyle: AvatarRenderStyle = { type: 'vector' } ) => { let surfaceCache = previewGeometryCache.get(expression) if (!surfaceCache) { @@ -74,13 +77,19 @@ export const getPreviewGeometry = ( bodyCache = new WeakMap() surfaceCache.set(surface, bodyCache) } - const positionKey = JSON.stringify(eyes) + const positionKey = JSON.stringify([eyes, renderStyle]) const cached = bodyCache.get(bodyNodes) if (cached?.positionKey === positionKey) return cached.geometry - const geometry = renderAvatar(poseWithAvatarEyes(expression, eyes), surface, 1, { - includeWire: false, - bodyNodes, - }) + const geometry = renderAvatarByStyle( + poseWithAvatarEyes(expression, eyes), + surface, + 1, + renderStyle, + { + includeWire: false, + bodyNodes, + } + ) bodyCache.set(bodyNodes, { positionKey, geometry }) return geometry } diff --git a/src/app/styles.css b/src/app/styles.css index ab5fe5c..f906c3f 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1219,6 +1219,11 @@ input { stroke-linecap: round; stroke-linejoin: round; } +.avatar-headset { + fill: #111316 !important; + opacity: 1; + stroke: none !important; +} .avatar-eye, .preview-eye { fill: var(--avatar-eye-color, #ffffff); diff --git a/src/assets/headset-filled.svg b/src/assets/headset-filled.svg new file mode 100644 index 0000000..0674a73 --- /dev/null +++ b/src/assets/headset-filled.svg @@ -0,0 +1 @@ + diff --git a/src/features/avatar/__tests__/avatar-definition-test.ts b/src/features/avatar/__tests__/avatar-definition-test.ts index 1c5d009..0b24e89 100644 --- a/src/features/avatar/__tests__/avatar-definition-test.ts +++ b/src/features/avatar/__tests__/avatar-definition-test.ts @@ -486,8 +486,9 @@ describe('Studio to avatar definition conversion', () => { expect(result.ok).toBe(true) if (!result.ok) return - expect(result.value.expressionOrder).toHaveLength(31) + expect(result.value.expressionOrder).toHaveLength(32) expect(result.value.expressionOrder[0]).toBe('neutral') + expect(result.value.expressionOrder).toContain('front-facing') expect(result.value.animationOrder).toHaveLength(25) expect(result.value.animationOrder).toContain('idle') expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([ diff --git a/src/features/avatar/__tests__/live-expression-preview-test.tsx b/src/features/avatar/__tests__/live-expression-preview-test.tsx new file mode 100644 index 0000000..b25cf56 --- /dev/null +++ b/src/features/avatar/__tests__/live-expression-preview-test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react' + +import { LiveExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' +import { poseFromExpression } from '@/features/avatar/geometry' +import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories' +import { + createRenderedColors, + createRenderedScene, +} from '@/features/rendering/renderedScene' +import { loadStudioDocument } from '@/features/studio/studioDocument' + +it('keeps the headset visible and transformed in a live animation preview', () => { + const studio = loadStudioDocument({ getItem: () => null }) + const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')! + const expression = studio.expressions.find(item => item.semanticKey === 'front-facing')! + const geometry = renderAvatarByStyle( + poseFromExpression(expression), + avatar.body.primary, + 1, + avatar.renderStyle, + { bodyNodes: avatar.body.nodes } + ) + const scene = createRenderedScene(geometry) + + const view = render( + + ) + + const headset = view.container.querySelector('.avatar-headset') + expect(headset).not.toBeNull() + expect(headset?.getAttribute('d')).toBe(geometry.frontPaths.at(-1)) + expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1)) +}) diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts index 1bf3a45..9835b80 100644 --- a/src/features/avatar/avatars.ts +++ b/src/features/avatar/avatars.ts @@ -31,7 +31,12 @@ export type PixelRenderStyle = { type: 'pixel' resolution: number } -export type VectorRenderStyle = { type: 'vector'; filled?: boolean } +export type VectorAccessory = 'headset' +export type VectorRenderStyle = { + type: 'vector' + filled?: boolean + accessory?: VectorAccessory +} export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle export type AvatarEyeDefaults = Pick< Expression, @@ -68,6 +73,11 @@ export const defaultAvatarColors: AvatarColors = { } export const defaultAvatarRenderStyle: AvatarRenderStyle = { type: 'vector' } export const filledAvatarRenderStyle: VectorRenderStyle = { type: 'vector', filled: true } +export const headsetAvatarRenderStyle: VectorRenderStyle = { + type: 'vector', + filled: true, + accessory: 'headset', +} const filledAvatarEyeColor = '#111316' export const defaultPixelRenderStyle: PixelRenderStyle = { type: 'pixel', @@ -154,6 +164,7 @@ const bundledAvatarColorMigrations: Record = { 'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor }, + 'primitive-ghost-headset': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor }, 'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor }, 'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor }, } @@ -975,7 +991,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib ) if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) { const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => { - if (avatar.id === 'primitive-ghost') { + if (['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id)) { return { ...avatar, behavior: mementoBehavior(avatar.behavior) } } if (filledBundledAvatarIds.has(avatar.id)) { @@ -1106,7 +1122,7 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib ? { ...avatarWithCurrentNodes, name: bundled.name } : avatarWithCurrentNodes const avatarWithCurrentEyes = - avatarWithCurrentName.id === 'primitive-ghost' && + ['primitive-ghost', 'primitive-ghost-headset'].includes(avatarWithCurrentName.id) && [defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes( avatarWithCurrentName.eyes.positionYLeft ) && @@ -1121,21 +1137,25 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight ? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } } : avatarWithCurrentEyes - const avatarWithFixedColorBehavior = - avatarWithAppleBiteEyes.id === 'primitive-ghost' + const avatarWithFixedColorBehavior = ['primitive-ghost', 'primitive-ghost-headset'].includes( + avatarWithAppleBiteEyes.id + ) + ? { + ...avatarWithAppleBiteEyes, + behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior), + } + : filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id) ? { ...avatarWithAppleBiteEyes, - behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior), + behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior), } - : filledBundledAvatarIds.has(avatarWithAppleBiteEyes.id) - ? { - ...avatarWithAppleBiteEyes, - behavior: fixedSkinColorBehavior(avatarWithAppleBiteEyes.behavior), - } - : avatarWithAppleBiteEyes - const avatarWithCurrentRenderStyle = filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id) - ? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } } - : avatarWithFixedColorBehavior + : avatarWithAppleBiteEyes + const avatarWithCurrentRenderStyle = + avatarWithFixedColorBehavior.id === 'primitive-ghost-headset' + ? { ...avatarWithFixedColorBehavior, renderStyle: { ...headsetAvatarRenderStyle } } + : filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id) + ? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } } + : avatarWithFixedColorBehavior const bundledFilledColors = mementoAnimalColors[avatarWithCurrentRenderStyle.id] ?? oneWorksBundledAnimalColors[avatarWithCurrentRenderStyle.id] ?? @@ -1215,9 +1235,18 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe : fallback export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => { - const candidate = value as { type?: unknown; filled?: unknown; resolution?: unknown } | null + const candidate = value as { + type?: unknown + filled?: unknown + accessory?: unknown + resolution?: unknown + } | null if (candidate?.type === 'vector') { - return candidate.filled === true ? { type: 'vector', filled: true } : { type: 'vector' } + return { + type: 'vector', + ...(candidate.filled === true ? { filled: true } : {}), + ...(candidate.accessory === 'headset' ? { accessory: 'headset' as const } : {}), + } } if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') { return { ...defaultAvatarRenderStyle } diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx index 6529fbe..f519441 100644 --- a/src/features/avatar/components/ExpressionWorkspace.tsx +++ b/src/features/avatar/components/ExpressionWorkspace.tsx @@ -38,6 +38,7 @@ import { LivePixelAvatarCanvas, StaticPixelAvatarCanvas, } from '@/features/rendering/components/PixelAvatarCanvas' +import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas' import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene' export function SurfaceThumbnail({ surface }: { surface: SurfaceConfig }) { const geometry = getPreviewGeometry(defaultExpression, surface, emptyBodyNodes) @@ -82,7 +83,7 @@ export function ExpressionPreview({ renderStyle: AvatarRenderStyle id: string }) { - const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes) + const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes, renderStyle) const resolvedColors = resolveColors(expression, colors) const outlineWidth = avatarBodyOutlineWidth() const filled = renderStyle.type === 'vector' && renderStyle.filled === true @@ -129,6 +130,7 @@ export function ExpressionPreview({ { const color = nodeColor(scene.backNodeIds.current[index]) return ( - { const color = nodeColor(scene.frontNodeIds.current[index]) return ( - { const geometry = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1) @@ -29,6 +31,29 @@ describe('avatar snapshot export', () => { expect(svg).not.toContain('width="300" height="300" fill=') }) + it('exports the Memento headset as a transformed translucent front layer', () => { + const studio = loadStudioDocument({ getItem: () => null }) + const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')! + const headsetGeometry = renderAvatarByStyle( + poseFromExpression({ ...defaultExpression, headY: 18, headZ: 6 }), + avatar.body.primary, + 1, + avatar.renderStyle, + { bodyNodes: avatar.body.nodes } + ) + const headsetScene = createRenderedScene(headsetGeometry) + const svg = serializeAvatarSnapshot('Memento · Headset', headsetScene, avatar.colors, { + background: 'transparent', + colorFrom: '#ffffff', + colorTo: '#000000', + size: 1024, + }) + + expect(svg).toContain('fill="#111316" opacity="1"') + expect(svg).toContain(`transform="${headsetGeometry.pathTransforms?.front.at(-1)}"`) + expect(svg).not.toContain('stroke=') + }) + it('applies logo framing without changing the rendered avatar scene', () => { const svg = serializeAvatarSnapshot('Strobi', scene, colors, { background: 'solid', diff --git a/src/features/export/snapshotExporter.ts b/src/features/export/snapshotExporter.ts index 16f5e73..0e7e496 100644 --- a/src/features/export/snapshotExporter.ts +++ b/src/features/export/snapshotExporter.ts @@ -28,8 +28,10 @@ const escapeXml = (value: string) => return entities[character] }) -const path = (value: string, fill: string, opacity = 1) => - value ? `` : '' +const path = (value: string, fill: string, opacity = 1, transform?: string) => + value + ? `` + : '' const backgroundMarkup = (options: SnapshotOptions) => { if (options.background === 'transparent') return '' @@ -62,21 +64,30 @@ 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 ? [{ value, index }] : [] }) - const frontPaths = scene.frontPaths.flatMap(item => { + const frontPaths = scene.frontPaths.flatMap((item, index) => { const value = item.get() - return value ? [value] : [] + return value ? [{ value, index }] : [] }) const offsetX = scene.offsetX.get() const offsetY = scene.offsetY.get() const body = [ - ...backPaths.map(value => path(value, colors.body)), - 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)), + ...backPaths.map(({ value, index }) => + path(value, colors.body, 1, scene.backTransforms[index].get()) + ), + path(headPath, colors.body, 1, scene.headTransform.get()), + `${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get(), scene.leftTransform.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get(), scene.rightTransform.get())}`, + ...frontPaths.map(({ value, index }) => + path( + value, + scene.headsetFrontIndex.current === index ? '#111316' : colors.body, + 1, + scene.frontTransforms[index].get() + ) + ), ].join('') return ` diff --git a/src/features/rendering/__tests__/rendered-scene-test.ts b/src/features/rendering/__tests__/rendered-scene-test.ts index a9a4aeb..332dfba 100644 --- a/src/features/rendering/__tests__/rendered-scene-test.ts +++ b/src/features/rendering/__tests__/rendered-scene-test.ts @@ -9,6 +9,7 @@ import { paintRenderedScene, } from '@/features/rendering/renderedScene' import { surfacePresets } from '@/features/avatar/surfaces' +import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories' import { loadStudioDocument } from '@/features/studio/studioDocument' import defaultStudioDocument from '@/features/studio/defaultStudioDocument.json' import type { BodyNode } from '@/features/avatar/body' @@ -33,6 +34,41 @@ const overlap = (first: ReturnType, second: ReturnType { + it('keeps the Memento headset in front of the face and moves it with the pose', () => { + const studio = loadStudioDocument({ getItem: () => null }) + const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset')! + const neutralPose = poseFromExpression(defaultExpression) + const turnedPose = poseFromExpression({ ...defaultExpression, headY: 22, headZ: -8 }) + const neutral = renderAvatarByStyle( + neutralPose, + avatar.body.primary as SurfaceConfig, + 1, + avatar.renderStyle, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + const turned = renderAvatarByStyle( + turnedPose, + avatar.body.primary as SurfaceConfig, + 1, + avatar.renderStyle, + { bodyNodes: avatar.body.nodes as BodyNode[] } + ) + + expect(avatar.name).toBe('Memento · Headset') + expect(avatar.colors).toEqual({ body: '#ff9d45', eyes: '#111316' }) + expect(avatar.renderStyle).toEqual({ type: 'vector', filled: true, accessory: 'headset' }) + expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1) + expect(neutral.frontPaths.at(-1)).toContain('M12 1.875C17.7 1.875') + expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1)) + + const scene = createRenderedScene(neutral) + paintRenderedScene(scene, turned) + expect(scene.headsetFrontIndex.current).toBe(turned.frontPaths.length - 1) + expect(scene.frontTransforms[scene.headsetFrontIndex.current!].get()).toBe( + turned.pathTransforms?.front.at(-1) + ) + }) + it('keeps layer identity and hit mapping behind the scene seam', () => { const node = createBodyNode('sphere', 0) const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, { diff --git a/src/features/rendering/avatarAccessories.ts b/src/features/rendering/avatarAccessories.ts new file mode 100644 index 0000000..821aa4c --- /dev/null +++ b/src/features/rendering/avatarAccessories.ts @@ -0,0 +1,106 @@ +import headsetSvg from '@/assets/headset-filled.svg?raw' + +import type { AvatarRenderStyle } from '@/features/avatar/avatars' +import { + renderAvatar, + type AvatarGeometry, + type AvatarPose, + type RenderAvatarOptions, +} from '@/features/avatar/geometry' +import type { SurfaceConfig } from '@/features/avatar/surfaces' + +type Matrix = readonly [number, number, number, number, number, number] + +export type AvatarPathTransforms = { + back: string[] + head: string + left: string + right: string + front: string[] +} + +export type StudioAvatarGeometry = AvatarGeometry & { + pathTransforms?: AvatarPathTransforms + headsetFrontIndex?: number +} + +const pathValues = [...headsetSvg.matchAll(/]*\bd="([^"]+)"/g)].map(match => match[1]) + +if (pathValues.length !== 1) { + throw new Error(`Expected one path in headset-filled.svg, received ${pathValues.length}`) +} + +const [headsetPath] = pathValues +export const identityTransform = 'matrix(1 0 0 1 0 0)' + +const identity: Matrix = [1, 0, 0, 1, 0, 0] +const translate = (x: number, y: number): Matrix => [1, 0, 0, 1, x, y] +const scale = (x: number, y: number): Matrix => [x, 0, 0, y, 0, 0] +const rotate = (degrees: number): Matrix => { + const angle = (degrees * Math.PI) / 180 + const cosine = Math.cos(angle) + const sine = Math.sin(angle) + return [cosine, sine, -sine, cosine, 0, 0] +} +const skewX = (degrees: number): Matrix => [1, 0, Math.tan((degrees * Math.PI) / 180), 1, 0, 0] +const skewY = (degrees: number): Matrix => [1, Math.tan((degrees * Math.PI) / 180), 0, 1, 0, 0] + +const multiply = (left: Matrix, right: Matrix): Matrix => [ + left[0] * right[0] + left[2] * right[1], + left[1] * right[0] + left[3] * right[1], + left[0] * right[2] + left[2] * right[3], + left[1] * right[2] + left[3] * right[3], + left[0] * right[4] + left[2] * right[5] + left[4], + left[1] * right[4] + left[3] * right[5] + left[5], +] + +const compose = (...matrices: Matrix[]) => matrices.reduce(multiply, identity) +const clamp = (value: number, minimum: number, maximum: number) => + Math.min(maximum, Math.max(minimum, value)) +const matrixValue = (matrix: Matrix) => + `matrix(${matrix.map(value => Number(value.toFixed(5))).join(' ')})` + +const headsetTransform = (pose: AvatarPose, surface: SurfaceConfig) => { + const expression = pose.expression + const horizontalTurn = clamp(expression.headY / 25, -1, 1) + const verticalTurn = clamp(expression.headX / 25, -1, 1) + const perspective = clamp(expression.perspective, 0.6, 1.4) + const horizontalScale = Math.max(0.38, Math.cos((expression.headY * Math.PI) / 180)) + const verticalScale = Math.max(0.46, Math.cos((expression.headX * Math.PI) / 180)) + + return matrixValue( + compose( + rotate(expression.headZ), + skewY(verticalTurn * 3.2), + skewX(-horizontalTurn * 4.2), + scale(horizontalScale * perspective, verticalScale * perspective), + scale((surface.width * 1.42) / 24, (surface.height * 1.28) / 24), + translate(-12, -13.25) + ) + ) +} + +export const renderAvatarByStyle = ( + pose: AvatarPose, + surface: SurfaceConfig, + blink: number, + renderStyle: AvatarRenderStyle, + options: RenderAvatarOptions = {} +): StudioAvatarGeometry => { + const geometry = renderAvatar(pose, surface, blink, options) + if (renderStyle.type !== 'vector' || renderStyle.accessory !== 'headset') return geometry + + return { + ...geometry, + frontPaths: [...geometry.frontPaths, headsetPath], + frontNodeIds: [...geometry.frontNodeIds, null], + headsetFrontIndex: geometry.frontPaths.length, + pathTransforms: { + back: geometry.backPaths.map(() => identityTransform), + head: identityTransform, + left: identityTransform, + right: identityTransform, + front: [...geometry.frontPaths.map(() => identityTransform), headsetTransform(pose, surface)], + }, + } +} diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx index 8ebb5fe..a31de10 100644 --- a/src/features/rendering/components/AvatarCanvas.tsx +++ b/src/features/rendering/components/AvatarCanvas.tsx @@ -1,6 +1,7 @@ import { RotateCcw } from 'lucide-react' import { motion } from 'motion/react' -import { useEffect, useRef, useState, type CSSProperties } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' +import type { MotionValue } from 'motion' import { Button } from '@/components/ui/button' import { useStudioLanguage } from '@/i18n' @@ -226,6 +227,23 @@ export function RotationGizmo({ type TransformAxis = 'x' | 'y' | 'z' +export function SvgTransformPath({ + svgTransform, + ...props +}: Omit, 'transform'> & { + svgTransform: MotionValue +}) { + const ref = useRef(null) + + useLayoutEffect(() => { + const applyTransform = (value: string) => ref.current?.setAttribute('transform', value) + applyTransform(svgTransform.get()) + return svgTransform.on('change', applyTransform) + }, [svgTransform]) + + return +} + export function BodyNodeGizmo({ svgRef, pose, @@ -555,7 +573,9 @@ export function AvatarCanvas({ const { wirePaths, backPaths, + backTransforms, frontPaths, + frontTransforms, backNodeIds, frontNodeIds, headPath, @@ -823,9 +843,10 @@ export function AvatarCanvas({ {backPaths.map((pathValue, index) => ( - selectBodyPath(event, backNodeIds.current[index])} @@ -858,9 +879,10 @@ 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 5f7b03c..866baa0 100644 --- a/src/features/rendering/renderedScene.ts +++ b/src/features/rendering/renderedScene.ts @@ -2,17 +2,23 @@ import { motionValue, type MotionValue } from 'motion' import type { AvatarColors } from '../avatar/avatars' import { MAX_BODY_NODES } from '../avatar/body' -import type { AvatarGeometry } from '../avatar/geometry' +import { identityTransform, type StudioAvatarGeometry } from './avatarAccessories' export type RenderedScene = { headPath: MotionValue + headTransform: MotionValue backPaths: MotionValue[] + backTransforms: MotionValue[] frontPaths: MotionValue[] + frontTransforms: MotionValue[] + headsetFrontIndex: { current: number | null } backNodeIds: { current: (string | null)[] } frontNodeIds: { current: (string | null)[] } nodeOrderVersion: MotionValue leftPath: MotionValue rightPath: MotionValue + leftTransform: MotionValue + rightTransform: MotionValue leftOpacity: MotionValue rightOpacity: MotionValue offsetX: MotionValue @@ -25,21 +31,31 @@ export type RenderedColors = { eyes: MotionValue } -const bodyPathSlots = MAX_BODY_NODES + 2 +const bodyPathSlots = MAX_BODY_NODES + 3 -export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene => ({ +export const createRenderedScene = (geometry: StudioAvatarGeometry): RenderedScene => ({ headPath: motionValue(geometry.headPath), + headTransform: motionValue(geometry.pathTransforms?.head ?? identityTransform), backPaths: Array.from({ length: bodyPathSlots }, (_, index) => motionValue(geometry.backPaths[index] ?? '') ), + backTransforms: Array.from({ length: bodyPathSlots }, (_, index) => + motionValue(geometry.pathTransforms?.back[index] ?? identityTransform) + ), frontPaths: Array.from({ length: bodyPathSlots }, (_, index) => motionValue(geometry.frontPaths[index] ?? '') ), + frontTransforms: Array.from({ length: bodyPathSlots }, (_, index) => + motionValue(geometry.pathTransforms?.front[index] ?? identityTransform) + ), + headsetFrontIndex: { current: geometry.headsetFrontIndex ?? null }, backNodeIds: { current: geometry.backNodeIds }, frontNodeIds: { current: geometry.frontNodeIds }, nodeOrderVersion: motionValue(0), leftPath: motionValue(geometry.leftPath), rightPath: motionValue(geometry.rightPath), + leftTransform: motionValue(geometry.pathTransforms?.left ?? identityTransform), + rightTransform: motionValue(geometry.pathTransforms?.right ?? identityTransform), leftOpacity: motionValue(geometry.leftVisible ? 1 : 0), rightOpacity: motionValue(geometry.rightVisible ? 1 : 0), offsetX: motionValue(0), @@ -62,8 +78,9 @@ export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y scene.offsetY.set(offset.y) } -export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => { +export const paintRenderedScene = (scene: RenderedScene, geometry: StudioAvatarGeometry) => { scene.headPath.set(geometry.headPath) + scene.headTransform.set(geometry.pathTransforms?.head ?? identityTransform) const nodeOrderChanged = scene.backNodeIds.current.length !== geometry.backNodeIds.length || scene.frontNodeIds.current.length !== geometry.frontNodeIds.length || @@ -71,11 +88,20 @@ export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometr scene.frontNodeIds.current.some((id, index) => id !== geometry.frontNodeIds[index]) scene.backNodeIds.current = geometry.backNodeIds scene.frontNodeIds.current = geometry.frontNodeIds + scene.headsetFrontIndex.current = geometry.headsetFrontIndex ?? null if (nodeOrderChanged) scene.nodeOrderVersion.set(scene.nodeOrderVersion.get() + 1) scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? '')) + scene.backTransforms.forEach((transform, index) => + transform.set(geometry.pathTransforms?.back[index] ?? identityTransform) + ) scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? '')) + scene.frontTransforms.forEach((transform, index) => + transform.set(geometry.pathTransforms?.front[index] ?? identityTransform) + ) scene.leftPath.set(geometry.leftPath) scene.rightPath.set(geometry.rightPath) + scene.leftTransform.set(geometry.pathTransforms?.left ?? identityTransform) + scene.rightTransform.set(geometry.pathTransforms?.right ?? identityTransform) scene.leftOpacity.set(geometry.leftVisible ? 1 : 0) scene.rightOpacity.set(geometry.rightVisible ? 1 : 0) scene.wirePaths.forEach((path, index) => path.set(geometry.wirePaths[index] ?? '')) diff --git a/src/features/studio/__tests__/studio-document-test.ts b/src/features/studio/__tests__/studio-document-test.ts index ad1da43..2ad3783 100644 --- a/src/features/studio/__tests__/studio-document-test.ts +++ b/src/features/studio/__tests__/studio-document-test.ts @@ -30,6 +30,7 @@ describe('Studio document', () => { const storage = (value: string | null = null) => ({ getItem: () => value }) const bundledAvatarNames = [ 'Memento', + 'Memento · Headset', 'Apple Bite', 'Lock', 'Siamese', @@ -79,7 +80,8 @@ describe('Studio document', () => { .filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled) .every(avatar => avatar.colors.body === avatar.colors.eyes) ).toBe(true) - expect(document.library.avatars.slice(0, 13).map(avatar => avatar.body.primary.type)).toEqual([ + expect(document.library.avatars.slice(0, 14).map(avatar => avatar.body.primary.type)).toEqual([ + 'ghost', 'ghost', 'apple-bite', 'lock', @@ -224,7 +226,7 @@ describe('Studio document', () => { ]) expect( document.library.avatars - .filter(avatar => avatar.id !== 'primitive-ghost') + .filter(avatar => !['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id)) .every(avatar => avatar.colors.body !== '#ff9d45') ).toBe(true) expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20) @@ -242,7 +244,7 @@ describe('Studio document', () => { .filter(expression => expression.bodyColor || expression.eyeColor) .map(expression => [expression.semanticKey, expression.bodyColor, expression.eyeColor]) ).toEqual([['angry-brows', '#ba3636', '#610000']]) - expect(document.expressions).toHaveLength(28) + expect(document.expressions).toHaveLength(29) expect(document.sequences).toHaveLength(24) expect(document.sequences.find(sequence => sequence.id === 'angry')?.steps).toHaveLength(2) expect( @@ -467,6 +469,39 @@ describe('Studio document', () => { expect(restored.expressions.at(-1)?.semanticKey).toBe('joyful-arc') }) + it('adds the front-facing expression to existing base and avatar libraries', () => { + const fallback = loadStudioDocument(storage()) + const withoutFront = fallback.expressions.filter( + expression => expression.semanticKey !== 'front-facing' + ) + const localDocument = { + ...fallback, + expressions: withoutFront, + library: { + ...fallback.library, + avatars: fallback.library.avatars.map(avatar => + avatar.behavior + ? { ...avatar, behavior: { ...avatar.behavior, expressions: withoutFront } } + : avatar + ), + }, + } + + const restored = loadStudioDocument(storage(JSON.stringify(localDocument))) + const front = restored.expressions.find(expression => expression.semanticKey === 'front-facing') + + expect(front).toMatchObject({ headX: 0, headY: 0, headZ: 0 }) + expect(front?.widthLeft).toBe(front?.widthRight) + expect(front?.heightLeft).toBe(front?.heightRight) + expect( + restored.library.avatars + .filter(avatar => avatar.behavior) + .every(avatar => + avatar.behavior?.expressions.some(expression => expression.semanticKey === 'front-facing') + ) + ).toBe(true) + }) + it('adds the joyful animation to an existing local project', () => { const fallback = loadStudioDocument(storage()) const localDocument = { diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx index 019e3c6..7f143f8 100644 --- a/src/features/studio/components/AvatarDrawer.tsx +++ b/src/features/studio/components/AvatarDrawer.tsx @@ -14,10 +14,10 @@ import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorksp import { defaultExpression } from '@/features/avatar/presets' import { avatarBodyOutlineWidth, - useAvatarBodyColorOverride, useRenderedSceneNodeOrder, } from '@/features/rendering/avatarAppearance' import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' +import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas' import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene' import type { StudioController } from '@/features/studio/useStudioController' @@ -35,14 +35,9 @@ function LiveAvatarPreview({ useRenderedSceneNodeOrder(scene) const outlineWidth = avatarBodyOutlineWidth() const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true - const fixedSkinColors = - avatar.behavior?.expressions.every( - expression => !expression.bodyColor && !expression.eyeColor - ) ?? false - const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, avatar.colors.body) - const bodyColor = fixedSkinColors && !bodyColorIsOverridden ? avatar.colors.body : colors.body - const eyeColor = fixedSkinColors ? avatar.colors.eyes : colors.eyes - const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden + const bodyColor = expressionBodyColor ? colors.body : avatar.colors.body + const eyeColor = expressionBodyColor ? colors.eyes : avatar.colors.eyes + const inheritBodyColor = Boolean(expressionBodyColor) const nodeColor = (id: string | null | undefined) => inheritBodyColor ? bodyColor @@ -76,9 +71,10 @@ function LiveAvatarPreview({ {scene.backPaths.map((pathValue, index) => { const color = nodeColor(scene.backNodeIds.current[index]) return ( - @@ -110,9 +106,10 @@ function LiveAvatarPreview({ {scene.frontPaths.map((pathValue, index) => { const color = nodeColor(scene.frontNodeIds.current[index]) return ( - diff --git a/src/features/studio/defaultStudioDocument.json b/src/features/studio/defaultStudioDocument.json index 1a091d8..fc4a046 100644 --- a/src/features/studio/defaultStudioDocument.json +++ b/src/features/studio/defaultStudioDocument.json @@ -1276,6 +1276,29 @@ "perspective": 1, "eyeMotion": "none", "bodyMotion": "none" + }, + { + "id": "expression-front-facing", + "semanticKey": "front-facing", + "headX": 0, + "headY": 0, + "headZ": 0, + "widthLeft": 20, + "widthRight": 20, + "heightLeft": 50, + "heightRight": 50, + "curvatureLeft": 0, + "curvatureRight": 0, + "spacing": 35, + "positionXLeft": 0, + "positionXRight": 0, + "positionYLeft": -7, + "positionYRight": -7, + "leftAngle": 0, + "rightAngle": 0, + "perspective": 1, + "eyeMotion": "none", + "bodyMotion": "none" } ], "sequences": [ diff --git a/src/features/studio/studioDocument.ts b/src/features/studio/studioDocument.ts index 45d7ef4..c8ecf98 100644 --- a/src/features/studio/studioDocument.ts +++ b/src/features/studio/studioDocument.ts @@ -175,6 +175,7 @@ const restoreSignatureExpressions = ( addMissing = true ) => { const signatureKeys = new Set(['joyful-arc']) + const additiveKeys = new Set(['joyful-arc', 'front-facing']) const fallbackByKey = new Map( fallback .filter(expression => expression.semanticKey && signatureKeys.has(expression.semanticKey)) @@ -206,7 +207,7 @@ const restoreSignatureExpressions = ( ? fallback.filter( expression => expression.semanticKey && - signatureKeys.has(expression.semanticKey) && + additiveKeys.has(expression.semanticKey) && !existingKeys.has(expression.semanticKey) ) : [] @@ -214,6 +215,14 @@ const restoreSignatureExpressions = ( return changed ? restored : expressions } +const restoreFrontFacingExpression = (expressions: Expression[], fallback: Expression[]) => { + if (expressions.some(expression => expression.semanticKey === 'front-facing')) { + return expressions + } + const frontFacing = fallback.find(expression => expression.semanticKey === 'front-facing') + return frontFacing ? [...expressions, { ...frontFacing }] : expressions +} + const restoreAvatarSignatureBehavior = ( library: AvatarLibrary, fallbackExpressions: Expression[], @@ -222,10 +231,9 @@ const restoreAvatarSignatureBehavior = ( let changed = false const avatars = library.avatars.map(avatar => { if (!avatar.behavior) return avatar - const expressions = restoreSignatureExpressions( - avatar.behavior.expressions, - fallbackExpressions, - false + const expressions = restoreFrontFacingExpression( + restoreSignatureExpressions(avatar.behavior.expressions, fallbackExpressions, false), + fallbackExpressions ) const availableExpressionIds = new Set(expressions.map(expression => expression.id)) const joyful = fallbackSequences.find(sequence => sequence.id === 'joyful') diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts index 8bd014b..695d5a9 100644 --- a/src/features/studio/useStudioController.ts +++ b/src/features/studio/useStudioController.ts @@ -80,7 +80,6 @@ import { import { expressionFields, poseFromExpression, - renderAvatar, type AvatarPose, type Expression, } from '@/features/avatar/geometry' @@ -119,6 +118,7 @@ import { paintRenderedScene, } from '@/features/rendering/renderedScene' import { paintPixelAvatar } from '@/features/rendering/pixelRenderer' +import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories' import { createStudioDocumentStore, loadStudioDocument, @@ -309,10 +309,11 @@ export function useStudioController() { const pose = poseFromExpression(initialExpression) return { pose, - geometry: renderAvatar( + geometry: renderAvatarByStyle( poseWithAvatarEyes(initialExpression, initialAvatar.eyes), surface, 1, + initialAvatar.renderStyle, { bodyNodes } ), } @@ -411,11 +412,17 @@ export function useStudioController() { const renderPose = avatar ? poseWithAvatarEyes(renderedExpression, avatar.eyes ?? defaultAvatarEyes) : poseFromExpression(renderedExpression) - const geometry = renderAvatar(renderPose, surfaceRef.current, blink ?? blinkValue.get(), { - includeWire: showWireRef.current || highlightRef.current === 'head', - bodyNodes: bodyNodesRef.current, - eyeOffset, - }) + const geometry = renderAvatarByStyle( + renderPose, + surfaceRef.current, + blink ?? blinkValue.get(), + avatar?.renderStyle ?? { type: 'vector' }, + { + includeWire: showWireRef.current || highlightRef.current === 'head', + bodyNodes: bodyNodesRef.current, + eyeOffset, + } + ) paintRenderedScene(renderedScene, geometry) paintRenderedOffset( renderedScene, @@ -1325,6 +1332,7 @@ export function useStudioController() { const previewCanvasExpression = (next: Expression, target: CanvasPreviewTarget) => { const now = performance.now() + const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) const updateInspector = shouldSyncCanvasPreviewToReact(bodyEditing, target) && now - lastInspectorFrame.current >= INSPECTOR_FRAME_MS @@ -1340,12 +1348,13 @@ export function useStudioController() { paintRenderedRotationGizmo(renderedRotationGizmo, next) paintRenderedScene( renderedScene, - renderAvatar( + renderAvatarByStyle( poseFromExpression( resolveCanvasPreviewExpression(next, activeAvatarEyes, bodyEditing, target) ), surfaceRef.current, blinkValue.get(), + avatar?.renderStyle ?? { type: 'vector' }, { includeWire: showWireRef.current || highlightRef.current === 'head', bodyNodes: bodyNodesRef.current, @@ -1354,7 +1363,6 @@ export function useStudioController() { ) return } - const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current) if (avatar) setDisplayColors(resolveColors(next, avatar.colors)) paintPose(poseFromExpression(next)) }