from __future__ import annotations
import numpy as np
import trimesh
from ..ccta.manipulating import order_points_list
try:
import pymeshlab
except ImportError:
pymeshlab = None # type: ignore[assignment]
[docs]
def manual_hole_fill(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
"""Fill holes by fan-triangulating each boundary loop to its centroid."""
outline = mesh.outline()
new_vertices = list(mesh.vertices)
new_faces = list(mesh.faces)
# Build a lookup from coordinate tuple -> vertex index (original mesh)
coord_to_idx = {tuple(v): i for i, v in enumerate(mesh.vertices)}
for entity in outline.entities:
pts = outline.vertices[entity.points]
ordered = order_points_list(mesh, pts)
if len(ordered) < 3:
continue
ordered_arr = np.array(ordered)
centroid = ordered_arr.mean(axis=0)
centroid_idx = len(new_vertices)
new_vertices.append(centroid)
n = len(ordered)
for i in range(n):
i0 = coord_to_idx.get(tuple(ordered[i]))
i1 = coord_to_idx.get(tuple(ordered[(i + 1) % n]))
if i0 is not None and i1 is not None:
new_faces.append([i0, i1, centroid_idx])
result = trimesh.Trimesh(
vertices=np.array(new_vertices),
faces=np.array(new_faces),
process=False,
)
result.fix_normals()
return result
def postprocess_stitched_mesh(
mesh: trimesh.Trimesh,
*,
postprocessing: bool = False,
target_edge_length_mm: float | None = None,
remesh_iterations: int = 10,
lamb: float = 0.5,
nu: float = 0.5,
verbose: bool = False,
**kwargs,
) -> trimesh.Trimesh:
"""Optionally remesh and smooth a stitched mesh.
Parameters
----------
mesh:
Input stitched mesh.
postprocessing:
When ``True``, run :func:`fix_and_remesh_stitched_mesh` followed by
Taubin smoothing. Requires pymeshlab to be installed.
target_edge_length_mm:
Passed to :func:`fix_and_remesh_stitched_mesh`.
remesh_iterations:
Passed to :func:`fix_and_remesh_stitched_mesh`.
verbose:
Passed to :func:`fix_and_remesh_stitched_mesh`.
**kwargs:
Additional keyword arguments passed to :func:`fix_and_remesh_stitched_mesh`.
"""
if not postprocessing:
return mesh
mesh = fix_and_remesh_stitched_mesh(
mesh,
target_edge_length_mm=target_edge_length_mm,
remesh_iterations=remesh_iterations,
verbose=verbose,
**kwargs,
)
trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=nu)
return mesh
def _trimesh_to_meshset(mesh: trimesh.Trimesh):
ms = pymeshlab.MeshSet()
m = pymeshlab.Mesh(
vertex_matrix=mesh.vertices.astype(np.float64),
face_matrix=mesh.faces.astype(np.int32),
)
ms.add_mesh(m)
return ms
def _meshset_to_trimesh(ms) -> trimesh.Trimesh:
m = ms.current_mesh()
return trimesh.Trimesh(
vertices=m.vertex_matrix(),
faces=m.face_matrix(),
process=False,
)
[docs]
def fix_and_remesh_stitched_mesh(
mesh: trimesh.Trimesh,
*,
target_edge_length_mm: float | None = None,
remesh_iterations: int = 10,
verbose: bool = False,
) -> trimesh.Trimesh:
"""Fill holes and remesh a stitched mesh, replicating Meshmixer workflow.
Steps
-----
1. Repair non-manifold edges/vertices.
2. Close holes - flat fill (all holes).
3. Isotropic remesh to ``target_edge_length_mm``.
Parameters
----------
mesh:
Input trimesh (the stitched surface).
target_edge_length_mm:
Target edge length in mm for the isotropic remesh. If ``None``,
uses the 25th-percentile edge length of the input mesh (preserves
the fine IV-mesh resolution as reference).
remesh_iterations:
Number of isotropic remeshing iterations (default 10).
verbose:
Print progress info.
"""
if pymeshlab is None:
raise ImportError(
"pymeshlab is required for fix_and_remesh_stitched_mesh. "
"Install it with: pip install 'multimodars[meshlab]'"
)
def _log(label: str, m: trimesh.Trimesh) -> None:
if verbose:
print(
f"[{label:35s}] verts={len(m.vertices):>7,} "
f"faces={len(m.faces):>7,} "
f"watertight={m.is_watertight}"
)
_log("input", mesh)
# Use the fine end of the edge-length distribution as reference so that
# the IV mesh resolution drives the target (not the coarser CCTA edges).
if target_edge_length_mm is None:
target_edge_length_mm = float(np.percentile(mesh.edges_unique_length, 25))
if verbose:
print(f" auto target edge length = {target_edge_length_mm:.4f} mm (P25)")
ms = _trimesh_to_meshset(mesh)
# ------------------------------------------------------------------
# 0. Repair non-manifold geometry (required before hole filling)
# ------------------------------------------------------------------
ms.meshing_repair_non_manifold_edges(method=0) # 0 = remove faces
ms.meshing_repair_non_manifold_vertices()
ms.meshing_remove_duplicate_faces()
ms.meshing_remove_duplicate_vertices()
ms.meshing_remove_null_faces()
if verbose:
print(" non-manifold edges/vertices repaired")
# ------------------------------------------------------------------
# 1. Fill holes – flat fill
# ------------------------------------------------------------------
ms.meshing_close_holes(
maxholesize=1000,
selfintersection=False,
)
if verbose:
print(" holes closed")
mesh_filled = _meshset_to_trimesh(ms)
_log("after hole fill", mesh_filled)
# ------------------------------------------------------------------
# 2. Isotropic remesh
# targetlen / maxsurfdist are expressed as % of bbox diagonal.
# ------------------------------------------------------------------
bbox_diag = float(np.linalg.norm(mesh_filled.bounding_box.extents))
targetlen_pct = (target_edge_length_mm / bbox_diag) * 100.0
maxsurfdist_pct = targetlen_pct * 0.5
if verbose:
print(
f" target edge={target_edge_length_mm:.4f} mm "
f"({targetlen_pct:.4f}% of bbox diag={bbox_diag:.2f} mm)"
)
ms2 = _trimesh_to_meshset(mesh_filled)
ms2.meshing_isotropic_explicit_remeshing(
targetlen=pymeshlab.PercentageValue(targetlen_pct),
iterations=remesh_iterations,
adaptive=False,
selectedonly=False,
checksurfdist=True,
maxsurfdist=pymeshlab.PercentageValue(maxsurfdist_pct),
splitflag=True,
collapseflag=True,
swapflag=True,
smoothflag=True,
reprojectflag=True,
)
mesh_remeshed = _meshset_to_trimesh(ms2)
mesh_remeshed.fix_normals()
_log("after remesh", mesh_remeshed)
# ------------------------------------------------------------------
# 3. Post-remesh cleanup: remeshing can open small holes; close them.
# ------------------------------------------------------------------
if not mesh_remeshed.is_watertight:
ms3 = _trimesh_to_meshset(mesh_remeshed)
ms3.meshing_repair_non_manifold_edges(method=0)
ms3.meshing_repair_non_manifold_vertices()
ms3.meshing_remove_duplicate_faces()
ms3.meshing_remove_null_faces()
ms3.meshing_close_holes(maxholesize=1000, selfintersection=False)
mesh_remeshed = _meshset_to_trimesh(ms3)
mesh_remeshed.fix_normals()
_log("after post-remesh fix", mesh_remeshed)
return mesh_remeshed