Skip to main content

kcl_lib/std/
appearance.rs

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