//! 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> = 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, 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, span: Option, lifetimes: Vec, markers: Vec, } impl Rendering { pub(in crate::behavior) fn new( project: &Project, owner: Id, lanes: &BTreeMap, rows: &BTreeMap, insertion_scopes: &BTreeMap, 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 { 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 { 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 { 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 { 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 { 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;