//! 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, pub role_assignments: Vec, pub role_realizations: Vec, pub effective_performer: Option, } /// 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, pub leaves: BTreeMap, pub placements: BTreeMap, 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, performers: Vec, message: String, }, MissingAllocationId(Id), InvalidCandidate(String), CollateralChangeRequired(Vec), LayoutRequired(String), } pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result { 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, accepted: Project, candidate: Project, index: ViewIndex, commands: Vec, 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 } #[cfg(test)] 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, ) -> Result { 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::>() .into_iter() .collect(); let performers: Vec<_> = leaf .role_realizations .iter() .map(Relation::source) .collect::>() .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::>() .join(", "), if performers.is_empty() { String::new() } else { format!( " ({})", performers .iter() .map(|id| p.named(*id)) .collect::>() .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, } #[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, } #[derive(Debug)] pub struct PlacementPlan { pub diagram: Diagram, pub view: ProjectedView, pub delta: Position, pub unfolded: Option, requested: Position, presentation: PresentationContext, commands: Vec, } /// `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 { 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("A visible accepted performer target is required.".into()) })?; let node = diagram .nodes .get_mut(&target.placement) .ok_or(Refusal::StaleScope)?; let visible_target = plan .before_visible .nodes .iter() .any(|n| n.id == target.placement && n.element == performer); if node.element != performer || !visible_target { 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(), )); } // Destination identity lives in a fixed toolbar slot. Frozen hit geometry // and its outline never introduce an opaque label over this work header. 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, 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> { 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( expected: &BTreeMap, actual: &BTreeMap, category: &'static str, project: &Project, other: &Project, records: &mut Vec, ) { 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;