Files

188 lines
5.9 KiB
Python

"""ComfyUI custom nodes for Trellis mesh postprocessing."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, List, Tuple
import numpy as np
def _normalize(v: np.ndarray) -> np.ndarray:
length = float(np.linalg.norm(v))
if not np.isfinite(length) or length <= 1e-12:
return np.array([0.0, 1.0, 0.0], dtype=np.float64)
return v / length
def _position_key(vertices: np.ndarray, index: int, epsilon: float) -> str:
p = vertices[index]
qx = int(round(float(p[0]) / epsilon))
qy = int(round(float(p[1]) / epsilon))
qz = int(round(float(p[2]) / epsilon))
return f"{qx},{qy},{qz}"
@dataclass
class _Cluster:
avg: np.ndarray
count: int
class TamataTrellisMeshPostprocessNormals:
"""
Recalculate normals for duplicated/split Trellis vertices by clustering
face normals in quantized position space.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"trimesh": ("TRIMESH",),
"position_epsilon": (
"FLOAT",
{
"default": 0.00001,
"min": 0.00000001,
"max": 0.01,
"step": 0.000001,
},
),
"normal_crease_deg": (
"FLOAT",
{
"default": 55.0,
"min": 1.0,
"max": 180.0,
"step": 0.5,
},
),
}
}
RETURN_TYPES = ("TRIMESH",)
RETURN_NAMES = ("trimesh",)
FUNCTION = "process"
CATEGORY = "Trellis2/Mesh"
def _assign_face_cluster(
self,
groups: Dict[str, List[_Cluster]],
pos_key: str,
face_normal: np.ndarray,
min_dot: float,
) -> int:
cluster_list = groups.get(pos_key)
if cluster_list is None:
cluster_list = []
groups[pos_key] = cluster_list
for idx, cluster in enumerate(cluster_list):
if cluster.count <= 0:
continue
dot = float(np.dot(cluster.avg, face_normal))
if dot >= min_dot:
next_count = cluster.count + 1
cluster.avg = _normalize((cluster.avg * cluster.count + face_normal) / next_count)
cluster.count = next_count
return idx
cluster_list.append(_Cluster(avg=face_normal.copy(), count=1))
return len(cluster_list) - 1
def process(self, trimesh, position_epsilon: float, normal_crease_deg: float):
mesh = trimesh.copy()
vertices = np.asarray(mesh.vertices, dtype=np.float64)
faces = np.asarray(mesh.faces, dtype=np.int64)
if vertices.ndim != 2 or vertices.shape[1] != 3:
return (mesh,)
if faces.ndim != 2 or faces.shape[1] != 3 or faces.shape[0] == 0:
return (mesh,)
epsilon = max(1e-8, min(1e-2, float(position_epsilon)))
crease = max(1.0, min(180.0, float(normal_crease_deg)))
min_dot = float(np.cos(np.deg2rad(crease)))
vertex_count = int(vertices.shape[0])
position_keys = [_position_key(vertices, i, epsilon) for i in range(vertex_count)]
groups_by_position: Dict[str, List[_Cluster]] = {}
preferred_cluster_by_vertex: List[Dict[int, float]] = [defaultdict(float) for _ in range(vertex_count)]
accum: Dict[str, np.ndarray] = {}
for face in faces:
i0, i1, i2 = (int(face[0]), int(face[1]), int(face[2]))
if i0 < 0 or i1 < 0 or i2 < 0:
continue
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
continue
p0 = vertices[i0]
p1 = vertices[i1]
p2 = vertices[i2]
face_vec = np.cross(p1 - p0, p2 - p0)
magnitude = float(np.linalg.norm(face_vec))
if not np.isfinite(magnitude) or magnitude <= 1e-12:
continue
face_normal = face_vec / magnitude
for vidx in (i0, i1, i2):
pos_key = position_keys[vidx]
cluster_index = self._assign_face_cluster(groups_by_position, pos_key, face_normal, min_dot)
key = f"{pos_key}|{cluster_index}"
if key in accum:
accum[key] += face_vec
else:
accum[key] = face_vec.copy()
preferred_cluster_by_vertex[vidx][cluster_index] += magnitude
fallback_normals = None
try:
current_normals = np.asarray(mesh.vertex_normals, dtype=np.float64)
if current_normals.shape == vertices.shape:
fallback_normals = current_normals
except Exception:
fallback_normals = None
normals = np.zeros_like(vertices, dtype=np.float64)
up = np.array([0.0, 1.0, 0.0], dtype=np.float64)
for vidx in range(vertex_count):
pos_key = position_keys[vidx]
preferred = preferred_cluster_by_vertex[vidx]
cluster_index = 0
if preferred:
cluster_index = max(preferred.items(), key=lambda item: item[1])[0]
key = f"{pos_key}|{cluster_index}"
summed = accum.get(key)
if summed is None:
if fallback_normals is not None:
summed = fallback_normals[vidx]
else:
summed = up
normals[vidx] = _normalize(summed)
mesh.vertex_normals = normals.astype(np.float32)
return (mesh,)
NODE_CLASS_MAPPINGS = {
"TamataTrellisMeshPostprocessNormals": TamataTrellisMeshPostprocessNormals,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"TamataTrellisMeshPostprocessNormals": "Trellis2 - Mesh Postprocess Normals",
}