diff --git a/comfy_api/latest/_util/geometry_types.py b/comfy_api/latest/_util/geometry_types.py index cdde60b10..c615700b4 100644 --- a/comfy_api/latest/_util/geometry_types.py +++ b/comfy_api/latest/_util/geometry_types.py @@ -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: diff --git a/comfy_extras/nodes_load_3d.py b/comfy_extras/nodes_load_3d.py index 9112bdd0a..1992f623c 100644 --- a/comfy_extras/nodes_load_3d.py +++ b/comfy_extras/nodes_load_3d.py @@ -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, ] diff --git a/comfy_extras/nodes_save_3d.py b/comfy_extras/nodes_save_3d.py index c03524246..0b86dbb14 100644 --- a/comfy_extras/nodes_save_3d.py +++ b/comfy_extras/nodes_save_3d.py @@ -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, diff --git a/requirements.txt b/requirements.txt index e20b6e044..4069b0e42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,3 +35,4 @@ pydantic~=2.0 pydantic-settings~=2.0 PyOpenGL glfw +trimesh>=4.0