Resolve saved Scenario geometry through explicit complete Scene plans

This commit is contained in:
2026-09-12 21:26:33 -07:00
parent 45a7fde2c1
commit 68a81c3f50
4 changed files with 2161 additions and 10 deletions
+74 -6
View File
@@ -21,6 +21,9 @@ pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
#[allow(dead_code)]
mod scenario_layout;
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
@@ -1172,12 +1175,15 @@ struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
size: Size,
layout_origin: Point,
selected: Id,
scale: f32,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
// Exact render coordinates include unassigned lanes; do not derive from cards.
render_lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
@@ -1228,16 +1234,27 @@ impl SequenceInput {
}
impl Scene {
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
Self::sequence_geometry(p, owner, selected, None, None)
}
fn sequence_geometry(
p: &Project,
owner: Id,
selected: Id,
lane_override: Option<&BTreeMap<Id, f32>>,
plan: Option<&scenario_layout::Plan>,
) -> Self {
let mut scene = Self {
scale: 1.,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
layout_origin: Point::ORIGIN,
selected,
interaction: None,
pending_placement: None,
lanes: BTreeMap::new(),
render_lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
@@ -1308,12 +1325,32 @@ impl Scene {
})
.collect();
}
let lane_x = lanes
let mut lane_x = lanes
.iter()
.enumerate()
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
.collect::<BTreeMap<_, _>>();
if let Some(values) = lane_override {
for (id, x) in values {
if let Some(lane) = lane_x.get_mut(id) {
*lane = *x;
}
}
}
scene.render_lanes = lane_x.clone();
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
let layout_left = if lane_override.is_some() {
lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.))
} else {
0.
};
scene.layout_origin.x = layout_left;
if lane_override.is_some() {
scene.size.width = lane_x
.values()
.fold(scene.size.width, |right, x| right.max(*x + 240.))
- layout_left;
}
if scenario {
scene.lanes = lane_x
.iter()
@@ -1329,8 +1366,26 @@ impl Scene {
.collect();
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
scene.size.height = (y + 45.).max(210.);
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan);
if layout_left != 0. {
for item in &mut scene.boxes {
let body = scenario_layout::occurrence(p, item.id);
if matches!(
body,
Some(
OccurrenceBody::Branch { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. }
| OccurrenceBody::Exchange { .. }
)
) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. })))
{
item.rect.x += layout_left;
}
}
}
scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height);
if scenario {
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
p,
@@ -1341,6 +1396,9 @@ impl Scene {
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
if let Some(plan) = plan {
plan.align_message_anchors(&mut scene.lines);
}
scene.layout_activations(p, &lane_x);
}
for (id, name, detail) in lanes {
@@ -1371,6 +1429,7 @@ impl Scene {
}
scene
}
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
fn occurrences(
&mut self,
p: &Project,
@@ -1379,6 +1438,7 @@ impl Scene {
lanes: &BTreeMap<Id, f32>,
y: &mut f32,
depth: usize,
plan: Option<&scenario_layout::Plan>,
) {
let steps = p
.elements
@@ -1418,6 +1478,9 @@ impl Scene {
}
#[cfg(test)]
scenario_lifecycle::render::apply_test_row_offset(*id, y);
if let Some(plan) = plan {
*y = plan.anchors[id].y;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1434,7 +1497,7 @@ impl Scene {
OccurrenceBody::Fragment { operator, .. } => {
let top = *y;
*y += 55.;
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
let rect = Rectangle::new(
Point::new(18. + depth as f32 * 10., top),
Size::new(
@@ -1442,6 +1505,7 @@ impl Scene {
(*y - top).max(95.),
),
);
let rect = plan.map_or(rect, |plan| plan.frames[id]);
self.boxes.insert(
0,
BoxItem {
@@ -1473,7 +1537,7 @@ impl Scene {
marker: Some(Kind::Occurrence),
});
*y += if scenario { 90. } else { 62. };
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
}
OccurrenceBody::Exchange {
exchange,
@@ -1501,7 +1565,7 @@ impl Scene {
source.and_then(|id| lanes.get(&id)),
target.and_then(|id| lanes.get(&id)),
) {
let points = if a == b {
let points = if source == target {
vec![
Point::new(*a, *y),
Point::new(a + 75., *y),
@@ -1626,6 +1690,10 @@ impl Scene {
}
if ordered {
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
if let Some(plan) = plan {
self.sequence_bounds.insert(owner, plan.scopes[&owner]);
*y = plan.scopes[&owner].1 + 42.;
}
let slot = scenario::Slot {
parent: owner,
before: None,
@@ -0,0 +1,817 @@
//! 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;
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)]
enum Diagnostic {
SourceOrderCrossing { scope: Id, before: Id, after: Id },
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) 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,
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))
{
if let Some((previous, previous_y)) =
last.insert(*lane, (*id, message.receive_y))
{
if 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 });
}
if covered.y + covered.height > rect.y + rect.height {
let old_covered = old_coverage[&id];
let padding =
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
rect.height = 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]) {
if 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(super) 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) {
if (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 {
if (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
}
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ struct EventMarker {
capsule: Rectangle,
name: String,
sender_x: Option<f32>,
self_message: bool,
}
#[derive(Debug, Default, Clone, PartialEq)]
@@ -89,7 +90,7 @@ impl Rendering {
continue;
};
for event in &lane.markers {
let sender_x = project
let sender_id = project
.elements
.get(&event.occurrence)
.and_then(|e| e.shared())
@@ -101,7 +102,7 @@ impl Rendering {
..
},
..
} => lanes.get(sender).copied(),
} => Some(*sender),
_ => None,
});
markers.push(EventMarker {
@@ -113,7 +114,8 @@ impl Rendering {
Size::new(140., 28.),
),
name: project.named(lane.lifeline).to_owned(),
sender_x,
sender_x: sender_id.and_then(|id| lanes.get(&id).copied()),
self_message: sender_id == Some(lane.lifeline),
});
}
}
@@ -138,7 +140,7 @@ impl Rendering {
};
let x = marker.center.x;
let y = marker.center.y;
let self_message = sender == x;
let self_message = marker.self_message;
let end_x = match marker.event.kind {
MarkerKind::Birth if sender < x => marker.capsule.x,
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
@@ -166,6 +168,48 @@ impl Rendering {
}
}
/// Geometry loss is distinct from warning-admitted semantic uncertainty.
/// The saved bridge compares these exact projector diagnostics with baseline.
pub(in crate::behavior) fn missing_geometry(&self) -> std::collections::BTreeSet<(Id, Id)> {
self.lifetimes
.iter()
.flat_map(|lane| {
lane.uncertainties
.iter()
.filter(|issue| issue.reason == super::UncertaintyReason::MissingGeometry)
.map(|issue| (lane.lifeline, issue.scope))
})
.collect()
}
/// Complete structural lifetime bounds, including the birth capsule and death mark.
pub(in crate::behavior) fn bounds(&self) -> Option<Rectangle> {
let mut bounds = self.span.and_then(|span| {
self.lanes
.values()
.map(|x| {
Rectangle::new(
Point::new(*x, span.start),
Size::new(0., span.end - span.start),
)
})
.reduce(crate::behavior::scenario_layout::union)
});
for marker in &self.markers {
let rect = match marker.event.kind {
MarkerKind::Birth => marker.capsule,
MarkerKind::Death => Rectangle::new(
Point::new(marker.center.x - 10., marker.center.y - 10.),
Size::new(20., 20.),
),
};
bounds = Some(bounds.map_or(rect, |old| {
crate::behavior::scenario_layout::union(old, rect)
}));
}
bounds
}
pub(in crate::behavior) fn alive_spans(
&self,
lifeline: Id,