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;
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
31pub 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 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
59pub 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 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 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 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 let mut needs_oit = false;
171 let opacity_param = if let Some(opacity) = opacity {
172 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 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 }
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}