mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-30 10:57:23 +08:00
CORE-225 add File3DToMesh node and PBR material props for MESH
This commit is contained in:
parent
38ebc19037
commit
bcf0283963
@ -17,7 +17,8 @@ class MESH:
|
||||
vertex_colors: torch.Tensor | None = None,
|
||||
texture: torch.Tensor | None = None,
|
||||
vertex_counts: torch.Tensor | None = None,
|
||||
face_counts: torch.Tensor | None = None):
|
||||
face_counts: torch.Tensor | None = None,
|
||||
material_props: dict | None = None):
|
||||
|
||||
assert (vertex_counts is None) == (face_counts is None), \
|
||||
"vertex_counts and face_counts must be provided together (both or neither)"
|
||||
@ -30,6 +31,7 @@ class MESH:
|
||||
# these hold the real per-item lengths (B,). None means rows are uniform and no slicing is needed.
|
||||
self.vertex_counts = vertex_counts
|
||||
self.face_counts = face_counts
|
||||
self.material_props = material_props
|
||||
|
||||
|
||||
class File3D:
|
||||
|
||||
@ -3,15 +3,141 @@ import folder_paths
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from typing_extensions import override
|
||||
from comfy_api.latest import IO, UI, ComfyExtension, InputImpl, Types
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_SUPPORTED_MESH_FORMATS = {"glb", "obj"}
|
||||
|
||||
|
||||
def normalize_path(path):
|
||||
return path.replace('\\', '/')
|
||||
|
||||
|
||||
def _normalize_color_factor(value, length: int):
|
||||
# trimesh stores baseColorFactor/emissiveFactor as either uint8 (0-255) or float (0-1).
|
||||
# glTF spec values are float [0, 1]; normalize here.
|
||||
arr = np.asarray(value, dtype=np.float64).reshape(-1)
|
||||
if arr.size < length:
|
||||
return None
|
||||
arr = arr[:length]
|
||||
if np.issubdtype(np.asarray(value).dtype, np.integer) or arr.max() > 1.0 + 1e-6:
|
||||
arr = arr / 255.0
|
||||
return tuple(float(x) for x in np.clip(arr, 0.0, 1.0))
|
||||
|
||||
|
||||
def _extract_material_props(material) -> dict | None:
|
||||
if material is None:
|
||||
return None
|
||||
props: dict = {}
|
||||
|
||||
bcf = getattr(material, "baseColorFactor", None)
|
||||
if bcf is not None:
|
||||
v = _normalize_color_factor(bcf, 4)
|
||||
if v is not None:
|
||||
props["base_color_factor"] = v
|
||||
ef = getattr(material, "emissiveFactor", None)
|
||||
if ef is not None:
|
||||
v = _normalize_color_factor(ef, 3)
|
||||
if v is not None:
|
||||
props["emissive_factor"] = v
|
||||
for src_attr, dst_key in (
|
||||
("metallicFactor", "metallic_factor"),
|
||||
("roughnessFactor", "roughness_factor"),
|
||||
("alphaCutoff", "alpha_cutoff"),
|
||||
):
|
||||
v = getattr(material, src_attr, None)
|
||||
if v is not None:
|
||||
props[dst_key] = float(v)
|
||||
ds = getattr(material, "doubleSided", None)
|
||||
if ds is not None:
|
||||
props["double_sided"] = bool(ds)
|
||||
am = getattr(material, "alphaMode", None)
|
||||
if am is not None:
|
||||
props["alpha_mode"] = getattr(am, "name", None) or str(am)
|
||||
|
||||
if "base_color_factor" not in props:
|
||||
# SimpleMaterial.diffuse always exists and defaults to [102, 102, 102, 255]
|
||||
# (40% gray) even when the source MTL doesn't declare Kd. Compare against the
|
||||
# trimesh default to avoid silently darkening textures that only specified map_Kd.
|
||||
diffuse = getattr(material, "diffuse", None)
|
||||
if diffuse is not None:
|
||||
d_arr = np.asarray(diffuse)
|
||||
is_default = (d_arr.dtype == np.uint8 and d_arr.shape == (4,)
|
||||
and bool(np.array_equal(d_arr, [102, 102, 102, 255])))
|
||||
if not is_default:
|
||||
v = _normalize_color_factor(diffuse, 4)
|
||||
if v is not None:
|
||||
props["base_color_factor"] = v
|
||||
|
||||
return props or None
|
||||
|
||||
|
||||
def _file3d_to_mesh(file_3d: Types.File3D) -> Types.MESH:
|
||||
import trimesh
|
||||
|
||||
fmt = (file_3d.format or "").lower()
|
||||
if fmt not in _SUPPORTED_MESH_FORMATS:
|
||||
raise ValueError(
|
||||
f"File3DToMesh only supports {sorted(_SUPPORTED_MESH_FORMATS)}, got '.{fmt}'"
|
||||
)
|
||||
|
||||
source = file_3d.get_source() if file_3d.is_disk_backed else file_3d.get_data()
|
||||
loaded = trimesh.load(source, file_type=fmt, process=False)
|
||||
|
||||
if isinstance(loaded, trimesh.Scene):
|
||||
geometries = [g for g in loaded.dump(concatenate=False) if isinstance(g, trimesh.Trimesh)]
|
||||
if not geometries:
|
||||
raise ValueError("File3DToMesh: scene contains no triangle meshes")
|
||||
mesh = trimesh.util.concatenate(geometries) if len(geometries) > 1 else geometries[0]
|
||||
elif isinstance(loaded, trimesh.Trimesh):
|
||||
mesh = loaded
|
||||
else:
|
||||
raise ValueError(f"File3DToMesh: unsupported geometry type '{type(loaded).__name__}'")
|
||||
|
||||
if len(mesh.faces) == 0:
|
||||
raise ValueError("File3DToMesh: mesh has no faces (point clouds are not supported)")
|
||||
|
||||
vertices = torch.from_numpy(np.ascontiguousarray(mesh.vertices, dtype=np.float32)).unsqueeze(0)
|
||||
faces = torch.from_numpy(np.ascontiguousarray(mesh.faces, dtype=np.int64)).unsqueeze(0)
|
||||
n_verts = vertices.shape[1]
|
||||
|
||||
uvs = None
|
||||
vertex_colors = None
|
||||
texture = None
|
||||
material_props = None
|
||||
|
||||
visual = getattr(mesh, "visual", None)
|
||||
if visual is not None:
|
||||
uv = getattr(visual, "uv", None)
|
||||
if uv is not None and len(uv) == n_verts:
|
||||
uvs = torch.from_numpy(np.ascontiguousarray(uv, dtype=np.float32)).unsqueeze(0)
|
||||
|
||||
try:
|
||||
vc = getattr(visual, "vertex_colors", None)
|
||||
except (AttributeError, ValueError, KeyError):
|
||||
vc = None
|
||||
if vc is not None and len(vc) == n_verts:
|
||||
vc_arr = np.asarray(vc, dtype=np.float32) / 255.0
|
||||
if vc_arr.ndim == 2 and vc_arr.shape[1] >= 3:
|
||||
vc_arr = vc_arr[:, :4] if vc_arr.shape[1] >= 4 else vc_arr[:, :3]
|
||||
vertex_colors = torch.from_numpy(np.ascontiguousarray(vc_arr)).unsqueeze(0)
|
||||
|
||||
material = getattr(visual, "material", None)
|
||||
if material is not None:
|
||||
tex_img = getattr(material, "baseColorTexture", None) or getattr(material, "image", None)
|
||||
if tex_img is not None:
|
||||
tex_np = np.asarray(tex_img.convert("RGB"), dtype=np.float32) / 255.0
|
||||
texture = torch.from_numpy(np.ascontiguousarray(tex_np)).unsqueeze(0)
|
||||
material_props = _extract_material_props(material)
|
||||
|
||||
return Types.MESH(vertices, faces, uvs=uvs, vertex_colors=vertex_colors,
|
||||
texture=texture, material_props=material_props)
|
||||
|
||||
|
||||
class Load3D(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@ -118,12 +244,39 @@ class Preview3D(IO.ComfyNode):
|
||||
process = execute # TODO: remove
|
||||
|
||||
|
||||
class File3DToMesh(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="File3DToMesh",
|
||||
display_name="File3D to Mesh",
|
||||
search_aliases=["parse 3d file", "load mesh"],
|
||||
category="3d",
|
||||
is_experimental=True,
|
||||
inputs=[
|
||||
IO.MultiType.Input(
|
||||
IO.File3DAny.Input("file_3d"),
|
||||
types=[IO.File3DGLB, IO.File3DOBJ],
|
||||
tooltip="3D file to parse into a MESH (.glb or .obj only)",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Mesh.Output(),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, file_3d: Types.File3D) -> IO.NodeOutput:
|
||||
return IO.NodeOutput(_file3d_to_mesh(file_3d))
|
||||
|
||||
|
||||
class Load3DExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [
|
||||
Load3D,
|
||||
Preview3D,
|
||||
File3DToMesh,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -16,11 +16,12 @@ from comfy.cli_args import args
|
||||
from comfy_api.latest import ComfyExtension, IO, Types
|
||||
|
||||
|
||||
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None):
|
||||
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, material_props=None):
|
||||
# Pack lists of (Nᵢ, *) vertex/face/color/uv tensors into padded batched tensors,
|
||||
# stashing per-item lengths as runtime attrs so consumers can recover the real slice.
|
||||
# colors and uvs are 1:1 with vertices, so they're padded to max_vertices and read with vertex_counts.
|
||||
# texture is (B, H, W, 3) — passed through unchanged
|
||||
# material_props is shared across the batch — passed through unchanged
|
||||
batch_size = len(vertices)
|
||||
max_vertices = max(v.shape[0] for v in vertices)
|
||||
max_faces = max(f.shape[0] for f in faces)
|
||||
@ -54,7 +55,8 @@ def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=Non
|
||||
|
||||
return Types.MESH(packed_vertices, packed_faces,
|
||||
uvs=packed_uvs, vertex_colors=packed_colors, texture=texture,
|
||||
vertex_counts=vertex_counts, face_counts=face_counts)
|
||||
vertex_counts=vertex_counts, face_counts=face_counts,
|
||||
material_props=material_props)
|
||||
|
||||
|
||||
def get_mesh_batch_item(mesh, index):
|
||||
@ -77,7 +79,8 @@ def get_mesh_batch_item(mesh, index):
|
||||
|
||||
|
||||
def save_glb(vertices, faces, filepath, metadata=None,
|
||||
uvs=None, vertex_colors=None, texture_image=None):
|
||||
uvs=None, vertex_colors=None, texture_image=None,
|
||||
material_props=None):
|
||||
"""
|
||||
Save PyTorch tensor vertices and faces as a GLB file without external dependencies.
|
||||
|
||||
@ -86,15 +89,25 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
||||
faces: torch.Tensor of shape (M, 3) - The face indices (triangle faces)
|
||||
filepath: str - Output filepath (should end with .glb)
|
||||
metadata: dict - Optional asset.extras metadata
|
||||
uvs: torch.Tensor of shape (N, 2) - Optional per-vertex texture coordinates
|
||||
uvs: torch.Tensor of shape (N, 2) - Optional per-vertex texture coordinates in OpenGL/trimesh
|
||||
convention (V=0 at bottom of texture). save_glb flips V
|
||||
to satisfy the glTF spec convention (V=0 at top) on disk.
|
||||
vertex_colors: torch.Tensor of shape (N, 3) or (N, 4) - Optional per-vertex colors in [0, 1]
|
||||
texture_image: PIL.Image - Optional baseColor texture, embedded as PNG
|
||||
material_props: dict - Optional PBR factors
|
||||
"""
|
||||
|
||||
# Convert tensors to numpy arrays
|
||||
vertices_np = vertices.cpu().numpy().astype(np.float32)
|
||||
faces_signed = faces.cpu().numpy().astype(np.int64)
|
||||
uvs_np = uvs.cpu().numpy().astype(np.float32) if uvs is not None else None
|
||||
if uvs_np is not None:
|
||||
# MESH stores UVs with V=0 at the bottom of the texture (OpenGL / trimesh / OBJ
|
||||
# convention). glTF stores V=0 at the top of the texture. Flip V here so the
|
||||
# written GLB renders correctly in spec-compliant viewers (Three.js, glTF Sample
|
||||
# Viewer, etc.). Copy first to avoid mutating the caller's tensor-backed array.
|
||||
uvs_np = uvs_np.copy()
|
||||
uvs_np[:, 1] = 1.0 - uvs_np[:, 1]
|
||||
colors_np = vertex_colors.cpu().numpy().astype(np.float32) if vertex_colors is not None else None
|
||||
if colors_np is not None:
|
||||
colors_np = np.clip(colors_np, 0.0, 1.0)
|
||||
@ -234,23 +247,53 @@ def save_glb(vertices, faces, filepath, metadata=None,
|
||||
textures = []
|
||||
samplers = []
|
||||
materials = []
|
||||
if texture_png_bytes is not None and "TEXCOORD_0" in primitive_attributes:
|
||||
buffer_views.append({
|
||||
"buffer": 0,
|
||||
"byteOffset": texture_byte_offset,
|
||||
"byteLength": len(texture_buffer),
|
||||
})
|
||||
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
|
||||
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
|
||||
textures.append({"source": 0, "sampler": 0})
|
||||
materials.append({
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorTexture": {"index": 0, "texCoord": 0},
|
||||
"metallicFactor": 0.0,
|
||||
"roughnessFactor": 1.0,
|
||||
},
|
||||
"doubleSided": True,
|
||||
})
|
||||
write_texture = texture_png_bytes is not None and "TEXCOORD_0" in primitive_attributes
|
||||
if write_texture or material_props:
|
||||
pbr: dict = {}
|
||||
material: dict = {"pbrMetallicRoughness": pbr}
|
||||
|
||||
if write_texture:
|
||||
buffer_views.append({
|
||||
"buffer": 0,
|
||||
"byteOffset": texture_byte_offset,
|
||||
"byteLength": len(texture_buffer),
|
||||
})
|
||||
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
|
||||
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
|
||||
textures.append({"source": 0, "sampler": 0})
|
||||
pbr["baseColorTexture"] = {"index": 0, "texCoord": 0}
|
||||
|
||||
if material_props is None:
|
||||
# Legacy default: matte plastic, double-sided. Kept for backward compatibility
|
||||
# with producers (e.g., VoxelToMesh) that never carried PBR factors.
|
||||
if write_texture:
|
||||
pbr["metallicFactor"] = 0.0
|
||||
pbr["roughnessFactor"] = 1.0
|
||||
material["doubleSided"] = True
|
||||
else:
|
||||
bcf = material_props.get("base_color_factor")
|
||||
if bcf is not None:
|
||||
pbr["baseColorFactor"] = [float(x) for x in bcf]
|
||||
mf = material_props.get("metallic_factor")
|
||||
if mf is not None:
|
||||
pbr["metallicFactor"] = float(mf)
|
||||
rf = material_props.get("roughness_factor")
|
||||
if rf is not None:
|
||||
pbr["roughnessFactor"] = float(rf)
|
||||
ef = material_props.get("emissive_factor")
|
||||
if ef is not None:
|
||||
material["emissiveFactor"] = [float(x) for x in ef]
|
||||
ds = material_props.get("double_sided")
|
||||
if ds is not None:
|
||||
material["doubleSided"] = bool(ds)
|
||||
am = material_props.get("alpha_mode")
|
||||
if am is not None:
|
||||
material["alphaMode"] = str(am)
|
||||
ac = material_props.get("alpha_cutoff")
|
||||
if ac is not None:
|
||||
material["alphaCutoff"] = float(ac)
|
||||
|
||||
materials.append(material)
|
||||
primitive["material"] = 0
|
||||
|
||||
gltf = {
|
||||
@ -358,7 +401,7 @@ class SaveGLB(IO.ComfyNode):
|
||||
})
|
||||
counter += 1
|
||||
else:
|
||||
# Handle Mesh input - save vertices and faces as GLB; carry optional UVs / colors / texture.
|
||||
# Handle Mesh input - save vertices and faces as GLB; carry optional UVs / colors / texture / material props.
|
||||
texture_b = getattr(mesh, "texture", None)
|
||||
texture_np = None
|
||||
if texture_b is not None:
|
||||
@ -366,6 +409,7 @@ class SaveGLB(IO.ComfyNode):
|
||||
assert texture_np.ndim == 4 and texture_np.shape[-1] == 3, (
|
||||
f"texture must be (B, H, W, 3) RGB, got shape {tuple(texture_np.shape)}"
|
||||
)
|
||||
material_props = getattr(mesh, "material_props", None)
|
||||
for i in range(mesh.vertices.shape[0]):
|
||||
vertices_i, faces_i, v_colors, uvs_i = get_mesh_batch_item(mesh, i)
|
||||
if vertices_i.shape[0] == 0 or faces_i.shape[0] == 0:
|
||||
@ -376,7 +420,8 @@ class SaveGLB(IO.ComfyNode):
|
||||
save_glb(vertices_i, faces_i, os.path.join(full_output_folder, f), metadata,
|
||||
uvs=uvs_i,
|
||||
vertex_colors=v_colors,
|
||||
texture_image=tex_img)
|
||||
texture_image=tex_img,
|
||||
material_props=material_props)
|
||||
results.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
|
||||
@ -35,3 +35,4 @@ pydantic~=2.0
|
||||
pydantic-settings~=2.0
|
||||
PyOpenGL
|
||||
glfw
|
||||
trimesh>=4.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user