gioser

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main llimphi-voxel/src/mineral.rs Rust · 557 siq'ikuna · 20.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! **Minerales** embebidos en la roca del terreno: vetas, pepitas, cristales,
2//! estratos y geodas. A diferencia de la [`Forma`](crate::Forma) de flora (que se
3//! posa *sobre* el suelo), un mineral **reemplaza** vóxels sólidos bajo la piel del
4//! terreno, así que se lee en las paredes de roca expuestas (acantilados, cortes).
5//!
6//! Cada [`Mineral`] trae **tipo, color y dureza Mohs** intrínsecos; el [`Aspecto`]
7//! decide cómo se distribuye en la roca, y [`MineralUso`] lo instancia en un bioma
8//! con su abundancia y banda de profundidad. La dureza es visible: los blandos
9//! salen terrosos/granulados, los duros cristalinos y las gemas (Mohs ≥ 7)
10//! destellan. Todo es función pura de la posición de mundo → seamless en streaming.
11
12use serde::{Deserialize, Serialize};
13
14use crate::terrain::{hash2, smooth};
15
16/// **Cómo se distribuye** un mineral dentro de la roca.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum Aspecto {
19    /// Vetas: láminas finas diagonales que cruzan la roca.
20    Veta,
21    /// Pepitas: bolsones sólidos aislados.
22    Pepita,
23    /// Diseminado: motas sueltas de un vóxel salpicadas.
24    Diseminado,
25    /// Estrato: capa que sigue el relieve, rota lateralmente.
26    Estrato,
27    /// Geoda: bolsones redondeados (bolsas cristalinas).
28    Geoda,
29    /// Cristal: racimos con sesgo vertical (cristales que crecen hacia arriba).
30    Cristal,
31}
32
33impl Aspecto {
34    /// Todos los aspectos (para ciclar en un editor).
35    pub const ALL: [Aspecto; 6] = [
36        Aspecto::Veta,
37        Aspecto::Pepita,
38        Aspecto::Diseminado,
39        Aspecto::Estrato,
40        Aspecto::Geoda,
41        Aspecto::Cristal,
42    ];
43
44    /// Nombre legible (español).
45    pub fn label(self) -> &'static str {
46        match self {
47            Aspecto::Veta => "veta",
48            Aspecto::Pepita => "pepita",
49            Aspecto::Diseminado => "diseminado",
50            Aspecto::Estrato => "estrato",
51            Aspecto::Geoda => "geoda",
52            Aspecto::Cristal => "cristal",
53        }
54    }
55
56    /// El aspecto siguiente (cicla).
57    pub fn next(self) -> Aspecto {
58        let i = Aspecto::ALL.iter().position(|&a| a == self).unwrap_or(0);
59        Aspecto::ALL[(i + 1) % Aspecto::ALL.len()]
60    }
61}
62
63/// **Catálogo de minerales** semilla: tipo con color y dureza Mohs de fábrica, más
64/// el aspecto y la banda de profundidad típicos. Ordenado de blando/superficial a
65/// duro/profundo.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub enum Mineral {
68    /// Carbón — negro mate, blando, en estratos poco profundos.
69    Carbon,
70    /// Sal (halita) — blanco rosado, blando, en estratos.
71    Sal,
72    /// Cobre (malaquita) — verde azulado, en vetas.
73    Cobre,
74    /// Hierro (hematita) — pardo rojizo, en vetas.
75    Hierro,
76    /// Plata — gris claro metálico, en vetas.
77    Plata,
78    /// Oro — dorado, en pepitas, profundo.
79    Oro,
80    /// Lapislázuli — azul intenso, en estratos.
81    Lapis,
82    /// Cuarzo — blanco translúcido, duro, en vetas.
83    Cuarzo,
84    /// Amatista — violeta, dura, en geodas.
85    Amatista,
86    /// Esmeralda — verde vivo, dura, en cristales.
87    Esmeralda,
88    /// Rubí — rojo, muy duro, en cristales profundos.
89    Rubi,
90    /// Diamante — blanco cian, el más duro, en pepitas muy profundas.
91    Diamante,
92    // --- Exóticos ---
93    /// Obsidiana — vidrio volcánico negro y lustroso.
94    Obsidiana,
95    /// Pirita — «oro de los tontos», latón metálico en cubos.
96    Pirita,
97    /// Zafiro — azul profundo, casi tan duro como el diamante.
98    Zafiro,
99    /// Topacio — gema dorada translúcida.
100    Topacio,
101    /// Turquesa — celeste opaco en vetas.
102    Turquesa,
103    /// Ópalo — nácar pálido iridiscente, en geodas.
104    Opalo,
105    /// Ámbar — resina fósil anaranjada, cerca de la superficie.
106    Ambar,
107    /// Fluorita — cristal violeta translúcido.
108    Fluorita,
109    /// Jade — verde sedoso en vetas.
110    Jade,
111    /// Cinabrio — bermellón intenso (mena de mercurio).
112    Cinabrio,
113    /// Bismuto — iridiscente rosa-violeta escalonado, profundo.
114    Bismuto,
115    /// Uraninita — verde radiactivo, rara y muy profunda.
116    Uraninita,
117}
118
119impl Mineral {
120    /// Color RGB de fábrica.
121    pub fn color(self) -> [u8; 3] {
122        match self {
123            Mineral::Carbon => [34, 34, 40],
124            Mineral::Sal => [232, 220, 224],
125            Mineral::Cobre => [70, 176, 150],
126            Mineral::Hierro => [150, 96, 74],
127            Mineral::Plata => [200, 206, 214],
128            Mineral::Oro => [232, 192, 84],
129            Mineral::Lapis => [42, 72, 182],
130            Mineral::Cuarzo => [224, 226, 236],
131            Mineral::Amatista => [150, 92, 206],
132            Mineral::Esmeralda => [40, 182, 112],
133            Mineral::Rubi => [200, 44, 64],
134            Mineral::Diamante => [196, 232, 240],
135            Mineral::Obsidiana => [26, 24, 32],
136            Mineral::Pirita => [198, 168, 84],
137            Mineral::Zafiro => [40, 78, 190],
138            Mineral::Topacio => [222, 182, 92],
139            Mineral::Turquesa => [64, 190, 190],
140            Mineral::Opalo => [224, 214, 228],
141            Mineral::Ambar => [216, 138, 44],
142            Mineral::Fluorita => [150, 96, 210],
143            Mineral::Jade => [72, 168, 116],
144            Mineral::Cinabrio => [190, 40, 44],
145            Mineral::Bismuto => [186, 132, 196],
146            Mineral::Uraninita => [124, 202, 62],
147        }
148    }
149
150    /// **Dureza Mohs** `[1,10]`.
151    pub fn dureza(self) -> f32 {
152        match self {
153            Mineral::Carbon => 1.5,
154            Mineral::Sal => 2.5,
155            Mineral::Cobre => 3.5,
156            Mineral::Plata => 2.7,
157            Mineral::Oro => 2.7,
158            Mineral::Hierro => 5.5,
159            Mineral::Lapis => 5.5,
160            Mineral::Cuarzo => 7.0,
161            Mineral::Amatista => 7.0,
162            Mineral::Esmeralda => 7.5,
163            Mineral::Rubi => 9.0,
164            Mineral::Diamante => 10.0,
165            Mineral::Cinabrio => 2.2,
166            Mineral::Ambar => 2.5,
167            Mineral::Bismuto => 2.5,
168            Mineral::Fluorita => 4.0,
169            Mineral::Turquesa => 5.5,
170            Mineral::Obsidiana => 5.5,
171            Mineral::Uraninita => 6.0,
172            Mineral::Opalo => 6.0,
173            Mineral::Pirita => 6.2,
174            Mineral::Jade => 6.5,
175            Mineral::Topacio => 8.0,
176            Mineral::Zafiro => 9.0,
177        }
178    }
179
180    /// Aspecto típico de este mineral.
181    pub fn aspecto(self) -> Aspecto {
182        match self {
183            Mineral::Carbon | Mineral::Sal | Mineral::Lapis => Aspecto::Estrato,
184            Mineral::Cobre | Mineral::Hierro | Mineral::Plata | Mineral::Cuarzo => Aspecto::Veta,
185            Mineral::Oro | Mineral::Diamante => Aspecto::Pepita,
186            Mineral::Amatista => Aspecto::Geoda,
187            Mineral::Esmeralda | Mineral::Rubi => Aspecto::Cristal,
188            Mineral::Obsidiana | Mineral::Turquesa | Mineral::Jade | Mineral::Cinabrio => Aspecto::Veta,
189            Mineral::Pirita | Mineral::Ambar | Mineral::Uraninita => Aspecto::Pepita,
190            Mineral::Zafiro | Mineral::Topacio | Mineral::Fluorita => Aspecto::Cristal,
191            Mineral::Opalo | Mineral::Bismuto => Aspecto::Geoda,
192        }
193    }
194
195    /// Banda de profundidad típica `(min, max)` como fracción bajo la superficie
196    /// `[0,1]` (0 = superficie, 1 = fondo del mundo).
197    pub fn profundidad(self) -> (f32, f32) {
198        match self {
199            Mineral::Carbon => (0.04, 0.30),
200            Mineral::Sal => (0.10, 0.45),
201            Mineral::Cobre => (0.12, 0.70),
202            Mineral::Hierro => (0.12, 0.85),
203            Mineral::Plata => (0.30, 0.85),
204            Mineral::Lapis => (0.40, 0.85),
205            Mineral::Oro => (0.45, 0.95),
206            Mineral::Cuarzo => (0.10, 0.80),
207            Mineral::Amatista => (0.35, 0.85),
208            Mineral::Esmeralda => (0.50, 0.95),
209            Mineral::Rubi => (0.55, 0.95),
210            Mineral::Diamante => (0.70, 1.00),
211            Mineral::Ambar => (0.02, 0.25),
212            Mineral::Turquesa => (0.10, 0.50),
213            Mineral::Cinabrio => (0.15, 0.55),
214            Mineral::Obsidiana => (0.10, 0.60),
215            Mineral::Jade => (0.20, 0.70),
216            Mineral::Pirita => (0.20, 0.72),
217            Mineral::Opalo => (0.30, 0.80),
218            Mineral::Fluorita => (0.30, 0.80),
219            Mineral::Topacio => (0.45, 0.90),
220            Mineral::Zafiro => (0.55, 0.95),
221            Mineral::Bismuto => (0.60, 0.95),
222            Mineral::Uraninita => (0.72, 1.00),
223        }
224    }
225
226    /// **Brillo/reflectividad** `[0,1]` de fábrica: cuánto lustre especular y
227    /// glaseado devuelve al sol. Terrosos (carbón, sal) ~0; metales (plata, oro)
228    /// medio-alto; gemas cristalinas (cuarzo→diamante) alto. Lo aprovecha el
229    /// ray-march (a diferencia de la luz plana de Minecraft) como reflejo
230    /// dependiente de la vista.
231    pub fn brillo(self) -> f32 {
232        match self {
233            Mineral::Carbon => 0.05,
234            Mineral::Sal => 0.15,
235            Mineral::Hierro => 0.25,
236            Mineral::Lapis => 0.22,
237            Mineral::Cobre => 0.4,
238            Mineral::Plata => 0.72,
239            Mineral::Oro => 0.78,
240            Mineral::Cuarzo => 0.82,
241            Mineral::Amatista => 0.85,
242            Mineral::Esmeralda => 0.9,
243            Mineral::Rubi => 0.95,
244            Mineral::Diamante => 1.0,
245            Mineral::Cinabrio => 0.5,
246            Mineral::Turquesa => 0.52,
247            Mineral::Jade => 0.6,
248            Mineral::Uraninita => 0.6,
249            Mineral::Ambar => 0.68,
250            Mineral::Fluorita => 0.78,
251            Mineral::Pirita => 0.85,
252            Mineral::Opalo => 0.88,
253            Mineral::Obsidiana => 0.9,
254            Mineral::Topacio => 0.9,
255            Mineral::Bismuto => 0.92,
256            Mineral::Zafiro => 0.95,
257        }
258    }
259
260    /// Abundancia típica `[0,1]` (los raros/preciosos, más bajos).
261    pub fn abundancia(self) -> f32 {
262        match self {
263            Mineral::Carbon | Mineral::Hierro | Mineral::Cuarzo => 0.5,
264            Mineral::Sal | Mineral::Cobre => 0.4,
265            Mineral::Plata | Mineral::Lapis | Mineral::Amatista => 0.3,
266            Mineral::Oro => 0.25,
267            Mineral::Esmeralda => 0.18,
268            Mineral::Rubi => 0.16,
269            Mineral::Diamante => 0.12,
270            Mineral::Obsidiana => 0.35,
271            Mineral::Pirita | Mineral::Turquesa | Mineral::Jade | Mineral::Fluorita => 0.22,
272            Mineral::Cinabrio => 0.20,
273            Mineral::Ambar => 0.18,
274            Mineral::Opalo | Mineral::Topacio => 0.16,
275            Mineral::Zafiro => 0.14,
276            Mineral::Bismuto => 0.12,
277            Mineral::Uraninita => 0.10,
278        }
279    }
280
281    /// Todos los minerales (catálogo, para ciclar en un editor).
282    pub const ALL: [Mineral; 24] = [
283        Mineral::Carbon,
284        Mineral::Sal,
285        Mineral::Cobre,
286        Mineral::Hierro,
287        Mineral::Plata,
288        Mineral::Oro,
289        Mineral::Lapis,
290        Mineral::Cuarzo,
291        Mineral::Amatista,
292        Mineral::Esmeralda,
293        Mineral::Rubi,
294        Mineral::Diamante,
295        Mineral::Obsidiana,
296        Mineral::Pirita,
297        Mineral::Zafiro,
298        Mineral::Topacio,
299        Mineral::Turquesa,
300        Mineral::Opalo,
301        Mineral::Ambar,
302        Mineral::Fluorita,
303        Mineral::Jade,
304        Mineral::Cinabrio,
305        Mineral::Bismuto,
306        Mineral::Uraninita,
307    ];
308
309    /// Nombre legible (español).
310    pub fn label(self) -> &'static str {
311        match self {
312            Mineral::Carbon => "carbón",
313            Mineral::Sal => "sal",
314            Mineral::Cobre => "cobre",
315            Mineral::Hierro => "hierro",
316            Mineral::Plata => "plata",
317            Mineral::Oro => "oro",
318            Mineral::Lapis => "lapislázuli",
319            Mineral::Cuarzo => "cuarzo",
320            Mineral::Amatista => "amatista",
321            Mineral::Esmeralda => "esmeralda",
322            Mineral::Rubi => "rubí",
323            Mineral::Diamante => "diamante",
324            Mineral::Obsidiana => "obsidiana",
325            Mineral::Pirita => "pirita",
326            Mineral::Zafiro => "zafiro",
327            Mineral::Topacio => "topacio",
328            Mineral::Turquesa => "turquesa",
329            Mineral::Opalo => "ópalo",
330            Mineral::Ambar => "ámbar",
331            Mineral::Fluorita => "fluorita",
332            Mineral::Jade => "jade",
333            Mineral::Cinabrio => "cinabrio",
334            Mineral::Bismuto => "bismuto",
335            Mineral::Uraninita => "uraninita",
336        }
337    }
338
339    /// El mineral siguiente (cicla).
340    pub fn next(self) -> Mineral {
341        let i = Mineral::ALL.iter().position(|&m| m == self).unwrap_or(0);
342        Mineral::ALL[(i + 1) % Mineral::ALL.len()]
343    }
344}
345
346/// **Uso de un mineral en un bioma**, con **todos sus factores editables**: el tipo
347/// semilla (nombre) + cómo se distribuye ([`Aspecto`]), su abundancia `[0,1]`, la
348/// banda de profundidad `(min, max)`, y los factores autorables **color**, **dureza**
349/// y **brillo** — que arrancan de los de fábrica del mineral pero se pueden ajustar
350/// en la UI del studio sin tocar el catálogo. `mineral_at` usa estos campos
351/// (no el enum), así que editar cualquiera cambia el render.
352#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
353pub struct MineralUso {
354    pub mineral: Mineral,
355    pub aspecto: Aspecto,
356    pub abundancia: f32,
357    pub prof_min: f32,
358    pub prof_max: f32,
359    /// Color efectivo (default = [`Mineral::color`]).
360    pub color: [u8; 3],
361    /// Dureza Mohs efectiva (default = [`Mineral::dureza`]); afecta el grano.
362    pub dureza: f32,
363    /// Brillo/reflectividad efectivo (default = [`Mineral::brillo`]); afecta el lustre.
364    pub brillo: f32,
365}
366
367impl MineralUso {
368    /// Instancia con **todos** los valores típicos del propio mineral.
369    pub fn of(mineral: Mineral) -> Self {
370        let (prof_min, prof_max) = mineral.profundidad();
371        Self {
372            mineral,
373            aspecto: mineral.aspecto(),
374            abundancia: mineral.abundancia(),
375            prof_min,
376            prof_max,
377            color: mineral.color(),
378            dureza: mineral.dureza(),
379            brillo: mineral.brillo(),
380        }
381    }
382
383    /// Igual que [`of`](Self::of) pero forzando la abundancia (útil para el demo o
384    /// para un bioma más rico/pobre en un mineral).
385    pub fn with_abundancia(mineral: Mineral, abundancia: f32) -> Self {
386        Self { abundancia, ..Self::of(mineral) }
387    }
388
389    /// Reinicia los factores autorables (color/dureza/brillo/aspecto/profundidad) a
390    /// los de fábrica de `mineral`, conservando la abundancia. Lo usa la UI al
391    /// cambiar el tipo semilla.
392    pub fn reset_to(&mut self, mineral: Mineral) {
393        let ab = self.abundancia;
394        *self = Self::of(mineral);
395        self.abundancia = ab;
396    }
397}
398
399// =============================================================================
400//  Distribución espacial (ruido de mundo)
401// =============================================================================
402
403/// Hash 3D determinista `[0,1)` (combina [`hash2`] sobre las tres coordenadas).
404#[inline]
405fn hash3(x: i32, y: i32, z: i32, seed: u32) -> f32 {
406    let a = x.wrapping_mul(0x1B).wrapping_add(z);
407    let b = y.wrapping_mul(0x2F).wrapping_sub(z.wrapping_mul(0x11));
408    hash2(a, b, seed)
409}
410
411/// Ruido de valor 3D suave `[0,1]` (interpolación trilineal de la retícula hash).
412fn value3(fx: f32, fy: f32, fz: f32, seed: u32) -> f32 {
413    let (xi, yi, zi) = (fx.floor() as i32, fy.floor() as i32, fz.floor() as i32);
414    let (tx, ty, tz) = (smooth(fx - fx.floor()), smooth(fy - fy.floor()), smooth(fz - fz.floor()));
415    let c = |dx, dy, dz| hash3(xi + dx, yi + dy, zi + dz, seed);
416    let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
417    let x00 = l(c(0, 0, 0), c(1, 0, 0), tx);
418    let x10 = l(c(0, 1, 0), c(1, 1, 0), tx);
419    let x01 = l(c(0, 0, 1), c(1, 0, 1), tx);
420    let x11 = l(c(0, 1, 1), c(1, 1, 1), tx);
421    l(l(x00, x10, ty), l(x01, x11, ty), tz)
422}
423
424/// `true` si el vóxel de mundo `(wx,y,wz)` cae dentro de un depósito de `aspecto`
425/// con abundancia `ab`. Puramente espacial (la banda de profundidad se filtra fuera).
426pub(crate) fn aspecto_hit(aspecto: Aspecto, wx: i32, y: u32, wz: i32, seed: u32, ab: f32) -> bool {
427    let (fx, fy, fz) = (wx as f32, y as f32, wz as f32);
428    match aspecto {
429        Aspecto::Diseminado => hash3(wx, y as i32, wz, seed) < ab * 0.35,
430        Aspecto::Veta => {
431            let n = value3(fx * 0.22, fy * 0.22, fz * 0.22, seed);
432            (n - 0.5).abs() < 0.03 + ab * 0.05
433        }
434        Aspecto::Pepita => {
435            let n = value3(fx * 0.16, fy * 0.16, fz * 0.16, seed ^ 0x55);
436            n > 1.0 - (0.06 + ab * 0.14)
437        }
438        Aspecto::Cristal => {
439            let n = value3(fx * 0.2, fy * 0.12, fz * 0.2, seed ^ 0xC7);
440            n > 1.0 - (0.05 + ab * 0.12)
441        }
442        Aspecto::Estrato => {
443            let n = value3(fx * 0.08, fy * 0.5, fz * 0.08, seed ^ 0x09);
444            n < 0.55 + ab * 0.4
445        }
446        Aspecto::Geoda => {
447            let n = value3(fx * 0.13, fy * 0.13, fz * 0.13, seed ^ 0x6A);
448            n > 1.0 - (0.05 + ab * 0.10)
449        }
450    }
451}
452
453/// Colorea un vóxel de mineral aplicando la **dureza** como textura: blandos →
454/// terrosos/granulados; duros → lisos; gemas (Mohs ≥ 7) → destello en algunos vóxels.
455pub(crate) fn mineral_shade(base: [u8; 3], dureza: f32, wx: i32, y: u32, wz: i32, seed: u32) -> [u8; 3] {
456    let d = (dureza / 10.0).clamp(0.0, 1.0);
457    // Grano decreciente con la dureza.
458    let grain = 0.45 * (1.0 - d);
459    let h = hash2(wx.wrapping_mul(9).wrapping_add(y as i32 * 7), wz, seed ^ 0x6817);
460    let bright = 1.0 + (h - 0.5) * 2.0 * grain * 0.3;
461    let mut c = [
462        (base[0] as f32 * bright).clamp(0.0, 255.0) as u8,
463        (base[1] as f32 * bright).clamp(0.0, 255.0) as u8,
464        (base[2] as f32 * bright).clamp(0.0, 255.0) as u8,
465    ];
466    if dureza >= 7.0 {
467        // Destello de gema.
468        let s = hash2(wx.wrapping_mul(5).wrapping_add(y as i32), wz, seed ^ 0x6E3);
469        if s > 0.72 {
470            let b = 1.28;
471            c = [
472                (c[0] as f32 * b).min(255.0) as u8,
473                (c[1] as f32 * b).min(255.0) as u8,
474                (c[2] as f32 * b).min(255.0) as u8,
475            ];
476        }
477    }
478    c
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn catalogo_coherente() {
487        // Dureza en rango Mohs y duro ⇒ menos abundante que el más común.
488        for m in Mineral::ALL {
489            let d = m.dureza();
490            assert!((1.0..=10.0).contains(&d), "{} dureza fuera de Mohs", m.label());
491            let (a, b) = m.profundidad();
492            assert!(a >= 0.0 && b <= 1.0 && a < b, "{} banda inválida", m.label());
493        }
494        assert!(Mineral::Diamante.abundancia() < Mineral::Hierro.abundancia());
495        assert!(Mineral::Diamante.dureza() > Mineral::Carbon.dureza());
496    }
497
498    #[test]
499    fn aspecto_es_determinista_y_esparce() {
500        let seed = 7;
501        // Determinista.
502        assert_eq!(
503            aspecto_hit(Aspecto::Veta, 12, 8, -4, seed, 0.5),
504            aspecto_hit(Aspecto::Veta, 12, 8, -4, seed, 0.5)
505        );
506        // Al menos algunos hits en un volumen (no todo vacío ni todo lleno).
507        let mut hits = 0;
508        let mut total = 0;
509        for wx in 0..40 {
510            for y in 0..40u32 {
511                for wz in 0..40 {
512                    total += 1;
513                    if aspecto_hit(Aspecto::Pepita, wx, y, wz, seed, 0.5) {
514                        hits += 1;
515                    }
516                }
517            }
518        }
519        assert!(hits > 0 && hits < total, "pepita: {hits}/{total}");
520    }
521
522    #[test]
523    fn brillo_ordena_de_mate_a_gema() {
524        // Terroso mate < metal < gema; el brillo se codifica en un alpha que sigue
525        // siendo sólido para el shader (>127).
526        assert!(Mineral::Carbon.brillo() < Mineral::Oro.brillo());
527        assert!(Mineral::Oro.brillo() < Mineral::Diamante.brillo());
528        for m in Mineral::ALL {
529            let b = m.brillo();
530            assert!((0.0..=1.0).contains(&b), "{} brillo fuera de rango", m.label());
531            let a = llimphi_3d::VoxelGrid::shine_to_alpha(b);
532            assert!(a > 127, "{} alpha {a} dejaría de ser sólido", m.label());
533        }
534        // El más lustroso debe dar el alpha más bajo (más brillo).
535        assert!(
536            llimphi_3d::VoxelGrid::shine_to_alpha(Mineral::Diamante.brillo())
537                < llimphi_3d::VoxelGrid::shine_to_alpha(Mineral::Carbon.brillo())
538        );
539    }
540
541    #[test]
542    fn dureza_afecta_el_grano() {
543        // Un mineral blando varía más de color entre vóxels que uno duro (comparado
544        // bajo el umbral de destello de gema, 7, para aislar el grano).
545        let spread = |dureza: f32| {
546            let mut min = 255i32;
547            let mut max = 0i32;
548            for k in 0..30 {
549                let c = mineral_shade([120, 120, 120], dureza, k, 3, k * 2, 1);
550                min = min.min(c[0] as i32);
551                max = max.max(c[0] as i32);
552            }
553            max - min
554        };
555        assert!(spread(1.5) > spread(6.0), "blando debería granular más que duro");
556    }
557}