feat:加上手动调节与表情回放
This commit is contained in:
@@ -0,0 +1,821 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
openRingPath,
|
||||
projectEyePoint,
|
||||
projectFacePoint,
|
||||
RADIUS,
|
||||
roundedRectangle,
|
||||
rotatePoseAroundAxis,
|
||||
rotatePoseAroundCamera,
|
||||
rotatePoseWithArcball,
|
||||
rotationRing,
|
||||
splitRingArcs,
|
||||
unitVector,
|
||||
wirePaths,
|
||||
type HeadPose,
|
||||
type Point3
|
||||
} from '@/ui/pose/math'
|
||||
import {
|
||||
clampEyes,
|
||||
clampPose,
|
||||
DEFAULT_EYE,
|
||||
DEFAULT_POSE,
|
||||
scaleEye,
|
||||
updateEye,
|
||||
type EyeSide,
|
||||
type EyeTune,
|
||||
type ManualPart,
|
||||
type ManualState
|
||||
} from '@/ui/pose/model'
|
||||
import {
|
||||
beginManipulation,
|
||||
finishManipulation,
|
||||
previewManipulation,
|
||||
type ManipulationSession
|
||||
} from '@/ui/pose/session'
|
||||
import { localHeadRadius, overlayToHost, toLocal, type LocalPoint } from '@/ui/pose/space'
|
||||
|
||||
export type HeroHandle = {
|
||||
parts: () => {
|
||||
svg: SVGSVGElement
|
||||
body: SVGPathElement
|
||||
eyes: SVGPathElement[]
|
||||
group: SVGGElement
|
||||
} | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
manual: ManualState
|
||||
hero: HeroHandle | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:pose': [HeadPose]
|
||||
'update:selected': [ManualPart | null]
|
||||
'update:eyes': [EyeTune]
|
||||
}>()
|
||||
|
||||
const gizmoSvg = ref<SVGSVGElement | null>(null)
|
||||
const manualLayer = ref<HTMLDivElement | null>(null)
|
||||
const overlayStyle = ref<Record<string, string>>({})
|
||||
const pose = computed(() => props.manual.pose)
|
||||
const selected = computed(() => props.manual.selected)
|
||||
const eyes = computed(() => props.manual.eyes)
|
||||
const headRadius = ref(95)
|
||||
|
||||
const rings = computed(() => {
|
||||
const split = (axis: 'x' | 'y' | 'z') => {
|
||||
const { front, back } = splitRingArcs(rotationRing(pose.value, axis))
|
||||
return {
|
||||
front: front.map(openRingPath).filter(Boolean),
|
||||
back: back.map(openRingPath).filter(Boolean)
|
||||
}
|
||||
}
|
||||
return { x: split('x'), y: split('y'), z: split('z') }
|
||||
})
|
||||
|
||||
const activeAxis = ref<'x' | 'y' | 'z' | 'view' | null>(null)
|
||||
let wireGroup: SVGGElement | null = null
|
||||
|
||||
type EyeHandle = 'width' | 'height' | 'size' | 'spacing' | 'rotate'
|
||||
|
||||
type Drag =
|
||||
| { kind: 'arcball'; start: LocalPoint; pose: HeadPose; radius: number }
|
||||
| {
|
||||
kind: 'axis'
|
||||
axis: 'x' | 'y' | 'z'
|
||||
start: LocalPoint
|
||||
tangent: LocalPoint
|
||||
pose: HeadPose
|
||||
}
|
||||
| { kind: 'view'; startAngle: number; pose: HeadPose }
|
||||
| {
|
||||
kind: 'eye'
|
||||
handle: EyeHandle
|
||||
side: 'left' | 'right'
|
||||
start: LocalPoint
|
||||
eyes: EyeTune
|
||||
center: LocalPoint
|
||||
widthAxis: LocalPoint
|
||||
heightAxis: LocalPoint
|
||||
spacingAxis: LocalPoint
|
||||
startAngle: number
|
||||
startDistance: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
}
|
||||
|
||||
const drag = ref<Drag | null>(null)
|
||||
let poseSession: ManipulationSession<HeadPose> | null = null
|
||||
let eyeSession: ManipulationSession<EyeTune> | null = null
|
||||
|
||||
type EyeFrame = {
|
||||
visible: boolean
|
||||
available: boolean
|
||||
center: LocalPoint
|
||||
widthAxis: LocalPoint
|
||||
heightAxis: LocalPoint
|
||||
widthHandle: LocalPoint
|
||||
heightHandle: LocalPoint
|
||||
rotateHandle: LocalPoint
|
||||
sizeHandle: LocalPoint
|
||||
outline: string
|
||||
width: number
|
||||
height: number
|
||||
scaleX: number
|
||||
scaleY: number
|
||||
plusX: number
|
||||
plusY: number
|
||||
minusY: number
|
||||
faceX: number
|
||||
faceY: number
|
||||
}
|
||||
|
||||
const emptyFrame = (): EyeFrame => ({
|
||||
visible: false,
|
||||
available: false,
|
||||
center: [0, 0],
|
||||
widthAxis: [1, 0],
|
||||
heightAxis: [0, 1],
|
||||
widthHandle: [0, 0],
|
||||
heightHandle: [0, 0],
|
||||
rotateHandle: [0, 0],
|
||||
sizeHandle: [0, 0],
|
||||
outline: '',
|
||||
width: DEFAULT_EYE.width,
|
||||
height: DEFAULT_EYE.height,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
plusX: DEFAULT_EYE.width / 2,
|
||||
plusY: DEFAULT_EYE.height / 2,
|
||||
minusY: -DEFAULT_EYE.height / 2,
|
||||
faceX: 0,
|
||||
faceY: 0
|
||||
})
|
||||
|
||||
const leftFrame = ref<EyeFrame>(emptyFrame())
|
||||
const rightFrame = ref<EyeFrame>(emptyFrame())
|
||||
|
||||
const sceneSvg = () => props.hero?.parts()?.svg ?? null
|
||||
|
||||
const syncOverlay = () => {
|
||||
const host = sceneSvg()
|
||||
const layer = manualLayer.value
|
||||
if (!host || !layer) return
|
||||
const hostBox = host.getBoundingClientRect()
|
||||
const layerBox = layer.getBoundingClientRect()
|
||||
overlayStyle.value = {
|
||||
left: `${(hostBox.left - layerBox.left).toFixed(2)}px`,
|
||||
top: `${(hostBox.top - layerBox.top).toFixed(2)}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
width: `${hostBox.width.toFixed(2)}px`,
|
||||
height: `${hostBox.height.toFixed(2)}px`
|
||||
}
|
||||
}
|
||||
|
||||
const localOf = (event: PointerEvent): LocalPoint | null =>
|
||||
toLocal(sceneSvg(), event.clientX, event.clientY)
|
||||
|
||||
const gizmoOf = (event: PointerEvent): LocalPoint | null => {
|
||||
const svg = gizmoSvg.value
|
||||
if (!svg) return null
|
||||
const rectangle = svg.getBoundingClientRect()
|
||||
if (rectangle.width < 1) return null
|
||||
return [
|
||||
((event.clientX - rectangle.left) / rectangle.width) * 86 - 43,
|
||||
((event.clientY - rectangle.top) / rectangle.height) * 86 - 43
|
||||
]
|
||||
}
|
||||
|
||||
const line = (from: LocalPoint, to: LocalPoint) =>
|
||||
`M${from[0].toFixed(2)} ${from[1].toFixed(2)}L${to[0].toFixed(2)} ${to[1].toFixed(2)}`
|
||||
|
||||
const commitPose = (next: HeadPose) => emit('update:pose', clampPose(next))
|
||||
const commitEyes = (next: EyeTune) => emit('update:eyes', clampEyes(next))
|
||||
|
||||
const xy = (point: Point3): LocalPoint => [point[0], point[1]]
|
||||
|
||||
const overlayPath = (points: Point3[]) => {
|
||||
if (points.length < 2) return ''
|
||||
return `M${points.map((point) => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`
|
||||
}
|
||||
|
||||
const hostPath = (host: SVGSVGElement | null, points: Point3[]) => {
|
||||
const mapped = points
|
||||
.map((point) => overlayToHost(host, point[0], point[1]))
|
||||
.filter((point): point is LocalPoint => !!point)
|
||||
if (mapped.length < 2) return ''
|
||||
return `M${mapped.map((point) => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`
|
||||
}
|
||||
|
||||
const offsetAlong = (origin: LocalPoint, axis: LocalPoint, distance: number): LocalPoint => [
|
||||
origin[0] + axis[0] * distance,
|
||||
origin[1] + axis[1] * distance
|
||||
]
|
||||
|
||||
const readEye = (tune: EyeSide, side: 'left' | 'right'): EyeFrame => {
|
||||
const faceX = (side === 'left' ? -1 : 1) * eyes.value.spacing / 2 + tune.x
|
||||
const faceY = tune.y
|
||||
const width = Math.max(tune.width, 1)
|
||||
const height = Math.max(tune.height, 1)
|
||||
const project = (localX: number, localY: number) =>
|
||||
projectEyePoint(pose.value, faceX, faceY, localX, localY, tune.angle, DEFAULT_POSE)
|
||||
const outline = roundedRectangle(width, height).map(([localX, localY]) =>
|
||||
project(localX, localY)
|
||||
)
|
||||
const center = xy(project(0, 0))
|
||||
const widthHandle = xy(project(width / 2 + 9, 0))
|
||||
const heightHandle = xy(project(0, -height / 2 - 9))
|
||||
const rotateHandle = xy(project(0, -height / 2 - 30))
|
||||
const sizeHandle = xy(project(width / 2 + 11, height / 2 + 11))
|
||||
const plusX = Math.hypot(widthHandle[0] - center[0], widthHandle[1] - center[1])
|
||||
const minusY = -Math.hypot(heightHandle[0] - center[0], heightHandle[1] - center[1])
|
||||
return {
|
||||
visible: outline.reduce((sum, point) => sum + point[2], 0) > 0,
|
||||
available: true,
|
||||
center,
|
||||
widthAxis: unitVector(center, widthHandle),
|
||||
heightAxis: unitVector(center, [
|
||||
center[0] - (heightHandle[0] - center[0]),
|
||||
center[1] - (heightHandle[1] - center[1])
|
||||
]),
|
||||
widthHandle,
|
||||
heightHandle,
|
||||
rotateHandle,
|
||||
sizeHandle,
|
||||
outline: overlayPath(outline),
|
||||
width,
|
||||
height,
|
||||
scaleX: (2 * Math.max(plusX - 9, 0.001)) / width,
|
||||
scaleY: (2 * Math.max(-minusY - 9, 0.001)) / height,
|
||||
plusX,
|
||||
plusY: Math.abs(minusY),
|
||||
minusY,
|
||||
faceX,
|
||||
faceY
|
||||
}
|
||||
}
|
||||
|
||||
const paintStadiums = () => {
|
||||
const parts = props.hero?.parts()
|
||||
const host = sceneSvg()
|
||||
if (!parts || !host) return
|
||||
;(['left', 'right'] as const).forEach((side, index) => {
|
||||
const el = parts.eyes[index]
|
||||
const frame = side === 'left' ? leftFrame.value : rightFrame.value
|
||||
if (!el) return
|
||||
const tune = eyes.value[side]
|
||||
const outline = roundedRectangle(frame.width, frame.height).map(([localX, localY]) =>
|
||||
projectEyePoint(
|
||||
pose.value,
|
||||
frame.faceX,
|
||||
frame.faceY,
|
||||
localX,
|
||||
localY,
|
||||
tune.angle,
|
||||
DEFAULT_POSE
|
||||
)
|
||||
)
|
||||
el.setAttribute('d', hostPath(host, outline))
|
||||
el.removeAttribute('transform')
|
||||
el.style.display = frame.visible ? '' : 'none'
|
||||
})
|
||||
}
|
||||
|
||||
const sampleEyes = () => {
|
||||
const host = sceneSvg()
|
||||
leftFrame.value = readEye(eyes.value.left, 'left')
|
||||
rightFrame.value = readEye(eyes.value.right, 'right')
|
||||
paintStadiums()
|
||||
headRadius.value = localHeadRadius(host, engineRadius())
|
||||
}
|
||||
|
||||
const downOf = (frame: EyeFrame): LocalPoint => unitVector(frame.heightHandle, frame.center)
|
||||
|
||||
const spacingPoint = (left: EyeFrame, right: EyeFrame, selected: EyeFrame): LocalPoint => {
|
||||
const midX = (eyes.value.left.x + eyes.value.right.x) / 2
|
||||
const midY = (eyes.value.left.y + eyes.value.right.y) / 2
|
||||
const point = projectFacePoint(
|
||||
pose.value,
|
||||
midX,
|
||||
midY + selected.height / 2 + 34,
|
||||
RADIUS,
|
||||
[0, 0],
|
||||
DEFAULT_POSE
|
||||
)
|
||||
if (Number.isFinite(point[0]) && Number.isFinite(point[1])) return [point[0], point[1]]
|
||||
return offsetAlong(
|
||||
[(left.center[0] + right.center[0]) / 2, (left.center[1] + right.center[1]) / 2],
|
||||
downOf(selected),
|
||||
selected.height / 2 + 34
|
||||
)
|
||||
}
|
||||
|
||||
const editor = computed(() => {
|
||||
const side: 'left' | 'right' | null =
|
||||
selected.value === 'left' ? 'left' : selected.value === 'right' ? 'right' : null
|
||||
if (!side) return null
|
||||
const frame = side === 'left' ? leftFrame.value : rightFrame.value
|
||||
if (!frame.visible) return null
|
||||
const other = side === 'left' ? rightFrame.value : leftFrame.value
|
||||
const both = other.available
|
||||
const mid: LocalPoint = both
|
||||
? [(frame.center[0] + other.center[0]) / 2, (frame.center[1] + other.center[1]) / 2]
|
||||
: frame.center
|
||||
const spacingHandle = both
|
||||
? spacingPoint(frame, other, frame)
|
||||
: offsetAlong(frame.center, downOf(frame), frame.plusY + 34)
|
||||
return {
|
||||
side,
|
||||
frame,
|
||||
other,
|
||||
both,
|
||||
mid,
|
||||
spacingHandle,
|
||||
widthGuide: line(frame.center, frame.widthHandle),
|
||||
heightGuide: line(frame.center, frame.heightHandle),
|
||||
rotationGuide: line(frame.heightHandle, frame.rotateHandle),
|
||||
spacingGuide: both
|
||||
? `${line(frame.center, other.center)}${line(mid, spacingHandle)}`
|
||||
: line(frame.center, spacingHandle)
|
||||
}
|
||||
})
|
||||
|
||||
const draggingHead = computed(() => !!drag.value && drag.value.kind !== 'eye')
|
||||
const draggingHandle = computed(() => drag.value?.kind === 'eye')
|
||||
const showWire = draggingHead
|
||||
|
||||
const engineRadius = () => window.GROK_GEO?.Re ?? 114.27
|
||||
|
||||
const ensureWires = () => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return null
|
||||
if (wireGroup?.isConnected) return wireGroup
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
const group = document.createElementNS(ns, 'g')
|
||||
group.setAttribute('class', 'manual-wires')
|
||||
group.setAttribute('aria-hidden', 'true')
|
||||
const clip = parts.svg.querySelector('clipPath')
|
||||
if (clip?.id) group.setAttribute('clip-path', `url(#${clip.id})`)
|
||||
parts.body.insertAdjacentElement('afterend', group)
|
||||
wireGroup = group
|
||||
return group
|
||||
}
|
||||
|
||||
const paintWires = () => {
|
||||
const group = ensureWires()
|
||||
if (!group) return
|
||||
const radius = engineRadius()
|
||||
const paths = showWire.value ? wirePaths(pose.value, radius, [radius, radius]) : []
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
while (group.childNodes.length > paths.length) group.lastChild?.remove()
|
||||
paths.forEach((d, index) => {
|
||||
let path = group.childNodes[index] as SVGPathElement | undefined
|
||||
if (!path) {
|
||||
path = document.createElementNS(ns, 'path')
|
||||
path.setAttribute('class', 'wire')
|
||||
group.appendChild(path)
|
||||
}
|
||||
path.setAttribute('d', d)
|
||||
})
|
||||
}
|
||||
|
||||
const paintFocus = () => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return
|
||||
const ns = 'http://www.w3.org/2000/svg'
|
||||
let ring = parts.group.querySelector('.manual-head-outline') as SVGPathElement | null
|
||||
if (draggingHead.value) {
|
||||
if (!ring) {
|
||||
ring = document.createElementNS(ns, 'path')
|
||||
ring.setAttribute('class', 'manual-head-outline')
|
||||
parts.group.appendChild(ring)
|
||||
}
|
||||
ring.setAttribute('d', parts.body.getAttribute('d') || '')
|
||||
} else {
|
||||
ring?.remove()
|
||||
}
|
||||
const left = parts.eyes[0]
|
||||
const right = parts.eyes[1]
|
||||
left?.classList.toggle('cyan-outline', selected.value === 'left')
|
||||
right?.classList.toggle('cyan-outline', selected.value === 'right')
|
||||
}
|
||||
|
||||
const clearDecor = () => {
|
||||
wireGroup?.remove()
|
||||
wireGroup = null
|
||||
const parts = props.hero?.parts()
|
||||
if (parts) parts.body.style.cursor = ''
|
||||
parts?.group.querySelector('.manual-head-outline')?.remove()
|
||||
parts?.eyes.forEach((el) => {
|
||||
el.classList.remove('cyan-outline')
|
||||
el.style.cursor = ''
|
||||
el.style.display = ''
|
||||
el.removeAttribute('transform')
|
||||
})
|
||||
}
|
||||
|
||||
const detachDrag = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', stop)
|
||||
window.removeEventListener('pointercancel', cancel)
|
||||
}
|
||||
|
||||
const attachDrag = (event: PointerEvent) => {
|
||||
event.preventDefault()
|
||||
;(event.currentTarget as Element | null)?.setPointerCapture?.(event.pointerId)
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', stop)
|
||||
window.addEventListener('pointercancel', cancel)
|
||||
}
|
||||
|
||||
const setBodyCursor = (cursor: string) => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return
|
||||
parts.body.style.cursor = cursor
|
||||
}
|
||||
|
||||
const startArcball = (event: PointerEvent) => {
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
if (selected.value !== 'left' && selected.value !== 'right') emit('update:selected', 'body')
|
||||
poseSession = beginManipulation(pose.value)
|
||||
drag.value = {
|
||||
kind: 'arcball',
|
||||
start: point,
|
||||
pose: pose.value,
|
||||
radius: headRadius.value
|
||||
}
|
||||
setBodyCursor('grabbing')
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const startAxis = (axis: 'x' | 'y' | 'z', event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
const point = gizmoOf(event)
|
||||
if (!point) return
|
||||
const ring = rotationRing(pose.value, axis)
|
||||
let closest = 0
|
||||
let best = Infinity
|
||||
ring.slice(0, -1).forEach((ringPoint, index) => {
|
||||
const distance = Math.hypot(ringPoint[0] - point[0], ringPoint[1] - point[1])
|
||||
if (distance < best) {
|
||||
closest = index
|
||||
best = distance
|
||||
}
|
||||
})
|
||||
const previous = ring[(closest - 1 + ring.length - 1) % (ring.length - 1)]!
|
||||
const next = ring[(closest + 1) % (ring.length - 1)]!
|
||||
poseSession = beginManipulation(pose.value)
|
||||
activeAxis.value = axis
|
||||
drag.value = {
|
||||
kind: 'axis',
|
||||
axis,
|
||||
start: point,
|
||||
tangent: unitVector(previous, next),
|
||||
pose: pose.value
|
||||
}
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const startView = (event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
const point = gizmoOf(event)
|
||||
if (!point) return
|
||||
poseSession = beginManipulation(pose.value)
|
||||
activeAxis.value = 'view'
|
||||
drag.value = { kind: 'view', startAngle: Math.atan2(point[1], point[0]), pose: pose.value }
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const startHandle = (handle: EyeHandle, event: PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
const live = editor.value
|
||||
if (!live) return
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
eyeSession = beginManipulation(eyes.value)
|
||||
const left = leftFrame.value
|
||||
const right = rightFrame.value
|
||||
const both = left.visible && right.visible
|
||||
const spacingFrom = both ? left.center : live.frame.center
|
||||
const spacingTo = both ? right.center : live.spacingHandle
|
||||
drag.value = {
|
||||
kind: 'eye',
|
||||
handle,
|
||||
side: live.side,
|
||||
start: point,
|
||||
eyes: eyes.value,
|
||||
center: live.frame.center,
|
||||
widthAxis: unitVector(live.frame.center, live.frame.widthHandle),
|
||||
heightAxis: unitVector(live.frame.center, live.frame.heightHandle),
|
||||
spacingAxis: unitVector(spacingFrom, spacingTo),
|
||||
startAngle: Math.atan2(point[1] - live.frame.center[1], point[0] - live.frame.center[0]),
|
||||
startDistance: Math.max(
|
||||
Math.hypot(point[0] - live.frame.center[0], point[1] - live.frame.center[1]),
|
||||
1
|
||||
),
|
||||
scaleX: live.frame.scaleX,
|
||||
scaleY: live.frame.scaleY
|
||||
}
|
||||
attachDrag(event)
|
||||
}
|
||||
|
||||
const move = (event: PointerEvent) => {
|
||||
const interaction = drag.value
|
||||
if (!interaction) return
|
||||
if (interaction.kind === 'arcball') {
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
const next = rotatePoseWithArcball(
|
||||
interaction.pose,
|
||||
interaction.start,
|
||||
point,
|
||||
interaction.radius
|
||||
)
|
||||
if (poseSession) previewManipulation(poseSession, next, commitPose)
|
||||
return
|
||||
}
|
||||
if (interaction.kind === 'eye') {
|
||||
const point = localOf(event)
|
||||
if (!point) return
|
||||
const deltaX = point[0] - interaction.start[0]
|
||||
const deltaY = point[1] - interaction.start[1]
|
||||
const along = (axis: LocalPoint) => deltaX * axis[0] + deltaY * axis[1]
|
||||
const start = interaction.eyes
|
||||
const side = interaction.side
|
||||
const current = start[side]
|
||||
let next = start
|
||||
if (interaction.handle === 'width') {
|
||||
next = updateEye(
|
||||
start,
|
||||
side,
|
||||
{
|
||||
width: Math.max(10, Math.min(100, current.width + along(interaction.widthAxis) * 2))
|
||||
},
|
||||
props.manual.links
|
||||
)
|
||||
} else if (interaction.handle === 'height') {
|
||||
next = updateEye(
|
||||
start,
|
||||
side,
|
||||
{
|
||||
height: Math.max(10, Math.min(100, current.height + along(interaction.heightAxis) * 2))
|
||||
},
|
||||
props.manual.links
|
||||
)
|
||||
} else if (interaction.handle === 'size') {
|
||||
const distance = Math.hypot(point[0] - interaction.center[0], point[1] - interaction.center[1])
|
||||
next = scaleEye(start, side, distance / interaction.startDistance, props.manual.links.size)
|
||||
} else if (interaction.handle === 'spacing') {
|
||||
next = clampEyes({
|
||||
...start,
|
||||
spacing: start.spacing + along(interaction.spacingAxis)
|
||||
})
|
||||
} else {
|
||||
const currentAngle = Math.atan2(point[1] - interaction.center[1], point[0] - interaction.center[0])
|
||||
const delta = Math.atan2(
|
||||
Math.sin(currentAngle - interaction.startAngle),
|
||||
Math.cos(currentAngle - interaction.startAngle)
|
||||
)
|
||||
next = updateEye(
|
||||
start,
|
||||
side,
|
||||
{ angle: current.angle + (delta * 180) / Math.PI },
|
||||
props.manual.links
|
||||
)
|
||||
}
|
||||
if (eyeSession) previewManipulation(eyeSession, next, commitEyes)
|
||||
return
|
||||
}
|
||||
const gizmoPoint = gizmoOf(event)
|
||||
if (!gizmoPoint) return
|
||||
if (interaction.kind === 'view') {
|
||||
const currentAngle = Math.atan2(gizmoPoint[1], gizmoPoint[0])
|
||||
const delta = Math.atan2(
|
||||
Math.sin(currentAngle - interaction.startAngle),
|
||||
Math.cos(currentAngle - interaction.startAngle)
|
||||
)
|
||||
const next = rotatePoseAroundCamera(interaction.pose, delta)
|
||||
if (poseSession) previewManipulation(poseSession, next, commitPose)
|
||||
return
|
||||
}
|
||||
const signed =
|
||||
(gizmoPoint[0] - interaction.start[0]) * interaction.tangent[0] +
|
||||
(gizmoPoint[1] - interaction.start[1]) * interaction.tangent[1]
|
||||
const next = rotatePoseAroundAxis(interaction.pose, interaction.axis, signed * 1.5)
|
||||
if (poseSession) previewManipulation(poseSession, next, commitPose)
|
||||
}
|
||||
|
||||
const finishPose = (outcome: 'commit' | 'cancel') => {
|
||||
if (poseSession)
|
||||
finishManipulation(poseSession, outcome, { preview: commitPose, commit: commitPose })
|
||||
poseSession = null
|
||||
}
|
||||
|
||||
const finishEyes = (outcome: 'commit' | 'cancel') => {
|
||||
if (eyeSession)
|
||||
finishManipulation(eyeSession, outcome, { preview: commitEyes, commit: commitEyes })
|
||||
eyeSession = null
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (drag.value?.kind === 'eye') finishEyes('commit')
|
||||
else finishPose('commit')
|
||||
drag.value = null
|
||||
activeAxis.value = null
|
||||
setBodyCursor('grab')
|
||||
detachDrag()
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (drag.value?.kind === 'eye') finishEyes('cancel')
|
||||
else finishPose('cancel')
|
||||
drag.value = null
|
||||
activeAxis.value = null
|
||||
setBodyCursor('grab')
|
||||
detachDrag()
|
||||
}
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && drag.value) {
|
||||
event.stopPropagation()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
const onBodyDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
startArcball(event)
|
||||
}
|
||||
|
||||
const onEyeDown = (side: 'left' | 'right') => (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
emit('update:selected', side)
|
||||
}
|
||||
|
||||
const bindParts = () => {
|
||||
const parts = props.hero?.parts()
|
||||
if (!parts) return () => {}
|
||||
const left = parts.eyes[0]
|
||||
const right = parts.eyes[1]
|
||||
parts.body.style.cursor = 'grab'
|
||||
if (left) left.style.cursor = 'pointer'
|
||||
if (right) right.style.cursor = 'pointer'
|
||||
const onLeft = onEyeDown('left')
|
||||
const onRight = onEyeDown('right')
|
||||
parts.body.addEventListener('pointerdown', onBodyDown)
|
||||
left?.addEventListener('pointerdown', onLeft)
|
||||
right?.addEventListener('pointerdown', onRight)
|
||||
return () => {
|
||||
parts.body.style.cursor = ''
|
||||
if (left) left.style.cursor = ''
|
||||
if (right) right.style.cursor = ''
|
||||
parts.body.removeEventListener('pointerdown', onBodyDown)
|
||||
left?.removeEventListener('pointerdown', onLeft)
|
||||
right?.removeEventListener('pointerdown', onRight)
|
||||
}
|
||||
}
|
||||
|
||||
let unbind = () => {}
|
||||
let bound = false
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
syncOverlay()
|
||||
sampleEyes()
|
||||
paintWires()
|
||||
paintFocus()
|
||||
if (!bound && props.hero?.parts()) {
|
||||
unbind()
|
||||
unbind = bindParts()
|
||||
bound = true
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.hero,
|
||||
() => {
|
||||
unbind()
|
||||
bound = false
|
||||
unbind = bindParts()
|
||||
bound = !!props.hero?.parts()
|
||||
sampleEyes()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
raf = requestAnimationFrame(tick)
|
||||
syncOverlay()
|
||||
unbind = bindParts()
|
||||
bound = !!props.hero?.parts()
|
||||
sampleEyes()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onKey, true)
|
||||
cancelAnimationFrame(raf)
|
||||
stop()
|
||||
unbind()
|
||||
clearDecor()
|
||||
})
|
||||
|
||||
const resetHead = () => emit('update:pose', { ...DEFAULT_POSE })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="manualLayer" class="manual-layer">
|
||||
<svg
|
||||
class="manual-overlay"
|
||||
:style="overlayStyle"
|
||||
viewBox="-150 -150 300 300"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g v-if="editor" class="eye-editor">
|
||||
<path v-if="draggingHandle" class="selection-outline" :d="editor.frame.outline" />
|
||||
<path class="editor-guide" :d="editor.widthGuide" />
|
||||
<path class="editor-guide" :d="editor.heightGuide" />
|
||||
<path class="editor-guide" :d="editor.rotationGuide" />
|
||||
<path class="editor-guide" :d="editor.spacingGuide" />
|
||||
<g class="editor-control" data-eye-handle="width" @pointerdown="startHandle('width', $event)">
|
||||
<circle class="editor-handle" :cx="editor.frame.widthHandle[0]" :cy="editor.frame.widthHandle[1]" r="5.5" />
|
||||
<text class="editor-label" :x="editor.frame.widthHandle[0]" :y="editor.frame.widthHandle[1] + 2.6">L</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="height" @pointerdown="startHandle('height', $event)">
|
||||
<circle class="editor-handle" :cx="editor.frame.heightHandle[0]" :cy="editor.frame.heightHandle[1]" r="5.5" />
|
||||
<text class="editor-label" :x="editor.frame.heightHandle[0]" :y="editor.frame.heightHandle[1] + 2.6">H</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="rotate" @pointerdown="startHandle('rotate', $event)">
|
||||
<circle class="editor-handle" :cx="editor.frame.rotateHandle[0]" :cy="editor.frame.rotateHandle[1]" r="5.5" />
|
||||
<text class="editor-label" :x="editor.frame.rotateHandle[0]" :y="editor.frame.rotateHandle[1] + 2.6">R</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="size" @pointerdown="startHandle('size', $event)">
|
||||
<rect
|
||||
class="editor-handle"
|
||||
:x="editor.frame.sizeHandle[0] - 5"
|
||||
:y="editor.frame.sizeHandle[1] - 5"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
/>
|
||||
<text class="editor-label" :x="editor.frame.sizeHandle[0]" :y="editor.frame.sizeHandle[1] + 2.6">S</text>
|
||||
</g>
|
||||
<g class="editor-control" data-eye-handle="spacing" @pointerdown="startHandle('spacing', $event)">
|
||||
<rect
|
||||
class="editor-handle"
|
||||
:x="editor.spacingHandle[0] - 5"
|
||||
:y="editor.spacingHandle[1] - 5"
|
||||
width="10"
|
||||
height="10"
|
||||
rx="2"
|
||||
/>
|
||||
<text class="editor-label" :x="editor.spacingHandle[0]" :y="editor.spacingHandle[1] + 2.6">E</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="gizmo-cluster">
|
||||
<svg ref="gizmoSvg" class="gizmo" viewBox="-43 -43 86 86" :aria-label="t('manual.gizmo')">
|
||||
<g class="gizmo-back" aria-hidden="true">
|
||||
<path v-for="(d, i) in rings.y.back" :key="'yb' + i" class="gizmo-orbit gizmo-y" :d="d" />
|
||||
<path v-for="(d, i) in rings.x.back" :key="'xb' + i" class="gizmo-orbit gizmo-x" :d="d" />
|
||||
<path v-for="(d, i) in rings.z.back" :key="'zb' + i" class="gizmo-orbit gizmo-z" :d="d" />
|
||||
</g>
|
||||
<circle class="gizmo-hit gizmo-camera-hit" cx="0" cy="0" r="38" @pointerdown="startView" />
|
||||
<circle
|
||||
class="gizmo-orbit gizmo-camera"
|
||||
:class="{ 'is-active': activeAxis === 'view' }"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="38"
|
||||
/>
|
||||
<g class="gizmo-front">
|
||||
<g v-for="(d, i) in rings.y.front" :key="'yf' + i" class="gizmo-arc">
|
||||
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('y', $event)" />
|
||||
<path class="gizmo-orbit gizmo-y" :class="{ 'is-active': activeAxis === 'y' }" :d="d" />
|
||||
</g>
|
||||
<g v-for="(d, i) in rings.x.front" :key="'xf' + i" class="gizmo-arc">
|
||||
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('x', $event)" />
|
||||
<path class="gizmo-orbit gizmo-x" :class="{ 'is-active': activeAxis === 'x' }" :d="d" />
|
||||
</g>
|
||||
<g v-for="(d, i) in rings.z.front" :key="'zf' + i" class="gizmo-arc">
|
||||
<path class="gizmo-hit" :d="d" @pointerdown="startAxis('z', $event)" />
|
||||
<path class="gizmo-orbit gizmo-z" :class="{ 'is-active': activeAxis === 'z' }" :d="d" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<button type="button" class="gizmo-reset" :aria-label="t('manual.reset')" @click="resetHead">
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
<div class="axis-key" aria-hidden="true">
|
||||
<i class="x" />X <i class="y" />Y <i class="z" />Z
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user