updated 'all' workflows

This commit is contained in:
PozzettiAndrea
2026-03-08 00:33:07 +01:00
parent ed980a9fd3
commit 139e6cd76f
28 changed files with 75 additions and 816 deletions
@@ -4,7 +4,6 @@
"""Individual Blender remeshing backend nodes (hidden, invoked via unified Remesh node)."""
from .voxel import NODE_CLASS_MAPPINGS as VOXEL_MAPS, NODE_DISPLAY_NAME_MAPPINGS as VOXEL_DISP
from .smooth import NODE_CLASS_MAPPINGS as SMOOTH_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SMOOTH_DISP
from .sharp import NODE_CLASS_MAPPINGS as SHARP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as SHARP_DISP
from .blocks import NODE_CLASS_MAPPINGS as BLOCKS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BLOCKS_DISP
@@ -12,12 +11,10 @@ NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(VOXEL_MAPS)
NODE_CLASS_MAPPINGS.update(SMOOTH_MAPS)
NODE_CLASS_MAPPINGS.update(SHARP_MAPS)
NODE_CLASS_MAPPINGS.update(BLOCKS_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(VOXEL_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(SMOOTH_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(SHARP_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(BLOCKS_DISP)
@@ -1,77 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""Blender smooth remesh modifier backend node."""
import logging
import numpy as np
import trimesh as trimesh_module
from comfy_api.latest import io
from .voxel import _bpy_setup_object, _bpy_extract_and_cleanup
log = logging.getLogger("geometrypack")
class RemeshBlenderSmoothNode(io.ComfyNode):
"""Blender smooth remesh modifier backend."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackRemesh_BlenderSmooth",
display_name="Remesh Blender Smooth (backend)",
category="geompack/remeshing",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
io.Int.Input("octree_depth", default=6, min=1, max=10, step=1, tooltip="Resolution. Higher = more detail, more faces."),
io.Float.Input("scale", default=0.9, min=0.0, max=1.0, step=0.05, display_mode="number", tooltip="Ratio of output size to input bounding box."),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="remeshed_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, trimesh, octree_depth=6, scale=0.9):
import bpy
log.info("Backend: blender_smooth")
log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces))
log.info("Parameters: octree_depth=%d, scale=%s", octree_depth, scale)
obj, mesh = _bpy_setup_object(
np.asarray(trimesh.vertices, dtype=np.float32),
np.asarray(trimesh.faces, dtype=np.int32)
)
mod = obj.modifiers.new(name="Remesh", type='REMESH')
mod.mode = 'SMOOTH'
mod.octree_depth = octree_depth
mod.scale = scale
bpy.ops.object.modifier_apply(modifier="Remesh")
result = _bpy_extract_and_cleanup(obj)
remeshed_mesh = trimesh_module.Trimesh(
vertices=np.array(result['vertices'], dtype=np.float32),
faces=np.array(result['faces'], dtype=np.int32),
process=False
)
remeshed_mesh.metadata = trimesh.metadata.copy()
remeshed_mesh.metadata['remeshing'] = {
'algorithm': 'blender_smooth', 'octree_depth': octree_depth, 'scale': scale,
}
log.info("Output: %d vertices, %d faces", len(remeshed_mesh.vertices), len(remeshed_mesh.faces))
info = (f"Remesh (Blender Smooth): "
f"{len(trimesh.vertices):,}v/{len(trimesh.faces):,}f -> "
f"{len(remeshed_mesh.vertices):,}v/{len(remeshed_mesh.faces):,}f | "
f"depth={octree_depth}, scale={scale}")
return io.NodeOutput(remeshed_mesh, info, ui={"text": [info]})
NODE_CLASS_MAPPINGS = {"GeomPackRemesh_BlenderSmooth": RemeshBlenderSmoothNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackRemesh_BlenderSmooth": "Remesh Blender Smooth (backend)"}
-3
View File
@@ -31,7 +31,6 @@ from . import paraview
# CGAL nodes (moved from nodes/cgal/)
from . import boolean
from . import reconstruction_cgal
from . import remeshing_cgal
from . import repair_cgal
from . import decimation_cgal
@@ -54,7 +53,6 @@ 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)
NODE_CLASS_MAPPINGS.update(boolean.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(reconstruction_cgal.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(remeshing_cgal.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(repair_cgal.NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(smoothing.NODE_CLASS_MAPPINGS)
@@ -79,7 +77,6 @@ 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)
NODE_DISPLAY_NAME_MAPPINGS.update(boolean.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(reconstruction_cgal.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(remeshing_cgal.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(repair_cgal.NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(smoothing.NODE_DISPLAY_NAME_MAPPINGS)
@@ -9,8 +9,6 @@ from .poisson import NODE_CLASS_MAPPINGS as POISSON_MAPS, NODE_DISPLAY_NAME_MAPP
from .ball_pivoting import NODE_CLASS_MAPPINGS as BP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as BP_DISP
from .alpha_shape import NODE_CLASS_MAPPINGS as AS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as AS_DISP
from .convex_hull import NODE_CLASS_MAPPINGS as CH_MAPS, NODE_DISPLAY_NAME_MAPPINGS as CH_DISP
from .delaunay_2d import NODE_CLASS_MAPPINGS as D2D_MAPS, NODE_DISPLAY_NAME_MAPPINGS as D2D_DISP
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
@@ -18,12 +16,10 @@ NODE_CLASS_MAPPINGS.update(POISSON_MAPS)
NODE_CLASS_MAPPINGS.update(BP_MAPS)
NODE_CLASS_MAPPINGS.update(AS_MAPS)
NODE_CLASS_MAPPINGS.update(CH_MAPS)
NODE_CLASS_MAPPINGS.update(D2D_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(POISSON_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(BP_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(AS_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(CH_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(D2D_DISP)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
@@ -24,7 +24,7 @@ class ReconstructBallPivotingNode(io.ComfyNode):
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("points"),
io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Radius of the virtual ball rolled over the point cloud to form triangles. Smaller = finer detail but more holes, larger = smoother but loses detail. 0 = auto (PyMeshLab estimates from point spacing)."),
io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Ball radius in absolute units (same as mesh coordinates). Smaller = finer detail but more holes, larger = smoother but loses detail. 0 = auto (PyMeshLab estimates from point spacing)."),
io.Combo.Input("estimate_normals", options=["true", "false"], default="true", tooltip="Re-estimate point normals using k-nearest neighbors. Enable if the input has no normals or unreliable normals."),
io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01, tooltip="Search radius for normal estimation via k-nearest neighbors. Should be 2-3x the average point spacing. Only used when estimate_normals is true."),
],
@@ -68,7 +68,7 @@ class ReconstructBallPivotingNode(io.ComfyNode):
ms.generate_surface_reconstruction_ball_pivoting()
else:
ms.generate_surface_reconstruction_ball_pivoting(
ballradius=pymeshlab.PercentageValue(ball_radius * 100)
ballradius=pymeshlab.PureValue(ball_radius)
)
result_mesh = ms.current_mesh()
@@ -1,81 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""2D Delaunay triangulation surface reconstruction backend node."""
import logging
import numpy as np
import trimesh as trimesh_module
from comfy_api.latest import io
log = logging.getLogger("geometrypack")
class ReconstructDelaunay2DNode(io.ComfyNode):
"""2D Delaunay triangulation (for height fields)."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackReconstruct_Delaunay2D",
display_name="Reconstruct Delaunay 2D (backend)",
category="geompack/reconstruction",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("points"),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="reconstructed_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, points):
log.info("Backend: delaunay_2d")
vertices = np.asarray(points.vertices, dtype=np.float64)
log.info("Computing 2D Delaunay triangulation...")
try:
from scipy.spatial import Delaunay
points_2d = vertices[:, :2]
tri = Delaunay(points_2d)
result = trimesh_module.Trimesh(
vertices=vertices,
faces=tri.simplices,
process=False
)
info = f"""Reconstruct Surface Results (2D Delaunay):
Input Points: {len(vertices):,}
Output Vertices: {len(result.vertices):,}
Output Faces: {len(result.faces):,}
2D Delaunay projects points to XY plane for triangulation.
Best for height fields and terrain data.
"""
# Preserve metadata
if hasattr(points, 'metadata') and points.metadata:
result.metadata = points.metadata.copy()
else:
result.metadata = {}
result.metadata['reconstruction'] = {
'method': 'delaunay_2d',
'input_points': len(vertices),
'output_vertices': len(result.vertices),
'output_faces': len(result.faces),
}
return io.NodeOutput(result, info, ui={"text": [info]})
except ImportError:
raise ImportError("2D Delaunay requires scipy. Install with: pip install scipy")
NODE_CLASS_MAPPINGS = {"GeomPackReconstruct_Delaunay2D": ReconstructDelaunay2DNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackReconstruct_Delaunay2D": "Reconstruct Delaunay 2D (backend)"}
@@ -26,14 +26,10 @@ class ReconstructSurfaceNode(io.ComfyNode):
"ball_pivoting": "GeomPackReconstruct_BallPivoting",
"alpha_shape": "GeomPackReconstruct_AlphaShape",
"convex_hull": "GeomPackReconstruct_ConvexHull",
"delaunay_2d": "GeomPackReconstruct_Delaunay2D",
"alpha_wrap": "GeomPackAlphaWrap",
}
# Remap frontend param names to backend param names where they differ
PARAM_REMAP = {
"alpha_wrap": {"points": "input_mesh"},
}
PARAM_REMAP = {}
@classmethod
def define_schema(cls):
@@ -50,9 +46,7 @@ class ReconstructSurfaceNode(io.ComfyNode):
"poisson=smooth watertight, "
"ball_pivoting=preserves detail, "
"alpha_shape=controllable detail, "
"convex_hull=fast rough, "
"delaunay_2d=height fields, "
"alpha_wrap=shrink wrap (CGAL)"
"convex_hull=fast rough"
), options=[
io.DynamicCombo.Option("poisson", [
io.Int.Input("poisson_depth", default=8, min=1, max=12, step=1, tooltip="Octree depth for the Poisson solver. Higher values capture finer detail but use exponentially more memory and time. 6=coarse, 8=balanced, 10+=high detail."),
@@ -61,7 +55,7 @@ class ReconstructSurfaceNode(io.ComfyNode):
io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01, tooltip="Search radius for normal estimation via k-nearest neighbors. Should be 2-3x the average point spacing. Only used when estimate_normals is true."),
]),
io.DynamicCombo.Option("ball_pivoting", [
io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Radius of the virtual ball rolled over the point cloud to form triangles. Smaller = finer detail but more holes, larger = smoother but loses detail. 0 = auto (PyMeshLab estimates from point spacing)."),
io.Float.Input("ball_radius", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Ball radius in absolute units (same as mesh coordinates). Smaller = finer detail but more holes, larger = smoother but loses detail. 0 = auto (PyMeshLab estimates from point spacing)."),
io.Combo.Input("estimate_normals", options=["true", "false"], default="true", tooltip="Re-estimate point normals using k-nearest neighbors. Enable if the input has no normals or unreliable normals."),
io.Float.Input("normal_radius", default=0.1, min=0.001, max=10.0, step=0.01, tooltip="Search radius for normal estimation via k-nearest neighbors. Should be 2-3x the average point spacing. Only used when estimate_normals is true."),
]),
@@ -69,11 +63,6 @@ class ReconstructSurfaceNode(io.ComfyNode):
io.Float.Input("alpha", default=0.0, min=0.0, max=100.0, step=0.01, tooltip="Radius threshold controlling which Delaunay tetrahedra are kept. Only tetrahedra with longest edge < 2*alpha are included. Larger = coarser shape with more fill, smaller = tighter fit with more holes. 0 = auto (10%% of bounding box diagonal)."),
]),
io.DynamicCombo.Option("convex_hull", []),
io.DynamicCombo.Option("delaunay_2d", []),
io.DynamicCombo.Option("alpha_wrap", [
io.Float.Input("alpha_percent", default=2.0, min=0.001, max=50.0, step=0.1, tooltip="Wrap tightness as %% of bounding box diagonal. Smaller = tighter wrap with more detail, but MUCH slower."),
io.Float.Input("offset_percent", default=2.0, min=0.01, max=10.0, step=0.1, tooltip="Surface offset as %% of bounding box diagonal. Smaller = closer to original surface."),
]),
]),
],
outputs=[
@@ -1,16 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
CGAL reconstruction nodes - requires pymeshlab with CGAL support
"""
from .alpha_wrap import NODE_CLASS_MAPPINGS as ALPHA_WRAP_MAPS, NODE_DISPLAY_NAME_MAPPINGS as ALPHA_WRAP_DISP
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_CLASS_MAPPINGS.update(ALPHA_WRAP_MAPS)
NODE_DISPLAY_NAME_MAPPINGS.update(ALPHA_WRAP_DISP)
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
@@ -1,267 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""
Alpha Wrap Node - Shrink wrap mesh generation using CGAL's Alpha Wrap algorithm.
Creates a watertight mesh that tightly wraps around input geometry.
Useful for non-manifold meshes or polygon soups.
"""
import logging
import os
import threading
import time
import numpy as np
import trimesh
import comfy.utils
from comfy_api.latest import io
from CGAL import CGAL_Alpha_wrap_3
from CGAL.CGAL_Kernel import Point_3
from CGAL.CGAL_Polyhedron_3 import Polyhedron_3
log = logging.getLogger("geometrypack")
# Progress bar layout (percentage of total)
_PHASE_BUILD = 15 # 0-15%: building CGAL input
_PHASE_WRAP_START = 15 # 15%: wrap starts
_PHASE_WRAP_END = 85 # ..85%: wrap ends (time-based fill)
_PHASE_EXTRACT = 100 # 85-100%: extracting result
def _get_rss_mb():
"""Get current process RSS in MB (Linux)."""
try:
with open(f"/proc/{os.getpid()}/statm", "r") as f:
pages = int(f.read().split()[1])
return pages * os.sysconf("SC_PAGE_SIZE") / (1024 * 1024)
except Exception:
return 0.0
def _polyhedron_to_arrays(poly):
"""Extract vertices and faces from a CGAL Polyhedron_3."""
verts = []
vertex_map = {}
for i, vertex in enumerate(poly.vertices()):
point = vertex.point()
verts.append([float(point.x()), float(point.y()), float(point.z())])
vertex_map[vertex] = i
faces = []
for facet in poly.facets():
halfedge = facet.halfedge()
face_verts = []
start_he = halfedge
current = start_he
while True:
face_verts.append(vertex_map[current.vertex()])
current = current.next()
if current == start_he:
break
if len(face_verts) == 3:
faces.append(face_verts)
return np.array(verts, dtype=np.float64), np.array(faces, dtype=np.int32)
class AlphaWrapNode(io.ComfyNode):
"""
Alpha Wrap - Generate a watertight shrink-wrapped mesh.
Uses CGAL's Alpha Wrap algorithm to create a tight-fitting
watertight mesh around input geometry.
Works with:
- Non-manifold meshes
- Meshes with holes
- Polygon soups (overlapping/intersecting geometry)
Parameters:
- alpha: Controls wrap tightness. Smaller = tighter wrap, more detail.
Relative to bounding box diagonal (0.01 = 1% of bbox diagonal).
- offset: Surface offset distance. Smaller = closer to original surface.
Relative to bounding box diagonal.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackAlphaWrap",
display_name="Alpha Wrap (Shrink Wrap)",
category="geompack/reconstruction",
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("input_mesh"),
io.Float.Input("alpha_percent", default=2.0, min=0.001, max=50.0, step=0.1, tooltip="Wrap tightness as % of bounding box diagonal. Smaller = tighter wrap with more detail, but MUCH slower (runtime ~ 1/alpha\u00b3). Start at 2-5% and tighten if needed.", optional=True),
io.Float.Input("offset_percent", default=2.0, min=0.01, max=10.0, step=0.1, tooltip="Surface offset as % of bounding box diagonal. Smaller = closer to original surface.", optional=True),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="wrapped_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, input_mesh, alpha_percent=2.0, offset_percent=2.0):
vertices = np.asarray(input_mesh.vertices, dtype=np.float64)
input_vertex_count = len(vertices)
is_point_cloud = (
not hasattr(input_mesh, 'faces') or
input_mesh.faces is None or
len(input_mesh.faces) == 0
)
if is_point_cloud:
raise ValueError(
"Alpha Wrap requires a mesh with faces (triangle soup), not a point cloud.\n"
"For point clouds, first use 'Reconstruct Surface' node (e.g., Poisson or Ball Pivoting) "
"to create a mesh, then apply Alpha Wrap."
)
input_face_count = len(input_mesh.faces)
input_type = "mesh"
# Compute bounding box diagonal for relative parameters
bbox_min = vertices.min(axis=0)
bbox_max = vertices.max(axis=0)
bbox_diagonal = np.linalg.norm(bbox_max - bbox_min)
# Convert percentages to absolute values
alpha = (alpha_percent / 100.0) * bbox_diagonal
offset = (offset_percent / 100.0) * bbox_diagonal
log.info("Input: %s vertices, %s faces (%s)", f"{input_vertex_count:,}", f"{input_face_count:,}", input_type)
log.info("Bounding box diagonal: %.4f", bbox_diagonal)
log.info("Alpha: %.6f (%s%% of bbox)", alpha, alpha_percent)
log.info("Offset: %.6f (%s%% of bbox)", offset, offset_percent)
pbar = comfy.utils.ProgressBar(100)
# --- Phase 1: Build CGAL input data (0-15%) ---
t0 = time.monotonic()
cgal_points = CGAL_Alpha_wrap_3.Point_3_Vector()
cgal_points.reserve(input_vertex_count)
report_every = max(1, input_vertex_count // 10)
for i, v in enumerate(vertices):
cgal_points.append(Point_3(float(v[0]), float(v[1]), float(v[2])))
if i % report_every == 0:
pbar.update_absolute(int(_PHASE_BUILD * i / input_vertex_count))
cgal_faces = [[int(idx) for idx in face] for face in input_mesh.faces]
pbar.update_absolute(_PHASE_BUILD)
log.info("Built CGAL input data in %.1fs", time.monotonic() - t0)
# --- Phase 2: Run alpha wrap (15-85%) ---
output_poly = Polyhedron_3()
exc_holder = [None]
rss_before = _get_rss_mb()
def _run():
try:
CGAL_Alpha_wrap_3.alpha_wrap_3(cgal_points, cgal_faces, alpha, offset, output_poly)
except Exception as e:
exc_holder[0] = e
log.info("Running CGAL Alpha Wrap...")
thread = threading.Thread(target=_run, daemon=True)
thread.start()
wrap_start = time.monotonic()
interval = 5
wrap_range = _PHASE_WRAP_END - _PHASE_WRAP_START
while thread.is_alive():
thread.join(timeout=interval)
elapsed = time.monotonic() - wrap_start
rss = _get_rss_mb()
delta = rss - rss_before
if thread.is_alive():
# Asymptotic progress: approaches _PHASE_WRAP_END but never reaches it
# Uses 1 - 1/(1 + t/30) so it fills ~50% at 30s, ~75% at 90s, etc.
frac = 1.0 - 1.0 / (1.0 + elapsed / 30.0)
progress = int(_PHASE_WRAP_START + wrap_range * frac)
pbar.update_absolute(progress)
log.info("Alpha Wrap running... %.0fs elapsed, RSS %.0f MB (+%.0f MB)",
elapsed, rss, delta)
elapsed_total = time.monotonic() - wrap_start
rss_final = _get_rss_mb()
pbar.update_absolute(_PHASE_WRAP_END)
log.info("Alpha Wrap finished in %.1fs, RSS %.0f MB (+%.0f MB)",
elapsed_total, rss_final, rss_final - rss_before)
if exc_holder[0] is not None:
raise exc_holder[0]
# --- Phase 3: Extract result mesh (85-100%) ---
t0 = time.monotonic()
result_vertices, result_faces = _polyhedron_to_arrays(output_poly)
pbar.update_absolute(_PHASE_EXTRACT)
log.info("Extracted result mesh in %.1fs", time.monotonic() - t0)
result_mesh = trimesh.Trimesh(
vertices=result_vertices,
faces=result_faces,
process=False
)
# Copy metadata if present
if hasattr(input_mesh, 'metadata') and input_mesh.metadata:
result_mesh.metadata = input_mesh.metadata.copy()
else:
result_mesh.metadata = {}
result_mesh.metadata['alpha_wrap'] = {
'alpha': alpha,
'alpha_percent': alpha_percent,
'offset': offset,
'offset_percent': offset_percent,
'bbox_diagonal': bbox_diagonal,
'input_type': input_type
}
output_vertex_count = len(result_mesh.vertices)
output_face_count = len(result_mesh.faces)
is_watertight = result_mesh.is_watertight
log.info("Result: %s vertices, %s faces", f"{output_vertex_count:,}", f"{output_face_count:,}")
log.info("Watertight: %s", is_watertight)
report = f"""Alpha Wrap Report
{'='*40}
Input:
Type: {input_type}
Vertices: {input_vertex_count:,}
Faces: {input_face_count:,}
Parameters:
Alpha: {alpha:.6f} ({alpha_percent}% of bbox diagonal)
Offset: {offset:.6f} ({offset_percent}% of bbox diagonal)
BBox Diagonal: {bbox_diagonal:.4f}
Output:
Vertices: {output_vertex_count:,}
Faces: {output_face_count:,}
Watertight: {'Yes' if is_watertight else 'No'}
Tips:
- Decrease alpha_percent for tighter wrap / more detail
- Decrease offset_percent to get closer to original surface
- Lower values = slower computation
"""
return io.NodeOutput(result_mesh, report, ui={"text": [report]})
NODE_CLASS_MAPPINGS = {
"GeomPackAlphaWrap": AlphaWrapNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"GeomPackAlphaWrap": "Alpha Wrap (Shrink Wrap)",
}
-5
View File
@@ -35,7 +35,6 @@ class RemeshNode(io.ComfyNode):
"quadwild": "GeomPackRemesh_QuadWild",
"cgal_isotropic": "GeomPackRemesh_CGAL",
"blender_voxel": "GeomPackRemesh_BlenderVoxel",
"blender_smooth": "GeomPackRemesh_BlenderSmooth",
"blender_sharp": "GeomPackRemesh_BlenderSharp",
"blender_blocks": "GeomPackRemesh_BlenderBlocks",
"gpu_cumesh": "GeomPackRemesh_GPU",
@@ -123,10 +122,6 @@ class RemeshNode(io.ComfyNode):
io.DynamicCombo.Option("blender_voxel", [
io.Float.Input("voxel_size", default=1, min=0.001, max=1.0, step=0.01, display_mode="number", tooltip="Voxel size. Smaller = more detail. Output is always watertight."),
]),
io.DynamicCombo.Option("blender_smooth", [
io.Int.Input("octree_depth", default=6, min=1, max=10, step=1, tooltip="Resolution. Higher = more detail, more faces."),
io.Float.Input("scale", default=0.9, min=0.0, max=1.0, step=0.05, display_mode="number", tooltip="Ratio of output size to input bounding box."),
]),
io.DynamicCombo.Option("blender_sharp", [
io.Int.Input("octree_depth", default=6, min=1, max=10, step=1, tooltip="Resolution. Higher = more detail, more faces."),
io.Float.Input("scale", default=0.9, min=0.0, max=1.0, step=0.05, display_mode="number", tooltip="Ratio of output size to input bounding box."),
+6 -30
View File
@@ -24,10 +24,6 @@ Available backends:
- guided_normal: Guided mesh normal filtering (Zhang et al. 2015). Uses a
min-range-metric guidance signal to drive bilateral normal filtering while
preserving sharp edges. Interleaves vertex updates within normal iterations.
- vsa_snap: Variational Shape Approximation face clustering with vertex
snapping. Clusters faces into proxy plane groups via Lloyd iteration, then
snaps vertices to proxy planes (interior), plane intersection lines (edges),
or plane intersection points (corners).
- fast_effective: Fast and Effective Feature-Preserving Mesh Denoising
(Sun et al. TVCG 2007). Uses thresholded cosine-similarity weights for
normal filtering: w = max(0, dot(ni,nj) - T)^2. Simple and fast.
@@ -56,7 +52,6 @@ class SharpenMeshNode(io.ComfyNode):
"libigl_unsharp": "GeomPackSharpen_LibiglUnsharp",
"l0_minimize": "GeomPackSharpen_L0Minimize",
"guided_normal": "GeomPackSharpen_GuidedNormal",
"vsa_snap": "GeomPackSharpen_VSASnap",
"fast_effective": "GeomPackSharpen_FastEffective",
"non_iterative": "GeomPackSharpen_NonIterative",
}
@@ -78,7 +73,6 @@ class SharpenMeshNode(io.ComfyNode):
"libigl_unsharp=cotangent-weighted unsharp (geometry-aware), "
"l0_minimize=piecewise-flat L0 optimization (aggressive CAD prep), "
"guided_normal=guided normal filtering with min-range-metric (controllable), "
"vsa_snap=face clustering + vertex snapping (explicit patch control), "
"fast_effective=thresholded cosine weight normal filtering (fast), "
"non_iterative=mollified normal single-pass bilateral (non-iterative)"
), options=[
@@ -105,15 +99,14 @@ class SharpenMeshNode(io.ComfyNode):
)),
]),
io.DynamicCombo.Option("libigl_unsharp", [
io.Float.Input("lambda_", default=0.5, min=0.01, max=5.0, step=0.01, tooltip=(
"Unsharp mask strength. Controls how much the cotangent "
"Laplacian displacement is amplified. Higher values produce "
"stronger sharpening but may cause overshooting. "
"Start with 0.3-0.5."
io.Float.Input("weight", default=0.5, min=0.01, max=5.0, step=0.01, tooltip=(
"How much detail to add back. 0.5 = subtle sharpening, "
"1.0 = double the detail, 2.0+ = aggressive."
)),
io.Int.Input("iterations", default=3, min=1, max=50, step=1, tooltip=(
"Number of unsharp mask passes. Multiple light passes "
"are more stable than a single heavy pass."
"Smoothing iterations for the reference mesh. "
"More iterations = smoother reference = sharpens broader features. "
"Fewer iterations = sharpens fine detail."
)),
]),
io.DynamicCombo.Option("l0_minimize", [
@@ -156,23 +149,6 @@ class SharpenMeshNode(io.ComfyNode):
"dihedral angle threshold."
)),
]),
io.DynamicCombo.Option("vsa_snap", [
io.Int.Input("num_proxies", default=20, min=2, max=500, step=1, tooltip=(
"Number of proxy planes (face clusters). More proxies "
"preserve more detail. Fewer proxies create a more "
"abstract/simplified appearance. 10-50 for simple shapes, "
"50-200 for complex shapes."
)),
io.Int.Input("lloyd_iterations", default=10, min=1, max=100, step=1, tooltip=(
"Number of Lloyd clustering iterations. 10 is usually "
"sufficient for convergence."
)),
io.Float.Input("snap_strength", default=1.0, min=0.0, max=1.0, step=0.01, tooltip=(
"How aggressively to snap vertices to proxy planes. "
"0.0 = no change, 1.0 = full snap. "
"Values like 0.5-0.8 give a subtle sharpening effect."
)),
]),
io.DynamicCombo.Option("fast_effective", [
io.Float.Input("threshold_T", default=0.5, min=1e-10, max=1.0, step=0.01, tooltip=(
"Cosine similarity threshold (Sun et al. TVCG 2007). "
@@ -10,7 +10,6 @@ from .unsharp_mask import NODE_CLASS_MAPPINGS as UM_MAPS, NODE_DISPLAY_NAME_MAPP
from .libigl_unsharp import NODE_CLASS_MAPPINGS as LU_MAPS, NODE_DISPLAY_NAME_MAPPINGS as LU_DISP
from .l0_minimize import NODE_CLASS_MAPPINGS as L0_MAPS, NODE_DISPLAY_NAME_MAPPINGS as L0_DISP
from .guided_normal import NODE_CLASS_MAPPINGS as GN_MAPS, NODE_DISPLAY_NAME_MAPPINGS as GN_DISP
from .vsa_snap import NODE_CLASS_MAPPINGS as VS_MAPS, NODE_DISPLAY_NAME_MAPPINGS as VS_DISP
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
@@ -22,7 +21,6 @@ NODE_CLASS_MAPPINGS.update(UM_MAPS)
NODE_CLASS_MAPPINGS.update(LU_MAPS)
NODE_CLASS_MAPPINGS.update(L0_MAPS)
NODE_CLASS_MAPPINGS.update(GN_MAPS)
NODE_CLASS_MAPPINGS.update(VS_MAPS)
NODE_CLASS_MAPPINGS.update(FE_MAPS)
NODE_CLASS_MAPPINGS.update(NI_MAPS)
@@ -31,7 +29,6 @@ NODE_DISPLAY_NAME_MAPPINGS.update(UM_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(LU_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(L0_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(GN_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(VS_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(FE_DISP)
NODE_DISPLAY_NAME_MAPPINGS.update(NI_DISP)
@@ -11,16 +11,19 @@ from comfy_api.latest import io
log = logging.getLogger("geometrypack")
def _libigl_unsharp_sharpen(mesh, lambda_, iterations):
def _libigl_unsharp_sharpen(mesh, weight, iterations):
"""Cotangent-weighted geometric unsharp mask via libigl.
V_sharp = V + lambda * M^{-1} * L * V
where L is the cotangent Laplacian and M is the Voronoi mass matrix.
Uses implicit Laplacian smoothing (backward Euler) to compute a stable
smooth reference, then amplifies the detail:
V_smooth = solve (M - dt*L) V_smooth = M @ V (repeated)
V_sharp = V + weight * (V - V_smooth)
"""
try:
import igl
except ImportError:
return None, "libigl is not installed. Install with: pip install libigl"
from scipy.sparse.linalg import spsolve
V = np.asarray(mesh.vertices, dtype=np.float64)
F = np.asarray(mesh.faces, dtype=np.int64)
@@ -31,19 +34,21 @@ def _libigl_unsharp_sharpen(mesh, lambda_, iterations):
L = igl.cotmatrix(V, F)
M = igl.massmatrix(V, F, igl.MASSMATRIX_TYPE_VORONOI)
# M is diagonal -- invert via element-wise reciprocal
M_diag = M.diagonal().copy()
M_diag[M_diag == 0] = 1e-12
M_inv = 1.0 / M_diag
# Implicit smoothing: solve (M - dt*L) V_smooth = M @ V
# dt=1.0 gives strong smoothing per iteration; always stable.
A = M - L
V_smooth = V.copy()
for _ in range(iterations):
# L @ V gives curvature-weighted displacement (n, 3)
LV = L @ V
delta = M_inv[:, None] * LV
V = V + lambda_ * delta
for dim in range(3):
rhs = M @ V_smooth[:, dim]
V_smooth[:, dim] = spsolve(A, rhs)
# Unsharp mask: amplify the detail signal
detail = V - V_smooth
V_sharp = V + weight * detail
result = trimesh_module.Trimesh(
vertices=V,
vertices=V_sharp,
faces=np.asarray(mesh.faces, dtype=np.int32),
process=False,
)
@@ -63,15 +68,14 @@ class SharpenLibiglUnsharpNode(io.ComfyNode):
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
io.Float.Input("lambda_", default=0.5, min=0.01, max=5.0, step=0.01, tooltip=(
"Unsharp mask strength. Controls how much the cotangent "
"Laplacian displacement is amplified. Higher values produce "
"stronger sharpening but may cause overshooting. "
"Start with 0.3-0.5."
io.Float.Input("weight", default=0.5, min=0.01, max=5.0, step=0.01, tooltip=(
"How much detail to add back. 0.5 = subtle sharpening, "
"1.0 = double the detail, 2.0+ = aggressive."
)),
io.Int.Input("iterations", default=3, min=1, max=50, step=1, tooltip=(
"Number of unsharp mask passes. Multiple light passes "
"are more stable than a single heavy pass."
"Smoothing iterations for the reference mesh. "
"More iterations = smoother reference = sharpens broader features. "
"Fewer iterations = sharpens fine detail."
)),
],
outputs=[
@@ -81,16 +85,16 @@ class SharpenLibiglUnsharpNode(io.ComfyNode):
)
@classmethod
def execute(cls, trimesh, lambda_=0.5, iterations=3):
def execute(cls, trimesh, weight=0.5, iterations=3):
log.info("Backend: libigl_unsharp")
log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces))
log.info("Parameters: lambda=%.3f, iterations=%d", lambda_, iterations)
log.info("Parameters: weight=%.3f, iterations=%d", weight, iterations)
initial_vertices = len(trimesh.vertices)
initial_faces = len(trimesh.faces)
sharpened, error = _libigl_unsharp_sharpen(
trimesh, lambda_, iterations,
trimesh, weight, iterations,
)
if sharpened is None:
@@ -117,7 +121,7 @@ class SharpenLibiglUnsharpNode(io.ComfyNode):
log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp)
param_text = (
f"Lambda: {lambda_}\n"
f"Weight: {weight}\n"
f"Iterations: {iterations}"
)
@@ -1,252 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 ComfyUI-GeometryPack Contributors
"""Variational Shape Approximation face clustering + vertex snapping backend node."""
import logging
import numpy as np
import trimesh as trimesh_module
from comfy_api.latest import io
from ._helpers import _compute_face_geometry
log = logging.getLogger("geometrypack")
def _vsa_snap_sharpen(mesh, num_proxies, lloyd_iterations, snap_strength):
"""Variational Shape Approximation clustering with vertex snapping.
Clusters mesh faces into proxy plane groups via Lloyd iteration (using
area-weighted normal distance as the L2,1 metric), then snaps vertices:
- Interior vertices (one proxy): project onto proxy plane
- Edge vertices (two proxies): snap to plane intersection line
- Corner vertices (3+ proxies): snap to plane intersection point
"""
V = np.asarray(mesh.vertices, dtype=np.float64)
F = np.asarray(mesh.faces, dtype=np.int64)
if len(V) == 0 or len(F) == 0:
return None, "Empty mesh (no vertices or faces)."
m = len(F)
k = min(num_proxies, m)
normals, centroids, areas = _compute_face_geometry(V, F)
# --- Step 1: farthest-point seeding on area-weighted normal space ---
seeds = [0]
min_dists = np.full(m, np.inf)
for _ in range(k - 1):
last = seeds[-1]
d = areas * np.linalg.norm(normals - normals[last], axis=1)
min_dists = np.minimum(min_dists, d)
seeds.append(int(np.argmax(min_dists)))
proxy_normals = normals[seeds].copy()
proxy_centroids = centroids[seeds].copy()
# --- Step 2: Lloyd iteration ---
labels = np.zeros(m, dtype=np.int32)
for _ in range(lloyd_iterations):
# Assign each face to nearest proxy (L2,1 metric)
# (m, k) distance matrix
normal_diffs = np.linalg.norm(
normals[:, None, :] - proxy_normals[None, :, :], axis=2
)
weighted_diffs = areas[:, None] * normal_diffs
labels = np.argmin(weighted_diffs, axis=1).astype(np.int32)
# Refit proxies
for p in range(k):
mask = labels == p
if not np.any(mask):
# Empty cluster -- re-seed from farthest face
assigned_dists = weighted_diffs[np.arange(m), labels]
worst = int(np.argmax(assigned_dists))
proxy_normals[p] = normals[worst]
proxy_centroids[p] = centroids[worst]
continue
a = areas[mask]
total_a = np.sum(a) + 1e-12
avg_n = np.sum(normals[mask] * a[:, None], axis=0) / total_a
norm_len = np.linalg.norm(avg_n)
if norm_len > 1e-12:
avg_n /= norm_len
proxy_normals[p] = avg_n
proxy_centroids[p] = np.sum(centroids[mask] * a[:, None], axis=0) / total_a
# --- Step 3: snap vertices ---
# Build vertex-to-face adjacency
vertex_faces = [[] for _ in range(len(V))]
for fi in range(m):
for vi in F[fi]:
vertex_faces[vi].append(fi)
V_snapped = V.copy()
for vi in range(len(V)):
adj_fi = vertex_faces[vi]
if not adj_fi:
continue
adj_labels = list(set(labels[fi] for fi in adj_fi))
if len(adj_labels) == 1:
# Interior: project onto single proxy plane
p = adj_labels[0]
n = proxy_normals[p]
c = proxy_centroids[p]
d = np.dot(V[vi] - c, n)
V_snapped[vi] = V[vi] - snap_strength * d * n
elif len(adj_labels) == 2:
# Edge: snap to intersection of two planes
p1, p2 = adj_labels
n1, c1 = proxy_normals[p1], proxy_centroids[p1]
n2, c2 = proxy_normals[p2], proxy_centroids[p2]
line_dir = np.cross(n1, n2)
line_dir_norm = np.linalg.norm(line_dir)
if line_dir_norm < 1e-8:
# Nearly parallel planes -- project onto average
avg_n = (n1 + n2)
avg_n_norm = np.linalg.norm(avg_n)
if avg_n_norm > 1e-12:
avg_n /= avg_n_norm
avg_c = (c1 + c2) / 2.0
d = np.dot(V[vi] - avg_c, avg_n)
V_snapped[vi] = V[vi] - snap_strength * d * avg_n
else:
line_dir /= line_dir_norm
# Find a point on the intersection line
A = np.array([n1, n2])
b = np.array([np.dot(n1, c1), np.dot(n2, c2)])
try:
AAT = A @ A.T
p0 = A.T @ np.linalg.solve(AAT, b)
except np.linalg.LinAlgError:
continue
# Project vertex onto the line
v_rel = V[vi] - p0
t = np.dot(v_rel, line_dir)
closest = p0 + t * line_dir
V_snapped[vi] = V[vi] + snap_strength * (closest - V[vi])
else:
# Corner: snap to intersection of 3+ planes
A = np.array([proxy_normals[p] for p in adj_labels])
b = np.array([np.dot(proxy_normals[p], proxy_centroids[p])
for p in adj_labels])
try:
if len(adj_labels) == 3:
corner = np.linalg.solve(A, b)
else:
corner, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
V_snapped[vi] = V[vi] + snap_strength * (corner - V[vi])
except np.linalg.LinAlgError:
continue
result = trimesh_module.Trimesh(
vertices=V_snapped,
faces=np.asarray(mesh.faces, dtype=np.int32),
process=False,
)
return result, ""
class SharpenVSASnapNode(io.ComfyNode):
"""Variational Shape Approximation face clustering + vertex snapping backend."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="GeomPackSharpen_VSASnap",
display_name="Sharpen VSA Snap (backend)",
category="geompack/smoothing",
is_dev_only=True,
is_output_node=True,
inputs=[
io.Custom("TRIMESH").Input("trimesh"),
io.Int.Input("num_proxies", default=20, min=2, max=500, step=1, tooltip=(
"Number of proxy planes (face clusters). More proxies "
"preserve more detail. Fewer proxies create a more "
"abstract/simplified appearance. 10-50 for simple shapes, "
"50-200 for complex shapes."
)),
io.Int.Input("lloyd_iterations", default=10, min=1, max=100, step=1, tooltip=(
"Number of Lloyd clustering iterations. 10 is usually "
"sufficient for convergence."
)),
io.Float.Input("snap_strength", default=1.0, min=0.0, max=1.0, step=0.01, tooltip=(
"How aggressively to snap vertices to proxy planes. "
"0.0 = no change, 1.0 = full snap. "
"Values like 0.5-0.8 give a subtle sharpening effect."
)),
],
outputs=[
io.Custom("TRIMESH").Output(display_name="sharpened_mesh"),
io.String.Output(display_name="info"),
],
)
@classmethod
def execute(cls, trimesh, num_proxies=20, lloyd_iterations=10, snap_strength=1.0):
log.info("Backend: vsa_snap")
log.info("Input: %d vertices, %d faces", len(trimesh.vertices), len(trimesh.faces))
log.info("Parameters: num_proxies=%d, lloyd_iter=%d, snap_strength=%.2f",
num_proxies, lloyd_iterations, snap_strength)
initial_vertices = len(trimesh.vertices)
initial_faces = len(trimesh.faces)
sharpened, error = _vsa_snap_sharpen(
trimesh, num_proxies, lloyd_iterations, snap_strength,
)
if sharpened is None:
raise ValueError(f"Sharpening failed (vsa_snap): {error}")
# Copy metadata
if hasattr(trimesh, "metadata") and trimesh.metadata:
sharpened.metadata = trimesh.metadata.copy()
sharpened.metadata["sharpening"] = {
"algorithm": "vsa_snap",
"original_vertices": initial_vertices,
"original_faces": initial_faces,
}
# Compute displacement stats
disp = np.linalg.norm(
np.asarray(sharpened.vertices) - np.asarray(trimesh.vertices), axis=1
)
avg_disp = float(np.mean(disp))
max_disp = float(np.max(disp))
log.info("Output: %d vertices, %d faces",
len(sharpened.vertices), len(sharpened.faces))
log.info("Avg vertex displacement: %.6f, max: %.6f", avg_disp, max_disp)
param_text = (
f"Num Proxies: {num_proxies}\n"
f"Lloyd Iterations: {lloyd_iterations}\n"
f"Snap Strength: {snap_strength}"
)
info = f"""Sharpen Mesh Results (vsa_snap):
{param_text}
Vertices: {initial_vertices:,} (unchanged)
Faces: {initial_faces:,} (unchanged)
Displacement:
Average: {avg_disp:.6f}
Maximum: {max_disp:.6f}
"""
return io.NodeOutput(sharpened, info, ui={"text": [info]})
NODE_CLASS_MAPPINGS = {"GeomPackSharpen_VSASnap": SharpenVSASnapNode}
NODE_DISPLAY_NAME_MAPPINGS = {"GeomPackSharpen_VSASnap": "Sharpen VSA Snap (backend)"}
+20 -2
View File
@@ -71,15 +71,33 @@ def export_mesh_with_scalars_vtp(trimesh: trimesh_module.Trimesh, filepath: str)
# PointData section (scalar fields)
point_data = ET.SubElement(piece, 'PointData')
# Add vertex normals if available (built-in trimesh property, not in vertex_attributes)
if not is_pc and hasattr(trimesh, 'vertex_normals') and len(trimesh.vertex_normals) > 0:
try:
normals = np.asarray(trimesh.vertex_normals, dtype=np.float32)
if normals.shape == (num_verts, 3):
log.info("Adding normals field (%d vertices)", num_verts)
normals_array = ET.SubElement(point_data, 'DataArray',
type='Float32',
Name='normals',
NumberOfComponents='3',
format='ascii')
normals_array.text = ' '.join(map(str, normals.flatten()))
except Exception as e:
log.warning("Could not export normals: %s", e)
# Add vertex attributes as scalar arrays
if hasattr(trimesh, 'vertex_attributes') and trimesh.vertex_attributes:
for attr_name, attr_values in trimesh.vertex_attributes.items():
log.info("Adding scalar field: %s", attr_name)
attr_arr = np.asarray(attr_values)
num_components = attr_arr.shape[1] if attr_arr.ndim > 1 else 1
log.info("Adding scalar field: %s (components: %d)", attr_name, num_components)
scalar_array = ET.SubElement(point_data, 'DataArray',
type='Float32',
Name=attr_name,
NumberOfComponents=str(num_components),
format='ascii')
scalar_array.text = ' '.join(map(str, attr_values.flatten()))
scalar_array.text = ' '.join(map(str, attr_arr.flatten()))
# CellData section (face attributes) - only for meshes with faces
if not is_pc:
@@ -147,10 +147,8 @@ class PreviewMeshMultiNode(io.ComfyNode):
# Export mesh
if mode == "texture":
filename = f"preview_multi_{i+1}_{preview_id}.glb"
elif mesh_has_fields or mesh_is_pc:
filename = f"preview_multi_{i+1}_{preview_id}.vtp"
else:
filename = f"preview_multi_{i+1}_{preview_id}.stl"
filename = f"preview_multi_{i+1}_{preview_id}.vtp"
if COMFYUI_OUTPUT_FOLDER:
filepath = os.path.join(COMFYUI_OUTPUT_FOLDER, filename)
@@ -161,12 +159,9 @@ class PreviewMeshMultiNode(io.ComfyNode):
if mode == "texture":
mesh.export(filepath, file_type='glb', include_normals=True)
log.info("Exported GLB: %s", filepath)
elif mesh_has_fields or mesh_is_pc:
else:
export_mesh_with_scalars_vtp(mesh, filepath)
log.info("Exported VTP: %s", filepath)
else:
mesh.export(filepath, file_type='stl')
log.info("Exported STL: %s", filepath)
except Exception as e:
log.error("Export failed: %s, trying OBJ fallback", e)
filename = f"preview_multi_{i+1}_{preview_id}.obj"
+3 -15
View File
@@ -129,16 +129,9 @@ class PreviewMeshVTKNode(io.ComfyNode):
viewer_type = "texture"
log.info("Using texture mode - GLB export")
else:
# Fields mode: Export VTP/STL for scalar field visualization
if has_fields or is_pc:
# Export to VTP for: scalar fields OR point clouds (STL doesn't support point clouds)
filename = f"preview_vtk_{uuid.uuid4().hex[:8]}.vtp"
log.info("Using VTP format (fields=%s, point_cloud=%s)", has_fields, is_pc)
else:
# Export to STL (compact format for simple surface meshes)
filename = f"preview_vtk_{uuid.uuid4().hex[:8]}.stl"
filename = f"preview_vtk_{uuid.uuid4().hex[:8]}.vtp"
viewer_type = "fields"
log.info("Using fields mode - VTP/STL export")
log.info("Using fields mode - VTP export")
# Use ComfyUI's output directory
if COMFYUI_OUTPUT_FOLDER:
@@ -166,14 +159,9 @@ class PreviewMeshVTKNode(io.ComfyNode):
# Export GLB for texture/PBR rendering
trimesh.export(filepath, file_type='glb', include_normals=True)
log.info("Exported GLB to: %s", filepath)
elif has_fields or is_pc:
# Use VTP exporter for fields or point clouds
else:
export_mesh_with_scalars_vtp(trimesh, filepath)
log.info("Exported VTP to: %s", filepath)
else:
# Use STL for simple surface meshes
trimesh.export(filepath, file_type='stl')
log.info("Exported STL to: %s", filepath)
except Exception as e:
log.error("Export failed: %s", e)
# Fallback to OBJ
+1 -1
View File
@@ -1,5 +1,5 @@
# Main process deps (prestartup + lightweight shared libs)
comfy-env==0.2.38
comfy-env==0.2.39
comfy-dynamic-widgets==0.1.4
comfy-3d-viewers==0.2.30
numpy
+1 -1
View File
@@ -1 +1 @@
{"id":"test-boolean-all-0001","revision":0,"last_node_id":6,"last_link_id":8,"nodes":[{"id":2,"type":"GeomPackCreatePrimitive","pos":[-600,100],"size":[270,150],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"shape","name":"shape","type":"COMBO","widget":{"name":"shape"},"link":null},{"localized_name":"size","name":"size","type":"FLOAT","widget":{"name":"size"},"link":null},{"localized_name":"subdivisions","name":"subdivisions","shape":7,"type":"INT","widget":{"name":"subdivisions"},"link":null}],"outputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","links":[2,4]}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackCreatePrimitive"},"widgets_values":["cube",0.8,1]},{"id":3,"type":"GeomPackBoolean","pos":[-200,-200],"size":[370,102],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mesh_a","name":"mesh_a","type":"TRIMESH","link":1},{"localized_name":"mesh_b","name":"mesh_b","type":"TRIMESH","link":2},{"localized_name":"operation","name":"operation","type":"COMBO","widget":{"name":"operation"},"link":null},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null}],"outputs":[{"localized_name":"result_mesh","name":"result_mesh","type":"TRIMESH","links":[5]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackBoolean"},"widgets_values":["difference","libigl_cgal"]},{"id":4,"type":"GeomPackBoolean","pos":[-200,100],"size":[370,102],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mesh_a","name":"mesh_a","type":"TRIMESH","link":3},{"localized_name":"mesh_b","name":"mesh_b","type":"TRIMESH","link":4},{"localized_name":"operation","name":"operation","type":"COMBO","widget":{"name":"operation"},"link":null},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null}],"outputs":[{"localized_name":"result_mesh","name":"result_mesh","type":"TRIMESH","links":[6]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackBoolean"},"widgets_values":["difference","blender_exact"]},{"id":5,"type":"GeomPackPreviewMeshMulti","pos":[213.07101477171835,-194.17306733063847],"size":[768,702],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":5},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":6},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackCreatePrimitive","pos":[-600,-200],"size":[270,150],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"shape","name":"shape","type":"COMBO","widget":{"name":"shape"},"link":null},{"localized_name":"size","name":"size","type":"FLOAT","widget":{"name":"size"},"link":null},{"localized_name":"subdivisions","name":"subdivisions","shape":7,"type":"INT","widget":{"name":"subdivisions"},"link":null}],"outputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","links":[1,3]}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackCreatePrimitive"},"widgets_values":["sphere",1,4]}],"links":[[1,1,0,3,0,"TRIMESH"],[2,2,0,3,1,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[4,2,0,4,1,"TRIMESH"],[5,3,0,5,0,"TRIMESH"],[6,4,0,5,1,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.9663060000000003,"offset":[877.1812568381522,409.15273095993916]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4}
{"id":"test-boolean-all-0001","revision":0,"last_node_id":6,"last_link_id":8,"nodes":[{"id":2,"type":"GeomPackCreatePrimitive","pos":[-600,100],"size":[270,150],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"shape","name":"shape","type":"COMBO","widget":{"name":"shape"},"link":null},{"localized_name":"size","name":"size","type":"FLOAT","widget":{"name":"size"},"link":null},{"localized_name":"subdivisions","name":"subdivisions","shape":7,"type":"INT","widget":{"name":"subdivisions"},"link":null}],"outputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","links":[2,4]}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackCreatePrimitive"},"widgets_values":["cube",0.8,1]},{"id":3,"type":"GeomPackBoolean","pos":[-200,-200],"size":[370,102],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"mesh_a","name":"mesh_a","type":"TRIMESH","link":1},{"localized_name":"mesh_b","name":"mesh_b","type":"TRIMESH","link":2},{"localized_name":"operation","name":"operation","type":"COMBO","widget":{"name":"operation"},"link":null},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null}],"outputs":[{"localized_name":"result_mesh","name":"result_mesh","type":"TRIMESH","links":[5]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackBoolean"},"widgets_values":["difference","libigl_cgal"]},{"id":4,"type":"GeomPackBoolean","pos":[-200,100],"size":[370,102],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mesh_a","name":"mesh_a","type":"TRIMESH","link":3},{"localized_name":"mesh_b","name":"mesh_b","type":"TRIMESH","link":4},{"localized_name":"operation","name":"operation","type":"COMBO","widget":{"name":"operation"},"link":null},{"localized_name":"backend","name":"backend","type":"COMFY_DYNAMICCOMBO_V3","widget":{"name":"backend"},"link":null}],"outputs":[{"localized_name":"result_mesh","name":"result_mesh","type":"TRIMESH","links":[6]},{"localized_name":"info","name":"info","type":"STRING","links":null}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackBoolean"},"widgets_values":["difference","blender_exact"]},{"id":5,"type":"GeomPackPreviewMeshMulti","pos":[213.07101477171835,-194.17306733063847],"size":[768,702],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mesh_1","name":"mesh_1","type":"TRIMESH","link":5},{"localized_name":"mesh_2","name":"mesh_2","shape":7,"type":"TRIMESH","link":6},{"localized_name":"mesh_3","name":"mesh_3","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mesh_4","name":"mesh_4","shape":7,"type":"TRIMESH","link":null},{"localized_name":"mode","name":"mode","shape":7,"type":"COMBO","widget":{"name":"mode"},"link":null}],"outputs":[],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackPreviewMeshMulti"},"widgets_values":["fields","{\"show_edges\":true,\"camera_state\":\"{\\\"position\\\":[1.2245775204403364,1.2245775204403364,1.2245775204403364],\\\"focalPoint\\\":[0,0,0],\\\"viewUp\\\":[0,0,1]}\",\"selected_field\":\"\"}"]},{"id":1,"type":"GeomPackCreatePrimitive","pos":[-600,-200],"size":[270,150],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"shape","name":"shape","type":"COMBO","widget":{"name":"shape"},"link":null},{"localized_name":"size","name":"size","type":"FLOAT","widget":{"name":"size"},"link":null},{"localized_name":"subdivisions","name":"subdivisions","shape":7,"type":"INT","widget":{"name":"subdivisions"},"link":null}],"outputs":[{"localized_name":"mesh","name":"mesh","type":"TRIMESH","links":[1,3]}],"properties":{"cnr_id":"comfyui-geometrypack","ver":"224d843d5a35546212d1fb7babe30efabdf2116c","Node name for S&R":"GeomPackCreatePrimitive"},"widgets_values":["sphere",1,4]}],"links":[[1,1,0,3,0,"TRIMESH"],[2,2,0,3,1,"TRIMESH"],[3,1,0,4,0,"TRIMESH"],[4,2,0,4,1,"TRIMESH"],[5,3,0,5,0,"TRIMESH"],[6,4,0,5,1,"TRIMESH"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8719083213796316,"offset":[536.4213213855968,421.65866536973317]},"frontendVersion":"1.30.6","workflowRendererVersion":"LG"},"version":0.4}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long