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::is_default;
13
14#[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 #[serde(default)]
25 #[validate(nested)]
26 pub settings: PerProjectSettings,
27
28 #[serde(default, skip_serializing_if = "is_default")]
30 #[validate(nested)]
31 pub cloud: ProjectCloudSettings,
32}
33
34impl ProjectConfiguration {
35 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#[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 #[serde(default)]
54 #[validate(nested)]
55 pub meta: ProjectMetaSettings,
56
57 #[serde(default)]
59 #[validate(nested)]
60 pub app: ProjectAppSettings,
61 #[serde(default)]
63 #[validate(nested)]
64 pub modeling: ProjectModelingSettings,
65 #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
69 pub other: IndexMap<String, serde_json::Value>,
70}
71
72#[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#[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 #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
90 pub environments: IndexMap<String, ProjectCloudEnvironmentSettings>,
91}
92
93#[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#[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 #[serde(default, skip_serializing_if = "is_default")]
111 pub stream_idle_mode: bool,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub zookeeper_mode: Option<String>,
115 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
117 pub named_views: IndexMap<uuid::Uuid, NamedView>,
118 #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
120 pub other: IndexMap<String, serde_json::Value>,
121}
122
123#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub base_unit: Option<UnitLength>,
131 #[serde(default, skip_serializing_if = "is_default")]
133 pub highlight_edges: DefaultTrue,
134 #[serde(default, skip_serializing_if = "is_default")]
136 pub enable_ssao: DefaultTrue,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub fixed_size_grid: Option<bool>,
142 #[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 #[serde(default)]
157 pub name: String,
158 #[serde(default)]
160 pub eye_offset: f64,
161 #[serde(default)]
163 pub fov_y: f64,
164 #[serde(default)]
166 pub is_ortho: bool,
167 #[serde(default)]
169 pub ortho_scale_enabled: bool,
170 #[serde(default)]
172 pub ortho_scale_factor: f64,
173 #[serde(default)]
175 pub pivot_position: [f64; 3],
176 #[serde(default)]
178 pub pivot_rotation: [f64; 4],
179 #[serde(default)]
181 pub world_coord_system: String,
182 #[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 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 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 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}