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