Skip to main content

kcl_lib/std/
csg.rs

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