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