fix: merge Memento headset contours

This commit is contained in:
Наер Михаил Александрович 2026-08-27 01:07:57 +03:00
parent 14472cb8e6
commit 1515a7c6cf
9 changed files with 293 additions and 8 deletions

View File

@ -33,9 +33,23 @@ it('keeps the headset visible and transformed in a live animation preview', () =
)
const headset = view.container.querySelector('.avatar-headset')
const head = view.container.querySelector(
'path[mask="url(#live-preview-animated-headset-headset-occlusion-head-mask)"]'
)
expect(headset).not.toBeNull()
expect(head?.getAttribute('mask')).toBe(
'url(#live-preview-animated-headset-headset-occlusion-head-mask)'
)
expect(headset?.getAttribute('d')).toBe(geometry.frontPaths.at(-1))
expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1))
expect(headset?.getAttribute('mask')).toBe(
'url(#live-preview-animated-headset-headset-occlusion-mask)'
)
expect(
view.container
.querySelector('#live-preview-animated-headset-headset-occlusion-left-half rect')
?.getAttribute('width')
).toBe('573.13')
})
it('keeps the stroked Frame 172 skin unfilled in a live animation preview', () => {
@ -72,4 +86,7 @@ it('keeps the stroked Frame 172 skin unfilled in a live animation preview', () =
expect(Array.from(eyes).every(eye => eye.style.fill === 'none')).toBe(true)
expect(headset.style.fill).toBe('none')
expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1))
expect(headset?.getAttribute('mask')).toBe(
'url(#live-preview-animated-stroke-headset-headset-occlusion-mask)'
)
})

View File

