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::is_default;
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    /// Settings for cloud-backed project metadata.
29    #[serde(default, skip_serializing_if = "is_default")]
30    #[validate(nested)]
31    pub cloud: ProjectCloudSettings,
32}
33
34impl ProjectConfiguration {
35    // TODO: remove this when we remove backwards compatibility with the old settings file.
36    pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
37        let settings = toml::from_str::<Self>(toml_str)?;
38
39        settings.validate()?;
40
41        Ok(settings)
42    }
43}
44
45/// High level project settings.
46#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
47#[ts(export)]
48#[serde(rename_all = "snake_case")]
49pub struct PerProjectSettings {
50    /// Information about the project itself.
51    /// Choices about how settings are merged have prevent me (lee) from easily
52    /// moving this out of the settings structure.
53    #[serde(default)]
54    #[validate(nested)]
55    pub meta: ProjectMetaSettings,
56
57    /// The settings for the Design Studio.
58    #[serde(default)]
59    #[validate(nested)]
60    pub app: ProjectAppSettings,
61    /// Settings that affect the behavior while modeling.
62    #[serde(default)]
63    #[validate(nested)]
64    pub modeling: ProjectModelingSettings,
65    /// Other fields that weren't recognized by our schema.
66    /// App-owned extension settings can live here without Rust understanding
67    /// their inner structure.
68    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
69    pub other: IndexMap<String, serde_json::Value>,
70}
71
72/// Information about the project.
73#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
74#[ts(export)]
75#[serde(rename_all = "snake_case")]
76pub struct ProjectMetaSettings {
77    #[serde(default, skip_serializing_if = "is_default")]
78    pub id: uuid::Uuid,
79}
80
81/// Cloud-backed project metadata.
82#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
83#[ts(export)]
84#[serde(rename_all = "snake_case")]
85pub struct ProjectCloudSettings {
86    /// Environment-scoped cloud metadata keyed by environment name.
87    /// TOML with dotted environment names should use quoted table names, for
88    /// example `[cloud."zoo.dev"]`.
89    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
90    pub environments: IndexMap<String, ProjectCloudEnvironmentSettings>,
91}
92
93/// Cloud-backed metadata for a single environment.
94#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
95#[ts(export)]
96#[serde(rename_all = "snake_case")]
97pub struct ProjectCloudEnvironmentSettings {
98    #[serde(default, skip_serializing_if = "is_default")]
99    pub project_id: uuid::Uuid,
100}
101
102/// Project specific application settings.
103// TODO: When we remove backwards compatibility with the old settings file, we can remove the
104// aliases to camelCase (and projects plural) from everywhere.
105#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
106#[ts(export)]
107#[serde(rename_all = "snake_case")]
108pub struct ProjectAppSettings {
109    /// When the user is idle, and this is true, the stream will be torn down.
110    #[serde(default, skip_serializing_if = "is_default")]
111    pub stream_idle_mode: bool,
112    /// Zookeeper reasoning mode. Uses the app default if not set.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub zookeeper_mode: Option<String>,
115    /// Settings that affect the behavior of the command bar.
116    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
117    pub named_views: IndexMap<uuid::Uuid, NamedView>,
118    /// Other fields that weren't recognized by our schema.
119    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
120    pub other: IndexMap<String, serde_json::Value>,
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    /// Other fields that weren't recognized by our schema.
143    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
144    pub other: IndexMap<String, serde_json::Value>,
145}
146
147fn named_view_point_version_one() -> f64 {
148    1.0
149}
150
151#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
152#[serde(rename_all = "snake_case")]
153#[ts(export)]
154pub struct NamedView {
155    /// User defined name to identify the named view. A label.
156    #[serde(default)]
157    pub name: String,
158    /// Engine camera eye off set
159    #[serde(default)]
160    pub eye_offset: f64,
161    /// Engine camera vertical FOV
162    #[serde(default)]
163    pub fov_y: f64,
164    // Engine camera is orthographic or perspective projection
165    #[serde(default)]
166    pub is_ortho: bool,
167    /// Engine camera is orthographic camera scaling enabled
168    #[serde(default)]
169    pub ortho_scale_enabled: bool,
170    /// Engine camera orthographic scaling factor
171    #[serde(default)]
172    pub ortho_scale_factor: f64,
173    /// Engine camera position that the camera pivots around
174    #[serde(default)]
175    pub pivot_position: [f64; 3],
176    /// Engine camera orientation in relation to the pivot position
177    #[serde(default)]
178    pub pivot_rotation: [f64; 4],
179    /// Engine camera world coordinate system orientation
180    #[serde(default)]
181    pub world_coord_system: String,
182    /// Version number of the view point if the engine camera API changes
183    #[serde(default = "named_view_point_version_one")]
184    pub version: f64,
185}
186
187#[cfg(test)]
188mod tests {
189    use indexmap::IndexMap;
190    use pretty_assertions::assert_eq;
191    use serde_json::Value;
192    use serde_json::json;
193
194    use super::NamedView;
195    use super::PerProjectSettings;
196    use super::ProjectAppSettings;
197    use super::ProjectCloudEnvironmentSettings;
198    use super::ProjectCloudSettings;
199    use super::ProjectConfiguration;
200    use super::ProjectMetaSettings;
201    use super::ProjectModelingSettings;
202    use crate::settings::types::UnitLength;
203
204    #[test]
205    fn test_project_settings_empty_file_parses() {
206        let empty_settings_file = r#""#;
207
208        let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
209        assert_eq!(parsed, ProjectConfiguration::default());
210
211        // Write the file back out.
212        let serialized = toml::to_string(&parsed).unwrap();
213        assert_eq!(
214            serialized,
215            r#"[settings.meta]
216
217[settings.app]
218
219[settings.modeling]
220"#
221        );
222
223        let parsed = ProjectConfiguration::parse_and_validate(empty_settings_file).unwrap();
224        assert_eq!(parsed, ProjectConfiguration::default());
225    }
226
227    #[test]
228    fn named_view_serde_json() {
229        let json = r#"
230        [
231          {
232            "name":"dog",
233            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
234            "pivot_position":[0.5,0,0.5],
235            "eye_offset":231.52048,
236            "fov_y":45,
237            "ortho_scale_factor":1.574129,
238            "is_ortho":true,
239            "ortho_scale_enabled":true,
240            "world_coord_system":"RightHandedUpZ"
241          }
242    ]
243    "#;
244        // serde_json to a NamedView will produce default values
245        let named_views: Vec<NamedView> = serde_json::from_str(json).unwrap();
246        let version = named_views[0].version;
247        assert_eq!(version, 1.0);
248    }
249
250    #[test]
251    fn named_view_serde_json_string() {
252        let json = r#"
253        [
254          {
255            "name":"dog",
256            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
257            "pivot_position":[0.5,0,0.5],
258            "eye_offset":231.52048,
259            "fov_y":45,
260            "ortho_scale_factor":1.574129,
261            "is_ortho":true,
262            "ortho_scale_enabled":true,
263            "world_coord_system":"RightHandedUpZ"
264          }
265    ]
266    "#;
267
268        // serde_json to string does not produce default values
269        let named_views: Value = match serde_json::from_str(json) {
270            Ok(x) => x,
271            Err(_) => return,
272        };
273        println!("{}", named_views);
274    }
275
276    #[test]
277    fn test_project_settings_named_views() {
278        let conf = ProjectConfiguration {
279            settings: PerProjectSettings {
280                meta: ProjectMetaSettings { id: uuid::Uuid::nil() },
281                app: ProjectAppSettings {
282                    stream_idle_mode: false,
283                    zookeeper_mode: None,
284                    named_views: IndexMap::from([
285                        (
286                            uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
287                            NamedView {
288                                name: String::from("Hello"),
289                                eye_offset: 1236.4015,
290                                fov_y: 45.0,
291                                is_ortho: false,
292                                ortho_scale_enabled: false,
293                                ortho_scale_factor: 45.0,
294                                pivot_position: [-100.0, 100.0, 100.0],
295                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
296                                world_coord_system: String::from("RightHandedUpZ"),
297                                version: 1.0,
298                            },
299                        ),
300                        (
301                            uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
302                            NamedView {
303                                name: String::from("Goodbye"),
304                                eye_offset: 1236.4015,
305                                fov_y: 45.0,
306                                is_ortho: false,
307                                ortho_scale_enabled: false,
308                                ortho_scale_factor: 45.0,
309                                pivot_position: [-100.0, 100.0, 100.0],
310                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
311                                world_coord_system: String::from("RightHandedUpZ"),
312                                version: 1.0,
313                            },
314                        ),
315                    ]),
316                    other: IndexMap::from([("show_debug_panel".to_owned(), json!(true))]),
317                },
318                modeling: ProjectModelingSettings {
319                    base_unit: Some(UnitLength::Yards),
320                    highlight_edges: Default::default(),
321                    enable_ssao: true.into(),
322                    fixed_size_grid: None,
323                    other: Default::default(),
324                },
325                other: IndexMap::from([
326                    (
327                        "command_bar".to_owned(),
328                        json!({
329                            "include_settings": false,
330                        }),
331                    ),
332                    (
333                        "text_editor".to_owned(),
334                        json!({
335                            "text_wrapping": false,
336                            "blinking_cursor": false,
337                        }),
338                    ),
339                ]),
340            },
341            cloud: ProjectCloudSettings::default(),
342        };
343        let serialized = toml::to_string(&conf).unwrap();
344        assert!(serialized.contains("[settings.app]"));
345        assert!(serialized.contains("show_debug_panel = true"));
346        assert!(serialized.contains("[settings.app.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]"));
347        assert!(serialized.contains("[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]"));
348        assert!(serialized.contains("[settings.modeling]"));
349        assert!(serialized.contains("base_unit = \"yd\""));
350        assert!(serialized.contains("[settings.command_bar]"));
351        assert!(serialized.contains("include_settings = false"));
352        assert!(serialized.contains("[settings.text_editor]"));
353        assert!(serialized.contains("blinking_cursor = false"));
354        assert!(serialized.contains("text_wrapping = false"));
355        let reparsed = toml::from_str::<ProjectConfiguration>(&serialized).unwrap();
356        assert_eq!(reparsed, conf);
357    }
358
359    #[test]
360    fn test_project_settings_cloud_metadata_round_trip() {
361        let local_project_id = uuid::uuid!("e8f5178c-5227-4567-bb5a-f52b3caef5ea");
362        let zoo_cloud_project_id = uuid::uuid!("04c988e3-ec37-48a4-b491-45c3668934f1");
363        let dev_cloud_project_id = uuid::uuid!("e9632dae-19ca-49ea-bcc1-ee8e34ff9de3");
364
365        let conf = ProjectConfiguration {
366            settings: PerProjectSettings {
367                meta: ProjectMetaSettings { id: local_project_id },
368                ..Default::default()
369            },
370            cloud: ProjectCloudSettings {
371                environments: IndexMap::from([
372                    (
373                        "zoo.dev".to_owned(),
374                        ProjectCloudEnvironmentSettings {
375                            project_id: zoo_cloud_project_id,
376                        },
377                    ),
378                    (
379                        "dev.zoo.dev".to_owned(),
380                        ProjectCloudEnvironmentSettings {
381                            project_id: dev_cloud_project_id,
382                        },
383                    ),
384                ]),
385            },
386        };
387
388        let serialized = toml::to_string(&conf).unwrap();
389        assert!(serialized.contains(&format!(
390            "[cloud.\"zoo.dev\"]\nproject_id = \"{zoo_cloud_project_id}\"\n"
391        )));
392        assert!(serialized.contains(&format!(
393            "[cloud.\"dev.zoo.dev\"]\nproject_id = \"{dev_cloud_project_id}\"\n"
394        )));
395        assert!(serialized.contains(&format!("[settings.meta]\nid = \"{local_project_id}\"\n")));
396
397        let parsed = ProjectConfiguration::parse_and_validate(&serialized).unwrap();
398        assert_eq!(parsed, conf);
399    }
400}