325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
export type Quaternion = readonly [number, number, number, number]
|
|
export type Point3 = readonly [number, number, number]
|
|
export type HeadPose = { turn: number; tilt: number; roll: number }
|
|
|
|
export const RADIUS = 120
|
|
const RING = 30
|
|
const FOCAL = 620
|
|
const PERSPECTIVE = 1
|
|
const QUARTER_ARC_SAMPLES = 14
|
|
|
|
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)]
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
|
|
// Engine eyes.js EYE_HOME: the frame where eyes rest centered. Wires, gizmo
|
|
// and eye projection share it so the grid's central meridian splits the eyes.
|
|
export const EYE_HOME: HeadPose = { turn: 17, tilt: -14, roll: 29 }
|
|
|
|
export const conjugateFlipY = ([w, x, y, z]: Quaternion): Quaternion => [w, -x, y, -z]
|
|
|
|
export const orientationOf = (pose: HeadPose): Quaternion =>
|
|
quaternionFromEuler(radians(pose.tilt), radians(pose.turn), radians(pose.roll))
|
|
|
|
export const poseFromOrientation = (pose: HeadPose, orientation: Quaternion): HeadPose => {
|
|
const [rx, ry, rz] = quaternionToEuler(orientation)
|
|
return {
|
|
tilt: nearestEquivalentAngle((rx * 180) / Math.PI, pose.tilt),
|
|
turn: nearestEquivalentAngle((ry * 180) / Math.PI, pose.turn),
|
|
roll: nearestEquivalentAngle((rz * 180) / Math.PI, pose.roll)
|
|
}
|
|
}
|
|
|
|
export const visualOrientation = (pose: HeadPose, home: HeadPose = EYE_HOME): Quaternion => {
|
|
const [w, x, y, z] = orientationOf(home)
|
|
return multiplyQuaternions(orientationOf(pose), [w, -x, -y, -z])
|
|
}
|
|
|
|
export const screenOrientation = (pose: HeadPose, home: HeadPose = EYE_HOME): Quaternion =>
|
|
conjugateFlipY(visualOrientation(pose, home))
|
|
|
|
export const poseFromScreen = (
|
|
pose: HeadPose,
|
|
screen: Quaternion,
|
|
home: HeadPose = EYE_HOME
|
|
): HeadPose =>
|
|
poseFromOrientation(pose, multiplyQuaternions(conjugateFlipY(screen), orientationOf(home)))
|
|
|
|
export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
|
|
axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
|
|
|
|
export const rotatePoseAroundAxis = (pose: HeadPose, axis: 'x' | 'y' | 'z', deltaDegrees: number) => {
|
|
const start = screenOrientation(pose)
|
|
const worldAxis = rotateWithQuaternion(start, axisVector(axis))
|
|
return poseFromScreen(
|
|
pose,
|
|
multiplyQuaternions(quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)), start)
|
|
)
|
|
}
|
|
|
|
export const rotatePoseAroundCamera = (pose: HeadPose, deltaRadians: number) =>
|
|
poseFromScreen(
|
|
pose,
|
|
multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), screenOrientation(pose))
|
|
)
|
|
|
|
const arcballVector = (
|
|
[xValue, yValue]: readonly [number, number],
|
|
radius = RADIUS
|
|
): 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 rotatePoseWithArcball = (
|
|
pose: HeadPose,
|
|
startPoint: readonly [number, number],
|
|
currentPoint: readonly [number, number],
|
|
radius = RADIUS
|
|
) => {
|
|
const start = screenOrientation(pose)
|
|
const delta = quaternionFromVectors(
|
|
arcballVector(startPoint, radius),
|
|
arcballVector(currentPoint, radius)
|
|
)
|
|
return poseFromScreen(pose, multiplyQuaternions(delta, start))
|
|
}
|
|
|
|
export const rotationRing = (pose: HeadPose, axis: 'x' | 'y' | 'z', radius = RING): 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(screenOrientation(pose), point)
|
|
return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
|
|
})
|
|
|
|
export const ringPath = (points: Point3[]) =>
|
|
`M${points[0]![0]} ${points[0]![1]}${points
|
|
.slice(1)
|
|
.map((point) => `L${point[0]} ${point[1]}`)
|
|
.join('')}Z`
|
|
|
|
export const openRingPath = (points: Point3[]) => {
|
|
if (!points.length) return ''
|
|
return `M${points[0]![0]} ${points[0]![1]}${points
|
|
.slice(1)
|
|
.map((point) => `L${point[0]} ${point[1]}`)
|
|
.join('')}`
|
|
}
|
|
|
|
export const splitRingArcs = (points: Point3[]): { front: Point3[][]; back: Point3[][] } => {
|
|
const front: Point3[][] = []
|
|
const back: Point3[][] = []
|
|
if (points.length < 2) return { front, back }
|
|
const cut = (from: Point3, to: Point3): Point3 => {
|
|
const span = to[2] - from[2]
|
|
const t = Math.abs(span) < 1e-8 ? 0.5 : from[2] / -span
|
|
return [
|
|
from[0] + (to[0] - from[0]) * t,
|
|
from[1] + (to[1] - from[1]) * t,
|
|
0
|
|
]
|
|
}
|
|
let current: Point3[] = [points[0]!]
|
|
let facing = points[0]![2] >= 0
|
|
for (let i = 1; i < points.length; i++) {
|
|
const point = points[i]!
|
|
const nowFront = point[2] >= 0
|
|
if (nowFront !== facing) {
|
|
const mid = cut(current[current.length - 1]!, point)
|
|
current.push(mid)
|
|
;(facing ? front : back).push(current)
|
|
current = [mid, point]
|
|
facing = nowFront
|
|
} else {
|
|
current.push(point)
|
|
}
|
|
}
|
|
if (current.length > 1) (facing ? front : back).push(current)
|
|
return { front, back }
|
|
}
|
|
|
|
export const unitVector = (
|
|
from: readonly [number, number] | Point3,
|
|
to: readonly [number, number] | 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]
|
|
}
|
|
|
|
export const projectLocalPoint = (
|
|
pose: HeadPose,
|
|
local: Point3,
|
|
origin: readonly [number, number] = [0, 0],
|
|
home: HeadPose = EYE_HOME
|
|
): Point3 => {
|
|
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
|
|
const scale = FOCAL / Math.max(FOCAL - rotated[2] * PERSPECTIVE, 0.0001)
|
|
return [origin[0] + rotated[0] * scale, origin[1] + rotated[1] * scale, rotated[2]]
|
|
}
|
|
|
|
export 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
|
|
}
|
|
|
|
export const projectEyePoint = (
|
|
pose: HeadPose,
|
|
faceX: number,
|
|
faceY: number,
|
|
localX: number,
|
|
localY: number,
|
|
angleDeg: number,
|
|
home: HeadPose = EYE_HOME
|
|
): Point3 => {
|
|
const angle = radians(angleDeg)
|
|
const cosine = Math.cos(angle)
|
|
const sine = Math.sin(angle)
|
|
return projectFacePoint(
|
|
pose,
|
|
faceX + localX * cosine - localY * sine,
|
|
faceY + localX * sine + localY * cosine,
|
|
RADIUS,
|
|
[0, 0],
|
|
home
|
|
)
|
|
}
|
|
|
|
export const projectFacePoint = (
|
|
pose: HeadPose,
|
|
x: number,
|
|
y: number,
|
|
radius: number,
|
|
origin: readonly [number, number] = [radius, radius],
|
|
home: HeadPose = EYE_HOME
|
|
): Point3 => {
|
|
const diskX = x / radius
|
|
const diskY = y / radius
|
|
// Face coords are authored against RADIUS (original lab units) but ride the
|
|
// visible head: rim = RADIUS scaled to the head edge in overlay units, so
|
|
// the eye sphere silhouette lands on the head outline (engine parity).
|
|
const rim = radius * (95 / 120)
|
|
// Past the silhouette points stay raw on the z=0 plane and get sliced by
|
|
// the head clip instead of folding back inward.
|
|
const diskZ = Math.sqrt(Math.max(0, 1 - diskX * diskX - diskY * diskY))
|
|
const local: Point3 = [diskX, diskY, diskZ]
|
|
const rotated = rotateWithQuaternion(screenOrientation(pose, home), local)
|
|
const depth = rotated[2] * rim
|
|
const scale = FOCAL / Math.max(FOCAL - depth * PERSPECTIVE, 0.0001)
|
|
return [
|
|
origin[0] + rotated[0] * rim * scale,
|
|
origin[1] + rotated[1] * rim * scale,
|
|
rotated[2]
|
|
]
|
|
}
|