]*\bd="([^"]+)"/g)].map(match => match[1])
+const frame172ColoredPathValues = svgPathValues(frame172ColoredSvg)
+const frame172StrokePathValues = svgPathValues(frame172StrokeSvg)
+
+if (frame172ColoredPathValues.length !== 4 || frame172StrokePathValues.length !== 4) {
+ throw new Error(
+ `Expected four paths in each Frame 172 SVG, received ${frame172ColoredPathValues.length} and ${frame172StrokePathValues.length}`
+ )
+}
+
+const [frame172ColoredBodyPath, , , frame172ColoredHeadsetPath] = frame172ColoredPathValues
+const [frame172StrokeBodyPath, , , frame172StrokeHeadsetPath] = frame172StrokePathValues
export const identityTransform = 'matrix(1 0 0 1 0 0)'
const identity: Matrix = [1, 0, 0, 1, 0, 0]
@@ -80,6 +95,26 @@ const headsetTransform = (pose: AvatarPose, surface: SurfaceConfig) => {
)
}
+const frame172Transform = (pose: AvatarPose, artwork: 'frame-172-colored' | 'frame-172-stroke') => {
+ 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))
+ const normalize =
+ artwork === 'frame-172-colored'
+ ? compose(scale(0.2, 0.2), translate(-573.13, -468))
+ : compose(scale(0.2, 0.2), translate(-572.13, -539.5))
+ const poseTransform = compose(
+ rotate(expression.headZ),
+ skewY(verticalTurn * 3.2),
+ skewX(-horizontalTurn * 4.2),
+ scale(horizontalScale * perspective, verticalScale * perspective)
+ )
+ return matrixValue(compose(poseTransform, normalize))
+}
+
export const renderAvatarByStyle = (
pose: AvatarPose,
surface: SurfaceConfig,
@@ -88,6 +123,27 @@ export const renderAvatarByStyle = (
options: RenderAvatarOptions = {}
): StudioAvatarGeometry => {
const geometry = renderAvatar(pose, surface, blink, options)
+ if (renderStyle.type === 'vector' && renderStyle.artwork) {
+ const colored = renderStyle.artwork === 'frame-172-colored'
+ const artworkTransform = frame172Transform(pose, renderStyle.artwork)
+ return {
+ ...geometry,
+ backPaths: [],
+ frontPaths: [colored ? frame172ColoredHeadsetPath : frame172StrokeHeadsetPath],
+ backNodeIds: [],
+ frontNodeIds: [null],
+ headPath: colored ? frame172ColoredBodyPath : frame172StrokeBodyPath,
+ wirePaths: [],
+ headsetFrontIndex: 0,
+ pathTransforms: {
+ back: [],
+ head: artworkTransform,
+ left: identityTransform,
+ right: identityTransform,
+ front: [artworkTransform],
+ },
+ }
+ }
if (renderStyle.type !== 'vector' || renderStyle.accessory !== 'headset') return geometry
return {
diff --git a/src/features/rendering/avatarAppearance.ts b/src/features/rendering/avatarAppearance.ts
index 6d89bd9..6d1355d 100644
--- a/src/features/rendering/avatarAppearance.ts
+++ b/src/features/rendering/avatarAppearance.ts
@@ -1,9 +1,16 @@
import { useMotionValueEvent, type MotionValue } from 'motion/react'
import { useEffect, useState } from 'react'
+import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import type { RenderedScene } from './renderedScene'
export const avatarBodyOutlineWidth = () => 12
+export const avatarOutlineWidth = (renderStyle: AvatarRenderStyle) =>
+ renderStyle.type === 'vector' && renderStyle.artwork === 'frame-172-stroke'
+ ? 1
+ : renderStyle.type === 'vector' && Boolean(renderStyle.artwork)
+ ? 2.4
+ : avatarBodyOutlineWidth()
const colorsMatch = (left: string, right: string) =>
left.trim().toLowerCase() === right.trim().toLowerCase()
diff --git a/src/features/rendering/components/AvatarCanvas.tsx b/src/features/rendering/components/AvatarCanvas.tsx
index a31de10..5ec6e51 100644
--- a/src/features/rendering/components/AvatarCanvas.tsx
+++ b/src/features/rendering/components/AvatarCanvas.tsx
@@ -18,6 +18,7 @@ import {
applyAvatarEyeDefaults,
type AvatarEyeDefaults,
type AvatarRenderStyle,
+ isStrokeOnlyRenderStyle,
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
@@ -579,8 +580,11 @@ export function AvatarCanvas({
backNodeIds,
frontNodeIds,
headPath,
+ headTransform,
leftPath,
rightPath,
+ leftTransform,
+ rightTransform,
leftOpacity,
rightOpacity,
offsetX,
@@ -806,7 +810,7 @@ export function AvatarCanvas({
useEscapeToCancel(cancelDrag)
return (
{playback && (
-
+
@@ -852,9 +856,10 @@ export function AvatarCanvas({
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
/>
))}
- {
onBodyNodeSelect('primary')
startDrag(event)
@@ -865,15 +870,17 @@ export function AvatarCanvas({
wirePaths.map((pathValue, index) => (
))}
- selectEye(-1, event)}
/>
- selectEye(1, event)}
/>
diff --git a/src/features/studio/__tests__/studio-document-test.ts b/src/features/studio/__tests__/studio-document-test.ts
index 2ad3783..b1b5fa0 100644
--- a/src/features/studio/__tests__/studio-document-test.ts
+++ b/src/features/studio/__tests__/studio-document-test.ts
@@ -31,6 +31,7 @@ describe('Studio document', () => {
const bundledAvatarNames = [
'Memento',
'Memento · Headset',
+ 'Memento · Stroke',
'Apple Bite',
'Lock',
'Siamese',
@@ -80,7 +81,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, 14).map(avatar => avatar.body.primary.type)).toEqual([
+ expect(document.library.avatars.slice(0, 15).map(avatar => avatar.body.primary.type)).toEqual([
+ 'ghost',
'ghost',
'ghost',
'apple-bite',
@@ -121,6 +123,13 @@ describe('Studio document', () => {
type: 'vector',
filled: true,
})
+ expect(
+ document.library.avatars.find(avatar => avatar.id === 'primitive-ghost-headset')?.renderStyle
+ ).toEqual({ type: 'vector', filled: true, artwork: 'frame-172-colored' })
+ expect(
+ document.library.avatars.find(avatar => avatar.id === 'primitive-ghost-headset-stroke')
+ ?.renderStyle
+ ).toEqual({ type: 'vector', strokeOnly: true, artwork: 'frame-172-stroke' })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle
).toEqual({ type: 'vector', filled: true })
@@ -203,7 +212,18 @@ describe('Studio document', () => {
?.body.nodes.map(node => node.color)
).toEqual(['#a95f47', '#a95f47'])
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
+ const headsetMemento = document.library.avatars.find(
+ avatar => avatar.id === 'primitive-ghost-headset'
+ )
+ const strokeMemento = document.library.avatars.find(
+ avatar => avatar.id === 'primitive-ghost-headset-stroke'
+ )
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#111316' })
+ expect(headsetMemento?.colors).toEqual({ body: '#ff9d45', eyes: '#222222' })
+ expect(strokeMemento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
+ expect(
+ strokeMemento?.behavior?.sequences.some(sequence => sequence.semanticKey === 'onboarding')
+ ).toBe(true)
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors
).toEqual({ body: '#ff5558', eyes: '#111316' })
@@ -226,7 +246,14 @@ describe('Studio document', () => {
])
expect(
document.library.avatars
- .filter(avatar => !['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id))
+ .filter(
+ avatar =>
+ ![
+ 'primitive-ghost',
+ 'primitive-ghost-headset',
+ 'primitive-ghost-headset-stroke',
+ ].includes(avatar.id)
+ )
.every(avatar => avatar.colors.body !== '#ff9d45')
).toBe(true)
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)
diff --git a/src/features/studio/components/AvatarDrawer.tsx b/src/features/studio/components/AvatarDrawer.tsx
index 7f143f8..5c0f110 100644
--- a/src/features/studio/components/AvatarDrawer.tsx
+++ b/src/features/studio/components/AvatarDrawer.tsx
@@ -9,11 +9,11 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu'
-import { defaultAvatarEyes } from '@/features/avatar/avatars'
+import { defaultAvatarEyes, isStrokeOnlyRenderStyle } from '@/features/avatar/avatars'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets'
import {
- avatarBodyOutlineWidth,
+ avatarOutlineWidth,
useRenderedSceneNodeOrder,
} from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
@@ -33,16 +33,26 @@ function LiveAvatarPreview({
expressionBodyColor?: string
}) {
useRenderedSceneNodeOrder(scene)
- const outlineWidth = avatarBodyOutlineWidth()
+ const outlineWidth = avatarOutlineWidth(avatar.renderStyle)
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
- const bodyColor = expressionBodyColor ? colors.body : avatar.colors.body
- const eyeColor = expressionBodyColor ? colors.eyes : avatar.colors.eyes
+ const strokeOnly = isStrokeOnlyRenderStyle(avatar.renderStyle)
+ const sourceArtwork = avatar.renderStyle.type === 'vector' && Boolean(avatar.renderStyle.artwork)
+ const bodyColor = strokeOnly
+ ? avatar.colors.body
+ : expressionBodyColor
+ ? colors.body
+ : avatar.colors.body
+ const eyeColor = strokeOnly
+ ? avatar.colors.eyes
+ : expressionBodyColor
+ ? colors.eyes
+ : avatar.colors.eyes
const inheritBodyColor = Boolean(expressionBodyColor)
const nodeColor = (id: string | null | undefined) =>
inheritBodyColor
? bodyColor
: (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor)
- const bodyFill = filled ? bodyColor : '#ffffff'
+ const bodyFill = strokeOnly ? 'none' : filled ? bodyColor : '#ffffff'
const clipId = `live-avatar-${avatar.id}`
if (avatar.renderStyle.type === 'pixel') {
@@ -64,7 +74,7 @@ function LiveAvatarPreview({
>
-
+
@@ -80,38 +90,56 @@ function LiveAvatarPreview({
/>
)
})}
-
-
-
{scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index])
+ const headset = scene.headsetFrontIndex.current === index
return (
)
})}
diff --git a/src/features/studio/components/StudioStage.tsx b/src/features/studio/components/StudioStage.tsx
index 54d9a2c..8890f77 100644
--- a/src/features/studio/components/StudioStage.tsx
+++ b/src/features/studio/components/StudioStage.tsx
@@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { defaultExpression } from '@/features/avatar/presets'
-import { avatarBodyOutlineWidth } from '@/features/rendering/avatarAppearance'
+import { isStrokeOnlyRenderStyle } from '@/features/avatar/avatars'
+import { avatarOutlineWidth } from '@/features/rendering/avatarAppearance'
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
@@ -61,6 +62,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
updateImmediate,
} = controller
const photoMode = mode === 'photo'
+ const fixedStrokeColor = isStrokeOnlyRenderStyle(activeAvatar.renderStyle)
const activatePhotoTool = (tool: typeof photoTool) => {
setPhotoTool(tool)
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
@@ -116,9 +118,9 @@ export function StudioStage({ controller }: { controller: StudioController }) {
className={`stage-column${photoMode ? ' photo-mode-active' : ''}`}
style={
{
- '--avatar-body-color': renderedColors.body,
- '--avatar-eye-color': renderedColors.eyes,
- '--avatar-outline-width': avatarBodyOutlineWidth(),
+ '--avatar-body-color': fixedStrokeColor ? activeAvatar.colors.body : renderedColors.body,
+ '--avatar-eye-color': fixedStrokeColor ? activeAvatar.colors.eyes : renderedColors.eyes,
+ '--avatar-outline-width': avatarOutlineWidth(activeAvatar.renderStyle),
} as CSSProperties
}
>