@ -40,7 +40,13 @@ import {
LivePixelAvatarCanvas,
StaticPixelAvatarCanvas,
} from '@/features/rendering/components/PixelAvatarCanvas'
import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas'
import {
headsetHeadOcclusionMaskValue,
headsetOcclusionMaskValue,
LiveHeadsetOcclusionMask,
StaticHeadsetOcclusionMask,
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)
@ -125,12 +131,21 @@ export function ExpressionPreview({
)
}
const clipId = `preview-${id}`
const headsetOcclusionId = `${clipId}-headset-occlusion`
return (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<path d={geometry.headPath} transform={geometry.pathTransforms?.head} />
</clipPath>
{geometry.headsetOcclusion && (
<StaticHeadsetOcclusionMask
id={headsetOcclusionId}
headPath={geometry.headPath}
headsetPath={geometry.frontPaths[geometry.headsetFrontIndex!]}
occlusion={geometry.headsetOcclusion}
/>
)}
</defs>
{geometry.backPaths.map((pathValue, index) => {
const color = nodeColor(geometry.backNodeIds[index])
@ -153,6 +168,9 @@ export function ExpressionPreview({
className="preview-head"
d={geometry.headPath}
transform={geometry.pathTransforms?.head}
mask={
geometry.headsetOcclusion ? headsetHeadOcclusionMaskValue(headsetOcclusionId) : undefined
}
style={{
fill: bodyFill,
stroke: resolvedColors.body,
@ -197,6 +215,11 @@ export function ExpressionPreview({
className={`preview-head${headset && !strokeOnly ? ' avatar-headset' : ''}`}
d={pathValue}
transform={geometry.pathTransforms?.front[index]}
mask={
headset && geometry.headsetOcclusion
? headsetOcclusionMaskValue(headsetOcclusionId)
: undefined
}
key={`front-${index}`}
style={{
fill: headset && strokeOnly ? 'none' : filled ? color : bodyFill,
@ -244,6 +267,7 @@ export function LiveExpressionPreview({
}
const clipId = `live-preview-${id}`
const headsetOcclusionId = `${clipId}-headset-occlusion`
const filled = renderStyle.filled === true
const strokeOnly = isStrokeOnlyRenderStyle(renderStyle)
const outlineWidth = avatarOutlineWidth(renderStyle)
@ -263,6 +287,14 @@ export function LiveExpressionPreview({
<clipPath id={clipId}>
<SvgTransformPath d={scene.headPath} svgTransform={scene.headTransform} />
</clipPath>
{scene.headsetOcclusion.current && (
<LiveHeadsetOcclusionMask
id={headsetOcclusionId}
headPath={scene.headPath}
headsetPath={scene.frontPaths[scene.headsetFrontIndex.current!]}
occlusion={scene.headsetOcclusion.current}
/>
)}
</defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => {
@ -290,6 +322,11 @@ export function LiveExpressionPreview({
<SvgTransformPath
d={scene.headPath}
svgTransform={scene.headTransform}
mask={
scene.headsetOcclusion.current
? headsetHeadOcclusionMaskValue(headsetOcclusionId)
: undefined
}
strokeLinecap="round"
strokeLinejoin="round"
style={{
@ -333,6 +370,11 @@ export function LiveExpressionPreview({
className={`preview-head${headset && !strokeOnly ? ' avatar-headset' : ''}`}
d={pathValue}
svgTransform={scene.frontTransforms[index]}
mask={
headset && scene.headsetOcclusion.current
? headsetOcclusionMaskValue(headsetOcclusionId)
: undefined
}
strokeLinecap="round"
strokeLinejoin="round"
style={{

View File

@ -51,7 +51,12 @@ describe('avatar snapshot export', () => {
expect(svg).toContain('fill="#111316" opacity="1"')
expect(svg).toContain(`transform="${headsetGeometry.pathTransforms?.front.at(-1)}"`)
expect(svg).not.toContain('stroke=')
expect(svg).toContain('mask="url(#snapshot-headset-mask)"')
expect(svg).toContain('mask="url(#snapshot-headset-head-mask)"')
expect(svg).toContain('<clipPath id="snapshot-headset-left-half">')
expect(svg).toContain('width="573.13" height="936"')
expect(svg).toContain('maskContentUnits="userSpaceOnUse"')
expect(svg).toContain('stroke-width="2.4"')
})
it('applies logo framing without changing the rendered avatar scene', () => {

View File

@ -28,9 +28,9 @@ const escapeXml = (value: string) =>
return entities[character]
})
const path = (value: string, fill: string, opacity = 1, transform?: string) =>
const path = (value: string, fill: string, opacity = 1, transform?: string, mask?: string) =>
value
? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"${transform ? ` transform="${escapeXml(transform)}"` : ''}/>`
? `<path d="${escapeXml(value)}" fill="${fill}" opacity="${opacity}"${transform ? ` transform="${escapeXml(transform)}"` : ''}${mask ? ` mask="${escapeXml(mask)}"` : ''}/>`
: ''
const backgroundMarkup = (options: SnapshotOptions) => {
@ -74,25 +74,36 @@ export const serializeAvatarSnapshot = (
})
const offsetX = scene.offsetX.get()
const offsetY = scene.offsetY.get()
const headsetOcclusion = scene.headsetOcclusion.current
const headTransform = scene.headTransform.get()
const body = [
...backPaths.map(({ value, index }) =>
path(value, colors.body, 1, scene.backTransforms[index].get())
),
path(headPath, colors.body, 1, scene.headTransform.get()),
path(
headPath,
colors.body,
1,
headTransform,
headsetOcclusion ? 'url(#snapshot-headset-head-mask)' : undefined
),
`<g clip-path="url(#snapshot-head-clip)">${path(scene.leftPath.get(), colors.eyes, scene.leftOpacity.get(), scene.leftTransform.get())}${path(scene.rightPath.get(), colors.eyes, scene.rightOpacity.get(), scene.rightTransform.get())}</g>`,
...frontPaths.map(({ value, index }) =>
path(
value,
scene.headsetFrontIndex.current === index ? '#111316' : colors.body,
1,
scene.frontTransforms[index].get()
scene.frontTransforms[index].get(),
scene.headsetFrontIndex.current === index && headsetOcclusion
? 'url(#snapshot-headset-mask)'
: undefined
)
),
].join('')
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${options.size}" height="${options.size}" role="img" aria-label="${escapeXml(name)}">
<defs>${gradientMarkup(options)}<clipPath id="snapshot-frame-clip"><rect x="-150" y="-150" width="300" height="300" rx="${snapshotCornerRadius(composition.cornerRadius)}"/></clipPath><clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
<defs>${gradientMarkup(options)}<clipPath id="snapshot-frame-clip"><rect x="-150" y="-150" width="300" height="300" rx="${snapshotCornerRadius(composition.cornerRadius)}"/></clipPath><clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}" transform="${escapeXml(headTransform)}"/></clipPath>${headsetOcclusion ? `<clipPath id="snapshot-headset-left-half"><rect x="0" y="0" width="${headsetOcclusion.splitX}" height="${headsetOcclusion.height}"/></clipPath><mask id="snapshot-headset-mask" x="0" y="0" width="${headsetOcclusion.width}" height="${headsetOcclusion.height}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" style="mask-type:luminance"><rect x="0" y="0" width="${headsetOcclusion.width}" height="${headsetOcclusion.height}" fill="white"/><path d="${escapeXml(headPath)}" clip-path="url(#snapshot-headset-left-half)" fill="black" stroke="black" stroke-width="${headsetOcclusion.strokeWidth}" vector-effect="non-scaling-stroke"/></mask><mask id="snapshot-headset-head-mask" x="0" y="0" width="${headsetOcclusion.width}" height="${headsetOcclusion.height}" maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse" style="mask-type:luminance"><rect x="0" y="0" width="${headsetOcclusion.width}" height="${headsetOcclusion.height}" fill="white"/><path d="${escapeXml(frontPaths[scene.headsetFrontIndex.current!].value)}" clip-path="url(#snapshot-headset-left-half)" fill="black" stroke="black" stroke-width="${headsetOcclusion.strokeWidth}" vector-effect="non-scaling-stroke"/></mask>` : ''}</defs>
<g clip-path="url(#snapshot-frame-clip)">
${backgroundMarkup(options)}
<g transform="translate(${composition.x} ${composition.y}) scale(${composition.scale})"><g transform="translate(${offsetX} ${offsetY})">${body}</g></g>

View File

@ -62,6 +62,13 @@ describe('rendered avatar scene', () => {
artwork: 'frame-172-colored',
})
expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1)
expect(neutral.headsetOcclusion).toEqual({
side: 'left',
strokeWidth: 2.4,
width: 1145,
height: 936,
splitX: 573.13,
})
expect(neutral.headPath).toContain('M575.918 77.6582')
expect(neutral.frontPaths.at(-1)).toContain('M573.131 2.5')
expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1))
@ -69,6 +76,7 @@ describe('rendered avatar scene', () => {
const scene = createRenderedScene(neutral)
paintRenderedScene(scene, turned)
expect(scene.headsetFrontIndex.current).toBe(turned.frontPaths.length - 1)
expect(scene.headsetOcclusion.current).toEqual(neutral.headsetOcclusion)
expect(scene.frontTransforms[scene.headsetFrontIndex.current!].get()).toBe(
turned.pathTransforms?.front.at(-1)
)
@ -95,6 +103,13 @@ describe('rendered avatar scene', () => {
artwork: 'frame-172-stroke',
})
expect(geometry.headsetFrontIndex).toBe(geometry.frontPaths.length - 1)
expect(geometry.headsetOcclusion).toEqual({
side: 'left',
strokeWidth: 1,
width: 1145,
height: 1079,
splitX: 572.13,
})
expect(geometry.headPath).toContain('M574.918 77.6582')
expect(geometry.frontPaths.at(-1)).toContain('M572.131 2.5')
})

View File

@ -24,6 +24,13 @@ export type AvatarPathTransforms = {
export type StudioAvatarGeometry = AvatarGeometry & {
pathTransforms?: AvatarPathTransforms
headsetFrontIndex?: number
headsetOcclusion?: {
side: 'left'
strokeWidth: number
width: number
height: number
splitX: number
}
}
const pathValues = [...headsetSvg.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)].map(match => match[1])
@ -135,6 +142,13 @@ export const renderAvatarByStyle = (
headPath: colored ? frame172ColoredBodyPath : frame172StrokeBodyPath,
wirePaths: [],
headsetFrontIndex: 0,
headsetOcclusion: {
side: 'left',
strokeWidth: colored ? 2.4 : 1,
width: 1145,
height: colored ? 936 : 1079,
splitX: colored ? 573.13 : 572.13,
},
pathTransforms: {
back: [],
head: artworkTransform,

View File

@ -22,6 +22,7 @@ import {
} from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
import type { StudioAvatarGeometry } from '@/features/rendering/avatarAccessories'
import {
poseFromExpression,
renderBodyNodeEditor,
@ -245,6 +246,139 @@ export function SvgTransformPath({
return <motion.path {...props} ref={ref} />
}
const headsetOcclusionIds = (id: string) => ({
leftClip: `${id}-left-half`,
mask: `${id}-mask`,
headMask: `${id}-head-mask`,
})
export function StaticHeadsetOcclusionMask({
id,
headPath,
headsetPath,
occlusion,
}: {
id: string
headPath: string
headsetPath: string
occlusion: NonNullable<StudioAvatarGeometry['headsetOcclusion']>
}) {
const ids = headsetOcclusionIds(id)
return (
<>
<clipPath id={ids.leftClip}>
<rect x="0" y="0" width={occlusion.splitX} height={occlusion.height} />
</clipPath>
<mask
id={ids.mask}
x="0"
y="0"
width={occlusion.width}
height={occlusion.height}
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
{...({ 'mask-type': 'luminance' } as React.SVGProps<SVGMaskElement>)}
>
<rect x="0" y="0" width={occlusion.width} height={occlusion.height} fill="white" />
<path
d={headPath}
clipPath={`url(#${ids.leftClip})`}
fill="black"
stroke="black"
strokeWidth={occlusion.strokeWidth}
vectorEffect="non-scaling-stroke"
/>
</mask>
<mask
id={ids.headMask}
x="0"
y="0"
width={occlusion.width}
height={occlusion.height}
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
{...({ 'mask-type': 'luminance' } as React.SVGProps<SVGMaskElement>)}
>
<rect x="0" y="0" width={occlusion.width} height={occlusion.height} fill="white" />
<path
d={headsetPath}
clipPath={`url(#${ids.leftClip})`}
fill="black"
stroke="black"
strokeWidth={occlusion.strokeWidth}
vectorEffect="non-scaling-stroke"
/>
</mask>
</>
)
}
export function LiveHeadsetOcclusionMask({
id,
headPath,
headsetPath,
occlusion,
}: {
id: string
headPath: MotionValue<string>
headsetPath: MotionValue<string>
occlusion: NonNullable<StudioAvatarGeometry['headsetOcclusion']>
}) {
const ids = headsetOcclusionIds(id)
return (
<>
<clipPath id={ids.leftClip}>
<rect x="0" y="0" width={occlusion.splitX} height={occlusion.height} />
</clipPath>
<mask
id={ids.mask}
x="0"
y="0"
width={occlusion.width}
height={occlusion.height}
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
{...({ 'mask-type': 'luminance' } as React.SVGProps<SVGMaskElement>)}
>
<rect x="0" y="0" width={occlusion.width} height={occlusion.height} fill="white" />
<motion.path
d={headPath}
clipPath={`url(#${ids.leftClip})`}
fill="black"
stroke="black"
strokeWidth={occlusion.strokeWidth}
vectorEffect="non-scaling-stroke"
/>
</mask>
<mask
id={ids.headMask}
x="0"
y="0"
width={occlusion.width}
height={occlusion.height}
maskUnits="userSpaceOnUse"
maskContentUnits="userSpaceOnUse"
{...({ 'mask-type': 'luminance' } as React.SVGProps<SVGMaskElement>)}
>
<rect x="0" y="0" width={occlusion.width} height={occlusion.height} fill="white" />
<motion.path
d={headsetPath}
clipPath={`url(#${ids.leftClip})`}
fill="black"
stroke="black"
strokeWidth={occlusion.strokeWidth}
vectorEffect="non-scaling-stroke"
/>
</mask>
</>
)
}
export const headsetOcclusionMaskValue = (id: string) => `url(#${headsetOcclusionIds(id).mask})`
export const headsetHeadOcclusionMaskValue = (id: string) =>
`url(#${headsetOcclusionIds(id).headMask})`
export function BodyNodeGizmo({
svgRef,
pose,
@ -579,6 +713,7 @@ export function AvatarCanvas({
frontTransforms,
backNodeIds,
frontNodeIds,
headsetOcclusion,
headPath,
headTransform,
leftPath,
@ -590,6 +725,7 @@ export function AvatarCanvas({
offsetX,
offsetY,
} = scene
const headsetOcclusionId = 'avatar-headset-left-occlusion'
const svgRef = useRef<SVGSVGElement>(null)
const [activeDragType, setActiveDragType] = useState<
'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null
@ -844,6 +980,14 @@ export function AvatarCanvas({
<clipPath id="avatar-head-clip">
<SvgTransformPath d={headPath} svgTransform={headTransform} />
</clipPath>
{headsetOcclusion.current && (
<LiveHeadsetOcclusionMask
id={headsetOcclusionId}
headPath={headPath}
headsetPath={frontPaths[scene.headsetFrontIndex.current!]}
occlusion={headsetOcclusion.current}
/>
)}
</defs>
<motion.g style={{ x: offsetX, y: offsetY }}>
{backPaths.map((pathValue, index) => (
@ -860,6 +1004,11 @@ export function AvatarCanvas({
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={headPath}
svgTransform={headTransform}
mask={
headsetOcclusion.current
? headsetHeadOcclusionMaskValue(headsetOcclusionId)
: undefined
}
onPointerDown={event => {
onBodyNodeSelect('primary')
startDrag(event)
@ -890,6 +1039,11 @@ export function AvatarCanvas({
className={`avatar-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''} ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={pathValue}
svgTransform={frontTransforms[index]}
mask={
scene.headsetFrontIndex.current === index && headsetOcclusion.current
? headsetOcclusionMaskValue(headsetOcclusionId)
: undefined
}
key={index}
style={nodeColorStyle(frontNodeIds.current[index])}
onPointerDown={event => selectBodyPath(event, frontNodeIds.current[index])}

View File

@ -12,6 +12,7 @@ export type RenderedScene = {
frontPaths: MotionValue<string>[]
frontTransforms: MotionValue<string>[]
headsetFrontIndex: { current: number | null }
headsetOcclusion: { current: StudioAvatarGeometry['headsetOcclusion'] }
backNodeIds: { current: (string | null)[] }
frontNodeIds: { current: (string | null)[] }
nodeOrderVersion: MotionValue<number>
@ -49,6 +50,7 @@ export const createRenderedScene = (geometry: StudioAvatarGeometry): RenderedSce
motionValue(geometry.pathTransforms?.front[index] ?? identityTransform)
),
headsetFrontIndex: { current: geometry.headsetFrontIndex ?? null },
headsetOcclusion: { current: geometry.headsetOcclusion },
backNodeIds: { current: geometry.backNodeIds },
frontNodeIds: { current: geometry.frontNodeIds },
nodeOrderVersion: motionValue(0),
@ -89,6 +91,7 @@ export const paintRenderedScene = (scene: RenderedScene, geometry: StudioAvatarG
scene.backNodeIds.current = geometry.backNodeIds
scene.frontNodeIds.current = geometry.frontNodeIds
scene.headsetFrontIndex.current = geometry.headsetFrontIndex ?? null
scene.headsetOcclusion.current = geometry.headsetOcclusion
if (nodeOrderChanged) scene.nodeOrderVersion.set(scene.nodeOrderVersion.get() + 1)
scene.backPaths.forEach((path, index) => path.set(geometry.backPaths[index] ?? ''))
scene.backTransforms.forEach((transform, index) =>

View File

@ -17,7 +17,12 @@ import {
useRenderedSceneNodeOrder,
} from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { SvgTransformPath } from '@/features/rendering/components/AvatarCanvas'
import {
headsetHeadOcclusionMaskValue,
headsetOcclusionMaskValue,
LiveHeadsetOcclusionMask,
SvgTransformPath,
} from '@/features/rendering/components/AvatarCanvas'
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
import type { StudioController } from '@/features/studio/useStudioController'
@ -54,6 +59,7 @@ function LiveAvatarPreview({
: (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor)
const bodyFill = strokeOnly ? 'none' : filled ? bodyColor : '#ffffff'
const clipId = `live-avatar-${avatar.id}`
const headsetOcclusionId = `${clipId}-headset-occlusion`
if (avatar.renderStyle.type === 'pixel') {
return (
@ -76,6 +82,14 @@ function LiveAvatarPreview({
<clipPath id={clipId}>
<SvgTransformPath d={scene.headPath} svgTransform={scene.headTransform} />
</clipPath>
{scene.headsetOcclusion.current && (
<LiveHeadsetOcclusionMask
id={headsetOcclusionId}
headPath={scene.headPath}
headsetPath={scene.frontPaths[scene.headsetFrontIndex.current!]}
occlusion={scene.headsetOcclusion.current}
/>
)}
</defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => {
@ -94,6 +108,11 @@ function LiveAvatarPreview({
className="preview-head"
d={scene.headPath}
svgTransform={scene.headTransform}
mask={
scene.headsetOcclusion.current
? headsetHeadOcclusionMaskValue(headsetOcclusionId)
: undefined
}
style={{
fill: bodyFill,
stroke: bodyColor,
@ -133,6 +152,11 @@ function LiveAvatarPreview({
className={`preview-head${headset && !strokeOnly ? ' avatar-headset' : ''}`}
d={pathValue}
svgTransform={scene.frontTransforms[index]}
mask={
headset && scene.headsetOcclusion.current
? headsetOcclusionMaskValue(headsetOcclusionId)
: undefined
}
key={`front-${index}`}
style={{
fill: strokeOnly ? 'none' : filled ? color : bodyFill,