tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definitions

⎇ main widgets/color-picker/src/cone.rs Rust · 215 lines · 8.0 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}