1use 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#[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 #[serde(default)]
28 #[validate(nested)]
29 pub settings: PerProjectSettings,
30
31 #[serde(default, skip_serializing_if = "is_default")]
33 #[validate(nested)]
34 pub cloud: ProjectCloudSettings,
35}
36
37impl ProjectConfiguration {
38 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#[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 #[serde(default)]
57 #[validate(nested)]
58 pub meta: ProjectMetaSettings,
59
60 #[serde(default)]
62 #[validate(nested)]
63 pub app: ProjectAppSettings,
64 #[serde(default)]
66 #[validate(nested)]
67 pub modeling: ProjectModelingSettings,
68 #[serde(default)]
70 #[validate(nested)]
71 pub text_editor: ProjectTextEditorSettings,
72 #[serde(default)]
74 #[validate(nested)]
75 pub command_bar: ProjectCommandBarSettings,
76}
77
78#[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#[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 #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
96 pub environments: IndexMap<String, ProjectCloudEnvironmentSettings>,
97}
98
99#[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#[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 #[serde(default, skip_serializing_if = "is_default")]
117 pub onboarding_status: OnboardingStatus,
118 #[serde(default, skip_serializing_if = "is_default")]
120 pub stream_idle_mode: bool,
121 #[serde(default, skip_serializing_if = "is_default")]
123 pub allow_orbit_in_sketch_mode: bool,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub show_debug_panel: Option<bool>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub zookeeper_mode: Option<String>,
131 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
133 pub named_views: IndexMap<uuid::Uuid, NamedView>,
134}
135
136#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub base_unit: Option<UnitLength>,
144 #[serde(default, skip_serializing_if = "is_default")]
146 pub highlight_edges: DefaultTrue,
147 #[serde(default, skip_serializing_if = "is_default")]
149 pub enable_ssao: DefaultTrue,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub fixed_size_grid: Option<bool>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub snap_to_grid: Option<bool>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub major_grid_spacing: Option<f64>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub minor_grids_per_major: Option<f64>,
164 #[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 #[serde(default)]
179 pub name: String,
180 #[serde(default)]
182 pub eye_offset: f64,
183 #[serde(default)]
185 pub fov_y: f64,
186 #[serde(default)]
188 pub is_ortho: bool,
189 #[serde(default)]
191 pub ortho_scale_enabled: bool,
192 #[serde(default)]
194 pub ortho_scale_factor: f64,
195 #[serde(default)]
197 pub pivot_position: [f64; 3],
198 #[serde(default)]
200 pub pivot_rotation: [f64; 4],
201 #[serde(default)]
203 pub world_coord_system: String,
204 #[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 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 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 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}