188 lines
5.9 KiB
Python
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",
|
|
}
|