gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-motion/src/morfo.rs Rust · 680 líneas · 25.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

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}