fix: refine avatar accessory occlusion
This commit is contained in:
parent
9114def84e
commit
edc7a6004d
@ -1382,7 +1382,7 @@ const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|||||||
return path(convexHull(projectedSamples))
|
return path(convexHull(projectedSamples))
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
|
const accessoryGeometry = (pose: AvatarPose, node: BodyNode) => {
|
||||||
const key = surfaceCacheKey(node.surface)
|
const key = surfaceCacheKey(node.surface)
|
||||||
let localSamples = accessorySamplesCache.get(key)
|
let localSamples = accessorySamplesCache.get(key)
|
||||||
if (!localSamples) {
|
if (!localSamples) {
|
||||||
@ -1415,11 +1415,13 @@ const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
|
|||||||
(node.surface.type === 'cube' || node.surface.type === 'diamond') &&
|
(node.surface.type === 'cube' || node.surface.type === 'diamond') &&
|
||||||
node.surface.roundness <= 0
|
node.surface.roundness <= 0
|
||||||
) {
|
) {
|
||||||
return path(hull)
|
return { hull, path: path(hull) }
|
||||||
}
|
}
|
||||||
return smoothClosedPath(densifyClosedPoints(hull))
|
return { hull, path: smoothClosedPath(densifyClosedPoints(hull)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Point2 = readonly [number, number]
|
||||||
|
|
||||||
const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
|
const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
|
||||||
|
|
||||||
const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
|
const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
|
||||||
@ -1444,17 +1446,82 @@ const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
|
const polygonSignedArea = (points: readonly Point2[]) =>
|
||||||
|
points.reduce((total, point, index) => {
|
||||||
|
const next = points[(index + 1) % points.length]
|
||||||
|
return total + point[0] * next[1] - next[0] * point[1]
|
||||||
|
}, 0) / 2
|
||||||
|
|
||||||
|
const convexPolygonIntersection = (subject: Point2[], clip: Point2[]) => {
|
||||||
|
let output = subject
|
||||||
|
const orientation = Math.sign(polygonSignedArea(clip)) || 1
|
||||||
|
const inside = (point: Point2, start: Point2, end: Point2) =>
|
||||||
|
orientation *
|
||||||
|
((end[0] - start[0]) * (point[1] - start[1]) - (end[1] - start[1]) * (point[0] - start[0])) >=
|
||||||
|
0
|
||||||
|
const crossing = (from: Point2, to: Point2, start: Point2, end: Point2): Point2 => {
|
||||||
|
const deltaX = to[0] - from[0]
|
||||||
|
const deltaY = to[1] - from[1]
|
||||||
|
const edgeX = end[0] - start[0]
|
||||||
|
const edgeY = end[1] - start[1]
|
||||||
|
const denominator = deltaX * edgeY - deltaY * edgeX
|
||||||
|
const amount =
|
||||||
|
denominator === 0
|
||||||
|
? 0
|
||||||
|
: ((start[0] - from[0]) * edgeY - (start[1] - from[1]) * edgeX) / denominator
|
||||||
|
return [from[0] + amount * deltaX, from[1] + amount * deltaY]
|
||||||
|
}
|
||||||
|
|
||||||
|
clip.forEach((start, index) => {
|
||||||
|
const end = clip[(index + 1) % clip.length]
|
||||||
|
const input = output
|
||||||
|
output = []
|
||||||
|
input.forEach((current, currentIndex) => {
|
||||||
|
const previous = input[(currentIndex + input.length - 1) % input.length]
|
||||||
|
const currentInside = inside(current, start, end)
|
||||||
|
const previousInside = inside(previous, start, end)
|
||||||
|
if (currentInside) {
|
||||||
|
if (!previousInside) output.push(crossing(previous, current, start, end))
|
||||||
|
output.push(current)
|
||||||
|
} else if (previousInside) {
|
||||||
|
output.push(crossing(previous, current, start, end))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_ACCESSORY_EYE_OCCLUSION_RATIO = 0.08
|
||||||
|
|
||||||
|
const eyeOcclusionRatio = (accessory: Point2[], eye: Point2[]) => {
|
||||||
|
const eyeArea = Math.abs(polygonSignedArea(eye))
|
||||||
|
if (eyeArea === 0) return 0
|
||||||
|
return Math.abs(polygonSignedArea(convexPolygonIntersection(eye, accessory))) / eyeArea
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[], visibleEyes: Point2[][]) => {
|
||||||
const layers = nodes
|
const layers = nodes
|
||||||
.map(node => {
|
.map(node => {
|
||||||
const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
|
const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
|
||||||
|
const geometry = accessoryGeometry(pose, node)
|
||||||
|
const stronglyOccludesEye =
|
||||||
|
node.layer === 'auto' &&
|
||||||
|
visibleEyes.some(
|
||||||
|
eye =>
|
||||||
|
eyeOcclusionRatio(
|
||||||
|
geometry.hull.map(point => [point[0], point[1]] as Point2),
|
||||||
|
eye
|
||||||
|
) >= AUTO_ACCESSORY_EYE_OCCLUSION_RATIO
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
path: accessoryPath(pose, node),
|
path: geometry.path,
|
||||||
depth,
|
depth,
|
||||||
front:
|
front:
|
||||||
node.layer === 'front' ||
|
node.layer === 'front' ||
|
||||||
(node.layer !== 'back' &&
|
(node.layer === 'auto'
|
||||||
|
? !stronglyOccludesEye
|
||||||
|
: node.layer !== 'back' &&
|
||||||
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
|
depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -1477,7 +1544,12 @@ export const renderAvatar = (
|
|||||||
const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
|
const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
|
||||||
const left = leftSamples.map(sample => sample.point)
|
const left = leftSamples.map(sample => sample.point)
|
||||||
const right = rightSamples.map(sample => sample.point)
|
const right = rightSamples.map(sample => sample.point)
|
||||||
const accessories = accessoryLayers(pose, options.bodyNodes ?? [])
|
const leftVisible = leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0
|
||||||
|
const rightVisible = rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0
|
||||||
|
const visibleEyes = [leftVisible ? left : null, rightVisible ? right : null]
|
||||||
|
.filter((eye): eye is Point3[] => eye !== null)
|
||||||
|
.map(eye => eye.map(point => [point[0], point[1]] as Point2))
|
||||||
|
const accessories = accessoryLayers(pose, options.bodyNodes ?? [], visibleEyes)
|
||||||
const compositePaths = compositeBackPaths(pose, surface)
|
const compositePaths = compositeBackPaths(pose, surface)
|
||||||
return {
|
return {
|
||||||
backPaths: [...compositePaths, ...accessories.backPaths],
|
backPaths: [...compositePaths, ...accessories.backPaths],
|
||||||
@ -1487,8 +1559,8 @@ export const renderAvatar = (
|
|||||||
headPath: headPath(pose, surface),
|
headPath: headPath(pose, surface),
|
||||||
leftPath: path(left),
|
leftPath: path(left),
|
||||||
rightPath: path(right),
|
rightPath: path(right),
|
||||||
leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
|
leftVisible,
|
||||||
rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
|
rightVisible,
|
||||||
wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
|
wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,10 +85,10 @@ describe('OneWorks animal construction', () => {
|
|||||||
library.avatars.find(avatar => avatar.id === id)?.body.nodes.map(node => node.layer)
|
library.avatars.find(avatar => avatar.id === id)?.body.nodes.map(node => node.layer)
|
||||||
|
|
||||||
expect(layers('oneworks-cat')).toBeUndefined()
|
expect(layers('oneworks-cat')).toBeUndefined()
|
||||||
expect(layers('oneworks-dog')).toEqual(['front', 'front'])
|
expect(layers('oneworks-dog')).toEqual(['auto', 'auto'])
|
||||||
expect(layers('oneworks-bear')).toEqual(['back', 'back'])
|
expect(layers('oneworks-bear')).toEqual(['back', 'back'])
|
||||||
expect(layers('oneworks-rabbit')).toEqual(['back', 'back'])
|
expect(layers('oneworks-rabbit')).toEqual(['back', 'back'])
|
||||||
expect(layers('oneworks-bun')).toEqual(['front'])
|
expect(layers('oneworks-bun')).toEqual(['back'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the unfinished piglet hidden and removes a persisted copy', () => {
|
it('keeps the unfinished piglet hidden and removes a persisted copy', () => {
|
||||||
@ -153,7 +153,7 @@ describe('OneWorks animal construction', () => {
|
|||||||
avatar => avatar.id === 'oneworks-dog'
|
avatar => avatar.id === 'oneworks-dog'
|
||||||
)!
|
)!
|
||||||
|
|
||||||
expect(dog.body.nodes.map(node => node.layer)).toEqual(['front', 'front'])
|
expect(dog.body.nodes.map(node => node.layer)).toEqual(['auto', 'auto'])
|
||||||
expect(dog.body.nodes.map(node => node.position)).toEqual([
|
expect(dog.body.nodes.map(node => node.position)).toEqual([
|
||||||
[-82, -65, 0],
|
[-82, -65, 0],
|
||||||
[82, -65, 0],
|
[82, -65, 0],
|
||||||
|
|||||||
@ -746,7 +746,7 @@ const oneWorksBundledAvatars: StudioAvatar[] = [
|
|||||||
nodes: [
|
nodes: [
|
||||||
oneWorksNode('bun-crown', 'Rounded bun crown', 'cone', [0, -46, -14], 0.5, 0.23, {
|
oneWorksNode('bun-crown', 'Rounded bun crown', 'cone', [0, -46, -14], 0.5, 0.23, {
|
||||||
scaleZ: 0.5,
|
scaleZ: 0.5,
|
||||||
layer: 'front',
|
layer: 'back',
|
||||||
surface: { roundness: 0.92, topScale: 0.82 },
|
surface: { roundness: 0.92, topScale: 0.82 },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
avatarBodyOutlineWidth,
|
avatarBodyOutlineWidth,
|
||||||
resolveAvatarNodeColor,
|
resolveAvatarNodeColor,
|
||||||
useAvatarBodyColorOverride,
|
useAvatarBodyColorOverride,
|
||||||
|
useRenderedSceneNodeOrder,
|
||||||
} from '@/features/rendering/avatarAppearance'
|
} from '@/features/rendering/avatarAppearance'
|
||||||
import {
|
import {
|
||||||
LivePixelAvatarCanvas,
|
LivePixelAvatarCanvas,
|
||||||
@ -199,6 +200,7 @@ export function LiveExpressionPreview({
|
|||||||
expressionBodyColor?: string
|
expressionBodyColor?: string
|
||||||
id: string
|
id: string
|
||||||
}) {
|
}) {
|
||||||
|
useRenderedSceneNodeOrder(scene)
|
||||||
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor)
|
const bodyColorIsOverridden = useAvatarBodyColorOverride(colors.body, baseBodyColor)
|
||||||
|
|
||||||
if (renderStyle.type === 'pixel') {
|
if (renderStyle.type === 'pixel') {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -39,6 +39,7 @@ describe('rendered avatar scene', () => {
|
|||||||
bodyNodes: [node],
|
bodyNodes: [node],
|
||||||
})
|
})
|
||||||
const scene = createRenderedScene(first)
|
const scene = createRenderedScene(first)
|
||||||
|
const initialNodeOrderVersion = scene.nodeOrderVersion.get()
|
||||||
const rotated = renderAvatar(
|
const rotated = renderAvatar(
|
||||||
poseFromExpression({ ...defaultExpression, headY: 35 }),
|
poseFromExpression({ ...defaultExpression, headY: 35 }),
|
||||||
surfacePresets.sphere,
|
surfacePresets.sphere,
|
||||||
@ -51,6 +52,11 @@ describe('rendered avatar scene', () => {
|
|||||||
expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath)
|
expect(findBodyNodePath(scene, 'primary')).toBe(scene.headPath)
|
||||||
expect(findBodyNodePath(scene, node.id)).not.toBeNull()
|
expect(findBodyNodePath(scene, node.id)).not.toBeNull()
|
||||||
expect(scene.headPath.get()).toBe(rotated.headPath)
|
expect(scene.headPath.get()).toBe(rotated.headPath)
|
||||||
|
expect(scene.nodeOrderVersion.get()).toBeGreaterThan(initialNodeOrderVersion)
|
||||||
|
|
||||||
|
const stableNodeOrderVersion = scene.nodeOrderVersion.get()
|
||||||
|
paintRenderedScene(scene, rotated)
|
||||||
|
expect(scene.nodeOrderVersion.get()).toBe(stableNodeOrderVersion)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates animated colors without replacing their motion values', () => {
|
it('updates animated colors without replacing their motion values', () => {
|
||||||
@ -98,13 +104,11 @@ describe('rendered avatar scene', () => {
|
|||||||
expect(forcedFront.frontNodeIds).toContain(avatar.body.nodes[0].id)
|
expect(forcedFront.frontNodeIds).toContain(avatar.body.nodes[0].id)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('moves dog ears between front and back with head depth', () => {
|
it('moves a dog ear behind the head only when it strongly overlaps an eye', () => {
|
||||||
const studio = loadStudioDocument({ getItem: () => null })
|
const studio = loadStudioDocument({ getItem: () => null })
|
||||||
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-dog')!
|
const avatar = studio.library.avatars.find(item => item.id === 'oneworks-dog')!
|
||||||
const detachedEars: string[] = []
|
const detachedEars: string[] = []
|
||||||
|
|
||||||
let splitDepthExpressions = 0
|
|
||||||
|
|
||||||
studio.expressions.forEach(expression => {
|
studio.expressions.forEach(expression => {
|
||||||
const geometry = renderAvatar(
|
const geometry = renderAvatar(
|
||||||
poseFromExpression(expression as Expression),
|
poseFromExpression(expression as Expression),
|
||||||
@ -115,12 +119,6 @@ describe('rendered avatar scene', () => {
|
|||||||
const head = pathBounds(geometry.headPath)
|
const head = pathBounds(geometry.headPath)
|
||||||
|
|
||||||
const earIds = ['dog-ear-left', 'dog-ear-right'] as const
|
const earIds = ['dog-ear-left', 'dog-ear-right'] as const
|
||||||
if (
|
|
||||||
geometry.frontNodeIds.some(id => earIds.includes(id as (typeof earIds)[number])) &&
|
|
||||||
geometry.backNodeIds.some(id => earIds.includes(id as (typeof earIds)[number]))
|
|
||||||
) {
|
|
||||||
splitDepthExpressions += 1
|
|
||||||
}
|
|
||||||
earIds.forEach(nodeId => {
|
earIds.forEach(nodeId => {
|
||||||
const frontIndex = geometry.frontNodeIds.indexOf(nodeId)
|
const frontIndex = geometry.frontNodeIds.indexOf(nodeId)
|
||||||
const backIndex = geometry.backNodeIds.indexOf(nodeId)
|
const backIndex = geometry.backNodeIds.indexOf(nodeId)
|
||||||
@ -137,8 +135,25 @@ describe('rendered avatar scene', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(splitDepthExpressions).toBeGreaterThan(0)
|
|
||||||
expect(detachedEars, 'detached dog ears').toEqual([])
|
expect(detachedEars, 'detached dog ears').toEqual([])
|
||||||
|
|
||||||
|
const expressionById = new Map(
|
||||||
|
studio.expressions.map(expression => [expression.id, expression])
|
||||||
|
)
|
||||||
|
const geometryFor = (sequenceId: string) => {
|
||||||
|
const sequence = studio.sequences.find(item => item.id === sequenceId)!
|
||||||
|
const expression = expressionById.get(sequence.steps[0].expressionId) as Expression
|
||||||
|
return renderAvatar(poseFromExpression(expression), avatar.body.primary as SurfaceConfig, 1, {
|
||||||
|
bodyNodes: avatar.body.nodes as BodyNode[],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
;['idle', 'shy', 'proud'].forEach(sequenceId => {
|
||||||
|
expect(geometryFor(sequenceId).backNodeIds, sequenceId).toContain('dog-ear-right')
|
||||||
|
})
|
||||||
|
expect(geometryFor('angry').frontNodeIds).toEqual(
|
||||||
|
expect.arrayContaining(['dog-ear-left', 'dog-ear-right'])
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps bear ears attached behind the head across expressions', () => {
|
it('keeps bear ears attached behind the head across expressions', () => {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useMotionValueEvent, type MotionValue } from 'motion/react'
|
import { useMotionValueEvent, type MotionValue } from 'motion/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
import type { RenderedScene } from './renderedScene'
|
||||||
|
|
||||||
export const avatarBodyOutlineWidth = () => 12
|
export const avatarBodyOutlineWidth = () => 12
|
||||||
|
|
||||||
const colorsMatch = (left: string, right: string) =>
|
const colorsMatch = (left: string, right: string) =>
|
||||||
@ -26,6 +28,14 @@ export const useAvatarBodyColorOverride = (
|
|||||||
return overridden
|
return overridden
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useRenderedSceneNodeOrder = (scene: RenderedScene) => {
|
||||||
|
const [, setVersion] = useState(0)
|
||||||
|
|
||||||
|
useMotionValueEvent(scene.nodeOrderVersion, 'change', () => {
|
||||||
|
setVersion(version => version + 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const resolveAvatarNodeColor = ({
|
export const resolveAvatarNodeColor = ({
|
||||||
nodeColor,
|
nodeColor,
|
||||||
bodyColor,
|
bodyColor,
|
||||||
|
|||||||
@ -44,6 +44,7 @@ import {
|
|||||||
} from '@/features/avatar/manipulationSession'
|
} from '@/features/avatar/manipulationSession'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview'
|
import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview'
|
||||||
|
import { useRenderedSceneNodeOrder } from '@/features/rendering/avatarAppearance'
|
||||||
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||||
import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo'
|
import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo'
|
||||||
import {
|
import {
|
||||||
@ -550,6 +551,7 @@ export function AvatarCanvas({
|
|||||||
onManipulationStart: () => Expression
|
onManipulationStart: () => Expression
|
||||||
}) {
|
}) {
|
||||||
const { t } = useStudioLanguage()
|
const { t } = useStudioLanguage()
|
||||||
|
useRenderedSceneNodeOrder(scene)
|
||||||
const {
|
const {
|
||||||
wirePaths,
|
wirePaths,
|
||||||
backPaths,
|
backPaths,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export type RenderedScene = {
|
|||||||
frontPaths: MotionValue<string>[]
|
frontPaths: MotionValue<string>[]
|
||||||
backNodeIds: { current: (string | null)[] }
|
backNodeIds: { current: (string | null)[] }
|
||||||
frontNodeIds: { current: (string | null)[] }
|
frontNodeIds: { current: (string | null)[] }
|
||||||
|
nodeOrderVersion: MotionValue<number>
|
||||||
leftPath: MotionValue<string>
|
leftPath: MotionValue<string>
|
||||||
rightPath: MotionValue<string>
|
rightPath: MotionValue<string>
|
||||||
leftOpacity: MotionValue<number>
|
leftOpacity: MotionValue<number>
|
||||||
@ -36,6 +37,7 @@ export const createRenderedScene = (geometry: AvatarGeometry): RenderedScene =>
|
|||||||
),
|
),
|
||||||
backNodeIds: { current: geometry.backNodeIds },
|
backNodeIds: { current: geometry.backNodeIds },
|
||||||
frontNodeIds: { current: geometry.frontNodeIds },
|
frontNodeIds: { current: geometry.frontNodeIds },
|
||||||
|
nodeOrderVersion: motionValue(0),
|
||||||
leftPath: motionValue(geometry.leftPath),
|
leftPath: motionValue(geometry.leftPath),
|
||||||
rightPath: motionValue(geometry.rightPath),
|
rightPath: motionValue(geometry.rightPath),
|
||||||
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
leftOpacity: motionValue(geometry.leftVisible ? 1 : 0),
|
||||||
@ -62,8 +64,14 @@ export const paintRenderedOffset = (scene: RenderedScene, offset: { x: number; y
|
|||||||
|
|
||||||
export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
|
export const paintRenderedScene = (scene: RenderedScene, geometry: AvatarGeometry) => {
|
||||||
scene.headPath.set(geometry.headPath)
|
scene.headPath.set(geometry.headPath)
|
||||||
|
const nodeOrderChanged =
|
||||||
|
scene.backNodeIds.current.length !== geometry.backNodeIds.length ||
|
||||||
|
scene.frontNodeIds.current.length !== geometry.frontNodeIds.length ||
|
||||||
|
scene.backNodeIds.current.some((id, index) => id !== geometry.backNodeIds[index]) ||
|
||||||
|
scene.frontNodeIds.current.some((id, index) => id !== geometry.frontNodeIds[index])
|
||||||
scene.backNodeIds.current = geometry.backNodeIds
|
scene.backNodeIds.current = geometry.backNodeIds
|
||||||
scene.frontNodeIds.current = geometry.frontNodeIds
|
scene.frontNodeIds.current = geometry.frontNodeIds
|
||||||
|
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.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
|
scene.frontPaths.forEach((path, index) => path.set(geometry.frontPaths[index] ?? ''))
|
||||||
scene.leftPath.set(geometry.leftPath)
|
scene.leftPath.set(geometry.leftPath)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { defaultExpression } from '@/features/avatar/presets'
|
|||||||
import {
|
import {
|
||||||
avatarBodyOutlineWidth,
|
avatarBodyOutlineWidth,
|
||||||
useAvatarBodyColorOverride,
|
useAvatarBodyColorOverride,
|
||||||
|
useRenderedSceneNodeOrder,
|
||||||
} from '@/features/rendering/avatarAppearance'
|
} from '@/features/rendering/avatarAppearance'
|
||||||
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||||
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
import type { RenderedColors, RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
@ -31,6 +32,7 @@ function LiveAvatarPreview({
|
|||||||
scene: RenderedScene
|
scene: RenderedScene
|
||||||
expressionBodyColor?: string
|
expressionBodyColor?: string
|
||||||
}) {
|
}) {
|
||||||
|
useRenderedSceneNodeOrder(scene)
|
||||||
const outlineWidth = avatarBodyOutlineWidth()
|
const outlineWidth = avatarBodyOutlineWidth()
|
||||||
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
|
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
|
||||||
const fixedSkinColors =
|
const fixedSkinColors =
|
||||||
|
|||||||
@ -1573,12 +1573,7 @@ export function useStudioController() {
|
|||||||
activeSequence && playbackVisual.position !== null
|
activeSequence && playbackVisual.position !== null
|
||||||
? expressionById.get(activeSequence.steps[playbackVisual.position]?.expressionId ?? '')
|
? expressionById.get(activeSequence.steps[playbackVisual.position]?.expressionId ?? '')
|
||||||
: null
|
: null
|
||||||
const activeSequenceUsesBodyColor =
|
const renderedExpressionBodyColor = playbackExpression?.bodyColor ?? expression.bodyColor
|
||||||
activeSequence?.steps.some(step => expressionById.get(step.expressionId)?.bodyColor) ?? false
|
|
||||||
const renderedExpressionBodyColor =
|
|
||||||
playbackExpression?.bodyColor ??
|
|
||||||
expression.bodyColor ??
|
|
||||||
(activeSequenceUsesBodyColor ? renderedColors.body.get() : undefined)
|
|
||||||
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
|
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
|
||||||
t(
|
t(
|
||||||
issue === 'missing_semantic_key'
|
issue === 'missing_semantic_key'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user