mod batch;
#[cfg(feature = "editor")]
mod editor;
mod library;
mod optics;
mod remote;
mod render;
pub mod solid_preview;
mod startup_settings;
mod tilt;
pub use library::diagram_list::sync_range_bounds_to_ui;
pub use library::search;
use startup_settings::apply_loaded_settings;
pub(in crate::gui) use startup_settings::{
color_space_from_index, env_map_status_text, is_c_axis_override_available,
local_compute_target_from_index, local_preview_scale_from_index,
refresh_lighting_preset_options, refresh_material_options,
};
use crate::{
EditorModel, LibraryModel, MainWindow, RemoteWorkerModel, SettingsModel, SolidPreviewModel,
TiltModel, ViewportModel,
bridge::{
library::source::LibrarySource,
render_thread::{RenderContext, spawn_render_thread},
},
gui::{
optics::{crystal_optics::gem_material_from_row, curve_path::tilt_curve_path},
remote::{refresh_worker_options, setup_remote_rendering, setup_worker_callbacks},
solid_preview::{
diagram_wiring::{self, DiagramFacetTier, DiagramHoverText, DiagramPick},
preview_state::{
PickBuffer, PreviewFrame, PreviewSink, SolidLastSolved, SolidPreviewState,
},
},
},
settings::{self, SettingsPersister},
};
use indicatrix::optics::materials::GemMaterial;
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, Weak};
use std::sync::{Arc, Mutex};
const DB_PATH: &str = "facet_diagrams.sqlite";
pub fn main() -> anyhow::Result<()> {
run_gui()
}
pub struct MainWindowHandle {
pub ui: MainWindow,
#[expect(
dead_code,
reason = "RAII guard field -- kept alive, never read; see comment above"
)]
render_ctx: Arc<Mutex<RenderContext>>,
#[expect(
dead_code,
reason = "RAII guard field -- kept alive, never read; see comment above"
)]
settings_store: Arc<SettingsPersister>,
#[expect(
dead_code,
reason = "RAII guard field -- kept alive, never read; see comment above"
)]
remote_rendering_timer: slint::Timer,
}
pub fn run_gui() -> anyhow::Result<()> {
let handle = build_main_window()?;
handle.ui.run()?;
Ok(())
}
struct SlintSolidSink {
ui: Weak<MainWindow>,
pick: Arc<Mutex<Option<PickBuffer>>>,
last_solved: SolidLastSolved,
diagram_pick: DiagramPick,
diagram_hover_text: DiagramHoverText,
diagram_facet_tier: DiagramFacetTier,
}
impl PreviewSink for SlintSolidSink {
fn apply(&self, frame: PreviewFrame) {
let PreviewFrame {
image,
has_solid,
status,
solved,
stale,
pick,
edges_image,
diagram_image,
has_diagram,
diagram_pick,
diagram_hover_text,
diagram_facet_tier,
} = frame;
let pick_state = Arc::clone(&self.pick);
let last_solved_state = Arc::clone(&self.last_solved);
let diagram_pick_state = Arc::clone(&self.diagram_pick);
let diagram_hover_text_state = Arc::clone(&self.diagram_hover_text);
let diagram_facet_tier_state = Arc::clone(&self.diagram_facet_tier);
let _ = self.ui.upgrade_in_event_loop(move |ui| {
*pick_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(pick);
*last_solved_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = solved;
if let Some(diagram_pick) = diagram_pick {
*diagram_pick_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(diagram_pick);
}
if let Some(diagram_hover_text) = diagram_hover_text {
*diagram_hover_text_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(diagram_hover_text);
}
if let Some(diagram_facet_tier) = diagram_facet_tier {
*diagram_facet_tier_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(diagram_facet_tier);
}
ui.global::<SolidPreviewModel>()
.set_image(slint::Image::from_rgba8(image));
ui.global::<SolidPreviewModel>().set_has_solid(has_solid);
ui.global::<SolidPreviewModel>().set_status(status.into());
ui.global::<SolidPreviewModel>().set_stale(stale);
if let Some(edges_image) = edges_image {
ui.global::<SolidPreviewModel>()
.set_edges_image(slint::Image::from_rgba8(edges_image));
ui.global::<SolidPreviewModel>().set_has_solid_edges(true);
} else {
ui.global::<SolidPreviewModel>().set_has_solid_edges(false);
}
if let Some(diagram_image) = diagram_image {
ui.global::<SolidPreviewModel>()
.set_diagram_image(slint::Image::from_rgba8(diagram_image));
}
ui.global::<SolidPreviewModel>()
.set_has_diagram(has_diagram);
});
}
}
#[expect(
clippy::too_many_lines,
reason = "a flat sequence of already-extracted setup_* calls (see comment above) -- \
splitting further just moves the same line count into a wrapper that \
calls them all, not a real reduction"
)]
pub fn build_main_window() -> anyhow::Result<MainWindowHandle> {
let ui = MainWindow::new()?;
ui.global::<SettingsModel>()
.set_gpu_build(cfg!(feature = "gpu"));
ui.global::<EditorModel>()
.set_enabled(cfg!(feature = "editor"));
let db = match Database::new(Some(DB_PATH)) {
Ok(db_inst) => Arc::new(Mutex::new(db_inst)),
Err(e) => {
match Database::new(Some(":memory:")) {
Ok(memory_db) => {
show_toast(
&ui,
&format!(
"Could not open the design library ({e}) -- running in a \
temporary, unsaved library for this session."
),
"error",
);
Arc::new(Mutex::new(memory_db))
}
Err(memory_err) => {
return Err(anyhow::anyhow!(
"failed to open design library at {DB_PATH:?} ({e}), and the \
in-memory fallback database also failed to open ({memory_err})"
));
}
}
}
};
let render_ctx = Arc::new(Mutex::new(RenderContext::default()));
let solid_pick: Arc<Mutex<Option<PickBuffer>>> = Arc::new(Mutex::new(None));
let solid_last_solved: SolidLastSolved = Arc::new(Mutex::new(None));
let diagram_pick: DiagramPick = Arc::new(Mutex::new(None));
let diagram_hover_text: DiagramHoverText = Arc::new(Mutex::new(None));
let diagram_facet_tier: DiagramFacetTier = Arc::new(Mutex::new(None));
let solid_preview_state = SolidPreviewState::new(Arc::new(SlintSolidSink {
ui: ui.as_weak(),
pick: Arc::clone(&solid_pick),
last_solved: Arc::clone(&solid_last_solved),
diagram_pick: Arc::clone(&diagram_pick),
diagram_hover_text: Arc::clone(&diagram_hover_text),
diagram_facet_tier: Arc::clone(&diagram_facet_tier),
}));
diagram_wiring::setup_diagram_hover_and_click_callbacks(
&ui,
&diagram_pick,
&diagram_hover_text,
&diagram_facet_tier,
);
let library_source: Arc<Mutex<LibrarySource>> = Arc::new(Mutex::new(LibrarySource::default()));
let initial_custom_mats: Vec<GemMaterial> = db
.lock()
.unwrap()
.get_custom_materials()
.unwrap_or_default()
.iter()
.map(gem_material_from_row)
.collect();
render_ctx.lock().unwrap().custom_materials = Arc::new(initial_custom_mats.clone());
refresh_material_options(&ui, &initial_custom_mats);
let settings_path = settings::store::default_settings_path();
settings::store::migrate_legacy_settings_if_needed(&settings_path);
let loaded_settings = settings::store::load_or_default(&settings_path);
apply_loaded_settings(&ui, &render_ctx, &loaded_settings);
refresh_lighting_preset_options(&ui, &loaded_settings.presets);
refresh_worker_options(&ui, &loaded_settings.settings.remote_workers);
ui.global::<RemoteWorkerModel>()
.set_denoise_enabled(loaded_settings.settings.denoise_enabled);
let settings_store = Arc::new(SettingsPersister::spawn(settings_path, loaded_settings));
let detached_frame_target =
render::detached_render::setup_live_render_visibility_callbacks(&ui, &render_ctx);
let ui_weak_render = ui.as_weak();
spawn_render_thread(
ui_weak_render,
render_ctx.clone(),
move |ui: &MainWindow, img: slint::SharedPixelBuffer<slint::Rgba8Pixel>| {
let slint_img = slint::Image::from_rgba8(img);
ui.global::<ViewportModel>()
.set_render_image(slint_img.clone());
ui.global::<ViewportModel>().set_has_render(true);
let detached_weak = detached_frame_target.lock().unwrap().clone();
if let Some(detached) = detached_weak.and_then(|weak| weak.upgrade()) {
detached.set_render_image(slint_img);
detached.set_has_render(true);
}
},
|ui: &MainWindow,
brilliance: f32,
fire: f32,
scintillation: f32,
windowing: f32,
extinction: f32,
graph_brilliance: [f32; 19],
graph_extinction: [f32; 19],
graph_windowing: [f32; 19],
cam_pitch_deg: f32| {
ui.global::<ViewportModel>().set_brilliance_pct(brilliance);
ui.global::<ViewportModel>().set_fire_index(fire);
ui.global::<ViewportModel>()
.set_scintillation_pct(scintillation);
ui.global::<ViewportModel>().set_windowing_pct(windowing);
ui.global::<ViewportModel>().set_extinction_pct(extinction);
ui.global::<TiltModel>()
.set_graph_brilliance(slint::ModelRc::new(slint::VecModel::from(
graph_brilliance.to_vec(),
)));
ui.global::<TiltModel>()
.set_graph_extinction(slint::ModelRc::new(slint::VecModel::from(
graph_extinction.to_vec(),
)));
ui.global::<TiltModel>()
.set_graph_windowing(slint::ModelRc::new(slint::VecModel::from(
graph_windowing.to_vec(),
)));
ui.global::<TiltModel>()
.set_graph_brilliance_path(tilt_curve_path(&graph_brilliance).into());
ui.global::<TiltModel>()
.set_graph_extinction_path(tilt_curve_path(&graph_extinction).into());
ui.global::<TiltModel>()
.set_graph_windowing_path(tilt_curve_path(&graph_windowing).into());
ui.global::<TiltModel>().set_cam_pitch_deg(cam_pitch_deg);
},
);
let settings_store_panel = settings_store.clone();
ui.global::<LibraryModel>()
.on_panel_collapsed_changed(move |collapsed: bool| {
settings_store_panel.update(|s| s.settings.library_panel_collapsed = collapsed);
});
setup_user_manual_callback(&ui);
render::camera_lighting::setup_camera_and_lighting_callbacks(
&ui,
&render_ctx,
&settings_store,
&solid_preview_state,
);
render::material_quality::setup_material_changed_callback(&ui, &render_ctx, &settings_store);
render::material_quality::setup_material_and_quality_callbacks(
&ui,
&render_ctx,
&settings_store,
);
render::material_quality::setup_material_effect_override_callbacks(
&ui,
&render_ctx,
&settings_store,
);
render::camera_lighting::setup_environment_map_callbacks(&ui, &render_ctx, &settings_store);
render::lighting_presets::setup_lighting_preset_callbacks(&ui, &render_ctx, &settings_store);
render::render_export::setup_render_export_callbacks(&ui, &render_ctx, &settings_store);
optics::custom_materials::setup_custom_material_callbacks(&ui, &render_ctx, &db);
library::clipboard::setup_copy_callbacks(&ui);
tilt::tilt_profile::setup_tilt_profile_callback(&ui, &render_ctx);
tilt::tilt_hover_preview::setup_tilt_hover_preview_callback(&ui, &render_ctx);
library::diagram_list::load_filter_options_and_initial_list(&ui, &db);
library::diagram_list::setup_search_and_filter_callbacks(&ui, &db, &library_source);
library::diagram_list::setup_diagram_selection_and_export_callbacks(
&ui,
&db,
&library_source,
&render_ctx,
);
library::diagram_list::setup_performance_filter_callbacks(&ui);
library::local::setup_import_callback(&ui, &db, &library_source);
library::local::setup_rename_callback(&ui, &db, &library_source);
library::local::setup_set_shape_callback(&ui, &db, &library_source);
library::local::setup_delete_callback(&ui, &db, &library_source, &render_ctx);
library::local::setup_export_asc_callback(&ui, &db, &library_source);
library::local::setup_ignore_toggle_callback(&ui, &db, &library_source);
library::detail::setup_save_metadata_callback(&ui, &db, &library_source, &render_ctx);
#[cfg(feature = "editor")]
editor::setup_editor_callbacks(
&ui,
&db,
&library_source,
&render_ctx,
&solid_preview_state,
&solid_last_solved,
&solid_pick,
);
let settings_store_solid_view_mode = settings_store.clone();
ui.global::<SolidPreviewModel>()
.on_view_mode_changed(move |mode: i32| {
settings_store_solid_view_mode.update(|s| {
s.settings.solid_view_mode = u8::try_from(mode).unwrap_or_default();
});
});
let settings_store_auto_solve_budget = settings_store.clone();
ui.global::<EditorModel>()
.on_auto_solve_budget_ms_changed(move |budget_ms: i32| {
settings_store_auto_solve_budget.update(|s| {
s.settings.editor_auto_solve_budget_ms =
u32::try_from(budget_ms).unwrap_or_default();
});
});
library::remote::setup_library_source_callbacks(&ui, &db, &library_source, &settings_store);
library::remote::setup_mirror_sync_callbacks(&ui, &db, &settings_store);
setup_worker_callbacks(&ui, &render_ctx, &settings_store);
batch::preview_cache::setup_preview_thumbnail_callback(&ui, &db);
batch::preview::setup_preview_batch_callbacks(&ui, &db, &render_ctx, &settings_store);
batch::tilt::setup_tilt_batch_callbacks(&ui, &db, &render_ctx, &settings_store);
let remote_rendering_timer = setup_remote_rendering(&ui, &render_ctx, &settings_store);
let render_ctx_close = render_ctx.clone();
let settings_store_close = settings_store.clone();
ui.window().on_close_requested(move || {
render_ctx_close.lock().unwrap().running = false;
settings_store_close.flush();
slint::CloseRequestResponse::HideWindow
});
Ok(MainWindowHandle {
ui,
render_ctx,
settings_store,
remote_rendering_timer,
})
}
fn starting_dir_from_picker_field(current: &str) -> Option<std::path::PathBuf> {
if current.is_empty() {
return None;
}
let path = std::path::Path::new(current);
if path.is_dir() {
Some(path.to_path_buf())
} else if path.is_file() {
path.parent().map(std::path::Path::to_path_buf)
} else {
None
}
}
fn user_manual_candidates(exe_dir: &std::path::Path) -> Vec<std::path::PathBuf> {
vec![
exe_dir.join("docs/manual/README.md"),
exe_dir.join("../docs/manual/README.md"),
exe_dir.join("../../apps/indicatrix-cut/docs/manual/README.md"),
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/manual/README.md"),
]
}
fn locate_user_manual() -> Option<std::path::PathBuf> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf))?;
user_manual_candidates(&exe_dir)
.into_iter()
.find(|candidate| candidate.is_file())
}
fn setup_user_manual_callback(ui: &MainWindow) {
let ui_weak = ui.as_weak();
ui.on_open_user_manual(move || {
let Some(ui) = ui_weak.upgrade() else {
return;
};
let Some(manual_path) = locate_user_manual() else {
show_toast(&ui, "User manual not found", "error");
return;
};
let path_str = manual_path.to_string_lossy().into_owned();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(&path_str)
.spawn();
#[cfg(target_os = "windows")]
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", &path_str])
.spawn();
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open").arg(&path_str).spawn();
});
}
pub fn show_toast(ui: &MainWindow, msg: &str, toast_type: &str) {
ui.set_toast_message(msg.into());
ui.set_toast_type(toast_type.into());
ui.set_toast_visible(true);
let generation = ui.get_toast_generation() + 1;
ui.set_toast_generation(generation);
if toast_type == "error" {
return;
}
let ui_weak = ui.as_weak();
slint::Timer::single_shot(std::time::Duration::from_millis(3500), move || {
if let Some(ui) = ui_weak.upgrade()
&& ui.get_toast_generation() == generation
{
ui.set_toast_visible(false);
}
});
}
#[cfg(test)]
mod tests {
use super::user_manual_candidates;
#[test]
fn user_manual_candidates_are_relative_to_exe_dir_in_order() {
let exe_dir = std::path::Path::new("/opt/indicatrix-cut/bin");
let candidates = user_manual_candidates(exe_dir);
assert_eq!(
candidates[0],
std::path::Path::new("/opt/indicatrix-cut/bin/docs/manual/README.md")
);
assert_eq!(
candidates[1],
std::path::Path::new("/opt/indicatrix-cut/bin/../docs/manual/README.md")
);
assert_eq!(
candidates[2],
std::path::Path::new(
"/opt/indicatrix-cut/bin/../../apps/indicatrix-cut/docs/manual/README.md"
)
);
assert_eq!(
candidates[3],
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/manual/README.md")
);
assert_eq!(candidates.len(), 4);
}
}