tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! Demo **lottie + rive** en una sola app: los dos paradigmas de animación del
2//! motor nativo trabajando juntos.
3//!
4//! - **rive (esqueletal + IK)**: un brazo de 2 huesos con una malla texturizada
5//! skinneada, que **persigue el cursor** resolviendo IK de 2 huesos cada frame
6//! (`llimphi_anim::constraint::solve_two_bone_ik`).
7//! - **lottie**: un pulso vectorial (`llimphi-lottie` sobre el fork de velato)
8//! reproducido en la **punta del brazo** y otro marcando el **objetivo** bajo
9//! el cursor.
10//!
11//! Mové el mouse: el brazo alcanza el cursor (IK), el Lottie late en la punta y
12//! en el blanco. **F** alterna el codo (flip de la solución IK). Todo Rust
13//! nativo sobre vello 0.7, cero C++.
14//!
15//! Corre con:
16//! `cargo run -p llimphi-mesh --example lottie_rive_demo --release`
17
18use std::sync::Arc;
19use std::time::Duration;
20
21use llimphi_anim::constraint::solve_two_bone_ik;
22use llimphi_anim::skel::{BoneId, Mesh, Pose, Skeleton, Vertex, Weight};
23use llimphi_lottie::LottieAsset;
24use llimphi_mesh::{paint_textured, paint_wireframe};
25use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
26use llimphi_ui::llimphi_raster::kurbo::{Affine, Point, Vec2};
27use llimphi_ui::llimphi_raster::peniko::{
28 Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
29};
30use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, PaintRect, View};
31
32// Brazo en coordenadas locales del lienzo (px). Ancla a la izquierda-centro.
33const ANCHOR: (f64, f64) = (110.0, 200.0);
34const L1: f64 = 95.0; // hueso superior
35const L2: f64 = 95.0; // hueso inferior
36const ARM_H: f64 = 40.0; // grosor del brazo
37const COLS: usize = 6; // columnas de la malla a lo largo del brazo
38
39/// Pulso azul: círculo cuya opacidad late en 1.5 s.
40const PULSE_LOTTIE: &str = r#"{
41 "v":"5.5.2","fr":30,"ip":0,"op":45,"w":100,"h":100,
42 "layers":[{"ty":4,"ip":0,"op":45,"st":0,"sr":1,
43 "ks":{"o":{"a":1,"k":[
44 {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":0,"s":[100]},
45 {"i":{"x":[0.5],"y":[0.5]},"o":{"x":[0.5],"y":[0.5]},"t":22,"s":[25]},
46 {"t":45,"s":[100]}]},
47 "r":{"a":0,"k":0},"p":{"a":0,"k":[50,50]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]}},
48 "shapes":[{"ty":"gr","it":[
49 {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[60,60]}},
50 {"ty":"fl","c":{"a":0,"k":[0.45,0.75,1.0]},"o":{"a":0,"k":100}},
51 {"ty":"tr","p":{"a":0,"k":[0,0]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100}}]}]}]}"#;
52
53/// Brazo de 2 huesos + malla-tira skinneada (peso suave alrededor del codo).
54fn build_arm() -> (Skeleton, Mesh, BoneId, BoneId) {
55 let mut s = Skeleton::new();
56 let a = s.add_bone(None, Pose::translate(Vec2::new(ANCHOR.0, ANCHOR.1)));
57 let b = s.add_bone(Some(a), Pose::translate(Vec2::new(L1, 0.0)));
58 s.bind();
59 s.update();
60
61 let total = L1 + L2;
62 let blend = total * 0.16; // ancho del blend de peso en el codo
63 let mut m = Mesh::new();
64 for i in 0..=COLS {
65 let p = total * i as f64 / COLS as f64; // posición a lo largo del brazo
66 // Peso suave: 1→A antes del codo, 0→A (todo B) después.
67 let wa = (1.0 - (p - (L1 - blend)) / (2.0 * blend)).clamp(0.0, 1.0);
68 let weights = vec![
69 Weight { bone: a, weight: wa },
70 Weight { bone: b, weight: 1.0 - wa },
71 ];
72 let u = i as f64 / COLS as f64;
73 let x = ANCHOR.0 + p;
74 m.vertices.push(Vertex {
75 rest: Point::new(x, ANCHOR.1 - ARM_H / 2.0),
76 uv: (u, 0.0),
77 weights: weights.clone(),
78 });
79 m.vertices.push(Vertex {
80 rest: Point::new(x, ANCHOR.1 + ARM_H / 2.0),
81 uv: (u, 1.0),
82 weights,
83 });
84 }
85 for i in 0..COLS {
86 let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
87 let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
88 m.triangles.push([t0, t1, b1]);
89 m.triangles.push([t0, b1, b0]);
90 }
91 (s, m, a, b)
92}
93
94fn checker(n: u32, sq: u32) -> ImageBrush {
95 let mut px = Vec::with_capacity((n * n * 4) as usize);
96 for y in 0..n {
97 for x in 0..n {
98 let on = ((x / sq + y / sq) % 2) == 0;
99 let (r, g, b) = if on { (235, 110, 90) } else { (250, 200, 120) };
100 px.extend_from_slice(&[r, g, b, 255]);
101 }
102 }
103 ImageBrush::new(ImageData {
104 data: Blob::new(Arc::new(px)),
105 format: ImageFormat::Rgba8,
106 alpha_type: ImageAlphaType::Alpha,
107 width: n,
108 height: n,
109 })
110}
111
112#[derive(Clone)]
113enum Msg {
114 Tick,
115 Target(f64, f64),
116 Flip,
117}
118
119struct Model {
120 skel: Skeleton,
121 mesh: Mesh,
122 upper: BoneId,
123 lower: BoneId,
124 image: ImageBrush,
125 pulse: LottieAsset,
126 target: Point,
127 t: f64,
128 flip: bool,
129}
130
131struct Demo;
132
133const TICK: Duration = Duration::from_millis(16);
134const TIP_LOCAL: Vec2 = Vec2::new(L2, 0.0);
135
136impl App for Demo {
137 type Model = Model;
138 type Msg = Msg;
139
140 fn title() -> &'static str {
141 "llimphi · lottie + rive"
142 }
143 fn initial_size() -> (u32, u32) {
144 (640, 420)
145 }
146
147 fn init(handle: &Handle<Self::Msg>) -> Self::Model {
148 let (skel, mesh, upper, lower) = build_arm();
149 handle.spawn_periodic(TICK, || Msg::Tick);
150 Model {
151 skel,
152 mesh,
153 upper,
154 lower,
155 image: checker(8, 1),
156 pulse: LottieAsset::from_str(PULSE_LOTTIE).expect("pulse lottie"),
157 target: Point::new(360.0, 150.0),
158 t: 0.0,
159 flip: false,
160 }
161 }
162
163 fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
164 match msg {
165 Msg::Tick => {
166 model.t += TICK.as_secs_f64();
167 // IK cada frame: el brazo alcanza el objetivo (cursor).
168 solve_two_bone_ik(
169 &mut model.skel,
170 model.upper,
171 model.lower,
172 TIP_LOCAL,
173 model.target,
174 model.flip,
175 );
176 }
177 Msg::Target(x, y) => model.target = Point::new(x, y),
178 Msg::Flip => model.flip = !model.flip,
179 }
180 model
181 }
182
183 fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
184 if e.state == KeyState::Pressed {
185 if let Key::Character(c) = &e.key {
186 if c.as_str() == "f" {
187 return Some(Msg::Flip);
188 }
189 }
190 }
191 None
192 }
193
194 fn view(model: &Self::Model) -> View<Self::Msg> {
195 let positions = model.mesh.deform(&model.skel);
196 // Punta del brazo (mundo local) = world(lower) · tip_local.
197 let tip_local = model.skel.world(model.lower) * Point::new(TIP_LOCAL.x, TIP_LOCAL.y);
198 let mesh = model.mesh.clone();
199 let image = model.image.clone();
200 let pulse = model.pulse.clone();
201 let target = model.target;
202 let t = model.t;
203
204 View::new(Style {
205 size: Size {
206 width: percent(1.0_f32),
207 height: percent(1.0_f32),
208 },
209 ..Default::default()
210 })
211 .fill(Color::from_rgba8(18, 22, 30, 255))
212 .paint_with(move |scene, _ts, rect| {
213 // Coords locales → pantalla (el lienzo arranca en rect.x/rect.y).
214 let to_screen = Affine::translate((rect.x as f64, rect.y as f64));
215 // Brazo skinneado (rive).
216 paint_textured(scene, &mesh, &positions, to_screen, &image);
217 paint_wireframe(
218 scene,
219 &mesh,
220 &positions,
221 to_screen,
222 Color::from_rgba8(255, 255, 255, 90),
223 1.0,
224 );
225 // Pulso lottie en la punta del brazo y en el objetivo.
226 let pulse_rect = |p: Point, s: f64| PaintRect {
227 x: (rect.x as f64 + p.x - s) as f32,
228 y: (rect.y as f64 + p.y - s) as f32,
229 w: (s * 2.0) as f32,
230 h: (s * 2.0) as f32,
231 };
232 pulse.paint_at_time(scene, pulse_rect(target, 30.0), t);
233 pulse.paint_at_time(scene, pulse_rect(tip_local, 22.0), t);
234 })
235 .on_pointer_move_at(|lx, ly, _w, _h| Some(Msg::Target(lx as f64, ly as f64)))
236 }
237}
238
239fn main() {
240 llimphi_ui::run::<Demo>();
241}