1use 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
34pub 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 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
62pub 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 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 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 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 let mut needs_oit = false;
186 let opacity_param = if let Some(opacity) = opacity {
187 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 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 }
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 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 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}