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//! **Kit de ensamble SDF** — los ladrillos comunes con los que se escriben los seres
2//! analíticos ([`llimphi_3d::SdfSer`]): esfera, cápsula/cono, elipsoide, decals, y las
3//! operaciones de conjunto (trasladar, escalar, arquear una cadena).
4//!
5//! Existe porque [`ser_sdf`](crate::ser_sdf) nació con estos constructores como
6//! closures privadas dentro de una función de 400 líneas, y el bestiario
7//! ([`bicho_sdf`](crate::bicho_sdf)) y la vegetación ([`flora_sdf`](crate::flora_sdf))
8//! los necesitaban igual. Son cinco funciones triviales: lo que aportan no es
9//! abstracción sino **un solo lugar donde están las convenciones** — el orden de la
10//! lista importa (el `smin` acumula contra lo anterior), el `blend` se mide en las
11//! mismas unidades que el mundo, y los decals van SIEMPRE después de la geometría.
12//!
13//! Convención de espacio, igual que el ser humanoide: **base en `y = 0`**, centrado
14//! en X/Z, **mirando `+Z`**. El caller lo posa con
15//! [`SdfSer::posado`](llimphi_3d::SdfSer::posado).
16
17use llimphi_3d::glam::Vec3;
18use llimphi_3d::{SdfKind, SdfPrim};
19
20/// Color desde un hexadecimal `0xRRGGBB`, componente a componente en `[0,1]`.
21///
22/// **Sin corrección de gamma a propósito**: los colores del ser humanoide se
23/// eligieron a ojo mirando el render (`[0.90, 0.72, 0.58]` de piel), así que la
24/// paleta del bestiario se escribe en la MISMA escala. Convertir sRGB→lineal acá
25/// dejaría a los animales oscuros al lado de la gente.
26#[inline]
27pub fn rgb(hex: u32) -> [f32; 3] {
28 [
29 ((hex >> 16) & 0xFF) as f32 / 255.0,
30 ((hex >> 8) & 0xFF) as f32 / 255.0,
31 (hex & 0xFF) as f32 / 255.0,
32 ]
33}
34
35/// Mezcla lineal de dos colores (`t = 0` → `a`).
36#[inline]
37pub fn mezcla(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
38 let t = t.clamp(0.0, 1.0);
39 [
40 a[0] + (b[0] - a[0]) * t,
41 a[1] + (b[1] - a[1]) * t,
42 a[2] + (b[2] - a[2]) * t,
43 ]
44}
45
46/// Aclara (`k > 1`) u oscurece (`k < 1`) un color, con tope en 1.
47#[inline]
48pub fn tono(c: [f32; 3], k: f32) -> [f32; 3] {
49 [
50 (c[0] * k).clamp(0.0, 1.0),
51 (c[1] * k).clamp(0.0, 1.0),
52 (c[2] * k).clamp(0.0, 1.0),
53 ]
54}
55
56#[inline]
57pub(crate) fn v3(v: Vec3) -> [f32; 3] {
58 [v.x, v.y, v.z]
59}
60
61/// Esfera de radio `r` en `c`.
62#[inline]
63pub fn esfera(c: Vec3, r: f32, color: [f32; 3], blend: f32) -> SdfPrim {
64 SdfPrim {
65 kind: SdfKind::Esfera,
66 a: v3(c),
67 ra: r,
68 b: v3(c),
69 rb: 0.0,
70 color,
71 blend,
72 }
73}
74
75/// Cápsula/cono redondeado del segmento `a→b`, con radio `ra` en `a` y `rb` en `b`.
76/// Es el ladrillo de todo lo alargado: patas, cuellos, troncos, frondas, colas.
77#[inline]
78pub fn capsula(a: Vec3, ra: f32, b: Vec3, rb: f32, color: [f32; 3], blend: f32) -> SdfPrim {
79 SdfPrim {
80 kind: SdfKind::Capsula,
81 a: v3(a),
82 ra,
83 b: v3(b),
84 rb,
85 color,
86 blend,
87 }
88}
89
90/// Elipsoide centrado en `c` de **semiejes** `r` (no un segundo punto: el campo `b`
91/// del elipsoide son los semiejes, y trasladarlo es el error clásico — deja discos
92/// gigantes; ver `LECCIONES` del ser SDF).
93#[inline]
94pub fn elipsoide(c: Vec3, r: Vec3, color: [f32; 3], blend: f32) -> SdfPrim {
95 SdfPrim {
96 kind: SdfKind::Elipsoide,
97 a: v3(c),
98 ra: 0.0,
99 b: v3(r),
100 rb: 0.0,
101 color,
102 blend,
103 }
104}
105
106/// Decal esférico: **pinta** color sobre la superficie ya trazada, sin geometría.
107/// Manchas, rayas, ojos, pupilas, flores en la copa.
108#[inline]
109pub fn decal(c: Vec3, r: f32, color: [f32; 3]) -> SdfPrim {
110 SdfPrim {
111 kind: SdfKind::DecalEsfera,
112 a: v3(c),
113 ra: r,
114 b: v3(c),
115 rb: 0.0,
116 color,
117 blend: 0.1,
118 }
119}
120
121/// Decal capsular: pinta una franja a lo largo del segmento `a→b` (rayas de cebra,
122/// líneas de la boca, bandas de la abeja).
123#[inline]
124pub fn decal_linea(a: Vec3, b: Vec3, r: f32, color: [f32; 3]) -> SdfPrim {
125 SdfPrim {
126 kind: SdfKind::DecalCapsula,
127 a: v3(a),
128 ra: r,
129 b: v3(b),
130 rb: r,
131 color,
132 blend: 0.1,
133 }
134}
135
136/// Rota `v` alrededor de X (plano Y-Z; adelante = `+Z`).
137#[inline]
138pub fn rot_x(v: Vec3, a: f32) -> Vec3 {
139 let (s, c) = a.sin_cos();
140 Vec3::new(v.x, v.y * c - v.z * s, v.y * s + v.z * c)
141}
142
143/// Rota `v` alrededor de Y (rumbo).
144#[inline]
145pub fn rot_y(v: Vec3, a: f32) -> Vec3 {
146 let (s, c) = a.sin_cos();
147 Vec3::new(v.x * c + v.z * s, v.y, -v.x * s + v.z * c)
148}
149
150/// Rota `v` alrededor de Z (alabeo lateral).
151#[inline]
152pub fn rot_z(v: Vec3, a: f32) -> Vec3 {
153 let (s, c) = a.sin_cos();
154 Vec3::new(v.x * c - v.y * s, v.x * s + v.y * c, v.z)
155}
156
157/// **Escala no uniforme** todo un ensamble ya armado, alrededor del origen local.
158/// Es el truco del *aplastar y estirar* del ser humanoide, generalizado: en vez de
159/// propagar un factor por veinte fórmulas de proporción, se deforma la lista.
160///
161/// El elipsoide se deforma EXACTO (sus semiejes viven en `b`); los radios de esfera
162/// y cápsula son isótropos y se llevan la media de los tres factores — a las
163/// amplitudes de un rebote o un achatamiento de estilo, eso no se ve.
164pub fn escalar(prims: &mut [SdfPrim], k: Vec3) {
165 let km = (k.x + k.y + k.z) / 3.0;
166 for p in prims.iter_mut() {
167 p.a = [p.a[0] * k.x, p.a[1] * k.y, p.a[2] * k.z];
168 p.b = [p.b[0] * k.x, p.b[1] * k.y, p.b[2] * k.z];
169 p.ra *= km;
170 p.rb *= km;
171 p.blend *= km;
172 }
173}
174
175/// Traslada un ensamble ya armado. Ojo: el elipsoide **no** se traslada por `b`
176/// (son semiejes), sólo por `a`.
177pub fn trasladar(prims: &mut [SdfPrim], d: Vec3) {
178 for p in prims.iter_mut() {
179 p.a = [p.a[0] + d.x, p.a[1] + d.y, p.a[2] + d.z];
180 if p.kind != SdfKind::Elipsoide {
181 p.b = [p.b[0] + d.x, p.b[1] + d.y, p.b[2] + d.z];
182 }
183 }
184}
185
186/// **Cadena de cápsulas** por una polilínea, con radio interpolado de `r0` a `r1`.
187/// El ladrillo de todo lo que serpentea: la serpiente, la cola, el tronco curvo de
188/// una palmera, la liana. `blend` generoso = un solo cuerpo continuo.
189pub fn cadena(
190 pts: &[Vec3],
191 r0: f32,
192 r1: f32,
193 color: [f32; 3],
194 blend: f32,
195 out: &mut Vec<SdfPrim>,
196) {
197 if pts.len() < 2 {
198 return;
199 }
200 let n = (pts.len() - 1) as f32;
201 for (i, w) in pts.windows(2).enumerate() {
202 let t0 = i as f32 / n;
203 let t1 = (i + 1) as f32 / n;
204 out.push(capsula(
205 w[0],
206 r0 + (r1 - r0) * t0,
207 w[1],
208 r0 + (r1 - r0) * t1,
209 color,
210 blend,
211 ));
212 }
213}
214
215/// **Un ojo** en `c` mirando hacia `dir`: domo blanco (geometría, para que sobreviva
216/// el escorzo 3/4) + pupila y chispa como decals pegadas al domo.
217///
218/// Es el mismo diseño que la cara del ser humanoide, extraído para que el bestiario
219/// no invente otro ojo: dos técnicas de ojo en la misma escena se notan.
220/// `esclera = None` = ojo de animal sin blanco visible (la mayoría): sólo la pupila
221/// oscura sobre el pelaje, que es lo que se ve de verdad en un ciervo o un pájaro.
222pub fn ojo(
223 c: Vec3,
224 dir: Vec3,
225 r: f32,
226 esclera: Option<[f32; 3]>,
227 pupila: [f32; 3],
228 chispa: bool,
229 out: &mut Vec<SdfPrim>,
230) {
231 let d = dir.normalize_or_zero();
232 let d = if d.length_squared() < 0.5 { Vec3::Z } else { d };
233 match esclera {
234 Some(blanco) => {
235 out.push(esfera(c, r, blanco, r * 0.25));
236 out.push(decal(c + d * (r * 0.99), r * 0.48, pupila));
237 if chispa {
238 out.push(decal(
239 c + d * (r * 0.99) + Vec3::new(0.0, r * 0.26, 0.0) + d * (r * 0.10),
240 r * 0.20,
241 [0.98, 0.985, 0.99],
242 ));
243 }
244 }
245 None => {
246 // Globo oscuro como GEOMETRÍA: en un hocico peludo un decal plano se
247 // pierde con la luz rasante; la esferita capta su propio brillo.
248 out.push(esfera(c, r, pupila, r * 0.3));
249 if chispa {
250 out.push(decal(
251 c + d * (r * 0.92) + Vec3::new(0.0, r * 0.30, 0.0),
252 r * 0.30,
253 [0.97, 0.98, 0.99],
254 ));
255 }
256 }
257 }
258}
259
260/// **Extensión vertical** de un ensamble (mínimo y máximo de `y`), contando sólo la
261/// GEOMETRÍA: los decals pintan sobre lo ya trazado y no agrandan a nadie — si
262/// contaran, una mancha del patrón correría la altura del animal.
263pub fn extension_y(prims: &[SdfPrim]) -> (f32, f32) {
264 let mut lo = f32::INFINITY;
265 let mut hi = f32::NEG_INFINITY;
266 for p in prims {
267 let (a, b) = match p.kind {
268 SdfKind::Elipsoide => (p.a[1] - p.b[1], p.a[1] + p.b[1]),
269 SdfKind::Esfera => (p.a[1] - p.ra, p.a[1] + p.ra),
270 SdfKind::Capsula => (
271 (p.a[1] - p.ra).min(p.b[1] - p.rb),
272 (p.a[1] + p.ra).max(p.b[1] + p.rb),
273 ),
274 _ => continue,
275 };
276 lo = lo.min(a);
277 hi = hi.max(b);
278 }
279 if lo > hi {
280 (0.0, 0.0)
281 } else {
282 (lo, hi)
283 }
284}
285
286/// **Asienta y escala**: baja el ensamble hasta apoyar en `y = 0` y lo escala
287/// uniforme para que mida `alto`.
288///
289/// Es lo que deja escribir cada receta del catálogo en las unidades que le quedan
290/// cómodas (el cuadrúpedo mide su mundo en alturas de lomo, el ave en alturas
291/// totales) sin que nadie tenga que hacer la cuenta: la jirafa y la hormiga salen de
292/// la misma tabla. Devuelve el factor aplicado.
293pub fn asentar(prims: &mut Vec<SdfPrim>, alto: f32) -> f32 {
294 let (lo, hi) = extension_y(prims);
295 let h = hi - lo;
296 if h <= 1e-6 {
297 return 1.0;
298 }
299 trasladar(prims, Vec3::new(0.0, -lo, 0.0));
300 let k = alto / h;
301 escalar(prims, Vec3::splat(k));
302 k
303}
304
305/// Ruido hash determinista `[0,1)` a partir de dos enteros — la variedad por
306/// semilla del catálogo (qué tan torcido sale este árbol, de qué color esta remera)
307/// sin arrastrar un generador aleatorio por toda la API.
308#[inline]
309pub fn hash01(a: u32, b: u32) -> f32 {
310 let mut h = a.wrapping_mul(0x9E37_79B9) ^ b.wrapping_mul(0x85EB_CA77);
311 h ^= h >> 15;
312 h = h.wrapping_mul(0x2C1B_3C6D);
313 h ^= h >> 13;
314 (h & 0x00FF_FFFF) as f32 / 16_777_216.0
315}
316
317/// Igual que [`hash01`] pero centrado en `[-1, 1)`.
318#[inline]
319pub fn hash11(a: u32, b: u32) -> f32 {
320 hash01(a, b) * 2.0 - 1.0
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn rgb_mapea_el_hexadecimal() {
329 assert_eq!(rgb(0xFFFFFF), [1.0, 1.0, 1.0]);
330 assert_eq!(rgb(0x000000), [0.0, 0.0, 0.0]);
331 let c = rgb(0x804020);
332 assert!((c[0] - 0.502).abs() < 0.01 && (c[1] - 0.251).abs() < 0.01);
333 }
334
335 /// El elipsoide se traslada por `a` y NUNCA por `b` (sus semiejes): el error que
336 /// dejaba discos gigantes cuando nació el ser SDF.
337 #[test]
338 fn trasladar_no_toca_los_semiejes_del_elipsoide() {
339 let mut p = vec![elipsoide(Vec3::ZERO, Vec3::new(1.0, 2.0, 3.0), [1.0; 3], 0.1)];
340 trasladar(&mut p, Vec3::new(10.0, 0.0, 0.0));
341 assert_eq!(p[0].a, [10.0, 0.0, 0.0]);
342 assert_eq!(p[0].b, [1.0, 2.0, 3.0], "los semiejes no se mueven");
343 }
344
345 #[test]
346 fn escalar_deforma_el_elipsoide_por_eje() {
347 let mut p = vec![elipsoide(Vec3::new(0.0, 1.0, 0.0), Vec3::ONE, [1.0; 3], 0.1)];
348 escalar(&mut p, Vec3::new(2.0, 0.5, 1.0));
349 assert_eq!(p[0].a, [0.0, 0.5, 0.0]);
350 assert_eq!(p[0].b, [2.0, 0.5, 1.0]);
351 }
352
353 #[test]
354 fn cadena_hila_los_tramos_con_radio_interpolado() {
355 let pts = [Vec3::ZERO, Vec3::Y, Vec3::new(0.0, 2.0, 0.0)];
356 let mut out = Vec::new();
357 cadena(&pts, 1.0, 0.0, [1.0; 3], 0.1, &mut out);
358 assert_eq!(out.len(), 2);
359 assert_eq!(out[0].ra, 1.0);
360 assert!((out[0].rb - 0.5).abs() < 1e-6, "mitad del recorrido = mitad del radio");
361 assert_eq!(out[1].rb, 0.0);
362 }
363
364 #[test]
365 fn asentar_apoya_en_cero_y_da_la_altura_pedida() {
366 let mut p = vec![
367 esfera(Vec3::new(0.0, 5.0, 0.0), 1.0, [1.0; 3], 0.1),
368 capsula(Vec3::new(0.0, 2.0, 0.0), 0.5, Vec3::new(0.0, 4.0, 0.0), 0.5, [1.0; 3], 0.1),
369 ];
370 let k = asentar(&mut p, 3.0);
371 let (lo, hi) = extension_y(&p);
372 assert!(lo.abs() < 1e-5, "apoya en 0, no en {lo}");
373 assert!((hi - 3.0).abs() < 1e-5, "mide 3, no {hi}");
374 assert!((k - 3.0 / 4.5).abs() < 1e-5, "factor {k}");
375 }
376
377 /// Un decal no puede agrandar al ser: pinta sobre lo que ya está trazado.
378 #[test]
379 fn los_decals_no_cuentan_para_la_altura() {
380 let base = vec![esfera(Vec3::new(0.0, 1.0, 0.0), 1.0, [1.0; 3], 0.1)];
381 let mut con = base.clone();
382 con.push(decal(Vec3::new(0.0, 9.0, 0.0), 0.5, [1.0; 3]));
383 assert_eq!(extension_y(&base), extension_y(&con));
384 }
385
386 #[test]
387 fn el_hash_es_determinista_y_reparte() {
388 assert_eq!(hash01(3, 7), hash01(3, 7));
389 assert_ne!(hash01(3, 7), hash01(3, 8));
390 let n = 400;
391 let media: f32 = (0..n).map(|i| hash01(i, 1)).sum::<f32>() / n as f32;
392 assert!((media - 0.5).abs() < 0.06, "media {media} lejos de 0.5");
393 }
394}