{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Profiles and Shapes\n", "\n", "## Introduction\n", "\n", "This tutorial is about generating custom 2d profiles using the geometry package. The process can be divided into 3 separate steps.\n", "\n", "- Create segments\n", "- Create Shapes from segments\n", "- Create a profile from multiple shapes\n", "\n", "Each individual step will be discussed separately.\n", "\n", "Before we can start, we need to import some packages:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import copy\n", "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "from mpl_toolkits.mplot3d import Axes3D # noqa: F401 unused import\n", "\n", "import weldx.geometry as geo\n", "import weldx.transformations as tf\n", "from weldx import Q_" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Apart from some standard packages and the geometry module, we also import the transformation module. This is explained in detail in another tutorial, but the utilized functionality is rather self explanatory.\n", "\n", "## Segments\n", "\n", "Segments are small, 2-dimensional objects which define an elementary shape bounded by a starting point and an end point. Arbitrary shapes can be constructed with those basic entities. The simplest one is the line segment.\n", "\n", "### Line segments\n", "\n", "The LineSegment class describes a straight line between 2 points in 2d space. It can be constructed using the class method 'construct_with_points':" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "line_segment_0 = geo.LineSegment.construct_with_points(\n", " Q_([0, 0], \"mm\"), Q_([1, 0], \"mm\")\n", ")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "It is also possible to create a line segment by calling its constructor directly. It requires a 2x2 matrix as input, where the first column contains the coordinates of the starting point and the second column the coordinates of the end point." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "line_segment_1 = geo.LineSegment(Q_([[1, 0], [-1, -1]], \"mm\"))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "However, using the constructor directly might cause some confusion when just looking onto the code because of the way a matrix is defined. In the line above one can make the mistake and think the line segment is composed by the two points [1, 0] and [-1, -1]. But instead its points are [1, -1] and [0, -1], because each inner set of brackets define a row of the matrix (This convention is adapted from numpy) and the points are stored in columns. Keep that in mind or use the construct_with_points method exclusively.\n", "\n", "### Rasterization\n", "\n", "Each segment type has a 'rasterize' method which creates a set of data points that lie on the curve defined by the segment. The start and end point are always included. Additionally, all points have the same distance to each other. How large this distance is must be specified using the functions raster_width parameter. The data is returned in form of a 2xN matrix.\n", "\n", "For a LineSegment, all data points are located on the connecting line between start and end point." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# rasterize both segments\n", "data_line_segment_0 = line_segment_0.rasterize(\"0.1mm\")\n", "data_line_segment_1 = line_segment_1.rasterize(\"0.3mm\")\n", "\n", "# plot data\n", "plt.plot(data_line_segment_0[0], data_line_segment_0[1], \"o\")\n", "plt.plot(data_line_segment_1[0], data_line_segment_1[1], \"ro\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In the above example we rasterized both segments using different raster_width. While it is possible to create a set of equidistant data points using raster_width=0.1 for a line segment of length 1, it can not be done with raster_width=0.3. So the function calculates an effective raster width which is as close as possible to the specified one and uses this instead. For our example with raster_width=0.3 the effective raster width is 1/3.\n", "\n", "In case the raster width exceeds the length of the segment, it is automatically clipped to the segment length. In result, the data contains only the start and end point. Negative values or 0 will trigger an exception.\n", "\n", "### Arc segments\n", "\n", "Another default segment type is the ArcSegment. As the name suggests, it represents an arc between the defined start and end point. There are several ways to create an arc segment. The first one is using three points, the start and end point of the segment and the center point of the arc. The corresponding function is the construct_with_points method. It takes 4 parameters. The first 3 are the points. The fourth one is a bool which defines the winding order of the arc. If it is set to True the arc connects the start point to the end point using an counter clockwise arc. Otherwise both points are connected with a clockwise arc segment. This is shown in the following example, were we create two arc segment with identical points and their center point being the coordinate systems origin:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create arc segments\n", "arc_segment_0_cw = geo.ArcSegment.construct_with_points(\n", " Q_([-1, 0], \"mm\"),\n", " Q_([0, 1], \"mm\"),\n", " point_center=Q_([0, 0], \"mm\"),\n", " arc_winding_ccw=False,\n", ")\n", "arc_segment_0_ccw = geo.ArcSegment.construct_with_points(\n", " Q_([-1, 0], \"mm\"),\n", " Q_([0, 1], \"mm\"),\n", " point_center=Q_([0, 0], \"mm\"),\n", " arc_winding_ccw=True,\n", ")\n", "\n", "# rasterize segments\n", "data_arc_segment_0_cw = arc_segment_0_cw.rasterize(\"0.1mm\")\n", "data_arc_segment_0_ccw = arc_segment_0_ccw.rasterize(\"0.1mm\")\n", "\n", "# plot data\n", "plt.plot(data_arc_segment_0_cw[0], data_arc_segment_0_cw[1], \"o\", label=\"clockwise\")\n", "plt.plot(\n", " data_arc_segment_0_ccw[0],\n", " data_arc_segment_0_ccw[1],\n", " \"ro\",\n", " label=\"counter clockwise\",\n", ")\n", "plt.grid()\n", "plt.legend()\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The provided center point must have the same distance to the start and the end point of the segment. Otherwise we would get an elliptical arch instead of an arc. If this condition is not fulfilled, an exception is raised.\n", "\n", "Another method to construct an arc segment is to provide the segments start and end point and a radius. To do so, use the \"construct_with_radius\" method. It takes 5 parameters. The first to parameters are the segments start and end point. The third is the radius. Since there usually exist two possible center points with the same radius, the fourth parameter is a bool defining which center point should be selected. If True, the point to the left of the line connecting start and end point is selected. Otherwise the right point is selected. The fifth parameter determines the winding of the arc segment.\n", "\n", "Here is a short example:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create arc segments\n", "arc_segment_1_rcp = geo.ArcSegment.construct_with_radius(\n", " Q_([0, 0], \"mm\"),\n", " Q_([1, 1], \"mm\"),\n", " radius=\"1mm\",\n", " center_left_of_line=False,\n", " arc_winding_ccw=True,\n", ")\n", "arc_segment_1_lcp = geo.ArcSegment.construct_with_radius(\n", " Q_([0, 0], \"mm\"),\n", " Q_([1, 1], \"mm\"),\n", " radius=\"1mm\",\n", " center_left_of_line=True,\n", " arc_winding_ccw=True,\n", ")\n", "\n", "# rasterize segments\n", "data_arc_segment_1_rcp = arc_segment_1_rcp.rasterize(\"0.1mm\")\n", "data_arc_segment_1_lcp = arc_segment_1_lcp.rasterize(\"0.1mm\")\n", "\n", "# extract center points\n", "center_point_right = arc_segment_1_rcp.point_center\n", "center_point_left = arc_segment_1_lcp.point_center\n", "\n", "# plot everything\n", "plt.plot(data_arc_segment_1_rcp[0], data_arc_segment_1_rcp[1], \"ro\")\n", "plt.plot(data_arc_segment_1_lcp[0], data_arc_segment_1_lcp[1], \"bo\")\n", "plt.plot(center_point_right[0], center_point_right[1], \"rx\", label=\"right center point\")\n", "plt.plot(center_point_left[0], center_point_left[1], \"bx\", label=\"left center point\")\n", "plt.plot([0, 1], [0, 1], \"g\", label=\"start point -> end point\")\n", "plt.grid()\n", "plt.legend(loc=\"lower left\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As for the LineSegment, an ArcSegment can also be constructed using the class constructor. It takes a 2x3 matrix and a winding order as parameters, were the columns are start, end and center point. However, for the same reasons mentioned in the section about the LineSegment, it is better to use the \"construct\" methods.\n", "\n", "## Shape\n", "\n", "The Shape class is a container class that stores multiple segments and ensures that they are connected to each other. The start point of a segment must always be identical to the end point of the previous segment, if it is not the first. If you try to add a segment which does not fulfill this requirement, an exception is raised.\n", "\n", "The class constructor takes a single segment or a list of segments as parameter. You can also provide an empty list or no parameter at all, which results in an empty Shape. You can also always add more segments using the add_segments function. As for the constructor, you can provide single segments or lists of segments:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create some segments\n", "segment_0 = geo.LineSegment.construct_with_points(Q_([0, 0], \"mm\"), Q_([0, 1], \"mm\"))\n", "segment_1 = geo.LineSegment.construct_with_points(Q_([0, 1], \"mm\"), Q_([1, 1], \"mm\"))\n", "segment_2 = geo.ArcSegment.construct_with_points(\n", " Q_([1, 1], \"mm\"),\n", " Q_([3, 1], \"mm\"),\n", " point_center=Q_([2, 1], \"mm\"),\n", " arc_winding_ccw=False,\n", ")\n", "segment_3 = geo.LineSegment.construct_with_points(Q_([3, 1], \"mm\"), Q_([4, 1], \"mm\"))\n", "segment_4 = geo.LineSegment.construct_with_points(Q_([4, 1], \"mm\"), Q_([4, 0], \"mm\"))\n", "segment_5 = geo.LineSegment.construct_with_points(Q_([4, 0], \"mm\"), Q_([0, 0], \"mm\"))\n", "\n", "\n", "# create a shape\n", "shape_0 = geo.Shape([segment_0, segment_1])\n", "\n", "# add more segments to the shape\n", "shape_0.add_segments(segment_2) # single segment\n", "shape_0.add_segments([segment_3, segment_4, segment_5]) # list of segments" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Like the segments, the Shape class has a rasterize function, which works quite similar. Internally it just calls the rasterize methods of all its segments using the provided raster_width and returns the combined data. Because of this behaviour, the effective raster width may vary for each individual segment of the shape. An extreme example is to take a raster_width which is bigger than the length of the largest segment. Each segment will clip it to its own length and return just its start and end point:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# rasterize shape with different raster width\n", "shape_0_data = shape_0.rasterize(\"0.1mm\")\n", "shape_0_data_clipped = shape_0.rasterize(\"10000mm\")\n", "\n", "# plot data\n", "plt.plot(shape_0_data[0], shape_0_data[1], \"bx-\", label=\"raster width = 0.1\")\n", "plt.plot(shape_0_data_clipped[0], shape_0_data_clipped[1], \"ro-\", label=\"clipped\")\n", "plt.grid()\n", "plt.legend(loc=\"upper left\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that the shared points of the connected segments occur only once in the data array even though the rasterize methods of two neighboring segments both return their shared point. Additionally, the Shape recognizes that the first point of the first segment is identical to the last point of the last segment and includes it only once.\n", "To be sure let's print the number of points of the clipped data, which must be 6 if no duplications occur:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(shape_0_data_clipped.shape[1])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "However, the shape does not generally filter duplications. If two segments have a common point which is not the first/last point of the shape and one or more segments were added between them, then this point will usually be included more than once. If this is a problem, you need to take care of that yourself by using numpy.unique or an equivalent function.\n", "\n", "## Adding multiple line segments to a shape\n", "\n", "So far, creating a Shape required us to generate all the segments in advance, which is a little bit tedious. Additionally, because all segments need to be connected to each other, a lot of points occur at least twice during the segments creation. By using the add_line_segments function of the Shape, it is no longer necessary to create line segments in advance.\n", "\n", "The function takes a list of points and creates line segments from them, which are subsequently added to the Shape. The segments are created by traversing the list in ascending order and using the corresponding point as the segments end point. The start point is taken from the previous segment in the Shape. In case the Shape is empty, the first two points of the list are used to create the new segment:\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create first 2 segments with an empty shape\n", "shape_1 = geo.Shape()\n", "shape_1.add_line_segments(Q_([[0, 0], [0, 1], [1, 1]], \"mm\"))\n", "\n", "# add 6 more segments to the existing shape\n", "shape_1.add_line_segments(\n", " Q_([[0.5, 1.5], [0, 1], [1, 0], [0, 0], [1, 1], [1, 0]], \"mm\")\n", ")\n", "\n", "# rasterize data\n", "data_shape_1 = shape_1.rasterize(\"0.05mm\")\n", "\n", "# plot\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "There is no equivalent function for arc segments. They can be constructed in different ways and during construction you need to provide more data than just the segments start and end point. Hence an \"add_arc_segments\" function would have a bloated and probably non-intuitive interface.\n", "\n", "## Transformations\n", "\n", "Consider you want to generate the shape of the previous example but rotated about a certain angle. Calculating the new position of the points and creating a new shape would be rather wearisome. Instead one can use shape's transformation functions. These are translate, transform, apply_translation and apply_transformation. The functions with the preceding \"apply_\" perform in-place transformations while the other ones return a transformed copy of the original object.\n", "\n", "translate and apply_translation take a 2d vector as parameter. It defines the translation which is applied to every segment of the shape:\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "shape_2 = shape_1.translate(Q_([-2, 3], \"mm\"))\n", "\n", "# rasterize data\n", "data_shape_2 = shape_2.rasterize(\"0.05mm\")\n", "\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\")\n", "plt.plot(data_shape_2[0], data_shape_2[1], \"bx\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "transform and apply_transformation expect a 2x2 transformation matrix as parameter, which is applied to all segments of the shape:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create a transformation matrix which distorts and rotates the shape\n", "angle = np.pi / 6\n", "s = np.sin(angle)\n", "c = np.cos(angle)\n", "\n", "distortion_matrix = np.array([[2, 0], [0, 5]])\n", "rotation_matrix = np.array([[c, -s], [s, c]])\n", "transformation_matrix = np.matmul(rotation_matrix, distortion_matrix)\n", "\n", "# get transformed shape\n", "shape_3 = shape_1.transform(transformation_matrix)\n", "\n", "# rasterize\n", "data_shape_3 = shape_3.rasterize(\"0.05mm\")\n", "\n", "# plot all shapes\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\")\n", "plt.plot(data_shape_2[0], data_shape_2[1], \"bx\")\n", "plt.plot(data_shape_3[0], data_shape_3[1], \"gx\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Reflections\n", "\n", "Since a lot of profiles consist of symmetric shapes, there is a reflect and a apply_reflection function. Similar to the other transformation functions reflect creates a reflected copy of the shape while apply_reflection modifies the original shape. These function perform a reflection across an arbitrary line. The first parameter of those functions is the normal of the line of reflection. The second parameter is optional and specifies the line of reflection's distance to the coordinate systems origin. The default distance is 0. Here are 3 examples:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# reflect across the y axis\n", "shape_4 = shape_1.reflect(reflection_normal=Q_([1, 0], \"mm\"))\n", "\n", "# rasterize\n", "data_shape_4 = shape_4.rasterize(\"0.05mm\")\n", "\n", "# plot all shapes\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\", label=\"original\")\n", "plt.plot(data_shape_4[0], data_shape_4[1], \"bx\", label=\"reflected\")\n", "plt.plot([0, 0], [-1, 2], \"y--\", label=\"line of reflection\")\n", "plt.legend()\n", "plt.axis(\"equal\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# reflect across the horizontal line with y = 2\n", "shape_5 = shape_1.reflect(reflection_normal=Q_([0, 1], \"mm\"), distance_to_origin=\"2mm\")\n", "\n", "# rasterize\n", "data_shape_5 = shape_5.rasterize(\"0.05mm\")\n", "\n", "# plot all shapes\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\", label=\"original\")\n", "plt.plot(data_shape_5[0], data_shape_5[1], \"bx\", label=\"reflected\")\n", "plt.plot([-1, 2], [2, 2], \"y--\", label=\"line of reflection\")\n", "plt.legend(loc=\"lower left\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# reflect across a line with slope 1 containing the points (0,1) and (0.5, 1.5)\n", "shape_6 = shape_1.reflect(\n", " reflection_normal=Q_([-1, 1], \"mm\"), distance_to_origin=Q_(1 / np.sqrt(2), \"mm\")\n", ")\n", "\n", "# rasterize\n", "data_shape_6 = shape_6.rasterize(\"0.05mm\")\n", "\n", "# plot all shapes\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\", label=\"original\")\n", "plt.plot(data_shape_6[0], data_shape_6[1], \"bx\", label=\"reflected\")\n", "plt.plot([-1, 2], [0, 3], \"y--\", label=\"line of reflection\")\n", "plt.grid()\n", "plt.legend(loc=\"upper right\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "In the last example we wanted to reflect across a line that is not parallel to one of the coordinate systems axes. Providing the normal and distance to the origin gets a little bit more complicated for this case, since they aren't obvious anymore and need to be calculated. As an alternative to the previous ones, there also exist the methods reflect_across_line and apply_reflection_across_line. They perform a reflection across a line which is defined by its start and end point. Here is the alternative version of the previous example:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# reflect across a line with slope 1 containing the points (0,1) and (0.5, 1.5)\n", "point_0 = Q_([0, 1], \"mm\")\n", "point_1 = Q_([0.5, 1.5], \"mm\")\n", "shape_7 = shape_1.reflect_across_line(point_0, point_1)\n", "\n", "# rasterize\n", "data_shape_7 = shape_7.rasterize(\"0.05mm\")\n", "\n", "# plot all shapes\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\", label=\"original\")\n", "plt.plot(data_shape_7[0], data_shape_7[1], \"bx\", label=\"reflected\")\n", "plt.plot(\n", " [point_0[0].m, point_1[0].m], [point_0[1].m, point_1[1].m], \"co\", label=\"points\"\n", ")\n", "plt.plot([-1, 2], [0, 3], \"y--\", label=\"line of reflection\")\n", "plt.grid()\n", "plt.legend(loc=\"upper right\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here is another example:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# reflect across a line with slope 1 containing the points (-2,1) and (2, -4.5)\n", "point_0 = Q_([-2, 1], \"mm\")\n", "point_1 = Q_([2, -4.5], \"mm\")\n", "shape_8 = shape_1.reflect_across_line(point_0, point_1)\n", "\n", "# rasterize\n", "data_shape_8 = shape_8.rasterize(\"0.05mm\")\n", "\n", "# plot all shapes\n", "plt.plot(data_shape_1[0], data_shape_1[1], \"rx\", label=\"original\")\n", "plt.plot(data_shape_8[0], data_shape_8[1], \"bx\", label=\"reflected\")\n", "plt.plot(\n", " [point_0[0].m, point_1[0].m], [point_0[1].m, point_1[1].m], \"co\", label=\"points\"\n", ")\n", "plt.plot(\n", " [point_0[0].m, point_1[0].m],\n", " [point_0[1].m, point_1[1].m],\n", " \"y--\",\n", " label=\"line of reflection\",\n", ")\n", "plt.grid()\n", "plt.legend(loc=\"lower left\")\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Profiles\n", "\n", "A Profile is a container class that stores multiple shapes. It represents the cross section of an assembly. One can add shapes to a Profile via its constructor or the add_shapes method. Both accept single shapes and lists of shapes. Like segments and the Shape class, a Profile also has a rasterize with identical functionality. Lets create a symmetric profile and rasterize it:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create shapes\n", "shape_9 = geo.Shape()\n", "shape_9.add_line_segments(\n", " Q_([[0, 1], [1.5, 1], [3, 0.25], [3, -1], [0, -1], [0, 1]], \"mm\")\n", ")\n", "shape_10 = shape_9.reflect(\n", " reflection_normal=Q_([1, 0], \"mm\"), distance_to_origin=\"3.5mm\"\n", ")\n", "\n", "# create profile\n", "profile_0 = geo.Profile(shape_9)\n", "profile_0.add_shapes(shape_10)\n", "\n", "# rasterize\n", "data_profile_0 = profile_0.rasterize(\"0.1mm\")\n", "\n", "# plot\n", "plt.plot(data_profile_0[0], data_profile_0[1], \"rx\")\n", "plt.grid()\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "That's already everything you need to know about profiles\n", "\n", "## Custom segments\n", "\n", "It might happen that lines and arcs are not enough to sufficiently describe a certain shape or profile. For this reason it is possible to define custom segment types. A segment which is usable with the Shape and Profile classes is a python class that needs at least a rasterize method and a point_start and point_end property. However, you want to use the Shapes transformation functions, you also need to define the segments translate, transform, apply_translation and apply_transformation functions. \n", "\n", "As a small example, we will create a sinusoidal wave segment. It should generate a sinusoidal wave in normal direction to the line from the segments start to its end. Since the start and end points must be included in the segments shape (otherwise we get visual gaps during rasterization) we can only use waves with wave lengths $N\\pi$, were $N$ is the number half waves. Additionally we will add the option to vary the waves amplitude. The constructor of the class looks as follows:\n", "\n", "~~~ python\n", " def __init__(self, point_start, point_end, num_half_waves, amplitude=1):\n", " self._points = np.array([point_start, point_end], float).transpose()\n", " self._num_half_waves = num_half_waves\n", "\n", " vector_start_end = self.point_end - self.point_start\n", " normal = np.array([-vector_start_end[1], vector_start_end[0]], float)\n", "\n", " self._amplitude_vector = np.ndarray((2, 1), float, tf.normalize(normal)) * amplitude\n", "~~~\n", "\n", "The points are stored as columns in a 2x2 matrix. Instead of storing the amplitude value itself, the normal to the vector point_start->point_end is calculated. Its length is adjusted so that it is equal to the amplitude. We call this vector _amplitude_vector and store it.\n", "The reason for this is, that certain transformations (uneven scaling) distort the wave. As we will see later, this distortion can be memoized using the _amplitude_vector.\n", "\n", "The implementation of point_start and point_end properties should be self explanatory:\n", "\n", "~~~ python\n", " @property\n", " def point_start(self):\n", " return self._points[:, 0]\n", "\n", " @property\n", " def point_end(self):\n", " return self._points[:, 1]\n", "~~~\n", "\n", "The last mandatory method a segment must provide is the rasterize method with a single parameter, the raster_width:\n", "\n", "~~~ python\n", " def rasterize(self, raster_width):\n", " points_on_line = self._calculate_points_on_line(raster_width)\n", " offsets = self._calculate_offsets(points_on_line.shape[1] - 1)\n", "\n", " return points_on_line + offsets\n", "~~~ \n", "\n", "The implementation is split into 2 parts. First an equidistant set of points on the line point_start->point_end is generated. Afterwards the offset in direction of the _amplitude_vector are calculated and added. \n", "\n", "Note, that the point are not equidistant anymore, after the offset is applied. This is not consistent with the implementation of the LineSegment and the ArcSegment, but we want to keep things simple here.\n", "\n", "The implementation of the function _calculate_points_on_line is:\n", "\n", "~~~ python\n", " def _calculate_points_on_line(self, raster_width):\n", " # calculate distance between start and end point\n", " vector_start_end = self.point_end - self.point_start\n", " distance = np.linalg.norm(vector_start_end)\n", "\n", " # normalized effective raster width\n", " num_raster_segments = np.round(distance / raster_width)\n", " nerw = 1. / num_raster_segments\n", " \n", " # linear interpolation of the points\n", " weights = np.arange(0, 1 + 0.5 * nerw, nerw)\n", " weight_matrix = np.array([1 - weights, weights])\n", " return np.matmul(self._points, weight_matrix)\n", "~~~\n", "\n", "First, the length of the vector point_start->point_end is calculated. Using the specified raster_width, one can calculate how many points will fit into the raster. Afterwards, linear interpolation between start and end point is used.\n", "\n", "The offsets are determined as follows:\n", "\n", "~~~ python\n", " def _calculate_offsets(self, num_raster_segments):\n", " total_range = np.pi * self._num_half_waves\n", " increment = total_range / num_raster_segments\n", " \n", " angles = np.arange(0, total_range + 0.5 * increment, increment)\n", " return np.sin(angles) * self._amplitude_vector\n", "~~~\n", "\n", "It calculates the sine for each point multiplies it with the amplitude vector. Note that the points positions are not needed here, since we know that they areequidistant.\n", "\n", "Here is the fully implemented class:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "from weldx.constants import WELDX_UNIT_REGISTRY as UREG\n", "\n", "_DEFAULT_LEN_UNIT = UREG.millimeters\n", "\n", "\n", "class SineWaveSegmentBase:\n", " @UREG.wraps(\n", " None,\n", " (None, _DEFAULT_LEN_UNIT, _DEFAULT_LEN_UNIT, None, _DEFAULT_LEN_UNIT),\n", " strict=True,\n", " )\n", " def __init__(self, point_start, point_end, num_half_waves, amplitude=1):\n", " self._points = np.array(\n", " [point_start.tolist(), point_end.tolist()], float\n", " ).transpose()\n", " self._num_half_waves = num_half_waves\n", "\n", " vector_start_end = self.point_end.m - self.point_start.m\n", " normal = np.array([-vector_start_end[1], vector_start_end[0]], float)\n", "\n", " self._amplitude_vector = (\n", " np.ndarray((2, 1), float, tf.normalize(normal)) * amplitude\n", " )\n", "\n", " @property\n", " @UREG.wraps(_DEFAULT_LEN_UNIT, (None,), strict=True)\n", " def point_start(self):\n", " return self._points[:, 0]\n", "\n", " @property\n", " @UREG.wraps(_DEFAULT_LEN_UNIT, (None,), strict=True)\n", " def point_end(self):\n", " return self._points[:, 1]\n", "\n", " def _calculate_points_on_line(self, raster_width):\n", " # calculate distance between start and end point\n", " vector_start_end = self.point_end.m - self.point_start.m\n", " distance = np.linalg.norm(vector_start_end)\n", "\n", " # normalized effective raster width\n", " num_raster_segments = np.round(distance / raster_width)\n", " nerw = 1.0 / num_raster_segments\n", "\n", " # linear interpolation of the points\n", " weights = np.arange(0, 1 + 0.5 * nerw, nerw)\n", " weight_matrix = np.array([1 - weights, weights])\n", " return np.matmul(self._points, weight_matrix)\n", "\n", " def _calculate_offsets(self, num_raster_segments):\n", " total_range = np.pi * self._num_half_waves\n", " increment = total_range / num_raster_segments\n", "\n", " angles = np.arange(0, total_range + 0.5 * increment, increment)\n", " return np.sin(angles) * self._amplitude_vector\n", "\n", " @UREG.wraps(_DEFAULT_LEN_UNIT, (None, _DEFAULT_LEN_UNIT), strict=True)\n", " def rasterize(self, raster_width):\n", " points_on_line = self._calculate_points_on_line(raster_width)\n", " offsets = self._calculate_offsets(points_on_line.shape[1] - 1)\n", "\n", " return points_on_line + offsets" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we generate a shape, add an instance of this segment and rasterize it." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create custom segment\n", "sw_base_segment = SineWaveSegmentBase(Q_([0, 0], \"mm\"), Q_([5, 5], \"mm\"), 5, \"1mm\")\n", "\n", "# create a shape\n", "shape_11 = geo.Shape(sw_base_segment)\n", "shape_11.add_line_segments(Q_([[10, 0], [5, -5], [0, 0]], \"mm\"))\n", "\n", "# rasterize\n", "data_shape_11 = shape_11.rasterize(\"0.25mm\")\n", "\n", "\n", "# plot data\n", "plt.plot(data_shape_11[0], data_shape_11[1], \"rx\")\n", "plt.grid()\n", "plt.axis(\"equal\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "As you can see, we have successfully implemented our custom segment. \n", "\n", "If we want to apply transformations to the shape, we need to add the corresponding functionality to the segment type. We generate a new class which inherits from the SineWaveSegmentBase. Then we add the following functions, which perform \"in-place\" transformations:\n", "\n", "~~~ python\n", "def apply_translation(self, vector):\n", " self._points += np.ndarray((2, 1), float, np.array(vector, float))\n", " return self\n", "\n", "def apply_transformation(self, matrix):\n", " self._points = np.matmul(matrix, self._points)\n", " self._amplitude_vector = np.matmul(matrix, self._amplitude_vector)\n", " return self\n", "~~~\n", "\n", "The translation is applied by adding the passed vector to the segments start and end point. A transformation is done by multiplying the _points matrix with the passed transformation matrix. Additionally, the _amplitude_vector must also be multiplied by the matrix.\n", "\n", "We can use those 2 functions to add the other two functions that return a transformed copy:\n", "\n", "~~~ python\n", " def translate(self, vector):\n", " new_segment = copy.deepcopy(self)\n", " return new_segment.apply_translation(vector)\n", "\n", " def transform(self, matrix):\n", " new_segment = copy.deepcopy(self)\n", " return new_segment.apply_transformation(matrix)\n", "~~~\n", "\n", "Here is the full class:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "class SineWaveSegmentFull(SineWaveSegmentBase):\n", " def __init__(self, point_start, point_end, num_half_waves, amplitude=\"1mm\"):\n", " super().__init__(point_start, point_end, num_half_waves, amplitude)\n", "\n", " @UREG.wraps(None, (None, _DEFAULT_LEN_UNIT), strict=True)\n", " def apply_translation(self, vector):\n", " self._points += np.ndarray((2, 1), float, np.array(vector, float))\n", " return self\n", "\n", " @UREG.check(None, _DEFAULT_LEN_UNIT)\n", " def translate(self, vector):\n", " new_segment = copy.deepcopy(self)\n", " return new_segment.apply_translation(vector)\n", "\n", " def apply_transformation(self, matrix):\n", " self._points = np.matmul(matrix, self._points)\n", " self._amplitude_vector = np.matmul(matrix, self._amplitude_vector)\n", " return self\n", "\n", " def transform(self, matrix):\n", " new_segment = copy.deepcopy(self)\n", " return new_segment.apply_transformation(matrix)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Here are some examples that show that our implementation works:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create custom segment\n", "sw_full_segment = SineWaveSegmentFull(Q_([0, 0], \"mm\"), Q_([5, 5], \"mm\"), 4, \"2mm\")\n", "\n", "# create a shape\n", "shape_12 = geo.Shape(sw_full_segment)\n", "shape_12.add_line_segments(Q_([[10, 0], [5, -5], [0, 0]], \"mm\"))\n", "\n", "# create translated copy\n", "shape_13 = shape_12.translate(Q_([-14, 7], \"mm\"))\n", "\n", "# create a distorted and translated copy\n", "shape_14 = shape_12.transform([[2, 0], [0, 0.5]])\n", "shape_14.apply_translation(Q_([0, 10], \"mm\"))\n", "\n", "# create a rotated and translated copy\n", "s = np.sin(-np.pi / 4)\n", "c = np.cos(-np.pi / 4)\n", "shape_15 = shape_12.transform([[c, -s], [s, c]])\n", "shape_15.apply_translation(Q_([25, 10], \"mm\"))\n", "\n", "# create a reflected copy\n", "point_0 = Q_([-5, 0], \"mm\")\n", "point_1 = Q_([0, -10], \"mm\")\n", "shape_16 = shape_12.reflect_across_line(point_0, point_1)\n", "\n", "\n", "# rasterize\n", "data_shape_12 = shape_12.rasterize(\"0.25mm\")\n", "data_shape_13 = shape_13.rasterize(\"0.25mm\")\n", "data_shape_14 = shape_14.rasterize(\"0.25mm\")\n", "data_shape_15 = shape_15.rasterize(\"0.25mm\")\n", "data_shape_16 = shape_16.rasterize(\"0.25mm\")\n", "\n", "# plot data\n", "plt.plot(data_shape_12[0], data_shape_12[1], \"r\", label=\"original\")\n", "plt.plot(data_shape_13[0], data_shape_13[1], \"b\", label=\"translated\")\n", "plt.plot(data_shape_14[0], data_shape_14[1], \"g\", label=\"distorted and translated\")\n", "plt.plot(data_shape_15[0], data_shape_15[1], \"k\", label=\"rotated and translated\")\n", "plt.plot(data_shape_16[0], data_shape_16[1], \"m\", label=\"reflected\")\n", "plt.plot(\n", " [point_0[0].m, point_1[0].m], [point_0[1].m, point_1[1].m], \"co\", label=\"points\"\n", ")\n", "plt.plot(\n", " [point_0[0].m, point_1[0].m],\n", " [point_0[1].m, point_1[1].m],\n", " \"y--\",\n", " label=\"line of reflection\",\n", ")\n", "plt.grid()\n", "plt.axis(\"equal\")\n", "plt.legend(loc=\"lower right\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "", "language": "python", "name": "" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.12" } }, "nbformat": 4, "nbformat_minor": 4 }