Commit b563d2da1c80
tarball19 files changed+2004-1
@@ -18,12 +18,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1818
checksum = "e149dc73cd30538307e7ffa2acd3d2221148eaeed4871f246657b1c3eaa1cbd2"1919
2020
[[package]]21
+name = "autocfg"22
+version = "1.5.1"23
+source = "registry+https://github.com/rust-lang/crates.io-index"24
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"25
+26
+[[package]]27
+name = "bit-set"28
+version = "0.11.1"29
+source = "registry+https://github.com/rust-lang/crates.io-index"30
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"31
+dependencies = [32
+ "bit-vec",33
+]34
+35
+[[package]]36
+name = "bit-vec"37
+version = "0.10.1"38
+source = "registry+https://github.com/rust-lang/crates.io-index"39
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"40
+dependencies = [41
+ "borsh",42
+ "serde",43
+]44
+45
+[[package]]2146
name = "bitflags"2247
version = "2.13.2"2348
source = "registry+https://github.com/rust-lang/crates.io-index"2449
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"2550
2651
[[package]]52
+name = "borsh"53
+version = "1.8.1"54
+source = "registry+https://github.com/rust-lang/crates.io-index"55
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"56
+dependencies = [57
+ "borsh-derive",58
+ "bytes",59
+ "cfg_aliases",60
+]61
+62
+[[package]]63
+name = "borsh-derive"64
+version = "1.8.1"65
+source = "registry+https://github.com/rust-lang/crates.io-index"66
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"67
+dependencies = [68
+ "once_cell",69
+ "proc-macro-crate",70
+ "proc-macro2",71
+ "quote",72
+ "syn 3.0.9",73
+]74
+75
+[[package]]76
+name = "bytes"77
+version = "1.12.1"78
+source = "registry+https://github.com/rust-lang/crates.io-index"79
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"80
+81
+[[package]]2782
name = "calloop"2883
version = "0.14.5"2984
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -52,6 +107,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
52107
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"53108
54109
[[package]]110
+name = "cfg_aliases"111
+version = "0.2.2"112
+source = "registry+https://github.com/rust-lang/crates.io-index"113
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"114
+115
+[[package]]55116
name = "chacha20"56117
version = "0.10.2"57118
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -72,6 +133,12 @@ dependencies = [
72133
]73134
74135
[[package]]136
+name = "core_detect"137
+version = "1.0.0"138
+source = "registry+https://github.com/rust-lang/crates.io-index"139
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"140
+141
+[[package]]75142
name = "cpufeatures"76143
version = "0.3.1"77144
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -153,6 +220,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
153220
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"154221
155222
[[package]]223
+name = "fnv"224
+version = "1.0.7"225
+source = "registry+https://github.com/rust-lang/crates.io-index"226
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"227
+228
+[[package]]156229
name = "generator"157230
version = "0.8.11"158231
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -299,10 +372,16 @@ version = "0.0.0"
299372
[[package]]300373
name = "monolith-motion"301374
version = "0.0.0"375
+dependencies = [376
+ "proptest",377
+]302378
303379
[[package]]304380
name = "monolith-planner"305381
version = "0.0.0"382
+dependencies = [383
+ "proptest",384
+]306385
307386
[[package]]308387
name = "monolith-protocols"@@ -335,6 +414,15 @@ dependencies = [
335414
]336415
337416
[[package]]417
+name = "num-traits"418
+version = "0.2.19"419
+source = "registry+https://github.com/rust-lang/crates.io-index"420
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"421
+dependencies = [422
+ "autocfg",423
+]424
+425
+[[package]]338426
name = "once_cell"339427
version = "1.21.4"340428
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -373,6 +461,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
373461
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"374462
375463
[[package]]464
+name = "proc-macro-crate"465
+version = "3.5.0"466
+source = "registry+https://github.com/rust-lang/crates.io-index"467
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"468
+dependencies = [469
+ "toml_edit",470
+]471
+472
+[[package]]376473
name = "proc-macro2"377474
version = "1.0.107"378475
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -401,6 +498,32 @@ dependencies = [
401498
]402499
403500
[[package]]501
+name = "proptest"502
+version = "1.12.0"503
+source = "registry+https://github.com/rust-lang/crates.io-index"504
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"505
+dependencies = [506
+ "bit-set",507
+ "bit-vec",508
+ "bitflags",509
+ "chacha20",510
+ "core_detect",511
+ "num-traits",512
+ "rand",513
+ "rand_xorshift",514
+ "regex-syntax",515
+ "rusty-fork",516
+ "tempfile",517
+ "unarray",518
+]519
+520
+[[package]]521
+name = "quick-error"522
+version = "1.2.3"523
+source = "registry+https://github.com/rust-lang/crates.io-index"524
+checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"525
+526
+[[package]]404527
name = "quick-xml"405528
version = "0.41.0"406529
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -442,6 +565,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
442565
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"443566
444567
[[package]]568
+name = "rand_xorshift"569
+version = "0.5.0"570
+source = "registry+https://github.com/rust-lang/crates.io-index"571
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"572
+dependencies = [573
+ "rand_core",574
+]575
+576
+[[package]]445577
name = "regex-automata"446578
version = "0.4.18"447579
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -490,12 +622,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
490622
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"491623
492624
[[package]]625
+name = "rusty-fork"626
+version = "0.3.1"627
+source = "registry+https://github.com/rust-lang/crates.io-index"628
+checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"629
+dependencies = [630
+ "fnv",631
+ "quick-error",632
+ "tempfile",633
+ "wait-timeout",634
+]635
+636
+[[package]]493637
name = "scoped-tls"494638
version = "1.0.1"495639
source = "registry+https://github.com/rust-lang/crates.io-index"496640
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"497641
498642
[[package]]643
+name = "serde"644
+version = "1.0.229"645
+source = "registry+https://github.com/rust-lang/crates.io-index"646
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"647
+dependencies = [648
+ "serde_core",649
+ "serde_derive",650
+]651
+652
+[[package]]653
+name = "serde_core"654
+version = "1.0.229"655
+source = "registry+https://github.com/rust-lang/crates.io-index"656
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"657
+dependencies = [658
+ "serde_derive",659
+]660
+661
+[[package]]662
+name = "serde_derive"663
+version = "1.0.229"664
+source = "registry+https://github.com/rust-lang/crates.io-index"665
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"666
+dependencies = [667
+ "proc-macro2",668
+ "quote",669
+ "syn 3.0.9",670
+]671
+672
+[[package]]499673
name = "sharded-slab"500674
version = "0.1.7"501675
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -619,6 +793,36 @@ dependencies = [
619793
]620794
621795
[[package]]796
+name = "toml_datetime"797
+version = "1.1.2+spec-1.1.0"798
+source = "registry+https://github.com/rust-lang/crates.io-index"799
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"800
+dependencies = [801
+ "serde_core",802
+]803
+804
+[[package]]805
+name = "toml_edit"806
+version = "0.25.17+spec-1.1.0"807
+source = "registry+https://github.com/rust-lang/crates.io-index"808
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"809
+dependencies = [810
+ "indexmap",811
+ "toml_datetime",812
+ "toml_parser",813
+ "winnow",814
+]815
+816
+[[package]]817
+name = "toml_parser"818
+version = "1.1.5+spec-1.1.0"819
+source = "registry+https://github.com/rust-lang/crates.io-index"820
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"821
+dependencies = [822
+ "winnow",823
+]824
+825
+[[package]]622826
name = "tracing"623827
version = "0.1.44"624828
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -702,6 +906,12 @@ dependencies = [
702906
]703907
704908
[[package]]909
+name = "unarray"910
+version = "0.1.4"911
+source = "registry+https://github.com/rust-lang/crates.io-index"912
+checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"913
+914
+[[package]]705915
name = "unicode-ident"706916
version = "1.0.27"707917
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -714,6 +924,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
714924
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"715925
716926
[[package]]927
+name = "wait-timeout"928
+version = "0.2.1"929
+source = "registry+https://github.com/rust-lang/crates.io-index"930
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"931
+dependencies = [932
+ "libc",933
+]934
+935
+[[package]]717936
name = "wayland-backend"718937
version = "0.3.17"719938
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -886,6 +1105,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
8861105
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"8871106
8881107
[[package]]1108
+name = "winnow"1109
+version = "1.0.4"1110
+source = "registry+https://github.com/rust-lang/crates.io-index"1111
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"1112
+dependencies = [1113
+ "memchr",1114
+]1115
+1116
+[[package]]8891117
name = "xkbcommon"8901118
version = "0.9.0"8911119
source = "registry+https://github.com/rust-lang/crates.io-index"@@ -20,6 +20,7 @@ monolith-testkit = { path = "crates/testkit" }
2020
2121
smithay = { git = "https://github.com/Smithay/smithay", rev = "023b78b1c5a05ca512767cacc07efca55fae8be4", default-features = false, features = ["wayland_frontend"] }2222
tracy-client = { version = "0.19", default-features = false }23
+proptest = "1"2324
2425
[workspace.lints.rust]2526
unsafe_op_in_unsafe_fn = "deny"@@ -8,3 +8,5 @@ publish.workspace = true
88
[lints]99
workspace = true1010
11
+[dev-dependencies]12
+proptest.workspace = true@@ -0,0 +1,139 @@
1
+/// A CSS-style cubic bezier from (0, 0) to (1, 1) with control points2
+/// (x1, y1) and (x2, y2). The x coordinate is time, y is progress.3
+#[derive(Clone, Copy, Debug, PartialEq)]4
+pub struct CubicBezier {5
+ pub x1: f64,6
+ pub y1: f64,7
+ pub x2: f64,8
+ pub y2: f64,9
+}10
+11
+impl CubicBezier {12
+ pub const LINEAR: Self = Self::new(0.0, 0.0, 1.0, 1.0);13
+ pub const EASE: Self = Self::new(0.45, 0.0, 0.2, 1.0);14
+15
+ pub const fn new(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {16
+ Self { x1, y1, x2, y2 }17
+ }18
+19
+ /// Whether x is monotonic, which every valid easing curve needs.20
+ pub fn is_valid(&self) -> bool {21
+ (0.0..=1.0).contains(&self.x1) && (0.0..=1.0).contains(&self.x2)22
+ }23
+24
+ /// Progress at normalized time `t`, clamped to the phase.25
+ pub fn sample(&self, t: f64) -> f64 {26
+ if t <= 0.0 {27
+ return 0.0;28
+ }29
+ if t >= 1.0 {30
+ return 1.0;31
+ }32
+ let u = self.solve_x(t);33
+ poly(self.y1, self.y2, u)34
+ }35
+36
+ /// Initial slope, d(progress)/d(time) at t = 0.37
+ pub fn initial_slope(&self) -> f64 {38
+ if self.x1 == 0.0 {39
+ return if self.y1 == 0.0 { 0.0 } else { f64::INFINITY };40
+ }41
+ self.y1 / self.x142
+ }43
+44
+ /// The same curve, starting at `slope` instead of its own initial slope.45
+ ///46
+ /// The first control point's height is clamped to 1. A cubic bezier stays47
+ /// inside the convex hull of its control points, so with both heights in48
+ /// [0, 1] the curve can never overshoot its target.49
+ pub fn with_initial_slope(&self, slope: f64) -> Self {50
+ let x1 = if self.x1 > 0.0 { self.x1 } else { 0.3 };51
+ let y1 = (x1 * slope.max(0.0)).min(1.0);52
+ Self::new(x1, y1, self.x2, self.y2)53
+ }54
+55
+ fn solve_x(&self, t: f64) -> f64 {56
+ let mut u = t;57
+ for _ in 0..8 {58
+ let err = poly(self.x1, self.x2, u) - t;59
+ if err.abs() < 1e-9 {60
+ return u;61
+ }62
+ let d = poly_derivative(self.x1, self.x2, u);63
+ if d.abs() < 1e-9 {64
+ break;65
+ }66
+ u = (u - err / d).clamp(0.0, 1.0);67
+ }68
+ let (mut lo, mut hi) = (0.0, 1.0);69
+ u = t;70
+ for _ in 0..60 {71
+ if poly(self.x1, self.x2, u) < t {72
+ lo = u;73
+ } else {74
+ hi = u;75
+ }76
+ u = (lo + hi) / 2.0;77
+ }78
+ u79
+ }80
+}81
+82
+fn poly(p1: f64, p2: f64, u: f64) -> f64 {83
+ let c = 3.0 * p1;84
+ let b = 3.0 * (p2 - p1) - c;85
+ let a = 1.0 - c - b;86
+ ((a * u + b) * u + c) * u87
+}88
+89
+fn poly_derivative(p1: f64, p2: f64, u: f64) -> f64 {90
+ let c = 3.0 * p1;91
+ let b = 3.0 * (p2 - p1) - c;92
+ let a = 1.0 - c - b;93
+ (3.0 * a * u + 2.0 * b) * u + c94
+}95
+96
+#[cfg(test)]97
+mod tests {98
+ use super::*;99
+ use proptest::prelude::*;100
+101
+ #[test]102
+ fn endpoints_are_exact() {103
+ for c in [CubicBezier::LINEAR, CubicBezier::EASE] {104
+ assert_eq!(c.sample(0.0), 0.0);105
+ assert_eq!(c.sample(1.0), 1.0);106
+ }107
+ }108
+109
+ #[test]110
+ fn linear_is_identity() {111
+ for i in 0..=100 {112
+ let t = f64::from(i) / 100.0;113
+ assert!((CubicBezier::LINEAR.sample(t) - t).abs() < 1e-6);114
+ }115
+ }116
+117
+ #[test]118
+ fn matched_slope_is_honoured() {119
+ let c = CubicBezier::EASE.with_initial_slope(2.0);120
+ assert!((c.initial_slope() - 2.0).abs() < 1e-9);121
+ let dt = 1e-4;122
+ assert!((c.sample(dt) / dt - 2.0).abs() < 0.05);123
+ }124
+125
+ proptest! {126
+ #[test]127
+ fn never_overshoots(slope in 0.0f64..50.0, t in 0.0f64..=1.0) {128
+ let c = CubicBezier::EASE.with_initial_slope(slope);129
+ let p = c.sample(t);130
+ prop_assert!((-1e-9..=1.0 + 1e-9).contains(&p));131
+ }132
+133
+ #[test]134
+ fn monotonic(x1 in 0.0f64..=1.0, y1 in 0.0f64..=1.0, x2 in 0.0f64..=1.0, y2 in 0.0f64..=1.0, t in 0.0f64..0.99) {135
+ let c = CubicBezier::new(x1, y1, x2, y2);136
+ prop_assert!(c.sample(t + 0.01) >= c.sample(t) - 1e-6);137
+ }138
+ }139
+}@@ -1 +1,14 @@
1
+//! Curves and timelines for phased animations.2
+//!3
+//! Every phase lasts exactly its configured duration. Progress inside a phase4
+//! runs from 0 to 1 through a cubic bezier easing curve. Times are presentation5
+//! timestamps in nanoseconds, so callers sample at the vblank a frame targets6
+//! rather than at render time.17
8
+mod curve;9
+mod timeline;10
+mod velocity;11
+12
+pub use curve::CubicBezier;13
+pub use timeline::{Position, Timeline};14
+pub use velocity::{Edges, group_initial_slope};@@ -0,0 +1,65 @@
1
+/// A plan's place in time. Every phase lasts `phase_ns`.2
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]3
+pub struct Timeline {4
+ pub start_ns: u64,5
+ pub phase_ns: u64,6
+ pub phases: usize,7
+}8
+9
+#[derive(Clone, Copy, Debug, PartialEq)]10
+pub enum Position {11
+ NotStarted,12
+ /// Inside `phase`, with linear time `t` in [0, 1) before easing.13
+ Phase {14
+ phase: usize,15
+ t: f64,16
+ },17
+ Finished,18
+}19
+20
+impl Timeline {21
+ pub fn end_ns(&self) -> u64 {22
+ self.start_ns + self.phase_ns * self.phases as u6423
+ }24
+25
+ pub fn locate(&self, now_ns: u64) -> Position {26
+ if now_ns < self.start_ns {27
+ return Position::NotStarted;28
+ }29
+ if self.phase_ns == 0 || now_ns >= self.end_ns() {30
+ return Position::Finished;31
+ }32
+ let elapsed = now_ns - self.start_ns;33
+ let phase = (elapsed / self.phase_ns) as usize;34
+ let t = (elapsed % self.phase_ns) as f64 / self.phase_ns as f64;35
+ Position::Phase { phase, t }36
+ }37
+}38
+39
+#[cfg(test)]40
+mod tests {41
+ use super::*;42
+43
+ #[test]44
+ fn locates_phases() {45
+ let tl = Timeline {46
+ start_ns: 1_000,47
+ phase_ns: 100,48
+ phases: 3,49
+ };50
+ assert_eq!(tl.locate(999), Position::NotStarted);51
+ assert_eq!(tl.locate(1_000), Position::Phase { phase: 0, t: 0.0 });52
+ assert_eq!(tl.locate(1_150), Position::Phase { phase: 1, t: 0.5 });53
+ assert_eq!(tl.locate(1_300), Position::Finished);54
+ }55
+56
+ #[test]57
+ fn total_time_is_phases_times_duration() {58
+ let tl = Timeline {59
+ start_ns: 0,60
+ phase_ns: 160_000_000,61
+ phases: 4,62
+ };63
+ assert_eq!(tl.end_ns(), 640_000_000);64
+ }65
+}@@ -0,0 +1,59 @@
1
+/// Rectangle edges as (left, right, top, bottom).2
+pub type Edges = [f64; 4];3
+4
+/// Initial progress slope for the first phase of a replanned motion group.5
+///6
+/// Each item is a window's current edge velocity (units per second) and its7
+/// edge displacement over the new first phase. The fastest mover with a real8
+/// displacement decides the slope, because windows sharing an edge must share9
+/// a curve. Returns `None` when that mover would have to reverse, so the group10
+/// starts from rest instead.11
+pub fn group_initial_slope(12
+ movers: impl IntoIterator<Item = (Edges, Edges)>,13
+ phase_secs: f64,14
+) -> Option<f64> {15
+ let mut best: Option<(f64, Edges, Edges)> = None;16
+ for (velocity, displacement) in movers {17
+ if dot(&displacement, &displacement) < 1.0 {18
+ continue;19
+ }20
+ let speed = dot(&velocity, &velocity);21
+ if best.is_none_or(|(s, _, _)| speed > s) {22
+ best = Some((speed, velocity, displacement));23
+ }24
+ }25
+ let (_, velocity, displacement) = best?;26
+ let slope = dot(&velocity, &displacement) / dot(&displacement, &displacement) * phase_secs;27
+ (slope > 0.05).then_some(slope)28
+}29
+30
+fn dot(a: &Edges, b: &Edges) -> f64 {31
+ a.iter().zip(b).map(|(x, y)| x * y).sum()32
+}33
+34
+#[cfg(test)]35
+mod tests {36
+ use super::*;37
+38
+ #[test]39
+ fn continues_same_direction() {40
+ let slope = group_initial_slope([([100.0, 100.0, 0.0, 0.0], [50.0, 50.0, 0.0, 0.0])], 0.5);41
+ assert_eq!(slope, Some(1.0));42
+ }43
+44
+ #[test]45
+ fn reversal_starts_from_rest() {46
+ let slope =47
+ group_initial_slope([([100.0, 100.0, 0.0, 0.0], [-50.0, -50.0, 0.0, 0.0])], 0.5);48
+ assert_eq!(slope, None);49
+ }50
+51
+ #[test]52
+ fn ignores_windows_that_do_not_move_this_phase() {53
+ let movers = [54
+ ([500.0, 500.0, 0.0, 0.0], [0.0; 4]),55
+ ([10.0, 10.0, 0.0, 0.0], [20.0, 20.0, 0.0, 0.0]),56
+ ];57
+ assert_eq!(group_initial_slope(movers, 1.0), Some(0.5));58
+ }59
+}@@ -8,3 +8,5 @@ publish.workspace = true
88
[lints]99
workspace = true1010
11
+[dev-dependencies]12
+proptest.workspace = true@@ -0,0 +1,88 @@
1
+//! Renders a plan's keyframes side by side as SVG.2
+//!3
+//! `cargo run -p monolith-planner --example filmstrip -- swap > swap.svg`4
+5
+use monolith_planner::{Config, Rect, Style, plan};6
+7
+const W: f64 = 320.0;8
+const H: f64 = 200.0;9
+const G: f64 = 6.0;10
+const COLOURS: [&str; 6] = [11
+ "#e0774a", "#4aa3e0", "#7bc96f", "#c77be0", "#e0c24a", "#4ae0c2",12
+];13
+14
+fn cols(n: usize) -> Vec<Rect> {15
+ let w = (W - G * (n + 1) as f64) / n as f64;16
+ (0..n)17
+ .map(|i| Rect::new(G + i as f64 * (w + G), G, w, H - 2.0 * G))18
+ .collect()19
+}20
+21
+fn rows(n: usize) -> Vec<Rect> {22
+ let h = (H - G * (n + 1) as f64) / n as f64;23
+ (0..n)24
+ .map(|i| Rect::new(G, G + i as f64 * (h + G), W - 2.0 * G, h))25
+ .collect()26
+}27
+28
+fn scenario(name: &str) -> Option<(Vec<Rect>, Vec<Rect>)> {29
+ Some(match name {30
+ "swap" => {31
+ let c = cols(2);32
+ (c.clone(), vec![c[1], c[0]])33
+ }34
+ "reverse" => {35
+ let c = cols(4);36
+ (c.clone(), c.iter().rev().copied().collect())37
+ }38
+ "flip" => (cols(3), rows(3)),39
+ _ => return None,40
+ })41
+}42
+43
+fn main() {44
+ let name = std::env::args().nth(1).unwrap_or_else(|| "swap".into());45
+ let Some((from, to)) = scenario(&name) else {46
+ eprintln!("scenarios: swap, reverse, flip");47
+ std::process::exit(2);48
+ };49
+ let cfg = Config {50
+ style: Style::Orthogonal,51
+ gap: G,52
+ ..Config::default()53
+ };54
+ let p = plan(&from, &to, &cfg);55
+ let frames = p.keyframes.len() as f64;56
+ println!(57
+ r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" style="background:#111317">"#,58
+ frames * (W + 20.0),59
+ H + 40.060
+ );61
+ for (k, frame) in p.keyframes.iter().enumerate() {62
+ let ox = k as f64 * (W + 20.0);63
+ println!(r##"<rect x="{ox}" y="0" width="{W}" height="{H}" fill="#1b1e25"/>"##);64
+ for (i, r) in frame.iter().enumerate() {65
+ if r.w > 0.5 && r.h > 0.5 {66
+ println!(67
+ r#"<rect x="{}" y="{}" width="{}" height="{}" rx="4" fill="{}"/>"#,68
+ ox + r.x,69
+ r.y,70
+ r.w,71
+ r.h,72
+ COLOURS[i % COLOURS.len()]73
+ );74
+ }75
+ }76
+ println!(77
+ r##"<text x="{}" y="{}" fill="#8a909c" font-family="sans-serif" font-size="13">keyframe {k}</text>"##,78
+ ox + 4.0,79
+ H + 18.080
+ );81
+ }82
+ println!(83
+ r##"<text x="4" y="{}" fill="#8a909c" font-family="sans-serif" font-size="13">{name}: {:?}</text>"##,84
+ H + 34.0,85
+ p.groups.iter().map(|g| g.strategy).collect::<Vec<_>>()86
+ );87
+ println!("</svg>");88
+}@@ -0,0 +1,58 @@
1
+use crate::geom::Rect;2
+use crate::plan::{Config, Group, Plan, plan};3
+4
+/// Plans with each block moving rigidly, for moves whose unit is a whole5
+/// container such as reordering viewed tags. Blocks must partition the6
+/// windows, and every window must keep its place inside its block.7
+pub fn plan_blocks(from: &[Rect], to: &[Rect], blocks: &[Vec<usize>], cfg: &Config) -> Plan {8
+ let bounds = |l: &[Rect]| -> Vec<Rect> {9
+ blocks10
+ .iter()11
+ .map(|b| Rect::bounding(b.iter().map(|&i| &l[i])))12
+ .collect()13
+ };14
+ let (bf, bt) = (bounds(from), bounds(to));15
+ let bp = plan(&bf, &bt, cfg);16
+ let last = bp.keyframes.len() - 1;17
+ let keyframes = bp18
+ .keyframes19
+ .iter()20
+ .enumerate()21
+ .map(|(k, key)| match k {22
+ 0 => from.to_vec(),23
+ k if k == last => to.to_vec(),24
+ _ => {25
+ let mut frame = from.to_vec();26
+ for (b, members) in blocks.iter().enumerate() {27
+ for &i in members {28
+ frame[i] = reframe(&from[i], &bf[b], &key[b]);29
+ }30
+ }31
+ frame32
+ }33
+ })34
+ .collect();35
+ let groups = bp36
+ .groups37
+ .into_iter()38
+ .map(|g| Group {39
+ members: g40
+ .members41
+ .iter()42
+ .flat_map(|&b| blocks[b].iter().copied())43
+ .collect(),44
+ ..g45
+ })46
+ .collect();47
+ Plan { keyframes, groups }48
+}49
+50
+fn reframe(r: &Rect, from: &Rect, to: &Rect) -> Rect {51
+ let scale = |v: f64, len: f64| if len > 0.0 { v / len } else { 0.0 };52
+ Rect::new(53
+ to.x + scale(r.x - from.x, from.w) * to.w,54
+ to.y + scale(r.y - from.y, from.h) * to.h,55
+ scale(r.w, from.w) * to.w,56
+ scale(r.h, from.h) * to.h,57
+ )58
+}@@ -0,0 +1,106 @@
1
+#[derive(Clone, Copy, Debug, PartialEq, Default)]2
+pub struct Rect {3
+ pub x: f64,4
+ pub y: f64,5
+ pub w: f64,6
+ pub h: f64,7
+}8
+9
+pub(crate) const TOL: f64 = 0.05;10
+11
+impl Rect {12
+ pub const fn new(x: f64, y: f64, w: f64, h: f64) -> Self {13
+ Self { x, y, w, h }14
+ }15
+16
+ pub fn right(&self) -> f64 {17
+ self.x + self.w18
+ }19
+20
+ pub fn bottom(&self) -> f64 {21
+ self.y + self.h22
+ }23
+24
+ pub fn center_x(&self) -> f64 {25
+ self.x + self.w / 2.026
+ }27
+28
+ pub fn center_y(&self) -> f64 {29
+ self.y + self.h / 2.030
+ }31
+32
+ pub fn lerp(&self, other: &Rect, s: f64) -> Rect {33
+ Rect::new(34
+ self.x + (other.x - self.x) * s,35
+ self.y + (other.y - self.y) * s,36
+ self.w + (other.w - self.w) * s,37
+ self.h + (other.h - self.h) * s,38
+ )39
+ }40
+41
+ pub(crate) fn same_x(&self, o: &Rect) -> bool {42
+ (self.x - o.x).abs() < TOL && (self.w - o.w).abs() < TOL43
+ }44
+45
+ pub(crate) fn same_y(&self, o: &Rect) -> bool {46
+ (self.y - o.y).abs() < TOL && (self.h - o.h).abs() < TOL47
+ }48
+49
+ /// Takes the horizontal extent of `x_from` and the vertical extent of `y_from`.50
+ pub(crate) fn mixed(x_from: &Rect, y_from: &Rect) -> Rect {51
+ Rect::new(x_from.x, y_from.y, x_from.w, y_from.h)52
+ }53
+54
+ pub(crate) fn bounding<'a>(rects: impl IntoIterator<Item = &'a Rect>) -> Rect {55
+ let (mut x0, mut y0, mut x1, mut y1) = (56
+ f64::INFINITY,57
+ f64::INFINITY,58
+ f64::NEG_INFINITY,59
+ f64::NEG_INFINITY,60
+ );61
+ for r in rects {62
+ x0 = x0.min(r.x);63
+ y0 = y0.min(r.y);64
+ x1 = x1.max(r.right());65
+ y1 = y1.max(r.bottom());66
+ }67
+ Rect::new(x0, y0, x1 - x0, y1 - y0)68
+ }69
+70
+ pub(crate) fn expanded(&self, by: f64) -> Rect {71
+ Rect::new(72
+ self.x - by,73
+ self.y - by,74
+ self.w + 2.0 * by,75
+ self.h + 2.0 * by,76
+ )77
+ }78
+79
+ pub(crate) fn intersects(&self, o: &Rect) -> bool {80
+ self.x < o.right() && o.x < self.right() && self.y < o.bottom() && o.y < self.bottom()81
+ }82
+}83
+84
+/// How a window changes between two keyframes.85
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]86
+pub(crate) struct Change {87
+ pub x: bool,88
+ pub y: bool,89
+}90
+91
+impl Change {92
+ pub fn between(a: &Rect, b: &Rect) -> Self {93
+ Self {94
+ x: !a.same_x(b),95
+ y: !a.same_y(b),96
+ }97
+ }98
+99
+ pub fn moves(&self) -> bool {100
+ self.x || self.y101
+ }102
+103
+ pub fn both(&self) -> bool {104
+ self.x && self.y105
+ }106
+}@@ -1 +1,24 @@
1
+//! Multiphase layout animation planning.2
+//!3
+//! Given where tiled windows are and where they should be, produce a short4
+//! sequence of keyframe layouts. Every window moves between consecutive5
+//! keyframes on one shared curve, so a plan is correct if every pair of6
+//! windows shares a separating relation across every phase.7
+//!8
+//! See `design/planner.html` for the reasoning and interactive demos.19
10
+mod blocks;11
+mod geom;12
+mod plan;13
+mod relation;14
+mod sat;15
+mod strategy;16
+mod validate;17
+18
+#[cfg(test)]19
+mod tests;20
+21
+pub use blocks::plan_blocks;22
+pub use geom::Rect;23
+pub use plan::{Config, Group, Plan, Strategy, Style, plan};24
+pub use validate::{Violation, validate};@@ -0,0 +1,176 @@
1
+use crate::geom::{Change, Rect};2
+use crate::strategy::plan_group;3
+4
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]5
+pub enum Style {6
+ /// Every window moves or resizes along one axis per phase.7
+ Orthogonal,8
+ /// Windows may change both axes in one phase when no pair loses its separation.9
+ Direct,10
+}11
+12
+#[derive(Clone, Copy, Debug, PartialEq)]13
+pub struct Config {14
+ pub style: Style,15
+ /// Gap between tiled windows. Neighbours closer than this are adjacent.16
+ pub gap: f64,17
+ /// Tolerance for separation and adjacency tests.18
+ pub eps: f64,19
+ /// Lanes thinner than this are not used.20
+ pub min_lane: f64,21
+}22
+23
+impl Default for Config {24
+ fn default() -> Self {25
+ Self {26
+ style: Style::Orthogonal,27
+ gap: 10.0,28
+ eps: 0.5,29
+ min_lane: 6.0,30
+ }31
+ }32
+}33
+34
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]35
+pub enum Strategy {36
+ Unchanged,37
+ Direct,38
+ AxisOrder,39
+ Lanes { lanes: usize },40
+ Grid { cells: usize },41
+ Pinch,42
+}43
+44
+/// Windows that plan together. Groups never touch each other while moving.45
+#[derive(Clone, Debug, PartialEq)]46
+pub struct Group {47
+ pub members: Vec<usize>,48
+ pub strategy: Strategy,49
+ pub phases: usize,50
+ /// Whether the plan briefly separates two neighbours that touch at both ends.51
+ pub opens_hole: bool,52
+}53
+54
+/// Keyframe layouts indexed like the input. Every phase lasts the same time55
+/// and every window in a group moves on that group's curve.56
+#[derive(Clone, Debug, PartialEq)]57
+pub struct Plan {58
+ pub keyframes: Vec<Vec<Rect>>,59
+ pub groups: Vec<Group>,60
+}61
+62
+impl Plan {63
+ pub fn phases(&self) -> usize {64
+ self.keyframes.len().saturating_sub(1)65
+ }66
+67
+ /// Window positions at `phase` with eased progress `s`.68
+ pub fn sample(&self, phase: usize, s: f64) -> Vec<Rect> {69
+ match (self.keyframes.get(phase), self.keyframes.get(phase + 1)) {70
+ (Some(a), Some(b)) => a.iter().zip(b).map(|(a, b)| a.lerp(b, s)).collect(),71
+ _ => self.keyframes.last().cloned().unwrap_or_default(),72
+ }73
+ }74
+}75
+76
+pub(crate) struct GroupPlan {77
+ pub keys: Vec<Vec<Rect>>,78
+ pub strategy: Strategy,79
+ pub opens_hole: bool,80
+}81
+82
+impl GroupPlan {83
+ pub fn reversed(mut self) -> Self {84
+ self.keys.reverse();85
+ self86
+ }87
+}88
+89
+/// Plans the transition from `from` to `to`. Both slices index the same windows.90
+pub fn plan(from: &[Rect], to: &[Rect], cfg: &Config) -> Plan {91
+ assert_eq!(92
+ from.len(),93
+ to.len(),94
+ "layouts must describe the same windows"95
+ );96
+ let mut groups = Vec::new();97
+ let mut plans = Vec::new();98
+ for members in motion_groups(from, to, cfg) {99
+ let f: Vec<Rect> = members.iter().map(|&i| from[i]).collect();100
+ let t: Vec<Rect> = members.iter().map(|&i| to[i]).collect();101
+ let gp = plan_canonical(&f, &t, cfg);102
+ groups.push(Group {103
+ members: members.clone(),104
+ strategy: gp.strategy,105
+ phases: gp.keys.len() - 1,106
+ opens_hole: gp.opens_hole,107
+ });108
+ plans.push((members, gp));109
+ }110
+ let phases = groups.iter().map(|g| g.phases).max().unwrap_or(0);111
+ let mut keyframes = vec![to.to_vec(); phases + 1];112
+ for (members, gp) in &plans {113
+ for (k, frame) in keyframes.iter_mut().enumerate() {114
+ let key = &gp.keys[k.min(gp.keys.len() - 1)];115
+ for (local, &i) in members.iter().enumerate() {116
+ frame[i] = key[local];117
+ }118
+ }119
+ }120
+ Plan { keyframes, groups }121
+}122
+123
+/// Plans in one fixed direction per pair of layouts, so planning back plays124
+/// the same plan in reverse.125
+pub(crate) fn plan_canonical(from: &[Rect], to: &[Rect], cfg: &Config) -> GroupPlan {126
+ if canonical_order(from, to).is_le() {127
+ plan_group(from, to, cfg)128
+ } else {129
+ plan_group(to, from, cfg).reversed()130
+ }131
+}132
+133
+fn canonical_order(a: &[Rect], b: &[Rect]) -> std::cmp::Ordering {134
+ let q = |v: f64| (v * 10.0).round() as i64;135
+ let key = |r: &Rect| [q(r.x), q(r.y), q(r.w), q(r.h)];136
+ a.iter().map(key).cmp(b.iter().map(key))137
+}138
+139
+/// Partitions windows so that windows in different groups can never meet.140
+/// A moving window joins every window its swept area comes within a gap of.141
+fn motion_groups(from: &[Rect], to: &[Rect], cfg: &Config) -> Vec<Vec<usize>> {142
+ let n = from.len();143
+ let swept: Vec<Rect> = (0..n)144
+ .map(|i| Rect::bounding([&from[i], &to[i]]).expanded(cfg.gap / 2.0 + cfg.eps))145
+ .collect();146
+ let moves: Vec<bool> = (0..n)147
+ .map(|i| Change::between(&from[i], &to[i]).moves())148
+ .collect();149
+ let mut parent: Vec<usize> = (0..n).collect();150
+ fn find(parent: &mut [usize], mut i: usize) -> usize {151
+ while parent[i] != i {152
+ parent[i] = parent[parent[i]];153
+ i = parent[i];154
+ }155
+ i156
+ }157
+ for i in 0..n {158
+ for j in i + 1..n {159
+ if (moves[i] || moves[j]) && swept[i].intersects(&swept[j]) {160
+ let (a, b) = (find(&mut parent, i), find(&mut parent, j));161
+ parent[a] = b;162
+ }163
+ }164
+ }165
+ let mut groups: Vec<Vec<usize>> = Vec::new();166
+ let mut slot = vec![usize::MAX; n];167
+ for i in 0..n {168
+ let root = find(&mut parent, i);169
+ if slot[root] == usize::MAX {170
+ slot[root] = groups.len();171
+ groups.push(Vec::new());172
+ }173
+ groups[slot[root]].push(i);174
+ }175
+ groups176
+}@@ -0,0 +1,47 @@
1
+use crate::geom::Rect;2
+3
+pub(crate) const LEFT: u8 = 1;4
+pub(crate) const RIGHT: u8 = 2;5
+pub(crate) const ABOVE: u8 = 4;6
+pub(crate) const BELOW: u8 = 8;7
+pub(crate) const X_BITS: u8 = LEFT | RIGHT;8
+pub(crate) const Y_BITS: u8 = ABOVE | BELOW;9
+10
+/// Which ways `a` is apart from `b`. Touching edges count as apart.11
+pub(crate) fn separation(a: &Rect, b: &Rect, eps: f64) -> u8 {12
+ let mut s = 0;13
+ if a.right() <= b.x + eps {14
+ s |= LEFT;15
+ }16
+ if b.right() <= a.x + eps {17
+ s |= RIGHT;18
+ }19
+ if a.bottom() <= b.y + eps {20
+ s |= ABOVE;21
+ }22
+ if b.bottom() <= a.y + eps {23
+ s |= BELOW;24
+ }25
+ s26
+}27
+28
+/// Which sides of `a` sit against `b`, at most one gap apart and facing each other.29
+pub(crate) fn adjacency(a: &Rect, b: &Rect, gap: f64, eps: f64) -> u8 {30
+ let overlap_x = a.x < b.right() + eps && b.x < a.right() + eps;31
+ let overlap_y = a.y < b.bottom() + eps && b.y < a.bottom() + eps;32
+ let near = |d: f64| d.abs() <= gap + eps;33
+ let mut s = 0;34
+ if overlap_y && near(b.x - a.right()) {35
+ s |= LEFT;36
+ }37
+ if overlap_y && near(a.x - b.right()) {38
+ s |= RIGHT;39
+ }40
+ if overlap_x && near(b.y - a.bottom()) {41
+ s |= ABOVE;42
+ }43
+ if overlap_x && near(a.y - b.bottom()) {44
+ s |= BELOW;45
+ }46
+ s47
+}@@ -0,0 +1,175 @@
1
+/// A 2-SAT instance over boolean variables, solved through strongly2
+/// connected components of the implication graph.3
+pub(crate) struct TwoSat {4
+ vars: usize,5
+ clauses: Vec<(Lit, Lit)>,6
+}7
+8
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]9
+pub(crate) struct Lit {10
+ pub var: usize,11
+ pub value: bool,12
+}13
+14
+impl Lit {15
+ pub fn new(var: usize, value: bool) -> Self {16
+ Self { var, value }17
+ }18
+19
+ fn node(self) -> usize {20
+ 2 * self.var + usize::from(self.value)21
+ }22
+23
+ fn negated(self) -> Self {24
+ Self {25
+ var: self.var,26
+ value: !self.value,27
+ }28
+ }29
+}30
+31
+impl TwoSat {32
+ pub fn new(vars: usize) -> Self {33
+ Self {34
+ vars,35
+ clauses: Vec::new(),36
+ }37
+ }38
+39
+ pub fn either(&mut self, a: Lit, b: Lit) {40
+ self.clauses.push((a, b));41
+ }42
+43
+ pub fn require(&mut self, a: Lit) {44
+ self.clauses.push((a, a));45
+ }46
+47
+ pub fn solve(&self) -> Option<Vec<bool>> {48
+ let n = 2 * self.vars;49
+ let mut graph = vec![Vec::new(); n];50
+ for &(a, b) in &self.clauses {51
+ graph[a.negated().node()].push(b.node());52
+ graph[b.negated().node()].push(a.node());53
+ }54
+ let comp = tarjan(&graph);55
+ let mut out = Vec::with_capacity(self.vars);56
+ for v in 0..self.vars {57
+ let (f, t) = (comp[2 * v], comp[2 * v + 1]);58
+ if f == t {59
+ return None;60
+ }61
+ out.push(t < f);62
+ }63
+ Some(out)64
+ }65
+66
+ /// A solution that takes each variable's preferred value, in order,67
+ /// whenever that stays satisfiable.68
+ pub fn solve_preferring(mut self, preferred: &[bool]) -> Option<Vec<bool>> {69
+ self.solve()?;70
+ for (var, &value) in preferred.iter().enumerate() {71
+ self.require(Lit::new(var, value));72
+ if self.solve().is_none() {73
+ self.clauses.pop();74
+ self.require(Lit::new(var, !value));75
+ }76
+ }77
+ self.solve()78
+ }79
+}80
+81
+/// Tarjan's algorithm, iterative. Components come out in reverse82
+/// topological order, so a lower index means later in the implication order.83
+fn tarjan(graph: &[Vec<usize>]) -> Vec<usize> {84
+ struct State {85
+ index: Vec<usize>,86
+ low: Vec<usize>,87
+ on_stack: Vec<bool>,88
+ stack: Vec<usize>,89
+ next: usize,90
+ }91
+ impl State {92
+ fn enter(&mut self, v: usize, work: &mut Vec<(usize, usize)>) {93
+ self.index[v] = self.next;94
+ self.low[v] = self.next;95
+ self.next += 1;96
+ self.stack.push(v);97
+ self.on_stack[v] = true;98
+ work.push((v, 0));99
+ }100
+ }101
+ let n = graph.len();102
+ let mut st = State {103
+ index: vec![usize::MAX; n],104
+ low: vec![0; n],105
+ on_stack: vec![false; n],106
+ stack: Vec::new(),107
+ next: 0,108
+ };109
+ let mut comp = vec![usize::MAX; n];110
+ let mut next_comp = 0;111
+ let mut work = Vec::new();112
+ for root in 0..n {113
+ if st.index[root] != usize::MAX {114
+ continue;115
+ }116
+ st.enter(root, &mut work);117
+ while let Some(&(v, edge)) = work.last() {118
+ if let Some(&w) = graph[v].get(edge) {119
+ if let Some(top) = work.last_mut() {120
+ top.1 += 1;121
+ }122
+ if st.index[w] == usize::MAX {123
+ st.enter(w, &mut work);124
+ } else if st.on_stack[w] {125
+ st.low[v] = st.low[v].min(st.index[w]);126
+ }127
+ continue;128
+ }129
+ work.pop();130
+ if let Some(&(parent, _)) = work.last() {131
+ st.low[parent] = st.low[parent].min(st.low[v]);132
+ }133
+ if st.low[v] == st.index[v] {134
+ while let Some(w) = st.stack.pop() {135
+ st.on_stack[w] = false;136
+ comp[w] = next_comp;137
+ if w == v {138
+ break;139
+ }140
+ }141
+ next_comp += 1;142
+ }143
+ }144
+ }145
+ comp146
+}147
+148
+#[cfg(test)]149
+mod tests {150
+ use super::*;151
+152
+ #[test]153
+ fn satisfiable() {154
+ let mut s = TwoSat::new(2);155
+ s.either(Lit::new(0, true), Lit::new(1, true));156
+ s.either(Lit::new(0, false), Lit::new(1, true));157
+ let sol = s.solve().unwrap();158
+ assert!(sol[1]);159
+ }160
+161
+ #[test]162
+ fn unsatisfiable() {163
+ let mut s = TwoSat::new(1);164
+ s.require(Lit::new(0, true));165
+ s.require(Lit::new(0, false));166
+ assert!(s.solve().is_none());167
+ }168
+169
+ #[test]170
+ fn preferences_are_kept_when_possible() {171
+ let mut s = TwoSat::new(2);172
+ s.either(Lit::new(0, false), Lit::new(1, false));173
+ assert_eq!(s.solve_preferring(&[true, true]), Some(vec![true, false]));174
+ }175
+}@@ -0,0 +1,397 @@
1
+use crate::geom::{Change, Rect};2
+use crate::plan::{Config, GroupPlan, Strategy, Style};3
+use crate::relation::{ABOVE, LEFT, X_BITS, Y_BITS, adjacency, separation};4
+use crate::sat::{Lit, TwoSat};5
+6
+/// Upper bound on lane-assignment search steps before giving up on a lane count.7
+const LANE_SEARCH_BUDGET: usize = 20_000;8
+9
+#[derive(Clone, Copy, PartialEq, Eq)]10
+enum Axis {11
+ X,12
+ Y,13
+}14
+15
+pub(crate) fn plan_group(from: &[Rect], to: &[Rect], cfg: &Config) -> GroupPlan {16
+ let done = |keys: Vec<Vec<Rect>>, strategy| GroupPlan {17
+ keys,18
+ strategy,19
+ opens_hole: false,20
+ };21
+ let movers: Vec<usize> = (0..from.len())22
+ .filter(|&i| Change::between(&from[i], &to[i]).moves())23
+ .collect();24
+ if movers.is_empty() {25
+ return done(vec![from.to_vec()], Strategy::Unchanged);26
+ }27
+ let pure = movers28
+ .iter()29
+ .all(|&i| !Change::between(&from[i], &to[i]).both());30
+ if phase_ok(from, to, cfg) && (cfg.style == Style::Direct || pure) {31
+ return done(vec![from.to_vec(), to.to_vec()], Strategy::Direct);32
+ }33
+ if let Some((k1, opens_hole)) = axis_order(from, to, &movers, cfg) {34
+ return GroupPlan {35
+ keys: vec![from.to_vec(), k1, to.to_vec()],36
+ strategy: Strategy::AxisOrder,37
+ opens_hole,38
+ };39
+ }40
+ for axis in [Axis::Y, Axis::X] {41
+ if let Some((k1, k2, lanes)) = lanes(from, to, &movers, axis, cfg) {42
+ return done(43
+ vec![from.to_vec(), k1, k2, to.to_vec()],44
+ Strategy::Lanes { lanes },45
+ );46
+ }47
+ }48
+ if let Some((keys, cells)) = grid(from, to, &movers, cfg) {49
+ return done(keys, Strategy::Grid { cells });50
+ }51
+ done(pinch(from, to, &movers), Strategy::Pinch)52
+}53
+54
+/// Every pair shares a separating relation at both ends of the phase.55
+pub(crate) fn phase_ok(a: &[Rect], b: &[Rect], cfg: &Config) -> bool {56
+ (0..a.len()).all(|i| {57
+ (i + 1..a.len())58
+ .all(|j| separation(&a[i], &a[j], cfg.eps) & separation(&b[i], &b[j], cfg.eps) != 0)59
+ })60
+}61
+62
+fn pure_ok(keys: &[&[Rect]], cfg: &Config) -> bool {63
+ cfg.style == Style::Direct64
+ || keys.windows(2).all(|w| {65
+ w[0].iter()66
+ .zip(w[1])67
+ .all(|(a, b)| !Change::between(a, b).both())68
+ })69
+}70
+71
+fn valid(keys: &[&[Rect]], cfg: &Config) -> bool {72
+ keys.windows(2).all(|w| phase_ok(w[0], w[1], cfg)) && pure_ok(keys, cfg)73
+}74
+75
+fn conflict_set(from: &[Rect], to: &[Rect], cfg: &Config) -> Vec<usize> {76
+ let n = from.len();77
+ let mut hit = vec![false; n];78
+ for i in 0..n {79
+ for j in i + 1..n {80
+ if separation(&from[i], &from[j], cfg.eps) & separation(&to[i], &to[j], cfg.eps) == 0 {81
+ hit[i] = true;82
+ hit[j] = true;83
+ }84
+ }85
+ }86
+ (0..n).filter(|&i| hit[i]).collect()87
+}88
+89
+/// Two phases. Each moving window takes one axis of its target in phase 1 and90
+/// the other in phase 2. The choice per window is a boolean, every pair forbids91
+/// some combinations, and the result is a 2-SAT instance. Neighbours that touch92
+/// at both ends must also touch in the middle keyframe when that is possible.93
+fn axis_order(94
+ from: &[Rect],95
+ to: &[Rect],96
+ movers: &[usize],97
+ cfg: &Config,98
+) -> Option<(Vec<Rect>, bool)> {99
+ let n = from.len();100
+ let mut var = vec![None; n];101
+ for (v, &i) in movers.iter().enumerate() {102
+ var[i] = Some(v);103
+ }104
+ let options = |i: usize| -> Vec<(Option<bool>, Rect)> {105
+ if var[i].is_some() {106
+ vec![107
+ (Some(false), Rect::mixed(&from[i], &to[i])),108
+ (Some(true), Rect::mixed(&to[i], &from[i])),109
+ ]110
+ } else {111
+ vec![(None, from[i])]112
+ }113
+ };114
+ let preferred: Vec<bool> = movers115
+ .iter()116
+ .map(|&i| {117
+ let c = Change::between(&from[i], &to[i]);118
+ c.x && !c.y119
+ })120
+ .collect();121
+ for keep_edges in [true, false] {122
+ let mut sat = TwoSat::new(movers.len());123
+ let mut impossible = false;124
+ for i in 0..n {125
+ for j in i + 1..n {126
+ if var[i].is_none() && var[j].is_none() {127
+ continue;128
+ }129
+ let sf = separation(&from[i], &from[j], cfg.eps);130
+ let st = separation(&to[i], &to[j], cfg.eps);131
+ let coupled = adjacency(&from[i], &from[j], cfg.gap, cfg.eps)132
+ & adjacency(&to[i], &to[j], cfg.gap, cfg.eps);133
+ for (ci, ri) in options(i) {134
+ for (cj, rj) in options(j) {135
+ let s = separation(&ri, &rj, cfg.eps);136
+ let held = !keep_edges137
+ || adjacency(&ri, &rj, cfg.gap, cfg.eps) & coupled == coupled;138
+ if sf & s != 0 && s & st != 0 && held {139
+ continue;140
+ }141
+ match (ci, cj) {142
+ (Some(a), Some(b)) => {143
+ sat.either(Lit::new(var[i]?, !a), Lit::new(var[j]?, !b))144
+ }145
+ (Some(a), None) => sat.require(Lit::new(var[i]?, !a)),146
+ (None, Some(b)) => sat.require(Lit::new(var[j]?, !b)),147
+ (None, None) => impossible = true,148
+ }149
+ }150
+ }151
+ }152
+ }153
+ if impossible {154
+ continue;155
+ }156
+ if let Some(choice) = sat.solve_preferring(&preferred) {157
+ let mut k1 = from.to_vec();158
+ for (v, &i) in movers.iter().enumerate() {159
+ k1[i] = if choice[v] {160
+ Rect::mixed(&to[i], &from[i])161
+ } else {162
+ Rect::mixed(&from[i], &to[i])163
+ };164
+ }165
+ let hole = opens_hole(from, to, &k1, cfg);166
+ return Some((k1, hole));167
+ }168
+ }169
+ None170
+}171
+172
+fn opens_hole(from: &[Rect], to: &[Rect], mid: &[Rect], cfg: &Config) -> bool {173
+ let n = from.len();174
+ (0..n).any(|i| {175
+ (i + 1..n).any(|j| {176
+ let coupled = adjacency(&from[i], &from[j], cfg.gap, cfg.eps)177
+ & adjacency(&to[i], &to[j], cfg.gap, cfg.eps);178
+ adjacency(&mid[i], &mid[j], cfg.gap, cfg.eps) & coupled != coupled179
+ })180
+ })181
+}182
+183
+/// Three phases. Windows that pass each other shrink across the axis of travel184
+/// into separate lanes, slide, then expand. Lanes span only the band the185
+/// involved windows occupy.186
+fn lanes(187
+ from: &[Rect],188
+ to: &[Rect],189
+ movers: &[usize],190
+ axis: Axis,191
+ cfg: &Config,192
+) -> Option<(Vec<Rect>, Vec<Rect>, usize)> {193
+ let n = from.len();194
+ let (travel, before) = match axis {195
+ Axis::Y => (X_BITS, ABOVE),196
+ Axis::X => (Y_BITS, LEFT),197
+ };198
+ let span = |r: &Rect| match axis {199
+ Axis::Y => (r.y, r.bottom()),200
+ Axis::X => (r.x, r.right()),201
+ };202
+ for set in [203
+ conflict_set(from, to, cfg),204
+ movers.to_vec(),205
+ (0..n).collect(),206
+ ] {207
+ let m = set.len();208
+ if m < 2 {209
+ continue;210
+ }211
+ let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);212
+ for &i in &set {213
+ for r in [&from[i], &to[i]] {214
+ let (a, b) = span(r);215
+ lo = lo.min(a);216
+ hi = hi.max(b);217
+ }218
+ }219
+ let mut diff = vec![vec![false; m]; m];220
+ let mut lt = vec![vec![false; m]; m];221
+ for a in 0..m {222
+ for b in 0..m {223
+ if a == b {224
+ continue;225
+ }226
+ let f = separation(&from[set[a]], &from[set[b]], cfg.eps);227
+ let t = separation(&to[set[a]], &to[set[b]], cfg.eps);228
+ diff[a][b] = f & t & travel == 0;229
+ lt[a][b] = [f, t].iter().any(|r| r & travel == 0 && r & before != 0);230
+ }231
+ }232
+ let in_set: Vec<Option<usize>> = (0..n).map(|i| set.iter().position(|&s| s == i)).collect();233
+ for k in 2..=m {234
+ let size = (hi - lo - (k - 1) as f64 * cfg.gap) / k as f64;235
+ if size < cfg.min_lane {236
+ break;237
+ }238
+ let mut lane = vec![usize::MAX; m];239
+ let mut budget = LANE_SEARCH_BUDGET;240
+ if !assign_lanes(0, k, &mut lane, &diff, <, &mut budget) {241
+ continue;242
+ }243
+ let strip = |l: usize| lo + l as f64 * (size + cfg.gap);244
+ let in_lane = |r: &Rect, l: usize| match axis {245
+ Axis::Y => Rect::new(r.x, strip(l), r.w, size),246
+ Axis::X => Rect::new(strip(l), r.y, size, r.h),247
+ };248
+ for phase in [2, 1, 3] {249
+ let mut k1 = Vec::with_capacity(n);250
+ let mut k2 = Vec::with_capacity(n);251
+ for i in 0..n {252
+ let (f, t) = (from[i], to[i]);253
+ let (a, b) = match (in_set[i], phase) {254
+ (Some(s), _) => (in_lane(&f, lane[s]), in_lane(&t, lane[s])),255
+ (None, 1) => (t, t),256
+ (None, 2) => (f, t),257
+ (None, _) => (f, f),258
+ };259
+ k1.push(a);260
+ k2.push(b);261
+ }262
+ if valid(&[from, &k1, &k2, to], cfg) {263
+ return Some((k1, k2, k));264
+ }265
+ }266
+ }267
+ }268
+ None269
+}270
+271
+fn assign_lanes(272
+ i: usize,273
+ k: usize,274
+ lane: &mut [usize],275
+ diff: &[Vec<bool>],276
+ lt: &[Vec<bool>],277
+ budget: &mut usize,278
+) -> bool {279
+ if i == lane.len() {280
+ return true;281
+ }282
+ for l in 0..k {283
+ if *budget == 0 {284
+ return false;285
+ }286
+ *budget -= 1;287
+ let fits = (0..i).all(|j| {288
+ !(diff[i][j] && lane[j] == l)289
+ && !(lt[i][j] && l >= lane[j])290
+ && !(lt[j][i] && lane[j] >= l)291
+ });292
+ if fits {293
+ lane[i] = l;294
+ if assign_lanes(i + 1, k, lane, diff, lt, budget) {295
+ return true;296
+ }297
+ lane[i] = usize::MAX;298
+ }299
+ }300
+ false301
+}302
+303
+/// Four phases that work for any input. Involved windows shrink into rows304
+/// ordered by where they start, then into columns ordered by where they end,305
+/// then move vertically to their target, then horizontally.306
+fn grid(307
+ from: &[Rect],308
+ to: &[Rect],309
+ movers: &[usize],310
+ cfg: &Config,311
+) -> Option<(Vec<Vec<Rect>>, usize)> {312
+ let n = from.len();313
+ for set in [314
+ conflict_set(from, to, cfg),315
+ movers.to_vec(),316
+ (0..n).collect(),317
+ ] {318
+ let m = set.len();319
+ if m < 2 {320
+ continue;321
+ }322
+ let area = Rect::bounding(set.iter().flat_map(|&i| [&from[i], &to[i]]));323
+ let gap = |len: f64| {324
+ if len - (m - 1) as f64 * cfg.gap > m as f64 {325
+ cfg.gap326
+ } else {327
+ 0.0328
+ }329
+ };330
+ let (gh, gw) = (gap(area.h), gap(area.w));331
+ let sh = (area.h - (m - 1) as f64 * gh) / m as f64;332
+ let sw = (area.w - (m - 1) as f64 * gw) / m as f64;333
+ let mut by_row = set.clone();334
+ by_row.sort_by(|&a, &b| {335
+ from[a]336
+ .center_y()337
+ .total_cmp(&from[b].center_y())338
+ .then(from[a].center_x().total_cmp(&from[b].center_x()))339
+ });340
+ let mut by_col = set.clone();341
+ by_col.sort_by(|&a, &b| {342
+ to[a]343
+ .center_x()344
+ .total_cmp(&to[b].center_x())345
+ .then(to[a].center_y().total_cmp(&to[b].center_y()))346
+ });347
+ let mut row = vec![0.0; n];348
+ let mut col = vec![0.0; n];349
+ for (r, &i) in by_row.iter().enumerate() {350
+ row[i] = area.y + r as f64 * (sh + gh);351
+ }352
+ for (c, &i) in by_col.iter().enumerate() {353
+ col[i] = area.x + c as f64 * (sw + gw);354
+ }355
+ let in_set: Vec<bool> = (0..n).map(|i| set.contains(&i)).collect();356
+ for phase in [3, 2, 4, 1] {357
+ let mut keys = vec![358
+ from.to_vec(),359
+ Vec::new(),360
+ Vec::new(),361
+ Vec::new(),362
+ to.to_vec(),363
+ ];364
+ for i in 0..n {365
+ let (f, t) = (from[i], to[i]);366
+ let mid = if in_set[i] {367
+ [368
+ Rect::new(f.x, row[i], f.w, sh),369
+ Rect::new(col[i], row[i], sw, sh),370
+ Rect::new(col[i], t.y, sw, t.h),371
+ ]372
+ } else {373
+ [1, 2, 3].map(|k| if k >= phase { t } else { f })374
+ };375
+ for (k, r) in mid.into_iter().enumerate() {376
+ keys[k + 1].push(r);377
+ }378
+ }379
+ let refs: Vec<&[Rect]> = keys.iter().map(|k| k.as_slice()).collect();380
+ if valid(&refs, cfg) {381
+ return Some((keys, m));382
+ }383
+ }384
+ }385
+ None386
+}387
+388
+/// Safety net. Movers shrink to a point, travel invisibly, and regrow.389
+fn pinch(from: &[Rect], to: &[Rect], movers: &[usize]) -> Vec<Vec<Rect>> {390
+ let mut k1 = from.to_vec();391
+ let mut k2 = to.to_vec();392
+ for &i in movers {393
+ k1[i] = Rect::new(from[i].center_x(), from[i].center_y(), 0.0, 0.0);394
+ k2[i] = Rect::new(to[i].center_x(), to[i].center_y(), 0.0, 0.0);395
+ }396
+ vec![from.to_vec(), k1, k2, to.to_vec()]397
+}@@ -0,0 +1,331 @@
1
+use crate::geom::Rect;2
+use crate::plan::{Config, Plan, Strategy, Style, plan, plan_canonical};3
+use crate::{plan_blocks, validate};4
+use proptest::prelude::*;5
+6
+const G: f64 = 10.0;7
+8
+fn region() -> Rect {9
+ Rect::new(G, G, 1180.0, 680.0)10
+}11
+12
+fn split(rect: Rect, ratios: &[f64], horizontal: bool) -> Vec<Rect> {13
+ let total: f64 = ratios.iter().sum();14
+ let len = if horizontal { rect.w } else { rect.h };15
+ let avail = len - G * (ratios.len() - 1) as f64;16
+ let mut pos = if horizontal { rect.x } else { rect.y };17
+ ratios18
+ .iter()19
+ .map(|r| {20
+ let size = avail * r / total;21
+ let out = if horizontal {22
+ Rect::new(pos, rect.y, size, rect.h)23
+ } else {24
+ Rect::new(rect.x, pos, rect.w, size)25
+ };26
+ pos += size + G;27
+ out28
+ })29
+ .collect()30
+}31
+32
+fn cols(rect: Rect, n: usize) -> Vec<Rect> {33
+ split(rect, &vec![1.0; n], true)34
+}35
+36
+fn rows(rect: Rect, n: usize) -> Vec<Rect> {37
+ split(rect, &vec![1.0; n], false)38
+}39
+40
+fn cfg(style: Style) -> Config {41
+ Config {42
+ style,43
+ ..Config::default()44
+ }45
+}46
+47
+fn assert_clean(p: &Plan, c: &Config) {48
+ let v = validate(p, c);49
+ assert!(v.is_empty(), "violations {v:?} in {p:?}");50
+ assert!(p.phases() <= 4, "{} phases", p.phases());51
+ assert!(52
+ p.groups.iter().all(|g| g.strategy != Strategy::Pinch),53
+ "fell back to pinch: {p:?}"54
+ );55
+}56
+57
+fn strategy(p: &Plan) -> Strategy {58
+ p.groups59
+ .iter()60
+ .map(|g| g.strategy)61
+ .find(|s| *s != Strategy::Unchanged)62
+ .unwrap_or(Strategy::Unchanged)63
+}64
+65
+#[test]66
+fn swap_uses_two_lanes() {67
+ let c = cfg(Style::Orthogonal);68
+ let ab = cols(region(), 2);69
+ let ba = vec![ab[1], ab[0]];70
+ let p = plan(&ab, &ba, &c);71
+ assert_clean(&p, &c);72
+ assert_eq!(strategy(&p), Strategy::Lanes { lanes: 2 });73
+}74
+75
+#[test]76
+fn full_reversal_needs_one_lane_per_window() {77
+ let c = cfg(Style::Orthogonal);78
+ let a = cols(region(), 4);79
+ let b: Vec<Rect> = (0..4).map(|i| a[3 - i]).collect();80
+ let p = plan(&a, &b, &c);81
+ assert_clean(&p, &c);82
+ assert_eq!(strategy(&p), Strategy::Lanes { lanes: 4 });83
+}84
+85
+#[test]86
+fn orientation_flip_is_two_phases() {87
+ let c = cfg(Style::Orthogonal);88
+ let p = plan(&cols(region(), 3), &rows(region(), 3), &c);89
+ assert_clean(&p, &c);90
+ assert_eq!(strategy(&p), Strategy::AxisOrder);91
+}92
+93
+#[test]94
+fn growing_both_axes_depends_on_style() {95
+ let grid = |wide: bool| {96
+ let r = if wide { [7.0, 3.0] } else { [1.0, 1.0] };97
+ let rs = split(region(), &r, false);98
+ let mut out = split(rs[0], &r, true);99
+ out.extend(split(rs[1], &[1.0, 1.0], true));100
+ out101
+ };102
+ let o = cfg(Style::Orthogonal);103
+ let d = cfg(Style::Direct);104
+ let po = plan(&grid(false), &grid(true), &o);105
+ let pd = plan(&grid(false), &grid(true), &d);106
+ assert_clean(&po, &o);107
+ assert_clean(&pd, &d);108
+ assert_eq!((po.phases(), pd.phases()), (2, 1));109
+}110
+111
+#[test]112
+fn insert_and_split_rewinds() {113
+ let c = cfg(Style::Orthogonal);114
+ let layout = |on: bool| {115
+ let n = if on { 4 } else { 3 };116
+ let ratios: Vec<f64> = (0..n)117
+ .map(|i| if i == 0 && on { 2.0 } else { 1.0 })118
+ .collect();119
+ let cs = split(region(), &ratios, true);120
+ let (a, mut b, c3) = (cs[0], cs[1], cs[n - 1]);121
+ let d = if on {122
+ cs[2]123
+ } else {124
+ Rect::new((b.right() + c3.x) / 2.0, b.y, 0.0, b.h)125
+ };126
+ let e = if on {127
+ let s = rows(b, 2);128
+ b = s[0];129
+ s[1]130
+ } else {131
+ Rect::new(b.x, b.bottom(), b.w, 0.0)132
+ };133
+ vec![a, b, c3, d, e]134
+ };135
+ let on = plan(&layout(false), &layout(true), &c);136
+ let off = plan(&layout(true), &layout(false), &c);137
+ assert_clean(&on, &c);138
+ assert_clean(&off, &c);139
+ assert!(on.groups.iter().all(|g| !g.opens_hole));140
+ let mut rewound = off.keyframes.clone();141
+ rewound.reverse();142
+ assert_eq!(on.keyframes, rewound);143
+}144
+145
+#[test]146
+fn inserting_a_seed_is_one_phase() {147
+ let c = cfg(Style::Orthogonal);148
+ let three = cols(region(), 3);149
+ let four = cols(region(), 4);150
+ let seed = Rect::new(151
+ (three[1].right() + three[2].x) / 2.0,152
+ three[1].y,153
+ 0.0,154
+ three[1].h,155
+ );156
+ let from = vec![three[0], three[1], three[2], seed];157
+ let to = vec![four[0], four[1], four[3], four[2]];158
+ let p = plan(&from, &to, &c);159
+ assert_clean(&p, &c);160
+ assert_eq!(strategy(&p), Strategy::Direct);161
+}162
+163
+#[test]164
+fn tag_rows_swap_rigidly_with_two_lanes() {165
+ let c = cfg(Style::Orthogonal);166
+ let layout = |first_top: bool| {167
+ let rs = rows(region(), 2);168
+ let (t1, t2) = if first_top {169
+ (rs[0], rs[1])170
+ } else {171
+ (rs[1], rs[0])172
+ };173
+ let mut out = cols(t1, 3);174
+ out.extend(cols(t2, 2));175
+ out176
+ };177
+ let blocks = vec![vec![0, 1, 2], vec![3, 4]];178
+ let p = plan_blocks(&layout(true), &layout(false), &blocks, &c);179
+ assert_clean(&p, &c);180
+ assert_eq!(strategy(&p), Strategy::Lanes { lanes: 2 });181
+}182
+183
+#[test]184
+fn simultaneous_reversals_on_both_axes_use_the_grid() {185
+ let c = cfg(Style::Orthogonal);186
+ let halves = cols(region(), 2);187
+ let (l, r) = (rows(halves[0], 2), rows(halves[1], 2));188
+ let from = vec![l[0], r[1], r[0], l[1]];189
+ let left3 = rows(halves[0], 3);190
+ let to = vec![left3[0], left3[1], left3[2], halves[1]];191
+ let p = plan(&from, &to, &c);192
+ assert_clean(&p, &c);193
+ assert!(matches!(strategy(&p), Strategy::Grid { .. }));194
+}195
+196
+#[test]197
+fn distant_groups_plan_independently() {198
+ let c = cfg(Style::Orthogonal);199
+ let thirds = cols(region(), 3);200
+ let l = rows(thirds[0], 2);201
+ let from = vec![l[0], l[1], thirds[1], thirds[2]];202
+ let to = vec![l[1], l[0], thirds[1], thirds[2]];203
+ let p = plan(&from, &to, &c);204
+ assert_clean(&p, &c);205
+ assert!(206
+ p.groups207
+ .iter()208
+ .any(|g| g.members == [3] && g.strategy == Strategy::Unchanged)209
+ );210
+}211
+212
+struct Rng(u64);213
+214
+impl Rng {215
+ fn next(&mut self) -> u64 {216
+ self.0 ^= self.0 << 13;217
+ self.0 ^= self.0 >> 7;218
+ self.0 ^= self.0 << 17;219
+ self.0220
+ }221
+222
+ fn below(&mut self, n: usize) -> usize {223
+ (self.next() % n as u64) as usize224
+ }225
+226
+ fn unit(&mut self) -> f64 {227
+ (self.next() >> 11) as f64 / (1u64 << 53) as f64228
+ }229
+}230
+231
+fn random_layout(rng: &mut Rng, n: usize) -> Vec<Rect> {232
+ let mut ids: Vec<usize> = (0..n).collect();233
+ for i in (1..n).rev() {234
+ ids.swap(i, rng.below(i + 1));235
+ }236
+ let mut out = vec![Rect::default(); n];237
+ let horizontal = rng.below(2) == 0;238
+ build(rng, &ids, region(), horizontal, &mut out);239
+ out240
+}241
+242
+fn build(rng: &mut Rng, ids: &[usize], rect: Rect, horizontal: bool, out: &mut [Rect]) {243
+ if let [only] = ids {244
+ out[*only] = rect;245
+ return;246
+ }247
+ let k = ids.len().min(2 + rng.below(2));248
+ let mut cuts = Vec::with_capacity(k);249
+ let mut rest = ids;250
+ for i in 0..k {251
+ let take = if i == k - 1 {252
+ rest.len()253
+ } else {254
+ 1 + rng.below(rest.len() - (k - 1 - i))255
+ };256
+ let (head, tail) = rest.split_at(take);257
+ cuts.push(head);258
+ rest = tail;259
+ }260
+ let ratios: Vec<f64> = cuts.iter().map(|_| 0.5 + rng.unit()).collect();261
+ for (part, ids) in split(rect, &ratios, horizontal).into_iter().zip(cuts) {262
+ build(rng, ids, part, !horizontal, out);263
+ }264
+}265
+266
+proptest! {267
+ #![proptest_config(ProptestConfig::with_cases(512))]268
+269
+ #[test]270
+ fn random_transitions_plan_cleanly(seed in 1u64.., n in 2usize..9, direct: bool) {271
+ let mut rng = Rng(seed);272
+ let c = cfg(if direct { Style::Direct } else { Style::Orthogonal });273
+ let (a, b) = (random_layout(&mut rng, n), random_layout(&mut rng, n));274
+ let p = plan(&a, &b, &c);275
+ assert_clean(&p, &c);276
+ prop_assert_eq!(&p.keyframes[0], &a);277
+ prop_assert_eq!(p.keyframes.last().unwrap(), &b);278
+ }279
+280
+ #[test]281
+ fn planning_back_rewinds(seed in 1u64.., n in 2usize..9) {282
+ let mut rng = Rng(seed);283
+ let c = cfg(Style::Orthogonal);284
+ let (a, b) = (random_layout(&mut rng, n), random_layout(&mut rng, n));285
+ let mut back = plan_canonical(&b, &a, &c).keys;286
+ back.reverse();287
+ prop_assert_eq!(plan_canonical(&a, &b, &c).keys, back);288
+ }289
+290
+ #[test]291
+ fn interrupts_replan_cleanly(seed in 1u64.., n in 2usize..9, at in 0.0f64..1.0, s in 0.0f64..1.0) {292
+ let mut rng = Rng(seed);293
+ let c = cfg(Style::Orthogonal);294
+ let (a, b, d) = (random_layout(&mut rng, n), random_layout(&mut rng, n), random_layout(&mut rng, n));295
+ let first = plan(&a, &b, &c);296
+ if first.phases() == 0 {297
+ return Ok(());298
+ }299
+ let phase = ((at * first.phases() as f64) as usize).min(first.phases() - 1);300
+ let mid = first.sample(phase, s);301
+ let second = plan(&mid, &d, &c);302
+ assert_clean(&second, &c);303
+ prop_assert_eq!(&second.keyframes[0], &mid);304
+ }305
+}306
+307
+#[test]308
+#[ignore = "statistics, run with --ignored --nocapture"]309
+fn strategy_distribution() {310
+ use std::collections::BTreeMap;311
+ for style in [Style::Orthogonal, Style::Direct] {312
+ let c = cfg(style);313
+ let mut rng = Rng(0x9e37_79b9_7f4a_7c15);314
+ let mut counts: BTreeMap<String, usize> = BTreeMap::new();315
+ let mut holes = 0;316
+ for _ in 0..20_000 {317
+ let n = 2 + rng.below(7);318
+ let (a, b) = (random_layout(&mut rng, n), random_layout(&mut rng, n));319
+ let p = plan(&a, &b, &c);320
+ assert_clean(&p, &c);321
+ holes += p.groups.iter().filter(|g| g.opens_hole).count();322
+ let key = match strategy(&p) {323
+ Strategy::Lanes { .. } => "Lanes".to_string(),324
+ Strategy::Grid { .. } => "Grid".to_string(),325
+ s => format!("{s:?}"),326
+ };327
+ *counts.entry(key).or_default() += 1;328
+ }329
+ println!("{style:?}: {counts:?}, plans opening a hole: {holes}");330
+ }331
+}@@ -0,0 +1,91 @@
1
+use crate::geom::{Change, Rect};2
+use crate::plan::{Config, Plan, Style};3
+4
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]5
+pub enum Violation {6
+ /// Windows `a` and `b` overlap at some point during `phase`.7
+ Overlap { phase: usize, a: usize, b: usize },8
+ /// In orthogonal style, `window` changes both axes during `phase`.9
+ TwoAxes { phase: usize, window: usize },10
+}11
+12
+/// Exact check of a plan under linear interpolation. Every window in a phase13
+/// shares one curve, so overlap depends only on the keyframes, not the easing.14
+pub fn validate(plan: &Plan, cfg: &Config) -> Vec<Violation> {15
+ let mut out = Vec::new();16
+ for (phase, pair) in plan.keyframes.windows(2).enumerate() {17
+ let (a, b) = (&pair[0], &pair[1]);18
+ for i in 0..a.len() {19
+ if cfg.style == Style::Orthogonal && Change::between(&a[i], &b[i]).both() {20
+ out.push(Violation::TwoAxes { phase, window: i });21
+ }22
+ for j in i + 1..a.len() {23
+ if overlaps_during(&a[i], &b[i], &a[j], &b[j], cfg.eps) {24
+ out.push(Violation::Overlap { phase, a: i, b: j });25
+ }26
+ }27
+ }28
+ }29
+ out30
+}31
+32
+/// Whether two linearly moving rectangles share more than `tol` of width and33
+/// height at any instant. Each condition is linear in time, so the set of34
+/// instants satisfying all of them is an interval.35
+fn overlaps_during(a0: &Rect, a1: &Rect, b0: &Rect, b1: &Rect, tol: f64) -> bool {36
+ let conditions = [37
+ (a0.w, a1.w),38
+ (b0.w, b1.w),39
+ (a0.right() - b0.x, a1.right() - b1.x),40
+ (b0.right() - a0.x, b1.right() - a1.x),41
+ (a0.h, a1.h),42
+ (b0.h, b1.h),43
+ (a0.bottom() - b0.y, a1.bottom() - b1.y),44
+ (b0.bottom() - a0.y, b1.bottom() - a1.y),45
+ ];46
+ let (mut lo, mut hi) = (0.0f64, 1.0f64);47
+ for (v0, v1) in conditions {48
+ let slope = v1 - v0;49
+ if slope.abs() < 1e-12 {50
+ if v0 <= tol {51
+ return false;52
+ }53
+ continue;54
+ }55
+ let root = (tol - v0) / slope;56
+ if slope > 0.0 {57
+ lo = lo.max(root);58
+ } else {59
+ hi = hi.min(root);60
+ }61
+ }62
+ lo < hi - 1e-963
+}64
+65
+#[cfg(test)]66
+mod tests {67
+ use super::*;68
+69
+ #[test]70
+ fn crossing_windows_overlap() {71
+ let a0 = Rect::new(0.0, 0.0, 100.0, 100.0);72
+ let b0 = Rect::new(110.0, 0.0, 100.0, 100.0);73
+ assert!(overlaps_during(&a0, &b0, &b0, &a0, 0.5));74
+ }75
+76
+ #[test]77
+ fn neighbours_moving_together_do_not() {78
+ let a0 = Rect::new(0.0, 0.0, 100.0, 100.0);79
+ let a1 = Rect::new(0.0, 0.0, 150.0, 100.0);80
+ let b0 = Rect::new(110.0, 0.0, 100.0, 100.0);81
+ let b1 = Rect::new(160.0, 0.0, 50.0, 100.0);82
+ assert!(!overlaps_during(&a0, &a1, &b0, &b1, 0.5));83
+ }84
+85
+ #[test]86
+ fn zero_size_seed_inside_a_window_is_not_an_overlap() {87
+ let a = Rect::new(0.0, 0.0, 100.0, 100.0);88
+ let seed = Rect::new(50.0, 0.0, 0.0, 100.0);89
+ assert!(!overlaps_during(&a, &a, &seed, &seed, 0.5));90
+ }91
+}@@ -26,6 +26,8 @@
2626
lavapipeIcd = "${pkgs.mesa}/share/vulkan/icd.d/lvp_icd.${pkgs.stdenv.hostPlatform.parsed.cpu.name}.json";2727
commonArgs = {2828
inherit src;29
+ pname = "monolith";30
+ version = "0.0.0";2931
strictDeps = true;3032
nativeBuildInputs = [ pkgs.pkg-config ];3133
buildInputs = with pkgs; [@@ -54,7 +56,7 @@
5456
cargoClippyExtraArgs = "--all-targets -- --deny warnings";5557
}5658
);57
- fmt = craneLib.cargoFmt { inherit src; };59
+ fmt = craneLib.cargoFmt { inherit src; pname = "monolith"; version = "0.0.0"; };5860
test = craneLib.cargoTest (5961
commonArgs6062
// {