turbo_turtle._gmsh_python — turbo-turtle 1.2.9 documentation

Skip to main content
`Ctrl`+`K`

#

# Source code for turbo_turtle._gmsh_python

    """Python 3 module that imports python-gmsh."""

    import pathlib
    import typing

    import numpy

    from turbo_turtle import _utilities
    from turbo_turtle._abaqus_python.turbo_turtle_abaqus import _mixed_utilities, parsers, vertices

    gmsh = _utilities.import_gmsh()

    def geometry(
        input_file: typing.Sequence[str | pathlib.Path],
        output_file: str | pathlib.Path,
        planar: bool = parsers.geometry_defaults["planar"],  # type: ignore[assignment]
        model_name: str = parsers.geometry_defaults["model_name"],  # type: ignore[assignment]
        part_name: str = parsers.geometry_defaults["part_name"],  # type: ignore[assignment]
        unit_conversion: float = parsers.geometry_defaults["unit_conversion"],  # type: ignore[assignment]
        euclidean_distance: float = parsers.geometry_defaults["euclidean_distance"],  # type: ignore[assignment]
        delimiter: str = parsers.geometry_defaults["delimiter"],  # type: ignore[assignment]
        header_lines: int = parsers.geometry_defaults["header_lines"],  # type: ignore[assignment]
        revolution_angle: float = parsers.geometry_defaults["revolution_angle"],  # type: ignore[assignment]
        y_offset: float = parsers.geometry_defaults["y_offset"],  # type: ignore[assignment]
        rtol: float = parsers.geometry_defaults["rtol"],  # type: ignore[assignment]
        atol: float = parsers.geometry_defaults["atol"],  # type: ignore[assignment]
    ) -> None:
        """Create 2D planar, 2D axisymmetric, or 3D revolved geometry from an array of XY coordinates.

        Note that 2D axisymmetric sketches and sketches for 3D bodies of revolution about the global Y-axis must lie
        entirely on the positive-X side of the global Y-axis.

        This function can create multiple surfaces or volumes in the same Gmsh ``*.step`` file. If no part (body/volume)
        names are provided, the body/volume will be named after the input file base name.

        :param input_file: input text file(s) with coordinates to draw
        :param output_file: Gmsh ``*.step`` database to save the part(s)
        :param planar: switch to indicate that 2D model dimensionality is planar, not axisymmetric
        :param model_name: name of the Gmsh model in which to create the part
        :param part_name: name(s) of the part(s) being created
        :param unit_conversion: multiplication factor applies to all coordinates
        :param euclidean_distance: if the distance between two coordinates is greater than this, draw a straight line.
            Distance should be provided in units *after* the unit conversion
        :param delimiter: character to use as a delimiter when reading the input file
        :param header_lines: number of lines in the header to skip when reading the input file
        :param revolution_angle: angle of solid revolution for ``3D`` geometries. Ignore when planar is True.
        :param y_offset: vertical offset along the global Y-axis. Offset should be provided in units *after* the unit
            conversion.
        :param rtol: relative tolerance for vertical/horizontal line checks
        :param atol: absolute tolerance for vertical/horizontal line checks

        :returns: writes ``{output_file}.step``
        """
        # Universally required setup
        gmsh.initialize()
        gmsh.logger.start()

        # Input/Output setup
        # TODO: allow other output formats supported by Gmsh
        output_file = pathlib.Path(output_file).with_suffix(".step")

        # Model setup
        gmsh.model.add(model_name)
        part_name = _mixed_utilities.validate_part_name(input_file, part_name)
        part_name = _mixed_utilities.cubit_part_names(part_name)

        # Create part(s)
        surfaces = []
        for file_name, _new_part in zip(input_file, part_name, strict=True):
            coordinates = _mixed_utilities.return_genfromtxt(
                file_name, delimiter, header_lines, expected_dimensions=2, expected_columns=2
            )
            coordinates = vertices.scale_and_offset_coordinates(coordinates, unit_conversion, y_offset)
            lines_and_splines = vertices.ordered_lines_and_splines(coordinates, euclidean_distance, rtol=rtol, atol=atol)
            surfaces.append(_draw_surface(lines_and_splines))

        # Conditionally create the 3D revolved shape
        for surface, new_part in zip(surfaces, part_name, strict=True):
            _rename_and_sweep(surface, new_part, planar=planar, revolution_angle=revolution_angle)

        # Output and cleanup
        # FIXME: Write physical groups to geometry output files
        # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/221
        gmsh.write(str(output_file))
        gmsh.logger.stop()
        gmsh.finalize()

    def _draw_surface(lines_and_splines: list[numpy.ndarray]) -> int:
        """Given ordered lists of line/spline coordinates, create a Gmsh 2D surface object.

        :param lines_and_splines: list of [N, 2] shaped arrays of (x, y) coordinates defining a line (N=2) or spline
            (N>2)

        :returns: Gmsh 2D entity tag
        """
        curves = []
        for coordinates in lines_and_splines:
            if len(coordinates) == 2:
                point1 = (*tuple(coordinates[0]), 0.0)
                point2 = (*tuple(coordinates[1]), 0.0)
                curves.append(_create_line_from_coordinates(point1, point2))
            else:
                zero_column = numpy.zeros([len(coordinates), 1])
                spline_3d = numpy.append(coordinates, zero_column, axis=1)
                curves.append(_create_spline_from_coordinates(spline_3d))

        curve_loop = gmsh.model.occ.addCurveLoop(curves)
        return gmsh.model.occ.addPlaneSurface([curve_loop])

    def _create_line_from_coordinates(point1: tuple[float, float, float], point2: tuple[float, float, float]) -> int:
        """Create a curve from 2 three-dimensional coordinates.

        :param point1: First set of coordinates (x1, y1, z1)
        :param point2: Second set of coordinates (x2, y2, z2)

        :returns: Gmsh 1D entity tag
        """
        point1_tag = gmsh.model.occ.addPoint(*point1)
        point2_tag = gmsh.model.occ.addPoint(*point2)
        return gmsh.model.occ.addLine(point1_tag, point2_tag)

    def _create_spline_from_coordinates(coordinates: typing.Sequence[tuple[float, float, float]] | numpy.ndarray) -> int:
        """Create a spline from a sequence of coordinates.

        :param coordinates: [N, 3] array of coordinates (x, y, z)

        :returns: Gmsh 1D entity tag
        """
        coordinates = numpy.array(coordinates)
        minimum = 2
        if coordinates.shape[0] < minimum:
            raise RuntimeError(f"Requires at least {minimum} coordinates to create a spline")

        points = [gmsh.model.occ.addPoint(*tuple(point)) for point in coordinates]

        return gmsh.model.occ.addBSpline(points)

    def _rename_and_sweep(
        surface: int,
        part_name: str,
        center: tuple[float, float, float] | numpy.ndarray = (0.0, 0.0, 0.0),
        planar: bool = parsers.geometry_defaults["planar"],  # type: ignore[assignment]
        revolution_angle: float = parsers.geometry_defaults["revolution_angle"],  # type: ignore[assignment]
    ) -> tuple[int, int]:
        """Recover surface, sweep part if required, and rename surface/volume by part name.

        Hyphens are replaced by underscores to make the ACIS engine happy.

        :param surface: Gmsh surface tag to rename and conditionally sweep
        :param part_name: name(s) of the part(s) being created
        :param center: coordinate location for the center of axisymmetric sweep
        :param planar: switch to indicate that 2D model dimensionality is planar, not axisymmetric
        :param revolution_angle: angle of solid revolution for ``3D`` geometries. Ignore when planar is True.

        :returns: Gmsh dimTag (dimension, tag)
        """
        center = numpy.array(center)
        revolution_axis = numpy.array([0.0, 1.0, 0.0])
        if planar:
            dim_tag = (2, surface)
        elif numpy.isclose(revolution_angle, 0.0):
            dim_tag = (2, surface)
        else:
            # Using naming convention of the external library, Gmsh
            dimTags = gmsh.model.occ.revolve(  # noqa: N806
                [(2, surface)],
                *center,
                *revolution_axis,
                numpy.radians(revolution_angle),
            )
            dim_tag = dimTags[0]

        part_dimension = dim_tag[0]
        part_tag = dim_tag[0]
        part_name = _mixed_utilities.cubit_part_names(part_name)
        gmsh.model.occ.synchronize()
        part_tag = gmsh.model.addPhysicalGroup(part_dimension, [part_tag], name=part_name)

        return dim_tag

    def cylinder(
        inner_radius: float,
        outer_radius: float,
        height: float,
        output_file: str | pathlib.Path,
        model_name: str = parsers.geometry_defaults["model_name"],  # type: ignore[assignment]
        part_name: str = parsers.cylinder_defaults["part_name"],  # type: ignore[assignment]
        revolution_angle: float = parsers.geometry_defaults["revolution_angle"],  # type: ignore[assignment]
        y_offset: float = parsers.cylinder_defaults["y_offset"],  # type: ignore[assignment]
    ) -> None:
        """Accept dimensions of a right circular cylinder and generate an axisymmetric revolved geometry.

        Centroid of cylinder is located on the global coordinate origin by default.

        :param inner_radius: Radius of the hollow center
        :param outer_radius: Outer radius of the cylinder
        :param height: Height of the cylinder
        :param output_file: Gmsh ``*.step`` file to save the part(s)
        :param model_name: name of the Gmsh model in which to create the part
        :param part_name: name(s) of the part(s) being created
        :param revolution_angle: angle of solid revolution for ``3D`` geometries
        :param y_offset: vertical offset along the global Y-axis
        """
        # Universally required setup
        gmsh.initialize()
        gmsh.logger.start()

        # Input/Output setup
        # TODO: allow other output formats supported by Gmsh
        output_file = pathlib.Path(output_file).with_suffix(".step")
        gmsh.model.add(model_name)

        # Create the 2D axisymmetric shape
        lines = vertices.cylinder_lines(inner_radius, outer_radius, height, y_offset=y_offset)
        surface_tag = _draw_surface(lines)

        # Conditionally create the 3D revolved shape
        _rename_and_sweep(surface_tag, part_name, revolution_angle=revolution_angle)

        # Output and cleanup
        # FIXME: Write physical groups to geometry output files
        # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/221
        gmsh.write(str(output_file))
        gmsh.logger.stop()
        gmsh.finalize()

    def sphere(
        inner_radius: float,
        outer_radius: float,
        output_file: str | pathlib.Path,
        input_file: str | pathlib.Path | None = parsers.sphere_defaults["input_file"],  # type: ignore[assignment]
        quadrant: typing.Literal["upper", "lower", "both"] = parsers.sphere_defaults["quadrant"],  # type: ignore[assignment]
        revolution_angle: float = parsers.sphere_defaults["revolution_angle"],  # type: ignore[assignment]
        y_offset: float = parsers.sphere_defaults["y_offset"],  # type: ignore[assignment]
        model_name: str = parsers.sphere_defaults["model_name"],  # type: ignore[assignment]
        part_name: str = parsers.sphere_defaults["part_name"],  # type: ignore[assignment]
    ) -> None:
        """Create a sphere geometry with file I/O handling.

        :param inner_radius: inner radius (size of hollow)
        :param outer_radius: outer radius (size of sphere)
        :param output_file: output file name. Will be stripped of the extension and ``.step`` will be used.
        :param input_file: input file name. Will be stripped of the extension and ``.step`` will be used.
        :param quadrant: quadrant of XY plane for the sketch: upper (I), lower (IV), both
        :param revolution_angle: angle of rotation 0.-360.0 degrees. Provide 0 for a 2D axisymmetric model.
        :param y_offset: vertical offset along the global Y-axis
        :param model_name: name of the Gmsh model in which to create the part
        :param part_name: name of the part to be created in the Abaqus model
        """
        # Universally required setup
        gmsh.initialize()
        gmsh.logger.start()

        # Input/Output setup
        # TODO: allow other output formats supported by Gmsh
        output_file = pathlib.Path(output_file).with_suffix(".step")

        # Preserve the (X, Y) center implementation, but use the simpler y-offset interface
        center = (0.0, y_offset)

        if input_file is not None:
            # TODO: allow other input formats supported by Gmsh
            input_file = pathlib.Path(input_file).with_suffix(".step")
            # Avoid modifying the contents or timestamp on the input file.
            # Required to get conditional re-builds with a build system such as GNU Make, CMake, or SCons
            with _utilities.NamedTemporaryFileCopy(input_file, suffix=".step", dir=".") as copy_file:
                gmsh.open(copy_file.name)
                _sphere(
                    inner_radius,
                    outer_radius,
                    quadrant=quadrant,
                    revolution_angle=revolution_angle,
                    center=center,
                    part_name=part_name,
                )
                # FIXME: Write physical groups to geometry output files
                # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/221
                gmsh.write(str(output_file))
        else:
            gmsh.model.add(model_name)
            _sphere(
                inner_radius,
                outer_radius,
                quadrant=quadrant,
                revolution_angle=revolution_angle,
                center=center,
                part_name=part_name,
            )
            # FIXME: Write physical groups to geometry output files
            # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/221
            gmsh.write(str(output_file))

        # Output and cleanup
        gmsh.logger.stop()
        gmsh.finalize()

    def _sphere(
        inner_radius: float,
        outer_radius: float,
        quadrant: str = parsers.sphere_defaults["quadrant"],  # type: ignore[assignment]
        revolution_angle: float = parsers.sphere_defaults["revolution_angle"],  # type: ignore[assignment]
        center: tuple[float, float] = parsers.sphere_defaults["center"],  # type: ignore[assignment]
        part_name: str = parsers.sphere_defaults["part_name"],  # type: ignore[assignment]
    ) -> None:
        """Create a sphere geometry without file I/O handling.

        :param inner_radius: inner radius (size of hollow)
        :param outer_radius: outer radius (size of sphere)
        :param quadrant: quadrant of XY plane for the sketch: upper (I), lower (IV), both
        :param revolution_angle: angle of rotation 0.-360.0 degrees. Provide 0 for a 2D axisymmetric model.
        :param center: tuple of floats (X, Y) location for the center of the sphere
        :param part_name: name of the part to be created in the Abaqus model
        """
        # TODO: consolidate pure Python 3 logic in a common module for both Gmsh and Cubit
        # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/boards
        arc_points = vertices.sphere(center, inner_radius, outer_radius, quadrant)

        center_3d = numpy.append(center, [0.0])
        zero_column = numpy.zeros([len(arc_points), 1])
        arc_points_3d = numpy.append(arc_points, zero_column, axis=1)
        inner_point1 = arc_points_3d[0]
        inner_point2 = arc_points_3d[1]
        outer_point1 = arc_points_3d[2]
        outer_point2 = arc_points_3d[3]

        curves = []
        if numpy.allclose(inner_point1, center_3d) and numpy.allclose(inner_point2, center_3d):
            inner_point1 = center_3d
            inner_point2 = center_3d
        else:
            curves.append(_create_arc_from_coordinates(center_3d, inner_point1, inner_point2))
        curves.append(_create_line_from_coordinates(inner_point2, outer_point2))
        curves.append(_create_arc_from_coordinates(center_3d, outer_point2, outer_point1))
        curves.append(_create_line_from_coordinates(outer_point1, inner_point1))
        curve_loop = gmsh.model.occ.addCurveLoop(curves)
        surface = gmsh.model.occ.addPlaneSurface([curve_loop])

        _rename_and_sweep(surface, part_name, revolution_angle=revolution_angle, center=center_3d)

    def _create_arc_from_coordinates(
        center: tuple[float, float, float] | numpy.ndarray,
        point1: tuple[float, float, float] | numpy.ndarray,
        point2: tuple[float, float, float] | numpy.ndarray,
    ) -> int:
        """Create a circle arc Gmsh object from center and points on the curve.

        :param center: tuple of floats (X, Y, Z) location for the center of the circle arc
        :param point1: tuple of floats (X, Y, Z) location for the first point on the arc
        :param point2: tuple of floats (X, Y, Z) location for the second point on the arc

        :returns: Gmsh curve tag
        """
        center_tag = gmsh.model.occ.addPoint(*center)
        point1_tag = gmsh.model.occ.addPoint(*point1)
        point2_tag = gmsh.model.occ.addPoint(*point2)

        return gmsh.model.occ.addCircleArc(point1_tag, center_tag, point2_tag, center=True)

    # TODO: Remove ``noqa: ARG001`` when this function is implemented.
    # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/212
    def partition(*args, **kwargs) -> typing.NoReturn:  # noqa: ARG001
        raise RuntimeError("partition subcommand is not yet implemented")

    # TODO: Remove ``noqa: ARG001`` when this function is implemented.
    def sets(*args, **kwargs) -> typing.NoReturn:  # noqa: ARG001
        raise RuntimeError("sets subcommand is not yet implemented")

    # Argument(s) retained for compatibility with ``_cubit_python.mesh`` API

    def mesh(
        input_file: str | pathlib.Path,
        element_type: str,  # noqa: ARG001
        output_file: str | pathlib.Path | None = parsers.mesh_defaults["output_file"],  # type: ignore[assignment]
        model_name: str | None = parsers.mesh_defaults["model_name"],  # type: ignore[assignment] # noqa: ARG001
        part_name: str | None = parsers.mesh_defaults["part_name"],  # type: ignore[assignment] # noqa: ARG001
        global_seed: float = parsers.mesh_defaults["global_seed"],  # type: ignore[assignment]
        edge_seeds: typing.Sequence[tuple[str, str | int | float]] | None = parsers.mesh_defaults["edge_seeds"],  # type: ignore[assignment] # noqa: ARG001
    ) -> None:
        """Mesh Gmsh physical entities by part name.

        :param input_file: Gmsh ``*.step`` file to open that already contains physical entities to be meshed
        :param element_type: Gmsh scheme.
        :param output_file: Gmsh mesh file to write
        :param model_name: name of the Gmsh model in which to create the part
        :param part_name: physical entity name prefix
        :param global_seed: The global mesh seed size
        :param edge_seeds: Edge seed tuples (name, number)
        """
        # Universally required setup
        gmsh.initialize()
        gmsh.logger.start()

        # Input/Output setup
        # TODO: allow other output formats supported by Gmsh
        input_file = pathlib.Path(input_file).with_suffix(".step")
        if output_file is None:
            output_file = input_file.with_suffix(".msh")
        output_file = pathlib.Path(output_file)

        with _utilities.NamedTemporaryFileCopy(input_file, suffix=input_file.suffix, dir=".") as copy_file:
            gmsh.open(copy_file.name)
            # TODO: Move to dedicated meshing function
            # TODO: Do physical group names apply to all dimensional entities associated with original name? Can we jump
            # straight to points with matching physical/entity names?
            # FIXME: The physical groups are not getting saved. Stop global application of seed without regard to
            # part/entity name.
            # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/222
            points = gmsh.model.getEntities(dim=0)
            gmsh.model.mesh.setSize(points, global_seed)
            gmsh.model.mesh.generate(3)
            gmsh.write(str(output_file))

        gmsh.option.setNumber("Mesh.SaveGroupsOfElements", 1)
        gmsh.option.setNumber("Mesh.SaveGroupsOfNodes", 1)

        # Output and cleanup
        gmsh.logger.stop()
        gmsh.finalize()

    # TODO: Remove ``noqa: ARG001`` when this function is implemented.
    # https://re-git.lanl.gov/aea/python-projects/turbo-turtle/-/issues/216
    def merge(*args, **kwargs) -> typing.NoReturn:  # noqa: ARG001
        raise RuntimeError("merge subcommand is not yet implemented")

    # TODO: Remove ``noqa: ARG001`` when this function is implemented.
    def export(*args, **kwargs) -> typing.NoReturn:  # noqa: ARG001
        raise RuntimeError("export subcommand is not yet implemented")

    def image(
        input_file: str | pathlib.Path,
        output_file: str | pathlib.Path,
        x_angle: float = parsers.image_defaults["x_angle"],  # type: ignore[assignment]
        y_angle: float = parsers.image_defaults["y_angle"],  # type: ignore[assignment]
        z_angle: float = parsers.image_defaults["z_angle"],  # type: ignore[assignment]
        # ARG001: Argument retained for compatibility with ``_cubit_python.image`` API
        image_size: tuple[int, int] = parsers.image_defaults["image_size"],  # type: ignore[assignment] # noqa: ARG001
    ) -> None:
        """Open a Gmsh geometry or mesh file and save an image.

        Uses the Gmsh ``write`` command, which accepts gif, jpg, tex, pdf, png, pgf, ps, ppm, svg, tikz, and yuv file
        extensions.

        :param str input_file: Gmsh input file to open
        :param str output_file: Screenshot file to write
        :param float x_angle: Rotation about 'world' X-axis in degrees
        :param float y_angle: Rotation about 'world' Y-axis in degrees
        :param float z_angle: Rotation about 'world' Z-axis in degrees
        :param tuple image_size: Image size in pixels (width, height)
        """
        # Universally required setup
        gmsh.initialize()
        gmsh.logger.start()

        # Input/Output setup
        input_file = pathlib.Path(input_file)
        output_file = pathlib.Path(output_file)

        gmsh.open(str(input_file))

        gmsh.fltk.initialize()
        gmsh.option.setNumber("General.Trackball", 0)
        gmsh.option.setNumber("General.RotationX", x_angle)
        gmsh.option.setNumber("General.RotationY", y_angle)
        gmsh.option.setNumber("General.RotationZ", z_angle)

        # Output and cleanup
        gmsh.write(str(output_file))
        gmsh.logger.stop()
        gmsh.finalize()