kcl_lib/settings/types/
project.rs

1//! Types specific for modeling-app projects.
2
3use anyhow::Result;
4use indexmap::IndexMap;
5use kittycad_modeling_cmds::units::UnitLength;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use validator::Validate;
9
10use crate::settings::types::{
11    AppColor, DefaultTrue, OnboardingStatus, ProjectCommandBarSettings, ProjectTextEditorSettings, is_default,
12};
13
14/// Project specific settings for the app.
15/// These live in `project.toml` in the base of the project directory.
16/// Updating the settings for the project in the app will update this file automatically.
17/// Do not edit this file manually, as it may be overwritten by the app.
18/// Manual edits can cause corruption of the settings file.
19#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
20#[ts(export)]
21#[serde(rename_all = "snake_case")]
22pub struct ProjectConfiguration {
23    /// The settings for the project.
24    #[serde(default)]
25    #[validate(nested)]
26    pub settings: PerProjectSettings,
27}
28
29impl ProjectConfiguration {
30    // TODO: remove this when we remove backwards compatibility with the old settings file.
31    pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
32        let settings = toml::from_str::<Self>(toml_str)?;
33
34        settings.validate()?;
35
36        Ok(settings)
37    }
38}
39
40/// High level project settings.
41#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
42#[ts(export)]
43#[serde(rename_all = "snake_case")]
44pub struct PerProjectSettings {
45    /// Information about the project itself.
46    /// Choices about how settings are merged have prevent me (lee) from easily
47    /// moving this out of the settings structure.
48    #[serde(default)]
49    #[validate(nested)]
50    pub meta: ProjectMetaSettings,
51
52    /// The settings for the Design Studio.
53    #[serde(default)]
54    #[validate(nested)]
55    pub app: ProjectAppSettings,
56    /// Settings that affect the behavior while modeling.
57    #[serde(default)]
58    #[validate(nested)]
59    pub modeling: ProjectModelingSettings,
60    /// Settings that affect the behavior of the KCL text editor.
61    #[serde(default)]
62    #[validate(nested)]
63    pub text_editor: ProjectTextEditorSettings,
64    /// Settings that affect the behavior of the command bar.
65    #[serde(default)]
66    #[validate(nested)]
67    pub command_bar: ProjectCommandBarSettings,
68}
69
70/// Information about the project.
71#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
72#[ts(export)]
73#[serde(rename_all = "snake_case")]
74pub struct ProjectMetaSettings {
75    #[serde(default, skip_serializing_if = "is_default")]
76    pub id: uuid::Uuid,
77}
78
79/// Project specific application settings.
80// TODO: When we remove backwards compatibility with the old settings file, we can remove the
81// aliases to camelCase (and projects plural) from everywhere.
82#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
83#[ts(export)]
84#[serde(rename_all = "snake_case")]
85pub struct ProjectAppSettings {
86    /// The settings for the appearance of the app.
87    #[serde(default, skip_serializing_if = "is_default")]
88    #[validate(nested)]
89    pub appearance: ProjectAppearanceSettings,
90    /// The onboarding status of the app.
91    #[serde(default, skip_serializing_if = "is_default")]
92    pub onboarding_status: OnboardingStatus,
93    /// Permanently dismiss the banner warning to download the desktop app.
94    /// This setting only applies to the web app. And is temporary until we have Linux support.
95    #[serde(default, skip_serializing_if = "is_default")]
96    pub dismiss_web_banner: bool,
97    /// When the user is idle, and this is true, the stream will be torn down.
98    #[serde(default, skip_serializing_if = "is_default")]
99    pub stream_idle_mode: bool,
100    /// When the user is idle, and this is true, the stream will be torn down.
101    #[serde(default, skip_serializing_if = "is_default")]
102    pub allow_orbit_in_sketch_mode: bool,
103    /// Whether to show the debug panel, which lets you see various states
104    /// of the app to aid in development.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub show_debug_panel: Option<bool>,
107    /// Settings that affect the behavior of the command bar.
108    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
109    pub named_views: IndexMap<uuid::Uuid, NamedView>,
110}
111
112/// Project specific appearance settings.
113#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
114#[ts(export)]
115#[serde(rename_all = "snake_case")]
116pub struct ProjectAppearanceSettings {
117    /// The hue of the primary theme color for the app.
118    #[serde(default, skip_serializing_if = "is_default")]
119    #[validate(nested)]
120    pub color: AppColor,
121}
122
123/// Project specific settings that affect the behavior while modeling.
124#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
125#[serde(rename_all = "snake_case")]
126#[ts(export)]
127pub struct ProjectModelingSettings {
128    /// The default unit to use in modeling dimensions.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub base_unit: Option<UnitLength>,
131    /// Highlight edges of 3D objects?
132    #[serde(default, skip_serializing_if = "is_default")]
133    pub highlight_edges: DefaultTrue,
134    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
135    #[serde(default, skip_serializing_if = "is_default")]
136    pub enable_ssao: DefaultTrue,
137    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
138    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
139    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub fixed_size_grid: Option<bool>,
142    /// When enabled, tools like line, rectangle, etc. will snap to the grid.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub snap_to_grid: Option<bool>,
145    /// The space between major grid lines, specified in the current unit.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub major_grid_spacing: Option<f64>,
148    /// The number of minor grid lines per major grid line.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub minor_grids_per_major: Option<f64>,
151    /// The number of snaps between minor grid lines. 1 means snapping to each minor grid line.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub snaps_per_minor: Option<f64>,
154}
155
156fn named_view_point_version_one() -> f64 {
157    1.0
158}
159
160#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
161#[serde(rename_all = "snake_case")]
162#[ts(export)]
163pub struct NamedView {
164    /// User defined name to identify the named view. A label.
165    #[serde(default)]
166    pub name: String,
167    /// Engine camera eye off set
168    #[serde(default)]
169    pub eye_offset: f64,
170    /// Engine camera vertical FOV
171    #[serde(default)]
172    pub fov_y: f64,
173    // Engine camera is orthographic or perspective projection
174    #[serde(default)]
175    pub is_ortho: bool,
176    /// Engine camera is orthographic camera scaling enabled
177    #[serde(default)]
178    pub ortho_scale_enabled: bool,
179    /// Engine camera orthographic scaling factor
180    #[serde(default)]
181    pub ortho_scale_factor: f64,
182    /// Engine camera position that the camera pivots around
183    #[serde(default)]
184    pub pivot_position: [f64; 3],
185    /// Engine camera orientation in relation to the pivot position
186    #[serde(default)]
187    pub pivot_rotation: [f64; 4],
188    /// Engine camera world coordinate system orientation
189    #[serde(default)]
190    pub world_coord_system: String,
191    /// Version number of the view point if the engine camera API changes
192    #[serde(default = "named_view_point_version_one")]
193    pub version: f64,
194}
195
196#[cfg(test)]
197mod tests {
198    use indexmap::IndexMap;
199    use pretty_assertions::assert_eq;
200    use serde_json::Value;
201
202    use super::{
203        NamedView, PerProjectSettings, ProjectAppSettings, ProjectAppearanceSettings, ProjectCommandBarSettings,
204        ProjectConfiguration, ProjectMetaSettings, ProjectModelingSettings, ProjectTextEditorSettings,
205    };
206    use crate::settings::types::UnitLength;
207
208    #[test]
209    fn test_project_settings_empty_file_parses() {
210        let empty_settings_file = r#""#;
211
212        let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
213        assert_eq!(parsed, ProjectConfiguration::default());
214
215        // Write the file back out.
216        let serialized = toml::to_string(&parsed).unwrap();
217        assert_eq!(
218            serialized,
219            r#"[settings.meta]
220
221[settings.app]
222
223[settings.modeling]
224
225[settings.text_editor]
226
227[settings.command_bar]
228"#
229        );
230
231        let parsed = ProjectConfiguration::parse_and_validate(empty_settings_file).unwrap();
232        assert_eq!(parsed, ProjectConfiguration::default());
233    }
234
235    #[test]
236    fn test_project_settings_color_validation_error() {
237        let settings_file = r#"[settings.app.appearance]
238color = 1567.4"#;
239
240        let result = ProjectConfiguration::parse_and_validate(settings_file);
241        if let Ok(r) = result {
242            panic!("Expected an error, but got success: {r:?}");
243        }
244        assert!(result.is_err());
245
246        assert!(
247            result
248                .unwrap_err()
249                .to_string()
250                .contains("color: Validation error: color")
251        );
252    }
253
254    #[test]
255    fn named_view_serde_json() {
256        let json = r#"
257        [
258          {
259            "name":"dog",
260            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
261            "pivot_position":[0.5,0,0.5],
262            "eye_offset":231.52048,
263            "fov_y":45,
264            "ortho_scale_factor":1.574129,
265            "is_ortho":true,
266            "ortho_scale_enabled":true,
267            "world_coord_system":"RightHandedUpZ"
268          }
269    ]
270    "#;
271        // serde_json to a NamedView will produce default values
272        let named_views: Vec<NamedView> = serde_json::from_str(json).unwrap();
273        let version = named_views[0].version;
274        assert_eq!(version, 1.0);
275    }
276
277    #[test]
278    fn named_view_serde_json_string() {
279        let json = r#"
280        [
281          {
282            "name":"dog",
283            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
284            "pivot_position":[0.5,0,0.5],
285            "eye_offset":231.52048,
286            "fov_y":45,
287            "ortho_scale_factor":1.574129,
288            "is_ortho":true,
289            "ortho_scale_enabled":true,
290            "world_coord_system":"RightHandedUpZ"
291          }
292    ]
293    "#;
294
295        // serde_json to string does not produce default values
296        let named_views: Value = match serde_json::from_str(json) {
297            Ok(x) => x,
298            Err(_) => return,
299        };
300        println!("{}", named_views);
301    }
302
303    #[test]
304    fn test_project_settings_named_views() {
305        let conf = ProjectConfiguration {
306            settings: PerProjectSettings {
307                meta: ProjectMetaSettings { id: uuid::Uuid::nil() },
308                app: ProjectAppSettings {
309                    appearance: ProjectAppearanceSettings { color: 138.0.into() },
310                    onboarding_status: Default::default(),
311                    dismiss_web_banner: false,
312                    stream_idle_mode: false,
313                    allow_orbit_in_sketch_mode: false,
314                    show_debug_panel: Some(true),
315                    named_views: IndexMap::from([
316                        (
317                            uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
318                            NamedView {
319                                name: String::from("Hello"),
320                                eye_offset: 1236.4015,
321                                fov_y: 45.0,
322                                is_ortho: false,
323                                ortho_scale_enabled: false,
324                                ortho_scale_factor: 45.0,
325                                pivot_position: [-100.0, 100.0, 100.0],
326                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
327                                world_coord_system: String::from("RightHandedUpZ"),
328                                version: 1.0,
329                            },
330                        ),
331                        (
332                            uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
333                            NamedView {
334                                name: String::from("Goodbye"),
335                                eye_offset: 1236.4015,
336                                fov_y: 45.0,
337                                is_ortho: false,
338                                ortho_scale_enabled: false,
339                                ortho_scale_factor: 45.0,
340                                pivot_position: [-100.0, 100.0, 100.0],
341                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
342                                world_coord_system: String::from("RightHandedUpZ"),
343                                version: 1.0,
344                            },
345                        ),
346                    ]),
347                },
348                modeling: ProjectModelingSettings {
349                    base_unit: Some(UnitLength::Yards),
350                    highlight_edges: Default::default(),
351                    enable_ssao: true.into(),
352                    snap_to_grid: None,
353                    major_grid_spacing: None,
354                    minor_grids_per_major: None,
355                    snaps_per_minor: None,
356                    fixed_size_grid: None,
357                },
358                text_editor: ProjectTextEditorSettings {
359                    text_wrapping: Some(false),
360                    blinking_cursor: Some(false),
361                },
362                command_bar: ProjectCommandBarSettings {
363                    include_settings: Some(false),
364                },
365            },
366        };
367        let serialized = toml::to_string(&conf).unwrap();
368        let old_project_file = r#"[settings.meta]
369
370[settings.app]
371show_debug_panel = true
372
373[settings.app.appearance]
374color = 138.0
375
376[settings.app.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]
377name = "Hello"
378eye_offset = 1236.4015
379fov_y = 45.0
380is_ortho = false
381ortho_scale_enabled = false
382ortho_scale_factor = 45.0
383pivot_position = [-100.0, 100.0, 100.0]
384pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
385world_coord_system = "RightHandedUpZ"
386version = 1.0
387
388[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]
389name = "Goodbye"
390eye_offset = 1236.4015
391fov_y = 45.0
392is_ortho = false
393ortho_scale_enabled = false
394ortho_scale_factor = 45.0
395pivot_position = [-100.0, 100.0, 100.0]
396pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
397world_coord_system = "RightHandedUpZ"
398version = 1.0
399
400[settings.modeling]
401base_unit = "yd"
402
403[settings.text_editor]
404text_wrapping = false
405blinking_cursor = false
406
407[settings.command_bar]
408include_settings = false
409"#;
410
411        assert_eq!(serialized, old_project_file)
412    }
413}