//! Ordinary work-only group assignment, with one combined semantic/geometry plan. //! App retains authority over the complete typed selection and gesture lifetime. use super::*; use std::sync::Arc; #[derive(Debug)] struct Captured { accepted: Project, diagram: Id, selected: BTreeMap, anchor: Id, roots: Vec, placements: BTreeMap, leaves: BTreeMap, index: ViewIndex, before_visible: ProjectedView, before_expanded: ProjectedView, } /// Destination plans share one immutable accepted capture. #[derive(Debug, Clone)] pub struct Scope(Arc); impl Scope { pub fn project(&self) -> &Project { &self.0.accepted } pub fn index(&self) -> &ViewIndex { &self.0.index } pub fn diagram(&self) -> &Diagram { &self.0.accepted.diagrams[&self.0.diagram] } pub fn selected(&self) -> &BTreeMap { &self.0.selected } pub fn affected(&self) -> &BTreeMap { &self.0.placements } pub fn anchor(&self) -> Id { self.0.anchor } pub fn leaves(&self) -> &BTreeMap { &self.0.leaves } } /// App must reject non-placement members before constructing this complete map. /// Every original identity is checked before redundant work roots are removed. pub fn capture( p: &Project, diagram: Id, selected: &BTreeMap, anchor: Id, ) -> Result { validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?; let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?; if d.projection != ProjectionKind::Allocation || selected.is_empty() || !selected.contains_key(&anchor) { return Err(Refusal::Ineligible( "Assignment requires selected work placements and a selected anchor in an Allocation view." .into(), )); } let index = ViewIndex::new(p); let before_visible = projection::project_diagram_with_index(p, d, &index); let visible: BTreeMap<_, _> = before_visible .nodes .iter() .map(|node| (node.id, node.element)) .collect(); let mut work = BTreeMap::new(); for (placement, element) in selected { let node = d.nodes.get(placement).ok_or(Refusal::StaleScope)?; let record = p.elements.get(element).ok_or(Refusal::StaleScope)?; if node.element != *element || visible.get(placement) != Some(element) { return Err(Refusal::StaleScope); } if !record.kind().is_work() || record.layer() != d.layer { return Err(Refusal::Ineligible( "Assignment requires only activities or functions in this layer. Use Arrange only for a supported mixed selection." .into(), )); } work.insert(*element, *placement); } let mut roots = Vec::new(); let mut placements = BTreeMap::new(); let mut leaves = BTreeMap::new(); for (element, placement) in &work { let record = &p.elements[element]; let mut ancestor = record.parent; let mut seen = BTreeSet::new(); let mut covered = false; while let Some(id) = ancestor { if !seen.insert(id) { return Err(Refusal::StaleScope); } let parent = p.elements.get(&id).ok_or(Refusal::StaleScope)?; if parent.kind() != record.kind() { break; } if work.contains_key(&id) { covered = true; break; } ancestor = parent.parent; } if covered { continue; } let root = capture_validated(p, diagram, *element, *placement)?; if root.work_records.len() > 1 && root.uniform == Uniform::MixedOrUnresolved { return Err(Refusal::MixedLayoutRequired); } for (id, leaf) in &root.leaves { if leaves.insert(*id, leaf.clone()).is_some() { return Err(Refusal::StaleScope); } } placements.extend(root.placements.clone()); roots.push(root); } let expansion = geometry::expansion_ids(p, d, &index, &placements)?; let before_expanded = geometry::expanded_view(p, d, &index, &expansion); if roots .iter() .any(|root| root.work_records.len() > 1 && !coherent(root, &before_expanded)) { return Err(Refusal::MixedLayoutRequired); } Ok(Scope(Arc::new(Captured { accepted: p.clone(), diagram, selected: selected.clone(), anchor, roots, placements, leaves, index, before_visible, before_expanded, }))) } #[derive(Debug)] pub struct SemanticPlan { scope: Scope, destination: Destination, candidate: Project, index: ViewIndex, commands: Vec, changed: BTreeSet, expanded_ids: BTreeSet, label: String, warnings: Vec, } impl SemanticPlan { pub fn scope(&self) -> &Scope { &self.scope } pub fn project(&self) -> &Project { &self.candidate } pub fn index(&self) -> &ViewIndex { &self.index } pub fn destination(&self) -> Destination { self.destination } pub fn label(&self) -> &str { &self.label } pub fn warnings(&self) -> &[Issue] { &self.warnings } pub fn changed_count(&self) -> usize { self.changed.len() } /// Already effective at the performer, or already unassigned for Outside. pub fn already_correct_count(&self) -> usize { self.scope.leaves().len() - self.changed.len() } } /// Build one destination candidate. Per-root decisions read accepted facts; /// aliases and new identities are reserved in the combined expected Project. pub fn plan_semantics( scope: &Scope, destination: Destination, allocation_ids: &BTreeMap, ) -> Result { let accepted = scope.project(); match destination { Destination::LayoutOnly => { return Err(Refusal::Ineligible( "Use the explicit Arrange only planner for presentation-only movement.".into(), )); } Destination::Performer(id) => { if !accepted.elements.get(&id).is_some_and(|element| { element.kind().is_performer() && element.layer() == scope.diagram().layer }) { return Err(Refusal::Ineligible( "Choose a performer in this layer.".into(), )); } } Destination::OutsidePerformers => {} } let mut expected = accepted.clone(); let mut edits = IntendedEdits::default(); for root in &scope.0.roots { append_semantic_edits( accepted, root, destination, allocation_ids, &mut expected, &mut edits, )?; } for diagram in expected.diagrams.values_mut() { diagram .routes .retain(|route| !edits.deleted.contains(&route.edge)); } let candidate = prepare_command(accepted, &Command::Batch(edits.commands.clone())) .map_err(|e| Refusal::InvalidCandidate(e.to_string()))? .project; exact_project(&expected, &candidate)?; let warnings = validation::validate(&candidate) .into_iter() .filter(|issue| issue.severity == Severity::Warning) .collect(); let index = ViewIndex::new(&candidate); // Assignment may change visual ancestry: never reuse accepted expansion IDs. let expanded_ids = geometry::expansion_ids( &candidate, &candidate.diagrams[&scope.0.diagram], &index, scope.affected(), )?; let total = scope.leaves().len(); let changed = edits.changed.len(); let already_correct = total - changed; let noun = match (scope.diagram().layer, total) { (Layer::Oa, 1) => "activity", (Layer::Oa, _) => "activities", (Layer::Sa, 1) => "function", (Layer::Sa, _) => "functions", }; let retained_roles = scope .leaves() .values() .any(|leaf| !leaf.role_assignments.is_empty()); let label = match destination { Destination::Performer(id) if changed == 0 => { format!( "Arrange within {} — all {total} leaf {noun} already assigned", accepted.named(id) ) } Destination::Performer(id) => format!( "Assign {changed} of {total} leaf {noun} to {}{}{}", accepted.named(id), if already_correct > 0 { format!(" · {already_correct} already assigned") } else { String::new() }, if retained_roles { " — role responsibility retained" } else { "" }, ), Destination::OutsidePerformers if changed == 0 => { "Arrange unassigned work — decomposition retained".into() } Destination::OutsidePerformers => format!( "Remove assignments from {changed} of {total} leaf {noun} — decomposition retained" ), Destination::LayoutOnly => unreachable!(), }; Ok(SemanticPlan { scope: scope.clone(), destination, candidate, index, commands: edits.commands, changed: edits.changed, expanded_ids, label, warnings, }) } /// Translate the entire captured Q once, then evaluate the combined candidate. /// This path does not clone/validate a Project or rebuild semantic indexes. 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 scope = &plan.scope; let geometry::Translation { mut diagram, mut commands, delta, } = geometry::translate( scope.project(), scope.diagram(), &plan.candidate.diagrams[&scope.0.diagram], scope.affected(), scope.anchor(), requested, )?; 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()) })?; if !scope .0 .before_visible .nodes .iter() .any(|node| node.id == target.placement && node.element == performer) { return Err(Refusal::LayoutRequired( "Make room or adjust the view, then drag again.".into(), )); } let target_node = diagram .nodes .get_mut(&target.placement) .ok_or(Refusal::StaleScope)?; if target_node.element != performer { return Err(Refusal::StaleScope); } if !plan.changed.is_empty() && target_node.collapsed { target_node.collapsed = false; unfolded = Some(target_node.id); commands.push(Command::EditNode { diagram: diagram.id, original: target_node.id, node: target_node.clone(), }); } } else if presentation.target.is_some() { return Err(Refusal::Ineligible( "An outside destination has no performer target.".into(), )); } let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index); let expanded = geometry::expanded_view(&plan.candidate, &diagram, &plan.index, &plan.expanded_ids); if scope .0 .roots .iter() .any(|root| root.work_records.len() > 1 && !coherent(root, &expanded)) { return Err(Refusal::MixedLayoutRequired); } geometry::check_scenes( &scope.0.before_visible, &scope.0.before_expanded, &view, &expanded, )?; geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?; geometry::check_headers(&view, scope.selected(), presentation.viewport)?; Ok(PlacementPlan { diagram, view, delta, unfolded, requested, presentation, commands, }) } /// Recheck immutable facts and recompute geometry before one final source batch. /// Zero delta remains a transaction when assignment semantics changed. pub fn finish( plan: &SemanticPlan, live: &Project, placement: &PlacementPlan, ) -> Result, Refusal> { if live != plan.scope.project() { 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)) }