From 900456109f3871b94c64d5e78123d2a7dc7fc743 Mon Sep 17 00:00:00 2001
From: Alessia Saccardo <s212246@dtu.dk>
Date: Mon, 17 Feb 2025 14:51:04 +0100
Subject: [PATCH] remove all trimesh code and add new functions with pygel3d

---
 qim3d/features/_common_features_methods.py | 175 +--------------------
 qim3d/io/_loading.py                       |  14 +-
 qim3d/io/_saving.py                        |  45 ++++--
 qim3d/mesh/__init__.py                     |   2 +-
 qim3d/mesh/_common_mesh_methods.py         |  76 +--------
 qim3d/viz/_k3d.py                          | 141 +++++++++--------
 test_mesh.ipynb                            | Bin 74439407 -> 74439618 bytes
 7 files changed, 124 insertions(+), 329 deletions(-)

diff --git a/qim3d/features/_common_features_methods.py b/qim3d/features/_common_features_methods.py
index 62a7611e..aef19d81 100644
--- a/qim3d/features/_common_features_methods.py
+++ b/qim3d/features/_common_features_methods.py
@@ -1,173 +1,10 @@
 import numpy as np
 import qim3d.processing
 from qim3d.utils._logger import log
-import trimesh
 import qim3d
 from pygel3d import hmesh
 
-
-def volume(obj: np.ndarray|trimesh.Trimesh, 
-           **mesh_kwargs
-           ) -> float:
-    """
-    Compute the volume of a 3D volume or mesh.
-
-    Args:
-        obj (np.ndarray or trimesh.Trimesh): Either a np.ndarray volume or a mesh object of type trimesh.Trimesh.
-        **mesh_kwargs (Any): Additional arguments for mesh creation if the input is a volume.
-
-    Returns:
-        volume (float): The volume of the object.
-
-    Example:
-        Compute volume from a mesh:
-        ```python
-        import qim3d
-
-        # Load a mesh from a file
-        mesh = qim3d.io.load_mesh('path/to/mesh.obj')
-
-        # Compute the volume of the mesh
-        vol = qim3d.features.volume(mesh)
-        print('Volume:', vol)
-        ```
-
-        Compute volume from a np.ndarray:
-        ```python
-        import qim3d
-
-        # Generate a 3D blob
-        synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015)
-        synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015)
-
-        # Compute the volume of the blob
-        volume = qim3d.features.volume(synthetic_blob, level=0.5)
-        volume = qim3d.features.volume(synthetic_blob, level=0.5)
-        print('Volume:', volume)
-        ```
-
-    """
-    if isinstance(obj, np.ndarray):
-        log.info("Converting volume to mesh.")
-        obj = qim3d.mesh.from_volume(obj, **mesh_kwargs)
-
-    return obj.volume
-
-
-def area(obj: np.ndarray|trimesh.Trimesh, 
-         **mesh_kwargs
-         ) -> float:
-    """
-    Compute the surface area of a 3D volume or mesh.
-
-    Args:
-        obj (np.ndarray or trimesh.Trimesh): Either a np.ndarray volume or a mesh object of type trimesh.Trimesh.
-        **mesh_kwargs (Any): Additional arguments for mesh creation if the input is a volume.
-
-    Returns:
-        area (float): The surface area of the object.
-
-    Example:
-        Compute area from a mesh:
-        ```python
-        import qim3d
-
-        # Load a mesh from a file
-        mesh = qim3d.io.load_mesh('path/to/mesh.obj')
-
-        # Compute the surface area of the mesh
-        area = qim3d.features.area(mesh)
-        area = qim3d.features.area(mesh)
-        print(f"Area: {area}")
-        ```
-
-        Compute area from a np.ndarray:
-        ```python
-        import qim3d
-
-        # Generate a 3D blob
-        synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015)
-        synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015)
-
-        # Compute the surface area of the blob
-        area = qim3d.features.area(synthetic_blob, level=0.5)
-        area = qim3d.features.area(synthetic_blob, level=0.5)
-        print('Area:', area)
-        ```
-    """
-    if isinstance(obj, np.ndarray):
-        log.info("Converting volume to mesh.")
-        obj = qim3d.mesh.from_volume(obj, **mesh_kwargs)
-
-    return obj.area
-
-
-def sphericity(obj: np.ndarray|trimesh.Trimesh, 
-               **mesh_kwargs
-               ) -> float:
-    """
-    Compute the sphericity of a 3D volume or mesh.
-
-    Sphericity is a measure of how spherical an object is. It is defined as the ratio
-    of the surface area of a sphere with the same volume as the object to the object's
-    actual surface area.
-
-    Args:
-        obj (np.ndarray or trimesh.Trimesh): Either a np.ndarray volume or a mesh object of type trimesh.Trimesh.
-        **mesh_kwargs (Any): Additional arguments for mesh creation if the input is a volume.
-
-    Returns:
-        sphericity (float): A float value representing the sphericity of the object.
-
-    Example:
-        Compute sphericity from a mesh:
-        ```python
-        import qim3d
-
-        # Load a mesh from a file
-        mesh = qim3d.io.load_mesh('path/to/mesh.obj')
-
-        # Compute the sphericity of the mesh
-        sphericity = qim3d.features.sphericity(mesh)
-        sphericity = qim3d.features.sphericity(mesh)
-        ```
-
-        Compute sphericity from a np.ndarray:
-        ```python
-        import qim3d
-
-        # Generate a 3D blob
-        synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015)
-        synthetic_blob = qim3d.generate.noise_object(noise_scale = 0.015)
-
-        # Compute the sphericity of the blob
-        sphericity = qim3d.features.sphericity(synthetic_blob, level=0.5)
-        sphericity = qim3d.features.sphericity(synthetic_blob, level=0.5)
-        ```
-
-    !!! info "Limitations due to pixelation"
-        Sphericity is particularly sensitive to the resolution of the mesh, as it directly impacts the accuracy of surface area and volume calculations.
-        Since the mesh is generated from voxel-based 3D volume data, the discrete nature of the voxels leads to pixelation effects that reduce the precision of sphericity measurements.
-        Higher resolution meshes may mitigate these errors but often at the cost of increased computational demands.
-    """
-    if isinstance(obj, np.ndarray):
-        log.info("Converting volume to mesh.")
-        obj = qim3d.mesh.from_volume(obj, **mesh_kwargs)
-
-    volume = qim3d.features.volume(obj)
-    area = qim3d.features.area(obj)
-    volume = qim3d.features.volume(obj)
-    area = qim3d.features.area(obj)
-
-    if area == 0:
-        log.warning("Surface area is zero, sphericity is undefined.")
-        return np.nan
-
-    sphericity = (np.pi ** (1 / 3) * (6 * volume) ** (2 / 3)) / area
-    log.info(f"Sphericity: {sphericity}")
-    return sphericity
-
-def volume_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float:
+def volume(obj: np.ndarray|hmesh.Manifold) -> float:
     """
     Compute the volume of a 3D mesh using the Pygel3D library.
 
@@ -180,11 +17,11 @@ def volume_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float:
 
     if isinstance(obj, np.ndarray):
         log.info("Converting volume to mesh.")
-        obj = qim3d.mesh.from_volume_pygel3d(obj)
+        obj = qim3d.mesh.from_volume(obj)
 
     return hmesh.volume(obj)
 
-def area_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float:
+def area(obj: np.ndarray|hmesh.Manifold) -> float:
     """
     Compute the surface area of a 3D mesh using the Pygel3D library.
 
