1use std::{cell::Cell, rc::Rc};
18
19use cranpose_core::{RuntimeHandle, with_current_composer};
20use cranpose_macros::composable;
21
22use crate::material::GlassDeformation;
23
24const STRETCH_PER_SPEED: f32 = 3.2e-4;
25const STRETCH_PER_ACCEL: f32 = 3.5e-5;
26pub const STRETCH_MIN: f32 = 0.78;
29pub const STRETCH_MAX: f32 = 1.50;
30const BULGE_PER_ACCELERATION: f32 = 4.5e-4;
31pub const BULGE_MAX: f32 = 8.0;
32const ATTACK_TAU: f32 = 0.03;
33const RELEASE_TAU: f32 = 0.11;
34const POINTER_VELOCITY_TAU: f32 = 0.045;
35const POINTER_STOP_HORIZON_NANOS: u64 = 40_000_000;
36const POINTER_COAST_TAU: f32 = 0.10;
37const AXIS_MIN_SPEED: f32 = 60.0;
38const DT_MIN: f32 = 1.0 / 1000.0;
39const DT_MAX: f32 = 1.0 / 15.0;
40const TELEPORT_SPEED: f32 = 30_000.0;
41
42#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct LiquidPose {
45 pub stretch: f32,
47 pub ortho: f32,
49 pub axis: (f32, f32),
52 pub bulge_amplitude: f32,
54 pub bulge_direction: f32,
55 pub speed: f32,
58}
59
60impl Default for LiquidPose {
61 fn default() -> Self {
62 Self {
63 stretch: 1.0,
64 ortho: 1.0,
65 axis: (1.0, 0.0),
66 bulge_amplitude: 0.0,
67 bulge_direction: 0.0,
68 speed: 0.0,
69 }
70 }
71}
72
73impl LiquidPose {
74 pub fn deformation(&self) -> GlassDeformation {
79 GlassDeformation::incompressible(self.axis, self.stretch)
80 }
81
82 pub fn energy(&self) -> f32 {
85 (self.speed / 1100.0).clamp(0.0, 1.0)
86 }
87}
88
89pub struct LiquidDynamics {
92 runtime: RuntimeHandle,
93 last_nanos: Cell<Option<u64>>,
94 last_pos: Cell<Option<(f32, f32)>>,
95 velocity: Cell<(f32, f32)>,
96 stretch: Cell<f32>,
97 bulge_vector: Cell<(f32, f32)>,
98 speed: Cell<f32>,
99 axis: Cell<(f32, f32)>,
100 pose: Cell<LiquidPose>,
101 pointer_pose_pending: Cell<bool>,
102 pointer_active: Cell<bool>,
103 last_pointer_nanos: Cell<Option<u64>>,
104}
105
106impl LiquidDynamics {
107 pub fn new(runtime: RuntimeHandle) -> Self {
108 Self {
109 runtime,
110 last_nanos: Cell::new(None),
111 last_pos: Cell::new(None),
112 velocity: Cell::new((0.0, 0.0)),
113 stretch: Cell::new(1.0),
114 bulge_vector: Cell::new((0.0, 0.0)),
115 speed: Cell::new(0.0),
116 axis: Cell::new((1.0, 0.0)),
117 pose: Cell::new(LiquidPose::default()),
118 pointer_pose_pending: Cell::new(false),
119 pointer_active: Cell::new(false),
120 last_pointer_nanos: Cell::new(None),
121 }
122 }
123
124 pub fn reset(&self) {
127 self.last_nanos.set(None);
128 self.last_pos.set(None);
129 self.velocity.set((0.0, 0.0));
130 self.stretch.set(1.0);
131 self.bulge_vector.set((0.0, 0.0));
132 self.speed.set(0.0);
133 self.pose.set(LiquidPose {
134 axis: self.axis.get(),
135 ..LiquidPose::default()
136 });
137 self.pointer_pose_pending.set(false);
138 self.pointer_active.set(false);
139 self.last_pointer_nanos.set(None);
140 }
141
142 pub(crate) fn anchor_pointer(&self, pos: (f32, f32)) {
143 self.reset();
144 self.last_pos.set(Some(pos));
145 let now = self.runtime.last_frame_time_nanos();
146 self.last_nanos.set(now);
147 self.last_pointer_nanos.set(now);
148 self.pointer_active.set(true);
149 }
150
151 pub(crate) fn advance_pointer(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
152 let Some(last_pos) = self.last_pos.get() else {
153 self.last_pos.set(Some(pos));
154 return self.pose.get();
155 };
156 let dt = dt.clamp(DT_MIN, DT_MAX);
157 let raw_velocity = ((pos.0 - last_pos.0) / dt, (pos.1 - last_pos.1) / dt);
158 self.last_pos.set(Some(pos));
159 let previous = self.velocity.get();
160 let follow = 1.0 - (-dt / POINTER_VELOCITY_TAU).exp();
161 let filtered_velocity = (
162 previous.0 + (raw_velocity.0 - previous.0) * follow,
163 previous.1 + (raw_velocity.1 - previous.1) * follow,
164 );
165 let pose = self.advance_velocity(filtered_velocity, dt);
166 let now = self.runtime.last_frame_time_nanos();
167 self.last_nanos.set(now);
168 self.last_pointer_nanos.set(now);
169 self.pointer_active.set(true);
170 self.pointer_pose_pending.set(true);
171 pose
172 }
173
174 pub(crate) fn release_pointer(&self) {
175 self.pointer_active.set(false);
176 }
177
178 pub(crate) fn update_pointer(&self, pos: (f32, f32)) -> LiquidPose {
179 if self.pointer_pose_pending.replace(false) {
180 self.last_pos.set(Some(pos));
181 self.last_nanos.set(self.runtime.last_frame_time_nanos());
182 return self.pose.get();
183 }
184 let Some(now) = self.runtime.last_frame_time_nanos() else {
185 self.last_pos.set(Some(pos));
186 return self.pose.get();
187 };
188 let Some(last) = self.last_nanos.get() else {
189 self.last_nanos.set(Some(now));
190 self.last_pos.set(Some(pos));
191 return self.pose.get();
192 };
193 if last == now {
194 return self.pose.get();
195 }
196 let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
197 self.last_nanos.set(Some(now));
198 let stationary = self.last_pos.get().is_some_and(|last_pos| {
199 (last_pos.0 - pos.0).abs() < 0.001 && (last_pos.1 - pos.1).abs() < 0.001
200 });
201 if !stationary {
202 return self.advance(pos, dt);
203 }
204 let within_pointer_horizon = self.pointer_active.get()
205 && self
206 .last_pointer_nanos
207 .get()
208 .is_some_and(|sample| now.saturating_sub(sample) <= POINTER_STOP_HORIZON_NANOS);
209 if within_pointer_horizon {
210 return self.pose.get();
211 }
212 let dt = dt.clamp(DT_MIN, DT_MAX);
213 let decay = (-dt / POINTER_COAST_TAU).exp();
214 let velocity = self.velocity.get();
215 self.advance_velocity((velocity.0 * decay, velocity.1 * decay), dt)
216 }
217
218 pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
221 let Some(now) = self.runtime.last_frame_time_nanos() else {
222 self.last_pos.set(Some(pos));
223 return self.pose.get();
224 };
225 match self.last_nanos.get() {
226 Some(last) if last == now => {
227 self.last_pos.set(Some(pos));
228 self.pose.get()
229 }
230 Some(last) => {
231 let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
232 self.last_nanos.set(Some(now));
233 self.advance(pos, dt)
234 }
235 None => {
236 self.last_nanos.set(Some(now));
237 self.last_pos.set(Some(pos));
238 self.pose.get()
239 }
240 }
241 }
242
243 pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
246 let Some(last_pos) = self.last_pos.get() else {
247 self.last_pos.set(Some(pos));
248 return self.pose.get();
249 };
250 let dt = dt.clamp(DT_MIN, DT_MAX);
251 let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
252 self.last_pos.set(Some(pos));
253
254 let velocity = (delta.0 / dt, delta.1 / dt);
255 let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
256 if raw_speed > TELEPORT_SPEED {
257 self.velocity.set((0.0, 0.0));
258 return self.pose.get();
259 }
260
261 self.advance_velocity(velocity, dt)
262 }
263
264 fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
265 let dt = dt.clamp(DT_MIN, DT_MAX);
266 let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
267 let previous_velocity = self.velocity.get();
268 self.velocity.set(velocity);
269 let accel = (
270 (velocity.0 - previous_velocity.0) / dt,
271 (velocity.1 - previous_velocity.1) / dt,
272 );
273
274 let mut axis = self.axis.get();
275 if raw_speed > AXIS_MIN_SPEED {
276 axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
277 self.axis.set(axis);
278 }
279 let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
280
281 let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
282 - STRETCH_PER_ACCEL * accel_along)
283 .clamp(STRETCH_MIN, STRETCH_MAX);
284 let signed_bulge = (-BULGE_PER_ACCELERATION * accel_along).clamp(-BULGE_MAX, BULGE_MAX);
285 let target_bulge = (axis.0 * signed_bulge, axis.1 * signed_bulge);
286
287 let follow = |current: f32, target: f32, neutral: f32| {
288 let tau = if (target - neutral).abs() > (current - neutral).abs() {
289 ATTACK_TAU
290 } else {
291 RELEASE_TAU
292 };
293 current + (target - current) * (1.0 - (-dt / tau).exp())
294 };
295 let stretch = follow(self.stretch.get(), target_stretch, 1.0);
296 let current_bulge = self.bulge_vector.get();
297 let current_bulge_length = current_bulge.0.hypot(current_bulge.1);
298 let target_bulge_length = target_bulge.0.hypot(target_bulge.1);
299 let bulge_tau = if target_bulge_length > current_bulge_length {
300 ATTACK_TAU
301 } else {
302 RELEASE_TAU
303 };
304 let bulge_follow = 1.0 - (-dt / bulge_tau).exp();
305 let bulge_vector = (
306 current_bulge.0 + (target_bulge.0 - current_bulge.0) * bulge_follow,
307 current_bulge.1 + (target_bulge.1 - current_bulge.1) * bulge_follow,
308 );
309 let bulge = bulge_vector.0.hypot(bulge_vector.1);
310 let speed = follow(self.speed.get(), raw_speed, 0.0);
311 self.stretch.set(stretch);
312 self.bulge_vector.set(bulge_vector);
313 self.speed.set(speed);
314
315 let bulge_direction = if bulge > 1.0e-4 {
316 bulge_vector.1.atan2(bulge_vector.0)
317 } else {
318 axis.1.atan2(axis.0)
319 };
320
321 let pose = LiquidPose {
322 stretch,
323 ortho: 1.0 / stretch,
324 axis,
325 bulge_amplitude: bulge,
326 bulge_direction,
327 speed,
328 };
329 self.pose.set(pose);
330 pose
331 }
332
333 pub fn pose(&self) -> LiquidPose {
335 self.pose.get()
336 }
337}
338
339#[composable]
341#[track_caller]
342pub fn rememberLiquidDynamics() -> Rc<LiquidDynamics> {
343 let caller = cranpose_core::caller_location_key();
344 with_current_composer(|composer| {
345 let runtime = composer.runtime_handle();
346 composer
347 .remember_at(caller, move || Rc::new(LiquidDynamics::new(runtime)))
348 .with(Rc::clone)
349 })
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn dynamics() -> LiquidDynamics {
357 let runtime =
358 cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
359 LiquidDynamics::new(runtime.handle())
360 }
361
362 fn settle(d: &LiquidDynamics, pos: (f32, f32), frames: usize) -> LiquidPose {
363 let mut pose = d.pose();
364 for _ in 0..frames {
365 pose = d.advance(pos, 1.0 / 60.0);
366 }
367 pose
368 }
369
370 fn cruise(d: &LiquidDynamics, speed_dp_s: f32, frames: usize) -> LiquidPose {
371 let dt = 1.0 / 60.0;
372 let mut x = 0.0;
373 let mut pose = d.pose();
374 for _ in 0..frames {
375 x += speed_dp_s * dt;
376 pose = d.advance((x, 0.0), dt);
377 }
378 pose
379 }
380
381 #[test]
382 fn constant_speed_elongates_along_axis_and_conserves_area() {
383 let d = dynamics();
384 let pose = cruise(&d, 1200.0, 40);
385 assert!((1.36..1.40).contains(&pose.stretch));
386 assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
387 assert!((pose.axis.0 - 1.0).abs() < 1e-4);
388 }
389
390 #[test]
391 fn subpixel_pointer_jitter_cannot_invent_extreme_strain() {
392 let d = dynamics();
393 d.anchor_pointer((0.0, 0.0));
394 let mut pose = d.pose();
395 for sample in 1..=12 {
396 pose = d.advance_pointer((sample as f32 * 0.20, 0.0), 1.0 / 60.0);
397 }
398 assert!(
399 (pose.stretch - 1.0).abs() < 0.025,
400 "subpixel travel must stay near equilibrium: {pose:?}"
401 );
402 assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
403 }
404
405 #[test]
406 fn launch_compresses_before_cruise_stretch_wins() {
407 let d = dynamics();
408 d.advance((0.0, 0.0), 1.0 / 60.0);
409 let dt = 1.0 / 60.0;
410 let pose = d.advance((1500.0 * dt, 0.0), dt);
411 assert!(pose.stretch < 1.0, "launch stretch {}", pose.stretch);
412 assert!(pose.ortho > 1.0, "launch ortho {}", pose.ortho);
413 }
414
415 #[test]
416 fn launch_acceleration_leaves_a_persistent_trailing_material_wake() {
417 let d = dynamics();
418 let dt = 1.0 / 60.0;
419 d.advance((0.0, 0.0), dt);
420 let launch = d.advance((1200.0 * dt, 0.0), dt);
421 assert!(
422 launch.bulge_amplitude > 0.5,
423 "launch must displace liquid toward the trailing edge: {launch:?}"
424 );
425 assert!(
426 (launch.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.1,
427 "rightward acceleration must trail to the left: {launch:?}"
428 );
429
430 let cruise = d.advance((2400.0 * dt, 0.0), dt);
431 assert!(
432 cruise.bulge_amplitude > 0.25,
433 "the material wake must survive beyond one pointer sample: {launch:?} -> {cruise:?}"
434 );
435 assert!(
436 (cruise.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.2,
437 "the remembered wake cannot flip on the next sample: {cruise:?}"
438 );
439 }
440
441 #[test]
442 fn direct_drag_cadence_remains_launch_compressed() {
443 let d = dynamics();
444 d.anchor_pointer((0.0, 0.0));
445 d.advance_pointer((-20.0, 0.0), 0.08);
446 let pose = d.advance_pointer((-40.0, 0.0), 0.03);
447 assert!(
448 pose.stretch <= 0.92,
449 "the target's two-event launch must compress along travel: {pose:?}"
450 );
451 assert!(
452 pose.ortho >= 1.08,
453 "launch must expand across travel: {pose:?}"
454 );
455 assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
456 }
457
458 #[test]
459 fn braking_decompresses_past_cruise_and_swells_leading_edge() {
460 let d = dynamics();
461 let cruise_pose = cruise(&d, 1200.0, 40);
462 let dt = 1.0 / 60.0;
463 let x = d.last_pos.get().unwrap().0;
464 let brake = d.advance((x + 300.0 * dt, 0.0), dt);
465 assert!(
466 brake.stretch > cruise_pose.stretch + 0.04,
467 "brake {} vs cruise {}",
468 brake.stretch,
469 cruise_pose.stretch
470 );
471 assert!(
472 brake.bulge_amplitude > 0.5,
473 "bulge {}",
474 brake.bulge_amplitude
475 );
476 assert!(
477 brake.ortho < cruise_pose.ortho,
478 "brake ortho {}",
479 brake.ortho
480 );
481 assert!(brake.bulge_direction.abs() < 1e-3);
482 }
483
484 #[test]
485 fn rest_decays_to_identity() {
486 let d = dynamics();
487 cruise(&d, 1200.0, 40);
488 let pose = settle(&d, d.last_pos.get().unwrap(), 60);
489 assert!(
490 (pose.stretch - 1.0).abs() < 0.02,
491 "stretch {}",
492 pose.stretch
493 );
494 assert!(pose.bulge_amplitude < 0.2);
495 assert!(pose.speed < 15.0);
496 }
497
498 #[test]
499 fn axis_follows_motion_direction_and_holds_at_rest() {
500 let d = dynamics();
501 let dt = 1.0 / 60.0;
502 d.advance((0.0, 0.0), dt);
503 let mut x = 0.0;
504 let mut pose = d.pose();
505 for _ in 0..10 {
506 x -= 900.0 * dt;
507 pose = d.advance((x, 0.0), dt);
508 }
509 assert!((pose.axis.0 + 1.0).abs() < 1e-4, "axis {:?}", pose.axis);
510 assert!(
511 pose.bulge_direction.abs() < 0.1,
512 "leftward launch inertia must trail toward +x: {pose:?}"
513 );
514 let held = settle(&d, (x, 0.0), 30);
515 assert!((held.axis.0 + 1.0).abs() < 1e-4);
516 }
517
518 #[test]
519 fn frame_rate_independent_cruise() {
520 let d60 = dynamics();
521 let d120 = dynamics();
522 let cruise60 = cruise(&d60, 1000.0, 30);
523 let dt = 1.0 / 120.0;
524 let mut x = 0.0;
525 let mut cruise120 = d120.pose();
526 for _ in 0..60 {
527 x += 1000.0 * dt;
528 cruise120 = d120.advance((x, 0.0), dt);
529 }
530 assert!(
531 (cruise60.stretch - cruise120.stretch).abs() < 0.02,
532 "60Hz {} vs 120Hz {}",
533 cruise60.stretch,
534 cruise120.stretch
535 );
536 }
537
538 #[test]
539 fn teleport_re_anchors_without_energy() {
540 let d = dynamics();
541 d.advance((0.0, 0.0), 1.0 / 60.0);
542 let pose = d.advance((4000.0, 0.0), 1.0 / 60.0);
543 assert_eq!(pose, d.pose());
544 assert!(
545 (pose.stretch - 1.0).abs() < 1e-4,
546 "teleport {}",
547 pose.stretch
548 );
549 let resumed = cruise(&d, 800.0, 30);
550 assert!(resumed.stretch > 1.01);
551 }
552
553 #[test]
554 fn vertical_travel_stretches_height_not_width() {
555 let d = dynamics();
556 let dt = 1.0 / 60.0;
557 let mut y = 0.0;
558 d.advance((0.0, 0.0), dt);
559 let mut pose = d.pose();
560 for _ in 0..30 {
561 y += 1000.0 * dt;
562 pose = d.advance((0.0, y), dt);
563 }
564 assert!(
565 (1.30..1.34).contains(&pose.stretch),
566 "stretch {}",
567 pose.stretch
568 );
569 assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
570 }
571
572 #[test]
573 fn reset_forgets_motion() {
574 let d = dynamics();
575 cruise(&d, 1200.0, 40);
576 d.reset();
577 assert_eq!(d.pose().stretch, 1.0);
578 let pose = d.advance((500.0, 0.0), 1.0 / 60.0);
579 assert!((pose.stretch - 1.0).abs() < 1e-4);
580 }
581}