Skip to main content

viewport_lib/interaction/
annotation.rs

1//! World-space annotation labels that project 3D positions to 2D screen coordinates.
2//!
3//! # Purpose
4//!
5//! Callers can pin text labels to arbitrary 3D world positions (peak pressure values,
6//! boundary condition names, measurement callouts) that are reprojected to screen space
7//! each frame. Rendering is handled by the caller via egui (or any 2D drawing API) so
8//! there is no wgpu pipeline involved.
9//!
10//! # Typical usage
11//!
12//! ```rust,ignore
13//! // Build a label once (or each frame if the data changes).
14//! let mut label = AnnotationLabel::default();
15//! label.world_pos = glam::Vec3::new(2.0, 3.0, 0.0);
16//! label.text = "Peak pressure: 101.3 kPa".to_string();
17//! label.leader_end = Some(glam::Vec3::new(1.0, 1.0, 0.0));
18//!
19//! // Each frame, project to screen.
20//! let view = camera.view_matrix();
21//! let proj = camera.proj_matrix();
22//! if let Some(screen_pos) = world_to_screen(label.world_pos, &view, &proj, viewport_size) {
23//!     // Draw with your 2D API here.
24//! }
25//! ```
26
27use crate::renderer::FrameData;
28
29/// A text label pinned to a 3D world position.
30///
31/// Labels are rendered by the caller (typically via egui's painter) after projecting
32/// the world position to screen space using [`world_to_screen`] or [`world_to_screen_from_frame`].
33/// No wgpu pipeline changes are required.
34///
35/// # Examples
36///
37/// ```rust
38/// # use viewport_lib::annotation::AnnotationLabel;
39/// let mut label = AnnotationLabel::default();
40/// label.world_pos = glam::Vec3::new(0.0, 5.0, 0.0);
41/// label.text = "Inlet".to_string();
42/// label.color = [0.4, 0.8, 1.0, 1.0]; // light blue
43/// ```
44#[derive(Debug, Clone)]
45#[non_exhaustive]
46pub struct AnnotationLabel {
47    /// 3D world-space position the label is pinned to.
48    pub world_pos: glam::Vec3,
49
50    /// Text content to display.
51    pub text: String,
52
53    /// Optional world-space endpoint for an offset callout leader line.
54    ///
55    /// When `Some(end)`, callers should project `end` to screen space and draw
56    /// a line segment from the leader end to the label's screen position.
57    pub leader_end: Option<glam::Vec3>,
58
59    /// RGBA colour in linear float format. Default: opaque white `[1.0, 1.0, 1.0, 1.0]`.
60    pub color: [f32; 4],
61
62    /// Font size in egui points. Default: `14.0`.
63    pub font_size: f32,
64
65    /// Whether to draw a semi-transparent background rectangle behind the text.
66    /// Default: `true`.
67    pub background: bool,
68}
69
70impl Default for AnnotationLabel {
71    fn default() -> Self {
72        Self {
73            world_pos: glam::Vec3::ZERO,
74            text: String::new(),
75            leader_end: None,
76            color: [1.0, 1.0, 1.0, 1.0],
77            font_size: 14.0,
78            background: true,
79        }
80    }
81}
82
83/// Projects a 3D world-space position to 2D screen-space coordinates.
84///
85/// Returns `None` if the point is behind the camera (`clip.w <= 0`) or
86/// outside the view frustum (NDC outside `[-1, 1]` on X or Y).
87///
88/// # Arguments
89///
90/// * `pos` — World-space position to project.
91/// * `view` — Camera view matrix (world -> view space).
92/// * `proj` — Camera projection matrix (view space -> clip space).
93/// * `viewport_size` — Viewport dimensions in physical pixels `[width, height]`.
94///
95/// # Returns
96///
97/// Screen-space position in physical pixels with the origin at the top-left corner,
98/// or `None` if the point would not be visible on screen.
99///
100/// # Examples
101///
102/// ```rust
103/// # use viewport_lib::annotation::world_to_screen;
104/// # use glam::{Mat4, Vec2, Vec3};
105/// let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Y);
106/// let proj = Mat4::perspective_rh(std::f32::consts::FRAC_PI_4, 1.0, 0.1, 100.0);
107/// let screen = world_to_screen(Vec3::ZERO, &view, &proj, [800.0, 600.0]);
108/// assert!(screen.is_some()); // origin is in front of the camera
109/// ```
110pub fn world_to_screen(
111    pos: glam::Vec3,
112    view: &glam::Mat4,
113    proj: &glam::Mat4,
114    viewport_size: [f32; 2],
115) -> Option<glam::Vec2> {
116    // Transform to clip space: proj * view * pos (homogeneous).
117    let clip = *proj * *view * pos.extend(1.0);
118
119    // Points at or behind the camera have w <= 0 and are not visible.
120    if clip.w <= 0.0 {
121        return None;
122    }
123
124    // Perspective division -> NDC in [-1, 1] on each axis.
125    let ndc = glam::Vec3::new(clip.x, clip.y, clip.z) / clip.w;
126
127    // Clip any point outside the view frustum on X or Y.
128    if ndc.x < -1.0 || ndc.x > 1.0 || ndc.y < -1.0 || ndc.y > 1.0 {
129        return None;
130    }
131
132    // Convert NDC to screen pixels.
133    // NDC X: -1 = left edge,  +1 = right edge.
134    // NDC Y: -1 = bottom, +1 = top (OpenGL convention).
135    // Screen Y: 0 = top, viewport_height = bottom -> flip Y.
136    let x = (ndc.x * 0.5 + 0.5) * viewport_size[0];
137    let y = (1.0 - (ndc.y * 0.5 + 0.5)) * viewport_size[1];
138
139    Some(glam::Vec2::new(x, y))
140}
141
142/// Convenience wrapper that extracts camera matrices and viewport size from a [`FrameData`].
143///
144/// Equivalent to calling [`world_to_screen`] with `frame.camera.render_camera.view`,
145/// `frame.camera.render_camera.projection`, and `frame.camera.viewport_size`.
146///
147/// # Arguments
148///
149/// * `pos` — World-space position to project.
150/// * `frame` — Current frame data containing camera matrices and viewport dimensions.
151///
152/// # Returns
153///
154/// Screen-space position in physical pixels (top-left origin), or `None` if not visible.
155pub fn world_to_screen_from_frame(pos: glam::Vec3, frame: &FrameData) -> Option<glam::Vec2> {
156    world_to_screen(
157        pos,
158        &frame.camera.render_camera.view,
159        &frame.camera.render_camera.projection,
160        frame.camera.viewport_size,
161    )
162}
163
164/// Draws a slice of [`AnnotationLabel`]s using an egui `Painter`.
165///
166/// For each label:
167///
168/// 1. Projects `world_pos` to screen space; skips the label if not visible.
169/// 2. Converts `color` to `egui::Color32`.
170/// 3. Draws the text with an optional semi-transparent background.
171/// 4. If `leader_end` is `Some` and also projects successfully, draws a 1 px leader line
172///    from the leader endpoint to the label anchor.
173///
174/// Clipping is handled automatically by egui: the painter clips to its own rect, so
175/// passing a tight `clip_rect` (the viewport rectangle) is sufficient.
176///
177/// # Arguments
178///
179/// * `painter` — An egui painter whose clip rect covers the viewport.
180/// * `labels` — Slice of labels to draw.
181/// * `view` — Camera view matrix.
182/// * `proj` — Camera projection matrix.
183/// * `viewport_size` — Viewport in physical pixels `[width, height]`.
184/// * `_clip_rect` — Viewport rectangle (egui clips automatically; passed for explicitness).
185///
186/// # Feature
187///
188/// This function is only available when the `egui` feature of `viewport-lib` is enabled.
189#[cfg(feature = "egui")]
190pub fn draw_annotation_labels(
191    painter: &egui::Painter,
192    labels: &[AnnotationLabel],
193    view: &glam::Mat4,
194    proj: &glam::Mat4,
195    viewport_size: [f32; 2],
196    clip_rect: egui::Rect,
197) {
198    use egui::{Color32, FontId, Stroke, Vec2, pos2};
199
200    // `world_to_screen` returns positions relative to the viewport top-left corner.
201    // Add `clip_rect.min` to convert to window-absolute egui coordinates.
202    let ox = clip_rect.min.x;
203    let oy = clip_rect.min.y;
204
205    for label in labels {
206        let Some(screen) = world_to_screen(label.world_pos, view, proj, viewport_size) else {
207            continue;
208        };
209
210        let [r, g, b, a] = label.color;
211        let color = Color32::from_rgba_unmultiplied(
212            (r * 255.0) as u8,
213            (g * 255.0) as u8,
214            (b * 255.0) as u8,
215            (a * 255.0) as u8,
216        );
217
218        let anchor = pos2(ox + screen.x, oy + screen.y);
219
220        // Draw leader line first (underneath the text).
221        if let Some(end_world) = label.leader_end {
222            if let Some(end_screen) = world_to_screen(end_world, view, proj, viewport_size) {
223                let end_pos = pos2(ox + end_screen.x, oy + end_screen.y);
224                painter.line_segment([end_pos, anchor], Stroke::new(1.0, color));
225            }
226        }
227
228        // Draw label text.
229        let font = FontId::proportional(label.font_size);
230
231        if label.background {
232            // Measure the text first so we can draw the background rectangle.
233            let galley = painter.layout_no_wrap(label.text.clone(), font.clone(), color);
234            let text_size = galley.size();
235            let bg_rect =
236                egui::Rect::from_min_size(anchor, Vec2::new(text_size.x + 4.0, text_size.y + 2.0));
237            painter.rect_filled(bg_rect, 2.0, Color32::from_rgba_unmultiplied(0, 0, 0, 160));
238            painter.galley(pos2(anchor.x + 2.0, anchor.y + 1.0), galley, color);
239        } else {
240            painter.text(anchor, egui::Align2::LEFT_TOP, &label.text, font, color);
241        }
242    }
243}