gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-voxel/src/documento.rs Rust · 502 lines · 19.5 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1// =============================================================================
2//  llimphi-voxel :: documento — abrir, guardar y combinar escenas
3// -----------------------------------------------------------------------------
4//  El estudio administra su trabajo por ARCHIVOS. Un `Documento` envuelve un
5//  `Project` (los seis niveles: Leyes → Materiales → Seres → Biomas → Mundos →
6//  Escenas) con una marca y una versión, y se serializa en RON —editable a mano,
7//  diffeable, combinable—. Tres operaciones lo gobiernan:
8//
9//    * ABRIR / GUARDAR: `Documento::abrir` / `guardar` (o `de_ron`/`a_ron` sin
10//      tocar disco, para que la UI decida el path).
11//    * COMBINAR: `Project::combinar` funde otro proyecto dentro, REMAPEANDO ids
12//      e índices para que nada colisione y TODAS las referencias cruzadas se
13//      reconecten. Es lo que permite armar una escena juntando piezas de varios
14//      archivos.
15//    * EXTRAER: `Project::extraer_escena` saca una escena con TODA su clausura
16//      de dependencias (su mundo, sus biomas, sus materiales/seres, sus leyes)
17//      en un proyecto mínimo y portable — el fragmento que se comparte y se
18//      combina en otro lado.
19//
20//  Núcleo agnóstico de UI (vive en `llimphi-voxel`, no en el studio): la lógica
21//  de archivos es del dominio; el frontend sólo elige rutas y pinta botones.
22// =============================================================================
23
24use std::collections::{BTreeSet, HashMap};
25use std::path::Path;
26
27use serde::{Deserialize, Serialize};
28
29use crate::studio::Project;
30
31/// Marca del format — distingue un documento del estudio de un RON ajeno.
32pub const MAGIA_DOC: &str = "tawasuyu-voxel-studio";
33/// Versión del format serializado. Sube al cambiar la forma de `Project`.
34pub const VERSION_DOC: u32 = 1;
35
36/// Por qué falló abrir, guardar o parsear un documento.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ErrorDoc {
39    /// No se pudo serializar el proyecto a texto.
40    Serializar(String),
41    /// El texto no es un documento válido.
42    Parsear(String),
43    /// El archivo no lleva nuestra marca — es otro tipo de RON.
44    MagiaAjena,
45    /// El archivo es de una versión más nueva que la que este binario entiende.
46    VersionFutura(u32),
47    /// Se pidió extraer/combinar una escena que no existe.
48    EscenaAusente(u64),
49    /// Fallo de lectura/escritura en disco.
50    Io(String),
51}
52
53impl std::fmt::Display for ErrorDoc {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            ErrorDoc::Serializar(e) => write!(f, "no se pudo serializar: {e}"),
57            ErrorDoc::Parsear(e) => write!(f, "no se pudo leer el documento: {e}"),
58            ErrorDoc::MagiaAjena => write!(f, "el archivo no es un documento del studio"),
59            ErrorDoc::VersionFutura(v) => write!(f, "documento de una versión más nueva (v{v})"),
60            ErrorDoc::EscenaAusente(id) => write!(f, "no existe la escena {id}"),
61            ErrorDoc::Io(e) => write!(f, "error de disco: {e}"),
62        }
63    }
64}
65
66impl std::error::Error for ErrorDoc {}
67
68/// Un documento del estudio: el proyecto entero, con marca y versión.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Documento {
71    /// Debe ser [`MAGIA_DOC`].
72    pub magia: String,
73    /// Versión del format (ver [`VERSION_DOC`]).
74    pub version: u32,
75    /// El proyecto — los seis niveles.
76    pub proyecto: Project,
77}
78
79impl Documento {
80    /// Envuelve un proyecto en un documento de la versión actual.
81    pub fn nuevo(proyecto: Project) -> Self {
82        Self {
83            magia: MAGIA_DOC.to_string(),
84            version: VERSION_DOC,
85            proyecto,
86        }
87    }
88
89    /// Serializa a RON legible.
90    pub fn a_ron(&self) -> Result<String, ErrorDoc> {
91        ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
92            .map_err(|e| ErrorDoc::Serializar(e.to_string()))
93    }
94
95    /// Parsea desde RON, verificando la marca y la versión. Un archivo con marca
96    /// ajena o de una versión futura se rechaza en vez de malinterpretarse.
97    pub fn de_ron(texto: &str) -> Result<Documento, ErrorDoc> {
98        let doc: Documento = ron::from_str(texto).map_err(|e| ErrorDoc::Parsear(e.to_string()))?;
99        if doc.magia != MAGIA_DOC {
100            return Err(ErrorDoc::MagiaAjena);
101        }
102        if doc.version > VERSION_DOC {
103            return Err(ErrorDoc::VersionFutura(doc.version));
104        }
105        Ok(doc)
106    }
107
108    /// Guarda el documento en `path` (RON). La UI elige la ruta.
109    pub fn guardar(&self, path: impl AsRef<Path>) -> Result<(), ErrorDoc> {
110        let texto = self.a_ron()?;
111        std::fs::write(path, texto).map_err(|e| ErrorDoc::Io(e.to_string()))
112    }
113
114    /// Abre un documento desde `path` (RON).
115    pub fn abrir(path: impl AsRef<Path>) -> Result<Documento, ErrorDoc> {
116        let texto = std::fs::read_to_string(path).map_err(|e| ErrorDoc::Io(e.to_string()))?;
117        Self::de_ron(&texto)
118    }
119
120    /// Serializa al format NATIVO compacto (postcard, determinista) — el mismo
121    /// que usa el resto de la suite en disco/cable. Para archivos chicos y
122    /// direccionables por contenido (BLAKE3). La UI elige RON o postcard.
123    pub fn a_bytes(&self) -> Result<Vec<u8>, ErrorDoc> {
124        postcard::to_allocvec(self).map_err(|e| ErrorDoc::Serializar(e.to_string()))
125    }
126
127    /// Parsea desde el format nativo compacto, verificando marca y versión.
128    pub fn de_bytes(bytes: &[u8]) -> Result<Documento, ErrorDoc> {
129        let doc: Documento = postcard::from_bytes(bytes).map_err(|e| ErrorDoc::Parsear(e.to_string()))?;
130        if doc.magia != MAGIA_DOC {
131            return Err(ErrorDoc::MagiaAjena);
132        }
133        if doc.version > VERSION_DOC {
134            return Err(ErrorDoc::VersionFutura(doc.version));
135        }
136        Ok(doc)
137    }
138}
139
140/// El mapeo de ids viejos → nuevos que produjo una combinación. Útil para que la
141/// UI reubique la selección tras combinar, o para depurar.
142#[derive(Debug, Clone, Default)]
143pub struct Remapeo {
144    /// id en el proyecto de origen → id nuevo en el destino.
145    pub id: HashMap<u64, u64>,
146    /// índice de `seres` en el origen → índice en el destino (los actores de una
147    /// escena referencian al ser por POSICIÓN, no por id).
148    pub indice_sere: HashMap<usize, usize>,
149}
150
151impl Remapeo {
152    /// El id nuevo de `viejo`, o el mismo si no estaba en el mapa (referencia
153    /// colgante en el origen — se deja como estaba, no se inventa).
154    fn id(&self, viejo: u64) -> u64 {
155        *self.id.get(&viejo).unwrap_or(&viejo)
156    }
157}
158
159impl Project {
160    /// Combina `otro` DENTRO de este proyecto. Reserva ids nuevos para cada item
161    /// de `otro`, clona sus items y reconecta TODAS las referencias cruzadas
162    /// (parents de material, leyes usadas, materiales/seres de bioma, biomas de
163    /// mundo, mundo de escena, e índices de actor). Nada colisiona y nada queda
164    /// apuntando al proyecto viejo. Devuelve el [`Remapeo`] aplicado.
165    pub fn combinar(&mut self, otro: &Project) -> Remapeo {
166        let mut r = Remapeo::default();
167        let offset_seres = self.seres.len();
168
169        // 1. Reservar un id nuevo para cada item de `otro`, en orden de nivel.
170        for l in &otro.leyes {
171            r.id.insert(l.id, self.alloc_id());
172        }
173        for m in &otro.materiales {
174            r.id.insert(m.id, self.alloc_id());
175        }
176        for s in &otro.seres {
177            r.id.insert(s.id, self.alloc_id());
178        }
179        for b in &otro.biomas {
180            r.id.insert(b.id, self.alloc_id());
181        }
182        for w in &otro.mundos {
183            r.id.insert(w.id, self.alloc_id());
184        }
185        for e in &otro.escenas {
186            r.id.insert(e.id, self.alloc_id());
187        }
188
189        // 2. Clonar y reescribir cada item con sus referencias remapeadas.
190        for l in &otro.leyes {
191            let mut l = l.clone();
192            l.id = r.id(l.id);
193            self.leyes.push(l);
194        }
195        for m in &otro.materiales {
196            let mut m = m.clone();
197            m.id = r.id(m.id);
198            m.parent = m.parent.map(|p| r.id(p));
199            for uso in &mut m.leyes {
200                uso.ley = r.id(uso.ley);
201            }
202            self.materiales.push(m);
203        }
204        for (i, s) in otro.seres.iter().enumerate() {
205            let mut s = s.clone();
206            s.id = r.id(s.id);
207            r.indice_sere.insert(i, offset_seres + i);
208            self.seres.push(s);
209        }
210        for b in &otro.biomas {
211            let mut b = b.clone();
212            b.id = r.id(b.id);
213            b.ground = r.id(b.ground);
214            b.cliff = r.id(b.cliff);
215            b.peak = b.peak.map(|p| r.id(p));
216            for o in &mut b.objetos {
217                o.material = r.id(o.material);
218            }
219            for uso in &mut b.seres {
220                uso.sere = r.id(uso.sere);
221            }
222            self.biomas.push(b);
223        }
224        for w in &otro.mundos {
225            let mut w = w.clone();
226            w.id = r.id(w.id);
227            for bid in &mut w.biomas {
228                *bid = r.id(*bid);
229            }
230            self.mundos.push(w);
231        }
232        for e in &otro.escenas {
233            let mut e = e.clone();
234            e.id = r.id(e.id);
235            e.mundo = r.id(e.mundo);
236            for a in &mut e.actors {
237                a.character += offset_seres;
238            }
239            for s in &mut e.sucesos {
240                if let crate::studio::TipoSuceso::Aparece(id) = &mut s.tipo {
241                    if *id != 0 {
242                        *id = r.id(*id);
243                    }
244                }
245            }
246            self.escenas.push(e);
247        }
248
249        r
250    }
251
252    /// Extrae la escena `escena_id` con TODA su clausura de dependencias en un
253    /// `Project` nuevo, mínimo y portable: sólo el mundo, los biomas, los
254    /// materiales (con su cadena de padres y sus leyes), los seres y las leyes
255    /// que la escena realmente usa. Los índices de actor se reajustan al
256    /// subconjunto de seres. `None` si la escena no existe.
257    pub fn extraer_escena(&self, escena_id: u64) -> Option<Project> {
258        let escena = self.escenas.iter().find(|e| e.id == escena_id)?.clone();
259
260        // --- Recolectar los ids necesarios, nivel por nivel ---
261        let mut mundos = BTreeSet::new();
262        let mut biomas = BTreeSet::new();
263        let mut materiales = BTreeSet::new();
264        let mut seres = BTreeSet::new();
265        let mut leyes = BTreeSet::new();
266
267        mundos.insert(escena.mundo);
268        if let Some(w) = self.mundo(escena.mundo) {
269            for &bid in &w.biomas {
270                biomas.insert(bid);
271            }
272        }
273        for &bid in &biomas {
274            if let Some(b) = self.bioma(bid) {
275                materiales.insert(b.ground);
276                materiales.insert(b.cliff);
277                if let Some(p) = b.peak {
278                    materiales.insert(p);
279                }
280                for o in &b.objetos {
281                    materiales.insert(o.material);
282                }
283                for uso in &b.seres {
284                    seres.insert(uso.sere);
285                }
286            }
287        }
288        // Los actores de la escena referencian seres por índice → sumar sus ids.
289        for a in &escena.actors {
290            if let Some(s) = self.seres.get(a.character) {
291                seres.insert(s.id);
292            }
293        }
294        // Los sucesos "aparece un ser" también arrastran ese ser a la clausura.
295        for s in &escena.sucesos {
296            if let crate::studio::TipoSuceso::Aparece(id) = s.tipo {
297                if id != 0 {
298                    seres.insert(id);
299                }
300            }
301        }
302        // Cerrar la clausura de materiales: cadena de padres + leyes usadas.
303        let mut pendientes: Vec<u64> = materiales.iter().copied().collect();
304        while let Some(mid) = pendientes.pop() {
305            if let Some(m) = self.material(mid) {
306                if let Some(p) = m.parent {
307                    if materiales.insert(p) {
308                        pendientes.push(p);
309                    }
310                }
311                for uso in &m.leyes {
312                    leyes.insert(uso.ley);
313                }
314            }
315        }
316
317        // --- Construir el proyecto extraído (ids conservados) ---
318        let mut out = Project {
319            next_id: self.next_id,
320            ..Default::default()
321        };
322        out.leyes = self.leyes.iter().filter(|l| leyes.contains(&l.id)).cloned().collect();
323        out.materiales =
324            self.materiales.iter().filter(|m| materiales.contains(&m.id)).cloned().collect();
325
326        // Seres: subconjunto en el orden original, y un mapa índice viejo→nuevo
327        // para reajustar los actores.
328        let mut idx_map = HashMap::new();
329        for (viejo, s) in self.seres.iter().enumerate() {
330            if seres.contains(&s.id) {
331                idx_map.insert(viejo, out.seres.len());
332                out.seres.push(s.clone());
333            }
334        }
335
336        out.biomas = self.biomas.iter().filter(|b| biomas.contains(&b.id)).cloned().collect();
337        out.mundos = self.mundos.iter().filter(|w| mundos.contains(&w.id)).cloned().collect();
338
339        let mut escena = escena;
340        for a in &mut escena.actors {
341            if let Some(&nuevo) = idx_map.get(&a.character) {
342                a.character = nuevo;
343            }
344        }
345        out.escenas = vec![escena];
346
347        Some(out)
348    }
349
350    /// Combina en este proyecto SÓLO la escena `escena_id` de `otro` (con su
351    /// clausura). Azúcar sobre `extraer_escena` + `combinar`. Error si la escena
352    /// no existe en `otro`.
353    pub fn combinar_escena(&mut self, otro: &Project, escena_id: u64) -> Result<Remapeo, ErrorDoc> {
354        let fragmento = otro
355            .extraer_escena(escena_id)
356            .ok_or(ErrorDoc::EscenaAusente(escena_id))?;
357        Ok(self.combinar(&fragmento))
358    }
359
360    /// Verifica que TODA referencia cruzada del proyecto apunte a un item que
361    /// existe: material→parent/ley, bioma→material/sere, mundo→bioma, escena→
362    /// mundo, actor→sere (por índice). Devuelve la lista de referencias rotas
363    /// (vacía = íntegro). La UI la usa para avisar tras abrir o combinar.
364    pub fn validar_referencias(&self) -> Vec<String> {
365        let mut rotas = Vec::new();
366        let tiene_ley = |id: u64| self.leyes.iter().any(|l| l.id == id);
367        let tiene_mat = |id: u64| self.materiales.iter().any(|m| m.id == id);
368        let tiene_sere = |id: u64| self.seres.iter().any(|s| s.id == id);
369        let tiene_bioma = |id: u64| self.biomas.iter().any(|b| b.id == id);
370        let tiene_mundo = |id: u64| self.mundos.iter().any(|w| w.id == id);
371
372        for m in &self.materiales {
373            if let Some(p) = m.parent {
374                if !tiene_mat(p) {
375                    rotas.push(format!("material {} → parent {} inexistente", m.id, p));
376                }
377            }
378            for uso in &m.leyes {
379                if !tiene_ley(uso.ley) {
380                    rotas.push(format!("material {} → ley {} inexistente", m.id, uso.ley));
381                }
382            }
383        }
384        for b in &self.biomas {
385            for (etiqueta, id) in [("ground", b.ground), ("cliff", b.cliff)] {
386                if !tiene_mat(id) {
387                    rotas.push(format!("bioma {} → {} {} inexistente", b.id, etiqueta, id));
388                }
389            }
390            if let Some(p) = b.peak {
391                if !tiene_mat(p) {
392                    rotas.push(format!("bioma {} → peak {} inexistente", b.id, p));
393                }
394            }
395            for o in &b.objetos {
396                if !tiene_mat(o.material) {
397                    rotas.push(format!("bioma {} → objeto/material {} inexistente", b.id, o.material));
398                }
399            }
400            for uso in &b.seres {
401                if !tiene_sere(uso.sere) {
402                    rotas.push(format!("bioma {} → sere {} inexistente", b.id, uso.sere));
403                }
404            }
405        }
406        for w in &self.mundos {
407            for &bid in &w.biomas {
408                if !tiene_bioma(bid) {
409                    rotas.push(format!("mundo {} → bioma {} inexistente", w.id, bid));
410                }
411            }
412        }
413        for e in &self.escenas {
414            if !tiene_mundo(e.mundo) {
415                rotas.push(format!("escena {} → mundo {} inexistente", e.id, e.mundo));
416            }
417            for (i, a) in e.actors.iter().enumerate() {
418                if self.seres.get(a.character).is_none() {
419                    rotas.push(format!("escena {} → actor {} índice de sere {} fuera de rango", e.id, i, a.character));
420                }
421            }
422            for (i, s) in e.sucesos.iter().enumerate() {
423                if let crate::studio::TipoSuceso::Aparece(id) = s.tipo {
424                    if id != 0 && !tiene_sere(id) {
425                        rotas.push(format!("escena {} → suceso {} aparece ser {} inexistente", e.id, i, id));
426                    }
427                }
428            }
429        }
430        rotas
431    }
432}
433
434#[cfg(test)]
435mod pruebas {
436    use super::*;
437    use crate::studio::Project;
438
439    /// **Un fragmento extraído tiene que serializar igual siempre.**
440    ///
441    /// No es una manía: `ende` guarda ese texto como un artefacto de su
442    /// catálogo y lo hashea para saber qué escenas re-filmar. Si el mismo
443    /// fragmento diera dos textos distintos —porque un `HashMap` cambió de
444    /// orden entre corridas—, la clave cambiaría sola y cada corrida
445    /// re-filmaría las escenas de voxel para dar exactamente la misma imagen.
446    #[test]
447    fn extraer_una_escena_da_siempre_el_mismo_texto() {
448        let p = Project::starter();
449        let id = p.escenas.first().expect("el starter trae una escena").id;
450        let a = Documento::nuevo(p.extraer_escena(id).expect("la escena existe"))
451            .a_ron()
452            .unwrap();
453        let b = Documento::nuevo(p.extraer_escena(id).expect("la escena existe"))
454            .a_ron()
455            .unwrap();
456        assert_eq!(a, b, "extraer dos veces dio dos textos distintos");
457        // Y el fragmento es MUCHO más chico que el proyecto entero: es su
458        // clausura, no una copia.
459        let entero = Documento::nuevo(p.clone()).a_ron().unwrap();
460        assert!(
461            a.len() * 2 < entero.len(),
462            "el fragmento ({} B) debería ser bastante menor que el proyecto ({} B)",
463            a.len(),
464            entero.len()
465        );
466    }
467
468    /// El fragmento extraído se abre y **cierra sus referencias**: si le
469    /// faltara el mundo o un material, `ende` filmaría un vacío.
470    #[test]
471    fn el_fragmento_extraido_no_tiene_referencias_rotas() {
472        let p = Project::starter();
473        let id = p.escenas.first().unwrap().id;
474        let frag = p.extraer_escena(id).unwrap();
475        assert!(
476            frag.validar_referencias().is_empty(),
477            "{:?}",
478            frag.validar_referencias()
479        );
480        // Y sobrevive el viaje por texto, que es como viaja adentro del haz.
481        let texto = Documento::nuevo(frag).a_ron().unwrap();
482        let vuelta = Documento::de_ron(&texto).unwrap().proyecto;
483        assert_eq!(vuelta.escenas.len(), 1);
484        assert!(vuelta.validar_referencias().is_empty());
485    }
486
487    /// **Combinar tiene que ser determinista**, por la misma razón: el día que
488    /// un artefacto se arme juntando otros, su clave sale del texto combinado.
489    #[test]
490    fn combinar_dos_veces_da_el_mismo_proyecto() {
491        let base = Project::starter();
492        let otro = Project::starter();
493        let mut a = base.clone();
494        a.combinar(&otro);
495        let mut b = base.clone();
496        b.combinar(&otro);
497        assert_eq!(
498            Documento::nuevo(a).a_ron().unwrap(),
499            Documento::nuevo(b).a_ron().unwrap()
500        );
501    }
502}