311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
import { TAU, lerp, r2 } from './math'
|
|
import { PROFILES, PROFILE_SAMPLES, type ProfileName } from './profiles'
|
|
|
|
export interface Point {
|
|
x: number
|
|
y: number
|
|
}
|
|
|
|
/**
|
|
* Une silhouette = un profil radial r(theta) plus une pose.
|
|
*
|
|
* Tout passe par des profils echantillonnes au MEME nombre d'angles : deux
|
|
* formes quelconques ont donc des points qui se correspondent un a un, et le
|
|
* morphing se reduit a une interpolation lineaire des rayons. C'est ce qui
|
|
* rend les transitions propres sans librairie de morphing de path.
|
|
*/
|
|
export interface Silhouette {
|
|
radii: number[]
|
|
/** rotation du profil, en radians */
|
|
rot: number
|
|
/** decalage du centre, en unites de rayon de boule */
|
|
cx: number
|
|
cy: number
|
|
/** squash & stretch, applique en repere ecran (apres rotation) */
|
|
sx: number
|
|
sy: number
|
|
}
|
|
|
|
const ANGLES = Array.from({ length: PROFILE_SAMPLES }, (_, i) => (i / PROFILE_SAMPLES) * TAU)
|
|
const COS = ANGLES.map(Math.cos)
|
|
const SIN = ANGLES.map(Math.sin)
|
|
|
|
export function silhouette(name: ProfileName, pose: Partial<Silhouette> = {}): Silhouette {
|
|
return {
|
|
radii: [...PROFILES[name]],
|
|
rot: 0,
|
|
cx: 0,
|
|
cy: 0,
|
|
sx: 1,
|
|
sy: 1,
|
|
...pose
|
|
}
|
|
}
|
|
|
|
/** Cercle parfait : sert de base neutre (point, bulle, cible de fondu). */
|
|
export function circle(radius: number, pose: Partial<Silhouette> = {}): Silhouette {
|
|
return {
|
|
radii: new Array(PROFILE_SAMPLES).fill(radius),
|
|
rot: 0,
|
|
cx: 0,
|
|
cy: 0,
|
|
sx: 1,
|
|
sy: 1,
|
|
...pose
|
|
}
|
|
}
|
|
|
|
/** Interpolation de deux silhouettes. `out` est reutilise pour eviter d'allouer a 60 fps. */
|
|
export function blend(a: Silhouette, b: Silhouette, t: number, out?: Silhouette): Silhouette {
|
|
const dst = out ?? { radii: new Array<number>(PROFILE_SAMPLES), rot: 0, cx: 0, cy: 0, sx: 1, sy: 1 }
|
|
for (let i = 0; i < PROFILE_SAMPLES; i++) {
|
|
dst.radii[i] = lerp(a.radii[i] ?? 1, b.radii[i] ?? 1, t)
|
|
}
|
|
// Rotation par le chemin le plus court : evite de faire un tour complet
|
|
// quand on passe par exemple de +170deg a -170deg.
|
|
let dRot = b.rot - a.rot
|
|
while (dRot > Math.PI) dRot -= TAU
|
|
while (dRot < -Math.PI) dRot += TAU
|
|
dst.rot = a.rot + dRot * t
|
|
dst.cx = lerp(a.cx, b.cx, t)
|
|
dst.cy = lerp(a.cy, b.cy, t)
|
|
dst.sx = lerp(a.sx, b.sx, t)
|
|
dst.sy = lerp(a.sy, b.sy, t)
|
|
return dst
|
|
}
|
|
|
|
/** Projette la silhouette en points ecran. `scale` = rayon de la boule en unites de viewBox. */
|
|
export function toPoints(s: Silhouette, scale: number, out: Point[] = []): Point[] {
|
|
const cr = Math.cos(s.rot)
|
|
const sr = Math.sin(s.rot)
|
|
for (let i = 0; i < PROFILE_SAMPLES; i++) {
|
|
const r = s.radii[i] ?? 1
|
|
const x = r * (COS[i] ?? 0)
|
|
const y = r * (SIN[i] ?? 0)
|
|
// rotation puis squash en repere ecran, puis translation
|
|
const rx = x * cr - y * sr
|
|
const ry = x * sr + y * cr
|
|
const p = out[i] ?? { x: 0, y: 0 }
|
|
p.x = (rx * s.sx + s.cx) * scale
|
|
p.y = (ry * s.sy + s.cy) * scale
|
|
out[i] = p
|
|
}
|
|
out.length = PROFILE_SAMPLES
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Polyligne fermee -> cubiques Catmull-Rom.
|
|
*
|
|
* Avec 64 points les tangentes centrees suffisent largement : le contour est
|
|
* lisse au pixel pres meme affiche en 600 px, et la chaine reste courte.
|
|
*/
|
|
export function closedPath(pts: Point[], tension = 1 / 6): string {
|
|
const n = pts.length
|
|
if (n < 3) return ''
|
|
const first = pts[0]!
|
|
let d = `M${r2(first.x)} ${r2(first.y)}`
|
|
for (let i = 0; i < n; i++) {
|
|
const p0 = pts[(i - 1 + n) % n]!
|
|
const p1 = pts[i]!
|
|
const p2 = pts[(i + 1) % n]!
|
|
const p3 = pts[(i + 2) % n]!
|
|
const c1x = p1.x + (p2.x - p0.x) * tension
|
|
const c1y = p1.y + (p2.y - p0.y) * tension
|
|
const c2x = p2.x - (p3.x - p1.x) * tension
|
|
const c2y = p2.y - (p3.y - p1.y) * tension
|
|
d += `C${r2(c1x)} ${r2(c1y)} ${r2(c2x)} ${r2(c2y)} ${r2(p2.x)} ${r2(p2.y)}`
|
|
}
|
|
return `${d}Z`
|
|
}
|
|
|
|
/**
|
|
* Polygone quelconque -> profil radial, par lancer de rayon depuis `center`.
|
|
*
|
|
* Sert a fabriquer les formes qui ne s'expriment pas naturellement en r(theta)
|
|
* (la barre tronconique du "!"). Calcule une seule fois au chargement, jamais
|
|
* dans la boucle de rendu.
|
|
*/
|
|
export function profileFromPolygon(poly: Point[], cx: number, cy: number): number[] {
|
|
const radii = new Array<number>(PROFILE_SAMPLES).fill(0)
|
|
const n = poly.length
|
|
for (let k = 0; k < PROFILE_SAMPLES; k++) {
|
|
const dx = COS[k] ?? 0
|
|
const dy = SIN[k] ?? 0
|
|
let best = 0
|
|
for (let i = 0; i < n; i++) {
|
|
const a = poly[i]!
|
|
const b = poly[(i + 1) % n]!
|
|
const ex = b.x - a.x
|
|
const ey = b.y - a.y
|
|
const den = dx * ey - dy * ex
|
|
if (Math.abs(den) < 1e-9) continue
|
|
const px = a.x - cx
|
|
const py = a.y - cy
|
|
const t = (px * ey - py * ex) / den // distance le long du rayon
|
|
const u = (px * dy - py * dx) / den // position sur le segment
|
|
if (t > best && u >= 0 && u <= 1) best = t
|
|
}
|
|
radii[k] = best
|
|
}
|
|
return radii
|
|
}
|
|
|
|
/** Enveloppe convexe de deux cercles : la barre tronconique du "!" vertical. */
|
|
export function hullOfCircles(
|
|
x1: number,
|
|
y1: number,
|
|
r1: number,
|
|
x2: number,
|
|
y2: number,
|
|
r2v: number,
|
|
steps = 96
|
|
): Point[] {
|
|
const dx = x2 - x1
|
|
const dy = y2 - y1
|
|
const dist = Math.hypot(dx, dy) || 1e-6
|
|
// angle des tangentes externes communes
|
|
const base = Math.atan2(dy, dx)
|
|
const spread = Math.acos(Math.max(-1, Math.min(1, (r1 - r2v) / dist)))
|
|
const pts: Point[] = []
|
|
// arc du grand cercle
|
|
for (let i = 0; i <= steps / 2; i++) {
|
|
const a = base + spread + ((TAU - 2 * spread) * i) / (steps / 2)
|
|
pts.push({ x: x1 + Math.cos(a) * r1, y: y1 + Math.sin(a) * r1 })
|
|
}
|
|
// arc du petit cercle
|
|
for (let i = 0; i <= steps / 2; i++) {
|
|
const a = base - spread + ((2 * spread) * i) / (steps / 2)
|
|
pts.push({ x: x2 + Math.cos(a) * r2v, y: y2 + Math.sin(a) * r2v })
|
|
}
|
|
return pts
|
|
}
|
|
|
|
/**
|
|
* Rayon du profil dans une direction quelconque, par interpolation entre les
|
|
* deux echantillons voisins.
|
|
*
|
|
* Sert a recaler ce qui est pose "sur" le corps (les yeux, la pastille de
|
|
* notification) quand la silhouette n'est plus un cercle : sans ca, un oeil
|
|
* place a 0.62 rayon sort d'une forme dont le bord est a 0.55 dans cette
|
|
* direction, et le masque le rogne.
|
|
*/
|
|
export function radiusAtAngle(radii: number[], angle: number): number {
|
|
const n = radii.length
|
|
const t = ((((angle / TAU) % 1) + 1) % 1) * n
|
|
const i = Math.floor(t)
|
|
return lerp(radii[i % n] ?? 1, radii[(i + 1) % n] ?? 1, t - i)
|
|
}
|
|
|
|
/**
|
|
* Superellipse : |x/sx|^n + |y/sy|^n = 1.
|
|
* n = 2 donne une ellipse, n ~ 4 le squircle du personnalisateur.
|
|
*/
|
|
export function superellipseProfile(n: number, sx = 1, sy = 1): number[] {
|
|
return ANGLES.map((_, i) => {
|
|
const c = Math.abs((COS[i] ?? 0) / sx) ** n
|
|
const s = Math.abs((SIN[i] ?? 0) / sy) ** n
|
|
return (c + s) ** (-1 / n)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Profil radial de l'UNION de disques : r(theta) = la plus lointaine des
|
|
* intersections rayon/cercle. Exact tant que l'origine est dans l'union — c'est
|
|
* ce qui donne les bosses du nuage sans booleen de path.
|
|
*/
|
|
export function unionOfCirclesProfile(circles: Array<{ x: number; y: number; r: number }>): number[] {
|
|
const out = new Array<number>(PROFILE_SAMPLES).fill(0)
|
|
for (let i = 0; i < PROFILE_SAMPLES; i++) {
|
|
const dx = COS[i] ?? 0
|
|
const dy = SIN[i] ?? 0
|
|
let best = 0
|
|
for (const c of circles) {
|
|
const b = dx * c.x + dy * c.y
|
|
const disc = b * b - (c.x * c.x + c.y * c.y - c.r * c.r)
|
|
if (disc < 0) continue
|
|
const t = b + Math.sqrt(disc)
|
|
if (t > best) best = t
|
|
}
|
|
out[i] = best
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Polygone a coins arrondis, par somme de Minkowski avec un disque : chaque
|
|
* arete est poussee de `rc` vers l'exterieur, chaque sommet devient un arc de
|
|
* rayon `rc`. Les sommets sont donc a poser au rayon voulu MOINS rc.
|
|
* Attend un polygone en sens horaire (repere ecran, y vers le bas).
|
|
*/
|
|
function roundedPolygon(verts: Point[], rc: number, arcSteps = 10): Point[] {
|
|
const n = verts.length
|
|
const out: Point[] = []
|
|
const normal = (a: Point, b: Point) => {
|
|
const dx = b.x - a.x
|
|
const dy = b.y - a.y
|
|
const len = Math.hypot(dx, dy) || 1
|
|
// sens horaire + y vers le bas : la normale sortante est (dy, -dx)
|
|
return Math.atan2(-dx / len, dy / len)
|
|
}
|
|
for (let i = 0; i < n; i++) {
|
|
const prev = verts[(i - 1 + n) % n]!
|
|
const cur = verts[i]!
|
|
const next = verts[(i + 1) % n]!
|
|
const a0 = normal(prev, cur)
|
|
const a1 = normal(cur, next)
|
|
let d = a1 - a0
|
|
while (d > Math.PI) d -= TAU
|
|
while (d < -Math.PI) d += TAU
|
|
for (let k = 0; k <= arcSteps; k++) {
|
|
const a = a0 + (d * k) / arcSteps
|
|
out.push({ x: cur.x + Math.cos(a) * rc, y: cur.y + Math.sin(a) * rc })
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Polygone regulier a coins arrondis, inscrit dans `radius`. */
|
|
export function regularPolygonProfile(
|
|
sides: number,
|
|
radius: number,
|
|
rc: number,
|
|
rotationDeg = 0
|
|
): number[] {
|
|
const rot = (rotationDeg * Math.PI) / 180
|
|
const verts = Array.from({ length: sides }, (_, i) => {
|
|
// sens horaire a l'ecran : theta croissant avec y vers le bas
|
|
const a = rot + (i / sides) * TAU
|
|
return { x: Math.cos(a) * (radius - rc), y: Math.sin(a) * (radius - rc) }
|
|
})
|
|
return profileFromPolygon(roundedPolygon(verts, rc), 0, 0)
|
|
}
|
|
|
|
/** Polyligne fermee exacte : garde les segments droits (contrairement a closedPath). */
|
|
export function polyPath(pts: Point[], scale = 1): string {
|
|
if (pts.length < 3) return ''
|
|
let d = ''
|
|
for (let i = 0; i < pts.length; i++) {
|
|
const p = pts[i]!
|
|
d += `${i === 0 ? 'M' : 'L'}${r2(p.x * scale)} ${r2(p.y * scale)}`
|
|
}
|
|
return `${d}Z`
|
|
}
|
|
|
|
/** Capsule (stade) centree sur l'origine : la forme exacte des yeux du bot. */
|
|
export function capsulePath(w: number, h: number): string {
|
|
const hw = Math.max(w, 0.01) / 2
|
|
const hh = Math.max(h, 0.01) / 2
|
|
const r = Math.min(hw, hh)
|
|
return (
|
|
`M${r2(-hw)} ${r2(-hh + r)}` +
|
|
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw + r)} ${r2(-hh)}` +
|
|
`L${r2(hw - r)} ${r2(-hh)}` +
|
|
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw)} ${r2(-hh + r)}` +
|
|
`L${r2(hw)} ${r2(hh - r)}` +
|
|
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw - r)} ${r2(hh)}` +
|
|
`L${r2(-hw + r)} ${r2(hh)}` +
|
|
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw)} ${r2(hh - r)}Z`
|
|
)
|
|
}
|