@@ -197,7 +34,7 @@ def area_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float:
 
     if isinstance(obj, np.ndarray):
         log.info("Converting volume to mesh.")
-        obj = qim3d.mesh.from_volume_pygel3d(obj)
+        obj = qim3d.mesh.from_volume(obj)
 
     return hmesh.area(obj)
 
@@ -216,8 +53,8 @@ def sphericity_pygel3d(obj: np.ndarray|hmesh.Manifold) -> float:
         log.info("Converting volume to mesh.")
         obj = qim3d.mesh.from_volume_pygel3d(obj)
 
-    volume = volume_pygel3d(obj)
-    area = area_pygel3d(obj)
+    volume = volume(obj)
+    area = area(obj)
 
     if area == 0:
         log.warning("Surface area is zero, sphericity is undefined.")
diff --git a/qim3d/io/_loading.py b/qim3d/io/_loading.py
index df8320c9..0ed9ae83 100644
--- a/qim3d/io/_loading.py
+++ b/qim3d/io/_loading.py
@@ -28,7 +28,7 @@ from qim3d.utils._misc import get_file_size, sizeof, stringify_path
 from qim3d.utils import Memory
 from qim3d.utils._progress_bar import FileLoadingProgressBar
 import trimesh
-
+from pygel3d import hmesh
 from typing import Optional, Dict
 
 dask.config.set(scheduler="processes") 
