Add Region Growing node + Decrease-Gaussian sharpen backend
Region Growing (analysis): seed faces flattest-first and grow regions by a tunable similarity metric -- criterion dropdown (normals / normals+area / curvature+normals / curvature) with per-term weights and a threshold. Greedy flood-fill against the region's running area-weighted representative; small regions merged into their strongest neighbour. Outputs per-face region_id. On 101 (CAD): by normals it recovers the planar structure (top region ~79k faces, ~70% of faces in large regions) in ~6s. Decrease Gaussian (sharpen backend): gradient flow on the Gaussian-curvature energy sum K_i^2 (K_i = vertex angle defect) + Tikhonov anchor, so |K| is driven toward zero -> locally developable (planes/cylinders/cones). Exact analytic angle-defect gradient (verified vs finite differences to 1e-10); every step passes through the foldless signed-area barrier. Verified: monotone decrease of mean|K|, total K conserved (Gauss-Bonnet, 2*pi*chi), watertight/foldless/volume-stable. Wired into Sharpen Mesh. (Includes segment_patches.py, previously-untracked, so the analysis __init__ imports resolve on a clean checkout.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
75ec0b3889
commit
b49f4e08b5
@@ -27,6 +27,10 @@ from .compute_fillet_field import NODE_CLASS_MAPPINGS as FILLET_FIELD_MAPPINGS
|
||||
from .compute_fillet_field import NODE_DISPLAY_NAME_MAPPINGS as FILLET_FIELD_DISPLAY
|
||||
from .segment_by_curvature import NODE_CLASS_MAPPINGS as SEGMENT_CURVATURE_MAPPINGS
|
||||
from .segment_by_curvature import NODE_DISPLAY_NAME_MAPPINGS as SEGMENT_CURVATURE_DISPLAY
|
||||
from .segment_patches import NODE_CLASS_MAPPINGS as SEGMENT_PATCHES_MAPPINGS
|
||||
from .segment_patches import NODE_DISPLAY_NAME_MAPPINGS as SEGMENT_PATCHES_DISPLAY
|
||||
from .region_growing import NODE_CLASS_MAPPINGS as REGION_GROWING_MAPPINGS
|
||||
from .region_growing import NODE_DISPLAY_NAME_MAPPINGS as REGION_GROWING_DISPLAY
|
||||
|
||||
# Combine all mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -42,6 +46,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
**COMPUTE_CURVATURE_MAPPINGS,
|
||||
**FILLET_FIELD_MAPPINGS,
|
||||
**SEGMENT_CURVATURE_MAPPINGS,
|
||||
**SEGMENT_PATCHES_MAPPINGS,
|
||||
**REGION_GROWING_MAPPINGS,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -57,6 +63,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**COMPUTE_CURVATURE_DISPLAY,
|
||||
**FILLET_FIELD_DISPLAY,
|
||||
**SEGMENT_CURVATURE_DISPLAY,
|
||||
**SEGMENT_PATCHES_DISPLAY,
|
||||
**REGION_GROWING_DISPLAY,
|
||||
}
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
|
||||
|
||||
"""Region growing segmentation: seed faces and grow regions by SIMILARITY.
|
||||
|
||||
Unlike segment_patches (which cuts at sharp dihedral edges), this clusters faces
|
||||
by a tunable similarity metric -- a face joins a region while it stays close to
|
||||
the region's running representative in:
|
||||
- normal direction (group co-planar / smoothly-varying faces)
|
||||
- + face area weighting (large faces dominate the representative -> robust
|
||||
region normals, the VSA-style area-weighted L2,1 metric)
|
||||
- + curvature (also require similar mean curvature -> separate a
|
||||
flat from a fillet even when their normals agree at
|
||||
the seam)
|
||||
Seeds are taken flattest-first (lowest curvature), so big planar regions form
|
||||
first and creases/fillets fall out as their own regions. Each component term has
|
||||
its own weight. Outputs per-face `region_id` (1..R) for colouring / Split By Field
|
||||
/ per-region primitive fitting.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
import trimesh as trimesh_module
|
||||
from comfy_api.latest import io
|
||||
|
||||
log = logging.getLogger("geometrypack")
|
||||
|
||||
_CRITERIA = {
|
||||
"normals": (True, False, False),
|
||||
"normals + area": (True, False, True),
|
||||
"curvature + normals": (True, True, False),
|
||||
"curvature": (False, True, False),
|
||||
}
|
||||
|
||||
|
||||
def _face_mean_curvature(V, F):
|
||||
"""Per-face |mean curvature|: vertex mean-curvature (cotan Laplacian) averaged
|
||||
to faces. Robust, cheap, and only used for seed ordering + the curvature term."""
|
||||
import igl
|
||||
L = igl.cotmatrix(V, F)
|
||||
M = igl.massmatrix(V, F, igl.MASSMATRIX_TYPE_BARYCENTRIC)
|
||||
Minv = 1.0 / np.clip(np.asarray(M.diagonal()), 1e-12, None)
|
||||
Hv = 0.5 * np.linalg.norm(Minv[:, None] * (L @ V), axis=1) # per-vertex |H|
|
||||
return Hv[F].mean(axis=1) # per-face |H|
|
||||
|
||||
|
||||
def _build_face_neighbors_csr(adj_pairs, m):
|
||||
"""CSR (indptr, indices) face-adjacency from the shared-edge face pairs."""
|
||||
a = adj_pairs[:, 0]
|
||||
b = adj_pairs[:, 1]
|
||||
src = np.concatenate([a, b])
|
||||
dst = np.concatenate([b, a])
|
||||
order = np.argsort(src, kind="stable")
|
||||
src_s = src[order]
|
||||
dst_s = dst[order]
|
||||
indptr = np.zeros(m + 1, dtype=np.int64)
|
||||
np.add.at(indptr, src_s + 1, 1)
|
||||
np.cumsum(indptr, out=indptr)
|
||||
return indptr, dst_s.astype(np.int64)
|
||||
|
||||
|
||||
def _region_grow(N, areas, Hface, indptr, indices, use_normal, use_curv, use_area,
|
||||
w_normal, w_curv, threshold):
|
||||
"""Greedy flood-fill region growing. A face joins a region while its weighted
|
||||
dissimilarity to the region's running (area-weighted) representative stays
|
||||
below `threshold`. Seeds flattest-first. Returns per-face region id (1..R)."""
|
||||
m = len(N)
|
||||
cscale = float(Hface.std()) + 1e-9
|
||||
order = np.argsort(Hface, kind="stable") # flattest faces seed first
|
||||
labels = np.full(m, -1, dtype=np.int64)
|
||||
rid = 0
|
||||
for seed in order:
|
||||
if labels[seed] >= 0:
|
||||
continue
|
||||
rid += 1
|
||||
labels[seed] = rid
|
||||
w0 = float(areas[seed]) if use_area else 1.0
|
||||
n_acc = N[seed] * w0 # running (weighted) normal sum
|
||||
c_acc = float(Hface[seed]) * w0 # running weighted curvature sum
|
||||
w_sum = w0
|
||||
q = deque((seed,))
|
||||
while q:
|
||||
g = q.popleft()
|
||||
n_rep = n_acc / (np.linalg.norm(n_acc) + 1e-12)
|
||||
c_rep = c_acc / w_sum
|
||||
for idx in range(indptr[g], indptr[g + 1]):
|
||||
h = indices[idx]
|
||||
if labels[h] >= 0:
|
||||
continue
|
||||
d = 0.0
|
||||
if use_normal:
|
||||
d += w_normal * (1.0 - float(np.dot(N[h], n_rep)))
|
||||
if use_curv:
|
||||
d += w_curv * abs(float(Hface[h]) - c_rep) / cscale
|
||||
if d <= threshold:
|
||||
labels[h] = rid
|
||||
wh = float(areas[h]) if use_area else 1.0
|
||||
n_acc = n_acc + N[h] * wh
|
||||
c_acc += float(Hface[h]) * wh
|
||||
w_sum += wh
|
||||
q.append(h)
|
||||
return labels
|
||||
|
||||
|
||||
def _merge_small(labels, indptr, indices, areas, min_faces):
|
||||
"""Merge regions smaller than min_faces into the adjacent region they share the
|
||||
most faces with. Iterates to a fixed point; relabels 1..R contiguously."""
|
||||
if min_faces <= 1:
|
||||
uniq, inv = np.unique(labels, return_inverse=True)
|
||||
return (inv + 1).astype(np.int64)
|
||||
labels = labels.copy()
|
||||
for _ in range(1000):
|
||||
uniq, inv = np.unique(labels, return_inverse=True)
|
||||
labels = inv.astype(np.int64)
|
||||
counts = np.bincount(labels)
|
||||
small = np.where(counts < min_faces)[0]
|
||||
if len(small) == 0:
|
||||
break
|
||||
small_set = set(small.tolist())
|
||||
changed = False
|
||||
for r in small:
|
||||
members = np.where(labels == r)[0]
|
||||
# tally neighbour-region face counts
|
||||
nbr = {}
|
||||
for g in members:
|
||||
for idx in range(indptr[g], indptr[g + 1]):
|
||||
rl = labels[indices[idx]]
|
||||
if rl != r:
|
||||
nbr[rl] = nbr.get(rl, 0) + 1
|
||||
if nbr:
|
||||
best = max(nbr, key=nbr.get)
|
||||
labels[members] = best
|
||||
changed = True
|
||||
if not changed:
|
||||
break
|
||||
uniq, inv = np.unique(labels, return_inverse=True)
|
||||
return (inv + 1).astype(np.int64)
|
||||
|
||||
|
||||
class RegionGrowingNode(io.ComfyNode):
|
||||
"""Seed + grow regions, clustering faces by a tunable similarity metric."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="GeomPackRegionGrowing",
|
||||
display_name="Region Growing",
|
||||
category="geompack/analysis",
|
||||
description=(
|
||||
"Segment a mesh by SEEDING faces (flattest first) and GROWING regions: a "
|
||||
"face joins while it stays similar to the region's running representative. "
|
||||
"Choose what 'similar' means in `criterion` (normals / normals+area / "
|
||||
"curvature+normals / curvature) and tune each component's weight + the "
|
||||
"`threshold` (lower = more, smaller regions). Outputs per-face region_id "
|
||||
"(1..R) -- colour it, Split By Field, or fit a primitive per region."
|
||||
),
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("TRIMESH").Input("trimesh"),
|
||||
io.Combo.Input("criterion",
|
||||
options=list(_CRITERIA.keys()), default="curvature + normals",
|
||||
tooltip=(
|
||||
"What makes two faces belong to the same region:\n"
|
||||
" normals = co-planar / smoothly-varying normals only;\n"
|
||||
" normals + area = same, but big faces dominate the region normal "
|
||||
"(area-weighted, robust on uneven tessellation);\n"
|
||||
" curvature + normals = also require similar mean curvature (separates a "
|
||||
"flat from a fillet that meet tangentially);\n"
|
||||
" curvature = group by curvature magnitude alone.")),
|
||||
io.Float.Input("threshold", default=0.05, min=0.0005, max=2.0, step=0.005, display_mode="number", tooltip=(
|
||||
"Similarity tolerance to ADD a face to the growing region. LOWER = stricter "
|
||||
"= more, smaller regions; HIGHER = looser = fewer, larger regions. The "
|
||||
"normal term is 1-cos(angle) (0.015~10deg, 0.06~20deg, 0.13~30deg); the "
|
||||
"curvature term is in units of the mesh's curvature std. Default 0.05.")),
|
||||
io.Float.Input("normal_weight", default=1.0, min=0.0, max=20.0, step=0.1, tooltip=(
|
||||
"Weight of the NORMAL-difference term (1-cos angle between the face normal "
|
||||
"and the region's representative normal). Higher = normals matter more. "
|
||||
"Ignored unless the criterion includes normals.")),
|
||||
io.Float.Input("curvature_weight", default=1.0, min=0.0, max=20.0, step=0.1, tooltip=(
|
||||
"Weight of the CURVATURE-difference term (|H_face - H_region| / curvature "
|
||||
"std). Higher = curvature matters more, so a fillet won't merge into the "
|
||||
"abutting flat. Ignored unless the criterion includes curvature.")),
|
||||
io.Int.Input("min_region_size", default=10, min=1, max=1000000, step=1, tooltip=(
|
||||
"Regions with fewer faces than this are merged into the neighbour they "
|
||||
"share the most boundary with (de-speckle). 1 = keep every region.")),
|
||||
],
|
||||
outputs=[
|
||||
io.Custom("TRIMESH").Output(display_name="segmented_mesh"),
|
||||
io.String.Output(display_name="info"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, trimesh, criterion="curvature + normals", threshold=0.05,
|
||||
normal_weight=1.0, curvature_weight=1.0, min_region_size=10):
|
||||
mesh = trimesh.copy()
|
||||
try:
|
||||
mesh.merge_vertices()
|
||||
mesh.update_faces(mesh.nondegenerate_faces())
|
||||
mesh.remove_unreferenced_vertices()
|
||||
except Exception as e:
|
||||
log.debug("preclean skipped: %s", e)
|
||||
|
||||
V = np.ascontiguousarray(mesh.vertices, dtype=np.float64)
|
||||
F = np.ascontiguousarray(mesh.faces, dtype=np.int64)
|
||||
m = len(F)
|
||||
use_normal, use_curv, use_area = _CRITERIA[criterion]
|
||||
|
||||
N = np.asarray(mesh.face_normals, dtype=np.float64)
|
||||
areas = np.asarray(mesh.area_faces, dtype=np.float64)
|
||||
Hface = _face_mean_curvature(V, F) # also used for seed ordering
|
||||
|
||||
adj = np.asarray(mesh.face_adjacency)
|
||||
if len(adj) == 0:
|
||||
raise ValueError("Mesh has no face adjacency (disconnected or degenerate).")
|
||||
indptr, indices = _build_face_neighbors_csr(adj, m)
|
||||
|
||||
labels = _region_grow(N, areas, Hface, indptr, indices,
|
||||
use_normal, use_curv, use_area,
|
||||
float(normal_weight), float(curvature_weight), float(threshold))
|
||||
labels = _merge_small(labels, indptr, indices, areas, int(min_region_size))
|
||||
n_reg = int(labels.max())
|
||||
|
||||
mesh.face_attributes["region_id"] = labels.astype(np.int64)
|
||||
# also a per-vertex copy (majority face label) for vertex-field consumers
|
||||
vlab = np.zeros(len(V), dtype=np.float32)
|
||||
cnt = np.zeros(len(V))
|
||||
for k in range(3):
|
||||
np.add.at(vlab, F[:, k], labels)
|
||||
np.add.at(cnt, F[:, k], 1.0)
|
||||
mesh.vertex_attributes["region_id"] = (vlab / np.maximum(cnt, 1.0)).astype(np.float32)
|
||||
|
||||
sizes = np.bincount(labels)[1:]
|
||||
info = (
|
||||
f"Region Growing:\n\n"
|
||||
f"Faces: {m:,} | regions: {n_reg:,}\n"
|
||||
f"criterion: {criterion} (n_w={normal_weight}, c_w={curvature_weight})\n"
|
||||
f"threshold: {threshold} | min_region_size: {min_region_size}\n\n"
|
||||
f"region face-count: min {int(sizes.min())}, median {int(np.median(sizes))}, "
|
||||
f"max {int(sizes.max())}\n"
|
||||
f"Field: face_attributes['region_id'] (1..{n_reg})"
|
||||
)
|
||||
log.info("Region Growing: %d regions (%s, thr=%.3f)", n_reg, criterion, threshold)
|
||||
return io.NodeOutput(mesh, info, ui={"text": [info]})
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"GeomPackRegionGrowing": RegionGrowingNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackRegionGrowing": "Region Growing"}
|
||||
@@ -0,0 +1,192 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
|
||||
|
||||
"""Segment a mesh into CADable patches and OUTPUT them (mesh + per-face region_id).
|
||||
|
||||
Pipeline:
|
||||
1. feature edges = dihedral angle >= crease_angle_deg (optionally broadened over
|
||||
`wide_rings` so chamfers / fine fillets read as one turn, not many tiny ones).
|
||||
2. flood-fill faces into regions, cutting at those feature edges.
|
||||
3. CLEAN UP: iteratively merge each region smaller than min_patch_faces (or
|
||||
min_patch_area_pct of the surface) into the neighbour it shares the most
|
||||
boundary with. This is what turns a speckled segmentation into a usable set
|
||||
of patches.
|
||||
|
||||
Outputs the mesh with face_attributes['region_id'] (1..R) so you can colour it in
|
||||
Preview Mesh (fields), split it with Split By Field, or fit primitives per patch.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import trimesh as trimesh_module
|
||||
from comfy_api.latest import io
|
||||
|
||||
from ..visualization.preview_mesh_boundaries import _edge_values, _face_regions
|
||||
|
||||
log = logging.getLogger("geometrypack")
|
||||
|
||||
|
||||
def _merge_small_regions(region_id, adj_pairs, wall_mask, face_areas, min_faces, min_area):
|
||||
"""Merge regions below the size thresholds into their strongest neighbour
|
||||
(most shared boundary edges, ties broken by neighbour size). Iterates to a
|
||||
fixed point. Returns cleaned, contiguously-relabelled region_id (1..R)."""
|
||||
from collections import defaultdict
|
||||
|
||||
labels = np.asarray(region_id, dtype=np.int64).copy()
|
||||
adj_pairs = np.asarray(adj_pairs).reshape(-1, 2)
|
||||
wall = np.asarray(wall_mask, dtype=bool)
|
||||
walls = adj_pairs[wall] if len(adj_pairs) else np.zeros((0, 2), np.int64)
|
||||
areas = np.asarray(face_areas, dtype=np.float64)
|
||||
|
||||
def relabel(lbl):
|
||||
uniq, inv = np.unique(lbl, return_inverse=True)
|
||||
return (inv + 1).astype(np.int64)
|
||||
|
||||
for _ in range(10000): # converges fast (region count strictly drops per pass)
|
||||
labels = relabel(labels)
|
||||
n = int(labels.max()) if len(labels) else 0
|
||||
fcount = np.bincount(labels, minlength=n + 1)
|
||||
acount = np.bincount(labels, weights=areas, minlength=n + 1)
|
||||
|
||||
small = [r for r in range(1, n + 1)
|
||||
if fcount[r] > 0 and (fcount[r] < min_faces or acount[r] < min_area)]
|
||||
if not small:
|
||||
break
|
||||
|
||||
# region-adjacency strength from the wall edges (boundary between regions)
|
||||
rl = labels[walls[:, 0]] if len(walls) else np.zeros(0, np.int64)
|
||||
rr = labels[walls[:, 1]] if len(walls) else np.zeros(0, np.int64)
|
||||
m = rl != rr
|
||||
nbr = defaultdict(lambda: defaultdict(int))
|
||||
for a, b in zip(rl[m].tolist(), rr[m].tolist()):
|
||||
nbr[a][b] += 1
|
||||
nbr[b][a] += 1
|
||||
|
||||
# union-find merge of every small region into its strongest neighbour
|
||||
parent = list(range(n + 1))
|
||||
|
||||
def find(x):
|
||||
r = x
|
||||
while parent[r] != r:
|
||||
r = parent[r]
|
||||
while parent[x] != r:
|
||||
parent[x], x = r, parent[x]
|
||||
return r
|
||||
|
||||
merged = False
|
||||
for r in sorted(small, key=lambda r: fcount[r]):
|
||||
if not nbr[r]:
|
||||
continue # isolated region with no neighbour: leave it
|
||||
target = max(nbr[r].items(), key=lambda kv: (kv[1], fcount[kv[0]]))[0]
|
||||
ra, rt = find(r), find(target)
|
||||
if ra != rt:
|
||||
parent[ra] = rt
|
||||
merged = True
|
||||
if not merged:
|
||||
break
|
||||
labels = np.array([find(int(l)) for l in labels], dtype=np.int64)
|
||||
|
||||
return relabel(labels)
|
||||
|
||||
|
||||
class SegmentPatchesNode(io.ComfyNode):
|
||||
"""Flood-fill a mesh into CADable patches, merge speckle, output region_id."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="GeomPackSegmentPatches",
|
||||
display_name="Segment Mesh into Patches",
|
||||
category="geompack/analysis",
|
||||
description=(
|
||||
"Segment a mesh into CADable surface patches and output them as a per-face "
|
||||
"region_id field (1..R). Feature edges = dihedral >= crease_angle_deg cut the "
|
||||
"mesh; faces flood-fill into patches; then small/speckle patches are merged into "
|
||||
"their strongest neighbour so you get a clean set instead of fragments.\n"
|
||||
"\n"
|
||||
"wide_rings broadens the normals first (chamfers / fine fillets become one patch "
|
||||
"border instead of many tiny ones). min_patch_faces / min_patch_area_pct control "
|
||||
"how aggressively speckle is absorbed.\n"
|
||||
"\n"
|
||||
"Color the output in Preview Mesh (fields -> region_id), split it per-patch with "
|
||||
"Split By Field, or feed it to primitive fitting."
|
||||
),
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("TRIMESH").Input("trimesh"),
|
||||
io.Float.Input("crease_angle_deg", default=30.0, min=0.0, max=180.0, step=1.0,
|
||||
tooltip="Dihedral angle (deg) above which an edge cuts patches apart."),
|
||||
io.Int.Input("wide_rings", default=0, min=0, max=6, step=1,
|
||||
tooltip="Broaden each face's normal over this many rings before measuring the "
|
||||
"dihedral, so chamfers / fine fillets read as a single border. 0 = off, "
|
||||
"2-4 catches wider blends (also rounds genuinely-sharp single edges)."),
|
||||
io.Int.Input("min_patch_faces", default=20, min=0, max=1000000, step=1,
|
||||
tooltip="Merge any patch with fewer than this many faces into a neighbour. "
|
||||
"Raise to absorb more speckle."),
|
||||
io.Float.Input("min_patch_area_pct", default=0.05, min=0.0, max=50.0, step=0.01,
|
||||
tooltip="Also merge any patch whose area is below this percent of the total "
|
||||
"surface area. Catches thin/sliver patches that have many faces."),
|
||||
io.Combo.Input("preclean", options=["true", "false"], default="true",
|
||||
tooltip="Merge duplicate vertices + drop degenerate faces first (recommended; "
|
||||
"needed for correct face adjacency)."),
|
||||
],
|
||||
outputs=[
|
||||
io.Custom("TRIMESH").Output(display_name="mesh_with_patches"),
|
||||
io.String.Output(display_name="info"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, trimesh, crease_angle_deg=30.0, wide_rings=0,
|
||||
min_patch_faces=20, min_patch_area_pct=0.05, preclean="true"):
|
||||
mesh = trimesh.copy()
|
||||
if preclean == "true":
|
||||
try:
|
||||
mesh.merge_vertices()
|
||||
except Exception as e:
|
||||
log.debug("merge_vertices skipped: %s", e)
|
||||
try:
|
||||
mesh.update_faces(mesh.nondegenerate_faces())
|
||||
mesh.remove_unreferenced_vertices()
|
||||
except Exception as e:
|
||||
log.debug("degenerate cleanup skipped: %s", e)
|
||||
|
||||
n_faces = int(len(mesh.faces))
|
||||
adj_pairs = np.asarray(getattr(mesh, "face_adjacency", np.zeros((0, 2), int)))
|
||||
_, vals, used, _ = _edge_values(mesh, "face_normals", "angle", wide_dihedral=int(wide_rings))
|
||||
passing = vals >= float(crease_angle_deg)
|
||||
|
||||
region_id, n_raw = _face_regions(n_faces, adj_pairs, passing)
|
||||
|
||||
areas = np.asarray(mesh.area_faces, dtype=np.float64)
|
||||
total_area = float(areas.sum()) or 1.0
|
||||
min_area = (float(min_patch_area_pct) / 100.0) * total_area
|
||||
region_id = _merge_small_regions(region_id, adj_pairs, passing, areas,
|
||||
int(min_patch_faces), min_area)
|
||||
n_final = int(region_id.max()) if len(region_id) else 0
|
||||
|
||||
mesh.face_attributes["region_id"] = region_id.astype(np.float32)
|
||||
mesh.metadata = (mesh.metadata.copy() if mesh.metadata else {})
|
||||
mesh.metadata["patches"] = {
|
||||
"crease_angle_deg": float(crease_angle_deg), "wide_rings": int(wide_rings),
|
||||
"n_patches": n_final, "n_raw": int(n_raw),
|
||||
}
|
||||
|
||||
# patch-size summary (top few)
|
||||
counts = np.bincount(region_id.astype(np.int64))
|
||||
sizes = sorted(counts[1:].tolist(), reverse=True)
|
||||
biggest = ", ".join(str(s) for s in sizes[:8])
|
||||
info = (
|
||||
f"Segment Mesh into Patches\n"
|
||||
f"faces={n_faces:,} feature edges={int(passing.sum()):,} ({used})\n"
|
||||
f"patches: {n_raw:,} raw -> {n_final:,} after merging speckle\n"
|
||||
f" (min {int(min_patch_faces)} faces / {min_patch_area_pct:g}% area)\n"
|
||||
f"largest patches (faces): {biggest}"
|
||||
)
|
||||
log.info("[SegmentPatches] %s", info.replace("\n", " | "))
|
||||
return io.NodeOutput(mesh, info, ui={"text": [info]})
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"GeomPackSegmentPatches": SegmentPatchesNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSegmentPatches": "Segment Mesh into Patches"}
|
||||
@@ -55,6 +55,7 @@ class SharpenMeshNode(io.ComfyNode):
|
||||
"fast_effective": "GeomPackSharpen_FastEffective",
|
||||
"non_iterative": "GeomPackSharpen_NonIterative",
|
||||
"curvature_guided": "GeomPackSharpen_CurvatureGuided",
|
||||
"decrease_gaussian": "GeomPackSharpen_DecreaseGaussian",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -237,6 +238,24 @@ class SharpenMeshNode(io.ComfyNode):
|
||||
"more feature-preserving."
|
||||
)),
|
||||
]),
|
||||
io.DynamicCombo.Option("decrease_gaussian", [
|
||||
io.Int.Input("iterations", default=20, min=1, max=2000, step=1, tooltip=(
|
||||
"Gradient-descent steps on the Gaussian-curvature energy sum K_i^2 "
|
||||
"(K_i = vertex angle defect). More steps = lower |Gaussian curvature| "
|
||||
"= more DEVELOPABLE (planes/cylinders/cones, K->0). Gauss-Bonnet keeps "
|
||||
"the topological total fixed, so the flow flattens the bulk and the "
|
||||
"anchor keeps the overall shape.")),
|
||||
io.Float.Input("strength", default=0.05, min=0.001, max=1.0, step=0.001, display_mode="number", tooltip=(
|
||||
"Per-step size as a fraction of average edge length (largest vertex "
|
||||
"move ~ strength*edge). The foldless barrier prevents triangle "
|
||||
"inversion regardless. Default 0.05.")),
|
||||
io.Float.Input("anchor_weight", default=0.5, min=0.0, max=50.0, step=0.01, display_mode="number", tooltip=(
|
||||
"Tikhonov lambda: how strongly vertices stay at their ORIGINAL "
|
||||
"positions while K is reduced. LOWER = flatten harder (bigger shape "
|
||||
"change toward developable); HIGHER = stay close to input. Default 0.5.")),
|
||||
io.Combo.Input("use_gpu", options=["true", "false"], default="true", tooltip=(
|
||||
"Run on CUDA (recommended). false = CPU torch.")),
|
||||
]),
|
||||
io.DynamicCombo.Option("curvature_guided", [
|
||||
io.Combo.Input("regularizer", options=["tv", "bilateral"], default="tv", tooltip=(
|
||||
"tv = TOTAL-VARIATION on the curvature field (Chambolle-Pock) -> "
|
||||
|
||||
@@ -13,6 +13,7 @@ from .guided_normal import NODE_CLASS_MAPPINGS as GN_MAPS, NODE_DISPLAY_NAME_MAP
|
||||
from .fast_effective import NODE_CLASS_MAPPINGS as FE_MAPS, NODE_DISPLAY_NAME_MAPPINGS as FE_DISP
|
||||
from .non_iterative import NODE_CLASS_MAPPINGS as NI_MAPS, NODE_DISPLAY_NAME_MAPPINGS as NI_DISP
|
||||
from .curvature_guided import NODE_CLASS_MAPPINGS as CG_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CG_DISP
|
||||
from .decrease_gaussian import NODE_CLASS_MAPPINGS as DG_MAPS, NODE_DISPLAY_NAME_MAPPINGS as DG_DISP
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
@@ -25,6 +26,7 @@ NODE_CLASS_MAPPINGS.update(GN_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(FE_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(NI_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(CG_MAPS)
|
||||
NODE_CLASS_MAPPINGS.update(DG_MAPS)
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(TS_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(UM_DISP)
|
||||
@@ -34,5 +36,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(GN_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(FE_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(NI_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(CG_DISP)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(DG_DISP)
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
|
||||
|
||||
"""Sharpen backend that literally DECREASES Gaussian curvature (toward developable).
|
||||
|
||||
Discrete Gaussian curvature at a vertex is the angle defect K_i = 2*pi - sum of the
|
||||
incident triangle angles. This backend does gradient-descent on the Gaussian-curvature
|
||||
energy
|
||||
|
||||
E = sum_i K_i^2 + lambda * ||x - x0||^2
|
||||
|
||||
so the pointwise |K| is driven toward zero -> the surface becomes locally DEVELOPABLE
|
||||
(planes / cylinders / cones, K=0), which is what most CAD surfaces are. (Gauss-Bonnet
|
||||
fixes the TOTAL sum_i K_i = 2*pi*chi, so the flow spreads that topological remainder
|
||||
thinly and flattens the bulk; the anchor lambda keeps it near the input so the shape
|
||||
isn't destroyed.) The exact analytic angle-defect gradient is used (verified vs finite
|
||||
differences), and every step is passed through the same signed-area barrier as the
|
||||
foldless guided_normal update, so no triangle inverts.
|
||||
|
||||
Related: developable surface flows (Stein, Grinspun, Crane 2018). Here we use the simple
|
||||
L2 angle-defect energy (decrease |K|); an L1 variant would instead CONCENTRATE K onto
|
||||
sparse seams."""
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import trimesh as trimesh_module
|
||||
from comfy_api.latest import io
|
||||
|
||||
log = logging.getLogger("geometrypack")
|
||||
|
||||
_TWO_PI = 2.0 * np.pi
|
||||
|
||||
|
||||
def _boundary_vertices(F, n):
|
||||
"""Indices of vertices on a boundary edge (edge used by exactly one face)."""
|
||||
E = np.sort(np.vstack([F[:, [0, 1]], F[:, [1, 2]], F[:, [2, 0]]]), axis=1)
|
||||
uniq, cnt = np.unique(E, axis=0, return_counts=True)
|
||||
be = uniq[cnt == 1]
|
||||
bv = np.zeros(n, dtype=bool)
|
||||
if len(be):
|
||||
bv[be.ravel()] = True
|
||||
return bv
|
||||
|
||||
|
||||
def _decrease_gaussian_gpu(mesh, iterations, strength, anchor_weight, use_gpu):
|
||||
import torch
|
||||
from .guided_normal_gpu import _anti_flip_step_torch, _torch_device
|
||||
|
||||
V0 = np.asarray(mesh.vertices, dtype=np.float64)
|
||||
Ff = np.asarray(mesh.faces, dtype=np.int64)
|
||||
if len(V0) == 0 or len(Ff) == 0:
|
||||
return None, "Empty mesh (no vertices or faces).", "cpu"
|
||||
|
||||
n = len(V0)
|
||||
dev = _torch_device() if use_gpu else __import__("torch").device("cpu")
|
||||
eps = 1e-12
|
||||
bv = _boundary_vertices(Ff, n)
|
||||
|
||||
V = torch.as_tensor(V0, dtype=torch.float32, device=dev)
|
||||
X0 = V.clone()
|
||||
F = torch.as_tensor(Ff, dtype=torch.long, device=dev)
|
||||
i0, i1, i2 = F[:, 0], F[:, 1], F[:, 2]
|
||||
bmask = torch.as_tensor(bv, device=dev)
|
||||
mean_edge = float(np.mean(np.linalg.norm(V0[Ff[:, 1]] - V0[Ff[:, 0]], axis=1)))
|
||||
lam = float(anchor_weight)
|
||||
|
||||
def ang_grad(p0, p1, p2):
|
||||
"""Angle at p0 between edges p0->p1, p0->p2, and its gradient w.r.t. p0,p1,p2."""
|
||||
e1 = p1 - p0
|
||||
e2 = p2 - p0
|
||||
N = torch.cross(e1, e2, dim=1)
|
||||
N = N / (N.norm(dim=1, keepdim=True) + eps)
|
||||
u = e1 / (e1.norm(dim=1, keepdim=True) + eps)
|
||||
w = e2 / (e2.norm(dim=1, keepdim=True) + eps)
|
||||
ang = torch.arccos(torch.clamp((u * w).sum(1), -1.0 + 1e-7, 1.0 - 1e-7))
|
||||
g1 = torch.cross(N, p0 - p1, dim=1) / ((p0 - p1).pow(2).sum(1, keepdim=True) + eps)
|
||||
g2 = torch.cross(N, p2 - p0, dim=1) / ((p2 - p0).pow(2).sum(1, keepdim=True) + eps)
|
||||
g0 = -(g1 + g2)
|
||||
return ang, g0, g1, g2
|
||||
|
||||
for _ in range(int(iterations)):
|
||||
a, b, c = V[i0], V[i1], V[i2]
|
||||
al, gaa, gab, gac = ang_grad(a, b, c) # angle at vertex i0
|
||||
be, gbb, gbc, gba = ang_grad(b, c, a) # angle at vertex i1 (p0=b,p1=c,p2=a)
|
||||
ga, gcc, gca, gcb = ang_grad(c, a, b) # angle at vertex i2
|
||||
|
||||
K = torch.full((n,), _TWO_PI, device=dev)
|
||||
K.index_add_(0, i0, -al)
|
||||
K.index_add_(0, i1, -be)
|
||||
K.index_add_(0, i2, -ga)
|
||||
K[bmask] = 0.0 # boundary defect isn't curvature
|
||||
Ka, Kb, Kc = K[i0], K[i1], K[i2]
|
||||
|
||||
# dE/dx = sum_v 2 K_v * grad K_v, grad K_v = -sum_{angle at v} grad(angle)
|
||||
grad = torch.zeros((n, 3), device=dev)
|
||||
ca = (2.0 * Ka).unsqueeze(1)
|
||||
grad.index_add_(0, i0, ca * (-gaa)); grad.index_add_(0, i1, ca * (-gab)); grad.index_add_(0, i2, ca * (-gac))
|
||||
cb = (2.0 * Kb).unsqueeze(1)
|
||||
grad.index_add_(0, i1, cb * (-gbb)); grad.index_add_(0, i2, cb * (-gbc)); grad.index_add_(0, i0, cb * (-gba))
|
||||
cc = (2.0 * Kc).unsqueeze(1)
|
||||
grad.index_add_(0, i2, cc * (-gcc)); grad.index_add_(0, i0, cc * (-gca)); grad.index_add_(0, i1, cc * (-gcb))
|
||||
|
||||
grad = grad + 2.0 * lam * (V - X0) # Tikhonov anchor (keeps shape / gauge)
|
||||
grad[bmask] = 0.0
|
||||
|
||||
# descent step: cap the LARGEST vertex move to strength*edge (conservative
|
||||
# but guarantees monotone descent on this stiff non-convex energy; median-
|
||||
# based stepping overshoots and drives |K| back up). Barrier => no fold.
|
||||
gmax = grad.norm(dim=1).max()
|
||||
eta = strength * mean_edge / (gmax + eps)
|
||||
move = -eta * grad
|
||||
V = V + _anti_flip_step_torch(V, F, move) # foldless barrier
|
||||
|
||||
out = trimesh_module.Trimesh(vertices=V.detach().cpu().numpy().astype(np.float64),
|
||||
faces=Ff.astype(np.int32), process=False)
|
||||
return out, "", str(dev)
|
||||
|
||||
|
||||
class SharpenDecreaseGaussianNode(io.ComfyNode):
|
||||
"""Decrease Gaussian curvature (developable flow) sharpening backend."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="GeomPackSharpen_DecreaseGaussian",
|
||||
display_name="Sharpen Decrease Gaussian (backend)",
|
||||
category="geompack/smoothing",
|
||||
is_dev_only=True,
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
io.Custom("TRIMESH").Input("trimesh"),
|
||||
io.Int.Input("iterations", default=20, min=1, max=2000, step=1, tooltip=(
|
||||
"Gradient-descent steps on the Gaussian-curvature energy sum K_i^2 "
|
||||
"(K_i = vertex angle defect). More steps = lower |Gaussian curvature| = "
|
||||
"more developable (flatter / more cylinder-and-cone-like).")),
|
||||
io.Float.Input("strength", default=0.05, min=0.001, max=1.0, step=0.001, display_mode="number", tooltip=(
|
||||
"Per-step size, as a fraction of the average edge length (the largest "
|
||||
"vertex move each step is ~strength * edge). Higher = faster but coarser; "
|
||||
"the foldless barrier still prevents triangle inversion. Default 0.05.")),
|
||||
io.Float.Input("anchor_weight", default=0.5, min=0.0, max=50.0, step=0.01, display_mode="number", tooltip=(
|
||||
"Tikhonov lambda -- how strongly vertices are held to their ORIGINAL "
|
||||
"positions while Gaussian curvature is reduced. LOWER = flatten harder "
|
||||
"(bigger shape change); HIGHER = stay close to input. Default 0.5.")),
|
||||
io.Combo.Input("use_gpu", options=["true", "false"], default="true", tooltip=(
|
||||
"Run on CUDA (recommended). false = CPU torch.")),
|
||||
],
|
||||
outputs=[
|
||||
io.Custom("TRIMESH").Output(display_name="sharpened_mesh"),
|
||||
io.String.Output(display_name="info"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, trimesh, iterations=20, strength=0.05, anchor_weight=0.5, use_gpu="true"):
|
||||
import time
|
||||
iv, ifc = len(trimesh.vertices), len(trimesh.faces)
|
||||
log.info("Backend: decrease_gaussian | %d verts %d faces | iters=%d strength=%.3f anchor=%.3f gpu=%s",
|
||||
iv, ifc, iterations, strength, anchor_weight, use_gpu)
|
||||
t0 = time.perf_counter()
|
||||
sharpened, error, dev = _decrease_gaussian_gpu(
|
||||
trimesh, iterations, strength, anchor_weight, use_gpu == "true")
|
||||
elapsed = time.perf_counter() - t0
|
||||
if sharpened is None:
|
||||
raise ValueError(f"Sharpening failed (decrease_gaussian): {error}")
|
||||
|
||||
if hasattr(trimesh, "metadata") and trimesh.metadata:
|
||||
sharpened.metadata = trimesh.metadata.copy()
|
||||
sharpened.metadata["sharpening"] = {
|
||||
"algorithm": "decrease_gaussian", "device": str(dev),
|
||||
"iterations": iterations, "strength": strength, "anchor_weight": anchor_weight,
|
||||
}
|
||||
disp = np.linalg.norm(np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1)
|
||||
info = (f"Sharpen Mesh Results (decrease_gaussian, device={dev}):\n\n"
|
||||
f"Iterations: {iterations}\nStrength: {strength}\nAnchor weight: {anchor_weight}\n"
|
||||
f"Time: {elapsed:.2f}s\n\nVertices: {iv:,} (unchanged)\nFaces: {ifc:,} (unchanged)\n\n"
|
||||
f"Displacement:\n Average: {float(np.mean(disp)):.6f}\n Maximum: {float(np.max(disp)):.6f}\n")
|
||||
if len(sharpened.vertices) == len(trimesh.vertices):
|
||||
sharpened.vertex_attributes["sharpen_displacement_magnitude"] = disp.astype(np.float32)
|
||||
return io.NodeOutput(sharpened, info, ui={"text": [info]})
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"GeomPackSharpen_DecreaseGaussian": SharpenDecreaseGaussianNode}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_DecreaseGaussian": "Sharpen Decrease Gaussian (backend)"}
|
||||
Reference in New Issue
Block a user