// ============================================================================= // llimphi-voxel :: documento — abrir, guardar y combinar escenas // ----------------------------------------------------------------------------- // El estudio administra su trabajo por ARCHIVOS. Un `Documento` envuelve un // `Project` (los seis niveles: Leyes → Materiales → Seres → Biomas → Mundos → // Escenas) con una marca y una versión, y se serializa en RON —editable a mano, // diffeable, combinable—. Tres operaciones lo gobiernan: // // * ABRIR / GUARDAR: `Documento::abrir` / `guardar` (o `de_ron`/`a_ron` sin // tocar disco, para que la UI decida el path). // * COMBINAR: `Project::combinar` funde otro proyecto dentro, REMAPEANDO ids // e índices para que nada colisione y TODAS las referencias cruzadas se // reconecten. Es lo que permite armar una escena juntando piezas de varios // archivos. // * EXTRAER: `Project::extraer_escena` saca una escena con TODA su clausura // de dependencias (su mundo, sus biomas, sus materiales/seres, sus leyes) // en un proyecto mínimo y portable — el fragmento que se comparte y se // combina en otro lado. // // Núcleo agnóstico de UI (vive en `llimphi-voxel`, no en el studio): la lógica // de archivos es del dominio; el frontend sólo elige rutas y pinta botones. // ============================================================================= use std::collections::{BTreeSet, HashMap}; use std::path::Path; use serde::{Deserialize, Serialize}; use crate::studio::Project; /// Marca del format — distingue un documento del estudio de un RON ajeno. pub const MAGIA_DOC: &str = "tawasuyu-voxel-studio"; /// Versión del format serializado. Sube al cambiar la forma de `Project`. pub const VERSION_DOC: u32 = 1; /// Por qué falló abrir, guardar o parsear un documento. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ErrorDoc { /// No se pudo serializar el proyecto a texto. Serializar(String), /// El texto no es un documento válido. Parsear(String), /// El archivo no lleva nuestra marca — es otro tipo de RON. MagiaAjena, /// El archivo es de una versión más nueva que la que este binario entiende. VersionFutura(u32), /// Se pidió extraer/combinar una escena que no existe. EscenaAusente(u64), /// Fallo de lectura/escritura en disco. Io(String), } impl std::fmt::Display for ErrorDoc { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ErrorDoc::Serializar(e) => write!(f, "no se pudo serializar: {e}"), ErrorDoc::Parsear(e) => write!(f, "no se pudo leer el documento: {e}"), ErrorDoc::MagiaAjena => write!(f, "el archivo no es un documento del studio"), ErrorDoc::VersionFutura(v) => write!(f, "documento de una versión más nueva (v{v})"), ErrorDoc::EscenaAusente(id) => write!(f, "no existe la escena {id}"), ErrorDoc::Io(e) => write!(f, "error de disco: {e}"), } } } impl std::error::Error for ErrorDoc {} /// Un documento del estudio: el proyecto entero, con marca y versión. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Documento { /// Debe ser [`MAGIA_DOC`]. pub magia: String, /// Versión del format (ver [`VERSION_DOC`]). pub version: u32, /// El proyecto — los seis niveles. pub proyecto: Project, } impl Documento { /// Envuelve un proyecto en un documento de la versión actual. pub fn nuevo(proyecto: Project) -> Self { Self { magia: MAGIA_DOC.to_string(), version: VERSION_DOC, proyecto, } } /// Serializa a RON legible. pub fn a_ron(&self) -> Result { ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) .map_err(|e| ErrorDoc::Serializar(e.to_string())) } /// Parsea desde RON, verificando la marca y la versión. Un archivo con marca /// ajena o de una versión futura se rechaza en vez de malinterpretarse. pub fn de_ron(texto: &str) -> Result { let doc: Documento = ron::from_str(texto).map_err(|e| ErrorDoc::Parsear(e.to_string()))?; if doc.magia != MAGIA_DOC { return Err(ErrorDoc::MagiaAjena); } if doc.version > VERSION_DOC { return Err(ErrorDoc::VersionFutura(doc.version)); } Ok(doc) } /// Guarda el documento en `path` (RON). La UI elige la ruta. pub fn guardar(&self, path: impl AsRef) -> Result<(), ErrorDoc> { let texto = self.a_ron()?; std::fs::write(path, texto).map_err(|e| ErrorDoc::Io(e.to_string())) } /// Abre un documento desde `path` (RON). pub fn abrir(path: impl AsRef) -> Result { let texto = std::fs::read_to_string(path).map_err(|e| ErrorDoc::Io(e.to_string()))?; Self::de_ron(&texto) } /// Serializa al format NATIVO compacto (postcard, determinista) — el mismo /// que usa el resto de la suite en disco/cable. Para archivos chicos y /// direccionables por contenido (BLAKE3). La UI elige RON o postcard. pub fn a_bytes(&self) -> Result, ErrorDoc> { postcard::to_allocvec(self).map_err(|e| ErrorDoc::Serializar(e.to_string())) } /// Parsea desde el format nativo compacto, verificando marca y versión. pub fn de_bytes(bytes: &[u8]) -> Result { let doc: Documento = postcard::from_bytes(bytes).map_err(|e| ErrorDoc::Parsear(e.to_string()))?; if doc.magia != MAGIA_DOC { return Err(ErrorDoc::MagiaAjena); } if doc.version > VERSION_DOC { return Err(ErrorDoc::VersionFutura(doc.version)); } Ok(doc) } } /// El mapeo de ids viejos → nuevos que produjo una combinación. Útil para que la /// UI reubique la selección tras combinar, o para depurar. #[derive(Debug, Clone, Default)] pub struct Remapeo { /// id en el proyecto de origen → id nuevo en el destino. pub id: HashMap, /// índice de `seres` en el origen → índice en el destino (los actores de una /// escena referencian al ser por POSICIÓN, no por id). pub indice_sere: HashMap, } impl Remapeo { /// El id nuevo de `viejo`, o el mismo si no estaba en el mapa (referencia /// colgante en el origen — se deja como estaba, no se inventa). fn id(&self, viejo: u64) -> u64 { *self.id.get(&viejo).unwrap_or(&viejo) } } impl Project { /// Combina `otro` DENTRO de este proyecto. Reserva ids nuevos para cada item /// de `otro`, clona sus items y reconecta TODAS las referencias cruzadas /// (parents de material, leyes usadas, materiales/seres de bioma, biomas de /// mundo, mundo de escena, e índices de actor). Nada colisiona y nada queda /// apuntando al proyecto viejo. Devuelve el [`Remapeo`] aplicado. pub fn combinar(&mut self, otro: &Project) -> Remapeo { let mut r = Remapeo::default(); let offset_seres = self.seres.len(); // 1. Reservar un id nuevo para cada item de `otro`, en orden de nivel. for l in &otro.leyes { r.id.insert(l.id, self.alloc_id()); } for m in &otro.materiales { r.id.insert(m.id, self.alloc_id()); } for s in &otro.seres { r.id.insert(s.id, self.alloc_id()); } for b in &otro.biomas { r.id.insert(b.id, self.alloc_id()); } for w in &otro.mundos { r.id.insert(w.id, self.alloc_id()); } for e in &otro.escenas { r.id.insert(e.id, self.alloc_id()); } // 2. Clonar y reescribir cada item con sus referencias remapeadas. for l in &otro.leyes { let mut l = l.clone(); l.id = r.id(l.id); self.leyes.push(l); } for m in &otro.materiales { let mut m = m.clone(); m.id = r.id(m.id); m.parent = m.parent.map(|p| r.id(p)); for uso in &mut m.leyes { uso.ley = r.id(uso.ley); } self.materiales.push(m); } for (i, s) in otro.seres.iter().enumerate() { let mut s = s.clone(); s.id = r.id(s.id); r.indice_sere.insert(i, offset_seres + i); self.seres.push(s); } for b in &otro.biomas { let mut b = b.clone(); b.id = r.id(b.id); b.ground = r.id(b.ground); b.cliff = r.id(b.cliff); b.peak = b.peak.map(|p| r.id(p)); for o in &mut b.objetos { o.material = r.id(o.material); } for uso in &mut b.seres { uso.sere = r.id(uso.sere); } self.biomas.push(b); } for w in &otro.mundos { let mut w = w.clone(); w.id = r.id(w.id); for bid in &mut w.biomas { *bid = r.id(*bid); } self.mundos.push(w); } for e in &otro.escenas { let mut e = e.clone(); e.id = r.id(e.id); e.mundo = r.id(e.mundo); for a in &mut e.actors { a.character += offset_seres; } for s in &mut e.sucesos { if let crate::studio::TipoSuceso::Aparece(id) = &mut s.tipo { if *id != 0 { *id = r.id(*id); } } } self.escenas.push(e); } r } /// Extrae la escena `escena_id` con TODA su clausura de dependencias en un /// `Project` nuevo, mínimo y portable: sólo el mundo, los biomas, los /// materiales (con su cadena de padres y sus leyes), los seres y las leyes /// que la escena realmente usa. Los índices de actor se reajustan al /// subconjunto de seres. `None` si la escena no existe. pub fn extraer_escena(&self, escena_id: u64) -> Option { let escena = self.escenas.iter().find(|e| e.id == escena_id)?.clone(); // --- Recolectar los ids necesarios, nivel por nivel --- let mut mundos = BTreeSet::new(); let mut biomas = BTreeSet::new(); let mut materiales = BTreeSet::new(); let mut seres = BTreeSet::new(); let mut leyes = BTreeSet::new(); mundos.insert(escena.mundo); if let Some(w) = self.mundo(escena.mundo) { for &bid in &w.biomas { biomas.insert(bid); } } for &bid in &biomas { if let Some(b) = self.bioma(bid) { materiales.insert(b.ground); materiales.insert(b.cliff); if let Some(p) = b.peak { materiales.insert(p); } for o in &b.objetos { materiales.insert(o.material); } for uso in &b.seres { seres.insert(uso.sere); } } } // Los actores de la escena referencian seres por índice → sumar sus ids. for a in &escena.actors { if let Some(s) = self.seres.get(a.character) { seres.insert(s.id); } } // Los sucesos "aparece un ser" también arrastran ese ser a la clausura. for s in &escena.sucesos { if let crate::studio::TipoSuceso::Aparece(id) = s.tipo { if id != 0 { seres.insert(id); } } } // Cerrar la clausura de materiales: cadena de padres + leyes usadas. let mut pendientes: Vec = materiales.iter().copied().collect(); while let Some(mid) = pendientes.pop() { if let Some(m) = self.material(mid) { if let Some(p) = m.parent { if materiales.insert(p) { pendientes.push(p); } } for uso in &m.leyes { leyes.insert(uso.ley); } } } // --- Construir el proyecto extraído (ids conservados) --- let mut out = Project { next_id: self.next_id, ..Default::default() }; out.leyes = self.leyes.iter().filter(|l| leyes.contains(&l.id)).cloned().collect(); out.materiales = self.materiales.iter().filter(|m| materiales.contains(&m.id)).cloned().collect(); // Seres: subconjunto en el orden original, y un mapa índice viejo→nuevo // para reajustar los actores. let mut idx_map = HashMap::new(); for (viejo, s) in self.seres.iter().enumerate() { if seres.contains(&s.id) { idx_map.insert(viejo, out.seres.len()); out.seres.push(s.clone()); } } out.biomas = self.biomas.iter().filter(|b| biomas.contains(&b.id)).cloned().collect(); out.mundos = self.mundos.iter().filter(|w| mundos.contains(&w.id)).cloned().collect(); let mut escena = escena; for a in &mut escena.actors { if let Some(&nuevo) = idx_map.get(&a.character) { a.character = nuevo; } } out.escenas = vec![escena]; Some(out) } /// Combina en este proyecto SÓLO la escena `escena_id` de `otro` (con su /// clausura). Azúcar sobre `extraer_escena` + `combinar`. Error si la escena /// no existe en `otro`. pub fn combinar_escena(&mut self, otro: &Project, escena_id: u64) -> Result { let fragmento = otro .extraer_escena(escena_id) .ok_or(ErrorDoc::EscenaAusente(escena_id))?; Ok(self.combinar(&fragmento)) } /// Verifica que TODA referencia cruzada del proyecto apunte a un item que /// existe: material→parent/ley, bioma→material/sere, mundo→bioma, escena→ /// mundo, actor→sere (por índice). Devuelve la lista de referencias rotas /// (vacía = íntegro). La UI la usa para avisar tras abrir o combinar. pub fn validar_referencias(&self) -> Vec { let mut rotas = Vec::new(); let tiene_ley = |id: u64| self.leyes.iter().any(|l| l.id == id); let tiene_mat = |id: u64| self.materiales.iter().any(|m| m.id == id); let tiene_sere = |id: u64| self.seres.iter().any(|s| s.id == id); let tiene_bioma = |id: u64| self.biomas.iter().any(|b| b.id == id); let tiene_mundo = |id: u64| self.mundos.iter().any(|w| w.id == id); for m in &self.materiales { if let Some(p) = m.parent { if !tiene_mat(p) { rotas.push(format!("material {} → parent {} inexistente", m.id, p)); } } for uso in &m.leyes { if !tiene_ley(uso.ley) { rotas.push(format!("material {} → ley {} inexistente", m.id, uso.ley)); } } } for b in &self.biomas { for (etiqueta, id) in [("ground", b.ground), ("cliff", b.cliff)] { if !tiene_mat(id) { rotas.push(format!("bioma {} → {} {} inexistente", b.id, etiqueta, id)); } } if let Some(p) = b.peak { if !tiene_mat(p) { rotas.push(format!("bioma {} → peak {} inexistente", b.id, p)); } } for o in &b.objetos { if !tiene_mat(o.material) { rotas.push(format!("bioma {} → objeto/material {} inexistente", b.id, o.material)); } } for uso in &b.seres { if !tiene_sere(uso.sere) { rotas.push(format!("bioma {} → sere {} inexistente", b.id, uso.sere)); } } } for w in &self.mundos { for &bid in &w.biomas { if !tiene_bioma(bid) { rotas.push(format!("mundo {} → bioma {} inexistente", w.id, bid)); } } } for e in &self.escenas { if !tiene_mundo(e.mundo) { rotas.push(format!("escena {} → mundo {} inexistente", e.id, e.mundo)); } for (i, a) in e.actors.iter().enumerate() { if self.seres.get(a.character).is_none() { rotas.push(format!("escena {} → actor {} índice de sere {} fuera de rango", e.id, i, a.character)); } } for (i, s) in e.sucesos.iter().enumerate() { if let crate::studio::TipoSuceso::Aparece(id) = s.tipo { if id != 0 && !tiene_sere(id) { rotas.push(format!("escena {} → suceso {} aparece ser {} inexistente", e.id, i, id)); } } } } rotas } } #[cfg(test)] mod pruebas { use super::*; use crate::studio::Project; /// **Un fragmento extraído tiene que serializar igual siempre.** /// /// No es una manía: `ende` guarda ese texto como un artefacto de su /// catálogo y lo hashea para saber qué escenas re-filmar. Si el mismo /// fragmento diera dos textos distintos —porque un `HashMap` cambió de /// orden entre corridas—, la clave cambiaría sola y cada corrida /// re-filmaría las escenas de voxel para dar exactamente la misma imagen. #[test] fn extraer_una_escena_da_siempre_el_mismo_texto() { let p = Project::starter(); let id = p.escenas.first().expect("el starter trae una escena").id; let a = Documento::nuevo(p.extraer_escena(id).expect("la escena existe")) .a_ron() .unwrap(); let b = Documento::nuevo(p.extraer_escena(id).expect("la escena existe")) .a_ron() .unwrap(); assert_eq!(a, b, "extraer dos veces dio dos textos distintos"); // Y el fragmento es MUCHO más chico que el proyecto entero: es su // clausura, no una copia. let entero = Documento::nuevo(p.clone()).a_ron().unwrap(); assert!( a.len() * 2 < entero.len(), "el fragmento ({} B) debería ser bastante menor que el proyecto ({} B)", a.len(), entero.len() ); } /// El fragmento extraído se abre y **cierra sus referencias**: si le /// faltara el mundo o un material, `ende` filmaría un vacío. #[test] fn el_fragmento_extraido_no_tiene_referencias_rotas() { let p = Project::starter(); let id = p.escenas.first().unwrap().id; let frag = p.extraer_escena(id).unwrap(); assert!( frag.validar_referencias().is_empty(), "{:?}", frag.validar_referencias() ); // Y sobrevive el viaje por texto, que es como viaja adentro del haz. let texto = Documento::nuevo(frag).a_ron().unwrap(); let vuelta = Documento::de_ron(&texto).unwrap().proyecto; assert_eq!(vuelta.escenas.len(), 1); assert!(vuelta.validar_referencias().is_empty()); } /// **Combinar tiene que ser determinista**, por la misma razón: el día que /// un artefacto se arme juntando otros, su clave sale del texto combinado. #[test] fn combinar_dos_veces_da_el_mismo_proyecto() { let base = Project::starter(); let otro = Project::starter(); let mut a = base.clone(); a.combinar(&otro); let mut b = base.clone(); b.combinar(&otro); assert_eq!( Documento::nuevo(a).a_ron().unwrap(), Documento::nuevo(b).a_ron().unwrap() ); } }