add functions for fastmesh

This commit is contained in:
DenisKochetov
2025-09-02 23:14:05 +02:00
parent ae3156305d
commit b4aa1f8bf3
9 changed files with 1313 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
from .io_gltf import (
GltfDocument,
load_gltf_or_glb,
save_glb,
)
from .accessors import (
access_data,
update_accessor_binary_data,
append_accessor_and_bufferview,
recompute_accessor_min_max,
)
from .transforms import (
compose_matrix,
quaternion_matrix,
direction_to_quaternion,
parse_node_transform,
compute_global_transform,
compute_mesh_global_transforms,
transform_positions_inplace,
set_node_trs_identity,
transform_local_vertices_to_world,
)
from .mesh_ops import (
get_all_meshes_triangles,
merge_all_meshes_to_single,
update_nodes_for_merged_mesh,
)
from .rigging_ops import (
check_input_file_compatibility,
clean_rigging_data_from_gltf,
clean_skin_data_from_gltf,
insert_generated_data_to_gltf,
)
from .validate import validate_before_save
from .mesh import (
Mesh,
FastMesh,
TrimeshLike,
TrimeshLikeLoader,
PointCloud,
)
__all__ = [
"GltfDocument",
"load_gltf_or_glb",
"save_glb",
"access_data",
"update_accessor_binary_data",
"append_accessor_and_bufferview",
"recompute_accessor_min_max",
"compose_matrix",
"quaternion_matrix",
"direction_to_quaternion",
"parse_node_transform",
"compute_global_transform",
"compute_mesh_global_transforms",
"transform_positions_inplace",
"set_node_trs_identity",
"transform_local_vertices_to_world",
"get_all_meshes_triangles",
"merge_all_meshes_to_single",
"update_nodes_for_merged_mesh",
"check_input_file_compatibility",
"clean_rigging_data_from_gltf",
"clean_skin_data_from_gltf",
"insert_generated_data_to_gltf",
"validate_before_save",
"Mesh",
"FastMesh",
"TrimeshLike",
"TrimeshLikeLoader",
"PointCloud",
]
try:
from .export_utils import (
export_to_fastmesh,
export_to_mesh,
export_to_mesh_ultra_fast,
)
__all__.extend([
"export_to_fastmesh",
"export_to_mesh",
"export_to_mesh_ultra_fast",
])
except ImportError:
pass
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from .io_gltf import GltfDocument, get_binary_data
from .utils import ensure_little_endian, as_uint8_buffer
_ELEMENT_SHAPES: Dict[str, Tuple[int, ...]] = {
"SCALAR": (1,),
"VEC2": (2,),
"VEC3": (3,),
"VEC4": (4,),
"MAT2": (2, 2),
"MAT3": (3, 3),
"MAT4": (4, 4),
}
_ITEM_TYPES: Dict[int, Any] = {
5120: np.int8,
5121: np.uint8,
5122: np.int16,
5123: np.uint16,
5125: np.uint32,
5126: np.float32,
}
def _dtype_itemsize(component_type: int) -> int:
return np.dtype(_ITEM_TYPES[component_type]).itemsize
def _validate_stride(stride: int) -> None:
if stride != 0 and stride < 4:
raise ValueError("Stride is too small.")
if stride > 252:
raise ValueError("Stride is too big.")
def access_data(doc: GltfDocument, accessor_index: int) -> np.ndarray:
accessor = doc.accessors()[accessor_index]
buffer_view_index = accessor.get("bufferView")
if buffer_view_index is None:
raise NotImplementedError("Undefined buffer view")
accessor_byte_offset = int(accessor.get("byteOffset", 0))
component_type = int(accessor["componentType"]) # glTF enum
element_count = int(accessor["count"]) # number of elements
element_type = accessor["type"]
if accessor.get("sparse") is not None:
raise NotImplementedError("Sparse accessors are not supported")
buffer_view = doc.bufferViews()[buffer_view_index]
buffer_index = int(buffer_view["buffer"]) # which buffer
buffer_byte_length = int(buffer_view["byteLength"]) # length of this view
element_byte_offset = int(buffer_view.get("byteOffset", 0))
element_byte_stride = int(buffer_view.get("byteStride", 0))
_validate_stride(element_byte_stride)
element_shape = _ELEMENT_SHAPES[element_type]
item_dtype = np.dtype(_ITEM_TYPES[component_type])
item_count = int(np.prod(element_shape))
item_size = item_dtype.itemsize
size = element_count * item_count * item_size
if size > buffer_byte_length:
raise ValueError("Buffer did not have enough data for the accessor")
buffers = doc.buffers()
if buffer_index >= len(buffers):
raise ValueError("Invalid buffer index")
binary_data = get_binary_data(doc, buffer_index)
if len(binary_data) < buffers[buffer_index].get("byteLength", 0):
raise ValueError("Not enough binary data for the buffer")
if element_byte_stride == 0:
element_byte_stride = item_size * item_count
if element_byte_stride < item_size * item_count:
raise ValueError("Items should not overlap")
# force little-endian
item_dtype_str = np.dtype(item_dtype).newbyteorder("<").str
dtype = np.dtype(
{
"names": ["element"],
"formats": [str(element_shape) + item_dtype_str],
"offsets": [0],
"itemsize": element_byte_stride,
}
)
byte_offset = accessor_byte_offset + element_byte_offset
if byte_offset % item_size != 0:
raise ValueError("Misaligned data")
byte_length = element_count * element_byte_stride
view = binary_data[byte_offset : byte_offset + byte_length].view(dtype)["element"]
if element_type in ("MAT2", "MAT3", "MAT4"):
view = np.transpose(view, (0, 2, 1))
return view
def update_accessor_binary_data(doc: GltfDocument, accessor_idx: int, new_data: np.ndarray) -> None:
accessor = doc.accessors()[accessor_idx]
buffer_view_idx = accessor.get("bufferView")
if buffer_view_idx is None:
raise NotImplementedError("Undefined buffer view")
buffer_view = doc.bufferViews()[buffer_view_idx]
buffer_idx = int(buffer_view["buffer"]) # which buffer
byte_offset = int(buffer_view.get("byteOffset", 0))
accessor_byte_offset = int(accessor.get("byteOffset", 0))
binary_data = doc.binary(buffer_idx)
# ensure little-endian float32 or appropriate component
new_bytes = ensure_little_endian(new_data).tobytes()
start_pos = byte_offset + accessor_byte_offset
end_pos = start_pos + len(new_bytes)
new_binary_data = binary_data.copy()
new_binary_data[start_pos:end_pos] = as_uint8_buffer(np.frombuffer(new_bytes, dtype=np.uint8))
doc.set_binary(buffer_idx, new_binary_data)
doc.update_buffer_length(buffer_idx)
def recompute_accessor_min_max(doc: GltfDocument, accessor_idx: int) -> None:
accessor = doc.accessors()[accessor_idx]
if accessor.get("type") != "VEC3":
return
data = access_data(doc, accessor_idx)
accessor["min"] = data.min(axis=0).tolist()
accessor["max"] = data.max(axis=0).tolist()
def append_accessor_and_bufferview(
doc: GltfDocument,
array: np.ndarray,
component_type: int,
element_type: str,
target: Optional[int] = None,
) -> Tuple[int, int]:
"""Append array bytes into buffer 0 and create bufferView + accessor.
Returns: (accessor_index, buffer_view_index)
"""
if element_type not in _ELEMENT_SHAPES:
raise ValueError("Invalid element type")
if component_type not in _ITEM_TYPES:
raise ValueError("Invalid component type")
array_le = ensure_little_endian(array, np.dtype(_ITEM_TYPES[component_type]))
element_shape = _ELEMENT_SHAPES[element_type]
# validate last dimension matches element
num_components = int(np.prod(element_shape))
if array_le.size % num_components != 0:
raise ValueError("Array size is not divisible by element components")
if array_le.ndim == 1:
count = array_le.size // num_components
else:
count = array_le.shape[0]
# append to binary buffer 0
buf0 = doc.binary(0)
byte_offset = int(buf0.nbytes)
bytes_data = array_le.tobytes()
buf0_new = np.concatenate([buf0, np.frombuffer(bytes_data, dtype=np.uint8)])
doc.set_binary(0, buf0_new)
# create bufferView
bv_index = len(doc.bufferViews())
byte_length = len(bytes_data)
bufferview: Dict[str, Any] = {
"buffer": 0,
"byteOffset": byte_offset,
"byteLength": byte_length,
}
if target is not None:
bufferview["target"] = target
doc.bufferViews().append(bufferview)
# create accessor
acc_index = len(doc.accessors())
accessor: Dict[str, Any] = {
"bufferView": bv_index,
"componentType": int(component_type),
"count": int(count),
"type": element_type,
}
# if POSITION like, store min/max
if element_type == "VEC3":
arr2d = array_le.reshape((-1, 3))
accessor["min"] = arr2d.min(axis=0).tolist()
accessor["max"] = arr2d.max(axis=0).tolist()
doc.accessors().append(accessor)
doc.update_buffer_length(0)
return acc_index, bv_index
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
import numpy as np
from .io_gltf import GltfDocument, load_gltf_or_glb, save_glb
from .mesh_ops import get_all_meshes_triangles, merge_all_meshes_to_single
from .transforms import (
compose_matrix,
quaternion_matrix,
direction_to_quaternion,
compute_mesh_global_transforms,
transform_local_vertices_to_world,
preprocess_transforms_inplace,
)
from .rigging_ops import (
check_input_file_compatibility,
clean_rigging_data_from_gltf,
insert_generated_data_to_gltf,
)
from .validate import validate_before_save
__all__ = [
"GltfDocument",
"load_gltf_or_glb",
"save_glb",
"get_all_meshes_triangles",
"merge_all_meshes_to_single",
"compose_matrix",
"quaternion_matrix",
"direction_to_quaternion",
"compute_mesh_global_transforms",
"transform_local_vertices_to_world",
"preprocess_transforms_inplace",
"check_input_file_compatibility",
"clean_rigging_data_from_gltf",
"insert_generated_data_to_gltf",
"validate_before_save",
]
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
import json
import struct
from base64 import b64decode
from typing import Any, BinaryIO, Dict, List, Optional, Tuple
import numpy as np
from .utils import NpEncoder, pad_to_4_bytes, warn
GLTF_MAGIC = 0x46546C67
JSON_CHUNK_TYPE = 0x4E4F534A
BINARY_CHUNK_TYPE = 0x004E4942
DATA_URI_PREFIX = "data:application/octet-stream;base64,"
def read_header(stream: BinaryIO) -> Optional[Tuple[int, int]]:
header = stream.read(12)
if len(header) != 12:
return None
magic, version, length = struct.unpack("<III", header)
if magic != GLTF_MAGIC:
return None
return version, length
def read_chunks(stream: BinaryIO, length: int) -> Optional[Tuple[Dict[str, Any], np.ndarray]]:
json_data: Optional[Dict[str, Any]] = None
binary_data: Optional[np.ndarray] = None
while stream.tell() < length:
chunk_header = stream.read(8)
if len(chunk_header) != 8:
warn(f"Invalid chunk header size: {len(chunk_header)} bytes (expected 8)")
break
chunk_length, chunk_type = struct.unpack("<II", chunk_header)
chunk_data = stream.read(chunk_length)
if len(chunk_data) != chunk_length:
warn(
f"Incomplete chunk data: {len(chunk_data)} bytes (expected {chunk_length})"
)
return None
if chunk_type == JSON_CHUNK_TYPE:
json_data = json.loads(chunk_data)
elif chunk_type == BINARY_CHUNK_TYPE:
binary_data = np.frombuffer(chunk_data, dtype=np.uint8)
else:
warn("Unsupported chunk type")
return None
if json_data is None:
raise ValueError("Missing json header")
if binary_data is None:
binary_data = np.array([], dtype=np.uint8)
return json_data, binary_data
class GltfDocument:
"""Minimal GLTF/GLB container with helpers over core collections."""
def __init__(self, json_data: Dict[str, Any], binary_buffers: Optional[Dict[int, np.ndarray]] = None):
self._json = json_data
self._bin: Dict[int, np.ndarray] = binary_buffers or {0: np.array([], dtype=np.uint8)}
# Accessors to top-level collections
def json(self) -> Dict[str, Any]:
return self._json
def buffers(self) -> List[Dict[str, Any]]:
return self._json.setdefault("buffers", [])
def bufferViews(self) -> List[Dict[str, Any]]:
return self._json.setdefault("bufferViews", [])
def accessors(self) -> List[Dict[str, Any]]:
return self._json.setdefault("accessors", [])
def nodes(self) -> List[Dict[str, Any]]:
return self._json.setdefault("nodes", [])
def meshes(self) -> List[Dict[str, Any]]:
return self._json.setdefault("meshes", [])
def scenes(self) -> List[Dict[str, Any]]:
return self._json.setdefault("scenes", [])
def materials(self) -> List[Dict[str, Any]]:
return self._json.setdefault("materials", [])
def images(self) -> List[Dict[str, Any]]:
return self._json.setdefault("images", [])
def textures(self) -> List[Dict[str, Any]]:
return self._json.setdefault("textures", [])
def samplers(self) -> List[Dict[str, Any]]:
return self._json.setdefault("samplers", [])
def binary(self, index: int = 0) -> np.ndarray:
return self._bin.setdefault(index, np.array([], dtype=np.uint8))
def set_binary(self, index: int, data: np.ndarray) -> None:
self._bin[index] = data
def update_buffer_length(self, buffer_index: int = 0) -> None:
if not self.buffers():
self._json["buffers"] = [{"byteLength": int(self.binary(buffer_index).nbytes)}]
else:
self.buffers()[buffer_index]["byteLength"] = int(self.binary(buffer_index).nbytes)
def get_binary_data(doc: GltfDocument, buffer_index: int) -> np.ndarray:
"""Return binary data for a buffer index.
Supports embedded base64 buffers in GLTF as well as GLB's main buffer.
"""
buffers = doc.buffers()
if buffer_index >= len(buffers):
raise IndexError("Buffer index out of range")
binary = doc._bin.get(buffer_index)
if binary is not None and binary.size > 0:
return binary
uri = buffers[buffer_index].get("uri")
if uri and uri.startswith(DATA_URI_PREFIX):
binary_data = b64decode(uri[len(DATA_URI_PREFIX) :])
arr = np.frombuffer(binary_data, dtype=np.uint8)
doc.set_binary(buffer_index, arr)
return arr
# if no uri, return existing buffer (may be empty for GLB until provided)
return doc.binary(buffer_index)
def load_gltf_or_glb(path: str) -> GltfDocument:
with open(path, "rb") as f:
stream = f
version_and_length = read_header(stream)
if version_and_length is None: # GLTF JSON
stream.seek(0)
json_data = json.load(stream)
# embedded or external buffers will be resolved lazily
return GltfDocument(json_data=json_data)
version, length = version_and_length
if version != 2:
raise ValueError(f"Only GLB version 2 is supported, found version {version}")
json_data, binary_data = read_chunks(stream, length)
return GltfDocument(json_data=json_data, binary_buffers={0: binary_data})
def save_glb(doc: GltfDocument, stream: BinaryIO) -> None:
"""Write GLB v2 with proper 4-byte alignment for JSON and BIN chunks."""
# ensure buffers length is correct for buffer 0
doc.update_buffer_length(0)
json_bytes = json.dumps(doc.json(), cls=NpEncoder, separators=(",", ":")).encode("utf-8")
json_padded = pad_to_4_bytes(json_bytes, b"\x20")
bin_bytes = bytes(doc.binary(0))
bin_padded = pad_to_4_bytes(bin_bytes, b"\x00")
version = 2
file_header_size = 12
chunk_header_size = 8
file_length = (
file_header_size
+ chunk_header_size
+ len(json_padded)
+ chunk_header_size
+ len(bin_padded)
)
# write GLB header
stream.write(struct.pack("<III", GLTF_MAGIC, version, file_length))
# JSON chunk
stream.write(struct.pack("<II", len(json_padded), JSON_CHUNK_TYPE))
stream.write(json_padded)
# BIN chunk
stream.write(struct.pack("<II", len(bin_padded), BINARY_CHUNK_TYPE))
stream.write(bin_padded)
+161
View File
@@ -0,0 +1,161 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
import numpy as np
from .io_gltf import GltfDocument
from .accessors import access_data, append_accessor_and_bufferview
from .transforms import compute_mesh_global_transforms, transform_local_vertices_to_world, set_node_trs_identity
def get_all_meshes_triangles(doc: GltfDocument, transform_to_global: bool = True):
if not doc.meshes():
raise ValueError("No meshes found in the GLB/GLTF.")
all_vertices_groups: List[Dict[str, Any]] = []
vertices_count = 0
all_pos_data: List[np.ndarray] = []
all_indices_data: List[np.ndarray] = []
mesh_transforms: Dict[int, np.ndarray] = {}
if transform_to_global:
mesh_transforms = compute_mesh_global_transforms(doc)
for i_m, mesh_json in enumerate(doc.meshes()):
for i_p, primitive in enumerate(mesh_json.get("primitives", [])):
if "attributes" in primitive and "indices" in primitive:
attributes = primitive["attributes"]
if "POSITION" in attributes:
pos_accessor_index = int(attributes["POSITION"])
pos_data = access_data(doc, pos_accessor_index)
if pos_data.dtype != np.float32:
pos_data = pos_data.astype(np.float32)
if transform_to_global and i_m in mesh_transforms:
transform_matrix = mesh_transforms[i_m]
identity = np.eye(4, dtype=np.float32)
if not np.allclose(transform_matrix, identity, atol=1e-6):
fake_node = {"matrix": transform_matrix.flatten(order="F").tolist()}
pos_data = transform_local_vertices_to_world(pos_data, fake_node)
all_vertices_groups.append({
"mesh_index": i_m,
"primitive_index": i_p,
"vertices_range": (vertices_count, vertices_count + pos_data.shape[0]),
"global_transform_applied": transform_to_global and i_m in mesh_transforms,
})
all_pos_data.append(pos_data)
indices_accessor_index = int(primitive["indices"])
indices_data = access_data(doc, indices_accessor_index)
if indices_data.dtype != np.int64:
indices_data = indices_data.astype(np.int64)
all_indices_data.append(indices_data + vertices_count)
vertices_count += pos_data.shape[0]
return (
np.concatenate(all_pos_data, axis=0),
np.concatenate(all_indices_data, axis=0).reshape(-1, 3),
all_vertices_groups,
)
def _choose_index_component_type(num_vertices: int) -> int:
# 5123 = UNSIGNED_SHORT, 5125 = UNSIGNED_INT
return 5123 if num_vertices <= 65535 else 5125
def merge_all_meshes_to_single(doc: GltfDocument) -> bool:
if len(doc.meshes()) <= 1:
return True
mesh_transforms = compute_mesh_global_transforms(doc)
merged_vertices: List[np.ndarray] = []
merged_indices: List[np.ndarray] = []
vertex_offset = 0
for mesh_index, mesh in enumerate(doc.meshes()):
for primitive in mesh.get("primitives", []):
if "attributes" in primitive and "POSITION" in primitive["attributes"]:
pos_accessor_index = int(primitive["attributes"]["POSITION"])
vertices = access_data(doc, pos_accessor_index).astype(np.float32)
if mesh_index in mesh_transforms:
transform_matrix = mesh_transforms[mesh_index]
identity = np.eye(4, dtype=np.float32)
if not np.allclose(transform_matrix, identity, atol=1e-6):
pos_h = np.ones((vertices.shape[0], 4), dtype=np.float32)
pos_h[:, :3] = vertices
vertices = (pos_h @ transform_matrix.T)[:, :3].astype(np.float32)
if "indices" in primitive:
indices_accessor_index = int(primitive["indices"])
indices = access_data(doc, indices_accessor_index).astype(np.int64)
indices = indices + vertex_offset
else:
indices = np.arange(len(vertices), dtype=np.int64) + vertex_offset
indices = indices.reshape(-1, 3)
merged_vertices.append(vertices)
merged_indices.append(indices)
vertex_offset += len(vertices)
if not merged_vertices:
return False
final_vertices = np.concatenate(merged_vertices, axis=0)
final_indices = np.concatenate(merged_indices, axis=0)
acc_pos, _ = append_accessor_and_bufferview(
doc,
final_vertices.astype(np.float32),
component_type=5126,
element_type="VEC3",
target=34962,
)
index_component_type = _choose_index_component_type(final_vertices.shape[0])
index_dtype = np.uint16 if index_component_type == 5123 else np.uint32
acc_idx, _ = append_accessor_and_bufferview(
doc,
final_indices.astype(index_dtype).reshape(-1),
component_type=index_component_type,
element_type="SCALAR",
target=34963,
)
merged_mesh = {
"name": "Merged_Mesh",
"primitives": [
{
"attributes": {"POSITION": acc_pos},
"indices": acc_idx,
"mode": 4,
}
],
}
doc.json()["meshes"] = [merged_mesh]
update_nodes_for_merged_mesh(doc)
return True
def update_nodes_for_merged_mesh(doc: GltfDocument) -> None:
nodes = doc.nodes()
meshes = doc.meshes()
merged_node = {"name": "Merged_Mesh_Node", "mesh": 0}
nodes.append(merged_node)
merged_node_index = len(nodes) - 1
scene_index = doc.json().get("scene", 0)
scenes = doc.scenes()
if not scenes:
doc.json()["scenes"] = [{"nodes": [merged_node_index]}]
doc.json()["scene"] = 0
else:
scenes[scene_index].setdefault("nodes", [])
scenes[scene_index]["nodes"].append(merged_node_index)
+340
View File
@@ -0,0 +1,340 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
from .io_gltf import GltfDocument
from .transforms import (
compute_mesh_global_transforms,
compose_matrix,
direction_to_quaternion,
)
from .accessors import append_accessor_and_bufferview
_EPS = np.finfo(float).eps * 4.0
def check_input_file_compatibility(doc: GltfDocument) -> Dict[str, Any]:
info = {
"has_skins": False,
"has_animations": False,
"skin_count": 0,
"animation_count": 0,
"issues": [],
"recommendations": [],
"mesh_transforms_non_identity": [],
}
if "skins" in doc.json():
info["has_skins"] = True
info["skin_count"] = len(doc.json()["skins"])
info["issues"].append(f"File already contains {info['skin_count']} skin(s)")
info["recommendations"].append("Remove existing skin data before auto-rigging")
if "animations" in doc.json():
info["has_animations"] = True
info["animation_count"] = len(doc.json()["animations"])
info["issues"].append(
f"File already contains {info['animation_count']} animation(s)"
)
info["recommendations"].append(
"Existing animations may conflict with generated rig"
)
mesh_transforms = compute_mesh_global_transforms(doc)
for mesh_index, transform in mesh_transforms.items():
identity = np.eye(4, dtype=np.float32)
if not np.allclose(transform, identity, atol=1e-6):
mesh_name = doc.meshes()[mesh_index].get("name", f"Mesh_{mesh_index}")
info["mesh_transforms_non_identity"].append(
{"mesh_index": mesh_index, "mesh_name": mesh_name, "transform": transform.tolist()}
)
if info["mesh_transforms_non_identity"]:
info["issues"].append(
f"{len(info['mesh_transforms_non_identity'])} mesh(es) have non-identity transformations"
)
info["recommendations"].append(
"Mesh positions will be transformed to global space for auto-rigging"
)
return info
def clean_rigging_data_from_gltf(doc: GltfDocument) -> Dict[str, Any]:
cleanup_info = {
"removed_skins": 0,
"removed_animations": 0,
"removed_skin_refs": 0,
"removed_joint_attrs": 0,
"removed_weight_attrs": 0,
"removed_accessors": 0,
"removed_buffer_views": 0,
"binary_buffer_rebuilt": False,
"operations": [],
}
# collect accessors to remove
joint_weight_accessors = set()
for mesh in doc.meshes():
for primitive in mesh.get("primitives", []):
attrs = primitive.get("attributes", {})
if "JOINTS_0" in attrs:
joint_weight_accessors.add(int(attrs["JOINTS_0"]))
cleanup_info["removed_joint_attrs"] += 1
if "WEIGHTS_0" in attrs:
joint_weight_accessors.add(int(attrs["WEIGHTS_0"]))
cleanup_info["removed_weight_attrs"] += 1
inverse_bind_accessors = set()
if "skins" in doc.json():
for skin in doc.json()["skins"]:
if "inverseBindMatrices" in skin:
inverse_bind_accessors.add(int(skin["inverseBindMatrices"]))
all_skin_accessors = joint_weight_accessors | inverse_bind_accessors
skin_buffer_views = set()
for accessor_idx in all_skin_accessors:
if accessor_idx < len(doc.accessors()):
accessor = doc.accessors()[accessor_idx]
if "bufferView" in accessor:
skin_buffer_views.add(int(accessor["bufferView"]))
# remove animations
if "animations" in doc.json():
cleanup_info["removed_animations"] = len(doc.json()["animations"])
del doc.json()["animations"]
cleanup_info["operations"].append(
f"Removed 'animations' section with {cleanup_info['removed_animations']} elements"
)
# remove skins
if "skins" in doc.json():
cleanup_info["removed_skins"] = len(doc.json()["skins"])
del doc.json()["skins"]
cleanup_info["operations"].append(
f"Removed 'skins' section with {cleanup_info['removed_skins']} elements"
)
# remove node skin refs
for idx, node in enumerate(doc.nodes()):
if "skin" in node:
node.pop("skin")
cleanup_info["removed_skin_refs"] += 1
cleanup_info["operations"].append(f"Removed skin reference from node index {idx}")
# remove JOINTS_0/WEIGHTS_0 attributes
for m_idx, mesh in enumerate(doc.meshes()):
for p_idx, primitive in enumerate(mesh.get("primitives", [])):
attrs = primitive.get("attributes", {})
removed = []
if "JOINTS_0" in attrs:
attrs.pop("JOINTS_0")
removed.append("JOINTS_0")
if "WEIGHTS_0" in attrs:
attrs.pop("WEIGHTS_0")
removed.append("WEIGHTS_0")
if removed:
cleanup_info["operations"].append(
f"Removed attributes {removed} from mesh {m_idx} primitive {p_idx}"
)
# rebuild accessors without skin ones
if all_skin_accessors:
old_accessors = doc.accessors()
new_accessors = []
old_to_new = {}
for i, acc in enumerate(old_accessors):
if i in all_skin_accessors:
cleanup_info["removed_accessors"] += 1
else:
old_to_new[i] = len(new_accessors)
new_accessors.append(acc)
doc.json()["accessors"] = new_accessors
# remap references
for mesh in doc.meshes():
for primitive in mesh.get("primitives", []):
attrs = primitive.get("attributes", {})
for key, acc_idx in list(attrs.items()):
if acc_idx in old_to_new:
attrs[key] = old_to_new[acc_idx]
else:
# invalid -> drop attribute
attrs.pop(key)
if "indices" in primitive:
idx = primitive["indices"]
if idx in old_to_new:
primitive["indices"] = old_to_new[idx]
else:
primitive.pop("indices", None)
# bufferViews cleanup requires rebuilding binary; keep it simple for now
# caller may opt to rebuild or leave holes; we just report counts
cleanup_info["removed_buffer_views"] = len(skin_buffer_views)
return cleanup_info
def clean_skin_data_from_gltf(doc: GltfDocument) -> Dict[str, Any]:
return clean_rigging_data_from_gltf(doc)
@dataclass
class JointData:
joint_node_index: int
joint_name: str
parent_index: Optional[int] = None
children_indices: Tuple[int, ...] = ()
forward_bind_matrix: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float32))
inverse_bind_matrix: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float32))
local_transform_matrix: np.ndarray = field(default_factory=lambda: np.eye(4, dtype=np.float32))
original_index: Optional[int] = None
def insert_generated_data_to_gltf(
doc: GltfDocument,
joints: np.ndarray,
conns: np.ndarray,
skins: np.ndarray,
all_vertices_groups: List[Dict[str, Any]],
) -> None:
# A) collect used joints and build remap
used = set()
for v_group in all_vertices_groups:
v0, v1 = v_group["vertices_range"]
skins_data = skins[v0:v1]
top_idx = np.argsort(-skins_data, axis=1)[:, :4]
rows = np.arange(skins_data.shape[0])[:, None]
top_w = skins_data[rows, top_idx]
mask = top_w > _EPS
used.update(top_idx[mask].tolist())
used_sorted = sorted(used)
old_to_new = {old: new for new, old in enumerate(used_sorted)}
# B) build joint nodes and skin
current_nodes_len = len(doc.nodes())
joints_nodes_indices: List[int] = []
all_joints_data: List[JointData] = []
joints_global_pos: List[np.ndarray] = []
for new_joint_idx, old_joint_idx in enumerate(used_sorted):
parent_index = conns[old_joint_idx] if old_joint_idx < len(conns) else old_joint_idx
joint_node_index = current_nodes_len + new_joint_idx
joint_name = f"Joint_{new_joint_idx}"
jd = JointData(joint_node_index, joint_name)
jd.original_index = int(old_joint_idx)
joints_nodes_indices.append(joint_node_index)
all_joints_data.append(jd)
parent_pick = int(parent_index) if parent_index < len(joints) else int(old_joint_idx)
joints_global_pos.append(joints[parent_pick])
# build parent/children links
for jd in all_joints_data:
old_idx = int(jd.original_index)
parent_old = int(conns[old_idx]) if old_idx < len(conns) else old_idx
if parent_old == old_idx:
continue
if parent_old in old_to_new:
parent_new = old_to_new[parent_old]
parent_node_index = all_joints_data[parent_new].joint_node_index
jd.parent_index = parent_node_index
parent_children = list(all_joints_data[parent_new].children_indices)
if jd.joint_node_index not in parent_children:
parent_children.append(jd.joint_node_index)
all_joints_data[parent_new].children_indices = tuple(parent_children)
# compute matrices
inverse_bind_mats: List[np.ndarray] = []
for i_name, jd in enumerate(all_joints_data):
global_scale = np.array([1.0, 1.0, 1.0], dtype=np.float32)
global_translation = np.asarray(joints_global_pos[i_name], dtype=np.float32)
if jd.children_indices:
offset = np.zeros(3, dtype=np.float32)
for child_index in jd.children_indices:
offset += (
joints_global_pos[child_index - current_nodes_len] - global_translation
)
global_rotation = direction_to_quaternion(offset)
elif jd.parent_index is not None:
offset_from_parent = (
global_translation
- joints_global_pos[jd.parent_index - current_nodes_len]
)
global_rotation = direction_to_quaternion(offset_from_parent)
else:
global_rotation = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
forward = compose_matrix(global_scale, global_rotation, global_translation)
jd.forward_bind_matrix = forward
jd.inverse_bind_matrix = np.linalg.inv(forward)
inverse_bind_mats.append(jd.inverse_bind_matrix)
inverse_bind_mats = np.stack(inverse_bind_mats, axis=0).astype(np.float32)
# glTF expects column-major arrays in JSON; we transpose like original code
inverse_bind_mats = np.transpose(inverse_bind_mats, (0, 2, 1)).copy(order="C")
# C) add vertex joint/weight attributes per groups
for v_group in all_vertices_groups:
v0, v1 = v_group["vertices_range"]
skins_data = skins[v0:v1]
top_idx = np.argsort(-skins_data, axis=1)[:, :4]
rows = np.arange(skins_data.shape[0])[:, None]
weights_data = skins_data[rows, top_idx].astype(np.float32)
joints_data = np.zeros_like(top_idx, dtype=np.uint16)
for i_row in range(top_idx.shape[0]):
for i_col in range(4):
old = int(top_idx[i_row, i_col])
if old in old_to_new:
joints_data[i_row, i_col] = np.uint16(old_to_new[old])
else:
joints_data[i_row, i_col] = np.uint16(0)
weights_data[i_row, i_col] = 0.0
# D) normalize weights
sums = np.sum(weights_data, axis=1, keepdims=True)
mask_zero = sums < _EPS
weights_data = np.where(mask_zero, 0.25, weights_data / sums)
mesh_index = v_group["mesh_index"]
prim_index = v_group["primitive_index"]
primitive = doc.meshes()[mesh_index]["primitives"][prim_index]
acc_j, _ = append_accessor_and_bufferview(
doc, joints_data.astype(np.uint16), component_type=5123, element_type="VEC4", target=34962
)
acc_w, _ = append_accessor_and_bufferview(
doc, weights_data.astype(np.float32), component_type=5126, element_type="VEC4", target=34962
)
primitive.setdefault("attributes", {})
primitive["attributes"]["JOINTS_0"] = acc_j
primitive["attributes"]["WEIGHTS_0"] = acc_w
# skin JSON
acc_ibm, _ = append_accessor_and_bufferview(
doc, inverse_bind_mats.astype(np.float32), component_type=5126, element_type="MAT4", target=None
)
doc.json()["skins"] = [
{"joints": joints_nodes_indices, "inverseBindMatrices": acc_ibm, "name": "Generated_Skin"}
]
# add joint nodes
root_joint_nodes: List[int] = []
for jd in all_joints_data:
node_json = {
"name": jd.joint_name,
"matrix": np.transpose(jd.local_transform_matrix, (1, 0)).copy(order="C").flatten().tolist(),
}
if jd.children_indices:
node_json["children"] = list(jd.children_indices)
doc.nodes().append(node_json)
if jd.parent_index is None:
root_joint_nodes.append(jd.joint_node_index)
# joints root
doc.nodes().append({"name": "Joints_Root", "children": root_joint_nodes})
joints_root_index = len(doc.nodes()) - 1
scene_index = doc.json().get("scene", 0)
doc.scenes()[scene_index].setdefault("nodes", [])
doc.scenes()[scene_index]["nodes"].append(joints_root_index)
+178
View File
@@ -0,0 +1,178 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
import numpy as np
from .io_gltf import GltfDocument
from .accessors import access_data, update_accessor_binary_data, recompute_accessor_min_max
def quaternion_matrix(quaternion: np.ndarray) -> np.ndarray:
x, y, z, w = quaternion
x2 = x * x
y2 = y * y
z2 = z * z
xy = x * y
xz = x * z
yz = y * z
wx = w * x
wy = w * y
wz = w * z
return np.array(
[
[1.0 - 2.0 * (y2 + z2), 2.0 * (xy - wz), 2.0 * (xz + wy), 0.0],
[2.0 * (xy + wz), 1.0 - 2.0 * (x2 + z2), 2.0 * (yz - wx), 0.0],
[2.0 * (xz - wy), 2.0 * (yz + wx), 1.0 - 2.0 * (x2 + y2), 0.0],
[0.0, 0.0, 0.0, 1.0],
]
)
def compose_matrix(scale: np.ndarray, rotation: np.ndarray, translation: np.ndarray) -> np.ndarray:
S = np.diag([scale[0], scale[1], scale[2], 1.0])
R = quaternion_matrix(rotation)
T = np.eye(4)
T[:3, 3] = translation
return T @ (R @ S)
def direction_to_quaternion(direction: np.ndarray) -> np.ndarray:
if not np.issubdtype(direction.dtype, np.floating):
direction = direction.astype(np.float32)
norm = np.linalg.norm(direction)
if norm < 1e-10:
return np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
direction = direction / norm
forward = np.array([0.0, 1.0, 0.0], dtype=np.float32)
up = np.array([0.0, 0.0, 1.0], dtype=np.float32)
dot = float(np.clip(np.dot(forward, direction), -1.0, 1.0))
if dot > 0.9999:
return np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
if dot < -0.9999:
return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float32)
axis = np.cross(forward, direction)
axis_norm = np.linalg.norm(axis)
if axis_norm < 1e-10:
if abs(direction[2]) < 0.9:
axis = np.cross(up, direction)
else:
axis = np.cross(np.array([1.0, 0.0, 0.0], dtype=np.float32), direction)
axis = axis / np.linalg.norm(axis)
else:
axis = axis / axis_norm
angle = np.arccos(dot)
half = angle * 0.5
s = np.sin(half)
c = np.cos(half)
quat = np.array([axis[0] * s, axis[1] * s, axis[2] * s, c], dtype=np.float32)
return quat / np.linalg.norm(quat)
def parse_node_transform(node_json: Dict[str, Any]) -> np.ndarray:
if "matrix" in node_json:
m = np.array(node_json["matrix"], dtype=np.float32).reshape(4, 4)
return m.T
t = np.array(node_json.get("translation", [0.0, 0.0, 0.0]), dtype=np.float32)
r = np.array(node_json.get("rotation", [0.0, 0.0, 0.0, 1.0]), dtype=np.float32)
s = np.array(node_json.get("scale", [1.0, 1.0, 1.0]), dtype=np.float32)
return compose_matrix(s, r, t)
def compute_global_transform(doc: GltfDocument, node_index: int, parent_transform: Optional[np.ndarray] = None) -> np.ndarray:
node_json = doc.nodes()[node_index]
local = parse_node_transform(node_json)
return parent_transform @ local if parent_transform is not None else local
def compute_mesh_global_transforms(doc: GltfDocument) -> Dict[int, np.ndarray]:
mesh_to_transform: Dict[int, np.ndarray] = {}
scene_index = doc.json().get("scene")
if scene_index is None:
return mesh_to_transform
root_nodes = doc.scenes()[scene_index].get("nodes", [])
def traverse(node_idx: int, parent: Optional[np.ndarray] = None) -> None:
global_m = compute_global_transform(doc, node_idx, parent)
node = doc.nodes()[node_idx]
if "mesh" in node:
mesh_to_transform[int(node["mesh"])] = global_m
for child in node.get("children", []) or []:
traverse(int(child), global_m)
for root in root_nodes:
traverse(int(root))
return mesh_to_transform
def transform_positions_inplace(doc: GltfDocument, mesh_idx: int, matrix: np.ndarray) -> None:
mesh = doc.meshes()[mesh_idx]
for primitive in mesh.get("primitives", []):
attrs = primitive.get("attributes", {})
if "POSITION" not in attrs:
continue
acc_idx = int(attrs["POSITION"])
data = access_data(doc, acc_idx)
pos_h = np.ones((data.shape[0], 4), dtype=np.float32)
pos_h[:, :3] = data.astype(np.float32)
transformed = (pos_h @ matrix.T)[:, :3].astype(np.float32)
update_accessor_binary_data(doc, acc_idx, transformed)
recompute_accessor_min_max(doc, acc_idx)
def transform_local_vertices_to_world(pos_data: np.ndarray, node: Dict[str, Any]) -> np.ndarray:
if "matrix" in node:
M = np.array(node["matrix"], dtype=np.float32).reshape(4, 4).T
else:
t = np.array(node.get("translation", [0.0, 0.0, 0.0]), dtype=np.float32)
r = np.array(node.get("rotation", [0.0, 0.0, 0.0, 1.0]), dtype=np.float32)
s = np.array(node.get("scale", [1.0, 1.0, 1.0]), dtype=np.float32)
M = compose_matrix(s, r, t)
pos_h = np.concatenate([pos_data, np.ones((pos_data.shape[0], 1), dtype=np.float32)], axis=1)
out = (M @ pos_h.T).T[:, :3]
return out
def set_node_trs_identity(doc: GltfDocument, node_idx: int) -> None:
node = doc.nodes()[node_idx]
for key in ["matrix", "translation", "rotation", "scale"]:
if key in node:
node.pop(key)
def preprocess_transforms_inplace(doc: GltfDocument) -> None:
"""Apply node transforms to vertex positions and set TRS for all nodes.
Idea: for each mesh, we calculate the global matrix, transform the POSITION of all
primitives of this mesh and remove the transform for the nodes referring to this mesh.
"""
mesh_transforms = compute_mesh_global_transforms(doc)
# quick exit if all identity
any_non_identity = any(
not np.allclose(M, np.eye(4), atol=1e-6) for M in mesh_transforms.values()
)
if not any_non_identity:
return
for mesh_idx, M in mesh_transforms.items():
if np.allclose(M, np.eye(4), atol=1e-6):
continue
# transform all positions in the mesh
mesh = doc.meshes()[mesh_idx]
for primitive in mesh.get("primitives", []):
attrs = primitive.get("attributes", {})
if "POSITION" not in attrs:
continue
acc_idx = int(attrs["POSITION"])
data = access_data(doc, acc_idx)
pos_h = np.ones((data.shape[0], 4), dtype=np.float32)
pos_h[:, :3] = data.astype(np.float32)
transformed = (pos_h @ M.T)[:, :3].astype(np.float32)
update_accessor_binary_data(doc, acc_idx, transformed)
recompute_accessor_min_max(doc, acc_idx)
# reset TRS for all nodes referring to this mesh
for node_idx, node in enumerate(doc.nodes()):
if node.get("mesh") == mesh_idx:
set_node_trs_identity(doc, node_idx)
+63
View File
@@ -0,0 +1,63 @@
import json
import warnings
from typing import Any, Optional
import numpy as np
def pad_to_4_bytes(data: bytes, pad_byte: bytes = b"\x20") -> bytes:
"""Pad given bytes to the next 4-byte boundary.
JSON chunks must be padded with spaces (0x20),
binary chunks must be padded with zeros (0x00).
"""
if not data:
return data
remainder = len(data) % 4
if remainder == 0:
return data
return data + pad_byte * (4 - remainder)
def ensure_little_endian(array: np.ndarray, dtype: Optional[np.dtype] = None) -> np.ndarray:
"""Return an array with little-endian dtype (copy if needed)."""
if dtype is not None:
array = array.astype(dtype, copy=False)
if array.dtype.byteorder in (">", "|"):
return array.byteswap().newbyteorder("<")
if array.dtype.byteorder == "<":
return array
# native without explicit endianness
return array.astype(array.dtype.newbyteorder("<"), copy=False)
def as_uint8_buffer(array: np.ndarray) -> np.ndarray:
"""View array bytes as uint8 vector."""
if not isinstance(array, np.ndarray):
raise TypeError("as_uint8_buffer expects a numpy array")
return np.frombuffer(array.tobytes(), dtype=np.uint8)
def warn(message: str) -> None:
warnings.warn(message)
class NpEncoder(json.JSONEncoder):
"""JSON encoder that converts numpy scalars/arrays to native Python types.
Important: cast numpy integers to int, not to string.
"""
def default(self, obj: Any) -> Any:
# scalars
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, (np.bool_,)):
return bool(obj)
# arrays
if isinstance(obj, np.ndarray):
return obj.tolist()
return super().default(obj)
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from typing import Dict, List, Tuple
import numpy as np
from .io_gltf import GltfDocument
def validate_before_save(doc: GltfDocument) -> List[str]:
errors: List[str] = []
# buffers length and 4-byte alignment will be handled by writer, but sanity-check
for i, buf in enumerate(doc.buffers()):
expect = int(doc.binary(i).nbytes)
if buf.get("byteLength", -1) != expect:
errors.append(f"Buffer {i} byteLength mismatch: json={buf.get('byteLength')} actual={expect}")
# bufferViews within ranges
for i, bv in enumerate(doc.bufferViews()):
b = int(bv.get("buffer", 0))
off = int(bv.get("byteOffset", 0))
ln = int(bv.get("byteLength", 0))
if off + ln > int(doc.binary(b).nbytes):
errors.append(f"bufferView {i} out of range: {off}+{ln}>{doc.binary(b).nbytes}")
if off % 4 != 0:
errors.append(f"bufferView {i} is not 4-byte aligned: offset={off}")
# accessors consistency
for i, acc in enumerate(doc.accessors()):
if "bufferView" not in acc:
continue
bv = doc.bufferViews()[acc["bufferView"]]
component_type = acc.get("componentType")
acc_type = acc.get("type")
if component_type not in (5120, 5121, 5122, 5123, 5125, 5126):
errors.append(f"accessor {i} invalid componentType {component_type}")
if acc_type not in ("SCALAR", "VEC2", "VEC3", "VEC4", "MAT2", "MAT3", "MAT4"):
errors.append(f"accessor {i} invalid type {acc_type}")
# position min/max
for i, acc in enumerate(doc.accessors()):
if acc.get("type") == "VEC3":
if "min" not in acc or "max" not in acc:
errors.append(f"accessor {i} missing min/max for VEC3")
# primitives mode
for m_idx, mesh in enumerate(doc.meshes()):
for p_idx, prim in enumerate(mesh.get("primitives", [])):
if prim.get("mode", 4) != 4:
errors.append(f"mesh {m_idx} prim {p_idx} mode must be 4 (TRIANGLES)")
return errors