1use anyhow::Result;
4use kcl_error::CompilationIssue;
5use kcmc::ModelingCmd;
6use kcmc::each_cmd as mcmd;
7use kcmc::length_unit::LengthUnit;
8use kittycad_modeling_cmds::ok_response::OkModelingCmdResponse;
9use kittycad_modeling_cmds::websocket::OkWebSocketResponseData;
10use kittycad_modeling_cmds::{self as kcmc};
11
12use super::DEFAULT_TOLERANCE_MM;
13use super::args::TyF64;
14use super::solid_consumption::record_consumed_solids;
15use super::solid_consumption::validate_solids_not_consumed;
16use crate::errors::KclError;
17use crate::errors::KclErrorDetails;
18use crate::execution::ConsumedSolidOperation;
19use crate::execution::ExecState;
20use crate::execution::KclValue;
21use crate::execution::ModelingCmdMeta;
22use crate::execution::Solid;
23use crate::execution::annotations;
24use crate::execution::types::RuntimeType;
25use crate::std::Args;
26use crate::std::patterns::GeometryTrait;
27
28pub async fn union(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
30 let solids: Vec<Solid> =
31 args.get_unlabeled_kw_arg("solids", &RuntimeType::Union(vec![RuntimeType::solids()]), exec_state)?;
32 let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
33 let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
34 let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
35
36 if solids.len() < 2 {
37 return Err(KclError::new_semantic(KclErrorDetails::new(
38 "At least two solids are required for a union operation.".to_string(),
39 vec![args.source_range],
40 )));
41 }
42
43 let solids = inner_union(solids, tolerance, csg_algorithm, exec_state, args).await?;
44 Ok(solids.into())
45}
46
47pub enum CsgAlgorithm {
48 Latest,
49 Legacy,
50}
51
52impl CsgAlgorithm {
53 pub fn legacy(is_legacy: bool) -> Self {
54 if is_legacy { Self::Legacy } else { Self::Latest }
55 }
56 pub fn is_legacy(&self) -> bool {
57 match self {
58 CsgAlgorithm::Latest => false,
59 CsgAlgorithm::Legacy => true,
60 }
61 }
62}
63
64fn is_single_target_self_subtract(target_ids: &[uuid::Uuid], tool_ids: &[uuid::Uuid]) -> bool {
65 target_ids.len() == 1 && tool_ids.len() == 1 && target_ids[0] == tool_ids[0]
66}
67
68fn subtract_output_ids(
69 solid_out_id: uuid::Uuid,
70 target_ids: &[uuid::Uuid],
71 tool_ids: &[uuid::Uuid],
72 extra_solid_ids: &[uuid::Uuid],
73) -> Vec<uuid::Uuid> {
74 if is_single_target_self_subtract(target_ids, tool_ids) {
75 return Vec::new();
76 }
77
78 let mut output_ids = if target_ids.len() == 1 {
79 vec![solid_out_id]
80 } else {
81 Vec::new()
82 };
83
84 for extra_solid_id in extra_solid_ids {
85 if !output_ids.contains(extra_solid_id) {
86 output_ids.push(*extra_solid_id);
87 }
88 }
89
90 output_ids
91}
92
93fn inherit_face_tags<'item, I>(output: &mut Solid, inputs: I)
94where
95 I: Iterator<Item = &'item Solid>,
96{
97 for input in inputs {
98 for (name, tag) in &input.faces {
99 output.faces.entry(name.clone()).or_insert_with(|| tag.clone());
101 }
102 }
103}
104
105pub(crate) async fn inner_union(
106 solids: Vec<Solid>,
107 tolerance: Option<TyF64>,
108 csg_algorithm: CsgAlgorithm,
109 exec_state: &mut ExecState,
110 args: Args,
111) -> Result<Vec<Solid>, KclError> {
112 validate_solids_not_consumed(&solids, exec_state, args.source_range)?;
113
114 let solid_out_id = exec_state.next_uuid();
115
116 let mut solid = solids[0].clone();
117 inherit_face_tags(&mut solid, solids.iter());
118 solid.set_id(solid_out_id);
119 solid.become_new_body(solid_out_id, solid_out_id.into());
120 let mut new_solids = vec![solid.clone()];
121
122 if args.ctx.no_engine_commands().await {
123 record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Union, &new_solids);
124 return Ok(new_solids);
125 }
126
127 exec_state
129 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &solids)
130 .await?;
131
132 let result = exec_state
133 .send_modeling_cmd(
134 ModelingCmdMeta::from_args_id(exec_state, &args, solid_out_id),
135 ModelingCmd::from(
136 mcmd::BooleanUnion::builder()
137 .use_legacy(csg_algorithm.is_legacy())
138 .solid_ids(solids.iter().map(|s| s.id).collect())
139 .tolerance(LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM)))
140 .build(),
141 ),
142 )
143 .await?;
144
145 let OkWebSocketResponseData::Modeling {
146 modeling_response: OkModelingCmdResponse::BooleanUnion(boolean_resp),
147 } = result
148 else {
149 return Err(KclError::new_internal(KclErrorDetails::new(
150 "Failed to get the result of the union operation.".to_string(),
151 vec![args.source_range],
152 )));
153 };
154
155 if !boolean_resp.any_intersections {
156 exec_state.warn(
157 CompilationIssue::err(
158 args.source_range,
159 "The bodies in this union had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
160 ),
161 annotations::WARN_CSG_NO_INTERSECTION,
162 );
163 }
164
165 for extra_solid_id in boolean_resp.extra_solid_ids {
167 if extra_solid_id == solid_out_id {
168 continue;
169 }
170 let mut new_solid = solid.clone();
171 new_solid.set_id(extra_solid_id);
172 new_solid.value_id = solid_out_id;
173 new_solid.become_new_body(extra_solid_id, extra_solid_id.into());
174 new_solids.push(new_solid);
175 }
176
177 record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Union, &new_solids);
178
179 Ok(new_solids)
180}
181
182pub async fn intersect(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
185 let solids: Vec<Solid> = args.get_unlabeled_kw_arg("solids", &RuntimeType::solids(), exec_state)?;
186 let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
187 let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
188 let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
189
190 if solids.len() < 2 {
191 return Err(KclError::new_semantic(KclErrorDetails::new(
192 "At least two solids are required for an intersect operation.".to_string(),
193 vec![args.source_range],
194 )));
195 }
196
197 let solids = inner_intersect(solids, tolerance, csg_algorithm, exec_state, args).await?;
198 Ok(solids.into())
199}
200
201pub(crate) async fn inner_intersect(
202 solids: Vec<Solid>,
203 tolerance: Option<TyF64>,
204 csg_algorithm: CsgAlgorithm,
205 exec_state: &mut ExecState,
206 args: Args,
207) -> Result<Vec<Solid>, KclError> {
208 validate_solids_not_consumed(&solids, exec_state, args.source_range)?;
209
210 let solid_out_id = exec_state.next_uuid();
211
212 let mut solid = solids[0].clone();
213 inherit_face_tags(&mut solid, solids.iter());
214 solid.set_id(solid_out_id);
215 solid.become_new_body(solid_out_id, solid_out_id.into());
216 let mut new_solids = vec![solid.clone()];
217
218 if args.ctx.no_engine_commands().await {
219 record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Intersect, &new_solids);
220 return Ok(new_solids);
221 }
222
223 exec_state
225 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &solids)
226 .await?;
227
228 let result = exec_state
229 .send_modeling_cmd(
230 ModelingCmdMeta::from_args_id(exec_state, &args, solid_out_id),
231 ModelingCmd::from(
232 mcmd::BooleanIntersection::builder()
233 .use_legacy(csg_algorithm.is_legacy())
234 .solid_ids(solids.iter().map(|s| s.id).collect())
235 .tolerance(LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM)))
236 .build(),
237 ),
238 )
239 .await?;
240
241 let OkWebSocketResponseData::Modeling {
242 modeling_response: OkModelingCmdResponse::BooleanIntersection(boolean_resp),
243 } = result
244 else {
245 return Err(KclError::new_internal(KclErrorDetails::new(
246 "Failed to get the result of the intersection operation.".to_string(),
247 vec![args.source_range],
248 )));
249 };
250 if !boolean_resp.any_intersections {
251 exec_state.warn(
252 CompilationIssue::err(
253 args.source_range,
254 "The bodies in this intersection had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
255 ),
256 annotations::WARN_CSG_NO_INTERSECTION,
257 );
258 }
259
260 for extra_solid_id in boolean_resp.extra_solid_ids {
262 if extra_solid_id == solid_out_id {
263 continue;
264 }
265 let mut new_solid = solid.clone();
266 new_solid.set_id(extra_solid_id);
267 new_solid.value_id = solid_out_id;
268 new_solid.become_new_body(extra_solid_id, extra_solid_id.into());
269 new_solids.push(new_solid);
270 }
271
272 record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Intersect, &new_solids);
273
274 Ok(new_solids)
275}
276
277pub async fn subtract(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
279 let solids: Vec<Solid> = args.get_unlabeled_kw_arg("solids", &RuntimeType::solids(), exec_state)?;
280 let tools: Vec<Solid> = args.get_kw_arg("tools", &RuntimeType::solids(), exec_state)?;
281
282 let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
283 let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
284 let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
285
286 let solids = inner_subtract(solids, tools, tolerance, csg_algorithm, exec_state, args).await?;
287 Ok(solids.into())
288}
289
290pub(crate) async fn inner_subtract(
291 solids: Vec<Solid>,
292 tools: Vec<Solid>,
293 tolerance: Option<TyF64>,
294 csg_algorithm: CsgAlgorithm,
295 exec_state: &mut ExecState,
296 args: Args,
297) -> Result<Vec<Solid>, KclError> {
298 let combined_solids = solids.iter().chain(tools.iter()).cloned().collect::<Vec<Solid>>();
299 validate_solids_not_consumed(&combined_solids, exec_state, args.source_range)?;
300
301 let solid_out_id = exec_state.next_uuid();
302 let target_ids = solids.iter().map(|s| s.id).collect::<Vec<_>>();
303 let tool_ids = tools.iter().map(|s| s.id).collect::<Vec<_>>();
304
305 if args.ctx.no_engine_commands().await {
306 let new_solids = solids
308 .iter()
309 .enumerate()
310 .map(|(index, solid)| {
311 let output_id = if index == 0 {
314 solid_out_id
315 } else {
316 exec_state.next_uuid()
317 };
318 let mut new_solid = solid.clone();
319 let first = vec![solid];
320 inherit_face_tags(&mut new_solid, first.into_iter().chain(tools.iter()));
321 new_solid.set_id(output_id);
322 new_solid.become_new_body(output_id, output_id.into());
323 new_solid
324 })
325 .collect::<Vec<_>>();
326 record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Subtract, &new_solids);
327 record_consumed_solids(exec_state, &tools, ConsumedSolidOperation::Subtract, &[]);
328 return Ok(new_solids);
329 }
330
331 exec_state
333 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &combined_solids)
334 .await?;
335
336 let result = exec_state
337 .send_modeling_cmd(
338 ModelingCmdMeta::from_args_id(exec_state, &args, solid_out_id),
339 ModelingCmd::from(
340 mcmd::BooleanSubtract::builder()
341 .use_legacy(csg_algorithm.is_legacy())
342 .target_ids(target_ids.clone())
343 .tool_ids(tool_ids.clone())
344 .tolerance(LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM)))
345 .build(),
346 ),
347 )
348 .await?;
349
350 let OkWebSocketResponseData::Modeling {
351 modeling_response: OkModelingCmdResponse::BooleanSubtract(boolean_resp),
352 } = result
353 else {
354 return Err(KclError::new_internal(KclErrorDetails::new(
355 "Failed to get the result of the subtract operation.".to_string(),
356 vec![args.source_range],
357 )));
358 };
359
360 if !boolean_resp.any_intersections {
361 exec_state.warn(
362 CompilationIssue::err(
363 args.source_range,
364 "The bodies in this subtraction had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
365 ),
366 annotations::WARN_CSG_NO_INTERSECTION,
367 );
368 }
369
370 let output_ids = subtract_output_ids(solid_out_id, &target_ids, &tool_ids, &boolean_resp.extra_solid_ids);
371 let new_solids = output_ids
372 .into_iter()
373 .map(|output_id| {
374 let mut new_solid = solids[0].clone();
375 let first = solids.first().map(|s| vec![s]).unwrap_or_default();
376 inherit_face_tags(&mut new_solid, first.into_iter().chain(tools.iter()));
377 new_solid.set_id(output_id);
378 new_solid.value_id = solid_out_id;
379 new_solid.become_new_body(output_id, output_id.into());
380 new_solid
381 })
382 .collect::<Vec<_>>();
383
384 record_consumed_solids(exec_state, &solids, ConsumedSolidOperation::Subtract, &new_solids);
385 record_consumed_solids(exec_state, &tools, ConsumedSolidOperation::Subtract, &[]);
386
387 Ok(new_solids)
388}
389
390pub async fn split(exec_state: &mut ExecState, args: Args) -> Result<KclValue, KclError> {
392 let targets: Vec<Solid> = args.get_unlabeled_kw_arg("targets", &RuntimeType::solids(), exec_state)?;
393 let tolerance: Option<TyF64> = args.get_kw_arg_opt("tolerance", &RuntimeType::length(), exec_state)?;
394 let legacy_csg: Option<bool> = args.get_kw_arg_opt("legacyMethod", &RuntimeType::bool(), exec_state)?;
395 let csg_algorithm = CsgAlgorithm::legacy(legacy_csg.unwrap_or_default());
396 let tools: Option<Vec<Solid>> = args.get_kw_arg_opt("tools", &RuntimeType::solids(), exec_state)?;
397 let keep_tools = args
398 .get_kw_arg_opt("keepTools", &RuntimeType::bool(), exec_state)?
399 .unwrap_or_default();
400 let merge = args
401 .get_kw_arg_opt("merge", &RuntimeType::bool(), exec_state)?
402 .unwrap_or_default();
403
404 if targets.is_empty() {
405 return Err(KclError::new_semantic(KclErrorDetails::new(
406 "At least one target body is required.".to_string(),
407 vec![args.source_range],
408 )));
409 }
410
411 let body = inner_imprint(
412 targets,
413 tools,
414 keep_tools,
415 merge,
416 tolerance,
417 csg_algorithm,
418 exec_state,
419 args,
420 )
421 .await?;
422 Ok(body.into())
423}
424
425#[allow(clippy::too_many_arguments)]
426pub(crate) async fn inner_imprint(
427 targets: Vec<Solid>,
428 tools: Option<Vec<Solid>>,
429 keep_tools: bool,
430 merge: bool,
431 tolerance: Option<TyF64>,
432 csg_algorithm: CsgAlgorithm,
433 exec_state: &mut ExecState,
434 args: Args,
435) -> Result<Vec<Solid>, KclError> {
436 validate_solids_not_consumed(&targets, exec_state, args.source_range)?;
437 if let Some(tools) = tools.as_ref() {
438 validate_solids_not_consumed(tools, exec_state, args.source_range)?;
439 }
440
441 let body_out_id = exec_state.next_uuid();
442
443 let mut body = targets[0].clone();
444 body.set_id(body_out_id);
445 body.become_new_body(body_out_id, body_out_id.into());
446 let mut new_solids = vec![body.clone()];
447 let separate_bodies = !merge;
448
449 if args.ctx.no_engine_commands().await {
450 if separate_bodies {
451 let extra_solid_id = exec_state.next_uuid();
452 let mut new_solid = body.clone();
453 new_solid.set_id(extra_solid_id);
454 new_solid.value_id = body_out_id;
455 new_solid.become_new_body(extra_solid_id, extra_solid_id.into());
456 new_solids.push(new_solid);
457 }
458 record_consumed_solids(exec_state, &targets, ConsumedSolidOperation::Split, &new_solids);
459 if !keep_tools && let Some(tools) = tools.as_ref() {
460 record_consumed_solids(exec_state, tools, ConsumedSolidOperation::Split, &[]);
461 }
462 return Ok(new_solids);
463 }
464
465 let mut imprint_solids = targets.clone();
467 if let Some(tool_solids) = tools.as_ref() {
468 imprint_solids.extend_from_slice(tool_solids);
469 }
470 exec_state
471 .flush_batch_for_solids(ModelingCmdMeta::from_args(exec_state, &args), &imprint_solids)
472 .await?;
473
474 let body_ids = targets.iter().map(|body| body.id).collect();
475 let tool_ids = tools.as_ref().map(|tools| tools.iter().map(|tool| tool.id).collect());
476 let tolerance = LengthUnit(tolerance.map(|t| t.to_mm()).unwrap_or(DEFAULT_TOLERANCE_MM));
477 let imprint_cmd = mcmd::BooleanImprint::builder()
478 .use_legacy(csg_algorithm.is_legacy())
479 .body_ids(body_ids)
480 .tolerance(tolerance)
481 .separate_bodies(separate_bodies)
482 .keep_tools(keep_tools)
483 .maybe_tool_ids(tool_ids)
484 .build();
485 let result = exec_state
486 .send_modeling_cmd(
487 ModelingCmdMeta::from_args_id(exec_state, &args, body_out_id),
488 ModelingCmd::from(imprint_cmd),
489 )
490 .await?;
491
492 let OkWebSocketResponseData::Modeling {
493 modeling_response: OkModelingCmdResponse::BooleanImprint(boolean_resp),
494 } = result
495 else {
496 return Err(KclError::new_internal(KclErrorDetails::new(
497 "Failed to get the result of the Imprint operation.".to_string(),
498 vec![args.source_range],
499 )));
500 };
501 if !boolean_resp.any_intersections {
502 exec_state.warn(
503 CompilationIssue::err(
504 args.source_range,
505 "The bodies in this split had no overlap. This usually indicates a problem in your model, these bodies were probably intended to intersect somewhere.".to_string(),
506 ),
507 annotations::WARN_CSG_NO_INTERSECTION,
508 );
509 }
510
511 for extra_solid_id in boolean_resp.extra_solid_ids {
513 if extra_solid_id == body_out_id {
514 continue;
515 }
516 let mut new_solid = body.clone();
517 new_solid.set_id(extra_solid_id);
518 new_solid.value_id = body_out_id;
519 new_solid.become_new_body(extra_solid_id, extra_solid_id.into());
520 new_solids.push(new_solid);
521 }
522
523 record_consumed_solids(exec_state, &targets, ConsumedSolidOperation::Split, &new_solids);
524 if !keep_tools && let Some(tools) = tools.as_ref() {
525 record_consumed_solids(exec_state, tools, ConsumedSolidOperation::Split, &[]);
526 }
527
528 Ok(new_solids)
529}
530
531#[cfg(test)]
532mod tests {
533 use uuid::Uuid;
534
535 use super::subtract_output_ids;
536 use crate::errors::KclError;
537 use crate::execution::KclValue;
538 use crate::execution::MockConfig;
539 use crate::execution::parse_execute;
540
541 const FACE_TAG_INPUTS: &str = r#"@settings(kclVersion = 2.0)
542fn profile(@plane) {
543 return sketch(on = plane) {
544 bottom = line(start = [-10mm, -10mm], end = [10mm, -10mm])
545 right = line(start = [10mm, -10mm], end = [10mm, 10mm])
546 top = line(start = [10mm, 10mm], end = [-10mm, 10mm])
547 left = line(start = [-10mm, 10mm], end = [-10mm, -10mm])
548 }
549}
550firstProfile = profile(XY)
551secondProfile = profile(YZ)
552thirdProfile = profile(XZ)
553firstRegion = region(segments = [firstProfile.bottom])
554secondRegion = region(segments = [secondProfile.bottom])
555thirdRegion = region(segments = [thirdProfile.bottom])
556first = extrude(firstRegion, length = 5mm, symmetric = true, tagEnd = $firstEnd)
557second = extrude(secondRegion, length = 5mm, symmetric = true, tagEnd = $secondEnd)
558third = extrude(thirdRegion, length = 5mm, symmetric = true, tagEnd = $thirdEnd)
559untagged = extrude(region(segments = [firstProfile.bottom]), length = 5mm, symmetric = true)
560"#;
561
562 async fn assert_csg_inherits_face_tags(operation: &str) {
563 for (input_names, tag_names) in [
564 ("first, second", &["first", "second"][..]),
565 ("first, second, third", &["first", "second", "third"]),
566 ("third, second, first", &["third", "second", "first"]),
567 ("untagged, second", &["second"]),
568 ] {
569 let mut code = FACE_TAG_INPUTS.to_owned();
570 for name in tag_names {
571 code.push_str(&format!("{name}Original = {name}.faces.{name}End\n"));
572 }
573 if operation == "subtract" {
574 let (target, tools) = input_names.split_once(", ").unwrap();
575 code.push_str(&format!("body = subtract({target}, tools = [{tools}])\n"));
576 } else {
577 code.push_str(&format!("body = {operation}([{input_names}])\n"));
578 }
579 for name in tag_names {
580 code.push_str(&format!("{name}FromBody = body.faces.{name}End\n"));
581 }
582 let result = parse_execute(&code).await.unwrap();
583 for name in tag_names {
584 let output_tag = result.variable(&format!("{name}FromBody"));
585 assert!(matches!(&output_tag, KclValue::TagIdentifier(_)));
586 assert_eq!(
587 output_tag,
588 result.variable(&format!("{name}Original")),
589 "{operation}: {name}"
590 );
591 }
592 }
593 }
594
595 #[tokio::test(flavor = "multi_thread")]
596 async fn union_inherits_face_tags_from_all_inputs() {
597 assert_csg_inherits_face_tags("union").await;
598 }
599
600 #[tokio::test(flavor = "multi_thread")]
601 async fn intersect_inherits_face_tags_from_all_inputs() {
602 assert_csg_inherits_face_tags("intersect").await;
603 }
604
605 #[tokio::test(flavor = "multi_thread")]
606 async fn subtract_inherits_face_tags_from_target_and_tools() {
607 let tag_names = ["first", "second", "third"];
608 let mut code = FACE_TAG_INPUTS.to_owned();
609 for name in tag_names {
610 code.push_str(&format!("{name}Original = {name}.faces.{name}End\n"));
611 }
612 code.push_str("bodies = subtract([first, second], tools = [third, untagged])\n");
613 let output_tag_names = [["first", "third"], ["second", "third"]];
614 for (index, names) in output_tag_names.iter().enumerate() {
615 for name in names {
616 code.push_str(&format!("{name}FromBody{index} = bodies[{index}].faces.{name}End\n"));
617 }
618 }
619 let result = parse_execute(&code).await.unwrap();
620 let KclValue::HomArray { value: bodies, .. } = result.variable("bodies") else {
621 panic!("Expected subtract to return an array of solids");
622 };
623 assert_eq!(bodies.len(), output_tag_names.len());
624 for (index, names) in output_tag_names.iter().enumerate() {
625 let KclValue::Solid { value: body } = &bodies[index] else {
626 panic!("Expected subtract output {index} to be a solid");
627 };
628 assert_eq!(body.faces.len(), names.len(), "subtract: output {index}");
629 for name in names {
630 assert_eq!(
631 result.variable(&format!("{name}FromBody{index}")),
632 result.variable(&format!("{name}Original")),
633 "subtract: output {index}, tag {name}"
634 );
635 }
636 }
637 }
638
639 #[tokio::test(flavor = "multi_thread")]
640 async fn csg_keeps_first_input_for_duplicate_face_tag_names() {
641 let inputs = r#"@settings(kclVersion = 2.0)
642fn body(@plane) {
643 profile = sketch(on = plane) {
644 circle1 = circle(center = [0mm, 0mm], start = [10mm, 0mm])
645 }
646 return extrude(region(segments = [profile.circle1]), length = 5mm, tagEnd = $cap)
647}
648first = body(XY)
649second = body(YZ)
650firstCap = first.faces.cap
651secondCap = second.faces.cap
652"#;
653 for operation in ["union", "intersect", "subtract"] {
654 for (inputs_order, expected, other) in [
655 ("first, second", "firstCap", "secondCap"),
656 ("second, first", "secondCap", "firstCap"),
657 ] {
658 let expression = if operation == "subtract" {
659 let (target, tool) = inputs_order.split_once(", ").unwrap();
660 format!("subtract({target}, tools = {tool})")
661 } else {
662 format!("{operation}([{inputs_order}])")
663 };
664 let code = format!("{inputs}\ncombined = {expression}\nselected = combined.faces.cap\n");
665 let result = parse_execute(&code).await.unwrap();
666 assert_eq!(result.variable("selected"), result.variable(expected));
667 assert_ne!(result.variable("selected"), result.variable(other));
668 }
669 }
670 }
671
672 fn test_uuid(id: u128) -> Uuid {
673 Uuid::from_u128(id)
674 }
675
676 #[test]
677 fn subtract_output_ids_single_target_uses_command_id() {
678 let output_id = test_uuid(100);
679 let target_id = test_uuid(1);
680 let tool_id = test_uuid(2);
681 let extra_id = test_uuid(3);
682
683 let output_ids = subtract_output_ids(output_id, &[target_id], &[tool_id], &[extra_id]);
684
685 assert_eq!(output_ids, vec![output_id, extra_id]);
686 }
687
688 #[test]
689 fn subtract_output_ids_multi_target_uses_response_ids_only() {
690 let output_id = test_uuid(100);
691 let target_ids = [test_uuid(1), test_uuid(2)];
692 let tool_id = test_uuid(3);
693 let extra_ids = [test_uuid(4), test_uuid(5)];
694
695 let output_ids = subtract_output_ids(output_id, &target_ids, &[tool_id], &extra_ids);
696
697 assert_eq!(output_ids, extra_ids);
698 }
699
700 #[test]
701 fn subtract_output_ids_self_subtract_returns_no_outputs() {
702 let output_id = test_uuid(100);
703 let target_id = test_uuid(1);
704
705 let output_ids = subtract_output_ids(output_id, &[target_id], &[target_id], &[]);
706
707 assert!(output_ids.is_empty());
708 }
709
710 #[tokio::test(flavor = "multi_thread")]
711 async fn subtract_reusing_consumed_target_reports_kcl_error() {
712 let code = r#"
713targetSketch = sketch(on = XY) {
714 line1 = line(start = [var -10, var -10], end = [var 10, var -10])
715 line2 = line(start = [var 10, var -10], end = [var 10, var 10])
716 line3 = line(start = [var 10, var 10], end = [var -10, var 10])
717 line4 = line(start = [var -10, var 10], end = [var -10, var -10])
718 coincident([line1.end, line2.start])
719 coincident([line2.end, line3.start])
720 coincident([line3.end, line4.start])
721 coincident([line4.end, line1.start])
722 equalLength([line1, line2, line3, line4])
723}
724
725target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
726
727tool1Sketch = sketch(on = XY) {
728 line1 = line(start = [var -11, var -11], end = [var -7, var -11])
729 line2 = line(start = [var -7, var -11], end = [var -7, var -7])
730 line3 = line(start = [var -7, var -7], end = [var -11, var -7])
731 line4 = line(start = [var -11, var -7], end = [var -11, var -11])
732 coincident([line1.end, line2.start])
733 coincident([line2.end, line3.start])
734 coincident([line3.end, line4.start])
735 coincident([line4.end, line1.start])
736 equalLength([line1, line2, line3, line4])
737}
738
739tool1 = extrude(region(point = [-9, -9], sketch = tool1Sketch), length = 4)
740
741tool2Sketch = sketch(on = XY) {
742 line1 = line(start = [var 7, var 7], end = [var 11, var 7])
743 line2 = line(start = [var 11, var 7], end = [var 11, var 11])
744 line3 = line(start = [var 11, var 11], end = [var 7, var 11])
745 line4 = line(start = [var 7, var 11], end = [var 7, var 7])
746 coincident([line1.end, line2.start])
747 coincident([line2.end, line3.start])
748 coincident([line3.end, line4.start])
749 coincident([line4.end, line1.start])
750 equalLength([line1, line2, line3, line4])
751}
752
753tool2 = extrude(region(point = [9, 9], sketch = tool2Sketch), length = 4)
754
755first = subtract(target, tools = [tool1])
756second = subtract(target, tools = [tool2])
757"#;
758
759 let ctx = crate::ExecutorContext::new_mock(None).await;
760 let program = crate::Program::parse_no_errs(code).unwrap();
761 let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
762 ctx.close().await;
763
764 assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
765 let message = err.error.message();
766 assert!(
767 message.contains("`target` was already consumed by a `subtract` operation"),
768 "{message}"
769 );
770 assert!(
771 message.contains("The operation result is now in `first`; use that for subsequent operations"),
772 "{message}"
773 );
774 }
775
776 #[tokio::test(flavor = "multi_thread")]
777 async fn subtract_reusing_consumed_tool_reports_kcl_error() {
778 let code = r#"
779targetSketch = sketch(on = XY) {
780 line1 = line(start = [var -10, var -10], end = [var 10, var -10])
781 line2 = line(start = [var 10, var -10], end = [var 10, var 10])
782 line3 = line(start = [var 10, var 10], end = [var -10, var 10])
783 line4 = line(start = [var -10, var 10], end = [var -10, var -10])
784 coincident([line1.end, line2.start])
785 coincident([line2.end, line3.start])
786 coincident([line3.end, line4.start])
787 coincident([line4.end, line1.start])
788 equalLength([line1, line2, line3, line4])
789}
790
791target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
792
793toolSketch = sketch(on = XY) {
794 line1 = line(start = [var -2, var -2], end = [var 2, var -2])
795 line2 = line(start = [var 2, var -2], end = [var 2, var 2])
796 line3 = line(start = [var 2, var 2], end = [var -2, var 2])
797 line4 = line(start = [var -2, var 2], end = [var -2, var -2])
798 coincident([line1.end, line2.start])
799 coincident([line2.end, line3.start])
800 coincident([line3.end, line4.start])
801 coincident([line4.end, line1.start])
802 equalLength([line1, line2, line3, line4])
803}
804
805tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 4)
806
807first = subtract(target, tools = [tool])
808second = subtract(first, tools = [tool])
809"#;
810
811 let ctx = crate::ExecutorContext::new_mock(None).await;
812 let program = crate::Program::parse_no_errs(code).unwrap();
813 let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
814 ctx.close().await;
815
816 assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
817 let message = err.error.message();
818 assert!(
819 message.contains("`tool` was already consumed by a `subtract` operation"),
820 "{message}"
821 );
822 assert!(message.contains("can no longer be used"), "{message}");
823 }
824
825 #[tokio::test(flavor = "multi_thread")]
826 async fn union_reusing_consumed_solid_reports_kcl_error() {
827 let code = r#"
828leftSketch = sketch(on = XY) {
829 line1 = line(start = [var -10, var -10], end = [var -2, var -10])
830 line2 = line(start = [var -2, var -10], end = [var -2, var -2])
831 line3 = line(start = [var -2, var -2], end = [var -10, var -2])
832 line4 = line(start = [var -10, var -2], end = [var -10, var -10])
833 coincident([line1.end, line2.start])
834 coincident([line2.end, line3.start])
835 coincident([line3.end, line4.start])
836 coincident([line4.end, line1.start])
837 equalLength([line1, line2, line3, line4])
838}
839
840left = extrude(region(point = [-6, -6], sketch = leftSketch), length = 8)
841
842rightSketch = sketch(on = XY) {
843 line1 = line(start = [var -2, var -2], end = [var 6, var -2])
844 line2 = line(start = [var 6, var -2], end = [var 6, var 6])
845 line3 = line(start = [var 6, var 6], end = [var -2, var 6])
846 line4 = line(start = [var -2, var 6], end = [var -2, var -2])
847 coincident([line1.end, line2.start])
848 coincident([line2.end, line3.start])
849 coincident([line3.end, line4.start])
850 coincident([line4.end, line1.start])
851 equalLength([line1, line2, line3, line4])
852}
853
854right = extrude(region(point = [2, 2], sketch = rightSketch), length = 8)
855
856toolSketch = sketch(on = XY) {
857 line1 = line(start = [var -1, var -1], end = [var 1, var -1])
858 line2 = line(start = [var 1, var -1], end = [var 1, var 1])
859 line3 = line(start = [var 1, var 1], end = [var -1, var 1])
860 line4 = line(start = [var -1, var 1], end = [var -1, var -1])
861 coincident([line1.end, line2.start])
862 coincident([line2.end, line3.start])
863 coincident([line3.end, line4.start])
864 coincident([line4.end, line1.start])
865 equalLength([line1, line2, line3, line4])
866}
867
868tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 2)
869
870first = union([left, right])
871second = union([first, tool])
872third = subtract(left, tools = [tool])
873"#;
874
875 let ctx = crate::ExecutorContext::new_mock(None).await;
876 let program = crate::Program::parse_no_errs(code).unwrap();
877 let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
878 ctx.close().await;
879
880 assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
881 let message = err.error.message();
882 assert!(
883 message.contains("`left` was already consumed by a `union` operation"),
884 "{message}"
885 );
886 assert!(
887 message.contains("The operation result is now in `second`; use that for subsequent operations"),
888 "{message}"
889 );
890 }
891
892 #[tokio::test(flavor = "multi_thread")]
893 async fn intersect_reusing_consumed_solid_reports_kcl_error() {
894 let code = r#"
895leftSketch = sketch(on = XY) {
896 line1 = line(start = [var -10, var -10], end = [var 4, var -10])
897 line2 = line(start = [var 4, var -10], end = [var 4, var 4])
898 line3 = line(start = [var 4, var 4], end = [var -10, var 4])
899 line4 = line(start = [var -10, var 4], end = [var -10, var -10])
900 coincident([line1.end, line2.start])
901 coincident([line2.end, line3.start])
902 coincident([line3.end, line4.start])
903 coincident([line4.end, line1.start])
904 equalLength([line1, line2, line3, line4])
905}
906
907left = extrude(region(point = [-3, -3], sketch = leftSketch), length = 8)
908
909rightSketch = sketch(on = XY) {
910 line1 = line(start = [var -4, var -4], end = [var 10, var -4])
911 line2 = line(start = [var 10, var -4], end = [var 10, var 10])
912 line3 = line(start = [var 10, var 10], end = [var -4, var 10])
913 line4 = line(start = [var -4, var 10], end = [var -4, var -4])
914 coincident([line1.end, line2.start])
915 coincident([line2.end, line3.start])
916 coincident([line3.end, line4.start])
917 coincident([line4.end, line1.start])
918 equalLength([line1, line2, line3, line4])
919}
920
921right = extrude(region(point = [3, 3], sketch = rightSketch), length = 8)
922
923toolSketch = sketch(on = XY) {
924 line1 = line(start = [var -1, var -1], end = [var 1, var -1])
925 line2 = line(start = [var 1, var -1], end = [var 1, var 1])
926 line3 = line(start = [var 1, var 1], end = [var -1, var 1])
927 line4 = line(start = [var -1, var 1], end = [var -1, var -1])
928 coincident([line1.end, line2.start])
929 coincident([line2.end, line3.start])
930 coincident([line3.end, line4.start])
931 coincident([line4.end, line1.start])
932 equalLength([line1, line2, line3, line4])
933}
934
935tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 2)
936
937first = intersect([left, right])
938second = subtract(left, tools = [tool])
939"#;
940
941 let ctx = crate::ExecutorContext::new_mock(None).await;
942 let program = crate::Program::parse_no_errs(code).unwrap();
943 let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
944 ctx.close().await;
945
946 assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
947 let message = err.error.message();
948 assert!(
949 message.contains("`left` was already consumed by an `intersect` operation"),
950 "{message}"
951 );
952 assert!(
953 message.contains("The operation result is now in `first`; use that for subsequent operations"),
954 "{message}"
955 );
956 }
957
958 #[tokio::test(flavor = "multi_thread")]
959 async fn split_keep_tools_does_not_consume_tools() {
960 let code = r#"
961targetSketch = sketch(on = XY) {
962 line1 = line(start = [var -10, var -10], end = [var 10, var -10])
963 line2 = line(start = [var 10, var -10], end = [var 10, var 10])
964 line3 = line(start = [var 10, var 10], end = [var -10, var 10])
965 line4 = line(start = [var -10, var 10], end = [var -10, var -10])
966 coincident([line1.end, line2.start])
967 coincident([line2.end, line3.start])
968 coincident([line3.end, line4.start])
969 coincident([line4.end, line1.start])
970 equalLength([line1, line2, line3, line4])
971}
972
973target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
974
975toolSketch = sketch(on = XY) {
976 line1 = line(start = [var -2, var -10], end = [var 2, var -10])
977 line2 = line(start = [var 2, var -10], end = [var 2, var 10])
978 line3 = line(start = [var 2, var 10], end = [var -2, var 10])
979 line4 = line(start = [var -2, var 10], end = [var -2, var -10])
980 coincident([line1.end, line2.start])
981 coincident([line2.end, line3.start])
982 coincident([line3.end, line4.start])
983 coincident([line4.end, line1.start])
984}
985
986tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 20)
987
988first = split(target, tools = [tool], keepTools = true)
989second = subtract(first, tools = [tool])
990"#;
991
992 let ctx = crate::ExecutorContext::new_mock(None).await;
993 let program = crate::Program::parse_no_errs(code).unwrap();
994 let outcome = ctx.run_mock(&program, &MockConfig::default()).await.unwrap();
995 ctx.close().await;
996
997 assert!(outcome.variables.contains_key("second"));
998 }
999
1000 #[tokio::test(flavor = "multi_thread")]
1001 async fn split_without_keep_tools_consumes_tools() {
1002 let code = r#"
1003targetSketch = sketch(on = XY) {
1004 line1 = line(start = [var -10, var -10], end = [var 10, var -10])
1005 line2 = line(start = [var 10, var -10], end = [var 10, var 10])
1006 line3 = line(start = [var 10, var 10], end = [var -10, var 10])
1007 line4 = line(start = [var -10, var 10], end = [var -10, var -10])
1008 coincident([line1.end, line2.start])
1009 coincident([line2.end, line3.start])
1010 coincident([line3.end, line4.start])
1011 coincident([line4.end, line1.start])
1012 equalLength([line1, line2, line3, line4])
1013}
1014
1015target = extrude(region(point = [0, 0], sketch = targetSketch), length = 20)
1016
1017toolSketch = sketch(on = XY) {
1018 line1 = line(start = [var -2, var -10], end = [var 2, var -10])
1019 line2 = line(start = [var 2, var -10], end = [var 2, var 10])
1020 line3 = line(start = [var 2, var 10], end = [var -2, var 10])
1021 line4 = line(start = [var -2, var 10], end = [var -2, var -10])
1022 coincident([line1.end, line2.start])
1023 coincident([line2.end, line3.start])
1024 coincident([line3.end, line4.start])
1025 coincident([line4.end, line1.start])
1026}
1027
1028tool = extrude(region(point = [0, 0], sketch = toolSketch), length = 20)
1029
1030first = split(target, tools = [tool])
1031second = subtract(first, tools = [tool])
1032"#;
1033
1034 let ctx = crate::ExecutorContext::new_mock(None).await;
1035 let program = crate::Program::parse_no_errs(code).unwrap();
1036 let err = ctx.run_mock(&program, &MockConfig::default()).await.unwrap_err();
1037 ctx.close().await;
1038
1039 assert!(matches!(&err.error, KclError::Semantic { .. }), "{:?}", err.error);
1040 let message = err.error.message();
1041 assert!(
1042 message.contains("`tool` was already consumed by a `split` operation"),
1043 "{message}"
1044 );
1045 assert!(message.contains("can no longer be used"), "{message}");
1046 }
1047}