1254 lines
44 KiB
TypeScript
1254 lines
44 KiB
TypeScript
import {
|
|
cursorLayout,
|
|
surfaceFrontSampleAt,
|
|
surfacePointAt,
|
|
surfaceSampleAt,
|
|
type SurfaceConfig,
|
|
} from './surfaces'
|
|
import type { BodyNode } from './body'
|
|
|
|
export type Quaternion = readonly [number, number, number, number]
|
|
export type Point3 = readonly [number, number, number]
|
|
export type EyeMotion = 'none' | 'microSaccades' | 'shake'
|
|
export type BodyMotion = 'none' | 'slowDrift' | 'shake'
|
|
|
|
export type Expression = {
|
|
id: string
|
|
headX: number
|
|
headY: number
|
|
headZ: number
|
|
widthLeft: number
|
|
widthRight: number
|
|
heightLeft: number
|
|
heightRight: number
|
|
spacing: number
|
|
positionXLeft: number
|
|
positionXRight: number
|
|
positionYLeft: number
|
|
positionYRight: number
|
|
leftAngle: number
|
|
rightAngle: number
|
|
perspective: number
|
|
eyeMotion: EyeMotion
|
|
bodyMotion: BodyMotion
|
|
bodyColor?: string
|
|
eyeColor?: string
|
|
}
|
|
|
|
export type ExpressionNumericField = Exclude<
|
|
keyof Expression,
|
|
'id' | 'bodyColor' | 'eyeColor' | 'eyeMotion' | 'bodyMotion'
|
|
>
|
|
|
|
export type AvatarPose = {
|
|
expression: Expression
|
|
orientation: Quaternion
|
|
}
|
|
|
|
export type AvatarGeometry = {
|
|
backPaths: string[]
|
|
frontPaths: string[]
|
|
backNodeIds: (string | null)[]
|
|
frontNodeIds: (string | null)[]
|
|
headPath: string
|
|
leftPath: string
|
|
rightPath: string
|
|
leftVisible: boolean
|
|
rightVisible: boolean
|
|
wirePaths: string[]
|
|
}
|
|
|
|
export type RenderAvatarOptions = {
|
|
includeWire?: boolean
|
|
bodyNodes?: BodyNode[]
|
|
eyeOffset?: Readonly<{ x: number; y: number }>
|
|
}
|
|
|
|
export type EyeEditorGeometry = {
|
|
visible: boolean
|
|
selectionPath: string
|
|
widthGuide: string
|
|
heightGuide: string
|
|
rotationGuide: string
|
|
spacingGuide: string
|
|
center: Point3
|
|
widthHandle: Point3
|
|
heightHandle: Point3
|
|
rotateHandle: Point3
|
|
sizeHandle: Point3
|
|
spacingHandle: Point3
|
|
}
|
|
|
|
export type BodyNodeEditorGeometry = {
|
|
center: Point3
|
|
axes: Record<'x' | 'y' | 'z', Point3>
|
|
rings: Record<'x' | 'y' | 'z', Point3[]>
|
|
}
|
|
|
|
export const RADIUS = 120
|
|
const FOCAL_LENGTH = 620
|
|
const QUARTER_ARC_SAMPLES = 14
|
|
|
|
export const expressionFields: ExpressionNumericField[] = [
|
|
'headX',
|
|
'headY',
|
|
'headZ',
|
|
'widthLeft',
|
|
'widthRight',
|
|
'heightLeft',
|
|
'heightRight',
|
|
'spacing',
|
|
'positionXLeft',
|
|
'positionXRight',
|
|
'positionYLeft',
|
|
'positionYRight',
|
|
'leftAngle',
|
|
'rightAngle',
|
|
'perspective',
|
|
]
|
|
|
|
export const clamp = (value: number, min: number, max: number) =>
|
|
Math.max(min, Math.min(max, value))
|
|
|
|
export const radians = (degrees: number) => (degrees * Math.PI) / 180
|
|
|
|
export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => {
|
|
const length = Math.hypot(w, x, y, z) || 1
|
|
return [w / length, x / length, y / length, z / length]
|
|
}
|
|
|
|
export const multiplyQuaternions = (
|
|
[aw, ax, ay, az]: Quaternion,
|
|
[bw, bx, by, bz]: Quaternion
|
|
): Quaternion =>
|
|
normalizeQuaternion([
|
|
aw * bw - ax * bx - ay * by - az * bz,
|
|
aw * bx + ax * bw + ay * bz - az * by,
|
|
aw * by - ax * bz + ay * bw + az * bx,
|
|
aw * bz + ax * by - ay * bx + az * bw,
|
|
])
|
|
|
|
export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => {
|
|
const halfAngle = angle / 2
|
|
const sine = Math.sin(halfAngle)
|
|
return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
|
|
}
|
|
|
|
export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => {
|
|
const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
|
|
const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
|
|
const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
|
|
return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
|
|
}
|
|
|
|
export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => {
|
|
const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
|
|
const cross: Point3 = [
|
|
from[1] * to[2] - from[2] * to[1],
|
|
from[2] * to[0] - from[0] * to[2],
|
|
from[0] * to[1] - from[1] * to[0],
|
|
]
|
|
return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
|
|
}
|
|
|
|
export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => {
|
|
const matrix00 = 1 - 2 * (y * y + z * z)
|
|
const matrix01 = 2 * (x * y - z * w)
|
|
const matrix10 = 2 * (x * y + z * w)
|
|
const matrix11 = 1 - 2 * (x * x + z * z)
|
|
const matrix20 = 2 * (x * z - y * w)
|
|
const matrix21 = 2 * (y * z + x * w)
|
|
const matrix22 = 1 - 2 * (x * x + y * y)
|
|
const headX = Math.asin(clamp(matrix21, -1, 1))
|
|
if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)]
|
|
return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
|
|
}
|
|
|
|
const nearestEquivalentAngle = (angle: number, current: number) => {
|
|
let result = angle
|
|
while (result - current > 180) result -= 360
|
|
while (result - current < -180) result += 360
|
|
return clamp(result, -365, 365)
|
|
}
|
|
|
|
export const expressionWithOrientation = (
|
|
expression: Expression,
|
|
orientation: Quaternion
|
|
): Expression => {
|
|
const [radiansX, radiansY, radiansZ] = quaternionToEuler(orientation)
|
|
const x = (radiansX * 180) / Math.PI
|
|
const y = (radiansY * 180) / Math.PI
|
|
const z = (radiansZ * 180) / Math.PI
|
|
return {
|
|
...expression,
|
|
headX: nearestEquivalentAngle(x, expression.headX),
|
|
headY: nearestEquivalentAngle(y, expression.headY),
|
|
headZ: nearestEquivalentAngle(z, expression.headZ),
|
|
}
|
|
}
|
|
|
|
export const slerpQuaternion = (
|
|
start: Quaternion,
|
|
end: Quaternion,
|
|
progress: number
|
|
): Quaternion => {
|
|
let target = end
|
|
let dot = start.reduce((total, value, index) => total + value * target[index], 0)
|
|
if (dot < 0) {
|
|
target = target.map(value => -value) as unknown as Quaternion
|
|
dot = -dot
|
|
}
|
|
if (dot > 0.9995) {
|
|
return normalizeQuaternion(
|
|
start.map(
|
|
(value, index) => value + (target[index] - value) * progress
|
|
) as unknown as Quaternion
|
|
)
|
|
}
|
|
const angle = Math.acos(clamp(dot, -1, 1))
|
|
const sine = Math.sin(angle)
|
|
const startWeight = Math.sin((1 - progress) * angle) / sine
|
|
const targetWeight = Math.sin(progress * angle) / sine
|
|
return normalizeQuaternion(
|
|
start.map(
|
|
(value, index) => value * startWeight + target[index] * targetWeight
|
|
) as unknown as Quaternion
|
|
)
|
|
}
|
|
|
|
export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => {
|
|
const tx = 2 * (y * pz - z * py)
|
|
const ty = 2 * (z * px - x * pz)
|
|
const tz = 2 * (x * py - y * px)
|
|
return [
|
|
px + w * tx + (y * tz - z * ty),
|
|
py + w * ty + (z * tx - x * tz),
|
|
pz + w * tz + (x * ty - y * tx),
|
|
]
|
|
}
|
|
|
|
const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => {
|
|
const halfWidth = width / 2
|
|
const halfHeight = height / 2
|
|
const cornerRadius = Math.min(halfHeight, halfWidth)
|
|
const points: (readonly [number, number])[] = []
|
|
const addLine = (start: readonly [number, number], end: readonly [number, number]) => {
|
|
const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5))
|
|
for (let index = 0; index < samples; index += 1) {
|
|
const progress = index / samples
|
|
points.push([
|
|
start[0] + (end[0] - start[0]) * progress,
|
|
start[1] + (end[1] - start[1]) * progress,
|
|
])
|
|
}
|
|
}
|
|
const addArc = (centerX: number, centerY: number, startAngle: number) => {
|
|
for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) {
|
|
const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2)
|
|
points.push([
|
|
centerX + Math.cos(angle) * cornerRadius,
|
|
centerY + Math.sin(angle) * cornerRadius,
|
|
])
|
|
}
|
|
}
|
|
addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
|
|
addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
|
|
addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
|
|
addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
|
|
addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
|
|
addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
|
|
addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
|
|
addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
|
|
return points
|
|
}
|
|
|
|
const project = (point: Point3, perspective: number): Point3 => {
|
|
const denominator = FOCAL_LENGTH - point[2] * perspective
|
|
const scale = Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
|
|
return [point[0] * scale, point[1] * scale, point[2]]
|
|
}
|
|
|
|
export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
|
|
axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
|
|
|
|
export const rotateExpressionAroundAxis = (
|
|
expression: Expression,
|
|
axis: 'x' | 'y' | 'z',
|
|
deltaDegrees: number
|
|
) => {
|
|
const startOrientation = poseFromExpression(expression).orientation
|
|
const worldAxis = rotateWithQuaternion(startOrientation, axisVector(axis))
|
|
const orientation = multiplyQuaternions(
|
|
quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)),
|
|
startOrientation
|
|
)
|
|
return expressionWithOrientation(expression, orientation)
|
|
}
|
|
|
|
export const rotateExpressionAroundCamera = (expression: Expression, deltaRadians: number) => {
|
|
const startOrientation = poseFromExpression(expression).orientation
|
|
return expressionWithOrientation(
|
|
expression,
|
|
multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), startOrientation)
|
|
)
|
|
}
|
|
|
|
const arcballVector = ([xValue, yValue]: readonly [number, number]): Point3 => {
|
|
const x = xValue / RADIUS
|
|
const y = yValue / RADIUS
|
|
const squaredLength = x * x + y * y
|
|
if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
|
|
const length = Math.sqrt(squaredLength)
|
|
return [x / length, y / length, 0]
|
|
}
|
|
|
|
export const rotateExpressionWithArcball = (
|
|
expression: Expression,
|
|
startPoint: readonly [number, number],
|
|
currentPoint: readonly [number, number]
|
|
) => {
|
|
const startOrientation = poseFromExpression(expression).orientation
|
|
const delta = quaternionFromVectors(arcballVector(startPoint), arcballVector(currentPoint))
|
|
return expressionWithOrientation(expression, multiplyQuaternions(delta, startOrientation))
|
|
}
|
|
|
|
export const rotationRing = (pose: AvatarPose, axis: 'x' | 'y' | 'z', radius = 30): Point3[] =>
|
|
Array.from({ length: 97 }, (_, index) => {
|
|
const angle = (index / 96) * Math.PI * 2
|
|
const cosine = Math.cos(angle)
|
|
const sine = Math.sin(angle)
|
|
const point: Point3 =
|
|
axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
|
|
const rotated = rotateWithQuaternion(pose.orientation, point)
|
|
return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
|
|
})
|
|
|
|
export const renderBodyNodeEditor = (
|
|
pose: AvatarPose,
|
|
node: BodyNode,
|
|
axisLength = 34,
|
|
ringRadius = 26
|
|
): BodyNodeEditorGeometry => {
|
|
const projectInHeadSpace = (point: Point3) =>
|
|
project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
|
|
const center = projectInHeadSpace(node.position)
|
|
const localOrientation = quaternionFromEuler(
|
|
radians(node.rotation[0]),
|
|
radians(node.rotation[1]),
|
|
radians(node.rotation[2])
|
|
)
|
|
const axes = Object.fromEntries(
|
|
(['x', 'y', 'z'] as const).map(axis => {
|
|
const vector = rotateWithQuaternion(localOrientation, axisVector(axis))
|
|
return [
|
|
axis,
|
|
projectInHeadSpace([
|
|
node.position[0] + vector[0] * axisLength,
|
|
node.position[1] + vector[1] * axisLength,
|
|
node.position[2] + vector[2] * axisLength,
|
|
]),
|
|
]
|
|
})
|
|
) as BodyNodeEditorGeometry['axes']
|
|
;(['x', 'y', 'z'] as const).forEach(axis => {
|
|
const endpoint = axes[axis]
|
|
if (Math.hypot(endpoint[0] - center[0], endpoint[1] - center[1]) >= 12) return
|
|
const fallback: Point3 =
|
|
axis === 'x'
|
|
? [center[0] + 18, center[1], endpoint[2]]
|
|
: axis === 'y'
|
|
? [center[0], center[1] + 18, endpoint[2]]
|
|
: [center[0] + 14, center[1] + 14, endpoint[2]]
|
|
axes[axis] = fallback
|
|
})
|
|
const rings = Object.fromEntries(
|
|
(['x', 'y', 'z'] as const).map(axis => [
|
|
axis,
|
|
Array.from({ length: 65 }, (_, index) => {
|
|
const angle = (index / 64) * Math.PI * 2
|
|
const cosine = Math.cos(angle) * ringRadius
|
|
const sine = Math.sin(angle) * ringRadius
|
|
const localPoint: Point3 =
|
|
axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
|
|
const rotated = rotateWithQuaternion(localOrientation, localPoint)
|
|
return projectInHeadSpace([
|
|
node.position[0] + rotated[0],
|
|
node.position[1] + rotated[1],
|
|
node.position[2] + rotated[2],
|
|
])
|
|
}),
|
|
])
|
|
) as BodyNodeEditorGeometry['rings']
|
|
return { center, axes, rings }
|
|
}
|
|
|
|
export const translateBodyNodeAlongLocalAxis = (
|
|
node: BodyNode,
|
|
axis: 'x' | 'y' | 'z',
|
|
distance: number
|
|
): BodyNode => {
|
|
const orientation = quaternionFromEuler(
|
|
radians(node.rotation[0]),
|
|
radians(node.rotation[1]),
|
|
radians(node.rotation[2])
|
|
)
|
|
const direction = rotateWithQuaternion(orientation, axisVector(axis))
|
|
return {
|
|
...node,
|
|
position: [
|
|
node.position[0] + direction[0] * distance,
|
|
node.position[1] + direction[1] * distance,
|
|
node.position[2] + direction[2] * distance,
|
|
],
|
|
}
|
|
}
|
|
|
|
export const translateBodyNodeInCameraPlane = (
|
|
node: BodyNode,
|
|
pose: AvatarPose,
|
|
screenDeltaX: number,
|
|
screenDeltaY: number
|
|
): BodyNode => {
|
|
const cameraPosition = rotateWithQuaternion(pose.orientation, node.position)
|
|
const denominator = FOCAL_LENGTH - cameraPosition[2] * pose.expression.perspective
|
|
const perspectiveScale =
|
|
Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
|
|
const [w, x, y, z] = pose.orientation
|
|
const headDelta = rotateWithQuaternion(
|
|
[w, -x, -y, -z],
|
|
[screenDeltaX / perspectiveScale, screenDeltaY / perspectiveScale, 0]
|
|
)
|
|
return {
|
|
...node,
|
|
position: [
|
|
node.position[0] + headDelta[0],
|
|
node.position[1] + headDelta[1],
|
|
node.position[2] + headDelta[2],
|
|
],
|
|
}
|
|
}
|
|
|
|
export const rotateBodyNodeAroundLocalAxis = (
|
|
node: BodyNode,
|
|
axis: 'x' | 'y' | 'z',
|
|
deltaDegrees: number
|
|
): BodyNode => {
|
|
const orientation = quaternionFromEuler(
|
|
radians(node.rotation[0]),
|
|
radians(node.rotation[1]),
|
|
radians(node.rotation[2])
|
|
)
|
|
const rotated = multiplyQuaternions(
|
|
orientation,
|
|
quaternionFromAxisAngle(axisVector(axis), radians(deltaDegrees))
|
|
)
|
|
const next = quaternionToEuler(rotated).map(value => (value * 180) / Math.PI) as [
|
|
number,
|
|
number,
|
|
number,
|
|
]
|
|
return {
|
|
...node,
|
|
rotation: next.map((value, index) => nearestEquivalentAngle(value, node.rotation[index])) as [
|
|
number,
|
|
number,
|
|
number,
|
|
],
|
|
}
|
|
}
|
|
|
|
const path = (points: Point3[], close = true) => {
|
|
if (!points.length) return ''
|
|
return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
|
|
.slice(1)
|
|
.map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
|
|
.join('')}${close ? 'Z' : ''}`
|
|
}
|
|
|
|
export const poseFromExpression = (expression: Expression): AvatarPose => ({
|
|
expression,
|
|
orientation: quaternionFromEuler(
|
|
radians(expression.headX),
|
|
radians(expression.headY),
|
|
radians(expression.headZ)
|
|
),
|
|
})
|
|
|
|
export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
|
|
const expression: Expression = { ...from.expression }
|
|
expressionFields.forEach(field => {
|
|
expression[field] =
|
|
from.expression[field] + (to.expression[field] - from.expression[field]) * progress
|
|
})
|
|
return {
|
|
expression,
|
|
orientation: slerpQuaternion(from.orientation, to.orientation, progress),
|
|
}
|
|
}
|
|
|
|
type ProjectedSurfacePoint = { point: Point3; normal: Point3 }
|
|
type LocalSurfacePoint = ProjectedSurfacePoint
|
|
|
|
const MAX_SURFACE_CACHE_ENTRIES = 24
|
|
const HEAD_LATITUDE_SAMPLES = 25
|
|
const HEAD_LONGITUDE_SAMPLES = 73
|
|
const PRIMITIVE_RING_SAMPLES = 144
|
|
const ROUNDED_PRIMITIVE_LATITUDE_SAMPLES = 33
|
|
const ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES = 73
|
|
const headSamplesCache = new Map<string, Point3[]>()
|
|
const accessorySamplesCache = new Map<string, Point3[]>()
|
|
const wireSamplesCache = new Map<string, LocalSurfacePoint[][]>()
|
|
|
|
const surfaceCacheKey = (surface: SurfaceConfig) =>
|
|
[
|
|
surface.type,
|
|
surface.width,
|
|
surface.height,
|
|
surface.depth,
|
|
surface.roundness,
|
|
surface.morphRoundness,
|
|
surface.tipRoundness,
|
|
surface.baseRoundness,
|
|
]
|
|
.map(value => (typeof value === 'number' ? value.toFixed(4) : value))
|
|
.join(':')
|
|
|
|
const cacheSurfaceValue = <Value>(cache: Map<string, Value>, key: string, value: Value) => {
|
|
if (cache.size >= MAX_SURFACE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!)
|
|
cache.set(key, value)
|
|
return value
|
|
}
|
|
|
|
const localSurfacePoint = (
|
|
surface: SurfaceConfig,
|
|
longitude: number,
|
|
latitude: number
|
|
): LocalSurfacePoint => surfaceSampleAt(surface, longitude, latitude)
|
|
|
|
const projectLocalSurfacePoint = (
|
|
pose: AvatarPose,
|
|
sample: LocalSurfacePoint
|
|
): ProjectedSurfacePoint => ({
|
|
point: project(rotateWithQuaternion(pose.orientation, sample.point), pose.expression.perspective),
|
|
normal: rotateWithQuaternion(pose.orientation, sample.normal),
|
|
})
|
|
|
|
const canonicalFaceCoordinates = (x: number, y: number): readonly [number, number] => {
|
|
const longitude = x / RADIUS
|
|
const latitude = y / RADIUS
|
|
return [RADIUS * Math.cos(latitude) * Math.sin(longitude), RADIUS * Math.sin(latitude)]
|
|
}
|
|
|
|
const projectFacePoint = (
|
|
pose: AvatarPose,
|
|
surface: SurfaceConfig,
|
|
x: number,
|
|
y: number
|
|
): ProjectedSurfacePoint => {
|
|
const [faceX, faceY] = canonicalFaceCoordinates(x, y)
|
|
return projectLocalSurfacePoint(pose, surfaceFrontSampleAt(surface, faceX, faceY))
|
|
}
|
|
|
|
const eyePoints = (
|
|
pose: AvatarPose,
|
|
surface: SurfaceConfig,
|
|
side: -1 | 1,
|
|
blink: number,
|
|
offset: Readonly<{ x: number; y: number }> = { x: 0, y: 0 }
|
|
): ProjectedSurfacePoint[] => {
|
|
const expression = pose.expression
|
|
const suffix = side < 0 ? 'Left' : 'Right'
|
|
const width = expression[`width${suffix}`]
|
|
const restingHeight = expression[`height${suffix}`]
|
|
const height = 5 + (restingHeight - 5) * blink
|
|
const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
|
|
const centerY = expression[`positionY${suffix}`] + offset.y
|
|
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
|
|
return roundedRectangle(width, height).map(([localX, localY]) => {
|
|
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
|
|
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
|
|
return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
|
|
})
|
|
}
|
|
|
|
const visiblePath = (points: ProjectedSurfacePoint[]) => {
|
|
const segments: Point3[][] = []
|
|
let segment: Point3[] = []
|
|
points.forEach(({ point, normal }) => {
|
|
if (normal[2] > 0) segment.push(point)
|
|
else if (segment.length) {
|
|
segments.push(segment)
|
|
segment = []
|
|
}
|
|
})
|
|
if (segment.length) segments.push(segment)
|
|
return segments
|
|
.filter(item => item.length > 1)
|
|
.map(item => path(item, false))
|
|
.join('')
|
|
}
|
|
|
|
const wirePaths = (pose: AvatarPose, surface: SurfaceConfig): string[] => {
|
|
const key = surfaceCacheKey(surface)
|
|
let samples = wireSamplesCache.get(key)
|
|
if (!samples) {
|
|
const parallels = [-60, -30, 0, 30, 60].map(latitude =>
|
|
Array.from({ length: 73 }, (_, index) =>
|
|
localSurfacePoint(surface, radians(-180 + index * 5), radians(latitude))
|
|
)
|
|
)
|
|
const meridians = Array.from(
|
|
{ length: 12 },
|
|
(_, longitudeIndex) => -150 + longitudeIndex * 30
|
|
).map(longitude =>
|
|
Array.from({ length: 37 }, (_, index) =>
|
|
localSurfacePoint(surface, radians(longitude), radians(-90 + index * 5))
|
|
)
|
|
)
|
|
samples = cacheSurfaceValue(wireSamplesCache, key, [...parallels, ...meridians])
|
|
}
|
|
return samples.map(curve =>
|
|
visiblePath(curve.map(sample => projectLocalSurfacePoint(pose, sample)))
|
|
)
|
|
}
|
|
|
|
const projectEyePoint = (
|
|
pose: AvatarPose,
|
|
surface: SurfaceConfig,
|
|
side: -1 | 1,
|
|
localX: number,
|
|
localY: number
|
|
): Point3 => {
|
|
const expression = pose.expression
|
|
const suffix = side < 0 ? 'Left' : 'Right'
|
|
const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
|
|
const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
|
|
const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
|
|
return projectFacePoint(
|
|
pose,
|
|
surface,
|
|
(side * expression.spacing) / 2 + expression[`positionX${suffix}`] + rotatedX,
|
|
expression[`positionY${suffix}`] + rotatedY
|
|
).point
|
|
}
|
|
|
|
export const renderEyeEditor = (
|
|
pose: AvatarPose,
|
|
surface: SurfaceConfig,
|
|
side: -1 | 1
|
|
): EyeEditorGeometry => {
|
|
const expression = pose.expression
|
|
const suffix = side < 0 ? 'Left' : 'Right'
|
|
const width = expression[`width${suffix}`]
|
|
const height = expression[`height${suffix}`]
|
|
const selectedSamples = eyePoints(pose, surface, side, 1)
|
|
const selectedPoints = selectedSamples.map(sample => sample.point)
|
|
const center = projectEyePoint(pose, surface, side, 0, 0)
|
|
const widthHandle = projectEyePoint(pose, surface, side, width / 2 + 9, 0)
|
|
const heightHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 9)
|
|
const rotateHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 30)
|
|
const sizeHandle = projectEyePoint(pose, surface, side, width / 2 + 11, height / 2 + 11)
|
|
const leftCenter = projectEyePoint(pose, surface, -1, 0, 0)
|
|
const rightCenter = projectEyePoint(pose, surface, 1, 0, 0)
|
|
const spacingCenterX = (expression.positionXLeft + expression.positionXRight) / 2
|
|
const spacingCenterY = (expression.positionYLeft + expression.positionYRight) / 2
|
|
const spacingHandle = projectFacePoint(
|
|
pose,
|
|
surface,
|
|
spacingCenterX,
|
|
spacingCenterY + height / 2 + 34
|
|
).point
|
|
const spacingMiddle: Point3 = [
|
|
(leftCenter[0] + rightCenter[0]) / 2,
|
|
(leftCenter[1] + rightCenter[1]) / 2,
|
|
(leftCenter[2] + rightCenter[2]) / 2,
|
|
]
|
|
const line = (from: Point3, to: Point3) => path([from, to], false)
|
|
return {
|
|
visible: selectedSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
|
|
selectionPath: path(selectedPoints),
|
|
widthGuide: line(center, widthHandle),
|
|
heightGuide: line(center, heightHandle),
|
|
rotationGuide: line(heightHandle, rotateHandle),
|
|
spacingGuide: `${line(leftCenter, rightCenter)}${line(spacingMiddle, spacingHandle)}`,
|
|
center,
|
|
widthHandle,
|
|
heightHandle,
|
|
rotateHandle,
|
|
sizeHandle,
|
|
spacingHandle,
|
|
}
|
|
}
|
|
|
|
const convexHull = (points: Point3[]): Point3[] => {
|
|
const sorted = [...points].sort((left, right) => left[0] - right[0] || left[1] - right[1])
|
|
const cross = (origin: Point3, first: Point3, second: Point3) =>
|
|
(first[0] - origin[0]) * (second[1] - origin[1]) -
|
|
(first[1] - origin[1]) * (second[0] - origin[0])
|
|
const half = (source: Point3[]) => {
|
|
const result: Point3[] = []
|
|
source.forEach(point => {
|
|
while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop()
|
|
result.push(point)
|
|
})
|
|
return result
|
|
}
|
|
return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)]
|
|
}
|
|
|
|
const smoothClosedPath = (points: Point3[]) => {
|
|
if (points.length < 3) return path(points)
|
|
const pointAt = (index: number) => points[(index + points.length) % points.length]
|
|
return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
|
|
.map((point, index) => {
|
|
const previous = pointAt(index - 1)
|
|
const next = pointAt(index + 1)
|
|
const afterNext = pointAt(index + 2)
|
|
const firstControl: Point3 = [
|
|
point[0] + (next[0] - previous[0]) / 6,
|
|
point[1] + (next[1] - previous[1]) / 6,
|
|
point[2],
|
|
]
|
|
const secondControl: Point3 = [
|
|
next[0] - (afterNext[0] - point[0]) / 6,
|
|
next[1] - (afterNext[1] - point[1]) / 6,
|
|
next[2],
|
|
]
|
|
return `C${firstControl[0].toFixed(2)} ${firstControl[1].toFixed(2)} ${secondControl[0].toFixed(2)} ${secondControl[1].toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
|
|
})
|
|
.join('')}Z`
|
|
}
|
|
|
|
const densifyClosedPoints = (points: Point3[], maximumDistance = 7) =>
|
|
points.flatMap((point, index) => {
|
|
const next = points[(index + 1) % points.length]
|
|
const steps = Math.max(
|
|
1,
|
|
Math.ceil(Math.hypot(next[0] - point[0], next[1] - point[1]) / maximumDistance)
|
|
)
|
|
return Array.from({ length: steps }, (_, step) => {
|
|
const progress = step / steps
|
|
return [
|
|
point[0] + (next[0] - point[0]) * progress,
|
|
point[1] + (next[1] - point[1]) * progress,
|
|
point[2] + (next[2] - point[2]) * progress,
|
|
] as Point3
|
|
})
|
|
})
|
|
|
|
const smoothOpenPath = (points: Point3[]) => {
|
|
if (!points.length) return ''
|
|
if (points.length === 1) return `${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`
|
|
return points
|
|
.slice(0, -1)
|
|
.map((point, index) => {
|
|
const previous = points[Math.max(0, index - 1)]
|
|
const next = points[index + 1]
|
|
const afterNext = points[Math.min(points.length - 1, index + 2)]
|
|
const firstControlX = point[0] + (next[0] - previous[0]) / 6
|
|
const firstControlY = point[1] + (next[1] - previous[1]) / 6
|
|
const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
|
|
const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
|
|
return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
|
|
})
|
|
.join('')
|
|
}
|
|
|
|
const projectLocalPoint = (pose: AvatarPose, point: Point3) =>
|
|
project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
|
|
|
|
const ringPoints = (width: number, depth: number, y: number) =>
|
|
Array.from({ length: PRIMITIVE_RING_SAMPLES + 1 }, (_, index) => {
|
|
const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
|
|
return [(width / 2) * Math.sin(angle), y, (depth / 2) * Math.cos(angle)] as Point3
|
|
})
|
|
|
|
const projectedRoundedPrimitivePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
const key = surfaceCacheKey(surface)
|
|
let localSamples = headSamplesCache.get(key)
|
|
if (!localSamples) {
|
|
localSamples = Array.from(
|
|
{ length: ROUNDED_PRIMITIVE_LATITUDE_SAMPLES },
|
|
(_, latitudeIndex) => {
|
|
const latitude =
|
|
-Math.PI / 2 + (latitudeIndex / (ROUNDED_PRIMITIVE_LATITUDE_SAMPLES - 1)) * Math.PI
|
|
return Array.from({ length: ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
|
|
const longitude =
|
|
-Math.PI + (longitudeIndex / (ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
|
|
return surfacePointAt(surface, longitude, latitude)
|
|
})
|
|
}
|
|
).flat()
|
|
cacheSurfaceValue(headSamplesCache, key, localSamples)
|
|
}
|
|
const projected = localSamples.map(point => projectLocalPoint(pose, point))
|
|
return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
|
|
}
|
|
|
|
const projectedCylinderPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (surface.roundness > 0 || (surface.morphRoundness ?? 0) > 0) {
|
|
return projectedRoundedPrimitivePath(pose, surface)
|
|
}
|
|
|
|
const halfHeight = surface.height / 2
|
|
const projected = [
|
|
...ringPoints(surface.width, surface.depth, -halfHeight),
|
|
...ringPoints(surface.width, surface.depth, halfHeight),
|
|
].map(point => projectLocalPoint(pose, point))
|
|
return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
|
|
}
|
|
|
|
const projectedCursorBodyPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
const layout = cursorLayout(surface)
|
|
const halfHeight = layout.bodyHeight / 2
|
|
const projected = [
|
|
...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY - halfHeight),
|
|
...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY + halfHeight),
|
|
].map(point => projectLocalPoint(pose, point))
|
|
return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
|
|
}
|
|
|
|
const projectedCursorConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
const layout = cursorLayout(surface)
|
|
const apex = projectLocalPoint(pose, [0, layout.coneApexY, 0])
|
|
const base = ringPoints(surface.width, surface.depth, layout.coneBaseY).map(point =>
|
|
projectLocalPoint(pose, point)
|
|
)
|
|
return smoothClosedPath(densifyClosedPoints(convexHull([...base, apex])))
|
|
}
|
|
|
|
const projectedConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (
|
|
(surface.morphRoundness ?? 0) > 0 ||
|
|
(surface.tipRoundness ?? 0) > 0 ||
|
|
(surface.baseRoundness ?? 0) > 0
|
|
) {
|
|
return projectedRoundedPrimitivePath(pose, surface)
|
|
}
|
|
|
|
const apex = projectLocalPoint(pose, [0, -surface.height / 2, 0])
|
|
const base = ringPoints(surface.width, surface.depth, surface.height / 2).map(point =>
|
|
projectLocalPoint(pose, point)
|
|
)
|
|
const hull = convexHull([...base, apex])
|
|
const apexIndex = hull.findIndex(
|
|
point => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < 0.01
|
|
)
|
|
if (apexIndex < 0) return smoothClosedPath(hull)
|
|
|
|
const ordered = [...hull.slice(apexIndex), ...hull.slice(0, apexIndex)]
|
|
const baseArc = ordered.slice(1)
|
|
if (baseArc.length < 2) return path(hull)
|
|
return `M${apex[0].toFixed(2)} ${apex[1].toFixed(2)}L${baseArc[0][0].toFixed(2)} ${baseArc[0][1].toFixed(2)}${smoothOpenPath(baseArc)}L${apex[0].toFixed(2)} ${apex[1].toFixed(2)}Z`
|
|
}
|
|
|
|
const projectedCubePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
|
|
|
|
const halfWidth = surface.width / 2
|
|
const halfHeight = surface.height / 2
|
|
const halfDepth = surface.depth / 2
|
|
const vertices = [-1, 1].flatMap(x =>
|
|
[-1, 1].flatMap(y => [-1, 1].map(z => [x * halfWidth, y * halfHeight, z * halfDepth] as Point3))
|
|
)
|
|
return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
|
|
}
|
|
|
|
const projectedDiamondPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
|
|
|
|
const halfWidth = surface.width / 2
|
|
const halfHeight = surface.height / 2
|
|
const halfDepth = surface.depth / 2
|
|
const vertices: Point3[] = [
|
|
[-halfWidth, 0, 0],
|
|
[halfWidth, 0, 0],
|
|
[0, -halfHeight, 0],
|
|
[0, halfHeight, 0],
|
|
[0, 0, -halfDepth],
|
|
[0, 0, halfDepth],
|
|
]
|
|
return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
|
|
}
|
|
|
|
type ProjectedEllipse = {
|
|
centerX: number
|
|
centerY: number
|
|
majorRadius: number
|
|
minorRadius: number
|
|
rotation: number
|
|
}
|
|
|
|
const ellipseProjection = (
|
|
centerX: number,
|
|
centerY: number,
|
|
covarianceXX: number,
|
|
covarianceXY: number,
|
|
covarianceYY: number
|
|
): ProjectedEllipse | null => {
|
|
const trace = covarianceXX + covarianceYY
|
|
const difference = Math.hypot(covarianceXX - covarianceYY, covarianceXY * 2)
|
|
const majorSquared = (trace + difference) / 2
|
|
const minorSquared = (trace - difference) / 2
|
|
if (majorSquared <= 0 || minorSquared <= 0) return null
|
|
|
|
return {
|
|
centerX,
|
|
centerY,
|
|
majorRadius: Math.sqrt(majorSquared),
|
|
minorRadius: Math.sqrt(minorSquared),
|
|
rotation: Math.atan2(covarianceXY * 2, covarianceXX - covarianceYY) / 2,
|
|
}
|
|
}
|
|
|
|
const ellipsePath = ({
|
|
centerX,
|
|
centerY,
|
|
majorRadius,
|
|
minorRadius,
|
|
rotation,
|
|
}: ProjectedEllipse) => {
|
|
const rotationDegrees = (rotation * 180) / Math.PI
|
|
const offsetX = Math.cos(rotation) * majorRadius
|
|
const offsetY = Math.sin(rotation) * majorRadius
|
|
const startX = centerX + offsetX
|
|
const startY = centerY + offsetY
|
|
const endX = centerX - offsetX
|
|
const endY = centerY - offsetY
|
|
|
|
return `M${startX.toFixed(2)} ${startY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${endX.toFixed(2)} ${endY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${startX.toFixed(2)} ${startY.toFixed(2)}Z`
|
|
}
|
|
|
|
const projectedEllipsoid = (
|
|
pose: AvatarPose,
|
|
axes: Point3,
|
|
localCenter: Point3 = [0, 0, 0]
|
|
): ProjectedEllipse | null => {
|
|
const rotatedAxes = [
|
|
rotateWithQuaternion(pose.orientation, [1, 0, 0]),
|
|
rotateWithQuaternion(pose.orientation, [0, 1, 0]),
|
|
rotateWithQuaternion(pose.orientation, [0, 0, 1]),
|
|
]
|
|
const center = rotateWithQuaternion(pose.orientation, localCenter)
|
|
|
|
if (Math.abs(pose.expression.perspective) < 0.0001) {
|
|
const covarianceXX = rotatedAxes.reduce(
|
|
(total, axis, index) => total + axis[0] * axis[0] * axes[index] * axes[index],
|
|
0
|
|
)
|
|
const covarianceXY = rotatedAxes.reduce(
|
|
(total, axis, index) => total + axis[0] * axis[1] * axes[index] * axes[index],
|
|
0
|
|
)
|
|
const covarianceYY = rotatedAxes.reduce(
|
|
(total, axis, index) => total + axis[1] * axis[1] * axes[index] * axes[index],
|
|
0
|
|
)
|
|
return ellipseProjection(center[0], center[1], covarianceXX, covarianceXY, covarianceYY)
|
|
}
|
|
|
|
const inverseAxesSquared = axes.map(axis => 1 / (axis * axis))
|
|
const quadratic = Array.from({ length: 3 }, (_, row) =>
|
|
Array.from({ length: 3 }, (_, column) =>
|
|
rotatedAxes.reduce(
|
|
(total, axis, index) => total + axis[row] * inverseAxesSquared[index] * axis[column],
|
|
0
|
|
)
|
|
)
|
|
)
|
|
const focalLength = FOCAL_LENGTH / pose.expression.perspective
|
|
const cameraOffset: Point3 = [-center[0], -center[1], focalLength - center[2]]
|
|
const cameraNormal: Point3 = [
|
|
quadratic[0][0] * cameraOffset[0] +
|
|
quadratic[0][1] * cameraOffset[1] +
|
|
quadratic[0][2] * cameraOffset[2],
|
|
quadratic[1][0] * cameraOffset[0] +
|
|
quadratic[1][1] * cameraOffset[1] +
|
|
quadratic[1][2] * cameraOffset[2],
|
|
quadratic[2][0] * cameraOffset[0] +
|
|
quadratic[2][1] * cameraOffset[1] +
|
|
quadratic[2][2] * cameraOffset[2],
|
|
]
|
|
const cameraTerm =
|
|
cameraOffset[0] * cameraNormal[0] +
|
|
cameraOffset[1] * cameraNormal[1] +
|
|
cameraOffset[2] * cameraNormal[2] -
|
|
1
|
|
const tangentLinear = [cameraNormal[0], cameraNormal[1], -focalLength * cameraNormal[2]]
|
|
const rayQuadratic = [
|
|
[quadratic[0][0], quadratic[0][1], -focalLength * quadratic[0][2]],
|
|
[quadratic[1][0], quadratic[1][1], -focalLength * quadratic[1][2]],
|
|
[
|
|
-focalLength * quadratic[2][0],
|
|
-focalLength * quadratic[2][1],
|
|
focalLength * focalLength * quadratic[2][2],
|
|
],
|
|
]
|
|
const conic = Array.from({ length: 3 }, (_, row) =>
|
|
Array.from(
|
|
{ length: 3 },
|
|
(_, column) =>
|
|
tangentLinear[row] * tangentLinear[column] - cameraTerm * rayQuadratic[row][column]
|
|
)
|
|
)
|
|
const determinant = conic[0][0] * conic[1][1] - conic[0][1] * conic[0][1]
|
|
if (Math.abs(determinant) < 1e-12) return null
|
|
|
|
const centerX = -(conic[1][1] * conic[0][2] - conic[0][1] * conic[1][2]) / determinant
|
|
const centerY = (conic[0][1] * conic[0][2] - conic[0][0] * conic[1][2]) / determinant
|
|
const centeredConstant = conic[2][2] + conic[0][2] * centerX + conic[1][2] * centerY
|
|
const scale = -centeredConstant
|
|
if (Math.abs(scale) < 1e-12) return null
|
|
|
|
const shapeXX = conic[0][0] / scale
|
|
const shapeXY = conic[0][1] / scale
|
|
const shapeYY = conic[1][1] / scale
|
|
const shapeDeterminant = shapeXX * shapeYY - shapeXY * shapeXY
|
|
if (shapeDeterminant <= 0) return null
|
|
|
|
return ellipseProjection(
|
|
centerX,
|
|
centerY,
|
|
shapeYY / shapeDeterminant,
|
|
-shapeXY / shapeDeterminant,
|
|
shapeXX / shapeDeterminant
|
|
)
|
|
}
|
|
|
|
const projectedEllipsoidPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
const ellipse = projectedEllipsoid(pose, [
|
|
surface.width / 2,
|
|
surface.height / 2,
|
|
surface.depth / 2,
|
|
])
|
|
const isSphere = surface.width === surface.height && surface.height === surface.depth
|
|
if (ellipse && isSphere) {
|
|
const radius = (ellipse.majorRadius + ellipse.minorRadius) / 2
|
|
return ellipsePath({
|
|
centerX: 0,
|
|
centerY: 0,
|
|
majorRadius: radius,
|
|
minorRadius: radius,
|
|
rotation: 0,
|
|
})
|
|
}
|
|
return ellipse ? ellipsePath(ellipse) : null
|
|
}
|
|
|
|
const mickeyEarPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (surface.type !== 'mickey') return []
|
|
|
|
const radius = Math.min(surface.width, surface.height) * 0.23
|
|
const depthRadius = Math.min(radius, surface.depth * 0.29)
|
|
const centerX = surface.width * 0.37
|
|
const centerY = -surface.height * 0.39
|
|
const centerZ = -surface.depth * 0.12
|
|
const axes: Point3 = [radius, radius, depthRadius]
|
|
return [-1, 1]
|
|
.map(side => projectedEllipsoid(pose, axes, [side * centerX, centerY, centerZ]))
|
|
.filter((ear): ear is ProjectedEllipse => ear !== null)
|
|
.map(ellipsePath)
|
|
}
|
|
|
|
const compositeBackPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (surface.type === 'mickey') return mickeyEarPaths(pose, surface)
|
|
if (surface.type === 'cursor') return [projectedCursorConePath(pose, surface)]
|
|
return []
|
|
}
|
|
|
|
const ellipsePoints = (ellipse: ProjectedEllipse) =>
|
|
Array.from({ length: PRIMITIVE_RING_SAMPLES }, (_, index) => {
|
|
const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
|
|
const major = Math.cos(angle) * ellipse.majorRadius
|
|
const minor = Math.sin(angle) * ellipse.minorRadius
|
|
return [
|
|
ellipse.centerX + major * Math.cos(ellipse.rotation) - minor * Math.sin(ellipse.rotation),
|
|
ellipse.centerY + major * Math.sin(ellipse.rotation) + minor * Math.cos(ellipse.rotation),
|
|
0,
|
|
] as Point3
|
|
})
|
|
|
|
const smoothHullPath = (points: Point3[]) => {
|
|
if (points.length < 3) return path(points)
|
|
const distances = points.map((point, index) => {
|
|
const next = points[(index + 1) % points.length]
|
|
return Math.hypot(next[0] - point[0], next[1] - point[1])
|
|
})
|
|
const sortedDistances = [...distances].sort((left, right) => left - right)
|
|
const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] || 1
|
|
const straightThreshold = Math.max(8, medianDistance * 3.5)
|
|
const straightEdges = distances.map(distance => distance > straightThreshold)
|
|
|
|
return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
|
|
.map((point, index) => {
|
|
const nextIndex = (index + 1) % points.length
|
|
const next = points[nextIndex]
|
|
if (straightEdges[index]) return `L${next[0].toFixed(2)} ${next[1].toFixed(2)}`
|
|
const previous = straightEdges[(index - 1 + points.length) % points.length]
|
|
? point
|
|
: points[(index - 1 + points.length) % points.length]
|
|
const afterNext = straightEdges[nextIndex] ? next : points[(index + 2) % points.length]
|
|
const firstControlX = point[0] + (next[0] - previous[0]) / 6
|
|
const firstControlY = point[1] + (next[1] - previous[1]) / 6
|
|
const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
|
|
const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
|
|
return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
|
|
})
|
|
.join('')}Z`
|
|
}
|
|
|
|
const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
const radiusX = surface.width / 2
|
|
const radiusY = Math.min(radiusX, surface.height / 2)
|
|
const radiusZ = surface.depth / 2
|
|
const straightHalf = Math.max(0, (surface.height - radiusY * 2) / 2)
|
|
const axes: Point3 = [radiusX, radiusY, radiusZ]
|
|
const top = projectedEllipsoid(pose, axes, [0, straightHalf, 0])
|
|
const bottom = projectedEllipsoid(pose, axes, [0, -straightHalf, 0])
|
|
if (!top || !bottom) return null
|
|
return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)]))
|
|
}
|
|
|
|
const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
|
|
if (surface.type === 'sphere' || surface.type === 'mickey') {
|
|
const exactPath = projectedEllipsoidPath(pose, surface)
|
|
if (exactPath) return exactPath
|
|
}
|
|
|
|
if (surface.type === 'capsule') {
|
|
const exactPath = projectedCapsulePath(pose, surface)
|
|
if (exactPath) return exactPath
|
|
}
|
|
|
|
if (surface.type === 'cylinder') return projectedCylinderPath(pose, surface)
|
|
if (surface.type === 'cursor') return projectedCursorBodyPath(pose, surface)
|
|
if (surface.type === 'cone') return projectedConePath(pose, surface)
|
|
if (surface.type === 'cube') return projectedCubePath(pose, surface)
|
|
if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
|
|
|
|
const key = surfaceCacheKey(surface)
|
|
let localSamples = headSamplesCache.get(key)
|
|
if (!localSamples) {
|
|
localSamples = Array.from({ length: HEAD_LATITUDE_SAMPLES }, (_, latitudeIndex) => {
|
|
const latitude = -Math.PI / 2 + (latitudeIndex / (HEAD_LATITUDE_SAMPLES - 1)) * Math.PI
|
|
return Array.from({ length: HEAD_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
|
|
const longitude = -Math.PI + (longitudeIndex / (HEAD_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
|
|
return surfacePointAt(surface, longitude, latitude)
|
|
})
|
|
}).flat()
|
|
cacheSurfaceValue(headSamplesCache, key, localSamples)
|
|
}
|
|
const projectedSamples = localSamples.map(sample =>
|
|
project(rotateWithQuaternion(pose.orientation, sample), pose.expression.perspective)
|
|
)
|
|
return path(convexHull(projectedSamples))
|
|
}
|
|
|
|
const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
|
|
const key = surfaceCacheKey(node.surface)
|
|
let localSamples = accessorySamplesCache.get(key)
|
|
if (!localSamples) {
|
|
localSamples = Array.from({ length: 17 }, (_, latitudeIndex) => {
|
|
const latitude = -Math.PI / 2 + (latitudeIndex / 16) * Math.PI
|
|
return Array.from({ length: 49 }, (_, longitudeIndex) => {
|
|
const longitude = -Math.PI + (longitudeIndex / 48) * Math.PI * 2
|
|
return surfacePointAt(node.surface, longitude, latitude)
|
|
})
|
|
}).flat()
|
|
cacheSurfaceValue(accessorySamplesCache, key, localSamples)
|
|
}
|
|
|
|
const localOrientation = quaternionFromEuler(
|
|
radians(node.rotation[0]),
|
|
radians(node.rotation[1]),
|
|
radians(node.rotation[2])
|
|
)
|
|
const projected = localSamples.map(point => {
|
|
const locallyRotated = rotateWithQuaternion(localOrientation, point)
|
|
const positioned: Point3 = [
|
|
locallyRotated[0] + node.position[0],
|
|
locallyRotated[1] + node.position[1],
|
|
locallyRotated[2] + node.position[2],
|
|
]
|
|
return project(rotateWithQuaternion(pose.orientation, positioned), pose.expression.perspective)
|
|
})
|
|
const hull = convexHull(projected)
|
|
if (
|
|
(node.surface.type === 'cube' || node.surface.type === 'diamond') &&
|
|
node.surface.roundness <= 0
|
|
) {
|
|
return path(hull)
|
|
}
|
|
return smoothClosedPath(densifyClosedPoints(hull))
|
|
}
|
|
|
|
const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
|
|
|
|
const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
|
|
const localOrientation = quaternionFromEuler(
|
|
radians(node.rotation[0]),
|
|
radians(node.rotation[1]),
|
|
radians(node.rotation[2])
|
|
)
|
|
const cameraDepthByAxis = (
|
|
[
|
|
[1, 0, 0],
|
|
[0, 1, 0],
|
|
[0, 0, 1],
|
|
] as Point3[]
|
|
).map(
|
|
axis => rotateWithQuaternion(pose.orientation, rotateWithQuaternion(localOrientation, axis))[2]
|
|
)
|
|
return Math.hypot(
|
|
cameraDepthByAxis[0] * (node.surface.width / 2),
|
|
cameraDepthByAxis[1] * (node.surface.height / 2),
|
|
cameraDepthByAxis[2] * (node.surface.depth / 2)
|
|
)
|
|
}
|
|
|
|
const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
|
|
const layers = nodes
|
|
.map(node => {
|
|
const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
|
|
return {
|
|
id: node.id,
|
|
path: accessoryPath(pose, node),
|
|
depth,
|
|
front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO,
|
|
}
|
|
})
|
|
.sort((left, right) => left.depth - right.depth)
|
|
return {
|
|
backPaths: layers.filter(layer => !layer.front).map(layer => layer.path),
|
|
frontPaths: layers.filter(layer => layer.front).map(layer => layer.path),
|
|
backNodeIds: layers.filter(layer => !layer.front).map(layer => layer.id),
|
|
frontNodeIds: layers.filter(layer => layer.front).map(layer => layer.id),
|
|
}
|
|
}
|
|
|
|
export const renderAvatar = (
|
|
pose: AvatarPose,
|
|
surface: SurfaceConfig,
|
|
blink = 1,
|
|
options: RenderAvatarOptions = {}
|
|
): AvatarGeometry => {
|
|
const leftSamples = eyePoints(pose, surface, -1, blink, options.eyeOffset)
|
|
const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
|
|
const left = leftSamples.map(sample => sample.point)
|
|
const right = rightSamples.map(sample => sample.point)
|
|
const accessories = accessoryLayers(pose, options.bodyNodes ?? [])
|
|
const compositePaths = compositeBackPaths(pose, surface)
|
|
return {
|
|
backPaths: [...compositePaths, ...accessories.backPaths],
|
|
frontPaths: accessories.frontPaths,
|
|
backNodeIds: [...compositePaths.map(() => null), ...accessories.backNodeIds],
|
|
frontNodeIds: accessories.frontNodeIds,
|
|
headPath: headPath(pose, surface),
|
|
leftPath: path(left),
|
|
rightPath: path(right),
|
|
leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
|
|
rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
|
|
wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
|
|
}
|
|
}
|