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