1use anyhow::Result;
4use indexmap::IndexMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9use crate::settings::types::{
10 is_default, AppColor, CommandBarSettings, DefaultTrue, FloatOrInt, OnboardingStatus, TextEditorSettings, UnitLength,
11};
12
13#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
19#[ts(export)]
20#[serde(rename_all = "snake_case")]
21pub struct ProjectConfiguration {
22 #[serde(default)]
24 #[validate(nested)]
25 pub settings: PerProjectSettings,
26}
27
28impl ProjectConfiguration {
29 pub fn backwards_compatible_toml_parse(toml_str: &str) -> Result<Self> {
31 let mut settings = toml::from_str::<Self>(toml_str)?;
32
33 if let Some(theme_color) = &settings.settings.app.theme_color {
34 if settings.settings.app.appearance.color == AppColor::default() {
35 settings.settings.app.appearance.color = theme_color.clone().into();
36 settings.settings.app.theme_color = None;
37 }
38 }
39
40 if let Some(enable_ssao) = settings.settings.app.enable_ssao {
41 if settings.settings.modeling.enable_ssao.into() {
42 settings.settings.modeling.enable_ssao = enable_ssao.into();
43 settings.settings.app.enable_ssao = None;
44 }
45 }
46
47 if settings.settings.modeling.show_debug_panel && !settings.settings.app.show_debug_panel {
48 settings.settings.app.show_debug_panel = settings.settings.modeling.show_debug_panel;
49 settings.settings.modeling.show_debug_panel = Default::default();
50 }
51
52 settings.validate()?;
53
54 Ok(settings)
55 }
56}
57
58#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
60#[ts(export)]
61#[serde(rename_all = "snake_case")]
62pub struct PerProjectSettings {
63 #[serde(default)]
65 #[validate(nested)]
66 pub app: ProjectAppSettings,
67 #[serde(default)]
69 #[validate(nested)]
70 pub modeling: ProjectModelingSettings,
71 #[serde(default, alias = "textEditor")]
73 #[validate(nested)]
74 pub text_editor: TextEditorSettings,
75 #[serde(default, alias = "commandBar")]
77 #[validate(nested)]
78 pub command_bar: CommandBarSettings,
79}
80
81#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
85#[ts(export)]
86#[serde(rename_all = "snake_case")]
87pub struct ProjectAppSettings {
88 #[serde(default, skip_serializing_if = "is_default")]
90 #[validate(nested)]
91 pub appearance: ProjectAppearanceSettings,
92 #[serde(default, alias = "onboardingStatus", skip_serializing_if = "is_default")]
94 pub onboarding_status: OnboardingStatus,
95 #[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
97 pub theme_color: Option<FloatOrInt>,
98 #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
100 pub enable_ssao: Option<bool>,
101 #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
104 pub dismiss_web_banner: bool,
105 #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
107 pub stream_idle_mode: bool,
108 #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
110 pub allow_orbit_in_sketch_mode: bool,
111 #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
114 pub show_debug_panel: bool,
115 #[serde(default, alias = "namedViews", skip_serializing_if = "IndexMap::is_empty")]
117 pub named_views: IndexMap<uuid::Uuid, NamedView>,
118}
119
120#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
122#[ts(export)]
123#[serde(rename_all = "snake_case")]
124pub struct ProjectAppearanceSettings {
125 #[serde(default, skip_serializing_if = "is_default")]
127 #[validate(nested)]
128 pub color: AppColor,
129}
130
131#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
133#[serde(rename_all = "snake_case")]
134#[ts(export)]
135pub struct ProjectModelingSettings {
136 #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
138 pub base_unit: UnitLength,
139 #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
141 pub highlight_edges: DefaultTrue,
142 #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
146 pub show_debug_panel: bool,
147 #[serde(default, skip_serializing_if = "is_default")]
149 pub enable_ssao: DefaultTrue,
150}
151
152fn named_view_point_version_one() -> f64 {
153 1.0
154}
155
156#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
157#[serde(rename_all = "snake_case")]
158#[ts(export)]
159pub struct NamedView {
160 #[serde(default, alias = "name", skip_serializing_if = "is_default")]
162 pub name: String,
163 #[serde(default, alias = "eyeOffset", skip_serializing_if = "is_default")]
165 pub eye_offset: f64,
166 #[serde(default, alias = "fovY", skip_serializing_if = "is_default")]
168 pub fov_y: f64,
169 #[serde(default, alias = "isOrtho")]
171 pub is_ortho: bool,
172 #[serde(default, alias = "orthoScaleEnabled")]
174 pub ortho_scale_enabled: bool,
175 #[serde(default, alias = "orthoScaleFactor", skip_serializing_if = "is_default")]
177 pub ortho_scale_factor: f64,
178 #[serde(default, alias = "pivotPosition", skip_serializing_if = "is_default")]
180 pub pivot_position: [f64; 3],
181 #[serde(default, alias = "pivotRotation", skip_serializing_if = "is_default")]
183 pub pivot_rotation: [f64; 4],
184 #[serde(default, alias = "worldCoordSystem", skip_serializing_if = "is_default")]
186 pub world_coord_system: String,
187 #[serde(default = "named_view_point_version_one")]
189 pub version: f64,
190}
191
192#[cfg(test)]
193mod tests {
194 use indexmap::IndexMap;
195 use pretty_assertions::assert_eq;
196 use serde_json::Value;
197
198 use super::{
199 CommandBarSettings, NamedView, PerProjectSettings, ProjectAppSettings, ProjectAppearanceSettings,
200 ProjectConfiguration, ProjectModelingSettings, TextEditorSettings,
201 };
202 use crate::settings::types::UnitLength;
203
204 #[test]
205 fn test_backwards_compatible_project_settings_file() {
208 let old_project_file = r#"[settings.app]
209themeColor = "138"
210
211[settings.textEditor]
212textWrapping = false
213blinkingCursor = false
214
215[settings.modeling]
216showDebugPanel = true
217
218[settings.commandBar]
219includeSettings = false
220#"#;
221
222 let parsed = ProjectConfiguration::backwards_compatible_toml_parse(old_project_file).unwrap();
224 assert_eq!(
225 parsed,
226 ProjectConfiguration {
227 settings: PerProjectSettings {
228 app: ProjectAppSettings {
229 appearance: ProjectAppearanceSettings { color: 138.0.into() },
230 onboarding_status: Default::default(),
231 theme_color: None,
232 dismiss_web_banner: false,
233 enable_ssao: None,
234 stream_idle_mode: false,
235 allow_orbit_in_sketch_mode: false,
236 show_debug_panel: true,
237 named_views: IndexMap::default()
238 },
239 modeling: ProjectModelingSettings {
240 base_unit: UnitLength::Mm,
241 highlight_edges: Default::default(),
242 show_debug_panel: Default::default(),
243 enable_ssao: true.into(),
244 },
245 text_editor: TextEditorSettings {
246 text_wrapping: false.into(),
247 blinking_cursor: false.into()
248 },
249 command_bar: CommandBarSettings {
250 include_settings: false.into()
251 },
252 }
253 }
254 );
255
256 let serialized = toml::to_string(&parsed).unwrap();
258 assert_eq!(
259 serialized,
260 r#"[settings.app]
261show_debug_panel = true
262
263[settings.app.appearance]
264color = 138.0
265
266[settings.modeling]
267
268[settings.text_editor]
269text_wrapping = false
270blinking_cursor = false
271
272[settings.command_bar]
273include_settings = false
274"#
275 );
276 }
277
278 #[test]
279 fn test_project_settings_empty_file_parses() {
280 let empty_settings_file = r#""#;
281
282 let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
283 assert_eq!(parsed, ProjectConfiguration::default());
284
285 let serialized = toml::to_string(&parsed).unwrap();
287 assert_eq!(
288 serialized,
289 r#"[settings.app]
290
291[settings.modeling]
292
293[settings.text_editor]
294
295[settings.command_bar]
296"#
297 );
298
299 let parsed = ProjectConfiguration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
300 assert_eq!(parsed, ProjectConfiguration::default());
301 }
302
303 #[test]
304 fn test_project_settings_color_validation_error() {
305 let settings_file = r#"[settings.app.appearance]
306color = 1567.4"#;
307
308 let result = ProjectConfiguration::backwards_compatible_toml_parse(settings_file);
309 if let Ok(r) = result {
310 panic!("Expected an error, but got success: {:?}", r);
311 }
312 assert!(result.is_err());
313
314 assert!(result
315 .unwrap_err()
316 .to_string()
317 .contains("color: Validation error: color"));
318 }
319
320 #[test]
321 fn named_view_serde_json() {
322 let json = r#"
323 [
324 {
325 "name":"dog",
326 "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
327 "pivot_position":[0.5,0,0.5],
328 "eye_offset":231.52048,
329 "fov_y":45,
330 "ortho_scale_factor":1.574129,
331 "is_ortho":true,
332 "ortho_scale_enabled":true,
333 "world_coord_system":"RightHandedUpZ"
334 }
335 ]
336 "#;
337 let named_views: Vec<NamedView> = serde_json::from_str(json).unwrap();
339 let version = named_views[0].version;
340 assert_eq!(version, 1.0);
341 }
342
343 #[test]
344 fn named_view_serde_json_string() {
345 let json = r#"
346 [
347 {
348 "name":"dog",
349 "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
350 "pivot_position":[0.5,0,0.5],
351 "eye_offset":231.52048,
352 "fov_y":45,
353 "ortho_scale_factor":1.574129,
354 "is_ortho":true,
355 "ortho_scale_enabled":true,
356 "world_coord_system":"RightHandedUpZ"
357 }
358 ]
359 "#;
360
361 let named_views: Value = match serde_json::from_str(json) {
363 Ok(x) => x,
364 Err(_) => return,
365 };
366 println!("{}", named_views);
367 }
368
369 #[test]
370 fn test_project_settings_named_views() {
371 let conf = ProjectConfiguration {
372 settings: PerProjectSettings {
373 app: ProjectAppSettings {
374 appearance: ProjectAppearanceSettings { color: 138.0.into() },
375 onboarding_status: Default::default(),
376 theme_color: None,
377 dismiss_web_banner: false,
378 enable_ssao: None,
379 stream_idle_mode: false,
380 allow_orbit_in_sketch_mode: false,
381 show_debug_panel: true,
382 named_views: IndexMap::from([
383 (
384 uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
385 NamedView {
386 name: String::from("Hello"),
387 eye_offset: 1236.4015,
388 fov_y: 45.0,
389 is_ortho: false,
390 ortho_scale_enabled: false,
391 ortho_scale_factor: 45.0,
392 pivot_position: [-100.0, 100.0, 100.0],
393 pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
394 world_coord_system: String::from("RightHandedUpZ"),
395 version: 1.0,
396 },
397 ),
398 (
399 uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
400 NamedView {
401 name: String::from("Goodbye"),
402 eye_offset: 1236.4015,
403 fov_y: 45.0,
404 is_ortho: false,
405 ortho_scale_enabled: false,
406 ortho_scale_factor: 45.0,
407 pivot_position: [-100.0, 100.0, 100.0],
408 pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
409 world_coord_system: String::from("RightHandedUpZ"),
410 version: 1.0,
411 },
412 ),
413 ]),
414 },
415 modeling: ProjectModelingSettings {
416 base_unit: UnitLength::Yd,
417 highlight_edges: Default::default(),
418 show_debug_panel: Default::default(),
419 enable_ssao: true.into(),
420 },
421 text_editor: TextEditorSettings {
422 text_wrapping: false.into(),
423 blinking_cursor: false.into(),
424 },
425 command_bar: CommandBarSettings {
426 include_settings: false.into(),
427 },
428 },
429 };
430 let serialized = toml::to_string(&conf).unwrap();
431 let old_project_file = r#"[settings.app]
432show_debug_panel = true
433
434[settings.app.appearance]
435color = 138.0
436
437[settings.app.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]
438name = "Hello"
439eye_offset = 1236.4015
440fov_y = 45.0
441is_ortho = false
442ortho_scale_enabled = false
443ortho_scale_factor = 45.0
444pivot_position = [-100.0, 100.0, 100.0]
445pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
446world_coord_system = "RightHandedUpZ"
447version = 1.0
448
449[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]
450name = "Goodbye"
451eye_offset = 1236.4015
452fov_y = 45.0
453is_ortho = false
454ortho_scale_enabled = false
455ortho_scale_factor = 45.0
456pivot_position = [-100.0, 100.0, 100.0]
457pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
458world_coord_system = "RightHandedUpZ"
459version = 1.0
460
461[settings.modeling]
462base_unit = "yd"
463
464[settings.text_editor]
465text_wrapping = false
466blinking_cursor = false
467
468[settings.command_bar]
469include_settings = false
470"#;
471
472 assert_eq!(serialized, old_project_file)
473 }
474}