tawasuyu

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Definitions

⎇ main llimphi-anim-studio/src/main.rs Rust · 1966 lines · 68.2 KB History Who touched what raw

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