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;
11use rgba_simple::RGB;
12
13use super::args::TyF64;
14use crate::errors::KclError;
15use crate::errors::KclErrorDetails;
16use crate::execution::ExecState;
17use crate::execution::HasAppearance;
18use crate::execution::KclValue;
19use crate::execution::ModelingCmdMeta;
20use crate::execution::annotations;
21use crate::execution::types::ArrayLen;
22use crate::execution::types::RuntimeType;
23use crate::std::Args;
24
25lazy_static::lazy_static! {
26    static ref HEX_REGEX: Regex = Regex::new(r"^#[0-9a-fA-F]{6}$").unwrap();
27}
28
29const DEFAULT_ROUGHNESS: f64 = 1.0;
30const DEFAULT_METALNESS: f64 = 0.0;
31
32/// Construct a color from its red, blue and green components.
33pub async fn hex_string(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
34    let rgb: [TyF64; 3] = args.get_unlabeled_kw_arg(
35        "rgb",
36        &RuntimeType::Array(Box::new(RuntimeType::count()), ArrayLen::Known(3)),
37        exec_state,
38    )?;
39
40    // Make sure the color if set is valid.
41    if let Some(component) = rgb.iter().find(|component| component.n < 0.0 || component.n > 255.0) {
42        return Err(KclError::new_semantic(KclErrorDetails::new(
43            format!("Colors are given between 0 and 255, so {} is invalid", component.n),
44            vec![args.source_range],
45        )));
46    }
47
48    inner_hex_string(rgb, exec_state, args).await
49}
50
51async fn inner_hex_string(rgb: [TyF64; 3], _: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
52    let [r, g, b] = rgb.map(|n| n.n.floor() as u32);
53    let s = format!("#{r:02x}{g:02x}{b:02x}");
54    Ok(KclValue::String {
55        value: s,
56        meta: args.into(),
57    })
58}
59
60/// Set the appearance of something (e.g. a solid, imported geometry, or plane).
61pub async fn appearance(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
62    let solids = args.get_unlabeled_kw_arg(
63        "solids",
64        &RuntimeType::Union(vec![
65            RuntimeType::solids(),
66            RuntimeType::imported(),
67            RuntimeType::plane(),
68        ]),
69        exec_state,
70    )?;
71
72    let color: String = args.get_kw_arg("color", &RuntimeType::string(), exec_state)?;
73    let backface_color: Option<String> = args.get_kw_arg_opt("backfaceColor", &RuntimeType::string(), exec_state)?;
74    let metalness: Option<TyF64> = args.get_kw_arg_opt("metalness", &RuntimeType::count(), exec_state)?;
75    let roughness: Option<TyF64> = args.get_kw_arg_opt("roughness", &RuntimeType::count(), exec_state)?;
76    let opacity: Option<TyF64> = args.get_kw_arg_opt("opacity", &RuntimeType::count(), exec_state)?;
77
78    // Validate front and back colors using the same format.
79    let all_colors_iter = std::iter::once(&color).chain(backface_color.iter());
80    for color in all_colors_iter {
81        if !HEX_REGEX.is_match(color) {
82            return Err(KclError::new_semantic(KclErrorDetails::new(
83                format!("Invalid hex color (`{color}`), try something like `#fff000`"),
84                vec![args.source_range],
85            )));
86        }
87    }
88
89    let result = inner_appearance(
90        solids,
91        color,
92        backface_color,
93        metalness.map(|t| t.n),
94        roughness.map(|t| t.n),
95        opacity.map(|t| t.n),
96        exec_state,
97        args,
98    )
99    .await?;
100    Ok(result.into())
101}
102
103fn to_hex_color(color: &str, args: &Args) -> Result<RGB<f32>, KclError> {
104    rgba_simple::RGB::<f32>::from_hex(color).map_err(|err| {
105        KclError::new_semantic(KclErrorDetails::new(
106            format!("Invalid hex color (`{color}`): {err}"),
107            vec![args.source_range],
108        ))
109    })
110}
111
112#[allow(clippy::too_many_arguments)]
113async fn inner_appearance(
114    geometry: HasAppearance,
115    color: String,
116    backface_color: Option<String>,
117    metalness: Option<f64>,
118    roughness: Option<f64>,
119    opacity: Option<f64>,
120    exec_state: &mut ExecState,
121    args: Args,
122) -> Result<HasAppearance, KclError> {
123    let mut geometry = geometry;
124
125    // Set the material properties.
126    let rgb = to_hex_color(&color, &args)?;
127    let backface_color = backface_color.map(|s| to_hex_color(&s, &args)).transpose()?;
128
129    let percent_range = (0.0)..=100.0;
130    let zero_one_range = (0.0)..=1.0;
131    if let HasAppearance::Plane(plane) = &geometry {
132        if let Some(user_opacity) = opacity {
133            if zero_one_range.contains(&user_opacity) && user_opacity != 0.0 {
134                exec_state.warn(
135                        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()),
136                        annotations::WARN_SHOULD_BE_PERCENTAGE,
137                    );
138            }
139            if !percent_range.contains(&user_opacity) {
140                return Err(KclError::new_semantic(KclErrorDetails::new(
141                    format!("Opacity must be between 0 and 100, but it was {user_opacity}"),
142                    vec![args.source_range],
143                )));
144            }
145        }
146        let opacity = (opacity.unwrap_or(50.0) / 100.0) as f32;
147        exec_state
148            .batch_modeling_cmd(
149                ModelingCmdMeta::from_args(exec_state, &args),
150                ModelingCmd::from(
151                    mcmd::PlaneSetColor::builder()
152                        .color(Color::from_rgba(rgb.red, rgb.green, rgb.blue, opacity))
153                        .plane_id(plane.id)
154                        .build(),
155                ),
156            )
157            .await?;
158
159        return Ok(geometry);
160    }
161
162    // Set the material properties.
163    for (prop, val) in [("Metalness", metalness), ("Roughness", roughness), ("Opacity", opacity)] {
164        if let Some(x) = val {
165            if !(percent_range.contains(&x)) {
166                return Err(KclError::new_semantic(KclErrorDetails::new(
167                    format!("{prop} must be between 0 and 100, but it was {x}"),
168                    vec![args.source_range],
169                )));
170            }
171            if zero_one_range.contains(&x) && x != 0.0 {
172                exec_state.warn(
173                        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()),
174                        annotations::WARN_SHOULD_BE_PERCENTAGE,
175                    );
176            }
177        }
178    }
179
180    // OIT (order-independent transparency) is required to show transparency.
181    // But it degrades engine performance. So only enable it if necessary,
182    // i.e. if user has chosen to make something transparent.
183    let mut needs_oit = false;
184    let opacity_param = if let Some(opacity) = opacity {
185        // The engine errors out if you toggle OIT with SSAO off.
186        // So ignore OIT settings if SSAO is off.
187        if opacity < 100.0 && args.ctx.settings.enable_ssao {
188            needs_oit = true;
189        }
190        opacity / 100.0
191    } else {
192        1.0
193    };
194    let color = Color::from_rgba(rgb.red, rgb.green, rgb.blue, opacity_param as f32);
195    let backface_color = backface_color.map(|rgb| Color::from_rgba(rgb.red, rgb.green, rgb.blue, opacity_param as f32));
196
197    if needs_oit {
198        // TODO: Emit a warning annotation if SSAO is disabled.
199        exec_state
200            .batch_modeling_cmd(
201                ModelingCmdMeta::from_args(exec_state, &args),
202                ModelingCmd::from(mcmd::SetOrderIndependentTransparency::builder().enabled(true).build()),
203            )
204            .await?;
205    }
206
207    for solid_id in geometry.ids(&args.ctx).await? {
208        exec_state
209            .batch_modeling_cmd(
210                ModelingCmdMeta::from_args(exec_state, &args),
211                ModelingCmd::from(
212                    mcmd::ObjectSetMaterialParamsPbr::builder()
213                        .object_id(solid_id)
214                        .color(color)
215                        .maybe_backface_color(backface_color)
216                        .metalness(metalness.unwrap_or(DEFAULT_METALNESS) as f32 / 100.0)
217                        .roughness(roughness.unwrap_or(DEFAULT_ROUGHNESS) as f32 / 100.0)
218                        .ambient_occlusion(0.0)
219                        .build(),
220                ),
221            )
222            .await?;
223
224        // Idk if we want to actually modify the memory for the colors, but I'm not right now since
225        // I can't think of a use case for it.
226    }
227
228    Ok(geometry)
229}
230
231#[cfg(test)]
232mod tests {
233    use kittycad_modeling_cmds::ModelingCmd;
234
235    use crate::execution::parse_execute;
236
237    #[tokio::test(flavor = "multi_thread")]
238    async fn appearance_backface_color() {
239        for (argument, expected) in [
240            ("", None),
241            (r##", backfaceColor = "#00FF80""##, Some((0.0, 1.0, 128.0 / 255.0))),
242        ] {
243            let code = format!(
244                r##"
245@settings(kclVersion = 3.0)
246profile = sketch(on = XY) {{
247    c = circle(center = [var 0mm, var 0mm], start = [var 10mm, var 0mm])
248}}
249body = extrude(region(segments = [profile.c]), length = 5mm)
250appearance(body, color = "#ff0000", opacity = 40{argument})
251"##
252            );
253            let result = parse_execute(&code).await.unwrap();
254            let commands = result.root_module_artifact_commands();
255            let materials = commands
256                .iter()
257                .filter_map(|command| match &command.command {
258                    ModelingCmd::ObjectSetMaterialParamsPbr(params) => Some(params),
259                    _ => None,
260                })
261                .collect::<Vec<_>>();
262            assert!(!materials.is_empty());
263            for params in materials {
264                assert_eq!(
265                    params.color,
266                    kittycad_modeling_cmds::shared::Color::from_rgba(1.0, 0.0, 0.0, 0.4)
267                );
268                assert_eq!(
269                    params.backface_color,
270                    expected.map(|(r, g, b)| kittycad_modeling_cmds::shared::Color::from_rgba(r, g, b, 0.4))
271                );
272            }
273        }
274    }
275
276    #[tokio::test(flavor = "multi_thread")]
277    async fn appearance_rejects_invalid_backface_color() {
278        for color in ["red", "#fff", "#gg0000", "#ff000000"] {
279            let code = format!(r##"appearance(XY, color = "#ff0000", backfaceColor = "{color}")"##);
280            let error = parse_execute(&code).await.unwrap_err();
281            assert!(error.to_string().contains(&format!("Invalid hex color (`{color}`)")));
282        }
283    }
284
285    #[tokio::test(flavor = "multi_thread")]
286    async fn appearance_on_plane_uses_plane_set_color_only() {
287        let result = parse_execute(
288            r##"
289plane = offsetPlane(XZ, offset = 4)
290styled = plane |> appearance(color = "#ff0000", opacity = 30, metalness = 200, roughness = 200)
291"##,
292        )
293        .await
294        .unwrap();
295
296        let commands = result
297            .root_module_artifact_commands()
298            .iter()
299            .map(|artifact_command| &artifact_command.command)
300            .collect::<Vec<_>>();
301
302        assert_eq!(
303            commands
304                .iter()
305                .filter(|command| matches!(command, ModelingCmd::PlaneSetColor(_)))
306                .count(),
307            2
308        );
309        assert!(
310            !commands
311                .iter()
312                .any(|command| matches!(command, ModelingCmd::ObjectSetMaterialParamsPbr(_)))
313        );
314        assert!(
315            !commands
316                .iter()
317                .any(|command| matches!(command, ModelingCmd::SetOrderIndependentTransparency(_)))
318        );
319    }
320}