use crate::{
ExportModel, MainWindow,
bridge::{
export_thread::{self, ComputeTarget, SceneSnapshot, TemplateContext},
render_thread::{RenderContext, load_env_map},
},
gui::{
progress_eta::{EtaEstimator, format_eta},
show_toast,
},
settings::{LightingPreset as SavedLightingPreset, LocalComputeTarget},
};
use indicatrix::{color::ColorSpace, optics::raytracer::LightingPreset};
use slint::ComponentHandle;
use std::{
collections::VecDeque,
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
};
pub(super) const fn compute_target_from_index(index: i32) -> ComputeTarget {
match index {
0 => ComputeTarget::LocalOnly,
1 => ComputeTarget::RemoteOnly,
_ => ComputeTarget::Both,
}
}
#[must_use]
pub(super) const fn apply_export_bounce_cap(
mut scene: SceneSnapshot,
max_bounces: u32,
) -> SceneSnapshot {
scene.max_bounces = max_bounces;
scene
}
#[must_use]
pub(super) fn apply_preset_to_scene(
mut scene: SceneSnapshot,
preset: &SavedLightingPreset,
mesh_bounding_radius: f64,
) -> SceneSnapshot {
scene.light_yaw = preset.light_yaw_deg.to_radians();
scene.light_pitch = preset.light_pitch_deg.to_radians().clamp(0.15, 1.55);
scene.exposure = preset.exposure.clamp(0.2, 5.0);
scene.lighting_preset = LightingPreset::from_label(&preset.lighting_rig);
let (min_distance, max_distance) =
crate::gui::render::camera_lighting::orbit_distance_bounds(mesh_bounding_radius);
scene.distance = preset.camera_distance.clamp(min_distance, max_distance);
if let (Some(yaw), Some(pitch)) = (preset.camera_yaw, preset.camera_pitch) {
scene.yaw = yaw;
scene.pitch = pitch.clamp(-1.48, 1.48);
}
if let Some(path) = &preset.env_map_path
&& let Ok(map) = load_env_map(path)
{
scene.env_map = Some(map);
}
scene
}
pub(super) struct ExportJob {
pub(super) scene: SceneSnapshot,
pub(super) preset_label: String,
}
pub(super) struct ExportQueue {
pub(super) jobs: VecDeque<ExportJob>,
pub(super) total: usize,
pub(super) current_index: usize,
pub(super) completed: Vec<PathBuf>,
pub(super) failures: Vec<String>,
pub(super) cancelled: bool,
pub(super) current_handle: Option<export_thread::ExportHandle>,
pub(super) export_dir: PathBuf,
pub(super) template: String,
pub(super) design: String,
pub(super) designer: String,
pub(super) shape: String,
pub(super) ri: String,
pub(super) width: u32,
pub(super) height: u32,
pub(super) spp: u32,
pub(super) bounces: u32,
pub(super) colorspace: String,
pub(super) params: export_thread::ExportParams,
pub(super) color_space: ColorSpace,
pub(super) remote: export_thread::RemoteSelection,
pub(super) local_compute: LocalComputeTarget,
pub(super) eta: EtaEstimator,
}
pub(super) const fn colorspace_label(cs: ColorSpace) -> &'static str {
match cs {
ColorSpace::Srgb => "sRGB",
ColorSpace::DisplayP3 => "Display P3",
ColorSpace::Rec2020 => "Rec.2020",
ColorSpace::AcesCg => "ACEScg",
}
}
fn template_context_for_job(queue: &ExportQueue, job: &ExportJob) -> TemplateContext {
TemplateContext {
design: queue.design.clone(),
designer: queue.designer.clone(),
shape: queue.shape.clone(),
material: job.scene.material.name.clone(),
ri: queue.ri.clone(),
width: queue.width,
height: queue.height,
spp: queue.spp,
bounces: queue.bounces,
colorspace: queue.colorspace.clone(),
preset: job.preset_label.clone(),
lighting: job.scene.lighting_preset.label().to_string(),
yaw_deg: job.scene.yaw.to_degrees(),
pitch_deg: job.scene.pitch.to_degrees(),
distance: job.scene.distance,
exposure: job.scene.exposure,
}
}
pub(super) fn finish_export_queue(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
queue: &ExportQueue,
) {
ui.global::<ExportModel>().set_is_exporting(false);
RenderContext::lock(render_ctx).export_active_count -= 1;
ui.global::<ExportModel>()
.set_preview_image(slint::Image::default());
ui.global::<ExportModel>()
.set_eta_text(String::new().into());
if queue.cancelled {
ui.global::<ExportModel>().set_has_error(false);
ui.global::<ExportModel>()
.set_status_message("Export cancelled.".into());
return;
}
if queue.failures.is_empty() {
let msg = if queue.completed.len() == 1 {
format!("Exported to {}", queue.completed[0].display())
} else {
format!(
"Exported {} files to {}",
queue.completed.len(),
queue.export_dir.display()
)
};
ui.global::<ExportModel>().set_has_error(false);
ui.global::<ExportModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "success");
} else {
let msg = format!(
"Exported {} of {} files -- {} failed: {}",
queue.completed.len(),
queue.total,
queue.failures.len(),
queue.failures.join("; ")
);
ui.global::<ExportModel>().set_has_error(true);
ui.global::<ExportModel>()
.set_status_message(msg.clone().into());
show_toast(ui, &msg, "error");
}
}
pub(super) fn start_next_export_job(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
queue: &Arc<Mutex<ExportQueue>>,
) {
let job = {
let mut q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if q.cancelled {
None
} else {
q.jobs.pop_front()
}
};
let Some(job) = job else {
let q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
finish_export_queue(ui, render_ctx, &q);
return;
};
let (current_index, total, output_path) = {
let mut q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let ctx = template_context_for_job(&q, &job);
let output_path = export_thread::resolve_export_path(&q.export_dir, &q.template, &ctx);
q.eta.reset();
(q.current_index, q.total, output_path)
};
set_starting_job_ui_state(ui, current_index, total, &job);
let (params, color_space, remote, local_compute) = {
let q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
(q.params, q.color_space, q.remote.clone(), q.local_compute)
};
ui.global::<ExportModel>()
.set_preview_samples_total(params.samples_per_pixel as i32);
let queue_progress = queue.clone();
let render_ctx_done = render_ctx.clone();
let queue_done = queue.clone();
let handle = export_thread::spawn_export(
ui.as_weak(),
job.scene,
params,
color_space,
output_path,
remote,
local_compute,
move |ui: &MainWindow, progress: export_thread::ExportProgress| {
handle_export_job_progress(ui, &queue_progress, progress);
},
move |ui: &MainWindow, outcome: export_thread::ExportOutcome| {
handle_export_job_done(ui, &render_ctx_done, &queue_done, outcome);
},
);
{
let mut q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
q.current_handle = Some(handle);
}
}
fn set_starting_job_ui_state(ui: &MainWindow, current_index: usize, total: usize, job: &ExportJob) {
ui.global::<ExportModel>()
.set_preset_index(current_index as i32);
ui.global::<ExportModel>().set_preset_total(total as i32);
ui.global::<ExportModel>()
.set_current_preset_label(if job.preset_label.is_empty() {
"Current view".into()
} else {
job.preset_label.clone().into()
});
ui.global::<ExportModel>()
.set_progress(current_index as f32 / total as f32);
ui.global::<ExportModel>().set_preview_samples_done(0);
ui.global::<ExportModel>()
.set_preview_image(slint::Image::default());
ui.global::<ExportModel>()
.set_eta_text(String::new().into());
}
fn handle_export_job_progress(
ui: &MainWindow,
queue: &Arc<Mutex<ExportQueue>>,
progress: export_thread::ExportProgress,
) {
let (current_index, total, eta_text) = {
let mut q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
q.eta.observe(now, f64::from(progress.fraction));
let eta_text = format_eta(q.eta.eta(now));
(q.current_index, q.total, eta_text)
};
let combined = (current_index as f32 + progress.fraction) / total as f32;
ui.global::<ExportModel>().set_progress(combined);
ui.global::<ExportModel>().set_eta_text(eta_text.into());
ui.global::<ExportModel>()
.set_preview_samples_done(progress.samples_done as i32);
ui.global::<ExportModel>()
.set_preview_samples_total(progress.samples_total as i32);
if let Some(buf) = progress.preview {
ui.global::<ExportModel>()
.set_preview_image(slint::Image::from_rgba8(buf));
}
if let Some(note) = progress.note {
show_toast(ui, ¬e, "info");
}
}
fn handle_export_job_done(
ui: &MainWindow,
render_ctx: &Arc<Mutex<RenderContext>>,
queue: &Arc<Mutex<ExportQueue>>,
outcome: export_thread::ExportOutcome,
) {
{
let mut q = queue
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
q.current_handle = None;
match outcome {
export_thread::ExportOutcome::Completed(path) => q.completed.push(path),
export_thread::ExportOutcome::Cancelled => q.cancelled = true,
export_thread::ExportOutcome::Failed(err) => q.failures.push(err),
}
q.current_index += 1;
}
start_next_export_job(ui, render_ctx, queue);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn apply_export_bounce_cap_overrides_the_snapshot_not_the_viewport_default() {
let viewport_ctx = Mutex::new(RenderContext {
max_bounces: 12, ..Default::default()
});
let captured = SceneSnapshot::capture(&viewport_ctx).expect("Diamond resolves");
assert_eq!(
captured.max_bounces, 12,
"capture must still read the viewport's own setting by itself"
);
let exported = apply_export_bounce_cap(captured, 64);
assert_eq!(
exported.max_bounces, 64,
"the export dialog's own bounce cap must win over the captured viewport value"
);
}
#[test]
fn compute_target_from_index_maps_all_three_pill_values() {
assert_eq!(compute_target_from_index(0), ComputeTarget::LocalOnly);
assert_eq!(compute_target_from_index(1), ComputeTarget::RemoteOnly);
assert_eq!(compute_target_from_index(2), ComputeTarget::Both);
}
#[test]
fn compute_target_from_index_falls_back_to_both_for_unknown_values() {
assert_eq!(compute_target_from_index(-1), ComputeTarget::Both);
assert_eq!(compute_target_from_index(99), ComputeTarget::Both);
}
#[test]
fn colorspace_label_covers_every_variant() {
assert_eq!(colorspace_label(ColorSpace::Srgb), "sRGB");
assert_eq!(colorspace_label(ColorSpace::DisplayP3), "Display P3");
assert_eq!(colorspace_label(ColorSpace::Rec2020), "Rec.2020");
assert_eq!(colorspace_label(ColorSpace::AcesCg), "ACEScg");
}
#[test]
fn apply_preset_to_scene_leaves_camera_untouched_when_the_preset_carries_none() {
let base = SceneSnapshot::capture(&Mutex::new(RenderContext {
yaw: 1.23,
pitch: 0.44,
..Default::default()
}))
.expect("Diamond resolves");
let preset = SavedLightingPreset {
name: "Mood Only".to_string(),
built_in: false,
light_yaw_deg: 10.0,
light_pitch_deg: 20.0,
exposure: 0.8,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: None,
camera_pitch: None,
env_map_path: None,
export_usable: true,
};
let overlaid = apply_preset_to_scene(base.clone(), &preset, 1.5);
assert_eq!(overlaid.yaw, base.yaw);
assert_eq!(overlaid.pitch, base.pitch);
assert_eq!(overlaid.light_yaw, preset.light_yaw_deg.to_radians());
}
#[test]
fn apply_preset_to_scene_overlays_camera_when_the_preset_carries_one() {
let base = SceneSnapshot::capture(&Mutex::new(RenderContext {
yaw: 1.23,
pitch: 0.44,
..Default::default()
}))
.expect("Diamond resolves");
let preset = SavedLightingPreset {
name: "Full Shot".to_string(),
built_in: false,
light_yaw_deg: 10.0,
light_pitch_deg: 20.0,
exposure: 0.8,
lighting_rig: "Gem Studio Ring Lights".to_string(),
camera_distance: 2.0,
camera_yaw: Some(0.75),
camera_pitch: Some(0.3),
env_map_path: None,
export_usable: true,
};
let overlaid = apply_preset_to_scene(base, &preset, 1.5);
assert_eq!(overlaid.yaw, 0.75);
assert_eq!(overlaid.pitch, 0.3);
}
}