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