From 87c55225c99bfec45c0ea7dd6a458b47029f14ba Mon Sep 17 00:00:00 2001 From: Ilya Portnov Date: Tue, 16 Jun 2020 23:40:09 +0500 Subject: [PATCH 1/3] "Pipe Surface" node. --- index.md | 1 + nodes/surface/pipe.py | 92 +++++++++++++++++++++++++++++++++++++++++++ utils/curve.py | 53 ++++++++++++++++++++++++- utils/field/vector.py | 34 ++-------------- utils/math.py | 8 ++++ utils/surface.py | 84 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 nodes/surface/pipe.py diff --git a/index.md b/index.md index 6c15dbcfe..6c1dd143f 100644 --- a/index.md +++ b/index.md @@ -106,6 +106,7 @@ SvExExtrudeCurveVectorNode SvExExtrudeCurveCurveSurfaceNode SvExExtrudeCurvePointNode + SvPipeSurfaceNode SvExCurveLerpNode SvExSurfaceLerpNode SvCoonsPatchNode diff --git a/nodes/surface/pipe.py b/nodes/surface/pipe.py new file mode 100644 index 000000000..31aaee6e6 --- /dev/null +++ b/nodes/surface/pipe.py @@ -0,0 +1,92 @@ + +import numpy as np + +import bpy +from bpy.props import FloatProperty, EnumProperty, BoolProperty, IntProperty + +from sverchok.node_tree import SverchCustomTreeNode, throttled +from sverchok.data_structure import updateNode, zip_long_repeat, ensure_nesting_level +from sverchok.utils.logging import info, exception + +from sverchok.utils.curve import SvCurve +from sverchok.utils.surface import SvConstPipeSurface +from sverchok.utils.math import ZERO, FRENET, HOUSEHOLDER, TRACK, DIFF, TRACK_NORMAL + +class SvPipeSurfaceNode(bpy.types.Node, SverchCustomTreeNode): + """ + Triggers: Constant Cylindric Pipe + Tooltip: Generate a cylindric pipe surface + """ + bl_idname = 'SvPipeSurfaceNode' + bl_label = 'Pipe (Surface)' + bl_icon = 'MOD_THICKNESS' + + modes = [ + (FRENET, "Frenet", "Frenet / native rotation", 0), + (ZERO, "Zero-twist", "Zero-twist rotation", 1), + (HOUSEHOLDER, "Householder", "Use Householder reflection matrix", 2), + (TRACK, "Tracking", "Use quaternion-based tracking", 3), + (DIFF, "Rotation difference", "Use rotational difference calculation", 4), + (TRACK_NORMAL, "Track normal", "Try to maintain constant normal direction by tracking along curve", 5) + ] + + @throttled + def update_sockets(self, context): + self.inputs['Resolution'].hide_safe = self.algorithm not in {ZERO, TRACK_NORMAL} + + algorithm : EnumProperty( + name = "Algorithm", + items = modes, + default = FRENET, + update = update_sockets) + + resolution : IntProperty( + name = "Resolution", + min = 10, default = 50, + update = updateNode) + + radius : FloatProperty( + name = "Radius", + description = "Pipe radius", + min = 0.0, default = 0.1, + update = updateNode) + + def draw_buttons(self, context, layout): + layout.prop(self, "algorithm") + + def sv_init(self, context): + self.inputs.new('SvCurveSocket', "Curve") + self.inputs.new('SvStringsSocket', "Radius").prop_name = 'radius' + self.inputs.new('SvStringsSocket', "Resolution").prop_name = 'resolution' + self.outputs.new('SvSurfaceSocket', "Surface") + self.update_sockets(context) + + def process(self): + if not any(socket.is_linked for socket in self.outputs): + return + + profile_s = self.inputs['Curve'].sv_get() + resolution_s = self.inputs['Resolution'].sv_get() + radius_s = self.inputs['Radius'].sv_get() + + profile_s = ensure_nesting_level(profile_s, 2, data_types=(SvCurve,)) + resolution_s = ensure_nesting_level(resolution_s, 2) + radius_s = ensure_nesting_level(radius_s, 2) + + surface_out = [] + for profiles, radiuses, resolutions in zip_long_repeat(profile_s, radius_s, resolution_s): + new_surfaces = [] + for profile, radius, resolution in zip_long_repeat(profiles, radiuses, resolutions): + surface = SvConstPipeSurface(profile, radius, + algorithm = self.algorithm, + resolution = resolution) + new_surfaces.append(surface) + surface_out.append(new_surfaces) + self.outputs['Surface'].sv_set(surface_out) + +def register(): + bpy.utils.register_class(SvPipeSurfaceNode) + +def unregister(): + bpy.utils.unregister_class(SvPipeSurfaceNode) + diff --git a/utils/curve.py b/utils/curve.py index 4ecb85967..148e3b9da 100644 --- a/utils/curve.py +++ b/utils/curve.py @@ -13,7 +13,11 @@ from mathutils import Vector, Matrix from sverchok.utils.geom import PlaneEquation, LineEquation, LinearSpline, CubicSpline, CircleEquation2D, CircleEquation3D, Ellipse3D from sverchok.utils.integrate import TrapezoidIntegral from sverchok.utils.logging import error, exception -from sverchok.utils.math import binomial +from sverchok.utils.math import ( + binomial, + ZERO, FRENET, HOUSEHOLDER, TRACK, DIFF, TRACK_NORMAL + ) +from sverchok.utils.geom import autorotate_householder, autorotate_track, autorotate_diff ################## # # @@ -412,6 +416,53 @@ class SvNormalTrack(object): matrix_out.append(matrix) return np.array(matrix_out) +class MathutilsRotationCalculator(object): + + @classmethod + def get_matrix(cls, tangent, scale, axis, algorithm, scale_all=True, up_axis='X'): + """ + Calculate matrix required to rotate object according to `tangent` vector. + inputs: + * tangent - np.array of shape (3,) + * scale - float + * axis - int, 0 - X, 1 - Y, 2 - Z + * algorithm - one of HOUSEHOLDER, TRACK, DIFF + * scale_all - True to scale along all axes, False to scale along tangent only + * up_axis - string, "X", "Y" or "Z", for algorithm == TRACK only. + output: + np.array of shape (3,3). + """ + x = Vector((1.0, 0.0, 0.0)) + y = Vector((0.0, 1.0, 0.0)) + z = Vector((0.0, 0.0, 1.0)) + + if axis == 0: + ax1, ax2, ax3 = x, y, z + elif axis == 1: + ax1, ax2, ax3 = y, x, z + else: + ax1, ax2, ax3 = z, x, y + + if scale_all: + scale_matrix = Matrix.Scale(1/scale, 4, ax1) @ Matrix.Scale(scale, 4, ax2) @ Matrix.Scale(scale, 4, ax3) + else: + scale_matrix = Matrix.Scale(1/scale, 4, ax1) + scale_matrix = np.array(scale_matrix.to_3x3()) + + tangent = Vector(tangent) + if algorithm == HOUSEHOLDER: + rot = autorotate_householder(ax1, tangent).inverted() + elif algorithm == TRACK: + axis = "XYZ"[axis] + rot = autorotate_track(axis, tangent, up_axis) + elif algorithm == DIFF: + rot = autorotate_diff(tangent, ax1) + else: + raise Exception("Unsupported algorithm") + rot = np.array(rot.to_3x3()) + + return np.matmul(rot, scale_matrix) + class SvScalarFunctionCurve(SvCurve): __description__ = "Function" diff --git a/utils/field/vector.py b/utils/field/vector.py index 06e50a8f5..7f2ce1551 100644 --- a/utils/field/vector.py +++ b/utils/field/vector.py @@ -15,7 +15,7 @@ from mathutils import bvhtree from sverchok.utils.geom import autorotate_householder, autorotate_track, autorotate_diff, diameter, LineEquation, CircleEquation3D from sverchok.utils.math import from_cylindrical, from_spherical -from sverchok.utils.curve import SvCurveLengthSolver, SvNormalTrack +from sverchok.utils.curve import SvCurveLengthSolver, SvNormalTrack, MathutilsRotationCalculator ################## # # @@ -825,36 +825,8 @@ class SvBendAlongCurveField(SvVectorField): self.__description__ = "Bend along {}".format(curve) def get_matrix(self, tangent, scale): - x = Vector((1.0, 0.0, 0.0)) - y = Vector((0.0, 1.0, 0.0)) - z = Vector((0.0, 0.0, 1.0)) - - if self.axis == 0: - ax1, ax2, ax3 = x, y, z - elif self.axis == 1: - ax1, ax2, ax3 = y, x, z - else: - ax1, ax2, ax3 = z, x, y - - if self.scale_all: - scale_matrix = Matrix.Scale(1/scale, 4, ax1) @ Matrix.Scale(scale, 4, ax2) @ Matrix.Scale(scale, 4, ax3) - else: - scale_matrix = Matrix.Scale(1/scale, 4, ax1) - scale_matrix = np.array(scale_matrix.to_3x3()) - - tangent = Vector(tangent) - if self.algorithm == SvBendAlongCurveField.HOUSEHOLDER: - rot = autorotate_householder(ax1, tangent).inverted() - elif self.algorithm == SvBendAlongCurveField.TRACK: - axis = "XYZ"[self.axis] - rot = autorotate_track(axis, tangent, self.up_axis) - elif self.algorithm == SvBendAlongCurveField.DIFF: - rot = autorotate_diff(tangent, ax1) - else: - raise Exception("Unsupported algorithm") - rot = np.array(rot.to_3x3()) - - return np.matmul(rot, scale_matrix) + return MathutilsRotationCalculator.get_matrix(tangent, scale, self.axis, + self.algorithm, self.scale_all, self.up_axis) def get_matrices(self, ts, scale): n = len(ts) diff --git a/utils/math.py b/utils/math.py index 17901773e..41b7f28ea 100644 --- a/utils/math.py +++ b/utils/math.py @@ -47,6 +47,14 @@ proportional_falloff_types = [ all_falloff_types = falloff_types + [(id, title, desc, i + len(falloff_types)) for id, title, desc, i in proportional_falloff_types] +# Vector rotation calculation algorithms +ZERO = 'ZERO' +FRENET = 'FRENET' +HOUSEHOLDER = 'householder' +TRACK = 'track' +DIFF = 'diff' +TRACK_NORMAL = 'track_normal' + def smooth(x): return 3*x*x - 2*x*x*x diff --git a/utils/surface.py b/utils/surface.py index 1b61059fc..c093669e9 100644 --- a/utils/surface.py +++ b/utils/surface.py @@ -12,9 +12,12 @@ from collections import defaultdict from mathutils import Matrix, Vector from sverchok.utils.logging import info, exception -from sverchok.utils.math import from_spherical +from sverchok.utils.math import ( + from_spherical, + ZERO, FRENET, HOUSEHOLDER, TRACK, DIFF, TRACK_NORMAL + ) from sverchok.utils.geom import LineEquation, rotate_vector_around_vector, autorotate_householder, autorotate_track, autorotate_diff -from sverchok.utils.curve import SvFlipCurve, SvNormalTrack +from sverchok.utils.curve import SvFlipCurve, SvNormalTrack, SvCircle, MathutilsRotationCalculator def rotate_vector_around_vector_np(v, k, theta): """ @@ -1558,6 +1561,83 @@ class SvExtrudeCurveMathutilsSurface(SvSurface): def get_v_max(self): return self.extrusion.get_u_bounds()[1] +class SvConstPipeSurface(SvSurface): + __description__ = "Pipe" + + def __init__(self, curve, radius, algorithm = FRENET, resolution=50): + self.curve = curve + self.radius = radius + self.circle = SvCircle(Matrix(), radius) + self.algorithm = algorithm + self.normal_delta = 0.001 + self.u_bounds = self.circle.get_u_bounds() + if algorithm == TRACK_NORMAL: + self.normal_tracker = SvNormalTrack(curve, resolution) + elif algorithm == ZERO: + self.curve.pre_calc_torsion_integral(resolution) + + def get_u_min(self): + return self.u_bounds[0] + + def get_u_max(self): + return self.u_bounds[1] + + def get_v_min(self): + return self.curve.get_u_bounds()[0] + + def get_v_max(self): + return self.curve.get_u_bounds()[1] + + def evaluate(self, u, v): + return self.evaluate_array(np.array([u]), np.array([v]))[0] + + def get_matrix(self, tangent): + return MathutilsRotationCalculator.get_matrix(tangent, scale=1.0, + axis=2, + algorithm = self.algorithm, + scale_all=False) + + def get_matrices(self, ts): + n = len(ts) + if self.algorithm == FRENET: + frenet, _ , _ = self.curve.frame_array(ts) + return frenet + elif self.algorithm == ZERO: + frenet, _ , _ = self.curve.frame_array(ts) + angles = - self.curve.torsion_integral(ts) + zeros = np.zeros((n,)) + ones = np.ones((n,)) + row1 = np.stack((np.cos(angles), np.sin(angles), zeros)).T # (n, 3) + row2 = np.stack((-np.sin(angles), np.cos(angles), zeros)).T # (n, 3) + row3 = np.stack((zeros, zeros, ones)).T # (n, 3) + rotation_matrices = np.dstack((row1, row2, row3)) + return frenet @ rotation_matrices + elif self.algorithm == TRACK_NORMAL: + matrices = self.normal_tracker.evaluate_array(ts) + return matrices + elif self.algorithm in {HOUSEHOLDER, TRACK, DIFF}: + tangents = self.curve.tangent_array(ts) + matrices = np.vectorize(lambda t : self.get_matrix(t), signature='(3)->(3,3)')(tangents) + return matrices + else: + raise Exception("Unsupported algorithm") + + def evaluate_array(self, us, vs): + profile_vectors = self.circle.evaluate_array(us) + u_min, u_max = self.circle.get_u_bounds() + v_min, v_max = self.curve.get_u_bounds() + profile_vectors = np.transpose(profile_vectors[np.newaxis], axes=(1, 2, 0)) + extrusion_start = self.curve.evaluate(v_min) + extrusion_points = self.curve.evaluate_array(vs) + extrusion_vectors = extrusion_points - extrusion_start + + matrices = self.get_matrices(vs) + + profile_vectors = (matrices @ profile_vectors)[:,:,0] + result = extrusion_vectors + profile_vectors + result = result + extrusion_start + return result + class SvCurveLerpSurface(SvSurface): __description__ = "Lerp" -- GitLab From e629bd61404c9e1c79d136d94f73f60cef78a1ca Mon Sep 17 00:00:00 2001 From: Ilya Portnov Date: Tue, 16 Jun 2020 23:52:36 +0500 Subject: [PATCH 2/3] Pipe node documentation. --- docs/nodes/surface/extrude_curve.rst | 11 ++-- docs/nodes/surface/pipe.rst | 86 ++++++++++++++++++++++++++++ docs/nodes/surface/surface_index.rst | 1 + nodes/surface/pipe.py | 2 +- 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 docs/nodes/surface/pipe.rst diff --git a/docs/nodes/surface/extrude_curve.rst b/docs/nodes/surface/extrude_curve.rst index 28c70b4a5..b7faa1a5d 100644 --- a/docs/nodes/surface/extrude_curve.rst +++ b/docs/nodes/surface/extrude_curve.rst @@ -7,6 +7,9 @@ Functionality This node generates a Surface object by extruding one Curve (called "profile") along another Curve (called "Extrusion"). +In case your Profile curve is just a circle with center at global origin, you +may wish to use simpler "Pipe (Surface)" node. + It is supposed that the profile curve is positioned so that it's "logical center" (i.e., the point, which is to be moved along the extrusion curve) is located at the global origin `(0, 0, 0)`. @@ -44,10 +47,10 @@ This node has the following inputs: * **Extrusion**. The extrusion curve (the curve along which the profile is to be extruded). This input is mandatory. * **Resolution**. Number of samples for **Zero-Twist** or **Track normal** - rotation algorithm - calculation. The more the number is, the more precise the calculation is, but - the slower. The default value is 50. This input is only available when - **Algorithm** parameter is set to **Zero-Twist** or **Track normal**. + rotation algorithm calculation. The more the number is, the more precise the + calculation is, but the slower. The default value is 50. This input is only + available when **Algorithm** parameter is set to **Zero-Twist** or **Track + normal**. Parameters ---------- diff --git a/docs/nodes/surface/pipe.rst b/docs/nodes/surface/pipe.rst new file mode 100644 index 000000000..cbec475ef --- /dev/null +++ b/docs/nodes/surface/pipe.rst @@ -0,0 +1,86 @@ +Pipe (Curve) +============ + +Functionality +------------- + +This node generates cylindrical "pipe" Surface along a given Curve; the result +is the same as if you extruded circular profile curve along your curve. + +If you want more complex (not cylindrical) profile, or you want more control +over extrusion, you probably want to use "Extrude Curve Along Curve" node. + +The Profile curve (circle) may optionally be rotated while extruding, to make result +look more naturally; though since the profile is always a circle, the choice of +algorithm is not so important for this node, usually. + +Several algorithms to calculate rotation of profile curve are available. In +simplest cases, all of them will give very similar results. In more complex +cases, results will be very different. Different algorithms give best results +in different cases: + +* "Frenet" or "Zero-Twist" algorithms give very good results in case when + extrusion curve has non-zero curvature in all points. If the extrusion curve + has zero curvature points, or, even worse, it has straight segments, these + algorithms will either make "flipping" surface, or give an error. +* "Householder", "Tracking" and "Rotation difference" algorithms are + "curve-agnostic", they work independently of curve by itself, depending only + on tangent direction. They give "good enough" result (at least, without + errors or sudden flips) for all extrusion curves, but may make twisted + surfaces in some special cases. +* "Track normal" algorithm is supposed to give good results without twisting + for all extrusion curves. It will give better results with higher values of + "resolution" parameter, but that may be slow. + +Surface domain: Along U direction - from 0 to ``2*pi``; along V direction - the +same as of "extrusion" curve. + +Inputs +------ + +This node has the following inputs: + +* **Curve**. The curve to build pipe around (the extrusion curve). This input + is mandatory. +* **Radius**. Pipe radius. The default value is 0.1. +* **Resolution**. Number of samples for **Zero-Twist** or **Track normal** + rotation algorithm calculation. The more the number is, the more precise the + calculation is, but the slower. The default value is 50. This input is only + available when **Algorithm** parameter is set to **Zero-Twist** or **Track + normal**. + +Parameters +---------- + +This node has the following parameters: + +* **Algorithm**. Profile curve rotation calculation algorithm. The available options are: + + * **Frenet**. Rotate the profile curve according to Frenet frame of the extrusion curve. + * **Zero-Twist**. Rotate the profile curve according to "zero-twist" frame of the extrusion curve. + * **Householder**: calculate rotation by using Householder's reflection matrix + (see Wikipedia_ article). + * **Tracking**: use the same algorithm as in Blender's "TrackTo" kinematic + constraint. This node currently always uses X as the Up axis. + * **Rotation difference**: calculate rotation as rotation difference between two + vectors. + * **Track normal**: try to maintain constant normal direction by tracking it along the curve. + + The default option is **Householder**. + +.. _Wikipedia: https://en.wikipedia.org/wiki/QR_decomposition#Using_Householder_reflections + +Outputs +------- + +This node has the following output: + +* **Surface**. The generated pipe surface. + +Example of usage +---------------- + +Build a pipe from cubic curve: + +.. image:: https://user-images.githubusercontent.com/284644/84814573-1e7fdd80-b02b-11ea-82d9-572288d7a770.png + diff --git a/docs/nodes/surface/surface_index.rst b/docs/nodes/surface/surface_index.rst index 16d01a24c..e0be8e63e 100644 --- a/docs/nodes/surface/surface_index.rst +++ b/docs/nodes/surface/surface_index.rst @@ -16,6 +16,7 @@ Surface extrude_vector extrude_point extrude_curve + pipe coons_patch apply_field_to_surface surface_domain diff --git a/nodes/surface/pipe.py b/nodes/surface/pipe.py index 31aaee6e6..2896cbc42 100644 --- a/nodes/surface/pipe.py +++ b/nodes/surface/pipe.py @@ -37,7 +37,7 @@ class SvPipeSurfaceNode(bpy.types.Node, SverchCustomTreeNode): algorithm : EnumProperty( name = "Algorithm", items = modes, - default = FRENET, + default = HOUSEHOLDER, update = update_sockets) resolution : IntProperty( -- GitLab From 3198800a578dd70f80168854e179552f6d9b2c00 Mon Sep 17 00:00:00 2001 From: Ilya Portnov Date: Wed, 17 Jun 2020 00:05:49 +0500 Subject: [PATCH 3/3] Pipe node icon. --- nodes/surface/pipe.py | 1 + ui/icons/sv_pipe_surface.png | Bin 0 -> 2367 bytes ui/icons/svg/sv_pipe.svg | 430 +++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 ui/icons/sv_pipe_surface.png create mode 100644 ui/icons/svg/sv_pipe.svg diff --git a/nodes/surface/pipe.py b/nodes/surface/pipe.py index 2896cbc42..00da5da42 100644 --- a/nodes/surface/pipe.py +++ b/nodes/surface/pipe.py @@ -20,6 +20,7 @@ class SvPipeSurfaceNode(bpy.types.Node, SverchCustomTreeNode): bl_idname = 'SvPipeSurfaceNode' bl_label = 'Pipe (Surface)' bl_icon = 'MOD_THICKNESS' + sv_icon = 'SV_PIPE_SURFACE' modes = [ (FRENET, "Frenet", "Frenet / native rotation", 0), diff --git a/ui/icons/sv_pipe_surface.png b/ui/icons/sv_pipe_surface.png new file mode 100644 index 0000000000000000000000000000000000000000..e607d36ce6a79680fad068ef9c890b2c0cad7872 GIT binary patch literal 2367 zcmV-F3BdM=P)w4at7)qxVHek}jS5}$Acb$QmH_SXY*)O>XbKkq?p6`42zI)%f2jEM@g%SW~0CWQg z0dNE81n@S1d=33dIlc@L{E6~5d;E(zO1Y)ACw|Ew4;z56i;8L z(}jIL-`!Z`BdD#d{RBz@BmhO)(NK@~!4Rpcs%l$^{2Y81i)98%5&ohf4^_y4yrw@R zF*P;S8K(f$(b3Ter2rwwOX(K|KLA6(VzD@h=xMwH5K+KpvkgKi!aExBi3@t>@9(k~ z_U_#~1Rx^;TTANe>qoe{UqIeQe<4s0=IOP!w>Ks5^dk@o3kx4YISQJzqm&qKf^q!u z-YQbsS$vP#LCLb4k!h10x)SuEk2BaaXJ(VIaCTj zGcz;YdcFQBlp?&VA-A|N0>)uM5bmoLfQabSsZ$@~`KfSFJ9-P_zfewrPm>XVhyrD0 zWez9>kN~`;9mV<44#wH>@$uV93P6G&IL&7B6rLXmzsCy_J7D(#L+pi~o*qxK#*Ief z>i+)zhgn%!0q)Y<09pXNl58Kzf)(ICd!eDB;ibjsz-%^`4G$0B%g)aBahJXiIq33R z0NIPtSF-pHm|?nn`EoZANy$!w2&Si}Kebpaij{^bqX6E79D~#h833LzPEjBbNNOeE zA&TPIwr$(^Jpx<208RoZ)kFaRAF&QKG&KBkF$I`|)YaAXp~+;57EWW!O#piVEKR2X z)vOcc<>eozKmb_ufLHSTaYuBB4| z7J?u=xP1BYwX(9ZJBWW|ga8}|U{Nc=d#u6I($dd}NYqM%2!g@j{kpokD*#+^3h_CB z@2L@>6r9=v0|VE!7J!I+TefVOZyyn@2mtn|5kYZ0R!~sjBqEQ7-@4pd+pj25| z`AJPp&CRN+s{8r*`FwajG9@-8DnJS3HTapKGiT0R(TaU*Yil!4tgx`q>G62(aJL38 zT)5Dr*XzUF{bNv(%kU0s#9%PY`u+aT(oH@b4iEDirjnA9UQrY$Vzue*?Y*wk=|oP1 zGl>fDGJtW`NLg7~FA@3D$e)fLJ*s&6IXO8VK@gk^wL5q2ToWfi7{IzjMcB<5p~Hs{ zr)?^TqUg@Z$WScHZEbC>aW)SY6&3M&iN7W)007i+Mx@Tp&QyFTJ%{GzX2sLDTCF2Q z6o|LI!{NBW`R;{^_=5>@#ORqJqtQ6)@p!sZ$eoCYDk>`e!}fde;>G3!O$aMjuH^R( zKT$IZUIn*9T)K4WqhK)Dn-bj85|t6+wFD=g+fj>4#vW* zTepnt?~00wkQzHWo$hO^SFdJ$Ghl8xR3m@{c`BS|D~E@N*Vt^f9Iw~gt;We{a5|lt z>~Fi>zC^99;^JaH%x?zN2>{5EnKuA5u$6ANJ3lWk@0I@k{zW+w9~~W45H^`iIcjZL zES4Zwl?$jB0FXa0ehlEOqB0c9UAJ!C504!?)&x07p}vDqNO3linVA{u)&*fUoB8Qu z0+JH|khhjU1Mo+#URPaRy=CXlowtZc)r$)N3f^0Lbrz zUqOy2i|mE-=g(KHSg}GF9UW~~V>mxQUvZ@7^Z91gIypHxnZ;F21B)X-1jhhuft)G& z3~sl(z-qOYA3S*QG7){Ah(VjprkFx*x7)AQIfui+Z-1YtwY3mz0J;z*I#WiM zai2AuK7G2CZGZCQ$$0Y?c?cO98Gg?sojXp_|yon0X z*w}cD>;Fo+1OViq$_a4cwMfay$(g!z>C#n-k&wM=)hb2m&fU9r6AsR9+O&xu0l!YC z01z^!N^03egeGTKP*HKEKEpU>9~WrSMI zAnw?)-2~%BF67T_-r;%6fddDch-ii<@AvzA?RGo=uRLJ@<>@AtD6#?U z1Hk_WO3bo$?OIQLeSPQj^mNzw`1s8uM~<{)W@gStx%Y)m(u{_0Lk?l{J2*Av11#>g zKds1yoNfL- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + -- GitLab