tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
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}