596 lines
20 KiB
Rust
596 lines
20 KiB
Rust
//! Bounded outgoing clearance proposals. The ordinary placement and semantic
|
|
//! validators still decide whether any proposal may be previewed or committed.
|
|
use iced::Point;
|
|
use oa_core::{Id, NodePlacement, Position, Project, projection, view_options::ViewIndex};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
const GRID: f32 = 10.;
|
|
const GAP: f32 = 10.;
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Bounds {
|
|
left: f32,
|
|
top: f32,
|
|
right: f32,
|
|
bottom: f32,
|
|
}
|
|
|
|
impl Bounds {
|
|
fn node(node: &projection::ProjectedNode) -> Option<Self> {
|
|
let result = Self {
|
|
left: node.position.x,
|
|
top: node.position.y,
|
|
right: node.position.x + node.size.width,
|
|
bottom: node.position.y + node.size.height,
|
|
};
|
|
([result.left, result.top, result.right, result.bottom]
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
&& result.right > result.left
|
|
&& result.bottom > result.top)
|
|
.then_some(result)
|
|
}
|
|
|
|
fn union(self, other: Self) -> Self {
|
|
Self {
|
|
left: self.left.min(other.left),
|
|
top: self.top.min(other.top),
|
|
right: self.right.max(other.right),
|
|
bottom: self.bottom.max(other.bottom),
|
|
}
|
|
}
|
|
|
|
fn overlaps(self, other: Self) -> bool {
|
|
self.left < other.right
|
|
&& other.left < self.right
|
|
&& self.top < other.bottom
|
|
&& other.top < self.bottom
|
|
}
|
|
}
|
|
|
|
/// Called only after an OutsidePerformers candidate fails ordinary placement.
|
|
/// Coordinates use the frozen world transform. The entire affected footprint
|
|
/// receives one anchor translation; this never moves or packs other cards.
|
|
pub(super) fn requests(
|
|
accepted: (&Project, &ViewIndex),
|
|
candidate: (&Project, &ViewIndex),
|
|
diagram: Id,
|
|
affected: &BTreeMap<Id, NodePlacement>,
|
|
anchor: Id,
|
|
requested: Position,
|
|
motion: (Point, Point),
|
|
) -> Vec<Position> {
|
|
let (accepted, accepted_index) = accepted;
|
|
let (candidate, candidate_index) = candidate;
|
|
let (start, pointer) = motion;
|
|
if ![
|
|
requested.x,
|
|
requested.y,
|
|
start.x,
|
|
start.y,
|
|
pointer.x,
|
|
pointer.y,
|
|
]
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return Vec::new();
|
|
}
|
|
let (Some(original), Some(base), Some(anchor)) = (
|
|
accepted.diagrams.get(&diagram),
|
|
candidate.diagrams.get(&diagram),
|
|
affected.get(&anchor),
|
|
) else {
|
|
return Vec::new();
|
|
};
|
|
let snapped = Position {
|
|
x: (requested.x / GRID).round() * GRID,
|
|
y: (requested.y / GRID).round() * GRID,
|
|
};
|
|
let delta = Position {
|
|
x: snapped.x - anchor.position.x,
|
|
y: snapped.y - anchor.position.y,
|
|
};
|
|
let before = projection::project_diagram_with_index(accepted, original, accepted_index);
|
|
let before_nodes: BTreeMap<_, _> = before.nodes.iter().map(|node| (node.id, node)).collect();
|
|
let mut sources = BTreeSet::new();
|
|
for node in before
|
|
.nodes
|
|
.iter()
|
|
.filter(|node| affected.contains_key(&node.id))
|
|
{
|
|
let mut parent = node.visual_parent;
|
|
let mut seen = BTreeSet::new();
|
|
while let Some(id) = parent {
|
|
if !seen.insert(id) {
|
|
return Vec::new();
|
|
}
|
|
let Some(node) = before_nodes.get(&id) else {
|
|
break;
|
|
};
|
|
if !affected.contains_key(&id)
|
|
&& accepted
|
|
.elements
|
|
.get(&node.element)
|
|
.is_some_and(|element| element.kind().is_performer())
|
|
{
|
|
sources.insert(id);
|
|
}
|
|
parent = node.visual_parent;
|
|
}
|
|
}
|
|
if sources.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
// Projection after responsibility removal supplies the former performer's
|
|
// remaining enclosure, including other allocated work. Projecting the moved
|
|
// block also includes the full visible descendant enclosure of every root.
|
|
let mut moved = base.clone();
|
|
for (id, captured) in affected {
|
|
let Some(node) = moved.nodes.get_mut(id) else {
|
|
return Vec::new();
|
|
};
|
|
node.position = Position {
|
|
x: captured.position.x + delta.x,
|
|
y: captured.position.y + delta.y,
|
|
};
|
|
if ![
|
|
node.position.x,
|
|
node.position.y,
|
|
node.size.width,
|
|
node.size.height,
|
|
]
|
|
.iter()
|
|
.all(|value| value.is_finite())
|
|
{
|
|
return Vec::new();
|
|
}
|
|
}
|
|
let after = projection::project_diagram_with_index(candidate, &moved, candidate_index);
|
|
let mut footprint: Option<Bounds> = None;
|
|
for node in after
|
|
.nodes
|
|
.iter()
|
|
.filter(|node| affected.contains_key(&node.id))
|
|
{
|
|
let Some(bounds) = Bounds::node(node) else {
|
|
return Vec::new();
|
|
};
|
|
footprint = Some(footprint.map_or(bounds, |previous| previous.union(bounds)));
|
|
}
|
|
let Some(footprint) = footprint else {
|
|
return Vec::new();
|
|
};
|
|
let mut overlaps = Vec::new();
|
|
for node in after.nodes.iter().filter(|node| sources.contains(&node.id)) {
|
|
let (Some(old), Some(current)) = (Bounds::node(before_nodes[&node.id]), Bounds::node(node))
|
|
else {
|
|
return Vec::new();
|
|
};
|
|
if footprint.overlaps(current) {
|
|
overlaps.push((old, current));
|
|
}
|
|
}
|
|
if overlaps.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
// Each entry is a half-plane actually crossed by the pointer. At a corner,
|
|
// prefer the boundary reached first on the press-to-pointer segment. The
|
|
// direction index is a stable tie-break, independent of placement UUIDs.
|
|
let mut crossings = [None::<f32>; 4];
|
|
for (old, _) in &overlaps {
|
|
for (direction, initial, final_, boundary, outward) in [
|
|
(
|
|
0,
|
|
start.x,
|
|
pointer.x,
|
|
old.left,
|
|
pointer.x < old.left && start.x >= old.left,
|
|
),
|
|
(
|
|
1,
|
|
start.x,
|
|
pointer.x,
|
|
old.right,
|
|
pointer.x >= old.right && start.x < old.right,
|
|
),
|
|
(
|
|
2,
|
|
start.y,
|
|
pointer.y,
|
|
old.top,
|
|
pointer.y < old.top && start.y >= old.top,
|
|
),
|
|
(
|
|
3,
|
|
start.y,
|
|
pointer.y,
|
|
old.bottom,
|
|
pointer.y >= old.bottom && start.y < old.bottom,
|
|
),
|
|
] {
|
|
if outward {
|
|
let fraction = (boundary - initial) / (final_ - initial);
|
|
if fraction.is_finite() && (0. ..=1.).contains(&fraction) {
|
|
crossings[direction] =
|
|
Some(crossings[direction].map_or(fraction, |old| old.min(fraction)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut directions: Vec<_> = crossings
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(direction, fraction)| fraction.map(|fraction| (direction, fraction)))
|
|
.collect();
|
|
directions.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
|
let mut result = Vec::new();
|
|
for (direction, _) in directions {
|
|
let mut corrected = snapped;
|
|
match direction {
|
|
0 => {
|
|
let edge = overlaps
|
|
.iter()
|
|
.map(|(_, current)| current.left)
|
|
.fold(f32::INFINITY, f32::min);
|
|
corrected.x = ((snapped.x + edge - GAP - footprint.right) / GRID).floor() * GRID;
|
|
}
|
|
1 => {
|
|
let edge = overlaps
|
|
.iter()
|
|
.map(|(_, current)| current.right)
|
|
.fold(f32::NEG_INFINITY, f32::max);
|
|
corrected.x = ((snapped.x + edge + GAP - footprint.left) / GRID).ceil() * GRID;
|
|
}
|
|
2 => {
|
|
let edge = overlaps
|
|
.iter()
|
|
.map(|(_, current)| current.top)
|
|
.fold(f32::INFINITY, f32::min);
|
|
corrected.y = ((snapped.y + edge - GAP - footprint.bottom) / GRID).floor() * GRID;
|
|
}
|
|
3 => {
|
|
let edge = overlaps
|
|
.iter()
|
|
.map(|(_, current)| current.bottom)
|
|
.fold(f32::NEG_INFINITY, f32::max);
|
|
corrected.y = ((snapped.y + edge + GAP - footprint.top) / GRID).ceil() * GRID;
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
if corrected.x.is_finite() && corrected.y.is_finite() && !result.contains(&corrected) {
|
|
result.push(corrected);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::allocation_drag as plan;
|
|
use oa_core::{Kind, Layer, ProjectionKind, RelationKind, Size};
|
|
|
|
struct Fixture {
|
|
project: Project,
|
|
diagram: Id,
|
|
performer: Id,
|
|
work: Id,
|
|
node: Id,
|
|
}
|
|
|
|
fn xy(x: f32, y: f32) -> Position {
|
|
Position { x, y }
|
|
}
|
|
|
|
fn fixture(layer: Layer) -> Fixture {
|
|
let mut project = Project::empty();
|
|
let (performer, work) = match layer {
|
|
Layer::Oa => (Kind::Entity, Kind::Activity),
|
|
Layer::Sa => (Kind::System, Kind::Function),
|
|
};
|
|
let performer = project
|
|
.add_element(performer, layer, "Performer", None)
|
|
.unwrap();
|
|
let work = project.add_element(work, layer, "Work", None).unwrap();
|
|
let diagram = project.ensure_diagram(layer);
|
|
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
|
let performer = project.place(diagram, performer, xy(100., 100.));
|
|
project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&performer)
|
|
.unwrap()
|
|
.size = Size {
|
|
width: 400.,
|
|
height: 250.,
|
|
};
|
|
let node = project.place(diagram, work, xy(160., 180.));
|
|
let owner = project.diagrams[&diagram].nodes[&performer].element;
|
|
project.relate(RelationKind::Allocation, "Responsibility", owner, work);
|
|
Fixture {
|
|
project,
|
|
diagram,
|
|
performer,
|
|
work,
|
|
node,
|
|
}
|
|
}
|
|
|
|
fn semantic(f: &Fixture) -> (plan::Scope, plan::SemanticPlan) {
|
|
let scope = plan::capture(&f.project, f.diagram, f.work, f.node).unwrap();
|
|
let semantic = plan::plan_semantics(
|
|
&f.project,
|
|
&scope,
|
|
plan::Destination::OutsidePerformers,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
(scope, semantic)
|
|
}
|
|
|
|
fn presentation() -> plan::PresentationContext {
|
|
plan::PresentationContext {
|
|
viewport: plan::Rect {
|
|
position: xy(-2000., -2000.),
|
|
size: Size {
|
|
width: 5000.,
|
|
height: 5000.,
|
|
},
|
|
},
|
|
zoom: 1.,
|
|
target: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_outgoing_edge_clears_the_source_and_passes_ordinary_placement() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let f = fixture(layer);
|
|
let (scope, semantic) = semantic(&f);
|
|
for (start, pointer, requested, expected) in [
|
|
(
|
|
Point::new(170., 210.),
|
|
Point::new(99., 210.),
|
|
xy(89., 180.),
|
|
xy(-140., 180.),
|
|
),
|
|
(
|
|
Point::new(340., 210.),
|
|
Point::new(501., 210.),
|
|
xy(321., 180.),
|
|
xy(510., 180.),
|
|
),
|
|
(
|
|
Point::new(200., 190.),
|
|
Point::new(200., 99.),
|
|
xy(160., 89.),
|
|
xy(160., -10.),
|
|
),
|
|
(
|
|
Point::new(200., 260.),
|
|
Point::new(200., 351.),
|
|
xy(160., 271.),
|
|
xy(160., 360.),
|
|
),
|
|
] {
|
|
assert!(matches!(
|
|
plan::place(&semantic, requested, presentation()),
|
|
Err(plan::Refusal::LayoutRequired(_))
|
|
));
|
|
let proposed = requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
requested,
|
|
(start, pointer),
|
|
);
|
|
assert_eq!(proposed, vec![expected]);
|
|
let placed = plan::place(&semantic, proposed[0], presentation()).unwrap();
|
|
assert!(
|
|
plan::finish(&semantic, &f.project, &placed)
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_shrink_uses_candidate_enclosure_not_frozen_target() {
|
|
let mut f = fixture(Layer::Oa);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&f.diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.performer)
|
|
.unwrap()
|
|
.size = Size {
|
|
width: 224.,
|
|
height: 94.,
|
|
};
|
|
let (scope, semantic) = semantic(&f);
|
|
let proposed = requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
xy(229., 180.),
|
|
(Point::new(340., 210.), Point::new(409., 210.)),
|
|
);
|
|
assert_eq!(proposed, vec![xy(340., 180.)]);
|
|
plan::place(&semantic, proposed[0], presentation()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn corner_order_follows_boundary_crossing_and_is_deterministic() {
|
|
let f = fixture(Layer::Sa);
|
|
let (scope, semantic) = semantic(&f);
|
|
// Right crossed at 160/161 of the segment; bottom at 90/91.
|
|
let args = (
|
|
xy(321., 271.),
|
|
Point::new(340., 260.),
|
|
Point::new(501., 351.),
|
|
);
|
|
let proposed = requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
args.0,
|
|
(args.1, args.2),
|
|
);
|
|
assert_eq!(proposed, vec![xy(320., 360.), xy(510., 270.)]);
|
|
assert_eq!(
|
|
proposed,
|
|
requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
args.0,
|
|
(args.1, args.2)
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_crossing_no_overlap_no_source_and_nonfinite_requests_are_empty() {
|
|
let f = fixture(Layer::Oa);
|
|
let (scope, semantic) = semantic(&f);
|
|
for (requested, pointer) in [
|
|
(xy(170., 180.), Point::new(210., 210.)),
|
|
(xy(700., 180.), Point::new(740., 210.)),
|
|
(xy(f32::NAN, 180.), Point::new(99., 210.)),
|
|
(xy(89., 180.), Point::new(f32::INFINITY, 210.)),
|
|
] {
|
|
assert!(
|
|
requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
requested,
|
|
(Point::new(200., 210.), pointer)
|
|
)
|
|
.is_empty()
|
|
);
|
|
}
|
|
assert!(
|
|
requests(
|
|
(semantic.project(), semantic.index()),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
xy(89., 180.),
|
|
(Point::new(200., 210.), Point::new(99., 210.))
|
|
)
|
|
.is_empty()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn descendant_footprint_controls_clearance_without_mutating_projects() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let kind = f.project.elements[&f.work].kind();
|
|
let child = f
|
|
.project
|
|
.add_element(kind, layer, "Child", Some(f.work))
|
|
.unwrap();
|
|
let child_node = f.project.place(f.diagram, child, xy(240., 270.));
|
|
// First-child allocation transfer is normally performed by commands;
|
|
// this fixture expresses the resulting leaf allocation directly.
|
|
f.project.relations.clear();
|
|
let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element;
|
|
f.project.relate(
|
|
RelationKind::Allocation,
|
|
"Leaf responsibility",
|
|
owner,
|
|
child,
|
|
);
|
|
let (scope, semantic) = semantic(&f);
|
|
assert!(scope.placements.contains_key(&child_node));
|
|
let before = f.project.clone();
|
|
let candidate = semantic.project().clone();
|
|
let proposed = requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(&candidate, semantic.index()),
|
|
f.diagram,
|
|
&scope.placements,
|
|
f.node,
|
|
xy(89., 180.),
|
|
(Point::new(170., 210.), Point::new(99., 210.)),
|
|
);
|
|
assert_eq!(proposed, vec![xy(-240., 180.)]);
|
|
let placed = plan::place(&semantic, proposed[0], presentation()).unwrap();
|
|
let root = &placed.diagram.nodes[&f.node];
|
|
let child = &placed.diagram.nodes[&child_node];
|
|
assert_eq!(child.position.x - root.position.x, 80.);
|
|
assert_eq!(child.position.y - root.position.y, 90.);
|
|
assert_eq!(f.project, before);
|
|
assert_eq!(semantic.project(), &candidate);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn independent_selected_roots_keep_one_rigid_group_translation() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let kind = f.project.elements[&f.work].kind();
|
|
let second = f
|
|
.project
|
|
.add_element(kind, layer, "Second work", None)
|
|
.unwrap();
|
|
let second_node = f.project.place(f.diagram, second, xy(240., 300.));
|
|
let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element;
|
|
f.project.relate(
|
|
RelationKind::Allocation,
|
|
"Second responsibility",
|
|
owner,
|
|
second,
|
|
);
|
|
let selected = BTreeMap::from([(f.node, f.work), (second_node, second)]);
|
|
let scope =
|
|
plan::assignment::capture(&f.project, f.diagram, &selected, f.node).unwrap();
|
|
let semantic = plan::assignment::plan_semantics(
|
|
&scope,
|
|
plan::Destination::OutsidePerformers,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
let proposed = requests(
|
|
(&f.project, &ViewIndex::new(&f.project)),
|
|
(semantic.project(), semantic.index()),
|
|
f.diagram,
|
|
scope.affected(),
|
|
f.node,
|
|
xy(89., 180.),
|
|
(Point::new(170., 210.), Point::new(99., 210.)),
|
|
);
|
|
assert_eq!(proposed, vec![xy(-220., 180.)]);
|
|
let placed = plan::assignment::place(&semantic, proposed[0], presentation()).unwrap();
|
|
let root = &placed.diagram.nodes[&f.node];
|
|
let second = &placed.diagram.nodes[&second_node];
|
|
assert_eq!(second.position.x - root.position.x, 80.);
|
|
assert_eq!(second.position.y - root.position.y, 120.);
|
|
assert!(
|
|
plan::assignment::finish(&semantic, &f.project, &placed)
|
|
.unwrap()
|
|
.is_some()
|
|
);
|
|
}
|
|
}
|
|
}
|