kcl-lib 0.2.147

KittyCAD Language implementation and tools
Documentation
//! Types specific for modeling-app projects.

use anyhow::Result;
use indexmap::IndexMap;
use kittycad_modeling_cmds::units::UnitLength;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use validator::Validate;

use crate::settings::types::DefaultTrue;
use crate::settings::types::is_default;

/// Project specific settings for the app.
/// These live in `project.toml` in the base of the project directory.
/// Updating the settings for the project in the app will update this file automatically.
/// Do not edit this file manually, as it may be overwritten by the app.
/// Manual edits can cause corruption of the settings file.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectConfiguration {
    /// The settings for the project.
    #[serde(default)]
    #[validate(nested)]
    pub settings: PerProjectSettings,

    /// Settings for cloud-backed project metadata.
    #[serde(default, skip_serializing_if = "is_default")]
    #[validate(nested)]
    pub cloud: ProjectCloudSettings,
}

impl ProjectConfiguration {
    // TODO: remove this when we remove backwards compatibility with the old settings file.
    pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
        let settings = toml::from_str::<Self>(toml_str)?;

        settings.validate()?;

        Ok(settings)
    }
}

/// High level project settings.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct PerProjectSettings {
    /// Information about the project itself.
    /// Choices about how settings are merged have prevent me (lee) from easily
    /// moving this out of the settings structure.
    #[serde(default)]
    #[validate(nested)]
    pub meta: ProjectMetaSettings,

    /// The settings for the Design Studio.
    #[serde(default)]
    #[validate(nested)]
    pub app: ProjectAppSettings,
    /// Settings that affect the behavior while modeling.
    #[serde(default)]
    #[validate(nested)]
    pub modeling: ProjectModelingSettings,
    /// Other fields that weren't recognized by our schema.
    /// App-owned extension settings can live here without Rust understanding
    /// their inner structure.
    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
    pub other: IndexMap<String, serde_json::Value>,
}

/// Information about the project.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectMetaSettings {
    #[serde(default, skip_serializing_if = "is_default")]
    pub id: uuid::Uuid,
}

/// Cloud-backed project metadata.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectCloudSettings {
    /// Environment-scoped cloud metadata keyed by environment name.
    /// TOML with dotted environment names should use quoted table names, for
    /// example `[cloud."zoo.dev"]`.
    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
    pub environments: IndexMap<String, ProjectCloudEnvironmentSettings>,
}

/// Cloud-backed metadata for a single environment.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectCloudEnvironmentSettings {
    #[serde(default, skip_serializing_if = "is_default")]
    pub project_id: uuid::Uuid,
}

/// Project specific application settings.
// TODO: When we remove backwards compatibility with the old settings file, we can remove the
// aliases to camelCase (and projects plural) from everywhere.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct ProjectAppSettings {
    /// When the user is idle, and this is true, the stream will be torn down.
    #[serde(default, skip_serializing_if = "is_default")]
    pub stream_idle_mode: bool,
    /// Zookeeper reasoning mode. Uses the app default if not set.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub zookeeper_mode: Option<String>,
    /// Settings that affect the behavior of the command bar.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub named_views: IndexMap<uuid::Uuid, NamedView>,
    /// Other fields that weren't recognized by our schema.
    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
    pub other: IndexMap<String, serde_json::Value>,
}

/// Project specific settings that affect the behavior while modeling.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectModelingSettings {
    /// The default unit to use in modeling dimensions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_unit: Option<UnitLength>,
    /// Highlight edges of 3D objects?
    #[serde(default, skip_serializing_if = "is_default")]
    pub highlight_edges: DefaultTrue,
    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
    #[serde(default, skip_serializing_if = "is_default")]
    pub enable_ssao: DefaultTrue,
    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fixed_size_grid: Option<bool>,
    /// Other fields that weren't recognized by our schema.
    #[serde(flatten, default, skip_serializing_if = "IndexMap::is_empty")]
    pub other: IndexMap<String, serde_json::Value>,
}