@@ -865,15 +865,16 @@ def load(
 
     return data
 
-def load_mesh(filename: str) -> trimesh.Trimesh:
+
+def load_mesh(filename: str) -> hmesh.Manifold:
     """
-    Load a mesh from an .obj file using trimesh.
+    Load a mesh from an an X3D/OBJ/OFF/PLY file.
 
     Args:
         filename (str or os.PathLike): The path to the .obj file.
 
     Returns:
-        mesh (trimesh.Trimesh): A trimesh object containing the mesh data (vertices and faces).
+        mesh (hmesh.Manifold or None): A hmesh object containing the mesh data or None if loading failed.
 
     Example:
         ```python
@@ -882,5 +883,6 @@ def load_mesh(filename: str) -> trimesh.Trimesh:
         mesh = qim3d.io.load_mesh("path/to/mesh.obj")
         ```
     """
-    mesh = trimesh.load(filename)
-    return mesh
+    mesh = hmesh.load(filename)
+
+    return mesh
\ No newline at end of file
diff --git a/qim3d/io/_saving.py b/qim3d/io/_saving.py
index 0a1fae81..6dd770b5 100644
--- a/qim3d/io/_saving.py
+++ b/qim3d/io/_saving.py
@@ -36,7 +36,7 @@ import zarr
 from pydicom.dataset import FileDataset, FileMetaDataset
 from pydicom.uid import UID
 import trimesh
-
+from pygel3d import hmesh
 from qim3d.utils import log
 from qim3d.utils._misc import sizeof, stringify_path
 
@@ -464,16 +464,41 @@ def save(
     ).save(path, data)
 
 
-def save_mesh(
-        filename: str, 
-        mesh: trimesh.Trimesh
-        ) -> None:
-    """
-    Save a trimesh object to an .obj file.
+# def save_mesh(
+#         filename: str, 
+#         mesh: trimesh.Trimesh
+#         ) -> None:
+#     """
+#     Save a trimesh object to an .obj file.
+
+#     Args:
+#         filename (str or os.PathLike): The name of the file to save the mesh.
+#         mesh (trimesh.Trimesh): A trimesh.Trimesh object representing the mesh.
+
+#     Example:
+#         ```python
+#         import qim3d
+
+#         vol = qim3d.generate.noise_object(base_shape=(32, 32, 32),
+#                                   final_shape=(32, 32, 32),
+#                                   noise_scale=0.05,
+#                                   order=1,
+#                                   gamma=1.0,
+#                                   max_value=255,
+#                                   threshold=0.5)
+#         mesh = qim3d.mesh.from_volume(vol)
+#         qim3d.io.save_mesh("mesh.obj", mesh)
+#         ```
+#     """
+#     # Export the mesh to the specified filename
+#     mesh.export(filename)
+    
+def save_mesh(filename: str, mesh: hmesh.Manifold) -> None:
+    """Save a mesh object to an X3D/OBJ/OFF file. The file format is determined by the file extension.
 
     Args:
         filename (str or os.PathLike): The name of the file to save the mesh.
-        mesh (trimesh.Trimesh): A trimesh.Trimesh object representing the mesh.
+        mesh (hmesh.Manifold): A hmesh.Manifold object representing the mesh.
 
     Example:
         ```python
@@ -481,7 +506,7 @@ def save_mesh(
 
         vol = qim3d.generate.noise_object(base_shape=(32, 32, 32),
                                   final_shape=(32, 32, 32),
-                                  noise_scale=0.05,
+                                  noise_scale=0.05,mesh.export(filename)
                                   order=1,
                                   gamma=1.0,
                                   max_value=255,
@@ -491,4 +516,4 @@ def save_mesh(
         ```
     """
     # Export the mesh to the specified filename
-    mesh.export(filename)
\ No newline at end of file
+    hmesh.save(filename, mesh)
\ No newline at end of file
diff --git a/qim3d/mesh/__init__.py b/qim3d/mesh/__init__.py
index ffa1fd60..932dfd9e 100644
--- a/qim3d/mesh/__init__.py
+++ b/qim3d/mesh/__init__.py
@@ -1 +1 @@
-from ._common_mesh_methods import from_volume, from_volume_pygel3d
+from ._common_mesh_methods import from_volume
diff --git a/qim3d/mesh/_common_mesh_methods.py b/qim3d/mesh/_common_mesh_methods.py
index c6b1266a..101539ab 100644
--- a/qim3d/mesh/_common_mesh_methods.py
+++ b/qim3d/mesh/_common_mesh_methods.py
@@ -1,6 +1,5 @@
 import numpy as np
 from skimage import measure, filters
-import trimesh
 from pygel3d import hmesh
 from typing import Tuple, Any
 from qim3d.utils._logger import log
@@ -8,80 +7,7 @@ from qim3d.utils._logger import log
 
 def from_volume(
     volume: np.ndarray,
-    level: float = None,
-    step_size: int = 1,
-    allow_degenerate: bool = False,
-    padding: Tuple[int, int, int] = (2, 2, 2),
-    **kwargs: Any,
-) -> trimesh.Trimesh:
-    """
-    Convert a volume to a mesh using the Marching Cubes algorithm, with optional thresholding and padding.
-
-    Args:
-        volume (np.ndarray): The 3D numpy array representing the volume.
-        level (float, optional): The threshold value for Marching Cubes. If None, Otsu's method is used.
-        step_size (int, optional): The step size for the Marching Cubes algorithm.
-        allow_degenerate (bool, optional): Whether to allow degenerate (i.e. zero-area) triangles in the end-result. If False, degenerate triangles are removed, at the cost of making the algorithm slower. Default False.
-        padding (tuple of ints, optional): Padding to add around the volume.
-        **kwargs: Additional keyword arguments to pass to `skimage.measure.marching_cubes`.
-
-    Returns:
-        mesh (trimesh.Trimesh): The generated mesh.
-
-    Example:
-        ```python
-        import qim3d
-        vol = qim3d.generate.noise_object(base_shape=(128,128,128),
-                                  final_shape=(128,128,128),
-                                  noise_scale=0.03,
-                                  order=1,
-                                  gamma=1,
-                                  max_value=255,
-                                  threshold=0.5,
-                                  dtype='uint8'
-                                  )
-        mesh = qim3d.mesh.from_volume(vol, step_size=3)
-        qim3d.viz.mesh(mesh.vertices, mesh.faces)
-        ```
-        <iframe src="https://platform.qim.dk/k3d/mesh_visualization.html" width="100%" height="500" frameborder="0"></iframe>
-    """
-    if volume.ndim != 3:
-        raise ValueError("The input volume must be a 3D numpy array.")
-
-    # Compute the threshold level if not provided
-    if level is None:
-        level = filters.threshold_otsu(volume)
-        log.info(f"Computed level using Otsu's method: {level}")
-
-    # Apply padding to the volume
-    if padding is not None:
-        pad_z, pad_y, pad_x = padding
-        padding_value = np.min(volume)
-        volume = np.pad(
-            volume,
-            ((pad_z, pad_z), (pad_y, pad_y), (pad_x, pad_x)),
-            mode="constant",
-            constant_values=padding_value,
-        )
-        log.info(f"Padded volume with {padding} to shape: {volume.shape}")
-
-    # Call skimage.measure.marching_cubes with user-provided kwargs
-    verts, faces, normals, values = measure.marching_cubes(
-        volume, level=level, step_size=step_size, allow_degenerate=allow_degenerate, **kwargs
-    )
-
-    # Create the Trimesh object
-    mesh = trimesh.Trimesh(vertices=verts, faces=faces)
-
-    # Fix face orientation to ensure normals point outwards
-    trimesh.repair.fix_inversion(mesh, multibody=True) 
-
-    return mesh
-
-
-def from_volume_pygel3d(
-        volume: np.ndarray,
-        **Kwargs
+    **Kwargs
 ) -> hmesh.Manifold:
     
     if volume.ndim != 3:
diff --git a/qim3d/viz/_k3d.py b/qim3d/viz/_k3d.py
index 6fa107d4..2bf4aa54 100644
--- a/qim3d/viz/_k3d.py
+++ b/qim3d/viz/_k3d.py
@@ -12,7 +12,9 @@ import matplotlib.pyplot as plt
 from matplotlib.colors import Colormap
 from qim3d.utils._logger import log
 from qim3d.utils._misc import downscale_img, scale_to_float16
-
+from pygel3d import hmesh
+from pygel3d import jupyter_display as jd
+import k3d
 
 def volumetric(
     img: np.ndarray,
@@ -81,7 +83,6 @@ def volumetric(
         ```
 
     """
-    import k3d
 
     pixel_count = img.shape[0] * img.shape[1] * img.shape[2]
     # target is 60fps on m1 macbook pro, using test volume: https://data.qim.dk/pages/foam.html
@@ -172,10 +173,9 @@ def volumetric(
     else:
         return plot
 
-
 def mesh(
-    verts: np.ndarray,
-    faces: np.ndarray,
+    mesh,
+    backend: str = "k3d",
     wireframe: bool = True,
     flat_shading: bool = True,
     grid_visible: bool = False,
@@ -183,76 +183,81 @@ def mesh(
     save: bool = False,
     **kwargs,
 ):
-    """
-    Visualizes a 3D mesh using K3D.
-
+    """Visualize a 3D mesh using `k3d` or `pygel3d`.
+    
     Args:
-        verts (numpy.ndarray): A 2D array (Nx3) containing the vertices of the mesh.
-        faces (numpy.ndarray): A 2D array (Mx3) containing the indices of the mesh faces.
-        wireframe (bool, optional): If True, the mesh is rendered as a wireframe. Defaults to True.
-        flat_shading (bool, optional): If True, flat shading is applied to the mesh. Defaults to True.
-        grid_visible (bool, optional): If True, the grid is visible in the plot. Defaults to False.
-        show (bool, optional): If True, displays the visualization inline. Defaults to True.
+        mesh (pygel3d.hmesh.HMesh): The input mesh object.
+        backend (str, optional): The visualization backend to use. 
+            Choose between `"k3d"` (default) and `"pygel3d"`.
+        wireframe (bool, optional): If True, displays the mesh as a wireframe.
+            Works both with `"k3d"` and `"pygel3d"`. Defaults to True.
+        flat_shading (bool, optional): If True, applies flat shading to the mesh.
+            Works only with `"k3d"`. Defaults to True.
+        grid_visible (bool, optional): If True, shows a grid in the visualization.
+            Works only with `"k3d"`. Defaults to False.
+        show (bool, optional): If True, displays the visualization inline.
+            Works for both `"k3d"` and `"pygel3d"`. Defaults to True.
         save (bool or str, optional): If True, saves the visualization as an HTML file.
             If a string is provided, it's interpreted as the file path where the HTML
-            file will be saved. Defaults to False.
-        **kwargs (Any): Additional keyword arguments to be passed to the `k3d.plot` function.
+            file will be saved. Works only with `"k3d"`. Defaults to False.
+        **kwargs (Any): Additional keyword arguments specific to the chosen backend:
+            
+            - `k3d.plot` kwargs: Arguments that customize the `k3d.plot` visualization. 
+              See full reference: https://k3d-jupyter.org/reference/factory.plot.html
+            
+            - `pygel3d.display` kwargs: Arguments for `pygel3d` visualization, such as:
+                - `smooth` (bool, default=True): Enables smooth shading.
+                - `data` (optional): Allows embedding custom data in the visualization.
+              See full reference: https://www2.compute.dtu.dk/projects/GEL/PyGEL/pygel3d/jupyter_display.html#display
 
     Returns:
-        plot (k3d.plot): If `show=False`, returns the K3D plot object.
+        k3d.Plot or None: 
+            - If `backend="k3d"`, returns a `k3d.Plot` object.
+            - If `backend="pygel3d"`, the function displays the mesh but does not return a plot object.
+    """
 
-    Example:
-        ```python
-        import qim3d
 
-        vol = qim3d.generate.noise_object(base_shape=(128,128,128),
-                                  final_shape=(128,128,128),
-                                  noise_scale=0.03,
-                                  order=1,
-                                  gamma=1,
-                                  max_value=255,
-                                  threshold=0.5,
-                                  dtype='uint8'
-                                  )
-        mesh = qim3d.mesh.from_volume(vol, step_size=3)
-        qim3d.viz.mesh(mesh.vertices, mesh.faces)
-        ```
-        <iframe src="https://platform.qim.dk/k3d/mesh_visualization.html" width="100%" height="500" frameborder="0"></iframe>
-    """
-    import k3d
-
-    # Validate the inputs
-    if verts.shape[1] != 3:
-        raise ValueError("Vertices array must have shape (N, 3)")
-    if faces.shape[1] != 3:
-        raise ValueError("Faces array must have shape (M, 3)")
-
-    # Ensure the correct data types and memory layout
-    verts = np.ascontiguousarray(
-        verts.astype(np.float32)
-    )  # Cast and ensure C-contiguous layout
-    faces = np.ascontiguousarray(
-        faces.astype(np.uint32)
-    )  # Cast and ensure C-contiguous layout
-
-    # Create the mesh plot
-    plt_mesh = k3d.mesh(
-        vertices=verts,
-        indices=faces,
-        wireframe=wireframe,
-        flat_shading=flat_shading,
-    )
+    if backend not in ["k3d", "pygel3d"]:
+        raise ValueError("Invalid backend. Choose 'k3d' or 'pygel3d'.")
 
-    # Create plot
-    plot = k3d.plot(grid_visible=grid_visible, **kwargs)
-    plot += plt_mesh
+    # Extract vertex positions and face indices
+    face_indices = list(mesh.faces())
+    vertices_array = np.array(mesh.positions())
 
-    if save:
-        # Save html to disk
-        with open(str(save), "w", encoding="utf-8") as fp:
-            fp.write(plot.get_snapshot())
+    # Extract face vertex indices
+    face_vertices = [
+        list(mesh.circulate_face(int(fid), mode="v"))[:3] for fid in face_indices
+    ]
+    face_vertices = np.array(face_vertices, dtype=np.uint32)
 
-    if show:
-        plot.display()
-    else:
-        return plot
+    # Validate the mesh structure
+    if vertices_array.shape[1] != 3 or face_vertices.shape[1] != 3:
+        raise ValueError("Vertices must have shape (N, 3) and faces (M, 3)")
+
+    # Separate valid kwargs for each backend
+    valid_k3d_kwargs = {k: v for k, v in kwargs.items() if k not in ["smooth", "data"]}
+    valid_pygel_kwargs = {k: v for k, v in kwargs.items() if k in ["smooth", "data"]}
+
+    if backend == "k3d":
+        vertices_array = np.ascontiguousarray(vertices_array.astype(np.float32))
+        face_vertices = np.ascontiguousarray(face_vertices)
+
+        mesh_plot = k3d.mesh(
+            vertices=vertices_array,
+            indices=face_vertices,
+            wireframe=wireframe,
+            flat_shading=flat_shading,
+        )
+
+        plot = k3d.plot(grid_visible=grid_visible, **valid_k3d_kwargs)
+        plot += mesh_plot
+
+        if save:
+            with open(str(save), "w", encoding="utf-8") as fp:
+                fp.write(plot.get_snapshot())
+
+        return plot.display() if show else plot
+
+    elif backend == "pygel3d":
+        jd.set_export_mode(True)
+        return jd.display(mesh, **valid_pygel_kwargs)
diff --git a/test_mesh.ipynb b/test_mesh.ipynb
index c32c3fca598dffdf80d630ee86e3dc025252420f..6893bcf0e769a2931e9a729b9d32f589ae463a1a 100644
GIT binary patch
delta 6908
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
AKmY&$

delta 6442
zcmX@KeZTVit^1WXC^Ak~WKo=7b1|RAcylLXB(s9KsZokys)1Qbnq{I%szs88xuvmT
zs!?j9VM?-zfpPMDnH%{mx92+?$Y<f6{DSoWZ+t;zdTNewibh#}j^_Nn3;8T!&4=0A
z53@10A7*1}Kg`D5ewdA={V*GA`(ZYaID7kHHjehgY@F?f*|^#dvvIc{X5(o;%*NY(
zn2oRfFdKjSVK#yG!)$`>huMVM53>okA7&G2Kg=fDewa<H{V<z&`(ZYT_QPzF?T6W<
z+7Giyw;yJcX+O*++kTi$uKh5ZeEVTGh4#a2itUHll-duoDYqYHQ)xfUrrLg(O|AVf
zn|k|UHjVbfY?|$d*|gdZvuU><X47du%%<CZm`$(!Fq?k+VK#&I!)%7_huMtU53?D!
zA7(RYKg?#@ewfXy{V<z(`(ZYV_QPzJ?T6W{+7Gi?w;yJ+X+O+n+kTkMuKh5ZefwcH
zhxWs4j_rrpoZ1hwIkz8Xb7?=!=GuOk&8__~n|u3VHjnnhY@Y3h*}U2hvw624X7g!3
z%;wvEn9Z;KFq?n-VYYzw!)$@=huMPK53>cgA7%?_Kg<@|ewZz+{V-d2`(d_-_QPzE
z?T6W-+7Giuw;yJUX+O*s+kTiWuKh4ueEVUxg!aR1iS38klG+cmCAS}DOKCsMmfC)p
zEv@}9TYCFpwv6_}Y?<wc*|ORXvt_p*X3J?m%$D1Jm@TjUFk626VYY(y!)%4^huMnS
z53?1wA7(3QKg?FzeweMS{V-d3`(d_<_QPzI?T6W_+7Gi;w;yJ!X+O+X+kTj>uKh4u
zefwdyhW5j3jqQion%WPuHMbvTYiU2s*4loUt*!kqTYLLqwvP70Y@O|g*}B>fvvs#0
zX6tD`%+}j}n60n<Fk657VYUhFhuJ2!A7-1>ewb}?`(d^z?T6W>wjXAj)_$06di!Cv
z8SRJJX0{(@o7H}pZFc)%wmI#G+2*z%W}DZ3m~DRhVYUVBhuIdkA7)$Bewb}>`(d^v
z?T6WxwjX9&)_$06dHZ3u743)FR<<8zTh)G;ZFT!$wl(dC+19onW?R>Om~DOgVYUtJ
zhuJo^A7<Osewb}@`(d^%?T6X6wjXBO)_$06d;4Lw9qotNcD5g8+tq%UZFl=&wmt2K
z+4i;{X4}_(m~DUiVYUP9huIFcA7(q$ewgiW`(d^t?T6WpwjX9Y)_$1nc>7_t6YYoD
zPPQLrJJo)e?R5KLwlnRA+0M2fW;@q@nC*P~VYUnHhuJQ+A7;DMewgiY`(d^#?T6W}
zwjXA@)_$1ndi!Cv8|{bLZnht0yVZV}?RNWNwma>I+3vO<X1mvZnC*W1VYUbDhuI#s
zA7*>hewgiX`(d^x?T6W(wjXAD)_$1ndHZ3u7ww1HUbY`*d)0oJ?REQMwm0pE+1|Dv
zW_#CunC*T0VYUzLhuJ>1A7=a1ewgiZ`(d^(?T6XEwjXBu)_$1nd;4LwAMJ<PezqTG
z`_+D!?RWcOwm<EM+5Wa4X8YHEnC*Y}VK&D8!)%Nof(b-0g9sK7!3rY4vg{xh2Z-PV
z5nLdG8$|Gc2wo7u2O{`EgaC*T1Q9|YLKsAdfCy0#AqFDEL4*W|kOUD@AVL~M$bbl0
z5FrO5<Uxc2h)@I(N+3cRM5urWRS=;DBGf^I28hrE5n3QZ8${@U2wf1N2O{)AgaL>!
z1QA9c!WcxDfCy6%VFn`1L4*Z}umllSAi^3%*nkLI5Mc)*>_LPBh;RfEP9VY=M7V$m
zR}kR_BHTfQ2Z-<l5ndp|8$|ek2wxE42O|7IL;#2g1Q9_XA{a!3fQV2K5e6c{K|}<I
zhy)Q)AR-z>#DIuc5D^C=;z2|Lh)4txNgyH_M5KU-R1lE{BGN%b28hT65m_K28${%Q
zh+Ghn2O{!8L;;8>1QA6bq8LP!fQV8MQ3fK)K|}?Js00yJAfg&X)PRUu5K#vr>On*U
zh-d^6O(3EfM6`g2RuIt!BHBSj2Z-nd5nUjn8$|Sgh+YuU2O|1G!~_sA5kyP^5tBj0
z6c8~LL`(w_(?P@x5HS-(%mNX!LBt#oF&9M40}=B<!~zhp5JW5j5sN{@5)iQzL@Wal
z%R$5n5U~<OtO60MLBtvmu@*$E0}<;%#0C(t5kzbP5t~8877(!&L~H{Q+d;$*5U~?P
z>;e(HLBt*qu@^+_0}=Z{!~qa-5JVgT5r;v<5fE_{L>vPV$3esi5OESjoB|Q2LBtsl
zaTY|J0}<yz#03y>5ky=95tl*46%cV1L|g+A*FnS$5OEVk+yW7|LBt&paTi3~0}=N@
z!~+oV5JWrz5syK{6A<wfL_7l#&q2fs5b+X3yaExgLBtyn@fJk90}<~*#0L=Z5k!0f
z5uZWC7ZC9kM0^7g-$BF=5b+a4`~nfbLBt;r@fSq=0}=ljyAQK5fr>v6!2}|hK?Dnk
zU<DCiS#}VM14M9w2rdx84I+3z1TTo-0}=cnLI6Yvf(RiHAq*l!K!hlW5Cak7AVLB}
zNP-9{5FrgBWI%*0h>!yj@*qM1L@0s?B@m$uB2++xDu_@65$Yg914L+o2rUqy4I*?v
zgf58C0}=Wl!T>}Vf(RoJVGJTnK!hoXFar_hAi@GfSb_*E5Md1>Y(Ru9h_C|@_8`Im
zL^y&7ClKKbB3wX(D~NCd5$+(u14MX&2rm%f4I+F%gfEEj0}=ipA^=1Lf`}jx5ey<i
zKtw2r2m=w}AR+=pM1qJY5D^U`Vn9SJh=>Cb@gO1rL?nWUBoL7dB2qv^Du_q}5$PZz
z14Lwkh%6A14I*+tL@tQP0}=Tkq5wn`f`}pzQ4AtVKtw5sC<77YAff_9RDy^q5K#>x
zYCuFSh^PY*^&p}FL^OhkCJ@mKB3eL1D~M<V5$zzN14ML!h%OM(4I+9#L@$Ww0}=fo
zVgiVm2qGqdh{+&g3W%5rBBp_e=^$bTh?of?W`T&=AYu-Pm<uB2fr$AaVgZO)2qG4N
zh{Ygc35Zw<B9?)O<sf1Oh*$|CR)L7sAYu)OSPLT7fr#}WVgrcS2qHFth|M5k3y9bX
zBDR5u?I2<Yh}a1tc7cf9AYu=Q*b5@|fr$Me;sA&^2qF%Fh{GV_2#7ceB94KG;~?S$
zh&Tx%PJxKiAmR*&I13`qfr#@U;sS`c2qG?lh|3`23W&H0BCdgm>mcF=h`0$NZh?r~
zAmR>)xC<igfr$Gc;sJ<w2qGSVh{qt}35a+KBA$VW=OE$*h<FJiUV(_$AmR;(cnc!l
zfr$4Y;sc2I2qHc)?LW-+ndwx3N^+`6nt7r{s&0}+l98^7g;|=eg{7&9ZmOBNiDimu
zlDUCd>i&)4pP6(RiBdbCG4eB$vpO-x#wh7b-pC};-yHdwslPe$3yAm%BEEr$?;zp_
zi1-O2eu0SJAmR^*_zNQbfr$T1-OZ8A{mqe#Ac6@*FoOsd5Wxx}*gyn3h~NMboFIY=
zL~w%$9uUC`BKSZAKZp<j5rQB>2t){j2oVq=3L?ZnggA(h01=WPLJCAkg9sTAAqyhp
zK!iMqPyi8%AVLX5D1!(U5TObp)Ifwfh|mBLnjk_8L}-Hu9T1@lBJ@CnK8P>?5r!bb
z2t*iz2on%t3L?xvggJ<?01=iT!U{xKg9sZCVGAPcK!iPrZ~zgGAi@bmID-fm5a9|U
z+(3joi0}Xro*=>tM0kS;9}wXSBK$yvKZpnb5rH5g2t)*fh!7AF3L?TlL^z0u01@EG
zivqEtK|~CQhy@XGAR-<_B!GxS5Rn8Tl0ifYh)4wyX&@pUL}Y-7Oc0R;BC<h54v5GF
z5qTgYA4C*@h(Zui1R{z-L<xu}1rcQ+q8vn2fQU*EQ3WEZK|~FRs09&qAfg^bG=PXk
z5YYr8nn6Shh-d{7Z6Kl@M09|NP7u)rBDz6D4~Xam5q%({A4E(55feegBoHwfL`(q@
zQ$fTu5HTG@%m5KHLBuQ&F&jk80TFXS#5@o&A4Dtw5eq@YA`r0{L@WUjOF_gk5V0IY
ztN;-!LBuK$u^L3I0TF9K#5xeM9z<*a5gS3oCJ?b1L~H>OTS3G&5V0LZ>;MrvLBuW)
zu^U9}0TFva#6A$QA4D7g5eGrUArNsGL>vJTM?u6f5OEwtoB$CgLBuH#aT-LN0TE|G
z#5oXg9z<LK5f?$kB@l5LL|g$8S3$%z5OEzu+yD_bLBuT(aT`S30TFjW#61vkA4EI=
z5f4GcBM|WzL_7fzPeH^p5b+#DyZ{j|LBuN%@ft+D0TFLO#5)l29z=Wq5g$RsClK)&
zM0^1eUqQq-5b+&E`~VR@LBuZ*@f$?^0TF*e#6J-6pSim^k_A-!fe0oL!3-i;Km;p@
zU;`2CAc6x#aDoUf5Wx*1ct8X%h~NVe{2)RAL<oWiArK)9B1AxhD2NaP5#k_10z^oH
z2q_RD4I*Sfge-`V0}=8dLIFf5f(RuLp$sBaK!hrYPy-R_AVLE~Xo3hW5TOksbU=hI
zh|mKO`XIspL>PhyBM@N>B1}MpDTpuw5#}Jm0z_DX2rCd_4I*qnge{1$0}=Kh!U04$
zf(R!N;S3^NK!huZa03zUAi@Jgc!CHo5aA6Xd_aURi0}gu{vaX%L<E9}AP^A@B0@k!
zD2NCH5#b;r0z`l#FABtp1`#nJA{Io%frxkzkpLnRK|~UWNCpupAR-k+q=ATZ5Rm~Q
zGC@QZh{y&JIUphzMC7sTZ;s4ok&h>;9^Zd#M?Om{%Y4VZ`79ap*PqU3k)5x;FQ3I|
z{+lEDEZp<?4(GGP&o4im&tf8PXl$gT03$SG@|3s~Kp+-O)#}WjcRimaPQk#~)I80|
z)ZE0vG{wX)(J;v*&CoE(EX~*=HO0)pC=EQ~d~yEl{rN0hMtLa;WtqjLi8+~7sS3HN
z#Tg2v#hH2O3MECEAYM$Kk`CB(rQCR^(BytLSx+5>;*!*Y_~Oi}R9j<^OQ5O>Gjoko
z^vW`;^gy~b;F|QxQj1D5lT(Xz6cA!*iOH$+4_wM;ahU(-Vm^!N{QF1pSz@K3_E;&H
zS{Ry|=$V=tn3$TFm>L)vn9M(WJfB5U5U#?+(8SWxQqN#M<B5D0@A>zS=Cg>+w>_56
zBFUCml$xlKJ74ZtK8qAsNQoV)Z9e14d=|<1_fO=ru<}90tQ4w28s;xNmd~Okg%B*u
z&neAKg?YVJeg45q`7GP!uQ;C1A|hyNW^7=lXJ~F_YHDs~VQyjwcAk=;v4x3|nSq{x
zg{i5LIY<rId5LP!fqeyAg$z*W=z(n1$VCc0ux<0_p8z=-;YgABKThPcSaCwNLmi<6
zo@6kYe|K*_i`RU%E1(p!`g%S~pn{oYqM1=rnqg|9nURUHg@uKgxv6ndO0uzqsi|q2
zC1l0{691p)S6t6$F$O0!*Zmv^^I48DG8r1qXS|%xVzVEV^j0!585)84W%~_}=Ck}|
zWHK~{@<E|+osr4VWTK<>{(UF&S@@Wk3{9buZfEjY%viw*+I6x&o8W%_Tlp-T839&8
Bged?3

-- 
GitLab