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