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