"""Hace estanca (watertight) una malla 3D GLB/OBJ/PLY. Post-proceso de las mallas de ComfyUI/Hunyuan3D producidas con el nodo VoxelToMeshBasic (DEPRECATED), que genera mallas NO estancas (is_watertight=False): crea 4 vertices nuevos por cara expuesta sin soldarlos, dejando huecos y bordes non-manifold. Dos metodos: - method="voxel" (default, garantiza is_watertight=True): voxeliza el solido, rellena el interior y reconstruye la superficie con marching cubes (trimesh voxelized(pitch).fill().marching_cubes). Produce una malla cerrada por construccion. Coste: mas caras (densidad del marching cubes) y descarta la apariencia (UV/vertex colors). Necesita scikit-image (marching cubes). - method="repair": limpieza ligera con trimesh.repair (fix_winding + fill_holes + fix_normals + merge_vertices). Conserva el detalle y las caras, pero NO garantiza estanqueidad en mallas muy rotas (solo cierra huecos pequenos). La via de RAIZ (no este post-proceso) es generar con el nodo VoxelToMesh algorithm='surface net', que da malla manifold cerrada sin reparar (ver report 0088). Impura: lee y escribe archivos en disco. Requiere trimesh (+ scikit-image para voxel). """ import os import numpy as np def _load_mesh(path): import trimesh obj = trimesh.load(path, process=False) if isinstance(obj, trimesh.Scene): obj = trimesh.util.concatenate(list(obj.geometry.values())) return obj def comfyui_make_watertight( in_path: str, *, method: str = "voxel", pitch: float | None = None, out_path: str | None = None, ) -> dict: """Hace estanca una malla GLB/OBJ/PLY por voxel-remesh o reparacion. Args: in_path: ruta de la malla de entrada (.glb/.obj/.ply/.gltf/.stl/.off). method: "voxel" (default, garantiza is_watertight=True via voxeliza+fill+ marching cubes) o "repair" (fill_holes + fix_normals, conserva detalle pero no siempre estanca). keyword-only. pitch: solo para method="voxel". Tamano de voxel absoluto. Si None, se calcula como diagonal_bbox / 200 (mas fino = mas caras y detalle). keyword-only. out_path: ruta de salida. Si None, escribe "_watertight.glb" junto al original. keyword-only. Returns: dict {ok, was_watertight, is_watertight, out_path, method, pitch, out_faces, error}. was/is_watertight = estanqueidad antes/despues (trimesh). Si falla, ok=False y error explica. """ base_err = { "ok": False, "was_watertight": None, "is_watertight": None, "out_path": "", "method": method, "pitch": None, "out_faces": 0, } try: import trimesh except ImportError as exc: return {**base_err, "error": f"falta trimesh: {exc}. cd python && uv add trimesh"} if method not in ("voxel", "repair"): return {**base_err, "error": f"method '{method}' invalido (usa 'voxel' o 'repair')"} if not os.path.exists(in_path): return {**base_err, "error": f"no existe el archivo de entrada: {in_path!r}"} if out_path is None: out_path = os.path.splitext(in_path)[0] + "_watertight.glb" try: mesh = _load_mesh(in_path) except Exception as exc: return {**base_err, "error": f"no se pudo cargar la malla {in_path!r}: {exc}"} was = bool(mesh.is_watertight) try: if method == "voxel": m = mesh.copy() m.merge_vertices() if pitch is None: diag = float(np.linalg.norm(m.extents)) pitch = diag / 200.0 vg = m.voxelized(pitch=float(pitch)).fill() out = vg.marching_cubes out.merge_vertices() trimesh.repair.fix_normals(out) else: # repair out = mesh.copy() out.merge_vertices() trimesh.repair.fix_winding(out) trimesh.repair.fill_holes(out) trimesh.repair.fix_normals(out) parent = os.path.dirname(out_path) if parent: os.makedirs(parent, exist_ok=True) out.export(out_path) except ImportError as exc: return {**base_err, "was_watertight": was, "error": f"falta dependencia para method='{method}': {exc}. " f"El voxel-remesh necesita scikit-image: cd python && uv add scikit-image"} except Exception as exc: return {**base_err, "was_watertight": was, "error": f"fallo en method='{method}': {type(exc).__name__}: {exc}"} return { "ok": True, "was_watertight": was, "is_watertight": bool(out.is_watertight), "out_path": out_path, "method": method, "pitch": round(float(pitch), 6) if pitch is not None else None, "out_faces": int(len(out.faces)), "error": "", } if __name__ == "__main__": import json import sys src = sys.argv[1] if len(sys.argv) > 1 else ( os.path.expanduser("~/ComfyUI/output/3d_robot_mesh_00001__dec80k.glb")) method = sys.argv[2] if len(sys.argv) > 2 else "voxel" out = sys.argv[3] if len(sys.argv) > 3 else None print(json.dumps(comfyui_make_watertight(src, method=method, out_path=out), indent=2))