feat:按形体画导线并点空白取消选眼

导线从球体抽成独立曲面模块,按 blob/bean/egg 等形体采样。
点空白处取消眼睛选中并回到身体。
This commit is contained in:
Edom
2026-08-20 21:29:26 +08:00
parent e880350f62
commit ba78973e51
4 changed files with 313 additions and 48 deletions
+1
View File
@@ -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 })"
+25 -3
View File
@@ -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()
+9 -45
View File
@@ -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)
}
+278
View File
@@ -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<string, Kind> = {
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<string, SurfaceConfig>()
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<string, Sample[][]>()
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)