1
0
Fork 0
forked from wry/wry

Add multiphase no-overlap planner groundwork

This commit is contained in:
atagen 2026-05-21 18:23:33 +10:00
parent 2115518edf
commit 41d2fef177
3 changed files with 629 additions and 0 deletions

608
src/animation/multiphase.rs Normal file
View file

@ -0,0 +1,608 @@
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 {
pub bounds: Rect,
pub windows: Vec<MultiphaseWindow>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct MultiphaseWindow {
pub node_id: NodeId,
pub from: Rect,
pub to: Rect,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlan {
pub phases: Vec<MultiphasePhase>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePhase {
pub action: PhaseAction,
pub steps: Vec<MultiphaseStep>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct MultiphaseStep {
pub node_id: NodeId,
pub from: Rect,
pub to: Rect,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct PhaseAction {
pub kind: PhaseKind,
pub axis: PhaseAxis,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PhaseKind {
Move,
Scale,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PhaseAxis {
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphaseError {
EmptyBounds,
EmptyWindow,
DuplicateWindow,
InitialOverlap,
FinalOverlap,
NoPlan,
}
pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphaseError> {
validate_request(request)?;
if request
.windows
.iter()
.all(|window| window.from == window.to)
{
return Ok(MultiphasePlan { phases: vec![] });
}
if let Some(plan) = plan_forward(request) {
return Ok(plan);
}
let reversed = reverse_request(request);
if let Some(plan) = plan_forward(&reversed) {
return Ok(reverse_plan(plan));
}
Err(MultiphaseError::NoPlan)
}
fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError> {
if request.bounds.is_empty() {
return Err(MultiphaseError::EmptyBounds);
}
for (idx, window) in request.windows.iter().enumerate() {
if window.from.is_empty() || window.to.is_empty() {
return Err(MultiphaseError::EmptyWindow);
}
for other in &request.windows[..idx] {
if other.node_id == window.node_id {
return Err(MultiphaseError::DuplicateWindow);
}
}
}
if overlaps(request.windows.iter().map(|window| window.from)) {
return Err(MultiphaseError::InitialOverlap);
}
if overlaps(request.windows.iter().map(|window| window.to)) {
return Err(MultiphaseError::FinalOverlap);
}
Ok(())
}
fn plan_forward(request: &MultiphaseRequest) -> Option<MultiphasePlan> {
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
if let Some(plan) = plan_axis_crossing_lanes(request, axis) {
return Some(plan);
}
}
plan_horizontal_space_then_vertical_growth(request)
}
fn plan_axis_crossing_lanes(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Option<MultiphasePlan> {
if request.windows.len() != 2 {
return None;
}
let orth_min = request
.windows
.iter()
.map(|window| orth_start(window.from, axis))
.min()?;
let orth_max = request
.windows
.iter()
.map(|window| orth_end(window.from, axis))
.max()?;
if request.windows.iter().any(|window| {
main_size(window.from, axis) != main_size(window.to, axis)
|| orth_start(window.from, axis) != orth_min
|| orth_end(window.from, axis) != orth_max
|| orth_start(window.to, axis) != orth_min
|| orth_end(window.to, axis) != orth_max
|| main_start(window.from, axis) == main_start(window.to, axis)
}) {
return None;
}
let lane_size = (orth_max - orth_min) / request.windows.len() as i32;
if lane_size < sane_min_size(orth_max - orth_min) {
return None;
}
let mut windows = request.windows.clone();
windows.sort_by_key(|window| window.node_id.0);
let mut phase1 = vec![];
let mut phase2 = vec![];
let mut phase3 = vec![];
for (idx, window) in windows.iter().enumerate() {
let lane_start = orth_min + lane_size * idx as i32;
let lane_end = if idx + 1 == windows.len() {
orth_max
} else {
lane_start + lane_size
};
let lane_from = with_orth_interval(window.from, axis, lane_start, lane_end);
let lane_to = with_main_interval(
lane_from,
axis,
main_start(window.to, axis),
main_end(window.to, axis),
);
push_step(&mut phase1, window.node_id, window.from, lane_from);
push_step(&mut phase2, window.node_id, lane_from, lane_to);
push_step(&mut phase3, window.node_id, lane_to, window.to);
}
build_validated_plan(
request,
[
(PhaseKind::Scale, axis.other(), phase1),
(PhaseKind::Move, axis, phase2),
(PhaseKind::Scale, axis.other(), phase3),
],
)
}
fn plan_horizontal_space_then_vertical_growth(
request: &MultiphaseRequest,
) -> Option<MultiphasePlan> {
if request.windows.len() < 2 {
return None;
}
let min_width = sane_min_size(request.bounds.width());
let min_height = sane_min_size(request.bounds.height());
let mut phase1 = vec![];
let mut phase2 = vec![];
let mut phase3 = vec![];
for window in &request.windows {
if window.to.width() < min_width || window.to.height() < min_height {
return None;
}
let x_changes = window.from.x1() != window.to.x1() || window.from.x2() != window.to.x2();
let y_changes = window.from.y1() != window.to.y1() || window.from.y2() != window.to.y2();
if x_changes && window.from.width() == window.to.width() {
let after_move = Rect::new_sized_saturating(
window.to.x1(),
window.from.y1(),
window.to.width(),
window.from.height(),
);
push_step(&mut phase2, window.node_id, window.from, after_move);
if y_changes {
push_step(&mut phase3, window.node_id, after_move, window.to);
}
} else if x_changes {
let after_x_scale = Rect::new_sized_saturating(
window.to.x1(),
window.from.y1(),
window.to.width(),
window.from.height(),
);
push_step(&mut phase1, window.node_id, window.from, after_x_scale);
if y_changes {
push_step(&mut phase3, window.node_id, after_x_scale, window.to);
}
} else if y_changes {
push_step(&mut phase3, window.node_id, window.from, window.to);
}
}
if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() {
return None;
}
build_validated_plan(
request,
[
(PhaseKind::Scale, PhaseAxis::Horizontal, phase1),
(PhaseKind::Move, PhaseAxis::Horizontal, phase2),
(PhaseKind::Scale, PhaseAxis::Vertical, phase3),
],
)
}
fn build_validated_plan<const N: usize>(
request: &MultiphaseRequest,
phases: [(PhaseKind, PhaseAxis, Vec<MultiphaseStep>); N],
) -> Option<MultiphasePlan> {
let phases: Vec<_> = phases
.into_iter()
.filter_map(|(kind, axis, steps)| {
(!steps.is_empty()).then_some(MultiphasePhase {
action: PhaseAction { kind, axis },
steps,
})
})
.collect();
if phases.iter().any(|phase| {
phase
.steps
.iter()
.any(|step| classify_step(*step) != Some(phase.action))
}) {
return None;
}
let plan = MultiphasePlan { phases };
validate_plan_samples(request, &plan).then_some(plan)
}
fn validate_plan_samples(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool {
let mut current: Vec<_> = request
.windows
.iter()
.map(|window| (window.node_id, window.from))
.collect();
if overlaps(current.iter().map(|(_, rect)| *rect)) {
return false;
}
for phase in &plan.phases {
for t in PHASE_VALIDATION_SAMPLES {
let rects = current.iter().map(|(node_id, rect)| {
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) {
return false;
}
}
for step in &phase.steps {
let Some((_, rect)) = current
.iter_mut()
.find(|(node_id, _)| *node_id == step.node_id)
else {
return false;
};
*rect = step.to;
}
if overlaps(current.iter().map(|(_, rect)| *rect)) {
return false;
}
}
request.windows.iter().all(|window| {
current
.iter()
.find(|(node_id, _)| *node_id == window.node_id)
.is_some_and(|(_, rect)| *rect == window.to)
})
}
fn classify_step(step: MultiphaseStep) -> Option<PhaseAction> {
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();
let same_size = step.from.size() == step.to.size();
match (same_x, same_y, same_size) {
(false, true, true) => Some(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal,
}),
(true, false, true) => Some(PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Vertical,
}),
(false, true, false) => Some(PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal,
}),
(true, false, false) => Some(PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical,
}),
_ => None,
}
}
fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest {
MultiphaseRequest {
bounds: request.bounds,
windows: request
.windows
.iter()
.map(|window| MultiphaseWindow {
node_id: window.node_id,
from: window.to,
to: window.from,
})
.collect(),
}
}
fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan {
MultiphasePlan {
phases: plan
.phases
.into_iter()
.rev()
.map(|phase| MultiphasePhase {
action: phase.action,
steps: phase
.steps
.into_iter()
.map(|step| MultiphaseStep {
node_id: step.node_id,
from: step.to,
to: step.from,
})
.collect(),
})
.collect(),
}
}
fn overlaps(rects: impl IntoIterator<Item = Rect>) -> bool {
let rects: Vec<_> = rects.into_iter().collect();
for (idx, rect) in rects.iter().enumerate() {
if rects[idx + 1..].iter().any(|other| rect.intersects(other)) {
return true;
}
}
false
}
fn push_step(steps: &mut Vec<MultiphaseStep>, node_id: NodeId, from: Rect, to: Rect) {
if from != to {
steps.push(MultiphaseStep { node_id, from, to });
}
}
fn sane_min_size(size: i32) -> i32 {
(size / MIN_SHRINK_DENOMINATOR).max(1)
}
fn main_start(rect: Rect, axis: PhaseAxis) -> i32 {
match axis {
PhaseAxis::Horizontal => rect.x1(),
PhaseAxis::Vertical => rect.y1(),
}
}
fn main_end(rect: Rect, axis: PhaseAxis) -> i32 {
match axis {
PhaseAxis::Horizontal => rect.x2(),
PhaseAxis::Vertical => rect.y2(),
}
}
fn main_size(rect: Rect, axis: PhaseAxis) -> i32 {
main_end(rect, axis) - main_start(rect, axis)
}
fn orth_start(rect: Rect, axis: PhaseAxis) -> i32 {
main_start(rect, axis.other())
}
fn orth_end(rect: Rect, axis: PhaseAxis) -> i32 {
main_end(rect, axis.other())
}
fn with_main_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect {
match axis {
PhaseAxis::Horizontal => Rect::new_saturating(start, rect.y1(), end, rect.y2()),
PhaseAxis::Vertical => Rect::new_saturating(rect.x1(), start, rect.x2(), end),
}
}
fn with_orth_interval(rect: Rect, axis: PhaseAxis, start: i32, end: i32) -> Rect {
with_main_interval(rect, axis.other(), start, end)
}
impl PhaseAxis {
fn other(self) -> Self {
match self {
Self::Horizontal => Self::Vertical,
Self::Vertical => Self::Horizontal,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(raw: u32) -> NodeId {
NodeId(raw)
}
fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect {
Rect::new_saturating(x1, y1, x2, y2)
}
fn request(windows: Vec<MultiphaseWindow>) -> MultiphaseRequest {
MultiphaseRequest {
bounds: rect(0, 0, 400, 100),
windows,
}
}
fn actions(plan: &MultiphasePlan) -> Vec<PhaseAction> {
plan.phases.iter().map(|phase| phase.action).collect()
}
#[test]
fn horizontal_swap_shrinks_moves_then_grows_without_overlap() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(100, 0, 200, 100),
},
MultiphaseWindow {
node_id: id(2),
from: rect(100, 0, 200, 100),
to: rect(0, 0, 100, 100),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(
actions(&plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
]
);
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));
}
#[test]
fn horizontal_swap_reverse_uses_equivalent_lanes() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(100, 0, 200, 100),
to: rect(0, 0, 100, 100),
},
MultiphaseWindow {
node_id: id(2),
from: rect(0, 0, 100, 100),
to: rect(100, 0, 200, 100),
},
]);
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));
}
#[test]
fn stack_extraction_creates_space_before_moving_child() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 200, 100),
to: rect(0, 0, 100, 100),
},
MultiphaseWindow {
node_id: id(2),
from: rect(200, 0, 400, 50),
to: rect(100, 0, 300, 100),
},
MultiphaseWindow {
node_id: id(3),
from: rect(200, 50, 400, 100),
to: rect(300, 0, 400, 100),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(
actions(&plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
]
);
assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 100));
assert_eq!(plan.phases[0].steps[1].to, rect(300, 50, 400, 100));
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));
}
#[test]
fn stack_extraction_reverse_replays_phases_in_reverse() {
let req = request(vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(0, 0, 200, 100),
},
MultiphaseWindow {
node_id: id(2),
from: rect(100, 0, 300, 100),
to: rect(200, 0, 400, 50),
},
MultiphaseWindow {
node_id: id(3),
from: rect(300, 0, 400, 100),
to: rect(200, 50, 400, 100),
},
]);
let plan = plan_no_overlap(&req).unwrap();
assert_eq!(
actions(&plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Vertical
},
PhaseAction {
kind: PhaseKind::Move,
axis: PhaseAxis::Horizontal
},
PhaseAction {
kind: PhaseKind::Scale,
axis: PhaseAxis::Horizontal
},
]
);
assert!(validate_plan_samples(&req, &plan));
}
#[test]
fn unsupported_diagonal_motion_falls_back_to_linear() {
let req = request(vec![MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 100, 100),
to: rect(100, 100, 200, 200),
}]);
assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan));
}
}