817 lines
29 KiB
Rust
817 lines
29 KiB
Rust
//! 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<Id, f32>;
|
|
|
|
#[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<Id, Anchor>,
|
|
messages: BTreeMap<Id, MessageAnchors>,
|
|
pub(super) frames: BTreeMap<Id, Rectangle>,
|
|
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
|
|
pub(super) height: f32,
|
|
pub(super) diagnostics: Vec<Diagnostic>,
|
|
}
|
|
|
|
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<Id, Anchor>,
|
|
seed: &BTreeMap<Id, Anchor>,
|
|
) -> BTreeMap<Id, Rectangle> {
|
|
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<Id, Rectangle>) {
|
|
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<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
|
|
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<Self, GeometryIssue> {
|
|
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<Id, f32>,
|
|
pub(super) plan: Plan,
|
|
pub(super) latent_messages: Vec<Id>,
|
|
pub(super) bounds: Rectangle,
|
|
pub(super) scene: Scene,
|
|
}
|
|
|
|
pub(super) fn resolve(
|
|
p: &Project,
|
|
representation: Representation,
|
|
selected: Id,
|
|
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
|
|
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
|
|
}
|