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/// High level project configuration.
14#[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    /// The settings for the project.
19    #[serde(default)]
20    #[validate(nested)]
21    pub settings: PerProjectSettings,
22}
23
24impl ProjectConfiguration {
25    // TODO: remove this when we remove backwards compatibility with the old settings file.
26    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/// High level project settings.
55#[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    /// The settings for the modeling app.
60    #[serde(default)]
61    #[validate(nested)]
62    pub app: ProjectAppSettings,
63    /// Settings that affect the behavior while modeling.
64    #[serde(default)]
65    #[validate(nested)]
66    pub modeling: ProjectModelingSettings,
67    /// Settings that affect the behavior of the KCL text editor.
68    #[serde(default, alias = "textEditor")]
69    #[validate(nested)]
70    pub text_editor: TextEditorSettings,
71    /// Settings that affect the behavior of the command bar.
72    #[serde(default, alias = "commandBar")]
73    #[validate(nested)]
74    pub command_bar: CommandBarSettings,
75}
76
77/// Project specific application settings.
78// TODO: When we remove backwards compatibility with the old settings file, we can remove the
79// aliases to camelCase (and projects plural) from everywhere.
80#[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    /// The settings for the appearance of the app.
85    #[serde(default, skip_serializing_if = "is_default")]
86    #[validate(nested)]
87    pub appearance: ProjectAppearanceSettings,
88    /// The onboarding status of the app.
89    #[serde(default, alias = "onboardingStatus", skip_serializing_if = "is_default")]
90    pub onboarding_status: OnboardingStatus,
91    /// The hue of the primary theme color for the app.
92    #[serde(default, skip_serializing_if = "Option::is_none", alias = "themeColor")]
93    pub theme_color: Option<FloatOrInt>,
94    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
95    #[serde(default, alias = "enableSSAO", skip_serializing_if = "Option::is_none")]
96    pub enable_ssao: Option<bool>,
97    /// Permanently dismiss the banner warning to download the desktop app.
98    /// This setting only applies to the web app. And is temporary until we have Linux support.
99    #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")]
100    pub dismiss_web_banner: bool,
101    /// When the user is idle, and this is true, the stream will be torn down.
102    #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")]
103    pub stream_idle_mode: bool,
104    /// When the user is idle, and this is true, the stream will be torn down.
105    #[serde(default, alias = "allowOrbitInSketchMode", skip_serializing_if = "is_default")]
106    pub allow_orbit_in_sketch_mode: bool,
107    /// Whether to show the debug panel, which lets you see various states
108    /// of the app to aid in development.
109    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
110    pub show_debug_panel: bool,
111    /// Settings that affect the behavior of the command bar.
112    #[serde(default, alias = "namedViews", skip_serializing_if = "IndexMap::is_empty")]
113    pub named_views: IndexMap<uuid::Uuid, NamedView>,
114}
115
116/// Project specific appearance settings.
117#[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    /// The hue of the primary theme color for the app.
122    #[serde(default, skip_serializing_if = "is_default")]
123    #[validate(nested)]
124    pub color: AppColor,
125}
126
127/// Project specific settings that affect the behavior while modeling.
128#[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    /// The default unit to use in modeling dimensions.
133    #[serde(default, alias = "defaultUnit", skip_serializing_if = "is_default")]
134    pub base_unit: UnitLength,
135    /// Highlight edges of 3D objects?
136    #[serde(default, alias = "highlightEdges", skip_serializing_if = "is_default")]
137    pub highlight_edges: DefaultTrue,
138    /// Whether to show the debug panel, which lets you see various states
139    /// of the app to aid in development.
140    /// Remove this when we remove backwards compatibility with the old settings file.
141    #[serde(default, alias = "showDebugPanel", skip_serializing_if = "is_default")]
142    pub show_debug_panel: bool,
143    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
144    #[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    /// User defined name to identify the named view. A label.
157    #[serde(default, alias = "name", skip_serializing_if = "is_default")]
158    pub name: String,
159    /// Engine camera eye off set
160    #[serde(default, alias = "eyeOffset", skip_serializing_if = "is_default")]
161    pub eye_offset: f64,
162    /// Engine camera vertical FOV
163    #[serde(default, alias = "fovY", skip_serializing_if = "is_default")]
164    pub fov_y: f64,
165    // Engine camera is orthographic or perspective projection
166    #[serde(default, alias = "isOrtho")]
167    pub is_ortho: bool,
168    /// Engine camera is orthographic camera scaling enabled
169    #[serde(default, alias = "orthoScaleEnabled")]
170    pub ortho_scale_enabled: bool,
171    /// Engine camera orthographic scaling factor
172    #[serde(default, alias = "orthoScaleFactor", skip_serializing_if = "is_default")]
173    pub ortho_scale_factor: f64,
174    /// Engine camera position that the camera pivots around
175    #[serde(default, alias = "pivotPosition", skip_serializing_if = "is_default")]
176    pub pivot_position: [f64; 3],
177    /// Engine camera orientation in relation to the pivot position
178    #[serde(default, alias = "pivotRotation", skip_serializing_if = "is_default")]
179    pub pivot_rotation: [f64; 4],
180    /// Engine camera world coordinate system orientation
181    #[serde(default, alias = "worldCoordSystem", skip_serializing_if = "is_default")]
182    pub world_coord_system: String,
183    /// Version number of the view point if the engine camera API changes
184    #[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    // Test that we can deserialize a project file from the old format.
202    // TODO: We can remove this functionality after a few versions.
203    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 = toml::from_str::<ProjectConfiguration(old_project_file).unwrap();
219        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        // Write the file back out.
253        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        // Write the file back out.
282        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        // serde_json to a NamedView will produce default values
334        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        // serde_json to string does not produce default values
358        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}