use egui::{Align2, Color32, FontId, PointerButton, Pos2, Response, Sense, Ui};
use egui_wgpu::RenderState;
use crate::core::scene3d::axes::{dash_segments, labelled_axes_chrome};
use crate::core::scene3d::camera::{Camera, CameraDirection, CameraFace};
use crate::core::scene3d::interaction::{OrbitDrag, PanDrag, window_to_ndc};
use crate::core::scene3d::mat4::Vec3;
use crate::core::scene3d::pick::{picking_segment, segment_triangles_intersection};
use crate::core::scene3d::plane::segment_plane_intersect;
use crate::render::gpu_scene3d::{
OVERVIEW_SIZE_PX, PointMarker, Scene3dFog, Scene3dGeometry, Scene3dId, Scene3dShading,
install_scene3d, paint_scene3d_with, set_scene3d, snapshot_scene3d_with,
};
const DEFAULT_BACKGROUND: Color32 = Color32::from_gray(51);
const DEFAULT_FOREGROUND: Color32 = Color32::WHITE;
const DEFAULT_TEXT_COLOR: Color32 = Color32::WHITE;
const AXES_FONT_SIZE: f32 = 10.0;
const OVERVIEW_ID_SALT: Scene3dId = 0x4F56_5256_4945_5750;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FogMode {
#[default]
None,
Linear,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SceneInteractiveMode {
#[default]
Rotate,
Pan,
Disabled,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum LeftGesture {
Orbit,
Pan,
}
impl SceneInteractiveMode {
fn left_gesture(self, ctrl: bool) -> Option<LeftGesture> {
match (self, ctrl) {
(SceneInteractiveMode::Rotate, false) | (SceneInteractiveMode::Pan, true) => {
Some(LeftGesture::Orbit)
}
(SceneInteractiveMode::Pan, false) | (SceneInteractiveMode::Rotate, true) => {
Some(LeftGesture::Pan)
}
(SceneInteractiveMode::Disabled, _) => None,
}
}
fn wheel_zooms(self) -> bool {
!matches!(self, SceneInteractiveMode::Disabled)
}
}
pub struct SceneWidget {
id: Scene3dId,
camera: Camera,
bounds: (Vec3, Vec3),
box_color: Color32,
text_color: Color32,
axes_labels: [String; 3],
background: Color32,
fog_mode: FogMode,
light_shininess: f32,
orientation_indicator: bool,
content: Scene3dGeometry,
interaction_mode: SceneInteractiveMode,
orbit: Option<OrbitDrag>,
pan: Option<PanDrag>,
}
impl SceneWidget {
pub fn new(render_state: &RenderState, id: Scene3dId) -> Self {
install_scene3d(render_state);
let bounds = (Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0));
let mut camera = Camera::new(
30.0,
0.1,
100.0,
(1.0, 1.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, -1.0),
Vec3::new(0.0, 1.0, 0.0),
);
camera.extrinsic.reset(CameraFace::Front);
camera.reset_camera(bounds);
camera.adjust_depth_extent(bounds);
let widget = SceneWidget {
id,
camera,
bounds,
box_color: DEFAULT_FOREGROUND,
text_color: DEFAULT_TEXT_COLOR,
axes_labels: [String::new(), String::new(), String::new()],
background: DEFAULT_BACKGROUND,
fog_mode: FogMode::None,
light_shininess: 0.0,
orientation_indicator: true,
content: Scene3dGeometry::new(),
interaction_mode: SceneInteractiveMode::default(),
orbit: None,
pan: None,
};
widget.upload(render_state);
set_scene3d(render_state, widget.overview_id(), &overview_geometry());
widget
}
pub fn set_background(&mut self, color: Color32) {
self.background = color;
}
pub fn background(&self) -> Color32 {
self.background
}
pub fn set_foreground_color(&mut self, render_state: &RenderState, color: Color32) {
if self.box_color != color {
self.box_color = color;
self.upload(render_state);
}
}
pub fn foreground_color(&self) -> Color32 {
self.box_color
}
pub fn set_text_color(&mut self, render_state: &RenderState, color: Color32) {
if self.text_color != color {
self.text_color = color;
self.upload(render_state);
}
}
pub fn text_color(&self) -> Color32 {
self.text_color
}
pub fn set_axes_labels(
&mut self,
xlabel: Option<&str>,
ylabel: Option<&str>,
zlabel: Option<&str>,
) {
for (slot, label) in self.axes_labels.iter_mut().zip([xlabel, ylabel, zlabel]) {
if let Some(label) = label {
*slot = label.to_string();
}
}
}
pub fn axes_labels(&self) -> (&str, &str, &str) {
(
&self.axes_labels[0],
&self.axes_labels[1],
&self.axes_labels[2],
)
}
pub fn set_fog_mode(&mut self, mode: FogMode) {
self.fog_mode = mode;
}
pub fn fog_mode(&self) -> FogMode {
self.fog_mode
}
pub fn set_light_shininess(&mut self, shininess: f32) {
self.light_shininess = shininess;
}
pub fn light_shininess(&self) -> f32 {
self.light_shininess
}
fn shading(&self) -> Scene3dShading {
Scene3dShading {
fog: (self.fog_mode == FogMode::Linear)
.then(|| Scene3dFog::linear(&self.camera, self.bounds, self.background)),
shininess: self.light_shininess,
}
}
pub fn set_orientation_indicator_visible(&mut self, visible: bool) {
self.orientation_indicator = visible;
}
pub fn is_orientation_indicator_visible(&self) -> bool {
self.orientation_indicator
}
fn overview_id(&self) -> Scene3dId {
self.id ^ OVERVIEW_ID_SALT
}
pub fn center(&self) -> Vec3 {
(self.bounds.0 + self.bounds.1) * 0.5
}
pub fn camera(&self) -> &Camera {
&self.camera
}
pub fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
pub fn set_bounds(&mut self, render_state: &RenderState, bounds: (Vec3, Vec3)) {
self.bounds = bounds;
self.camera.reset_camera(bounds);
self.camera.adjust_depth_extent(bounds);
self.upload(render_state);
}
pub fn set_bounds_keep_view(&mut self, render_state: &RenderState, bounds: (Vec3, Vec3)) {
self.bounds = bounds;
self.camera.adjust_depth_extent(bounds);
self.upload(render_state);
}
pub fn set_geometry(&mut self, render_state: &RenderState, geometry: Scene3dGeometry) {
self.content = geometry;
self.upload(render_state);
}
pub fn reset_camera(&mut self) {
self.camera.reset_camera(self.bounds);
self.camera.adjust_depth_extent(self.bounds);
}
pub fn set_viewpoint(&mut self, face: CameraFace) {
self.camera.extrinsic.reset(face);
self.camera.reset_camera(self.bounds);
self.camera.adjust_depth_extent(self.bounds);
}
pub fn set_interactive_mode(&mut self, mode: SceneInteractiveMode) {
self.interaction_mode = mode;
self.orbit = None;
self.pan = None;
}
pub fn interactive_mode(&self) -> SceneInteractiveMode {
self.interaction_mode
}
pub fn rotate_scene(&mut self, angle_degrees: f32) {
let center = self.center();
self.camera
.extrinsic
.orbit(CameraDirection::Left, center, angle_degrees);
self.camera.adjust_depth_extent(self.bounds);
}
fn upload(&self, render_state: &RenderState) {
let mut geometry = Scene3dGeometry::new();
geometry.add_bounding_box_with_axes(self.bounds, self.box_color);
self.add_tick_lines(&mut geometry);
geometry.extend_from(&self.content);
set_scene3d(render_state, self.id, &geometry);
}
fn add_tick_lines(&self, geometry: &mut Scene3dGeometry) {
let chrome = labelled_axes_chrome(self.bounds, ["", "", ""]);
let unit = (self.bounds.1 - self.bounds.0).length() / 500.0;
let color = Color32::from_rgba_unmultiplied(
self.text_color.r(),
self.text_color.g(),
self.text_color.b(),
(self.text_color.a() as f32 * 0.6).round() as u8,
);
for &(a, b) in &chrome.tick_segments {
for (s, e) in dash_segments(a, b, 5.0 * unit, 10.0 * unit) {
geometry.add_line(s.to_array(), e.to_array(), color);
}
}
}
fn draw_axes_labels(&self, ui: &Ui, rect: egui::Rect) {
let chrome = labelled_axes_chrome(
self.bounds,
[
self.axes_labels[0].as_str(),
self.axes_labels[1].as_str(),
self.axes_labels[2].as_str(),
],
);
let mvp = self.camera.matrix();
let painter = ui.painter_at(rect);
let font = FontId::proportional(AXES_FONT_SIZE);
for label in chrome.axis_labels.iter().chain(&chrome.tick_labels) {
let ndc = mvp.transform_point(label.position, true);
if !(ndc.x.is_finite()
&& ndc.y.is_finite()
&& ndc.x.abs() <= 1.2
&& ndc.y.abs() <= 1.2
&& (-1.0..=1.0).contains(&ndc.z))
{
continue;
}
let pos = Pos2::new(
rect.min.x + (ndc.x + 1.0) * 0.5 * rect.width(),
rect.min.y + (1.0 - ndc.y) * 0.5 * rect.height(),
);
painter.text(
pos,
Align2::CENTER_CENTER,
&label.text,
font.clone(),
self.text_color,
);
}
}
pub fn show(&mut self, ui: &mut Ui) -> Response {
let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
let ppp = ui.ctx().pixels_per_point();
let size_px = (
(rect.width() * ppp).max(1.0),
(rect.height() * ppp).max(1.0),
);
self.camera.set_size(size_px);
let center = self.center();
let to_local = |p: Pos2| ((p.x - rect.min.x) * ppp, (p.y - rect.min.y) * ppp);
let press_origin = ui.ctx().input(|i| i.pointer.press_origin());
if response.drag_started_by(PointerButton::Primary)
&& let Some(p) = press_origin
{
let ctrl = ui.ctx().input(|i| i.modifiers.command);
let win = to_local(p);
match self.interaction_mode.left_gesture(ctrl) {
Some(LeftGesture::Orbit) => {
let pivot = self
.pick(window_to_ndc(win, size_px))
.map_or(center, |hit| hit.position);
self.orbit = Some(OrbitDrag::begin(&self.camera, win, pivot));
}
Some(LeftGesture::Pan) => {
let plane_z = self
.pick(window_to_ndc(win, size_px))
.map_or(1.0, |hit| hit.ndc_depth);
self.pan = Some(PanDrag::begin(win, size_px, plane_z));
}
None => {}
}
}
if response.dragged_by(PointerButton::Primary)
&& let Some(p) = response.interact_pointer_pos()
{
let local = to_local(p);
if let Some(orbit) = self.orbit {
orbit.update(&mut self.camera, local, size_px);
}
if let Some(mut pan) = self.pan {
pan.update(&mut self.camera, local, size_px);
self.pan = Some(pan);
}
}
if response.drag_stopped_by(PointerButton::Primary) {
self.orbit = None;
self.pan = None;
}
if self.interaction_mode.wheel_zooms()
&& let Some(p) = response.hover_pos()
{
let steps = ui.input(|i| wheel_zoom_steps(&i.events));
let ndc = window_to_ndc(to_local(p), size_px);
for zoom_in in steps {
let ndc_z = self.pick(ndc).map_or(1.0, |hit| hit.ndc_depth);
self.camera.zoom_at(ndc, ndc_z, zoom_in);
}
}
self.camera.adjust_depth_extent(self.bounds);
paint_scene3d_with(
ui,
rect,
self.id,
&self.camera,
self.background,
self.shading(),
self.orientation_indicator.then(|| self.overview_id()),
);
self.draw_axes_labels(ui, rect);
response
}
pub fn snapshot(&self, render_state: &RenderState, size_px: (u32, u32)) -> Option<Vec<u8>> {
snapshot_scene3d_with(
render_state,
self.id,
&self.camera,
self.background,
size_px,
self.shading(),
self.orientation_indicator.then(|| self.overview_id()),
)
}
pub fn pick(&self, ndc: (f32, f32)) -> Option<ScenePick> {
let segment = picking_segment(&self.camera, ndc)?;
let mvp = self.camera.matrix();
let mut best: Option<ScenePick> = None;
let mut consider = |cand: ScenePick| {
if best.is_none_or(|b| cand.ndc_depth < b.ndc_depth) {
best = Some(cand);
}
};
let triangles = self.content.pick_triangles();
if let Some(hit) = segment_triangles_intersection(segment, &triangles).first() {
let position = hit.position(segment.0, segment.1);
let ndc_depth = mvp.transform_point(position, true).z;
consider(ScenePick {
position,
ndc_depth,
kind: ScenePickKind::Surface,
});
}
let (vw, vh) = self.camera.size();
let radius_ndc_x = 2.0 * PICK_POINT_TOLERANCE_PX / vw.max(1.0);
let radius_ndc_y = 2.0 * PICK_POINT_TOLERANCE_PX / vh.max(1.0);
for (index, world) in self.content.pick_points().into_iter().enumerate() {
let p = mvp.transform_point(world, true);
if !(-1.0..=1.0).contains(&p.z) {
continue; }
let dx = (p.x - ndc.0) / radius_ndc_x;
let dy = (p.y - ndc.1) / radius_ndc_y;
if dx * dx + dy * dy <= 1.0 {
consider(ScenePick {
position: world,
ndc_depth: p.z,
kind: ScenePickKind::Point { index },
});
}
}
let square_ndc_x = PICK_LINE_TOLERANCE_PX / vw.max(1.0);
let square_ndc_y = PICK_LINE_TOLERANCE_PX / vh.max(1.0);
for (index, &pos) in self.content.line_pick_anchors().iter().enumerate() {
let world = Vec3::new(pos[0], pos[1], pos[2]);
let p = mvp.transform_point(world, true);
if !(-1.0..=1.0).contains(&p.z) {
continue;
}
if (p.x - ndc.0).abs() < square_ndc_x && (p.y - ndc.1).abs() < square_ndc_y {
consider(ScenePick {
position: world,
ndc_depth: p.z,
kind: ScenePickKind::LinePoint { index },
});
}
}
for (image, layer) in self.content.images.iter().enumerate() {
let hits = segment_plane_intersect(
segment.0,
segment.1,
Vec3::new(0.0, 0.0, 1.0),
Vec3::new(0.0, 0.0, layer.origin[2]),
);
let [hit] = hits.as_slice() else {
continue; };
let col_f = (hit.x - layer.origin[0]) / layer.scale[0];
let row_f = (hit.y - layer.origin[1]) / layer.scale[1];
if col_f < 0.0 || row_f < 0.0 {
continue; }
let (col, row) = (col_f as usize, row_f as usize);
if row >= layer.height as usize || col >= layer.width as usize {
continue; }
consider(ScenePick {
position: *hit,
ndc_depth: mvp.transform_point(*hit, true).z,
kind: ScenePickKind::Image { image, row, col },
});
}
for (mesh, tm) in self.content.textured_meshes.iter().enumerate() {
let tris: Vec<[Vec3; 3]> = tm
.vertices
.chunks_exact(3)
.map(|t| {
[
Vec3::from_array(t[0]),
Vec3::from_array(t[1]),
Vec3::from_array(t[2]),
]
})
.collect();
if let Some(hit) = segment_triangles_intersection(segment, &tris).first() {
let position = hit.position(segment.0, segment.1);
consider(ScenePick {
position,
ndc_depth: mvp.transform_point(position, true).z,
kind: ScenePickKind::TexturedSurface { mesh },
});
}
}
best
}
}
pub const PICK_POINT_TOLERANCE_PX: f32 = 7.0;
pub const PICK_LINE_TOLERANCE_PX: f32 = 5.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ScenePickKind {
Surface,
Point { index: usize },
LinePoint { index: usize },
Image {
image: usize,
row: usize,
col: usize,
},
TexturedSurface { mesh: usize },
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScenePick {
pub position: Vec3,
pub ndc_depth: f32,
pub kind: ScenePickKind,
}
fn overview_geometry() -> Scene3dGeometry {
let mut g = Scene3dGeometry::new();
g.add_point(
[0.0; 3],
Color32::from_rgba_unmultiplied(255, 255, 255, 128),
OVERVIEW_SIZE_PX as f32,
PointMarker::Circle,
);
g.add_line([0.0; 3], [2.5, 0.0, 0.0], Color32::from_rgb(255, 0, 0));
g.add_line([0.0; 3], [0.0, 2.5, 0.0], Color32::from_rgb(0, 255, 0));
g.add_line([0.0; 3], [0.0, 0.0, 2.5], Color32::from_rgb(0, 0, 255));
g
}
fn wheel_zoom_steps(events: &[egui::Event]) -> Vec<bool> {
events
.iter()
.filter_map(|e| match e {
egui::Event::MouseWheel { delta, .. } if delta.y != 0.0 => Some(delta.y > 0.0),
_ => None,
})
.collect()
}
const VIEWPOINT_PRESETS: [(CameraFace, &str, &str); 7] = [
(CameraFace::Front, "Front", "View along the -Z axis"),
(CameraFace::Back, "Back", "View along the +Z axis"),
(CameraFace::Top, "Top", "View along the -Y axis"),
(CameraFace::Bottom, "Bottom", "View along the +Y axis"),
(CameraFace::Right, "Right", "View along the -X axis"),
(CameraFace::Left, "Left", "View along the +X axis"),
(CameraFace::Side, "Side", "Side view"),
];
pub fn viewpoint_menu(ui: &mut Ui, scene: &mut SceneWidget) -> Option<CameraFace> {
let mut chosen = None;
ui.menu_button("View", |ui| {
for (face, label, tip) in VIEWPOINT_PRESETS {
if ui.button(label).on_hover_text(tip).clicked() {
scene.set_viewpoint(face);
chosen = Some(face);
ui.close();
}
}
})
.response
.on_hover_text("Reset the viewpoint to a defined position");
chosen
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, Vec2};
fn wheel(unit: MouseWheelUnit, x: f32, y: f32) -> Event {
Event::MouseWheel {
unit,
delta: Vec2::new(x, y),
phase: TouchPhase::Move,
modifiers: Modifiers::NONE,
}
}
#[test]
fn one_wheel_event_is_one_step_regardless_of_magnitude() {
assert_eq!(
wheel_zoom_steps(&[wheel(MouseWheelUnit::Line, 0.0, 1.0)]),
vec![true]
);
assert_eq!(
wheel_zoom_steps(&[wheel(MouseWheelUnit::Line, 0.0, -3.0)]),
vec![false]
);
assert_eq!(
wheel_zoom_steps(&[wheel(MouseWheelUnit::Point, 0.0, 2.5)]),
vec![true]
);
}
#[test]
fn smoothing_frames_without_events_fire_nothing() {
assert_eq!(wheel_zoom_steps(&[]), Vec::<bool>::new());
assert_eq!(wheel_zoom_steps(&[Event::PointerGone]), Vec::<bool>::new());
}
#[test]
fn multiple_events_step_once_each_in_delivery_order() {
let events = [
wheel(MouseWheelUnit::Point, 0.0, 4.0),
wheel(MouseWheelUnit::Point, 0.0, -2.0),
wheel(MouseWheelUnit::Point, 0.0, 1.0),
];
assert_eq!(wheel_zoom_steps(&events), vec![true, false, true]);
}
#[test]
fn horizontal_only_wheel_events_do_not_zoom() {
assert_eq!(
wheel_zoom_steps(&[wheel(MouseWheelUnit::Line, 2.0, 0.0)]),
Vec::<bool>::new()
);
}
#[test]
fn rotate_mode_left_gesture_orbits_and_ctrl_pans() {
let m = SceneInteractiveMode::Rotate;
assert_eq!(m.left_gesture(false), Some(LeftGesture::Orbit));
assert_eq!(m.left_gesture(true), Some(LeftGesture::Pan));
}
#[test]
fn pan_mode_left_gesture_pans_and_ctrl_orbits() {
let m = SceneInteractiveMode::Pan;
assert_eq!(m.left_gesture(false), Some(LeftGesture::Pan));
assert_eq!(m.left_gesture(true), Some(LeftGesture::Orbit));
}
#[test]
fn disabled_mode_binds_no_gesture_and_no_wheel() {
let m = SceneInteractiveMode::Disabled;
assert_eq!(m.left_gesture(false), None);
assert_eq!(m.left_gesture(true), None);
assert!(!m.wheel_zooms());
}
#[test]
fn rotate_and_pan_modes_zoom_on_wheel() {
assert!(SceneInteractiveMode::Rotate.wheel_zooms());
assert!(SceneInteractiveMode::Pan.wheel_zooms());
}
#[test]
fn rotate_is_the_default_mode() {
assert_eq!(
SceneInteractiveMode::default(),
SceneInteractiveMode::Rotate
);
}
}