//! `morfo` — **transformación topológica** entre dos siluetas, estilo Rive. //! //! Es el escalón que faltaba entre [`crate::Tween`] (interpola *números*) y //! `llimphi-anim` (secuencia *clips*): interpolar la **forma misma**. Un glifo //! no se cambia por otro con un corte ni con un crossfade — se **deforma** hasta //! ser el otro, y de paso los contornos que sobran nacen o mueren. //! //! ## Qué quiere decir «topológica» acá //! //! Dos siluetas casi nunca tienen la misma cantidad de contornos: la chakana //! tiene su hueco central y el chevron `❯` no tiene ninguno; el micrófono tiene //! cápsula, arco y pie, y el avioncito de enviar es un solo trazo. Interpolar //! punto a punto sólo resuelve el caso fácil. Acá los contornos se **emparejan //! por área** y al que no le toca compañero se le da uno **degenerado** —todos //! sus puntos en el centroide de la otra silueta—, así que el hueco no //! desaparece de un frame al otro: se **cierra**. Ese cierre es el cambio de //! topología, y es la parte que se ve. //! //! ## Las tres decisiones que hacen que no se retuerza //! //! 1. **Remuestreo que preserva los vértices.** Repartir N puntos equiespaciados //! por longitud de arco es lo obvio y **redondea las esquinas**: en `t = 0` el //! dibujo ya no sería el original. Acá cada arista recibe su cuota //! proporcional *empezando en su vértice*, así que en los extremos el path //! pasa exactamente por donde pasaba (los puntos de más son colineales y no //! se ven). //! 2. **Mismo sentido de giro.** Un contorno horario contra uno antihorario se //! interpola dándose vuelta como una cinta. [`Silueta::nueva`] normaliza todo //! a antihorario por el signo del área. //! 3. **Desfase óptimo del índice de arranque.** Aunque los dos contornos giren //! igual, si el punto 0 de uno cae en la punta y el del otro en la base, la //! forma rota entera mientras se deforma. Se prueban los N desfases y gana el //! de menor suma de cuadrados — O(N²) con N = [`PUNTOS`], microsegundos, y //! sólo mientras la transición está viva. //! //! ## Uso //! //! Las siluetas se declaran **en caja unidad** (`0..1` en las dos coordenadas): //! el consumidor las escala a su `PaintRect` al pintar, así el mismo glifo sirve //! para un botón de 26 px y para uno de 38 sin tablas de tamaños. //! //! ```ignore //! let t = self.morfo.valor(ahora_ms); // 0 = mic, 1 = avioncito //! let contornos = morfo(&mic(), &avion(), t); //! let mut p = BezPath::new(); //! for c in &contornos { /* move_to/line_to/close_path escalando a rect */ } //! scene.fill(Fill::EvenOdd, Affine::IDENTITY, color, None, &p); //! ``` //! //! El `t` lo lleva [`Transicion`], que mide contra el **reloj de pared** y no //! contra `.animated_*` — bajo pata (capa layer-shell) las animaciones //! implícitas de Llimphi son inertes, y ésta tiene que andar ahí. use crate::motion; /// Un punto de un contorno, en la caja unidad. pub type Punto = (f64, f64); /// Cuántos puntos por contorno usa [`morfo`] al remuestrear. /// /// 64 alcanza y sobra para los glifos de una barra: la chakana, que es el más /// anguloso del repo, tiene 40 vértices, así que cada arista se queda con el /// suyo y todavía sobran 24 para repartir. Subirlo encarece el desfase óptimo al /// cuadrado sin que se vea la diferencia a 26 px. pub const PUNTOS: usize = 64; /// Una **silueta**: uno o más contornos cerrados, en la caja unidad. /// /// Los contornos vienen ordenados por área descendente y todos con el mismo /// sentido de giro — las dos condiciones que [`morfo`] necesita y que es más /// barato garantizar acá que verificar allá. #[derive(Debug, Clone, Default, PartialEq)] pub struct Silueta { contornos: Vec>, } impl Silueta { /// Construye la silueta normalizando lo que haga falta: descarta lo que no /// llega a polígono (< 3 puntos), pone todo antihorario y ordena por área. pub fn nueva(contornos: impl IntoIterator>) -> Self { let mut cs: Vec> = contornos .into_iter() .filter(|c| c.len() >= 3) .map(|mut c| { if area_con_signo(&c) < 0.0 { c.reverse(); } c }) .collect(); cs.sort_by(|a, b| { area_con_signo(b) .partial_cmp(&area_con_signo(a)) .unwrap_or(core::cmp::Ordering::Equal) }); Self { contornos: cs } } /// Los contornos, ya normalizados. pub fn contornos(&self) -> &[Vec] { &self.contornos } /// `true` si no quedó ningún contorno dibujable. pub fn vacia(&self) -> bool { self.contornos.is_empty() } /// El centroide de la silueta entera (promedio de los centroides de sus /// contornos, pesados por área). Es adonde colapsan los contornos que /// mueren, y de donde nacen los que aparecen. pub fn centroide(&self) -> Punto { let mut peso = 0.0; let (mut sx, mut sy) = (0.0, 0.0); for c in &self.contornos { let a = area_con_signo(c).abs(); let (cx, cy) = centroide(c); sx += cx * a; sy += cy * a; peso += a; } if peso <= f64::EPSILON { return (0.5, 0.5); } (sx / peso, sy / peso) } } /// Área con signo (fórmula del cordón): positiva antihoraria, negativa horaria. pub fn area_con_signo(c: &[Punto]) -> f64 { if c.len() < 3 { return 0.0; } let mut s = 0.0; for i in 0..c.len() { let (x0, y0) = c[i]; let (x1, y1) = c[(i + 1) % c.len()]; s += x0 * y1 - x1 * y0; } s * 0.5 } /// Centroide del polígono (promedio de vértices — alcanza para emparejar y para /// colapsar; el baricentro exacto no cambia nada de lo que se ve). pub fn centroide(c: &[Punto]) -> Punto { if c.is_empty() { return (0.5, 0.5); } let n = c.len() as f64; let (sx, sy) = c.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y)); (sx / n, sy / n) } /// Remuestrea el contorno a exactamente `n` puntos **preservando sus vértices**. /// /// Cada arista recibe una cuota proporcional a su longitud, con un piso de uno /// (el vértice) — por eso en los extremos del morfo el dibujo es el original y /// no una versión con las esquinas limadas, que es lo que da el remuestreo /// equiespaciado de manual. pub fn remuestrear(c: &[Punto], n: usize) -> Vec { let m = c.len(); if m < 3 || n < m { // Sin lugar para un punto por vértice no hay nada que preservar: se // devuelve tal cual y que el caller decida (morfo nunca cae acá porque // PUNTOS > los vértices de cualquier glifo que use esto). return c.to_vec(); } let largos: Vec = (0..m) .map(|i| dist(c[i], c[(i + 1) % m])) .collect(); let total: f64 = largos.iter().sum(); if total <= f64::EPSILON { return c.to_vec(); } // Cuota entera por arista, con piso 1, y el resto repartido por residuo // decreciente para que la suma dé exactamente `n`. let mut cuota: Vec = largos .iter() .map(|l| ((n as f64) * l / total).floor().max(1.0) as usize) .collect(); let mut suma: usize = cuota.iter().sum(); while suma < n { // Al de mayor «hambre»: la arista con más longitud por punto asignado. let i = (0..m) .max_by(|&a, &b| { (largos[a] / cuota[a] as f64) .partial_cmp(&(largos[b] / cuota[b] as f64)) .unwrap_or(core::cmp::Ordering::Equal) }) .unwrap_or(0); cuota[i] += 1; suma += 1; } while suma > n { // Al que menos lo necesita, y nunca por debajo de su vértice. let i = (0..m) .filter(|&i| cuota[i] > 1) .min_by(|&a, &b| { (largos[a] / cuota[a] as f64) .partial_cmp(&(largos[b] / cuota[b] as f64)) .unwrap_or(core::cmp::Ordering::Equal) }); match i { Some(i) => { cuota[i] -= 1; suma -= 1; } None => break, } } let mut out = Vec::with_capacity(suma); for i in 0..m { let a = c[i]; let b = c[(i + 1) % m]; for k in 0..cuota[i] { let f = k as f64 / cuota[i] as f64; out.push((a.0 + (b.0 - a.0) * f, a.1 + (b.1 - a.1) * f)); } } out } /// El desfase de índice que menos deforma: el que minimiza la suma de cuadrados /// entre los dos contornos ya remuestreados al mismo largo. fn desfase_optimo(a: &[Punto], b: &[Punto]) -> usize { let n = a.len().min(b.len()); if n == 0 { return 0; } let mut mejor = (0usize, f64::INFINITY); for d in 0..n { let mut s = 0.0; for i in 0..n { let (ax, ay) = a[i]; let (bx, by) = b[(i + d) % n]; s += (ax - bx) * (ax - bx) + (ay - by) * (ay - by); if s >= mejor.1 { break; // poda: ya perdió } } if s < mejor.1 { mejor = (d, s); } } mejor.0 } /// **El morfo**: los contornos de `a` deformándose hacia los de `b`, con `t` en /// `0..=1` (ya *sin* easing — el easing lo pone [`Transicion::valor`]). /// /// El resultado son contornos en la caja unidad listos para escalar y rellenar /// con `Fill::EvenOdd`: los huecos son huecos de verdad, así que un morfo con /// hueco no necesita repintar el fondo por encima. pub fn morfo(a: &Silueta, b: &Silueta, t: f32) -> Vec> { let t = t.clamp(0.0, 1.0) as f64; if a.vacia() && b.vacia() { return Vec::new(); } let cen_a = a.centroide(); let cen_b = b.centroide(); let n_max = a.contornos.len().max(b.contornos.len()); let mut out = Vec::with_capacity(n_max); for i in 0..n_max { // El que no tiene compañero se empareja con un degenerado en el centroide // del otro: así el contorno que sobra se cierra (o se abre) en el lugar // donde el ojo espera que pase, y no en una esquina. let ca = a .contornos .get(i) .cloned() .unwrap_or_else(|| vec![cen_a; PUNTOS.min(8)]); let cb = b .contornos .get(i) .cloned() .unwrap_or_else(|| vec![cen_b; PUNTOS.min(8)]); // Un degenerado no tiene aristas que preservar: se replica al largo del // otro y listo (remuestrear un punto repetido no significa nada). let (ra, rb) = match (a.contornos.get(i).is_some(), b.contornos.get(i).is_some()) { (true, true) => { let ra = remuestrear(&ca, PUNTOS); let rb = remuestrear(&cb, ra.len()); (ra, rb) } (true, false) => { let ra = remuestrear(&ca, PUNTOS); let p = centroide(&cb); let rb = vec![p; ra.len()]; (ra, rb) } (false, true) => { let rb = remuestrear(&cb, PUNTOS); let p = centroide(&ca); let ra = vec![p; rb.len()]; (ra, rb) } (false, false) => continue, }; let n = ra.len().min(rb.len()); if n == 0 { continue; } let d = desfase_optimo(&ra[..n], &rb[..n]); let mut c = Vec::with_capacity(n); for k in 0..n { let (ax, ay) = ra[k]; let (bx, by) = rb[(k + d) % n]; c.push((ax + (bx - ax) * t, ay + (by - ay) * t)); } out.push(c); } out } /// **El progreso de una transición entre dos formas**, medido contra el reloj de /// pared. /// /// No usa `.animated_*` de Llimphi a propósito: bajo pata (capa layer-shell) las /// animaciones implícitas son inertes, y el PS1 y el botón del input viven /// justamente ahí. El host guarda una de éstas, la mueve con [`Transicion::ir_a`] /// y pinta con [`Transicion::valor`]; mientras [`Transicion::activa`] diga que sí, /// el host tiene que seguir pidiendo cuadros (marcar el panel sucio), o el morfo /// se congela a mitad de camino. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Transicion { /// Valor del que arrancó esta tirada (0..1). Guardarlo es lo que hace que /// invertir a mitad de camino salga desde donde está y no desde el principio. desde: f32, /// Adónde va: `false` = 0, `true` = 1. destino: bool, /// Instante en que arrancó, en ms del mismo reloj que le pasen a `valor`. t0_ms: u64, /// Cuánto dura la tirada completa, en ms. dur_ms: u64, } impl Transicion { /// Una transición ya terminada en `destino` (no anima al arrancar). pub fn nueva(destino: bool, dur_ms: u64) -> Self { Self { desde: if destino { 1.0 } else { 0.0 }, destino, t0_ms: 0, dur_ms: dur_ms.max(1), } } /// La duración canónica de un morfo de glifo: [`motion::NORMAL`]. pub fn normal(destino: bool) -> Self { Self::nueva(destino, motion::NORMAL.as_millis() as u64) } /// Apunta a `destino`. Si ya iba para allá no hace nada (llamarla en cada /// cuadro es el uso previsto); si va para el otro lado, arranca una tirada /// nueva **desde el valor actual**, así una ida y vuelta rápida no salta. pub fn ir_a(&mut self, destino: bool, ahora_ms: u64) { if destino == self.destino { return; } self.desde = self.valor(ahora_ms); self.destino = destino; self.t0_ms = ahora_ms; } /// El valor actual, `0..=1`, con `ease_in_out_cubic` — que es la curva de un /// objeto que cambia de forma: sale y llega quieto. pub fn valor(&self, ahora_ms: u64) -> f32 { let hasta = if self.destino { 1.0 } else { 0.0 }; if self.t0_ms == 0 { return hasta; } let d = ahora_ms.saturating_sub(self.t0_ms) as f32 / self.dur_ms as f32; if d >= 1.0 { return hasta; } let e = motion::ease_in_out_cubic(d.clamp(0.0, 1.0)); self.desde + (hasta - self.desde) * e } /// `true` mientras el morfo esté en curso — el host lo consulta para seguir /// pidiendo cuadros. pub fn activa(&self, ahora_ms: u64) -> bool { self.t0_ms != 0 && ahora_ms.saturating_sub(self.t0_ms) < self.dur_ms } /// Adónde apunta hoy. pub fn destino(&self) -> bool { self.destino } } /// Cuántos puntos aproximan un semicírculo en [`banda`] y [`rect_redondo`]. /// Seis por media vuelta: a los tamaños de un glifo de barra (26–40 px) el error /// de cuerda queda muy por debajo del píxel, y cada punto de más se paga después /// en el desfase óptimo, que es cuadrático. const ARCO: usize = 6; /// El **contorno** de un trazo: la polilínea `centro` engrosada a `grosor`, con /// las dos puntas redondeadas. /// /// Está acá porque los glifos que hay que morfear están dibujados con `stroke` /// —el chevron del PS1, el arco y el pie del micrófono— y un morfo necesita /// contornos **rellenos**: no se puede interpolar «una línea de 1,4 px» con «un /// triángulo». Convertir el trazo en su silueta es lo que pone a los dos lados /// del morfo en el mismo idioma. /// /// Las uniones son en *miter* con tope: pasado el tope se corta a bisel, que es /// lo que evita que un ángulo cerrado dispare una púa de largo arbitrario. pub fn banda(centro: &[Punto], grosor: f64) -> Vec { let h = grosor * 0.5; let n = centro.len(); if n < 2 || h <= 0.0 { return Vec::new(); } // Normales de cada segmento (izquierda del avance). let normales: Vec = (0..n - 1) .map(|i| { let (dx, dy) = (centro[i + 1].0 - centro[i].0, centro[i + 1].1 - centro[i].1); let l = (dx * dx + dy * dy).sqrt().max(f64::EPSILON); (-dy / l, dx / l) }) .collect(); // Un lado: el punto de cada vértice desplazado `h` hacia `signo * normal`, // con miter en los vértices interiores. let lado = |signo: f64| -> Vec { let mut out = Vec::with_capacity(n); for i in 0..n { let p = centro[i]; if i == 0 || i == n - 1 { let nm = normales[if i == 0 { 0 } else { n - 2 }]; out.push((p.0 + signo * h * nm.0, p.1 + signo * h * nm.1)); continue; } let (a, b) = (normales[i - 1], normales[i]); let (bx, by) = (a.0 + b.0, a.1 + b.1); let l = (bx * bx + by * by).sqrt(); if l <= 1e-9 { // 180°: la bisectriz no existe, se usa la normal entrante. out.push((p.0 + signo * h * a.0, p.1 + signo * h * a.1)); continue; } let (ux, uy) = (bx / l, by / l); let cos = a.0 * ux + a.1 * uy; let largo = (h / cos.max(1e-3)).min(h * 3.0); out.push((p.0 + signo * largo * ux, p.1 + signo * largo * uy)); } out }; let izq = lado(1.0); let der = lado(-1.0); let mut out = Vec::with_capacity(izq.len() + der.len() + 2 * ARCO); out.extend(izq.iter().copied()); // Cap redondo en la punta final: de `izq` a `der` girando alrededor del // último punto del centro, **por afuera** (hacia donde avanza el trazo). let ult = normales[n - 2]; out.extend(arco_entre( centro[n - 1], *izq.last().unwrap(), der[n - 1], (ult.1, -ult.0), ARCO, )); out.extend(der.iter().rev().copied()); // Y el de la punta inicial, hacia atrás. let pri = normales[0]; out.extend(arco_entre(centro[0], der[0], izq[0], (-pri.1, pri.0), ARCO)); out } /// Los puntos intermedios del arco que va de `a` a `b` girando alrededor de `c` /// y pasando **del lado de `hacia`** (sin incluir los extremos, que ya los puso /// el llamador). /// /// El lado importa y no es un detalle: un cap de trazo es un giro de 180° exactos, /// donde los dos sentidos son igual de «cortos» y el que elija `atan2` es un /// volado. Con el equivocado, la punta se dibuja hacia adentro del cuerpo y el /// contorno se muerde a sí mismo — el área sale casi la mitad de la que debería. fn arco_entre(c: Punto, a: Punto, b: Punto, hacia: Punto, pasos: usize) -> Vec { let (r, a0) = ((dist(c, a) + dist(c, b)) * 0.5, (a.1 - c.1).atan2(a.0 - c.0)); let a1 = (b.1 - c.1).atan2(b.0 - c.0); let mut d = a1 - a0; while d > core::f64::consts::TAU { d -= core::f64::consts::TAU; } while d < 0.0 { d += core::f64::consts::TAU; } // El punto medio decide: si cae del lado contrario al que mira la punta, el // arco se toma por el otro lado. let medio = a0 + d * 0.5; if medio.cos() * hacia.0 + medio.sin() * hacia.1 < 0.0 { d -= core::f64::consts::TAU; } (1..pasos) .map(|k| { let ang = a0 + d * (k as f64 / pasos as f64); (c.0 + r * ang.cos(), c.1 + r * ang.sin()) }) .collect() } /// El contorno de un rectángulo de esquinas redondeadas — la cápsula del /// micrófono y cualquier chip que haya que morfear. pub fn rect_redondo(x0: f64, y0: f64, x1: f64, y1: f64, r: f64) -> Vec { let r = r.min((x1 - x0).abs() * 0.5).min((y1 - y0).abs() * 0.5).max(0.0); if r <= 1e-9 { return vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]; } let mut out = Vec::with_capacity(4 * (ARCO + 1)); // Cada esquina: su cuarto de vuelta, en orden horario en coordenadas de // pantalla (y hacia abajo). for (cx, cy, desde) in [ (x1 - r, y0 + r, -core::f64::consts::FRAC_PI_2), (x1 - r, y1 - r, 0.0), (x0 + r, y1 - r, core::f64::consts::FRAC_PI_2), (x0 + r, y0 + r, core::f64::consts::PI), ] { for k in 0..=ARCO { let ang = desde + core::f64::consts::FRAC_PI_2 * (k as f64 / ARCO as f64); out.push((cx + r * ang.cos(), cy + r * ang.sin())); } } out } fn dist(a: Punto, b: Punto) -> f64 { let (dx, dy) = (a.0 - b.0, a.1 - b.1); (dx * dx + dy * dy).sqrt() } #[cfg(test)] mod tests { use super::*; fn cuadrado() -> Vec { vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] } fn rombo() -> Vec { vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)] } /// La razón de ser del remuestreo con cuota por arista: en `t = 0` el dibujo /// tiene que ser **el original**, con sus esquinas donde estaban. Con el /// remuestreo equiespaciado de manual las esquinas se liman y el glifo cambia /// de aspecto al empezar a animar, que es justo lo que no puede pasar. #[test] fn los_vertices_sobreviven_al_remuestreo() { let r = remuestrear(&cuadrado(), PUNTOS); assert_eq!(r.len(), PUNTOS, "la cuota tiene que cerrar en N exacto"); for v in cuadrado() { assert!( r.iter().any(|p| dist(*p, v) < 1e-9), "se perdió el vértice {v:?}" ); } } #[test] fn en_los_extremos_el_morfo_es_cada_silueta() { let a = Silueta::nueva([cuadrado()]); let b = Silueta::nueva([rombo()]); for v in cuadrado() { assert!(morfo(&a, &b, 0.0)[0].iter().any(|p| dist(*p, v) < 1e-9)); } for v in rombo() { assert!(morfo(&a, &b, 1.0)[0].iter().any(|p| dist(*p, v) < 1e-9)); } } /// El área a mitad de camino queda entre las dos: si se retorciera (desfase /// mal elegido, sentidos opuestos) el polígono se cruzaría y el área con /// signo se desplomaría. #[test] fn a_mitad_de_camino_el_area_esta_entre_las_dos() { let a = Silueta::nueva([cuadrado()]); let b = Silueta::nueva([rombo()]); let m = morfo(&a, &b, 0.5); let area = area_con_signo(&m[0]); assert!( area > 0.5 && area < 1.0, "área intermedia fuera de rango: {area}" ); } /// El sentido de giro se normaliza: un contorno declarado al revés no puede /// hacer que el morfo se dé vuelta como una cinta. #[test] fn el_sentido_se_normaliza() { let mut horario = cuadrado(); horario.reverse(); let a = Silueta::nueva([horario]); let b = Silueta::nueva([rombo()]); assert!(area_con_signo(&a.contornos()[0]) > 0.0); let area = area_con_signo(&morfo(&a, &b, 0.5)[0]); assert!(area > 0.5, "se dio vuelta: {area}"); } /// **El cambio de topología.** El hueco de una silueta de dos contornos no /// desaparece de un cuadro al otro: se cierra sobre el centroide de la otra. #[test] fn el_hueco_que_sobra_se_cierra_sobre_el_centroide() { let hueco = vec![(0.4, 0.4), (0.6, 0.4), (0.6, 0.6), (0.4, 0.6)]; let con_hueco = Silueta::nueva([cuadrado(), hueco]); let sin_hueco = Silueta::nueva([rombo()]); assert_eq!(con_hueco.contornos().len(), 2, "y el grande va primero"); assert!(area_con_signo(&con_hueco.contornos()[0]).abs() > 0.5); let medio = morfo(&con_hueco, &sin_hueco, 0.5); let area_medio = area_con_signo(&medio[1]).abs(); let final_ = morfo(&con_hueco, &sin_hueco, 1.0); let area_final = area_con_signo(&final_[1]).abs(); assert!(area_medio < 0.04 && area_medio > 0.0, "a mitad: {area_medio}"); assert!(area_final < 1e-9, "al final el hueco no existe: {area_final}"); } /// El desfase óptimo es lo que evita el giro fantasma: con el punto 0 de una /// forma en la otra punta, alinear por índice crudo recorre mucha más /// distancia que alinear por el mejor desfase. #[test] fn el_desfase_optimo_ahorra_recorrido() { let a = remuestrear(&cuadrado(), 8); let mut b = a.clone(); b.rotate_left(4); assert_eq!(desfase_optimo(&a, &b), 4, "el desfase que lo deja quieto"); } #[test] fn la_transicion_invertida_arranca_donde_esta() { let mut tr = Transicion::nueva(false, 200); assert_eq!(tr.valor(1_000), 0.0); tr.ir_a(true, 1_000); assert!(tr.activa(1_100)); let medio = tr.valor(1_100); assert!(medio > 0.2 && medio < 0.8, "a mitad de tirada: {medio}"); // Se arrepiente a mitad de camino: no puede saltar a 1 ni volver a 0 de // golpe — sale de donde está. tr.ir_a(false, 1_100); assert!((tr.valor(1_100) - medio).abs() < 1e-6); assert_eq!(tr.valor(1_400), 0.0, "y llega"); assert!(!tr.activa(1_400)); } #[test] fn una_transicion_recien_creada_no_anima() { let tr = Transicion::normal(true); assert_eq!(tr.valor(0), 1.0); assert!(!tr.activa(u64::MAX)); } /// La banda es un trazo hecho silueta: tiene que **cerrar**, tener área /// proporcional al largo por el grosor, y no cruzarse en el codo. #[test] fn la_banda_de_un_trazo_cierra_con_su_area() { let c = banda(&[(0.0, 0.0), (1.0, 0.0)], 0.2); let area = area_con_signo(&c).abs(); // Recto de largo 1 y grosor 0,2 más los dos semicírculos de radio 0,1. let esperada = 1.0 * 0.2 + core::f64::consts::PI * 0.01; assert!( (area - esperada).abs() < 0.01, "área {area}, esperada ~{esperada}" ); } #[test] fn el_codo_no_afina_la_banda() { // Una V de 90°: en el vértice el miter tiene que ensanchar, no cortar. let v = banda(&[(0.0, 0.0), (0.5, 0.5), (0.0, 1.0)], 0.2); assert!(area_con_signo(&v).abs() > 0.2, "el codo se comió la banda"); } #[test] fn el_rect_redondo_no_se_pasa_de_radio() { // Radio mayor que el medio lado: se clampea y sigue siendo un círculo, // no un polígono dado vuelta. let c = rect_redondo(0.0, 0.0, 1.0, 1.0, 9.0); let a = area_con_signo(&c).abs(); assert!(a > 0.7 && a < 0.9, "área del círculo inscripto: {a}"); } }