822 lines
26 KiB
Vue
822 lines
26 KiB
Vue
<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>
|