fix: merge Memento headset contours
This commit is contained in:
parent
14472cb8e6
commit
1515a7c6cf
@ -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)'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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])}
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user