Source code for mosaic.tabs.model

from functools import partial

import numpy as np
from qtpy.QtWidgets import QWidget, QVBoxLayout

from .. import meshing
from .. import operations as _operations  # noqa: F401  # registers geometry operations
from ..registry import MethodRegistry
from ..widgets.ribbon import create_button
from ..parallel import submit_task, submit_task_batch


def _repair_mesh(
    geometry,
    max_hole_size=-1,
    smoothness=0,
    curvature_weight=0,
    pressure=0,
    flip_normals=False,
    fair_all=False,
    boundary_ring=0,
):
    import igl
    from .. import meshing
    from ..parametrization import TriangularMesh

    fair = not (smoothness == 0 and curvature_weight == 0 and pressure == 0)

    model = geometry.model
    model.mesh.remove_non_manifold_edges()
    model.mesh.remove_degenerate_triangles()
    model.mesh.remove_duplicated_triangles()
    model.mesh.remove_unreferenced_vertices()
    model.mesh.remove_duplicated_vertices()

    vs = model.vertices
    fs = model.triangles

    out_fs = meshing.close_holes(vs, fs, max_hole_size)
    if fair:
        hole_fids = np.arange(len(fs), len(out_fs))

        try:
            mesh = meshing.remesh(meshing.to_open3d(vs, out_fs))
            new_vs = np.asarray(mesh.vertices, dtype=np.float64)
            fs = np.asarray(mesh.triangles)
        except (ValueError, RuntimeError):
            new_vs, fs = vs, out_fs

        if fair_all:
            vids = np.arange(len(new_vs))
        else:
            _, face_ids, _ = igl.point_mesh_squared_distance(
                new_vs, vs, out_fs.astype(np.int64)
            )
            vids = np.where(np.isin(face_ids, hole_fids))[0]

        vs = new_vs
        if len(vids) > 0:
            vs = meshing.fair_mesh(
                vs,
                fs,
                vids,
                smoothness=smoothness,
                curvature_weight=curvature_weight,
                pressure=pressure,
                n_ring=boundary_ring,
            )

    if flip_normals:
        fs = fs[:, ::-1]

    from ..geometry import GeometryData

    return GeometryData(
        model=TriangularMesh(meshing.to_open3d(vs, fs)),
        sampling_rate=geometry.sampling_rate.copy(),
        meta=geometry._meta.copy(),
    )


def _fill_mesh(mesh_geometry):
    from ..geometry import GeometryData

    model = mesh_geometry.model
    voxel_size = max(mesh_geometry.sampling_rate)
    points = meshing.fill_mesh(model.vertices, model.triangles, voxel_size=voxel_size)
    return GeometryData(points=points, sampling_rate=mesh_geometry.sampling_rate.copy())


def _project(
    mesh_geometries,
    geometries,
    use_normals: bool = False,
    invert_normals: bool = False,
    update_normals: bool = False,
    partition: bool = False,
):
    from ..geometry import Geometry, GeometryData

    meshes = [mg.model for mg in mesh_geometries]
    n_meshes = len(meshes)

    data_out, meshes_out = [], []
    mesh_subsets = [[] for _ in range(n_meshes)]
    mesh_proj = [[] for _ in range(n_meshes)]
    mesh_tri = [[] for _ in range(n_meshes)]

    for geometry in geometries:
        normals = geometry.normals if use_normals else None
        if normals is not None:
            normals = normals * (-1 if invert_normals else 1)

        all_dist, all_proj, all_tri = [], [], []
        for mesh in meshes:
            dist, proj, tri = mesh.compute_distance(
                points=geometry.points,
                normals=normals,
                return_projection=True,
                return_indices=False,
                return_triangles=True,
            )
            all_dist.append(dist)
            all_proj.append(proj)
            all_tri.append(tri)

        best = np.argmin(np.stack(all_dist), axis=0)
        proj_sel = np.empty_like(all_proj[0])

        for m in range(n_meshes):
            mask = best == m
            if not mask.any():
                continue
            if partition:
                mesh_subsets[m].append(geometry[mask])
            else:
                proj_m = all_proj[m][mask]
                proj_sel[mask] = proj_m
                mesh_proj[m].append(proj_m)
                mesh_tri[m].append(all_tri[m][mask])

        if not partition:
            geo_normals = geometry.normals
            if update_normals:
                geo_normals = np.empty((len(geometry.points), 3))
                for m in range(n_meshes):
                    mask = best == m
                    if mask.any():
                        geo_normals[mask] = meshes[m].compute_normal(proj_sel[mask])
            data_out.append(
                GeometryData(
                    points=proj_sel,
                    normals=geo_normals,
                    sampling_rate=geometry.sampling_rate.copy(),
                )
            )

    if partition:
        for m, subsets in enumerate(mesh_subsets):
            if subsets:
                geom = subsets[0] if len(subsets) == 1 else Geometry.merge(subsets)
                name = mesh_geometries[m]._meta.get("name", f"Mesh {m}")
                geom._meta["name"] = f"{name}_partition"
                data_out.append(geom)
    else:
        for m in range(n_meshes):
            if not mesh_proj[m]:
                continue
            new_model = meshes[m].add_projections(
                np.concatenate(mesh_proj[m]),
                np.concatenate(mesh_tri[m]),
                return_indices=False,
            )
            meshes_out.append(
                GeometryData(
                    model=new_model,
                    sampling_rate=mesh_geometries[m].sampling_rate.copy(),
                )
            )

    return data_out + meshes_out


