452 lines
16 KiB
Rust
452 lines
16 KiB
Rust
//! Scene bridge for local lifetime geometry; no authoring or model mutations.
|
|
use super::{Geometry, LaneLifetime, Life, Marker, MarkerKind, Span, project_lifetimes};
|
|
use crate::{behavior::Line, style, views::short};
|
|
use iced::{
|
|
Color, Point, Rectangle, Size,
|
|
widget::canvas::{self, Frame, Path, Stroke, Text},
|
|
};
|
|
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
|
|
use std::collections::BTreeMap;
|
|
|
|
// Test-only geometry input, deliberately upstream of rows, scopes, lifecycle
|
|
// and activations. This is not an authored placement or drag policy.
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
static TEST_ROW_OFFSETS: std::cell::RefCell<BTreeMap<Id, f32>> = Default::default();
|
|
}
|
|
#[cfg(test)]
|
|
pub(in crate::behavior) fn apply_test_row_offset(id: Id, y: &mut f32) {
|
|
TEST_ROW_OFFSETS.with(|offsets| {
|
|
if let Some(offset) = offsets.borrow().get(&id) {
|
|
*y += offset;
|
|
}
|
|
});
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
struct EventMarker {
|
|
event: Marker,
|
|
lifeline: Id,
|
|
center: Point,
|
|
capsule: Rectangle,
|
|
name: String,
|
|
sender_x: Option<f32>,
|
|
self_message: bool,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq)]
|
|
pub(in crate::behavior) struct Rendering {
|
|
// Complete rendering positions, including warning-admitted unassigned lanes.
|
|
// Scene.lanes independently retains the existing authoring eligibility rule.
|
|
lanes: BTreeMap<Id, f32>,
|
|
span: Option<Span>,
|
|
lifetimes: Vec<LaneLifetime>,
|
|
markers: Vec<EventMarker>,
|
|
}
|
|
|
|
impl Rendering {
|
|
pub(in crate::behavior) fn new(
|
|
project: &Project,
|
|
owner: Id,
|
|
lanes: &BTreeMap<Id, f32>,
|
|
rows: &BTreeMap<Id, (Id, f32)>,
|
|
insertion_scopes: &BTreeMap<Id, (f32, f32)>,
|
|
height: f32,
|
|
) -> Self {
|
|
let span = Span {
|
|
start: 85.,
|
|
end: height - 20.,
|
|
};
|
|
if !span.valid() {
|
|
return Self::default();
|
|
}
|
|
// Insertion scopes deliberately start later and end earlier. Empty and
|
|
// Create-only scenarios still have the full visible lifetime extent.
|
|
let mut scopes = insertion_scopes.clone();
|
|
for (scope, (start, end)) in &mut scopes {
|
|
if *scope != owner && *start == *end && start.is_finite() {
|
|
// An empty operand has 42 px between its insertion axis and
|
|
// frame end. Match the existing 26 px open-activation allowance.
|
|
*end = *start + 26.;
|
|
}
|
|
}
|
|
scopes.insert(owner, (span.start, span.end));
|
|
let lifetimes = project_lifetimes(
|
|
project,
|
|
owner,
|
|
&Geometry {
|
|
rows,
|
|
scopes: &scopes,
|
|
},
|
|
);
|
|
let lanes: BTreeMap<_, _> = lanes
|
|
.iter()
|
|
.filter(|(_, x)| x.is_finite())
|
|
.map(|(id, x)| (*id, *x))
|
|
.collect();
|
|
let mut markers = Vec::new();
|
|
for lane in &lifetimes {
|
|
let Some(&x) = lanes.get(&lane.lifeline) else {
|
|
continue;
|
|
};
|
|
for event in &lane.markers {
|
|
let sender_id = project
|
|
.elements
|
|
.get(&event.occurrence)
|
|
.and_then(|e| e.shared())
|
|
.and_then(|body| match body {
|
|
SharedBody::Occurrence {
|
|
body:
|
|
OccurrenceBody::Exchange {
|
|
sender: Some(sender),
|
|
..
|
|
},
|
|
..
|
|
} => Some(*sender),
|
|
_ => None,
|
|
});
|
|
markers.push(EventMarker {
|
|
event: *event,
|
|
lifeline: lane.lifeline,
|
|
center: Point::new(x, event.y),
|
|
capsule: Rectangle::new(
|
|
Point::new(x - 70., event.y - 14.),
|
|
Size::new(140., 28.),
|
|
),
|
|
name: project.named(lane.lifeline).to_owned(),
|
|
sender_x: sender_id.and_then(|id| lanes.get(&id).copied()),
|
|
self_message: sender_id == Some(lane.lifeline),
|
|
});
|
|
}
|
|
}
|
|
Self {
|
|
lanes,
|
|
span: Some(span),
|
|
lifetimes,
|
|
markers,
|
|
}
|
|
}
|
|
|
|
pub(in crate::behavior) fn align_messages(&self, lines: &mut [Line]) {
|
|
for marker in &self.markers {
|
|
let Some(sender) = marker.sender_x else {
|
|
continue;
|
|
};
|
|
let Some(line) = lines
|
|
.iter_mut()
|
|
.find(|line| line.id == marker.event.occurrence)
|
|
else {
|
|
continue;
|
|
};
|
|
let x = marker.center.x;
|
|
let y = marker.center.y;
|
|
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,
|
|
MarkerKind::Death => x,
|
|
};
|
|
line.points = if self_message {
|
|
// The receiver is on the recorded occurrence row, including self
|
|
// lifecycle events. The elbow stays outside the birth capsule.
|
|
let elbow = x + 112.;
|
|
line.label_at = Point::new(x + 12., y - 52.);
|
|
vec![
|
|
Point::new(x, y - 24.),
|
|
Point::new(elbow, y - 24.),
|
|
Point::new(elbow, y),
|
|
Point::new(end_x, y),
|
|
]
|
|
} else {
|
|
if marker.event.kind == MarkerKind::Birth {
|
|
// Keep the label above the capsule, including right-to-left
|
|
// messages whose existing label starts beside the receiver.
|
|
line.label_at.y = y - 40.;
|
|
}
|
|
vec![Point::new(sender, y), Point::new(end_x, y)]
|
|
};
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
scope: Id,
|
|
top: f32,
|
|
bottom: f32,
|
|
) -> Vec<Span> {
|
|
if !top.is_finite() || !bottom.is_finite() || bottom <= top {
|
|
return vec![];
|
|
}
|
|
self.lifetimes
|
|
.iter()
|
|
.find(|lane| lane.lifeline == lifeline)
|
|
.into_iter()
|
|
.flat_map(|lane| &lane.intervals)
|
|
.filter(|interval| interval.scope == scope && interval.life == Life::Alive)
|
|
.filter_map(|interval| {
|
|
let start = top.max(interval.span.start);
|
|
let end = bottom.min(interval.span.end);
|
|
(start < end).then_some(Span { start, end })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
|
|
self.markers
|
|
.iter()
|
|
.rev()
|
|
.find(|marker| match marker.event.kind {
|
|
MarkerKind::Birth => marker.capsule.contains(point),
|
|
MarkerKind::Death => marker_distance(marker, point) <= 1.25,
|
|
})
|
|
.map(|marker| marker.event.occurrence)
|
|
}
|
|
|
|
pub(in crate::behavior) fn nearest_marker(&self, point: Point) -> Option<(Id, f32)> {
|
|
if !point.x.is_finite() || !point.y.is_finite() {
|
|
return None;
|
|
}
|
|
// Exact visible shapes are tested first by Scene. A low-zoom tolerance
|
|
// must not outrank another marker's actual capsule, a header, or a label.
|
|
self.markers
|
|
.iter()
|
|
.map(|marker| (marker.event.occurrence, marker_distance(marker, point)))
|
|
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
|
|
}
|
|
|
|
pub(in crate::behavior) fn lane_at(&self, point: Point, scale: f32) -> Option<Id> {
|
|
let span = self.span?;
|
|
if !scale.is_finite() || scale <= 0. || point.y < span.start || point.y > span.end {
|
|
return None;
|
|
}
|
|
self.lanes
|
|
.iter()
|
|
.filter(|(_, x)| (**x - point.x).abs() < 12. / scale)
|
|
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
|
|
.map(|(id, _)| *id)
|
|
}
|
|
|
|
pub(in crate::behavior) fn draw_lanes(&self, frame: &mut Frame, selected: Id) {
|
|
let Some(span) = self.span else {
|
|
return;
|
|
};
|
|
for (&id, &x) in &self.lanes {
|
|
frame.stroke(
|
|
&Path::line(Point::new(x, span.start), Point::new(x, span.end)),
|
|
Stroke {
|
|
line_dash: canvas::LineDash {
|
|
segments: &[2., 5.],
|
|
offset: 0,
|
|
},
|
|
..Stroke::default()
|
|
.with_width(1.)
|
|
.with_color(if id == selected {
|
|
Color::from_rgb8(148, 190, 187)
|
|
} else {
|
|
Color::from_rgb8(214, 222, 222)
|
|
})
|
|
},
|
|
);
|
|
}
|
|
for lifetime in &self.lifetimes {
|
|
let Some(&x) = self.lanes.get(&lifetime.lifeline) else {
|
|
continue;
|
|
};
|
|
for interval in &lifetime.intervals {
|
|
match interval.life {
|
|
Life::Alive => frame.stroke(
|
|
&Path::line(
|
|
Point::new(x, interval.span.start),
|
|
Point::new(x, interval.span.end),
|
|
),
|
|
Stroke::default().with_width(2.).with_color(
|
|
if lifetime.lifeline == selected {
|
|
style::TEAL
|
|
} else {
|
|
Color::from_rgb8(91, 113, 113)
|
|
},
|
|
),
|
|
),
|
|
Life::Unresolved if interval.span.end - interval.span.start >= 16. => {
|
|
frame.fill_text(Text {
|
|
content: "?".into(),
|
|
position: Point::new(x + 9., interval.span.start + 2.),
|
|
size: 11.into(),
|
|
color: style::MUTED,
|
|
..Default::default()
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
if !self.lanes.is_empty() {
|
|
frame.fill_text(Text {
|
|
content:
|
|
"Dotted: editing guide · Solid: local lifetime · ?: lifetime depends on path"
|
|
.into(),
|
|
position: Point::new(28., span.end + 4.),
|
|
size: 10.into(),
|
|
color: style::MUTED,
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(in crate::behavior) fn draw_markers(
|
|
&self,
|
|
frame: &mut Frame,
|
|
selected: Id,
|
|
scale: f32,
|
|
pan: iced::Vector,
|
|
) {
|
|
for marker in &self.markers {
|
|
let color = if selected == marker.event.occurrence || selected == marker.lifeline {
|
|
style::TEAL
|
|
} else {
|
|
style::INK
|
|
};
|
|
match marker.event.kind {
|
|
MarkerKind::Birth => {
|
|
let path = Path::rounded_rectangle(
|
|
marker.capsule.position(),
|
|
marker.capsule.size(),
|
|
6.into(),
|
|
);
|
|
frame.fill(&path, Color::from_rgb8(243, 248, 248));
|
|
frame.stroke(
|
|
&path,
|
|
Stroke::default().with_color(color).with_width(
|
|
if selected == marker.event.occurrence {
|
|
2.5
|
|
} else {
|
|
1.5
|
|
},
|
|
),
|
|
);
|
|
if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
|
|
// WGPU clipped frames start with an identity transform;
|
|
// neither the parent's pan/scale nor its clip is inherited.
|
|
frame.with_clip(clip, |label| {
|
|
label.translate(pan);
|
|
label.scale(scale);
|
|
label.fill_text(Text {
|
|
content: short(&marker.name, 18),
|
|
position: marker.capsule.position() + iced::Vector::new(8., 6.),
|
|
size: 12.into(),
|
|
color,
|
|
..Default::default()
|
|
});
|
|
});
|
|
}
|
|
}
|
|
MarkerKind::Death => {
|
|
for (a, b) in death_strokes(marker.center) {
|
|
frame.stroke(
|
|
&Path::line(a, b),
|
|
Stroke::default().with_color(color).with_width(2.5),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn birth_label_clip(
|
|
pan: iced::Vector,
|
|
capsule: Rectangle,
|
|
scale: f32,
|
|
viewport: Size,
|
|
) -> Option<Rectangle> {
|
|
if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
|
|
return None;
|
|
}
|
|
Rectangle::new(
|
|
Point::new(
|
|
pan.x + (capsule.x + 8.) * scale,
|
|
pan.y + (capsule.y + 4.) * scale,
|
|
),
|
|
Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
|
|
)
|
|
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
|
|
}
|
|
|
|
fn marker_distance(marker: &EventMarker, point: Point) -> f32 {
|
|
match marker.event.kind {
|
|
MarkerKind::Birth => {
|
|
let dx = (marker.capsule.x - point.x)
|
|
.max(point.x - marker.capsule.x - marker.capsule.width)
|
|
.max(0.);
|
|
let dy = (marker.capsule.y - point.y)
|
|
.max(point.y - marker.capsule.y - marker.capsule.height)
|
|
.max(0.);
|
|
dx.hypot(dy)
|
|
}
|
|
MarkerKind::Death => death_strokes(marker.center)
|
|
.iter()
|
|
.map(|(a, b)| crate::diagram::distance_to_segment(point, *a, *b))
|
|
.fold(f32::INFINITY, f32::min),
|
|
}
|
|
}
|
|
|
|
fn death_strokes(center: Point) -> [(Point, Point); 2] {
|
|
[
|
|
(
|
|
center + iced::Vector::new(-7., -7.),
|
|
center + iced::Vector::new(7., 7.),
|
|
),
|
|
(
|
|
center + iced::Vector::new(-7., 7.),
|
|
center + iced::Vector::new(7., -7.),
|
|
),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "render_tests.rs"]
|
|
mod tests;
|