1use anyhow::Result;
4use indexmap::IndexMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9use crate::settings::types::{
10 is_default, AppColor, CommandBarSettings, DefaultTrue, OnboardingStatus, TextEditorSettings, UnitLength,
11};
12
13#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
19#[ts(export)]
20#[serde(rename_all = "snake_case")]
21pub struct ProjectConfiguration {
22 #[serde(default)]
24 #[validate(nested)]
25 pub settings: PerProjectSettings,
26}
27
28impl ProjectConfiguration {
29 pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
31 let settings = toml::from_str::<Self>(toml_str)?;
32
33 settings.validate()?;
34
35 Ok(settings)
36 }
37}
38
39#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
41#[ts(export)]
42#[serde(rename_all = "snake_case")]
43pub struct PerProjectSettings {
44 #[serde(default)]
46 #[validate(nested)]
47 pub app: ProjectAppSettings,
48 #[serde(default)]
50 #[validate(nested)]
51 pub modeling: ProjectModelingSettings,
52 #[serde(default)]
54 #[validate(nested)]
55 pub text_editor: TextEditorSettings,
56 #[serde(default)]
58 #[validate(nested)]
59 pub command_bar: CommandBarSettings,
60}
61
62#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
66#[ts(export)]
67#[serde(rename_all = "snake_case")]
68pub struct ProjectAppSettings {
69 #[serde(default, skip_serializing_if = "is_default")]
71 #[validate(nested)]
72 pub appearance: ProjectAppearanceSettings,
73 #[serde(default, skip_serializing_if = "is_default")]
75 pub onboarding_status: OnboardingStatus,
76 #[serde(default, skip_serializing_if = "is_default")]
79 pub dismiss_web_banner: bool,
80 #[serde(default, skip_serializing_if = "is_default")]
82 pub stream_idle_mode: bool,
83 #[serde(default, skip_serializing_if = "is_default")]
85 pub allow_orbit_in_sketch_mode: bool,
86 #[serde(default, skip_serializing_if = "is_default")]
89 pub show_debug_panel: bool,
90 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
92 pub named_views: IndexMap<uuid::Uuid, NamedView>,
93}
94
95#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
97#[ts(export)]
98#[serde(rename_all = "snake_case")]
99pub struct ProjectAppearanceSettings {
100 #[serde(default, skip_serializing_if = "is_default")]
102 #[validate(nested)]
103 pub color: AppColor,
104}
105
106#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
108#[serde(rename_all = "snake_case")]
109#[ts(export)]
110pub struct ProjectModelingSettings {
111 #[serde(default, skip_serializing_if = "is_default")]
113 pub base_unit: UnitLength,
114 #[serde(default, skip_serializing_if = "is_default")]
116 pub highlight_edges: DefaultTrue,
117 #[serde(default, skip_serializing_if = "is_default")]
119 pub enable_ssao: DefaultTrue,
120}
121
122fn named_view_point_version_one() -> f64 {
123 1.0
124}
125
126#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
127#[serde(rename_all = "snake_case")]
128#[ts(export)]
129pub struct NamedView {
130 #[serde(default)]
132 pub name: String,
133 #[serde(default)]
135 pub eye_offset: f64,
136 #[serde(default)]
138 pub fov_y: f64,
139 #[serde(default)]
141 pub is_ortho: bool,
142 #[serde(default)]
144 pub ortho_scale_enabled: bool,
145 #[serde(default)]
147 pub ortho_scale_factor: f64,
148 #[serde(default)]
150 pub pivot_position: [f64; 3],
151 #[serde(default)]
153 pub pivot_rotation: [f64; 4],
154 #[serde(default)]
156 pub world_coord_system: String,
157 #[serde(default = "named_view_point_version_one")]
159 pub version: f64,
160}
161
162#[cfg(test)]
163mod tests {
164 use indexmap::IndexMap;
165 use pretty_assertions::assert_eq;
166 use serde_json::Value;
167
168 use super::{
169 CommandBarSettings, NamedView, PerProjectSettings, ProjectAppSettings, ProjectAppearanceSettings,
170 ProjectConfiguration, ProjectModelingSettings, TextEditorSettings,
171 };
172 use crate::settings::types::UnitLength;
173
174 #[test]
175 fn test_project_settings_empty_file_parses() {
176 let empty_settings_file = r#""#;
177
178 let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
179 assert_eq!(parsed, ProjectConfiguration::default());
180
181 let serialized = toml::to_string(&parsed).unwrap();
183 assert_eq!(
184 serialized,
185 r#"[settings.app]
186
187[settings.modeling]
188
189[settings.text_editor]
190
191[settings.command_bar]
192"#
193 );
194
195 let parsed = ProjectConfiguration::parse_and_validate(empty_settings_file).unwrap();
196 assert_eq!(parsed, ProjectConfiguration::default());
197 }
198
199 #[test]
200 fn test_project_settings_color_validation_error() {
201 let settings_file = r#"[settings.app.appearance]
202color = 1567.4"#;
203
204 let result = ProjectConfiguration::parse_and_validate(settings_file);
205 if let Ok(r) = result {
206 panic!("Expected an error, but got success: {:?}", r);
207 }
208 assert!(result.is_err());
209
210 assert!(result
211 .unwrap_err()
212 .to_string()
213 .contains("color: Validation error: color"));
214 }
215
216 #[test]
217 fn named_view_serde_json() {
218 let json = r#"
219 [
220 {
221 "name":"dog",
222 "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
223 "pivot_position":[0.5,0,0.5],
224 "eye_offset":231.52048,
225 "fov_y":45,
226 "ortho_scale_factor":1.574129,
227 "is_ortho":true,
228 "ortho_scale_enabled":true,
229 "world_coord_system":"RightHandedUpZ"
230 }
231 ]
232 "#;
233 let named_views: Vec<NamedView> = serde_json::from_str(json).unwrap();
235 let version = named_views[0].version;
236 assert_eq!(version, 1.0);
237 }
238
239 #[test]
240 fn named_view_serde_json_string() {
241 let json = r#"
242 [
243 {
244 "name":"dog",
245 "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
246 "pivot_position":[0.5,0,0.5],
247 "eye_offset":231.52048,
248 "fov_y":45,
249 "ortho_scale_factor":1.574129,
250 "is_ortho":true,
251 "ortho_scale_enabled":true,
252 "world_coord_system":"RightHandedUpZ"
253 }
254 ]
255 "#;
256
257 let named_views: Value = match serde_json::from_str(json) {
259 Ok(x) => x,
260 Err(_) => return,
261 };
262 println!("{}", named_views);
263 }
264
265 #[test]
266 fn test_project_settings_named_views() {
267 let conf = ProjectConfiguration {
268 settings: PerProjectSettings {
269 app: ProjectAppSettings {
270 appearance: ProjectAppearanceSettings { color: 138.0.into() },
271 onboarding_status: Default::default(),
272 dismiss_web_banner: false,
273 stream_idle_mode: false,
274 allow_orbit_in_sketch_mode: false,
275 show_debug_panel: true,
276 named_views: IndexMap::from([
277 (
278 uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
279 NamedView {
280 name: String::from("Hello"),
281 eye_offset: 1236.4015,
282 fov_y: 45.0,
283 is_ortho: false,
284 ortho_scale_enabled: false,
285 ortho_scale_factor: 45.0,
286 pivot_position: [-100.0, 100.0, 100.0],
287 pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
288 world_coord_system: String::from("RightHandedUpZ"),
289 version: 1.0,
290 },
291 ),
292 (
293 uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
294 NamedView {
295 name: String::from("Goodbye"),
296 eye_offset: 1236.4015,
297 fov_y: 45.0,
298 is_ortho: false,
299 ortho_scale_enabled: false,
300 ortho_scale_factor: 45.0,
301 pivot_position: [-100.0, 100.0, 100.0],
302 pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
303 world_coord_system: String::from("RightHandedUpZ"),
304 version: 1.0,
305 },
306 ),
307 ]),
308 },
309 modeling: ProjectModelingSettings {
310 base_unit: UnitLength::Yd,
311 highlight_edges: Default::default(),
312 enable_ssao: true.into(),
313 },
314 text_editor: TextEditorSettings {
315 text_wrapping: false.into(),
316 blinking_cursor: false.into(),
317 },
318 command_bar: CommandBarSettings {
319 include_settings: false.into(),
320 },
321 },
322 };
323 let serialized = toml::to_string(&conf).unwrap();
324 let old_project_file = r#"[settings.app]
325show_debug_panel = true
326
327[settings.app.appearance]
328color = 138.0
329
330[settings.app.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]
331name = "Hello"
332eye_offset = 1236.4015
333fov_y = 45.0
334is_ortho = false
335ortho_scale_enabled = false
336ortho_scale_factor = 45.0
337pivot_position = [-100.0, 100.0, 100.0]
338pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
339world_coord_system = "RightHandedUpZ"
340version = 1.0
341
342[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]
343name = "Goodbye"
344eye_offset = 1236.4015
345fov_y = 45.0
346is_ortho = false
347ortho_scale_enabled = false
348ortho_scale_factor = 45.0
349pivot_position = [-100.0, 100.0, 100.0]
350pivot_rotation = [-0.16391756, 0.9862819, -0.01956843, 0.0032552152]
351world_coord_system = "RightHandedUpZ"
352version = 1.0
353
354[settings.modeling]
355base_unit = "yd"
356
357[settings.text_editor]
358text_wrapping = false
359blinking_cursor = false
360
361[settings.command_bar]
362include_settings = false
363"#;
364
365 assert_eq!(serialized, old_project_file)
366 }
367}