1use 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#[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
29impl ProjectConfiguration {
30 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#[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 #[serde(default)]
49 #[validate(nested)]
50 pub meta: ProjectMetaSettings,
51
52 #[serde(default)]
54 #[validate(nested)]
55 pub app: ProjectAppSettings,
56 #[serde(default)]
58 #[validate(nested)]
59 pub modeling: ProjectModelingSettings,
60 #[serde(default)]
62 #[validate(nested)]
63 pub text_editor: ProjectTextEditorSettings,
64 #[serde(default)]
66 #[validate(nested)]
67 pub command_bar: ProjectCommandBarSettings,
68}
69
70#[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#[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 #[serde(default, skip_serializing_if = "is_default")]
88 #[validate(nested)]
89 pub appearance: ProjectAppearanceSettings,
90 #[serde(default, skip_serializing_if = "is_default")]
92 pub onboarding_status: OnboardingStatus,
93 #[serde(default, skip_serializing_if = "is_default")]
96 pub dismiss_web_banner: bool,
97 #[serde(default, skip_serializing_if = "is_default")]
99 pub stream_idle_mode: bool,
100 #[serde(default, skip_serializing_if = "is_default")]
102 pub allow_orbit_in_sketch_mode: bool,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub show_debug_panel: Option<bool>,
107 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
109 pub named_views: IndexMap<uuid::Uuid, NamedView>,
110}
111
112#[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 #[serde(default, skip_serializing_if = "is_default")]
119 #[validate(nested)]
120 pub color: AppColor,
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(default, skip_serializing_if = "Option::is_none")]
144 pub snap_to_grid: Option<bool>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub major_grid_spacing: Option<f64>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub minor_grids_per_major: Option<f64>,
151 #[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 #[serde(default)]
166 pub name: String,
167 #[serde(default)]
169 pub eye_offset: f64,
170 #[serde(default)]
172 pub fov_y: f64,
173 #[serde(default)]
175 pub is_ortho: bool,
176 #[serde(default)]
178 pub ortho_scale_enabled: bool,
179 #[serde(default)]
181 pub ortho_scale_factor: f64,
182 #[serde(default)]
184 pub pivot_position: [f64; 3],
185 #[serde(default)]
187 pub pivot_rotation: [f64; 4],
188 #[serde(default)]
190 pub world_coord_system: String,
191 #[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 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 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 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}