tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ e548698fc442f53d1977ef2838ef4dea94d545e2
llimphi-voxel/src/studio.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
1//! `studio` — el **modelo-documento del creador de mundos**: un [`Project`]
2//! agnóstico de UI que junta los *artefactos* (mundos, personajes, escenas) bajo
3//! nombre, para que **una interfaz** los cree/edite y la **IA** los emita o lea.
4//!
5//! Es contenido puro y **serializable** (RON para edición a mano / salida de la IA;
6//! postcard para la CAS): no toca GPU ni ventana. La studio app (o cualquier otra)
7//! lo carga, lo pinta con sus widgets y lo guarda. Cada artefacto referencia tipos
8//! que ya existen en este crate ([`WorldRecipe`], [`Age`]) — el `Project` sólo les
9//! pone nombre y los agrupa.
10
11use serde::{Deserialize, Serialize};
12
13use crate::actor::{Actor, Age, Clip};
14use crate::director::{ActorKey, ActorScript};
15use crate::worldgen::WorldRecipe;
16use llimphi_3d::glam::Vec3;
17use llimphi_3d::Camera3d;
18
19/// Dimensión por defecto de la grilla con la que el editor previsualiza un mundo
20/// (cúbica en XZ, alto = 0.4·lado, mínimo 48) — el mismo criterio que la app.
21pub const PREVIEW_DIM_XZ: u32 = 128;
22
23/// Calcula el `dim` `[x, y, z]` de un mundo de lado `xz` (alto derivado).
24pub fn world_dim(xz: u32) -> [u32; 3] {
25 let dy = (xz * 4 / 10).max(48);
26 [xz, dy, xz]
27}
28
29/// Un **mundo nombrado** del proyecto: nombre + su [`WorldRecipe`].
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct NamedWorld {
32 pub name: String,
33 pub recipe: WorldRecipe,
34}
35
36impl NamedWorld {
37 pub fn new(name: impl Into<String>, recipe: WorldRecipe) -> Self {
38 Self { name: name.into(), recipe }
39 }
40}
41
42/// **Especificación serializable de un personaje**: lo que un editor/IA fija (edad
43/// + colores). Se materializa con [`to_actor`](Self::to_actor) en un [`Actor`]
44/// posable. Los colores son `[r, g, b]` en `[0,1]` (como [`Actor`]).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CharSpec {
47 pub name: String,
48 pub age: Age,
49 pub skin: [f32; 3],
50 pub shirt: [f32; 3],
51 pub pants: [f32; 3],
52}
53
54impl CharSpec {
55 /// Un personaje con la paleta por defecto de [`Actor::new`] a la edad dada.
56 pub fn new(name: impl Into<String>, age: Age) -> Self {
57 let a = Actor::new(Vec3::ZERO, 0.0);
58 Self { name: name.into(), age, skin: a.skin, shirt: a.shirt, pants: a.pants }
59 }
60
61 /// Materializa el spec en un [`Actor`] parado en `pos` mirando a `facing`.
62 pub fn to_actor(&self, pos: Vec3, facing: f32) -> Actor {
63 Actor::new(pos, facing)
64 .with_age(self.age)
65 .with_colors(self.skin, self.shirt, self.pants)
66 }
67}
68
69/// **Keyframe serializable** de un actor (espejo de [`ActorKey`]): dónde está en
70/// la grilla en `t`, y opcionalmente qué clip reproduce y hacia dónde mira.
71#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
72pub struct ActorKeySpec {
73 pub t: f32,
74 pub gx: f32,
75 pub gz: f32,
76 #[serde(default)]
77 pub clip: Option<Clip>,
78 #[serde(default)]
79 pub face: Option<f32>,
80}
81
82impl ActorKeySpec {
83 /// Compila a un [`ActorKey`] del director.
84 pub fn to_key(self) -> ActorKey {
85 let mut k = ActorKey::at(self.t, self.gx, self.gz);
86 if let Some(c) = self.clip {
87 k = k.play(c);
88 }
89 if let Some(f) = self.face {
90 k = k.facing(f);
91 }
92 k
93 }
94}
95
96/// **Actor de una escena**: qué personaje del proyecto lo interpreta (`character`,
97/// índice en [`Project::characters`]) y su guion de keyframes.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ActorSpec {
100 pub character: usize,
101 pub keys: Vec<ActorKeySpec>,
102}
103
104impl ActorSpec {
105 /// Compila los keyframes a un [`ActorScript`] reproducible.
106 pub fn to_script(&self) -> ActorScript {
107 ActorScript::new(self.keys.iter().map(|k| k.to_key()).collect())
108 }
109}
110
111/// **Tipo de plano** de cámara: un encuadre cinematográfico de alto nivel que se
112/// resuelve contra el **centroide del reparto** (no contra `eye/target` crudos), así
113/// es trivial de elegir y de generar por IA. [`resolve`](Self::resolve) produce la
114/// [`Camera3d`] del frame.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub enum ShotKind {
117 /// Establecedor: lejos y alto, presenta la escena.
118 Establishing,
119 /// Primer plano: cerca, a la altura del pecho.
120 CloseUp,
121 /// Lateral: desde el costado.
122 Side,
123 /// Órbita: gira lento alrededor del reparto.
124 Orbit,
125}
126
127impl ShotKind {
128 /// Todos los planos (para ciclar en un editor).
129 pub const ALL: [ShotKind; 4] =
130 [ShotKind::Establishing, ShotKind::CloseUp, ShotKind::Side, ShotKind::Orbit];
131
132 /// Nombre legible (español).
133 pub fn label(self) -> &'static str {
134 match self {
135 ShotKind::Establishing => "establecedor",
136 ShotKind::CloseUp => "primer plano",
137 ShotKind::Side => "lateral",
138 ShotKind::Orbit => "órbita",
139 }
140 }
141
142 /// El plano siguiente (cicla).
143 pub fn next(self) -> ShotKind {
144 let i = ShotKind::ALL.iter().position(|&k| k == self).unwrap_or(0);
145 ShotKind::ALL[(i + 1) % ShotKind::ALL.len()]
146 }
147
148 /// Resuelve la cámara del plano: mira a `look` (centroide del reparto, ya
149 /// elevado a la altura del pecho), con el ojo según el tipo, a distancia base
150 /// `d` (escala con el tamaño del reparto). `t` (seg) anima la órbita.
151 pub fn resolve(self, look: Vec3, d: f32, t: f32) -> Camera3d {
152 let (eye, fov) = match self {
153 ShotKind::Establishing => {
154 (look + Vec3::new(-0.5 * d, 0.9 * d, -1.6 * d), 50.0)
155 }
156 ShotKind::CloseUp => (look + Vec3::new(0.25 * d, 0.45 * d, -0.85 * d), 40.0),
157 ShotKind::Side => (look + Vec3::new(1.35 * d, 0.4 * d, 0.15 * d), 46.0),
158 ShotKind::Orbit => {
159 let a = t * 0.6;
160 (look + Vec3::new(a.cos() * 1.3 * d, 0.6 * d, a.sin() * 1.3 * d), 48.0)
161 }
162 };
163 Camera3d { eye, target: look, fovy_rad: fov_f32_to_rad(fov), ..Camera3d::default() }
164 }
165}
166
167/// Grados → radianes (helper local para no depender de glam en el call site).
168fn fov_f32_to_rad(deg: f32) -> f32 {
169 deg * std::f32::consts::PI / 180.0
170}
171
172/// Un **plano** de la escena: el tipo de encuadre y desde qué instante (seg) está
173/// activo. El plano vigente en `t` es el último con `start ≤ t` (corte duro entre
174/// planos).
175#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
176pub struct ShotSpec {
177 pub start: f32,
178 pub kind: ShotKind,
179}
180
181/// **Especificación serializable de una escena**: el mundo de fondo (`world`,
182/// índice en [`Project::worlds`]), la duración, el reparto guionado y los **planos**
183/// de cámara. Es la versión editable/IA-emisible del [`Sequence`](crate::Sequence)
184/// del director; se compila con [`scripts`](Self::scripts) y se reproduce posando
185/// cada actor en `sample(t)` con la cámara del plano vigente.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct SceneSpec {
188 pub name: String,
189 pub world: usize,
190 pub duration: f32,
191 pub actors: Vec<ActorSpec>,
192 #[serde(default)]
193 pub shots: Vec<ShotSpec>,
194}
195
196impl SceneSpec {
197 /// Los guiones de los actores, listos para `sample(t)`.
198 pub fn scripts(&self) -> Vec<ActorScript> {
199 self.actors.iter().map(|a| a.to_script()).collect()
200 }
201
202 /// Los **instantes (seg) que merecen un acento musical**: los cortes de cámara
203 /// (inicio de cada plano salvo el primero) y los **gestos** de los actores (keys
204 /// con un clip *emote*). Es lo que deja caer la banda sonora *sobre la acción*.
205 /// Ordenados, sin repetir (dos a menos de `EPS` se funden). Espeja
206 /// [`Sequence::beat_times`](crate::Sequence::beat_times).
207 pub fn beat_times(&self) -> Vec<f32> {
208 const EPS: f32 = 0.05;
209 let mut ts: Vec<f32> = Vec::new();
210 for s in self.shots.iter().skip(1) {
211 ts.push(s.start);
212 }
213 for a in &self.actors {
214 for k in &a.keys {
215 if k.clip.is_some_and(|c| c.is_emote()) {
216 ts.push(k.t);
217 }
218 }
219 }
220 ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS);
221 ts.sort_by(f32::total_cmp);
222 ts.dedup_by(|a, b| (*a - *b).abs() < EPS);
223 ts
224 }
225
226 /// El plano vigente en `t` (el último con `start ≤ t`); `Establishing` si no
227 /// hay planos definidos.
228 pub fn active_shot(&self, t: f32) -> ShotKind {
229 self.shots
230 .iter()
231 .filter(|s| s.start <= t)
232 .last()
233 .map(|s| s.kind)
234 .unwrap_or(ShotKind::Establishing)
235 }
236
237 /// **Escena patrón "entran y saludan"**: `n` actores entran caminando por el
238 /// centro del mundo de izquierda a derecha, se giran y hacen `gesture`. Coords
239 /// de grilla (la altura del terreno se aplica al reproducir). La base tanto del
240 /// arranque como de la generación por IA.
241 pub fn walk_and_emote(
242 name: impl Into<String>,
243 world: usize,
244 n: usize,
245 gesture: Clip,
246 dim: [u32; 3],
247 ) -> Self {
248 use std::f32::consts::{FRAC_PI_2, PI};
249 let n = n.clamp(1, 5);
250 let margin = 18.0_f32;
251 let gx0 = margin;
252 let gx1 = (dim[0] as f32 - margin).max(gx0 + 1.0);
253 let cz = dim[2] as f32 * 0.5;
254 let (t_walk, t_turn, dur) = (2.6_f32, 3.0_f32, 5.6_f32);
255
256 let mut actors = Vec::with_capacity(n);
257 for i in 0..n {
258 let off = (i as f32 - (n as f32 - 1.0) / 2.0) * 3.0;
259 let gz = cz + off;
260 actors.push(ActorSpec {
261 character: i,
262 keys: vec![
263 ActorKeySpec { t: 0.0, gx: gx0, gz, clip: None, face: None },
264 ActorKeySpec { t: t_walk, gx: gx1, gz, clip: None, face: Some(FRAC_PI_2) },
265 ActorKeySpec { t: t_turn, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
266 ActorKeySpec { t: dur, gx: gx1, gz, clip: Some(gesture), face: Some(PI) },
267 ],
268 });
269 }
270 // Dos planos: establecedor durante la caminata, primer plano en el gesto.
271 let shots = vec![
272 ShotSpec { start: 0.0, kind: ShotKind::Establishing },
273 ShotSpec { start: t_turn, kind: ShotKind::CloseUp },
274 ];
275 Self { name: name.into(), world, duration: dur, actors, shots }
276 }
277}
278
279/// El **proyecto**: la bolsa de artefactos del creador (mundos, personajes,
280/// escenas). Vacío por defecto; [`starter`](Self::starter) trae algo que tocar.
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct Project {
283 pub worlds: Vec<NamedWorld>,
284 #[serde(default)]
285 pub characters: Vec<CharSpec>,
286 #[serde(default)]
287 pub scenes: Vec<SceneSpec>,
288}
289
290impl Project {
291 /// Proyecto de arranque: el desierto y la pradera, un trío de personajes
292 /// distinguibles y una escena demo (entran y saludan en el desierto).
293 pub fn starter() -> Self {
294 let characters = vec![
295 CharSpec { name: "rojo".into(), age: Age::Adult, skin: [0.90, 0.72, 0.58], shirt: [0.82, 0.28, 0.26], pants: [0.20, 0.20, 0.28] },
296 CharSpec { name: "azul".into(), age: Age::Adult, skin: [0.86, 0.68, 0.54], shirt: [0.22, 0.55, 0.78], pants: [0.18, 0.20, 0.24] },
297 CharSpec { name: "amarillo".into(), age: Age::Adult, skin: [0.92, 0.78, 0.62], shirt: [0.92, 0.80, 0.30], pants: [0.26, 0.22, 0.20] },
298 ];
299 let dim = world_dim(PREVIEW_DIM_XZ);
300 Self {
301 worlds: vec![
302 NamedWorld::new("desierto", WorldRecipe::desert(1337)),
303 NamedWorld::new("pradera", WorldRecipe::grassland(1337)),
304 ],
305 characters,
306 scenes: vec![SceneSpec::walk_and_emote("saludo en el desierto", 0, 3, Clip::Wave, dim)],
307 }
308 }
309
310 /// Agrega un mundo y devuelve su índice.
311 pub fn add_world(&mut self, w: NamedWorld) -> usize {
312 self.worlds.push(w);
313 self.worlds.len() - 1
314 }
315
316 /// Agrega una escena y devuelve su índice.
317 pub fn add_scene(&mut self, s: SceneSpec) -> usize {
318 self.scenes.push(s);
319 self.scenes.len() - 1
320 }
321
322 /// Personaje `i`, o uno por defecto si el índice se sale (escenas que piden
323 /// más actores que personajes hay).
324 pub fn character_or_default(&self, i: usize) -> CharSpec {
325 self.characters
326 .get(i)
327 .cloned()
328 .unwrap_or_else(|| CharSpec::new("actor", Age::Adult))
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn proyecto_round_trip_ron() {
338 let p = Project::starter();
339 let s = ron::ser::to_string(&p).expect("serializa a ron");
340 let back: Project = ron::from_str(&s).expect("deserializa de ron");
341 assert_eq!(back.worlds.len(), p.worlds.len());
342 assert_eq!(back.worlds[0].name, "desierto");
343 // La receta sobrevive el viaje (un parámetro de muestra).
344 assert!((back.worlds[0].recipe.base - p.worlds[0].recipe.base).abs() < 1e-6);
345 }
346
347 #[test]
348 fn charspec_se_materializa_con_la_edad() {
349 let spec = CharSpec::new("nene", Age::Baby);
350 let actor = spec.to_actor(Vec3::ZERO, 0.0);
351 assert_eq!(actor.age, Age::Baby);
352 }
353
354 #[test]
355 fn world_dim_minimo_48_de_alto() {
356 assert_eq!(world_dim(64)[1], 48); // 64*0.4=25.6 → clamp a 48
357 assert_eq!(world_dim(192)[1], 76);
358 }
359
360 #[test]
361 fn escena_round_trip_y_compila_a_guiones() {
362 let dim = world_dim(128);
363 let s = SceneSpec::walk_and_emote("demo", 0, 3, Clip::Wave, dim);
364 // RON ida y vuelta.
365 let txt = ron::ser::to_string(&s).expect("ron");
366 let back: SceneSpec = ron::from_str(&txt).expect("de-ron");
367 assert_eq!(back.actors.len(), 3);
368 // Compila a guiones reproducibles: a mitad de la caminata el actor se movió.
369 let scripts = back.scripts();
370 let start = scripts[0].sample(0.0);
371 let mid = scripts[0].sample(1.3);
372 assert!(mid.gx > start.gx, "el actor avanza en X: {} → {}", start.gx, mid.gx);
373 }
374
375 #[test]
376 fn plano_vigente_corta_en_el_tiempo() {
377 let dim = world_dim(128);
378 let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim);
379 // Arranca en establecedor; tras el giro (t≈3) pasa a primer plano.
380 assert_eq!(s.active_shot(0.5), ShotKind::Establishing);
381 assert_eq!(s.active_shot(3.5), ShotKind::CloseUp);
382 // El plano resuelve una cámara que mira al centroide.
383 let look = Vec3::new(10.0, 2.0, 10.0);
384 let cam = ShotKind::CloseUp.resolve(look, 9.0, 1.0);
385 assert_eq!(cam.target, look);
386 assert!((cam.eye - look).length() > 1.0, "el ojo está separado del objetivo");
387 }
388
389 #[test]
390 fn beats_son_cortes_y_gestos() {
391 let dim = world_dim(128);
392 let s = SceneSpec::walk_and_emote("demo", 0, 2, Clip::Wave, dim);
393 // walk_and_emote: corte de cámara a t_turn (3.0) + el gesto Wave a t_turn.
394 // Caen en el mismo instante → se funden en un solo beat.
395 let beats = s.beat_times();
396 assert!(!beats.is_empty(), "hay al menos un acento");
397 assert!(beats.iter().all(|&t| t >= 0.0 && t <= s.duration + 0.1));
398 assert!(beats.iter().any(|&t| (t - 3.0).abs() < 0.2), "acento cerca del gesto/corte");
399 }
400
401 #[test]
402 fn starter_trae_escena_y_personajes() {
403 let p = Project::starter();
404 assert_eq!(p.characters.len(), 3);
405 assert_eq!(p.scenes.len(), 1);
406 assert_eq!(p.scenes[0].world, 0);
407 }
408}