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 modeling app.
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    pub theme_color: Option<FloatOrInt>,
98    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
99    #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
100    pub enable_ssao: Option<bool>,
101    /// Permanently dismiss the banner warning to download the desktop app.
102    /// This setting only applies to the web app. And is temporary until we have Linux support.
103    #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
104    pub dismiss_web_banner: bool,
105    /// When the user is idle, and this is true, the stream will be torn down.
106    #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
107    pub stream_idle_mode: bool,
108    /// When the user is idle, and this is true, the stream will be torn down.
109    #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
110    pub allow_orbit_in_sketch_mode: bool,
111    /// Whether to show the debug panel, which lets you see various states
112    /// of the app to aid in development.
113    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
114    pub show_debug_panel: bool,
115    /// Settings that affect the behavior of the command bar.
116    #[serde(default, alias = "namedViews", skip_serializing_if = "IndexMap::is_empty")]
117    pub named_views: IndexMap<uuid::Uuid, NamedView>,
118}
119
120/// Project specific appearance settings.
121#[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    /// The hue of the primary theme color for the app.
126    #[serde(default, skip_serializing_if = "is_default")]
127    #[validate(nested)]
128    pub color: AppColor,
129}
130
131/// Project specific settings that affect the behavior while modeling.
132#[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    /// The default unit to use in modeling dimensions.
137    #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
138    pub base_unit: UnitLength,
139    /// Highlight edges of 3D objects?
140    #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
141    pub highlight_edges: DefaultTrue,
142    /// Whether to show the debug panel, which lets you see various states
143    /// of the app to aid in development.
144    /// Remove this when we remove backwards compatibility with the old settings file.
145    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
146    pub show_debug_panel: bool,
147    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
148    #[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    /// User defined name to identify the named view. A label.
161    #[serde(default, alias = "name", skip_serializing_if = "is_default")]
162    pub name: String,
163    /// Engine camera eye off set
164    #[serde(default, alias = "eyeOffset", skip_serializing_if = "is_default")]
165    pub eye_offset: f64,
166    /// Engine camera vertical FOV
167    #[serde(default, alias = "fovY", skip_serializing_if = "is_default")]
168    pub fov_y: f64,
169    // Engine camera is orthographic or perspective projection
170    #[serde(default, alias = "isOrtho")]
171    pub is_ortho: bool,
172    /// Engine camera is orthographic camera scaling enabled
173    #[serde(default, alias = "orthoScaleEnabled")]
174    pub ortho_scale_enabled: bool,
175    /// Engine camera orthographic scaling factor
176    #[serde(default, alias = "orthoScaleFactor", skip_serializing_if = "is_default")]
177    pub ortho_scale_factor: f64,
178    /// Engine camera position that the camera pivots around
179    #[serde(default, alias = "pivotPosition", skip_serializing_if = "is_default")]
180    pub pivot_position: [f64; 3],
181    /// Engine camera orientation in relation to the pivot position
182    #[serde(default, alias = "pivotRotation", skip_serializing_if = "is_default")]
183    pub pivot_rotation: [f64; 4],
184    /// Engine camera world coordinate system orientation
185    #[serde(default, alias = "worldCoordSystem", skip_serializing_if = "is_default")]
186    pub world_coord_system: String,
187    /// Version number of the view point if the engine camera API changes
188    #[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    // Test that we can deserialize a project file from the old format.
206    // TODO: We can remove this functionality after a few versions.
207    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 = toml::from_str::<ProjectConfiguration(old_project_file).unwrap();
223        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        // Write the file back out.
257        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        // Write the file back out.
286        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        // serde_json to a NamedView will produce default values
338        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        // serde_json to string does not produce default values
362        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}