1use std::collections::BTreeMap;
40
41use crate::feature_pipeline::features::common::{collect_edge_names, collect_face_names};
42use crate::feature_pipeline::{AddedSolid, FeatureDescriptor, SceneMap};
43use crate::{transform_brep, AffineTransform, BrepSolid};
44
45#[derive(Debug, Clone)]
55pub struct ComponentRecord {
56 pub id: String,
58 pub part_name: String,
60 pub transform: AffineTransform,
64 pub fixed: bool,
66 pub source: serde_json::Value,
69 pub solids: Vec<String>,
71}
72
73pub type ComponentMap = BTreeMap<String, ComponentRecord>;
76
77pub fn namespaced(component_id: &str, name: &str) -> String {
83 format!("{component_id}:{name}")
84}
85
86fn namespace_solid_names(solid: &mut BrepSolid, component_id: &str) {
91 for shell in &mut solid.shells {
92 for face in &mut shell.faces {
93 if let Some(name) = &face.name {
94 face.name = Some(namespaced(component_id, name));
95 }
96 }
97 }
98 for edge in &mut solid.edges {
99 if let Some(name) = &edge.name {
100 edge.name = Some(namespaced(component_id, name));
101 }
102 }
103}
104
105fn validate_component_id(id: &str) -> Result<(), String> {
108 if id.is_empty() {
109 return Err("component id must not be empty".into());
110 }
111 if id.contains(':') || id.contains('|') {
112 return Err(format!("component id '{id}' must not contain ':' or '|'"));
113 }
114 Ok(())
115}
116
117fn require_rigid(transform: &AffineTransform) -> Result<(), String> {
125 let m = &transform.elements;
126 let rows = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
127 for i in 0..3 {
128 for j in i..3 {
129 let dot: f64 = (0..3).map(|k| rows[i][k] * rows[j][k]).sum();
130 let expected = if i == j { 1.0 } else { 0.0 };
131 if (dot - expected).abs() > 1e-8 {
132 return Err("component transform must be rigid (rotation + translation)".into());
133 }
134 }
135 }
136 if (transform.determinant3() - 1.0).abs() > 1e-8 {
137 return Err("component transform must be rigid (no reflection/scale)".into());
138 }
139 Ok(())
140}
141
142fn compose(a: &AffineTransform, b: &AffineTransform) -> Result<AffineTransform, String> {
144 let (ma, mb) = (&a.elements, &b.elements);
145 let mut out = [0.0f64; 16];
146 for row in 0..4 {
147 for col in 0..4 {
148 out[row * 4 + col] = (0..4)
149 .map(|k| ma[row * 4 + k] * mb[k * 4 + col])
150 .sum();
151 }
152 }
153 AffineTransform::new(out)
154}
155
156fn rigid_inverse(transform: &AffineTransform) -> Result<AffineTransform, String> {
158 let m = &transform.elements;
159 let r = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
160 let t = [m[3], m[7], m[11]];
161 let mut out = [0.0f64; 16];
162 for row in 0..3 {
163 for col in 0..3 {
164 out[row * 4 + col] = r[col][row]; }
166 out[row * 4 + 3] = -(0..3).map(|k| r[k][row] * t[k]).sum::<f64>();
167 }
168 out[15] = 1.0;
169 AffineTransform::new(out)
170}
171
172#[allow(dead_code)]
189pub fn create_component(
190 id: &str,
191 part_name: &str,
192 fixed: bool,
193 source: serde_json::Value,
194 transform: AffineTransform,
195 members: Vec<(String, BrepSolid)>,
196) -> Result<(ComponentRecord, Vec<AddedSolid>), String> {
197 validate_component_id(id)?;
198 require_rigid(&transform)?;
199
200 let mut posed: Vec<(String, BrepSolid)> = Vec::with_capacity(members.len());
202 for (member_name, solid) in &members {
203 let mut body = transform_brep(solid, transform, false)
204 .map_err(|error| format!("component '{id}': member '{member_name}': {error}"))?;
205 namespace_solid_names(&mut body, id);
206 posed.push((namespaced(id, member_name), body));
207 }
208
209 let mut added = Vec::with_capacity(posed.len());
211 let mut solids = Vec::with_capacity(posed.len());
212 for (name, body) in posed {
213 let face_names = collect_face_names(&body);
214 let edge_names = collect_edge_names(&body);
215 let handle = crate::register_solid_value(body);
216 solids.push(name.clone());
217 added.push(AddedSolid {
218 handle,
219 name,
220 face_names,
221 edge_names,
222 ..AddedSolid::default()
223 });
224 }
225
226 Ok((
227 ComponentRecord {
228 id: id.to_string(),
229 part_name: part_name.to_string(),
230 transform,
231 fixed,
232 source,
233 solids,
234 },
235 added,
236 ))
237}
238
239#[allow(dead_code)]
255pub fn update_component_transform(
256 scene: &mut SceneMap,
257 id: &str,
258 new_transform: AffineTransform,
259) -> Result<(), String> {
260 require_rigid(&new_transform)?;
261 let record = scene
262 .components
263 .get(id)
264 .ok_or_else(|| format!("unknown component '{id}'"))?;
265 let delta = compose(&new_transform, &rigid_inverse(&record.transform)?)?;
266
267 let mut posed: Vec<(u32, BrepSolid)> = Vec::with_capacity(record.solids.len());
269 for name in &record.solids {
270 let handle = scene
271 .solids
272 .get(name)
273 .copied()
274 .ok_or_else(|| format!("component '{id}': member '{name}' is not scene-resident"))?;
275 let body = crate::with_registered_solid_str(handle, |solid| {
276 transform_brep(solid, delta, false)
277 })
278 .map_err(|error| format!("component '{id}': member '{name}': {error}"))?;
279 posed.push((handle, body));
280 }
281
282 for (handle, body) in posed {
284 crate::replace_registered_solid(handle, body)?;
285 }
286 scene
287 .components
288 .get_mut(id)
289 .expect("record fetched above")
290 .transform = new_transform;
291 Ok(())
292}
293
294pub fn reject_component_references<'a>(
300 scene: &SceneMap,
301 names: impl IntoIterator<Item = &'a str>,
302) -> Result<(), String> {
303 for name in names {
304 if let Some(record) = scene.owning_component(name) {
305 return Err(format!(
306 "'{name}' belongs to assembly component '{}' — modeling features cannot consume component geometry",
307 record.id
308 ));
309 }
310 }
311 Ok(())
312}
313
314pub fn enforce_reference_fence(
329 feature_type: &str,
330 descriptor: &FeatureDescriptor,
331 scene: &SceneMap,
332) -> Result<(), String> {
333 if scene.components.is_empty() {
336 return Ok(());
337 }
338 if matches!(
341 feature_type,
342 "S" | "SKETCH" | "D" | "DATUM" | "DATIUM" | "P" | "PLANE" | "ACOMP" | "ASSEMBLY COMPONENT"
343 ) {
344 return Ok(());
345 }
346 if let Some(name) = first_component_owned(&descriptor.input_params, scene)
347 .or_else(|| first_component_owned(&descriptor.persistent_data, scene))
348 {
349 return reject_component_references(scene, [name]);
350 }
351 Ok(())
352}
353
354fn first_component_owned<'a>(
357 value: &'a serde_json::Value,
358 scene: &SceneMap,
359) -> Option<&'a str> {
360 match value {
361 serde_json::Value::String(text) => {
362 let trimmed = text.trim();
363 (!trimmed.is_empty() && scene.is_component_owned(trimmed)).then_some(trimmed)
364 }
365 serde_json::Value::Array(items) => {
366 items.iter().find_map(|item| first_component_owned(item, scene))
367 }
368 serde_json::Value::Object(map) => {
369 map.values().find_map(|item| first_component_owned(item, scene))
370 }
371 _ => None,
372 }
373}
374
375impl SceneMap {
380 #[allow(dead_code)]
383 pub fn resolve_component(&self, id: &str) -> Option<&ComponentRecord> {
384 self.components.get(id)
385 }
386
387 pub fn owning_component(&self, name: &str) -> Option<&ComponentRecord> {
394 if let Some(record) = self.components.get(name) {
395 return Some(record);
396 }
397 let (prefix, _) = name.split_once(':')?;
398 self.components.get(prefix)
399 }
400
401 pub fn is_component_owned(&self, name: &str) -> bool {
406 self.owning_component(name).is_some()
407 }
408
409 #[allow(dead_code)]
412 pub fn iter_components(&self) -> impl Iterator<Item = &ComponentRecord> {
413 self.components.values()
414 }
415
416 #[allow(dead_code)]
420 pub fn component_solids(&self, id: &str) -> Vec<(String, u32)> {
421 let Some(record) = self.components.get(id) else {
422 return Vec::new();
423 };
424 record
425 .solids
426 .iter()
427 .filter_map(|name| self.solids.get(name).map(|&handle| (name.clone(), handle)))
428 .collect()
429 }
430
431 #[allow(dead_code)]
433 pub fn component_fixed(&self, id: &str) -> Option<bool> {
434 self.components.get(id).map(|record| record.fixed)
435 }
436
437 #[allow(dead_code)]
442 pub fn set_component_fixed(&mut self, id: &str, fixed: bool) -> bool {
443 match self.components.get_mut(id) {
444 Some(record) => {
445 record.fixed = fixed;
446 true
447 }
448 None => false,
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::feature_pipeline::{
457 clear_history_cache, execute_history, FeatureResult, HistoryRequest,
458 };
459
460 fn run(request: serde_json::Value) -> crate::feature_pipeline::HistoryResult {
461 let request: HistoryRequest =
462 serde_json::from_value(request).expect("request deserializes");
463 clear_history_cache();
464 execute_history(&request)
465 }
466
467 fn cube_descriptor(id: &str, sx: f64, sy: f64, sz: f64) -> serde_json::Value {
468 serde_json::json!({
469 "type": "P.CU",
470 "inputParams": { "id": id, "sizeX": sx, "sizeY": sy, "sizeZ": sz },
471 "persistentData": {}
472 })
473 }
474
475 fn source_part(id: &str) -> BrepSolid {
479 let history = run(serde_json::json!({
480 "features": [ cube_descriptor(id, 2.0, 3.0, 4.0) ]
481 }));
482 let handle = history.results[0].added[0].handle;
483 let solid = crate::with_registered_solid_str(handle, |solid| Ok(solid.clone()))
484 .expect("resident source solid");
485 clear_history_cache();
486 solid
487 }
488
489 fn identity() -> AffineTransform {
490 AffineTransform::new([
491 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
495 ])
496 .unwrap()
497 }
498
499 fn translation(x: f64, y: f64, z: f64) -> AffineTransform {
500 AffineTransform::new([
501 1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, y, 0.0, 0.0, 1.0, z, 0.0, 0.0, 0.0, 1.0,
505 ])
506 .unwrap()
507 }
508
509 fn all_names(added: &[AddedSolid]) -> Vec<String> {
511 let mut names = Vec::new();
512 for solid in added {
513 names.push(solid.name.clone());
514 names.extend(solid.face_names.iter().map(|(_, name)| name.clone()));
515 names.extend(solid.edge_names.iter().map(|(_, name)| name.clone()));
516 }
517 names
518 }
519
520 fn apply_component(scene: &mut SceneMap, record: ComponentRecord, added: Vec<AddedSolid>) {
522 let mut result = FeatureResult::empty(record.id.clone(), "ACOMP");
523 result.added = added;
524 result.components = vec![record];
525 scene.apply(&result);
526 }
527
528 fn free_added(added: &[AddedSolid]) {
529 for solid in added {
530 crate::free_registered_solid(solid.handle);
531 }
532 }
533
534 fn bbox(handle: u32) -> ([f64; 3], [f64; 3]) {
536 crate::with_registered_solid_str(handle, |solid| {
537 let mut lo = [f64::INFINITY; 3];
538 let mut hi = [f64::NEG_INFINITY; 3];
539 for vertex in &solid.vertices {
540 let p = [vertex.point.x, vertex.point.y, vertex.point.z];
541 for k in 0..3 {
542 lo[k] = lo[k].min(p[k]);
543 hi[k] = hi[k].max(p[k]);
544 }
545 }
546 Ok((lo, hi))
547 })
548 .expect("resident solid bbox")
549 }
550
551 #[test]
555 fn two_instances_of_one_part_never_collide_and_names_are_stable() {
556 let start = crate::registered_solid_count();
557 let part = source_part("Part");
558
559 let (record1, added1) = create_component(
560 "ACOMP1",
561 "bracket",
562 true,
563 serde_json::json!({ "sourceKey": "bracket" }),
564 identity(),
565 vec![("Part".into(), part.clone())],
566 )
567 .expect("component 1");
568 let (record2, added2) = create_component(
569 "ACOMP2",
570 "bracket",
571 false,
572 serde_json::json!({ "sourceKey": "bracket" }),
573 translation(10.0, 0.0, 0.0),
574 vec![("Part".into(), part.clone())],
575 )
576 .expect("component 2");
577
578 assert_eq!(added1[0].name, "ACOMP1:Part");
580 assert_eq!(added2[0].name, "ACOMP2:Part");
581 let faces1: Vec<&str> = added1[0].face_names.iter().map(|(_, n)| n.as_str()).collect();
582 assert_eq!(
583 faces1,
584 vec![
585 "ACOMP1:Part_NZ", "ACOMP1:Part_PZ", "ACOMP1:Part_NY",
586 "ACOMP1:Part_PY", "ACOMP1:Part_NX", "ACOMP1:Part_PX",
587 ],
588 );
589 assert!(
592 added1[0]
593 .edge_names
594 .iter()
595 .all(|(_, name)| name.starts_with("ACOMP1:") && name.contains('|')),
596 "edge names: {:?}",
597 added1[0].edge_names
598 );
599 assert_eq!(added1[0].edge_names.len(), 12, "cube edge count");
600
601 let names1 = all_names(&added1);
603 let names2 = all_names(&added2);
604 assert!(
605 names1.iter().all(|name| !names2.contains(name)),
606 "instances must not share any name"
607 );
608
609 let (record1b, added1b) = create_component(
611 "ACOMP1",
612 "bracket",
613 true,
614 serde_json::json!({ "sourceKey": "bracket" }),
615 identity(),
616 vec![("Part".into(), part.clone())],
617 )
618 .expect("component 1 rebuild");
619 assert_eq!(all_names(&added1b), names1, "names stable across rebuilds");
620 assert_eq!(record1b.solids, record1.solids);
621
622 assert_eq!(record1.id, "ACOMP1");
623 assert_eq!(record1.part_name, "bracket");
624 assert!(record1.fixed);
625 assert!(!record2.fixed);
626
627 free_added(&added1);
628 free_added(&added2);
629 free_added(&added1b);
630 assert_eq!(crate::registered_solid_count(), start, "no handle leak");
631 }
632
633 #[test]
636 fn scene_ingestion_owning_lookup_fence_and_removal() {
637 let start = crate::registered_solid_count();
638 let part = source_part("Part");
639 let (record1, added1) = create_component(
640 "ACOMP1", "bracket", false, serde_json::Value::Null,
641 identity(), vec![("Part".into(), part.clone())],
642 )
643 .expect("component 1");
644 let (record2, added2) = create_component(
645 "ACOMP2", "bracket", true, serde_json::Value::Null,
646 translation(10.0, 0.0, 0.0), vec![("Part".into(), part.clone())],
647 )
648 .expect("component 2");
649 let handle1 = added1[0].handle;
650 let handle2 = added2[0].handle;
651
652 let mut scene = SceneMap::default();
653 apply_component(&mut scene, record1, added1);
654 apply_component(&mut scene, record2, added2);
655
656 assert_eq!(scene.resolve_solid("ACOMP1:Part"), Some(handle1));
658 assert!(scene.resolve_face("ACOMP2:Part_PZ").is_some());
659
660 assert_eq!(scene.owning_component("ACOMP1").unwrap().id, "ACOMP1");
662 assert_eq!(scene.owning_component("ACOMP1:Part").unwrap().id, "ACOMP1");
663 assert_eq!(scene.owning_component("ACOMP2:Part_PZ").unwrap().id, "ACOMP2");
664 let edge_name = scene
665 .resolve_solid("ACOMP1:Part")
666 .and_then(|_| {
667 scene.edges.keys().find(|name| name.starts_with("ACOMP1:")).cloned()
668 })
669 .expect("a namespaced edge");
670 assert_eq!(scene.owning_component(&edge_name).unwrap().id, "ACOMP1");
671
672 assert!(scene.is_component_owned("ACOMP1"));
674 assert!(scene.is_component_owned("ACOMP2:Part_NX"));
675 assert!(!scene.is_component_owned("Part_NX"));
676 assert!(!scene.is_component_owned("S1:PROFILE"));
677 assert!(reject_component_references(&scene, ["Part_NX"]).is_ok());
678 let rejected = reject_component_references(&scene, ["Part_NX", "ACOMP2:Part_NX"]);
679 assert!(
680 rejected.as_ref().is_err_and(|error| error.contains("ACOMP2")),
681 "fence must name the owning component: {rejected:?}"
682 );
683
684 let ids: Vec<&str> = scene.iter_components().map(|r| r.id.as_str()).collect();
686 assert_eq!(ids, vec!["ACOMP1", "ACOMP2"]);
687 assert_eq!(
688 scene.component_solids("ACOMP1"),
689 vec![("ACOMP1:Part".to_string(), handle1)]
690 );
691 assert_eq!(scene.component_fixed("ACOMP1"), Some(false));
692 assert_eq!(scene.component_fixed("ACOMP2"), Some(true));
693 assert!(scene.set_component_fixed("ACOMP1", true));
694 assert_eq!(scene.component_fixed("ACOMP1"), Some(true));
695 assert!(!scene.set_component_fixed("ghost", true));
696
697 let mut removal = FeatureResult::empty("ACOMP1", "ACOMP");
700 removal.removed = vec!["ACOMP1".to_string()];
701 scene.apply(&removal);
702 assert!(scene.resolve_component("ACOMP1").is_none());
703 assert!(scene.resolve_solid("ACOMP1:Part").is_none());
704 assert!(scene.resolve_face("ACOMP1:Part_PZ").is_none());
705 assert!(!scene.is_component_owned("ACOMP1:Part"));
706 assert_eq!(scene.resolve_solid("ACOMP2:Part"), Some(handle2));
707
708 crate::free_registered_solid(handle1);
709 crate::free_registered_solid(handle2);
710 assert_eq!(crate::registered_solid_count(), start, "no handle leak");
711 }
712
713 #[test]
716 fn transform_update_reposes_member_geometry_absolutely() {
717 let start = crate::registered_solid_count();
718 let part = source_part("Part"); let (record, added) = create_component(
720 "ACOMP1", "bracket", false, serde_json::Value::Null,
721 identity(), vec![("Part".into(), part)],
722 )
723 .expect("component");
724 let handle = added[0].handle;
725 let pz_face = added[0]
726 .face_names
727 .iter()
728 .find(|(_, name)| name == "ACOMP1:Part_PZ")
729 .map(|(id, _)| *id)
730 .expect("_PZ face");
731
732 let mut scene = SceneMap::default();
733 apply_component(&mut scene, record, added);
734
735 update_component_transform(&mut scene, "ACOMP1", translation(5.0, 0.0, 0.0))
736 .expect("re-pose");
737 let (lo, hi) = bbox(handle);
738 assert!((lo[0] - 5.0).abs() < 1e-9 && (hi[0] - 7.0).abs() < 1e-9, "x {lo:?}..{hi:?}");
739
740 update_component_transform(&mut scene, "ACOMP1", translation(2.0, 0.0, 1.0))
742 .expect("second re-pose");
743 let (lo, hi) = bbox(handle);
744 assert!((lo[0] - 2.0).abs() < 1e-9 && (hi[0] - 4.0).abs() < 1e-9, "x {lo:?}..{hi:?}");
745 assert!((lo[2] - 1.0).abs() < 1e-9 && (hi[2] - 5.0).abs() < 1e-9, "z {lo:?}..{hi:?}");
746
747 let face = scene.resolve_face("ACOMP1:Part_PZ").expect("face still mapped");
750 assert_eq!(face.handle, handle);
751 assert_eq!(face.face_id, pz_face);
752 let posed = scene.resolve_component("ACOMP1").unwrap().transform.elements;
753 assert_eq!([posed[3], posed[7], posed[11]], [2.0, 0.0, 1.0]);
754
755 assert!(update_component_transform(&mut scene, "ghost", identity()).is_err());
757 let scaled = AffineTransform::new([
758 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0,
762 ])
763 .unwrap();
764 assert!(update_component_transform(&mut scene, "ACOMP1", scaled).is_err());
765
766 crate::free_registered_solid(handle);
767 assert_eq!(crate::registered_solid_count(), start, "no handle leak");
768 }
769
770 #[test]
773 fn nested_component_prefixes_chain() {
774 let start = crate::registered_solid_count();
775 let part = source_part("Part");
776 let (_, inner_added) = create_component(
778 "ACOMP1", "bracket", false, serde_json::Value::Null,
779 translation(1.0, 0.0, 0.0), vec![("Part".into(), part)],
780 )
781 .expect("inner component");
782 let inner_solid =
783 crate::with_registered_solid_str(inner_added[0].handle, |solid| Ok(solid.clone()))
784 .expect("inner resident");
785 let inner_name = inner_added[0].name.clone();
786 free_added(&inner_added);
787
788 let (record, added) = create_component(
790 "ACOMP3", "sub-assembly", false, serde_json::Value::Null,
791 identity(), vec![(inner_name, inner_solid)],
792 )
793 .expect("outer component");
794 assert_eq!(added[0].name, "ACOMP3:ACOMP1:Part");
795 assert!(added[0]
796 .face_names
797 .iter()
798 .any(|(_, name)| name == "ACOMP3:ACOMP1:Part_PZ"));
799
800 let mut scene = SceneMap::default();
802 let handle = added[0].handle;
803 apply_component(&mut scene, record, added);
804 assert_eq!(
805 scene.owning_component("ACOMP3:ACOMP1:Part_PZ").unwrap().id,
806 "ACOMP3"
807 );
808 assert!(scene.resolve_component("ACOMP1").is_none(), "inner id is not a scene component");
809
810 crate::free_registered_solid(handle);
811 assert_eq!(crate::registered_solid_count(), start, "no handle leak");
812 }
813
814 #[test]
816 fn create_component_rejects_bad_ids_and_non_rigid_transforms() {
817 let start = crate::registered_solid_count();
818 let part = source_part("Part");
819 for bad_id in ["", "A:B", "A|B"] {
820 assert!(
821 create_component(
822 bad_id, "x", false, serde_json::Value::Null,
823 identity(), vec![("Part".into(), part.clone())],
824 )
825 .is_err(),
826 "id '{bad_id}' must be rejected"
827 );
828 }
829 let scaled = AffineTransform::new([
830 1.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
834 ])
835 .unwrap();
836 assert!(
837 create_component(
838 "ACOMP1", "x", false, serde_json::Value::Null,
839 scaled, vec![("Part".into(), part)],
840 )
841 .is_err(),
842 "non-rigid transform must be rejected"
843 );
844 assert_eq!(crate::registered_solid_count(), start, "error paths leak nothing");
845 }
846
847 #[test]
851 fn componentless_scenes_keep_todays_names() {
852 let start = crate::registered_solid_count();
853 let history = run(serde_json::json!({
855 "features": [ cube_descriptor("cube1", 2.0, 3.0, 4.0) ]
856 }));
857 let added = &history.results[0].added[0];
858 let mut scene = SceneMap::default();
859 for result in &history.results {
860 scene.apply(result);
861 }
862 assert!(scene.components.is_empty(), "no components in a modeling scene");
863 let faces: Vec<&str> = added.face_names.iter().map(|(_, n)| n.as_str()).collect();
864 assert_eq!(
865 faces,
866 vec!["cube1_NZ", "cube1_PZ", "cube1_NY", "cube1_PY", "cube1_NX", "cube1_PX"],
867 );
868 let edges: Vec<&str> = added.edge_names.iter().map(|(_, n)| n.as_str()).collect();
869 assert_eq!(
870 edges,
871 vec![
872 "cube1_NX|cube1_NZ[0]", "cube1_NZ|cube1_PY[0]", "cube1_NZ|cube1_PX[0]",
873 "cube1_NY|cube1_NZ[0]", "cube1_NY|cube1_PZ[0]", "cube1_PX|cube1_PZ[0]",
874 "cube1_PY|cube1_PZ[0]", "cube1_NX|cube1_PZ[0]", "cube1_NY|cube1_PX[0]",
875 "cube1_NX|cube1_NY[0]", "cube1_NX|cube1_PY[0]", "cube1_PX|cube1_PY[0]",
876 ],
877 );
878 for name in all_names(std::slice::from_ref(added)) {
879 assert!(!scene.is_component_owned(&name), "'{name}' must be componentless");
880 }
881
882 let mut tool = cube_descriptor("B", 4.0, 4.0, 4.0);
884 tool["inputParams"]["boolean"] = serde_json::json!({
885 "operation": "SUBTRACT", "targets": ["A"], "mergeCoplanarFaces": true });
886 let history = run(serde_json::json!({
887 "features": [ cube_descriptor("A", 10.0, 10.0, 10.0), tool ]
888 }));
889 let cut = &history.results[1].added[0];
890 assert_eq!(cut.name, "A");
891 let mut cut_names = all_names(std::slice::from_ref(cut));
892 cut_names.sort();
893 assert_eq!(
894 cut_names,
895 vec![
896 "A", "A_NX", "A_NX|A_NY[0]", "A_NX|A_NZ[0]", "A_NX|A_PY[0]", "A_NX|A_PZ[0]",
897 "A_NX|B_PY[0]", "A_NX|B_PZ[0]", "A_NY", "A_NY|A_NZ[0]", "A_NY|A_PX[0]",
898 "A_NY|A_PZ[0]", "A_NY|B_PX[0]", "A_NY|B_PZ[0]", "A_NZ", "A_NZ|A_PX[0]",
899 "A_NZ|A_PY[0]", "A_NZ|B_PX[0]", "A_NZ|B_PY[0]", "A_PX", "A_PX|A_PY[0]",
900 "A_PX|A_PZ[0]", "A_PY", "A_PY|A_PZ[0]", "A_PZ", "B_PX", "B_PX|B_PY[0]",
901 "B_PX|B_PZ[0]", "B_PY", "B_PY|B_PZ[0]", "B_PZ",
902 ],
903 );
904
905 let mut scene = SceneMap::default();
906 for result in &history.results {
907 scene.apply(result);
908 }
909 for name in all_names(std::slice::from_ref(cut)) {
910 assert!(!scene.is_component_owned(&name), "'{name}' must be componentless");
911 }
912 clear_history_cache();
913 assert_eq!(crate::registered_solid_count(), start, "no handle leak");
914 }
915}