Integrate allocation drag candidate with guarded input and atomic history
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
//! Pure, bounded allocation-drag composition. See the v2 interaction contract.
|
||||
//!
|
||||
//! These values are not input capabilities. The App must independently admit a
|
||||
//! clean press and retire its token on every document/input/camera lifetime
|
||||
//! change. Cache a SemanticPlan per token and destination; only `place` belongs
|
||||
//! on pointer motion. `finish` rechecks the live accepted Project and commands,
|
||||
//! but Document::execute still owns source admission and atomic history.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedNode, ProjectedView},
|
||||
validation::{self, Issue, Severity},
|
||||
view_options::ViewIndex,
|
||||
*,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Destination {
|
||||
LayoutOnly,
|
||||
Performer(Id),
|
||||
OutsidePerformers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Uniform {
|
||||
Assigned(Id),
|
||||
Unassigned,
|
||||
MixedOrUnresolved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leaf {
|
||||
pub direct: Option<Relation>,
|
||||
pub role_assignments: Vec<Relation>,
|
||||
pub role_realizations: Vec<Relation>,
|
||||
pub effective_performer: Option<Id>,
|
||||
}
|
||||
|
||||
/// Exact semantic scope S, leaf scope L and active placement scope Q, including
|
||||
/// folded/unplaced leaves. Retaining records detects first-child responsibility
|
||||
/// transfer. Revision and input-epoch guards remain the caller's responsibility.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Scope {
|
||||
pub project: Id,
|
||||
pub work: Id,
|
||||
pub placement: Id,
|
||||
pub work_records: BTreeMap<Id, Element>,
|
||||
pub leaves: BTreeMap<Id, Leaf>,
|
||||
pub placements: BTreeMap<Id, NodePlacement>,
|
||||
pub diagram: Diagram,
|
||||
pub uniform: Uniform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChangedRecord {
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Refusal {
|
||||
Ineligible(String),
|
||||
StaleScope,
|
||||
MixedLayoutRequired,
|
||||
RoleExitRequired {
|
||||
work: Id,
|
||||
roles: Vec<Id>,
|
||||
performers: Vec<Id>,
|
||||
message: String,
|
||||
},
|
||||
MissingAllocationId(Id),
|
||||
InvalidCandidate(String),
|
||||
CollateralChangeRequired(Vec<ChangedRecord>),
|
||||
LayoutRequired(String),
|
||||
}
|
||||
|
||||
pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
|
||||
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
|
||||
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
|
||||
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
|
||||
if !root.kind().is_work()
|
||||
|| d.projection != ProjectionKind::Allocation
|
||||
|| d.layer != root.layer()
|
||||
{
|
||||
return Err(Refusal::Ineligible(
|
||||
"Select an activity or function in an Allocation view.".into(),
|
||||
));
|
||||
}
|
||||
if d.nodes.get(&placement).is_none_or(|n| n.element != work) {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut work_records = BTreeMap::new();
|
||||
let mut leaves = BTreeMap::new();
|
||||
let mut pending = vec![work];
|
||||
while let Some(id) = pending.pop() {
|
||||
let element = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
|
||||
if element.kind() != root.kind() || work_records.insert(id, element.clone()).is_some() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let children: Vec<_> = p
|
||||
.children(id)
|
||||
.filter(|e| e.kind() == root.kind())
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
if !children.is_empty() {
|
||||
pending.extend(children);
|
||||
continue;
|
||||
}
|
||||
let direct: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
if direct.len() > 1 {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let role_assignments: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let roles: BTreeSet<_> = role_assignments.iter().map(Relation::source).collect();
|
||||
let role_realizations = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target()))
|
||||
.cloned()
|
||||
.collect();
|
||||
leaves.insert(
|
||||
id,
|
||||
Leaf {
|
||||
direct: direct.into_iter().next(),
|
||||
role_assignments,
|
||||
role_realizations,
|
||||
effective_performer: validation::effective_performer(p, id),
|
||||
},
|
||||
);
|
||||
}
|
||||
if leaves.is_empty() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let uniform = if leaves
|
||||
.values()
|
||||
.all(|l| l.direct.is_none() && l.role_assignments.is_empty())
|
||||
{
|
||||
Uniform::Unassigned
|
||||
} else if let Some(performer) = leaves.values().next().and_then(|l| l.effective_performer)
|
||||
&& leaves
|
||||
.values()
|
||||
.all(|l| l.effective_performer == Some(performer))
|
||||
{
|
||||
Uniform::Assigned(performer)
|
||||
} else {
|
||||
Uniform::MixedOrUnresolved
|
||||
};
|
||||
let placements = d
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| work_records.contains_key(&n.element))
|
||||
.map(|(id, n)| (*id, n.clone()))
|
||||
.collect();
|
||||
Ok(Scope {
|
||||
project: p.id,
|
||||
work,
|
||||
placement,
|
||||
work_records,
|
||||
leaves,
|
||||
placements,
|
||||
diagram: d.clone(),
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SemanticPlan {
|
||||
pub scope: Scope,
|
||||
pub destination: Destination,
|
||||
pub label: String,
|
||||
pub warnings: Vec<Issue>,
|
||||
accepted: Project,
|
||||
candidate: Project,
|
||||
index: ViewIndex,
|
||||
commands: Vec<Command>,
|
||||
before_visible: ProjectedView,
|
||||
before_expanded: ProjectedView,
|
||||
}
|
||||
|
||||
impl SemanticPlan {
|
||||
/// All preview drawing, ports and routing must use this candidate and index.
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.candidate
|
||||
}
|
||||
pub fn index(&self) -> &ViewIndex {
|
||||
&self.index
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocation identities are allocated once by the gesture owner, keyed by leaf,
|
||||
/// and reused on target revisits and release. No random identity generation here.
|
||||
pub fn plan_semantics(
|
||||
p: &Project,
|
||||
scope: &Scope,
|
||||
destination: Destination,
|
||||
allocation_ids: &BTreeMap<Id, Id>,
|
||||
) -> Result<SemanticPlan, Refusal> {
|
||||
if &capture(p, scope.diagram.id, scope.work, scope.placement)? != scope {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
if scope.work_records.len() > 1 && scope.uniform == Uniform::MixedOrUnresolved {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
if let Destination::Performer(id) = destination {
|
||||
let valid = p
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|e| e.kind().is_performer() && e.layer() == scope.diagram.layer);
|
||||
if !valid {
|
||||
return Err(Refusal::Ineligible(
|
||||
"Choose a performer in this layer.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let before_index = ViewIndex::new(p);
|
||||
let before_visible = projection::project_diagram_with_index(p, &scope.diagram, &before_index);
|
||||
let before_expanded = expanded_view(p, &scope.diagram, scope, &before_index);
|
||||
if scope.work_records.len() > 1 && !coherent(scope, &before_expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
let same_performer =
|
||||
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
||||
let mut expected = p.clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deleted = BTreeSet::new();
|
||||
if destination != Destination::LayoutOnly && !same_performer {
|
||||
for (work, leaf) in &scope.leaves {
|
||||
match destination {
|
||||
Destination::Performer(performer) => {
|
||||
if let Some(existing) = &leaf.direct {
|
||||
if existing.source() != performer {
|
||||
let mut edited = existing.clone();
|
||||
edited.body = RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
};
|
||||
expected.relations.insert(edited.id, edited.clone());
|
||||
commands.push(Command::EditRelation(edited.id, edited));
|
||||
}
|
||||
} else {
|
||||
let id = *allocation_ids
|
||||
.get(work)
|
||||
.ok_or(Refusal::MissingAllocationId(*work))?;
|
||||
if id.is_nil() || expected.contains(id) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The gesture's allocation identity is already in use.".into(),
|
||||
));
|
||||
}
|
||||
let name = format!("{} performs {}", p.named(performer), p.named(*work));
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: expected.fresh_alias(&name, "allocation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
},
|
||||
};
|
||||
expected.relations.insert(id, relation.clone());
|
||||
commands.push(Command::CreateRelation(relation));
|
||||
}
|
||||
}
|
||||
Destination::OutsidePerformers => {
|
||||
if !leaf.role_assignments.is_empty() {
|
||||
let roles: Vec<_> = leaf
|
||||
.role_assignments
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let performers: Vec<_> = leaf
|
||||
.role_realizations
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let message = if leaf.direct.is_some() && performers.len() == 1 {
|
||||
format!(
|
||||
"Removing direct assignment would place {} in {} through its role.",
|
||||
p.named(*work),
|
||||
p.named(performers[0])
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} retains role responsibility through {}{}; edit that responsibility before moving outside performers.",
|
||||
p.named(*work),
|
||||
roles
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if performers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" ({})",
|
||||
performers
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
)
|
||||
};
|
||||
return Err(Refusal::RoleExitRequired {
|
||||
work: *work,
|
||||
roles,
|
||||
performers,
|
||||
message,
|
||||
});
|
||||
}
|
||||
if let Some(existing) = &leaf.direct {
|
||||
expected.relations.remove(&existing.id);
|
||||
deleted.insert(existing.id);
|
||||
commands.push(Command::Delete(existing.id));
|
||||
}
|
||||
}
|
||||
Destination::LayoutOnly => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A route cannot survive the intentional deletion of its own edge, even in
|
||||
// another diagram. No other saved-view cleanup is authorized by this drag.
|
||||
for diagram in expected.diagrams.values_mut() {
|
||||
diagram.routes.retain(|r| !deleted.contains(&r.edge));
|
||||
}
|
||||
let actual = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
let retained_roles = scope
|
||||
.leaves
|
||||
.values()
|
||||
.any(|l| !l.role_assignments.is_empty());
|
||||
let label = match destination {
|
||||
Destination::LayoutOnly => "Arrange only".into(),
|
||||
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
|
||||
Destination::Performer(id) => format!(
|
||||
"Assign {} leaf {} to {}{}",
|
||||
scope.leaves.len(),
|
||||
match (scope.diagram.layer, scope.leaves.len()) {
|
||||
(Layer::Oa, 1) => "activity",
|
||||
(Layer::Oa, _) => "activities",
|
||||
(Layer::Sa, 1) => "function",
|
||||
(Layer::Sa, _) => "functions",
|
||||
},
|
||||
p.named(id),
|
||||
if retained_roles {
|
||||
" — explicit assignment; role responsibility retained"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
Destination::OutsidePerformers if commands.is_empty() => {
|
||||
"Arrange unassigned work — decomposition retained".into()
|
||||
}
|
||||
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
|
||||
};
|
||||
let warnings = validation::validate(&actual)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Warning)
|
||||
.collect();
|
||||
let index = ViewIndex::new(&actual);
|
||||
Ok(SemanticPlan {
|
||||
scope: scope.clone(),
|
||||
destination,
|
||||
label,
|
||||
warnings,
|
||||
accepted: p.clone(),
|
||||
candidate: actual,
|
||||
index,
|
||||
commands,
|
||||
before_visible,
|
||||
before_expanded,
|
||||
})
|
||||
}
|
||||
|
||||
/// World-space rectangle in the gesture's frozen transform. The UI resolver
|
||||
/// owns half-open pointer hits, semantic target precedence and occlusion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
fn valid(self) -> bool {
|
||||
[
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
self.size.width,
|
||||
self.size.height,
|
||||
]
|
||||
.iter()
|
||||
.all(|n| n.is_finite() && n.abs() <= MAX_COORDINATE)
|
||||
&& self.size.width > 0.
|
||||
&& self.size.height > 0.
|
||||
}
|
||||
fn contains(self, other: Self) -> bool {
|
||||
other.position.x >= self.position.x
|
||||
&& other.position.y >= self.position.y
|
||||
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
||||
&& other.position.y + other.size.height <= self.position.y + self.size.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TargetPresentation {
|
||||
pub placement: Id,
|
||||
/// Exact frozen painted label bounds, supplied by the renderer/resolver.
|
||||
pub label: Rect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PresentationContext {
|
||||
/// Visible world rectangle derived from the actual captured pan/zoom/bounds.
|
||||
pub viewport: Rect,
|
||||
pub zoom: f32,
|
||||
pub target: Option<TargetPresentation>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlacementPlan {
|
||||
pub diagram: Diagram,
|
||||
pub view: ProjectedView,
|
||||
pub delta: Position,
|
||||
pub unfolded: Option<Id>,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
/// `requested` is the original selected placement position plus pointer motion
|
||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||
pub fn place(
|
||||
plan: &SemanticPlan,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
) -> Result<PlacementPlan, Refusal> {
|
||||
if !requested.x.is_finite()
|
||||
|| !requested.y.is_finite()
|
||||
|| !presentation.viewport.valid()
|
||||
|| !presentation.zoom.is_finite()
|
||||
|| presentation.zoom <= 0.
|
||||
{
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
let root = &plan.scope.placements[&plan.scope.placement];
|
||||
let delta = Position {
|
||||
x: (requested.x / 10.).round() * 10. - root.position.x,
|
||||
y: (requested.y / 10.).round() * 10. - root.position.y,
|
||||
};
|
||||
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deltas = BTreeMap::new();
|
||||
for (id, original) in &plan.scope.placements {
|
||||
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
||||
node.position = Position {
|
||||
x: original.position.x + delta.x,
|
||||
y: original.position.y + delta.y,
|
||||
};
|
||||
if !(Rect {
|
||||
position: node.position,
|
||||
size: node.size,
|
||||
})
|
||||
.valid()
|
||||
{
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved coordinates exceed the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
deltas.insert(*id, delta);
|
||||
if node != original {
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: *id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Resolve endpoints using the captured accepted semantics, not a newly
|
||||
// regrouped descendant walk. Own-edge deletions were removed above already.
|
||||
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
|
||||
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
if route.waypoints.iter().any(|p| {
|
||||
!p.x.is_finite()
|
||||
|| !p.y.is_finite()
|
||||
|| p.x.abs() > MAX_COORDINATE
|
||||
|| p.y.abs() > MAX_COORDINATE
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved route exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
*existing = route.clone();
|
||||
commands.push(Command::SetEdgeRoute {
|
||||
diagram: diagram.id,
|
||||
route,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut unfolded = None;
|
||||
if let Destination::Performer(performer) = plan.destination {
|
||||
let target = presentation.target.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("The frozen performer label must be visible.".into())
|
||||
})?;
|
||||
let node = diagram
|
||||
.nodes
|
||||
.get_mut(&target.placement)
|
||||
.ok_or(Refusal::StaleScope)?;
|
||||
let frozen_header = plan
|
||||
.before_visible
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == target.placement)
|
||||
.map(|n| Rect {
|
||||
position: n.position,
|
||||
size: Size {
|
||||
width: n.size.width,
|
||||
height: n.size.height.min(62.),
|
||||
},
|
||||
});
|
||||
if node.element != performer
|
||||
|| frozen_header.is_none_or(|header| !header.contains(target.label))
|
||||
|| !target.label.valid()
|
||||
|| !presentation.viewport.contains(target.label)
|
||||
{
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
if node.collapsed && !plan.commands.is_empty() {
|
||||
node.collapsed = false;
|
||||
unfolded = Some(node.id);
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: node.id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
} else if presentation.target.is_some() {
|
||||
return Err(Refusal::Ineligible(
|
||||
"This layout/outside destination has no performer target.".into(),
|
||||
));
|
||||
}
|
||||
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
|
||||
let expanded = expanded_view(&plan.candidate, &diagram, &plan.scope, &plan.index);
|
||||
check_geometry(plan, &view, &expanded, delta)?;
|
||||
let selected = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == plan.scope.placement)
|
||||
.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("The selected work header would be hidden.".into())
|
||||
})?;
|
||||
let header = Rect {
|
||||
position: selected.position,
|
||||
size: Size {
|
||||
width: selected.size.width,
|
||||
height: selected.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !presentation.viewport.contains(header) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
delta,
|
||||
unfolded,
|
||||
requested,
|
||||
presentation,
|
||||
commands,
|
||||
})
|
||||
}
|
||||
|
||||
/// Final semantic/presentation equality gate, after the App has checked its
|
||||
/// current token, owner, revision, clean drafts, frozen target and transform.
|
||||
/// Recompute geometry to avoid accepting a modified/stale placement result.
|
||||
/// None means a snapped no-op: the caller must not execute or add history.
|
||||
pub fn finish(
|
||||
plan: &SemanticPlan,
|
||||
live: &Project,
|
||||
placement: &PlacementPlan,
|
||||
) -> Result<Option<Command>, Refusal> {
|
||||
if live != &plan.accepted {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let checked = place(plan, placement.requested, placement.presentation)?;
|
||||
if checked.diagram != placement.diagram
|
||||
|| checked.view != placement.view
|
||||
|| checked.delta != placement.delta
|
||||
|| checked.unfolded != placement.unfolded
|
||||
|| checked.commands != placement.commands
|
||||
{
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut expected = plan.candidate.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(checked.diagram.id, checked.diagram);
|
||||
let mut commands = plan.commands.clone();
|
||||
commands.extend(checked.commands);
|
||||
let command = Command::Batch(commands);
|
||||
let actual = prepare_command(live, &command)
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
Ok((actual != *live).then_some(command))
|
||||
}
|
||||
|
||||
fn expanded_view(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
scope: &Scope,
|
||||
index: &ViewIndex,
|
||||
) -> ProjectedView {
|
||||
let mut expanded = diagram.clone();
|
||||
for id in scope.placements.keys() {
|
||||
if let Some(node) = expanded.nodes.get_mut(id) {
|
||||
node.collapsed = false;
|
||||
}
|
||||
}
|
||||
projection::project_diagram_with_index(p, &expanded, index)
|
||||
}
|
||||
|
||||
fn ancestors(view: &ProjectedView) -> BTreeMap<Id, BTreeSet<Id>> {
|
||||
let parents: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
|
||||
parents
|
||||
.keys()
|
||||
.map(|node| {
|
||||
let mut current = parents[node];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
(*node, seen)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn coherent(scope: &Scope, view: &ProjectedView) -> bool {
|
||||
let ancestors = ancestors(view);
|
||||
scope.placements.keys().all(|id| {
|
||||
ancestors
|
||||
.get(id)
|
||||
.is_some_and(|parents| *id == scope.placement || parents.contains(&scope.placement))
|
||||
})
|
||||
}
|
||||
|
||||
fn overlap(a: &ProjectedNode, b: &ProjectedNode) -> f32 {
|
||||
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
|
||||
- a.position.x.max(b.position.x);
|
||||
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
|
||||
- a.position.y.max(b.position.y);
|
||||
width.max(0.) * height.max(0.)
|
||||
}
|
||||
|
||||
fn overlaps(before: &ProjectedView, after: &ProjectedView) -> bool {
|
||||
// Build geometry ancestry once, avoiding a repeated descendant walk for
|
||||
// every pair during pointer motion. Pairwise collision checking is bounded
|
||||
// to these two projected scenes and does not traverse/clone model semantics.
|
||||
let before_ancestors = ancestors(before);
|
||||
let after_ancestors = ancestors(after);
|
||||
let prior_nodes: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
|
||||
for (i, a) in after.nodes.iter().enumerate() {
|
||||
for b in &after.nodes[i + 1..] {
|
||||
if after_ancestors[&a.id].contains(&b.id) || after_ancestors[&b.id].contains(&a.id) {
|
||||
continue;
|
||||
}
|
||||
let prior = match (prior_nodes.get(&a.id), prior_nodes.get(&b.id)) {
|
||||
(Some(x), Some(y))
|
||||
if !before_ancestors[&x.id].contains(&y.id)
|
||||
&& !before_ancestors[&y.id].contains(&x.id) =>
|
||||
{
|
||||
overlap(x, y)
|
||||
}
|
||||
_ => 0.,
|
||||
};
|
||||
if overlap(a, b) > prior + 0.01 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_geometry(
|
||||
plan: &SemanticPlan,
|
||||
visible: &ProjectedView,
|
||||
expanded: &ProjectedView,
|
||||
delta: Position,
|
||||
) -> Result<(), Refusal> {
|
||||
if visible.nodes.iter().chain(&expanded.nodes).any(|n| {
|
||||
!(Rect {
|
||||
position: n.position,
|
||||
size: n.size,
|
||||
})
|
||||
.valid()
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The projected enclosure exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
if plan.scope.work_records.len() > 1 {
|
||||
if !coherent(&plan.scope, expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
for id in plan.scope.placements.keys() {
|
||||
let old = plan
|
||||
.before_expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
let new = expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|
||||
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|
||||
|| (new.size.width - old.size.width).abs() > 0.01
|
||||
|| (new.size.height - old.size.height).abs() > 0.01
|
||||
{
|
||||
return Err(Refusal::LayoutRequired("This work subtree requires an explicit layout; its footprint cannot be translated unchanged.".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlaps(&plan.before_visible, visible) || overlaps(&plan.before_expanded, expanded) {
|
||||
return Err(Refusal::LayoutRequired("Make room before moving work: the candidate would introduce or enlarge overlapping cards.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare complete records, not just removed IDs. Deleting an Allocation can
|
||||
/// rewrite a surviving Constraint, remove artifacts/traces or prune view options.
|
||||
fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let mut records = Vec::new();
|
||||
fn changes<T: PartialEq>(
|
||||
expected: &BTreeMap<Id, T>,
|
||||
actual: &BTreeMap<Id, T>,
|
||||
category: &'static str,
|
||||
project: &Project,
|
||||
other: &Project,
|
||||
records: &mut Vec<ChangedRecord>,
|
||||
) {
|
||||
let ids: BTreeSet<_> = expected.keys().chain(actual.keys()).copied().collect();
|
||||
for id in ids {
|
||||
if expected.get(&id) != actual.get(&id) {
|
||||
records.push(ChangedRecord {
|
||||
id,
|
||||
name: if project.contains(id) {
|
||||
project.named(id)
|
||||
} else {
|
||||
other.named(id)
|
||||
}
|
||||
.into(),
|
||||
category,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
changes(
|
||||
&expected.elements,
|
||||
&actual.elements,
|
||||
"element",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.relations,
|
||||
&actual.relations,
|
||||
"relation",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.diagrams,
|
||||
&actual.diagrams,
|
||||
"diagram",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.artifact_links,
|
||||
&actual.artifact_links,
|
||||
"artifact",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.traces,
|
||||
&actual.traces,
|
||||
"trace",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
if expected.schema_version != actual.schema_version
|
||||
|| expected.id != actual.id
|
||||
|| expected.alias != actual.alias
|
||||
|| expected.name != actual.name
|
||||
|| expected.description != actual.description
|
||||
{
|
||||
records.push(ChangedRecord {
|
||||
id: expected.id,
|
||||
name: expected.name.clone(),
|
||||
category: "project",
|
||||
});
|
||||
}
|
||||
Err(Refusal::CollateralChangeRequired(records))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user