tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
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}