1use 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
32pub 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 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
60pub 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 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 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 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 let mut needs_oit = false;
184 let opacity_param = if let Some(opacity) = opacity {
185 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 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 }
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}