Compute Curvature: automatic resolution clamp (cap sub-triangle curvature)

Curvature finer than the LOCAL triangles is the quadric fit blowing up at a sharp
edge / sliver, not real geometry (sampling theory: Vasa et al. CGF 2016 need
kappa*edge small; Amenta-Bern epsilon-sampling / local feature size: radius 1/kappa
must exceed local sample spacing). New clamp_to_resolution (ON by default) caps
|kappa| per vertex so the radius of curvature can't be finer than min_radius_edges
x the LOCAL edge length, applied to the principal curvatures before deriving every
field -- so the 0.37-vs-150 artifact spikes are removed automatically. clamp_percentile
remains as a secondary statistical clip. Verified: a box's sharp-edge spike caps at
1/local_edge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
PozzettiAndrea
2026-06-17 02:32:10 +02:00
co-authored by Claude Opus 4.8
parent 4f890b7dec
commit fcacd4e807
+45 -3
View File
@@ -80,9 +80,21 @@ class ComputeCurvatureNode(io.ComfyNode):
io.Int.Input("smoothing_iterations", default=0, min=0, max=50, step=1, tooltip=(
"Explicit cotangent-Laplacian diffusion of the curvature fields. 0 = off. "
"A few passes denoise the field without re-fitting.")),
io.Combo.Input("clamp_to_resolution", options=["true", "false"], default="true", tooltip=(
"AUTOMATICALLY cap curvature the mesh can't actually represent. A fillet "
"finer than the local triangles is just the fit blowing up at a sharp edge "
"/ sliver -- this caps |kappa| per vertex so the radius can't be smaller "
"than min_radius_edges x the LOCAL edge length, killing those spikes. "
"On by default (sampling theory: curvature is only valid where kappa*edge "
"is small).")),
io.Float.Input("min_radius_edges", default=1.0, min=0.25, max=8.0, step=0.25, tooltip=(
"Resolution clamp strength: smallest trustworthy radius of curvature, in "
"units of the LOCAL edge length. 1.0 = cap where radius == local edge "
"(remove pure sub-triangle artifacts); 2-3 = stricter (only keep fillets "
"spanning several triangles). Only used when clamp_to_resolution is on.")),
io.Float.Input("clamp_percentile", default=1.0, min=0.0, max=20.0, step=0.5, tooltip=(
"Clip each field to its [p, 100-p] percentile range so a handful of sliver "
"triangles don't blow out the dynamic range. 0 = off. 1.0 is a safe default.")),
"Additionally clip each field to its [p, 100-p] percentile range so a few "
"remaining outliers don't blow out the dynamic range. 0 = off. 1.0 safe.")),
io.Combo.Input("preclean", options=["true", "false"], default="true", tooltip=(
"Merge duplicate vertices, drop degenerate faces, and fix normals before "
"estimating (recommended: degenerate triangles are the main failure mode, "
@@ -106,7 +118,8 @@ class ComputeCurvatureNode(io.ComfyNode):
@classmethod
def execute(cls, trimesh, method="quadric_fit", primary_output="mean",
radius=5, smoothing_iterations=0, clamp_percentile=1.0, preclean="true"):
radius=5, smoothing_iterations=0, clamp_to_resolution="true",
min_radius_edges=1.0, clamp_percentile=1.0, preclean="true"):
import igl
import scipy.sparse as sp
@@ -156,6 +169,35 @@ class ComputeCurvatureNode(io.ComfyNode):
k1 = mean + root
k2 = mean - root
# --- automatic resolution clamp -------------------------------------------
# A fillet finer than the LOCAL triangles cannot be real geometry -- it's the
# quadric fit blowing up at a sharp edge / sliver. Sampling theory says
# curvature is only trustworthy where kappa*edge is small (Vasa et al. CGF
# 2016: eps <= 1/(16*K*rho^2)) -- equivalently the radius 1/kappa must exceed
# the local sample spacing (Amenta-Bern epsilon-sampling / local feature size).
# So cap |kappa| per vertex so the radius can't be finer than
# `min_radius_edges` x the LOCAL edge length. Kills the spurious spikes
# automatically; every derived field inherits the cap.
if clamp_to_resolution == "true" and len(F):
ev = np.asarray(mesh.edges_unique)
el = np.asarray(mesh.edges_unique_length, dtype=np.float64)
loc = np.zeros(n, dtype=np.float64)
cnt = np.zeros(n, dtype=np.float64)
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) # per-vertex local edge length
loc[loc <= 0] = (float(np.mean(el)) if len(el) else 1.0)
kcap = 1.0 / (max(1e-6, float(min_radius_edges)) * loc) # max |kappa| per vertex
k1 = np.clip(k1, -kcap, kcap)
k2 = np.clip(k2, -kcap, kcap)
# Recompute dependents from the (possibly clamped) principals so everything is
# consistent (a no-op when nothing was clamped).
mean = 0.5 * (k1 + k2)
gaussian = k1 * k2
# abs_max = MAGNITUDE of the strongest principal curvature (>= 0): ~0 on a
# flat, ~1/r on a fillet of radius r -- the field to threshold for
# flat-vs-curved, independent of concave/convex. `dominant` keeps the sign