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

View File

@ -51,7 +51,12 @@ describe('avatar snapshot export', () => {
expect(svg).toContain('fill="#111316" opacity="1"') expect(svg).toContain('fill="#111316" opacity="1"')
expect(svg).toContain(`transform="${headsetGeometry.pathTransforms?.front.at(-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', () => { it('applies logo framing without changing the rendered avatar scene', () => {

View File

@ -28,9 +28,9 @@ const escapeXml = (value: string) =>
return entities[character] 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 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) => { const backgroundMarkup = (options: SnapshotOptions) => {
@ -74,25 +74,36 @@ export const serializeAvatarSnapshot = (
}) })
const offsetX = scene.offsetX.get() const offsetX = scene.offsetX.get()
const offsetY = scene.offsetY.get() const offsetY = scene.offsetY.get()
const headsetOcclusion = scene.headsetOcclusion.current
const headTransform = scene.headTransform.get()
const body = [ const body = [
...backPaths.map(({ value, index }) => ...backPaths.map(({ value, index }) =>
path(value, colors.body, 1, scene.backTransforms[index].get()) 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>`, `<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 }) => ...frontPaths.map(({ value, index }) =>
path( path(
value, value,
scene.headsetFrontIndex.current === index ? '#111316' : colors.body, scene.headsetFrontIndex.current === index ? '#111316' : colors.body,
1, 1,
scene.frontTransforms[index].get() scene.frontTransforms[index].get(),
scene.headsetFrontIndex.current === index && headsetOcclusion
? 'url(#snapshot-headset-mask)'
: undefined
) )
), ),
].join('') ].join('')
return `<?xml version="1.0" encoding="UTF-8"?> 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)}"> <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)"> <g clip-path="url(#snapshot-frame-clip)">
${backgroundMarkup(options)} ${backgroundMarkup(options)}
<g transform="translate(${composition.x} ${composition.y}) scale(${composition.scale})"><g transform="translate(${offsetX} ${offsetY})">${body}</g></g> <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', artwork: 'frame-172-colored',
}) })
expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1) 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.headPath).toContain('M575.918 77.6582')
expect(neutral.frontPaths.at(-1)).toContain('M573.131 2.5') expect(neutral.frontPaths.at(-1)).toContain('M573.131 2.5')
expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1)) 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) const scene = createRenderedScene(neutral)
paintRenderedScene(scene, turned) paintRenderedScene(scene, turned)
expect(scene.headsetFrontIndex.current).toBe(turned.frontPaths.length - 1) expect(scene.headsetFrontIndex.current).toBe(turned.frontPaths.length - 1)
expect(scene.headsetOcclusion.current).toEqual(neutral.headsetOcclusion)
expect(scene.frontTransforms[scene.headsetFrontIndex.current!].get()).toBe( expect(scene.frontTransforms[scene.headsetFrontIndex.current!].get()).toBe(
turned.pathTransforms?.front.at(-1) turned.pathTransforms?.front.at(-1)
) )
@ -95,6 +103,13 @@ describe('rendered avatar scene', () => {
artwork: 'frame-172-stroke', artwork: 'frame-172-stroke',
}) })
expect(geometry.headsetFrontIndex).toBe(geometry.frontPaths.length - 1) 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.headPath).toContain('M574.918 77.6582')
expect(geometry.frontPaths.at(-1)).toContain('M572.131 2.5') expect(geometry.frontPaths.at(-1)).toContain('M572.131 2.5')
}) })

View File

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

View File

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

View File

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

View File

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