{ "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 }