"""k3d.glb() and k3d.gltf(): glTF 2.0 scenes as K3D objects, one per primitive, read with numpy alone."""
import base64
import json
import os
import struct
import warnings
from typing import Any, Optional, Union
from urllib.parse import unquote
import numpy as np
from ..helpers import image_format
from ..objects import Group, Lines, Mesh, Points
from ..transform import Transform
GLB_MAGIC = b"glTF"
CHUNK_JSON = 0x4E4F534A
CHUNK_BIN = 0x004E4942
COMPONENT_TYPES = {
5120: np.int8,
5121: np.uint8,
5122: np.int16,
5123: np.uint16,
5125: np.uint32,
5126: np.float32,
}
# the divisor glTF uses to turn a normalized integer into a float
NORMALIZED = {
np.dtype(np.int8): 127.0,
np.dtype(np.uint8): 255.0,
np.dtype(np.int16): 32767.0,
np.dtype(np.uint16): 65535.0,
np.dtype(np.uint32): 4294967295.0,
}
TYPE_SIZES = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16}
WRAPPING = {10497: "repeat", 33071: "clamp", 33648: "mirror"}
# primitive modes
POINTS, LINES, LINE_LOOP, LINE_STRIP, TRIANGLES, TRIANGLE_STRIP, TRIANGLE_FAN = range(7)
# extensions read in full, or whose absence costs nothing visible
SUPPORTED_EXTENSIONS = {
"KHR_draco_mesh_compression",
"KHR_materials_emissive_strength",
"KHR_materials_ior",
"KHR_materials_transmission",
"KHR_materials_unlit",
"KHR_materials_volume",
"KHR_mesh_quantization",
"KHR_texture_transform",
"EXT_mesh_gpu_instancing",
"EXT_texture_webp",
}
# turning glTF's Y up into K3D's Z up: +90 degrees about x
Y_UP_TO_Z_UP = np.array(
[[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32
)
READ_MATERIAL_EXTENSIONS = ("KHR_materials_emissive_strength", "KHR_materials_ior",
"KHR_materials_transmission", "KHR_materials_unlit", "KHR_materials_volume")
TRANSFORM_ARGUMENTS = ("translation", "rotation", "scaling", "model_matrix", "transform")
def _uv_transform(info):
"""The uv set a textureInfo reads and its KHR_texture_transform as a 3x3 matrix."""
transform = info.get("extensions", {}).get("KHR_texture_transform")
if transform is None:
return info.get("texCoord", 0), None
ox, oy = transform.get("offset", [0.0, 0.0])
sx, sy = transform.get("scale", [1.0, 1.0])
angle = transform.get("rotation", 0.0)
c, s = np.cos(angle), np.sin(angle)
translation = np.array([[1, 0, ox], [0, 1, oy], [0, 0, 1]], dtype=np.float64)
rotation = np.array([[c, s, 0], [-s, c, 0], [0, 0, 1]], dtype=np.float64)
scale = np.diag([sx, sy, 1.0])
return transform.get("texCoord", info.get("texCoord", 0)), translation @ rotation @ scale
def _same_uvs(a, b):
"""Whether two (uv set, transform) pairs give the same coordinates."""
if a[0] != b[0]:
return False
ma = a[1] if a[1] is not None else np.identity(3)
mb = b[1] if b[1] is not None else np.identity(3)
return np.allclose(ma, mb)
def _srgb(linear):
"""Linear (glTF) to displayed (K3D) colour."""
linear = np.clip(np.asarray(linear, dtype=np.float64), 0.0, 1.0)
return np.where(linear <= 0.0031308, linear * 12.92, 1.055 * np.power(linear, 1.0 / 2.4) - 0.055)
def _pack(rgb):
"""Packed 0xRRGGBB out of rows of display-space floats in 0..1."""
channels = np.clip(np.rint(np.asarray(rgb) * 255.0), 0, 255).astype(np.uint32)
return (channels[..., 0] << 16) | (channels[..., 1] << 8) | channels[..., 2]
def _node_matrix(node):
"""A node's local transform as a row-major 4x4 matrix."""
if "matrix" in node:
# column-major in the file
return np.array(node["matrix"], dtype=np.float64).reshape(4, 4).T
t = np.array(node.get("translation", [0, 0, 0]), dtype=np.float64)
x, y, z, w = node.get("rotation", [0, 0, 0, 1])
s = np.array(node.get("scale", [1, 1, 1]), dtype=np.float64)
rotation = np.array([
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
])
matrix = np.identity(4)
matrix[:3, :3] = rotation * s
matrix[:3, 3] = t
return matrix
class _Reader:
"""One glTF document: its JSON, its buffers, and decoding of what refers to them."""
def __init__(self, document, binary_chunk, base_dir, label):
self.document = document
self.binary_chunk = binary_chunk
self.base_dir = base_dir
self.label = label
self.ignored = set()
self._buffers = {}
self._images = {}
self._draco = {}
asset = document.get("asset", {})
version = str(asset.get("version", ""))
if not version.startswith("2."):
raise ValueError("%s is glTF %s - only glTF 2.0 is read" % (label, version or "of no version"))
required = set(document.get("extensionsRequired", [])) - SUPPORTED_EXTENSIONS
if required:
hint = (" - decompress it first, e.g. `npx @gltf-transform/cli copy in.glb out.glb`"
if "EXT_meshopt_compression" in required else "")
raise NotImplementedError(
"%s requires %s, which K3D does not read%s" % (label, ", ".join(sorted(required)), hint)
)
def warn(self, what):
self.ignored.add(what)
# -- raw data ---------------------------------------------------------------------------
def _uri(self, uri, what):
if uri.startswith("data:"):
header, _, payload = uri.partition(",")
if header.endswith(";base64"):
return base64.b64decode(payload)
return unquote(payload).encode("latin-1")
if self.base_dir is None:
raise ValueError(
"%s refers to the external file %r for %s - pass the path of the .gltf file "
"rather than its bytes, so the file can be found next to it" % (self.label, uri, what)
)
with open(os.path.join(self.base_dir, unquote(uri)), "rb") as f:
return f.read()
def buffer(self, index):
if index not in self._buffers:
definition = self.document["buffers"][index]
if "uri" in definition:
data = self._uri(definition["uri"], "buffer %d" % index)
elif index == 0 and self.binary_chunk is not None:
data = self.binary_chunk
else:
raise ValueError("%s: buffer %d has no data" % (self.label, index))
self._buffers[index] = memoryview(data)
return self._buffers[index]
def buffer_view(self, index):
view = self.document["bufferViews"][index]
offset = view.get("byteOffset", 0)
return self.buffer(view["buffer"])[offset:offset + view["byteLength"]], view.get("byteStride")
def accessor(self, index):
"""The accessor as a float32 or integer array of shape (count, components)."""
accessor = self.document["accessors"][index]
dtype = np.dtype(COMPONENT_TYPES[accessor["componentType"]])
components = TYPE_SIZES[accessor["type"]]
count = accessor["count"]
if "bufferView" in accessor:
data, stride = self.buffer_view(accessor["bufferView"])
offset = accessor.get("byteOffset", 0)
element = dtype.itemsize * components
if stride and stride != element:
# interleaved: a strided view over the shared buffer, then a compact copy
layout = np.dtype({"names": ["v"], "formats": [(dtype, (components,))],
"offsets": [0], "itemsize": stride})
# numpy wants a whole stride after the last element too
raw = bytes(data[offset:offset + count * stride])
raw += bytes(1) * (count * stride - len(raw))
values = np.frombuffer(raw, dtype=layout, count=count)["v"].copy()
else:
values = np.frombuffer(data, dtype=dtype, count=count * components, offset=offset).copy()
values = values.reshape(count, components)
else:
# no buffer view: zeros, which a sparse accessor may then fill in
values = np.zeros((count, components), dtype=dtype)
if "sparse" in accessor:
sparse = accessor["sparse"]
idx_view, _ = self.buffer_view(sparse["indices"]["bufferView"])
idx_dtype = np.dtype(COMPONENT_TYPES[sparse["indices"]["componentType"]])
positions = np.frombuffer(idx_view, dtype=idx_dtype, count=sparse["count"],
offset=sparse["indices"].get("byteOffset", 0))
val_view, _ = self.buffer_view(sparse["values"]["bufferView"])
replacement = np.frombuffer(val_view, dtype=dtype, count=sparse["count"] * components,
offset=sparse["values"].get("byteOffset", 0))
values[positions.astype(np.int64)] = replacement.reshape(-1, components)
if accessor.get("normalized", False) and dtype in NORMALIZED:
values = np.maximum(values.astype(np.float32) / NORMALIZED[dtype], -1.0)
elif dtype == np.float32:
values = values.astype(np.float32, copy=False)
return values
def draco(self, primitive):
"""Attributes and indices of a Draco-compressed primitive, None when it is not one."""
extension = primitive.get("extensions", {}).get("KHR_draco_mesh_compression")
if extension is None:
return None
if extension["bufferView"] in self._draco:
return self._draco[extension["bufferView"]]
try:
import DracoPy
except ImportError:
raise ImportError(
"%s is Draco-compressed, which needs DracoPy: pip install DracoPy" % self.label
) from None
data, _ = self.buffer_view(extension["bufferView"])
mesh = DracoPy.decode(bytes(data))
attributes = {}
for name, unique_id in extension["attributes"].items():
values = np.asarray(mesh.get_attribute_by_unique_id(unique_id)["data"])
accessor = self.document["accessors"][primitive["attributes"][name]]
values = values.reshape(len(values), -1)
if accessor.get("normalized", False) and values.dtype in NORMALIZED:
values = np.maximum(values.astype(np.float32) / NORMALIZED[values.dtype], -1.0)
attributes[name] = values
faces = getattr(mesh, "faces", None)
indices = np.asarray(faces).reshape(-1) if faces is not None and np.size(faces) else None
self._draco[extension["bufferView"]] = attributes, indices
return attributes, indices
def image(self, index):
"""Encoded bytes of an image, or None when no browser can be relied on to decode it."""
if index in self._images:
return self._images[index]
definition = self.document["images"][index]
if "bufferView" in definition:
data, _ = self.buffer_view(definition["bufferView"])
data = bytes(data)
elif "uri" in definition:
data = self._uri(definition["uri"], "image %d" % index)
else:
data = None
if data is not None and image_format(data) not in ("png", "jpeg", "webp", "gif"):
self.warn("images other than PNG, JPEG and WebP (KTX2/Basis among them)")
data = None
self._images[index] = data
return data
def texture(self, info):
"""Image bytes and the sampler of a textureInfo, or (None, None)."""
if info is None:
return None, None
texture = self.document["textures"][info["index"]]
extensions = texture.get("extensions", {})
source = texture.get("source")
if "EXT_texture_webp" in extensions:
source = extensions["EXT_texture_webp"]["source"]
elif source is None:
self.warn("textures in compressed formats (KHR_texture_basisu)")
return None, None
sampler = None
if "sampler" in texture:
sampler = self.document["samplers"][texture["sampler"]]
return self.image(source), sampler
def _read_source(source):
"""The bytes of a source, and the directory relative references resolve against."""
if isinstance(source, (bytes, bytearray, memoryview)):
return bytes(source), None, None
if hasattr(source, "read"):
return source.read(), None, None
path = os.fspath(source)
with open(path, "rb") as f:
return f.read(), os.path.dirname(os.path.abspath(path)), path
def _parse_glb(data, label):
magic, version, length = struct.unpack_from("<4sII", data, 0)
if magic != GLB_MAGIC:
raise ValueError("%s is not a binary glTF (.glb) - for a .gltf file use k3d.gltf()" % label)
if version != 2:
raise ValueError("%s is GLB version %d - only version 2 is read" % (label, version))
document = None
binary_chunk = None
offset = 12
while offset + 8 <= min(length, len(data)):
chunk_length, chunk_type = struct.unpack_from("<II", data, offset)
offset += 8
chunk = data[offset:offset + chunk_length]
offset += chunk_length
if chunk_type == CHUNK_JSON:
document = json.loads(chunk.decode("utf-8"))
elif chunk_type == CHUNK_BIN and binary_chunk is None:
binary_chunk = chunk
if document is None:
raise ValueError("%s has no JSON chunk" % label)
return document, binary_chunk
def _parse_gltf(data, label):
if data[:4] == GLB_MAGIC:
raise ValueError("%s is a binary glTF (.glb) - use k3d.glb()" % label)
try:
return json.loads(data.decode("utf-8-sig"))
except (UnicodeDecodeError, ValueError):
raise ValueError("%s is not glTF JSON" % label) from None
def _strip_indices(count):
i = np.arange(max(count - 2, 0))
even = (i % 2) == 0
a = i
b = np.where(even, i + 1, i + 2)
c = np.where(even, i + 2, i + 1)
return np.stack([a, b, c], axis=1)
def _triangles(mode, indices):
if mode == TRIANGLES:
return indices.reshape(-1, 3)
if mode == TRIANGLE_STRIP:
return indices[_strip_indices(len(indices))]
# fan: every triangle shares the first vertex
i = np.arange(1, max(len(indices) - 1, 1))
return np.stack([indices[i], indices[i + 1], np.full(len(i), indices[0])], axis=1)
def _segments(mode, indices):
if mode == LINES:
return indices.reshape(-1, 2)
pairs = np.stack([indices[:-1], indices[1:]], axis=1)
if mode == LINE_LOOP and len(indices) > 1:
pairs = np.vstack([pairs, [[indices[-1], indices[0]]]])
return pairs
class _Builder:
"""Turns the primitives of a document into K3D objects under a hierarchy of Transforms."""
def __init__(self, reader, group, compression_level, visible):
self.reader = reader
self.group = group
self.compression_level = compression_level
self.visible = visible
self.objects = []
self.document = reader.document
def material(self, index):
"""K3D mesh parameters of a glTF material."""
reader = self.reader
material = self.document["materials"][index] if index is not None else {}
pbr = material.get("pbrMetallicRoughness", {})
extensions = material.get("extensions", {})
for name in extensions:
if name not in READ_MATERIAL_EXTENSIONS:
reader.warn(name)
if "KHR_materials_pbrSpecularGlossiness" in extensions and "pbrMetallicRoughness" not in material:
# the archived workflow: its diffuse is the closest thing to a base colour
spec_gloss = extensions["KHR_materials_pbrSpecularGlossiness"]
pbr = {
"baseColorFactor": spec_gloss.get("diffuseFactor", [1, 1, 1, 1]),
"baseColorTexture": spec_gloss.get("diffuseTexture"),
"metallicFactor": 0.0,
"roughnessFactor": 1.0 - spec_gloss.get("glossinessFactor", 1.0),
}
factor = pbr.get("baseColorFactor", [1.0, 1.0, 1.0, 1.0])
emissive = material.get("emissiveFactor", [0.0, 0.0, 0.0])
strength = extensions.get("KHR_materials_emissive_strength", {}).get("emissiveStrength", 1.0)
params = {
"color": int(_pack(_srgb(factor[:3]))),
"opacity": float(factor[3]),
"metalness": float(np.clip(pbr.get("metallicFactor", 1.0), 0, 1)),
"roughness": float(np.clip(pbr.get("roughnessFactor", 1.0), 0, 1)),
"side": "double" if material.get("doubleSided", False) else "front",
"alpha_mode": {"BLEND": "blend", "MASK": "mask"}.get(material.get("alphaMode"), "opaque"),
"alpha_cutoff": float(material.get("alphaCutoff", 0.5)),
"emissive": int(_pack(_srgb(emissive))),
"emissive_intensity": float(strength),
}
# the texture slots, and the uv set each of them reads
slots = {
"texture": pbr.get("baseColorTexture"),
"metalness_roughness_map": pbr.get("metallicRoughnessTexture"),
"normal_map": material.get("normalTexture"),
"occlusion_map": material.get("occlusionTexture"),
"emissive_map": material.get("emissiveTexture"),
}
uv_sets = {}
wrap = None
for trait, info in slots.items():
image, sampler = reader.texture(info)
if image is None:
continue
params[trait] = image
uv_sets[trait] = _uv_transform(info)
if wrap is None or trait == "texture":
sampler = sampler or {}
wrap_s = WRAPPING.get(sampler.get("wrapS", 10497), "repeat")
wrap_t = WRAPPING.get(sampler.get("wrapT", 10497), "repeat")
wrap = wrap_s if wrap_s == wrap_t else "%s %s" % (wrap_s, wrap_t)
if "normal_map" in params:
params["normal_scale"] = float(slots["normal_map"].get("scale", 1.0))
if "occlusion_map" in params:
params["occlusion_strength"] = float(slots["occlusion_map"].get("strength", 1.0))
if wrap is not None:
params["texture_wrap"] = wrap
transmission = extensions.get("KHR_materials_transmission")
if transmission is not None:
params["transmission"] = float(transmission.get("transmissionFactor", 0.0))
if "transmissionTexture" in transmission:
reader.warn("transmission textures (the factor is used)")
if "KHR_materials_ior" in extensions:
params["ior"] = float(np.clip(extensions["KHR_materials_ior"].get("ior", 1.5), 1.0, 5.0))
volume = extensions.get("KHR_materials_volume")
if volume is not None:
params["thickness"] = float(volume.get("thicknessFactor", 0.0))
params["attenuation_color"] = int(_pack(_srgb(volume.get("attenuationColor", [1, 1, 1]))))
distance = volume.get("attenuationDistance")
params["attenuation_distance"] = float(distance) if distance is not None else 0.0
if "thicknessTexture" in volume:
reader.warn("thickness textures (the factor is used)")
if "KHR_materials_unlit" in extensions:
# unlit: the base colour emitted, the black diffuse keeping only the texture alpha
params["emissive"] = params["color"]
params["emissive_intensity"] = 1.0
params["color"] = 0
params["metalness"] = 0.0
params["roughness"] = 1.0
if "texture" in params:
params["emissive_map"] = params["texture"]
uv_sets["emissive_map"] = uv_sets["texture"]
params["_unlit"] = True
return params, uv_sets
def attributes(self, primitive, weights):
"""The vertex attributes of a primitive, with the default morph weights applied."""
reader = self.reader
draco = reader.draco(primitive)
decoded = draco[0] if draco is not None else {}
attributes = {name: decoded[name] if name in decoded else reader.accessor(index)
for name, index in primitive["attributes"].items()}
targets = primitive.get("targets", [])
if targets:
if weights is not None and any(w != 0 for w in weights):
for target, weight in zip(targets, weights):
for name in ("POSITION", "NORMAL"):
if name in target and name in attributes and weight != 0:
attributes[name] = attributes[name] + weight * reader.accessor(target[name])
reader.warn("morph targets (shown with their default weights)")
return attributes
def primitive(self, primitive, name, weights, transform, custom_data):
reader = self.reader
attributes = self.attributes(primitive, weights)
draco = reader.draco(primitive)
if "POSITION" not in attributes:
return
vertices = attributes["POSITION"].astype(np.float32)
count = len(vertices)
mode = primitive.get("mode", TRIANGLES)
if draco is not None and draco[1] is not None:
indices = draco[1].astype(np.uint32)
elif "indices" in primitive:
indices = reader.accessor(primitive["indices"]).reshape(-1).astype(np.uint32)
else:
indices = np.arange(count, dtype=np.uint32)
material_index = primitive.get("material")
params, uv_sets = self.material(material_index)
common = {
"name": name,
"group": self.group,
"custom_data": custom_data,
"compression_level": self.compression_level,
"visible": self.visible,
}
unlit = params.pop("_unlit", False)
colors = None
opacities = None
if "COLOR_0" in attributes and unlit:
reader.warn("vertex colours of unlit materials")
if "COLOR_0" in attributes:
rgba = attributes["COLOR_0"].astype(np.float32)
colors = _pack(_srgb(rgba[:, :3])).astype(np.uint32)
if rgba.shape[1] == 4:
opacities = rgba[:, 3].astype(np.float32)
if mode in (TRIANGLES, TRIANGLE_STRIP, TRIANGLE_FAN):
obj = self.mesh(vertices, _triangles(mode, indices), attributes, params, uv_sets,
colors, opacities, common)
elif mode == POINTS:
# sized by _size_points in world units, which every renderer, cinematic too, agrees on
obj = Points(
positions=vertices,
colors=colors if colors is not None else [],
color=params["color"],
opacity=params["opacity"],
opacities=opacities if opacities is not None else [],
shader="3d",
**common,
)
else:
obj = Lines(
vertices=vertices,
indices=_segments(mode, indices).astype(np.uint32),
indices_type="segment",
colors=colors if colors is not None else [],
color=params["color"],
opacity=params["opacity"],
shader="simple",
width=0.01,
**common,
)
if obj is None:
return
transform.add_drawable(obj)
obj.transform = transform
obj.model_matrix = transform.model_matrix
self.objects.append(obj)
def mesh(self, vertices, triangles, attributes, params, uv_sets, colors, opacities, common):
reader = self.reader
uvs = None
uvs2 = None
def uv(uv_set):
index, transform = uv_set
key = "TEXCOORD_%d" % index
if key not in attributes:
return None
coordinates = attributes[key].astype(np.float64)
if transform is not None:
# KHR_texture_transform baked into the coordinates
coordinates = coordinates @ transform[:2, :2].T + transform[:2, 2]
return coordinates.astype(np.float32)
# every map but occlusion reads `uvs`, occlusion reads `uvs2` when it is set
others = [trait for trait in uv_sets if trait != "occlusion_map"]
main = uv_sets.get("texture") or (uv_sets[others[0]] if others else None)
if any(not _same_uvs(uv_sets[trait], main) for trait in others):
reader.warn("textures of one material reading different uv sets or transforms "
"(the base colour's are used)")
if uv_sets:
occlusion = uv_sets.get("occlusion_map")
uvs = uv(main if main is not None else occlusion)
if occlusion is not None and main is not None and not _same_uvs(occlusion, main):
uvs2 = uv(occlusion)
if uvs is None:
reader.warn("textures on primitives without texture coordinates")
normals = attributes.get("NORMAL")
alpha_mode = params.pop("alpha_mode")
return Mesh(
vertices=vertices,
indices=triangles.astype(np.uint32),
normals=normals.astype(np.float32) if normals is not None else [],
# glTF: a primitive without normals is flat shaded
flat_shading=normals is None,
colors=colors if colors is not None else [],
opacities=opacities if opacities is not None else [],
uvs=uvs if uvs is not None else [],
uvs2=uvs2 if uvs2 is not None else [],
texture_file_format=image_format(params["texture"]) if "texture" in params else None,
alpha_mode=alpha_mode,
attribute=[],
triangles_attribute=[],
color_map=[],
color_range=[],
volume=[],
volume_bounds=[],
opacity_function=[],
slice_planes=[],
wireframe=False,
**params,
**common,
)
def node(self, index, parent, path):
if index in path:
raise ValueError("%s: node %d is its own ancestor" % (self.reader.label, index))
node = self.document["nodes"][index]
transform = Transform(custom_matrix=_node_matrix(node), parent=parent)
node_name = node.get("name") or "node %d" % index
if "skin" in node:
self.reader.warn("skins (shown in the pose the vertices are stored in)")
if "camera" in node:
self.reader.warn("cameras")
if "KHR_lights_punctual" in node.get("extensions", {}):
self.reader.warn("lights (KHR_lights_punctual)")
if "mesh" in node:
self.mesh_node(node, index, node_name, transform)
for child in node.get("children", []):
self.node(child, transform, path | {index})
def mesh_node(self, node, index, node_name, transform):
mesh = self.document["meshes"][node["mesh"]]
weights = node.get("weights", mesh.get("weights"))
primitives = mesh.get("primitives", [])
instancing = node.get("extensions", {}).get("EXT_mesh_gpu_instancing")
instances = [transform]
if instancing is not None:
instances = [Transform(custom_matrix=m, parent=transform)
for m in self.instance_matrices(instancing["attributes"])]
for i, instance in enumerate(instances):
for p, primitive in enumerate(primitives):
name = node_name
if len(primitives) > 1:
material = primitive.get("material")
label = (self.document["materials"][material].get("name")
if material is not None else None)
name = "%s/%s" % (node_name, label or p)
if len(instances) > 1:
name = "%s [%d]" % (name, i)
custom_data = {"gltf_node": index, "gltf_mesh": node["mesh"], "gltf_primitive": p}
material = primitive.get("material")
if material is not None:
custom_data["gltf_material"] = self.document["materials"][material].get("name", material)
self.primitive(primitive, name, weights, instance, custom_data)
def instance_matrices(self, attributes):
reader = self.reader
t = reader.accessor(attributes["TRANSLATION"]) if "TRANSLATION" in attributes else None
r = reader.accessor(attributes["ROTATION"]) if "ROTATION" in attributes else None
s = reader.accessor(attributes["SCALE"]) if "SCALE" in attributes else None
count = len(next(a for a in (t, r, s) if a is not None))
return [
_node_matrix({
"translation": t[i].tolist() if t is not None else [0, 0, 0],
"rotation": r[i].tolist() if r is not None else [0, 0, 0, 1],
"scale": s[i].tolist() if s is not None else [1, 1, 1],
})
for i in range(count)
]
def _scene_roots(document, scene, label):
scenes = document.get("scenes", [])
if not scenes:
# no scene: every node nobody claims as a child is a root
children = {c for node in document.get("nodes", []) for c in node.get("children", [])}
return [i for i in range(len(document.get("nodes", []))) if i not in children]
if scene is None:
scene = document.get("scene", 0)
elif isinstance(scene, str):
names = [s.get("name") for s in scenes]
if scene not in names:
raise ValueError("%s has no scene %r - it has %s" % (label, scene, names))
scene = names.index(scene)
if not 0 <= scene < len(scenes):
raise ValueError("%s has no scene %d - it has %d" % (label, scene, len(scenes)))
return scenes[scene].get("nodes", [])
def _root_transform(up, kwargs):
unknown = set(kwargs) - set(TRANSFORM_ARGUMENTS)
if unknown:
raise TypeError("unexpected keyword argument(s): %s" % ", ".join(sorted(unknown)))
if up not in ("y", "z"):
raise ValueError("up is the axis the file has pointing up, 'y' (glTF's own) or 'z', not %r" % (up,))
if "transform" in kwargs:
root = kwargs["transform"]
if not isinstance(root, Transform):
raise ValueError("Provided transform argument is not a Transform object")
else:
root = Transform(
translation=kwargs.get("translation"),
rotation=kwargs.get("rotation"),
scaling=kwargs.get("scaling"),
custom_matrix=kwargs.get("model_matrix"),
)
axes = Transform(custom_matrix=Y_UP_TO_Z_UP if up == "y" else np.identity(4), parent=root)
return root, axes
def _size_points(objects):
"""Points get a hundredth of the model's diagonal, in their own node's units."""
points = [obj for obj in objects if isinstance(obj, Points)]
if not points:
return
boxes = np.array([obj.get_bounding_box() for obj in objects], dtype=np.float64)
low, high = boxes[:, 0::2].min(axis=0), boxes[:, 1::2].max(axis=0)
diagonal = float(np.linalg.norm(high - low)) or 1.0
for obj in points:
scale = abs(np.linalg.det(np.asarray(obj.model_matrix, np.float64)[:3, :3])) ** (1.0 / 3.0)
obj.point_size = max(0.01 * diagonal / (scale or 1.0), 1e-6)
def _load(document, binary_chunk, base_dir, label, scene, up, group, compression_level, visible, kwargs):
reader = _Reader(document, binary_chunk, base_dir, label)
root, axes = _root_transform(up, kwargs)
builder = _Builder(reader, group, compression_level, visible)
for index in _scene_roots(document, scene, label):
builder.node(index, axes, set())
_size_points(builder.objects)
if document.get("animations"):
reader.warn("animations")
if reader.ignored:
warnings.warn("%s: K3D does not show %s" % (label, "; ".join(sorted(reader.ignored))),
stacklevel=3)
if not builder.objects:
warnings.warn("%s holds no geometry in the scene that was read" % label, stacklevel=3)
return Group(builder.objects, transform=root)
def _label(path, data_label):
return os.path.basename(path) if path else data_label
def _group_name(group, path, data_label):
if group is not None:
return group
if path:
return os.path.splitext(os.path.basename(path))[0]
return data_label
Source = Union[str, os.PathLike, bytes, bytearray, memoryview, Any]
[docs]
def glb(
source: Source,
scene: Optional[Union[int, str]] = None,
up: str = "y",
group: Optional[str] = None,
compression_level: int = 0,
visible: bool = True,
**kwargs: Any,
) -> Group:
"""Read a binary glTF (.glb) scene as K3D objects.
Every primitive of every mesh in the scene becomes its own :func:`k3d.mesh` - or
:func:`k3d.points` / :func:`k3d.lines` for point and line primitives - with the
node's name, and all of them share `group`, so the panel shows the model as one
folder of parts. They come back together as a :class:`k3d.objects.Group`:
``plot += k3d.glb('model.glb')`` adds the whole model.
Materials map onto mesh parameters: base colour (`color`, `opacity`, `texture`),
metallic-roughness, normal, occlusion and emissive maps, `alpha_mode` and
`alpha_cutoff`, transmission, index of refraction and volume, double-sidedness and
the texture wrapping; texture transforms are baked into the uvs. Factors and vertex
colours, which glTF stores as linear values, are converted to the display values
K3D colours are. Node transforms are kept as a hierarchy of
:class:`k3d.transform.Transform`: each object's `model_matrix` is its node's, and
the group's `model_matrix` (or `transform`) moves the whole model.
Draco-compressed geometry is read when DracoPy is installed (``pip install DracoPy``).
Not shown, with one warning naming what was left out: animations, skins (the
vertices are shown as stored), cameras and lights, KTX2 textures, and the other
material extensions - clearcoat, sheen, dispersion and the like. Meshopt
compression is refused. Morph targets are applied with their default weights.
.. versionadded:: 3.2.0
Parameters
----------
source : str, os.PathLike, bytes or file-like
Path of a .glb file, or its bytes - a .glb is self-contained, so bytes from
anywhere do, e.g. ``plot.fetch_gltf()`` output.
scene : int or str, optional
Index or name of the scene to read, by default the file's default scene.
up : {'y', 'z'}, optional
The axis pointing up in the file. glTF is 'y' up and K3D 'z' up, so by default
the model is turned upright; 'z' keeps the coordinates as they are - K3D's own
exports are z up.
group : str, optional
Group of every object in the panel, by default the file name without extension.
compression_level : int, optional
Level of data compression [-1, 9] of every object, by default 0.
visible : bool, optional
Whether the objects are drawn, by default True.
**kwargs
translation, rotation, scaling, model_matrix, or transform - placing the whole
model, see :ref:`process_transform_arguments`.
Returns
-------
Group
The objects of the scene.
"""
data, base_dir, path = _read_source(source)
label = _label(path, "GLB data")
document, binary_chunk = _parse_glb(data, label)
return _load(document, binary_chunk, base_dir, label, scene, up,
_group_name(group, path, "glb"), compression_level, visible, kwargs)
[docs]
def gltf(
source: Source,
scene: Optional[Union[int, str]] = None,
up: str = "y",
group: Optional[str] = None,
compression_level: int = 0,
visible: bool = True,
**kwargs: Any,
) -> Group:
"""Read a glTF (.gltf) scene as K3D objects.
The JSON form of glTF: buffers and images are files next to it or data: URIs inside
it. Everything else - what becomes which object, what is converted, what is left
out - is as in :func:`k3d.glb`, which reads the binary form.
.. versionadded:: 3.2.0
Parameters
----------
source : str, os.PathLike, bytes or file-like
Path of a .gltf file. Bytes are read too, as long as every buffer and image is
inlined as a data: URI - there is no directory to find the other files in.
scene : int or str, optional
Index or name of the scene to read, by default the file's default scene.
up : {'y', 'z'}, optional
The axis pointing up in the file, by default 'y' as glTF has it; see :func:`k3d.glb`.
group : str, optional
Group of every object in the panel, by default the file name without extension.
compression_level : int, optional
Level of data compression [-1, 9] of every object, by default 0.
visible : bool, optional
Whether the objects are drawn, by default True.
**kwargs
translation, rotation, scaling, model_matrix, or transform - placing the whole
model, see :ref:`process_transform_arguments`.
Returns
-------
Group
The objects of the scene.
"""
data, base_dir, path = _read_source(source)
label = _label(path, "glTF data")
document = _parse_gltf(data, label)
return _load(document, None, base_dir, label, scene, up,
_group_name(group, path, "gltf"), compression_level, visible, kwargs)