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::NewBodyKind;
23use crate::execution::Solid;
24use crate::execution::annotations;
25use crate::execution::types::RuntimeType;
26use crate::std::Args;
27use crate::std::patterns::GeometryTrait;
28
29/// Union two or more solids into a single solid.
30pub 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            // Preserve the first input's tag when multiple bodies use the same name.
101            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    // Flush the fillets for the solids.
129    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    // If we have more solids, set those as well.
169    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
185/// Intersect returns the shared volume between multiple solids, preserving only
186/// overlapping regions.
187pub 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    // Flush the fillets for the solids.
227    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    // If we have more solids, set those as well.
266    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
282/// Subtract removes tool solids from base solids, leaving the remaining material.
283pub 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        // Output N new bodies, where N is the number of input target bodies.
312        let new_solids = solids
313            .iter()
314            .enumerate()
315            .map(|(index, solid)| {
316                // The first ID is set by the user, subsequent IDs are not.
317                // This matches the usual production normal execution path.
318                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    // Flush the fillets for the solids and the tools.
337    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
397/// Split a target body into two parts: the part that overlaps with the tool, and the part that doesn't.
398pub 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    // Flush pending edge-cut operations for any solids consumed by imprint.
473    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    // If we have more solids, set those as well.
519    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}