diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 1aac2cb3d..401bac658 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -11,7 +11,7 @@ cast, get_args, ) -from typing_extensions import Literal +from typing_extensions import Literal, Self from typish import instance_of from uuid import uuid1 as uuid @@ -34,6 +34,7 @@ exportGLTF, STEPExportModeLiterals, ) +from .occ_impl.importers.assembly import importStep as _importStep from .selectors import _expression_grammar as _selector_grammar from .utils import deprecate @@ -172,7 +173,7 @@ def add( loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, - ) -> "Assembly": + ) -> Self: """ Add a subassembly to the current assembly. @@ -194,7 +195,7 @@ def add( name: Optional[str] = None, color: Optional[Color] = None, metadata: Optional[Dict[str, Any]] = None, - ) -> "Assembly": + ) -> Self: """ Add a subassembly to the current assembly with explicit location and name. @@ -342,11 +343,11 @@ def _subloc(self, name: str) -> Tuple[Location, str]: @overload def constrain( self, q1: str, q2: str, kind: ConstraintKind, param: Any = None - ) -> "Assembly": + ) -> Self: ... @overload - def constrain(self, q1: str, kind: ConstraintKind, param: Any = None) -> "Assembly": + def constrain(self, q1: str, kind: ConstraintKind, param: Any = None) -> Self: ... @overload @@ -358,13 +359,13 @@ def constrain( s2: Shape, kind: ConstraintKind, param: Any = None, - ) -> "Assembly": + ) -> Self: ... @overload def constrain( self, id1: str, s1: Shape, kind: ConstraintKind, param: Any = None, - ) -> "Assembly": + ) -> Self: ... def constrain(self, *args, param=None): @@ -409,7 +410,7 @@ def constrain(self, *args, param=None): return self - def solve(self, verbosity: int = 0) -> "Assembly": + def solve(self, verbosity: int = 0) -> Self: """ Solve the constraints. """ @@ -504,7 +505,7 @@ def save( tolerance: float = 0.1, angularTolerance: float = 0.1, **kwargs, - ) -> "Assembly": + ) -> Self: """ Save assembly to a file. @@ -560,7 +561,7 @@ def export( tolerance: float = 0.1, angularTolerance: float = 0.1, **kwargs, - ) -> "Assembly": + ) -> Self: """ Save assembly to a file. @@ -609,7 +610,21 @@ def export( return self @classmethod - def load(cls, path: str) -> "Assembly": + def importStep(cls, path: str) -> Self: + """ + Reads an assembly from a STEP file. + + :param path: Path and filename for writing. + :return: An Assembly object. + """ + + assy = cls() + _importStep(assy, path) + + return assy + + @classmethod + def load(cls, path: str) -> Self: raise NotImplementedError diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 4ef486e26..4b00bb1d0 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -10,7 +10,7 @@ List, cast, ) -from typing_extensions import Protocol +from typing_extensions import Protocol, Self from math import degrees, radians from OCP.TDocStd import TDocStd_Document @@ -148,6 +148,10 @@ def loc(self, value: Location) -> None: def name(self) -> str: ... + @name.setter + def name(self, value: str) -> None: + ... + @property def parent(self) -> Optional["AssemblyProtocol"]: ... @@ -180,6 +184,50 @@ def _subshape_colors(self) -> Dict[Shape, Color]: def _subshape_layers(self) -> Dict[Shape, str]: ... + @overload + def add( + self, + obj: Self, + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + ) -> Self: + ... + + @overload + def add( + self, + obj: AssemblyObjects, + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> Self: + ... + + def add( + self, + obj: Union[Self, AssemblyObjects], + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Self: + """ + Add a subassembly to the current assembly. + """ + ... + + def addSubshape( + self, + s: Shape, + name: Optional[str] = None, + color: Optional[Color] = None, + layer: Optional[str] = None, + ) -> Self: + ... + def traverse(self) -> Iterable[Tuple[str, "AssemblyProtocol"]]: ... diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py new file mode 100644 index 000000000..182578160 --- /dev/null +++ b/cadquery/occ_impl/importers/assembly.py @@ -0,0 +1,216 @@ +from OCP.TopoDS import TopoDS_Shape +from OCP.TCollection import TCollection_ExtendedString +from OCP.Quantity import Quantity_ColorRGBA +from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator +from OCP.IFSelect import IFSelect_RetDone +from OCP.TDocStd import TDocStd_Document +from OCP.TDataStd import TDataStd_Name +from OCP.TNaming import TNaming_NamedShape +from OCP.STEPCAFControl import STEPCAFControl_Reader +from OCP.XCAFDoc import XCAFDoc_ColorSurf, XCAFDoc_DocumentTool, XCAFDoc_GraphNode + +import cadquery as cq +from ..assembly import AssemblyProtocol + + +def importStep(assy: AssemblyProtocol, path: str): + """ + Import a step file into an assembly. + + :param assy: An Assembly object that will be packed with the contents of the STEP file. + :param path: Path and filename to the STEP file to read. + + :return: None + """ + + def _process_label(lbl: TDF_Label): + """ + Recursive method to process the assembly in a top-down manner. + """ + + # Instantiate the new assembly + new_assy = cq.Assembly() + + # Look for components + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(lbl, comp_labels) + + for i in range(comp_labels.Length()): + comp_label = comp_labels.Value(i + 1) + + # Get the location of the component label + loc = shape_tool.GetLocation_s(comp_label) + cq_loc = cq.Location(loc) if loc else None + + if shape_tool.IsReference_s(comp_label): + ref_label = TDF_Label() + shape_tool.GetReferredShape_s(comp_label, ref_label) + + # Find the name of this referenced part + ref_name_attr = TDataStd_Name() + if ref_label.FindAttribute(TDataStd_Name.GetID_s(), ref_name_attr): + ref_name = str(ref_name_attr.Get().ToExtString()) + + if shape_tool.IsAssembly_s(ref_label): + # Recursively process subassemblies + sub_assy = _process_label(ref_label) + + # Add the appropriate attributes to the subassembly + new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) + elif shape_tool.IsSimpleShape_s(ref_label): + # A single shape needs to be added to the assembly + final_shape = shape_tool.GetShape_s(ref_label) + cq_shape = cq.Shape.cast(final_shape) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(final_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + else: + cq_color = None + + new_assy.add( + cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color + ) + + # Search for subshape names, layers and colors + for j in range(ref_label.NbChildren()): + child_label = ref_label.FindChild(j + 1) + + # Save the shape so that we can add it to the subshape data + cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + new_assy.addSubshape( + cq.Shape.cast(cur_shape), layer=layer_name + ) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha(), + ) + + # Save the color info via the assembly subshape mechanism + new_assy.addSubshape( + cq.Shape.cast(cur_shape), color=cq_color + ) + + # Iterate through all the attributes looking for subshape names. + # This is safer than trying to access the attributes directly with + # FindAttribute because it will cause a segfault in certain cases. + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # TNaming_NamedShape is used to store and manage references to + # topological shapes, and its attributes can be accessed directly. + # XCAFDoc_GraphNode contains a graph of labels, and so we must + # follow the branch back to a father. + if isinstance(current_attr, XCAFDoc_GraphNode): + lbl = current_attr.GetFather(1).Label() + + # Find the name attribute and add it for the subshape + name_attr = TDataStd_Name() + if lbl.FindAttribute( + TDataStd_Name.GetID_s(), name_attr + ): + # Save this as the name of the subshape + new_assy.addSubshape( + cq.Shape.cast(cur_shape), + name=name_attr.Get().ToExtString(), + ) + elif isinstance(current_attr, TNaming_NamedShape): + # Save the shape so that we can add it to the subshape data + cur_shape = shape_tool.GetShape_s(child_label) + + attr_iterator.Next() + + return new_assy + + # Document that the step file will be read into + doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) + + # Create and configure a STEP reader + step_reader = STEPCAFControl_Reader() + step_reader.SetColorMode(True) + step_reader.SetNameMode(True) + step_reader.SetLayerMode(True) + step_reader.SetSHUOMode(True) + + # Read the STEP file + status = step_reader.ReadFile(path) + if status != IFSelect_RetDone: + raise ValueError(f"Error reading STEP file: {path}") + + # Transfer the contents of the STEP file to the document + step_reader.Transfer(doc) + + # Shape and color tools for extracting XCAF data + shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) + color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + layer_tool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) + + # Collect all the labels representing shapes in the document + labels = TDF_LabelSequence() + shape_tool.GetFreeShapes(labels) + + # Get the top-level label, which should represent an assembly + top_level_label = labels.Value(1) + # Make sure there is a top-level assembly + if shape_tool.IsTopLevel(top_level_label) and shape_tool.IsAssembly_s( + top_level_label + ): + # Set the name of the top-level assembly to match the top-level label + name_attr = TDataStd_Name() + top_level_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + assy.name = str(name_attr.Get().ToExtString()) + + # Get the location of the top-level component + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(top_level_label, comp_labels) + comp_label = comp_labels.Value(1) + loc = shape_tool.GetLocation_s(comp_label) + assy.loc = cq.Location(loc) + + # Start the recursive processing of labels + imported_assy = _process_label(top_level_label) + + # Handle a possible extra top-level node. This is done because cq.Assembly.export + # adds an extra top-level node which will cause a cascade of + # extras on successive round-trips. exportStepMeta does not add the extra top-level + # node and so does not exhibit this behavior. + if assy.name == imported_assy.children[0].name: + imported_assy = imported_assy.children[0] + + # Copy all of the children over to the main assembly object + for child in imported_assy.children: + assy.add(child, name=child.name, color=child.color, loc=child.loc) + + # Copy across subshape data + for shape, name in imported_assy._subshape_names.items(): + assy.addSubshape(shape, name=name) + for shape, color in imported_assy._subshape_colors.items(): + assy.addSubshape(shape, color=color) + for shape, layer in imported_assy._subshape_layers.items(): + assy.addSubshape(shape, layer=layer) + else: + raise ValueError("Step file does not contain an assembly") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 75acac7b1..c33b6e361 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -34,7 +34,7 @@ from OCP.STEPCAFControl import STEPCAFControl_Reader from OCP.IFSelect import IFSelect_RetDone from OCP.TDF import TDF_ChildIterator -from OCP.Quantity import Quantity_ColorRGBA, Quantity_TOC_sRGB +from OCP.Quantity import Quantity_ColorRGBA, Quantity_TOC_sRGB, Quantity_NameOfColor from OCP.TopAbs import TopAbs_ShapeEnum @@ -366,6 +366,41 @@ def chassis0_assy(): return chassis +def subshape_assy(): + """ + Builds an assembly with the needed subshapes to test the export and import of STEP files. + """ + + # Create a simple assembly + assy = cq.Assembly(name="top-level") + cube_1 = cq.Workplane().box(10.0, 10.0, 10.0) + assy.add(cube_1, name="cube_1", color=cq.Color("green")) + + # Add subshape name, color and layer + assy.addSubshape( + cube_1.faces(">Z").val(), + name="cube_1_top_face", + color=cq.Color("red"), + layer="cube_1_top_face", + ) + + # Add a cylinder to the assembly + cyl_1 = cq.Workplane().cylinder(10.0, 2.5) + assy.add( + cyl_1, name="cyl_1", color=cq.Color("blue"), loc=cq.Location((0.0, 0.0, -10.0)) + ) + + # Add a subshape face for the cylinder + assy.addSubshape( + cyl_1.faces(" TDocStd_Document: """Read STEP file, return XCAF document""" @@ -785,6 +820,369 @@ def test_meta_step_export_edge_cases(tmp_path_factory): assert success +def test_assembly_step_import(tmp_path_factory): + """ + Test if the STEP import works correctly for an assembly with subshape data attached. + """ + assy = subshape_assy() + + # Use a temporary directory + tmpdir = tmp_path_factory.mktemp("out") + assy_step_path = os.path.join(tmpdir, "assembly_with_subshapes.step") + + success = exportStepMeta(assy, assy_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(assy_step_path) + + # Check that the assembly was imported successfully + assert imported_assy is not None + + # Check for appropriate part name + assert imported_assy.children[0].name == "cube_1" + # Check for approximate color match + assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == ( + 0.0, + 1.0, + 0.0, + 1.0, + ) + # Check for appropriate part name + assert imported_assy.children[1].name == "cyl_1" + # Check for approximate color match + assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == ( + 0.0, + 0.0, + 1.0, + 1.0, + ) + + # Make sure the shape locations were applied correctly + assert imported_assy.children[1].loc.toTuple()[0] == (0.0, 0.0, -10.0) + + # Check the top-level assembly name + assert imported_assy.name == "top-level" + + # Test a STEP file that does not contain an assembly + wp_step_path = os.path.join(tmpdir, "plain_workplane.step") + res = cq.Workplane().box(10, 10, 10) + res.export(wp_step_path) + + # Import the STEP file back in + with pytest.raises(ValueError): + imported_assy = cq.Assembly.importStep(wp_step_path) + + +def test_assembly_subshape_step_import(tmp_path_factory): + """ + Test if a STEP file containing subshape information can be imported correctly. + """ + + tmpdir = tmp_path_factory.mktemp("out") + assy_step_path = os.path.join(tmpdir, "subshape_assy.step") + + # Create a basic assembly + cube_1 = cq.Workplane().box(10, 10, 10) + assy = cq.Assembly(name="top_level") + assy.add(cube_1, name="cube_1") + + # Add subshape name, color and layer + assy.addSubshape( + cube_1.faces(">Z").val(), + name="cube_1_top_face", + color=cq.Color("red"), + layer="cube_1_top_face", + ) + + # Export the assembly + success = exportStepMeta(assy, assy_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(assy_step_path) + assert imported_assy.name == "top_level" + + # Check the advanced face name + assert len(imported_assy._subshape_names) == 1 + assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + + # Check the color + color = list(imported_assy._subshape_colors.values())[0] + assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() + + # Check the layer info + layer_name = list(imported_assy._subshape_layers.values())[0] + assert layer_name == "cube_1_top_face" + + +def test_assembly_multi_subshape_step_import(tmp_path_factory): + """ + Test if a STEP file containing subshape information can be imported correctly. + """ + + tmpdir = tmp_path_factory.mktemp("out") + assy_step_path = os.path.join(tmpdir, "multi_subshape_assy.step") + + # Create a basic assembly + cube_1 = cq.Workplane().box(10, 10, 10) + assy = cq.Assembly(name="top_level") + assy.add(cube_1, name="cube_1", color=cq.Color("green")) + cube_2 = cq.Workplane().box(5, 5, 5) + assy.add(cube_2, name="cube_2", color=cq.Color("blue"), loc=cq.Location(10, 10, 10)) + + # Add subshape name, color and layer + assy.addSubshape( + cube_1.faces(">Z").val(), + name="cube_1_top_face", + color=cq.Color("red"), + layer="cube_1_top_face", + ) + assy.addSubshape( + cube_2.faces(">X").val(), + name="cube_2_right_face", + color=cq.Color("red"), + layer="cube_2_right_face", + ) + + # Export the assembly + success = exportStepMeta(assy, assy_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(assy_step_path) + + # Check that the top-level assembly name is correct + assert imported_assy.name == "top_level" + + # Check the advanced face name + assert len(imported_assy._subshape_names) == 2 + assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + + # Check the color + color = list(imported_assy._subshape_colors.values())[0] + assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() + + # Check the layer info + layer_name = list(imported_assy._subshape_layers.values())[0] + assert layer_name == "cube_1_top_face" + + +def test_bad_step_file_import(tmp_path_factory): + """ + Test if a bad STEP file raises an error when importing. + """ + + tmpdir = tmp_path_factory.mktemp("out") + bad_step_path = os.path.join(tmpdir, "bad_step.step") + + # Check that an error is raised when trying to import a non-existent STEP file + with pytest.raises(ValueError): + # Export the assembly + imported_assy = cq.Assembly.importStep(bad_step_path) + + +def test_plain_assembly_import(tmp_path_factory): + """ + Test to make sure that importing plain assemblies has not been broken. + """ + + tmpdir = tmp_path_factory.mktemp("out") + plain_step_path = os.path.join(tmpdir, "plain_assembly_step.step") + + # Simple cubes + cube_1 = cq.Workplane().box(10, 10, 10) + cube_2 = cq.Workplane().box(5, 5, 5) + cube_3 = cq.Workplane().box(5, 5, 5) + cube_4 = cq.Workplane().box(5, 5, 5) + + assy = cq.Assembly(name="top_level", loc=cq.Location(10, 10, 10)) + assy.add(cube_1, color=cq.Color("green")) + assy.add(cube_2, loc=cq.Location((10, 10, 10)), color=cq.Color("red")) + assy.add(cube_3, loc=cq.Location((-10, -10, -10)), color=cq.Color("red")) + assy.add(cube_4, loc=cq.Location((10, -10, -10)), color=cq.Color("red")) + + # Export the assembly, but do not use the meta STEP export method + assy.export(plain_step_path) + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(plain_step_path) + assert imported_assy.name == "top_level" + + # Check the locations + assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0,) + assert imported_assy.children[1].loc.toTuple()[0] == (10.0, 10.0, 10.0,) + assert imported_assy.children[2].loc.toTuple()[0] == (-10.0, -10.0, -10.0,) + assert imported_assy.children[3].loc.toTuple()[0] == (10.0, -10.0, -10.0,) + + # Make sure the location of the top-level assembly was preserved + assert imported_assy.loc.toTuple() == cq.Location((10, 10, 10)).toTuple() + + # Check the colors + assert pytest.approx( + imported_assy.children[0].children[0].color.toTuple(), rel=0.01 + ) == ( + 0.0, + 1.0, + 0.0, + 1.0, + ) # green + assert pytest.approx( + imported_assy.children[1].children[0].color.toTuple(), rel=0.01 + ) == ( + 1.0, + 0.0, + 0.0, + 1.0, + ) # red + assert pytest.approx( + imported_assy.children[2].children[0].color.toTuple(), rel=0.01 + ) == ( + 1.0, + 0.0, + 0.0, + 1.0, + ) # red + assert pytest.approx( + imported_assy.children[3].children[0].color.toTuple(), rel=0.01 + ) == ( + 1.0, + 0.0, + 0.0, + 1.0, + ) # red + + +def test_copied_assembly_import(tmp_path_factory): + """ + Tests to make sure that copied children in assemblies work correctly. + """ + from cadquery import Assembly, Location, Color + from cadquery.func import box, rect + + # Create the temporary directory + tmpdir = tmp_path_factory.mktemp("out") + + # prepare the model + def make_model(name: str, COPY: bool): + name = os.path.join(tmpdir, name) + + b = box(1, 1, 1) + + assy = Assembly(name="test_assy") + assy.add(box(1, 2, 5), color=Color("green")) + + for v in rect(10, 10).vertices(): + assy.add( + b.copy() if COPY else b, loc=Location(v.Center()), color=Color("red") + ) + + assy.export(name) + + return assy + + make_model("test_assy_copy.step", True) + make_model("test_assy.step", False) + + # import the assy with copies + assy_copy = Assembly.importStep(os.path.join(tmpdir, "test_assy_copy.step")) + assert 5 == len(assy_copy.children) + + # import the assy without copies + assy_normal = Assembly.importStep(os.path.join(tmpdir, "test_assy.step")) + assert 5 == len(assy_normal.children) + + +def test_nested_subassembly_step_import(tmp_path_factory): + """ + Tests if the STEP import works correctly with nested subassemblies. + """ + + tmpdir = tmp_path_factory.mktemp("out") + nested_step_path = os.path.join(tmpdir, "plain_assembly_step.step") + + # Create a simple assembly + assy = cq.Assembly() + assy.add(cq.Workplane().box(10, 10, 10), name="box_1") + + # Create a simple subassembly + subassy = cq.Assembly() + subassy.add(cq.Workplane().box(5, 5, 5), name="box_2", loc=cq.Location(10, 10, 10)) + + # Nest the subassembly + assy.add(subassy) + + # Export and then re-import the nested assembly STEP + assy.export(nested_step_path) + imported_assy = cq.Assembly.importStep(nested_step_path) + + # Check the locations + assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) + assert imported_assy.children[1].objects["box_2"].loc.toTuple()[0] == ( + 10.0, + 10.0, + 10.0, + ) + + +def test_assembly_step_import_roundtrip(tmp_path_factory): + """ + Tests that the assembly does not mutate during successive export-import round trips. + """ + + # Set up the temporary directory + tmpdir = tmp_path_factory.mktemp("out") + round_trip_step_path = os.path.join(tmpdir, "round_trip.step") + + # Create a sample assembly + assy = cq.Assembly(name="top-level") + assy.add(cq.Workplane().box(10, 10, 10), name="cube_1", color=cq.Color("red")) + subshape_assy = cq.Assembly(name="nested-assy") + subshape_assy.add( + cq.Workplane().cylinder(height=10.0, radius=2.5), + name="cylinder_1", + color=cq.Color("blue"), + loc=cq.Location((20, 20, 20)), + ) + assy.add(subshape_assy) + + # First export + assy.export(round_trip_step_path) + + # First import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Second export + assy.export(round_trip_step_path) + + # Second import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Check some general aspects of the assembly structure now + assert len(assy.children) == 2 + assert assy.name == "top-level" + assert assy.children[0].name == "cube_1" + assert assy.children[1].children[0].name == "cylinder_1" + + # First meta export + exportStepMeta(assy, round_trip_step_path) + + # First meta import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Second meta export + exportStepMeta(assy, round_trip_step_path) + + # Second meta import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Check some general aspects of the assembly structure now + assert len(assy.children) == 2 + assert assy.name == "top-level" + assert assy.children[0].name == "cube_1" + assert assy.children[1].children[0].name == "cylinder_1" + + @pytest.mark.parametrize( "assy_fixture, expected", [