tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-anim-studio/src/main.rs
Historia
Quién tocó qué
crudo
1//! # llimphi-anim-studio — el autor de máquinas de animación, con interfaz
2//!
3//! Editor visual del grafo de estados estilo Rive de [`llimphi_anim`]: los
4//! **estados** son nodos en un lienzo (`llimphi-widget-nodegraph`), las
5//! **transiciones** son cables que se trazan arrastrando pin→pin, y un panel de
6//! **inputs en vivo** (toggles/sliders/triggers) maneja una `Instance` real cuyo
7//! estado actual **se ilumina en el grafo** y se pinta en un preview sintético.
8//! El documento ([`doc::Doc`]) es la fuente de verdad; el `StateMachine` es su
9//! proyección ejecutable, recompilada en cada edición.
10//!
11//! ```bash
12//! cargo run -p llimphi-anim-studio --release # ventana interactiva
13//! ```
14//!
15//! Persistencia en `anim-studio.ron` (texto editable a mano).
16
17use std::collections::HashMap;
18use std::time::Duration;
19
20use llimphi_anim::{Instance, RenderFrame};
21use llimphi_theme::Theme;
22use llimphi_ui::llimphi_layout::taffy::prelude::{
23 length, percent, AlignItems, Dimension, FlexDirection, Size, Style,
24};
25use llimphi_ui::llimphi_raster::kurbo::Rect as KRect;
26use llimphi_ui::llimphi_raster::peniko::Color;
27use llimphi_ui::llimphi_raster::vello;
28use llimphi_ui::{
29 App, DragPhase, Handle, KeyEvent, View,
30};
31use llimphi_widget_button::{button_view, ButtonPalette};
32use llimphi_widget_nodegraph::{
33 nodegraph_view_styled, NodeId, NodeSpec, NodegraphMetrics, NodegraphPalette, NodeTint, Wire,
34};
35use llimphi_widget_slider::{slider_view, SliderPalette};
36use llimphi_clipboard::SystemClipboard;
37use llimphi_widget_text_input::{
38 text_input_view_full, TextInputEvent, TextInputPalette, TextInputState,
39};
40
41use llimphi_anim_studio::{doc, rig, Project};
42use doc::{CmpOp, CondDef, Doc, InputDef, InputKind, StateDef, TransDef};
43use rig::{BoneDef, MeshMode, RigDoc};
44
45/// Dónde se guarda/carga el grafo (relativo al cwd).
46const PROJECT_PATH: &str = "anim-studio.ron";
47/// Paso de simulación del preview (~30 fps).
48const DT: f64 = 1.0 / 30.0;
49
50// =============================================================================
51// Selección
52// =============================================================================
53
54/// Qué elemento está bajo edición en el inspector.
55#[derive(Clone, Copy, PartialEq, Eq)]
56enum Sel {
57 None,
58 State(usize),
59 Trans(usize),
60}
61
62/// Las dos superficies del studio: el grafo de estados (F1) y el rig
63/// esqueletal (F2). Comparten ventana y persistencia; se conmutan en la barra
64/// superior.
65#[derive(Clone, Copy, PartialEq, Eq)]
66enum Mode {
67 /// Editor de la máquina de estados.
68 Estados,
69 /// Editor del rig esqueletal (cadena de huesos + malla + IK).
70 Rig,
71}
72
73// `Project` (doc + rig) vive ahora en la lib (`llimphi_anim_studio::Project`),
74// para que `mirada-fondo` cargue el mismo formato sin duplicar los tipos.
75
76// =============================================================================
77// Modelo
78// =============================================================================
79
80struct Model {
81 doc: Doc,
82 theme: Theme,
83 mode: Mode,
84 sel: Sel,
85
86 /// El rig esqueletal (modo Rig).
87 rig: RigDoc,
88 /// Hueso seleccionado en el modo Rig.
89 rig_sel: Option<usize>,
90 /// Textura cargada para deformar (no se serializa; se recarga del path).
91 texture: Option<llimphi_image::Image>,
92 /// Campo del path de la textura.
93 tex_input: TextInputState,
94 tex_focused: bool,
95 /// Tamaño en px del lienzo del rig en el último press (para invertir
96 /// pantalla→modelo al arrastrar el objetivo IK).
97 rig_canvas_wh: (f32, f32),
98
99 /// Instancia ejecutable (recompilada del `doc` en cada edición estructural).
100 instance: Instance,
101 /// Índice del estado actualmente activo (para iluminar el nodo en el grafo).
102 current_idx: Option<usize>,
103 /// Valores en vivo de los inputs (siembran los controles y se aplican cada tick).
104 live_bools: HashMap<String, bool>,
105 live_numbers: HashMap<String, f64>,
106 /// ¿Corre la simulación del preview?
107 playing: bool,
108
109 /// Campo de nombre del estado seleccionado.
110 name_input: TextInputState,
111 name_focused: bool,
112 /// Campo de nombre para crear un input nuevo.
113 new_input: TextInputState,
114 new_input_focused: bool,
115 /// Portapapeles del sistema para copiar/cortar/pegar en los campos.
116 clipboard: SystemClipboard,
117
118 status: String,
119}
120
121impl Model {
122 /// Recompila el `doc` a una `Instance` fresca, reaplicando los inputs en vivo.
123 fn rebuild(&mut self) {
124 self.instance = self.doc.compile().instance();
125 self.seed_live_inputs();
126 self.apply_live_inputs();
127 self.current_idx = self.find_current();
128 }
129
130 /// Asegura que cada input declarado tenga una entrada en los mapas en vivo
131 /// (con su default), y descarta los que ya no existen.
132 fn seed_live_inputs(&mut self) {
133 let mut bools = HashMap::new();
134 let mut numbers = HashMap::new();
135 for i in &self.doc.inputs {
136 match i.kind {
137 InputKind::Bool => {
138 let v = self.live_bools.get(&i.name).copied().unwrap_or(i.bool_default);
139 bools.insert(i.name.clone(), v);
140 }
141 InputKind::Number => {
142 let v = self.live_numbers.get(&i.name).copied().unwrap_or(i.num_default);
143 numbers.insert(i.name.clone(), v);
144 }
145 InputKind::Trigger => {}
146 }
147 }
148 self.live_bools = bools;
149 self.live_numbers = numbers;
150 }
151
152 fn apply_live_inputs(&mut self) {
153 for (k, v) in &self.live_bools {
154 self.instance.set_bool(k.clone(), *v);
155 }
156 for (k, v) in &self.live_numbers {
157 self.instance.set_number(k.clone(), *v);
158 }
159 }
160
161 fn find_current(&self) -> Option<usize> {
162 let name = self.instance.current_state();
163 self.doc.states.iter().position(|s| s.name == name)
164 }
165
166 fn selected_state(&self) -> Option<usize> {
167 match self.sel {
168 Sel::State(i) if i < self.doc.states.len() => Some(i),
169 _ => None,
170 }
171 }
172 fn selected_trans(&self) -> Option<usize> {
173 match self.sel {
174 Sel::Trans(i) if i < self.doc.transitions.len() => Some(i),
175 _ => None,
176 }
177 }
178
179 /// Sincroniza el campo de nombre con el estado seleccionado.
180 fn sync_name_input(&mut self) {
181 if let Some(i) = self.selected_state() {
182 self.name_input.set_text(self.doc.states[i].name.clone());
183 }
184 }
185}
186
187// =============================================================================
188// Mensajes
189// =============================================================================
190
191#[derive(Clone)]
192enum Msg {
193 Tick,
194 SetMode(Mode),
195 // --- rig (modo Rig) ---
196 RigAddBone,
197 RigDelBone,
198 RigSelectBone(usize),
199 RigSetAngle(usize, f64),
200 RigSetLen(usize, f64),
201 RigSetThickness(f64),
202 RigSetCols(f64),
203 RigSetMeshMode(MeshMode),
204 RigSetGridRes(f64),
205 RigSetAspect(f64),
206 /// Evento de mouse del campo de textura (click/arrastre). El `Press`
207 /// además lo enfoca. Ver [`text_input_view_full`].
208 RigTexCampo(TextInputEvent),
209 RigTexKey(KeyEvent),
210 RigLoadTexture,
211 RigClearTexture,
212 RigToggleIk,
213 RigToggleFlip,
214 RigSetTargetX(f64),
215 RigSetTargetY(f64),
216 RigResetPose,
217 /// Click en el lienzo: coloca el objetivo IK ahí. `(local_x, local_y, w, h)`.
218 RigCanvasClick(f32, f32, f32, f32),
219 /// Arrastre en el lienzo: mueve el objetivo IK por delta de pantalla.
220 RigCanvasDrag(f32, f32),
221 // --- grafo ---
222 DragNode(NodeId, DragPhase, f32, f32),
223 Connect(NodeId, NodeId),
224 SelectState(usize),
225 SelectTrans(usize),
226 // --- CRUD estados ---
227 AddState,
228 DeleteSelected,
229 SetEntry,
230 /// Evento de mouse del campo de nombre de estado (click/arrastre). El
231 /// `Press` además lo enfoca. Ver [`text_input_view_full`].
232 RenameCampo(TextInputEvent),
233 RenameKey(KeyEvent),
234 ToggleLoop,
235 SetSpeed(f64),
236 SetClipLen(f64),
237 // --- transiciones ---
238 SetTransDur(f64),
239 ToggleAnyState,
240 AddCondFor(String),
241 AddCondClipDone,
242 DeleteCond(usize),
243 ToggleCondBool(usize),
244 CycleCondOp(usize),
245 SetCondNum(usize, f64),
246 // --- inputs ---
247 /// Evento de mouse del campo de nombre de input nuevo (click/arrastre). El
248 /// `Press` además lo enfoca. Ver [`text_input_view_full`].
249 NewInputCampo(TextInputEvent),
250 NewInputKey(KeyEvent),
251 AddInput(InputKind),
252 DeleteInput(usize),
253 // --- controles en vivo ---
254 SetLiveBool(String, bool),
255 SetLiveNumber(String, f64),
256 FireTrigger(String),
257 TogglePlay,
258 Restart,
259 // --- persistencia ---
260 Save,
261 Load,
262}
263
264// =============================================================================
265// App
266// =============================================================================
267
268struct Studio;
269
270impl App for Studio {
271 type Model = Model;
272 type Msg = Msg;
273
274 fn title() -> &'static str {
275 "llimphi-anim-studio — autor de máquinas de animación"
276 }
277 fn initial_size() -> (u32, u32) {
278 (1240, 760)
279 }
280
281 fn init(handle: &Handle<Msg>) -> Model {
282 handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
283 let doc = Doc::starter();
284 let mut model = Model {
285 instance: doc.compile().instance(),
286 doc,
287 theme: Theme::dark(),
288 mode: Mode::Estados,
289 sel: Sel::None,
290 rig: RigDoc::starter(),
291 rig_sel: Some(1),
292 texture: None,
293 tex_input: TextInputState::new(),
294 tex_focused: false,
295 rig_canvas_wh: (1.0, 1.0),
296 current_idx: Some(0),
297 live_bools: HashMap::new(),
298 live_numbers: HashMap::new(),
299 playing: true,
300 name_input: TextInputState::new(),
301 name_focused: false,
302 new_input: TextInputState::new(),
303 new_input_focused: false,
304 clipboard: SystemClipboard::new(),
305 status: "listo — arrastra pin→pin para conectar; toca los inputs en vivo".into(),
306 };
307 model.rebuild();
308 model
309 }
310
311 fn on_key(model: &Model, ev: &KeyEvent) -> Option<Msg> {
312 if model.name_focused {
313 return Some(Msg::RenameKey(ev.clone()));
314 }
315 if model.new_input_focused {
316 return Some(Msg::NewInputKey(ev.clone()));
317 }
318 if model.tex_focused {
319 return Some(Msg::RigTexKey(ev.clone()));
320 }
321 None
322 }
323
324 fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
325 match msg {
326 Msg::Tick => {
327 if model.playing {
328 model.apply_live_inputs();
329 model.instance.advance(DT);
330 model.current_idx = model.find_current();
331 }
332 }
333 Msg::SetMode(m) => model.mode = m,
334
335 // ---------------- rig ----------------
336 Msg::RigAddBone => {
337 model.rig.bones.push(BoneDef::new(100.0));
338 model.rig_sel = Some(model.rig.bones.len() - 1);
339 }
340 Msg::RigDelBone => {
341 if let Some(i) = model.rig_sel {
342 if i < model.rig.bones.len() && model.rig.bones.len() > 1 {
343 model.rig.bones.remove(i);
344 model.rig_sel = Some(i.min(model.rig.bones.len() - 1));
345 }
346 }
347 }
348 Msg::RigSelectBone(i) => model.rig_sel = Some(i),
349 Msg::RigSetAngle(i, v) => {
350 if let Some(b) = model.rig.bones.get_mut(i) {
351 b.angle = v.clamp(-3.1, 3.1);
352 }
353 }
354 Msg::RigSetLen(i, v) => {
355 if let Some(b) = model.rig.bones.get_mut(i) {
356 b.len = v.clamp(10.0, 400.0);
357 }
358 }
359 Msg::RigSetThickness(v) => model.rig.thickness = v.clamp(2.0, 120.0),
360 Msg::RigSetCols(v) => model.rig.cols = (v as usize).clamp(2, 64),
361 Msg::RigSetMeshMode(m) => model.rig.mesh_mode = m,
362 Msg::RigSetGridRes(v) => model.rig.grid_res = (v as usize).clamp(2, 40),
363 Msg::RigSetAspect(v) => model.rig.mesh_aspect = (v as f64).clamp(0.1, 3.0),
364 Msg::RigTexCampo(ev) => {
365 // El press/click enfoca este campo (y desenfoca los demás); el
366 // arrastre extiende la selección.
367 if matches!(ev, TextInputEvent::Press(_)) {
368 model.tex_focused = true;
369 model.name_focused = false;
370 model.new_input_focused = false;
371 }
372 model.tex_input.handle(ev, &mut model.clipboard);
373 }
374 Msg::RigTexKey(ev) => {
375 // `handle` cubre escribir, copiar/cortar/pegar y acentos.
376 model.tex_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
377 }
378 Msg::RigLoadTexture => {
379 let path = model.tex_input.text();
380 let path = path.trim().to_string();
381 if path.is_empty() {
382 model.status = "escribe el path de una imagen".into();
383 } else {
384 match load_texture(&path) {
385 Ok((img, aspect)) => {
386 model.rig.mesh_aspect = aspect;
387 model.rig.mesh_mode = MeshMode::Grid; // textura ⇒ rejilla
388 model.rig.texture_path = Some(path.clone());
389 model.texture = Some(img);
390 model.status = format!("textura cargada: {path}");
391 }
392 Err(e) => model.status = format!("no se pudo cargar: {e}"),
393 }
394 }
395 }
396 Msg::RigClearTexture => {
397 model.texture = None;
398 model.rig.texture_path = None;
399 model.status = "textura quitada".into();
400 }
401 Msg::RigToggleIk => model.rig.ik_enabled = !model.rig.ik_enabled,
402 Msg::RigToggleFlip => model.rig.ik_flip = !model.rig.ik_flip,
403 Msg::RigSetTargetX(v) => model.rig.ik_target.0 = v,
404 Msg::RigSetTargetY(v) => model.rig.ik_target.1 = v,
405 Msg::RigResetPose => {
406 for b in &mut model.rig.bones {
407 b.angle = 0.0;
408 }
409 }
410 Msg::RigCanvasClick(lx, ly, rw, rh) => {
411 if model.rig.bones.len() >= 2 {
412 model.rig_canvas_wh = (rw, rh);
413 model.rig.ik_enabled = true; // colocar objetivo ⇒ quieres IK
414 let b = rig_view_bounds(&model.rig);
415 let (mx, my) = canvas_local_to_model(lx, ly, rw, rh, b);
416 model.rig.ik_target = (mx, my);
417 }
418 }
419 Msg::RigCanvasDrag(dx, dy) => {
420 if model.rig.ik_enabled && model.rig.bones.len() >= 2 {
421 let (rw, rh) = model.rig_canvas_wh;
422 let b = rig_view_bounds(&model.rig);
423 let s = canvas_scale(rw, rh, b);
424 if s > 0.0 {
425 model.rig.ik_target.0 += dx as f64 / s;
426 model.rig.ik_target.1 += dy as f64 / s;
427 }
428 }
429 }
430
431 // ---------------- grafo ----------------
432 Msg::DragNode(id, phase, dx, dy) => {
433 if matches!(phase, DragPhase::Move | DragPhase::End) {
434 if let Some(s) = model.doc.states.get_mut(id as usize) {
435 s.x = (s.x + dx).max(0.0);
436 s.y = (s.y + dy).max(0.0);
437 }
438 }
439 }
440 Msg::Connect(from, to) => {
441 let (from, to) = (from as usize, to as usize);
442 if from != to && from < model.doc.states.len() && to < model.doc.states.len() {
443 model.doc.transitions.push(TransDef {
444 from: Some(from),
445 to,
446 conditions: Vec::new(),
447 duration_secs: 0.2,
448 });
449 let idx = model.doc.transitions.len() - 1;
450 model.sel = Sel::Trans(idx);
451 model.status =
452 "transición creada — agregale una condición o nunca dispara".into();
453 model.rebuild();
454 }
455 }
456 Msg::SelectState(i) => {
457 model.sel = Sel::State(i);
458 model.name_focused = false;
459 model.sync_name_input();
460 }
461 Msg::SelectTrans(i) => {
462 model.sel = Sel::Trans(i);
463 model.name_focused = false;
464 }
465
466 // ---------------- CRUD estados ----------------
467 Msg::AddState => {
468 let n = model.doc.states.len();
469 let x = 80.0 + (n as f32 % 4.0) * 180.0;
470 let y = 80.0 + (n as f32 / 4.0).floor() * 150.0;
471 model
472 .doc
473 .states
474 .push(StateDef::new(format!("estado{n}"), x, y));
475 model.sel = Sel::State(n);
476 model.sync_name_input();
477 model.rebuild();
478 }
479 Msg::DeleteSelected => match model.sel {
480 Sel::State(i) => {
481 remove_state(&mut model.doc, i);
482 model.sel = Sel::None;
483 model.rebuild();
484 }
485 Sel::Trans(i) => {
486 if i < model.doc.transitions.len() {
487 model.doc.transitions.remove(i);
488 }
489 model.sel = Sel::None;
490 model.rebuild();
491 }
492 Sel::None => {}
493 },
494 Msg::SetEntry => {
495 if let Some(i) = model.selected_state() {
496 model.doc.entry = i;
497 model.rebuild();
498 model.status = format!("entry = {}", model.doc.states[i].name);
499 }
500 }
501 Msg::RenameCampo(ev) => {
502 if matches!(ev, TextInputEvent::Press(_)) {
503 model.name_focused = true;
504 model.new_input_focused = false;
505 }
506 model.name_input.handle(ev, &mut model.clipboard);
507 }
508 Msg::RenameKey(ev) => {
509 if model.name_input.handle(TextInputEvent::Key(ev), &mut model.clipboard) {
510 if let Some(i) = model.selected_state() {
511 let new = model.name_input.text();
512 // Renombrar el estado y propagar a sus referencias no hace
513 // falta: las transiciones referencian por índice, no nombre.
514 model.doc.states[i].name = new;
515 model.rebuild();
516 }
517 }
518 }
519 Msg::ToggleLoop => {
520 if let Some(i) = model.selected_state() {
521 model.doc.states[i].looping = !model.doc.states[i].looping;
522 model.rebuild();
523 }
524 }
525 Msg::SetSpeed(v) => {
526 if let Some(i) = model.selected_state() {
527 model.doc.states[i].speed = v.clamp(0.0, 4.0);
528 model.rebuild();
529 }
530 }
531 Msg::SetClipLen(v) => {
532 if let Some(i) = model.selected_state() {
533 model.doc.states[i].clip_len = v.clamp(0.1, 10.0);
534 model.rebuild();
535 }
536 }
537
538 // ---------------- transiciones ----------------
539 Msg::SetTransDur(v) => {
540 if let Some(i) = model.selected_trans() {
541 model.doc.transitions[i].duration_secs = v.clamp(0.0, 2.0);
542 model.rebuild();
543 }
544 }
545 Msg::ToggleAnyState => {
546 if let Some(i) = model.selected_trans() {
547 let t = &mut model.doc.transitions[i];
548 t.from = match t.from {
549 Some(_) => None,
550 None => Some(model.doc.entry),
551 };
552 model.rebuild();
553 }
554 }
555 Msg::AddCondFor(name) => {
556 if let Some(i) = model.selected_trans() {
557 let kind = model
558 .doc
559 .inputs
560 .iter()
561 .find(|x| x.name == name)
562 .map(|x| x.kind);
563 let cond = match kind {
564 Some(InputKind::Bool) => CondDef::Bool { input: name, value: true },
565 Some(InputKind::Number) => CondDef::Number {
566 input: name,
567 op: CmpOp::Gt,
568 value: 0.0,
569 },
570 Some(InputKind::Trigger) => CondDef::Trigger { input: name },
571 None => return model,
572 };
573 model.doc.transitions[i].conditions.push(cond);
574 model.rebuild();
575 }
576 }
577 Msg::AddCondClipDone => {
578 if let Some(i) = model.selected_trans() {
579 model.doc.transitions[i].conditions.push(CondDef::ClipDone);
580 model.rebuild();
581 }
582 }
583 Msg::DeleteCond(ci) => {
584 if let Some(i) = model.selected_trans() {
585 if ci < model.doc.transitions[i].conditions.len() {
586 model.doc.transitions[i].conditions.remove(ci);
587 model.rebuild();
588 }
589 }
590 }
591 Msg::ToggleCondBool(ci) => {
592 if let Some(i) = model.selected_trans() {
593 if let Some(CondDef::Bool { value, .. }) =
594 model.doc.transitions[i].conditions.get_mut(ci)
595 {
596 *value = !*value;
597 model.rebuild();
598 }
599 }
600 }
601 Msg::CycleCondOp(ci) => {
602 if let Some(i) = model.selected_trans() {
603 if let Some(CondDef::Number { op, .. }) =
604 model.doc.transitions[i].conditions.get_mut(ci)
605 {
606 let cur = CmpOp::ALL.iter().position(|o| o == op).unwrap_or(0);
607 *op = CmpOp::ALL[(cur + 1) % CmpOp::ALL.len()];
608 model.rebuild();
609 }
610 }
611 }
612 Msg::SetCondNum(ci, v) => {
613 if let Some(i) = model.selected_trans() {
614 if let Some(CondDef::Number { value, .. }) =
615 model.doc.transitions[i].conditions.get_mut(ci)
616 {
617 *value = v;
618 model.rebuild();
619 }
620 }
621 }
622
623 // ---------------- inputs ----------------
624 Msg::NewInputCampo(ev) => {
625 if matches!(ev, TextInputEvent::Press(_)) {
626 model.new_input_focused = true;
627 model.name_focused = false;
628 }
629 model.new_input.handle(ev, &mut model.clipboard);
630 }
631 Msg::NewInputKey(ev) => {
632 model.new_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
633 }
634 Msg::AddInput(kind) => {
635 let name = model.new_input.text();
636 let name = name.trim();
637 if !name.is_empty() && !model.doc.inputs.iter().any(|i| i.name == name) {
638 model.doc.inputs.push(InputDef::new(name, kind));
639 model.new_input.clear();
640 model.rebuild();
641 } else {
642 model.status = "nombre de input vacío o repetido".into();
643 }
644 }
645 Msg::DeleteInput(i) => {
646 if i < model.doc.inputs.len() {
647 model.doc.inputs.remove(i);
648 model.rebuild();
649 }
650 }
651
652 // ---------------- controles en vivo ----------------
653 Msg::SetLiveBool(name, v) => {
654 model.live_bools.insert(name, v);
655 model.apply_live_inputs();
656 }
657 Msg::SetLiveNumber(name, v) => {
658 model.live_numbers.insert(name, v);
659 model.apply_live_inputs();
660 }
661 Msg::FireTrigger(name) => {
662 model.instance.fire(name);
663 }
664 Msg::TogglePlay => model.playing = !model.playing,
665 Msg::Restart => {
666 model.rebuild();
667 model.status = "reiniciado al estado de entrada".into();
668 }
669
670 // ---------------- persistencia ----------------
671 Msg::Save => {
672 let project = Project {
673 doc: model.doc.clone(),
674 rig: model.rig.clone(),
675 };
676 let ron = ron::ser::to_string_pretty(&project, ron::ser::PrettyConfig::default());
677 model.status = match ron {
678 Ok(s) => match std::fs::write(PROJECT_PATH, s) {
679 Ok(_) => format!("guardado en {PROJECT_PATH}"),
680 Err(e) => format!("error al escribir: {e}"),
681 },
682 Err(e) => format!("error al serializar: {e}"),
683 };
684 }
685 Msg::Load => match std::fs::read_to_string(PROJECT_PATH) {
686 Ok(s) => match ron::from_str::<Project>(&s) {
687 Ok(p) => {
688 model.doc = p.doc;
689 model.rig = p.rig;
690 model.sel = Sel::None;
691 model.rig_sel = model.rig.bones.len().checked_sub(1);
692 // Recargar la textura referenciada por path, si la hay.
693 model.texture = model
694 .rig
695 .texture_path
696 .as_ref()
697 .and_then(|p| load_texture(p).ok().map(|(img, _)| img));
698 if let Some(p) = &model.rig.texture_path {
699 model.tex_input.set_text(p.clone());
700 }
701 model.rebuild();
702 model.status = format!("cargado de {PROJECT_PATH}");
703 }
704 Err(e) => model.status = format!("RON inválido: {e}"),
705 },
706 Err(e) => model.status = format!("no se pudo leer: {e}"),
707 },
708 }
709 model
710 }
711
712 fn view(model: &Model) -> View<Msg> {
713 let body = match model.mode {
714 Mode::Estados => View::new(row_full())
715 .children(vec![left_panel(model), graph_panel(model), right_panel(model)]),
716 Mode::Rig => View::new(row_full()).children(vec![
717 rig_left_panel(model),
718 rig_canvas_panel(model),
719 rig_right_panel(model),
720 ]),
721 };
722 View::new(Style {
723 flex_direction: FlexDirection::Column,
724 size: Size {
725 width: percent(1.0_f32),
726 height: percent(1.0_f32),
727 },
728 ..Default::default()
729 })
730 .fill(model.theme.bg_app)
731 .children(vec![top_bar(model), body])
732 }
733}
734
735/// Estilo de una fila que ocupa todo el ancho y el alto restante.
736fn row_full() -> Style {
737 Style {
738 flex_direction: FlexDirection::Row,
739 flex_grow: 1.0,
740 size: Size {
741 width: percent(1.0_f32),
742 height: Dimension::auto(),
743 },
744 ..Default::default()
745 }
746}
747
748/// Barra superior: conmutador de modo Estados / Rig.
749fn top_bar(model: &Model) -> View<Msg> {
750 let theme = &model.theme;
751 let tab = |label: &str, active: bool, msg: Msg| -> View<Msg> {
752 let (bg, fg) = if active {
753 (theme.accent, Color::from_rgba8(20, 20, 24, 255))
754 } else {
755 (theme.bg_panel_alt, theme.fg_muted)
756 };
757 View::new(Style {
758 size: Size {
759 width: length(120.0_f32),
760 height: length(28.0_f32),
761 },
762 align_items: Some(AlignItems::Center),
763 padding: pad(12.0, 0.0),
764 ..Default::default()
765 })
766 .fill(bg)
767 .radius(5.0)
768 .text(label.to_string(), 13.0, fg)
769 .on_click(msg)
770 };
771 View::new(Style {
772 flex_direction: FlexDirection::Row,
773 size: Size {
774 width: percent(1.0_f32),
775 height: length(44.0_f32),
776 },
777 flex_shrink: 0.0,
778 align_items: Some(AlignItems::Center),
779 padding: pad(12.0, 0.0),
780 gap: gap(8.0),
781 ..Default::default()
782 })
783 .fill(theme.bg_panel)
784 .children(vec![
785 tab("◆ Estados", model.mode == Mode::Estados, Msg::SetMode(Mode::Estados)),
786 tab("⦿ Rig", model.mode == Mode::Rig, Msg::SetMode(Mode::Rig)),
787 ])
788}
789
790// =============================================================================
791// Panel izquierdo — listas + CRUD
792// =============================================================================
793
794fn left_panel(model: &Model) -> View<Msg> {
795 let theme = &model.theme;
796 let btn = ButtonPalette::from_theme(theme);
797 let mut rows: Vec<View<Msg>> = Vec::new();
798
799 rows.push(section_title("ESTADOS", theme));
800 for (i, s) in model.doc.states.iter().enumerate() {
801 let is_sel = model.sel == Sel::State(i);
802 let is_entry = model.doc.entry == i;
803 let label = if is_entry {
804 format!("▶ {}", s.name)
805 } else {
806 s.name.clone()
807 };
808 rows.push(selectable_row(&label, is_sel, Msg::SelectState(i), theme));
809 }
810 rows.push(spacer(6.0));
811 rows.push(button_view("+ estado", &btn, Msg::AddState));
812
813 rows.push(spacer(14.0));
814 rows.push(section_title("TRANSICIONES", theme));
815 for (i, t) in model.doc.transitions.iter().enumerate() {
816 let is_sel = model.sel == Sel::Trans(i);
817 let from = match t.from {
818 Some(f) => model.doc.states.get(f).map(|s| s.name.as_str()).unwrap_or("?"),
819 None => "∗",
820 };
821 let to = model
822 .doc
823 .states
824 .get(t.to)
825 .map(|s| s.name.as_str())
826 .unwrap_or("?");
827 let mark = if t.conditions.is_empty() { " ⚠" } else { "" };
828 let label = format!("{from} → {to}{mark}");
829 rows.push(selectable_row(&label, is_sel, Msg::SelectTrans(i), theme));
830 }
831
832 rows.push(spacer(14.0));
833 rows.push(section_title("INPUTS", theme));
834 for (i, inp) in model.doc.inputs.iter().enumerate() {
835 rows.push(input_row(i, inp, theme));
836 }
837 rows.push(spacer(6.0));
838 rows.push(text_input_view_full(
839 &model.new_input,
840 "nombre del input…",
841 model.new_input_focused,
842 &TextInputPalette::from_theme(theme),
843 Msg::NewInputCampo,
844 ));
845 rows.push(spacer(4.0));
846 rows.push(
847 row(vec![
848 button_view("+bool", &btn, Msg::AddInput(InputKind::Bool)),
849 button_view("+núm", &btn, Msg::AddInput(InputKind::Number)),
850 button_view("+trig", &btn, Msg::AddInput(InputKind::Trigger)),
851 ]),
852 );
853
854 panel_column(rows, 250.0, theme.bg_panel)
855}
856
857fn input_row(i: usize, inp: &InputDef, theme: &Theme) -> View<Msg> {
858 let btn = ButtonPalette::from_theme(theme);
859 row(vec![
860 View::new(Style {
861 flex_grow: 1.0,
862 size: Size {
863 width: Dimension::auto(),
864 height: length(24.0_f32),
865 },
866 align_items: Some(AlignItems::Center),
867 ..Default::default()
868 })
869 .text(format!("{} · {}", inp.name, inp.kind.label()), 12.0, theme.fg_text),
870 View::new(Style {
871 size: Size {
872 width: length(30.0_f32),
873 height: length(24.0_f32),
874 },
875 flex_shrink: 0.0,
876 ..Default::default()
877 })
878 .children(vec![button_view("✕", &btn, Msg::DeleteInput(i))]),
879 ])
880}
881
882// =============================================================================
883// Panel central — el lienzo de nodos
884// =============================================================================
885
886fn graph_panel(model: &Model) -> View<Msg> {
887 let theme = &model.theme;
888 let palette = NodegraphPalette::from_theme(theme);
889 let metrics = NodegraphMetrics::default();
890
891 let nodes: Vec<NodeSpec> = model
892 .doc
893 .states
894 .iter()
895 .enumerate()
896 .map(|(i, s)| NodeSpec {
897 id: i as NodeId,
898 label: s.name.clone(),
899 x: s.x,
900 y: s.y,
901 inputs: vec!["in".into()],
902 outputs: vec!["out".into()],
903 })
904 .collect();
905
906 // Sólo las transiciones con origen concreto se dibujan como cable; las
907 // any-state no tienen nodo de origen (viven sólo en la lista).
908 let wires: Vec<Wire> = model
909 .doc
910 .transitions
911 .iter()
912 .filter_map(|t| {
913 t.from.map(|f| Wire {
914 from_node: f as NodeId,
915 from_output: 0,
916 to_node: t.to as NodeId,
917 to_input: 0,
918 })
919 })
920 .collect();
921
922 let current = model.current_idx;
923 let selected = model.selected_state();
924 let accent = theme.accent;
925 let sel_bg = theme.bg_selected;
926
927 let tint = move |id: NodeId| -> Option<NodeTint> {
928 let i = id as usize;
929 if current == Some(i) {
930 // Estado activo: título encendido en accent (se ve "prendido" en vivo).
931 Some(NodeTint {
932 bg_title: Some(accent),
933 fg_title: Some(Color::from_rgba8(20, 20, 24, 255)),
934 ..Default::default()
935 })
936 } else if selected == Some(i) {
937 Some(NodeTint {
938 bg_node: Some(sel_bg),
939 ..Default::default()
940 })
941 } else {
942 None
943 }
944 };
945
946 let graph = nodegraph_view_styled(
947 &nodes,
948 &wires,
949 &palette,
950 &metrics,
951 |id, phase, dx, dy| Some(Msg::DragNode(id, phase, dx, dy)),
952 |from_node, _from_out, to_node, _to_in| Some(Msg::Connect(from_node, to_node)),
953 Some(|id: NodeId| Some(Msg::SelectState(id as usize))),
954 Some(&tint as &dyn Fn(NodeId) -> Option<NodeTint>),
955 None,
956 );
957
958 View::new(Style {
959 flex_grow: 1.0,
960 flex_direction: FlexDirection::Column,
961 size: Size {
962 width: Dimension::auto(),
963 height: percent(1.0_f32),
964 },
965 ..Default::default()
966 })
967 .fill(theme.bg_app)
968 .children(vec![graph])
969}
970
971// =============================================================================
972// Panel derecho — preview en vivo + inspector
973// =============================================================================
974
975fn right_panel(model: &Model) -> View<Msg> {
976 let theme = &model.theme;
977 let btn = ButtonPalette::from_theme(theme);
978 let mut rows: Vec<View<Msg>> = Vec::new();
979
980 // --- Preview ---
981 rows.push(section_title("PREVIEW", theme));
982 rows.push(preview_canvas(model));
983 let cur = model
984 .current_idx
985 .and_then(|i| model.doc.states.get(i))
986 .map(|s| s.name.as_str())
987 .unwrap_or("—");
988 let trans = if model.instance.is_transitioning() {
989 " (mezclando…)"
990 } else {
991 ""
992 };
993 rows.push(
994 View::new(auto_h(22.0)).text(format!("estado: {cur}{trans}"), 13.0, theme.accent),
995 );
996 rows.push(spacer(4.0));
997 rows.push(row(vec![
998 button_view(if model.playing { "⏸ pausa" } else { "▶ play" }, &btn, Msg::TogglePlay),
999 button_view("⟲ reiniciar", &btn, Msg::Restart),
1000 ]));
1001
1002 // --- Controles en vivo ---
1003 rows.push(spacer(12.0));
1004 rows.push(section_title("INPUTS EN VIVO", theme));
1005 if model.doc.inputs.is_empty() {
1006 rows.push(muted("declará inputs en el panel izquierdo", theme));
1007 }
1008 let sp = SliderPalette::from_theme(theme);
1009 for inp in &model.doc.inputs {
1010 match inp.kind {
1011 InputKind::Bool => {
1012 let on = model.live_bools.get(&inp.name).copied().unwrap_or(false);
1013 let name = inp.name.clone();
1014 let lbl = format!("{}: {}", inp.name, if on { "true" } else { "false" });
1015 rows.push(button_view(lbl, &btn, Msg::SetLiveBool(name, !on)));
1016 }
1017 InputKind::Number => {
1018 let v = model.live_numbers.get(&inp.name).copied().unwrap_or(0.0) as f32;
1019 let name = inp.name.clone();
1020 rows.push(slider_view(
1021 inp.name.clone(),
1022 v,
1023 0.0,
1024 10.0,
1025 &sp,
1026 move |_p, nv| Some(Msg::SetLiveNumber(name.clone(), nv as f64)),
1027 ));
1028 }
1029 InputKind::Trigger => {
1030 let name = inp.name.clone();
1031 rows.push(button_view(format!("⚡ {}", inp.name), &btn, Msg::FireTrigger(name)));
1032 }
1033 }
1034 rows.push(spacer(4.0));
1035 }
1036
1037 // --- Inspector ---
1038 rows.push(spacer(10.0));
1039 rows.extend(inspector(model));
1040
1041 // --- Persistencia + estado ---
1042 rows.push(spacer(12.0));
1043 rows.push(row(vec![
1044 button_view("guardar", &btn, Msg::Save),
1045 button_view("cargar", &btn, Msg::Load),
1046 ]));
1047 rows.push(spacer(8.0));
1048 rows.push(View::new(auto_h(0.0)).text(model.status.clone(), 11.0, theme.fg_placeholder).max_lines(3));
1049
1050 panel_column(rows, 320.0, theme.bg_panel)
1051}
1052
1053/// El inspector del elemento seleccionado (estado o transición).
1054fn inspector(model: &Model) -> Vec<View<Msg>> {
1055 let theme = &model.theme;
1056 let btn = ButtonPalette::from_theme(theme);
1057 let sp = SliderPalette::from_theme(theme);
1058 let mut rows: Vec<View<Msg>> = Vec::new();
1059
1060 match model.sel {
1061 Sel::State(i) if i < model.doc.states.len() => {
1062 let s = &model.doc.states[i];
1063 rows.push(section_title("ESTADO", theme));
1064 rows.push(text_input_view_full(
1065 &model.name_input,
1066 "nombre…",
1067 model.name_focused,
1068 &TextInputPalette::from_theme(theme),
1069 Msg::RenameCampo,
1070 ));
1071 rows.push(spacer(6.0));
1072 rows.push(button_view(
1073 if s.looping { "loop: sí" } else { "loop: no" },
1074 &btn,
1075 Msg::ToggleLoop,
1076 ));
1077 rows.push(spacer(4.0));
1078 let speed = s.speed as f32;
1079 rows.push(slider_view(
1080 format!("velocidad {:.2}", s.speed),
1081 speed,
1082 0.0,
1083 4.0,
1084 &sp,
1085 move |_p, nv| Some(Msg::SetSpeed(nv as f64)),
1086 ));
1087 if !s.looping {
1088 let len = s.clip_len as f32;
1089 rows.push(slider_view(
1090 format!("duración {:.2}s", s.clip_len),
1091 len,
1092 0.1,
1093 10.0,
1094 &sp,
1095 move |_p, nv| Some(Msg::SetClipLen(nv as f64)),
1096 ));
1097 }
1098 rows.push(spacer(6.0));
1099 rows.push(row(vec![
1100 button_view("entry", &btn, Msg::SetEntry),
1101 button_view("borrar", &btn, Msg::DeleteSelected),
1102 ]));
1103 }
1104 Sel::Trans(i) if i < model.doc.transitions.len() => {
1105 let t = &model.doc.transitions[i];
1106 rows.push(section_title("TRANSICIÓN", theme));
1107 let from = match t.from {
1108 Some(f) => model.doc.states.get(f).map(|s| s.name.clone()).unwrap_or_default(),
1109 None => "∗ (any-state)".into(),
1110 };
1111 let to = model.doc.states.get(t.to).map(|s| s.name.clone()).unwrap_or_default();
1112 rows.push(muted(&format!("{from} → {to}"), theme));
1113 rows.push(spacer(4.0));
1114 let dur = t.duration_secs as f32;
1115 rows.push(slider_view(
1116 format!("blend {:.2}s", t.duration_secs),
1117 dur,
1118 0.0,
1119 2.0,
1120 &sp,
1121 move |_p, nv| Some(Msg::SetTransDur(nv as f64)),
1122 ));
1123 rows.push(spacer(4.0));
1124 rows.push(button_view(
1125 if t.from.is_some() { "→ volver any-state" } else { "← darle origen (entry)" },
1126 &btn,
1127 Msg::ToggleAnyState,
1128 ));
1129
1130 // Condiciones (AND).
1131 rows.push(spacer(8.0));
1132 rows.push(muted("CONDICIONES (AND)", theme));
1133 if t.conditions.is_empty() {
1134 rows.push(muted("⚠ sin condición → nunca dispara", theme));
1135 }
1136 for (ci, c) in t.conditions.iter().enumerate() {
1137 rows.push(cond_row(ci, c, theme));
1138 }
1139 // Agregar condición: un botón por input + clip-terminó.
1140 rows.push(spacer(6.0));
1141 rows.push(muted("agregar:", theme));
1142 let mut add_btns: Vec<View<Msg>> = Vec::new();
1143 for inp in &model.doc.inputs {
1144 let name = inp.name.clone();
1145 add_btns.push(button_view(format!("+{}", inp.name), &btn, Msg::AddCondFor(name)));
1146 }
1147 if !add_btns.is_empty() {
1148 rows.push(wrap_row(add_btns));
1149 }
1150 rows.push(spacer(4.0));
1151 rows.push(button_view("+ clip terminó", &btn, Msg::AddCondClipDone));
1152 rows.push(spacer(6.0));
1153 rows.push(button_view("borrar transición", &btn, Msg::DeleteSelected));
1154 }
1155 _ => {
1156 rows.push(muted("selecciona un estado o transición", theme));
1157 }
1158 }
1159 rows
1160}
1161
1162/// Una fila de condición editable.
1163fn cond_row(ci: usize, c: &CondDef, theme: &Theme) -> View<Msg> {
1164 let btn = ButtonPalette::from_theme(theme);
1165 let sp = SliderPalette::from_theme(theme);
1166 let mut items: Vec<View<Msg>> = Vec::new();
1167 match c {
1168 CondDef::Bool { input, value } => {
1169 items.push(grow_text(format!("{input} =="), theme));
1170 items.push(fixed_btn(
1171 if *value { "true" } else { "false" },
1172 Msg::ToggleCondBool(ci),
1173 &btn,
1174 64.0,
1175 ));
1176 }
1177 CondDef::Number { input, op, value } => {
1178 items.push(grow_text(format!("{input} {} {value:.1}", op.symbol()), theme));
1179 items.push(fixed_btn(op.symbol(), Msg::CycleCondOp(ci), &btn, 40.0));
1180 }
1181 CondDef::Trigger { input } => {
1182 items.push(grow_text(format!("⚡ {input}"), theme));
1183 }
1184 CondDef::ClipDone => {
1185 items.push(grow_text("clip terminó".to_string(), theme));
1186 }
1187 }
1188 items.push(fixed_btn("✕", Msg::DeleteCond(ci), &btn, 30.0));
1189
1190 let mut col = vec![row(items)];
1191 // Slider para el valor numérico (debajo).
1192 if let CondDef::Number { value, .. } = c {
1193 let v = *value as f32;
1194 col.push(slider_view(
1195 "valor",
1196 v,
1197 0.0,
1198 10.0,
1199 &sp,
1200 move |_p, nv| Some(Msg::SetCondNum(ci, nv as f64)),
1201 ));
1202 }
1203 View::new(Style {
1204 flex_direction: FlexDirection::Column,
1205 size: Size {
1206 width: percent(1.0_f32),
1207 height: Dimension::auto(),
1208 },
1209 ..Default::default()
1210 })
1211 .children(col)
1212}
1213
1214// =============================================================================
1215// Preview sintético
1216// =============================================================================
1217
1218/// Lienzo que pinta lo que emite la `Instance`: un disco por clip cuyo color =
1219/// el color del estado y cuyo movimiento (bob + orbe) avanza con `time_secs`.
1220/// Durante una transición pinta el clip entrante encima con alpha = mezcla — el
1221/// crossfade del runtime se ve literalmente.
1222fn preview_canvas(model: &Model) -> View<Msg> {
1223 let rf: RenderFrame = model.instance.render_frame();
1224 let n = model.doc.states.len().max(1);
1225 let colors: Vec<Color> = (0..n).map(state_color).collect();
1226 let bg = self_color(model.theme.bg_app, model.theme.bg_panel_alt);
1227
1228 View::new(Style {
1229 size: Size {
1230 width: percent(1.0_f32),
1231 height: length(220.0_f32),
1232 },
1233 ..Default::default()
1234 })
1235 .fill(bg)
1236 .radius(8.0)
1237 .paint_with(move |scene, _ts, rect| {
1238 use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Point};
1239 use llimphi_ui::llimphi_raster::peniko::Fill;
1240
1241 let cx = (rect.x + rect.w * 0.5) as f64;
1242 let cy = (rect.y + rect.h * 0.5) as f64;
1243 let unit = (rect.w.min(rect.h)) as f64;
1244 let r = unit * 0.20;
1245
1246 let draw = |scene: &mut vello::Scene, sample_clip: usize, time: f64, alpha: f32| {
1247 let color = colors
1248 .get(sample_clip)
1249 .copied()
1250 .unwrap_or(Color::from_rgba8(180, 180, 190, 255))
1251 .multiply_alpha(alpha);
1252 // Bob vertical con el tiempo del clip.
1253 let bob = (time * 2.4).sin() * unit * 0.16;
1254 let center = Point::new(cx, cy + bob);
1255 scene.fill(Fill::NonZero, Affine::IDENTITY, &color, None, &Circle::new(center, r));
1256 // Orbe que marca el avance del tiempo (gira con time).
1257 let a = time * 2.0;
1258 let orb = Point::new(cx + a.cos() * r * 1.6, cy + bob + a.sin() * r * 1.6);
1259 let orb_c = Color::from_rgba8(255, 255, 255, 230).multiply_alpha(alpha);
1260 scene.fill(Fill::NonZero, Affine::IDENTITY, &orb_c, None, &Circle::new(orb, unit * 0.03));
1261 };
1262
1263 // Primario a alpha pleno; entrante encima con su mezcla.
1264 draw(scene, rf.primary.clip as usize, rf.primary.time_secs, 1.0);
1265 if let Some((incoming, mix)) = rf.blend {
1266 draw(scene, incoming.clip as usize, incoming.time_secs, mix);
1267 }
1268 })
1269}
1270
1271/// Color estable por índice de estado (paleta de tintes distinguibles).
1272fn state_color(i: usize) -> Color {
1273 const PAL: [(u8, u8, u8); 8] = [
1274 (94, 168, 255), // azul
1275 (120, 210, 140), // verde
1276 (255, 178, 92), // naranja
1277 (220, 120, 220), // magenta
1278 (240, 220, 110), // amarillo
1279 (120, 210, 220), // cyan
1280 (240, 130, 130), // rojo
1281 (170, 150, 240), // violeta
1282 ];
1283 let (r, g, b) = PAL[i % PAL.len()];
1284 Color::from_rgba8(r, g, b, 255)
1285}
1286
1287// =============================================================================
1288// Modo Rig — paneles
1289// =============================================================================
1290
1291fn rig_left_panel(model: &Model) -> View<Msg> {
1292 let theme = &model.theme;
1293 let btn = ButtonPalette::from_theme(theme);
1294 let mut rows: Vec<View<Msg>> = Vec::new();
1295 rows.push(section_title("HUESOS (cadena)", theme));
1296 for (i, b) in model.rig.bones.iter().enumerate() {
1297 let sel = model.rig_sel == Some(i);
1298 let label = format!("hueso {i} · {:.0}", b.len);
1299 rows.push(selectable_row(&label, sel, Msg::RigSelectBone(i), theme));
1300 }
1301 rows.push(spacer(8.0));
1302 rows.push(row(vec![
1303 button_view("+ hueso", &btn, Msg::RigAddBone),
1304 button_view("− hueso", &btn, Msg::RigDelBone),
1305 ]));
1306 rows.push(spacer(10.0));
1307 rows.push(muted(
1308 "la cadena sale del origen hacia +x; cada hueso lleva su hijo y arrastra la malla por skinning (LBS).",
1309 theme,
1310 ));
1311 panel_column(rows, 230.0, theme.bg_panel)
1312}
1313
1314fn rig_right_panel(model: &Model) -> View<Msg> {
1315 let theme = &model.theme;
1316 let btn = ButtonPalette::from_theme(theme);
1317 let sp = SliderPalette::from_theme(theme);
1318 let mut rows: Vec<View<Msg>> = Vec::new();
1319
1320 // --- Hueso seleccionado ---
1321 rows.push(section_title("HUESO", theme));
1322 if let Some(i) = model.rig_sel.filter(|i| *i < model.rig.bones.len()) {
1323 let b = &model.rig.bones[i];
1324 let ang = b.angle as f32;
1325 rows.push(slider_view(
1326 format!("ángulo {:.2} rad", b.angle),
1327 ang,
1328 -3.1,
1329 3.1,
1330 &sp,
1331 move |_p, nv| Some(Msg::RigSetAngle(i, nv as f64)),
1332 ));
1333 let len = b.len as f32;
1334 rows.push(slider_view(
1335 format!("largo {:.0}", b.len),
1336 len,
1337 10.0,
1338 400.0,
1339 &sp,
1340 move |_p, nv| Some(Msg::RigSetLen(i, nv as f64)),
1341 ));
1342 } else {
1343 rows.push(muted("selecciona un hueso", theme));
1344 }
1345 rows.push(spacer(6.0));
1346 rows.push(button_view("⟲ pose neutra", &btn, Msg::RigResetPose));
1347
1348 // --- Malla ---
1349 rows.push(spacer(12.0));
1350 rows.push(section_title("MALLA", theme));
1351 let is_grid = matches!(model.rig.mesh_mode, MeshMode::Grid);
1352 rows.push(row(vec![
1353 toggle_btn("tubo", !is_grid, Msg::RigSetMeshMode(MeshMode::Tube), theme),
1354 toggle_btn("rejilla", is_grid, Msg::RigSetMeshMode(MeshMode::Grid), theme),
1355 ]));
1356 rows.push(spacer(4.0));
1357 if is_grid {
1358 let gr = model.rig.grid_res as f32;
1359 rows.push(slider_view(
1360 format!("resolución {}", model.rig.grid_res),
1361 gr,
1362 2.0,
1363 40.0,
1364 &sp,
1365 move |_p, nv| Some(Msg::RigSetGridRes(nv as f64)),
1366 ));
1367 let asp = model.rig.mesh_aspect as f32;
1368 rows.push(slider_view(
1369 format!("aspecto {:.2}", model.rig.mesh_aspect),
1370 asp,
1371 0.1,
1372 3.0,
1373 &sp,
1374 move |_p, nv| Some(Msg::RigSetAspect(nv as f64)),
1375 ));
1376 } else {
1377 let th = model.rig.thickness as f32;
1378 rows.push(slider_view(
1379 format!("grosor {:.0}", model.rig.thickness),
1380 th,
1381 2.0,
1382 120.0,
1383 &sp,
1384 move |_p, nv| Some(Msg::RigSetThickness(nv as f64)),
1385 ));
1386 let cols = model.rig.cols as f32;
1387 rows.push(slider_view(
1388 format!("columnas {}", model.rig.cols),
1389 cols,
1390 2.0,
1391 64.0,
1392 &sp,
1393 move |_p, nv| Some(Msg::RigSetCols(nv as f64)),
1394 ));
1395 }
1396
1397 // --- Textura (deformar una imagen real) ---
1398 rows.push(spacer(12.0));
1399 rows.push(section_title("TEXTURA", theme));
1400 rows.push(text_input_view_full(
1401 &model.tex_input,
1402 "/path/a/imagen.png…",
1403 model.tex_focused,
1404 &TextInputPalette::from_theme(theme),
1405 Msg::RigTexCampo,
1406 ));
1407 rows.push(spacer(4.0));
1408 rows.push(row(vec![
1409 button_view("cargar textura", &btn, Msg::RigLoadTexture),
1410 button_view("quitar", &btn, Msg::RigClearTexture),
1411 ]));
1412 if model.texture.is_some() {
1413 rows.push(muted("textura activa → modo rejilla la deforma", theme));
1414 } else {
1415 rows.push(muted("carga un PNG/JPG: se rige a la cadena y se dobla", theme));
1416 }
1417
1418 // --- IK ---
1419 rows.push(spacer(12.0));
1420 rows.push(section_title("IK (2 huesos)", theme));
1421 if model.rig.bones.len() < 2 {
1422 rows.push(muted("necesitas ≥2 huesos para el IK", theme));
1423 } else {
1424 rows.push(muted("clic/arrastra en el lienzo para mover el objetivo", theme));
1425 rows.push(row(vec![
1426 button_view(
1427 if model.rig.ik_enabled { "IK: on" } else { "IK: off" },
1428 &btn,
1429 Msg::RigToggleIk,
1430 ),
1431 button_view(
1432 if model.rig.ik_flip { "codo ↑" } else { "codo ↓" },
1433 &btn,
1434 Msg::RigToggleFlip,
1435 ),
1436 ]));
1437 if model.rig.ik_enabled {
1438 let reach = (model.rig.total_len() + 120.0) as f32;
1439 let tx = model.rig.ik_target.0 as f32;
1440 rows.push(slider_view(
1441 format!("objetivo x {:.0}", model.rig.ik_target.0),
1442 tx,
1443 -reach,
1444 reach,
1445 &sp,
1446 move |_p, nv| Some(Msg::RigSetTargetX(nv as f64)),
1447 ));
1448 let ty = model.rig.ik_target.1 as f32;
1449 rows.push(slider_view(
1450 format!("objetivo y {:.0}", model.rig.ik_target.1),
1451 ty,
1452 -reach,
1453 reach,
1454 &sp,
1455 move |_p, nv| Some(Msg::RigSetTargetY(nv as f64)),
1456 ));
1457 }
1458 }
1459
1460 // --- Persistencia ---
1461 rows.push(spacer(14.0));
1462 rows.push(row(vec![
1463 button_view("guardar", &btn, Msg::Save),
1464 button_view("cargar", &btn, Msg::Load),
1465 ]));
1466 rows.push(spacer(8.0));
1467 rows.push(
1468 View::new(auto_h(0.0))
1469 .text(model.status.clone(), 11.0, theme.fg_placeholder)
1470 .max_lines(3),
1471 );
1472
1473 panel_column(rows, 300.0, theme.bg_panel)
1474}
1475
1476/// Panel central del modo Rig: el lienzo con la malla deformada en vivo.
1477fn rig_canvas_panel(model: &Model) -> View<Msg> {
1478 let theme = &model.theme;
1479 View::new(Style {
1480 flex_grow: 1.0,
1481 flex_direction: FlexDirection::Column,
1482 size: Size {
1483 width: Dimension::auto(),
1484 height: percent(1.0_f32),
1485 },
1486 padding: pad(14.0, 14.0),
1487 ..Default::default()
1488 })
1489 .fill(theme.bg_app)
1490 .children(vec![rig_canvas(model)])
1491}
1492
1493/// Bbox de modelo que encuadra el rig (silueta + objetivo IK), inflado para
1494/// que las poses dobladas no se salgan. Lo comparten el render del lienzo y la
1495/// inversión pantalla→modelo del drag del objetivo IK.
1496fn rig_view_bounds(rig: &RigDoc) -> KRect {
1497 let total = rig.total_len();
1498 let half = match rig.mesh_mode {
1499 MeshMode::Tube => rig.thickness,
1500 MeshMode::Grid => (total * rig.mesh_aspect * 0.5).max(rig.thickness),
1501 };
1502 let mut x0: f64 = -20.0;
1503 let mut y0: f64 = -half - 20.0;
1504 let mut x1: f64 = total + 20.0;
1505 let mut y1: f64 = half + 20.0;
1506 if rig.ik_enabled {
1507 x0 = x0.min(rig.ik_target.0);
1508 y0 = y0.min(rig.ik_target.1);
1509 x1 = x1.max(rig.ik_target.0);
1510 y1 = y1.max(rig.ik_target.1);
1511 }
1512 let pad_m = (total * 0.18).max(24.0);
1513 KRect::new(x0 - pad_m, y0 - pad_m, x1 + pad_m, y1 + pad_m)
1514}
1515
1516/// Invierte una posición local del lienzo (px, relativa al rect del nodo) a
1517/// espacio de modelo, deshaciendo el `fit_transform(bounds, rect)`.
1518fn canvas_local_to_model(lx: f32, ly: f32, rw: f32, rh: f32, b: KRect) -> (f64, f64) {
1519 let (bw, bh) = (b.width(), b.height());
1520 if bw <= 0.0 || bh <= 0.0 || rw <= 0.0 || rh <= 0.0 {
1521 return (0.0, 0.0);
1522 }
1523 let s = (rw as f64 / bw).min(rh as f64 / bh);
1524 let mx = (lx as f64 - (rw as f64 - bw * s) * 0.5) / s + b.x0;
1525 let my = (ly as f64 - (rh as f64 - bh * s) * 0.5) / s + b.y0;
1526 (mx, my)
1527}
1528
1529/// Escala modelo→pantalla del `fit_transform` (px por unidad de modelo).
1530fn canvas_scale(rw: f32, rh: f32, b: KRect) -> f64 {
1531 let (bw, bh) = (b.width(), b.height());
1532 if bw <= 0.0 || bh <= 0.0 || rw <= 0.0 || rh <= 0.0 {
1533 return 0.0;
1534 }
1535 (rw as f64 / bw).min(rh as f64 / bh)
1536}
1537
1538/// El lienzo: malla deformada (relleno + wireframe) + huesos + objetivo IK.
1539fn rig_canvas(model: &Model) -> View<Msg> {
1540 let skel = model.rig.skeleton();
1541 let mesh = model.rig.mesh();
1542 let positions = mesh.deform(&skel);
1543
1544 // Segmentos de hueso en espacio de modelo (para dibujarlos encima).
1545 let mut bones_world: Vec<(
1546 llimphi_ui::llimphi_raster::kurbo::Point,
1547 llimphi_ui::llimphi_raster::kurbo::Point,
1548 )> = Vec::new();
1549 for (i, b) in model.rig.bones.iter().enumerate() {
1550 let w = skel.world(i);
1551 let a = w * llimphi_ui::llimphi_raster::kurbo::Point::ZERO;
1552 let e = w * llimphi_ui::llimphi_raster::kurbo::Point::new(b.len, 0.0);
1553 bones_world.push((a, e));
1554 }
1555
1556 // Encuadre estable, compartido con la inversión del drag del objetivo IK.
1557 let bounds = rig_view_bounds(&model.rig);
1558
1559 let target = if model.rig.ik_enabled {
1560 Some(llimphi_ui::llimphi_raster::kurbo::Point::new(
1561 model.rig.ik_target.0,
1562 model.rig.ik_target.1,
1563 ))
1564 } else {
1565 None
1566 };
1567
1568 let fill = theme_with_alpha(model.theme.accent, 90);
1569 let wire = model.theme.fg_text;
1570 let bone_col = Color::from_rgba8(255, 196, 92, 255); // ámbar, contrasta con la malla
1571 let bg = self_color(model.theme.bg_app, model.theme.bg_panel);
1572 let tex = model.texture.clone();
1573 let use_tex = matches!(model.rig.mesh_mode, MeshMode::Grid) && tex.is_some();
1574
1575 View::new(Style {
1576 flex_grow: 1.0,
1577 size: Size {
1578 width: percent(1.0_f32),
1579 height: percent(1.0_f32),
1580 },
1581 ..Default::default()
1582 })
1583 .fill(bg)
1584 .radius(8.0)
1585 .paint_with(move |scene, _ts, rect| {
1586 use llimphi_ui::llimphi_raster::kurbo::{Affine, Circle, Line, Stroke};
1587 use llimphi_ui::llimphi_raster::peniko::Fill;
1588 use llimphi_mesh::{fit_transform, paint_solid, paint_textured, paint_wireframe};
1589
1590 if mesh.vertices.is_empty() {
1591 return;
1592 }
1593 let xform = fit_transform(bounds, rect);
1594 // Malla deformada: textura real (modo Grid) o relleno + wireframe.
1595 if use_tex {
1596 if let Some(t) = &tex {
1597 paint_textured(scene, &mesh, &positions, xform, t);
1598 }
1599 // Wireframe tenue encima para leer la deformación.
1600 paint_wireframe(scene, &mesh, &positions, xform, theme_with_alpha(wire, 55), 0.7);
1601 } else {
1602 paint_solid(scene, &mesh, &positions, xform, fill);
1603 paint_wireframe(scene, &mesh, &positions, xform, wire, 1.0);
1604 }
1605
1606 // Huesos: líneas gruesas + nudos en las articulaciones.
1607 for (a, e) in &bones_world {
1608 let pa = xform * *a;
1609 let pe = xform * *e;
1610 scene.stroke(
1611 &Stroke::new(3.0),
1612 Affine::IDENTITY,
1613 &bone_col,
1614 None,
1615 &Line::new(pa, pe),
1616 );
1617 scene.fill(
1618 Fill::NonZero,
1619 Affine::IDENTITY,
1620 &bone_col,
1621 None,
1622 &Circle::new(pa, 4.0),
1623 );
1624 }
1625
1626 // Objetivo IK: anillo blanco.
1627 if let Some(t) = target {
1628 let pt = xform * t;
1629 let ring = Color::from_rgba8(255, 255, 255, 235);
1630 scene.stroke(
1631 &Stroke::new(2.0),
1632 Affine::IDENTITY,
1633 &ring,
1634 None,
1635 &Circle::new(pt, 9.0),
1636 );
1637 }
1638 })
1639 // Click coloca el objetivo IK; arrastrar lo mueve (el brazo lo persigue).
1640 .on_click_at(|lx, ly, w, h| Some(Msg::RigCanvasClick(lx, ly, w, h)))
1641 .draggable_at(|phase, dx, dy, _lx0, _ly0| match phase {
1642 DragPhase::Move => Some(Msg::RigCanvasDrag(dx, dy)),
1643 _ => None,
1644 })
1645}
1646
1647/// Color con alpha explícito.
1648fn theme_with_alpha(c: Color, a: u8) -> Color {
1649 let r = c.to_rgba8();
1650 Color::from_rgba8(r.r, r.g, r.b, a)
1651}
1652
1653// =============================================================================
1654// Helpers de layout
1655// =============================================================================
1656
1657fn auto_h(h: f32) -> Style {
1658 Style {
1659 size: Size {
1660 width: percent(1.0_f32),
1661 height: if h > 0.0 { length(h) } else { Dimension::auto() },
1662 },
1663 align_items: Some(AlignItems::Center),
1664 ..Default::default()
1665 }
1666}
1667
1668fn spacer(h: f32) -> View<Msg> {
1669 View::new(Style {
1670 size: Size {
1671 width: percent(1.0_f32),
1672 height: length(h),
1673 },
1674 ..Default::default()
1675 })
1676}
1677
1678fn section_title(text: &str, theme: &Theme) -> View<Msg> {
1679 View::new(Style {
1680 size: Size {
1681 width: percent(1.0_f32),
1682 height: length(20.0_f32),
1683 },
1684 ..Default::default()
1685 })
1686 .text(text.to_string(), 11.0, theme.fg_muted)
1687}
1688
1689fn muted(text: &str, theme: &Theme) -> View<Msg> {
1690 View::new(Style {
1691 size: Size {
1692 width: percent(1.0_f32),
1693 height: Dimension::auto(),
1694 },
1695 ..Default::default()
1696 })
1697 .text(text.to_string(), 11.0, theme.fg_placeholder)
1698 .max_lines(2)
1699}
1700
1701fn grow_text(text: String, theme: &Theme) -> View<Msg> {
1702 View::new(Style {
1703 flex_grow: 1.0,
1704 size: Size {
1705 width: Dimension::auto(),
1706 height: length(26.0_f32),
1707 },
1708 align_items: Some(AlignItems::Center),
1709 ..Default::default()
1710 })
1711 .text(text, 12.0, theme.fg_text)
1712}
1713
1714/// Botón de conmutación (segmented control): resaltado en accent si activo.
1715fn toggle_btn(label: &str, active: bool, msg: Msg, theme: &Theme) -> View<Msg> {
1716 let (bg, fg) = if active {
1717 (theme.accent, Color::from_rgba8(20, 20, 24, 255))
1718 } else {
1719 (theme.bg_button, theme.fg_muted)
1720 };
1721 View::new(Style {
1722 flex_grow: 1.0,
1723 size: Size {
1724 width: Dimension::auto(),
1725 height: length(28.0_f32),
1726 },
1727 align_items: Some(AlignItems::Center),
1728 padding: pad(10.0, 0.0),
1729 ..Default::default()
1730 })
1731 .fill(bg)
1732 .radius(5.0)
1733 .text(label.to_string(), 12.0, fg)
1734 .on_click(msg)
1735}
1736
1737fn fixed_btn(label: &str, msg: Msg, btn: &ButtonPalette, w: f32) -> View<Msg> {
1738 View::new(Style {
1739 size: Size {
1740 width: length(w),
1741 height: length(26.0_f32),
1742 },
1743 flex_shrink: 0.0,
1744 ..Default::default()
1745 })
1746 .children(vec![button_view(label.to_string(), btn, msg)])
1747}
1748
1749fn selectable_row(label: &str, selected: bool, msg: Msg, theme: &Theme) -> View<Msg> {
1750 let bg = if selected {
1751 theme.bg_selected
1752 } else {
1753 theme.bg_panel_alt
1754 };
1755 let fg = if selected { theme.fg_text } else { theme.fg_muted };
1756 View::new(Style {
1757 size: Size {
1758 width: percent(1.0_f32),
1759 height: length(26.0_f32),
1760 },
1761 align_items: Some(AlignItems::Center),
1762 padding: pad(8.0, 0.0),
1763 ..Default::default()
1764 })
1765 .fill(bg)
1766 .radius(4.0)
1767 .text(label.to_string(), 12.0, fg)
1768 .on_click(msg)
1769}
1770
1771fn row(children: Vec<View<Msg>>) -> View<Msg> {
1772 View::new(Style {
1773 flex_direction: FlexDirection::Row,
1774 size: Size {
1775 width: percent(1.0_f32),
1776 height: Dimension::auto(),
1777 },
1778 gap: gap(6.0),
1779 align_items: Some(AlignItems::Center),
1780 ..Default::default()
1781 })
1782 .children(children)
1783}
1784
1785/// Fila que envuelve (varios botones chicos).
1786fn wrap_row(children: Vec<View<Msg>>) -> View<Msg> {
1787 use llimphi_ui::llimphi_layout::taffy::prelude::FlexWrap;
1788 View::new(Style {
1789 flex_direction: FlexDirection::Row,
1790 flex_wrap: FlexWrap::Wrap,
1791 size: Size {
1792 width: percent(1.0_f32),
1793 height: Dimension::auto(),
1794 },
1795 gap: gap(4.0),
1796 ..Default::default()
1797 })
1798 .children(children)
1799}
1800
1801fn panel_column(rows: Vec<View<Msg>>, width: f32, bg: Color) -> View<Msg> {
1802 View::new(Style {
1803 flex_direction: FlexDirection::Column,
1804 size: Size {
1805 width: length(width),
1806 height: percent(1.0_f32),
1807 },
1808 flex_shrink: 0.0,
1809 padding: pad(12.0, 12.0),
1810 gap: gap(2.0),
1811 ..Default::default()
1812 })
1813 .fill(bg)
1814 .children(rows)
1815}
1816
1817fn pad(
1818 x: f32,
1819 y: f32,
1820) -> llimphi_ui::llimphi_layout::taffy::prelude::Rect<
1821 llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage,
1822> {
1823 use llimphi_ui::llimphi_layout::taffy::prelude::Rect;
1824 Rect {
1825 left: length(x),
1826 right: length(x),
1827 top: length(y),
1828 bottom: length(y),
1829 }
1830}
1831
1832fn gap(
1833 g: f32,
1834) -> Size<llimphi_ui::llimphi_layout::taffy::prelude::LengthPercentage> {
1835 Size {
1836 width: length(g),
1837 height: length(g),
1838 }
1839}
1840
1841/// Mezcla simple de dos colores al 50% (para el fondo del preview).
1842fn self_color(a: Color, b: Color) -> Color {
1843 let ca = a.to_rgba8();
1844 let cb = b.to_rgba8();
1845 Color::from_rgba8(
1846 ((ca.r as u16 + cb.r as u16) / 2) as u8,
1847 ((ca.g as u16 + cb.g as u16) / 2) as u8,
1848 ((ca.b as u16 + cb.b as u16) / 2) as u8,
1849 255,
1850 )
1851}
1852
1853// =============================================================================
1854// Operaciones sobre el documento
1855// =============================================================================
1856
1857/// Carga una imagen de disco y devuelve `(textura, aspecto alto/ancho)`.
1858fn load_texture(path: &str) -> Result<(llimphi_image::Image, f64), String> {
1859 use std::path::Path;
1860 const MAX: u64 = 64 * 1024 * 1024;
1861 let img = llimphi_image::load_path(Path::new(path), MAX).map_err(|e| format!("{e:?}"))?;
1862 let w = (img.image.width.max(1)) as f64;
1863 let h = (img.image.height.max(1)) as f64;
1864 Ok((img, (h / w).clamp(0.1, 3.0)))
1865}
1866
1867/// Borra el estado `idx` y reindexa transiciones/entry consistentemente.
1868fn remove_state(doc: &mut Doc, idx: usize) {
1869 if idx >= doc.states.len() {
1870 return;
1871 }
1872 doc.states.remove(idx);
1873 // Descartar transiciones que tocan el estado borrado; reindexar el resto.
1874 doc.transitions.retain(|t| t.from != Some(idx) && t.to != idx);
1875 for t in &mut doc.transitions {
1876 if let Some(f) = t.from {
1877 if f > idx {
1878 t.from = Some(f - 1);
1879 }
1880 }
1881 if t.to > idx {
1882 t.to -= 1;
1883 }
1884 }
1885 if doc.entry == idx {
1886 doc.entry = 0;
1887 } else if doc.entry > idx {
1888 doc.entry -= 1;
1889 }
1890}
1891
1892fn main() {
1893 llimphi_ui::run::<Studio>();
1894}
1895
1896#[cfg(test)]
1897mod tests {
1898 use super::*;
1899
1900 /// La inversión pantalla→modelo del drag debe deshacer exactamente el
1901 /// `fit_transform(bounds, rect)` que usa el render — round-trip < 1e-6.
1902 #[test]
1903 fn canvas_local_to_model_invierte_fit_transform() {
1904 let b = KRect::new(-30.0, -50.0, 260.0, 50.0);
1905 let (rw, rh) = (640.0_f64, 360.0_f64);
1906 let (bw, bh) = (b.width(), b.height());
1907 let s = (rw / bw).min(rh / bh);
1908 // Forward = misma fórmula de fit_transform, en coords LOCALES al rect.
1909 let forward = |mx: f64, my: f64| {
1910 let lx = (rw - bw * s) * 0.5 - b.x0 * s + s * mx;
1911 let ly = (rh - bh * s) * 0.5 - b.y0 * s + s * my;
1912 (lx, ly)
1913 };
1914 for (mx, my) in [(0.0, 0.0), (130.0, 10.0), (-20.0, 40.0), (255.0, -30.0)] {
1915 let (lx, ly) = forward(mx, my);
1916 let (rx, ry) = canvas_local_to_model(lx as f32, ly as f32, rw as f32, rh as f32, b);
1917 assert!(
1918 (rx - mx).abs() < 1e-3 && (ry - my).abs() < 1e-3,
1919 "round-trip falló para ({mx},{my}): recuperó ({rx},{ry})"
1920 );
1921 }
1922 }
1923
1924 /// El bbox de encuadre debe contener el objetivo IK cuando está activo
1925 /// (si no, el objetivo se saldría del lienzo y el drag sería inconsistente).
1926 #[test]
1927 fn bounds_contiene_objetivo_ik() {
1928 let mut rig = RigDoc::starter();
1929 rig.ik_enabled = true;
1930 rig.ik_target = (240.0, -130.0);
1931 let b = rig_view_bounds(&rig);
1932 assert!(b.x0 <= 240.0 && b.x1 >= 240.0);
1933 assert!(b.y0 <= -130.0 && b.y1 >= -130.0);
1934 }
1935}