field interpolation added

This commit is contained in:
PozzettiAndrea
2026-06-18 14:16:25 +02:00
parent 3c66b3649f
commit 4bfd51cd65
16 changed files with 704 additions and 184 deletions
+3
View File
@@ -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)
+148 -106
View File
@@ -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 <path-to>/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]})
+1 -1
View File
@@ -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=[
+1 -1
View File
@@ -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."),
]),
]),
+21
View File
@@ -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"]
+132
View File
@@ -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"}
+230
View File
@@ -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.<name>' (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"}
@@ -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."),
],
+1 -1
View File
@@ -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). "
+1 -1
View File
@@ -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."),
]),
+28 -35
View File
@@ -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)
+35 -37
View File
@@ -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
+33
View File
@@ -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)
+11
View File
@@ -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
+57
View File
@@ -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)
+1 -1
View File
@@ -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",