use crate::{
BatchModel, MainWindow,
bridge::preview_render::{self, PreviewJob, PreviewView},
gui::{batch::batch_queue::WorkQueue, library::detail::reconstruct_planes},
settings::WorkerSettings,
};
use indicatrix::{
geometry::{cuts::FacetSpec, plane::GpuFacetPlane},
optics::materials::GemMaterial,
renderer::gpu_backend::GpuBackend,
};
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, Weak};
use std::{
any::Any,
collections::HashMap,
panic::{self, AssertUnwindSafe},
sync::{
Mutex, PoisonError,
atomic::{AtomicBool, AtomicU32, Ordering},
},
thread,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use tracing::warn;
const PREVIEW_MAX_BOUNCES: u32 = 12;
pub const RI_MATCH_TOLERANCE: f64 = 0.02;
pub const FALLBACK_TARGET_RI: f64 = 2.417;
#[must_use]
pub fn target_ri_for_design(full: &indicatrix_vault::model::entry::FullDiagramRecord) -> f64 {
full.refractive_index
.as_deref()
.and_then(|s| s.trim().parse::<f64>().ok())
.filter(|ri| ri.is_finite() && *ri > 1.0)
.unwrap_or(FALLBACK_TARGET_RI)
}
const LOCAL_IDLE_POLL: Duration = Duration::from_millis(15);
pub fn seeded_random_unit(entry_id: i64) -> impl FnMut() -> f64 {
let time_bits = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64);
#[allow(
clippy::cast_sign_loss,
reason = "entry_id is a SQLite AUTOINCREMENT row id, always non-negative in \
practice; this cast only feeds a hash seed, where a wrapped negative \
id would still produce a valid (if different) seed rather than a \
wrong answer"
)]
let mut state = (entry_id as u64) ^ time_bits;
move || {
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
}
}
pub(super) struct BatchContext<'a> {
pub(super) db: &'a Mutex<Database>,
pub(super) material_candidates:
&'a [indicatrix_vault::model::material_match::RiPresetCandidate],
pub(super) preview_size: u32,
pub(super) preview_spp: u32,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct PreviewItem {
pub(super) entry_id: i64,
pub(super) view: PreviewView,
}
struct ResolvedDesign {
title: String,
planes: Vec<GpuFacetPlane>,
material: GemMaterial,
}
fn resolve_design(ctx: &BatchContext<'_>, entry_id: i64) -> Option<ResolvedDesign> {
let full = {
let guard = ctx.db.lock().unwrap_or_else(PoisonError::into_inner);
guard.get_diagram_full(entry_id)
};
let Ok(Some(full)) = full else {
return None;
};
let facet_specs: Vec<FacetSpec> = full
.angle_settings
.iter()
.map(|a| FacetSpec {
facet: a.facet.clone(),
angle: a.angle.clone(),
index: a.index.clone(),
notes: a.notes.clone(),
})
.collect();
let planes = reconstruct_planes(
full.shape.as_deref(),
full.index_gear.as_deref(),
&facet_specs,
);
if planes.is_empty() {
return None;
}
let target_ri = target_ri_for_design(&full);
let material_name = {
let guard = ctx.db.lock().unwrap_or_else(PoisonError::into_inner);
let mut rng = seeded_random_unit(entry_id);
guard.ensure_preview_material(
entry_id,
target_ri,
ctx.material_candidates,
RI_MATCH_TOLERANCE,
&mut rng,
)
};
let Ok(Some(material_name)) = material_name else {
return None;
};
let material = GemMaterial::by_name(&material_name)?;
Some(ResolvedDesign {
title: full.title,
planes,
material,
})
}
fn panic_message(payload: &(dyn Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
}
fn catch_render(view: PreviewView, f: impl FnOnce() -> Option<Vec<u8>>) -> Option<Vec<u8>> {
panic::catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|payload| {
warn!(
"Preview render panicked for a {view:?} view: {}",
panic_message(&*payload)
);
None
})
}
fn render_item_local(
ctx: &BatchContext<'_>,
gpu: &GpuBackend,
resolved: &ResolvedDesign,
view: PreviewView,
) -> Option<Vec<u8>> {
let job = PreviewJob {
planes: &resolved.planes,
material: &resolved.material,
size: ctx.preview_size,
spp: ctx.preview_spp,
max_bounces: PREVIEW_MAX_BOUNCES,
};
catch_render(view, || preview_render::render_view(&job, view, gpu))
}
fn render_item_remote(
ctx: &BatchContext<'_>,
worker: &WorkerSettings,
resolved: &ResolvedDesign,
view: PreviewView,
cancel: &AtomicBool,
) -> Option<Vec<u8>> {
let job = PreviewJob {
planes: &resolved.planes,
material: &resolved.material,
size: ctx.preview_size,
spp: ctx.preview_spp,
max_bounces: PREVIEW_MAX_BOUNCES,
};
catch_render(view, || {
preview_render::render_view_remote(&job, view, worker, cancel)
})
}
pub(super) struct DesignAccum {
front: Option<Vec<u8>>,
top: Option<Vec<u8>>,
remaining: u8,
}
type FinishedViews = Option<(Option<Vec<u8>>, Option<Vec<u8>>)>;
fn record_item_result(
design_state: &Mutex<HashMap<i64, DesignAccum>>,
entry_id: i64,
view: PreviewView,
bytes: Option<Vec<u8>>,
) -> FinishedViews {
let mut map = design_state.lock().unwrap_or_else(PoisonError::into_inner);
let accum = map.entry(entry_id).or_insert_with(|| DesignAccum {
front: None,
top: None,
remaining: 2,
});
match view {
PreviewView::Front => accum.front = bytes,
PreviewView::Top => accum.top = bytes,
}
accum.remaining = accum.remaining.saturating_sub(1);
if accum.remaining > 0 {
return None;
}
map.remove(&entry_id).map(|done| (done.front, done.top))
}
#[derive(Default)]
pub(super) struct Tally {
pub(super) generated: AtomicU32,
pub(super) failed: AtomicU32,
}
#[derive(Default, Clone)]
pub(super) struct LiveProgress {
completed: u32,
local_active: u32,
remote_title: String,
remote_active: bool,
}
fn push_progress(
ui_weak: &Weak<MainWindow>,
design_total: u32,
local_lane_total: u32,
progress: &Mutex<LiveProgress>,
) {
let snapshot = progress
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let ui_weak = ui_weak.clone();
let _ = ui_weak.upgrade_in_event_loop(move |ui| {
ui.global::<BatchModel>()
.set_preview_design_index(snapshot.completed as i32);
ui.global::<BatchModel>()
.set_preview_design_total(design_total as i32);
ui.global::<BatchModel>()
.set_preview_local_active(snapshot.local_active as i32);
ui.global::<BatchModel>()
.set_preview_local_lane_total(local_lane_total as i32);
ui.global::<BatchModel>()
.set_preview_remote_title(snapshot.remote_title.into());
ui.global::<BatchModel>()
.set_preview_remote_active(snapshot.remote_active);
});
}
fn set_remote_status(progress: &Mutex<LiveProgress>, active: bool, title: &str) {
let mut p = progress.lock().unwrap_or_else(PoisonError::into_inner);
p.remote_active = active;
p.remote_title = title.to_string();
}
fn increment_completed(progress: &Mutex<LiveProgress>) {
progress
.lock()
.unwrap_or_else(PoisonError::into_inner)
.completed += 1;
}
fn adjust_local_active(progress: &Mutex<LiveProgress>, delta: i32) {
let mut p = progress.lock().unwrap_or_else(PoisonError::into_inner);
p.local_active = p.local_active.saturating_add_signed(delta);
}
pub(super) struct LaneShared<'a> {
pub(super) ctx: &'a BatchContext<'a>,
pub(super) queue: &'a WorkQueue<PreviewItem>,
pub(super) design_state: &'a Mutex<HashMap<i64, DesignAccum>>,
pub(super) tally: &'a Tally,
pub(super) progress: &'a Mutex<LiveProgress>,
pub(super) cancel: &'a AtomicBool,
pub(super) design_total: u32,
pub(super) local_lane_total: u32,
}
fn finish_item(
shared: &LaneShared<'_>,
ui_weak: &Weak<MainWindow>,
entry_id: i64,
view: PreviewView,
bytes: Option<Vec<u8>>,
) {
let Some((front, top)) = record_item_result(shared.design_state, entry_id, view, bytes) else {
return;
};
let saved = (front.is_some() || top.is_some()) && {
#[allow(
clippy::cast_possible_wrap,
reason = "unix seconds fits in i64 until well past the year 292 billion; \
the column this feeds (`diagram_previews.preview_generated_at`) \
is already declared INTEGER (i64) to match"
)]
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let guard = shared.ctx.db.lock().unwrap_or_else(PoisonError::into_inner);
guard
.save_preview_images(entry_id, front.as_deref(), top.as_deref(), now)
.is_ok()
};
if saved {
shared.tally.generated.fetch_add(1, Ordering::Relaxed);
} else {
shared.tally.failed.fetch_add(1, Ordering::Relaxed);
}
increment_completed(shared.progress);
push_progress(
ui_weak,
shared.design_total,
shared.local_lane_total,
shared.progress,
);
}
pub(super) fn run_local_lane(
shared: &LaneShared<'_>,
gpu: &GpuBackend,
ui_weak: &Weak<MainWindow>,
remote_lane_done: &AtomicBool,
) {
loop {
if shared.cancel.load(Ordering::Relaxed) {
break;
}
let Some(item) = shared.queue.claim_local() else {
if remote_lane_done.load(Ordering::Acquire) {
break;
}
thread::sleep(LOCAL_IDLE_POLL);
continue;
};
adjust_local_active(shared.progress, 1);
push_progress(
ui_weak,
shared.design_total,
shared.local_lane_total,
shared.progress,
);
let resolved = resolve_design(shared.ctx, item.entry_id);
let bytes = resolved.and_then(|r| render_item_local(shared.ctx, gpu, &r, item.view));
finish_item(shared, ui_weak, item.entry_id, item.view, bytes);
adjust_local_active(shared.progress, -1);
push_progress(
ui_weak,
shared.design_total,
shared.local_lane_total,
shared.progress,
);
}
}
pub(super) fn run_remote_lane(
shared: &LaneShared<'_>,
ui_weak: &Weak<MainWindow>,
worker: Option<&WorkerSettings>,
fallback_to_local: bool,
remote_lane_done: &AtomicBool,
) {
loop {
if shared.cancel.load(Ordering::Relaxed) {
break;
}
let Some(item) = shared.queue.claim_shared() else {
break;
};
let resolved = resolve_design(shared.ctx, item.entry_id);
let title = resolved
.as_ref()
.map_or_else(|| format!("Design #{}", item.entry_id), |r| r.title.clone());
set_remote_status(shared.progress, true, &title);
push_progress(
ui_weak,
shared.design_total,
shared.local_lane_total,
shared.progress,
);
let bytes = match (resolved, worker) {
(Some(r), Some(w)) => render_item_remote(shared.ctx, w, &r, item.view, shared.cancel),
_ => None,
};
if bytes.is_some() {
finish_item(shared, ui_weak, item.entry_id, item.view, bytes);
} else if fallback_to_local {
shared.queue.return_to_local(item);
} else {
finish_item(shared, ui_weak, item.entry_id, item.view, None);
}
}
set_remote_status(shared.progress, false, "");
push_progress(
ui_weak,
shared.design_total,
shared.local_lane_total,
shared.progress,
);
remote_lane_done.store(true, Ordering::Release);
}
pub(super) fn build_items(entry_ids: &[i64]) -> Vec<PreviewItem> {
entry_ids
.iter()
.flat_map(|&entry_id| {
[
PreviewItem {
entry_id,
view: PreviewView::Front,
},
PreviewItem {
entry_id,
view: PreviewView::Top,
},
]
})
.collect()
}
pub(super) fn run_batch_lanes(
shared: &LaneShared<'_>,
gpu: &GpuBackend,
plan: super::super::batch_queue::LanePlan,
remote_worker: Option<&WorkerSettings>,
local_lane_total: u32,
ui_weak: &Weak<MainWindow>,
remote_lane_done: &AtomicBool,
) {
std::thread::scope(|scope| {
if plan.run_remote {
let remote_ui_weak = ui_weak.clone();
scope.spawn(move || {
run_remote_lane(
shared,
&remote_ui_weak,
remote_worker,
plan.fallback_to_local,
remote_lane_done,
);
});
}
if plan.run_local {
for _ in 0..local_lane_total {
let local_ui_weak = ui_weak.clone();
scope.spawn(move || {
run_local_lane(shared, gpu, &local_ui_weak, remote_lane_done);
});
}
}
});
}