tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
1//! `morfo` — **transformación topológica** entre dos siluetas, estilo Rive.
2//!
3//! Es el escalón que faltaba entre [`crate::Tween`] (interpola *números*) y
4//! `llimphi-anim` (secuencia *clips*): interpolar la **forma misma**. Un glifo
5//! no se cambia por otro con un corte ni con un crossfade — se **deforma** hasta
6//! ser el otro, y de paso los contornos que sobran nacen o mueren.
7//!
8//! ## Qué quiere decir «topológica» acá
9//!
10//! Dos siluetas casi nunca tienen la misma cantidad de contornos: la chakana
11//! tiene su hueco central y el chevron `❯` no tiene ninguno; el micrófono tiene
12//! cápsula, arco y pie, y el avioncito de enviar es un solo trazo. Interpolar
13//! punto a punto sólo resuelve el caso fácil. Acá los contornos se **emparejan
14//! por área** y al que no le toca compañero se le da uno **degenerado** —todos
15//! sus puntos en el centroide de la otra silueta—, así que el hueco no
16//! desaparece de un frame al otro: se **cierra**. Ese cierre es el cambio de
17//! topología, y es la parte que se ve.
18//!
19//! ## Las tres decisiones que hacen que no se retuerza
20//!
21//! 1. **Remuestreo que preserva los vértices.** Repartir N puntos equiespaciados
22//! por longitud de arco es lo obvio y **redondea las esquinas**: en `t = 0` el
23//! dibujo ya no sería el original. Acá cada arista recibe su cuota
24//! proporcional *empezando en su vértice*, así que en los extremos el path
25//! pasa exactamente por donde pasaba (los puntos de más son colineales y no
26//! se ven).
27//! 2. **Mismo sentido de giro.** Un contorno horario contra uno antihorario se
28//! interpola dándose vuelta como una cinta. [`Silueta::nueva`] normaliza todo
29//! a antihorario por el signo del área.
30//! 3. **Desfase óptimo del índice de arranque.** Aunque los dos contornos giren
31//! igual, si el punto 0 de uno cae en la punta y el del otro en la base, la
32//! forma rota entera mientras se deforma. Se prueban los N desfases y gana el
33//! de menor suma de cuadrados — O(N²) con N = [`PUNTOS`], microsegundos, y
34//! sólo mientras la transición está viva.
35//!
36//! ## Uso
37//!
38//! Las siluetas se declaran **en caja unidad** (`0..1` en las dos coordenadas):
39//! el consumidor las escala a su `PaintRect` al pintar, así el mismo glifo sirve
40//! para un botón de 26 px y para uno de 38 sin tablas de tamaños.
41//!
42//! ```ignore
43//! let t = self.morfo.valor(ahora_ms); // 0 = mic, 1 = avioncito
44//! let contornos = morfo(&mic(), &avion(), t);
45//! let mut p = BezPath::new();
46//! for c in &contornos { /* move_to/line_to/close_path escalando a rect */ }
47//! scene.fill(Fill::EvenOdd, Affine::IDENTITY, color, None, &p);
48//! ```
49//!
50//! El `t` lo lleva [`Transicion`], que mide contra el **reloj de pared** y no
51//! contra `.animated_*` — bajo pata (capa layer-shell) las animaciones
52//! implícitas de Llimphi son inertes, y ésta tiene que andar ahí.
53
54use crate::motion;
55
56/// Un punto de un contorno, en la caja unidad.
57pub type Punto = (f64, f64);
58
59/// Cuántos puntos por contorno usa [`morfo`] al remuestrear.
60///
61/// 64 alcanza y sobra para los glifos de una barra: la chakana, que es el más
62/// anguloso del repo, tiene 40 vértices, así que cada arista se queda con el
63/// suyo y todavía sobran 24 para repartir. Subirlo encarece el desfase óptimo al
64/// cuadrado sin que se vea la diferencia a 26 px.
65pub const PUNTOS: usize = 64;
66
67/// Una **silueta**: uno o más contornos cerrados, en la caja unidad.
68///
69/// Los contornos vienen ordenados por área descendente y todos con el mismo
70/// sentido de giro — las dos condiciones que [`morfo`] necesita y que es más
71/// barato garantizar acá que verificar allá.
72#[derive(Debug, Clone, Default, PartialEq)]
73pub struct Silueta {
74 contornos: Vec<Vec<Punto>>,
75}
76
77impl Silueta {
78 /// Construye la silueta normalizando lo que haga falta: descarta lo que no
79 /// llega a polígono (< 3 puntos), pone todo antihorario y ordena por área.
80 pub fn nueva(contornos: impl IntoIterator<Item = Vec<Punto>>) -> Self {
81 let mut cs: Vec<Vec<Punto>> = contornos
82 .into_iter()
83 .filter(|c| c.len() >= 3)
84 .map(|mut c| {
85 if area_con_signo(&c) < 0.0 {
86 c.reverse();
87 }
88 c
89 })
90 .collect();
91 cs.sort_by(|a, b| {
92 area_con_signo(b)
93 .partial_cmp(&area_con_signo(a))
94 .unwrap_or(core::cmp::Ordering::Equal)
95 });
96 Self { contornos: cs }
97 }
98
99 /// Los contornos, ya normalizados.
100 pub fn contornos(&self) -> &[Vec<Punto>] {
101 &self.contornos
102 }
103
104 /// `true` si no quedó ningún contorno dibujable.
105 pub fn vacia(&self) -> bool {
106 self.contornos.is_empty()
107 }
108
109 /// El centroide de la silueta entera (promedio de los centroides de sus
110 /// contornos, pesados por área). Es adonde colapsan los contornos que
111 /// mueren, y de donde nacen los que aparecen.
112 pub fn centroide(&self) -> Punto {
113 let mut peso = 0.0;
114 let (mut sx, mut sy) = (0.0, 0.0);
115 for c in &self.contornos {
116 let a = area_con_signo(c).abs();
117 let (cx, cy) = centroide(c);
118 sx += cx * a;
119 sy += cy * a;
120 peso += a;
121 }
122 if peso <= f64::EPSILON {
123 return (0.5, 0.5);
124 }
125 (sx / peso, sy / peso)
126 }
127}
128
129/// Área con signo (fórmula del cordón): positiva antihoraria, negativa horaria.
130pub fn area_con_signo(c: &[Punto]) -> f64 {
131 if c.len() < 3 {
132 return 0.0;
133 }
134 let mut s = 0.0;
135 for i in 0..c.len() {
136 let (x0, y0) = c[i];
137 let (x1, y1) = c[(i + 1) % c.len()];
138 s += x0 * y1 - x1 * y0;
139 }
140 s * 0.5
141}
142
143/// Centroide del polígono (promedio de vértices — alcanza para emparejar y para
144/// colapsar; el baricentro exacto no cambia nada de lo que se ve).
145pub fn centroide(c: &[Punto]) -> Punto {
146 if c.is_empty() {
147 return (0.5, 0.5);
148 }
149 let n = c.len() as f64;
150 let (sx, sy) = c.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
151 (sx / n, sy / n)
152}
153
154/// Remuestrea el contorno a exactamente `n` puntos **preservando sus vértices**.
155///
156/// Cada arista recibe una cuota proporcional a su longitud, con un piso de uno
157/// (el vértice) — por eso en los extremos del morfo el dibujo es el original y
158/// no una versión con las esquinas limadas, que es lo que da el remuestreo
159/// equiespaciado de manual.
160pub fn remuestrear(c: &[Punto], n: usize) -> Vec<Punto> {
161 let m = c.len();
162 if m < 3 || n < m {
163 // Sin lugar para un punto por vértice no hay nada que preservar: se
164 // devuelve tal cual y que el caller decida (morfo nunca cae acá porque
165 // PUNTOS > los vértices de cualquier glifo que use esto).
166 return c.to_vec();
167 }
168 let largos: Vec<f64> = (0..m)
169 .map(|i| dist(c[i], c[(i + 1) % m]))
170 .collect();
171 let total: f64 = largos.iter().sum();
172 if total <= f64::EPSILON {
173 return c.to_vec();
174 }
175 // Cuota entera por arista, con piso 1, y el resto repartido por residuo
176 // decreciente para que la suma dé exactamente `n`.
177 let mut cuota: Vec<usize> = largos
178 .iter()
179 .map(|l| ((n as f64) * l / total).floor().max(1.0) as usize)
180 .collect();
181 let mut suma: usize = cuota.iter().sum();
182 while suma < n {
183 // Al de mayor «hambre»: la arista con más longitud por punto asignado.
184 let i = (0..m)
185 .max_by(|&a, &b| {
186 (largos[a] / cuota[a] as f64)
187 .partial_cmp(&(largos[b] / cuota[b] as f64))
188 .unwrap_or(core::cmp::Ordering::Equal)
189 })
190 .unwrap_or(0);
191 cuota[i] += 1;
192 suma += 1;
193 }
194 while suma > n {
195 // Al que menos lo necesita, y nunca por debajo de su vértice.
196 let i = (0..m)
197 .filter(|&i| cuota[i] > 1)
198 .min_by(|&a, &b| {
199 (largos[a] / cuota[a] as f64)
200 .partial_cmp(&(largos[b] / cuota[b] as f64))
201 .unwrap_or(core::cmp::Ordering::Equal)
202 });
203 match i {
204 Some(i) => {
205 cuota[i] -= 1;
206 suma -= 1;
207 }
208 None => break,
209 }
210 }
211 let mut out = Vec::with_capacity(suma);
212 for i in 0..m {
213 let a = c[i];
214 let b = c[(i + 1) % m];
215 for k in 0..cuota[i] {
216 let f = k as f64 / cuota[i] as f64;
217 out.push((a.0 + (b.0 - a.0) * f, a.1 + (b.1 - a.1) * f));
218 }
219 }
220 out
221}
222
223/// El desfase de índice que menos deforma: el que minimiza la suma de cuadrados
224/// entre los dos contornos ya remuestreados al mismo largo.
225fn desfase_optimo(a: &[Punto], b: &[Punto]) -> usize {
226 let n = a.len().min(b.len());
227 if n == 0 {
228 return 0;
229 }
230 let mut mejor = (0usize, f64::INFINITY);
231 for d in 0..n {
232 let mut s = 0.0;
233 for i in 0..n {
234 let (ax, ay) = a[i];
235 let (bx, by) = b[(i + d) % n];
236 s += (ax - bx) * (ax - bx) + (ay - by) * (ay - by);
237 if s >= mejor.1 {
238 break; // poda: ya perdió
239 }
240 }
241 if s < mejor.1 {
242 mejor = (d, s);
243 }
244 }
245 mejor.0
246}
247
248/// **El morfo**: los contornos de `a` deformándose hacia los de `b`, con `t` en
249/// `0..=1` (ya *sin* easing — el easing lo pone [`Transicion::valor`]).
250///
251/// El resultado son contornos en la caja unidad listos para escalar y rellenar
252/// con `Fill::EvenOdd`: los huecos son huecos de verdad, así que un morfo con
253/// hueco no necesita repintar el fondo por encima.
254pub fn morfo(a: &Silueta, b: &Silueta, t: f32) -> Vec<Vec<Punto>> {
255 let t = t.clamp(0.0, 1.0) as f64;
256 if a.vacia() && b.vacia() {
257 return Vec::new();
258 }
259 let cen_a = a.centroide();
260 let cen_b = b.centroide();
261 let n_max = a.contornos.len().max(b.contornos.len());
262 let mut out = Vec::with_capacity(n_max);
263 for i in 0..n_max {
264 // El que no tiene compañero se empareja con un degenerado en el centroide
265 // del otro: así el contorno que sobra se cierra (o se abre) en el lugar
266 // donde el ojo espera que pase, y no en una esquina.
267 let ca = a
268 .contornos
269 .get(i)
270 .cloned()
271 .unwrap_or_else(|| vec![cen_a; PUNTOS.min(8)]);
272 let cb = b
273 .contornos
274 .get(i)
275 .cloned()
276 .unwrap_or_else(|| vec![cen_b; PUNTOS.min(8)]);
277 // Un degenerado no tiene aristas que preservar: se replica al largo del
278 // otro y listo (remuestrear un punto repetido no significa nada).
279 let (ra, rb) = match (a.contornos.get(i).is_some(), b.contornos.get(i).is_some()) {
280 (true, true) => {
281 let ra = remuestrear(&ca, PUNTOS);
282 let rb = remuestrear(&cb, ra.len());
283 (ra, rb)
284 }
285 (true, false) => {
286 let ra = remuestrear(&ca, PUNTOS);
287 let p = centroide(&cb);
288 let rb = vec![p; ra.len()];
289 (ra, rb)
290 }
291 (false, true) => {
292 let rb = remuestrear(&cb, PUNTOS);
293 let p = centroide(&ca);
294 let ra = vec![p; rb.len()];
295 (ra, rb)
296 }
297 (false, false) => continue,
298 };
299 let n = ra.len().min(rb.len());
300 if n == 0 {
301 continue;
302 }
303 let d = desfase_optimo(&ra[..n], &rb[..n]);
304 let mut c = Vec::with_capacity(n);
305 for k in 0..n {
306 let (ax, ay) = ra[k];
307 let (bx, by) = rb[(k + d) % n];
308 c.push((ax + (bx - ax) * t, ay + (by - ay) * t));
309 }
310 out.push(c);
311 }
312 out
313}
314
315/// **El progreso de una transición entre dos formas**, medido contra el reloj de
316/// pared.
317///
318/// No usa `.animated_*` de Llimphi a propósito: bajo pata (capa layer-shell) las
319/// animaciones implícitas son inertes, y el PS1 y el botón del input viven
320/// justamente ahí. El host guarda una de éstas, la mueve con [`Transicion::ir_a`]
321/// y pinta con [`Transicion::valor`]; mientras [`Transicion::activa`] diga que sí,
322/// el host tiene que seguir pidiendo cuadros (marcar el panel sucio), o el morfo
323/// se congela a mitad de camino.
324#[derive(Debug, Clone, Copy, PartialEq)]
325pub struct Transicion {
326 /// Valor del que arrancó esta tirada (0..1). Guardarlo es lo que hace que
327 /// invertir a mitad de camino salga desde donde está y no desde el principio.
328 desde: f32,
329 /// Adónde va: `false` = 0, `true` = 1.
330 destino: bool,
331 /// Instante en que arrancó, en ms del mismo reloj que le pasen a `valor`.
332 t0_ms: u64,
333 /// Cuánto dura la tirada completa, en ms.
334 dur_ms: u64,
335}
336
337impl Transicion {
338 /// Una transición ya terminada en `destino` (no anima al arrancar).
339 pub fn nueva(destino: bool, dur_ms: u64) -> Self {
340 Self {
341 desde: if destino { 1.0 } else { 0.0 },
342 destino,
343 t0_ms: 0,
344 dur_ms: dur_ms.max(1),
345 }
346 }
347
348 /// La duración canónica de un morfo de glifo: [`motion::NORMAL`].
349 pub fn normal(destino: bool) -> Self {
350 Self::nueva(destino, motion::NORMAL.as_millis() as u64)
351 }
352
353 /// Apunta a `destino`. Si ya iba para allá no hace nada (llamarla en cada
354 /// cuadro es el uso previsto); si va para el otro lado, arranca una tirada
355 /// nueva **desde el valor actual**, así una ida y vuelta rápida no salta.
356 pub fn ir_a(&mut self, destino: bool, ahora_ms: u64) {
357 if destino == self.destino {
358 return;
359 }
360 self.desde = self.valor(ahora_ms);
361 self.destino = destino;
362 self.t0_ms = ahora_ms;
363 }
364
365 /// El valor actual, `0..=1`, con `ease_in_out_cubic` — que es la curva de un
366 /// objeto que cambia de forma: sale y llega quieto.
367 pub fn valor(&self, ahora_ms: u64) -> f32 {
368 let hasta = if self.destino { 1.0 } else { 0.0 };
369 if self.t0_ms == 0 {
370 return hasta;
371 }
372 let d = ahora_ms.saturating_sub(self.t0_ms) as f32 / self.dur_ms as f32;
373 if d >= 1.0 {
374 return hasta;
375 }
376 let e = motion::ease_in_out_cubic(d.clamp(0.0, 1.0));
377 self.desde + (hasta - self.desde) * e
378 }
379
380 /// `true` mientras el morfo esté en curso — el host lo consulta para seguir
381 /// pidiendo cuadros.
382 pub fn activa(&self, ahora_ms: u64) -> bool {
383 self.t0_ms != 0 && ahora_ms.saturating_sub(self.t0_ms) < self.dur_ms
384 }
385
386 /// Adónde apunta hoy.
387 pub fn destino(&self) -> bool {
388 self.destino
389 }
390}
391
392/// Cuántos puntos aproximan un semicírculo en [`banda`] y [`rect_redondo`].
393/// Seis por media vuelta: a los tamaños de un glifo de barra (26–40 px) el error
394/// de cuerda queda muy por debajo del píxel, y cada punto de más se paga después
395/// en el desfase óptimo, que es cuadrático.
396const ARCO: usize = 6;
397
398/// El **contorno** de un trazo: la polilínea `centro` engrosada a `grosor`, con
399/// las dos puntas redondeadas.
400///
401/// Está acá porque los glifos que hay que morfear están dibujados con `stroke`
402/// —el chevron del PS1, el arco y el pie del micrófono— y un morfo necesita
403/// contornos **rellenos**: no se puede interpolar «una línea de 1,4 px» con «un
404/// triángulo». Convertir el trazo en su silueta es lo que pone a los dos lados
405/// del morfo en el mismo idioma.
406///
407/// Las uniones son en *miter* con tope: pasado el tope se corta a bisel, que es
408/// lo que evita que un ángulo cerrado dispare una púa de largo arbitrario.
409pub fn banda(centro: &[Punto], grosor: f64) -> Vec<Punto> {
410 let h = grosor * 0.5;
411 let n = centro.len();
412 if n < 2 || h <= 0.0 {
413 return Vec::new();
414 }
415 // Normales de cada segmento (izquierda del avance).
416 let normales: Vec<Punto> = (0..n - 1)
417 .map(|i| {
418 let (dx, dy) = (centro[i + 1].0 - centro[i].0, centro[i + 1].1 - centro[i].1);
419 let l = (dx * dx + dy * dy).sqrt().max(f64::EPSILON);
420 (-dy / l, dx / l)
421 })
422 .collect();
423 // Un lado: el punto de cada vértice desplazado `h` hacia `signo * normal`,
424 // con miter en los vértices interiores.
425 let lado = |signo: f64| -> Vec<Punto> {
426 let mut out = Vec::with_capacity(n);
427 for i in 0..n {
428 let p = centro[i];
429 if i == 0 || i == n - 1 {
430 let nm = normales[if i == 0 { 0 } else { n - 2 }];
431 out.push((p.0 + signo * h * nm.0, p.1 + signo * h * nm.1));
432 continue;
433 }
434 let (a, b) = (normales[i - 1], normales[i]);
435 let (bx, by) = (a.0 + b.0, a.1 + b.1);
436 let l = (bx * bx + by * by).sqrt();
437 if l <= 1e-9 {
438 // 180°: la bisectriz no existe, se usa la normal entrante.
439 out.push((p.0 + signo * h * a.0, p.1 + signo * h * a.1));
440 continue;
441 }
442 let (ux, uy) = (bx / l, by / l);
443 let cos = a.0 * ux + a.1 * uy;
444 let largo = (h / cos.max(1e-3)).min(h * 3.0);
445 out.push((p.0 + signo * largo * ux, p.1 + signo * largo * uy));
446 }
447 out
448 };
449 let izq = lado(1.0);
450 let der = lado(-1.0);
451 let mut out = Vec::with_capacity(izq.len() + der.len() + 2 * ARCO);
452 out.extend(izq.iter().copied());
453 // Cap redondo en la punta final: de `izq` a `der` girando alrededor del
454 // último punto del centro, **por afuera** (hacia donde avanza el trazo).
455 let ult = normales[n - 2];
456 out.extend(arco_entre(
457 centro[n - 1],
458 *izq.last().unwrap(),
459 der[n - 1],
460 (ult.1, -ult.0),
461 ARCO,
462 ));
463 out.extend(der.iter().rev().copied());
464 // Y el de la punta inicial, hacia atrás.
465 let pri = normales[0];
466 out.extend(arco_entre(centro[0], der[0], izq[0], (-pri.1, pri.0), ARCO));
467 out
468}
469
470/// Los puntos intermedios del arco que va de `a` a `b` girando alrededor de `c`
471/// y pasando **del lado de `hacia`** (sin incluir los extremos, que ya los puso
472/// el llamador).
473///
474/// El lado importa y no es un detalle: un cap de trazo es un giro de 180° exactos,
475/// donde los dos sentidos son igual de «cortos» y el que elija `atan2` es un
476/// volado. Con el equivocado, la punta se dibuja hacia adentro del cuerpo y el
477/// contorno se muerde a sí mismo — el área sale casi la mitad de la que debería.
478fn arco_entre(c: Punto, a: Punto, b: Punto, hacia: Punto, pasos: usize) -> Vec<Punto> {
479 let (r, a0) = ((dist(c, a) + dist(c, b)) * 0.5, (a.1 - c.1).atan2(a.0 - c.0));
480 let a1 = (b.1 - c.1).atan2(b.0 - c.0);
481 let mut d = a1 - a0;
482 while d > core::f64::consts::TAU {
483 d -= core::f64::consts::TAU;
484 }
485 while d < 0.0 {
486 d += core::f64::consts::TAU;
487 }
488 // El punto medio decide: si cae del lado contrario al que mira la punta, el
489 // arco se toma por el otro lado.
490 let medio = a0 + d * 0.5;
491 if medio.cos() * hacia.0 + medio.sin() * hacia.1 < 0.0 {
492 d -= core::f64::consts::TAU;
493 }
494 (1..pasos)
495 .map(|k| {
496 let ang = a0 + d * (k as f64 / pasos as f64);
497 (c.0 + r * ang.cos(), c.1 + r * ang.sin())
498 })
499 .collect()
500}
501
502/// El contorno de un rectángulo de esquinas redondeadas — la cápsula del
503/// micrófono y cualquier chip que haya que morfear.
504pub fn rect_redondo(x0: f64, y0: f64, x1: f64, y1: f64, r: f64) -> Vec<Punto> {
505 let r = r.min((x1 - x0).abs() * 0.5).min((y1 - y0).abs() * 0.5).max(0.0);
506 if r <= 1e-9 {
507 return vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)];
508 }
509 let mut out = Vec::with_capacity(4 * (ARCO + 1));
510 // Cada esquina: su cuarto de vuelta, en orden horario en coordenadas de
511 // pantalla (y hacia abajo).
512 for (cx, cy, desde) in [
513 (x1 - r, y0 + r, -core::f64::consts::FRAC_PI_2),
514 (x1 - r, y1 - r, 0.0),
515 (x0 + r, y1 - r, core::f64::consts::FRAC_PI_2),
516 (x0 + r, y0 + r, core::f64::consts::PI),
517 ] {
518 for k in 0..=ARCO {
519 let ang = desde + core::f64::consts::FRAC_PI_2 * (k as f64 / ARCO as f64);
520 out.push((cx + r * ang.cos(), cy + r * ang.sin()));
521 }
522 }
523 out
524}
525
526fn dist(a: Punto, b: Punto) -> f64 {
527 let (dx, dy) = (a.0 - b.0, a.1 - b.1);
528 (dx * dx + dy * dy).sqrt()
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 fn cuadrado() -> Vec<Punto> {
536 vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
537 }
538
539 fn rombo() -> Vec<Punto> {
540 vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)]
541 }
542
543 /// La razón de ser del remuestreo con cuota por arista: en `t = 0` el dibujo
544 /// tiene que ser **el original**, con sus esquinas donde estaban. Con el
545 /// remuestreo equiespaciado de manual las esquinas se liman y el glifo cambia
546 /// de aspecto al empezar a animar, que es justo lo que no puede pasar.
547 #[test]
548 fn los_vertices_sobreviven_al_remuestreo() {
549 let r = remuestrear(&cuadrado(), PUNTOS);
550 assert_eq!(r.len(), PUNTOS, "la cuota tiene que cerrar en N exacto");
551 for v in cuadrado() {
552 assert!(
553 r.iter().any(|p| dist(*p, v) < 1e-9),
554 "se perdió el vértice {v:?}"
555 );
556 }
557 }
558
559 #[test]
560 fn en_los_extremos_el_morfo_es_cada_silueta() {
561 let a = Silueta::nueva([cuadrado()]);
562 let b = Silueta::nueva([rombo()]);
563 for v in cuadrado() {
564 assert!(morfo(&a, &b, 0.0)[0].iter().any(|p| dist(*p, v) < 1e-9));
565 }
566 for v in rombo() {
567 assert!(morfo(&a, &b, 1.0)[0].iter().any(|p| dist(*p, v) < 1e-9));
568 }
569 }
570
571 /// El área a mitad de camino queda entre las dos: si se retorciera (desfase
572 /// mal elegido, sentidos opuestos) el polígono se cruzaría y el área con
573 /// signo se desplomaría.
574 #[test]
575 fn a_mitad_de_camino_el_area_esta_entre_las_dos() {
576 let a = Silueta::nueva([cuadrado()]);
577 let b = Silueta::nueva([rombo()]);
578 let m = morfo(&a, &b, 0.5);
579 let area = area_con_signo(&m[0]);
580 assert!(
581 area > 0.5 && area < 1.0,
582 "área intermedia fuera de rango: {area}"
583 );
584 }
585
586 /// El sentido de giro se normaliza: un contorno declarado al revés no puede
587 /// hacer que el morfo se dé vuelta como una cinta.
588 #[test]
589 fn el_sentido_se_normaliza() {
590 let mut horario = cuadrado();
591 horario.reverse();
592 let a = Silueta::nueva([horario]);
593 let b = Silueta::nueva([rombo()]);
594 assert!(area_con_signo(&a.contornos()[0]) > 0.0);
595 let area = area_con_signo(&morfo(&a, &b, 0.5)[0]);
596 assert!(area > 0.5, "se dio vuelta: {area}");
597 }
598
599 /// **El cambio de topología.** El hueco de una silueta de dos contornos no
600 /// desaparece de un cuadro al otro: se cierra sobre el centroide de la otra.
601 #[test]
602 fn el_hueco_que_sobra_se_cierra_sobre_el_centroide() {
603 let hueco = vec![(0.4, 0.4), (0.6, 0.4), (0.6, 0.6), (0.4, 0.6)];
604 let con_hueco = Silueta::nueva([cuadrado(), hueco]);
605 let sin_hueco = Silueta::nueva([rombo()]);
606 assert_eq!(con_hueco.contornos().len(), 2, "y el grande va primero");
607 assert!(area_con_signo(&con_hueco.contornos()[0]).abs() > 0.5);
608
609 let medio = morfo(&con_hueco, &sin_hueco, 0.5);
610 let area_medio = area_con_signo(&medio[1]).abs();
611 let final_ = morfo(&con_hueco, &sin_hueco, 1.0);
612 let area_final = area_con_signo(&final_[1]).abs();
613 assert!(area_medio < 0.04 && area_medio > 0.0, "a mitad: {area_medio}");
614 assert!(area_final < 1e-9, "al final el hueco no existe: {area_final}");
615 }
616
617 /// El desfase óptimo es lo que evita el giro fantasma: con el punto 0 de una
618 /// forma en la otra punta, alinear por índice crudo recorre mucha más
619 /// distancia que alinear por el mejor desfase.
620 #[test]
621 fn el_desfase_optimo_ahorra_recorrido() {
622 let a = remuestrear(&cuadrado(), 8);
623 let mut b = a.clone();
624 b.rotate_left(4);
625 assert_eq!(desfase_optimo(&a, &b), 4, "el desfase que lo deja quieto");
626 }
627
628 #[test]
629 fn la_transicion_invertida_arranca_donde_esta() {
630 let mut tr = Transicion::nueva(false, 200);
631 assert_eq!(tr.valor(1_000), 0.0);
632 tr.ir_a(true, 1_000);
633 assert!(tr.activa(1_100));
634 let medio = tr.valor(1_100);
635 assert!(medio > 0.2 && medio < 0.8, "a mitad de tirada: {medio}");
636 // Se arrepiente a mitad de camino: no puede saltar a 1 ni volver a 0 de
637 // golpe — sale de donde está.
638 tr.ir_a(false, 1_100);
639 assert!((tr.valor(1_100) - medio).abs() < 1e-6);
640 assert_eq!(tr.valor(1_400), 0.0, "y llega");
641 assert!(!tr.activa(1_400));
642 }
643
644 #[test]
645 fn una_transicion_recien_creada_no_anima() {
646 let tr = Transicion::normal(true);
647 assert_eq!(tr.valor(0), 1.0);
648 assert!(!tr.activa(u64::MAX));
649 }
650
651 /// La banda es un trazo hecho silueta: tiene que **cerrar**, tener área
652 /// proporcional al largo por el grosor, y no cruzarse en el codo.
653 #[test]
654 fn la_banda_de_un_trazo_cierra_con_su_area() {
655 let c = banda(&[(0.0, 0.0), (1.0, 0.0)], 0.2);
656 let area = area_con_signo(&c).abs();
657 // Recto de largo 1 y grosor 0,2 más los dos semicírculos de radio 0,1.
658 let esperada = 1.0 * 0.2 + core::f64::consts::PI * 0.01;
659 assert!(
660 (area - esperada).abs() < 0.01,
661 "área {area}, esperada ~{esperada}"
662 );
663 }
664
665 #[test]
666 fn el_codo_no_afina_la_banda() {
667 // Una V de 90°: en el vértice el miter tiene que ensanchar, no cortar.
668 let v = banda(&[(0.0, 0.0), (0.5, 0.5), (0.0, 1.0)], 0.2);
669 assert!(area_con_signo(&v).abs() > 0.2, "el codo se comió la banda");
670 }
671
672 #[test]
673 fn el_rect_redondo_no_se_pasa_de_radio() {
674 // Radio mayor que el medio lado: se clampea y sigue siendo un círculo,
675 // no un polígono dado vuelta.
676 let c = rect_redondo(0.0, 0.0, 1.0, 1.0, 9.0);
677 let a = area_con_signo(&c).abs();
678 assert!(a > 0.7 && a < 0.9, "área del círculo inscripto: {a}");
679 }
680}