kcl_lib/settings/types/
project.rs

1//! Types specific for modeling-app projects.
2
3use 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/// Project specific settings for the app.
14/// These live in `project.toml` in the base of the project directory.
15/// Updating the settings for the project in the app will update this file automatically.
16/// Do not edit this file manually, as it may be overwritten by the app.
17/// Manual edits can cause corruption of the settings file.
18#[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    /// The settings for the project.
23    #[serde(default)]
24    #[validate(nested)]
25    pub settings: PerProjectSettings,
26}
27
28impl ProjectConfiguration {
29    // TODO: remove this when we remove backwards compatibility with the old settings file.
30    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/// High level project settings.
59#[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    /// The settings for the Design Studio.
64    #[serde(default)]
65    #[validate(nested)]
66    pub app: ProjectAppSettings,
67    /// Settings that affect the behavior while modeling.
68    #[serde(default)]
69    #[validate(nested)]
70    pub modeling: ProjectModelingSettings,
71    /// Settings that affect the behavior of the KCL text editor.
72    #[serde(default, alias = "textEditor")]
73    #[validate(nested)]
74    pub text_editor: TextEditorSettings,
75    /// Settings that affect the behavior of the command bar.
76    #[serde(default, alias = "commandBar")]
77    #[validate(nested)]
78    pub command_bar: CommandBarSettings,
79}
80
81/// Project specific application settings.
82// TODO: When we remove backwards compatibility with the old settings file, we can remove the
83// aliases to camelCase (and projects plural) from everywhere.
84#[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    /// The settings for the appearance of the app.
89    #[serde(default, skip_serializing_if = "is_default")]
90    #[validate(nested)]
91    pub appearance: ProjectAppearanceSettings,
92    /// The onboarding status of the app.
93    #[serde(default, alias = "onboardingStatus", skip_serializing_if = "is_default")]
94    pub onboarding_status: OnboardingStatus,
95    /// The hue of the primary theme color for the app.
96    #[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
97    #[ts(skip)]
98    pub theme_color: Option<FloatOrInt>,
99    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
100    #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
101    #[ts(skip)]
102    pub enable_ssao: Option<bool>,
103    /// Permanently dismiss the banner warning to download the desktop app.
104    /// This setting only applies to the web app. And is temporary until we have Linux support.
105    #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
106    pub dismiss_web_banner: bool,
107    /// When the user is idle, and this is true, the stream will be torn down.
108    #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
109    pub stream_idle_mode: bool,
110    /// When the user is idle, and this is true, the stream will be torn down.
111    #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
112    pub allow_orbit_in_sketch_mode: bool,
113    /// Whether to show the debug panel, which lets you see various states
114    /// of the app to aid in development.
115    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
116    pub show_debug_panel: bool,
117    /// Settings that affect the behavior of the command bar.
118    #[serde(default, alias = "namedViews", skip_serializing_if = "IndexMap::is_empty")]
119    pub named_views: IndexMap<uuid::Uuid, NamedView>,
120}
121
122/// Project specific appearance settings.
123#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
124#[ts(export)]
125#[serde(rename_all = "snake_case")]
126pub struct ProjectAppearanceSettings {
127    /// The hue of the primary theme color for the app.
128    #[serde(default, skip_serializing_if = "is_default")]
129    #[validate(nested)]
130    pub color: AppColor,
131}
132
133/// Project specific settings that affect the behavior while modeling.
134#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
135#[serde(rename_all = "snake_case")]
136#[ts(export)]
137pub struct ProjectModelingSettings {
138    /// The default unit to use in modeling dimensions.
139    #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
140    pub base_unit: UnitLength,
141    /// Highlight edges of 3D objects?
142    #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
143    pub highlight_edges: DefaultTrue,
144    /// Whether to show the debug panel, which lets you see various states
145    /// of the app to aid in development.
146    /// Remove this when we remove backwards compatibility with the old settings file.
147    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
148    #[ts(skip)]
149    pub show_debug_panel: bool,
150    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
151    #[serde(default, skip_serializing_if = "is_default")]
152    pub enable_ssao: DefaultTrue,
153}
154
155fn named_view_point_version_one() -> f64 {
156    1.0
157}
158
159#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
160#[serde(rename_all = "snake_case")]
161#[ts(export)]
162pub struct NamedView {
163    /// User defined name to identify the named view. A label.
164    #[serde(default, alias = "name")]
165    pub name: String,
166    /// Engine camera eye off set
167    #[serde(default, alias = "eyeOffset")]
168    pub eye_offset: f64,
169    /// Engine camera vertical FOV
170    #[serde(default, alias = "fovY")]
171    pub fov_y: f64,
172    // Engine camera is orthographic or perspective projection
173    #[serde(default, alias = "isOrtho")]
174    pub is_ortho: bool,
175    /// Engine camera is orthographic camera scaling enabled
176    #[serde(default, alias = "orthoScaleEnabled")]
177    pub ortho_scale_enabled: bool,
178    /// Engine camera orthographic scaling factor
179    #[serde(default, alias = "orthoScaleFactor")]
180    pub ortho_scale_factor: f64,
181    /// Engine camera position that the camera pivots around
182    #[serde(default, alias = "pivotPosition")]
183    pub pivot_position: [f64; 3],
184    /// Engine camera orientation in relation to the pivot position
185    #[serde(default, alias = "pivotRotation")]
186    pub pivot_rotation: [f64; 4],
187    /// Engine camera world coordinate system orientation
188    #[serde(default, alias = "worldCoordSystem")]
189    pub world_coord_system: String,
190    /// Version number of the view point if the engine camera API changes
191    #[serde(default = "named_view_point_version_one")]
192    pub version: f64,
193}
194
195#[cfg(test)]
196mod tests {
197    use indexmap::IndexMap;
198    use pretty_assertions::assert_eq;
199    use serde_json::Value;
200
201    use super::{
202        CommandBarSettings, NamedView, PerProjectSettings, ProjectAppSettings, ProjectAppearanceSettings,
203        ProjectConfiguration, ProjectModelingSettings, TextEditorSettings,
204    };
205    use crate::settings::types::UnitLength;
206
207    #[test]
208    // Test that we can deserialize a project file from the old format.
209    // TODO: We can remove this functionality after a few versions.
210    fn test_backwards_compatible_project_settings_file() {
211        let old_project_file = r#"[settings.app]
212themeColor = "138"
213
214[settings.textEditor]
215textWrapping = false
216blinkingCursor = false
217
218[settings.modeling]
219showDebugPanel = true
220
221[settings.commandBar]
222includeSettings = false
223#"#;
224
225        //let parsed = toml::from_str::<ProjectConfiguration(old_project_file).unwrap();
226        let parsed = ProjectConfiguration::backwards_compatible_toml_parse(old_project_file).unwrap();
227        assert_eq!(
228            parsed,
229            ProjectConfiguration {
230                settings: PerProjectSettings {
231                    app: ProjectAppSettings {
232                        appearance: ProjectAppearanceSettings { color: 138.0.into() },
233                        onboarding_status: Default::default(),
234                        theme_color: None,
235                        dismiss_web_banner: false,
236                        enable_ssao: None,
237                        stream_idle_mode: false,
238                        allow_orbit_in_sketch_mode: false,
239                        show_debug_panel: true,
240                        named_views: IndexMap::default()
241                    },
242                    modeling: ProjectModelingSettings {
243                        base_unit: UnitLength::Mm,
244                        highlight_edges: Default::default(),
245                        show_debug_panel: Default::default(),
246                        enable_ssao: true.into(),
247                    },
248                    text_editor: TextEditorSettings {
249                        text_wrapping: false.into(),
250                        blinking_cursor: false.into()
251                    },
252                    command_bar: CommandBarSettings {
253                        include_settings: false.into()
254                    },
255                }
256            }
257        );
258
259        // Write the file back out.
260        let serialized = toml::to_string(&parsed).unwrap();
261        assert_eq!(
262            serialized,
263            r#"[settings.app]
264show_debug_panel = true
265
266[settings.app.appearance]
267color = 138.0
268
269[settings.modeling]
270
271[settings.text_editor]
272text_wrapping = false
273blinking_cursor = false
274
275[settings.command_bar]
276include_settings = false
277"#
278        );
279    }
280
281    #[test]
282    fn test_project_settings_empty_file_parses() {
283        let empty_settings_file = r#""#;
284
285        let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
286        assert_eq!(parsed, ProjectConfiguration::default());
287
288        // Write the file back out.
289        let serialized = toml::to_string(&parsed).unwrap();
290        assert_eq!(
291            serialized,
292            r#"[settings.app]
293
294[settings.modeling]
295
296[settings.text_editor]
297
298[settings.command_bar]
299"#
300        );
301
302        let parsed = ProjectConfiguration::backwards_compatible_toml_parse(empty_settings_file).unwrap();
303        assert_eq!(parsed, ProjectConfiguration::default());
304    }
305
306    #[test]
307    fn test_project_settings_color_validation_error() {
308        let settings_file = r#"[settings.app.appearance]
309color = 1567.4"#;
310
311        let result = ProjectConfiguration::backwards_compatible_toml_parse(settings_file);
312        if let Ok(r) = result {
313            panic!("Expected an error, but got success: {:?}", r);
314        }
315        assert!(result.is_err());
316
317        assert!(result
318            .unwrap_err()
319            .to_string()
320            .contains("color: Validation error: color"));
321    }
322
323    #[test]
324    fn named_view_serde_json() {
325        let json = r#"
326        [
327          {
328            "name":"dog",
329            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
330            "pivot_position":[0.5,0,0.5],
331            "eye_offset":231.52048,
332            "fov_y":45,
333            "ortho_scale_factor":1.574129,
334            "is_ortho":true,
335            "ortho_scale_enabled":true,
336            "world_coord_system":"RightHandedUpZ"
337          }
338    ]
339    "#;
340        // serde_json to a NamedView will produce default values
341        let named_views: Vec<NamedView> = serde_json::from_str(json).unwrap();
342        let version = named_views[0].version;
343        assert_eq!(version, 1.0);
344    }
345
346    #[test]
347    fn named_view_serde_json_string() {
348        let json = r#"
349        [
350          {
351            "name":"dog",
352            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
353            "pivot_position":[0.5,0,0.5],
354            "eye_offset":231.52048,
355            "fov_y":45,
356            "ortho_scale_factor":1.574129,
357            "is_ortho":true,
358            "ortho_scale_enabled":true,
359            "world_coord_system":"RightHandedUpZ"
360          }
361    ]
362    "#;
363
364        // serde_json to string does not produce default values
365        let named_views: Value = match serde_json::from_str(json) {
366            Ok(x) => x,
367            Err(_) => return,
368        };
369        println!("{}", named_views);
370    }
371
372    #[test]
373    fn test_project_settings_named_views() {
374        let conf = ProjectConfiguration {
375            settings: PerProjectSettings {
376                app: ProjectAppSettings {
377                    appearance: ProjectAppearanceSettings { color: 138.0.into() },
378                    onboarding_status: Default::default(),
379                    theme_color: None,
380                    dismiss_web_banner: false,
381                    enable_ssao: None,
382                    stream_idle_mode: false,
383                    allow_orbit_in_sketch_mode: false,
384                    show_debug_panel: true,
385                    named_views: IndexMap::from([
386                        (
387                            uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
388                            NamedView {
389                                name: String::from("Hello"),
390                                eye_offset: 1236.4015,
391                                fov_y: 45.0,
392                                is_ortho: false,
393                                ortho_scale_enabled: false,
394                                ortho_scale_factor: 45.0,
395                                pivot_position: [-100.0, 100.0, 100.0],
396                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
397                                world_coord_system: String::from("RightHandedUpZ"),
398                                version: 1.0,
399                            },
400                        ),
401                        (
402                            uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
403                            NamedView {
404                                name: String::from("Goodbye"),
405                                eye_offset: 1236.4015,
406                                fov_y: 45.0,
407                                is_ortho: false,
408                                ortho_scale_enabled: false,
409                                ortho_scale_factor: 45.0,
410                                pivot_position: [-100.0, 100.0, 100.0],
411                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
412                                world_coord_system: String::from("RightHandedUpZ"),
413                                version: 1.0,
414                            },
415                        ),
416                    ]),
417                },
418                modeling: ProjectModelingSettings {
419                    base_unit: UnitLength::Yd,
420                    highlight_edges: Default::default(),
421                    show_debug_panel: Default::default(),
422                    enable_ssao: true.into(),
423                },
424                text_editor: TextEditorSettings {
425                    text_wrapping: false.into(),
426                    blinking_cursor: false.into(),
427                },
428                command_bar: CommandBarSettings {
429                    include_settings: false.into(),
430                },
431            },
432        };
433        let serialized = toml::to_string(&conf).unwrap();
434        let old_project_file = r#"[settings.app]
435show_debug_panel = true
436
437[settings.app.appearance]
438color = 138.0
439
440[settings.app.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]
441name = "Hello"
442eye_offset = 1236.4015
443fov_y = 45.0
444is_ortho = false
445ortho_scale_enabled = false
446ortho_scale_factor = 45.0
447pivot_position = [-100.0, 100.0, 100.0]
448pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
449world_coord_system = "RightHandedUpZ"
450version = 1.0
451
452[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]
453name = "Goodbye"
454eye_offset = 1236.4015
455fov_y = 45.0
456is_ortho = false
457ortho_scale_enabled = false
458ortho_scale_factor = 45.0
459pivot_position = [-100.0, 100.0, 100.0]
460pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
461world_coord_system = "RightHandedUpZ"
462version = 1.0
463
464[settings.modeling]
465base_unit = "yd"
466
467[settings.text_editor]
468text_wrapping = false
469blinking_cursor = false
470
471[settings.command_bar]
472include_settings = false
473"#;
474
475        assert_eq!(serialized, old_project_file)
476    }
477}