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