tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-voxel/src/mineral.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
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}