//! Pure complete Scenario geometry. No persistence, gesture or fallback policy. //! The only renderer is Scene::sequence_geometry; this module resolves its inputs. use super::*; use oa_core::{DiagramLayout, ScenarioLayout}; #[cfg(test)] mod tests; pub(crate) mod presentation; type Candidate = BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AnchorRole { MessageSend, MessageReceive, Observation, FrameStart, OperandStart, } #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct Anchor { pub(super) y: f32, role: AnchorRole, } #[derive(Clone, Copy, Debug, PartialEq)] struct MessageAnchors { send_y: f32, receive_y: f32, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum Diagnostic { SourceOrderCrossing { scope: Id, before: Id, after: Id }, FrameExtendsPastFollowingAnchor { frame: Id, following: Id }, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum GeometryIssue { UnknownAnchor(Id), LifecycleGeometryNotSupported { lifeline: Id, scope: Id }, InvalidRepresentation(Id), InvalidCoordinate(Id), DuplicateCoordinate(Id), ExpandedAddressNotSupported(Id), CoincidentDistinctEndpoints(Id), TypedActivationOutsideScope { call: Id, scope: Id }, TypedActivationOutsideLifetime { call: Id, receiver: Id }, TypedActivationCrossingNotSupported { call: Id, event: Id }, NonFinite(Id), CompoundMoveNotImplemented(Id), FixedFrameHeaderWouldMove { frame: Id }, OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id }, LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id }, LifetimeInversionNotSupported { create: Id, delete: Id }, } #[derive(Clone, Debug)] pub(super) struct Plan { pub(super) anchors: BTreeMap, messages: BTreeMap, pub(super) frames: BTreeMap, pub(super) scopes: BTreeMap, pub(super) height: f32, pub(super) diagnostics: Vec, } pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> { match p.elements.get(&id)?.shared()? { SharedBody::Occurrence { body, .. } => Some(body), _ => None, } } fn role(body: &OccurrenceBody) -> AnchorRole { match body { OccurrenceBody::Exchange { message_kind: MessageKind::Create | MessageKind::Delete, .. } => AnchorRole::MessageReceive, OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend, OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart, OccurrenceBody::Branch { .. } => AnchorRole::OperandStart, _ => AnchorRole::Observation, } } fn inside(p: &Project, id: Id, owner: Id) -> bool { let mut current = p.elements.get(&id).and_then(|e| e.parent); for _ in 0..p.elements.len() { match current { Some(parent) if parent == owner => return true, Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent), None => return false, } } false } fn depth(p: &Project, id: Id) -> usize { let mut current = p.elements.get(&id).and_then(|e| e.parent); let mut result = 0; while let Some(parent) = current { result += 1; if result > p.elements.len() { break; } current = p.elements.get(&parent).and_then(|e| e.parent); } result } pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle { let x = a.x.min(b.x); let y = a.y.min(b.y); Rectangle::new( Point::new(x, y), Size::new( (a.x + a.width).max(b.x + b.width) - x, (a.y + a.height).max(b.y + b.height) - y, ), ) } /// Actual named shapes/message paths supply footprint measurements; activation /// bars are intentionally excluded because current Scene derives them later. fn footprints( scene: &Scene, anchors: &BTreeMap, seed: &BTreeMap, ) -> BTreeMap { let mut out = BTreeMap::new(); for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) { let Some(anchor) = anchors.get(&item.id) else { continue; }; let mut rect = item.rect; rect.y += anchor.y - seed[&item.id].y; out.insert(item.id, rect); } for line in &scene.lines { let Some(anchor) = anchors.get(&line.id) else { continue; }; let delta = anchor.y - seed[&line.id].y; let mut rect = line.label_bounds(); rect.y += delta; for point in &line.points { rect = union( rect, Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO), ); } out.entry(line.id) .and_modify(|old| *old = union(*old, rect)) .or_insert(rect); } out } fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap) { let mut current = p.elements.get(&id).and_then(|e| e.parent); for _ in 0..p.elements.len() { let Some(parent) = current else { break; }; covered .entry(parent) .and_modify(|old| *old = union(*old, rect)) .or_insert(rect); current = p.elements.get(&parent).and_then(|e| e.parent); } } fn coverage(p: &Project, footprints: &BTreeMap) -> BTreeMap { let mut covered = BTreeMap::new(); for (&id, &rect) in footprints { include_ancestors(p, id, rect, &mut covered); } covered } impl Plan { /// Authored endpoint values are primary inputs, not deltas reconstructed from /// rounded rows. Lifecycle alignment owns x and marker attachment; this final /// y alignment precedes typed activation derivation and preserves send bits. pub(super) fn align_message_anchors(&self, lines: &mut [Line]) { for line in lines { let Some(message) = self.messages.get(&line.id) else { continue; }; let previous_send = line.points.first().map_or(message.send_y, |point| point.y); line.label_at.y += message.send_y - previous_send; let last = line.points.len().saturating_sub(1); for (index, point) in line.points.iter_mut().enumerate() { point.y = if index == last || (last == 3 && index == 2) { message.receive_y } else { message.send_y }; } } } fn build( p: &Project, owner: Id, baseline: &Scene, desired: &Candidate, ) -> Result { let mut seed: BTreeMap<_, _> = baseline .rows .iter() .map(|(id, (_, y))| { ( *id, Anchor { y: *y, role: role(occurrence(p, *id).expect("Scene occurrence")), }, ) }) .collect(); // Operand headers are real Scene boxes, but are not entries in rows. for item in &baseline.boxes { if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) { seed.insert( item.id, Anchor { y: item.rect.y, role: AnchorRole::OperandStart, }, ); } } let mut anchors = seed.clone(); for (&id, &y) in desired { let anchor = anchors .get_mut(&id) .ok_or(GeometryIssue::UnknownAnchor(id))?; if !y.is_finite() { return Err(GeometryIssue::NonFinite(id)); } if matches!( anchor.role, AnchorRole::FrameStart | AnchorRole::OperandStart ) && y != anchor.y { return Err(GeometryIssue::CompoundMoveNotImplemented(id)); } anchor.y = y; } let mut messages = BTreeMap::new(); for line in &baseline.lines { if let (Some(anchor), Some(first), Some(last)) = ( anchors.get(&line.id), line.points.first(), line.points.last(), ) { let message = if desired.contains_key(&line.id) { let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange { sender: Some(sender), receiver, message_kind, .. }) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender)); let lifecycle = matches!( occurrence(p, line.id), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Create | MessageKind::Delete, .. }) ); if self_message && lifecycle { MessageAnchors { send_y: anchor.y - 24., receive_y: anchor.y, } } else { MessageAnchors { send_y: anchor.y, receive_y: if self_message { anchor.y + 24. } else { anchor.y }, } } } else { MessageAnchors { send_y: first.y, receive_y: last.y, } }; messages.insert(line.id, message); } } // Track actual semantic lifecycle order per receiver and scope. In particular, // a later recreation must not be compared with an earlier deletion. for element in p .elements .values() .filter(|e| e.id == owner || inside(p, e.id, owner)) { let Some(steps) = element.steps() else { continue; }; let mut last = BTreeMap::new(); for id in steps { if let ( Some(message), Some(OccurrenceBody::Exchange { receiver: Some(lane), message_kind: MessageKind::Create | MessageKind::Delete, .. }), ) = (messages.get(id), occurrence(p, *id)) && let Some((previous, previous_y)) = last.insert(*lane, (*id, message.receive_y)) && previous_y > message.receive_y { return Err(GeometryIssue::LifetimeInversionNotSupported { create: previous, delete: *id, }); } } } let old_footprints = footprints(baseline, &seed, &seed); let mut drawn = footprints(baseline, &anchors, &seed); let old_coverage = coverage(p, &old_footprints); let mut new_coverage = coverage(p, &drawn); let mut frames = BTreeMap::new(); let mut containers: Vec<_> = seed .keys() .copied() .filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. }))) .collect(); containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id))); for id in containers { let original = old_footprints[&id]; let mut rect = original; if let Some(covered) = new_coverage.get(&id).copied() { // Preserve automatic extents and their existing descendant padding. // Upward envelope growth would move the painted frame header // while its fixed FrameStart/row/slot remained at the seed. // Reject that unsupported compound change, not mere overlap. if covered.y < rect.y { return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id }); } let old_covered = old_coverage[&id]; let padding = (original.y + original.height - old_covered.y - old_covered.height).max(0.); // Preserve padding before the footprint itself reaches the old // frame edge: operand scopes already retain their own padding. rect.height = rect .height .max(covered.y + covered.height + padding - rect.y); } frames.insert(id, rect); drawn.insert(id, rect); include_ancestors(p, id, rect, &mut new_coverage); } let mut scopes = baseline.sequence_bounds.clone(); for (&scope, bounds) in &mut scopes { if let (Some(old), Some(new)) = ( old_coverage.get(&scope).copied(), new_coverage.get(&scope).copied(), ) { let top_padding = (old.y - bounds.0).max(0.); let bottom_padding = (bounds.1 - old.y - old.height).max(0.); if new.y < bounds.0 { bounds.0 = new.y - top_padding; } bounds.1 = bounds.1.max(new.y + new.height + bottom_padding); } } let tail = baseline.size.height - baseline.sequence_bounds[&owner].1; let height = baseline.size.height.max(scopes[&owner].1 + tail); for (&operand, &(start, end)) in &scopes { if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) { continue; } let fragment = p.elements[&operand].parent.expect("operand fragment"); let caller = p.elements[&fragment] .parent .expect("fragment caller sequence"); let enclosing_frame = frames[&fragment]; let (caller_start, caller_end) = if caller == owner { (85., height - 20.) } else { let (a, b) = scopes[&caller]; (a, if a == b { a + 26. } else { b }) }; let enclosing_start = anchors[&fragment].y.max(caller_start); let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end); let effective_end = if start == end { start + 26. } else { end }; // Actual lifecycle traversal cannot enter an operand that escapes // this enclosing interval. This guards bridge feasibility only; // it is not a generic event-spacing or sibling-ordering policy. if start < enclosing_start || effective_end > enclosing_end { return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, fragment, }); } } for (&id, message) in &messages { if matches!( occurrence(p, id), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Create | MessageKind::Delete, .. }) ) { let scope = p.elements[&id].parent.expect("lifecycle sequence owner"); // Match Rendering::new's effective scope, not the insertion // scope: it overrides the root span to 85..height-20 and gives // empty non-root scopes the existing 26px lifetime allowance. let (start, end) = if scope == owner { (85., height - 20.) } else if let Some(&(start, end)) = scopes.get(&scope) { (start, if start == end { start + 26. } else { end }) } else { return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope { occurrence: id, scope, }); }; if message.receive_y < start || message.receive_y > end { return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope { occurrence: id, scope, }); } } } let mut diagnostics = Vec::new(); for element in p .elements .values() .filter(|e| e.id == owner || inside(p, e.id, owner)) { if let Some(steps) = element.steps() { for pair in steps.windows(2) { if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) { if a.y > b.y { diagnostics.push(Diagnostic::SourceOrderCrossing { scope: element.id, before: pair[0], after: pair[1], }); } if let Some(frame) = frames.get(&pair[0]) && frame.y + frame.height > b.y { diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { frame: pair[0], following: pair[1], }); } } } } } Ok(Self { anchors, messages, frames, scopes, height, diagnostics, }) } } /// An appearance identity, never inferred from selection or from the owner alone. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Representation { Derived { owner: Id }, Saved { diagram: Id }, } /// Complete immutable geometry produced from one Project borrow. Scene is retained /// with its plan so a caller cannot accidentally render this plan against another /// Project or representation. Document/revision freshness belongs to that caller. pub(super) struct ResolvedScenarioPlan { pub(super) representation: Representation, pub(super) owner: Id, pub(super) lanes: BTreeMap, pub(super) plan: Plan, pub(super) latent_messages: Vec, pub(super) bounds: Rectangle, pub(super) scene: Scene, } pub(super) fn resolve( p: &Project, representation: Representation, selected: Id, ) -> Result> { let (owner, layout) = match representation { Representation::Derived { owner } => (owner, None), Representation::Saved { diagram } => { let view = p .diagrams .get(&diagram) .ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?; let owner = view .projection .scenario() .ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?; if p.elements .get(&owner) .is_none_or(|e| e.layer() != view.layer) { return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]); } let layout = view .layout .as_ref() .map(|DiagramLayout::Scenario(layout)| layout); (owner, layout) } }; if p.elements .get(&owner) .is_none_or(|e| e.kind() != Kind::Scenario) { return Err(vec![GeometryIssue::InvalidRepresentation(owner)]); } let empty = ScenarioLayout::default(); let layout = layout.unwrap_or(&empty); let mut lanes = BTreeMap::new(); let mut issues = Vec::new(); for lane in &layout.lanes { if p.elements .get(&lane.lifeline) .is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner)) { issues.push(GeometryIssue::UnknownAnchor(lane.lifeline)); } if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE { issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline)); } if lanes.insert(lane.lifeline, lane.x).is_some() { issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline)); } } if !issues.is_empty() { return Err(issues); } // The actual automatic traversal runs first, with authored lane centers already // applied. It supplies every untouched anchor, shape, insertion scope and label. let baseline = Scene::sequence_geometry( p, owner, selected, (!lanes.is_empty()).then_some(&lanes), None, ); let mut desired = BTreeMap::new(); let mut seen = std::collections::BTreeSet::new(); let mut latent_messages = Vec::new(); let line_ids: std::collections::BTreeSet<_> = baseline.lines.iter().map(|line| line.id).collect(); for message in &layout.messages { let id = message.address.occurrence; if !seen.insert(id) { issues.push(GeometryIssue::DuplicateCoordinate(id)); } if !message.address.references.is_empty() { issues.push(GeometryIssue::ExpandedAddressNotSupported(id)); } if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE { issues.push(GeometryIssue::InvalidCoordinate(id)); } let Some(OccurrenceBody::Exchange { sender, receiver, message_kind, .. }) = occurrence(p, id) else { issues.push(GeometryIssue::UnknownAnchor(id)); continue; }; if !baseline.rows.contains_key(&id) { issues.push(GeometryIssue::UnknownAnchor(id)); continue; } if !line_ids.contains(&id) { latent_messages.push(id); continue; } // Legacy lifecycle rows denote receipt, whereas the saved field denotes send. let row = if sender.is_some() && sender == receiver && matches!(message_kind, MessageKind::Create | MessageKind::Delete) { message.send_y + 24. } else { message.send_y }; desired.insert(id, row); } let actual_lanes = &baseline.render_lanes; // A coincident pair is not a self message. Do not invent an arrow or lane order. for line in &baseline.lines { if let Some(OccurrenceBody::Exchange { sender: Some(sender), receiver, message_kind, .. }) = occurrence(p, line.id) { let target = if matches!(message_kind, MessageKind::Timer { .. }) { Some(*sender) } else { *receiver }; if target.is_some_and(|target| target != *sender) && target .is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target)) { issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id)); } } } if !issues.is_empty() { return Err(issues); } let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?; // Keep the schema's exact send value; send+24 is performed once. In particular, // a self Create/Delete receive row must never round-trip back into saved send. for message in &layout.messages { let id = message.address.occurrence; if let Some(anchors) = plan.messages.get_mut(&id) { let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange { sender: Some(sender), receiver, message_kind, .. }) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender)); *anchors = MessageAnchors { send_y: message.send_y, receive_y: if self_message { message.send_y + 24. } else { message.send_y }, }; } } // Typed bars require a positive receiver interval. This is a narrow renderer // limitation, not a global ban on reordered/negative asynchronous anchors. for (&id, message) in &plan.messages { match occurrence(p, id) { Some(OccurrenceBody::Exchange { message_kind: MessageKind::Call, .. }) => { let scope = baseline.rows[&id].0; let (start, end) = if scope == owner { (85., plan.height - 20.) } else { plan.scopes[&scope] }; if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end) { issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope }); } } Some(OccurrenceBody::Exchange { message_kind: MessageKind::Reply, reply_to: Some(call), .. }) => { if let Some(start) = plan.messages.get(call) && (desired.contains_key(&id) || desired.contains_key(call)) && message.send_y <= start.receive_y { issues.push(GeometryIssue::TypedActivationCrossingNotSupported { call: *call, event: id, }); } } _ => {} } } if !issues.is_empty() { return Err(issues); } for segment in &baseline.activations { if baseline .rows .get(&segment.call) .is_none_or(|(scope, _)| *scope != segment.scope) { continue; } let Some(start) = plan.messages.get(&segment.call) else { continue; }; let event = match segment.finish { scenario_activation::Finish::Reply(event) | scenario_activation::Finish::Terminated(event) => Some(event), _ => None, }; if let Some(event) = event && (desired.contains_key(&event) || desired.contains_key(&segment.call)) && plan.anchors[&event].y <= start.receive_y { issues.push(GeometryIssue::TypedActivationCrossingNotSupported { call: segment.call, event, }); } } if !issues.is_empty() { return Err(issues); } let scene = Scene::sequence_geometry( p, owner, selected, (!lanes.is_empty()).then_some(&lanes), Some(&plan), ); // Compare the actual projector, whose cursor rules include sibling scopes and // lifecycle/fragment events. Retain existing incomplete-model uncertainty; // reject only newly missing geometry introduced by this presentation. let previous_missing = baseline.lifecycle.missing_geometry(); for (lifeline, scope) in scene .lifecycle .missing_geometry() .difference(&previous_missing) { issues.push(GeometryIssue::LifecycleGeometryNotSupported { lifeline: *lifeline, scope: *scope, }); } if !issues.is_empty() { return Err(issues); } for segment in &baseline.activations { let Some(seed) = baseline .lines .iter() .find(|line| line.id == segment.call) .and_then(|line| line.points.last()) else { continue; }; if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope { continue; } let receive = plan.messages[&segment.call].receive_y; if !desired.is_empty() && !scene.activations.iter().any(|current| { current.call == segment.call && current.scope == segment.scope && current.rect.y == receive }) { issues.push(GeometryIssue::TypedActivationOutsideLifetime { call: segment.call, receiver: segment.receiver, }); } } if !issues.is_empty() { return Err(issues); } let bounds = structural_bounds(&scene); let all_lanes = scene.render_lanes.clone(); Ok(ResolvedScenarioPlan { representation, owner, lanes: all_lanes, plan, latent_messages, bounds, scene, }) } fn structural_bounds(scene: &Scene) -> Rectangle { let mut bounds = Rectangle::new(scene.layout_origin, scene.size); for item in &scene.boxes { bounds = union(bounds, item.rect); } for line in &scene.lines { if !line.label.is_empty() { bounds = union(bounds, line.label_bounds()); } for point in &line.points { // Arrow wings extend beyond the centerline in either direction. bounds = union( bounds, Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)), ); } } for segment in &scene.activations { bounds = union(bounds, segment.rect); } for point in &scene.open_calls { bounds = union( bounds, Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)), ); } if let Some(lifetime) = scene.lifecycle.bounds() { bounds = union(bounds, lifetime); } bounds }