Skip to main content

kcl_lib/std/
appearance.rs

1//! Standard library appearance.
2
3use anyhow::Result;
4use kcl_error::CompilationIssue;
5use kcmc::ModelingCmd;
6use kcmc::each_cmd as mcmd;
7use kittycad_modeling_cmds::shared::Color;
8use kittycad_modeling_cmds::{self as kcmc};
9use regex::Regex;
10use rgba_simple::Hex;
11
12use super::args::TyF64;
13use crate::errors::KclError;
14use crate::errors::KclErrorDetails;
15use crate::execution::ExecState;
16use crate::execution::HasAppearance;
17use crate::execution::KclValue;
18use crate::execution::ModelingCmdMeta;
19use crate::execution::annotations;
20use crate::execution::types::ArrayLen;
21use crate::execution::types::RuntimeType;
22use crate::std::Args;
23
24lazy_static::lazy_static! {
25    static ref HEX_REGEX: Regex = Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap();
26}
27
28const DEFAULT_ROUGHNESS: f64 = 1.0;
29const DEFAULT_METALNESS: f64 = 0.0;
30
31/// Construct a color from its red, blue and green components.
32pub async fn hex_string(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
33    let rgb: [TyF64; 3] = args.get_unlabeled_kw_arg(
34        "rgb",
35        &RuntimeType::Array(Box::new(RuntimeType::count()), ArrayLen::Known(3)),
36        exec_state,
37    )?;
38
39    // Make sure the color if set is valid.
40    if let Some(component) = rgb.iter().find(|component| component.n < 0.0 || component.n > 255.0) {
41        return Err(KclError::new_semantic(KclErrorDetails::new(
42            format!("Colors are given between 0 and 255, so {} is invalid", component.n),
43            vec![args.source_range],
44        )));
45    }
46
47    inner_hex_string(rgb, exec_state, args).await
48}
49
50async fn inner_hex_string(rgb: [TyF64; 3], _: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
51    let [r, g, b] = rgb.map(|n| n.n.floor() as u32);
52    let s = format!("#{r:02x}{g:02x}{b:02x}");
53    Ok(KclValue::String {
54        value: s,
55        meta: args.into(),
56    })
57}
58
59/// Set the appearance of something (e.g. a solid, imported geometry, or plane).
60pub async fn appearance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
61    let solids = args.get_unlabeled_kw_arg(
62        "solids",
63        &RuntimeType::Union(vec![
64            RuntimeType::solids(),
65            RuntimeType::imported(),
66            RuntimeType::plane(),
67        ]),
68        exec_state,
69    )?;
70
71    let color: String = args.get_kw_arg("color", &RuntimeType::string(), exec_state)?;
72    let metalness: Option<TyF64> = args.get_kw_arg_opt("metalness", &RuntimeType::count(), exec_state)?;
73    let roughness: Option<TyF64> = args.get_kw_arg_opt("roughness", &RuntimeType::count(), exec_state)?;
74    let opacity: Option<TyF64> = args.get_kw_arg_opt("opacity", &RuntimeType::count(), exec_state)?;
75
76    // Make sure the color if set is valid.
77    if !HEX_REGEX.is_match(&color) {
78        return Err(KclError::new_semantic(KclErrorDetails::new(
79            format!("Invalid hex color (`{color}`), try something like `#fff000`"),
80            vec![args.source_range],
81        )));
82    }
83
84    let result = inner_appearance(
85        solids,
86        color,
87        metalness.map(|t| t.n),
88        roughness.map(|t| t.n),
89        opacity.map(|t| t.n),
90        exec_state,
91        args,
92    )
93    .await?;
94    Ok(result.into())
95}
96
97async fn inner_appearance(
98    geometry: HasAppearance,
99    color: String,
100    metalness: Option<f64>,
101    roughness: Option<f64>,
102    opacity: Option<f64>,
103    exec_state: &mut ExecState,
104    args: Args,
105) -> Result<HasAppearance, KclError> {
106    let mut geometry = geometry;
107
108    // Set the material properties.
109    let rgb = rgba_simple::RGB::<f32>::from_hex(&color).map_err(|err| {
110        KclError::new_semantic(KclErrorDetails::new(
111            format!("Invalid hex color (`{color}`): {err}"),
112            vec![args.source_range],
113        ))
114    })?;
115
116    let percent_range = (0.0)..=100.0;
117    let zero_one_range = (0.0)..=1.0;
118    if let HasAppearance::Plane(plane) = &geometry {
119        if let Some(user_opacity) = opacity {
120            if zero_one_range.contains(&user_opacity) && user_opacity != 0.0 {
121                exec_state.warn(
122                        CompilationIssue::err(args.source_range, "This looks like you're setting a property to a number between 0 and 1, but the property should be between 0 and 100.".to_string()),
123                        annotations::WARN_SHOULD_BE_PERCENTAGE,
124                    );
125            }
126            if !percent_range.contains(&user_opacity) {
127                return Err(KclError::new_semantic(KclErrorDetails::new(
128                    format!("Opacity must be between 0 and 100, but it was {user_opacity}"),
129                    vec![args.source_range],
130                )));
131            }
132        }
133        let opacity = (opacity.unwrap_or(50.0) / 100.0) as f32;
134        exec_state
135            .batch_modeling_cmd(
136                ModelingCmdMeta::from_args(exec_state, &args),
137                ModelingCmd::from(
138                    mcmd::PlaneSetColor::builder()
139                        .color(Color::from_rgba(rgb.red, rgb.green, rgb.blue, opacity))
140                        .plane_id(plane.id)
141                        .build(),
142                ),
143            )
144            .await?;
145
146        return Ok(geometry);
147    }
148
149    // Set the material properties.
150    for (prop, val) in [("Metalness", metalness), ("Roughness", roughness), ("Opacity", opacity)] {
151        if let Some(x) = val {
152            if !(percent_range.contains(&x)) {
153                return Err(KclError::new_semantic(KclErrorDetails::new(
154                    format!("{prop} must be between 0 and 100, but it was {x}"),
155                    vec![args.source_range],
156                )));
157            }
158            if zero_one_range.contains(&x) && x != 0.0 {
159                exec_state.warn(
160                        CompilationIssue::err(args.source_range, "This looks like you're setting a property to a number between 0 and 1, but the property should be between 0 and 100.".to_string()),
161                        annotations::WARN_SHOULD_BE_PERCENTAGE,
162                    );
163            }
164        }
165    }
166
167    // OIT (order-independent transparency) is required to show transparency.
168    // But it degrades engine performance. So only enable it if necessary,
169    // i.e. if user has chosen to make something transparent.
170    let mut needs_oit = false;
171    let opacity_param = if let Some(opacity) = opacity {
172        // The engine errors out if you toggle OIT with SSAO off.
173        // So ignore OIT settings if SSAO is off.
174        if opacity < 100.0 && args.ctx.settings.enable_ssao {
175            needs_oit = true;
176        }
177        opacity / 100.0
178    } else {
179        1.0
180    };
181    let color = Color::from_rgba(rgb.red, rgb.green, rgb.blue, opacity_param as f32);
182
183    if needs_oit {
184        // TODO: Emit a warning annotation if SSAO is disabled.
185        exec_state
186            .batch_modeling_cmd(
187                ModelingCmdMeta::from_args(exec_state, &args),
188                ModelingCmd::from(mcmd::SetOrderIndependentTransparency::builder().enabled(true).build()),
189            )
190            .await?;
191    }
192
193    for solid_id in geometry.ids(&args.ctx).await? {
194        exec_state
195            .batch_modeling_cmd(
196                ModelingCmdMeta::from_args(exec_state, &args),
197                ModelingCmd::from(
198                    mcmd::ObjectSetMaterialParamsPbr::builder()
199                        .object_id(solid_id)
200                        .color(color)
201                        .metalness(metalness.unwrap_or(DEFAULT_METALNESS) as f32 / 100.0)
202                        .roughness(roughness.unwrap_or(DEFAULT_ROUGHNESS) as f32 / 100.0)
203                        .ambient_occlusion(0.0)
204                        .build(),
205                ),
206            )
207            .await?;
208
209        // Idk if we want to actually modify the memory for the colors, but I'm not right now since
210        // I can't think of a use case for it.
211    }
212
213    Ok(geometry)
214}
215
216#[cfg(test)]
217mod tests {
218    use kittycad_modeling_cmds::ModelingCmd;
219
220    use crate::execution::parse_execute;
221
222    #[tokio::test(flavor = "multi_thread")]
223    async fn appearance_on_plane_uses_plane_set_color_only() {
224        let result = parse_execute(
225            r##"
226plane = offsetPlane(XZ, offset = 4)
227styled = plane |> appearance(color = "#ff0000", opacity = 30, metalness = 200, roughness = 200)
228"##,
229        )
230        .await
231        .unwrap();
232
233        let commands = result
234            .root_module_artifact_commands()
235            .iter()
236            .map(|artifact_command| &artifact_command.command)
237            .collect::<Vec<_>>();
238
239        assert_eq!(
240            commands
241                .iter()
242                .filter(|command| matches!(command, ModelingCmd::PlaneSetColor(_)))
243                .count(),
244            2
245        );
246        assert!(
247            !commands
248                .iter()
249                .any(|command| matches!(command, ModelingCmd::ObjectSetMaterialParamsPbr(_)))
250        );
251        assert!(
252            !commands
253                .iter()
254                .any(|command| matches!(command, ModelingCmd::SetOrderIndependentTransparency(_)))
255        );
256    }
257}