fn named_view_point_version_one() -> f64 {
    1.0
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct NamedView {
    /// User defined name to identify the named view. A label.
    #[serde(default)]
    pub name: String,
    /// Engine camera eye off set
    #[serde(default)]
    pub eye_offset: f64,
    /// Engine camera vertical FOV
    #[serde(default)]
    pub fov_y: f64,
    // Engine camera is orthographic or perspective projection
    #[serde(default)]
    pub is_ortho: bool,
    /// Engine camera is orthographic camera scaling enabled
    #[serde(default)]
    pub ortho_scale_enabled: bool,
    /// Engine camera orthographic scaling factor
    #[serde(default)]
    pub ortho_scale_factor: f64,
    /// Engine camera position that the camera pivots around
    #[serde(default)]
    pub pivot_position: [f64; 3],
    /// Engine camera orientation in relation to the pivot position
    #[serde(default)]
    pub pivot_rotation: [f64; 4],
    /// Engine camera world coordinate system orientation
    #[serde(default)]
    pub world_coord_system: String,
    /// Version number of the view point if the engine camera API changes
    #[serde(default = "named_view_point_version_one")]
    pub version: f64,
}

#[cfg(test)]
mod tests {
    use indexmap::IndexMap;
    use pretty_assertions::assert_eq;
    use serde_json::Value;
    use serde_json::json;

    use super::NamedView;
    use super::PerProjectSettings;
    use super::ProjectAppSettings;
    use super::ProjectCloudEnvironmentSettings;
    use super::ProjectCloudSettings;
    use super::ProjectConfiguration;
    use super::ProjectMetaSettings;
    use super::ProjectModelingSettings;
    use crate::settings::types::UnitLength;

    #[test]
    fn test_project_settings_empty_file_parses() {
        let empty_settings_file = r#""#;

        let parsed = toml::from_str::<ProjectConfiguration>(empty_settings_file).unwrap();
        assert_eq!(parsed, ProjectConfiguration::default());

        // Write the file back out.
        let serialized = toml::to_string(&parsed).unwrap();
        assert_eq!(
            serialized,
            r#"[settings.meta]

[settings.app]

[settings.modeling]
"#
        );

        let parsed = ProjectConfiguration::parse_and_validate(empty_settings_file).unwrap();
        assert_eq!(parsed, ProjectConfiguration::default());
    }

    #[test]
    fn named_view_serde_json() {
        let json = r#"
        [
          {
            "name":"dog",
            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
            "pivot_position":[0.5,0,0.5],
            "eye_offset":231.52048,
            "fov_y":45,
            "ortho_scale_factor":1.574129,
            "is_ortho":true,
            "ortho_scale_enabled":true,
            "world_coord_system":"RightHandedUpZ"
          }
    ]
    "#;
        // serde_json to a NamedView will produce default values
        let named_views: Vec<NamedView> = serde_json::from_str(json).unwrap();
        let version = named_views[0].version;
        assert_eq!(version, 1.0);
    }

    #[test]
    fn named_view_serde_json_string() {
        let json = r#"
        [
          {
            "name":"dog",
            "pivot_rotation":[0.53809947,0.0,0.0,0.8428814],
            "pivot_position":[0.5,0,0.5],
            "eye_offset":231.52048,
            "fov_y":45,
            "ortho_scale_factor":1.574129,
            "is_ortho":true,
            "ortho_scale_enabled":true,
            "world_coord_system":"RightHandedUpZ"
          }
    ]
    "#;

        // serde_json to string does not produce default values
        let named_views: Value = match serde_json::from_str(json) {
            Ok(x) => x,
            Err(_) => return,
        };
        println!("{}", named_views);
    }

    #[test]
    fn test_project_settings_named_views() {
        let conf = ProjectConfiguration {
            settings: PerProjectSettings {
                meta: ProjectMetaSettings { id: uuid::Uuid::nil() },
                app: ProjectAppSettings {
                    stream_idle_mode: false,
                    zookeeper_mode: None,
                    named_views: IndexMap::from([
                        (
                            uuid::uuid!("323611ea-66e3-43c9-9d0d-1091ba92948c"),
                            NamedView {
                                name: String::from("Hello"),
                                eye_offset: 1236.4015,
                                fov_y: 45.0,
                                is_ortho: false,
                                ortho_scale_enabled: false,
                                ortho_scale_factor: 45.0,
                                pivot_position: [-100.0, 100.0, 100.0],
                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
                                world_coord_system: String::from("RightHandedUpZ"),
                                version: 1.0,
                            },
                        ),
                        (
                            uuid::uuid!("423611ea-66e3-43c9-9d0d-1091ba92948c"),
                            NamedView {
                                name: String::from("Goodbye"),
                                eye_offset: 1236.4015,
                                fov_y: 45.0,
                                is_ortho: false,
                                ortho_scale_enabled: false,
                                ortho_scale_factor: 45.0,
                                pivot_position: [-100.0, 100.0, 100.0],
                                pivot_rotation: [-0.16391756, 0.9862819, -0.01956843, 0.0032552152],
                                world_coord_system: String::from("RightHandedUpZ"),
                                version: 1.0,
                            },
                        ),
                    ]),
                    other: IndexMap::from([("show_debug_panel".to_owned(), json!(true))]),
                },
                modeling: ProjectModelingSettings {
                    base_unit: Some(UnitLength::Yards),
                    highlight_edges: Default::default(),
                    enable_ssao: true.into(),
                    fixed_size_grid: None,
                    other: Default::default(),
                },
                other: IndexMap::from([
                    (
                        "command_bar".to_owned(),
                        json!({
                            "include_settings": false,
                        }),
                    ),
                    (
                        "text_editor".to_owned(),
                        json!({
                            "text_wrapping": false,
                            "blinking_cursor": false,
                        }),
                    ),
                ]),
            },
            cloud: ProjectCloudSettings::default(),
        };
        let serialized = toml::to_string(&conf).unwrap();
        assert!(serialized.contains("[settings.app]"));
        assert!(serialized.contains("show_debug_panel = true"));
        assert!(serialized.contains("[settings.app.named_views.323611ea-66e3-43c9-9d0d-1091ba92948c]"));
        assert!(serialized.contains("[settings.app.named_views.423611ea-66e3-43c9-9d0d-1091ba92948c]"));
        assert!(serialized.contains("[settings.modeling]"));
        assert!(serialized.contains("base_unit = \"yd\""));
        assert!(serialized.contains("[settings.command_bar]"));
        assert!(serialized.contains("include_settings = false"));
        assert!(serialized.contains("[settings.text_editor]"));
        assert!(serialized.contains("blinking_cursor = false"));
        assert!(serialized.contains("text_wrapping = false"));
        let reparsed = toml::from_str::<ProjectConfiguration>(&serialized).unwrap();
        assert_eq!(reparsed, conf);
    }

    #[test]
    fn test_project_settings_cloud_metadata_round_trip() {
        let local_project_id = uuid::uuid!("e8f5178c-5227-4567-bb5a-f52b3caef5ea");
        let zoo_cloud_project_id = uuid::uuid!("04c988e3-ec37-48a4-b491-45c3668934f1");
        let dev_cloud_project_id = uuid::uuid!("e9632dae-19ca-49ea-bcc1-ee8e34ff9de3");

        let conf = ProjectConfiguration {
            settings: PerProjectSettings {
                meta: ProjectMetaSettings { id: local_project_id },
                ..Default::default()
            },
            cloud: ProjectCloudSettings {
                environments: IndexMap::from([
                    (
                        "zoo.dev".to_owned(),
                        ProjectCloudEnvironmentSettings {
                            project_id: zoo_cloud_project_id,
                        },
                    ),
                    (
                        "dev.zoo.dev".to_owned(),
                        ProjectCloudEnvironmentSettings {
                            project_id: dev_cloud_project_id,
                        },
                    ),
                ]),
            },
        };

        let serialized = toml::to_string(&conf).unwrap();
        assert!(serialized.contains(&format!(
            "[cloud.\"zoo.dev\"]\nproject_id = \"{zoo_cloud_project_id}\"\n"
        )));
        assert!(serialized.contains(&format!(
            "[cloud.\"dev.zoo.dev\"]\nproject_id = \"{dev_cloud_project_id}\"\n"
        )));
        assert!(serialized.contains(&format!("[settings.meta]\nid = \"{local_project_id}\"\n")));

        let parsed = ProjectConfiguration::parse_and_validate(&serialized).unwrap();
        assert_eq!(parsed, conf);
    }
}