From b109cdf6f25c8b7bc107fbe7a6aff8ce162cd828 Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 21 May 2026 19:43:39 +1000 Subject: [PATCH] Validate multiphase overlap analytically --- docs/window-animations-plan.md | 4 +- src/animation/multiphase.rs | 261 ++++++++++++++++++++++++++++++--- 2 files changed, 244 insertions(+), 21 deletions(-) diff --git a/docs/window-animations-plan.md b/docs/window-animations-plan.md index 0252820c..a659d76a 100644 --- a/docs/window-animations-plan.md +++ b/docs/window-animations-plan.md @@ -220,7 +220,9 @@ Current pure planner status: - Stack extraction/return patterns are covered in both horizontal and vertical orientations: peer/container space scales first, the extracted child moves only after space exists, and orthogonal growth happens in the final phase. -- Every produced plan is sampled for overlap at each phase before it is accepted. +- Every produced plan is checked analytically for overlap over the full duration + of each phase before it is accepted. This solves the linear edge inequalities + for each pair of moving rectangles instead of relying on sampled frames. - Live layout batches are partitioned by overlapping motion bounds, so unrelated groups can still use multiphase animation when another group falls back to linear motion. diff --git a/src/animation/multiphase.rs b/src/animation/multiphase.rs index ffaea6d1..37b31619 100644 --- a/src/animation/multiphase.rs +++ b/src/animation/multiphase.rs @@ -1,7 +1,6 @@ use {crate::rect::Rect, crate::tree::NodeId}; const MIN_SHRINK_DENOMINATOR: i32 = 4; -const PHASE_VALIDATION_SAMPLES: [f64; 5] = [0.0, 0.25, 0.5, 0.75, 1.0]; #[derive(Clone, Debug)] pub struct MultiphaseRequest { @@ -288,10 +287,10 @@ fn build_validated_plan( return None; } let plan = MultiphasePlan { phases }; - validate_plan_samples(request, &plan).then_some(plan) + validate_plan_continuous(request, &plan).then_some(plan) } -fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { +fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool { let mut current: Vec<_> = request .windows .iter() @@ -301,26 +300,46 @@ fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> return false; } for phase in &plan.phases { - for t in PHASE_VALIDATION_SAMPLES { - let rects = current.iter().map(|(node_id, rect)| { - phase + for (idx, step) in phase.steps.iter().enumerate() { + if phase.steps[..idx] + .iter() + .any(|prev| prev.node_id == step.node_id) + { + return false; + } + let Some((_, rect)) = current.iter().find(|(node_id, _)| *node_id == step.node_id) + else { + return false; + }; + if *rect != step.from { + return false; + } + } + let motions: Vec<_> = current + .iter() + .map(|(node_id, rect)| { + let to = phase .steps .iter() .find(|step| step.node_id == *node_id) - .map(|step| super::lerp_rect(step.from, step.to, t)) - .unwrap_or(*rect) - }); - if overlaps(rects) { + .map(|step| step.to) + .unwrap_or(*rect); + RectMotion { from: *rect, to } + }) + .collect(); + for (idx, motion) in motions.iter().enumerate() { + if motions[idx + 1..] + .iter() + .any(|other| motions_overlap_during_phase(*motion, *other)) + { return false; } } for step in &phase.steps { - let Some((_, rect)) = current + let (_, rect) = current .iter_mut() .find(|(node_id, _)| *node_id == step.node_id) - else { - return false; - }; + .unwrap(); *rect = step.to; } if overlaps(current.iter().map(|(_, rect)| *rect)) { @@ -335,6 +354,122 @@ fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> }) } +#[derive(Copy, Clone)] +struct RectMotion { + from: Rect, + to: Rect, +} + +fn motions_overlap_during_phase(a: RectMotion, b: RectMotion) -> bool { + let mut interval = TimeInterval::unit(); + interval.intersect_less_than(edge_delta(a.from.x1(), a.to.x1(), b.from.x2(), b.to.x2())) + && interval.intersect_less_than(edge_delta(b.from.x1(), b.to.x1(), a.from.x2(), a.to.x2())) + && interval.intersect_less_than(edge_delta(a.from.y1(), a.to.y1(), b.from.y2(), b.to.y2())) + && interval.intersect_less_than(edge_delta(b.from.y1(), b.to.y1(), a.from.y2(), a.to.y2())) + && interval.is_non_empty() +} + +fn edge_delta(a0: i32, a1: i32, b0: i32, b1: i32) -> LinearDelta { + let from = a0 as i64 - b0 as i64; + let to = a1 as i64 - b1 as i64; + LinearDelta { + start: from, + velocity: to - from, + } +} + +#[derive(Copy, Clone)] +struct LinearDelta { + start: i64, + velocity: i64, +} + +#[derive(Copy, Clone)] +struct TimeInterval { + lower: Rational, + lower_open: bool, + upper: Rational, + upper_open: bool, +} + +impl TimeInterval { + fn unit() -> Self { + Self { + lower: Rational::new(0, 1), + lower_open: false, + upper: Rational::new(1, 1), + upper_open: false, + } + } + + fn intersect_less_than(&mut self, delta: LinearDelta) -> bool { + if delta.velocity == 0 { + return delta.start < 0; + } + let boundary = Rational::new(-delta.start, delta.velocity); + if delta.velocity > 0 { + self.tighten_upper(boundary, true); + } else { + self.tighten_lower(boundary, true); + } + self.is_non_empty() + } + + fn tighten_lower(&mut self, value: Rational, open: bool) { + match value.cmp(&self.lower) { + std::cmp::Ordering::Greater => { + self.lower = value; + self.lower_open = open; + } + std::cmp::Ordering::Equal => { + self.lower_open |= open; + } + std::cmp::Ordering::Less => {} + } + } + + fn tighten_upper(&mut self, value: Rational, open: bool) { + match value.cmp(&self.upper) { + std::cmp::Ordering::Less => { + self.upper = value; + self.upper_open = open; + } + std::cmp::Ordering::Equal => { + self.upper_open |= open; + } + std::cmp::Ordering::Greater => {} + } + } + + fn is_non_empty(&self) -> bool { + match self.lower.cmp(&self.upper) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Equal => !self.lower_open && !self.upper_open, + std::cmp::Ordering::Greater => false, + } + } +} + +#[derive(Copy, Clone, Eq, PartialEq)] +struct Rational { + num: i64, + den: i64, +} + +impl Rational { + fn new(mut num: i64, mut den: i64) -> Self { + if den < 0 { + num = -num; + den = -den; + } + Self { num, den } + } + + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + (self.num as i128 * other.den as i128).cmp(&(other.num as i128 * self.den as i128)) + } +} + fn classify_step(step: MultiphaseStep) -> Option { let same_x = step.from.x1() == step.to.x1() && step.from.x2() == step.to.x2(); let same_y = step.from.y1() == step.to.y1() && step.from.y2() == step.to.y2(); @@ -526,7 +661,7 @@ mod tests { ); assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50)); assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -546,7 +681,7 @@ mod tests { let plan = plan_no_overlap(&req).unwrap(); assert_eq!(plan.phases[0].steps[0].to, rect(100, 0, 200, 50)); assert_eq!(plan.phases[0].steps[1].to, rect(0, 50, 100, 100)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -591,7 +726,7 @@ mod tests { assert_eq!(plan.phases[1].steps[0].to, rect(100, 0, 300, 50)); assert_eq!(plan.phases[2].steps[0].to, rect(100, 0, 300, 100)); assert_eq!(plan.phases[2].steps[1].to, rect(300, 0, 400, 100)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -631,7 +766,7 @@ mod tests { }, ] ); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -676,7 +811,7 @@ mod tests { assert_eq!(plan.phases[1].steps[0].to, rect(0, 100, 50, 300)); assert_eq!(plan.phases[2].steps[0].to, rect(0, 100, 100, 300)); assert_eq!(plan.phases[2].steps[1].to, rect(0, 300, 100, 400)); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -716,7 +851,7 @@ mod tests { }, ] ); - assert!(validate_plan_samples(&req, &plan)); + assert!(validate_plan_continuous(&req, &plan)); } #[test] @@ -729,6 +864,92 @@ mod tests { assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan)); } + #[test] + fn continuous_validation_rejects_narrow_mid_phase_overlap() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(100, 0, 110, 10), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(13, 0, 14, 10), + to: rect(13, 0, 14, 10), + }, + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(100, 0, 110, 10), + }], + }], + }; + + assert!(!validate_plan_continuous(&req, &plan)); + } + + #[test] + fn continuous_validation_allows_edge_touching_motion() { + let req = request(vec![ + MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(10, 0, 20, 10), + }, + MultiphaseWindow { + node_id: id(2), + from: rect(20, 0, 30, 10), + to: rect(20, 0, 30, 10), + }, + ]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(10, 0, 20, 10), + }], + }], + }; + + assert!(validate_plan_continuous(&req, &plan)); + } + + #[test] + fn continuous_validation_rejects_stale_step_start_rect() { + let req = request(vec![MultiphaseWindow { + node_id: id(1), + from: rect(0, 0, 10, 10), + to: rect(20, 0, 30, 10), + }]); + let plan = MultiphasePlan { + phases: vec![MultiphasePhase { + action: PhaseAction { + kind: PhaseKind::Move, + axis: PhaseAxis::Horizontal, + }, + steps: vec![MultiphaseStep { + node_id: id(1), + from: rect(5, 0, 15, 10), + to: rect(20, 0, 30, 10), + }], + }], + }; + + assert!(!validate_plan_continuous(&req, &plan)); + } + #[test] fn motion_groups_split_disjoint_layout_changes() { let windows = vec![