Repository URL to install this package:
|
Version:
1.16.0 ▾
|
pygltflib
/
__init__.py
|
|---|
"""
pygltflib : A Python library for reading, writing and handling GLTF files.
Copyright (c) 2018,2021 Luke Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import base64
import copy
from dataclasses import (
_is_dataclass_instance,
dataclass,
field,
fields,
)
from datetime import date, datetime
from enum import Enum
import json
import mimetypes
from pathlib import Path
from shutil import copyfile
from typing import Any, Dict, List
from typing import Callable, Optional, Tuple, Type, Union
from urllib.parse import unquote
import struct
import warnings
from dataclasses_json import dataclass_json as dataclass_json
from dataclasses_json.core import _decode_dataclass
from dataclasses_json.core import _ExtendedEncoder as JsonEncoder
__version__ = "1.16.0"
"""
About the GLTF2 file format:
glTF uses a right-handed coordinate system, that is, the cross product of +X and +Y yields +Z. glTF defines +Y as up.
The front of a glTF asset faces +Z.
The units for all linear distances are meters.
All angles are in radians.
Positive rotation is counterclockwise.
"""
ANIM_LINEAR = "LINEAR"
ANIM_STEP = "STEP"
ANIM_CALMULLROMSPLINE = "CALMULLROMSPLINE"
ANIM_CUBICSPLINE = "CUBICSPLINE"
SCALAR = "SCALAR"
VEC2 = "VEC2"
VEC3 = "VEC3"
VEC4 = "VEC4"
MAT2 = "MAT2"
MAT3 = "MAT3"
MAT4 = "MAT4"
BYTE = 5120
UNSIGNED_BYTE = 5121
SHORT = 5122
UNSIGNED_SHORT = 5123
UNSIGNED_INT = 5125
FLOAT = 5126
COMPONENT_TYPES = [BYTE, UNSIGNED_BYTE, SHORT, UNSIGNED_SHORT, UNSIGNED_INT, FLOAT]
ACCESSOR_SPARSE_INDICES_COMPONENT_TYPES = [UNSIGNED_BYTE, UNSIGNED_SHORT, UNSIGNED_INT]
# MESH PRIMITIVE MODES
POINTS = 0
LINES = 1
LINE_LOOP = 2
LINE_STRIP = 3
TRIANGLES = 4
TRIANGLE_STRIP = 5
TRIANGLE_FAN = 6
MESH_PRIMITIVE_MODES = [POINTS, LINES, LINE_LOOP, LINE_STRIP, TRIANGLES, TRIANGLE_STRIP, TRIANGLE_FAN]
# The bufferView target that the GPU buffer should be bound to.
ARRAY_BUFFER = 34962 # eg vertex data
ELEMENT_ARRAY_BUFFER = 34963 # eg index data
BUFFERVIEW_TARGETS = [ARRAY_BUFFER, ELEMENT_ARRAY_BUFFER]
TRANSLATION = "translation"
ROTATION = "rotation"
SCALE = "scale"
WEIGHTS = "weights"
ANIMATION_CHANNEL_TARGET_PATHS = [TRANSLATION, ROTATION, SCALE, WEIGHTS]
POSITION = "POSITION"
NORMAL = "NORMAL"
TANGENT = "TANGENT"
TEXCOORD_0 = "TEXCOORD_0"
TEXCOORD_1 = "TEXCOORD_1"
COLOR_0 = "COLOR_0"
JOINTS_0 = "JOINTS_0"
WEIGHTS_0 = "WEIGHTS_0"
CLAMP_TO_EDGE = 33071
MIRRORED_REPEAT = 33648
REPEAT = 10497
WRAPPING_MODES = [CLAMP_TO_EDGE, MIRRORED_REPEAT, REPEAT]
IMAGEJPEG = 'image/jpeg'
IMAGEPNG = "image/png"
IMAGE_MIMETYPES = [IMAGEJPEG, IMAGEPNG]
NEAREST = 9728
LINEAR = 9729
NEAREST_MIPMAP_NEAREST = 9984
LINEAR_MIPMAP_NEAREST = 9985
NEAREST_MIPMAP_LINEAR = 9986
LINEAR_MIPMAP_LINEAR = 9987
MAGNIFICATION_FILTERS = [NEAREST, LINEAR]
MINIFICATION_FILTERS = [NEAREST, LINEAR, NEAREST_MIPMAP_NEAREST, LINEAR_MIPMAP_NEAREST, NEAREST_MIPMAP_LINEAR,
LINEAR_MIPMAP_LINEAR]
PERSPECTIVE = "perspective"
ORTHOGRAPHIC = "orthographic"
CAMERA_TYPES = [PERSPECTIVE, ORTHOGRAPHIC]
BLEND = "BLEND"
MASK = "MASK"
OPAQUE = "OPAQUE"
MATERIAL_ALPHAMODES = [OPAQUE, MASK, BLEND]
JSON = "JSON"
BIN = "BIN\x00"
MAGIC = b'glTF'
GLTF_VERSION = 2 # version this library exports
GLTF_MIN_VERSION = 2 # minimum version this library can load
GLTF_MAX_VERSION = 2 # maximum supported version this library can load
DATA_URI_HEADER = "data:application/octet-stream;base64,"
class BufferFormat(Enum):
DATAURI = "data uri"
BINARYBLOB = "binary blob"
BINFILE = "bin file"
class ImageFormat(Enum):
DATAURI = "data uri"
FILE = "image file"
BUFFERVIEW = "buffer view"
# backwards and forwards compat dataclasses-json and dataclasses
class LetterCase(Enum):
CAMEL = 'camelCase'
KEBAB = 'kebab-case'
SNAKE = 'snake_case'
def delete_empty_keys(dictionary):
"""
Delete keys with the value ``None`` in a dictionary, recursively.
This alters the input so you may wish to ``copy`` the dict first.
Courtesy Chris Morgan and modified from:
https://stackoverflow.com/questions/4255400/exclude-empty-null-values-from-json-serialization
"""
for key, value in list(dictionary.items()):
if value is None or (hasattr(value, '__iter__') and len(value) == 0):
del dictionary[key]
elif isinstance(value, dict) and key != "extensions":
# delete empty dicts except when the dictionary is an extension inside "extensions".
# The extension exemption is because we use dicts for extensions instead of dataclass objects
delete_empty_keys(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
delete_empty_keys(item)
return dictionary # For convenience
def json_serial(obj):
"""JSON serializer for objects not serializable by default json code"""
if isinstance(obj, (datetime, date)):
return obj.isoformat()
raise TypeError("Type %s not serializable" % type(obj))
def gltf_asdict(obj, *, dict_factory=dict):
# convert a dataclass object to a dict
if not _is_dataclass_instance(obj):
raise TypeError("asdict() should be called on dataclass instances")
return _asdict_inner(obj, dict_factory)
def _asdict_inner(obj, dict_factory):
# return the same result as dataclass _asdict_inner except for Attributes, which can have custom specifiers.
if type(obj) == Attributes:
return copy.deepcopy(obj.__dict__)
if _is_dataclass_instance(obj):
result = []
for f in fields(obj):
value = _asdict_inner(getattr(obj, f.name), dict_factory)
result.append((f.name, value))
return dict_factory(result)
elif isinstance(obj, tuple) and hasattr(obj, '_fields'):
return type(obj)(*[_asdict_inner(v, dict_factory) for v in obj])
elif isinstance(obj, (list, tuple)):
return type(obj)(_asdict_inner(v, dict_factory) for v in obj)
elif isinstance(obj, dict):
return type(obj)((_asdict_inner(k, dict_factory),
_asdict_inner(v, dict_factory))
for k, v in obj.items())
else:
return copy.deepcopy(obj)
@dataclass_json
@dataclass
class Property:
extensions: Optional[Dict[str, Any]] = field(default_factory=dict)
extras: Optional[Dict[str, Any]] = field(default_factory=dict)
@dataclass_json
@dataclass
class Asset(Property):
generator: Optional[str] = f"pygltflib@v{__version__}"
copyright: Optional[str] = None
version: str = "2.0" # required
minVersion: Optional[str] = None
# Attributes is a special case so we provide our own json handling
class Attributes:
def __init__(self,
POSITION=None,
NORMAL=None,
TANGENT=None,
TEXCOORD_0=None,
TEXCOORD_1=None,
COLOR_0: int = None,
JOINTS_0: int = None,
WEIGHTS_0=None, *args, **kwargs):
self.POSITION = POSITION
self.NORMAL = NORMAL
self.TANGENT = TANGENT
self.TEXCOORD_0 = TEXCOORD_0
self.TEXCOORD_1 = TEXCOORD_1
self.COLOR_0 = COLOR_0
self.JOINTS_0 = JOINTS_0
self.WEIGHTS_0 = WEIGHTS_0
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self):
return self.__class__.__qualname__ + '(' + ', '.join([f"{f}={v}" for f, v in self.__dict__.items()]) + ')'
def to_json(self, *args, **kwargs):
# Attributes objects can have custom attrs, so use our own json conversion methods.
data = copy.deepcopy(self.__dict__)
return json.dumps(data)
@staticmethod
def from_json():
warnings.warn("To allow custom attributes on Attributes, we don't use dataclasses-json on this class."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
@dataclass_json
@dataclass
class Primitive(Property):
attributes: Attributes = field(default_factory=Attributes) # required
indices: Optional[int] = None
mode: Optional[int] = TRIANGLES
material: Optional[int] = None
targets: Optional[List[Attributes]] = field(default_factory=list)
@dataclass_json
@dataclass
class Mesh(Property):
primitives: List[Primitive] = field(default_factory=list) # required
weights: Optional[List[float]] = field(default_factory=list)
name: Optional[str] = None
@dataclass_json
@dataclass
class AccessorSparseIndices(Property):
bufferView: int = None # required
byteOffset: Optional[int] = 0
componentType: int = None # required
@dataclass_json
@dataclass
class AccessorSparseValues(Property):
bufferView: int = None # required
byteOffset: Optional[int] = 0
@dataclass_json
@dataclass
class Sparse(Property):
count: int = None # required
indices: AccessorSparseIndices = None # required
values: AccessorSparseValues = None # required
@dataclass_json
@dataclass
class Accessor(Property):
bufferView: Optional[int] = None
byteOffset: Optional[int] = 0
componentType: int = None # required
normalized: Optional[bool] = False
count: int = None # required
type: str = None # required
sparse: Optional[Sparse] = None
max: Optional[List[float]] = field(default_factory=list)
min: Optional[List[float]] = field(default_factory=list)
name: Optional[str] = None
@dataclass_json
@dataclass
class BufferView(Property):
buffer: int = None
byteOffset: Optional[int] = 0
byteLength: int = None
byteStride: Optional[int] = None
target: Optional[int] = None
name: Optional[str] = None
@dataclass_json
@dataclass
class Buffer(Property):
uri: Optional[str] = None
byteLength: int = None
@dataclass_json
@dataclass
class Perspective(Property):
aspectRatio: Optional[float] = None
yfov: float = None # required
zfar: Optional[float] = None
znear: float = None # required
@dataclass_json
@dataclass
class Orthographic(Property):
xmag: float = None # required
ymag: float = None # required
zfar: float = None # required
znear: float = None # required
@dataclass_json
@dataclass
class Camera(Property):
perspective: Optional[Perspective] = None
orthographic: Optional[Orthographic] = None
type: str = None
name: Optional[str] = None
@dataclass_json
@dataclass
class TextureInfo(Property):
index: int = None # required
texCoord: Optional[int] = 0
@dataclass_json
@dataclass
class OcclusionTextureInfo(Property):
index: Optional[int] = None
texCoord: Optional[int] = None
strength: Optional[float] = 1.0
@dataclass_json
@dataclass
class NormalMaterialTexture(Property):
index: Optional[int] = None
texCoord: Optional[int] = None
scale: Optional[float] = 1.0
@dataclass_json
@dataclass
class PbrMetallicRoughness(Property):
baseColorFactor: Optional[List[float]] = field(default_factory=lambda: [1.0, 1.0, 1.0, 1.0])
metallicFactor: Optional[float] = 1.0
roughnessFactor: Optional[float] = 1.0
baseColorTexture: Optional[TextureInfo] = None
metallicRoughnessTexture: Optional[TextureInfo] = None
@dataclass_json
@dataclass
class Material(Property):
pbrMetallicRoughness: Optional[PbrMetallicRoughness] = None
normalTexture: Optional[NormalMaterialTexture] = None
occlusionTexture: Optional[OcclusionTextureInfo] = None
emissiveFactor: Optional[List[float]] = field(default_factory=lambda: [0.0, 0.0, 0.0])
emissiveTexture: Optional[TextureInfo] = None
alphaMode: Optional[str] = OPAQUE
alphaCutoff: Optional[float] = 0.5
doubleSided: Optional[bool] = False
name: Optional[str] = None
@dataclass_json
@dataclass
class Sampler(Property):
"""
Samplers are stored in the samplers array of the asset.
Each sampler specifies filter and wrapping options corresponding to the GL types
"""
input: Optional[int] = None
interpolation: Optional[str] = None
output: Optional[int] = None
magFilter: Optional[int] = None
minFilter: Optional[int] = None
wrapS: Optional[int] = REPEAT # repeat wrapping in S (U)
wrapT: Optional[int] = REPEAT # repeat wrapping in T (V)
@dataclass_json
@dataclass
class Node(Property):
mesh: Optional[int] = None
skin: Optional[int] = None
rotation: Optional[List[float]] = None
translation: Optional[List[float]] = None
scale: Optional[List[float]] = None
children: Optional[List[int]] = field(default_factory=list)
matrix: Optional[List[float]] = None
camera: Optional[int] = None
name: Optional[str] = None
@dataclass_json
@dataclass
class Skin(Property):
inverseBindMatrices: Optional[int] = None
skeleton: Optional[int] = None
joints: Optional[List[int]] = field(default_factory=list)
name: Optional[str] = None
@dataclass_json
@dataclass
class Scene(Property):
name: Optional[str] = None
nodes: Optional[List[int]] = field(default_factory=list)
@dataclass_json
@dataclass
class Texture(Property):
sampler: Optional[int] = None
source: Optional[int] = None
name: Optional[str] = None
@dataclass_json
@dataclass
class Image(Property):
uri: str = None
mimeType: str = None
bufferView: int = None
name: Optional[str] = None
@dataclass_json
@dataclass
class AnimationChannelTarget(Property):
node: Optional[int] = None
path: str = None # required
@dataclass_json
@dataclass
class AnimationSampler(Property):
input: int = None # required
interpolation: Optional[str] = ANIM_LINEAR
output: int = None # required
@dataclass_json
@dataclass
class AnimationChannel(Property):
sampler: int = None # required
target: AnimationChannelTarget = None # required
@dataclass_json
@dataclass
class Animation(Property):
name: Optional[str] = None
channels: List[AnimationChannel] = field(default_factory=list)
samplers: List[AnimationSampler] = field(default_factory=list)
@dataclass
class GLTF2(Property):
accessors: List[Accessor] = field(default_factory=list)
animations: List[Animation] = field(default_factory=list)
asset: Asset = field(default_factory=Asset) # required
bufferViews: List[BufferView] = field(default_factory=list)
buffers: List[Buffer] = field(default_factory=list)
cameras: List[Camera] = field(default_factory=list)
extensionsUsed: List[str] = field(default_factory=list)
extensionsRequired: List[str] = field(default_factory=list)
images: List[Image] = field(default_factory=list)
materials: List[Material] = field(default_factory=list)
meshes: List[Mesh] = field(default_factory=list)
nodes: List[Node] = field(default_factory=list)
samplers: List[Sampler] = field(default_factory=list)
scene: int = None
scenes: List[Scene] = field(default_factory=list)
skins: List[Skin] = field(default_factory=list)
textures: List[Texture] = field(default_factory=list)
# _glb_data: Any = None
# _path: Any = None
# _min_alignment: int = 4
def binary_blob(self):
""" Get the binary blob associated with glb files if available
Returns
(bytes): binary data
"""
return getattr(self, "_glb_data", None)
def set_binary_blob(self, blob):
setattr(self, "_glb_data", blob)
def destroy_binary_blob(self):
if hasattr(self, "_glb_data"):
setattr(self, "_glb_data", None)
def set_min_alignment(self, min_alignment: Optional[int]) -> None:
"""Set the minimum alignment for glb chunks.
A larger alignment may still be used if necessary.
Only power-of-two alignments are supported.
"""
if (min_alignment is None) or (min_alignment < 4):
min_alignment = 4
# round up to next power-of-two
min_alignment = 1 << (min_alignment - 1).bit_length()
setattr(self, "_min_alignment", min_alignment)
def required_alignment(self) -> int:
"""
Get the required alignment for glb chunks.
By default this is 4, unless a larger alignment is requested or
required by an extension.
Returns
required_alignment (int)
"""
alignment = getattr(self, "_min_alignment", 4)
if ("EXT_structural_metadata" in self.extensionsUsed or
"EXT_structural_metadata" in self.extensionsRequired or
"EXT_structural_metadata" in self.extensions):
# EXT_structural_metadata requires an 8 byte alignment.
alignment = max(alignment, 8)
return alignment
def load_file_uri(self, uri):
"""
Loads a file pointed to by a uri
"""
path = getattr(self, "_path", Path())
with open(Path(path, uri), 'rb') as fb:
data = fb.read()
return data
@staticmethod
def decode_data_uri(uri):
"""
Decodes the binary portion of a data uri.
"""
data = uri.split(DATA_URI_HEADER)[1]
data = base64.decodebytes(bytes(data, "utf8"))
return data
def identify_uri(self, uri):
"""
Identify the format of the requested buffer. File, data or binary blob.
Returns
buffer_type (str)
"""
path = getattr(self, "_path", Path())
uri_format = None
if uri is None: # assume loaded from glb binary file
uri_format = BufferFormat.BINARYBLOB
if len(self.buffers) > 1:
warnings.warn("GLTF has multiple buffers but only one buffer binary blob, pygltflib might corrupt data."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
elif uri.startswith("data"):
uri_format = BufferFormat.DATAURI
elif Path(path, uri).is_file():
uri_format = BufferFormat.BINFILE
else:
warnings.warn("pygltf.GLTF.identify_buffer_format can not identify buffer."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
return uri_format
def get_data_from_buffer_uri(self, uri):
"""
No matter how the buffer data is stored (the uri may be a long string, a file name or imply
a binary blob), strip off any headers and do any conversions are return a universal binary
blob for manipulation.
"""
current_buffer_format = self.identify_uri(uri)
if current_buffer_format == BufferFormat.BINFILE:
data = self.load_file_uri(uri)
elif current_buffer_format == BufferFormat.DATAURI:
data = self.decode_data_uri(uri)
elif current_buffer_format == BufferFormat.BINARYBLOB:
data = self.binary_blob()
else:
return None
return data
# noinspection PyPep8Naming
def remove_bufferView(self, buffer_view_id):
"""
Remove a bufferView and update all the bufferView pointers in the GLTF object
"""
bufferView = self.bufferViews.pop(buffer_view_id)
def update_obj(title, obj):
if obj:
if obj.bufferView == buffer_view_id:
warnings.warn(f"Removing bufferView {buffer_view_id} but "
f"{title}.bufferView still points to it. This may corrupt the GLTF.")
if obj.bufferView >= buffer_view_id:
obj.bufferView -= 1
else:
print(f"{obj} empty")
for i, accessor in enumerate(self.accessors):
update_obj(f"gltf.accessors[{i}]", accessor)
if accessor and accessor.sparse:
update_obj(f"gltf.accessors[{i}].sparse.indices", accessor.sparse.indices)
update_obj(f"gltf.accessors[{i}].sparse.values", accessor.sparse.values)
for i, obj in enumerate(self.images):
update_obj(f"gltf.images[{i}]", obj)
return bufferView
def export_datauri_as_image_file(self, data_uri, name, destination, override=False, index=0):
""" convert data uri to image file
If destination is full path and file name, use that.
If destination is just a directory, use the name of the data_uri
"""
header, encoded = data_uri.split(",", 1)
mime = header.split(":")[1].split(";")[0]
if name: # use image.name
file_name = name
else:
extension = mimetypes.guess_extension(mime)
file_name = f"{index}{extension}"
destination = Path(destination)
if destination.is_dir():
image_path = destination / unquote(file_name)
else: # assume filepath
image_path = destination
if image_path.is_file() and not override:
warnings.warn(f"Unable to write image file, a file already exists at {image_path}")
return None
data = base64.b64decode(encoded)
with open(image_path, "wb") as image_file:
image_file.write(data)
return file_name
def export_fileuri_as_image_file(self, file_uri, destination, override=False):
""" Export file uri as another image file (ie copy out of GLTF into own location) """
path = getattr(self, "_path", Path())
image_path = Path(path / unquote(file_uri))
if not image_path.exists():
warnings.warn(f"Unable to find image {image_path} for export.")
return None
if image_path.is_file() and not override:
warnings.warn(f"Unable to write image file, a file already exists at {image_path}")
return None
copyfile(image_path, destination)
return file_uri
def export_image(self, image_index, destination='', override=False):
""" Directly export an image to a file without affecting GLTF """
destination = Path(destination)
image = self.images[image_index]
if image.uri and not image.uri.startswith('data:'): # copy file to new location
self.export_fileuri_as_image_file(image.uri, destination)
elif image.bufferView is not None:
warnings.warn("pygltflib.export_image currently unable to convert image stored buffers to image file. "
"Try GLTF.convert_images() first. "
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
elif image.uri.startswith('data:'):
file_name = self.export_datauri_as_image_file(image.uri, image.name, destination, override, image_index)
return file_name
def export_image_to_file(self, image_index, destination_path='', override=False):
"""
Used primarily by convert_images. To export images consider using GLTF2.export_image
image_index (int): Image index
destination_path (str|Path): Path where to save images. Images will also be loaded from this path if needed.
override (bool): Only save image if it does not already exist
"""
destination_path = Path(destination_path)
image = self.images[image_index]
if image.uri and not image.uri.startswith('data:'):
# already in file format
image_path = destination_path / unquote(image.uri)
if not image_path.exists():
warnings.warn(f"Image {image_index} is already stored in a file {image_path} but file "
f"does not appear to exist.")
return None
elif image.bufferView is not None:
# TODO: remove bufferView from GLTF when create images or datauris from buffer data
bufferView = self.bufferViews[image.bufferView]
buffer = self.buffers[bufferView.buffer]
if buffer.uri: # buffer is stored as a data uri or an uri pointing to a non-existent file
warnings.warn("pygltflib currently unable to convert image stored buffers to image file."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
else: # buffer is stored in the binary blob
warnings.warn(
"pygltflib currently does not remove image data from the buffer when converting to files."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
data = self.binary_blob()
extension = mimetypes.guess_extension(image.mimeType)
file_name = f"{image_index}{extension}"
image_path = destination_path / file_name
if image_path.is_file() and not override:
warnings.warn(f"Unable to write image file, a file already exists at {image_path}")
return None
with open(image_path, "wb") as f:
f.write(data[bufferView.byteOffset:bufferView.byteOffset + bufferView.byteLength])
return file_name
return None
elif image.uri.startswith('data:'):
file_name = self.export_datauri_as_image_file(
image.uri,
image.name,
destination_path,
override,
image_index,
)
return file_name
def convert_images(self, image_format, path=None, override=False):
"""
GLTF files can store the image data in three different formats: In the buffers, as a data
uri string and as external images files. This converts the images between the formats.
image_format (ImageFormat.ENUM): Destination format to convert images
path (str|Path): Path to the directory to use for loading or saving images
override (bool): Override an image file if it already exists and is about to be replaced
"""
if path is None:
path = getattr(self, "_path", Path())
else:
path = Path(path)
for image_index, image in enumerate(self.images):
if image_format == ImageFormat.DATAURI: # convert to data uri
# load an image file or pull from the buffer
if image.uri: # either already in a datauri format, or in a file
if not image.uri.startswith('data:'): # not in data format, so assume a file name
# data is stored in a file, so load into data uri
image_path = Path(path / unquote(image.uri))
if not image_path.exists():
warnings.warn(f"Expected image file at {image_path} not found.")
continue
mime: str
mime, _ = mimetypes.guess_type(str(image_path))
with open(image_path, "rb") as image_file:
encoded_string = str(base64.b64encode(image_file.read()).decode('utf-8'))
image.name = copy.copy(image.uri) if not image.name else image.name
image.uri = f'data:{mime};base64,{encoded_string}'
elif image.bufferView is not None:
# TODO: remove bufferView from GLTF when create images or datauris from buffer data
warnings.warn("pygltflib currently does not remove image data "
"from the buffer when converting to data uri."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
data = self.binary_blob()
bufferView = self.bufferViews[image.bufferView]
image_data = data[bufferView.byteOffset:bufferView.byteOffset + bufferView.byteLength]
encoded_string = str(base64.b64encode(image_data).decode('utf-8'))
image.uri = f'data:{image.mimeType};base64,{encoded_string}'
else:
warnings.warn(f"Image {image_index} appears to have neither a uri nor a buffer view.")
elif image_format == ImageFormat.BUFFERVIEW: # convert to buffer
# load image data into the buffer and and add a bufferView
if image.bufferView is not None:
# already in bufferview format
continue
warnings.warn("pygltflib currently unable to add image data to buffers."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
continue
elif image_format == ImageFormat.FILE: # convert to images
file_name = self.export_image_to_file(image_index, path, override)
if file_name: # replace data uri with pointer to file
image.uri = file_name
def convert_buffers(self, buffer_format, override=False):
"""
GLTF files can store the buffer data in three different formats: As a binary blob ready for glb, as a data
uri string and as external bin files. This converts the buffers between the formats.
buffer_format (BufferFormat.ENUM)
override (bool): Override a bin file if it already exists and is about to be replaced
"""
path: Path = getattr(self, "_path", Path())
for i, buffer in enumerate(self.buffers):
current_buffer_format = self.identify_uri(buffer.uri)
if current_buffer_format == buffer_format: # already in the format
continue
if current_buffer_format == BufferFormat.BINFILE:
warnings.warn(f"Conversion will leave {buffer.uri} file orphaned since data is now in the GLTF object.")
data = self.get_data_from_buffer_uri(buffer.uri)
if not data:
return
self.destroy_binary_blob() # free up any binary blob floating around
if buffer_format == BufferFormat.BINARYBLOB:
if len(self.buffers) > 1:
warnings.warn("pygltflib currently unable to convert multiple buffers to a single binary blob."
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
return
self.set_binary_blob(data)
buffer.uri = None
elif buffer_format == BufferFormat.DATAURI:
# convert buffer to a data uri
data = base64.b64encode(data).decode('utf-8')
buffer.uri = f'{DATA_URI_HEADER}{data}'
elif buffer_format == BufferFormat.BINFILE:
filename = Path(f"{i}").with_suffix(".bin")
binfile_path = path / filename
if binfile_path.is_file() and not override:
warnings.warn(f"Unable to write buffer file, a file already exists at {binfile_path}")
continue
with open(binfile_path, "wb") as f: # save bin file with the gltf file
f.write(data)
buffer.uri = str(filename)
def to_json(self,
*,
skipkeys: bool = False,
ensure_ascii: bool = True,
check_circular: bool = True,
allow_nan: bool = True,
indent: Optional[Union[int, str]] = None,
separators: Tuple[str, str] = None,
default: Callable = None,
sort_keys: bool = False,
**kw) -> str:
"""
to_json and from_json from dataclasses_json
courtesy https://github.com/lidatong/dataclasses-json
"""
data = gltf_asdict(self)
data = delete_empty_keys(data)
return json.dumps(data,
cls=JsonEncoder,
skipkeys=skipkeys,
ensure_ascii=ensure_ascii,
check_circular=check_circular,
allow_nan=allow_nan,
indent=indent,
separators=separators,
default=default,
sort_keys=sort_keys,
**kw)
@classmethod
def from_json(cls: Type['GLTF2'],
s: str,
*,
parse_float=None,
parse_int=None,
parse_constant=None,
infer_missing=False,
**kw) -> 'GLTF2':
init_kwargs = json.loads(s,
parse_float=parse_float,
parse_int=parse_int,
parse_constant=parse_constant,
**kw)
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
result = _decode_dataclass(cls, init_kwargs, infer_missing) # type: GLTF2
for mesh in result.meshes:
for primitive in mesh.primitives:
raw_attributes = primitive.attributes
if raw_attributes:
attributes = Attributes(**raw_attributes)
primitive.attributes = attributes
return result
def gltf_to_json(self, separators=None, indent=" ") -> str:
return self.to_json(default=json_serial, indent=indent, allow_nan=False, skipkeys=True, separators=separators)
@staticmethod
def get_bin_name_from_path(path: Path):
""" remove an extension and path and return a bin filename (sans path) """
return str(Path(path.stem)) + ".bin"
def save_json(self, fname):
path = Path(fname)
original_buffers = copy.deepcopy(self.buffers)
for i, buffer in enumerate(self.buffers):
if buffer.uri is None: # save glb_data as bin file
# update the buffer uri to point to our new local bin file
glb_data = self.binary_blob()
if glb_data:
buffer.uri = self.get_bin_name_from_path(path)
with open(path.with_suffix(".bin"), "wb") as f: # save bin file with the gltf file
f.write(glb_data)
else:
warnings.warn(f"buffer {i} is empty: {buffer}")
with open(path, "w") as f:
f.write(self.gltf_to_json())
self.buffers = original_buffers # restore buffers
return True
def buffers_to_binary_blob(self):
""" Flatten all buffers into a single buffer """
buffer_blob = bytearray()
offset = 0
path = getattr(self, "_path", Path())
binary_blob: Optional[bytearray] = None
for i, bufferView in enumerate(self.bufferViews):
buffer = self.buffers[bufferView.buffer]
if buffer.uri is None: # assume loaded from glb binary file
if binary_blob is None:
binary_blob = self.binary_blob()
data = binary_blob
elif buffer.uri.startswith("data"):
data = self.decode_data_uri(buffer.uri)
elif Path(path, buffer.uri).is_file():
with open(Path(path, buffer.uri), 'rb') as fb:
data = fb.read()
else:
warnings.warn(f"Unable to save bufferView {buffer.uri[:20]} to glb, skipping. "
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
continue
byte_offset = bufferView.byteOffset if bufferView.byteOffset is not None else 0
byte_length = bufferView.byteLength
bufferView.byteOffset = offset
bufferView.byteLength = byte_length
bufferView.buffer = 0
buffer_blob += data[byte_offset:byte_offset + byte_length]
# Pad each buffer to the required alignment (usually 4 bytes) to make following data happy
padding = -byte_length % self.required_alignment()
buffer_blob += b'\0' * padding
offset += padding
offset += byte_length
return buffer_blob
def save_to_bytes(self):
# setup
original_buffer_views = copy.deepcopy(self.bufferViews)
original_buffers = copy.deepcopy(self.buffers)
new_buffer = Buffer()
buffer_blob = self.buffers_to_binary_blob()
new_buffer.byteLength = len(buffer_blob)
self.buffers = [new_buffer]
json_blob = self.gltf_to_json(separators=(',', ':'), indent=None).encode("utf-8")
version = struct.pack('<I', GLTF_VERSION)
chunk_header_len = 8
gltf_header_len = len(MAGIC) + len(version) + 4
# Pad each blob if needed; include the whole length before the json
# too, to reach global alignment. We subtract one chunk header length,
# so the start of the binary blob is aligned (otherwise the header
# would be aligned, instead of the data).
padding = -(gltf_header_len + chunk_header_len + len(json_blob) - chunk_header_len) % self.required_alignment()
if padding != 0:
json_blob += b' ' * padding
length = gltf_header_len + chunk_header_len * 2 + len(json_blob) + len(buffer_blob)
self.bufferViews = original_buffer_views # restore unpacked bufferViews
self.buffers = original_buffers # restore unpacked buffers
# header is MAGIC, version, length
# json chunk is json_blob length, JSON, json_blob
# buffer chunk is length of buffer_blob, utf-8, buffer_blob
return [
MAGIC,
version,
struct.pack('<I', length),
struct.pack('<I', len(json_blob)),
bytes(JSON, 'utf-8'),
json_blob,
struct.pack('<I', len(buffer_blob)),
bytes(BIN, 'utf-8'),
buffer_blob
]
def save_binary(self, fname):
with open(fname, 'wb') as f:
glb_structure = self.save_to_bytes()
if not glb_structure:
return False
for data in glb_structure:
f.write(data)
return True
def save(self, fname, asset=Asset()):
self.asset = asset
self._path = Path(fname).parent
self._name = Path(fname).name
ext = Path(fname).suffix
if ext.lower() in [".glb"]:
return self.save_binary(fname)
else:
if getattr(self, "_glb_data", None):
warnings.warn(
f"This file ({fname}) contains a binary blob loaded from a .glb file, "
"and this will be saved to a .bin file next to the json file.")
return self.save_json(fname)
@classmethod
def gltf_from_json(cls, json_data):
return cls.from_json(json_data, infer_missing=True)
@classmethod
def load_json(cls, fname):
with open(fname, "r") as f:
obj = cls.gltf_from_json(f.read())
return obj
@classmethod
def load_from_bytes(cls, data):
magic = struct.unpack("<BBBB", data[:4])
version, length = struct.unpack("<II", data[4:12])
if bytearray(magic) != MAGIC:
raise IOError("Unable to load binary gltf file. Header does not appear to be valid glb format.")
if version > GLTF_MAX_VERSION:
warnings.warn(f"pygltflib supports v{GLTF_MAX_VERSION} of the binary gltf format, "
"this file is version {version}, "
"it may not import correctly. "
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
index = 12
i = 0
obj = None
while index < length:
chunk_length = struct.unpack("<I", data[index:index + 4])[0]
index += 4
chunk_type = bytearray(struct.unpack("<BBBB", data[index:index + 4])).decode()
index += 4
if chunk_type not in [JSON, BIN]:
warnings.warn(f"Ignoring chunk {i} with unknown type '{chunk_type}', probably glTF extensions. "
"Please open an issue at https://gitlab.com/dodgyville/pygltflib/issues")
elif chunk_type == JSON:
raw_json = data[index:index + chunk_length].decode("utf-8")
obj = cls.from_json(raw_json, infer_missing=True)
else:
obj.set_binary_blob(data[index:index + chunk_length])
index += chunk_length
i += 1
return obj
@classmethod
def load_binary(cls, fname):
with open(fname, "rb") as f:
data = f.read()
return cls.load_from_bytes(data)
@classmethod
def load_binary_from_file_object(cls, f):
data = f.read()
return cls.load_from_bytes(data)
@classmethod
def load(cls, fname):
path = Path(fname)
ext = path.suffix
if ext.lower() in [".bin", ".glb"]:
obj = cls.load_binary(fname)
else:
obj = cls.load_json(fname)
obj._path = path.parent
obj._name = path.name
return obj
def main():
import doctest
doctest.testfile("../README.md")
if __name__ == "__main__":
main()