diff --git a/src/components/AvatarStage.vue b/src/components/AvatarStage.vue index 458e618..2a32e9e 100644 --- a/src/components/AvatarStage.vue +++ b/src/components/AvatarStage.vue @@ -157,6 +157,7 @@ defineExpose({ v-if="active && manual" :manual="manual" :hero="liveHero" + :shape="shape" @update:pose="(pose: HeadPose) => patch({ pose })" @update:selected="(selected: ManualPart | null) => patch({ selected })" @update:eyes="(eyes: EyeTune) => patch({ eyes })" diff --git a/src/components/ManualCanvas.vue b/src/components/ManualCanvas.vue index 21828f0..ef64531 100644 --- a/src/components/ManualCanvas.vue +++ b/src/components/ManualCanvas.vue @@ -13,10 +13,10 @@ import { rotationRing, splitRingArcs, unitVector, - wirePaths, type HeadPose, type Point3 } from '@/ui/pose/math' +import { surfaceOf, wirePaths } from '@/ui/pose/surface' import { clampEyes, clampPose, @@ -49,6 +49,7 @@ export type HeroHandle = { const props = defineProps<{ manual: ManualState hero: HeroHandle | null + shape?: string }>() const emit = defineEmits<{ @@ -369,7 +370,9 @@ const paintWires = () => { const group = ensureWires() if (!group) return const radius = engineRadius() - const paths = showWire.value ? wirePaths(pose.value, radius, [radius, radius]) : [] + const paths = showWire.value + ? wirePaths(pose.value, surfaceOf(props.shape || 'blob'), [radius, radius]) + : [] const ns = 'http://www.w3.org/2000/svg' while (group.childNodes.length > paths.length) group.lastChild?.remove() paths.forEach((d, index) => { @@ -438,10 +441,21 @@ const setBodyCursor = (cursor: string) => { parts.body.style.cursor = cursor } +const selectBody = () => { + if (selected.value !== 'body') emit('update:selected', 'body') +} + +const isEyeTarget = (target: EventTarget | null) => { + if (!(target instanceof Element)) return false + if (target.closest('.editor-control')) return true + const eyes = props.hero?.parts()?.eyes + return !!eyes?.some((el) => el === target || el.contains(target)) +} + const startArcball = (event: PointerEvent) => { const point = localOf(event) if (!point) return - if (selected.value !== 'left' && selected.value !== 'right') emit('update:selected', 'body') + selectBody() poseSession = beginManipulation(pose.value) drag.value = { kind: 'arcball', @@ -659,6 +673,12 @@ const onEyeDown = (side: 'left' | 'right') => (event: PointerEvent) => { emit('update:selected', side) } +const onBlankDown = (event: PointerEvent) => { + if (event.button !== 0) return + if (isEyeTarget(event.target)) return + if (selected.value === 'left' || selected.value === 'right') selectBody() +} + const bindParts = () => { const parts = props.hero?.parts() if (!parts) return () => {} @@ -711,6 +731,7 @@ watch( onMounted(() => { window.addEventListener('keydown', onKey, true) + window.addEventListener('pointerdown', onBlankDown, true) raf = requestAnimationFrame(tick) syncOverlay() unbind = bindParts() @@ -720,6 +741,7 @@ onMounted(() => { onBeforeUnmount(() => { window.removeEventListener('keydown', onKey, true) + window.removeEventListener('pointerdown', onBlankDown, true) cancelAnimationFrame(raf) stop() unbind() diff --git a/src/ui/pose/math.ts b/src/ui/pose/math.ts index 56cf748..62924de 100644 --- a/src/ui/pose/math.ts +++ b/src/ui/pose/math.ts @@ -225,29 +225,15 @@ export const unitVector = ( return [x / length, y / length] } -const polyline = (points: Point3[]) => { - if (points.length < 2) 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('')}` -} - -const visibleWire = (points: Point3[]) => { - const segments: Point3[][] = [] - let segment: Point3[] = [] - for (const point of points) { - if (point[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(polyline) - .join('') +export const projectLocalPoint = ( + pose: HeadPose, + local: Point3, + origin: readonly [number, number] = [0, 0], + home: HeadPose = POSE_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])[] => { @@ -332,25 +318,3 @@ export const projectFacePoint = ( rotated[2] ] } - -export const wirePaths = ( - pose: HeadPose, - radius: number, - origin: readonly [number, number] = [0, 0] -): string[] => { - const project = (longitude: number, latitude: number): Point3 => - projectFacePoint( - pose, - longitude * radius, - latitude * radius, - radius, - origin - ) - const parallels = [-60, -30, 0, 30, 60].map((latitude) => - Array.from({ length: 73 }, (_, index) => project(radians(-180 + index * 5), radians(latitude))) - ) - const meridians = Array.from({ length: 12 }, (_, index) => -150 + index * 30).map((longitude) => - Array.from({ length: 37 }, (_, step) => project(radians(longitude), radians(-90 + step * 5))) - ) - return [...parallels, ...meridians].map(visibleWire).filter(Boolean) -} diff --git a/src/ui/pose/surface.ts b/src/ui/pose/surface.ts new file mode 100644 index 0000000..c0319c5 --- /dev/null +++ b/src/ui/pose/surface.ts @@ -0,0 +1,278 @@ +import { + projectLocalPoint, + radians, + rotateWithQuaternion, + screenOrientation, + type HeadPose, + type Point3 +} from './math' + +export type SurfaceType = 'sphere' | 'ellipsoid' | 'capsule' | 'cube' | 'cone' | 'diamond' + +export type SurfaceConfig = { + type: SurfaceType + width: number + height: number + depth: number + roundness: number +} + +type Kind = { type: SurfaceType; roundness?: number; depthScale?: number } + +const KIND: Record = { + blob: { type: 'sphere' }, + bean: { type: 'capsule' }, + egg: { type: 'ellipsoid' }, + teardrop: { type: 'cone', roundness: 0.7 }, + cloud: { type: 'ellipsoid', depthScale: 0.58 }, + leaf: { type: 'diamond', roundness: 0.35 }, + squircle: { type: 'cube', roundness: 0.55 }, + capsule: { type: 'capsule' } +} + +type Sample = { point: Point3; normal: Point3 } + +const signedPow = (value: number, exp: number) => Math.sign(value) * Math.abs(value) ** exp + +const normalize = ([x, y, z]: Point3): Point3 => { + const length = Math.hypot(x, y, z) || 1 + return [x / length, y / length, z / length] +} + +const measure = (id: string): { width: number; height: number } => { + const geo = window.GROK_GEO + const shape = geo?.shapes?.[id] as + | { path?: string; radius?: number; top?: number; bottom?: number } + | undefined + const Re = geo?.Re ?? 114.27 + const flatten = ( + window as unknown as { GROK_MATH?: { flattenPath?: (d: string, step?: number) => number[][] } } + ).GROK_MATH?.flattenPath + if (shape?.path && flatten) { + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const pair of flatten(shape.path, 8)) { + const x = pair[0]! + const y = pair[1]! + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + if (Number.isFinite(minX)) return { width: maxX - minX, height: maxY - minY } + } + const height = + shape?.bottom != null && shape.top != null ? shape.bottom - shape.top : Re * 2 + return { width: (shape?.radius ?? Re) * 2, height } +} + +const surfaceCache = new Map() + +export const surfaceOf = (shape: string): SurfaceConfig => { + const hit = surfaceCache.get(shape) + if (hit) return hit + const kind = KIND[shape] ?? { type: 'ellipsoid' as const } + const { width, height } = measure(shape) + const depth = width * (kind.depthScale ?? (kind.type === 'cube' ? 0.9 : 1)) + const surface: SurfaceConfig = { + type: kind.type, + width, + height, + depth, + roundness: kind.roundness ?? 1 + } + surfaceCache.set(shape, surface) + return surface +} + +const cubeExp = (roundness: number) => { + if (roundness <= 0) return Infinity + const power = 0.04 + (Math.min(2, roundness) / 2) * 0.96 + return 2 / power +} + +const diamondExp = (roundness: number) => 1 + Math.min(2, Math.max(0, roundness)) / 2 + +const lpPoint = (config: SurfaceConfig, lon: number, lat: number, exp: number): Point3 => { + const sx = Math.cos(lat) * Math.sin(lon) + const sy = Math.sin(lat) + const sz = Math.cos(lat) * Math.cos(lon) + const length = Number.isFinite(exp) + ? (Math.abs(sx) ** exp + Math.abs(sy) ** exp + Math.abs(sz) ** exp) ** (1 / exp) || 1 + : Math.max(Math.abs(sx), Math.abs(sy), Math.abs(sz)) || 1 + return [(config.width / 2) * (sx / length), (config.height / 2) * (sy / length), (config.depth / 2) * (sz / length)] +} + +const ellipsoid = (config: SurfaceConfig, lon: number, lat: number): Point3 => [ + (config.width / 2) * Math.cos(lat) * Math.sin(lon), + (config.height / 2) * Math.sin(lat), + (config.depth / 2) * Math.cos(lat) * Math.cos(lon) +] + +const capsule = (config: SurfaceConfig, lon: number, lat: number): Point3 => { + const rx = config.width / 2 + const rz = config.depth / 2 + const cap = Math.min(rx, config.height / 2) + const mid = Math.max(0, config.height / 2 - cap) + const meridian = mid * 2 + Math.PI * cap + const distance = ((lat + Math.PI / 2) / Math.PI) * meridian + let radial = rx + let y = 0 + if (distance < (Math.PI * cap) / 2) { + const angle = -Math.PI / 2 + distance / cap + radial = rx * Math.cos(angle) + y = -mid + cap * Math.sin(angle) + } else if (distance <= (Math.PI * cap) / 2 + mid * 2) { + y = -mid + distance - (Math.PI * cap) / 2 + } else { + const angle = (distance - (Math.PI * cap) / 2 - mid * 2) / cap + radial = rx * Math.cos(angle) + y = mid + cap * Math.sin(angle) + } + return [radial * Math.sin(lon), y, radial * (rx ? rz / rx : 1) * Math.cos(lon)] +} + +const cone = (config: SurfaceConfig, lon: number, lat: number): Point3 => { + const t = (lat + Math.PI / 2) / Math.PI + const tip = 0.16 * config.roundness + const base = 0.28 * config.roundness + let radiusScale: number + let yT = t + if (tip > 0 && t < tip) { + const a = (t / tip) * (Math.PI / 2) + radiusScale = tip * Math.sin(a) + yT = tip * (1 - Math.cos(a)) + } else if (base > 0 && t > 1 - base) { + const a = ((t - (1 - base)) / base) * (Math.PI / 2) + radiusScale = 1 - base + base * Math.cos(a) + yT = 1 - base + base * Math.sin(a) + } else { + const span = 1 - tip - base + const u = span > 0 ? (t - tip) / span : 0 + radiusScale = tip + (1 - base - tip) * u + } + return [ + (config.width / 2) * radiusScale * Math.sin(lon), + -config.height / 2 + config.height * yT, + (config.depth / 2) * radiusScale * Math.cos(lon) + ] +} + +export const surfacePointAt = (config: SurfaceConfig, lon: number, lat: number): Point3 => { + switch (config.type) { + case 'sphere': + case 'ellipsoid': + return ellipsoid(config, lon, lat) + case 'capsule': + return capsule(config, lon, lat) + case 'cube': + return lpPoint(config, lon, lat, cubeExp(config.roundness)) + case 'diamond': + return lpPoint(config, lon, lat, diamondExp(config.roundness)) + case 'cone': + return cone(config, lon, lat) + } +} + +const lpNormal = (config: SurfaceConfig, point: Point3, exp: number): Point3 => { + const rx = config.width / 2 || 1 + const ry = config.height / 2 || 1 + const rz = config.depth / 2 || 1 + if (!Number.isFinite(exp)) { + const n: Point3 = [point[0] / rx, point[1] / ry, point[2] / rz] + const axis = n.reduce((i, v, j, a) => (Math.abs(v) > Math.abs(a[i]!) ? j : i), 0) + return normalize([axis === 0 ? Math.sign(n[0]) : 0, axis === 1 ? Math.sign(n[1]) : 0, axis === 2 ? Math.sign(n[2]) : 0]) + } + return normalize([ + signedPow(point[0] / rx, exp - 1) / rx, + signedPow(point[1] / ry, exp - 1) / ry, + signedPow(point[2] / rz, exp - 1) / rz + ]) +} + +const tangentNormal = (config: SurfaceConfig, lon: number, lat: number): Point3 => { + const eps = 0.0006 + const beforeLon = surfacePointAt(config, lon - eps, lat) + const afterLon = surfacePointAt(config, lon + eps, lat) + const beforeLat = surfacePointAt(config, lon, Math.max(-Math.PI / 2, lat - eps)) + const afterLat = surfacePointAt(config, lon, Math.min(Math.PI / 2, lat + eps)) + const dx: Point3 = [afterLon[0] - beforeLon[0], afterLon[1] - beforeLon[1], afterLon[2] - beforeLon[2]] + const dy: Point3 = [afterLat[0] - beforeLat[0], afterLat[1] - beforeLat[1], afterLat[2] - beforeLat[2]] + return normalize([ + dx[1] * dy[2] - dx[2] * dy[1], + dx[2] * dy[0] - dx[0] * dy[2], + dx[0] * dy[1] - dx[1] * dy[0] + ]) +} + +const sampleAt = (config: SurfaceConfig, lon: number, lat: number): Sample => { + const point = surfacePointAt(config, lon, lat) + if (config.type === 'sphere' || config.type === 'ellipsoid') { + const rx = config.width / 2 || 1 + const ry = config.height / 2 || 1 + const rz = config.depth / 2 || 1 + return { + point, + normal: normalize([point[0] / (rx * rx), point[1] / (ry * ry), point[2] / (rz * rz)]) + } + } + if (config.type === 'cube') return { point, normal: lpNormal(config, point, cubeExp(config.roundness)) } + if (config.type === 'diamond') return { point, normal: lpNormal(config, point, diamondExp(config.roundness)) } + return { point, normal: tangentNormal(config, lon, lat) } +} + +const keyOf = (surface: SurfaceConfig) => + `${surface.type}:${surface.width.toFixed(2)}:${surface.height.toFixed(2)}:${surface.depth.toFixed(2)}:${surface.roundness}` + +const cache = new Map() + +const curvesOf = (surface: SurfaceConfig): Sample[][] => { + const key = keyOf(surface) + const hit = cache.get(key) + if (hit) return hit + const parallels = [-60, -30, 0, 30, 60].map((lat) => + Array.from({ length: 73 }, (_, i) => sampleAt(surface, radians(-180 + i * 5), radians(lat))) + ) + const meridians = Array.from({ length: 12 }, (_, i) => -150 + i * 30).map((lon) => + Array.from({ length: 37 }, (_, step) => sampleAt(surface, radians(lon), radians(-90 + step * 5))) + ) + const curves = [...parallels, ...meridians] + if (cache.size > 24) cache.delete(cache.keys().next().value!) + cache.set(key, curves) + return curves +} + +const polyline = (points: Point3[]) => { + if (points.length < 2) 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('')}` +} + +const visibleWire = (samples: Sample[], pose: HeadPose, origin: readonly [number, number]) => { + const orientation = screenOrientation(pose) + const segments: Point3[][] = [] + let segment: Point3[] = [] + for (const sample of samples) { + const facing = rotateWithQuaternion(orientation, sample.normal)[2] > 0 + if (facing) segment.push(projectLocalPoint(pose, sample.point, origin)) + else if (segment.length) { + segments.push(segment) + segment = [] + } + } + if (segment.length) segments.push(segment) + return segments + .filter((item) => item.length > 1) + .map(polyline) + .join('') +} + +export const wirePaths = ( + pose: HeadPose, + surface: SurfaceConfig, + origin: readonly [number, number] = [0, 0] +): string[] => curvesOf(surface).map((curve) => visibleWire(curve, pose, origin)).filter(Boolean)