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//! Conexión IA **"poor"** del studio. Convierte prosa en artefactos del modelo:
2//! un [`Bioma`] (relieve + materiales), una [`SceneSpec`] o un [`CharSpec`]. Dos
3//! caminos en cascada donde aplica: LLM real (`pluma-llm`, autodetecta backend) y,
4//! si no hay modelo o falla el parseo, una **heurística local** offline — así el
5//! botón siempre produce algo.
6//!
7//! Tras el rediseño por niveles, los materiales son **autorables** (ids en el
8//! `Project`), así que la generación de un bioma referencia los materiales semilla
9//! vía [`MatRefs`] en vez de un `enum` cerrado.
10
11use llimphi_voxel::{Bioma, CharSpec, Clip, Forma, Mineral, MineralUso, ObjetoUso, Project, SceneSpec};
12use pluma_llm::pluma_llm_core::ChatRequest;
13use serde::Deserialize;
14
15/// Ids de los materiales **semilla** del proyecto que un bioma puede referenciar.
16/// Se resuelven una vez (con [`from_project`](Self::from_project)) y se capturan en
17/// el worker de IA — así la generación no necesita el `Project` entero.
18#[derive(Debug, Clone, Copy)]
19pub struct MatRefs {
20 pub sand: u64,
21 pub grass: u64,
22 pub rock: u64,
23 pub snow: u64,
24 pub cactus: u64,
25 pub leaf: u64,
26 pub wheat: u64,
27 pub bloom: u64,
28 pub tierra: u64,
29 pub arcilla: u64,
30 pub grava: u64,
31 pub arenisca: u64,
32 pub basalto: u64,
33 pub granito: u64,
34 pub marmol: u64,
35 pub hielo: u64,
36 pub lava: u64,
37 pub musgo: u64,
38}
39
40impl MatRefs {
41 /// Resuelve los ids semilla del proyecto (0 si falta alguno — no debería con el
42 /// proyecto de arranque).
43 pub fn from_project(p: &Project) -> Self {
44 use llimphi_voxel::Material::*;
45 let id = |m| p.material_id_for(m).unwrap_or(0);
46 Self {
47 sand: id(Sand),
48 grass: id(Grass),
49 rock: id(Rock),
50 snow: id(Snow),
51 cactus: id(Cactus),
52 leaf: id(Leaf),
53 wheat: id(Wheat),
54 bloom: id(Bloom),
55 tierra: id(Tierra),
56 arcilla: id(Arcilla),
57 grava: id(Grava),
58 arenisca: id(Arenisca),
59 basalto: id(Basalto),
60 granito: id(Granito),
61 marmol: id(Marmol),
62 hielo: id(Hielo),
63 lava: id(Lava),
64 musgo: id(Musgo),
65 }
66 }
67}
68
69/// Pregunta al LLM real (de `pluma-llm`, `from_env`) y devuelve el texto. `None` si
70/// el backend es **Mock** (sin credenciales) o si la red falla. Motor compartido de
71/// la asistencia; cada caller parsea la salida a su artefacto.
72fn ask_llm(system: &str, prompt: &str, max_tokens: u32) -> Option<String> {
73 let client = pluma_llm::del_sistema(None, Some("voxel-studio")).ok()?;
74 if client.model_id().to_lowercase().contains("mock") {
75 return None;
76 }
77 let req = ChatRequest::una_vuelta(prompt, max_tokens)
78 .con_sistema(system)
79 .con_temperatura(0.5);
80 let rt = tokio::runtime::Builder::new_current_thread()
81 .enable_all()
82 .build()
83 .ok()?;
84 rt.block_on(client.complete(&req)).ok().map(|r| r.content)
85}
86
87/// Recorta un texto al literal RON `( … )` que contenga (tolera ``` y ruido).
88fn ron_slice(text: &str) -> Option<&str> {
89 let start = text.find('(')?;
90 let end = text.rfind(')')?;
91 (end > start).then(|| &text[start..=end])
92}
93
94// =============================================================================
95// Biomas
96// =============================================================================
97
98/// **Bioma desde prosa** (heurística local por palabras clave). Arranca de un preset
99/// (desierto o pradera) referenciando los materiales semilla `m` y lo ajusta según
100/// términos del prompt. Offline e instantánea — el alma "poor". El id queda en 0; lo
101/// asigna el `Project` al agregarlo.
102pub fn generate_bioma(prompt: &str, m: &MatRefs) -> Bioma {
103 let p = prompt.to_lowercase();
104 let has = |k: &str| p.contains(k);
105
106 let desierto = has("desierto") || has("arena") || has("seco") || has("duna");
107 let mut b = if desierto {
108 Bioma {
109 id: 0,
110 name: name_from(prompt, "bioma"),
111 base: 0.30,
112 dune: 0.05,
113 relief: 0.45,
114 mountains: 0.12,
115 water_level: 0.26,
116 rivers: 0.18,
117 peak_at: 1.0,
118 ground: m.sand,
119 cliff: m.rock,
120 peak: None,
121 objetos: vec![ObjetoUso { material: m.cactus, densidad: 0.010, forma: Forma::Columnar }],
122 minerales: vec![],
123 densidad_urbana: 0.4,
124 casas: Vec::new(),
125 fauna: vec![],
126 seres: vec![],
127 }
128 } else {
129 Bioma {
130 id: 0,
131 name: name_from(prompt, "bioma"),
132 base: 0.22,
133 dune: 0.10,
134 relief: 0.7,
135 mountains: 0.5,
136 water_level: 0.30,
137 rivers: 0.25,
138 peak_at: 0.80,
139 ground: m.grass,
140 cliff: m.rock,
141 peak: Some(m.snow),
142 objetos: vec![],
143 minerales: vec![],
144 densidad_urbana: 0.4,
145 casas: Vec::new(),
146 fauna: vec![],
147 seres: vec![],
148 }
149 };
150
151 if has("nieve") || has("monta") || has("montañ") || has("cumbre") || has("alpin") || has("pico") {
152 b.relief = b.relief.max(0.8);
153 b.mountains = b.mountains.max(0.65);
154 b.peak = Some(m.snow);
155 b.peak_at = 0.62;
156 }
157 if has("llano") || has("plano") || has("pradera") || has("planicie") || has("estepa") {
158 b.relief = b.relief.min(0.35);
159 b.mountains = b.mountains.min(0.2);
160 }
161 if has("agua") || has("lago") || has("mar") || has("océano") || has("oceano") || has("isla") || has("inund") {
162 b.water_level = b.water_level.max(0.5);
163 }
164 if has("río") || has("rio") || has("cauce") || has("arroyo") {
165 b.rivers = b.rivers.max(0.6);
166 }
167 if has("bosque") || has("selva") || has("verde") || has("pasto") {
168 b.ground = m.grass;
169 }
170 if has("cactus") || desierto {
171 if !b.objetos.iter().any(|o| o.forma == Forma::Columnar) {
172 b.objetos.push(ObjetoUso { material: m.cactus, densidad: 0.012, forma: Forma::Columnar });
173 }
174 }
175 // --- Vegetación por palabra clave (cada forma sólo si no está ya presente) ---
176 let add = |b: &mut Bioma, material: u64, densidad: f32, forma: Forma| {
177 if !b.objetos.iter().any(|o| o.forma == forma) {
178 b.objetos.push(ObjetoUso { material, densidad, forma });
179 }
180 };
181 if has("bosque") || has("selva") || has("árbol") || has("arbol") || has("frondo") {
182 add(&mut b, m.leaf, 0.045, Forma::Arbol);
183 }
184 if has("selva") || has("pino") || has("conífer") || has("conifer") || has("abeto") || has("nieve") || has("alpin") {
185 add(&mut b, m.leaf, 0.030, Forma::Conifera);
186 }
187 if has("palmera") || has("playa") || has("tropical") || has("oasis") || has("costa") {
188 add(&mut b, m.leaf, 0.010, Forma::Palmera);
189 }
190 if has("arbusto") || has("matorral") || has("maleza") || has("bosque") {
191 add(&mut b, m.leaf, 0.025, Forma::Arbusto);
192 }
193 if has("helecho") || has("selva") || has("húmed") || has("humed") || has("sotobosque") {
194 add(&mut b, m.cactus, 0.030, Forma::Helecho);
195 }
196 if has("flor") || has("jardín") || has("jardin") || has("prado") || has("pradera") {
197 add(&mut b, m.cactus, 0.012, Forma::Flor);
198 }
199 if has("pasto") || has("grama") || has("césped") || has("cesped") || has("pradera") || has("hierba") {
200 add(&mut b, m.grass, 0.06, Forma::Pasto);
201 }
202 // --- 2ª tanda: especies de selva / bosque / pradera ---
203 if has("selva") || has("jungla") || has("tropical") || has("bambú") || has("bambu") || has("caña") {
204 add(&mut b, m.cactus, 0.05, Forma::Bambu);
205 }
206 if has("selva") || has("jungla") || has("banano") || has("platan") || has("hoja grande") {
207 add(&mut b, m.leaf, 0.03, Forma::Platanera);
208 }
209 if has("roble") || has("encina") || has("añoso") || has("anoso") || has("frondo") {
210 add(&mut b, m.leaf, 0.015, Forma::Roble);
211 }
212 if has("hongo") || has("seta") || has("champiñón") || has("champinon") || has("húmed") || has("humed") {
213 add(&mut b, m.leaf, 0.012, Forma::Hongo);
214 }
215 if has("tocón") || has("tocon") || has("tronco") || has("cepa") || has("talado") || has("viejo bosque") {
216 add(&mut b, m.leaf, 0.006, Forma::Tocon);
217 }
218 if has("trigo") || has("cereal") || has("espiga") || has("cultivo") || has("dorado") || has("campo") {
219 add(&mut b, m.wheat, 0.05, Forma::Trigo);
220 }
221 if has("junco") || has("cañaveral") || has("canaveral") || has("totora") || has("humedal") || has("orilla") {
222 add(&mut b, m.leaf, 0.010, Forma::Junco);
223 }
224 if has("lavanda") || has("flores") || has("silvestre") || has("prado") || has("colorido") {
225 add(&mut b, m.bloom, 0.02, Forma::Lavanda);
226 }
227
228 // --- Terreno por palabra clave (la tanda de materiales de suelo/roca) ---
229 if has("volcán") || has("volcan") || has("lava") || has("basalto") || has("infierno") {
230 b.ground = m.basalto;
231 b.cliff = m.basalto;
232 b.peak = Some(m.lava);
233 b.peak_at = 0.7;
234 }
235 if has("glaciar") || has("hielo") || has("ártico") || has("artico") || has("polar") || has("congelad") {
236 b.ground = m.hielo;
237 b.cliff = m.hielo;
238 b.peak = Some(m.hielo);
239 }
240 if has("arcilla") || has("arcillos") || has("barro") || has("badland") {
241 b.ground = m.arcilla;
242 }
243 if has("arenisca") || has("cañón") || has("canon") || has("meseta") || has("mesa") {
244 b.cliff = m.arenisca;
245 }
246 if has("granito") || has("pedregos") || has("rocoso") || has("montañoso") {
247 b.cliff = m.granito;
248 }
249 if has("mármol") || has("marmol") || has("pulid") || has("templo") {
250 b.cliff = m.marmol;
251 b.peak = Some(m.marmol);
252 }
253 if has("tierra") || has("labrant") || has("arado") || has("campo") {
254 b.ground = m.tierra;
255 }
256 if has("grava") || has("cantera") || has("escombr") {
257 b.ground = m.grava;
258 }
259 if has("musgo") || has("húmed") || has("humed") || has("ciénaga") || has("cienaga") || has("pantano") {
260 b.ground = m.musgo;
261 }
262
263 // --- Minerales por palabra clave (además del suelo común: carbón + hierro) ---
264 let mineral = |b: &mut Bioma, min: Mineral| {
265 if !b.minerales.iter().any(|u| u.mineral == min) {
266 b.minerales.push(MineralUso::of(min));
267 }
268 };
269 // Toda roca trae los comunes salvo que sea un mundo sin relieve pelado.
270 if b.relief > 0.2 {
271 mineral(&mut b, Mineral::Carbon);
272 mineral(&mut b, Mineral::Hierro);
273 }
274 if has("mineral") || has("mina") || has("cueva") || has("caverna") || has("subsuelo") || has("roca") {
275 mineral(&mut b, Mineral::Cobre);
276 mineral(&mut b, Mineral::Cuarzo);
277 }
278 if has("carbón") || has("carbon") || has("hulla") {
279 mineral(&mut b, Mineral::Carbon);
280 }
281 if has("hierro") || has("metal") || has("óxido") || has("oxido") {
282 mineral(&mut b, Mineral::Hierro);
283 }
284 if has("cobre") || has("bronce") {
285 mineral(&mut b, Mineral::Cobre);
286 }
287 if has("oro") || has("dorado") || has("tesoro") || has("rico") {
288 mineral(&mut b, Mineral::Oro);
289 }
290 if has("plata") || has("argent") {
291 mineral(&mut b, Mineral::Plata);
292 }
293 if has("sal") || has("salino") || has("halita") {
294 mineral(&mut b, Mineral::Sal);
295 }
296 if has("lapis") || has("lapislázuli") || has("lapislazuli") || has("azul") {
297 mineral(&mut b, Mineral::Lapis);
298 }
299 if has("cuarzo") || has("cristal") {
300 mineral(&mut b, Mineral::Cuarzo);
301 }
302 if has("amatista") || has("geoda") || has("violeta") || has("púrpura") || has("purpura") {
303 mineral(&mut b, Mineral::Amatista);
304 }
305 if has("esmeralda") || has("gema") || has("joya") || has("selva") || has("jungla") {
306 mineral(&mut b, Mineral::Esmeralda);
307 }
308 if has("rubí") || has("rubi") || has("rojo") {
309 mineral(&mut b, Mineral::Rubi);
310 }
311 if has("diamante") || has("brillante") || has("preciad") {
312 mineral(&mut b, Mineral::Diamante);
313 }
314 b
315}
316
317// =============================================================================
318// Escenas
319// =============================================================================
320
321/// "Brief" mínimo que el LLM emite para una escena: cuántos actores y qué gesto.
322#[derive(Deserialize)]
323struct SceneBrief {
324 actors: usize,
325 gesture: Clip,
326}
327
328const SCENE_SYSTEM: &str = "\
329Dirigís una escena voxel corta. Responde SÓLO con un literal RON de la forma
330(actors: N, gesture: G), sin markdown ni texto extra. N = cantidad de personajes
331(1..5). G = el gesto final: uno de Idle, Walk, Run, Wave, Point, Cheer.
332Ejemplo: (actors: 3, gesture: Cheer)";
333
334/// **Escena desde prosa** en el mundo `mundo` (id): LLM real (brief) → escena patrón;
335/// si no hay LLM o falla, heurística local. Siempre devuelve algo.
336pub fn generate_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> SceneSpec {
337 llm_scene(prompt, mundo, dim).unwrap_or_else(|| local_scene(prompt, mundo, dim))
338}
339
340fn llm_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> Option<SceneSpec> {
341 let brief: SceneBrief = ron::from_str(ron_slice(&ask_llm(SCENE_SYSTEM, prompt, 80)?)?).ok()?;
342 Some(SceneSpec::walk_and_emote(
343 scene_name(prompt),
344 mundo,
345 brief.actors.clamp(1, 5),
346 brief.gesture,
347 dim,
348 ))
349}
350
351fn local_scene(prompt: &str, mundo: u64, dim: [u32; 3]) -> SceneSpec {
352 let p = prompt.to_lowercase();
353 let n = parse_count(&p);
354 let gesture = if p.contains("salud") {
355 Clip::Wave
356 } else if p.contains("festej") || p.contains("celebr") || p.contains("baila") || p.contains("fiesta") {
357 Clip::Cheer
358 } else if p.contains("señal") || p.contains("apunt") || p.contains("muestr") {
359 Clip::Point
360 } else {
361 Clip::Wave
362 };
363 SceneSpec::walk_and_emote(scene_name(prompt), mundo, n, gesture, dim)
364}
365
366/// Cuántos actores pide el texto (palabra o dígito); 3 por defecto.
367fn parse_count(p: &str) -> usize {
368 for (w, n) in [
369 ("cinco", 5usize), ("cuatro", 4), ("tres", 3), ("dos", 2), ("una", 1), ("uno", 1),
370 (" 5", 5), (" 4", 4), (" 3", 3), (" 2", 2), (" 1", 1),
371 ] {
372 if p.contains(w) {
373 return n;
374 }
375 }
376 3
377}
378
379// =============================================================================
380// Personajes (seres)
381// =============================================================================
382
383const CHAR_SYSTEM: &str = "\
384Generás un personaje voxel. Responde SÓLO con un literal RON de CharSpec, sin
385markdown ni texto extra. Campos: name (texto), age (Baby|Child|Teen|Adult|Elder),
386skin/shirt/pants (cada uno [r, g, b] en 0..1).
387Ejemplo: (name: \"rojo\", age: Adult, skin: [0.9, 0.72, 0.58], shirt: [0.82, 0.28, 0.26], pants: [0.2, 0.2, 0.28])";
388
389/// **Personaje desde prosa**: LLM real (`CharSpec` en RON) y, si no hay o falla, la
390/// heurística local. Siempre devuelve algo (id 0; lo asigna el `Project`).
391pub fn generate_character(prompt: &str) -> CharSpec {
392 llm_character(prompt).unwrap_or_else(|| local_character(prompt))
393}
394
395fn llm_character(prompt: &str) -> Option<CharSpec> {
396 ron::from_str::<CharSpec>(ron_slice(&ask_llm(CHAR_SYSTEM, prompt, 200)?)?).ok()
397}
398
399fn local_character(prompt: &str) -> CharSpec {
400 use llimphi_voxel::Age;
401 let p = prompt.to_lowercase();
402 let age = if p.contains("bebé") || p.contains("bebe") || p.contains("recién") || p.contains("recien") {
403 Age::Baby
404 } else if p.contains("niñ") || p.contains("nin") || p.contains("chic") {
405 Age::Child
406 } else if p.contains("joven") || p.contains("adolesc") || p.contains("teen") {
407 Age::Teen
408 } else if p.contains("ancian") || p.contains("viej") || p.contains("abuel") {
409 Age::Elder
410 } else {
411 Age::Adult
412 };
413 let mut c = CharSpec::new(scene_name(prompt), age);
414 if let Some(rgb) = parse_color(&p) {
415 c.shirt = rgb;
416 }
417 c
418}
419
420/// Color (`[r,g,b]` en `[0,1]`) nombrado en el texto, si lo hay.
421fn parse_color(p: &str) -> Option<[f32; 3]> {
422 let table: [(&str, [f32; 3]); 12] = [
423 ("roj", [0.82, 0.28, 0.26]),
424 ("celest", [0.50, 0.75, 0.92]),
425 ("azul", [0.22, 0.55, 0.78]),
426 ("verd", [0.30, 0.70, 0.40]),
427 ("amarill", [0.92, 0.80, 0.30]),
428 ("naranj", [0.90, 0.50, 0.22]),
429 ("violet", [0.62, 0.40, 0.78]),
430 ("morad", [0.62, 0.40, 0.78]),
431 ("rosa", [0.90, 0.55, 0.70]),
432 ("blanc", [0.92, 0.92, 0.92]),
433 ("negr", [0.14, 0.14, 0.16]),
434 ("marr", [0.45, 0.32, 0.22]),
435 ];
436 table.iter().find(|(k, _)| p.contains(k)).map(|(_, c)| *c)
437}
438
439/// Nombre de escena = primeras ~4 palabras de la descripción.
440fn scene_name(prompt: &str) -> String {
441 name_from(prompt, "escena IA")
442}
443
444/// Nombre a partir de las primeras ~4 palabras del prompt, con un default.
445fn name_from(prompt: &str, default: &str) -> String {
446 let s: String = prompt.split_whitespace().take(4).collect::<Vec<_>>().join(" ");
447 if s.is_empty() {
448 default.into()
449 } else {
450 s
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 fn refs() -> MatRefs {
459 MatRefs::from_project(&Project::starter())
460 }
461
462 #[test]
463 fn heuristica_detecta_desierto_con_cactus() {
464 let m = refs();
465 let b = generate_bioma("un desierto enorme con cactus", &m);
466 assert_eq!(b.ground, m.sand);
467 assert!(b.objetos.iter().any(|o| o.material == m.cactus));
468 }
469
470 #[test]
471 fn heuristica_detecta_montañas_nevadas() {
472 let m = refs();
473 let b = generate_bioma("cordillera con picos de nieve", &m);
474 assert_eq!(b.peak, Some(m.snow));
475 assert!(b.mountains >= 0.65);
476 }
477
478 #[test]
479 fn bioma_referencia_materiales_del_proyecto() {
480 let p = Project::starter();
481 let m = MatRefs::from_project(&p);
482 let b = generate_bioma("pradera verde con ríos", &m);
483 // Los ids referenciados existen como materiales del proyecto.
484 assert!(p.material(b.ground).is_some());
485 assert!(p.material(b.cliff).is_some());
486 }
487
488 #[test]
489 fn personaje_lee_edad_y_color() {
490 use llimphi_voxel::Age;
491 let c = local_character("un niño de remera roja");
492 assert_eq!(c.age, Age::Child);
493 assert_eq!(c.shirt, [0.82, 0.28, 0.26]);
494 }
495
496 #[test]
497 fn escena_lee_cantidad_y_gesto() {
498 let s = local_scene("dos personajes que festejan", 7, [128, 56, 128]);
499 assert_eq!(s.actors.len(), 2);
500 assert_eq!(s.mundo, 7);
501 let tiene_cheer = s.actors[0].keys.iter().any(|k| k.clip == Some(Clip::Cheer));
502 assert!(tiene_cheer, "festejar → Cheer");
503 }
504}