field interpolation added
This commit is contained in:
@@ -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
@@ -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]})
|
||||
|
||||
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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."),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"}
|
||||
@@ -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."),
|
||||
],
|
||||
|
||||
@@ -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). "
|
||||
|
||||
@@ -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."),
|
||||
]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user