gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ e548698fc442f53d1977ef2838ef4dea94d545e2 llimphi-voxel/src/studio.rs Rust · 408 siq'ikuna · 15.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

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}