1use super::*;
2
3impl EngineState {
10 pub fn import_stl_feature(&mut self, bytes: &[u8]) -> Result<String, String> {
14 self.submit_mesh_import(crate::runner::MeshImportFormat::Stl, bytes.to_vec())
15 }
16
17 pub fn import_obj_feature(&mut self, text: &str) -> Result<String, String> {
19 self.import_obj_bytes_feature(text.as_bytes())
20 }
21
22 pub fn import_obj_bytes_feature(&mut self, bytes: &[u8]) -> Result<String, String> {
25 self.submit_mesh_import(crate::runner::MeshImportFormat::Obj, bytes.to_vec())
26 }
27
28 fn submit_mesh_import(
29 &mut self,
30 format: crate::runner::MeshImportFormat,
31 bytes: Vec<u8>,
32 ) -> Result<String, String> {
33 let id = self.submit_mesh_reconstruction(
34 format, bytes, Default::default(), MeshImportDestination::Document,
35 )?;
36 Ok(serde_json::json!({ "meshImport": "submitted", "id": id }).to_string())
37 }
38
39 pub fn reconstruct_mesh_preview(
42 &mut self,
43 format: crate::runner::MeshImportFormat,
44 bytes: Vec<u8>,
45 options: crate::runner::StlConversionOptions,
46 ) -> Result<u64, String> {
47 self.submit_mesh_reconstruction(format, bytes, options, MeshImportDestination::Preview)
48 }
49
50 pub fn take_mesh_preview(&mut self) -> Option<crate::runner::MeshImportReply> {
51 self.mesh_preview_results.pop_front()
52 }
53
54 fn submit_mesh_reconstruction(
55 &mut self,
56 format: crate::runner::MeshImportFormat,
57 bytes: Vec<u8>,
58 options: crate::runner::StlConversionOptions,
59 destination: MeshImportDestination,
60 ) -> Result<u64, String> {
61 if bytes.is_empty() {
62 return Err("mesh import failed: file is empty".into());
63 }
64 let id = self.next_mesh_import_id;
65 self.next_mesh_import_id = self.next_mesh_import_id.wrapping_add(1);
66 self.pending_mesh_imports.insert(id, destination);
67 self.runner.submit_mesh_import(crate::runner::MeshImportRequest {
68 id, format, bytes, options,
69 });
70 self.pump();
71 Ok(id)
72 }
73
74 pub fn import_step_feature(&mut self, step_text: &str) -> Result<String, String> {
81 if !step_text.contains("ISO-10303-21") {
82 return Err("not a STEP file (missing the ISO-10303-21 header)".into());
83 }
84 let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
85 let feature = serde_json::json!({
86 "type": "IMPORT3D",
87 "inputParams": { "id": id, "stepText": step_text },
88 "persistentData": {},
89 });
90 self.pending_fit = true;
94 self.add_feature(&feature.to_string())
95 }
96
97 pub fn export_step_text(&self) -> Result<String, String> {
104 let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
105 .map_err(|e| format!("export STEP: history request: {e}"))?;
106 let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
107 .into_iter()
108 .map(|(_, handle)| handle)
109 .collect();
110 if handles.is_empty() {
111 return Err("nothing to export: the model has no solids".into());
112 }
113 brep_kernel::export_step_handles(&handles, "Part", "MM", "")
114 }
115
116 fn flat_pattern_target_handle(&self) -> Result<u32, String> {
124 let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
125 .map_err(|e| format!("export flat pattern: history request: {e}"))?;
126 let sheet_metal: Vec<(String, u32)> = crate::pipeline::resident_solid_handles(&request)
127 .into_iter()
128 .filter(|(_, handle)| brep_kernel::is_sheet_metal_handle(*handle))
129 .collect();
130 if sheet_metal.is_empty() {
131 return Err("no sheet-metal body in the part".into());
132 }
133 let selected: Vec<u32> = sheet_metal
135 .iter()
136 .filter(|(name, _)| self.emphasis.selected_solids.contains(name))
137 .map(|(_, handle)| *handle)
138 .collect();
139 if let [handle] = selected.as_slice() {
140 return Ok(*handle);
141 }
142 match sheet_metal.as_slice() {
143 [(_, handle)] => Ok(*handle),
144 _ => Err(
145 "several sheet-metal bodies in the part — select the one to export".into(),
146 ),
147 }
148 }
149
150 pub fn export_flat_pattern_dxf(&self) -> Result<String, String> {
155 brep_kernel::flat_pattern_dxf(self.flat_pattern_target_handle()?)
156 }
157
158 pub fn export_flat_pattern_svg(&self) -> Result<String, String> {
161 brep_kernel::flat_pattern_svg(self.flat_pattern_target_handle()?)
162 }
163
164 pub fn import_iges_feature(&mut self, iges_text: &str) -> Result<String, String> {
170 if iges_text.contains("ISO-10303-21") {
171 return Err("not an IGES file (this looks like a STEP document)".into());
172 }
173 let looks_like_iges = iges_text.lines().any(|line| {
175 matches!(line.chars().nth(72), Some('S' | 'G' | 'D' | 'P' | 'T'))
176 });
177 if !looks_like_iges {
178 return Err("not an IGES file (no S/G/D/P/T section records found)".into());
179 }
180 let id = self.next_feature_id(&crate::features::feature_short_name("IMPORT3D"));
181 let feature = serde_json::json!({
182 "type": "IMPORT3D",
183 "inputParams": { "id": id, "igesText": iges_text },
184 "persistentData": {},
185 });
186 self.pending_fit = true;
189 self.add_feature(&feature.to_string())
190 }
191
192 pub fn export_iges_text(&self) -> Result<String, String> {
196 let request: HistoryRequest = serde_json::from_value(self.history.prefix_request())
197 .map_err(|e| format!("export IGES: history request: {e}"))?;
198 let handles: Vec<u32> = crate::pipeline::resident_solid_handles(&request)
199 .into_iter()
200 .map(|(_, handle)| handle)
201 .collect();
202 if handles.is_empty() {
203 return Err("nothing to export: the model has no solids".into());
204 }
205 brep_kernel::export_iges_handles(&handles, "Part", "MM", "")
206 }
207
208 pub fn export_stl_text(&self) -> Result<String, String> {
215 let mut out = String::from("solid brep\n");
216 let mut triangles = 0usize;
217 for solid in self.scene.solids() {
218 let positions = &solid.mesh.positions;
219 for tri in solid.mesh.indices.chunks_exact(3) {
220 let a = positions[tri[0] as usize];
221 let b = positions[tri[1] as usize];
222 let c = positions[tri[2] as usize];
223 let normal = triangle_normal(a, b, c);
224 out.push_str(&format!(
225 " facet normal {} {} {}\n outer loop\n",
226 normal[0], normal[1], normal[2]
227 ));
228 for v in [a, b, c] {
229 out.push_str(&format!(" vertex {} {} {}\n", v[0], v[1], v[2]));
230 }
231 out.push_str(" endloop\n endfacet\n");
232 triangles += 1;
233 }
234 }
235 out.push_str("endsolid brep\n");
236 if triangles == 0 {
237 return Err("nothing to export: the scene has no triangles".into());
238 }
239 Ok(out)
240 }
241}
242
243fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
246 let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
247 let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
248 let n = [
249 u[1] * v[2] - u[2] * v[1],
250 u[2] * v[0] - u[0] * v[2],
251 u[0] * v[1] - u[1] * v[0],
252 ];
253 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
254 if len > 0.0 {
255 [n[0] / len, n[1] / len, n[2] / len]
256 } else {
257 [0.0, 0.0, 0.0]
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub struct StepAssemblyProbe {
333 pub parts: usize,
336 pub instances: usize,
338 pub nested_depth: usize,
342}
343
344#[derive(Debug, Clone, Copy, Default)]
346pub struct StepAssemblyImport {
347 pub nested: bool,
354}
355
356#[derive(Debug, Clone, Default, PartialEq, Eq)]
358pub struct StepAssemblyReport {
359 pub parts: usize,
364 pub instances: usize,
368 pub baked_nonrigid: usize,
371 pub failed_products: usize,
376 pub first_error: Option<String>,
379 pub flat_fallback: bool,
385}
386
387enum Consumed {
391 Imported(StepAssemblyReport),
392 NoComponents {
395 failed_products: usize,
396 first_error: Option<String>,
397 },
398}
399
400type Mat4 = [f64; 16];
403
404struct PlacedProduct {
406 product: usize,
408 world: Mat4,
410 depth: usize,
412 rigid_path: bool,
415}
416
417type PartKey = (usize, [u64; 9]);
422
423const NO_FACTOR: [u64; 9] = [0; 9];
425
426impl EngineState {
427 pub fn probe_step_assembly(
441 &mut self,
442 step_text: &str,
443 ) -> Result<Option<StepAssemblyProbe>, String> {
444 let id = self.submit_step_probe(step_text);
445 match self.take_step_probe() {
448 Some((answered, outcome)) if answered == id => match outcome {
449 super::StepProbeOutcome::Structure(probe) => Ok(Some(probe)),
450 super::StepProbeOutcome::Flat => Ok(None),
451 super::StepProbeOutcome::Failed(error) => Err(error),
452 },
453 _ => Err(
454 "the STEP probe is still running on the background runner — use \
455 submit_step_probe / take_step_probe"
456 .into(),
457 ),
458 }
459 }
460
461 pub fn submit_step_probe(&mut self, step_text: &str) -> u64 {
477 self.pending_step_assembly = None;
478 let id = self.next_step_probe_id;
479 self.next_step_probe_id = self.next_step_probe_id.wrapping_add(1);
480 if !step_text.contains("NEXT_ASSEMBLY_USAGE_OCCURRENCE") {
481 self.step_probe_results
482 .push_back((id, super::StepProbeOutcome::Flat));
483 return id;
484 }
485 self.pending_step_probes.insert(id);
486 self.runner.submit_step_probe(crate::runner::StepProbeRequest {
487 id,
488 text: step_text.to_string(),
489 });
490 self.pump();
491 id
492 }
493
494 pub fn take_step_probe(&mut self) -> Option<(u64, super::StepProbeOutcome)> {
496 self.step_probe_results.pop_front()
497 }
498
499 pub fn step_probes_pending(&self) -> bool {
502 !self.pending_step_probes.is_empty()
503 }
504
505 pub fn import_probed_step_assembly(
519 &mut self,
520 doc_name: &str,
521 opts: StepAssemblyImport,
522 sink: &mut dyn PartSink,
523 ) -> Result<StepAssemblyReport, String> {
524 let assembly = self.pending_step_assembly.take().ok_or_else(|| {
525 "import STEP assembly: nothing probed (call probe_step_assembly first)".to_string()
526 })?;
527 match self.consume_step_assembly(assembly, doc_name, opts.nested, sink) {
528 Consumed::Imported(report) => Ok(report),
529 Consumed::NoComponents { first_error, .. } => Err(format!(
530 "import STEP assembly: no part of the assembly could be built{}",
531 first_error
532 .map(|error| format!(" ({error})"))
533 .unwrap_or_default()
534 )),
535 }
536 }
537
538 pub fn discard_probed_step_assembly(&mut self) {
541 self.pending_step_assembly = None;
542 }
543
544 pub fn import_step_assembly(
555 &mut self,
556 step_text: &str,
557 doc_name: &str,
558 opts: StepAssemblyImport,
559 ) -> Result<StepAssemblyReport, String> {
560 let structured = self.probe_step_assembly(step_text)?.is_some();
561 let outcome = structured.then(|| {
562 let assembly = self
563 .pending_step_assembly
564 .take()
565 .expect("a Some probe stashed the assembly it counted");
566 self.consume_step_assembly(assembly, doc_name, opts.nested, &mut EmbeddedOnly)
567 });
568 match outcome {
569 Some(Consumed::Imported(report)) => Ok(report),
570 Some(Consumed::NoComponents {
573 failed_products,
574 first_error,
575 }) => {
576 self.import_step_feature(step_text)?;
577 Ok(StepAssemblyReport {
578 failed_products,
579 first_error,
580 flat_fallback: true,
581 ..StepAssemblyReport::default()
582 })
583 }
584 None => {
585 self.import_step_feature(step_text)?;
586 Ok(StepAssemblyReport {
587 flat_fallback: true,
588 ..StepAssemblyReport::default()
589 })
590 }
591 }
592 }
593
594 fn consume_step_assembly(
598 &mut self,
599 assembly: brep_kernel::StepAssembly,
600 doc_name: &str,
601 nested: bool,
602 sink: &mut dyn PartSink,
603 ) -> Consumed {
604 let mut first_error = assembly.first_error.clone();
605 let mut writer = PartWriter::new(sink);
608
609 let plan = if nested {
615 plan_nested(&assembly, doc_name, &mut first_error, &mut writer)
616 } else {
617 plan_flat(&assembly, &mut first_error)
618 };
619 let Plan {
620 wanted,
621 factors,
622 documents,
623 mut failed_products,
624 baked_below_root,
625 } = plan;
626
627 let mut keys: Vec<PartKey> = wanted.iter().map(|(key, _)| *key).collect();
631 keys.sort_unstable();
632 keys.dedup();
633 let mut entry_names: std::collections::HashMap<PartKey, String> =
634 std::collections::HashMap::new();
635 {
636 let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
647 for key in &keys {
648 let built = match documents.get(key) {
652 Some((name, document)) => install_part(name, document, &mut writer),
653 None => {
654 let product = assembly
655 .products
656 .iter()
657 .find(|product| product.pd_ref == key.0)
658 .expect("every key names a product of this assembly");
659 build_library_entry(product, factors.get(key), doc_name, &mut writer)
660 }
661 };
662 match built {
663 Ok(name) => {
664 entry_names.insert(*key, name);
665 }
666 Err(error) => {
667 failed_products += 1;
668 note(&mut first_error, error);
669 }
670 }
671 }
672 }
673 if entry_names.is_empty() {
674 return Consumed::NoComponents {
675 failed_products,
676 first_error,
677 };
678 }
679
680 let mut ground_next = !(0..self.history.len()).any(|index| {
685 matches!(
686 self.history.feature_type(index).as_deref(),
687 Some("ACOMP") | Some("ASSEMBLY COMPONENT")
688 )
689 });
690 let mut features: Vec<serde_json::Value> = Vec::with_capacity(wanted.len());
691 let mut baked_nonrigid = 0usize;
692 for (key, pose) in &wanted {
693 let Some(part_name) = entry_names.get(key) else {
694 continue; };
696 let transform = match brep_kernel::AffineTransform::new(*pose) {
697 Ok(transform) => transform,
698 Err(error) => {
699 note(&mut first_error, format!("occurrence pose: {error}"));
700 continue;
701 }
702 };
703 if key.1 != NO_FACTOR {
704 baked_nonrigid += 1;
705 }
706 features.push(serde_json::json!({
707 "type": "ACOMP",
708 "inputParams": {
709 "id": self.history.next_feature_id("ACOMP"),
710 "partName": part_name,
711 "transform": brep_kernel::transform_to_pose_params(&transform),
712 "isFixed": ground_next,
713 },
714 "persistentData": {}
715 }));
716 ground_next = false;
717 }
718 if features.is_empty() {
719 return Consumed::NoComponents {
720 failed_products,
721 first_error,
722 };
723 }
724
725 if let Ok(library) =
730 serde_json::from_str::<serde_json::Value>(&brep_kernel::parts_library_json())
731 {
732 self.history.set_parts_library(library);
733 }
734 self.pending_fit = true;
737 let instances = features.len();
738 let baked_nonrigid = baked_nonrigid + baked_below_root;
739 self.add_features(&features);
740 Consumed::Imported(StepAssemblyReport {
741 parts: entry_names
745 .values()
746 .collect::<std::collections::HashSet<_>>()
747 .len(),
748 instances,
749 baked_nonrigid,
750 failed_products,
751 first_error,
752 flat_fallback: false,
753 })
754 }
755}
756
757#[derive(Default)]
761struct Plan {
762 wanted: Vec<(PartKey, Mat4)>,
764 factors: std::collections::HashMap<PartKey, Mat4>,
768 documents: std::collections::HashMap<PartKey, (String, serde_json::Value)>,
772 failed_products: usize,
775 baked_below_root: usize,
778}
779
780fn plan_flat(assembly: &brep_kernel::StepAssembly, first_error: &mut Option<String>) -> Plan {
784 let mut plan = Plan::default();
785 for placed in &compose_world_occurrences(assembly) {
786 let product = &assembly.products[placed.product];
787 if product.bodies.is_empty() {
788 continue; }
790 let (key, pose) = if placed.rigid_path {
791 ((product.pd_ref, NO_FACTOR), placed.world)
792 } else {
793 match split_rigid(&placed.world) {
797 Ok((rigid, factor)) if is_identity(&factor) => {
801 ((product.pd_ref, NO_FACTOR), rigid)
802 }
803 Ok((rigid, factor)) => {
804 let key = (product.pd_ref, factor_key(&factor));
805 plan.factors.insert(key, factor);
806 (key, rigid)
807 }
808 Err(error) => {
809 note(first_error, error);
810 continue;
811 }
812 }
813 };
814 plan.wanted.push((key, pose));
815 }
816 plan
817}
818
819fn plan_nested(
832 assembly: &brep_kernel::StepAssembly,
833 doc_name: &str,
834 first_error: &mut Option<String>,
835 writer: &mut PartWriter<'_>,
836) -> Plan {
837 let _isolation = brep_kernel::IsolatedSceneMetadata::begin();
842 let mut build = NestedBuild {
843 assembly,
844 doc_name,
845 writer,
846 memo: std::collections::HashMap::new(),
847 factors: std::collections::HashMap::new(),
848 entries: 0,
849 bytes: 0,
850 failed_products: 0,
851 baked_nonrigid: 0,
852 first_error: None,
853 };
854 let mut plan = Plan::default();
855 for &root in &assembly.roots {
856 let mut rows: Vec<(DocKey, Mat4)> = Vec::new();
857 if !assembly.products[root].bodies.is_empty() {
861 rows.push((
862 DocKey::Leaf((assembly.products[root].pd_ref, NO_FACTOR)),
863 MAT4_IDENTITY,
864 ));
865 }
866 let mut root_level_bakes = 0usize;
870 build.place_children(root, &[root], &mut rows, &mut root_level_bakes);
871 for (key, pose) in rows {
872 let part = match build.document(key, &mut vec![root]) {
873 Ok(Some(document)) => document,
874 Ok(None) => continue,
877 Err(error) => {
878 build.failed_products += 1;
879 note(&mut build.first_error, error);
880 continue;
881 }
882 };
883 let part_key = key.part_key(assembly);
884 plan.documents.insert(part_key, part);
885 plan.wanted.push((part_key, pose));
886 }
887 }
888 plan.failed_products = build.failed_products;
889 plan.baked_below_root = build.baked_nonrigid;
890 if let Some(error) = build.first_error {
891 note(first_error, error);
892 }
893 plan
894}
895
896const MAX_NESTED_DEPTH: usize = 64;
904
905const MAX_NESTED_ENTRIES: usize = 10_000;
909
910const MAX_NESTED_BYTES: usize = 256 * 1024 * 1024;
920
921#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
927enum DocKey {
928 Leaf(PartKey),
930 Assembly(usize),
932}
933
934impl DocKey {
935 fn part_key(self, assembly: &brep_kernel::StepAssembly) -> PartKey {
941 match self {
942 DocKey::Leaf(key) => key,
943 DocKey::Assembly(product) => (assembly.products[product].pd_ref, NO_FACTOR),
944 }
945 }
946}
947
948struct NestedBuild<'a, 'w> {
952 assembly: &'a brep_kernel::StepAssembly,
953 doc_name: &'a str,
954 writer: &'a mut PartWriter<'w>,
957 memo: std::collections::HashMap<DocKey, Option<(String, serde_json::Value)>>,
959 factors: std::collections::HashMap<PartKey, Mat4>,
962 entries: usize,
963 bytes: usize,
965 failed_products: usize,
966 baked_nonrigid: usize,
967 first_error: Option<String>,
968}
969
970impl NestedBuild<'_, '_> {
971 fn document(
979 &mut self,
980 key: DocKey,
981 ancestors: &mut Vec<usize>,
982 ) -> Result<Option<(String, serde_json::Value)>, String> {
983 if let Some(hit) = self.memo.get(&key) {
984 return Ok(hit.clone());
985 }
986 if ancestors.len() >= MAX_NESTED_DEPTH {
987 return Err(format!(
988 "nested import: sub-assembly nesting deeper than {MAX_NESTED_DEPTH} levels \
989 (import as bodies, or import flat)"
990 ));
991 }
992 let built = match key {
993 DocKey::Leaf(part) => self.leaf_document(part),
994 DocKey::Assembly(product) => {
995 ancestors.push(product);
996 let built = self.assembly_document(product, ancestors);
997 ancestors.pop();
998 built
999 }
1000 }?;
1001 self.memo.insert(key, built.clone());
1002 Ok(built)
1003 }
1004
1005 fn leaf_document(
1009 &mut self,
1010 key: PartKey,
1011 ) -> Result<Option<(String, serde_json::Value)>, String> {
1012 let product = self
1013 .assembly
1014 .products
1015 .iter()
1016 .find(|product| product.pd_ref == key.0)
1017 .expect("every key names a product of this assembly");
1018 if product.bodies.is_empty() {
1019 return Ok(None);
1020 }
1021 let factor = self.factors.get(&key).copied();
1022 self.spend_entry()?;
1023 native_part_document(product, factor.as_ref(), self.doc_name).map(Some)
1024 }
1025
1026 fn assembly_document(
1041 &mut self,
1042 product: usize,
1043 ancestors: &mut Vec<usize>,
1044 ) -> Result<Option<(String, serde_json::Value)>, String> {
1045 let node = &self.assembly.products[product];
1046 let mut library = serde_json::Map::new();
1047 let mut features: Vec<serde_json::Value> = Vec::new();
1048
1049 if !node.bodies.is_empty() {
1052 let payload = brep_kernel::native_import_payload_with_appearance(
1053 "IMPORT3D1",
1054 &node.bodies,
1055 &node.appearances,
1056 )
1057 .map_err(|error| format!("part '{}': {error}", part_name(node, self.doc_name)))?;
1058 features.push(serde_json::json!({
1059 "type": "IMPORT3D",
1060 "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1061 "persistentData": {},
1062 }));
1063 }
1064
1065 let mut rows: Vec<(DocKey, Mat4)> = Vec::new();
1067 let mut bakes = 0usize;
1068 self.place_children(product, ancestors, &mut rows, &mut bakes);
1069 self.baked_nonrigid += bakes;
1070 let mut names: std::collections::HashMap<DocKey, String> =
1071 std::collections::HashMap::new();
1072 let mut by_signature: std::collections::HashMap<String, String> =
1076 std::collections::HashMap::new();
1077 let mut components = 0usize;
1078 for (key, pose) in rows {
1079 let name = match names.get(&key) {
1080 Some(name) => name.clone(),
1081 None => {
1082 let built = match self.document(key, ancestors) {
1083 Ok(Some(built)) => built,
1084 Ok(None) => continue,
1085 Err(error) => {
1086 self.failed_products += 1;
1087 note(&mut self.first_error, error);
1088 continue;
1089 }
1090 };
1091 let serialized = built.1.to_string();
1092 let signature = document_signature(&serialized);
1093 let name = match by_signature.get(&signature) {
1094 Some(name) => name.clone(),
1095 None => {
1096 self.spend_bytes(serialized.len())?;
1099 let name = unique_entry_name(&library, &built.0);
1103 let source_key =
1108 self.writer.key_for(&name, &serialized, &signature);
1109 library.insert(
1110 name.clone(),
1111 serde_json::json!({
1112 "sourceKey": source_key,
1113 "sourceSignature": signature.clone(),
1114 "document": built.1,
1115 "snapshot": "",
1116 }),
1117 );
1118 by_signature.insert(signature, name.clone());
1119 name
1120 }
1121 };
1122 names.insert(key, name.clone());
1123 name
1124 }
1125 };
1126 let Ok(transform) = brep_kernel::AffineTransform::new(pose) else {
1127 note(
1128 &mut self.first_error,
1129 format!("sub-assembly '{name}': occurrence pose is not an affine"),
1130 );
1131 continue;
1132 };
1133 components += 1;
1134 features.push(serde_json::json!({
1135 "type": "ACOMP",
1136 "inputParams": {
1137 "id": format!("ACOMP{components}"),
1141 "partName": name,
1142 "transform": brep_kernel::transform_to_pose_params(&transform),
1143 "isFixed": components == 1,
1148 },
1149 "persistentData": {},
1150 }));
1151 }
1152
1153 if features.is_empty() {
1158 return Ok(None);
1159 }
1160 self.spend_entry()?;
1161 Ok(Some((
1162 part_name(node, self.doc_name),
1163 serde_json::json!({ "partsLibrary": library, "features": features }),
1164 )))
1165 }
1166
1167 fn place_children(
1172 &mut self,
1173 product: usize,
1174 ancestors: &[usize],
1175 rows: &mut Vec<(DocKey, Mat4)>,
1176 bakes: &mut usize,
1177 ) {
1178 let mut children: Vec<&brep_kernel::StepOccurrence> = self
1179 .assembly
1180 .occurrences
1181 .iter()
1182 .filter(|occurrence| occurrence.parent == product)
1183 .collect();
1184 children.sort_by_key(|occurrence| occurrence.nauo_ref);
1185 for occurrence in children {
1186 if ancestors.contains(&occurrence.child) {
1187 note(
1188 &mut self.first_error,
1189 format!(
1190 "occurrence #{} closes a cycle in the product structure and was skipped",
1191 occurrence.nauo_ref
1192 ),
1193 );
1194 continue;
1195 }
1196 let child = &self.assembly.products[occurrence.child];
1197 let is_assembly = self
1198 .assembly
1199 .occurrences
1200 .iter()
1201 .any(|edge| edge.parent == occurrence.child);
1202 if occurrence.rigid {
1203 let key = if is_assembly {
1204 DocKey::Assembly(occurrence.child)
1205 } else {
1206 DocKey::Leaf((child.pd_ref, NO_FACTOR))
1207 };
1208 rows.push((key, occurrence.placement));
1209 continue;
1210 }
1211 match split_rigid(&occurrence.placement) {
1214 Ok((rigid, factor)) if is_identity(&factor) => {
1215 let key = if is_assembly {
1216 DocKey::Assembly(occurrence.child)
1217 } else {
1218 DocKey::Leaf((child.pd_ref, NO_FACTOR))
1219 };
1220 rows.push((key, rigid));
1221 }
1222 Ok(_) if is_assembly => {
1228 note(
1229 &mut self.first_error,
1230 format!(
1231 "occurrence #{} places sub-assembly '{}' with a non-rigid transform, \
1232 which a nested import cannot represent — import flat instead",
1233 occurrence.nauo_ref,
1234 part_name(child, self.doc_name)
1235 ),
1236 );
1237 }
1238 Ok((rigid, factor)) => {
1239 *bakes += 1;
1240 let key = (child.pd_ref, factor_key(&factor));
1241 self.factors.insert(key, factor);
1242 rows.push((DocKey::Leaf(key), rigid));
1243 }
1244 Err(error) => note(&mut self.first_error, error),
1245 }
1246 }
1247 }
1248
1249 fn spend_entry(&mut self) -> Result<(), String> {
1251 self.entries += 1;
1252 if self.entries > MAX_NESTED_ENTRIES {
1253 return Err(format!(
1254 "nested import: more than {MAX_NESTED_ENTRIES} distinct parts \
1255 (import as bodies, or import flat)"
1256 ));
1257 }
1258 Ok(())
1259 }
1260
1261 fn spend_bytes(&mut self, bytes: usize) -> Result<(), String> {
1263 self.bytes = self.bytes.saturating_add(bytes);
1264 if self.bytes > MAX_NESTED_BYTES {
1265 return Err(format!(
1266 "nested import: the embedded sub-assembly documents exceed \
1267 {} MB (import as bodies, or import flat)",
1268 MAX_NESTED_BYTES / (1024 * 1024)
1269 ));
1270 }
1271 Ok(())
1272 }
1273}
1274
1275fn unique_entry_name(library: &serde_json::Map<String, serde_json::Value>, requested: &str) -> String {
1279 if !library.contains_key(requested) {
1280 return requested.to_string();
1281 }
1282 (2..)
1283 .map(|counter| format!("{requested}-{counter}"))
1284 .find(|candidate| !library.contains_key(candidate))
1285 .expect("the counter loop is unbounded")
1286}
1287
1288fn note(slot: &mut Option<String>, error: String) {
1291 if slot.is_none() {
1292 *slot = Some(error);
1293 }
1294}
1295
1296fn build_library_entry(
1304 product: &brep_kernel::StepProduct,
1305 factor: Option<&Mat4>,
1306 doc_name: &str,
1307 writer: &mut PartWriter<'_>,
1308) -> Result<String, String> {
1309 let (name, document) = native_part_document(product, factor, doc_name)?;
1310 install_part(&name, &document, writer)
1311}
1312
1313fn native_part_document(
1325 product: &brep_kernel::StepProduct,
1326 factor: Option<&Mat4>,
1327 doc_name: &str,
1328) -> Result<(String, serde_json::Value), String> {
1329 let mut name = part_name(product, doc_name);
1330 let bodies = match factor {
1331 None => product.bodies.clone(),
1332 Some(factor) => {
1333 let transform = brep_kernel::AffineTransform::new(*factor)
1334 .map_err(|error| format!("part '{name}': non-rigid factor: {error}"))?;
1335 let mirrored = transform.determinant3() < 0.0;
1336 name.push_str(if mirrored { " (mirrored)" } else { " (scaled)" });
1337 product
1338 .bodies
1339 .iter()
1340 .map(|body| {
1341 brep_kernel::transform_brep(body, transform, mirrored)
1344 .map_err(|error| format!("part '{name}': {error}"))
1345 })
1346 .collect::<Result<Vec<_>, _>>()?
1347 }
1348 };
1349 let payload = brep_kernel::native_import_payload_with_appearance(
1353 "IMPORT3D1",
1354 &bodies,
1355 &product.appearances,
1356 )
1357 .map_err(|error| format!("part '{name}': {error}"))?;
1358 let document = serde_json::json!({
1359 "features": [{
1360 "type": "IMPORT3D",
1361 "inputParams": { "id": "IMPORT3D1", "nativeBrep": payload },
1362 "persistentData": {},
1363 }]
1364 });
1365 Ok((name, document))
1366}
1367
1368fn install_part(
1380 name: &str,
1381 document: &serde_json::Value,
1382 writer: &mut PartWriter<'_>,
1383) -> Result<String, String> {
1384 let document = document.to_string();
1385 let signature = document_signature(&document);
1386 let source_key = writer.key_for(name, &document, &signature);
1387 brep_kernel::add_part_to_library(name, &source_key, &signature, &document)
1388 .map_err(|error| format!("part '{name}': {error:?}"))
1389}
1390
1391pub trait PartSink {
1400 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String>;
1404}
1405
1406pub struct EmbeddedOnly;
1409
1410impl PartSink for EmbeddedOnly {
1411 fn store_part(&mut self, _part_name: &str, _document_json: &str) -> Option<String> {
1412 None
1413 }
1414}
1415
1416struct PartWriter<'a> {
1431 sink: &'a mut dyn PartSink,
1432 by_signature: std::collections::HashMap<String, String>,
1433}
1434
1435impl<'a> PartWriter<'a> {
1436 fn new(sink: &'a mut dyn PartSink) -> Self {
1437 Self {
1438 sink,
1439 by_signature: std::collections::HashMap::new(),
1440 }
1441 }
1442
1443 fn key_for(&mut self, name: &str, document_json: &str, signature: &str) -> String {
1446 if let Some(key) = self.by_signature.get(signature) {
1447 return key.clone();
1448 }
1449 let key = self
1450 .sink
1451 .store_part(name, document_json)
1452 .unwrap_or_default();
1453 self.by_signature.insert(signature.to_string(), key.clone());
1454 key
1455 }
1456}
1457
1458fn part_name(product: &brep_kernel::StepProduct, doc_name: &str) -> String {
1461 let named = product.name.trim();
1462 if !named.is_empty() {
1463 return named.to_string();
1464 }
1465 match doc_name.trim() {
1466 "" => format!("part-{}", product.pd_ref),
1467 stem => format!("{stem}-part-{}", product.pd_ref),
1468 }
1469}
1470
1471pub(super) fn probe_counts(assembly: &brep_kernel::StepAssembly) -> StepAssemblyProbe {
1475 let mut parts = std::collections::HashSet::new();
1476 let mut instances = 0usize;
1477 let mut nested_depth = 0usize;
1478 for placed in compose_world_occurrences(assembly) {
1479 let product = &assembly.products[placed.product];
1480 if product.bodies.is_empty() {
1481 continue;
1482 }
1483 parts.insert(product.pd_ref);
1484 instances += 1;
1485 nested_depth = nested_depth.max(placed.depth);
1486 }
1487 StepAssemblyProbe {
1488 parts: parts.len(),
1489 instances,
1490 nested_depth,
1491 }
1492}
1493
1494fn compose_world_occurrences(assembly: &brep_kernel::StepAssembly) -> Vec<PlacedProduct> {
1506 struct Node {
1507 placed: PlacedProduct,
1508 ancestors: Vec<usize>,
1509 }
1510 let mut out = Vec::new();
1511 let mut stack: Vec<Node> = assembly
1512 .roots
1513 .iter()
1514 .rev()
1515 .map(|&product| Node {
1516 placed: PlacedProduct {
1517 product,
1518 world: MAT4_IDENTITY,
1519 depth: 0,
1520 rigid_path: true,
1521 },
1522 ancestors: vec![product],
1523 })
1524 .collect();
1525 while let Some(node) = stack.pop() {
1526 let (product, world, depth, rigid_path) = (
1527 node.placed.product,
1528 node.placed.world,
1529 node.placed.depth,
1530 node.placed.rigid_path,
1531 );
1532 out.push(node.placed);
1533 let mut children: Vec<&brep_kernel::StepOccurrence> = assembly
1534 .occurrences
1535 .iter()
1536 .filter(|occurrence| occurrence.parent == product)
1537 .collect();
1538 children.sort_by_key(|occurrence| occurrence.nauo_ref);
1539 for occurrence in children.into_iter().rev() {
1540 if node.ancestors.contains(&occurrence.child) {
1541 continue; }
1543 let mut ancestors = node.ancestors.clone();
1544 ancestors.push(occurrence.child);
1545 stack.push(Node {
1546 placed: PlacedProduct {
1547 product: occurrence.child,
1548 world: mat4_mul(&world, &occurrence.placement),
1549 depth: depth + 1,
1550 rigid_path: rigid_path && occurrence.rigid,
1554 },
1555 ancestors,
1556 });
1557 }
1558 }
1559 out
1560}
1561
1562const MAT4_IDENTITY: Mat4 = [
1563 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,
1567];
1568
1569fn mat4_mul(a: &Mat4, b: &Mat4) -> Mat4 {
1571 let mut out = [0.0; 16];
1572 for row in 0..4 {
1573 for column in 0..4 {
1574 out[row * 4 + column] = (0..4)
1575 .map(|k| a[row * 4 + k] * b[k * 4 + column])
1576 .sum();
1577 }
1578 }
1579 out
1580}
1581
1582fn split_rigid(world: &Mat4) -> Result<(Mat4, Mat4), String> {
1593 let column = |index: usize| [world[index], world[4 + index], world[8 + index]];
1594 let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1595 let axpy = |a: [f64; 3], scale: f64, b: [f64; 3]| {
1596 [a[0] - scale * b[0], a[1] - scale * b[1], a[2] - scale * b[2]]
1597 };
1598 let (a1, a2, a3) = (column(0), column(1), column(2));
1599
1600 let r11 = dot(a1, a1).sqrt();
1601 let mut q1 = normalize(a1, r11)?;
1602 let r12 = dot(q1, a2);
1603 let v2 = axpy(a2, r12, q1);
1604 let r22 = dot(v2, v2).sqrt();
1605 let q2 = normalize(v2, r22)?;
1606 let r13 = dot(q1, a3);
1607 let r23 = dot(q2, a3);
1608 let v3 = axpy(axpy(a3, r13, q1), r23, q2);
1609 let r33 = dot(v3, v3).sqrt();
1610 let q3 = normalize(v3, r33)?;
1611
1612 let cross = [
1614 q2[1] * q3[2] - q2[2] * q3[1],
1615 q2[2] * q3[0] - q2[0] * q3[2],
1616 q2[0] * q3[1] - q2[1] * q3[0],
1617 ];
1618 let (mut r11, mut r12, mut r13) = (r11, r12, r13);
1619 if dot(q1, cross) < 0.0 {
1620 q1 = [-q1[0], -q1[1], -q1[2]];
1621 r11 = -r11;
1622 r12 = -r12;
1623 r13 = -r13;
1624 }
1625 let rigid = [
1626 q1[0], q2[0], q3[0], world[3], q1[1], q2[1], q3[1], world[7], q1[2], q2[2], q3[2], world[11], 0.0, 0.0, 0.0, 1.0,
1630 ];
1631 let factor = [
1632 r11, r12, r13, 0.0, 0.0, r22, r23, 0.0, 0.0, 0.0, r33, 0.0, 0.0, 0.0, 0.0, 1.0,
1636 ];
1637 Ok((rigid, factor))
1638}
1639
1640fn normalize(vector: [f64; 3], length: f64) -> Result<[f64; 3], String> {
1643 if !(length > 1e-12) || !length.is_finite() {
1644 return Err("occurrence placement is singular (a degenerate axis)".into());
1645 }
1646 Ok([vector[0] / length, vector[1] / length, vector[2] / length])
1647}
1648
1649fn is_identity(matrix: &Mat4) -> bool {
1652 matrix
1653 .iter()
1654 .zip(MAT4_IDENTITY.iter())
1655 .all(|(value, want)| (value - want).abs() <= 1e-9)
1656}
1657
1658fn factor_key(factor: &Mat4) -> [u64; 9] {
1662 let mut key = [0u64; 9];
1663 for (slot, index) in key.iter_mut().zip([0, 1, 2, 4, 5, 6, 8, 9, 10]) {
1664 *slot = factor[index].to_bits();
1665 }
1666 key
1667}
1668
1669#[cfg(test)]
1670mod io_tests {
1671 use super::*;
1672 use crate::engine_state::StepProbeOutcome;
1673
1674 fn cube_history(id: &str, size: f64) -> String {
1677 serde_json::json!({
1678 "expressions": "",
1679 "configurator": {},
1680 "features": [{
1681 "type": "P.CU",
1682 "inputParams": {
1683 "id": id,
1684 "sizeX": size, "sizeY": size, "sizeZ": size,
1685 "transform": {
1686 "position": [0.0, 0.0, 0.0],
1687 "rotationEuler": [0.0, 0.0, 0.0],
1688 "scale": [1.0, 1.0, 1.0]
1689 },
1690 "boolean": { "targets": [], "operation": "NONE" }
1691 },
1692 "persistentData": {}
1693 }]
1694 })
1695 .to_string()
1696 }
1697
1698 fn box_step(sx: f64, sy: f64, sz: f64) -> String {
1700 let solid =
1701 brep_kernel::make_box_brep(brep_kernel::Vec3::new(0.0, 0.0, 0.0), sx, sy, sz)
1702 .unwrap();
1703 brep_kernel::export_step(&[solid], "part", "MM", "").unwrap()
1704 }
1705
1706 fn imported_volume(step_text: &str) -> f64 {
1708 let solids = brep_kernel::import_step(step_text).unwrap();
1709 assert_eq!(solids.len(), 1, "STEP round-trips to one solid");
1710 brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume
1711 }
1712
1713 #[test]
1717 fn import_step_feature_adds_the_body_to_the_model() {
1718 let step = box_step(4.0, 3.0, 2.0);
1719 let mut state = EngineState::new();
1720 state.import_step_feature(&step).unwrap();
1721 assert_eq!(state.scene.solids().len(), 1, "one imported body");
1722 let size = state.scene.solids()[0].bbox.size();
1723 assert!(
1724 (size[0] - 4.0).abs() < 1e-4
1725 && (size[1] - 3.0).abs() < 1e-4
1726 && (size[2] - 2.0).abs() < 1e-4,
1727 "imported bbox {size:?} != 4x3x2"
1728 );
1729
1730 let mut empty = EngineState::new();
1731 assert!(empty.import_step_feature("not a step file").is_err());
1732 assert_eq!(empty.history_len(), 0, "a bad import adds no feature");
1733 }
1734
1735 #[test]
1746 fn imported_step_colour_reaches_the_engine_metadata_store() {
1747 let step = include_str!(concat!(
1748 env!("CARGO_MANIFEST_DIR"),
1749 "/../BREP_kernel/tests/fixtures/step-import/freecad_partdesign_body.step"
1750 ));
1751 let mut state = EngineState::new();
1752 state.import_step_feature(step).expect("fixture imports");
1753 let name = state.scene.solids()[0].name.clone();
1754 assert_eq!(
1755 state.metadata.attribute(&name, "color"),
1756 Some("#CCCCCC"),
1757 "the imported body colour must reach the store the Info window reads"
1758 );
1759
1760 state.set_metadata_attribute(&name, "color", "#123456");
1763 state.roll_to(0);
1764 state.roll_to(state.history_len());
1765 assert_eq!(
1766 state.metadata.attribute(&name, "color"),
1767 Some("#123456"),
1768 "a user-edited colour must win over the re-stamped import"
1769 );
1770 }
1771
1772 #[test]
1776 fn import_obj_feature_reconstructs_mesh_into_a_cad_body() {
1777 let cube = r#"
1778v 0 0 0
1779v 1 0 0
1780v 1 1 0
1781v 0 1 0
1782v 0 0 1
1783v 1 0 1
1784v 1 1 1
1785v 0 1 1
1786f 1 3 2
1787f 1 4 3
1788f 5 6 7
1789f 5 7 8
1790f 1 2 6
1791f 1 6 5
1792f 2 3 7
1793f 2 7 6
1794f 3 4 8
1795f 3 8 7
1796f 4 1 5
1797f 4 5 8
1798"#;
1799 let mut state = EngineState::new();
1800 state.import_obj_feature(cube).unwrap();
1801
1802 assert_eq!(
1803 state.history_len(),
1804 1,
1805 "mesh import adds one undoable feature"
1806 );
1807 assert_eq!(
1808 state.scene.solids().len(),
1809 1,
1810 "reconstruction yields one body"
1811 );
1812 let size = state.scene.solids()[0].bbox.size();
1813 assert!(
1814 size.iter().all(|axis| (*axis - 1.0).abs() < 1e-4),
1815 "bbox: {size:?}"
1816 );
1817 assert!(
1818 state.history_request_json().contains("ISO-10303-21"),
1819 "history stores the validated reconstructed BREP as STEP"
1820 );
1821 }
1822
1823 #[test]
1827 #[cfg(not(target_arch = "wasm32"))]
1828 fn import_binary_stl_feature_reconstructs_mesh_into_a_cad_body() {
1829 let bytes = include_bytes!("../../../tests/fixtures/stl/PartDesignExample-Body.stl");
1830 let mut state = EngineState::new();
1831 state.set_runner(Box::new(crate::runner::ThreadRunner::new()));
1832 let submitted = std::time::Instant::now();
1833 state.import_stl_feature(bytes).unwrap();
1834
1835 assert!(state.mesh_imports_pending(), "RANSAC is running off-thread");
1836 assert_eq!(
1837 state.history_len(),
1838 0,
1839 "no feature is added before reconstruction"
1840 );
1841 assert!(
1842 submitted.elapsed() < std::time::Duration::from_secs(1),
1843 "submission must not wait for RANSAC"
1844 );
1845 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
1846 while state.mesh_imports_pending() || state.run_pending() {
1847 assert!(
1848 std::time::Instant::now() < deadline,
1849 "background import timed out"
1850 );
1851 state.pump();
1852 std::thread::sleep(std::time::Duration::from_millis(2));
1853 }
1854 assert_eq!(state.history_len(), 1);
1855 assert_eq!(state.scene.solids().len(), 1);
1856 assert!(state.history_request_json().contains("ISO-10303-21"));
1857 }
1858
1859 #[test]
1862 fn export_step_text_round_trips_a_box_model() {
1863 let mut state = EngineState::new();
1864 state.set_history_json(&cube_history("Box", 10.0)).unwrap();
1865 let step = state.export_step_text().unwrap();
1866 assert!(step.contains("ISO-10303-21"), "STEP header present");
1867 let volume = imported_volume(&step);
1868 assert!((volume - 1000.0).abs() < 1e-3, "exported volume {volume} != 1000");
1869
1870 let empty = EngineState::new();
1871 assert!(empty.export_step_text().is_err(), "empty model errs on export");
1872 }
1873
1874 #[test]
1877 fn import_export_import_preserves_count_and_volume() {
1878 let step_in = box_step(5.0, 4.0, 3.0); let mut state = EngineState::new();
1880 state.import_step_feature(&step_in).unwrap();
1881 assert_eq!(state.scene.solids().len(), 1);
1882
1883 let step_out = state.export_step_text().unwrap();
1884 let solids = brep_kernel::import_step(&step_out).unwrap();
1885 assert_eq!(solids.len(), 1, "solid count preserved");
1886 let volume = brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume;
1887 assert!((volume - 60.0).abs() < 1e-3, "round-trip volume {volume} != 60");
1888 }
1889
1890 #[test]
1894 fn export_iges_text_round_trips_a_box_model() {
1895 let mut state = EngineState::new();
1896 state.set_history_json(&cube_history("Box", 5.0)).unwrap(); let iges = state.export_iges_text().unwrap();
1898 assert_eq!(iges.chars().nth(72), Some('S'), "first record is the start section");
1899
1900 let solids = brep_kernel::import_iges(&iges).unwrap();
1901 assert_eq!(solids.len(), 1, "one solid round-trips through IGES");
1902 let volume = brep_kernel::solid_mass_properties(&solids[0]).unwrap().volume;
1903 assert!((volume - 125.0).abs() < 1e-3, "IGES round-trip volume {volume} != 125");
1904
1905 let mut other = EngineState::new();
1906 other.import_iges_feature(&iges).unwrap();
1907 assert_eq!(other.scene.solids().len(), 1, "imported one body via the feature");
1908
1909 let empty = EngineState::new();
1910 assert!(empty.export_iges_text().is_err(), "empty model errs on IGES export");
1911 assert!(
1912 other.import_iges_feature("not an iges file").is_err(),
1913 "a non-IGES payload is refused"
1914 );
1915 }
1916
1917 fn sheet_metal_tab_history() -> String {
1920 serde_json::json!({
1921 "expressions": "", "configurator": {},
1922 "features": [
1923 {
1924 "type": "S",
1925 "inputParams": { "id": "SkTab" },
1926 "persistentData": {
1927 "basis": { "origin": [0,0,0], "x": [1,0,0], "y": [0,1,0], "z": [0,0,1] },
1928 "sketch": {
1929 "points": [
1930 {"id":1,"x":0.0,"y":0.0,"fixed":true},
1931 {"id":2,"x":40.0,"y":0.0,"fixed":true},
1932 {"id":3,"x":40.0,"y":25.0,"fixed":true},
1933 {"id":4,"x":0.0,"y":25.0,"fixed":true}
1934 ],
1935 "geometries": [
1936 {"id":10,"type":"line","points":[1,2]},
1937 {"id":11,"type":"line","points":[2,3]},
1938 {"id":12,"type":"line","points":[3,4]},
1939 {"id":13,"type":"line","points":[4,1]}
1940 ],
1941 "constraints": []
1942 }
1943 },
1944 "timestamp": null
1945 },
1946 {
1947 "type": "SM.TAB",
1948 "inputParams": { "id": "tab1", "profile": "SkTab", "thickness": 2.0, "placementMode": "midplane" },
1949 "persistentData": {},
1950 "timestamp": null
1951 }
1952 ]
1953 })
1954 .to_string()
1955 }
1956
1957 #[test]
1961 fn export_flat_pattern_dxf_and_svg_for_a_sheet_metal_part() {
1962 let mut state = EngineState::new();
1963 state.set_history_json(&sheet_metal_tab_history()).unwrap();
1964
1965 let dxf = state.export_flat_pattern_dxf().unwrap();
1966 assert!(dxf.contains("AC1009"), "DXF R12 header present");
1967 assert!(dxf.contains("\nPOLYLINE\n"), "DXF has a polyline entity");
1968 assert!(dxf.trim_end().ends_with("EOF"), "DXF terminates with EOF");
1969
1970 let svg = state.export_flat_pattern_svg().unwrap();
1971 assert!(svg.starts_with("<svg"), "SVG opens with the svg root");
1972 assert!(svg.contains("<path"), "SVG has a path per loop");
1973
1974 assert_eq!(state.history_len(), 2, "flat-pattern export adds no feature");
1976
1977 let mut box_model = EngineState::new();
1979 box_model.set_history_json(&cube_history("Box", 10.0)).unwrap();
1980 let err = box_model.export_flat_pattern_dxf().unwrap_err();
1981 assert_eq!(err, "no sheet-metal body in the part", "clear no-target error");
1982 }
1983
1984 #[test]
1989 fn is_sheet_metal_object_marks_the_sheet_body_not_a_box() {
1990 let mut state = EngineState::new();
1991 state.set_history_json(&sheet_metal_tab_history()).unwrap();
1992
1993 let sheet = state
1995 .scene
1996 .solids()
1997 .iter()
1998 .find(|s| s.is_sheet_metal)
1999 .expect("the SM.TAB body carries the sheet-metal marker");
2000 let solid_name = sheet.name.clone();
2001 let face_name = sheet.faces.iter().find(|f| !f.name.is_empty()).map(|f| f.name.clone());
2002 let edge_name = sheet.edges.iter().find(|e| !e.name.is_empty()).map(|e| e.name.clone());
2003
2004 assert!(state.is_sheet_metal_object(&solid_name), "the solid is sheet metal");
2005 if let Some(face) = face_name {
2006 assert!(state.is_sheet_metal_object(&face), "a face of it is sheet metal");
2007 }
2008 if let Some(edge) = edge_name {
2009 assert!(state.is_sheet_metal_object(&edge), "an edge of it is sheet metal");
2010 }
2011 assert!(!state.is_sheet_metal_object(""), "empty name is not sheet metal");
2013 assert!(!state.is_sheet_metal_object("nope"), "unknown name is not sheet metal");
2014
2015 let mut box_model = EngineState::new();
2017 box_model.set_history_json(&cube_history("Box", 10.0)).unwrap();
2018 assert!(!box_model.is_sheet_metal_object("Box"), "a plain box is not sheet metal");
2019 }
2020
2021 #[test]
2024 fn export_stl_text_emits_ascii_facets() {
2025 let mut state = EngineState::new();
2026 state.set_history_json(&cube_history("Box", 6.0)).unwrap();
2027 let stl = state.export_stl_text().unwrap();
2028 assert!(stl.starts_with("solid brep"), "STL opens with the solid header");
2029 assert!(stl.trim_end().ends_with("endsolid brep"), "STL closes the solid");
2030 assert_eq!(
2031 stl.matches("facet normal").count(),
2032 12,
2033 "a box tessellates to 12 triangles"
2034 );
2035 assert_eq!(stl.matches("vertex").count(), 36, "3 vertices per triangle");
2036
2037 let empty = EngineState::new();
2038 assert!(empty.export_stl_text().is_err(), "empty scene errs on STL export");
2039 }
2040
2041 fn step_fixture(name: &str) -> String {
2050 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2051 .join("../BREP_kernel/tests/fixtures/step-import")
2052 .join(name);
2053 std::fs::read_to_string(&path)
2054 .unwrap_or_else(|error| panic!("read fixture {}: {error}", path.display()))
2055 }
2056
2057 fn library() -> serde_json::Map<String, serde_json::Value> {
2059 serde_json::from_str::<serde_json::Value>(&brep_kernel::parts_library_json())
2060 .expect("the parts library serializes as JSON")
2061 .as_object()
2062 .cloned()
2063 .expect("the parts library is an object")
2064 }
2065
2066 fn component_part_names(state: &EngineState) -> Vec<String> {
2068 serde_json::from_str::<serde_json::Value>(&state.history_request_json())
2069 .expect("history JSON")["features"]
2070 .as_array()
2071 .expect("features array")
2072 .iter()
2073 .filter(|feature| feature["type"] == "ACOMP")
2074 .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2075 .collect()
2076 }
2077
2078 fn instance_counts(state: &EngineState) -> std::collections::BTreeMap<String, usize> {
2080 let mut counts = std::collections::BTreeMap::new();
2081 for name in component_part_names(state) {
2082 *counts.entry(name).or_insert(0usize) += 1;
2083 }
2084 counts
2085 }
2086
2087 fn entry_payload(entry: &serde_json::Value) -> String {
2090 entry["document"]["features"][0]["inputParams"]["nativeBrep"]
2091 .as_str()
2092 .expect("the part document is one native IMPORT3D")
2093 .to_string()
2094 }
2095
2096 fn placed_bboxes(state: &EngineState) -> Vec<[i64; 6]> {
2099 let mut out: Vec<[i64; 6]> = state
2100 .scene
2101 .solids()
2102 .iter()
2103 .map(|solid| {
2104 let (center, size) = (solid.bbox.center(), solid.bbox.size());
2105 let q = |value: f64| (value * 1.0e4).round() as i64;
2106 [
2107 q(center[0]),
2108 q(center[1]),
2109 q(center[2]),
2110 q(size[0]),
2111 q(size[1]),
2112 q(size[2]),
2113 ]
2114 })
2115 .collect();
2116 out.sort_unstable();
2117 out
2118 }
2119
2120 fn synthetic_assembly(
2127 bodies: Vec<brep_kernel::BrepSolid>,
2128 placements: &[([f64; 16], bool)],
2129 ) -> brep_kernel::StepAssembly {
2130 brep_kernel::StepAssembly {
2131 products: vec![
2132 brep_kernel::StepProduct {
2133 pd_ref: 1,
2134 name: "root".into(),
2135 id: "root".into(),
2136 bodies: Vec::new(),
2137 appearances: Vec::new(),
2138 failed_bodies: 0,
2139 },
2140 brep_kernel::StepProduct {
2141 pd_ref: 2,
2142 name: "widget".into(),
2143 id: "widget".into(),
2144 bodies,
2145 appearances: Vec::new(),
2146 failed_bodies: 0,
2147 },
2148 ],
2149 occurrences: placements
2150 .iter()
2151 .enumerate()
2152 .map(|(index, (placement, rigid))| brep_kernel::StepOccurrence {
2153 nauo_ref: 10 + index,
2154 parent: 0,
2155 child: 1,
2156 designator: format!("widget-{index}"),
2157 placement: *placement,
2158 rigid: *rigid,
2159 })
2160 .collect(),
2161 roots: vec![0],
2162 first_error: None,
2163 }
2164 }
2165
2166 #[test]
2171 fn import_step_assembly_runs_one_rebuild() {
2172 let text = step_fixture("as1-ug-214.stp");
2173 let mut state = EngineState::new();
2174 let before = state.applied_generation();
2175
2176 let report = state
2177 .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2178 .expect("as1-ug-214 imports as an assembly");
2179 assert!(!report.flat_fallback, "as1-ug-214 carries a product structure");
2180 assert!(
2181 report.instances > 5,
2182 "the fixture is a real assembly: {} instances",
2183 report.instances
2184 );
2185 assert_eq!(
2186 state.applied_generation(),
2187 before + 1,
2188 "ONE rebuild for {} instances, not one per instance",
2189 report.instances
2190 );
2191 assert_eq!(
2192 component_part_names(&state).len(),
2193 report.instances,
2194 "every reported instance is an ACOMP feature"
2195 );
2196
2197 assert!(state.can_undo(), "the import is undoable");
2198 state.undo();
2199 assert!(
2200 component_part_names(&state).is_empty(),
2201 "the whole import undoes in ONE step"
2202 );
2203 }
2204
2205 #[test]
2210 fn import_step_assembly_parses_once() {
2211 let text = step_fixture("as1-ug-214.stp");
2212 let mut state = EngineState::new();
2213
2214 let probe = state
2215 .probe_step_assembly(&text)
2216 .expect("as1-ug-214 parses")
2217 .expect("as1-ug-214 carries structure");
2218 assert!(
2219 state.pending_step_assembly.is_some(),
2220 "the probe stashes THE parse for the consume"
2221 );
2222 assert!(probe.parts > 0 && probe.instances >= probe.parts);
2223 assert!(
2224 probe.nested_depth > 1,
2225 "as1-ug-214 has sub-assemblies: depth {}",
2226 probe.nested_depth
2227 );
2228
2229 let report = state
2230 .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut EmbeddedOnly)
2231 .expect("the probed assembly imports");
2232 assert!(
2233 state.pending_step_assembly.is_none(),
2234 "the consume TAKES the stash"
2235 );
2236 assert_eq!(
2237 (report.parts, report.instances),
2238 (probe.parts, probe.instances),
2239 "the dialog's counts are the import's counts"
2240 );
2241 assert_eq!(report.baked_nonrigid, 0, "every as1 occurrence is rigid");
2242 assert_eq!(report.failed_products, 0, "every product encodes");
2243
2244 assert!(
2245 state
2246 .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut EmbeddedOnly)
2247 .is_err(),
2248 "a second consume has nothing to import — never a double insert"
2249 );
2250 }
2251
2252 #[test]
2258 fn probe_submit_and_take_pair_answers_by_id() {
2259 let assembly = step_fixture("as1-ug-214.stp");
2260 let part = step_fixture("analytic_cube.step");
2261 let mut state = EngineState::new();
2262 assert!(state.take_step_probe().is_none());
2263
2264 let flat = state.submit_step_probe(&part);
2265 assert!(!state.step_probes_pending(), "no NAUO text: answered without the runner");
2266 assert_eq!(state.take_step_probe(), Some((flat, StepProbeOutcome::Flat)));
2267
2268 let structured = state.submit_step_probe(&assembly);
2269 assert_ne!(structured, flat, "ids are distinct");
2270 assert!(!state.step_probes_pending());
2272 match state.take_step_probe() {
2273 Some((id, StepProbeOutcome::Structure(probe))) => {
2274 assert_eq!(id, structured);
2275 assert_eq!(probe.instances, 18);
2276 }
2277 other => panic!("expected the as1 structure, got {other:?}"),
2278 }
2279 assert!(state.pending_step_assembly.is_some(), "stashed for the import");
2280 assert!(state.take_step_probe().is_none(), "each answer is taken once");
2281
2282 let broken = state.submit_step_probe("NEXT_ASSEMBLY_USAGE_OCCURRENCE but no header");
2284 assert!(matches!(
2285 state.take_step_probe(),
2286 Some((id, StepProbeOutcome::Failed(_))) if id == broken
2287 ));
2288 assert!(state.pending_step_assembly.is_none(), "a failed probe clears the stash");
2289
2290 state.submit_step_probe(&assembly);
2292 state.set_history_json(&cube_history("Box", 4.0)).unwrap();
2293 assert!(state.take_step_probe().is_none());
2294 assert!(state.pending_step_assembly.is_none());
2295 }
2296
2297 #[test]
2302 fn probing_replaces_the_stash_and_never_accumulates() {
2303 let assembly = step_fixture("as1-ug-214.stp");
2304 let part = step_fixture("analytic_cube.step");
2305 let mut state = EngineState::new();
2306
2307 assert!(state.probe_step_assembly(&assembly).unwrap().is_some());
2308 assert!(
2309 state.probe_step_assembly(&part).unwrap().is_none(),
2310 "a part file has no structure"
2311 );
2312 assert!(
2313 state.pending_step_assembly.is_none(),
2314 "a structureless probe must CLEAR the stash, or the next consume \
2315 imports the previous file"
2316 );
2317
2318 assert!(state.probe_step_assembly(&assembly).unwrap().is_some());
2319 state.discard_probed_step_assembly();
2320 assert!(state.pending_step_assembly.is_none(), "Cancel drops the parse");
2321
2322 assert!(state.probe_step_assembly(&assembly).unwrap().is_some());
2323 state.set_history_json(&cube_history("Box", 4.0)).unwrap();
2324 assert!(
2325 state.pending_step_assembly.is_none(),
2326 "a document switch drops a parse that belonged to the old document"
2327 );
2328 }
2329
2330 #[test]
2334 fn import_step_assembly_falls_back_to_flat_for_a_part_file() {
2335 let text = step_fixture("analytic_cube.step");
2336 let mut state = EngineState::new();
2337
2338 assert!(
2339 state.probe_step_assembly(&text).unwrap().is_none(),
2340 "the probe reports no structure, so the app never offers the dialog"
2341 );
2342 let report = state
2343 .import_step_assembly(&text, "analytic_cube", StepAssemblyImport::default())
2344 .expect("the part file still imports");
2345
2346 assert!(report.flat_fallback, "the flat lane ran");
2347 assert_eq!((report.parts, report.instances), (0, 0));
2348 assert!(library().is_empty(), "no parts-library entry for a flat import");
2349 assert_eq!(state.history_len(), 1, "one IMPORT3D feature");
2350 assert!(
2351 state.history_request_json().contains("stepText"),
2352 "the flat lane stores the STEP text, exactly as before"
2353 );
2354 assert!(!state.scene.solids().is_empty(), "the bodies are in the model");
2355 }
2356
2357 #[test]
2361 fn structured_import_dedups_parts() {
2362 let text = step_fixture("as1-ug-214.stp");
2363 let mut state = EngineState::new();
2364 let report = state
2365 .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2366 .expect("as1-ug-214 imports as an assembly");
2367
2368 let library = library();
2369 assert_eq!(
2370 library.len(),
2371 report.parts,
2372 "one entry per unique geometry-bearing product"
2373 );
2374 let counts = instance_counts(&state);
2375 assert_eq!(
2376 counts.values().sum::<usize>(),
2377 report.instances,
2378 "one ACOMP per geometry-bearing occurrence"
2379 );
2380 assert_eq!(
2381 counts.len(),
2382 library.len(),
2383 "every entry is instanced, and every instance names an entry"
2384 );
2385 assert_eq!(
2386 counts.get("bolt").copied(),
2387 Some(6),
2388 "the six-bolt classic: ONE stored bolt, six instances — {counts:?}"
2389 );
2390 assert_eq!(
2391 counts.get("nut").copied(),
2392 Some(8),
2393 "eight nuts (six on the bolts, two on the rods): {counts:?}"
2394 );
2395 assert_eq!(
2396 counts.get("l_bracket").copied(),
2397 Some(2),
2398 "two L-brackets: {counts:?}"
2399 );
2400 assert_eq!(
2401 (report.parts, report.instances),
2402 (5, 18),
2403 "as1-ug-214: 5 distinct parts in 18 places"
2404 );
2405
2406 for (name, entry) in &library {
2417 assert_eq!(
2418 entry["sourceKey"], "",
2419 "'{name}': with no sink there is no file, so the entry must \
2420 stay embedded-only or update-components badges it as falsely \
2421 outdated"
2422 );
2423 assert_eq!(
2424 entry["sourceSignature"],
2425 serde_json::Value::String(document_signature(&entry["document"].to_string())),
2426 "'{name}' signature is the ONE signature fn over its document"
2427 );
2428 assert!(
2429 !entry_payload(entry).is_empty(),
2430 "'{name}' carries a native payload"
2431 );
2432 assert!(
2433 !entry["document"].to_string().contains("ISO-10303-21"),
2434 "'{name}' must store NATIVE geometry, never the STEP text"
2435 );
2436 }
2437 }
2438
2439 #[derive(Default)]
2443 struct RecordingSink {
2444 written: std::collections::BTreeMap<String, String>,
2445 offers: usize,
2446 }
2447
2448 impl PartSink for RecordingSink {
2449 fn store_part(&mut self, part_name: &str, document_json: &str) -> Option<String> {
2450 self.offers += 1;
2451 let key = format!("/models/{part_name}.BREP.json");
2452 self.written.insert(key.clone(), document_json.to_string());
2453 Some(key)
2454 }
2455 }
2456
2457 #[test]
2468 fn a_sink_gives_every_unique_part_a_real_source_key_without_losing_dedup() {
2469 let text = step_fixture("as1-ug-214.stp");
2470 let mut state = EngineState::new();
2471 let mut sink = RecordingSink::default();
2472 state.probe_step_assembly(&text).unwrap();
2473 let report = state
2474 .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut sink)
2475 .expect("structured import");
2476
2477 assert_eq!(
2478 (report.parts, report.instances),
2479 (5, 18),
2480 "still 5 distinct parts in 18 places — the sink must not split them"
2481 );
2482 assert_eq!(
2483 sink.written.len(),
2484 5,
2485 "one file per DISTINCT part, not per occurrence: {:?}",
2486 sink.written.keys().collect::<Vec<_>>()
2487 );
2488 assert_eq!(
2489 sink.offers, 5,
2490 "and the store is offered each part exactly once — identical \
2491 content is written once, not written and then deduped"
2492 );
2493
2494 let library: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
2495 &brep_kernel::parts_library_json(),
2496 )
2497 .unwrap();
2498 assert_eq!(library.len(), 5, "five entries, one per part");
2499 for (name, entry) in &library {
2500 let key = entry["sourceKey"].as_str().unwrap_or_default();
2501 assert!(!key.is_empty(), "'{name}' must carry a real sourceKey");
2502 let stored = sink
2503 .written
2504 .get(key)
2505 .unwrap_or_else(|| panic!("'{name}' key '{key}' names a written file"));
2506 assert_eq!(
2510 entry["sourceSignature"],
2511 serde_json::Value::String(document_signature(stored)),
2512 "'{name}': the entry's signature and the stored file must \
2513 describe the same content"
2514 );
2515 }
2516 }
2517
2518 #[test]
2522 fn a_declining_sink_leaves_that_part_embedded_and_imports_the_rest() {
2523 struct PickySink;
2524 impl PartSink for PickySink {
2525 fn store_part(&mut self, part_name: &str, _document: &str) -> Option<String> {
2526 (part_name != "bolt").then(|| format!("/models/{part_name}.BREP.json"))
2527 }
2528 }
2529 let text = step_fixture("as1-ug-214.stp");
2530 let mut state = EngineState::new();
2531 state.probe_step_assembly(&text).unwrap();
2532 let report = state
2533 .import_probed_step_assembly("as1-ug", StepAssemblyImport::default(), &mut PickySink)
2534 .expect("structured import");
2535 assert_eq!(
2536 (report.parts, report.instances),
2537 (5, 18),
2538 "the import is unaffected by one refused write"
2539 );
2540 let library: serde_json::Map<String, serde_json::Value> =
2541 serde_json::from_str(&brep_kernel::parts_library_json()).unwrap();
2542 assert_eq!(
2543 library["bolt"]["sourceKey"], "",
2544 "the refused part falls back to embedded-only"
2545 );
2546 for (name, entry) in library.iter().filter(|(name, _)| name.as_str() != "bolt") {
2547 assert!(
2548 !entry["sourceKey"].as_str().unwrap_or_default().is_empty(),
2549 "'{name}' still got its file"
2550 );
2551 }
2552 }
2553
2554 #[test]
2561 fn structured_import_matches_the_flat_lane_geometry() {
2562 let text = step_fixture("as1-ug-214.stp");
2563
2564 let mut flat = EngineState::new();
2565 flat.import_step_feature(&text).expect("flat import");
2566
2567 let mut structured = EngineState::new();
2568 structured
2569 .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2570 .expect("structured import");
2571
2572 assert_eq!(
2573 structured.scene.solids().len(),
2574 flat.scene.solids().len(),
2575 "same body count"
2576 );
2577 assert_eq!(
2578 placed_bboxes(&structured),
2579 placed_bboxes(&flat),
2580 "every component must sit where the flat lane's baked body sits"
2581 );
2582 }
2583
2584 #[test]
2596 fn native_part_document_heals_and_converges() {
2597 let text = step_fixture("as1-ug-214.stp");
2598 let mut state = EngineState::new();
2599 state
2600 .import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
2601 .expect("as1-ug-214 imports as an assembly");
2602 let inserted = library();
2603
2604 let heal_once = |state: &mut EngineState| -> serde_json::Map<String, serde_json::Value> {
2609 let mut document: serde_json::Value =
2610 serde_json::from_str(&state.history_request_json()).expect("document JSON");
2611 for (_, entry) in document["partsLibrary"]
2612 .as_object_mut()
2613 .expect("the document carries the library")
2614 .iter_mut()
2615 {
2616 entry["snapshot"] = serde_json::Value::String(String::new());
2617 }
2618 state.set_history_json(&document.to_string()).expect("reopen");
2619 let healed = library();
2620 for (name, entry) in &healed {
2621 assert!(
2622 !entry["snapshot"].as_str().unwrap_or_default().is_empty(),
2623 "'{name}' must have healed its cleared snapshot"
2624 );
2625 }
2626 healed
2627 };
2628
2629 let first = heal_once(&mut state);
2630 let second = heal_once(&mut state);
2631 assert_eq!(
2632 first, second,
2633 "a second heal must reproduce the first BYTE for byte"
2634 );
2635 assert_eq!(first.len(), inserted.len(), "the heal keeps the same entries");
2636
2637 for (name, entry) in &first {
2638 assert_eq!(
2639 entry["snapshot"],
2640 inserted[name.as_str()]["snapshot"],
2641 "'{name}': the heal must reproduce what the INSERT stored — the \
2642 whole reason the import goes through add_part_to_library"
2643 );
2644 let payload = brep_kernel::restore_solids(&entry_payload(entry))
2647 .expect("the stored payload decodes");
2648 let healed = brep_kernel::restore_solids(entry["snapshot"].as_str().unwrap())
2649 .expect("the healed snapshot decodes");
2650 let names = |snapshot: &brep_kernel::RestoredSnapshot| -> Vec<String> {
2651 snapshot.solids.iter().map(|solid| solid.name.clone()).collect()
2652 };
2653 assert_eq!(names(&healed), names(&payload), "'{name}': identical body names");
2654 for (healed, stored) in healed.solids.iter().zip(payload.solids.iter()) {
2655 let (healed_data, healed_names) = brep_kernel::encode_solid(&healed.solid).unwrap();
2656 let (stored_data, stored_names) = brep_kernel::encode_solid(&stored.solid).unwrap();
2657 assert_eq!(healed_data, stored_data, "'{name}': identical geometry");
2658 assert_eq!(
2659 healed_names.faces, stored_names.faces,
2660 "'{name}': identical face names"
2661 );
2662 assert_eq!(
2663 healed_names.edges, stored_names.edges,
2664 "'{name}': identical edge names"
2665 );
2666 }
2667 assert!(
2668 healed.metadata.len() >= payload.metadata.len(),
2669 "'{name}': the heal's snapshot is a superset (sourceFeatureId records)"
2670 );
2671 }
2672 }
2673
2674 #[test]
2681 fn imported_part_payloads_never_capture_the_live_documents_metadata() {
2682 let step = box_step(4.0, 3.0, 2.0);
2683 let bodies = brep_kernel::import_step(&step).expect("the box imports");
2684
2685 let mut state = EngineState::new();
2689 state.import_step_feature(&step).expect("flat import");
2690
2691 let leaked = brep_kernel::restore_solids(
2695 &brep_kernel::native_import_payload("IMPORT3D1", &bodies).unwrap(),
2696 )
2697 .unwrap();
2698 assert!(
2699 !leaked.metadata.is_empty(),
2700 "the live document must actually hold records under these names"
2701 );
2702
2703 state.pending_step_assembly = Some(synthetic_assembly(bodies.clone(), &[(MAT4_IDENTITY, true)]));
2704 state
2705 .import_probed_step_assembly("collide", StepAssemblyImport::default(), &mut EmbeddedOnly)
2706 .expect("the synthetic assembly imports");
2707
2708 let entry = library().into_iter().next().expect("one entry").1;
2709 let stored = brep_kernel::restore_solids(&entry_payload(&entry)).expect("payload decodes");
2710 assert!(
2711 stored.metadata.is_empty(),
2712 "the part's payload must carry the PART's metadata (it has none), \
2713 never the live document's: {:?}",
2714 stored.metadata
2715 );
2716
2717 let after = brep_kernel::restore_solids(
2720 &brep_kernel::native_import_payload("IMPORT3D1", &bodies).unwrap(),
2721 )
2722 .unwrap();
2723 assert_eq!(
2724 after.metadata, leaked.metadata,
2725 "the live document's scene metadata must survive the import"
2726 );
2727 }
2728
2729 #[test]
2735 fn nonrigid_occurrence_bakes_a_distinct_part() {
2736 let bodies = brep_kernel::import_step(&box_step(4.0, 3.0, 2.0)).expect("box imports");
2737 let mirrored = [
2739 -1.0, 0.0, 0.0, 20.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,
2743 ];
2744 let mut state = EngineState::new();
2745 state.pending_step_assembly = Some(synthetic_assembly(
2746 bodies,
2747 &[(MAT4_IDENTITY, true), (mirrored, false)],
2748 ));
2749 let report = state
2750 .import_probed_step_assembly("mirror", StepAssemblyImport::default(), &mut EmbeddedOnly)
2751 .expect("the mirrored assembly imports");
2752
2753 assert_eq!(report.instances, 2, "both occurrences become components");
2754 assert_eq!(report.baked_nonrigid, 1, "one of them baked its factor");
2755 assert_eq!(report.parts, 2, "the mirrored instance is its OWN part");
2756 let counts = instance_counts(&state);
2757 assert_eq!(
2758 counts.get("widget").copied(),
2759 Some(1),
2760 "the plain instance keeps the plain part: {counts:?}"
2761 );
2762 assert_eq!(
2763 counts.get("widget (mirrored)").copied(),
2764 Some(1),
2765 "the mirrored instance gets its own entry: {counts:?}"
2766 );
2767 assert_eq!(state.scene.solids().len(), 2);
2770 let mut centers: Vec<f64> = state
2771 .scene
2772 .solids()
2773 .iter()
2774 .map(|solid| solid.bbox.center()[0])
2775 .collect();
2776 centers.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
2777 assert!(
2778 (centers[0] + centers[1] - 20.0).abs() < 1e-3
2779 && (centers[1] - centers[0]).abs() > 1e-3,
2780 "the mirror must land at 20 − x̄, not on top of its twin: {centers:?}"
2781 );
2782 }
2783
2784 const NESTED: StepAssemblyImport = StepAssemblyImport { nested: true };
2790
2791 fn child_library(document: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
2794 document["partsLibrary"]
2795 .as_object()
2796 .cloned()
2797 .unwrap_or_default()
2798 }
2799
2800 fn child_components(document: &serde_json::Value) -> Vec<String> {
2803 document["features"]
2804 .as_array()
2805 .map(Vec::as_slice)
2806 .unwrap_or_default()
2807 .iter()
2808 .filter(|feature| feature["type"] == "ACOMP")
2809 .map(|feature| feature["inputParams"]["partName"].as_str().unwrap().to_string())
2810 .collect()
2811 }
2812
2813 fn world_placed(state: &mut EngineState) -> Vec<[f64; 10]> {
2824 state.ensure_assembly_synced();
2825 let library = library();
2826 let mut out = Vec::new();
2827 for record in state.assembly_components() {
2828 let entry = &library[record.part_name.as_str()];
2829 let restored = brep_kernel::restore_solids(entry["snapshot"].as_str().unwrap())
2830 .expect("every entry's snapshot decodes");
2831 let mirrored = record.transform.determinant3() < 0.0;
2832 for solid in &restored.solids {
2833 let posed = brep_kernel::transform_brep(&solid.solid, record.transform, mirrored)
2834 .expect("a component pose is rigid");
2835 let mass =
2836 brep_kernel::solid_mass_properties_full(&posed).expect("mass properties");
2837 let mut lo = [f64::INFINITY; 3];
2838 let mut hi = [f64::NEG_INFINITY; 3];
2839 for vertex in &posed.vertices {
2840 for axis in 0..3 {
2841 let value = [vertex.point.x, vertex.point.y, vertex.point.z][axis];
2842 lo[axis] = lo[axis].min(value);
2843 hi[axis] = hi[axis].max(value);
2844 }
2845 }
2846 out.push([
2847 mass.volume,
2848 mass.centroid.x,
2849 mass.centroid.y,
2850 mass.centroid.z,
2851 lo[0],
2852 lo[1],
2853 lo[2],
2854 hi[0],
2855 hi[1],
2856 hi[2],
2857 ]);
2858 }
2859 }
2860 out.sort_by_key(|row| row.map(|value| (value * 1.0e6).round() as i64));
2863 out
2864 }
2865
2866 fn assert_placed_eq(a: &[[f64; 10]], b: &[[f64; 10]], tolerance: f64, what: &str) {
2868 assert_eq!(a.len(), b.len(), "{what}: solid count");
2869 for (index, (left, right)) in a.iter().zip(b.iter()).enumerate() {
2870 for (column, (l, r)) in left.iter().zip(right.iter()).enumerate() {
2871 assert!(
2872 (l - r).abs() <= tolerance,
2873 "{what}: solid {index} column {column}: {l} != {r}"
2874 );
2875 }
2876 }
2877 }
2878
2879 #[test]
2887 fn nested_import_builds_child_libraries() {
2888 let text = step_fixture("as1-ug-214.stp");
2889 let mut state = EngineState::new();
2890 let report = state
2891 .import_step_assembly(&text, "as1-ug", NESTED)
2892 .expect("as1-ug-214 imports as a nested assembly");
2893
2894 assert!(!report.flat_fallback, "the structured lane ran");
2895 assert_eq!(
2896 (report.parts, report.instances),
2897 (3, 4),
2898 "the ROOT's children: plate, lb_assem ×2, rod_assem — a sub-assembly \
2899 is ONE component (build-spec §2.2), not one per leaf body"
2900 );
2901 assert_eq!(report.failed_products, 0, "every product encodes");
2902 assert_eq!(report.baked_nonrigid, 0, "every as1 occurrence is rigid");
2903 assert_eq!(
2904 instance_counts(&state),
2905 [("lb_assem".to_string(), 2), ("plate".to_string(), 1), ("rod_assem".to_string(), 1)]
2906 .into_iter()
2907 .collect::<std::collections::BTreeMap<_, _>>(),
2908 );
2909
2910 let library = library();
2912 let lb = &library["lb_assem"]["document"];
2913 assert_eq!(
2914 child_library(lb).keys().cloned().collect::<Vec<_>>(),
2915 vec!["l_bracket".to_string(), "nba".to_string()],
2916 "the sub-document's library is its own (build-spec §2.2: a part \
2917 reused across levels is stored once PER level)"
2918 );
2919 assert_eq!(
2920 child_components(lb),
2921 vec!["l_bracket", "nba", "nba", "nba"],
2922 "one ACOMP per child occurrence — three nut-bolt assemblies"
2923 );
2924
2925 let nba = &child_library(lb)["nba"]["document"];
2927 assert_eq!(
2928 child_library(nba).keys().cloned().collect::<Vec<_>>(),
2929 vec!["bolt".to_string(), "nut".to_string()],
2930 );
2931 assert_eq!(child_components(nba), vec!["bolt", "nut"]);
2932 for (name, entry) in child_library(nba) {
2938 assert_eq!(
2939 entry["sourceKey"], "",
2940 "'{name}': no sink, so the nested child stays embedded"
2941 );
2942 assert!(!entry_payload(&entry).is_empty(), "'{name}' is a native part");
2943 }
2944
2945 let names: Vec<&str> = state
2948 .scene
2949 .solids()
2950 .iter()
2951 .map(|solid| solid.name.as_str())
2952 .collect();
2953 assert!(
2954 names.iter().any(|name| name.matches("ACOMP").count() == 3),
2955 "a three-level chain must appear in the scene names: {names:?}"
2956 );
2957 assert!(
2958 names.iter().any(|name| name.starts_with("ACOMP2:ACOMP2:ACOMP1:")),
2959 "the chained prefix the structure tree reads back: {names:?}"
2960 );
2961 assert_eq!(
2962 names.len(),
2963 18,
2964 "the same 18 bodies the flat lane produces, reached through the tree"
2965 );
2966 }
2967
2968 #[test]
2977 fn nested_matches_flat_for_a_depth_one_tree() {
2978 let text = step_fixture("AssemblyExample-Assembly.step");
2979 let mut state = EngineState::new();
2980 let probe = state
2981 .probe_step_assembly(&text)
2982 .unwrap()
2983 .expect("the fixture carries structure");
2984 assert_eq!(probe.nested_depth, 1, "the fixture must be depth 1");
2985
2986 let mut flat = EngineState::new();
2987 let flat_report = flat
2988 .import_step_assembly(&text, "example", StepAssemblyImport::default())
2989 .expect("flat");
2990 let flat_document = flat.history_request_json();
2991
2992 let mut nested = EngineState::new();
2993 let nested_report = nested
2994 .import_step_assembly(&text, "example", NESTED)
2995 .expect("nested");
2996 assert_eq!(nested_report, flat_report, "identical report");
2997 assert_eq!(
2998 nested.history_request_json(),
2999 flat_document,
3000 "a depth-1 nested import must produce the FLAT document, byte for byte"
3001 );
3002
3003 let bodies = brep_kernel::import_step(&box_step(4.0, 3.0, 2.0)).expect("box imports");
3008 let placed = [
3009 1.0, 0.0, 0.0, 12.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,
3013 ];
3014 let mirrored = [
3015 -1.0, 0.0, 0.0, 30.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,
3019 ];
3020 let with_root_bodies = || {
3021 let mut assembly =
3022 synthetic_assembly(bodies.clone(), &[(placed, true), (mirrored, false)]);
3023 assembly.products[0].bodies = bodies.clone();
3024 assembly
3025 };
3026 let mut flat = EngineState::new();
3027 flat.pending_step_assembly = Some(with_root_bodies());
3028 flat.import_probed_step_assembly("root", StepAssemblyImport::default(), &mut EmbeddedOnly)
3029 .expect("flat");
3030 let flat_document = flat.history_request_json();
3031
3032 let mut nested = EngineState::new();
3033 nested.pending_step_assembly = Some(with_root_bodies());
3034 let report = nested
3035 .import_probed_step_assembly("root", NESTED, &mut EmbeddedOnly)
3036 .expect("nested");
3037 assert_eq!(report.baked_nonrigid, 1, "the mirrored occurrence baked");
3038 assert_eq!(
3039 nested.history_request_json(),
3040 flat_document,
3041 "interior geometry AT THE ROOT, and a mirrored leaf, are the same \
3042 components in both lanes"
3043 );
3044 }
3045
3046 #[test]
3056 fn interior_node_geometry_lives_inside_its_own_document() {
3057 let mid_bodies = brep_kernel::import_step(&box_step(6.0, 6.0, 1.0)).expect("plate");
3058 let leaf_bodies = brep_kernel::import_step(&box_step(2.0, 2.0, 2.0)).expect("stud");
3059 let shift = |x: f64, z: f64| {
3060 [
3061 1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, z, 0.0, 0.0, 0.0, 1.0,
3065 ]
3066 };
3067 let assembly = || brep_kernel::StepAssembly {
3068 products: vec![
3069 brep_kernel::StepProduct {
3070 pd_ref: 1,
3071 name: "root".into(),
3072 id: "root".into(),
3073 bodies: Vec::new(),
3074 appearances: Vec::new(),
3075 failed_bodies: 0,
3076 },
3077 brep_kernel::StepProduct {
3078 pd_ref: 2,
3079 name: "mid".into(),
3080 id: "mid".into(),
3081 bodies: mid_bodies.clone(),
3083 appearances: Vec::new(),
3084 failed_bodies: 0,
3085 },
3086 brep_kernel::StepProduct {
3087 pd_ref: 3,
3088 name: "stud".into(),
3089 id: "stud".into(),
3090 bodies: leaf_bodies.clone(),
3091 appearances: Vec::new(),
3092 failed_bodies: 0,
3093 },
3094 ],
3095 occurrences: vec![
3096 brep_kernel::StepOccurrence {
3097 nauo_ref: 10,
3098 parent: 0,
3099 child: 1,
3100 designator: "mid-1".into(),
3101 placement: shift(20.0, 0.0),
3102 rigid: true,
3103 },
3104 brep_kernel::StepOccurrence {
3105 nauo_ref: 11,
3106 parent: 1,
3107 child: 2,
3108 designator: "stud-1".into(),
3109 placement: shift(2.0, 1.0),
3110 rigid: true,
3111 },
3112 ],
3113 roots: vec![0],
3114 first_error: None,
3115 };
3116
3117 let mut nested = EngineState::new();
3118 nested.pending_step_assembly = Some(assembly());
3119 let report = nested
3120 .import_probed_step_assembly("interior", NESTED, &mut EmbeddedOnly)
3121 .expect("nested import");
3122 assert_eq!(
3123 (report.parts, report.instances),
3124 (1, 1),
3125 "ONE component — `mid` and everything under it"
3126 );
3127
3128 let document = &library()["mid"]["document"];
3130 let kinds: Vec<&str> = document["features"]
3131 .as_array()
3132 .unwrap()
3133 .iter()
3134 .map(|feature| feature["type"].as_str().unwrap())
3135 .collect();
3136 assert_eq!(
3137 kinds,
3138 vec!["IMPORT3D", "ACOMP"],
3139 "the node's OWN bodies ride in ITS document, alongside its children"
3140 );
3141 assert_eq!(child_components(document), vec!["stud"]);
3142 assert!(
3143 !document["features"][0]["inputParams"]["nativeBrep"]
3144 .as_str()
3145 .unwrap_or_default()
3146 .is_empty(),
3147 "the interior geometry is a native payload, not STEP text"
3148 );
3149
3150 let names: Vec<&str> = nested
3154 .scene
3155 .solids()
3156 .iter()
3157 .map(|solid| solid.name.as_str())
3158 .collect();
3159 assert!(names.contains(&"ACOMP1:IMPORT3D1"), "mid's own body: {names:?}");
3160 assert!(
3161 names.contains(&"ACOMP1:ACOMP1:IMPORT3D1"),
3162 "the stud, one level deeper: {names:?}"
3163 );
3164
3165 let nested_geometry = world_placed(&mut nested);
3169 let mut flat = EngineState::new();
3170 flat.pending_step_assembly = Some(assembly());
3171 flat.import_probed_step_assembly("interior", StepAssemblyImport::default(), &mut EmbeddedOnly)
3172 .expect("flat import");
3173 assert_eq!(
3174 placed_bboxes(&nested),
3175 placed_bboxes(&flat),
3176 "interior geometry must sit where the flat lane's composed pose puts it"
3177 );
3178 assert_placed_eq(
3179 &nested_geometry,
3180 &world_placed(&mut flat),
3181 1.0e-9,
3182 "interior node, nested vs flat",
3183 );
3184 }
3185
3186 #[test]
3193 fn a_mirrored_sub_assembly_is_reported_not_silently_mis_handed() {
3194 let bodies = brep_kernel::import_step(&box_step(4.0, 3.0, 2.0)).expect("box imports");
3195 let mirror = [
3196 -1.0, 0.0, 0.0, 30.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,
3200 ];
3201 let assembly = || brep_kernel::StepAssembly {
3204 products: vec![
3205 brep_kernel::StepProduct {
3206 pd_ref: 1,
3207 name: "root".into(),
3208 id: "root".into(),
3209 bodies: Vec::new(),
3210 appearances: Vec::new(),
3211 failed_bodies: 0,
3212 },
3213 brep_kernel::StepProduct {
3214 pd_ref: 2,
3215 name: "subasm".into(),
3216 id: "subasm".into(),
3217 bodies: Vec::new(),
3218 appearances: Vec::new(),
3219 failed_bodies: 0,
3220 },
3221 brep_kernel::StepProduct {
3222 pd_ref: 3,
3223 name: "widget".into(),
3224 id: "widget".into(),
3225 bodies: bodies.clone(),
3226 appearances: Vec::new(),
3227 failed_bodies: 0,
3228 },
3229 ],
3230 occurrences: vec![
3231 brep_kernel::StepOccurrence {
3232 nauo_ref: 10,
3233 parent: 0,
3234 child: 1,
3235 designator: "sub".into(),
3236 placement: mirror,
3237 rigid: false,
3238 },
3239 brep_kernel::StepOccurrence {
3240 nauo_ref: 11,
3241 parent: 0,
3242 child: 2,
3243 designator: "loose".into(),
3244 placement: MAT4_IDENTITY,
3245 rigid: true,
3246 },
3247 brep_kernel::StepOccurrence {
3248 nauo_ref: 12,
3249 parent: 1,
3250 child: 2,
3251 designator: "inner".into(),
3252 placement: MAT4_IDENTITY,
3253 rigid: true,
3254 },
3255 ],
3256 roots: vec![0],
3257 first_error: None,
3258 };
3259
3260 let mut state = EngineState::new();
3261 state.pending_step_assembly = Some(assembly());
3262 let report = state
3263 .import_probed_step_assembly("mirror-sub", NESTED, &mut EmbeddedOnly)
3264 .expect("the rest of the file still imports");
3265 assert_eq!(report.instances, 1, "only the plain leaf lands");
3266 assert_eq!(report.baked_nonrigid, 0, "a sub-assembly is never baked");
3267 assert!(
3268 report
3269 .first_error
3270 .as_deref()
3271 .is_some_and(|error| error.contains("import flat instead")),
3272 "the user is told what to do instead: {:?}",
3273 report.first_error
3274 );
3275
3276 let mut flat = EngineState::new();
3278 flat.pending_step_assembly = Some(assembly());
3279 let flat_report = flat
3280 .import_probed_step_assembly("mirror-sub", StepAssemblyImport::default(), &mut EmbeddedOnly)
3281 .expect("flat");
3282 assert_eq!(
3283 (flat_report.instances, flat_report.baked_nonrigid),
3284 (2, 1),
3285 "flat places both and bakes the mirrored one"
3286 );
3287 }
3288
3289 #[test]
3295 fn nested_import_matches_the_flat_lane_geometry() {
3296 let text = step_fixture("as1-ug-214.stp");
3297
3298 let mut flat = EngineState::new();
3299 flat.import_step_assembly(&text, "as1-ug", StepAssemblyImport::default())
3300 .expect("flat import");
3301 let flat_geometry = world_placed(&mut flat);
3302
3303 let mut nested = EngineState::new();
3304 nested
3305 .import_step_assembly(&text, "as1-ug", NESTED)
3306 .expect("nested import");
3307 let nested_geometry = world_placed(&mut nested);
3308
3309 assert_eq!(flat_geometry.len(), 18, "as1-ug-214 places 18 bodies");
3310 assert_placed_eq(&nested_geometry, &flat_geometry, 1.0e-9, "nested vs flat");
3311 assert_eq!(placed_bboxes(&nested), placed_bboxes(&flat));
3314 }
3315
3316 #[test]
3321 fn nested_part_documents_heal_and_converge() {
3322 let text = step_fixture("as1-ug-214.stp");
3323 let mut state = EngineState::new();
3324 state
3325 .import_step_assembly(&text, "as1-ug", NESTED)
3326 .expect("nested import");
3327 let inserted = library();
3328
3329 let heal_once = |state: &mut EngineState| {
3330 let mut document: serde_json::Value =
3331 serde_json::from_str(&state.history_request_json()).expect("document JSON");
3332 for (_, entry) in document["partsLibrary"].as_object_mut().unwrap().iter_mut() {
3333 entry["snapshot"] = serde_json::Value::String(String::new());
3334 }
3335 state.set_history_json(&document.to_string()).expect("reopen");
3336 library()
3337 };
3338 let first = heal_once(&mut state);
3339 let second = heal_once(&mut state);
3340 assert_eq!(first, second, "a second heal reproduces the first");
3341 assert_eq!(
3342 first, inserted,
3343 "a heal of a SUB-ASSEMBLY entry reproduces what the insert stored — \
3344 the inner entries carry no snapshot, so this is the whole recursive \
3345 re-execution converging"
3346 );
3347 for (name, entry) in &first {
3348 assert!(
3349 !entry["snapshot"].as_str().unwrap_or_default().is_empty(),
3350 "'{name}' healed its cleared snapshot"
3351 );
3352 }
3353 }
3354
3355 #[test]
3360 fn nested_import_guards_cycles_and_depth() {
3361 let bodies = brep_kernel::import_step(&box_step(2.0, 2.0, 2.0)).expect("box imports");
3362 let shift = |x: f64| {
3363 [
3364 1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
3368 ]
3369 };
3370 let chain = |levels: usize, cycle: bool| {
3373 let mut products: Vec<brep_kernel::StepProduct> = (0..=levels)
3374 .map(|index| brep_kernel::StepProduct {
3375 pd_ref: index + 1,
3376 name: format!("n{index}"),
3377 id: format!("n{index}"),
3378 bodies: (index == levels).then(|| bodies.clone()).unwrap_or_default(),
3379 appearances: Vec::new(),
3380 failed_bodies: 0,
3381 })
3382 .collect();
3383 products[0].name = "root".into();
3384 let mut occurrences: Vec<brep_kernel::StepOccurrence> = (0..levels)
3385 .map(|index| brep_kernel::StepOccurrence {
3386 nauo_ref: 100 + index,
3387 parent: index,
3388 child: index + 1,
3389 designator: format!("link{index}"),
3390 placement: shift(1.0),
3391 rigid: true,
3392 })
3393 .collect();
3394 if cycle {
3395 occurrences.push(brep_kernel::StepOccurrence {
3396 nauo_ref: 90,
3397 parent: levels,
3398 child: 1,
3399 designator: "back".into(),
3400 placement: shift(1.0),
3401 rigid: true,
3402 });
3403 }
3404 brep_kernel::StepAssembly {
3405 products,
3406 occurrences,
3407 roots: vec![0],
3408 first_error: None,
3409 }
3410 };
3411
3412 let mut state = EngineState::new();
3414 state.pending_step_assembly = Some(chain(3, true));
3415 let report = state
3416 .import_probed_step_assembly("cyclic", NESTED, &mut EmbeddedOnly)
3417 .expect("a cyclic structure still imports what it can");
3418 assert_eq!(report.instances, 1, "the root places its one child");
3419 assert!(
3420 report
3421 .first_error
3422 .as_deref()
3423 .is_some_and(|error| error.contains("cycle")),
3424 "the skipped back-edge is reported: {:?}",
3425 report.first_error
3426 );
3427 assert!(!state.scene.solids().is_empty(), "the geometry still arrives");
3428
3429 let mut state = EngineState::new();
3432 state.pending_step_assembly = Some(chain(MAX_NESTED_DEPTH + 40, false));
3433 let error = state
3434 .import_probed_step_assembly("deep", NESTED, &mut EmbeddedOnly)
3435 .expect_err("a 100-level nesting has no usable document");
3436 assert!(
3437 error.contains("no part of the assembly could be built"),
3438 "the dialog's cue to fall back to the flat import: {error}"
3439 );
3440 let mut state = EngineState::new();
3441 state.pending_step_assembly = Some(chain(MAX_NESTED_DEPTH + 40, false));
3442 assert!(
3443 state
3444 .import_probed_step_assembly("deep", StepAssemblyImport::default(), &mut EmbeddedOnly)
3445 .is_ok(),
3446 "the FLAT lane composes instead of embedding, so depth costs it nothing"
3447 );
3448 }
3449
3450 #[test]
3453 fn add_features_appends_a_batch_in_one_rebuild() {
3454 let mut state = EngineState::new();
3455 state.set_history_json(&cube_history("Box", 4.0)).unwrap();
3456 let before = state.applied_generation();
3457
3458 state.add_features(&[]);
3459 assert_eq!(
3460 (state.applied_generation(), state.history_len()),
3461 (before, 1),
3462 "an empty batch neither re-runs nor appends"
3463 );
3464
3465 let features: Vec<serde_json::Value> = (0..3)
3466 .map(|index| {
3467 serde_json::json!({
3468 "type": "P.CU",
3469 "inputParams": {
3470 "id": format!("Cube{index}"),
3471 "sizeX": 2.0, "sizeY": 2.0, "sizeZ": 2.0,
3472 "transform": {
3473 "position": [10.0 * index as f64, 0.0, 0.0],
3474 "rotationEuler": [0.0, 0.0, 0.0],
3475 "scale": [1.0, 1.0, 1.0]
3476 },
3477 "boolean": { "targets": [], "operation": "NONE" }
3478 },
3479 "persistentData": {}
3480 })
3481 })
3482 .collect();
3483 state.add_features(&features);
3484
3485 assert_eq!(state.history_len(), 4, "all three appended");
3486 assert_eq!(
3487 state.applied_generation(),
3488 before + 1,
3489 "ONE rebuild for the batch"
3490 );
3491 assert_eq!(state.scene.solids().len(), 4);
3492 state.undo();
3493 assert_eq!(state.history_len(), 1, "the batch undoes in ONE step");
3494 }
3495}
3496
3497