gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-anim-studio/src/main.rs Rust · 1935 líneas · 66.6 KB Historia Quién tocó qué crudo

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//! # 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}