[docs] class ModelTab(QWidget):
[docs] def __init__(self, cdata, ribbon, legend, **kwargs): super().__init__() self.cdata = cdata self.ribbon = ribbon self.legend = legend layout = QVBoxLayout(self) layout.setSpacing(5) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.ribbon)
[docs] def show_ribbon(self): self.ribbon.clear() func = self._fit_parallel fitting_actions = [ create_button( "Sphere", "ph.circle", self, partial(func, "sphere"), "Fit to selected clusters", ), create_button( "Ellipse", "ph.link-simple-horizontal-break", self, partial(func, "ellipsoid"), "Fit to selected clusters", ), create_button( "Cylinder", "ph.hexagon", self, partial(func, "cylinder"), "Fit to selected clusters", ), create_button( "RBF", "ph.dots-nine", self, partial(func, "rbf"), "Fit to selected clusters", RBF_SETTINGS, ), create_button( "Mesh", "ph.triangle", self, func, "Fit to selected clusters", MethodRegistry.settings_dict("fit"), ), create_button( "Curve", "ph.line-segments", self, partial(func, "spline"), "Fit to selected clusters", SPLINE_SETTINGS, ), ] self.ribbon.add_section("Parametrization", fitting_actions) mesh_actions = [ create_button( "Sample", "ph.broadcast", self, self._sample_parallel, "Generate points from fitted model", SAMPLE_SETTINGS, ), ] self.ribbon.add_section("Sampling", mesh_actions) mesh_actions = [ create_button( "Repair", "ph.wrench", self, self._repair_mesh_parallel, "Fix holes and topology issues", REPAIR_SETTINGS, ), create_button( "Remesh", "ph.arrows-clockwise", self, self._remesh_parallel, "Adjust resolution and quality", MethodRegistry.settings_dict("remesh"), ), create_button( "Smooth", "ph.drop", self, self._smooth_parallel, "Reduce surface noise", MethodRegistry.settings_dict("smooth"), ), create_button( "Project", "ph.arrow-line-down", self, self._project_parallel, "Project points onto mesh", PROJECTION_SETTINGS, ), create_button( "Fill", "ph.cube", self, self._fill_parallel, "Fill the interior of a closed mesh with points", ), ] self.ribbon.add_section("Mesh Operations", mesh_actions)
def _default_callback(self, geom): from ..geometry import Geometry, GeometryData from ..parametrization import TriangularMesh if isinstance(geom, (Geometry, GeometryData)): geom = (geom,) new_model, new_cluster = False, False for new_geom in geom: if isinstance(new_geom, GeometryData): new_geom = Geometry(**new_geom.to_dict()) if isinstance(new_geom.model, TriangularMesh): new_geom.change_representation("surface") if new_geom.model is None: new_cluster = True self.cdata.data.add(new_geom) continue new_model = True self.cdata.models.add(new_geom) if new_model: self.cdata.models.render() if new_cluster: self.cdata.data.render() def _get_selected_meshes(self): from ..parametrization import TriangularMesh ret = [] for geometry in self.cdata.models.get_selected_geometries(): fit = geometry.model if not isinstance(fit, TriangularMesh): continue ret.append(geometry) return ret def _repair_mesh_parallel(self, **kwargs): for geometry in self._get_selected_meshes(): submit_task( "Repair Mesh", _repair_mesh, self._default_callback, geometry._geometry_data, **kwargs, ) def _fit_parallel(self, method: str, *args, **kwargs): from ..operations import GeometryOperations for geometry in self.cdata.data.get_selected_geometries(): submit_task( "Parametrization", GeometryOperations.fit, self._default_callback, geometry._geometry_data, method, **kwargs, ) def _smooth_parallel(self, method, **kwargs): from ..operations import GeometryOperations for geometry in self._get_selected_meshes(): submit_task( "Smooth", GeometryOperations.smooth, self._default_callback, geometry._geometry_data, method, **kwargs, ) def _sample_parallel(self, sampling, method, normal_offset=0.0, **kwargs): from ..operations import GeometryOperations for geometry in self.cdata.models.get_selected_geometries(): submit_task( "Sample Fit", GeometryOperations.sample, self._default_callback, geometry._geometry_data, method=method, sampling=sampling, normal_offset=normal_offset, **kwargs, ) def _remesh_parallel(self, method, **kwargs): from ..operations import GeometryOperations for geometry in self._get_selected_meshes(): submit_task( "Remesh", GeometryOperations.remesh, self._default_callback, geometry._geometry_data, method, **kwargs, ) def _fill_parallel(self, **kwargs): for geometry in self._get_selected_meshes(): submit_task( "Fill Mesh", _fill_mesh, self._default_callback, geometry._geometry_data ) def _project_parallel( self, use_normals: bool = False, invert_normals: bool = False, update_normals: bool = False, partition: bool = False, **kwargs, ): selected_meshes = self._get_selected_meshes() if not selected_meshes: raise ValueError("Please select at least one mesh for projection.") submit_task( "Project", _project, self._default_callback, selected_meshes, self.cdata.data.get_selected_geometries(), use_normals, invert_normals, update_normals, partition, )
SAMPLE_SETTINGS = MethodRegistry.settings_dict("sample") RBF_SETTINGS = { "title": "Settings", "settings": [ { "label": "Direction", "parameter": "direction", "type": "select", "options": ["xy", "xz", "yz"], "default": "xy", "description": "Coordinate plane to fit RBF in.", }, ], } SPLINE_SETTINGS = { "title": "Settings", "settings": [ { "label": "Order", "parameter": "order", "type": "number", "default": 3, "min": 1, "max": 5, "description": "Spline order to fit to control points.", }, ], } REPAIR_SETTINGS = { "title": "Settings", "settings": [ { "label": "Smoothness", "parameter": "smoothness", "type": "float", "default": 0.0, "min": 0.0, "max": 1.0, "description": "Balance between position anchoring and curvature " "minimization. 0 = stay in place, 1 = full smoothing.", }, { "label": "Curvature Weight", "parameter": "curvature_weight", "type": "float", "default": 0.0, "description": "Higher-order smoothing for curvature continuity.", }, { "label": "Pressure", "parameter": "pressure", "type": "float", "default": 0.0, "description": "Internal mesh pressure along vertex normals.", }, { "label": "Hole Size", "parameter": "max_hole_size", "type": "float", "min": -1.0, "default": -1.0, "description": "Maximum surface area of holes considered for triangulation.", }, { "label": "Flip Normals", "parameter": "flip_normals", "type": "boolean", "default": False, "description": "Reverse normal direction of the mesh.", }, { "label": "Fair All Vertices", "parameter": "fair_all", "type": "boolean", "default": False, "description": "Apply fairing to all vertices, not just inferred ones.", }, { "label": "Boundary Ring", "parameter": "boundary_ring", "type": "number", "default": 0, "min": 0, "description": "Number of vertex rings around inferred vertices to include in fairing.", }, ], } PROJECTION_SETTINGS = { "title": "Settings", "settings": [ { "label": "Cast Normals", "parameter": "use_normals", "type": "boolean", "default": True, "description": "Include normal vectors in raycasting.", }, { "label": "Invert Normals", "parameter": "invert_normals", "type": "boolean", "default": False, "description": "Invert direction of normal vectors.", }, { "label": "Update Normals", "parameter": "update_normals", "type": "boolean", "default": False, "description": "Update normal vectors of projection based on the mesh.", }, { "label": "Partition", "parameter": "partition", "type": "boolean", "default": False, "description": "Assign points to nearest mesh instead of projecting.", }, ], }