use super::{
local_compute::LocalComputeTarget,
remote_endpoint::{LegacyWorkerMigration, RemoteEndpoint, migrate_legacy_workers},
surface_glare::{DEFAULT_SURFACE_GLARE, default_surface_glare, deserialize_surface_glare},
worker::{LiveComputeTarget, LocalPreviewScale, WorkerSettings},
};
use crate::bridge::export_thread::DEFAULT_TEMPLATE as DEFAULT_EXPORT_FILENAME_TEMPLATE;
use indicatrix::optics::raytracer::DEFAULT_POSE;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
pub const DEFAULT_TARGET_SAMPLES: u32 = 256;
pub const DEFAULT_RENDER_WIDTH: u32 = 800;
pub const DEFAULT_RENDER_HEIGHT: u32 = 600;
pub use indicatrix::optics::raytracer::DEFAULT_MAX_BOUNCES;
pub const DEFAULT_EXPOSURE: f32 = 1.0;
pub const DEFAULT_INCLUSION_SIGMA_S: f32 = 0.0;
pub const DEFAULT_C_AXIS_OVERRIDE_ENABLED: bool = false;
pub const DEFAULT_C_AXIS_TILT_DEG: f32 = 0.0;
pub const DEFAULT_C_AXIS_AZIMUTH_DEG: f32 = 0.0;
pub const DEFAULT_GIRDLE_FROSTED: bool = false;
pub const DEFAULT_EDGE_ROUNDING_RADIUS: f32 = 0.0;
pub const DEFAULT_STONE_WIDTH_MM: f32 = 0.0;
pub const DEFAULT_LOCAL_PREVIEW_SCALE: LocalPreviewScale = LocalPreviewScale::Off;
pub const DEFAULT_LIVE_COMPUTE_TARGET: LiveComputeTarget = LiveComputeTarget::Both;
pub const DEFAULT_LOCAL_COMPUTE_TARGET: LocalComputeTarget = LocalComputeTarget::CpuGpu;
pub const DEFAULT_CONTRIBUTE_TO_FINAL_PICTURE: bool = true;
pub const DEFAULT_REMOTE_BATCH_LANES: u32 = 4;
pub const MIN_REMOTE_BATCH_LANES: u32 = 1;
pub const MAX_REMOTE_BATCH_LANES: u32 = 32;
#[must_use]
pub fn clamp_remote_batch_lanes(lanes: u32) -> u32 {
lanes.clamp(MIN_REMOTE_BATCH_LANES, MAX_REMOTE_BATCH_LANES)
}
pub const DEFAULT_LIGHT_YAW_DEG: f32 = 48.0;
pub const DEFAULT_LIGHT_PITCH_DEG: f32 = 54.0;
pub const DEFAULT_LIGHTING_RIG: &str = "Light tent + black cards";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum Backdrop {
AsLit,
#[default]
Grey,
White,
}
impl Backdrop {
#[must_use]
pub const fn level(self) -> f32 {
self.to_indicatrix().level()
}
#[must_use]
pub const fn index(self) -> i32 {
self.to_indicatrix().index()
}
#[must_use]
pub const fn from_index(index: i32) -> Self {
Self::from_indicatrix(indicatrix::render_setup::Backdrop::from_index(index))
}
const fn to_indicatrix(self) -> indicatrix::render_setup::Backdrop {
match self {
Self::AsLit => indicatrix::render_setup::Backdrop::AsLit,
Self::Grey => indicatrix::render_setup::Backdrop::Grey,
Self::White => indicatrix::render_setup::Backdrop::White,
}
}
const fn from_indicatrix(backdrop: indicatrix::render_setup::Backdrop) -> Self {
match backdrop {
indicatrix::render_setup::Backdrop::AsLit => Self::AsLit,
indicatrix::render_setup::Backdrop::Grey => Self::Grey,
indicatrix::render_setup::Backdrop::White => Self::White,
}
}
}
pub const DEFAULT_CAMERA_YAW: f32 = DEFAULT_POSE.yaw;
pub const DEFAULT_CAMERA_PITCH: f32 = DEFAULT_POSE.pitch;
pub const DEFAULT_CAMERA_DISTANCE: f32 = DEFAULT_POSE.distance;
pub const DEFAULT_MATERIAL: &str = "Diamond";
pub const DEFAULT_PREVIEW_SIZE: u32 = 160;
pub const DEFAULT_PREVIEW_SPP: u32 = 256;
pub const DEFAULT_SOLID_VIEW_MODE: u8 = 0;
pub const DEFAULT_LIVE_VIEW_MODE: u8 = 1;
pub const DEFAULT_EDITOR_AUTO_SOLVE_BUDGET_MS: u32 = 300;
pub const DEFAULT_EDITOR_DOCK_WIDTH: f32 = 640.0;
pub const DEFAULT_EDITOR_INSPECTOR_HEIGHT: f32 = 340.0;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AppSettings {
pub target_samples: u32,
pub render_width: u32,
pub render_height: u32,
pub max_bounces: u32,
pub exposure: f32,
pub light_yaw_deg: f32,
pub light_pitch_deg: f32,
pub lighting_rig: String,
pub camera_yaw: f32,
pub camera_pitch: f32,
pub camera_distance: f32,
pub selected_material: String,
#[serde(default)]
pub remote: Option<RemoteEndpoint>,
#[serde(rename = "remote_workers", default, skip_serializing)]
pub legacy_remote_workers: Vec<WorkerSettings>,
#[serde(default = "default_denoise_enabled")]
pub denoise_enabled: bool,
#[serde(default)]
pub backdrop: Backdrop,
#[serde(
default = "default_surface_glare",
deserialize_with = "deserialize_surface_glare"
)]
pub surface_glare: f32,
pub inclusion_sigma_s: f32,
pub c_axis_override_enabled: bool,
pub c_axis_tilt_deg: f32,
pub c_axis_azimuth_deg: f32,
pub girdle_frosted: bool,
pub edge_rounding_radius: f32,
pub stone_width_mm: f32,
pub local_preview_scale: LocalPreviewScale,
#[serde(default)]
pub live_compute_target: LiveComputeTarget,
#[serde(default)]
pub local_compute_target: LocalComputeTarget,
#[serde(default = "default_contribute_to_final_picture")]
pub contribute_to_final_picture: bool,
#[serde(
default = "default_remote_batch_lanes",
deserialize_with = "deserialize_remote_batch_lanes"
)]
pub remote_batch_lanes: u32,
#[serde(default)]
pub import_preview_choice: super::ImportPreviewChoice,
#[serde(
default,
deserialize_with = "indicatrix_net::messages::adaptive::PayloadChoice::deserialize_lenient"
)]
pub payload_encoding: indicatrix_net::messages::adaptive::PayloadChoice,
#[serde(default)]
pub env_map_path: String,
#[serde(default = "default_preview_size")]
pub preview_size: u32,
#[serde(default = "default_preview_spp")]
pub preview_spp: u32,
#[serde(default)]
pub export_directory: String,
#[serde(default)]
pub last_import_directory: String,
#[serde(default = "default_export_filename_template")]
pub export_filename_template: String,
#[serde(default)]
pub library_panel_collapsed: bool,
#[serde(default)]
pub solid_view_mode: u8,
#[serde(default = "default_live_view_mode")]
pub live_view_mode: u8,
#[serde(default = "default_editor_auto_solve_budget_ms")]
pub editor_auto_solve_budget_ms: u32,
#[serde(default = "default_editor_dock_width")]
pub editor_dock_width: f32,
#[serde(default = "default_editor_inspector_height")]
pub editor_inspector_height: f32,
#[serde(default)]
pub editor_settings_collapsed: bool,
#[serde(default)]
pub editor_inspector_collapsed: bool,
#[serde(default)]
pub editor_remap_collapsed: bool,
#[serde(default)]
pub editor_layout_touched: bool,
#[serde(default)]
pub recent_native_files: Vec<String>,
#[serde(default)]
pub suppressed_confirmations: BTreeSet<String>,
}
pub const MAX_RECENT_NATIVE_FILES: usize = 10;
const fn default_preview_size() -> u32 {
DEFAULT_PREVIEW_SIZE
}
const fn default_preview_spp() -> u32 {
DEFAULT_PREVIEW_SPP
}
const fn default_denoise_enabled() -> bool {
true
}
const fn default_contribute_to_final_picture() -> bool {
DEFAULT_CONTRIBUTE_TO_FINAL_PICTURE
}
const fn default_remote_batch_lanes() -> u32 {
DEFAULT_REMOTE_BATCH_LANES
}
fn deserialize_remote_batch_lanes<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = i64::deserialize(deserializer)?;
let limited = raw.clamp(
i64::from(MIN_REMOTE_BATCH_LANES),
i64::from(MAX_REMOTE_BATCH_LANES),
);
Ok(u32::try_from(limited).unwrap_or(DEFAULT_REMOTE_BATCH_LANES))
}
const fn default_editor_auto_solve_budget_ms() -> u32 {
DEFAULT_EDITOR_AUTO_SOLVE_BUDGET_MS
}
const fn default_live_view_mode() -> u8 {
DEFAULT_LIVE_VIEW_MODE
}
const fn default_editor_dock_width() -> f32 {
DEFAULT_EDITOR_DOCK_WIDTH
}
const fn default_editor_inspector_height() -> f32 {
DEFAULT_EDITOR_INSPECTOR_HEIGHT
}
fn default_export_filename_template() -> String {
DEFAULT_EXPORT_FILENAME_TEMPLATE.to_string()
}
impl Default for AppSettings {
fn default() -> Self {
Self {
target_samples: DEFAULT_TARGET_SAMPLES,
render_width: DEFAULT_RENDER_WIDTH,
render_height: DEFAULT_RENDER_HEIGHT,
max_bounces: DEFAULT_MAX_BOUNCES,
exposure: DEFAULT_EXPOSURE,
light_yaw_deg: DEFAULT_LIGHT_YAW_DEG,
light_pitch_deg: DEFAULT_LIGHT_PITCH_DEG,
lighting_rig: DEFAULT_LIGHTING_RIG.to_string(),
camera_yaw: DEFAULT_CAMERA_YAW,
camera_pitch: DEFAULT_CAMERA_PITCH,
camera_distance: DEFAULT_CAMERA_DISTANCE,
selected_material: DEFAULT_MATERIAL.to_string(),
remote: None,
legacy_remote_workers: Vec::new(),
denoise_enabled: true,
backdrop: Backdrop::default(),
surface_glare: DEFAULT_SURFACE_GLARE,
inclusion_sigma_s: DEFAULT_INCLUSION_SIGMA_S,
c_axis_override_enabled: DEFAULT_C_AXIS_OVERRIDE_ENABLED,
c_axis_tilt_deg: DEFAULT_C_AXIS_TILT_DEG,
c_axis_azimuth_deg: DEFAULT_C_AXIS_AZIMUTH_DEG,
girdle_frosted: DEFAULT_GIRDLE_FROSTED,
edge_rounding_radius: DEFAULT_EDGE_ROUNDING_RADIUS,
stone_width_mm: DEFAULT_STONE_WIDTH_MM,
local_preview_scale: DEFAULT_LOCAL_PREVIEW_SCALE,
live_compute_target: DEFAULT_LIVE_COMPUTE_TARGET,
local_compute_target: DEFAULT_LOCAL_COMPUTE_TARGET,
contribute_to_final_picture: DEFAULT_CONTRIBUTE_TO_FINAL_PICTURE,
remote_batch_lanes: DEFAULT_REMOTE_BATCH_LANES,
import_preview_choice: super::ImportPreviewChoice::Ask,
payload_encoding: indicatrix_net::messages::adaptive::PayloadChoice::Auto,
env_map_path: String::new(),
preview_size: DEFAULT_PREVIEW_SIZE,
preview_spp: DEFAULT_PREVIEW_SPP,
export_directory: String::new(),
last_import_directory: String::new(),
export_filename_template: DEFAULT_EXPORT_FILENAME_TEMPLATE.to_string(),
library_panel_collapsed: false,
solid_view_mode: DEFAULT_SOLID_VIEW_MODE,
live_view_mode: DEFAULT_LIVE_VIEW_MODE,
editor_auto_solve_budget_ms: DEFAULT_EDITOR_AUTO_SOLVE_BUDGET_MS,
editor_dock_width: DEFAULT_EDITOR_DOCK_WIDTH,
editor_inspector_height: DEFAULT_EDITOR_INSPECTOR_HEIGHT,
editor_settings_collapsed: false,
editor_inspector_collapsed: false,
editor_remap_collapsed: false,
editor_layout_touched: false,
recent_native_files: Vec::new(),
suppressed_confirmations: BTreeSet::new(),
}
}
}
impl AppSettings {
#[must_use]
pub fn remote_worker(&self) -> Option<WorkerSettings> {
self.remote.as_ref().map(|r| r.connection.clone())
}
pub fn migrate_legacy_remote_workers(&mut self) -> Option<LegacyWorkerMigration> {
migrate_legacy_workers(&mut self.legacy_remote_workers, &mut self.remote)
}
pub fn set_remote_batch_lanes(&mut self, lanes: u32) {
self.remote_batch_lanes = clamp_remote_batch_lanes(lanes);
}
pub fn record_recent_native_file(&mut self, path: String) {
self.recent_native_files.retain(|existing| {
existing != &path && !super::recent_files::is_superseded_sidecar(existing, &path)
});
self.recent_native_files.insert(0, path);
self.recent_native_files.truncate(MAX_RECENT_NATIVE_FILES);
}
#[must_use]
pub fn is_confirm_suppressed(&self, key: &str) -> bool {
self.suppressed_confirmations.contains(key)
}
pub fn suppress_confirm(&mut self, key: impl Into<String>) {
self.suppressed_confirmations.insert(key.into());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_recent_native_file_inserts_at_the_front() {
let mut settings = AppSettings::default();
settings.record_recent_native_file("a.indicatrix.toml".to_string());
settings.record_recent_native_file("b.indicatrix.toml".to_string());
assert_eq!(
settings.recent_native_files,
vec![
"b.indicatrix.toml".to_string(),
"a.indicatrix.toml".to_string()
]
);
}
#[test]
fn record_recent_native_file_moves_an_existing_entry_to_the_front_without_duplicating() {
let mut settings = AppSettings::default();
settings.record_recent_native_file("a.indicatrix.toml".to_string());
settings.record_recent_native_file("b.indicatrix.toml".to_string());
settings.record_recent_native_file("a.indicatrix.toml".to_string());
assert_eq!(
settings.recent_native_files,
vec![
"a.indicatrix.toml".to_string(),
"b.indicatrix.toml".to_string()
]
);
}
#[test]
fn recording_a_design_file_replaces_the_older_sidecar_entry_of_the_same_design() {
let mut settings = AppSettings::default();
settings.record_recent_native_file("d/round.indicatrix.toml".to_string());
settings.record_recent_native_file("d/other.gemcut.toml".to_string());
settings.record_recent_native_file("d/round.indicatrix".to_string());
assert_eq!(
settings.recent_native_files,
vec![
"d/round.indicatrix".to_string(),
"d/other.gemcut.toml".to_string()
],
"both kinds are accepted; the older entry of the saved design is gone"
);
settings.record_recent_native_file("d/legacy.indicatrix.toml".to_string());
assert_eq!(settings.recent_native_files[0], "d/legacy.indicatrix.toml");
assert_eq!(settings.recent_native_files.len(), 3);
}
#[test]
fn record_recent_native_file_caps_at_max_recent_native_files() {
let mut settings = AppSettings::default();
for i in 0..(MAX_RECENT_NATIVE_FILES + 3) {
settings.record_recent_native_file(format!("design-{i}.indicatrix.toml"));
}
assert_eq!(settings.recent_native_files.len(), MAX_RECENT_NATIVE_FILES);
assert_eq!(
settings.recent_native_files[0],
format!("design-{}.indicatrix.toml", MAX_RECENT_NATIVE_FILES + 2)
);
}
#[test]
fn a_fresh_settings_file_suppresses_nothing() {
let settings = AppSettings::default();
assert!(!settings.is_confirm_suppressed("write_confirm.not_closed_solid"));
}
#[test]
fn suppress_confirm_is_reflected_immediately() {
let mut settings = AppSettings::default();
settings.suppress_confirm("write_confirm.not_closed_solid");
assert!(settings.is_confirm_suppressed("write_confirm.not_closed_solid"));
}
#[test]
fn suppressing_one_key_does_not_suppress_a_different_one() {
let mut settings = AppSettings::default();
settings.suppress_confirm("write_confirm.not_closed_solid");
assert!(!settings.is_confirm_suppressed("write_confirm.overwrite_unrelated"));
}
#[test]
fn suppress_confirm_is_idempotent() {
let mut settings = AppSettings::default();
settings.suppress_confirm("write_confirm.not_closed_solid");
settings.suppress_confirm("write_confirm.not_closed_solid");
assert_eq!(settings.suppressed_confirmations.len(), 1);
}
#[test]
fn suppressed_confirmations_round_trips_through_toml() {
let mut settings = AppSettings::default();
settings.suppress_confirm("write_confirm.not_closed_solid");
settings.suppress_confirm("write_confirm.overwrite_unrelated");
let toml_text = toml::to_string(&settings).expect("settings must serialize");
let restored: AppSettings = toml::from_str(&toml_text).expect("settings must parse");
assert_eq!(
restored.suppressed_confirmations,
settings.suppressed_confirmations
);
}
#[test]
fn a_settings_file_predating_this_field_loads_with_nothing_suppressed() {
let settings = AppSettings::default();
let mut toml_text = toml::to_string(&settings).expect("settings must serialize");
let filtered: String = toml_text
.lines()
.filter(|line| !line.contains("suppressed_confirmations"))
.collect::<Vec<_>>()
.join("\n");
toml_text = filtered;
let restored: AppSettings = toml::from_str(&toml_text)
.expect("a settings file missing this field must still parse");
assert!(restored.suppressed_confirmations.is_empty());
}
}