"""Save-side 3D nodes: mesh packing/slicing helpers + GLB writer + SaveGLB node.""" import json import logging import os import struct from io import BytesIO import numpy as np from PIL import Image import torch from typing_extensions import override import folder_paths 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, 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) packed_vertices = vertices[0].new_zeros((batch_size, max_vertices, vertices[0].shape[1])) packed_faces = faces[0].new_zeros((batch_size, max_faces, faces[0].shape[1])) vertex_counts = torch.tensor([v.shape[0] for v in vertices], device=vertices[0].device, dtype=torch.int64) face_counts = torch.tensor([f.shape[0] for f in faces], device=faces[0].device, dtype=torch.int64) for i, (v, f) in enumerate(zip(vertices, faces)): packed_vertices[i, :v.shape[0]] = v packed_faces[i, :f.shape[0]] = f packed_colors = None if colors is not None: packed_colors = colors[0].new_zeros((batch_size, max_vertices, colors[0].shape[1])) for i, c in enumerate(colors): assert c.shape[0] == vertices[i].shape[0], ( f"vertex_colors[{i}] has {c.shape[0]} entries, expected {vertices[i].shape[0]} (1:1 with vertices)" ) packed_colors[i, :c.shape[0]] = c packed_uvs = None if uvs is not None: packed_uvs = uvs[0].new_zeros((batch_size, max_vertices, uvs[0].shape[1])) for i, u in enumerate(uvs): assert u.shape[0] == vertices[i].shape[0], ( f"uvs[{i}] has {u.shape[0]} entries, expected {vertices[i].shape[0]} (1:1 with vertices)" ) packed_uvs[i, :u.shape[0]] = u return Types.MESH(packed_vertices, packed_faces, uvs=packed_uvs, vertex_colors=packed_colors, texture=texture, vertex_counts=vertex_counts, face_counts=face_counts, material_props=material_props) def get_mesh_batch_item(mesh, index): # Returns (vertices, faces, colors, uvs) for batch index, slicing to real lengths # if the mesh carries per-item counts (variable-size batch). v_colors = getattr(mesh, "vertex_colors", None) v_uvs = getattr(mesh, "uvs", None) if getattr(mesh, "vertex_counts", None) is not None: vertex_count = int(mesh.vertex_counts[index].item()) face_count = int(mesh.face_counts[index].item()) vertices = mesh.vertices[index, :vertex_count] faces = mesh.faces[index, :face_count] colors = v_colors[index, :vertex_count] if v_colors is not None else None uvs = v_uvs[index, :vertex_count] if v_uvs is not None else None return vertices, faces, colors, uvs colors = v_colors[index] if v_colors is not None else None uvs = v_uvs[index] if v_uvs is not None else None return mesh.vertices[index], mesh.faces[index], colors, uvs def save_glb(vertices, faces, filepath, metadata=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. Parameters: vertices: torch.Tensor of shape (N, 3) - The vertex coordinates 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 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) n_verts = vertices_np.shape[0] if n_verts == 0: raise ValueError("save_glb: vertices is empty") if faces_signed.size > 0: fmin = int(faces_signed.min()) fmax = int(faces_signed.max()) if fmin < 0 or fmax >= n_verts: raise ValueError( f"save_glb: face index out of range [0, {n_verts}): min={fmin}, max={fmax}" ) if uvs_np is not None and uvs_np.shape[0] != n_verts: raise ValueError( f"save_glb: uvs has {uvs_np.shape[0]} entries but vertex count is {n_verts}" ) if colors_np is not None and colors_np.shape[0] != n_verts: raise ValueError( f"save_glb: vertex_colors has {colors_np.shape[0]} entries but vertex count is {n_verts}" ) faces_np = faces_signed.astype(np.uint32) texture_png_bytes = None if texture_image is not None: buf = BytesIO() texture_image.save(buf, format="PNG") texture_png_bytes = buf.getvalue() vertices_buffer = vertices_np.tobytes() indices_buffer = faces_np.tobytes() uvs_buffer = uvs_np.tobytes() if uvs_np is not None else b"" colors_buffer = colors_np.tobytes() if colors_np is not None else b"" texture_buffer = texture_png_bytes if texture_png_bytes is not None else b"" def pad_to_4_bytes(buffer): padding_length = (4 - (len(buffer) % 4)) % 4 return buffer + b'\x00' * padding_length vertices_buffer_padded = pad_to_4_bytes(vertices_buffer) indices_buffer_padded = pad_to_4_bytes(indices_buffer) uvs_buffer_padded = pad_to_4_bytes(uvs_buffer) colors_buffer_padded = pad_to_4_bytes(colors_buffer) texture_buffer_padded = pad_to_4_bytes(texture_buffer) buffer_data = b"".join([ vertices_buffer_padded, indices_buffer_padded, uvs_buffer_padded, colors_buffer_padded, texture_buffer_padded, ]) vertices_byte_length = len(vertices_buffer) vertices_byte_offset = 0 indices_byte_length = len(indices_buffer) indices_byte_offset = len(vertices_buffer_padded) uvs_byte_offset = indices_byte_offset + len(indices_buffer_padded) colors_byte_offset = uvs_byte_offset + len(uvs_buffer_padded) texture_byte_offset = colors_byte_offset + len(colors_buffer_padded) buffer_views = [ { "buffer": 0, "byteOffset": vertices_byte_offset, "byteLength": vertices_byte_length, "target": 34962 # ARRAY_BUFFER }, { "buffer": 0, "byteOffset": indices_byte_offset, "byteLength": indices_byte_length, "target": 34963 # ELEMENT_ARRAY_BUFFER } ] accessors = [ { "bufferView": 0, "byteOffset": 0, "componentType": 5126, # FLOAT "count": len(vertices_np), "type": "VEC3", "max": vertices_np.max(axis=0).tolist(), "min": vertices_np.min(axis=0).tolist() }, { "bufferView": 1, "byteOffset": 0, "componentType": 5125, # UNSIGNED_INT "count": faces_np.size, "type": "SCALAR" } ] primitive_attributes = {"POSITION": 0} if uvs_np is not None and len(uvs_np) > 0: buffer_views.append({ "buffer": 0, "byteOffset": uvs_byte_offset, "byteLength": len(uvs_buffer), "target": 34962 }) accessor_idx = len(accessors) accessors.append({ "bufferView": len(buffer_views) - 1, "byteOffset": 0, "componentType": 5126, "count": len(uvs_np), "type": "VEC2", }) primitive_attributes["TEXCOORD_0"] = accessor_idx if colors_np is not None and len(colors_np) > 0: buffer_views.append({ "buffer": 0, "byteOffset": colors_byte_offset, "byteLength": len(colors_buffer), "target": 34962 }) accessor_idx = len(accessors) accessors.append({ "bufferView": len(buffer_views) - 1, "byteOffset": 0, "componentType": 5126, "count": len(colors_np), "type": "VEC3" if colors_np.shape[1] == 3 else "VEC4", }) primitive_attributes["COLOR_0"] = accessor_idx primitive = { "attributes": primitive_attributes, "indices": 1, "mode": 4 # TRIANGLES } images = [] textures = [] samplers = [] materials = [] 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 = { "asset": {"version": "2.0", "generator": "ComfyUI"}, "buffers": [{"byteLength": len(buffer_data)}], "bufferViews": buffer_views, "accessors": accessors, "meshes": [{"primitives": [primitive]}], "nodes": [{"mesh": 0}], "scenes": [{"nodes": [0]}], "scene": 0, } if images: gltf["images"] = images if samplers: gltf["samplers"] = samplers if textures: gltf["textures"] = textures if materials: gltf["materials"] = materials if metadata: gltf["asset"]["extras"] = metadata # Convert the JSON to bytes gltf_json = json.dumps(gltf).encode('utf8') def pad_json_to_4_bytes(buffer): padding_length = (4 - (len(buffer) % 4)) % 4 return buffer + b' ' * padding_length gltf_json_padded = pad_json_to_4_bytes(gltf_json) # Create the GLB header (a 4-byte ASCII magic identifier glTF) glb_header = struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(gltf_json_padded) + 8 + len(buffer_data)) # Create JSON chunk header (chunk type 0) json_chunk_header = struct.pack('