feat:按形体画导线并点空白取消选眼
导线从球体抽成独立曲面模块,按 blob/bean/egg 等形体采样。 点空白处取消眼睛选中并回到身体。
This commit is contained in:
@@ -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 })"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user