diff --git a/bladex/blade.py b/bladex/blade.py index fac2567..7658110 100644 --- a/bladex/blade.py +++ b/bladex/blade.py @@ -5,6 +5,8 @@ import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D +from .export import (make_compound, sew_shapes, to_solid, write_iges, + write_stl) from .intepolatedface import InterpolatedFace class Blade(object): @@ -782,65 +784,47 @@ def _write_blade_errors(self, upper_face, lower_face, errors): output_string += '\n' f.write(output_string) - def generate_solid(self): + def shell(self, sewing_tolerance=1e-2): """ - Generate a solid blade assembling the upper face, lower face, tip and - root using the BRepBuilderAPI_MakeSolid algorithm. + Sew the four boundary faces into a single shape. - :param int max_deg: Define the maximal U degree of generated surface. - Default value is 1 - :raises RuntimeError: if the assembling of the solid blade is not - completed successfully + :param float sewing_tolerance: tolerance used by the sewing algorithm + :return: a shell when the faces close, otherwise the sewed compound + :rtype: OCC.Core.TopoDS.TopoDS_Shape """ - from OCC.Display.SimpleGui import init_display - from OCC.Core.TopoDS import TopoDS_Shell - import OCC.Core.TopoDS - from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Sewing, \ - BRepBuilderAPI_MakeSolid - - faces = [ - self.upper_face, self.lower_face, self.tip_face, self.root_face - ] - - sewer = BRepBuilderAPI_Sewing(1e-7) - for face in faces: - sewer.Add(face) - sewer.Perform() + return sew_shapes( + [self.upper_face, self.lower_face, self.root_face, + self.tip_face], sewing_tolerance) - result_shell = sewer.SewedShape() - solid_maker = BRepBuilderAPI_MakeSolid() - solid_maker.Add(OCC.Core.TopoDS.topods.Shell(result_shell)) + def generate_solid(self): + """ + Generate a solid blade from its boundary faces. - if not solid_maker.IsDone(): - raise RuntimeError('Unsuccessful assembling of solid blade') - result_solid = solid_maker.Solid() + If the four faces do not close into a single shell, the sewed shape is + returned instead: IGES and STL accept both, and this keeps the export + working on degenerate geometries (e.g. a tip collapsing to a point). - return result_solid + :return: the blade as a solid, or as the sewed shape when it cannot be + closed into a single shell + :rtype: OCC.Core.TopoDS.TopoDS_Shape + """ + return to_solid(self.shell()) - def export_stl(self, filename, linear_deflection=0.1): + def export_stl(self, filename, linear_deflection=0.05, + angular_deflection=0.3): """ Generate and export the .STL file for the entire blade. This method requires PythonOCC (7.4.0) to be installed. + + :param string filename: path (with the file extension) where to store + the .stl CAD for the blade + :param float linear_deflection: relative linear deflection used for the + tessellation; lower values give smoother surfaces + :param float angular_deflection: angular deflection (in radians) used + for the tessellation; lower values give smoother surfaces """ - from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Sewing - from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh - from OCC.Extend.DataExchange import write_stl_file - from OCC.Core.StlAPI import StlAPI_Writer - - sewer = BRepBuilderAPI_Sewing(1e-2) - sewer.Add(self.upper_face) - sewer.Add(self.lower_face) - sewer.Add(self.root_face) - sewer.Add(self.tip_face) - sewer.Perform() - sewed_shape = sewer.SewedShape() - - triangulation = BRepMesh_IncrementalMesh(sewed_shape, linear_deflection, True) - triangulation.Perform() - - writer = StlAPI_Writer() - writer.SetASCIIMode(False) - writer.Write(sewed_shape, filename) + write_stl(self.shell(), filename, linear_deflection, + angular_deflection) def _generate_leading_edge_curves(self): """ @@ -894,27 +878,27 @@ def _generate_leading_edge_curves(self): self.upper_le_edge = BRepBuilderAPI_MakeEdge(upper_curve.Curve()).Edge() self.lower_le_edge = BRepBuilderAPI_MakeEdge(lower_curve.Curve()).Edge() - def export_iges(self, filename, include_le_curves=False): + def export_iges(self, filename, include_le_curves=False, + sewing_tolerance=1e-2): """ Generate and export the .IGES file for the entire blade. - This method requires PythonOCC (7.4.0) to be installed. - """ - from OCC.Core.IGESControl import IGESControl_Writer + The export starts from the sewn shell of the four boundary faces, so + that IGES and STL share the same B-Rep and the sewing tolerance can be + tuned. The leading-edge curves are optional. + + :param string filename: path (with the file extension) where to store + the .iges CAD for the blade + :param bool include_le_curves: if True, the upper and lower leading + edge curves are exported together with the body + :param float sewing_tolerance: tolerance used to sew the faces + :raises RuntimeError: if the IGES file cannot be written + """ + shapes = [self.shell(sewing_tolerance)] if include_le_curves: self._generate_leading_edge_curves() - - iges_writer = IGESControl_Writer() - iges_writer.AddShape(self.upper_face) - iges_writer.AddShape(self.lower_face) - iges_writer.AddShape(self.root_face) - iges_writer.AddShape(self.tip_face) - - if include_le_curves: - iges_writer.AddShape(self.upper_le_edge) - iges_writer.AddShape(self.lower_le_edge) - - iges_writer.Write(filename) + shapes.extend([self.upper_le_edge, self.lower_le_edge]) + write_iges(make_compound(shapes), filename) @staticmethod def _check_string(filename): diff --git a/bladex/export.py b/bladex/export.py new file mode 100644 index 0000000..a2bd46b --- /dev/null +++ b/bladex/export.py @@ -0,0 +1,124 @@ +""" +Shared CAD export utilities. + +Everything needed to turn OCC faces into IGES/STL files: +compounds, sewing, solid assembly, tessellation and file writing. Keeping +these operations in one place avoids each class reinventing its own variant. +""" +from OCC.Core.BRep import BRep_Builder +from OCC.Core.BRepBuilderAPI import (BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_Sewing) +from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh +from OCC.Core.IGESControl import IGESControl_Writer +from OCC.Core.StlAPI import StlAPI_Writer +from OCC.Core.TopAbs import TopAbs_COMPOUND, TopAbs_SHELL +from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Iterator, topods + + +def make_compound(shapes): + """Group a list of shapes into a single TopoDS_Compound.""" + builder = BRep_Builder() + comp = TopoDS_Compound() + builder.MakeCompound(comp) + + for shape in shapes: + if shape is None: + raise ValueError("make_compound: shape is None") + builder.Add(comp, shape) + + return comp + + +def sew_shapes(shapes, tolerance=1e-2): + """Sew a list of faces/shapes into a single shape (shell or compound).""" + sewer = BRepBuilderAPI_Sewing(tolerance) + for shape in shapes: + if shape is None: + raise ValueError( + "sew_shapes: shape is None (call Blade.build first)") + sewer.Add(shape) + sewer.Perform() + return sewer.SewedShape() + + +def to_solid(shape): + """Convert `shape` to a solid when it contains exactly one shell. + + If the sewing did not produce a single shell (degenerate geometry, open + edges, ...) the input shape is returned unchanged: IGES and STL accept it, + while the forced cast to TopoDS_Shell used before raised + Standard_TypeMismatch and made the export fail. + """ + shell = _single_shell(shape) + if shell is None: + return shape + + maker = BRepBuilderAPI_MakeSolid() + maker.Add(shell) + if not maker.IsDone(): + return shape + return maker.Solid() + + +def _single_shell(shape): + """Return the shell of `shape` when there is exactly one, None otherwise. + + Compounds are inspected recursively, so a compound wrapping a compound + that wraps a single shell still resolves to that shell. Any other + sub-shape (a face, an edge, a second body) makes the shape ineligible, so + that nothing is silently dropped when converting to a solid. + """ + if shape.ShapeType() == TopAbs_SHELL: + return topods.Shell(shape) + + if shape.ShapeType() != TopAbs_COMPOUND: + return None + + shells = [] + iterator = TopoDS_Iterator(shape) + while iterator.More(): + child = iterator.Value() + if child.ShapeType() == TopAbs_SHELL: + shells.append(topods.Shell(child)) + elif child.ShapeType() == TopAbs_COMPOUND: + nested = _single_shell(child) + if nested is None: + return None + shells.append(nested) + else: + return None + iterator.Next() + return shells[0] if len(shells) == 1 else None + + +def mesh(shape, + linear_deflection=0.05, + angular_deflection=0.3, + is_relative=True): + """Tessellate `shape` with the incremental mesh algorithm.""" + triangulation = BRepMesh_IncrementalMesh(shape, linear_deflection, + is_relative, angular_deflection, + True) + triangulation.Perform() + + +def write_stl(shape, + filename, + linear_deflection=0.05, + angular_deflection=0.3, + is_relative=True): + """Write a binary STL file and check the writer status.""" + mesh(shape, linear_deflection, angular_deflection, is_relative) + + writer = StlAPI_Writer() + writer.SetASCIIMode(False) + if not writer.Write(shape, filename): + raise RuntimeError("Unsuccessful writing of STL file: " + filename) + + +def write_iges(shape, filename): + """Write an IGES file and check the writer status.""" + writer = IGESControl_Writer() + writer.AddShape(shape) + if not writer.Write(filename): + raise RuntimeError("Unsuccessful writing of IGES file: " + filename) diff --git a/bladex/propeller.py b/bladex/propeller.py index 9a4e09c..544868e 100644 --- a/bladex/propeller.py +++ b/bladex/propeller.py @@ -1,30 +1,17 @@ """ Module for the propeller with shaft bottom-up parametrized construction. """ +import copy +import os +import tempfile +import warnings + import numpy as np -from OCC.Core.IGESControl import IGESControl_Writer -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Sewing from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse -from OCC.Extend.DataExchange import write_stl_file from OCC.Display.SimpleGui import init_display -from smithers.io.obj import ObjHandler, WavefrontOBJ -from smithers.io.stlhandler import STLHandler - -from OCC.Core.BRep import BRep_Builder -from OCC.Core.TopoDS import TopoDS_Compound -import copy -def make_compound(shapes): - builder = BRep_Builder() - comp = TopoDS_Compound() - builder.MakeCompound(comp) +from .export import make_compound, sew_shapes, write_iges, write_stl - for s in shapes: - if s is None: - raise ValueError("make_compound: shape is None") - builder.Add(comp, s) - - return comp class Propeller(object): """ @@ -34,14 +21,21 @@ class Propeller(object): :param shaft.Shaft shaft: shaft to be added to the propeller :param blade.Blade blade: blade of the propeller :param int n_blades: number of blades composing the propeller - :cvar OCC.Core.TopoDS.TopoDS_Solid shaft_solid: solid shaft - :cvar OCC.Core.TopoDS.TopoDS_Shell sewed_full_body: propeller with shaft shell + :cvar list blades: the blades of the propeller + :cvar shaft.Shaft shaft: the shaft of the propeller """ def __init__(self, shaft, blade, n_blades, reflect_blade=False): self.shaft = shaft - blade.build(reflect=reflect_blade) + # The Propeller assembles an existing blade: rebuilding it here would + # silently discard any mirror/rotate the caller applied after build(). + if blade.upper_face is None: + blade.build(reflect=reflect_blade) + elif reflect_blade: + raise ValueError( + "reflect_blade is applied by Blade.build: build the blade " + "with the desired reflection instead of passing a built one") self.blades = [copy.deepcopy(blade) for _ in range(n_blades)] [ @@ -49,102 +43,112 @@ def __init__(self, shaft, blade, n_blades, reflect_blade=False): for i, blade in enumerate(self.blades) ] + def _blades_shapes(self, sewing_tolerance=1e-2): + """Sew the four boundary faces of every blade.""" + return [ + sew_shapes([ + blade.upper_face, blade.lower_face, blade.root_face, + blade.tip_face + ], sewing_tolerance) for blade in self.blades + ] + + def blades_shape(self, sewing_tolerance=1e-2): + """The blades only, collected in a single compound.""" + return make_compound(self._blades_shapes(sewing_tolerance)) + + def compound(self, sewing_tolerance=1e-2): + """The propeller and shaft assembled in a compound, without booleans.""" + shapes = self._blades_shapes(sewing_tolerance) + shapes.append(self.shaft.generate_solid()) + return make_compound(shapes) - def tmp_stl(self, filename, linear_deflection=0.1): + def tmp_stl(self, filename, linear_deflection=0.05): """ + Export the .stl CAD for the blades only. + Deprecated: use :meth:`export_stl` with ``include_shaft=False``. """ - from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh - from OCC.Core.StlAPI import StlAPI_Writer - shapes = [] - for blade_ in self.blades: - - sewer = BRepBuilderAPI_Sewing(1e-2) - sewer.Add(blade_.upper_face) - sewer.Add(blade_.lower_face) - sewer.Add(blade_.root_face) - sewer.Add(blade_.tip_face) - sewer.Perform() - sewed_shape = sewer.SewedShape() - shapes.append(sewed_shape) - - blades_compound = make_compound(shapes) - triangulation = BRepMesh_IncrementalMesh(blades_compound, linear_deflection, True) - triangulation.Perform() - - writer = StlAPI_Writer() - writer.SetASCIIMode(False) - writer.Write(blades_compound, filename) + warnings.warn( + "tmp_stl is deprecated, use export_stl(include_shaft=False)", + DeprecationWarning, + stacklevel=2) + self.export_stl(filename, linear_deflection=linear_deflection, + include_shaft=False) @property def shape(self): - if hasattr(self, '_shape'): - return self._shape - - solid = self.solid - - sewer = BRepBuilderAPI_Sewing(1e-2) - sewer.Add(solid) - sewer.Perform() - - self._shape = sewer.SewedShape() - return self._shape + """The assembled propeller (alias of :meth:`compound`).""" + return self.compound() @property def solid(self): """ - Docstring for build - - :param self: Description - """ - if hasattr(self, '_solid'): - return self._solid + Best-effort boolean union of shaft and blades. - shaft_solid = self.shaft.generate_solid() - blade_solids = [blade_.generate_solid() for blade_ in self.blades] - blades_compound = make_compound(blade_solids) + It is not used by the export methods: a boolean failure, or a blade + that does not close into a solid, only affects callers that explicitly + ask for a single solid. - fuse = BRepAlgoAPI_Fuse(shaft_solid, blades_compound) - # fuse.SetRunParallel(True) # if available in your build + :raises RuntimeError: if the boolean union fails + :rtype: OCC.Core.TopoDS.TopoDS_Shape + """ + blade_solids = [blade.generate_solid() for blade in self.blades] + fuse = BRepAlgoAPI_Fuse(self.shaft.generate_solid(), + make_compound(blade_solids)) fuse.SetFuzzyValue(1e-4) - fuse.Build() if not fuse.IsDone(): raise RuntimeError("Fuse shaft + blades failed") + return fuse.Shape() - self._solid = fuse.Shape() - return self._solid - - - def export_iges(self, filename): + def export_iges(self, filename, include_shaft=True, + sewing_tolerance=1e-2): """ - Export the .iges CAD for the propeller with shaft. + Export the .iges CAD for the propeller. + + Every blade is sewn into a shell and collected, together with the + shaft, in a single compound. No boolean fuse is performed: IGES does + not need it and the fuse is the operation that fails most often. :param string filename: path (with the file extension) where to store - the .iges CAD for the propeller and shaft - :raises RuntimeError: if the solid assembling of blades is not - completed successfully + the .iges CAD + :param bool include_shaft: if False, only the blades are exported + :param float sewing_tolerance: tolerance used to sew each blade + :raises RuntimeError: if the IGES file cannot be written """ - iges_writer = IGESControl_Writer() - iges_writer.AddShape(self.shape) - iges_writer.Write(filename) + if include_shaft: + shape = self.compound(sewing_tolerance) + else: + shape = self.blades_shape(sewing_tolerance) + write_iges(shape, filename) - def export_stl(self, filename): + def export_stl(self, filename, linear_deflection=0.05, + angular_deflection=0.3, include_shaft=True): """ - Export the .stl CAD for the propeller with shaft. + Export the .stl CAD for the propeller. :param string filename: path (with the file extension) where to store - the .stl CAD for the propeller and shaft - :raises RuntimeError: if the solid assembling of blades is not - completed successfully + the .stl CAD + :param float linear_deflection: relative linear deflection used for the + tessellation; lower values give smoother surfaces + :param float angular_deflection: angular deflection (in radians) used + for the tessellation; lower values give smoother surfaces + :param bool include_shaft: if False, only the blades are exported + :raises RuntimeError: if the STL file cannot be written """ - write_stl_file(self.shape, filename) + if include_shaft: + shape = self.compound() + else: + shape = self.blades_shape() + write_stl(shape, filename, linear_deflection, angular_deflection) def generate_obj(self, filename, region_selector="by_coords", **kwargs): """ Export the .obj CAD for the propeller with shaft. The resulting - file contains two regions: `propellerTip` and `propellerStem`, selected - according to the criteria passed in the parameter `region_selector`. + file contains the regions `blades`, `shaftHead` and `shaftTail` (or + `blades` and `shaft` with `region_selector="blades_and_stem"`), + selected according to the criteria passed in the parameter + `region_selector`. :param string filename: path (with the file extension). :param string region_selector: Two selectors available: @@ -153,19 +157,23 @@ def generate_obj(self, filename, region_selector="by_coords", **kwargs): the solid which represents the blades of the propeller. Then all the polygons (belonging to both blades and shaft) composed of points whose X coordinate is greater than :math:`x` are - considered to be part of the region `propellerTip`. The rest - belongs to `propellerStem`; - * `blades_and_stem`: The two regions are simply given by the two - solids which are used in :func:`__init__`. - :raises RuntimeError: if the solid assembling of blades is not - completed successfully + considered to be part of the region `shaftHead`. The rest + belongs to `shaftTail`; + * `blades_and_stem`: The two regions are simply given by the blades + and the shaft solids. + :raises RuntimeError: if the STL file cannot be written """ + from smithers.io.obj import ObjHandler, WavefrontOBJ + from smithers.io.stlhandler import STLHandler # we write the propeller to STL, then re-open it to obtain the points - write_stl_file(self.shaft_solid, "/tmp/temp_shaft.stl") - shaft = STLHandler.read("/tmp/temp_shaft.stl") - write_stl_file(self.blades_solid, "/tmp/temp_blades.stl") - blades = STLHandler.read("/tmp/temp_blades.stl") + with tempfile.TemporaryDirectory() as tmpdir: + shaft_stl = os.path.join(tmpdir, "shaft.stl") + blades_stl = os.path.join(tmpdir, "blades.stl") + write_stl(self.shaft.generate_solid(), shaft_stl) + write_stl(make_compound(self._blades_shapes()), blades_stl) + shaft = STLHandler.read(shaft_stl) + blades = STLHandler.read(blades_stl) obj_instance = WavefrontOBJ() @@ -259,10 +267,14 @@ def generate_obj_blades(self, filename): :param string filename: path (with the file extension). """ + from smithers.io.obj import ObjHandler, WavefrontOBJ + from smithers.io.stlhandler import STLHandler # we write the propeller to STL, then re-open it to obtain the points - write_stl_file(self.blades_solid, "/tmp/temp_blades.stl") - blades = STLHandler.read("/tmp/temp_blades.stl") + with tempfile.TemporaryDirectory() as tmpdir: + blades_stl = os.path.join(tmpdir, "blades.stl") + write_stl(make_compound(self._blades_shapes()), blades_stl) + blades = STLHandler.read(blades_stl) obj_instance = WavefrontOBJ() @@ -307,5 +319,5 @@ def display(self): Display the propeller with shaft. """ display, start_display = init_display()[:2] - display.DisplayShape(self.shape, update=True) + display.DisplayShape(self.compound(), update=True) start_display() \ No newline at end of file diff --git a/bladex/shaft/shaft.py b/bladex/shaft/shaft.py index 2677f90..faa41bc 100644 --- a/bladex/shaft/shaft.py +++ b/bladex/shaft/shaft.py @@ -1,10 +1,10 @@ import os from OCC.Core.IGESControl import IGESControl_Reader from OCC.Extend.DataExchange import read_stl_file -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeSolid, BRepBuilderAPI_Sewing -import OCC.Core.TopoDS from OCC.Display.SimpleGui import init_display +from bladex.export import sew_shapes, to_solid + class Shaft(object): """ Bottom-up parametrized shaft construction. @@ -21,13 +21,12 @@ def __init__(self, filename): def generate_solid(self): """ - Generate an assembled solid shaft using the BRepBuilderAPI_MakeSolid - algorithm. This method requires PythonOCC to be installed. + Generate an assembled solid shaft from its IGES/STL file. + This method requires PythonOCC to be installed. - :raises RuntimeError: if the assembling of the solid shaft is not - completed successfully - :return: solid shaft - :rtype: OCC.Core.TopoDS.TopoDS_Solid + :return: the shaft as a solid, or as the sewed shape when it cannot be + closed into a single shell + :rtype: OCC.Core.TopoDS.TopoDS_Shape """ ext = os.path.splitext(self.filename)[1][1:] if ext == 'stl': @@ -39,16 +38,7 @@ def generate_solid(self): shaft_compound = iges_reader.Shape() else: raise Exception('The shaft file is not in iges/stl formats') - sewer = BRepBuilderAPI_Sewing(1e-2) - sewer.Add(shaft_compound) - sewer.Perform() - result_sewed_shaft = sewer.SewedShape() - shaft_solid_maker = BRepBuilderAPI_MakeSolid() - shaft_solid_maker.Add(OCC.Core.TopoDS.topods.Shell(result_sewed_shaft)) - if not shaft_solid_maker.IsDone(): - raise RuntimeError('Unsuccessful assembling of solid shaft') - shaft_solid = shaft_solid_maker.Solid() - return shaft_solid + return to_solid(sew_shapes([shaft_compound], tolerance=1e-2)) def display(self): """ diff --git a/pyproject.toml b/pyproject.toml index 44c06e9..ba2b084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ test = [ "pytest", "pytest-cov" ] +obj = [ + "smithers[vtk]", +] [project.urls] Homepage = "https://github.com/mathLab/BladeX" diff --git a/tests/test_propeller.py b/tests/test_propeller.py index 12acbe8..ee117ed 100644 --- a/tests/test_propeller.py +++ b/tests/test_propeller.py @@ -1,7 +1,17 @@ from unittest import TestCase import os +import struct + import numpy as np -from bladex import NacaProfile, Shaft, Propeller, Blade +import pytest +from bladex import Blade, CylinderShaft, NacaProfile, Propeller, Shaft +from bladex.export import make_compound, to_solid +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCC.Core.IGESControl import IGESControl_Reader +from OCC.Core.TopAbs import (TopAbs_COMPOUND, TopAbs_EDGE, TopAbs_FACE, + TopAbs_SHELL, TopAbs_SOLID) +from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopoDS import TopoDS_Iterator from smithers.io.obj import ObjHandler from smithers.io.stlhandler import STLHandler @@ -227,3 +237,170 @@ def test_generate_obj_blades_and_stem(self): def test_isdisplay(self): assert hasattr(Propeller, "display") == True + + +def _stl_triangles(filename): + with open(filename, "rb") as stl_file: + header = stl_file.read(84) # 80 byte header + uint32 count + return struct.unpack(" _stl_triangles(blades) > 0 + with open(full, "rb") as stl_file: + assert not stl_file.read(5).startswith(b"solid") # binary + + +def test_blade_shell_is_a_single_body(): + blade = create_sample_blade_NACApptc() + blade.build() + + assert blade.shell().ShapeType() in (TopAbs_SHELL, TopAbs_SOLID) + + +def test_export_iges_is_readable(tmp_path): + blade = create_sample_blade_NACApptc() + propeller = Propeller(CylinderShaft(radius=0.02, height=0.05), blade, 4) + + path = str(tmp_path / "propeller.iges") + propeller.export_iges(path) + + roots, _ = _iges_faces(path) + assert roots > 0 + + +def test_blade_export_iges(tmp_path): + blade = create_sample_blade_NACApptc() + blade.build() + + plain = str(tmp_path / "blade.iges") + with_curves = str(tmp_path / "blade_le.iges") + blade.export_iges(plain) + blade.export_iges(with_curves, include_le_curves=True) + + roots, faces = _iges_faces(plain) + assert roots > 0 + assert faces >= 4 + assert _iges_edges(with_curves) > _iges_edges(plain) + + +def test_export_survives_unsewable_faces(tmp_path, monkeypatch): + blade = create_sample_blade_NACApptc() + blade.build() + + def broken_shell(self, sewing_tolerance=1e-2): + return make_compound([self.upper_face, self.lower_face, + self.root_face, self.tip_face]) + + monkeypatch.setattr(Blade, "shell", broken_shell) + + assert blade.generate_solid().ShapeType() == TopAbs_COMPOUND + + stl = str(tmp_path / "blade.stl") + iges = str(tmp_path / "blade.iges") + blade.export_stl(stl) + blade.export_iges(iges) + + assert _stl_triangles(stl) > 0 + assert _iges_faces(iges)[1] == 4 + + +def test_tmp_stl_is_an_alias_of_export_stl_without_shaft(tmp_path): + blade = create_sample_blade_NACApptc() + propeller = Propeller(CylinderShaft(radius=0.02, height=0.05), blade, 4) + + exported = str(tmp_path / "exported.stl") + deprecated = str(tmp_path / "deprecated.stl") + propeller.export_stl(exported, include_shaft=False) + with pytest.warns(DeprecationWarning): + propeller.tmp_stl(deprecated) + + assert _stl_triangles(exported) == _stl_triangles(deprecated) + + +def test_reflect_blade_requires_an_unbuilt_blade(): + blade = create_sample_blade_NACApptc() + blade.build() + + with pytest.raises(ValueError): + Propeller(CylinderShaft(), blade, 4, reflect_blade=True) + + +def test_generate_obj_regions(tmp_path): + blade = create_sample_blade_NACApptc() + propeller = Propeller(CylinderShaft(radius=0.02, height=0.05), blade, 3) + + path = str(tmp_path / "propeller.obj") + propeller.generate_obj(path) + + data = ObjHandler.read(path) + assert set(data.regions) == {"blades", "shaftHead", "shaftTail"} + + +def test_generate_obj_blades_regions(tmp_path): + blade = create_sample_blade_NACApptc() + propeller = Propeller(CylinderShaft(radius=0.02, height=0.05), blade, 3) + + path = str(tmp_path / "blades.obj") + propeller.generate_obj_blades(path) + + data = ObjHandler.read(path) + assert data.regions == ["blades"] + assert len(data.polygons) > 0