import { RotateCcw } from 'lucide-react' import { motion } from 'motion/react' import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react' import type { MotionValue } from 'motion' import { Button } from '@/components/ui/button' import { useStudioLanguage } from '@/i18n' import { PlaybackIdentity } from '@/app/components/common' import { bounded, poseWithAvatarEyes, useEscapeToCancel, type Highlight, type PlaybackStatus, } from '@/app/studio-utils' import { applyAvatarEyeDefaults, type AvatarEyeDefaults, type AvatarRenderStyle, isStrokeOnlyRenderStyle, } from '@/features/avatar/avatars' import { type BodyNode } from '@/features/avatar/body' import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import type { StudioAvatarGeometry } from '@/features/rendering/avatarAccessories' import { poseFromExpression, renderBodyNodeEditor, renderEyeEditor, rotateBodyNodeAroundLocalAxis, rotateExpressionAroundAxis, rotateExpressionAroundCamera, rotateExpressionWithArcball, rotationRing, translateBodyNodeAlongLocalAxis, translateBodyNodeInCameraPlane, type AvatarPose, type Expression, type Point3, } from '@/features/avatar/geometry' import { beginManipulation, beginManipulationFromRenderedValue, finishManipulation, previewManipulation, type ManipulationSession, } from '@/features/avatar/manipulationSession' import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type CanvasPreviewTarget } from '@/features/rendering/canvasPreview' import { useRenderedSceneNodeOrder } from '@/features/rendering/avatarAppearance' import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' import { type RenderedRotationGizmo } from '@/features/rendering/renderedRotationGizmo' import { findBodyNodePath, type RenderedColors, type RenderedScene, } from '@/features/rendering/renderedScene' export function RotationGizmo({ expression, rendered, onPreview, onChange, onActiveChange, onReset, }: { expression: Expression rendered: RenderedRotationGizmo onPreview: (next: Expression) => void onChange: (next: Expression) => void onActiveChange: (active: boolean) => void onReset: () => void }) { const { t } = useStudioLanguage() const drag = useRef< | { type: 'axis' axis: 'x' | 'y' | 'z' startPoint: readonly [number, number] tangent: readonly [number, number] expression: Expression } | { type: 'view'; startAngle: number; expression: Expression } | null >(null) const latestExpression = useRef(expression) const pose = poseFromExpression(expression) const rings = { x: rotationRing(pose, 'x'), y: rotationRing(pose, 'y'), z: rotationRing(pose, 'z'), } const toLocal = (event: React.PointerEvent): readonly [number, number] => { const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect() return [ ((event.clientX - rectangle.left) / rectangle.width) * 86 - 43, ((event.clientY - rectangle.top) / rectangle.height) * 86 - 43, ] } const unitVector = (from: Point3, to: Point3): readonly [number, number] => { const x = to[0] - from[0] const y = to[1] - from[1] const length = Math.hypot(x, y) || 1 return [x / length, y / length] } const startAxis = (axis: 'x' | 'y' | 'z', event: React.PointerEvent) => { event.stopPropagation() onActiveChange(true) const point = toLocal(event) const ring = rings[axis] let closestIndex = 0 let closestDistance = Number.POSITIVE_INFINITY ring.slice(0, -1).forEach((ringPoint, index) => { const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) if (distance < closestDistance) { closestIndex = index closestDistance = distance } }) const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] const next = ring[(closestIndex + 1) % (ring.length - 1)] drag.current = { type: 'axis', axis, startPoint: point, tangent: unitVector(previous, next), expression, } latestExpression.current = expression event.currentTarget.setPointerCapture(event.pointerId) } const startView = (event: React.PointerEvent) => { event.stopPropagation() onActiveChange(true) const point = toLocal(event) drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression } latestExpression.current = expression event.currentTarget.setPointerCapture(event.pointerId) } const move = (event: React.PointerEvent) => { if (!drag.current) return const point = toLocal(event) if (drag.current.type === 'view') { const currentAngle = Math.atan2(point[1], point[0]) const delta = Math.atan2( Math.sin(currentAngle - drag.current.startAngle), Math.cos(currentAngle - drag.current.startAngle) ) const next = rotateExpressionAroundCamera(drag.current.expression, delta) latestExpression.current = next onPreview(next) return } const signedDistance = (point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] + (point[1] - drag.current.startPoint[1]) * drag.current.tangent[1] const next = rotateExpressionAroundAxis( drag.current.expression, drag.current.axis, signedDistance * 1.5 ) latestExpression.current = next onPreview(next) } const stop = () => { if (drag.current) onChange(latestExpression.current) drag.current = null onActiveChange(false) } const cancel = () => { if (drag.current) { onPreview(drag.current.expression) onChange(drag.current.expression) } drag.current = null onActiveChange(false) } useEscapeToCancel(cancel) return (
startAxis('y', event)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} /> startAxis('x', event)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} /> startAxis('z', event)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} />
) } type TransformAxis = 'x' | 'y' | 'z' export function SvgTransformPath({ svgTransform, ...props }: Omit, 'transform'> & { svgTransform: MotionValue }) { const ref = useRef(null) useLayoutEffect(() => { const applyTransform = (value: string) => ref.current?.setAttribute('transform', value) applyTransform(svgTransform.get()) return svgTransform.on('change', applyTransform) }, [svgTransform]) return } 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 }) { const ids = headsetOcclusionIds(id) return ( <> )} > )} > ) } export function LiveHeadsetOcclusionMask({ id, headPath, headsetPath, occlusion, }: { id: string headPath: MotionValue headsetPath: MotionValue occlusion: NonNullable }) { const ids = headsetOcclusionIds(id) return ( <> )} > )} > ) } export const headsetOcclusionMaskValue = (id: string) => `url(#${headsetOcclusionIds(id).mask})` export const headsetHeadOcclusionMaskValue = (id: string) => `url(#${headsetOcclusionIds(id).headMask})` export function BodyNodeGizmo({ svgRef, pose, node, onPreview, onCommit, }: { svgRef: React.RefObject pose: AvatarPose node: BodyNode onPreview: (next: BodyNode) => void onCommit: (next: BodyNode) => void }) { const { t } = useStudioLanguage() const geometry = renderBodyNodeEditor(pose, node) const [activeControl, setActiveControl] = useState< { mode: 'translate' | 'rotate'; axis: TransformAxis } | { mode: 'plane' } | undefined >(undefined) const drag = useRef< | { mode: 'translate' | 'rotate' axis: TransformAxis startPoint: readonly [number, number] direction: readonly [number, number] scale: number node: BodyNode } | { mode: 'plane' startPoint: readonly [number, number] node: BodyNode } | undefined >(undefined) const latestNode = useRef(node) const manipulation = useRef | null>(null) const previewFrame = useRef(undefined) const axes: TransformAxis[] = ['x', 'y', 'z'] const toSvg = (event: React.PointerEvent): readonly [number, number] => { const rectangle = svgRef.current!.getBoundingClientRect() return [ ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, ] } const directionBetween = (from: Point3, to: Point3): readonly [number, number] => { const x = to[0] - from[0] const y = to[1] - from[1] const length = Math.hypot(x, y) || 1 return [x / length, y / length] } const ringPath = (points: Point3[]) => `M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z` const startTranslate = (axis: TransformAxis, event: React.PointerEvent) => { event.stopPropagation() const endpoint = geometry.axes[axis] const length = Math.max( Math.hypot(endpoint[0] - geometry.center[0], endpoint[1] - geometry.center[1]), 1 ) drag.current = { mode: 'translate', axis, startPoint: toSvg(event), direction: directionBetween(geometry.center, endpoint), scale: 34 / length, node, } setActiveControl({ mode: 'translate', axis }) latestNode.current = node manipulation.current = beginManipulation(node) event.currentTarget.setPointerCapture(event.pointerId) } const startRotate = (axis: TransformAxis, event: React.PointerEvent) => { event.stopPropagation() const point = toSvg(event) const ring = geometry.rings[axis] let closestIndex = 0 let closestDistance = Infinity ring.slice(0, -1).forEach((ringPoint, index) => { const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1]) if (distance < closestDistance) { closestIndex = index closestDistance = distance } }) const previous = ring[(closestIndex - 1 + ring.length - 1) % (ring.length - 1)] const next = ring[(closestIndex + 1) % (ring.length - 1)] const radius = Math.max( Math.hypot( ring[closestIndex][0] - geometry.center[0], ring[closestIndex][1] - geometry.center[1] ), 8 ) drag.current = { mode: 'rotate', axis, startPoint: point, direction: directionBetween(previous, next), scale: 180 / Math.PI / radius, node, } setActiveControl({ mode: 'rotate', axis }) latestNode.current = node manipulation.current = beginManipulation(node) event.currentTarget.setPointerCapture(event.pointerId) } const startPlaneTranslate = (event: React.PointerEvent) => { event.stopPropagation() drag.current = { mode: 'plane', startPoint: toSvg(event), node } setActiveControl({ mode: 'plane' }) latestNode.current = node manipulation.current = beginManipulation(node) event.currentTarget.setPointerCapture(event.pointerId) } const move = (event: React.PointerEvent) => { if (!drag.current) return const interaction = drag.current const point = toSvg(event) const deltaX = point[0] - interaction.startPoint[0] const deltaY = point[1] - interaction.startPoint[1] const next = (() => { if (interaction.mode === 'plane') { return translateBodyNodeInCameraPlane(interaction.node, pose, deltaX, deltaY) } const delta = (deltaX * interaction.direction[0] + deltaY * interaction.direction[1]) * interaction.scale return interaction.mode === 'translate' ? translateBodyNodeAlongLocalAxis(interaction.node, interaction.axis, delta) : rotateBodyNodeAroundLocalAxis(interaction.node, interaction.axis, delta) })() latestNode.current = next if (manipulation.current) { previewManipulation(manipulation.current, next) } if (previewFrame.current !== undefined) return previewFrame.current = requestAnimationFrame(() => { previewFrame.current = undefined onPreview(latestNode.current) }) } const stop = () => { if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) previewFrame.current = undefined if (manipulation.current) { finishManipulation(manipulation.current, 'commit', { preview: onPreview, commit: onCommit }) } manipulation.current = null drag.current = undefined setActiveControl(undefined) } const cancel = () => { if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) previewFrame.current = undefined if (manipulation.current) { finishManipulation(manipulation.current, 'cancel', { preview: onPreview, commit: onCommit }) } manipulation.current = null drag.current = undefined setActiveControl(undefined) } useEscapeToCancel(cancel) const resetTransform = (event: React.MouseEvent) => { event.stopPropagation() const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } latestNode.current = resetNode onCommit(resetNode) } useEffect( () => () => { if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current) }, [] ) return ( {axes.map(axis => ( startRotate(axis, event)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} /> ))} {axes.map(axis => ( startTranslate(axis, event)} onPointerMove={move} onPointerUp={stop} onPointerCancel={cancel} /> {axis.toUpperCase()} ))} { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() const resetNode: BodyNode = { ...node, position: [0, 0, 0], rotation: [0, 0, 0] } latestNode.current = resetNode onCommit(resetNode) }} > ↺ ) } export function AvatarCanvas({ expression, avatarEyes, surface, bodyNodes, scene, colors, expressionBodyColor, renderStyle, rotationGizmo, showWire, bodyEditing, selectedBodyNodeId, selectedBodyNode, selectedSide, linked, highlight, onHighlightChange, onBodyNodeSelect, onBodyNodePreview, onBodyNodeChange, onEyeSelect, onPreview, onChange, onReset, onEyeChange, playback, onManipulationStart, }: { expression: Expression avatarEyes: AvatarEyeDefaults surface: SurfaceConfig bodyNodes: BodyNode[] scene: RenderedScene colors: RenderedColors expressionBodyColor?: string renderStyle: AvatarRenderStyle rotationGizmo: RenderedRotationGizmo showWire: boolean bodyEditing: boolean selectedBodyNodeId: 'primary' | string | null selectedBodyNode: BodyNode | null selectedSide: -1 | 1 | null linked: { width: boolean; height: boolean; size: boolean } highlight: Highlight onHighlightChange: (highlight: Highlight) => void onBodyNodeSelect: (id: 'primary' | string | null) => void onBodyNodePreview: (next: BodyNode) => void onBodyNodeChange: (next: BodyNode) => void onEyeSelect: (side: -1 | 1) => void onPreview: (next: Expression, target: CanvasPreviewTarget) => void onChange: (next: Expression) => void onReset: (next: Expression) => void onEyeChange?: (next: Expression) => void playback: { name: string; status: Exclude } | null onManipulationStart: () => Expression }) { const { t } = useStudioLanguage() useRenderedSceneNodeOrder(scene) const { wirePaths, backPaths, backTransforms, frontPaths, frontTransforms, backNodeIds, frontNodeIds, headsetOcclusion, headPath, headTransform, leftPath, rightPath, leftTransform, rightTransform, leftOpacity, rightOpacity, offsetX, offsetY, } = scene const headsetOcclusionId = 'avatar-headset-left-occlusion' const svgRef = useRef(null) const [activeDragType, setActiveDragType] = useState< 'arcball' | 'width' | 'height' | 'size' | 'spacing' | 'rotate' | null >(null) const drag = useRef< | { type: 'arcball' startPoint: readonly [number, number] expression: Expression } | { type: 'width' | 'height' | 'size' | 'spacing' | 'rotate' side: -1 | 1 startPoint: readonly [number, number] expression: Expression center: Point3 widthAxis: readonly [number, number] heightAxis: readonly [number, number] spacingAxis: readonly [number, number] startPointerAngle: number startDistance: number } | null >(null) const canvasManipulation = useRef | null>(null) const editor = selectedSide === null ? null : renderEyeEditor(poseWithAvatarEyes(expression, avatarEyes), surface, selectedSide) const selectedBodyPath = (() => { if (!bodyEditing || !selectedBodyNodeId) return null return findBodyNodePath(scene, selectedBodyNodeId) })() const nodeColorStyle = (id: string | null | undefined) => { if (expressionBodyColor ?? expression.bodyColor) return undefined const color = bodyNodes.find(node => node.id === id)?.color return color ? ({ '--avatar-node-color': color } as CSSProperties) : undefined } const toSvg = (event: React.PointerEvent): readonly [number, number] => { const rectangle = svgRef.current!.getBoundingClientRect() return [ ((event.clientX - rectangle.left) / rectangle.width) * 300 - 150, ((event.clientY - rectangle.top) / rectangle.height) * 300 - 150, ] } const unitVector = (from: Point3, to: Point3): readonly [number, number] => { const x = to[0] - from[0] const y = to[1] - from[1] const length = Math.hypot(x, y) || 1 return [x / length, y / length] } const startDrag = (event: React.PointerEvent) => { const session = beginManipulationFromRenderedValue(onManipulationStart) const renderedExpression = session.initial onBodyNodeSelect('primary') onHighlightChange('head') drag.current = { type: 'arcball', startPoint: toSvg(event), expression: renderedExpression, } canvasManipulation.current = session setActiveDragType('arcball') svgRef.current!.setPointerCapture(event.pointerId) } const selectBodyPath = ( event: React.PointerEvent, nodeId: string | null | undefined ) => { if (!nodeId || !bodyEditing) { startDrag(event) return } event.stopPropagation() onBodyNodeSelect(nodeId) } const selectEye = (side: -1 | 1, event: React.PointerEvent) => { event.stopPropagation() onBodyNodeSelect(null) onEyeSelect(side) } const startHandle = ( type: 'width' | 'height' | 'size' | 'spacing' | 'rotate', event: React.PointerEvent ) => { event.stopPropagation() if (selectedSide === null || !editor) return const session = beginManipulationFromRenderedValue(onManipulationStart) const renderedExpression = session.initial onHighlightChange(selectedSide < 0 ? 'left' : 'right') const point = toSvg(event) const editableExpression = bodyEditing ? applyAvatarEyeDefaults(renderedExpression, avatarEyes) : renderedExpression const livePose = poseWithAvatarEyes(renderedExpression, avatarEyes) const liveEditor = renderEyeEditor(livePose, surface, selectedSide) const leftEditor = renderEyeEditor(livePose, surface, -1) const rightEditor = renderEyeEditor(livePose, surface, 1) drag.current = { type, side: selectedSide, startPoint: point, expression: editableExpression, center: liveEditor.center, widthAxis: unitVector(liveEditor.center, liveEditor.widthHandle), heightAxis: unitVector(liveEditor.center, liveEditor.heightHandle), spacingAxis: unitVector(leftEditor.center, rightEditor.center), startPointerAngle: Math.atan2( point[1] - liveEditor.center[1], point[0] - liveEditor.center[0] ), startDistance: Math.max( Math.hypot(point[0] - liveEditor.center[0], point[1] - liveEditor.center[1]), 1 ), } canvasManipulation.current = beginManipulation(editableExpression) setActiveDragType(type) svgRef.current!.setPointerCapture(event.pointerId) } const move = (event: React.PointerEvent) => { if (!drag.current) return const point = toSvg(event) if (drag.current.type === 'arcball') { const next = rotateExpressionWithArcball( drag.current.expression, drag.current.startPoint, point ) if (canvasManipulation.current) { previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'head')) } return } const interaction = drag.current const suffix = interaction.side < 0 ? 'Left' : 'Right' const deltaX = point[0] - interaction.startPoint[0] const deltaY = point[1] - interaction.startPoint[1] const along = (axis: readonly [number, number]) => deltaX * axis[0] + deltaY * axis[1] let next = { ...interaction.expression } const side = interaction.side < 0 ? 'Left' : 'Right' if (interaction.type === 'width') { const value = bounded( interaction.expression[`width${suffix}`] + along(interaction.widthAxis) * 2, 10, 100 ) next = updateEyeDimension(interaction.expression, side, 'width', value, linked.width) } else if (interaction.type === 'height') { const value = bounded( interaction.expression[`height${suffix}`] + along(interaction.heightAxis) * 2, 10, 100 ) next = updateEyeDimension(interaction.expression, side, 'height', value, linked.height) } else if (interaction.type === 'size') { const distance = Math.hypot( point[0] - interaction.center[0], point[1] - interaction.center[1] ) const factor = distance / interaction.startDistance const targetSize = Math.max( interaction.expression[`width${suffix}`], interaction.expression[`height${suffix}`] ) * factor next = scaleEye(interaction.expression, side, targetSize, linked.size) } else if (interaction.type === 'spacing') { const startSpacing = interaction.expression.spacing const spacing = bounded(startSpacing + along(interaction.spacingAxis), 0, 150) next.spacing = spacing } else { const currentAngle = Math.atan2( point[1] - interaction.center[1], point[0] - interaction.center[0] ) const deltaAngle = Math.atan2( Math.sin(currentAngle - interaction.startPointerAngle), Math.cos(currentAngle - interaction.startPointerAngle) ) next[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] = interaction.expression[interaction.side < 0 ? 'leftAngle' : 'rightAngle'] + (deltaAngle * 180) / Math.PI } if (canvasManipulation.current) { previewManipulation(canvasManipulation.current, next, value => onPreview(value, 'eyes')) } } const commitDrag = () => { const interaction = drag.current const session = canvasManipulation.current if (interaction && session) { finishManipulation(session, 'commit', { preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), commit: interaction.type === 'arcball' ? onChange : (onEyeChange ?? onChange), }) } canvasManipulation.current = null drag.current = null setActiveDragType(null) onHighlightChange(null) } const cancelDrag = () => { const interaction = drag.current const session = canvasManipulation.current if (interaction && session) { finishManipulation(session, 'cancel', { preview: value => onPreview(value, interaction.type === 'arcball' ? 'head' : 'eyes'), commit: onChange, }) } canvasManipulation.current = null drag.current = null setActiveDragType(null) onHighlightChange(null) } useEscapeToCancel(cancelDrag) return (
{playback && ( )} {renderStyle.type === 'pixel' && ( )} {headsetOcclusion.current && ( )} {backPaths.map((pathValue, index) => ( selectBodyPath(event, backNodeIds.current[index])} /> ))} { onBodyNodeSelect('primary') startDrag(event) }} /> {(showWire || highlight === 'head') && wirePaths.map((pathValue, index) => ( ))} selectEye(-1, event)} /> selectEye(1, event)} /> {frontPaths.map((pathValue, index) => ( selectBodyPath(event, frontNodeIds.current[index])} /> ))} {selectedBodyPath && ( )} {bodyEditing && selectedBodyNode && ( )} {editor?.visible && ( {activeDragType !== null && activeDragType !== 'arcball' && ( )} )} onPreview(next, 'head')} onChange={onChange} onActiveChange={active => onHighlightChange(active ? 'head' : null)} onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })} />
X Y Z
) } type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate' type HandleStart = (type: EyeHandle, event: React.PointerEvent) => void export function EditorCircle({ point, label, type, onStart, }: { point: Point3 label: string type: EyeHandle onStart: HandleStart }) { return ( onStart(type, event)} > {label} ) } export function EditorSquare({ point, label, type, onStart, }: { point: Point3 label: string type: EyeHandle onStart: HandleStart }) { return ( onStart(type, event)} > {label} ) }