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<'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            // Preserve the first input's tag when multiple bodies use the same name.
100            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    // Flush the fillets for the solids.
128    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    // If we have more solids, set those as well.
168    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
184/// Intersect returns the shared volume between multiple solids, preserving only
185/// overlapping regions.
186pub 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    // Flush the fillets for the solids.
226    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    // If we have more solids, set those as well.
265    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
281/// Subtract removes tool solids from base solids, leaving the remaining material.
282pub 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        // Output N new bodies, where N is the number of input target bodies.
311        let new_solids = solids
312            .iter()
313            .enumerate()
314            .map(|(index, solid)| {
315                // The first ID is set by the user, subsequent IDs are not.
316                // This matches the usual production normal execution path.
317                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    // Flush the fillets for the solids and the tools.
336    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
396/// Split a target body into two parts: the part that overlaps with the tool, and the part that doesn't.
397pub 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    // Flush pending edge-cut operations for any solids consumed by imprint.
472    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    // If we have more solids, set those as well.
518    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}