use crate::{
MainWindow, SettingsModel, SolidPreviewModel, ViewportModel,
bridge::render_thread::{RenderContext, load_env_map},
gui::{
env_map_status_text, show_toast,
solid_preview::preview_state::{CameraPose, SolidPreviewState},
},
settings::SettingsPersister,
};
use slint::{ComponentHandle, SharedString};
use std::{
f32::consts::FRAC_PI_2,
sync::{Arc, Mutex},
};
pub(in crate::gui) fn resubmit_at_current_pose(
ui: &MainWindow,
ctx: &RenderContext,
preview_state: &SolidPreviewState,
) {
if ctx.active_planes.is_empty() {
return;
}
let on_live_tab = ui.get_render_view_tab() == 0;
if on_live_tab && ui.global::<ViewportModel>().get_live_view_mode() != 0 {
return;
}
let view_mode = if on_live_tab {
0
} else {
ui.global::<SolidPreviewModel>().get_view_mode() as u8
};
let planes = ctx
.active_planes
.iter()
.map(|p| (glam::Vec3::from(p.normal), -p.d))
.collect();
let scale = ui.window().scale_factor();
let size = if on_live_tab {
(
(ui.global::<SolidPreviewModel>().get_live_viewport_width() * scale) as u32,
(ui.global::<SolidPreviewModel>().get_live_viewport_height() * scale) as u32,
)
} else {
let viewport_size = (
(ui.global::<SolidPreviewModel>().get_viewport_width() * scale) as u32,
(ui.global::<SolidPreviewModel>().get_viewport_height() * scale) as u32,
);
contained_request_size(view_mode, viewport_size, (ctx.width, ctx.height))
};
preview_state.request_redraw_with_gear(
planes,
CameraPose {
yaw: ctx.yaw,
pitch: ctx.pitch,
distance: ctx.distance,
},
size,
view_mode,
ctx.design_gear,
);
}
#[must_use]
pub(in crate::gui) fn contained_request_size(
view_mode: u8,
viewport_size: (u32, u32),
render_size: (u32, u32),
) -> (u32, u32) {
if !matches!(view_mode, 1 | 2) {
return viewport_size;
}
let (viewport_width, viewport_height) = viewport_size;
let (render_width, render_height) = render_size;
if viewport_width == 0 || viewport_height == 0 || render_width == 0 || render_height == 0 {
return viewport_size;
}
let viewport_aspect = viewport_width as f32 / viewport_height as f32;
let render_aspect = render_width as f32 / render_height as f32;
if render_aspect > viewport_aspect {
let height = (viewport_width as f32 / render_aspect).round().max(1.0) as u32;
(viewport_width, height)
} else {
let width = (viewport_height as f32 * render_aspect).round().max(1.0) as u32;
(width, viewport_height)
}
}
pub(in crate::gui) use indicatrix_solid::preview::camera::{
fit_distance_for_radius, orbit_distance_bounds, wrap_pitch,
};
pub(in crate::gui) fn resubmit_live_solid(
ui: &MainWindow,
ctx: &RenderContext,
preview_state: &SolidPreviewState,
) {
if ctx.active_planes.is_empty() {
return;
}
let planes = ctx
.active_planes
.iter()
.map(|p| (glam::Vec3::from(p.normal), -p.d))
.collect();
let scale = ui.window().scale_factor();
let size = (
(ui.global::<SolidPreviewModel>().get_live_viewport_width() * scale) as u32,
(ui.global::<SolidPreviewModel>().get_live_viewport_height() * scale) as u32,
);
preview_state.request_redraw_with_gear(
planes,
CameraPose {
yaw: ctx.yaw,
pitch: ctx.pitch,
distance: ctx.distance,
},
size,
0,
ctx.design_gear,
);
}
fn setup_light_callbacks(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
) {
let render_ctx_light = render_ctx.clone();
let settings_store_light = settings_store.clone();
ui.global::<ViewportModel>()
.on_light_move(move |dx: f32, dy: f32| {
let (light_yaw, light_pitch) = {
let mut ctx = RenderContext::lock(&render_ctx_light);
ctx.light_yaw = dx.mul_add(0.01, ctx.light_yaw);
ctx.light_pitch = dy.mul_add(0.01, ctx.light_pitch).clamp(0.15, 1.55);
ctx.dirty = true;
(ctx.light_yaw, ctx.light_pitch)
};
settings_store_light.update(|s| {
s.settings.light_yaw_deg = light_yaw.to_degrees();
s.settings.light_pitch_deg = light_pitch.to_degrees();
});
});
let render_ctx_light_pos = render_ctx.clone();
let settings_store_light_pos = settings_store.clone();
ui.global::<ViewportModel>()
.on_light_pos_changed(move |yaw_deg: f32, pitch_deg: f32| {
{
let mut ctx = RenderContext::lock(&render_ctx_light_pos);
ctx.light_yaw = yaw_deg.to_radians();
ctx.light_pitch = pitch_deg.to_radians().clamp(0.15, 1.55);
ctx.dirty = true;
}
settings_store_light_pos.update(|s| {
s.settings.light_yaw_deg = yaw_deg;
s.settings.light_pitch_deg = pitch_deg;
});
});
}
fn setup_set_view_callback(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
preview_state: &Arc<SolidPreviewState>,
mesh_bounding_radius: &Arc<Mutex<f64>>,
) {
let render_ctx = render_ctx.clone();
let settings_store = settings_store.clone();
let preview_state = Arc::clone(preview_state);
let mesh_bounding_radius = Arc::clone(mesh_bounding_radius);
let ui_weak = ui.as_weak();
ui.global::<ViewportModel>().on_set_view(move |kind: i32| {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let mut ctx = RenderContext::lock(&render_ctx);
if kind == 5 {
let radius = *mesh_bounding_radius.lock().unwrap();
let (min, max) = orbit_distance_bounds(radius);
ctx.distance = fit_distance_for_radius(radius).clamp(min, max);
} else {
let (yaw, pitch) = match kind {
1 => (0.0, FRAC_PI_2), 2 => (0.0, -FRAC_PI_2), 3 => (-FRAC_PI_2, 0.0), 4 => (FRAC_PI_2, 0.0), _ => (0.0, 0.0), };
ctx.yaw = yaw;
ctx.pitch = pitch;
}
ctx.dirty = true;
resubmit_at_current_pose(&ui, &ctx, &preview_state);
let (yaw, pitch, distance) = (ctx.yaw, ctx.pitch, ctx.distance);
drop(ctx);
settings_store.update(|s| {
s.settings.camera_yaw = yaw;
s.settings.camera_pitch = pitch;
s.settings.camera_distance = distance;
});
});
}
pub(in crate::gui) fn setup_camera_and_lighting_callbacks(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
preview_state: &Arc<SolidPreviewState>,
mesh_bounding_radius: &Arc<Mutex<f64>>,
) {
let render_ctx_orbit = render_ctx.clone();
let settings_store_orbit = settings_store.clone();
let preview_state_orbit = Arc::clone(preview_state);
let ui_weak_orbit = ui.as_weak();
ui.global::<ViewportModel>()
.on_camera_orbit(move |dx: f32, dy: f32| {
let Some(ui) = ui_weak_orbit.upgrade() else {
return;
};
let (yaw, pitch) = {
let mut ctx = RenderContext::lock(&render_ctx_orbit);
ctx.yaw = dx.mul_add(-0.008, ctx.yaw);
ctx.pitch = wrap_pitch(dy.mul_add(0.008, ctx.pitch));
ctx.dirty = true;
resubmit_at_current_pose(&ui, &ctx, &preview_state_orbit);
(ctx.yaw, ctx.pitch)
};
settings_store_orbit.update(|s| {
s.settings.camera_yaw = yaw;
s.settings.camera_pitch = pitch;
});
});
let render_ctx_zoom = render_ctx.clone();
let settings_store_zoom = settings_store.clone();
let preview_state_zoom = Arc::clone(preview_state);
let mesh_bounding_radius_zoom = Arc::clone(mesh_bounding_radius);
let ui_weak_zoom = ui.as_weak();
ui.global::<ViewportModel>()
.on_camera_zoom(move |delta: f32| {
let Some(ui) = ui_weak_zoom.upgrade() else {
return;
};
let distance = {
let mut ctx = RenderContext::lock(&render_ctx_zoom);
let radius = *mesh_bounding_radius_zoom.lock().unwrap();
let (min, max) = orbit_distance_bounds(radius);
ctx.distance = delta.mul_add(-0.002, ctx.distance).clamp(min, max);
ctx.dirty = true;
resubmit_at_current_pose(&ui, &ctx, &preview_state_zoom);
ctx.distance
};
settings_store_zoom.update(|s| s.settings.camera_distance = distance);
});
setup_light_callbacks(ui, render_ctx, settings_store);
setup_set_view_callback(
ui,
render_ctx,
settings_store,
preview_state,
mesh_bounding_radius,
);
let render_ctx_reset = render_ctx.clone();
let settings_store_reset = settings_store.clone();
let preview_state_reset = Arc::clone(preview_state);
let ui_weak_reset = ui.as_weak();
ui.global::<ViewportModel>().on_reset_camera(move || {
let Some(ui) = ui_weak_reset.upgrade() else {
return;
};
{
let mut ctx = RenderContext::lock(&render_ctx_reset);
ctx.yaw = 0.60;
ctx.pitch = 0.45;
ctx.distance = 2.4;
ctx.light_yaw = 0.85;
ctx.light_pitch = 0.95;
ctx.dirty = true;
resubmit_at_current_pose(&ui, &ctx, &preview_state_reset);
drop(ctx);
}
settings_store_reset.update(|s| {
s.settings.camera_yaw = 0.60;
s.settings.camera_pitch = 0.45;
s.settings.camera_distance = 2.4;
s.settings.light_yaw_deg = 0.85_f32.to_degrees();
s.settings.light_pitch_deg = 0.95_f32.to_degrees();
});
});
}
pub(in crate::gui) fn setup_camera_drag_callbacks(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
) {
let render_ctx_begin = render_ctx.clone();
ui.global::<ViewportModel>().on_camera_drag_begin(move || {
RenderContext::lock(&render_ctx_begin).camera_drag_held = true;
});
let render_ctx_end = render_ctx.clone();
ui.global::<ViewportModel>().on_camera_drag_end(move || {
RenderContext::lock(&render_ctx_end).camera_drag_held = false;
});
}
pub(in crate::gui) fn setup_environment_map_callbacks(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
settings_store: &Arc<SettingsPersister>,
) {
let render_ctx_load = render_ctx.clone();
let settings_store_load = settings_store.clone();
let ui_weak_load = ui.as_weak();
ui.global::<SettingsModel>()
.on_load_env_map(move |raw_path: SharedString| {
let Some(ui) = ui_weak_load.upgrade() else {
return;
};
let path = raw_path.to_string();
match load_env_map(&path) {
Ok(map) => {
let status = env_map_status_text(&map, &path);
{
let mut ctx = RenderContext::lock(&render_ctx_load);
ctx.env_map = Some(map);
ctx.dirty = true;
}
settings_store_load.update(|s| s.settings.env_map_path.clone_from(&path));
ui.global::<SettingsModel>()
.set_env_map_status(status.clone().into());
ui.global::<SettingsModel>().set_env_map_loaded(true);
show_toast(&ui, &status, "info");
}
Err(err) => {
show_toast(
&ui,
&format!("Could not load HDR environment: {err}"),
"error",
);
}
}
});
let render_ctx_clear = render_ctx.clone();
let settings_store_clear = settings_store.clone();
let ui_weak_clear = ui.as_weak();
ui.global::<SettingsModel>().on_clear_env_map(move || {
let Some(ui) = ui_weak_clear.upgrade() else {
return;
};
{
let mut ctx = RenderContext::lock(&render_ctx_clear);
ctx.env_map = None;
ctx.dirty = true;
}
settings_store_clear.update(|s| s.settings.env_map_path.clear());
ui.global::<SettingsModel>()
.set_env_map_status(String::new().into());
ui.global::<SettingsModel>().set_env_map_loaded(false);
show_toast(
&ui,
"Cleared HDR environment; back to the studio rig.",
"info",
);
});
let ui_weak_pick_hdr = ui.as_weak();
ui.global::<SettingsModel>()
.on_pick_hdr_file(move |current: SharedString| {
use crate::gui::pickers::{PickerFilter, PickerKind, PickerRequest, pick};
let Some(ui) = ui_weak_pick_hdr.upgrade() else {
return;
};
let starting_dir = crate::gui::starting_dir_from_picker_field(current.as_str());
let request = PickerRequest {
kind: PickerKind::OpenFile,
title: None,
filters: vec![PickerFilter {
label: "Radiance HDR".to_string(),
extensions: vec!["hdr".to_string()],
}],
default_file_name: None,
starting_dir,
};
pick(&ui, request, |ui, picked| {
if let Some(path) = picked {
ui.global::<SettingsModel>()
.set_env_map_path(path.display().to_string().into());
}
});
});
}
#[cfg(test)]
mod tests {
use super::{contained_request_size, fit_distance_for_radius, orbit_distance_bounds};
#[test]
fn solid_and_diagram_modes_are_untouched() {
for mode in [0, 3] {
assert_eq!(
contained_request_size(mode, (900, 500), (1920, 1080)),
(900, 500)
);
}
}
#[test]
fn wider_render_than_viewport_shrinks_height_to_the_letterboxed_rectangle() {
let (width, height) = contained_request_size(2, (900, 700), (1920, 1080));
assert_eq!(width, 900);
assert_eq!(height, 506);
}
#[test]
fn narrower_render_than_viewport_is_already_the_on_screen_size() {
let (width, height) = contained_request_size(1, (900, 500), (640, 480));
assert_eq!(height, 500);
assert_eq!(width, 667);
}
#[test]
fn zero_sized_input_is_returned_unchanged_rather_than_dividing_by_zero() {
assert_eq!(contained_request_size(2, (0, 500), (1920, 1080)), (0, 500));
assert_eq!(contained_request_size(2, (900, 500), (0, 1080)), (900, 500));
}
#[test]
fn equal_aspect_ratios_pass_through_unchanged() {
assert_eq!(
contained_request_size(2, (1920, 1080), (1920, 1080)),
(1920, 1080)
);
}
#[test]
fn default_bounding_radius_reproduces_the_old_fixed_clamp() {
let (min, max) = orbit_distance_bounds(1.5);
assert!((min - 1.2).abs() < 1e-6, "got {min}");
assert!((max - 8.0).abs() < 1e-6, "got {max}");
}
#[test]
fn a_larger_mesh_gets_a_wider_clamp() {
let (small_min, small_max) = orbit_distance_bounds(1.5);
let (large_min, large_max) = orbit_distance_bounds(4.5);
assert!(large_min > small_min);
assert!(large_max > small_max);
}
#[test]
fn a_zero_radius_never_collapses_the_clamp_to_a_single_point() {
let (min, max) = orbit_distance_bounds(0.0);
assert!(min > 0.0);
assert!(max > min);
}
#[test]
fn fit_distance_grows_with_the_bounding_radius() {
assert!(fit_distance_for_radius(3.0) > fit_distance_for_radius(1.5));
}
#[test]
fn fit_distance_stays_within_the_matching_orbit_clamp() {
let radius = 1.5_f64;
let (min, max) = orbit_distance_bounds(radius);
let fit = fit_distance_for_radius(radius).clamp(min, max);
assert!(fit >= min && fit <= max);
}
}