- add the v1 AvatarDefinition schema, bounded parser, strict validation, semantic catalog, pure geometry, scene generation, and deterministic playback to @bible-strong/avatar-core - add the React 19 renderer with semantic controls, SSR-safe embedded/floating layouts, direct frame updates, pointer and keyboard movement, constraints, callbacks, and accessible controls - add real tarball packaging and a clean React/Vite consumer using the exported Strobi definition - add Studio semantic-key authoring, runtime readiness, JSON download/copy, bundled-key recovery, concise errors, targeted local-project clearing, npm guidance, syntax highlighting, and a runnable package preview - preserve the historical ZIP and Studio project exports, regenerate the standalone engine, and synchronize English, French, and Simplified Chinese copy - add focused contract, playback, renderer, interaction, persistence, export, and localization coverage - archive the completed runtime and semantic-curation specs, retain the Vite performance draft, and record the engineering session in TIMELOG.md Validation: - pnpm check: 19 test files, 162 tests, typecheck, engine freshness, package builds, and Studio production build passed - npm pack dry runs: 30 core files and 10 React files - package smoke: real tarballs installed, typechecked, and built outside the workspace - browser checks: semantic playback, embedded/floating render, drag, mobile overflow, runtime export recovery, formatted copy, syntax colors, and live package preview Publication remains disabled: both packages stay private and AGPL-3.0-only pending licensing and repository metadata approval. Vue and Angular adapters remain deferred.
666 lines
21 KiB
TypeScript
666 lines
21 KiB
TypeScript
import type { Point3 } from './geometry'
|
|
|
|
export type SurfaceType =
|
|
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
|
|
|
|
export type SurfaceConfig = {
|
|
type: SurfaceType
|
|
width: number
|
|
height: number
|
|
depth: number
|
|
roundness: number
|
|
morphRoundness?: number
|
|
tipRoundness?: number
|
|
baseRoundness?: number
|
|
}
|
|
|
|
export type SurfaceSample = {
|
|
point: Point3
|
|
normal: Point3
|
|
}
|
|
|
|
export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
|
sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
|
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
|
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
|
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
|
|
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
|
|
cylinder: {
|
|
type: 'cylinder',
|
|
width: 235,
|
|
height: 250,
|
|
depth: 215,
|
|
roundness: 0.45,
|
|
morphRoundness: 0,
|
|
},
|
|
cone: {
|
|
type: 'cone',
|
|
width: 250,
|
|
height: 265,
|
|
depth: 225,
|
|
roundness: 0,
|
|
morphRoundness: 0,
|
|
tipRoundness: 0.55,
|
|
baseRoundness: 0.45,
|
|
},
|
|
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
|
}
|
|
|
|
export const surfaceLabels: Record<SurfaceType, string> = {
|
|
sphere: 'Sphère',
|
|
mickey: 'Mickey',
|
|
cursor: 'Curseur',
|
|
cube: 'Cube',
|
|
capsule: 'Capsule',
|
|
cylinder: 'Cylindre',
|
|
cone: 'Cône',
|
|
diamond: 'Diamant',
|
|
}
|
|
|
|
const signedPower = (value: number, exponent: number) =>
|
|
Math.sign(value) * Math.abs(value) ** exponent
|
|
|
|
const superellipsoid = (
|
|
longitude: number,
|
|
latitude: number,
|
|
width: number,
|
|
height: number,
|
|
depth: number,
|
|
verticalExponent: number,
|
|
horizontalExponent: number
|
|
): Point3 => {
|
|
const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
|
|
return [
|
|
(width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
|
|
(height / 2) * signedPower(Math.sin(latitude), verticalExponent),
|
|
(depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
|
|
]
|
|
}
|
|
|
|
const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
|
const radiusX = config.width / 2
|
|
const radiusZ = config.depth / 2
|
|
const capRadius = Math.min(radiusX, config.height / 2)
|
|
const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
|
|
const meridianLength = straightHalf * 2 + Math.PI * capRadius
|
|
const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
|
|
let radial = radiusX
|
|
let y = 0
|
|
|
|
if (distance < (Math.PI * capRadius) / 2) {
|
|
const angle = -Math.PI / 2 + distance / capRadius
|
|
radial = radiusX * Math.cos(angle)
|
|
y = -straightHalf + capRadius * Math.sin(angle)
|
|
} else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
|
|
y = -straightHalf + distance - (Math.PI * capRadius) / 2
|
|
} else {
|
|
const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
|
|
radial = radiusX * Math.cos(angle)
|
|
y = straightHalf + capRadius * Math.sin(angle)
|
|
}
|
|
|
|
const depthScale = radiusX ? radiusZ / radiusX : 1
|
|
return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
|
|
}
|
|
|
|
const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
|
|
|
|
const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
|
|
|
|
const MIN_CUBE_SURFACE_POWER = 0.04
|
|
|
|
const cubeExponent = (config: SurfaceConfig) => {
|
|
if (config.roundness <= 0) return Infinity
|
|
// The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
|
|
const surfacePower =
|
|
MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
|
|
return 2 / surfacePower
|
|
}
|
|
|
|
const lpSurface = (
|
|
config: SurfaceConfig,
|
|
longitude: number,
|
|
latitude: number,
|
|
exponent: number
|
|
): Point3 => {
|
|
const sphereX = Math.cos(latitude) * Math.sin(longitude)
|
|
const sphereY = Math.sin(latitude)
|
|
const sphereZ = Math.cos(latitude) * Math.cos(longitude)
|
|
const length = Number.isFinite(exponent)
|
|
? (Math.abs(sphereX) ** exponent +
|
|
Math.abs(sphereY) ** exponent +
|
|
Math.abs(sphereZ) ** exponent) **
|
|
(1 / exponent) || 1
|
|
: Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
|
|
return [
|
|
(config.width / 2) * (sphereX / length),
|
|
(config.height / 2) * (sphereY / length),
|
|
(config.depth / 2) * (sphereZ / length),
|
|
]
|
|
}
|
|
|
|
const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
|
return lpSurface(config, longitude, latitude, diamondExponent(config))
|
|
}
|
|
|
|
const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
|
|
lpSurface(config, longitude, latitude, cubeExponent(config))
|
|
|
|
const MAX_CONE_TIP_FRACTION = 0.24
|
|
const MAX_CONE_BASE_FRACTION = 0.2
|
|
const MAX_CYLINDER_EDGE_FRACTION = 0.22
|
|
|
|
type RadialProfile = {
|
|
radiusScale: number
|
|
verticalProgress: number
|
|
}
|
|
|
|
const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
|
|
|
|
const morphProfileToEllipsoid = (
|
|
config: SurfaceConfig,
|
|
progress: number,
|
|
profile: RadialProfile
|
|
): RadialProfile => {
|
|
const amount = morphProgress(config)
|
|
const clampedProgress = Math.max(0, Math.min(1, progress))
|
|
const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
|
|
const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
|
|
return {
|
|
radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
|
|
verticalProgress:
|
|
profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
|
|
}
|
|
}
|
|
|
|
const cubic = (
|
|
start: number,
|
|
firstControl: number,
|
|
secondControl: number,
|
|
end: number,
|
|
progress: number
|
|
) => {
|
|
const inverse = 1 - progress
|
|
return (
|
|
inverse ** 3 * start +
|
|
3 * inverse * inverse * progress * firstControl +
|
|
3 * inverse * progress * progress * secondControl +
|
|
progress ** 3 * end
|
|
)
|
|
}
|
|
|
|
const coneRounding = (config: SurfaceConfig) => ({
|
|
tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
|
|
baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
|
|
})
|
|
|
|
/** Cylinder half-profile with a quarter-round transition at both caps. */
|
|
const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
|
const clampedProgress = Math.max(0, Math.min(1, progress))
|
|
const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
|
|
if (edgeFraction <= 0) {
|
|
return {
|
|
radiusScale: 1,
|
|
verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
|
|
}
|
|
}
|
|
|
|
if (clampedProgress < edgeFraction) {
|
|
const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
|
|
return {
|
|
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
|
verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
|
|
}
|
|
}
|
|
|
|
if (clampedProgress > 1 - edgeFraction) {
|
|
const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
|
|
return {
|
|
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
|
verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
|
|
}
|
|
}
|
|
|
|
const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
|
|
return {
|
|
radiusScale: 1,
|
|
verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
|
|
}
|
|
}
|
|
|
|
const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
|
|
morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
|
|
|
|
const radiusScaleAtVerticalProgress = (
|
|
config: SurfaceConfig,
|
|
verticalProgress: number,
|
|
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
|
|
) => {
|
|
const progress = Math.max(0, Math.min(1, verticalProgress))
|
|
let lower = 0
|
|
let upper = 1
|
|
for (let iteration = 0; iteration < 14; iteration += 1) {
|
|
const candidate = (lower + upper) / 2
|
|
if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
|
|
else upper = candidate
|
|
}
|
|
return profileAt(config, (lower + upper) / 2).radiusScale
|
|
}
|
|
|
|
/** Rounded half-profile revolved around the cone's vertical axis. */
|
|
const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
|
const clampedProgress = Math.max(0, Math.min(1, progress))
|
|
const { tipFraction, baseFraction } = coneRounding(config)
|
|
|
|
if (baseFraction > 0 && clampedProgress < baseFraction) {
|
|
const curveProgress = clampedProgress / baseFraction
|
|
return {
|
|
radiusScale: cubic(
|
|
1 - baseFraction,
|
|
1,
|
|
1 - baseFraction / 2,
|
|
1 - baseFraction,
|
|
curveProgress
|
|
),
|
|
verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
|
|
}
|
|
}
|
|
|
|
if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
|
|
const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
|
|
return {
|
|
radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
|
|
verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
|
|
}
|
|
}
|
|
|
|
return {
|
|
radiusScale: 1 - clampedProgress,
|
|
verticalProgress: clampedProgress,
|
|
}
|
|
}
|
|
|
|
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
|
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
|
|
|
|
export const cursorLayout = (config: SurfaceConfig) => {
|
|
const coneHeight = config.height * 0.36
|
|
const bodyHeight = config.height - coneHeight
|
|
return {
|
|
coneApexY: -config.height / 2,
|
|
coneBaseY: -config.height / 2 + coneHeight,
|
|
bodyHeight,
|
|
bodyCenterY: config.height / 2 - bodyHeight / 2,
|
|
bodyWidth: config.width * 0.54,
|
|
bodyDepth: config.depth * 0.62,
|
|
}
|
|
}
|
|
|
|
export const surfacePointAt = (
|
|
config: SurfaceConfig,
|
|
longitude: number,
|
|
latitude: number
|
|
): Point3 => {
|
|
const { width, height, depth } = config
|
|
switch (config.type) {
|
|
case 'sphere':
|
|
case 'mickey':
|
|
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
|
|
case 'cube':
|
|
return cube(config, longitude, latitude)
|
|
case 'cylinder': {
|
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
|
const profile = morphedCylinderProfileAt(config, progress)
|
|
return [
|
|
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
|
-height / 2 + height * profile.verticalProgress,
|
|
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
|
]
|
|
}
|
|
case 'cursor': {
|
|
const layout = cursorLayout(config)
|
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
|
const bodyConfig = {
|
|
...config,
|
|
width: layout.bodyWidth,
|
|
height: layout.bodyHeight,
|
|
depth: layout.bodyDepth,
|
|
}
|
|
const profile = cylinderProfileAt(bodyConfig, progress)
|
|
return [
|
|
(layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
|
|
layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
|
|
(layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
|
|
]
|
|
}
|
|
case 'diamond':
|
|
return diamond(config, longitude, latitude)
|
|
case 'capsule':
|
|
return capsule(config, longitude, latitude)
|
|
case 'cone': {
|
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
|
const profile = morphedConeProfileAt(config, progress)
|
|
return [
|
|
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
|
height / 2 - height * profile.verticalProgress,
|
|
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
const subtract = (left: Point3, right: Point3): Point3 => [
|
|
left[0] - right[0],
|
|
left[1] - right[1],
|
|
left[2] - right[2],
|
|
]
|
|
|
|
const normalize = ([x, y, z]: Point3): Point3 => {
|
|
const length = Math.hypot(x, y, z) || 1
|
|
return [x / length, y / length, z / length]
|
|
}
|
|
|
|
const normalFromTangents = (
|
|
config: SurfaceConfig,
|
|
longitudeTangent: Point3,
|
|
latitudeTangent: Point3
|
|
) => {
|
|
const orientation = config.type === 'cone' ? -1 : 1
|
|
return normalize([
|
|
orientation *
|
|
(longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
|
|
orientation *
|
|
(longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
|
|
orientation *
|
|
(longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
|
|
])
|
|
}
|
|
|
|
const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
|
|
const epsilon = 0.0005
|
|
if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
|
|
const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
|
|
const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
|
|
const latitudeBefore = surfacePointAt(
|
|
config,
|
|
longitude,
|
|
Math.max(-Math.PI / 2, latitude - epsilon)
|
|
)
|
|
const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
|
|
return normalFromTangents(
|
|
config,
|
|
subtract(longitudeAfter, longitudeBefore),
|
|
subtract(latitudeAfter, latitudeBefore)
|
|
)
|
|
}
|
|
|
|
const signedMagnitude = (value: number, exponent: number) =>
|
|
Math.sign(value) * Math.abs(value) ** exponent
|
|
|
|
const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
|
|
const radiusX = config.width / 2 || 1
|
|
const radiusY = config.height / 2 || 1
|
|
const radiusZ = config.depth / 2 || 1
|
|
return normalize([
|
|
signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
|
|
signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
|
|
signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
|
|
])
|
|
}
|
|
|
|
const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
|
|
lpNormal(config, point, diamondExponent(config))
|
|
|
|
const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
|
|
const exponent = cubeExponent(config)
|
|
if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
|
|
|
|
const normalized = [
|
|
point[0] / (config.width / 2 || 1),
|
|
point[1] / (config.height / 2 || 1),
|
|
point[2] / (config.depth / 2 || 1),
|
|
] as Point3
|
|
const dominantAxis = normalized.reduce(
|
|
(largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
|
|
0
|
|
)
|
|
const normal: Point3 = [
|
|
dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
|
|
dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
|
|
dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
|
|
]
|
|
return normal
|
|
}
|
|
|
|
const lpFrontSample = (
|
|
config: SurfaceConfig,
|
|
x: number,
|
|
y: number,
|
|
exponent: number,
|
|
normalAt: (config: SurfaceConfig, point: Point3) => Point3
|
|
): SurfaceSample => {
|
|
const radiusX = config.width / 2 || 1
|
|
const radiusY = config.height / 2 || 1
|
|
const radiusZ = config.depth / 2 || 1
|
|
if (!Number.isFinite(exponent)) {
|
|
const point: Point3 = [
|
|
Math.max(-radiusX, Math.min(radiusX, x)),
|
|
Math.max(-radiusY, Math.min(radiusY, y)),
|
|
radiusZ,
|
|
]
|
|
return { point, normal: normalAt(config, point) }
|
|
}
|
|
|
|
const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
|
|
const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
|
|
const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
|
|
const normalizedX = surfaceX / radiusX
|
|
const normalizedZ =
|
|
Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
|
|
(1 / exponent)
|
|
const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
|
|
return { point, normal: normalAt(config, point) }
|
|
}
|
|
|
|
const ellipsoidFrontSample = (
|
|
x: number,
|
|
y: number,
|
|
radiusX: number,
|
|
radiusY: number,
|
|
radiusZ: number,
|
|
centerY = 0
|
|
): SurfaceSample => {
|
|
const localY = y - centerY
|
|
const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
|
|
const z = radiusZ * Math.sqrt(remaining)
|
|
return {
|
|
point: [x, y, z],
|
|
normal: normalize([
|
|
x / (radiusX * radiusX || 1),
|
|
localY / (radiusY * radiusY || 1),
|
|
z / (radiusZ * radiusZ || 1),
|
|
]),
|
|
}
|
|
}
|
|
|
|
const radialProfileFrontSample = (
|
|
config: SurfaceConfig,
|
|
x: number,
|
|
y: number,
|
|
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
|
|
verticalDirection: -1 | 1
|
|
): SurfaceSample => {
|
|
const radiusX = config.width / 2 || 1
|
|
const radiusZ = config.depth / 2 || 1
|
|
const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
|
|
const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
|
|
const sectionRadiusX = radiusX * radialScale
|
|
const sectionRadiusZ = radiusZ * radialScale
|
|
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
|
|
const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
|
|
const z = sectionRadiusZ * Math.sqrt(remaining)
|
|
const derivativeStep = 0.0001
|
|
const previousProgress = Math.max(0, verticalProgress - derivativeStep)
|
|
const nextProgress = Math.min(1, verticalProgress + derivativeStep)
|
|
const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
|
|
const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
|
|
const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
|
|
const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
|
|
const depthRatio = radiusZ / radiusX
|
|
const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
|
|
const depthYDerivative =
|
|
(verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
|
|
return {
|
|
point: [surfaceX, y, z],
|
|
normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
|
|
}
|
|
}
|
|
|
|
/** Project canonical face coordinates onto a primitive's front-facing sheet. */
|
|
export const surfaceFrontSampleAt = (
|
|
config: SurfaceConfig,
|
|
x: number,
|
|
y: number
|
|
): SurfaceSample => {
|
|
const radiusX = config.width / 2 || 1
|
|
const radiusY = config.height / 2 || 1
|
|
const radiusZ = config.depth / 2 || 1
|
|
|
|
switch (config.type) {
|
|
case 'sphere':
|
|
case 'mickey':
|
|
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
|
|
|
|
case 'cube':
|
|
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
|
|
|
case 'capsule': {
|
|
const capRadiusY = Math.min(radiusX, radiusY)
|
|
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
|
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
|
return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
|
|
}
|
|
|
|
case 'cylinder':
|
|
return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
|
|
|
|
case 'cursor': {
|
|
const layout = cursorLayout(config)
|
|
const bodyConfig = {
|
|
...config,
|
|
width: layout.bodyWidth,
|
|
height: layout.bodyHeight,
|
|
depth: layout.bodyDepth,
|
|
}
|
|
const sample = radialProfileFrontSample(
|
|
bodyConfig,
|
|
x,
|
|
y - layout.bodyCenterY,
|
|
cylinderProfileAt,
|
|
1
|
|
)
|
|
return {
|
|
point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
|
|
normal: sample.normal,
|
|
}
|
|
}
|
|
|
|
case 'cone':
|
|
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
|
|
|
|
case 'diamond':
|
|
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
|
|
}
|
|
}
|
|
|
|
export const surfaceNormalAt = (
|
|
config: SurfaceConfig,
|
|
longitude: number,
|
|
latitude: number
|
|
): Point3 => {
|
|
const point = surfacePointAt(config, longitude, latitude)
|
|
|
|
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
|
|
// common path for the default spherical head.
|
|
if (config.type === 'sphere' || config.type === 'mickey') {
|
|
const halfWidth = config.width / 2 || 1
|
|
const halfHeight = config.height / 2 || 1
|
|
const halfDepth = config.depth / 2 || 1
|
|
return normalize([
|
|
point[0] / (halfWidth * halfWidth),
|
|
point[1] / (halfHeight * halfHeight),
|
|
point[2] / (halfDepth * halfDepth),
|
|
])
|
|
}
|
|
|
|
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
|
return normalize([
|
|
Math.sin(longitude) / (config.width / 2 || 1),
|
|
0,
|
|
Math.cos(longitude) / (config.depth / 2 || 1),
|
|
])
|
|
}
|
|
|
|
if (config.type === 'diamond') {
|
|
return diamondNormal(config, point)
|
|
}
|
|
|
|
if (config.type === 'cube') {
|
|
return cubeNormal(config, point)
|
|
}
|
|
|
|
return tangentNormalAt(config, longitude, latitude)
|
|
}
|
|
|
|
export const surfaceSampleAt = (
|
|
config: SurfaceConfig,
|
|
longitude: number,
|
|
latitude: number
|
|
): SurfaceSample => {
|
|
const point = surfacePointAt(config, longitude, latitude)
|
|
|
|
if (config.type === 'sphere' || config.type === 'mickey') {
|
|
const halfWidth = config.width / 2 || 1
|
|
const halfHeight = config.height / 2 || 1
|
|
const halfDepth = config.depth / 2 || 1
|
|
return {
|
|
point,
|
|
normal: normalize([
|
|
point[0] / (halfWidth * halfWidth),
|
|
point[1] / (halfHeight * halfHeight),
|
|
point[2] / (halfDepth * halfDepth),
|
|
]),
|
|
}
|
|
}
|
|
|
|
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
|
return {
|
|
point,
|
|
normal: normalize([
|
|
Math.sin(longitude) / (config.width / 2 || 1),
|
|
0,
|
|
Math.cos(longitude) / (config.depth / 2 || 1),
|
|
]),
|
|
}
|
|
}
|
|
|
|
if (config.type === 'diamond') {
|
|
return {
|
|
point,
|
|
normal: diamondNormal(config, point),
|
|
}
|
|
}
|
|
|
|
if (config.type === 'cube') {
|
|
return {
|
|
point,
|
|
normal: cubeNormal(config, point),
|
|
}
|
|
}
|
|
|
|
return {
|
|
point,
|
|
normal: tangentNormalAt(config, longitude, latitude),
|
|
}
|
|
}
|