tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-anim-studio/src/doc.rs Rust · 410 lines · 14.1 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! El **documento** del studio: la representación editable del grafo de estados,
2//! agnóstica del render. Es la fuente de verdad que la UI manipula y que se
3//! **compila** a un [`llimphi_anim::StateMachine`] ejecutable cada vez que cambia,
4//! para alimentar el preview en vivo.
5//!
6//! ## Por qué un modelo propio
7//!
8//! `llimphi_anim::StateMachine` es un *builder* de una sola dirección: se
9//! construye con `add_state`/`transition`, se congela en un `Arc` al hacer
10//! `instance()`, y **no expone introspección** (sus campos son privados). Un
11//! editor necesita leer, reordenar y reescribir el grafo, así que el studio
12//! mantiene este `Doc` editable (con posiciones de canvas, defaults de inputs,
13//! etc.) y lo proyecta al runtime con [`Doc::compile`]. La relación es la misma
14//! que `Project → render` en voxel-studio: el documento es rico, el runtime es
15//! la proyección ejecutable.
16//!
17//! El `ClipId` que consume el runtime es simplemente el **índice del estado**:
18//! como el studio no carga assets reales (Lottie/rig) en la Fase 1, el preview
19//! pinta cada estado con un color/movimiento sintético derivado de su índice. El
20//! día que se cableen clips reales, sólo cambia el consumidor del `RenderFrame`.
21
22use llimphi_anim::{Cmp, Condition, StateMachine};
23use serde::{Deserialize, Serialize};
24
25/// Operador de comparación numérica — espejo serializable de [`llimphi_anim::Cmp`]
26/// (que no deriva serde). Se convierte a él al compilar.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum CmpOp {
29    Eq,
30    Ne,
31    Lt,
32    Le,
33    Gt,
34    Ge,
35}
36
37impl CmpOp {
38    pub const ALL: [CmpOp; 6] = [
39        CmpOp::Eq,
40        CmpOp::Ne,
41        CmpOp::Lt,
42        CmpOp::Le,
43        CmpOp::Gt,
44        CmpOp::Ge,
45    ];
46    pub fn symbol(self) -> &'static str {
47        match self {
48            CmpOp::Eq => "==",
49            CmpOp::Ne => "≠",
50            CmpOp::Lt => "<",
51            CmpOp::Le => "≤",
52            CmpOp::Gt => ">",
53            CmpOp::Ge => "≥",
54        }
55    }
56    fn to_anim(self) -> Cmp {
57        match self {
58            CmpOp::Eq => Cmp::Eq,
59            CmpOp::Ne => Cmp::Ne,
60            CmpOp::Lt => Cmp::Lt,
61            CmpOp::Le => Cmp::Le,
62            CmpOp::Gt => Cmp::Gt,
63            CmpOp::Ge => Cmp::Ge,
64        }
65    }
66}
67
68/// Tipo de un input de la máquina (espejo del trío Rive bool/number/trigger).
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub enum InputKind {
71    Bool,
72    Number,
73    Trigger,
74}
75
76impl InputKind {
77    pub fn label(self) -> &'static str {
78        match self {
79            InputKind::Bool => "bool",
80            InputKind::Number => "número",
81            InputKind::Trigger => "trigger",
82        }
83    }
84}
85
86/// Una guarda editable de una transición. Se compila a [`llimphi_anim::Condition`].
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub enum CondDef {
89    /// `bool input == value`.
90    Bool { input: String, value: bool },
91    /// `number input <op> value`.
92    Number { input: String, op: CmpOp, value: f64 },
93    /// el trigger `input` fue disparado este frame.
94    Trigger { input: String },
95    /// el clip del estado de origen terminó (sólo estados no-loop).
96    ClipDone,
97}
98
99impl CondDef {
100    fn to_anim(&self) -> Condition {
101        match self {
102            CondDef::Bool { input, value } => Condition::bool(input.clone(), *value),
103            CondDef::Number { input, op, value } => {
104                Condition::number(input.clone(), op.to_anim(), *value)
105            }
106            CondDef::Trigger { input } => Condition::trigger(input.clone()),
107            CondDef::ClipDone => Condition::clip_done(),
108        }
109    }
110}
111
112/// Un estado del grafo: un clip (por índice) con su velocidad y loop, más su
113/// posición en el lienzo y la duración nominal del clip (para `ClipDone`).
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct StateDef {
116    pub name: String,
117    /// Velocidad de reproducción del clip (escala el tiempo).
118    pub speed: f64,
119    /// ¿El clip hace loop? Si no, su `ClipDone` puede disparar transiciones.
120    pub looping: bool,
121    /// Duración nominal del clip en segundos (para `ClipDone` y el preview).
122    pub clip_len: f64,
123    /// Posición del nodo en el lienzo (pixels relativos al canvas).
124    pub x: f32,
125    pub y: f32,
126}
127
128impl StateDef {
129    pub fn new(name: impl Into<String>, x: f32, y: f32) -> Self {
130        StateDef {
131            name: name.into(),
132            speed: 1.0,
133            looping: true,
134            clip_len: 2.0,
135            x,
136            y,
137        }
138    }
139}
140
141/// Una transición editable. `from = None` ⇒ transición *any-state*.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct TransDef {
144    /// Estado de origen (índice en `states`), o `None` para any-state.
145    pub from: Option<usize>,
146    /// Estado destino (índice en `states`).
147    pub to: usize,
148    /// Guardas AND. Vacío ⇒ la transición **nunca** dispara (el runtime la ignora).
149    pub conditions: Vec<CondDef>,
150    /// Duración del crossfade (segundos). `0.0` = salto instantáneo.
151    pub duration_secs: f64,
152}
153
154/// Un input declarado, con su default. Los defaults siembran los controles en
155/// vivo del panel de preview.
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157pub struct InputDef {
158    pub name: String,
159    pub kind: InputKind,
160    pub bool_default: bool,
161    pub num_default: f64,
162}
163
164impl InputDef {
165    pub fn new(name: impl Into<String>, kind: InputKind) -> Self {
166        InputDef {
167            name: name.into(),
168            kind,
169            bool_default: false,
170            num_default: 0.0,
171        }
172    }
173}
174
175/// El documento entero: estados + transiciones + inputs + estado de entrada.
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177pub struct Doc {
178    pub states: Vec<StateDef>,
179    pub transitions: Vec<TransDef>,
180    pub inputs: Vec<InputDef>,
181    /// Índice del estado de entrada.
182    pub entry: usize,
183}
184
185impl Default for Doc {
186    fn default() -> Self {
187        Doc {
188            states: Vec::new(),
189            transitions: Vec::new(),
190            inputs: Vec::new(),
191            entry: 0,
192        }
193    }
194}
195
196impl Doc {
197    /// Proyecta el documento a un [`StateMachine`] ejecutable. Índice de estado =
198    /// `ClipId`. Transiciones con índices fuera de rango se descartan (defensa
199    /// ante un documento inconsistente recién editado).
200    pub fn compile(&self) -> StateMachine {
201        let mut sm = StateMachine::new();
202        let n = self.states.len();
203        for (i, s) in self.states.iter().enumerate() {
204            sm.add_state(s.name.clone(), i as u32, s.speed, s.looping);
205            sm.set_clip_duration(i as u32, s.clip_len);
206        }
207        if n > 0 {
208            sm.set_entry(self.entry.min(n - 1));
209        }
210        for t in &self.transitions {
211            if t.to >= n {
212                continue;
213            }
214            let conds: Vec<Condition> = t.conditions.iter().map(CondDef::to_anim).collect();
215            match t.from {
216                Some(f) if f < n => sm.transition(f, t.to, conds, t.duration_secs),
217                None => sm.transition_any(t.to, conds, t.duration_secs),
218                Some(_) => {} // origen fuera de rango → descartar
219            }
220        }
221        sm
222    }
223
224    /// Documento de arranque: el clásico `idle ⇄ walk` por un bool `moving`, más
225    /// un `jump` any-state por trigger. Da algo vivo que tocar al abrir.
226    pub fn starter() -> Self {
227        let mut doc = Doc::default();
228        doc.states.push(StateDef::new("idle", 60.0, 80.0));
229        let mut walk = StateDef::new("walk", 320.0, 80.0);
230        walk.speed = 1.0;
231        doc.states.push(walk);
232        let mut jump = StateDef::new("jump", 190.0, 240.0);
233        jump.looping = false;
234        jump.clip_len = 0.6;
235        doc.states.push(jump);
236        doc.entry = 0;
237
238        doc.inputs.push(InputDef::new("moving", InputKind::Bool));
239        doc.inputs.push(InputDef::new("jump", InputKind::Trigger));
240
241        doc.transitions.push(TransDef {
242            from: Some(0),
243            to: 1,
244            conditions: vec![CondDef::Bool {
245                input: "moving".into(),
246                value: true,
247            }],
248            duration_secs: 0.2,
249        });
250        doc.transitions.push(TransDef {
251            from: Some(1),
252            to: 0,
253            conditions: vec![CondDef::Bool {
254                input: "moving".into(),
255                value: false,
256            }],
257            duration_secs: 0.2,
258        });
259        // any-state: ⚡jump → jump (instantáneo)
260        doc.transitions.push(TransDef {
261            from: None,
262            to: 2,
263            conditions: vec![CondDef::Trigger {
264                input: "jump".into(),
265            }],
266            duration_secs: 0.0,
267        });
268        // jump termina → idle
269        doc.transitions.push(TransDef {
270            from: Some(2),
271            to: 0,
272            conditions: vec![CondDef::ClipDone],
273            duration_secs: 0.0,
274        });
275        doc
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn compila_y_arranca_en_entry() {
285        let doc = Doc::starter();
286        let inst = doc.compile().instance();
287        assert_eq!(inst.current_state(), "idle");
288    }
289
290    #[test]
291    fn bool_dispara_idle_a_walk() {
292        let mut inst = Doc::starter().compile().instance();
293        inst.set_bool("moving", true);
294        inst.advance(0.3); // > duración de blend (0.2)
295        assert_eq!(inst.current_state(), "walk");
296        inst.set_bool("moving", false);
297        inst.advance(0.3);
298        assert_eq!(inst.current_state(), "idle");
299    }
300
301    #[test]
302    fn trigger_anystate_salta_a_jump_y_vuelve() {
303        let mut inst = Doc::starter().compile().instance();
304        inst.fire("jump");
305        inst.advance(0.016);
306        assert_eq!(inst.current_state(), "jump");
307        // El clip de 0.6 s termina (no loop) → vuelve a idle por ClipDone.
308        inst.advance(0.7);
309        assert_eq!(inst.current_state(), "idle");
310    }
311
312    #[test]
313    fn transicion_con_indice_fuera_de_rango_se_descarta() {
314        let mut doc = Doc::default();
315        doc.states.push(StateDef::new("a", 0.0, 0.0));
316        // destino inexistente → no debe panickear al compilar
317        doc.transitions.push(TransDef {
318            from: Some(0),
319            to: 99,
320            conditions: vec![CondDef::ClipDone],
321            duration_secs: 0.0,
322        });
323        let inst = doc.compile().instance();
324        assert_eq!(inst.current_state(), "a");
325    }
326
327    #[test]
328    fn condicion_numerica_compila() {
329        let mut doc = Doc::default();
330        doc.states.push(StateDef::new("slow", 0.0, 0.0));
331        doc.states.push(StateDef::new("fast", 0.0, 0.0));
332        doc.inputs.push(InputDef::new("speed", InputKind::Number));
333        doc.transitions.push(TransDef {
334            from: Some(0),
335            to: 1,
336            conditions: vec![CondDef::Number {
337                input: "speed".into(),
338                op: CmpOp::Gt,
339                value: 5.0,
340            }],
341            duration_secs: 0.0,
342        });
343        let mut inst = doc.compile().instance();
344        inst.set_number("speed", 3.0);
345        inst.advance(0.016);
346        assert_eq!(inst.current_state(), "slow");
347        inst.set_number("speed", 9.0);
348        inst.advance(0.016);
349        assert_eq!(inst.current_state(), "fast");
350    }
351
352    #[test]
353    fn round_trip_ron() {
354        let doc = Doc::starter();
355        let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default())
356            .expect("serializa");
357        let back: Doc = ron::from_str(&ron).expect("deserializa");
358        assert_eq!(doc, back);
359    }
360
361    /// Un grafo con forma de **botón reactivo** (idle/hover/press, hovered+pressed)
362    /// authored en el studio sobrevive el round-trip RON y, al compilar, corre el
363    /// ciclo completo dirigido por inputs. Certifica el lazo autor→export→consumo
364    /// que enchufa `llimphi-widget-rive-button::from_state_machine`.
365    fn boton_doc() -> Doc {
366        let mut doc = Doc::default();
367        doc.states.push(StateDef::new("idle", 60.0, 80.0));
368        doc.states.push(StateDef::new("hover", 260.0, 80.0));
369        let mut press = StateDef::new("press", 460.0, 80.0);
370        press.looping = false;
371        press.clip_len = 0.4;
372        doc.states.push(press);
373        doc.entry = 0;
374
375        doc.inputs.push(InputDef::new("hovered", InputKind::Bool));
376        doc.inputs.push(InputDef::new("pressed", InputKind::Trigger));
377
378        let b = |input: &str, value: bool| CondDef::Bool { input: input.into(), value };
379        doc.transitions.push(TransDef { from: Some(0), to: 1, conditions: vec![b("hovered", true)], duration_secs: 0.18 });
380        doc.transitions.push(TransDef { from: Some(1), to: 0, conditions: vec![b("hovered", false)], duration_secs: 0.18 });
381        doc.transitions.push(TransDef { from: None, to: 2, conditions: vec![CondDef::Trigger { input: "pressed".into() }], duration_secs: 0.06 });
382        doc.transitions.push(TransDef { from: Some(2), to: 1, conditions: vec![CondDef::ClipDone, b("hovered", true)], duration_secs: 0.18 });
383        doc.transitions.push(TransDef { from: Some(2), to: 0, conditions: vec![CondDef::ClipDone, b("hovered", false)], duration_secs: 0.18 });
384        doc
385    }
386
387    #[test]
388    fn boton_authorado_round_trip_corre_el_ciclo() {
389        let doc = boton_doc();
390        // Export → import (lo que hace Project::save/load del studio).
391        let ron = ron::ser::to_string_pretty(&doc, ron::ser::PrettyConfig::default()).expect("serializa");
392        let back: Doc = ron::from_str(&ron).expect("deserializa");
393        assert_eq!(doc, back);
394
395        // Consumo: la máquina compilada del doc reimportado corre el ciclo.
396        let mut inst = back.compile().instance();
397        assert_eq!(inst.current_state(), "idle");
398        inst.set_bool("hovered", true);
399        inst.advance(0.5);
400        assert_eq!(inst.current_state(), "hover");
401        inst.fire("pressed");
402        inst.advance(0.2);
403        assert_eq!(inst.current_state(), "press");
404        inst.advance(0.6); // clip de 0.4 s termina → ClipDone, sigue hovered → hover
405        assert_eq!(inst.current_state(), "hover");
406        inst.set_bool("hovered", false);
407        inst.advance(0.5);
408        assert_eq!(inst.current_state(), "idle");
409    }
410}