CORE-225 add File3DToMesh node and PBR material props for MESH

This commit is contained in:
Terry Jia 2026-05-21 23:42:16 -04:00
parent 38ebc19037
commit bcf0283963
4 changed files with 225 additions and 24 deletions

View File

@ -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:

View File

@ -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,
]

View File

@ -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,

View File

@ -35,3 +35,4 @@ pydantic~=2.0
pydantic-settings~=2.0
PyOpenGL
glfw
trimesh>=4.0