feat: Add VRM Blender addon with complete import/export functionality

- Add core VRM addon infrastructure with manifest and registration
- Add common utilities module with file system, logging, and conversion helpers
- Add human bone mapper with support for multiple rigging standards (Mixamo, MMD, Unreal, Rigify, etc.)
- Add VRM 0.x and 1.x format support with property groups and handlers
- Add editor UI panels for VRM metadata, spring bones, and MToon materials
- Add exporter with glTF2 extension support for VRM format serialization
- Add importer with scene reconstruction and armature generation
- Add MToon shader support with auto-setup and material migration
- Add spring bone physics simulation with constraint handling
- Add node constraint editor for advanced rigging control
- Add comprehensive validation and error handling with user dialogs
- Add scene watcher for real-time property synchronization
- Add workspace management and preference system
- Include Python cache files and Blender manifest configuration
- This is the initial commit establishing the complete VRM addon ecosystem for Blender
This commit is contained in:
2026-01-01 14:21:56 +08:00
commit 091ad6a49a
243 changed files with 60636 additions and 0 deletions

605
common/gltf.py Normal file
View File

@@ -0,0 +1,605 @@
# SPDX-License-Identifier: MIT OR GPL-3.0-or-later
import base64
import json
import struct
from io import BytesIO
from typing import Final, Optional, Union
from urllib.parse import urlparse
from mathutils import Quaternion, Vector
from .convert import Json
from .deep import make_json
from .gl import (
GL_BYTE,
GL_FLOAT,
GL_INT,
GL_SHORT,
GL_UNSIGNED_BYTE,
GL_UNSIGNED_INT,
GL_UNSIGNED_SHORT,
)
# https://www.khronos.org/opengl/wiki/Small_Float_Formats#Numeric_limits_and_precision
FLOAT_POSITIVE_MAX: Final = 3.4028237e38
FLOAT_NEGATIVE_MAX: Final = -FLOAT_POSITIVE_MAX
def parse_glb(data: bytes) -> tuple[dict[str, Json], bytes]:
with BytesIO(data) as glb:
header_bytes = glb.read(12)
if len(header_bytes) != 12:
message = "Failed to read VRM glTF header: " + ",".join(
chr(b) for b in header_bytes
)
raise ValueError(message)
header: tuple[bytes, int, int] = struct.unpack("<4sII", header_bytes)
magic, version, length = header
if magic != b"glTF":
message = "Invalid VRM glTF magic bytes: " + ",".join(chr(b) for b in magic)
raise ValueError(message)
if version != 2:
message = f"Unsupported VRM glTF Version: {version}"
raise ValueError(message)
chunks_bytes_length = length - 12
if chunks_bytes_length < 0:
message = f"Invalid VRM glTF length: {length}"
raise ValueError(message)
chunks_bytes = glb.read(chunks_bytes_length)
if len(chunks_bytes) != chunks_bytes_length:
message = "Failed to read VRM chunks bytes"
raise ValueError(message)
with BytesIO(chunks_bytes) as chunks:
json_chunk_length_bytes = chunks.read(4)
if len(json_chunk_length_bytes) != 4:
message = "Failed to read VRM json chunk length bytes"
raise ValueError(message)
json_chunk_type_bytes = chunks.read(4)
if len(json_chunk_type_bytes) != 4:
message = "Failed to read VRM json chunk type bytes"
raise ValueError(message)
if json_chunk_type_bytes != b"JSON":
message = "Invalid VRM json chunk type bytes: " + ",".join(
chr(b) for b in json_chunk_type_bytes
)
raise ValueError(message)
json_chunk_data_length: int = struct.unpack("<I", json_chunk_length_bytes)[0]
json_chunk_data_bytes = chunks.read(json_chunk_data_length)
if len(json_chunk_data_bytes) != json_chunk_data_length:
message = "Failed to read VRM json chunk"
raise ValueError(message)
raw_json = json.loads(json_chunk_data_bytes) # raises json.JSONDecodeError
json_obj = make_json(raw_json)
if not isinstance(json_obj, dict):
message = f"Unexpected VRM json format: {type(json_obj)}"
raise TypeError(message)
bin_chunk_length_bytes = chunks.read(4)
if not bin_chunk_length_bytes:
return json_obj, b""
if len(bin_chunk_length_bytes) != 4:
message = "Failed to read VRM bin chunk length bytes"
raise ValueError(message)
bin_chunk_type_bytes = chunks.read(4)
if len(bin_chunk_type_bytes) != 4:
message = "Failed to read VRM bin chunk type bytes"
raise ValueError(message)
if bin_chunk_type_bytes != b"BIN\x00":
message = "Invalid VRM bin chunk type bytes: " + ",".join(
chr(b) for b in bin_chunk_type_bytes
)
raise ValueError(message)
bin_chunk_data_length: int = struct.unpack("<I", bin_chunk_length_bytes)[0]
bin_chunk_data_bytes = chunks.read(bin_chunk_data_length)
if len(bin_chunk_data_bytes) != bin_chunk_data_length:
message = "Failed to read VRM bin chunk"
raise ValueError(message)
return json_obj, bin_chunk_data_bytes
def pack_glb(
json_dict: dict[str, Json], bin_chunk_bytes: Union[bytes, bytearray]
) -> bytes:
# https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#binary-gltf-layout
json_chunk_bytes = json.dumps(
json_dict,
separators=(",", ":"),
sort_keys=True,
# UniVRM 0.56.3 cannot import a json containing unicode escape chars into
# Unity Editor.
ensure_ascii=False,
).encode()
while len(json_chunk_bytes) % 4 != 0:
json_chunk_bytes += b"\x20"
while len(bin_chunk_bytes) % 4 != 0:
bin_chunk_bytes += b"\x00"
glb = bytearray()
glb.extend(b"glTF") # magic
glb.extend(struct.pack("<I", 2)) # version
glb.extend( # length
struct.pack(
"<I",
# header
12
# json chunk
+ 8
+ len(json_chunk_bytes)
# binary chunk
+ 8
+ len(bin_chunk_bytes),
)
)
glb.extend(struct.pack("<I", len(json_chunk_bytes)))
glb.extend(b"JSON")
glb.extend(json_chunk_bytes)
glb.extend(struct.pack("<I", len(bin_chunk_bytes)))
glb.extend(b"BIN\x00")
glb.extend(bin_chunk_bytes)
return bytes(glb)
def read_accessor_as_bytes(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
bin_chunk_bytes: Optional[bytes],
) -> Optional[bytes]:
buffer_view_index = accessor_dict.get("bufferView")
if not isinstance(buffer_view_index, int):
return None
if not 0 <= buffer_view_index < len(buffer_view_dicts):
return None
buffer_view_dict = buffer_view_dicts[buffer_view_index]
if not isinstance(buffer_view_dict, dict):
return None
buffer_index = buffer_view_dict.get("buffer")
if not isinstance(buffer_index, int):
return None
if not 0 <= buffer_index < len(buffer_dicts):
return None
if buffer_index == 0 and bin_chunk_bytes is not None:
buffer_bytes = bin_chunk_bytes
else:
prefix = "application/gltf-buffer;base64,"
buffer_dict = buffer_dicts[buffer_index]
if not isinstance(buffer_dict, dict):
return None
uri = buffer_dict.get("uri")
if not isinstance(uri, str):
return None
try:
parsed_url = urlparse(uri)
except ValueError:
return None
if parsed_url.scheme != "data":
return None
if not parsed_url.path.startswith(prefix): # TODO: all variants
return None
buffer_base64 = parsed_url.path.removeprefix(prefix)
buffer_bytes = base64.b64decode(buffer_base64)
byte_offset = buffer_view_dict.get("byteOffset", 0)
if not isinstance(byte_offset, int):
return None
if not 0 <= byte_offset < len(buffer_bytes):
return None
byte_length = buffer_view_dict.get("byteLength")
if not isinstance(byte_length, int):
return None
if not 0 <= byte_offset + byte_length <= len(buffer_bytes):
return None
return buffer_bytes[byte_offset : byte_offset + byte_length]
def unpack_component(
component_type: int, unpack_count: int, buffer_bytes: bytes
) -> Optional[Union[tuple[int, ...], tuple[float, ...]]]:
for search_component_type, component_count, unpack_symbol in [
(GL_BYTE, 1, "b"),
(GL_UNSIGNED_BYTE, 1, "B"),
(GL_SHORT, 2, "h"),
(GL_UNSIGNED_SHORT, 2, "H"),
(GL_INT, 4, "i"),
(GL_UNSIGNED_INT, 4, "I"),
(GL_FLOAT, 4, "f"),
]:
if search_component_type != component_type:
continue
if unpack_count == 0:
return ()
if not 1 <= unpack_count * component_count <= len(buffer_bytes):
return None
return struct.unpack("<" + unpack_symbol * unpack_count, buffer_bytes)
return None
def unpack_accessor_as_scalar_components(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
unpack_count: int,
) -> Union[tuple[int, ...], tuple[float, ...], None]:
component_type = accessor_dict.get("componentType")
if not isinstance(component_type, int):
return None
raw_bytes = read_accessor_as_bytes(
accessor_dict,
buffer_view_dicts,
buffer_dicts,
buffer0_bytes,
)
if not raw_bytes:
return None
return unpack_component(component_type, unpack_count, raw_bytes)
def read_scalar_accessor(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Union[tuple[int, ...], tuple[float, ...], None]:
accessor_type = accessor_dict.get("type")
if accessor_type != "SCALAR":
return None
count = accessor_dict.get("count")
if not isinstance(count, int):
return None
return unpack_accessor_as_scalar_components(
accessor_dict,
buffer_view_dicts,
buffer_dicts,
buffer0_bytes,
count,
)
def read_vec2_accessor(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Union[tuple[tuple[int, int], ...], tuple[tuple[float, float], ...], None]:
accessor_type = accessor_dict.get("type")
if accessor_type != "VEC2":
return None
count = accessor_dict.get("count")
if not isinstance(count, int):
return None
components = unpack_accessor_as_scalar_components(
accessor_dict,
buffer_view_dicts,
buffer_dicts,
buffer0_bytes,
count * 2,
)
if components is None:
return None
return tuple(
(
components[i],
components[i + 1],
)
for i in range(0, count * 2, 2)
)
def read_vec3_accessor(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Union[
tuple[tuple[int, int, int], ...], tuple[tuple[float, float, float], ...], None
]:
accessor_type = accessor_dict.get("type")
if accessor_type != "VEC3":
return None
count = accessor_dict.get("count")
if not isinstance(count, int):
return None
components = unpack_accessor_as_scalar_components(
accessor_dict,
buffer_view_dicts,
buffer_dicts,
buffer0_bytes,
count * 3,
)
if components is None:
return None
return tuple(
(
components[i],
components[i + 1],
components[i + 2],
)
for i in range(0, count * 3, 3)
)
def read_vec4_accessor(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Union[
tuple[tuple[int, int, int, int], ...],
tuple[tuple[float, float, float, float], ...],
None,
]:
accessor_type = accessor_dict.get("type")
if accessor_type != "VEC4":
return None
count = accessor_dict.get("count")
if not isinstance(count, int):
return None
components = unpack_accessor_as_scalar_components(
accessor_dict,
buffer_view_dicts,
buffer_dicts,
buffer0_bytes,
count * 4,
)
if components is None:
return None
return tuple(
(
components[i],
components[i + 1],
components[i + 2],
components[i + 3],
)
for i in range(0, count * 4, 4)
)
def read_mat4_accessor(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Union[
tuple[
tuple[
tuple[int, int, int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
],
...,
],
tuple[
tuple[
tuple[float, float, float, float],
tuple[float, float, float, float],
tuple[float, float, float, float],
tuple[float, float, float, float],
],
...,
],
None,
]:
accessor_type = accessor_dict.get("type")
if accessor_type != "MAT4":
return None
count = accessor_dict.get("count")
if not isinstance(count, int):
return None
components = unpack_accessor_as_scalar_components(
accessor_dict,
buffer_view_dicts,
buffer_dicts,
buffer0_bytes,
count * 16,
)
if components is None:
return None
return tuple(
(
(
components[i],
components[i + 1],
components[i + 2],
components[i + 3],
),
(
components[i + 4],
components[i + 5],
components[i + 6],
components[i + 7],
),
(
components[i + 8],
components[i + 9],
components[i + 10],
components[i + 11],
),
(
components[i + 12],
components[i + 13],
components[i + 14],
components[i + 15],
),
)
for i in range(0, count * 16, 16)
)
def read_accessor(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Union[
tuple[int, ...],
tuple[float, ...],
tuple[tuple[int, int], ...],
tuple[tuple[float, float], ...],
tuple[tuple[int, int, int], ...],
tuple[tuple[float, float, float], ...],
tuple[tuple[int, int, int, int], ...],
tuple[tuple[float, float, float, float], ...],
tuple[
tuple[
tuple[int, int, int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
],
...,
],
tuple[
tuple[
tuple[float, float, float, float],
tuple[float, float, float, float],
tuple[float, float, float, float],
tuple[float, float, float, float],
],
...,
],
None,
]:
accessor_type = accessor_dict.get("type")
if accessor_type == "SCALAR":
return read_scalar_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if accessor_type == "VEC2":
return read_vec2_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if accessor_type == "VEC3":
return read_vec3_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if accessor_type == "VEC4":
return read_vec4_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if accessor_type == "MAT4":
return read_mat4_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
return None
def read_accessors(
json_dict: dict[str, Json],
buffer0_bytes: bytes,
) -> tuple[
Union[
tuple[int, ...],
tuple[float, ...],
tuple[tuple[int, int], ...],
tuple[tuple[float, float], ...],
tuple[tuple[int, int, int], ...],
tuple[tuple[float, float, float], ...],
tuple[tuple[int, int, int, int], ...],
tuple[tuple[float, float, float, float], ...],
tuple[
tuple[
tuple[int, int, int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
tuple[int, int, int, int],
],
...,
],
tuple[
tuple[
tuple[float, float, float, float],
tuple[float, float, float, float],
tuple[float, float, float, float],
tuple[float, float, float, float],
],
...,
],
None,
],
...,
]:
accessor_dicts = json_dict.get("accessors")
if not isinstance(accessor_dicts, list):
accessor_dicts = []
buffer_view_dicts = json_dict.get("bufferViews")
if not isinstance(buffer_view_dicts, list):
buffer_view_dicts = []
buffer_dicts = json_dict.get("buffers")
if not isinstance(buffer_dicts, list):
buffer_dicts = []
return tuple(
read_accessor(accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes)
for accessor_dict in accessor_dicts
if isinstance(accessor_dict, dict)
)
def read_accessor_as_animation_sampler_input(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Optional[list[float]]:
scalar_accessor = read_scalar_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if scalar_accessor is None:
return None
return [float(v) for v in scalar_accessor]
def read_accessor_as_animation_sampler_translation_output(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Optional[list[Vector]]:
vec3_accessor = read_vec3_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if vec3_accessor is None:
return None
return [Vector((x, -z, y)) for x, y, z in vec3_accessor]
def read_accessor_as_animation_sampler_rotation_output(
accessor_dict: dict[str, Json],
buffer_view_dicts: list[Json],
buffer_dicts: list[Json],
buffer0_bytes: bytes,
) -> Optional[list[Quaternion]]:
vec4_accessor = read_vec4_accessor(
accessor_dict, buffer_view_dicts, buffer_dicts, buffer0_bytes
)
if vec4_accessor is None:
return None
return [Quaternion((w, x, -z, y)).normalized() for x, y, z, w in vec4_accessor]