tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Geometría y proyección del **cono HSV/HSB en 3D** — la parte de sólo
2//! matemática (sin `View` ni vello), para poder testearla con asserts.
3//!
4//! ## El sólido HSV como cono
5//!
6//! El modelo HSV se representa canónicamente como un **cono** parado sobre su
7//! vértice: el ángulo alrededor del eje es el **tono** (H), la altura es el
8//! **valor** (V) y el radio es `S·V` (la **saturación** escala el radio del
9//! disco de ese valor). Así:
10//!
11//! - el **vértice** inferior (`V=0`) es negro (radio 0);
12//! - la **tapa** superior (`V=1`) es un disco: centro blanco (`S=0`), borde
13//! con el tono a plena saturación (`S=1`);
14//! - la **superficie lateral** (`S=1`) va del vértice negro al aro de colores
15//! puros.
16//!
17//! El sólido se centra en el origen: `z = v - 0.5` (rango `[-0.5, 0.5]`),
18//! `radio = s·v` (rango `[0, 1]`), `ángulo = h`.
19//!
20//! ## Proyección
21//!
22//! [`project_cone_point`] aplica **yaw** (giro sobre el eje vertical) + **pitch**
23//! (inclinación hacia el observador) y proyecta ortográficamente a coordenadas
24//! de pantalla normalizadas (`sx, sy ≈ [-1.4, 1.4]`, `sy` hacia arriba) más una
25//! **profundidad** para ordenar las caras de atrás hacia adelante (painter).
26//! El widget escala eso al rect del canvas.
27
28use alloc::vec::Vec;
29use core::f32::consts::PI;
30
31/// Segmentos alrededor del tono (H). Más = cono más redondo.
32pub const CONE_SEGMENTS: usize = 48;
33/// Anillos de valor (V) en la superficie lateral.
34pub const CONE_RINGS: usize = 12;
35/// Anillos de saturación (S) en la tapa superior.
36pub const CAP_RINGS: usize = 5;
37
38/// Orientación de la cámara que orbita el cono. `yaw` gira sobre el eje
39/// vertical; `pitch` inclina (positivo = ver el cono más "desde arriba").
40/// El caller la posee en su `Model` y la muta con [`ConeOrbit::orbit`].
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct ConeOrbit {
43 /// Giro alrededor del eje vertical (radianes).
44 pub yaw: f32,
45 /// Inclinación hacia el observador (radianes), acotada a `[0.05, 1.45]`.
46 pub pitch: f32,
47}
48
49impl Default for ConeOrbit {
50 fn default() -> Self {
51 // Un 3/4 clásico: girado y visto un poco desde arriba.
52 Self { yaw: 0.6, pitch: 0.55 }
53 }
54}
55
56impl ConeOrbit {
57 /// Acumula un arrastre en la órbita. `dyaw`/`dpitch` en radianes. El pitch
58 /// queda acotado para no cruzar los polos (el cono nunca se ve "de canto"
59 /// exacto ni invertido).
60 pub fn orbit(&mut self, dyaw: f32, dpitch: f32) {
61 self.yaw = (self.yaw + dyaw).rem_euclid(2.0 * PI);
62 self.pitch = (self.pitch + dpitch).clamp(0.05, 1.45);
63 }
64}
65
66/// Punto del sólido HSV en coordenadas de modelo, centrado en el origen.
67/// `h` en grados, `s`,`v` en `0..1`.
68pub fn hsv_model_point(h: f32, s: f32, v: f32) -> [f32; 3] {
69 let r = s * v; // radio del disco de valor `v`, escalado por saturación
70 let a = h.to_radians();
71 [r * a.cos(), r * a.sin(), v - 0.5]
72}
73
74/// Proyecta un punto de modelo a pantalla normalizada según la órbita.
75/// Devuelve `(sx, sy, depth)`: `sx` derecha, `sy` **arriba**, `depth` crece
76/// hacia el observador (ordenar ascendente = pintar de atrás hacia adelante).
77pub fn project_cone_point(p: [f32; 3], orbit: ConeOrbit) -> (f32, f32, f32) {
78 let (sy, cy) = orbit.yaw.sin_cos();
79 // Yaw sobre el eje vertical (z).
80 let x1 = p[0] * cy - p[1] * sy;
81 let y1 = p[0] * sy + p[1] * cy;
82 let z1 = p[2];
83 // Pitch sobre el eje x1 (inclina el eje vertical hacia el observador).
84 let (sp, cp) = orbit.pitch.sin_cos();
85 let y2 = y1 * cp - z1 * sp; // hacia adentro/afuera de la pantalla
86 let z2 = y1 * sp + z1 * cp; // vertical de pantalla
87 (x1, z2, y2)
88}
89
90/// Una cara del cono ya coloreada, en coordenadas de modelo (la proyección se
91/// hace al pintar, que es lo que depende de la órbita).
92#[derive(Debug, Clone, Copy)]
93pub struct ConeFacet {
94 /// Las 4 esquinas en coordenadas de modelo (quad, puede degenerar a
95 /// triángulo cerca del vértice).
96 pub corners: [[f32; 3]; 4],
97 /// Color plano de la cara (flat shading), RGB.
98 pub color: [u8; 3],
99}
100
101/// Genera la malla completa del cono: superficie lateral (`S=1`) + tapa
102/// superior (`V=1`). Geometría y color son fijos; la órbita sólo afecta la
103/// proyección. Con las constantes por defecto son `SEG·RINGS + SEG·CAP_RINGS`
104/// caras (~816).
105pub fn cone_facets() -> Vec<ConeFacet> {
106 let seg = CONE_SEGMENTS;
107 let mut out = Vec::with_capacity(seg * (CONE_RINGS + CAP_RINGS));
108 let dtheta = 360.0 / seg as f32;
109
110 // Superficie lateral: S = 1, v de 0 (vértice) a 1 (aro).
111 for ri in 0..CONE_RINGS {
112 let v0 = ri as f32 / CONE_RINGS as f32;
113 let v1 = (ri + 1) as f32 / CONE_RINGS as f32;
114 for si in 0..seg {
115 let t0 = si as f32 * dtheta;
116 let t1 = (si + 1) as f32 * dtheta;
117 let corners = [
118 hsv_model_point(t0, 1.0, v0),
119 hsv_model_point(t1, 1.0, v0),
120 hsv_model_point(t1, 1.0, v1),
121 hsv_model_point(t0, 1.0, v1),
122 ];
123 let color = crate::hsv_to_rgb((t0 + t1) * 0.5, 1.0, (v0 + v1) * 0.5);
124 out.push(ConeFacet { corners, color });
125 }
126 }
127
128 // Tapa superior: V = 1, s de 0 (centro blanco) a 1 (borde saturado).
129 for ri in 0..CAP_RINGS {
130 let s0 = ri as f32 / CAP_RINGS as f32;
131 let s1 = (ri + 1) as f32 / CAP_RINGS as f32;
132 for si in 0..seg {
133 let t0 = si as f32 * dtheta;
134 let t1 = (si + 1) as f32 * dtheta;
135 let corners = [
136 hsv_model_point(t0, s0, 1.0),
137 hsv_model_point(t1, s0, 1.0),
138 hsv_model_point(t1, s1, 1.0),
139 hsv_model_point(t0, s1, 1.0),
140 ];
141 let color = crate::hsv_to_rgb((t0 + t1) * 0.5, (s0 + s1) * 0.5, 1.0);
142 out.push(ConeFacet { corners, color });
143 }
144 }
145 out
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn vertice_es_el_negro_en_el_origen() {
154 // V=0 → radio 0, z=-0.5, sin importar tono/saturación.
155 for h in [0.0, 120.0, 240.0] {
156 let p = hsv_model_point(h, 1.0, 0.0);
157 assert!(p[0].abs() < 1e-6 && p[1].abs() < 1e-6);
158 assert!((p[2] + 0.5).abs() < 1e-6);
159 }
160 }
161
162 #[test]
163 fn aro_superior_tiene_radio_unidad() {
164 // V=1, S=1 → radio 1 en la tapa.
165 let p = hsv_model_point(0.0, 1.0, 1.0);
166 assert!(((p[0] * p[0] + p[1] * p[1]).sqrt() - 1.0).abs() < 1e-5);
167 assert!((p[2] - 0.5).abs() < 1e-6);
168 }
169
170 #[test]
171 fn centro_de_la_tapa_es_blanco_en_el_eje() {
172 // S=0 en V=1 → sobre el eje (radio 0), arriba.
173 let p = hsv_model_point(37.0, 0.0, 1.0);
174 assert!(p[0].abs() < 1e-6 && p[1].abs() < 1e-6);
175 assert!((p[2] - 0.5).abs() < 1e-6);
176 assert_eq!(crate::hsv_to_rgb(37.0, 0.0, 1.0), [255, 255, 255]);
177 }
178
179 #[test]
180 fn proyeccion_ordena_profundidad_frente_vs_dorso() {
181 let orbit = ConeOrbit::default();
182 // Dos puntos opuestos del aro: uno adelante, otro atrás. Su
183 // profundidad proyectada debe tener signo opuesto.
184 let front = project_cone_point(hsv_model_point(90.0, 1.0, 1.0), orbit);
185 let back = project_cone_point(hsv_model_point(270.0, 1.0, 1.0), orbit);
186 assert!(front.2 * back.2 < 0.0, "un lado adelante y otro atrás");
187 }
188
189 #[test]
190 fn pitch_mayor_sube_la_tapa_respecto_al_vertice() {
191 let orbit = ConeOrbit { yaw: 0.0, pitch: 0.6 };
192 let cima = project_cone_point([0.0, 0.0, 0.5], orbit); // centro tapa
193 let base = project_cone_point([0.0, 0.0, -0.5], orbit); // vértice
194 assert!(cima.1 > base.1, "la tapa se pinta más arriba que el vértice");
195 }
196
197 #[test]
198 fn orbit_acota_el_pitch() {
199 let mut o = ConeOrbit::default();
200 for _ in 0..100 {
201 o.orbit(0.1, 0.1);
202 }
203 assert!(o.pitch <= 1.45 + 1e-6);
204 for _ in 0..100 {
205 o.orbit(0.1, -0.1);
206 }
207 assert!(o.pitch >= 0.05 - 1e-6);
208 }
209
210 #[test]
211 fn malla_tiene_el_conteo_esperado() {
212 let f = cone_facets();
213 assert_eq!(f.len(), CONE_SEGMENTS * (CONE_RINGS + CAP_RINGS));
214 }
215}