Skip to main content

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;
8use serde::Serialize;
9use validator::Validate;
10
11use crate::settings::types::DefaultTrue;
12use crate::settings::types::OnboardingStatus;
13use crate::settings::types::ProjectCommandBarSettings;
14use crate::settings::types::ProjectTextEditorSettings;
15use crate::settings::types::is_default;
16
17/// Project specific settings for the app.
18/// These live in `project.toml` in the base of the project directory.
19/// Updating the settings for the project in the app will update this file automatically.
20/// Do not edit this file manually, as it may be overwritten by the app.
21/// Manual edits can cause corruption of the settings file.
22#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
23#[ts(export)]
24#[serde(rename_all = "snake_case")]
25pub struct ProjectConfiguration {
26    /// The settings for the project.
27    #[serde(default)]
28    #[validate(nested)]
29    pub settings: PerProjectSettings,
30
31    /// Settings for cloud-backed project metadata.
32    #[serde(default, skip_serializing_if = "is_default")]
33    #[validate(nested)]
34    pub cloud: ProjectCloudSettings,
35}
36
37impl ProjectConfiguration {
38    // TODO: remove this when we remove backwards compatibility with the old settings file.
39    pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
40        let settings = toml::from_str::<Self>(toml_str)?;
41
42        settings.validate()?;
43
44        Ok(settings)
45    }
46}
47
48/// High level project settings.
49#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
50#[ts(export)]
51#[serde(rename_all = "snake_case")]
52pub struct PerProjectSettings {
53    /// Information about the project itself.
54    /// Choices about how settings are merged have prevent me (lee) from easily
55    /// moving this out of the settings structure.
56    #[serde(default)]
57    #[validate(nested)]
58    pub meta: ProjectMetaSettings,
59
60    /// The settings for the Design Studio.
61    #[serde(default)]
62    #[validate(nested)]
63    pub app: ProjectAppSettings,
64    /// Settings that affect the behavior while modeling.
65    #[serde(default)]
66    #[validate(nested)]
67    pub modeling: ProjectModelingSettings,
68    /// Settings that affect the behavior of the KCL text editor.
69    #[serde(default)]
70    #[validate(nested)]
71    pub text_editor: ProjectTextEditorSettings,
72    /// Settings that affect the behavior of the command bar.
73    #[serde(default)]
74    #[validate(nested)]
75    pub command_bar: ProjectCommandBarSettings,
76}
77
78/// Information about the project.
79#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
80#[ts(export)]
81#[serde(rename_all = "snake_case")]
82pub struct ProjectMetaSettings {
83    #[serde(default, skip_serializing_if = "is_default")]
84    pub id: uuid::Uuid,
85}
86
87/// Cloud-backed project metadata.
88#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
89#[ts(export)]
90#[serde(rename_all = "snake_case")]
91pub struct ProjectCloudSettings {
92    /// Environment-scoped cloud metadata keyed by environment name.
93    /// TOML with dotted environment names should use quoted table names, for
94    /// example `[cloud."zoo.dev"]`.
95    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
96    pub environments: IndexMap<String, ProjectCloudEnvironmentSettings>,
97}
98
99/// Cloud-backed metadata for a single environment.
100#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
101#[ts(export)]
102#[serde(rename_all = "snake_case")]
103pub struct ProjectCloudEnvironmentSettings {
104    #[serde(default, skip_serializing_if = "is_default")]
105    pub project_id: uuid::Uuid,
106}
107
108/// Project specific application settings.
109// TODO: When we remove backwards compatibility with the old settings file, we can remove the
110// aliases to camelCase (and projects plural) from everywhere.
111#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
112#[ts(export)]
113#[serde(rename_all = "snake_case")]
114pub struct ProjectAppSettings {
115    /// The onboarding status of the app.
116    #[serde(default, skip_serializing_if = "is_default")]
117    pub onboarding_status: OnboardingStatus,
118    /// When the user is idle, and this is true, the stream will be torn down.
119    #[serde(default, skip_serializing_if = "is_default")]
120    pub stream_idle_mode: bool,
121    /// When the user is idle, and this is true, the stream will be torn down.
122    #[serde(default, skip_serializing_if = "is_default")]
123    pub allow_orbit_in_sketch_mode: bool,
124    /// Whether to show the debug panel, which lets you see various states
125    /// of the app to aid in development.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub show_debug_panel: Option<bool>,
128    /// Zookeeper reasoning mode. Uses the app default if not set.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub zookeeper_mode: Option<String>,
131    /// Settings that affect the behavior of the command bar.
132    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
133    pub named_views: IndexMap<uuid::Uuid, NamedView>,
134}
135
136/// Project specific settings that affect the behavior while modeling.
137#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
138#[serde(rename_all = "snake_case")]
139#[ts(export)]
140pub struct ProjectModelingSettings {
141    /// The default unit to use in modeling dimensions.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub base_unit: Option<UnitLength>,
144    /// Highlight edges of 3D objects?
145    #[serde(default, skip_serializing_if = "is_default")]
146    pub highlight_edges: DefaultTrue,
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    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
151    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
152    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub fixed_size_grid: Option<bool>,
155    /// When enabled, tools like line, rectangle, etc. will snap to the grid.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub snap_to_grid: Option<bool>,
158    /// The space between major grid lines, specified in the current unit.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub major_grid_spacing: Option<f64>,
161    /// The number of minor grid lines per major grid line.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub minor_grids_per_major: Option<f64>,
164    /// The number of snaps between minor grid lines. 1 means snapping to each minor grid line.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub snaps_per_minor: Option<f64>,
167}
168
169fn named_view_point_version_one() -> f64 {
170    1.0
171}
172
173#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
174#[serde(rename_all = "snake_case")]
175#[ts(export)]
176pub struct NamedView {
177    /// User defined name to identify the named view. A label.
178    #[serde(default)]
179    pub name: String,
180    /// Engine camera eye off set
181    #[serde(default)]
182    pub eye_offset: f64,
183    /// Engine camera vertical FOV
184    #[serde(default)]
185    pub fov_y: f64,
186    // Engine camera is orthographic or perspective projection
187    #[serde(default)]
188    pub is_ortho: bool,
189    /// Engine camera is orthographic camera scaling enabled
190    #[serde(default)]
191    pub ortho_scale_enabled: bool,
192    /// Engine camera orthographic scaling factor
193    #[serde(default)]
194    pub ortho_scale_factor: f64,
195    /// Engine camera position that the camera pivots around
196    #[serde(default)]
197    pub pivot_position: [f64; 3],
198    /// Engine camera orientation in relation to the pivot position
199    #[serde(default)]
200    pub pivot_rotation: [f64; 4],
201    /// Engine camera world coordinate system orientation
202    #[serde(default)]
203    pub world_coord_system: String,
204    /// Version number of the view point if the engine camera API changes
205    #[serde(default = "named_view_point_version_one")]
206    pub version: f64,
207}
208
209#[cfg(test)]
210mod tests {
211    use indexmap::IndexMap;
212    use pretty_assertions::assert_eq;
213    use serde_json::Value;
214
215    use super::NamedView;
216    use super::PerProjectSettings;
217    use super::ProjectAppSettings;
218    use super::ProjectCloudEnvironmentSettings;
219    use super::ProjectCloudSettings;
220    use super::ProjectCommandBarSettings;
221    use super::ProjectConfiguration;
222    use super::ProjectMetaSettings;
223    use super::ProjectModelingSettings;
224    use super::ProjectTextEditorSettings;
225    use crate::settings::types::UnitLength;
226
227    #[test]
228    fn test_project_settings_empty_file_parses() {
229        let empty_settings_file = r#""#;
230
231        let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
232        assert_eq!(parsed, ProjectConfiguration::default());
233
234        // Write the file back out.
235        let serialized = toml::to_string(&parsed).unwrap();
236        assert_eq!(
237            serialized,
238            r#"[settings.meta]
239
240[settings.app]
241
242[settings.modeling]
243
244[settings.text_editor]
245
246[settings.command_bar]
247"#
248        );
249
250        let parsed = ProjectConfiguration::parse_and_validate(empty_settings_file).unwrap();
251        assert_eq!(parsed, ProjectConfiguration::default());
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                    onboarding_status: Default::default(),
310                    stream_idle_mode: false,
311                    allow_orbit_in_sketch_mode: false,
312                    show_debug_panel: Some(true),
313                    zookeeper_mode: None,
314                    named_views: IndexMap::from([
315                        (
316                            uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
317                            NamedView {
318                                name: String::from("Hello"),
319                                eye_offset: 1236.4015,
320                                fov_y: 45.0,
321                                is_ortho: false,
322                                ortho_scale_enabled: false,
323                                ortho_scale_factor: 45.0,
324                                pivot_position: [-100.0, 100.0, 100.0],
325                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
326                                world_coord_system: String::from("RightHandedUpZ"),
327                                version: 1.0,
328                            },
329                        ),
330                        (
331                            uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
332                            NamedView {
333                                name: String::from("Goodbye"),
334                                eye_offset: 1236.4015,
335                                fov_y: 45.0,
336                                is_ortho: false,
337                                ortho_scale_enabled: false,
338                                ortho_scale_factor: 45.0,
339                                pivot_position: [-100.0, 100.0, 100.0],
340                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
341                                world_coord_system: String::from("RightHandedUpZ"),
342                                version: 1.0,
343                            },
344                        ),
345                    ]),
346                },
347                modeling: ProjectModelingSettings {
348                    base_unit: Some(UnitLength::Yards),
349                    highlight_edges: Default::default(),
350                    enable_ssao: true.into(),
351                    snap_to_grid: None,
352                    major_grid_spacing: None,
353                    minor_grids_per_major: None,
354                    snaps_per_minor: None,
355                    fixed_size_grid: None,
356                },
357                text_editor: ProjectTextEditorSettings {
358                    text_wrapping: Some(false),
359                    blinking_cursor: Some(false),
360                },
361                command_bar: ProjectCommandBarSettings {
362                    include_settings: Some(false),
363                },
364            },
365            cloud: ProjectCloudSettings::default(),
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.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]
374name = "Hello"
375eye_offset = 1236.4015
376fov_y = 45.0
377is_ortho = false
378ortho_scale_enabled = false
379ortho_scale_factor = 45.0
380pivot_position = [-100.0, 100.0, 100.0]
381pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
382world_coord_system = "RightHandedUpZ"
383version = 1.0
384
385[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]
386name = "Goodbye"
387eye_offset = 1236.4015
388fov_y = 45.0
389is_ortho = false
390ortho_scale_enabled = false
391ortho_scale_factor = 45.0
392pivot_position = [-100.0, 100.0, 100.0]
393pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
394world_coord_system = "RightHandedUpZ"
395version = 1.0
396
397[settings.modeling]
398base_unit = "yd"
399
400[settings.text_editor]
401text_wrapping = false
402blinking_cursor = false
403
404[settings.command_bar]
405include_settings = false
406"#;
407
408        assert_eq!(serialized, old_project_file)
409    }
410
411    #[test]
412    fn test_project_settings_cloud_metadata_round_trip() {
413        let local_project_id = uuid::uuid!("e8f5178c-5227-4567-bb5a-f52b3caef5ea");
414        let zoo_cloud_project_id = uuid::uuid!("04c988e3-ec37-48a4-b491-45c3668934f1");
415        let dev_cloud_project_id = uuid::uuid!("e9632dae-19ca-49ea-bcc1-ee8e34ff9de3");
416
417        let conf = ProjectConfiguration {
418            settings: PerProjectSettings {
419                meta: ProjectMetaSettings { id: local_project_id },
420                ..Default::default()
421            },
422            cloud: ProjectCloudSettings {
423                environments: IndexMap::from([
424                    (
425                        "zoo.dev".to_owned(),
426                        ProjectCloudEnvironmentSettings {
427                            project_id: zoo_cloud_project_id,
428                        },
429                    ),
430                    (
431                        "dev.zoo.dev".to_owned(),
432                        ProjectCloudEnvironmentSettings {
433                            project_id: dev_cloud_project_id,
434                        },
435                    ),
436                ]),
437            },
438        };
439
440        let serialized = toml::to_string(&conf).unwrap();
441        assert!(serialized.contains(&format!(
442            "[cloud.\"zoo.dev\"]\nproject_id = \"{zoo_cloud_project_id}\"\n"
443        )));
444        assert!(serialized.contains(&format!(
445            "[cloud.\"dev.zoo.dev\"]\nproject_id = \"{dev_cloud_project_id}\"\n"
446        )));
447        assert!(serialized.contains(&format!("[settings.meta]\nid = \"{local_project_id}\"\n")));
448
449        let parsed = ProjectConfiguration::parse_and_validate(&serialized).unwrap();
450        assert_eq!(parsed, conf);
451    }
452}