feat(3d): File3DToMesh node — parse GLB/GLTF/OBJ/STL into MESH (#15919)

This commit is contained in:
Terry Jia
2026-08-27 21:35:30 -04:00
committed by GitHub
parent 4c6359f2e3
commit a255b96872
4 changed files with 746 additions and 0 deletions
+410
View File
@@ -0,0 +1,410 @@
import base64
import json
import os
import struct
import urllib.parse
from io import BytesIO
import numpy as np
from PIL import Image
_COMPONENT_DTYPES = {
5120: np.int8,
5121: np.uint8,
5122: np.int16,
5123: np.uint16,
5125: np.uint32,
5126: np.float32,
}
_TYPE_SIZES = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16}
_SUPPORTED_REQUIRED = {
"EXT_mesh_gpu_instancing",
"EXT_texture_webp",
"KHR_materials_emissive_strength",
"KHR_materials_unlit",
}
_JSON_CHUNK = 0x4E4F534A
_BIN_CHUNK = 0x004E4942
def parse_container(data: bytes):
if data[:4] == b"glTF":
if len(data) < 12:
raise ValueError("GLB file truncated (missing 12-byte header)")
_, version, _ = struct.unpack_from("<4sII", data, 0)
if version != 2:
raise ValueError(f"unsupported GLB container version {version}")
json_chunk = None
bin_chunk = None
offset = 12
while offset + 8 <= len(data):
chunk_len, chunk_type = struct.unpack_from("<II", data, offset)
offset += 8
chunk = data[offset:offset + chunk_len]
offset += chunk_len
if chunk_type == _JSON_CHUNK and json_chunk is None:
json_chunk = chunk
elif chunk_type == _BIN_CHUNK and bin_chunk is None:
bin_chunk = chunk
if json_chunk is None:
raise ValueError("GLB file has no JSON chunk")
return json.loads(json_chunk), bin_chunk
return json.loads(data), None
def _resolve_uri(uri: str, base_dir: str | None) -> bytes:
if uri.startswith("data:"):
header, _, payload = uri.partition(",")
if ";base64" in header:
return base64.b64decode(payload)
return urllib.parse.unquote_to_bytes(payload)
scheme = urllib.parse.urlparse(uri).scheme
if scheme:
raise ValueError(f"glTF references URI scheme {scheme!r}; only relative file paths are allowed")
if base_dir is None:
raise ValueError(
f"glTF references external file {uri!r} but the source is an in-memory stream; "
"use .glb (self-contained) or a disk-backed file"
)
relative = urllib.parse.unquote(uri)
root = os.path.realpath(base_dir)
path = os.path.realpath(os.path.join(root, relative))
try:
contained = not os.path.isabs(relative) and os.path.commonpath([root, path]) == root
except ValueError:
contained = False
if not contained:
raise ValueError(f"glTF references file {uri!r} outside the model directory")
with open(path, "rb") as f:
return f.read()
def load_buffers(gltf: dict, bin_chunk: bytes | None, base_dir: str | None) -> list[bytes]:
buffers = []
for buf in gltf.get("buffers", []):
if "uri" in buf:
buffers.append(_resolve_uri(buf["uri"], base_dir))
else:
if bin_chunk is None:
raise ValueError("glTF buffer has no uri and there is no GLB BIN chunk")
buffers.append(bin_chunk)
return buffers
def _view_data(gltf: dict, buffers: list[bytes], view_index: int) -> bytes:
view = gltf["bufferViews"][view_index]
buf = buffers[view.get("buffer", 0)]
start = view.get("byteOffset", 0)
return buf[start:start + view["byteLength"]]
def read_accessor(gltf: dict, buffers: list[bytes], index: int):
acc = gltf["accessors"][index]
count = acc["count"]
ncomp = _TYPE_SIZES[acc["type"]]
dtype = np.dtype(_COMPONENT_DTYPES[acc["componentType"]])
elem = dtype.itemsize * ncomp
if "bufferView" in acc:
view = gltf["bufferViews"][acc["bufferView"]]
buf = buffers[view.get("buffer", 0)]
start = view.get("byteOffset", 0) + acc.get("byteOffset", 0)
stride = view.get("byteStride") or elem
if stride == elem:
arr = np.frombuffer(buf, dtype, count * ncomp, start).reshape(count, ncomp).copy()
else:
raw = np.frombuffer(buf, np.uint8, stride * (count - 1) + elem, start)
rows = np.lib.stride_tricks.as_strided(raw, (count, elem), (stride, 1))
arr = np.ascontiguousarray(rows).view(dtype).reshape(count, ncomp)
else:
arr = np.zeros((count, ncomp), dtype)
sparse = acc.get("sparse")
if sparse:
n = sparse["count"]
idx_def = sparse["indices"]
val_def = sparse["values"]
idx_dtype = np.dtype(_COMPONENT_DTYPES[idx_def["componentType"]])
iview = gltf["bufferViews"][idx_def["bufferView"]]
ibuf = buffers[iview.get("buffer", 0)]
sidx = np.frombuffer(ibuf, idx_dtype, n,
iview.get("byteOffset", 0) + idx_def.get("byteOffset", 0)).astype(np.int64)
vview = gltf["bufferViews"][val_def["bufferView"]]
vbuf = buffers[vview.get("buffer", 0)]
svals = np.frombuffer(vbuf, dtype, n * ncomp,
vview.get("byteOffset", 0) + val_def.get("byteOffset", 0)).reshape(n, ncomp)
arr[sidx] = svals
return arr, bool(acc.get("normalized"))
def to_float(arr: np.ndarray, normalized: bool) -> np.ndarray:
if arr.dtype == np.float32:
return arr
out = arr.astype(np.float32)
if not normalized:
return out
if arr.dtype == np.uint8:
return out / 255.0
if arr.dtype == np.uint16:
return out / 65535.0
if arr.dtype == np.int8:
return np.maximum(out / 127.0, -1.0)
if arr.dtype == np.int16:
return np.maximum(out / 32767.0, -1.0)
return out
def _quat_to_matrix(x: float, y: float, z: float, w: float) -> np.ndarray:
return np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
], dtype=np.float32)
def _node_local_matrix(node: dict) -> np.ndarray:
if "matrix" in node:
return np.array(node["matrix"], np.float32).reshape(4, 4).T # glTF stores column-major
m = np.eye(4, dtype=np.float32)
rot = _quat_to_matrix(*node.get("rotation", (0.0, 0.0, 0.0, 1.0)))
scale = np.array(node.get("scale", (1.0, 1.0, 1.0)), np.float32)
m[:3, :3] = rot * scale
m[:3, 3] = node.get("translation", (0.0, 0.0, 0.0))
return m
def _instance_matrices(gltf: dict, buffers: list[bytes], node: dict, warn):
attrs = node.get("extensions", {}).get("EXT_mesh_gpu_instancing", {}).get("attributes", {})
if not attrs:
return None
counts = {gltf["accessors"][a]["count"] for a in attrs.values()}
if len(counts) > 1:
raise ValueError(f"EXT_mesh_gpu_instancing attribute accessors disagree on instance count: {sorted(counts)}")
if not any(k in attrs for k in ("TRANSLATION", "ROTATION", "SCALE")):
warn("instancing-custom", "EXT_mesh_gpu_instancing has only custom attributes; importing a single copy")
return None
translation = to_float(*read_accessor(gltf, buffers, attrs["TRANSLATION"])) if "TRANSLATION" in attrs else None
rotation = to_float(*read_accessor(gltf, buffers, attrs["ROTATION"])) if "ROTATION" in attrs else None
scale = to_float(*read_accessor(gltf, buffers, attrs["SCALE"])) if "SCALE" in attrs else None
count = counts.pop()
matrices = []
for i in range(count):
m = np.eye(4, dtype=np.float32)
rot = _quat_to_matrix(*rotation[i]) if rotation is not None else np.eye(3, dtype=np.float32)
m[:3, :3] = rot * (scale[i] if scale is not None else 1.0)
if translation is not None:
m[:3, 3] = translation[i]
matrices.append(m)
return matrices
def _iter_mesh_nodes(gltf: dict, buffers: list[bytes], warn):
nodes = gltf.get("nodes", [])
scenes = gltf.get("scenes")
if scenes:
roots = scenes[gltf.get("scene", 0)].get("nodes", [])
elif nodes:
children = {c for n in nodes for c in n.get("children", [])}
roots = [i for i in range(len(nodes)) if i not in children]
else:
for i in range(len(gltf.get("meshes", []))):
yield {"mesh": i}, np.eye(4, dtype=np.float32)
return
seen = set()
stack = [(i, np.eye(4, dtype=np.float32)) for i in roots]
while stack:
index, parent = stack.pop()
if index in seen:
continue
seen.add(index)
node = nodes[index]
world = parent @ _node_local_matrix(node)
if "mesh" in node:
instances = _instance_matrices(gltf, buffers, node, warn)
if instances is None:
yield node, world
else:
warn("instancing", f"EXT_mesh_gpu_instancing: expanding {len(instances)} instances into merged geometry")
for matrix in instances:
yield node, world @ matrix
for child in node.get("children", []):
stack.append((child, world))
def _to_triangles(indices: np.ndarray, mode: int) -> np.ndarray:
if mode == 4:
if len(indices) % 3:
raise ValueError("TRIANGLES primitive index count must be divisible by 3")
return indices.reshape(-1, 3)
if len(indices) < 3:
return np.zeros((0, 3), np.int64)
if mode == 6:
first = np.full(len(indices) - 2, indices[0], dtype=np.int64)
return np.stack([first, indices[1:-1], indices[2:]], axis=1)
tris = np.stack([indices[:-2], indices[1:-1], indices[2:]], axis=1)
tris[1::2] = tris[1::2, ::-1]
return tris
def _vertex_attr(gltf, buffers, accessor_index, n_verts, warn, label):
arr = to_float(*read_accessor(gltf, buffers, accessor_index))
if arr.shape[0] < n_verts:
warn(f"count:{label}", f"{label} has {arr.shape[0]} entries for {n_verts} vertices; attribute dropped")
return None
return arr[:n_verts]
def load_scene_geometry(gltf: dict, buffers: list[bytes], warn) -> list[dict]:
required = set(gltf.get("extensionsRequired", []))
unsupported = required - _SUPPORTED_REQUIRED
if unsupported:
raise ValueError(f"glTF requires extensions this loader does not support: {sorted(unsupported)}")
prims = []
for node, world in _iter_mesh_nodes(gltf, buffers, warn):
if "skin" in node:
warn("skin", "skinned mesh: joints/weights ignored, geometry imported in bind pose")
linear = world[:3, :3]
det = float(np.linalg.det(linear))
normal_mat = np.linalg.inv(linear).T if abs(det) > 1e-12 else linear
flip_winding = det < 0.0
for prim in gltf["meshes"][node["mesh"]].get("primitives", []):
mode = prim.get("mode", 4)
if mode not in (4, 5, 6):
warn(f"mode{mode}", f"skipping non-triangle primitive (mode {mode})")
continue
attrs = prim.get("attributes", {})
if "POSITION" not in attrs:
continue
pos = to_float(*read_accessor(gltf, buffers, attrs["POSITION"]))[:, :3]
n_verts = pos.shape[0]
if n_verts == 0:
continue
pos = pos @ linear.T + world[:3, 3]
if "indices" in prim:
indices = read_accessor(gltf, buffers, prim["indices"])[0].reshape(-1).astype(np.int64)
else:
indices = np.arange(n_verts, dtype=np.int64)
faces = _to_triangles(indices, mode)
if faces.shape[0] == 0:
continue
if faces.min() < 0 or faces.max() >= n_verts:
raise ValueError("primitive contains a face index outside its POSITION accessor")
if flip_winding:
faces = np.ascontiguousarray(faces[:, ::-1])
if prim.get("targets"):
warn("morph", "morph targets ignored; base geometry imported")
out = {"positions": np.ascontiguousarray(pos, np.float32), "faces": faces,
"uvs": None, "colors": None, "normals": None, "tangents": None,
"material": prim.get("material")}
if "TEXCOORD_0" in attrs:
uv = _vertex_attr(gltf, buffers, attrs["TEXCOORD_0"], n_verts, warn, "TEXCOORD_0")
out["uvs"] = uv[:, :2] if uv is not None else None
if "COLOR_0" in attrs:
arr, normalized = read_accessor(gltf, buffers, attrs["COLOR_0"])
arr = to_float(arr, normalized or arr.dtype != np.float32)
if arr.shape[0] >= n_verts:
out["colors"] = np.clip(arr[:n_verts], 0.0, 1.0)
if "NORMAL" in attrs:
nrm = _vertex_attr(gltf, buffers, attrs["NORMAL"], n_verts, warn, "NORMAL")
if nrm is not None:
nrm = nrm[:, :3] @ normal_mat.T
out["normals"] = np.ascontiguousarray(
nrm / np.maximum(np.linalg.norm(nrm, axis=1, keepdims=True), 1e-12), np.float32)
if "TANGENT" in attrs:
tan = _vertex_attr(gltf, buffers, attrs["TANGENT"], n_verts, warn, "TANGENT")
if tan is not None and tan.shape[1] == 4:
txyz = tan[:, :3] @ linear.T
txyz /= np.maximum(np.linalg.norm(txyz, axis=1, keepdims=True), 1e-12)
tw = tan[:, 3:4] * (-1.0 if flip_winding else 1.0)
out["tangents"] = np.ascontiguousarray(np.concatenate([txyz, tw], axis=1), np.float32)
prims.append(out)
return prims
def _decode_texture(gltf, buffers, base_dir, tex_info, warn, label):
if tex_info is None:
return None
if tex_info.get("texCoord", 0) != 0:
warn(f"texcoord:{label}", f"{label} uses TEXCOORD_{tex_info['texCoord']}; MESH only carries TEXCOORD_0")
if "KHR_texture_transform" in tex_info.get("extensions", {}):
warn("textransform", "KHR_texture_transform ignored; UVs used as-is")
tex_def = gltf["textures"][tex_info["index"]]
source = tex_def.get("source")
if source is None:
webp = tex_def.get("extensions", {}).get("EXT_texture_webp")
source = webp.get("source") if webp is not None else None
if source is None:
warn(f"compressed:{label}", f"{label}: compressed texture (basisu/ktx2) without fallback; skipped")
return None
image_def = gltf["images"][source]
if "bufferView" in image_def:
raw = _view_data(gltf, buffers, image_def["bufferView"])
elif "uri" in image_def:
raw = _resolve_uri(image_def["uri"], base_dir)
else:
return None
img = Image.open(BytesIO(bytes(raw)))
return np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
def extract_material(gltf: dict, buffers: list[bytes], base_dir: str | None, material_index, warn) -> dict:
result = {"texture": None, "metallic_roughness": None, "normal_map": None, "emissive": None,
"occlusion_in_mr": False, "unlit": False, "material": None}
if material_index is None:
return result
mat = gltf["materials"][material_index]
extensions = mat.get("extensions", {})
result["unlit"] = "KHR_materials_unlit" in extensions
pbr = mat.get("pbrMetallicRoughness", {})
result["texture"] = _decode_texture(gltf, buffers, base_dir, pbr.get("baseColorTexture"), warn, "baseColorTexture")
mr_info = pbr.get("metallicRoughnessTexture")
result["metallic_roughness"] = _decode_texture(gltf, buffers, base_dir, mr_info, warn, "metallicRoughnessTexture")
normal_info = mat.get("normalTexture")
result["normal_map"] = _decode_texture(gltf, buffers, base_dir, normal_info, warn, "normalTexture")
result["emissive"] = _decode_texture(gltf, buffers, base_dir, mat.get("emissiveTexture"), warn, "emissiveTexture")
occlusion = mat.get("occlusionTexture")
if occlusion is not None:
if mr_info is not None and occlusion["index"] == mr_info["index"]:
result["occlusion_in_mr"] = True
else:
warn("occlusion", "standalone occlusionTexture not representable in MESH (ORM packing only); skipped")
overrides = {}
base_color = pbr.get("baseColorFactor")
if base_color is not None and list(base_color) != [1.0, 1.0, 1.0, 1.0]:
overrides["base_color_factor"] = [float(c) for c in base_color]
overrides["metallic_factor"] = float(pbr.get("metallicFactor", 1.0))
overrides["roughness_factor"] = float(pbr.get("roughnessFactor", 1.0))
overrides["double_sided"] = bool(mat.get("doubleSided", False))
if normal_info is not None and normal_info.get("scale", 1.0) != 1.0:
overrides["normal_scale"] = float(normal_info["scale"])
if occlusion is not None and occlusion.get("strength", 1.0) != 1.0:
overrides["occlusion_strength"] = float(occlusion["strength"])
emissive_factor = mat.get("emissiveFactor", (0.0, 0.0, 0.0))
if any(c > 0.0 for c in emissive_factor):
overrides["emissive_factor"] = [float(c) for c in emissive_factor]
strength = extensions.get("KHR_materials_emissive_strength", {}).get("emissiveStrength")
if strength is not None:
overrides["emissive_strength"] = float(strength)
result["material"] = overrides
return result
def load_gltf(data: bytes, base_dir: str | None, warn):
gltf, bin_chunk = parse_container(data)
version = str(gltf.get("asset", {}).get("version", ""))
if version.partition(".")[0] != "2":
raise ValueError(f"unsupported glTF asset version {version or 'unknown'}; only glTF 2.x is supported")
buffers = load_buffers(gltf, bin_chunk, base_dir)
return gltf, buffers, load_scene_geometry(gltf, buffers, warn)
__all__ = ["load_gltf", "extract_material", "parse_container", "read_accessor", "to_float"]
@@ -0,0 +1,172 @@
import logging
import re
import struct
import numpy as np
def _srgb_to_linear(c: np.ndarray) -> np.ndarray:
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4).astype(np.float32)
def load_obj(data: bytes) -> dict:
text = data.decode("utf-8", errors="replace")
text = text.replace("\r\n", "\n").replace("\\\n", "")
positions: list[tuple] = []
pos_colors: list[tuple] = []
uvs: list[tuple] = []
normals: list[tuple] = []
corner_map: dict[tuple, int] = {}
out_pos: list[tuple] = []
out_col: list[tuple] = []
out_uv: list[tuple] = []
out_nrm: list[tuple] = []
faces: list[tuple] = []
has_color = False
any_uv = False
any_normal = False
missing_normal = False
warned_mtl = False
def resolve(index_str: str, count: int) -> int:
i = int(index_str)
resolved = i - 1 if i > 0 else count + i
if i == 0 or not 0 <= resolved < count:
raise ValueError(f"OBJ index {i} out of range for {count} entries")
return resolved
def corner(spec: str) -> int:
nonlocal any_uv, any_normal, missing_normal
parts = spec.split("/")
vi = resolve(parts[0], len(positions))
ti = resolve(parts[1], len(uvs)) if len(parts) > 1 and parts[1] else None
ni = resolve(parts[2], len(normals)) if len(parts) > 2 and parts[2] else None
key = (vi, ti, ni)
cached = corner_map.get(key)
if cached is not None:
return cached
index = len(out_pos)
out_pos.append(positions[vi])
out_col.append(pos_colors[vi])
if ti is not None:
any_uv = True
u, v = uvs[ti]
out_uv.append((u, 1.0 - v))
else:
out_uv.append((0.0, 0.0))
if ni is not None:
any_normal = True
out_nrm.append(normals[ni])
else:
missing_normal = True
out_nrm.append((0.0, 0.0, 0.0))
corner_map[key] = index
return index
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
tag = parts[0]
if tag == "v":
positions.append(tuple(float(x) for x in parts[1:4]))
if len(parts) >= 7:
pos_colors.append(tuple(float(x) for x in parts[4:7]))
has_color = True
else:
pos_colors.append((1.0, 1.0, 1.0))
elif tag == "vt":
uvs.append((float(parts[1]), float(parts[2]) if len(parts) > 2 else 0.0))
elif tag == "vn":
normals.append(tuple(float(x) for x in parts[1:4]))
elif tag == "f":
specs = parts[1:]
if len(specs) < 3:
continue
indices = [corner(s) for s in specs]
for i in range(1, len(indices) - 1):
faces.append((indices[0], indices[i], indices[i + 1]))
elif tag in ("mtllib", "usemtl") and not warned_mtl:
warned_mtl = True
logging.warning("Get3DComponents: OBJ materials (.mtl) are not loaded; geometry only")
if not faces:
raise ValueError("OBJ contains no faces")
prim = {
"positions": np.array(out_pos, np.float32),
"faces": np.array(faces, np.int64),
"uvs": np.array(out_uv, np.float32) if any_uv else None,
"colors": _srgb_to_linear(np.clip(np.array(out_col, np.float32), 0.0, 1.0)) if has_color else None,
"normals": None,
"tangents": None,
"material": None,
}
if any_normal and missing_normal:
logging.warning("Get3DComponents: OBJ has faces without vn indices; normals dropped")
elif any_normal:
nrm = np.array(out_nrm, np.float32)
lengths = np.linalg.norm(nrm, axis=1, keepdims=True)
if float(lengths.max()) > 1e-6:
prim["normals"] = nrm / np.maximum(lengths, 1e-12)
return prim
_STL_RECORD = np.dtype([("normal", "<f4", (3,)), ("verts", "<f4", (3, 3)), ("attr", "<u2")])
_STL_ASCII_FACET = re.compile(
rb"facet\s+normal\s+(\S+)\s+(\S+)\s+(\S+).*?"
rb"vertex\s+(\S+)\s+(\S+)\s+(\S+).*?"
rb"vertex\s+(\S+)\s+(\S+)\s+(\S+).*?"
rb"vertex\s+(\S+)\s+(\S+)\s+(\S+)",
re.DOTALL,
)
def load_stl(data: bytes) -> dict:
n_faces = struct.unpack_from("<I", data, 80)[0] if len(data) >= 84 else 0
if n_faces > 0 and 84 + n_faces * _STL_RECORD.itemsize == len(data):
return _load_stl_binary(data, n_faces)
if b"solid" in data[:9]:
facets = _STL_ASCII_FACET.findall(data)
if not facets:
raise ValueError("ASCII STL contains no facets")
values = np.array(facets).astype(np.float32)
positions = values[:, 3:12].reshape(-1, 3)
file_normals = np.repeat(values[:, 0:3], 3, axis=0)
return _stl_prim(positions, file_normals)
available = (len(data) - 84) // _STL_RECORD.itemsize if len(data) >= 84 else 0
n_faces = min(n_faces, available)
if n_faces <= 0:
raise ValueError("not a valid STL file (neither binary layout nor ASCII 'solid')")
return _load_stl_binary(data, n_faces)
def _load_stl_binary(data: bytes, n_faces: int) -> dict:
records = np.frombuffer(data, _STL_RECORD, count=n_faces, offset=84)
positions = records["verts"].reshape(-1, 3).astype(np.float32)
file_normals = np.repeat(records["normal"], 3, axis=0).astype(np.float32)
return _stl_prim(positions, file_normals, _stl_binary_colors(data[:80], records["attr"]))
def _stl_binary_colors(header: bytes, attr: np.ndarray):
pos = header.find(b"COLOR=")
if pos < 0 or pos + 10 > len(header):
return None
default = np.frombuffer(header, np.uint8, 3, pos + 6).astype(np.float32) / 255.0
a = attr.astype(np.uint16)
per_face = np.stack([a & 0x1F, (a >> 5) & 0x1F, (a >> 10) & 0x1F], axis=1).astype(np.float32) / 31.0
face_colors = np.where(((a & 0x8000) != 0)[:, None], default[None, :], per_face)
return _srgb_to_linear(np.repeat(face_colors, 3, axis=0))
def _stl_prim(positions: np.ndarray, normals: np.ndarray, colors=None) -> dict:
if positions.shape[0] == 0:
raise ValueError("STL contains no facets")
faces = np.arange(positions.shape[0], dtype=np.int64).reshape(-1, 3)
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
normals = normals / np.maximum(lengths, 1e-12) if float(lengths.max()) > 1e-6 else None
return {"positions": np.ascontiguousarray(positions), "faces": faces, "uvs": None,
"colors": colors, "normals": normals, "tangents": None, "material": None}
+163
View File
@@ -0,0 +1,163 @@
import logging
import os
import numpy as np
import torch
from typing_extensions import override
from comfy_api.latest import ComfyExtension, IO, Types
from comfy_extras.mesh3d.fileio import gltf_read, mesh_file_read
def _sniff_format(data: bytes) -> str:
if data[:4] == b"glTF":
return "glb"
head = data[:512].lstrip()
if head[:1] == b"{":
return "gltf"
if head[:5].lower() == b"solid":
return "stl"
return ""
def _merge_primitives(prims: list[dict]) -> dict:
any_uv = any(p["uvs"] is not None for p in prims)
any_color = any(p["colors"] is not None for p in prims)
all_normals = all(p["normals"] is not None for p in prims)
all_tangents = all_normals and all(p["tangents"] is not None for p in prims)
color_channels = max((p["colors"].shape[1] for p in prims if p["colors"] is not None), default=3)
if not all_normals and any(p["normals"] is not None for p in prims):
logging.warning("Get3DComponents: some primitives lack normals; normals dropped "
"(MeshSmoothNormals can regenerate them)")
verts, faces, uvs, colors, normals, tangents = [], [], [], [], [], []
offset = 0
for p in prims:
v = p["positions"]
n = v.shape[0]
verts.append(v)
faces.append(p["faces"] + offset)
offset += n
if any_uv:
uvs.append(p["uvs"] if p["uvs"] is not None else np.zeros((n, 2), np.float32))
if any_color:
c = p["colors"] if p["colors"] is not None else np.ones((n, color_channels), np.float32)
if c.shape[1] < color_channels:
c = np.concatenate([c, np.ones((n, color_channels - c.shape[1]), np.float32)], axis=1)
colors.append(c)
if all_normals:
normals.append(p["normals"])
if all_tangents:
tangents.append(p["tangents"])
return {
"vertices": np.concatenate(verts, axis=0),
"faces": np.concatenate(faces, axis=0),
"uvs": np.concatenate(uvs, axis=0) if any_uv else None,
"colors": np.concatenate(colors, axis=0) if any_color else None,
"normals": np.concatenate(normals, axis=0) if all_normals else None,
"tangents": np.concatenate(tangents, axis=0) if all_tangents else None,
}
def _batch(arr):
return torch.from_numpy(arr)[None] if arr is not None else None
class Get3DComponents(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="Get3DComponents",
display_name="Get 3D Components",
category="3d",
description=(
"Parse a 3D model file (GLB, GLTF, OBJ, STL) into an editable MESH for the "
"mesh-processing nodes (decimate, remesh, UV unwrap, bake, ...). All scene "
"nodes/primitives are merged into one mesh with their transforms applied; "
"textures and material factors come from the first material. "
"Counterpart of MeshToFile3D."
),
search_aliases=["file 3d to mesh", "extract mesh", "convert 3d", "parse glb", "import mesh",
"load mesh from file", "file to mesh"],
is_experimental=True,
inputs=[
IO.MultiType.Input(
"model_3d",
types=[IO.File3DGLB, IO.File3DGLTF, IO.File3DOBJ, IO.File3DSTL, IO.File3DAny],
tooltip="3D model file from Load 3D or another 3D node. "
"FBX/USDZ are not supported - convert to GLB first.",
),
],
outputs=[IO.Mesh.Output(display_name="mesh")],
)
@classmethod
def execute(cls, model_3d: Types.File3D) -> IO.NodeOutput:
data = model_3d.get_bytes()
fmt = (model_3d.format or _sniff_format(data)).lower()
if fmt in ("fbx", "usdz"):
raise ValueError(f"Get3DComponents: .{fmt} parsing is not supported; convert the model to GLB/GLTF first")
warned = set()
def warn_once(key, message):
if key not in warned:
warned.add(key)
logging.warning("Get3DComponents: %s", message)
material_info = None
if fmt in ("glb", "gltf"):
base_dir = os.path.dirname(model_3d.get_source()) if model_3d.is_disk_backed else None
gltf, buffers, prims = gltf_read.load_gltf(data, base_dir, warn_once)
if not prims:
raise ValueError("Get3DComponents: no triangle geometry found in the glTF scene")
material_indices = [p["material"] for p in prims if p["material"] is not None]
if len(set(material_indices)) > 1:
warn_once("multimat", f"{len(set(material_indices))} materials found; "
"keeping textures/factors of the first only")
first_material = material_indices[0] if material_indices else None
material_info = gltf_read.extract_material(gltf, buffers, base_dir, first_material, warn_once)
elif fmt == "obj":
prims = [mesh_file_read.load_obj(data)]
elif fmt == "stl":
prims = [mesh_file_read.load_stl(data)]
else:
raise ValueError(f"Get3DComponents: unsupported or unrecognized format {fmt!r} "
"(supported: glb, gltf, obj, stl)")
merged = _merge_primitives(prims)
n_verts = merged["vertices"].shape[0]
max_face = int(merged["faces"].max())
if max_face >= n_verts:
raise ValueError(f"Get3DComponents: face index {max_face} out of range for {n_verts} vertices (corrupt file?)")
material_info = material_info or {}
mesh = Types.MESH(
vertices=_batch(merged["vertices"]),
faces=_batch(merged["faces"]),
uvs=_batch(merged["uvs"]),
vertex_colors=_batch(merged["colors"]),
normals=_batch(merged["normals"]),
tangents=_batch(merged["tangents"]),
texture=_batch(material_info.get("texture")),
metallic_roughness=_batch(material_info.get("metallic_roughness")),
normal_map=_batch(material_info.get("normal_map")),
emissive=_batch(material_info.get("emissive")),
unlit=bool(material_info.get("unlit", False)),
occlusion_in_mr=bool(material_info.get("occlusion_in_mr", False)),
material=material_info.get("material") or None,
)
logging.info("Get3DComponents: %s -> %d vertices, %d faces (%d primitives)",
fmt, n_verts, merged["faces"].shape[0], len(prims))
return IO.NodeOutput(mesh)
class MeshIOExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [Get3DComponents]
async def comfy_entrypoint() -> MeshIOExtension:
return MeshIOExtension()
+1
View File
@@ -2522,6 +2522,7 @@ async def init_builtin_extra_nodes():
"nodes_wandancer.py",
"nodes_hidream_o1.py",
"nodes_save_3d.py",
"nodes_mesh_io.py",
"nodes_moge.py",
"nodes_mediapipe.py",
"nodes_gaussian_splat.py",