diff --git a/nodes/__init__.py b/nodes/__init__.py index 16470264..0d4ea91f 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -46,6 +46,7 @@ from . import combine from . import uv from . import repair from . import remeshing +from . import fields from . import reconstruction from . import texture_remeshing from . import smoothing @@ -98,6 +99,7 @@ NODE_CLASS_MAPPINGS.update(combine.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(uv.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(repair.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(remeshing.NODE_CLASS_MAPPINGS) +NODE_CLASS_MAPPINGS.update(fields.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(reconstruction.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(texture_remeshing.NODE_CLASS_MAPPINGS) NODE_CLASS_MAPPINGS.update(paraview.NODE_CLASS_MAPPINGS) @@ -121,6 +123,7 @@ NODE_DISPLAY_NAME_MAPPINGS.update(combine.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(uv.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(repair.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(remeshing.NODE_DISPLAY_NAME_MAPPINGS) +NODE_DISPLAY_NAME_MAPPINGS.update(fields.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(reconstruction.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(texture_remeshing.NODE_DISPLAY_NAME_MAPPINGS) NODE_DISPLAY_NAME_MAPPINGS.update(paraview.NODE_DISPLAY_NAME_MAPPINGS) diff --git a/nodes/analysis/find_fillets.py b/nodes/analysis/find_fillets.py index dd56dafd..65213256 100644 --- a/nodes/analysis/find_fillets.py +++ b/nodes/analysis/find_fillets.py @@ -1,28 +1,27 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Copyright (C) 2025 ComfyUI-GeometryPack Contributors -"""Find fillet / rounded-edge regions on a CAD mesh. +"""Find fillet / rounded-edge regions via the DeFillet method (Jiang et al., +SIGGRAPH 2025) -- reimplemented clean-room from the paper. -A fillet is the surface swept by a ball of fixed radius r rolling along an edge, so -every point on it has the SAME small rolling-ball radius and is DEVELOPABLE (one -principal curvature ~1/r across the round, the other ~0 along it). We detect exactly -that signature from the principal curvatures: +Key idea: a fillet is swept by a rolling ball, so its surface samples all "look at" +a shared 1D trajectory of ball centers. In the VORONOI diagram of the surface +samples, Voronoi vertices CONCENTRATE (high density) along that trajectory and are +robust rolling-ball-center candidates (equidistant to 4 samples -> a robust radius, +not a noisy curvature). Pipeline: - - developable: |k_min| / |k_max| < developable_ratio (cylindrical/conical, NOT a - sphere/blob where the ratio ~1, and NOT a saddle) - - small radius: min_radius < r = 1/|k_max| < max_radius (a flat face has r huge -> - rejected; a big cylinder has r large -> rejected) - - locally-constant radius across the strip (low relative spread of r) + 1. samples = triangle centroids (subsampled for scale), normalized to a unit sphere. + 2. 3D Voronoi via GPU Delaunay (pygDel3D) -> Voronoi vertices = tet circumcenters, + each with a rolling-ball radius r = circumradius. + 3. density rho(v) of Voronoi vertices (mean-shift estimate, paper eq. 4). + 4. RTT: each sample's candidate centers are the circumcenters of its INCIDENT + Delaunay tets (the exact dual -- |sample - center| = r by construction, so no + epsilon shell needed); pick the max-density one -> the sample's fillet radius. + 5. fillet radius variation rate R (eq. 6): a fillet has near-CONSTANT radius -> R~0. + 6. threshold (1-R) + connected strips -> fillet regions, transferred to all faces. -then connect the qualifying faces into fillet STRIPS and report each strip's radius. -(This is the curvature/rolling-ball signature used by DeFillet, Jiang et al. SIGGRAPH -2025 -- their full method also uses a Voronoi/medial-axis radius estimate. Note: cleanly -separating a true small CYLINDER from a fillet is genuinely ambiguous from geometry alone --- DeFillet leaves that to semantics too -- so a small isolated cylinder may read as a -fillet; the radius cap and the 'bounded by two larger patches' shape help but aren't -perfect.) - -Outputs vertex fields: fillet (1 on fillets), fillet_radius (estimated r), fillet_id.""" +Requires the `pygdel3d` package (GPU Delaunay, BSD-3). Outputs vertex fields +fillet / fillet_radius / fillet_id.""" import logging @@ -33,19 +32,74 @@ from comfy_api.latest import io log = logging.getLogger("geometrypack") -def _local_edge_length(mesh, n): - ev = np.asarray(mesh.edges_unique) - el = np.asarray(mesh.edges_unique_length, dtype=np.float64) - loc = np.zeros(n); cnt = np.zeros(n) - np.add.at(loc, ev[:, 0], el); np.add.at(loc, ev[:, 1], el) - np.add.at(cnt, ev[:, 0], 1.0); np.add.at(cnt, ev[:, 1], 1.0) - loc = loc / np.maximum(cnt, 1.0) - loc[loc <= 0] = float(np.mean(el)) if len(el) else 1.0 - return loc, ev +def _defillet(C, F, adj, num_samples, density_k, density_sigma, variation_thresh, rng_seed=0): + """Core DeFillet detection. C = per-face centroids (M,3), F faces, adj face-adjacency. + Returns per-face fillet bool + per-face fillet radius (nan where not a fillet).""" + import pygdel3d + from scipy.spatial import cKDTree + + M = len(C) + # normalize samples to a unit sphere (the paper normalizes; makes sigma/scale stable) + center = 0.5 * (C.max(0) + C.min(0)) + scale = float(np.linalg.norm(C - center, axis=1).max()) + 1e-12 + Cn = (C - center) / scale + + # subsample for tractable exact Voronoi + if M > num_samples: + sidx = np.sort(np.random.default_rng(rng_seed).choice(M, num_samples, replace=False)) + else: + sidx = np.arange(M) + S = np.ascontiguousarray(Cn[sidx]) + ns = len(S) + + # --- Voronoi via GPU Delaunay --- + vv, tets, rvor = pygdel3d.voronoi_vertices(S) + fin = ~np.isnan(vv).any(1) & np.isfinite(rvor) + tets, vv, rvor = tets[fin], vv[fin], rvor[fin] + + # --- density rho(v_i) of Voronoi vertices (mean-shift, eq. 4) --- + tree_vv = cKDTree(vv) + k = min(density_k + 1, len(vv)) + dist, _ = tree_vv.query(vv, k=k, workers=-1) + d = dist[:, 1:] # drop self + sig2 = 2.0 * (density_sigma ** 2) + w = np.exp(-(d ** 2) / sig2) + rho = w.sum(1) / ((d * w).sum(1) + 1e-12) + + # --- RTT: per sample -> max-density incident circumcenter -> fillet radius --- + vert = tets.ravel() + rho_rep = np.repeat(rho, 4) + r_rep = np.repeat(rvor, 4) + best = np.full(ns, -np.inf) + np.maximum.at(best, vert, rho_rep) + rj = np.full(ns, np.nan) + is_best = rho_rep >= best[vert] - 1e-12 + rj[vert[is_best]] = r_rep[is_best] # radius of the densest incident center + + # --- fillet radius variation rate R(s_i) over nearest sample neighbors (eq. 6) --- + tree_s = cKDTree(S) + kk = min(4, ns) + nd, ni = tree_s.query(S, k=kk, workers=-1) + nb = ni[:, 1:kk] # (ns, kk-1) neighbor indices + dnb = nd[:, 1:kk] + ri = rj[:, None] + rb = rj[nb] + valid = np.isfinite(ri) & np.isfinite(rb) & (dnb > 1e-9) + ratio = np.where(valid, np.abs(ri - rb) / np.where(dnb > 1e-9, dnb, 1.0), 0.0) + cnt = valid.sum(1) + R = np.where(cnt > 0, ratio.sum(1) / np.maximum(cnt, 1), np.inf) + R = np.minimum(1.0, R) + fillet_sample = np.isfinite(rj) & (cnt > 0) & (R < float(variation_thresh)) + + # --- transfer per-sample result to ALL faces (nearest sample) --- + _, nearest = tree_s.query(Cn, k=1, workers=-1) + face_fillet = fillet_sample[nearest] + face_radius = np.where(face_fillet, rj[nearest] * scale, np.nan) # de-normalize radius + return face_fillet, face_radius class FindFilletsNode(io.ComfyNode): - """Detect fillet / rounded-edge strips (developable, constant small radius).""" + """Detect fillet / rounded-edge strips via the DeFillet Voronoi rolling-ball method.""" @classmethod def define_schema(cls): @@ -54,36 +108,32 @@ class FindFilletsNode(io.ComfyNode): display_name="Find Fillets", category="geompack/analysis", description=( - "Detect FILLETS / rounded edges: developable strips of locally-constant small " - "radius (the rolling-ball signature). Uses principal curvatures -- a fillet has " - "one curvature ~1/r (across the round) and the other ~0 (along it), with r small " - "and consistent. Outputs vertex fields fillet (1 on fillets), fillet_radius " - "(estimated radius), fillet_id (strip). Caveat: a true small cylinder can read as " - "a fillet -- separating them is semantically ambiguous from geometry alone." + "Detect FILLETS / rounded edges with the DeFillet method (Jiang et al., SIGGRAPH " + "2025): the Voronoi diagram of surface samples concentrates Voronoi vertices along " + "each fillet's rolling-ball-center trajectory; a sample is a fillet point when its " + "rolling-ball radius (from the densest incident Voronoi vertex) is near-CONSTANT " + "across the strip. More robust than curvature (uses global Voronoi structure, not " + "noisy 2nd derivatives). Requires the pygdel3d GPU-Delaunay package. Outputs vertex " + "fields fillet / fillet_radius / fillet_id." ), is_output_node=True, inputs=[ io.Custom("TRIMESH").Input("trimesh"), - io.Int.Input("radius", default=5, min=1, max=12, step=1, tooltip=( - "k-ring neighborhood for the libigl quadric principal-curvature fit. Set near " - "the fillet width in edges. Larger = smoother/robust, blurs tiny fillets.")), - io.Float.Input("max_radius_frac", default=0.02, min=0.002, max=0.5, step=0.002, display_mode="number", tooltip=( - "Max fillet radius as a FRACTION of the model's bounding-box diagonal -- the key " - "knob that separates fillets from the body. Surfaces whose radius of curvature " - "exceeds this are too big to be a fillet (flats, the main cylinder/barrel) and " - "are rejected; only tighter rounds (hole rims, edge rounds) survive. LOWER = " - "only tighter rounds. ~0.015-0.025 typical; 0.02 default.")), - io.Float.Input("min_radius_frac", default=0.002, min=0.0001, max=0.2, step=0.0005, display_mode="number", tooltip=( - "Min fillet radius (fraction of bbox diagonal) -- rejects sub-this curvature " - "noise / sharp creases. Default 0.002.")), - io.Float.Input("developable_ratio", default=0.4, min=0.02, max=1.0, step=0.02, tooltip=( - "A point counts as developable (cylindrical/conical = fillet-like) when " - "|k_min|/|k_max| < this. SMALLER = stricter (only near-perfect cylinders, " - "rejects spheres/blobs harder). ~0.3-0.5 typical.")), - io.Float.Input("radius_consistency", default=0.5, min=0.05, max=5.0, step=0.05, tooltip=( - "Reject points whose rolling-ball radius differs too much from the local median " - "(relative): keep if |r - r_med|/r_med < this. SMALLER = require very constant " - "radius (true fillets). Default 0.5. Set high to disable.")), + io.Int.Input("num_samples", default=60000, min=2000, max=400000, step=1000, tooltip=( + "Surface samples (triangle centroids) used for the exact Voronoi. Subsampled " + "for tractability; DeFillet is resolution-robust so 40-100k is plenty even for " + "1M-face meshes. Higher = sharper but slower Voronoi.")), + io.Float.Input("variation_thresh", default=0.15, min=0.005, max=1.0, step=0.005, display_mode="number", tooltip=( + "A sample is a fillet when its rolling-ball-radius VARIATION RATE (change in r " + "per unit surface distance to its neighbors) is below this. SMALLER = stricter " + "constant-radius requirement (only clean fillets). ~0.1-0.2 typical.")), + io.Int.Input("density_k", default=16, min=4, max=128, step=1, tooltip=( + "k nearest Voronoi vertices used to estimate vertex DENSITY (paper eq. 4). " + "Higher = smoother density. ~16 default.")), + io.Float.Input("density_sigma", default=0.03, min=0.002, max=0.5, step=0.002, display_mode="number", tooltip=( + "Bandwidth of the Voronoi-vertex density kernel, in UNIT-SPHERE-normalized " + "units (the mesh is normalized to a unit sphere first). SMALLER = more local " + "density. ~0.02-0.05 typical.")), io.Int.Input("min_strip_faces", default=30, min=1, max=1000000, step=1, tooltip=( "Drop fillet strips with fewer faces than this (de-speckle).")), ], @@ -94,11 +144,16 @@ class FindFilletsNode(io.ComfyNode): ) @classmethod - def execute(cls, trimesh, radius=5, max_radius_frac=0.06, min_radius_frac=0.002, - developable_ratio=0.4, radius_consistency=0.5, min_strip_faces=30): - import igl + def execute(cls, trimesh, num_samples=60000, variation_thresh=0.15, density_k=16, + density_sigma=0.03, min_strip_faces=30): import scipy.sparse as sp from scipy.sparse.csgraph import connected_components + try: + import pygdel3d # noqa: F401 + except ImportError: + raise ImportError( + "Find Fillets (DeFillet) needs the 'pygdel3d' package (GPU Delaunay). " + "Install it into this env: pip install /pygDel3D") mesh = trimesh.copy() try: @@ -109,66 +164,53 @@ class FindFilletsNode(io.ComfyNode): V = np.ascontiguousarray(mesh.vertices, dtype=np.float64) F = np.ascontiguousarray(mesh.faces, dtype=np.int64) - n = len(V) - diag = float(np.linalg.norm(V.max(0) - V.min(0))) - rmax = max_radius_frac * diag - rmin = min_radius_frac * diag + C = np.ascontiguousarray(mesh.triangles_center, dtype=np.float64) + adj = np.asarray(mesh.face_adjacency) + if len(adj) == 0: + raise ValueError("Mesh has no face adjacency (disconnected or degenerate).") - out = igl.principal_curvature(V, F, int(max(1, radius))) - k1 = np.asarray(out[2], dtype=np.float64) - k2 = np.asarray(out[3], dtype=np.float64) - kmax = np.maximum(np.abs(k1), np.abs(k2)) - kmin = np.minimum(np.abs(k1), np.abs(k2)) - r = 1.0 / np.maximum(kmax, 1e-12) # rolling-ball radius estimate - ratio = kmin / np.maximum(kmax, 1e-12) + face_fillet, face_radius = _defillet( + C, F, adj, int(num_samples), int(density_k), float(density_sigma), + float(variation_thresh)) - cand = (r < rmax) & (r > rmin) & (ratio < float(developable_ratio)) - - # local-radius consistency: compare r to its neighborhood median (1-ring smoothed) - loc, ev = _local_edge_length(mesh, n) - rmed = r.copy() - for _ in range(2): # couple of median-ish smoothing passes - acc = np.zeros(n); cnt = np.zeros(n) - np.add.at(acc, ev[:, 0], r[ev[:, 1]]); np.add.at(acc, ev[:, 1], r[ev[:, 0]]) - np.add.at(cnt, ev[:, 0], 1.0); np.add.at(cnt, ev[:, 1], 1.0) - rmed = acc / np.maximum(cnt, 1.0) - consistent = np.abs(r - rmed) / np.maximum(rmed, 1e-12) < float(radius_consistency) - cand = cand & consistent - - # connect candidate vertices into strips (subgraph of candidate-candidate edges) - both = cand[ev[:, 0]] & cand[ev[:, 1]] - e = ev[both] - rows = np.concatenate([e[:, 0], e[:, 1]]); cols = np.concatenate([e[:, 1], e[:, 0]]) - A = sp.coo_matrix((np.ones(len(rows)), (rows, cols)), shape=(n, n)).tocsr() + # connected fillet strips on the face graph + despeckle + both = face_fillet[adj[:, 0]] & face_fillet[adj[:, 1]] + e = adj[both] + m = len(F) + A = sp.coo_matrix((np.ones(len(e) * 2), + (np.concatenate([e[:, 0], e[:, 1]]), np.concatenate([e[:, 1], e[:, 0]]))), + shape=(m, m)).tocsr() ncomp, comp = connected_components(A, directed=False) - comp_size = np.bincount(comp) - keep = cand & (comp_size[comp] >= int(min_strip_faces)) + csz = np.bincount(comp, minlength=ncomp) + keep = face_fillet & (csz[comp] >= int(min_strip_faces)) - fillet_id = np.zeros(n, dtype=np.int64) + fid = np.zeros(m, dtype=np.int64) uids = np.unique(comp[keep]) remap = {c: i + 1 for i, c in enumerate(uids)} - for v in np.where(keep)[0]: - fillet_id[v] = remap[comp[v]] + for f in np.where(keep)[0]: + fid[f] = remap[comp[f]] n_strips = len(uids) - mesh.vertex_attributes["fillet"] = keep.astype(np.float32) - mesh.vertex_attributes["fillet_radius"] = np.where(keep, r, 0.0).astype(np.float32) - mesh.vertex_attributes["fillet_id"] = fillet_id.astype(np.float32) - mesh.vertex_attributes["kappa_max"] = kmax.astype(np.float32) + # face fields -> also splat to vertices for viewers that read vertex fields + mesh.face_attributes["fillet"] = keep.astype(np.int64) + mesh.face_attributes["fillet_id"] = fid + mesh.face_attributes["fillet_radius"] = np.where(keep, face_radius, 0.0).astype(np.float32) + vfill = np.zeros(len(V), dtype=np.float32); vcnt = np.zeros(len(V)) + for k in range(3): + np.add.at(vfill, F[:, k], keep.astype(np.float32)); np.add.at(vcnt, F[:, k], 1.0) + mesh.vertex_attributes["fillet"] = (vfill / np.maximum(vcnt, 1.0) >= 0.5).astype(np.float32) - radii = [float(np.median(r[fillet_id == i + 1])) for i in range(n_strips)] + rr = face_radius[keep] info = ( - f"Find Fillets:\n\n" - f"Vertices: {n:,} | fillet verts: {int(keep.sum()):,} ({100*keep.mean():.1f}%)\n" + f"Find Fillets (DeFillet / Voronoi rolling-ball):\n\n" + f"Faces: {m:,} | fillet faces: {int(keep.sum()):,} ({100*keep.mean():.1f}%)\n" f"fillet strips: {n_strips}\n" - f"bbox diag {diag:.3f} | radius window [{rmin:.4f}, {rmax:.4f}] | developable<{developable_ratio}\n" + f"samples: {min(num_samples, m):,} | variation_thresh: {variation_thresh}\n" ) - if radii: - rr = np.array(radii) - info += (f"strip radii (bbox-frac): min {rr.min()/diag:.4f}, median {np.median(rr)/diag:.4f}, " - f"max {rr.max()/diag:.4f}\n") - info += "\nFields: fillet (1 on fillets), fillet_radius, fillet_id, kappa_max" - log.info("Find Fillets: %d strips, %d fillet verts", n_strips, int(keep.sum())) + if len(rr): + info += f"fillet radius: min {np.nanmin(rr):.4f}, median {np.nanmedian(rr):.4f}, max {np.nanmax(rr):.4f}\n" + info += "\nFields: fillet, fillet_id, fillet_radius (per-face); fillet (per-vertex)" + log.info("Find Fillets (DeFillet): %d strips, %d fillet faces", n_strips, int(keep.sum())) return io.NodeOutput(mesh, info, ui={"text": [info]}) diff --git a/nodes/decimation/backends/decimate_pro.py b/nodes/decimation/backends/decimate_pro.py index 0b506b51..924cf341 100644 --- a/nodes/decimation/backends/decimate_pro.py +++ b/nodes/decimation/backends/decimate_pro.py @@ -57,7 +57,7 @@ class DecimateProNode(io.ComfyNode): inputs=[ io.Custom("TRIMESH").Input("trimesh"), io.Float.Input("target_reduction", default=0.5, min=0.01, max=0.99, step=0.01, tooltip="Fraction of faces to REMOVE. 0.5 = reduce to ~50%% of original faces, 0.9 = reduce to ~10%% of original."), - io.Float.Input("feature_angle", default=15.0, min=0.0, max=180.0, step=1.0, tooltip="Feature angle threshold (degrees). Edges with dihedral angle above this are preserved."), + io.Float.Input("feature_angle", default=15.0, min=0.0, max=180.0, step=0.1, tooltip="Feature angle threshold (degrees). Edges with dihedral angle above this are preserved."), io.Combo.Input("preserve_topology", options=["true", "false"], default="true", tooltip="Preserve mesh topology (genus) during decimation."), ], outputs=[ diff --git a/nodes/decimation/decimate.py b/nodes/decimation/decimate.py index f9c1ffb8..20a6a063 100644 --- a/nodes/decimation/decimate.py +++ b/nodes/decimation/decimate.py @@ -64,7 +64,7 @@ class DecimateMeshNode(io.ComfyNode): io.DynamicCombo.Option("decimate_pro", [ io.Float.Input("target_reduction", default=0.5, min=0.01, max=0.99, step=0.01, tooltip="Fraction of faces to REMOVE. 0.5 = reduce to ~50%% of original faces, 0.9 = reduce to ~10%% of original."), - io.Float.Input("feature_angle", default=15.0, min=0.0, max=180.0, step=1.0, tooltip="Feature angle threshold (degrees). Edges with dihedral angle above this are preserved."), + io.Float.Input("feature_angle", default=15.0, min=0.0, max=180.0, step=0.1, tooltip="Feature angle threshold (degrees). Edges with dihedral angle above this are preserved."), io.Combo.Input("preserve_topology", options=["true", "false"], default="true", tooltip="Preserve mesh topology (genus) during decimation."), ]), ]), diff --git a/nodes/fields/__init__.py b/nodes/fields/__init__.py new file mode 100644 index 00000000..e75448fc --- /dev/null +++ b/nodes/fields/__init__.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors +"""Field transfer / interpolation nodes.""" + +from .interpolate_field import ( + NODE_CLASS_MAPPINGS as INTERP_MAPS, + NODE_DISPLAY_NAME_MAPPINGS as INTERP_DISP, +) +from .edge_split import ( + NODE_CLASS_MAPPINGS as SPLIT_MAPS, + NODE_DISPLAY_NAME_MAPPINGS as SPLIT_DISP, +) + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +NODE_CLASS_MAPPINGS.update(INTERP_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(INTERP_DISP) +NODE_CLASS_MAPPINGS.update(SPLIT_MAPS) +NODE_DISPLAY_NAME_MAPPINGS.update(SPLIT_DISP) + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/fields/edge_split.py b/nodes/fields/edge_split.py new file mode 100644 index 00000000..279b2e0c --- /dev/null +++ b/nodes/fields/edge_split.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors +"""Edge Split — refine a mesh until every edge is <= max_edge_length, carrying fields exactly. + +This is the "split" operator of isotropic remeshing in isolation. Because splitting only +refines (it never moves or removes geometry), field transport is EXACT and search-free — +the operations themselves give the correspondence (cf. Progressive Meshes / AMR prolongation): + + - face fields -> each child face inherits its parent face's value (parent index is returned + by the subdivider). Exact for labels (cad_face_id) AND continuous per-face data. + - vertex fields -> new vertices lie exactly inside their parent ORIGINAL triangle (planar), + so we barycentric-interpolate the original per-vertex field there. Original vertices keep + their values verbatim. Integer/label vertex fields take the dominant-weight original vertex + (never averaged into nonexistent labels). + +No BVH / closest-point query is needed — the parent triangle is known from the subdivision. +""" +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("GeometryPack") + + +def _is_discrete(arr): + return np.issubdtype(arr.dtype, np.integer) or arr.dtype == np.bool_ + + +def split_to_max_edge(mesh, max_edge, max_iter=10): + """Return (new_mesh, info_dict). Refines until max edge <= max_edge, carrying all + vertex_attributes + face_attributes exactly via parent-triangle provenance.""" + V0 = np.asarray(mesh.vertices, dtype=np.float64) + F0 = np.asarray(mesh.faces) + n0 = len(V0) + + V2, F2, parent = trimesh_module.remesh.subdivide_to_size( + V0, F0, max_edge=float(max_edge), max_iter=int(max_iter), return_index=True) + parent = np.asarray(parent) # (n_newfaces,) -> original face id + + new = trimesh_module.Trimesh(vertices=V2, faces=F2, process=False) + new.metadata = dict(getattr(mesh, "metadata", {}) or {}) + + # --- vertex fields: original verts keep values; new verts barycentric in parent tri --- + vattr = dict(getattr(mesh, "vertex_attributes", {}) or {}) + n_new_v = len(V2) - n0 + bary = pf = None + if n_new_v > 0 and vattr: + # parent original face for each NEW vertex (any incident new face works) + pf_of_v = np.full(len(V2), -1, dtype=np.int64) + for fi in range(len(F2)): + for v in F2[fi]: + if v >= n0 and pf_of_v[v] < 0: + pf_of_v[v] = parent[fi] + new_ids = np.arange(n0, len(V2)) + pf = pf_of_v[new_ids] + tri = F0[pf] # (k,3) original vertex ids + bary = trimesh_module.triangles.points_to_barycentric( + V0[tri], V2[new_ids]) # (k,3), exact (planar) + + for name, arr in vattr.items(): + arr = np.asarray(arr) + out = np.empty((len(V2),) + arr.shape[1:], dtype=arr.dtype) + out[:n0] = arr # originals verbatim + if n_new_v > 0: + tri = F0[pf] + f = arr[tri] # (k,3,...) + if _is_discrete(arr): + dom = tri[np.arange(len(pf)), np.argmax(bary, axis=1)] + out[n0:] = arr[dom] + else: + w = bary.reshape(bary.shape + (1,) * (f.ndim - 2)) + out[n0:] = (w * f).sum(axis=1).astype(arr.dtype, copy=False) + new.vertex_attributes[name] = out + + # --- face fields: each child inherits its parent (exact, any dtype) --- + fattr = dict(getattr(mesh, "face_attributes", {}) or {}) + for name, arr in fattr.items(): + new.face_attributes[name] = np.asarray(arr)[parent] + + e = np.vstack([F2[:, [0, 1]], F2[:, [1, 2]], F2[:, [2, 0]]]) + max_after = float(np.linalg.norm(V2[e[:, 0]] - V2[e[:, 1]], axis=1).max()) if len(F2) else 0.0 + info = {"v0": n0, "f0": len(F0), "v1": len(V2), "f1": len(F2), + "max_edge_after": max_after, "target": float(max_edge), + "vfields": list(vattr.keys()), "ffields": list(fattr.keys())} + return new, info + + +class EdgeSplitNode(io.ComfyNode): + """Split long edges until every edge <= max length, carrying vertex & face fields exactly.""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackEdgeSplit", + display_name="Edge Split", + category="geompack/remeshing", + inputs=[ + io.Custom("TRIMESH").Input("mesh", + tooltip="Input mesh. Its vertex_attributes / face_attributes are carried through the split exactly."), + io.Float.Input("max_edge_length", default=1.0, min=1e-6, max=1e9, step=0.001, + tooltip="Upper bound on edge length, in the mesh's world units. Every edge longer than this is split (recursively) by inserting midpoints. Smaller = finer mesh. This is a true cap on edge length — unlike OCC BRepMesh, which has no max-length knob."), + io.Int.Input("max_iterations", default=10, min=1, max=50, step=1, advanced=True, + tooltip="Safety cap on subdivision passes. If the target needs more than this many halvings the result may still have a few longer edges (see the reported max_edge_after)."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, mesh, max_edge_length=1.0, max_iterations=10): + new, d = split_to_max_edge(mesh, max_edge_length, max_iterations) + lines = [ + f"Edge Split: {d['v0']}v/{d['f0']}f -> {d['v1']}v/{d['f1']}f | " + f"max edge {d['max_edge_after']:.4g} (target {d['target']:.4g})" + f"{' [hit max_iterations]' if d['max_edge_after'] > d['target'] * 1.0001 else ''}", + ] + if d["vfields"]: + lines.append(f" vertex fields carried: {d['vfields']}") + if d["ffields"]: + lines.append(f" face fields carried (exact, per parent face): {d['ffields']}") + if not d["vfields"] and not d["ffields"]: + lines.append(" (no fields on input mesh)") + info = "\n".join(lines) + log.info("[EdgeSplit] %s", info) + return io.NodeOutput(new, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackEdgeSplit": EdgeSplitNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackEdgeSplit": "Edge Split"} diff --git a/nodes/fields/interpolate_field.py b/nodes/fields/interpolate_field.py new file mode 100644 index 00000000..2af6b7e4 --- /dev/null +++ b/nodes/fields/interpolate_field.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 ComfyUI-GeometryPack Contributors +"""Interpolate Field — transfer per-vertex / per-face attributes from one mesh to another. + +Method consensus from two geometry-processing surveys: + - barycentric (DEFAULT): closest-point on a source triangle + barycentric interpolation + of the source per-vertex field. Second-order; exact when the meshes coincide. The right + default for resampling a continuous field after remeshing the SAME object. + - nearest: copy the nearest source value. The ONLY correct path for integer / label fields + (cad_face_id, segmentation, materials) — barycentric/idw/rbf would average ids into + nonexistent labels. Integer/boolean fields are therefore ALWAYS routed to nearest. + - idw: inverse-distance weighting over k nearest source points (smoothing / point-cloud src). + - rbf: scipy RBFInterpolator (smooth / sparse source). + +Per-face fields transfer by nearest source face (constant per face). A max_distance guard +flags target points whose closest source point is farther than the threshold. + +Heavy machinery deliberately skipped (overkill when source/target share the same space): +conservative L2/Galerkin projection (needs a supermesh), functional maps (no registration +problem here). Add later only if a use case demands integral conservation. +""" +import logging +import numpy as np +import trimesh as trimesh_module +from comfy_api.latest import io + +log = logging.getLogger("GeometryPack") + + +# ── core transfer helpers (no ComfyUI deps — unit-testable) ────────────────── + +def _is_discrete(arr): + """Integer / boolean fields must never be linearly blended.""" + return np.issubdtype(arr.dtype, np.integer) or arr.dtype == np.bool_ + + +def _closest_point(mesh, pts): + """Closest point on the mesh surface -> (closest, distance, triangle_id). + Fast rtree-backed path, with a brute-force fallback if rtree is unavailable.""" + try: + return trimesh_module.proximity.closest_point(mesh, pts) + except Exception: + return trimesh_module.proximity.closest_point_naive(mesh, pts) + + +def _as2d(arr): + """(n, ...) -> (n, C) plus the trailing shape, so scalar/vector/matrix fields + all flow through the same code.""" + a = np.asarray(arr) + trailing = a.shape[1:] + return a.reshape(a.shape[0], -1), trailing + + +def transfer_vertex_field(src, tgt, field, method, max_distance, fill_value, + idw_k, idw_power): + """Transfer a per-source-vertex field to the target vertices. + + Returns (values[n_tgt, ...], n_out_of_range). Discrete fields force 'nearest'. + """ + P = np.asarray(tgt.vertices, dtype=np.float64) + discrete = _is_discrete(field) + F, trailing = _as2d(field) + eff_method = "nearest" if discrete else method + + if eff_method in ("barycentric", "nearest"): + # surface-aware: closest point on a source triangle + closest, dist, tri_id = _closest_point(src, P) + tri_v = src.faces[tri_id] # (n,3) source vert ids + bary = trimesh_module.triangles.points_to_barycentric( + src.triangles[tri_id], closest) # (n,3) + if eff_method == "nearest": + dom = tri_v[np.arange(len(tri_id)), np.argmax(bary, axis=1)] + out = F[dom] + else: + f = F[tri_v] # (n,3,C) + out = (bary[:, :, None] * f).sum(axis=1) # (n,C) + else: + # meshless on source vertices + Vsrc = np.asarray(src.vertices, dtype=np.float64) + from scipy.spatial import cKDTree + tree = cKDTree(Vsrc) + if eff_method == "idw": + k = int(max(1, min(idw_k, len(Vsrc)))) + d, idx = tree.query(P, k=k) + d = np.atleast_2d(d).reshape(len(P), k) + idx = np.atleast_2d(idx).reshape(len(P), k) + w = 1.0 / (np.power(d, idw_power) + 1e-12) # (n,k) + w /= w.sum(axis=1, keepdims=True) + out = (w[:, :, None] * F[idx].astype(np.float64)).sum(axis=1) + dist = d[:, 0] + elif eff_method == "rbf": + from scipy.interpolate import RBFInterpolator + nbr = int(min(32, len(Vsrc))) + rbf = RBFInterpolator(Vsrc, F.astype(np.float64), + neighbors=nbr, kernel="thin_plate_spline") + out = rbf(P) + dist, _ = tree.query(P, k=1) + else: + raise ValueError(f"unknown method: {method}") + + n_oor = 0 + if max_distance and max_distance > 0: + oor = dist > float(max_distance) + n_oor = int(oor.sum()) + if n_oor: + out = out.astype(np.float64, copy=True) if not discrete else out.copy() + out[oor] = fill_value + out = out.astype(field.dtype, copy=False) if discrete else out + return out.reshape((len(P),) + trailing), n_oor + + +def transfer_face_field(src, tgt, field, max_distance, fill_value): + """Transfer a per-source-face field to the target faces by nearest source face + (constant-per-face is correct for both labels and piecewise face data).""" + centroids = np.asarray(tgt.triangles_center, dtype=np.float64) + _, dist, tri_id = _closest_point(src, centroids) + field = np.asarray(field) + out = field[tri_id] + n_oor = 0 + if max_distance and max_distance > 0: + oor = dist > float(max_distance) + n_oor = int(oor.sum()) + if n_oor: + if _is_discrete(field): + out = out.copy() + else: + out = out.astype(np.float64, copy=True) + out[oor] = fill_value + return out, n_oor + + +def _select_fields(src, field_name): + """Return [(name, kind, array)] to transfer. kind in {'vertex','face'}. + Blank field_name = every vertex + face attribute.""" + vattr = dict(getattr(src, "vertex_attributes", {}) or {}) + fattr = dict(getattr(src, "face_attributes", {}) or {}) + fn = (field_name or "").strip() + out = [] + if fn: + if fn.startswith("face.") and fn[5:] in fattr: + out.append((fn[5:], "face", fattr[fn[5:]])) + elif fn in vattr: + out.append((fn, "vertex", vattr[fn])) + elif fn in fattr: + out.append((fn, "face", fattr[fn])) + else: + out += [(n, "vertex", v) for n, v in vattr.items()] + out += [(n, "face", v) for n, v in fattr.items()] + return out + + +# ── node ───────────────────────────────────────────────────────────────────── + +class InterpolateFieldNode(io.ComfyNode): + """Transfer scalar/vector fields from one mesh onto another (e.g. after remeshing).""" + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GeomPackInterpolateField", + display_name="Interpolate Field", + category="geompack/fields", + inputs=[ + io.Custom("TRIMESH").Input("field_providing_mesh", + tooltip="Source mesh carrying the field(s) to transfer (in its vertex_attributes / face_attributes)."), + io.Custom("TRIMESH").Input("field_target_mesh", + tooltip="Target mesh to receive the interpolated field(s). Its geometry is unchanged; only attributes are added."), + io.String.Input("field_name", default="", + tooltip="Single field to transfer. Leave BLANK to transfer ALL vertex + face fields. Address a face field explicitly as 'face.' (e.g. 'face.cad_face_id')."), + io.Combo.Input("method", options=["barycentric", "nearest", "idw", "rbf"], default="barycentric", + tooltip="barycentric: closest-point + barycentric blend (default; accurate for continuous fields, exact when meshes coincide). nearest: copy nearest source value (used automatically for integer/label fields). idw: inverse-distance weighting (smoothing / point clouds). rbf: thin-plate RBF (smooth / sparse source). NOTE: integer & boolean fields are ALWAYS transferred by nearest regardless of this setting — blending labels is meaningless."), + io.Float.Input("max_distance", default=0.0, min=0.0, max=1e9, step=0.001, + tooltip="World-unit cap on the source->target projection distance. Target points whose closest source point is farther than this are out-of-range and set to fill_value (prevents grabbing values across holes / off the surface). 0 = no limit."), + io.Float.Input("fill_value", default=0.0, min=-1e12, max=1e12, step=0.1, advanced=True, + tooltip="Value written where a target point exceeds max_distance (no nearby source)."), + io.Int.Input("idw_k", default=8, min=1, max=128, step=1, advanced=True, + tooltip="IDW only: number of nearest source points averaged."), + io.Float.Input("idw_power", default=2.0, min=0.1, max=8.0, step=0.1, advanced=True, + tooltip="IDW only: inverse-distance exponent (higher = more local)."), + ], + outputs=[ + io.Custom("TRIMESH").Output(display_name="mesh"), + io.String.Output(display_name="info"), + ], + ) + + @classmethod + def execute(cls, field_providing_mesh, field_target_mesh, field_name="", + method="barycentric", max_distance=0.0, fill_value=0.0, + idw_k=8, idw_power=2.0): + src = field_providing_mesh + tgt = field_target_mesh.copy() + + fields = _select_fields(src, field_name) + if not fields: + avail_v = list((getattr(src, "vertex_attributes", {}) or {}).keys()) + avail_f = list((getattr(src, "face_attributes", {}) or {}).keys()) + msg = (f"No matching field. field_name={field_name!r}. " + f"Available vertex fields: {avail_v}; face fields: {avail_f}.") + log.warning("[InterpolateField] %s", msg) + return io.NodeOutput(tgt, msg, ui={"text": [msg]}) + + lines = [f"Interpolate Field: src {len(src.vertices)}v/{len(src.faces)}f " + f"-> tgt {len(tgt.vertices)}v/{len(tgt.faces)}f | method={method}"] + for name, kind, arr in fields: + arr = np.asarray(arr) + forced = _is_discrete(arr) and method != "nearest" + try: + if kind == "vertex": + out, n_oor = transfer_vertex_field( + src, tgt, arr, method, max_distance, fill_value, idw_k, idw_power) + tgt.vertex_attributes[name] = out + else: + out, n_oor = transfer_face_field(src, tgt, arr, max_distance, fill_value) + tgt.face_attributes[name] = out + lines.append( + f" [{kind}] {name}: dtype={arr.dtype} shape{arr.shape[1:] or '(scalar)'}" + f"{' -> nearest (discrete)' if forced else ''}" + f"{f' | {n_oor} out-of-range -> fill' if n_oor else ''}") + except Exception as e: + lines.append(f" [{kind}] {name}: FAILED ({type(e).__name__}: {e})") + log.exception("[InterpolateField] transfer failed for %s", name) + + info = "\n".join(lines) + log.info("[InterpolateField]\n%s", info) + return io.NodeOutput(tgt, info, ui={"text": [info]}) + + +NODE_CLASS_MAPPINGS = {"GeomPackInterpolateField": InterpolateFieldNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackInterpolateField": "Interpolate Field"} diff --git a/nodes/remeshing/backends/pymeshlab_isotropic.py b/nodes/remeshing/backends/pymeshlab_isotropic.py index a8e2f5ed..0762289c 100644 --- a/nodes/remeshing/backends/pymeshlab_isotropic.py +++ b/nodes/remeshing/backends/pymeshlab_isotropic.py @@ -73,7 +73,7 @@ class RemeshPyMeshLabNode(io.ComfyNode): io.Int.Input("target_vertices", default=0, min=0, max=20000000, step=100, tooltip="Target output vertex count (0 = off). Back-solves the edge length from the mesh area; overrides target_edge_length. Approximate."), io.Int.Input("target_faces", default=0, min=0, max=40000000, step=100, tooltip="Target output face count (0 = off). Back-solves the edge length from the mesh area; overrides target_vertices and target_edge_length. Approximate."), io.Int.Input("iterations", default=3, min=1, max=20, step=1, tooltip="Number of remeshing passes."), - io.Float.Input("feature_angle", default=30.0, min=0.0, max=180.0, step=1.0, tooltip="Angle threshold (degrees) for feature/crease edge detection -- edges sharper than this are preserved. Lower = preserve more edges; 180 = none."), + io.Float.Input("feature_angle", default=30.0, min=0.0, max=180.0, step=0.1, tooltip="Angle threshold (degrees) for feature/crease edge detection -- edges sharper than this are preserved. Lower = preserve more edges; 180 = none."), io.Combo.Input("adaptive", options=["true", "false"], default="false", tooltip="Use curvature-adaptive edge lengths."), io.Combo.Input("reproject", options=["true", "false"], default="true", tooltip="Reproject vertices back onto the original surface after each iteration (Botsch back-projection). true = stay faithful to the input surface (recommended); false = pure tangential smoothing, which lets vertices drift off the surface."), ], diff --git a/nodes/remeshing/edge_flip.py b/nodes/remeshing/edge_flip.py index 0df1fbfc..7edf7270 100644 --- a/nodes/remeshing/edge_flip.py +++ b/nodes/remeshing/edge_flip.py @@ -173,7 +173,7 @@ class EdgeFlipNode(io.ComfyNode): "delaunay = maximize min angle (flip when opposite angles sum > 180 deg). " "valence = Botsch-Kobbelt connectivity regularization (flip toward ideal " "valence 6/4).")), - io.Float.Input("feature_angle", default=30.0, min=0.0, max=180.0, step=1.0, tooltip=( + io.Float.Input("feature_angle", default=30.0, min=0.0, max=180.0, step=0.1, tooltip=( "Preserve sharp edges: any edge whose dihedral (angle between the two " "adjacent face normals) exceeds this is LOCKED and never flipped, so " "creases/feature lines survive. Lower = protect more edges (CAD: ~20-45). " diff --git a/nodes/remeshing/remesh.py b/nodes/remeshing/remesh.py index a82d60cc..9fde24e6 100644 --- a/nodes/remeshing/remesh.py +++ b/nodes/remeshing/remesh.py @@ -69,7 +69,7 @@ class RemeshNode(io.ComfyNode): io.Int.Input("target_vertices", default=0, min=0, max=20000000, step=100, tooltip="Target output vertex count (0 = off). Back-solves the edge length from the mesh area; overrides target_edge_length. Approximate."), io.Int.Input("target_faces", default=0, min=0, max=40000000, step=100, tooltip="Target output face count (0 = off). Back-solves the edge length from the mesh area; overrides target_vertices and target_edge_length. Approximate."), io.Int.Input("iterations", default=3, min=1, max=20, step=1, tooltip="Number of remeshing passes. More iterations = smoother result, slower processing."), - io.Float.Input("feature_angle", default=30.0, min=0.0, max=180.0, step=1.0, tooltip="Angle threshold (degrees) for feature/crease edge detection -- edges sharper than this are preserved. Lower = preserve more edges; 180 = none."), + io.Float.Input("feature_angle", default=30.0, min=0.0, max=180.0, step=0.1, tooltip="Angle threshold (degrees) for feature/crease edge detection -- edges sharper than this are preserved. Lower = preserve more edges; 180 = none."), io.Combo.Input("adaptive", options=["true", "false"], default="false", tooltip="Use curvature-adaptive edge lengths."), io.Combo.Input("reproject", options=["true", "false"], default="true", tooltip="Reproject vertices back onto the original surface after each iteration (Botsch back-projection). true = stay faithful to the input surface (recommended); false = pure tangential smoothing, which lets vertices drift off the surface."), ]), diff --git a/nodes/repair/compute_normals.py b/nodes/repair/compute_normals.py index 80545418..c8d27800 100644 --- a/nodes/repair/compute_normals.py +++ b/nodes/repair/compute_normals.py @@ -52,50 +52,43 @@ class ComputeNormalsNode(io.ComfyNode): """ log.info("Processing mesh with %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces)) - # Create a copy - result_mesh = trimesh.copy() + # 'trimesh' here is the input-mesh argument (it shadows the top-level + # `import trimesh`), so grab the module under another name for the helpers. + import trimesh as tm - # Face normals are always recomputed automatically by trimesh - # But we can force a cache clear and recomputation + result_mesh = trimesh.copy() result_mesh._cache.clear() + face_normals = np.asarray(result_mesh.face_normals, dtype=np.float64) if smooth_vertex_normals == "false": - # Use face normals directly (faceted appearance) - # This creates sharp edges by not averaging normals across faces - vertex_normals = np.zeros_like(result_mesh.vertices) - for i, face in enumerate(result_mesh.faces): - face_normal = result_mesh.face_normals[i] - vertex_normals[face] += face_normal - # Normalize - norms = np.linalg.norm(vertex_normals, axis=1, keepdims=True) - norms[norms == 0] = 1 # Avoid division by zero - vertex_normals = vertex_normals / norms - - # Store in mesh (note: trimesh will override this with smoothed normals) - # So we need to mark it in metadata + # Faceted: per-vertex mean of adjacent face normals -- vectorized, no + # Python loop. Prefer trimesh's helper; fall back to a numpy scatter. + faces = np.asarray(result_mesh.faces) + try: + vertex_normals = np.asarray( + tm.geometry.mean_vertex_normals( + len(result_mesh.vertices), faces, face_normals), + dtype=np.float64) + except Exception: + vn = np.zeros((len(result_mesh.vertices), 3), dtype=np.float64) + np.add.at(vn, faces.ravel(), np.repeat(face_normals, 3, axis=0)) + norms = np.linalg.norm(vn, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + vertex_normals = vn / norms result_mesh.metadata['normals_smoothed'] = False - - # Store normals as vertex attributes for visualization - result_mesh.vertex_attributes['normal_x'] = vertex_normals[:, 0] - result_mesh.vertex_attributes['normal_y'] = vertex_normals[:, 1] - result_mesh.vertex_attributes['normal_z'] = vertex_normals[:, 2] - result_mesh.vertex_attributes['normal_magnitude'] = np.linalg.norm(vertex_normals, axis=1) - - log.info("Computed faceted (non-smooth) normals") + log.info("Computed faceted (mean) vertex normals") else: - # Trimesh automatically computes smooth vertex normals - # Just access them to ensure they're computed - vertex_normals = result_mesh.vertex_normals + # Smooth: trimesh's angle-weighted vertex normals (also vectorized). + vertex_normals = np.asarray(result_mesh.vertex_normals, dtype=np.float64) result_mesh.metadata['normals_smoothed'] = True - - # Store normals as vertex attributes for visualization - result_mesh.vertex_attributes['normal_x'] = vertex_normals[:, 0] - result_mesh.vertex_attributes['normal_y'] = vertex_normals[:, 1] - result_mesh.vertex_attributes['normal_z'] = vertex_normals[:, 2] - result_mesh.vertex_attributes['normal_magnitude'] = np.linalg.norm(vertex_normals, axis=1) - log.info("Computed smooth vertex normals") + # Store normals as vertex attributes for visualization + result_mesh.vertex_attributes['normal_x'] = vertex_normals[:, 0] + result_mesh.vertex_attributes['normal_y'] = vertex_normals[:, 1] + result_mesh.vertex_attributes['normal_z'] = vertex_normals[:, 2] + result_mesh.vertex_attributes['normal_magnitude'] = np.linalg.norm(vertex_normals, axis=1) + return io.NodeOutput(result_mesh) diff --git a/nodes/texture_remeshing/texture_to_geometry.py b/nodes/texture_remeshing/texture_to_geometry.py index b1468c69..ab5d5fe6 100644 --- a/nodes/texture_remeshing/texture_to_geometry.py +++ b/nodes/texture_remeshing/texture_to_geometry.py @@ -43,9 +43,10 @@ class TextureToGeometryNode(io.ComfyNode): is_output_node=True, inputs=[ io.Float.Input("height_scale", default=1.0, min=0.01, max=10.0, step=0.1, display_mode="number"), - io.Mask.Input("mask", optional=True), - io.Image.Input("depth_image", optional=True), - io.Mask.Input("field", tooltip="Optional field to store as vertex attribute on the mesh (e.g., face IDs, curvature)", optional=True), + io.MultiType.Input("depth", [io.Image, io.Mask], + tooltip="The depth / height map. Each pixel becomes a vertex, displaced in Z by its value x height_scale. Accepts an IMAGE (RGB is averaged to grayscale) OR a MASK (single channel)."), + io.MultiType.Input("field", [io.Image, io.Mask], optional=True, + tooltip="Optional per-pixel value sampled at each vertex and stored as a named vertex attribute (field_name). Does NOT affect geometry -- e.g. a face-ID / segmentation / curvature map carried onto the mesh. IMAGE or MASK."), io.String.Input("field_name", default="field", tooltip="Name for the vertex attribute", optional=True), io.DynamicCombo.Input("backend", tooltip="Reconstruction backend: grid (fast), poisson (smooth), delaunay", options=[ io.DynamicCombo.Option("grid", [ @@ -71,7 +72,7 @@ class TextureToGeometryNode(io.ComfyNode): @classmethod def execute(cls, height_scale, - mask=None, depth_image=None, + depth=None, field=None, field_name="field", backend=None, poisson_depth=8, invert_height="false", @@ -93,9 +94,8 @@ class TextureToGeometryNode(io.ComfyNode): Returns: tuple: (mesh, info_string) """ - # Validate that at least one input is provided - if mask is None and depth_image is None: - raise ValueError("Either 'mask' or 'depth_image' must be provided") + if depth is None: + raise ValueError("'depth' input is required (an IMAGE or a MASK)") # Extract DynamicCombo values if backend is None: @@ -106,30 +106,25 @@ class TextureToGeometryNode(io.ComfyNode): log.info("Converting to geometry using backend: %s", selected_backend) - # Use depth_image if provided, otherwise use mask - if depth_image is not None: - log.info("Using depth_image input (averaging RGB channels)") - img_arr = _to_numpy(depth_image) - if img_arr.ndim == 4: - img_arr = img_arr[0] - - # Average RGB channels to create grayscale - if len(img_arr.shape) == 3 and img_arr.shape[2] >= 3: - heightmap = np.mean(img_arr[:, :, :3], axis=2) - elif len(img_arr.shape) == 3 and img_arr.shape[2] == 1: - heightmap = img_arr[:, :, 0] - else: - heightmap = img_arr + # The 'depth' input may be an IMAGE (B,H,W,C) or a MASK (B,H,W) -> grayscale heightmap. + arr = _to_numpy(depth) + if arr.ndim == 4: # IMAGE batch (B,H,W,C) + img = arr[0] + heightmap = np.mean(img[:, :, :3], axis=2) if img.shape[2] >= 3 else img[:, :, 0] + log.info("depth: IMAGE input (RGB averaged to grayscale)") + elif arr.ndim == 3: # could be MASK batch (B,H,W) or single image (H,W,C) + if arr.shape[2] in (3, 4): # (H,W,C) RGB(A) image + heightmap = np.mean(arr[:, :, :3], axis=2) + elif arr.shape[2] == 1: # (H,W,1) + heightmap = arr[:, :, 0] + else: # (B,H,W) mask -> first + heightmap = arr[0] + log.info("depth: 3D input -> heightmap %s", heightmap.shape) + elif arr.ndim == 2: # (H,W) + heightmap = arr + log.info("depth: 2D input") else: - log.info("Using mask input") - # Extract mask from ComfyUI tensor format (B, H, W) - heightmap = _to_numpy(mask) - if heightmap.ndim == 3: - heightmap = heightmap[0] - - # Ensure 2D array (masks are single-channel) - if len(heightmap.shape) > 2: - heightmap = heightmap[:, :, 0] if heightmap.shape[2] == 1 else np.mean(heightmap, axis=2) + raise ValueError(f"Unexpected 'depth' shape {arr.shape}; expected IMAGE or MASK") # Use native resolution height, width = heightmap.shape @@ -265,16 +260,19 @@ Output Mesh: mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) - # Add field as vertex attribute if provided + # Add field as vertex attribute if provided (IMAGE or MASK -> (H,W) scalar) if field is not None: field_arr = _to_numpy(field) + if field_arr.ndim == 4: # IMAGE (B,H,W,C) + field_arr = field_arr[0] if field_arr.ndim == 3: - field_arr = field_arr[0] - elif field_arr.ndim == 4: - field_arr = field_arr[0] - - # Handle different shapes - if len(field_arr.shape) > 2: + if field_arr.shape[2] in (3, 4): # (H,W,C) -> grayscale + field_arr = np.mean(field_arr[:, :, :3], axis=2) + elif field_arr.shape[2] == 1: + field_arr = field_arr[:, :, 0] + else: # (B,H,W) mask + field_arr = field_arr[0] + if field_arr.ndim > 2: field_arr = field_arr.squeeze() # Check resolution matches diff --git a/nodes/visualization/preview_mesh_dual.py b/nodes/visualization/preview_mesh_dual.py index 0d10d612..21814b53 100644 --- a/nodes/visualization/preview_mesh_dual.py +++ b/nodes/visualization/preview_mesh_dual.py @@ -51,6 +51,30 @@ def has_fields(mesh): return has_vertex_attrs or has_face_attrs +def with_normal_fields(mesh): + """Return a copy of the mesh with 'vertex_normals' (point) and 'face_normals' + (cell) attached as 3-component fields, so the viewer can colour by them and + pick the X/Y/Z channel. No-op for point clouds / on failure. Idempotent.""" + try: + if is_point_cloud(mesh) or not hasattr(mesh, "faces"): + return mesh + m = mesh.copy() + va = m.vertex_attributes if m.vertex_attributes is not None else {} + fa = m.face_attributes if m.face_attributes is not None else {} + if "vertex_normals" not in va: + vn = np.asarray(m.vertex_normals) + if vn.shape == (len(m.vertices), 3): + m.vertex_attributes["vertex_normals"] = vn.astype(np.float32) + if "face_normals" not in fa: + fn = np.asarray(m.face_normals) + if fn.shape == (len(m.faces), 3): + m.face_attributes["face_normals"] = fn.astype(np.float32) + return m + except Exception as e: + log.warning("normal-field attach skipped: %s", e) + return mesh + + def get_texture_info(mesh): """Extract texture/visual information from a mesh.""" has_visual = hasattr(mesh, 'visual') and mesh.visual is not None @@ -113,6 +137,15 @@ class PreviewMeshDualNode(io.ComfyNode): log.info("Mesh 1: %s - %d vertices, %d faces", get_geometry_type(mesh_1), len(mesh_1.vertices), get_face_count(mesh_1)) log.info("Mesh 2: %s - %d vertices, %d faces", get_geometry_type(mesh_2), len(mesh_2.vertices), get_face_count(mesh_2)) + # Expose face + vertex normals as selectable fields ONLY when the mesh + # already carries fields (so it was going to export as VTP anyway). Forcing + # it for plain meshes flipped STL->VTP and broke loading, so don't. + if mode != "texture": + if has_fields(mesh_1): + mesh_1 = with_normal_fields(mesh_1) + if has_fields(mesh_2): + mesh_2 = with_normal_fields(mesh_2) + # Check for field data mesh_1_has_fields = has_fields(mesh_1) mesh_2_has_fields = has_fields(mesh_2) diff --git a/nodes/visualization/preview_mesh_multi.py b/nodes/visualization/preview_mesh_multi.py index 07f97009..75352a8b 100644 --- a/nodes/visualization/preview_mesh_multi.py +++ b/nodes/visualization/preview_mesh_multi.py @@ -133,6 +133,7 @@ class PreviewMeshMultiNode(io.ComfyNode): bounds_list = [] extents_list = [] is_watertight_list = [] + avg_edge_lengths = [] field_names_list = [] texture_info_list = [] @@ -179,6 +180,15 @@ class PreviewMeshMultiNode(io.ComfyNode): extents_list.append(extents.tolist()) is_watertight_list.append(bool(mesh.is_watertight) if not mesh_is_pc else False) + # average edge length (mesh resolution); None for point clouds / empty meshes + avg_edge = None + if not mesh_is_pc: + try: + if get_face_count(mesh) > 0: + avg_edge = float(np.mean(mesh.edges_unique_length)) + except Exception as e: + log.info("Mesh %d: could not compute avg edge length: %s", i + 1, e) + avg_edge_lengths.append(avg_edge) field_names_list.append(extract_field_names(mesh)) texture_info_list.append(texture_info) @@ -204,6 +214,7 @@ class PreviewMeshMultiNode(io.ComfyNode): "bounds_list": [bounds_list], "extents_list": [extents_list], "is_watertight_list": [is_watertight_list], + "avg_edge_lengths": [avg_edge_lengths], } # Add mode-specific metadata diff --git a/nodes/visualization/preview_mesh_vtk.py b/nodes/visualization/preview_mesh_vtk.py index ad72608d..88de030a 100644 --- a/nodes/visualization/preview_mesh_vtk.py +++ b/nodes/visualization/preview_mesh_vtk.py @@ -202,6 +202,15 @@ class PreviewMeshVTKNode(io.ComfyNode): except Exception as e: log.info("Could not calculate volume/area: %s", e) + # Average edge length (mesh resolution) - skip point clouds / empty meshes + avg_edge_length = None + if not is_point_cloud(trimesh): + try: + if get_face_count(trimesh) > 0: + avg_edge_length = float(np.mean(trimesh.edges_unique_length)) + except Exception as e: + log.info("Could not calculate avg edge length: %s", e) + # Get field names (vertex/face data arrays) - for field visualization UI field_names = [] if has_vertex_attrs: @@ -211,6 +220,49 @@ class PreviewMeshVTKNode(io.ComfyNode): field_names.extend([f"face.{k}" for k in trimesh.face_attributes.keys()]) log.info("Face attributes: %s", list(trimesh.face_attributes.keys())) + # Per-field stats (parallel to field_names): [min,max] range + the 3D position of the + # element holding the min / max value, so the UI can click a range and fly the camera + # there. Vector fields are ranked by magnitude. Ties are broken at random. + import random as _random + + def _elem_positions(kind): + if kind == "vertex": + return np.asarray(trimesh.vertices, dtype=float) + try: + return np.asarray(trimesh.triangles_center, dtype=float) # face centroids + except Exception: + return None + + def _field_stats(a, positions): + try: + a = np.asarray(a) + if a.size == 0 or not np.issubdtype(a.dtype, np.number) or positions is None: + return None, None, None + s = a.astype(float) if a.ndim == 1 else np.linalg.norm( + a.reshape(a.shape[0], -1).astype(float), axis=1) + if len(s) != len(positions): + return None, None, None + lo, hi = float(np.nanmin(s)), float(np.nanmax(s)) + lo_idx = np.flatnonzero(s == lo) + hi_idx = np.flatnonzero(s == hi) + li = int(_random.choice(lo_idx)) if lo_idx.size else 0 + hi_i = int(_random.choice(hi_idx)) if hi_idx.size else 0 + return [lo, hi], positions[li].tolist(), positions[hi_i].tolist() + except Exception: + return None, None, None + + field_ranges, field_min_pos, field_max_pos = [], [], [] + vpos = _elem_positions("vertex") if has_vertex_attrs else None + fpos = _elem_positions("face") if has_face_attrs else None + if has_vertex_attrs: + for v in trimesh.vertex_attributes.values(): + rng, lp, hp = _field_stats(v, vpos) + field_ranges.append(rng); field_min_pos.append(lp); field_max_pos.append(hp) + if has_face_attrs: + for v in trimesh.face_attributes.values(): + rng, lp, hp = _field_stats(v, fpos) + field_ranges.append(rng); field_min_pos.append(lp); field_max_pos.append(hp) + # Return metadata for frontend widget ui_data = { "mesh_file": [filename], @@ -237,12 +289,17 @@ class PreviewMeshVTKNode(io.ComfyNode): else: # Fields mode metadata ui_data["field_names"] = [field_names] # Field visualization data + ui_data["field_ranges"] = [field_ranges] # [min, max] per field (parallel to field_names) + ui_data["field_min_pos"] = [field_min_pos] # 3D pos of min element (click-to-focus) + ui_data["field_max_pos"] = [field_max_pos] # 3D pos of max element (click-to-focus) # Add optional fields if available if volume is not None: ui_data["volume"] = [volume] if area is not None: ui_data["area"] = [area] + if avg_edge_length is not None: + ui_data["avg_edge_length"] = [avg_edge_length] if viewer_type == "pbr": log.info("PBR mode info: watertight=%s, volume=%s, area=%s, texture=%s, vertex_colors=%s", is_watertight, volume, area, has_texture, has_vertex_colors) diff --git a/prestartup_script.py b/prestartup_script.py index f6517881..725d6c70 100644 --- a/prestartup_script.py +++ b/prestartup_script.py @@ -18,7 +18,7 @@ COMFYUI_DIR = SCRIPT_DIR.parent.parent # Copy viewers (GeometryPack uses many viewer types) viewers = [ "viewer", "vtk", "vtk_batch", "vtk_textured", "pointcloud_vtk", - "multi", "dual", "dual_slider", "dual_textured", + "multi", "multi_slider", "dual", "dual_slider", "dual_textured", "uv", "pbr", "gaussian", "fbx", "fbx_debug", "fbx_compare", "bvh", "fbx_animation", "compare_smpl_bvh",