atagen

monolith

public
Star0
Watch0

Commit b563d2da1c80

tarball

atagen <boss@atagen.co>2026-10-16 08:32 UTC

19 files changed+2004-1
planner, motion: implement the multiphase planner

Plans are built under the shared-separator rule, so overlap freedom follows from construction rather than a post-hoc search, and the four-phase grid guarantees a plan for any input. Axis ordering is solved as 2-SAT with edge coupling as hard clauses, which removes the brute-force search the JavaScript prototype used. Property tests cover random transitions, rewind symmetry and mid-flight interrupts.
Modified file: Cargo.lock+228 additions
@@ -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"
Modified file: Cargo.toml+1 additions
@@ -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"
Modified file: crates/motion/Cargo.toml+2 additions
@@ -8,3 +8,5 @@ publish.workspace = true
88 [lints]
99 workspace = true
1010
11+[dev-dependencies]
12+proptest.workspace = true
Added file: crates/motion/src/curve.rs+139 additions
@@ -0,0 +1,139 @@
1+/// A CSS-style cubic bezier from (0, 0) to (1, 1) with control points
2+/// (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.x1
42+ }
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 stays
47+ /// inside the convex hull of its control points, so with both heights in
48+ /// [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+ u
79+ }
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) * u
87+}
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 + c
94+}
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+}
Modified file: crates/motion/src/lib.rs+13 additions
@@ -1 +1,14 @@
1+//! Curves and timelines for phased animations.
2+//!
3+//! Every phase lasts exactly its configured duration. Progress inside a phase
4+//! runs from 0 to 1 through a cubic bezier easing curve. Times are presentation
5+//! timestamps in nanoseconds, so callers sample at the vblank a frame targets
6+//! 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};
Added file: crates/motion/src/timeline.rs+65 additions
@@ -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 u64
23+ }
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+}
Added file: crates/motion/src/velocity.rs+59 additions
@@ -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 its
7+/// edge displacement over the new first phase. The fastest mover with a real
8+/// displacement decides the slope, because windows sharing an edge must share
9+/// a curve. Returns `None` when that mover would have to reverse, so the group
10+/// 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+}
Modified file: crates/planner/Cargo.toml+2 additions
@@ -8,3 +8,5 @@ publish.workspace = true
88 [lints]
99 workspace = true
1010
11+[dev-dependencies]
12+proptest.workspace = true
Added file: crates/planner/examples/filmstrip.rs+88 additions
@@ -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.0
60+ );
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.0
80+ );
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+}
Added file: crates/planner/src/blocks.rs+58 additions
@@ -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 whole
5+/// container such as reordering viewed tags. Blocks must partition the
6+/// 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+ blocks
10+ .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 = bp
18+ .keyframes
19+ .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+ frame
32+ }
33+ })
34+ .collect();
35+ let groups = bp
36+ .groups
37+ .into_iter()
38+ .map(|g| Group {
39+ members: g
40+ .members
41+ .iter()
42+ .flat_map(|&b| blocks[b].iter().copied())
43+ .collect(),
44+ ..g
45+ })
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+}
Added file: crates/planner/src/geom.rs+106 additions
@@ -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.w
18+ }
19+
20+ pub fn bottom(&self) -> f64 {
21+ self.y + self.h
22+ }
23+
24+ pub fn center_x(&self) -> f64 {
25+ self.x + self.w / 2.0
26+ }
27+
28+ pub fn center_y(&self) -> f64 {
29+ self.y + self.h / 2.0
30+ }
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() < TOL
43+ }
44+
45+ pub(crate) fn same_y(&self, o: &Rect) -> bool {
46+ (self.y - o.y).abs() < TOL && (self.h - o.h).abs() < TOL
47+ }
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.y
101+ }
102+
103+ pub fn both(&self) -> bool {
104+ self.x && self.y
105+ }
106+}
Modified file: crates/planner/src/lib.rs+23 additions
@@ -1 +1,24 @@
1+//! Multiphase layout animation planning.
2+//!
3+//! Given where tiled windows are and where they should be, produce a short
4+//! sequence of keyframe layouts. Every window moves between consecutive
5+//! keyframes on one shared curve, so a plan is correct if every pair of
6+//! 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};
Added file: crates/planner/src/plan.rs+176 additions
@@ -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 time
55+/// 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+ self
86+ }
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 plays
124+/// 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+ i
156+ }
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+ groups
176+}
Added file: crates/planner/src/relation.rs+47 additions
@@ -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+ s
26+}
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+ s
47+}
Added file: crates/planner/src/sat.rs+175 additions
@@ -0,0 +1,175 @@
1+/// A 2-SAT instance over boolean variables, solved through strongly
2+/// 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 reverse
82+/// 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+ comp
146+}
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+}
Added file: crates/planner/src/strategy.rs+397 additions
@@ -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 = movers
28+ .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::Direct
64+ || 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 and
90+/// the other in phase 2. The choice per window is a boolean, every pair forbids
91+/// some combinations, and the result is a 2-SAT instance. Neighbours that touch
92+/// 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> = movers
115+ .iter()
116+ .map(|&i| {
117+ let c = Change::between(&from[i], &to[i]);
118+ c.x && !c.y
119+ })
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_edges
137+ || 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+ None
170+}
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 != coupled
179+ })
180+ })
181+}
182+
183+/// Three phases. Windows that pass each other shrink across the axis of travel
184+/// into separate lanes, slide, then expand. Lanes span only the band the
185+/// 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, &lt, &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+ None
269+}
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+ false
301+}
302+
303+/// Four phases that work for any input. Involved windows shrink into rows
304+/// 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.gap
326+ } else {
327+ 0.0
328+ }
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+ None
386+}
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+}
Added file: crates/planner/src/tests.rs+331 additions
@@ -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+ ratios
18+ .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+ out
28+ })
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.groups
59+ .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+ out
101+ };
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+ out
176+ };
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.groups
207+ .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.0
220+ }
221+
222+ fn below(&mut self, n: usize) -> usize {
223+ (self.next() % n as u64) as usize
224+ }
225+
226+ fn unit(&mut self) -> f64 {
227+ (self.next() >> 11) as f64 / (1u64 << 53) as f64
228+ }
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+ out
240+}
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+}
Added file: crates/planner/src/validate.rs+91 additions
@@ -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 phase
13+/// 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+ out
30+}
31+
32+/// Whether two linearly moving rectangles share more than `tol` of width and
33+/// height at any instant. Each condition is linear in time, so the set of
34+/// 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-9
63+}
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+}
Modified file: flake.nix+3 additions−1 deletions
@@ -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 commonArgs
6062 // {