306 lines
11 KiB
Python
306 lines
11 KiB
Python
# SPDX-FileCopyrightText: 2025 Blender Authors & Khronos Group contributors
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
import sys
|
|
import pathlib
|
|
import struct
|
|
import json
|
|
import numpy as np
|
|
import base64
|
|
from urllib.parse import unquote
|
|
from enum import IntEnum
|
|
|
|
sys.path.append(str(pathlib.Path(__file__).parent.absolute()))
|
|
|
|
|
|
def is_meshopt_compare(json_):
|
|
"""Check if we need to avoid full comparison of the data"""
|
|
if 'extensionsUsed' in json_:
|
|
if 'KHR_meshopt_compression' in json_['extensionsUsed']:
|
|
return True
|
|
if 'EXT_meshopt_compression' in json_['extensionsUsed']:
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_draco_compare(json_):
|
|
"""Check if we need to avoid full comparison of the data"""
|
|
if 'extensionsUsed' in json_:
|
|
if 'KHR_draco_mesh_compression' in json_['extensionsUsed']:
|
|
return True
|
|
return False
|
|
|
|
|
|
def gltf_generate_descr(output_datafile: pathlib.Path) -> str:
|
|
gltf = glTFDataExtractor(output_datafile)
|
|
gltf.load()
|
|
|
|
text = ""
|
|
text += str(gltf.magic) + "\n"
|
|
text += str(gltf.version) + "\n"
|
|
text += str(gltf.file_size) + "\n"
|
|
|
|
# we need to override generator field to avoid test failures
|
|
gltf.json['asset']['generator'] = "glTF-Blender-IO Test Suite"
|
|
|
|
def round_floats(o):
|
|
if isinstance(o, float):
|
|
# round to avoid precision issues
|
|
# Also avoid -0.0
|
|
if abs(o) < 0.0005:
|
|
return 0.000
|
|
return round(o, 3)
|
|
if isinstance(o, dict):
|
|
return {k: round_floats(v) for k, v in o.items()}
|
|
if isinstance(o, (list, tuple)):
|
|
return [round_floats(x) for x in o]
|
|
return o
|
|
|
|
if is_meshopt_compare(gltf.json):
|
|
# Avoid comparing data when meshopt compression is used, as it can lead to
|
|
# small differences that are not relevant.
|
|
# Simple comparison : check the extensions, and that there are 2 buffers
|
|
for extension_used in sorted(gltf.json['extensionsUsed']):
|
|
text += extension_used + "\n"
|
|
for idx, buffer in enumerate(gltf.json.get('buffers', [])):
|
|
text += f"buffer {idx} with extension fallback {
|
|
buffer.get(
|
|
'extensions', {}).get(
|
|
'KHR_meshopt_compression', {}).get(
|
|
'fallback', False)}\n"
|
|
|
|
elif is_draco_compare(gltf.json):
|
|
# Avoid comparing data when draco compression is used, as it can lead to
|
|
# small differences that are not relevant.
|
|
# Simple comparison : check the extensions
|
|
for extension_used in gltf.json['extensionsUsed']:
|
|
text += extension_used + "\n"
|
|
else:
|
|
text += json.dumps(round_floats(gltf.json), indent=2, ensure_ascii=False)
|
|
for accessor in gltf.accessors_data:
|
|
text += accessor + "\n"
|
|
return text
|
|
|
|
# This is a simple glTF loader to extract the JSON content and buffer from a GLB file.
|
|
# Based on KhronosGroup/glTf-Blender-IO
|
|
|
|
|
|
class glTFDataExtractor:
|
|
def __init__(self, filepath):
|
|
self.filepath = filepath
|
|
self.buffers = []
|
|
self.json = None
|
|
self.magic = None
|
|
self.version = None
|
|
self.file_size = None
|
|
self.accessors_data = []
|
|
|
|
def load(self):
|
|
if not self.filepath.is_file():
|
|
raise FileNotFoundError(f"File not found: {self.filepath}")
|
|
|
|
with open(self.filepath, 'rb') as f:
|
|
content = memoryview(f.read())
|
|
|
|
if content[:4] == b'glTF':
|
|
# glb
|
|
self.load_glb(content)
|
|
else:
|
|
# glTF + bin + textures
|
|
self.json = glTFDataExtractor.load_json(content)
|
|
|
|
# Let's ignore buffers and binary data when the file has meshopt compression
|
|
if is_meshopt_compare(self.json) or is_draco_compare(self.json):
|
|
return
|
|
|
|
# Get buffers
|
|
for buffer in self.json.get('buffers', []):
|
|
uri = buffer.get('uri', '')
|
|
sep = ';base64,'
|
|
if uri.startswith('data:'):
|
|
idx = uri.find(sep)
|
|
if idx != -1:
|
|
data = uri[idx + len(sep):]
|
|
self.buffers.append(memoryview(base64.b64decode(data)))
|
|
else:
|
|
# External .bin file
|
|
bin_path = self.filepath.parent / uri_to_path(uri)
|
|
with open(bin_path, 'rb') as bf:
|
|
self.buffers.append(memoryview(bf.read()))
|
|
|
|
# Loop on accessors to extract data
|
|
for accessor in self.json.get('accessors', []):
|
|
buffer_view_index = accessor.get('bufferView')
|
|
if buffer_view_index is not None:
|
|
buffer_view = self.json['bufferViews'][buffer_view_index]
|
|
buffer_index = buffer_view['buffer']
|
|
buffer_data = self.buffers[buffer_index]
|
|
|
|
byte_offset = buffer_view.get('byteOffset', 0) + accessor.get('byteOffset', 0)
|
|
|
|
data = buffer_data[byte_offset: byte_offset + buffer_view['byteLength']]
|
|
|
|
# MAT2/3 have special alignment requirements that aren't handled. But it
|
|
# doesn't matter because nothing uses them.
|
|
assert accessor.get('type') not in ['MAT2', 'MAT3']
|
|
|
|
dtype = ComponentType.to_numpy_dtype(accessor.get('componentType'))
|
|
component_nb = DataType.num_elements(accessor.get('type'))
|
|
|
|
bytes_per_elem = dtype(1).nbytes
|
|
default_stride = bytes_per_elem * component_nb
|
|
stride = buffer_view.get('byteStride', default_stride)
|
|
if stride == default_stride:
|
|
array = np.frombuffer(
|
|
data,
|
|
dtype=np.dtype(dtype).newbyteorder('<'),
|
|
count=accessor.get('count') * component_nb,
|
|
)
|
|
array = array.reshape(accessor.get('count'), component_nb)
|
|
else:
|
|
# The data looks like
|
|
# XXXppXXXppXXXppXXX
|
|
# where X are the components and p are padding.
|
|
# One XXXpp group is one stride's worth of data.
|
|
assert stride % bytes_per_elem == 0
|
|
elems_per_stride = stride // bytes_per_elem
|
|
num_elems = (accessor.get('count') - 1) * elems_per_stride + component_nb
|
|
array = np.frombuffer(
|
|
buffer_data,
|
|
dtype=np.dtype(dtype).newbyteorder('<'),
|
|
count=num_elems,
|
|
)
|
|
assert array.strides[0] == bytes_per_elem
|
|
array = np.lib.stride_tricks.as_strided(
|
|
array,
|
|
shape=(accessor.count, component_nb),
|
|
strides=(stride, bytes_per_elem),
|
|
)
|
|
|
|
# TODO manage sparse accessors
|
|
# (currently not used in Blender roudntrip tests)
|
|
else:
|
|
# Need to init data with zeros
|
|
dtype = ComponentType.to_numpy_dtype(accessor.get('componentType'))
|
|
component_nb = DataType.num_elements(accessor.get('type'))
|
|
array = np.zeros((accessor.get('count'), component_nb), dtype=dtype)
|
|
|
|
# TODO manage sparse accessors
|
|
# (currently not used in Blender roudntrip tests)
|
|
|
|
# Normalization
|
|
if accessor.get('normalized'):
|
|
if accessor.get('componentType') == 5120: # int8
|
|
array = np.maximum(-1.0, array / 127.0)
|
|
elif accessor.get('componentType') == 5121: # uint8
|
|
array = array / 255.0
|
|
elif accessor.get('componentType') == 5122: # int16
|
|
array = np.maximum(-1.0, array / 32767.0)
|
|
elif accessor.get('componentType') == 5123: # uint16
|
|
array = array / 65535.0
|
|
|
|
array = array.astype(np.float32, copy=False)
|
|
|
|
self.accessors_data.append(np.array2string(array, formatter={'float_kind': lambda x: convert_float(x)}))
|
|
|
|
def load_glb(self, content):
|
|
self.magic = content[:4]
|
|
self.version, self.file_size = struct.unpack_from('<II', content, offset=4)
|
|
if self.version != 2:
|
|
raise ImportError("GLB version must be 2; got %d" % self.version)
|
|
if self.file_size != len(content):
|
|
raise ImportError("Bad GLB: file size doesn't match")
|
|
|
|
offset = 12 # header size = 12
|
|
type_, len_, json_bytes, offset = self.load_chunk(content, offset)
|
|
if type_ != b"JSON":
|
|
raise ImportError("Bad GLB: first chunk not JSON")
|
|
if len_ != len(json_bytes):
|
|
raise ImportError("Bad GLB: length of json chunk doesn't match")
|
|
self.json = glTFDataExtractor.load_json(json_bytes)
|
|
|
|
# BIN chunk(s) is second (if exists)
|
|
if offset < len(content):
|
|
type_, len_, bin_bytes, offset = self.load_chunk(content, offset)
|
|
if type_ != b"BIN\x00":
|
|
raise ImportError("Bad GLB: second chunk not BIN")
|
|
if len_ != len(bin_bytes):
|
|
raise ImportError("Bad GLB: length of bin chunk doesn't match")
|
|
self.buffers.append(bin_bytes)
|
|
|
|
def load_chunk(self, content, offset):
|
|
chunk_header = struct.unpack_from('<I4s', content, offset)
|
|
data_length = chunk_header[0]
|
|
data_type = chunk_header[1]
|
|
data = content[offset + 8: offset + 8 + data_length]
|
|
|
|
return data_type, data_length, data, offset + 8 + data_length
|
|
|
|
@staticmethod
|
|
def load_json(content):
|
|
def bad_constant(val):
|
|
raise ImportError('Bad glTF: json contained %s' % val)
|
|
try:
|
|
text = str(content, encoding='utf-8')
|
|
return json.loads(text, parse_constant=bad_constant)
|
|
except ValueError as e:
|
|
raise ImportError('Bad glTF: json error: %s' % e.args[0])
|
|
|
|
|
|
class ComponentType(IntEnum):
|
|
Byte = 5120
|
|
UnsignedByte = 5121
|
|
Short = 5122
|
|
UnsignedShort = 5123
|
|
UnsignedInt = 5125
|
|
Float = 5126
|
|
|
|
@classmethod
|
|
def to_numpy_dtype(cls, component_type):
|
|
import numpy as np
|
|
return {
|
|
ComponentType.Byte: np.int8,
|
|
ComponentType.UnsignedByte: np.uint8,
|
|
ComponentType.Short: np.int16,
|
|
ComponentType.UnsignedShort: np.uint16,
|
|
ComponentType.UnsignedInt: np.uint32,
|
|
ComponentType.Float: np.float32,
|
|
}[component_type]
|
|
|
|
|
|
class DataType:
|
|
Scalar = "SCALAR"
|
|
Vec2 = "VEC2"
|
|
Vec3 = "VEC3"
|
|
Vec4 = "VEC4"
|
|
Mat2 = "MAT2"
|
|
Mat3 = "MAT3"
|
|
Mat4 = "MAT4"
|
|
|
|
def __new__(cls, *args, **kwargs):
|
|
raise RuntimeError("{} should not be instantiated".format(cls.__name__))
|
|
|
|
@classmethod
|
|
def num_elements(cls, data_type):
|
|
return {
|
|
DataType.Scalar: 1,
|
|
DataType.Vec2: 2,
|
|
DataType.Vec3: 3,
|
|
DataType.Vec4: 4,
|
|
DataType.Mat2: 4,
|
|
DataType.Mat3: 9,
|
|
DataType.Mat4: 16
|
|
}[data_type]
|
|
|
|
|
|
def uri_to_path(uri):
|
|
uri = uri.replace('\\', '/') # Some files come with \\ as dir separator
|
|
uri = unquote(uri)
|
|
return pathlib.Path(uri)
|
|
|
|
|
|
def convert_float(x):
|
|
if abs(x) < 0.0005:
|
|
return "0.000"
|
|
return f"{x:.3f}"
|