tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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}