Basic b3d to glTF converter

#!/usr/bin/env python3
"""b3d (Blitz3D) -> glTF 2.0 converter.
An AI-generated Python port of modlib's Lua converter (https://github.com/appgurueu/modlib,
b3d.lua), with some robustness fixes:
* duplicate keyframes are merged instead of rejected,
* quaternions (node rotations & rotation keys) are normalized,
* bind matrix chains accumulate through *all* nodes below the mesh node
(modlib only multiplies transforms of bone nodes),
* >4 bone influences are sorted by weight so the strongest land in JOINTS_0,
* non-default texture/brush flags produce warnings instead of hard errors.
Format references:
* b3d: https://github.com/blitz-research/blitz3d/blob/master/blitz3d/loader_b3d.cpp
* glTF 2.0: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html
Coordinate systems: Blitz3D is left-handed (X+ right, Y+ up, Z+ forward),
glTF is right-handed (X- right, Y+ up, Z+ forward). Conversion: negate the
X component of translations/positions/normals, negate the x component of
quaternions, and flip triangle winding order.
Output is a self-contained .gltf (JSON with a base64 data URI buffer).
Only the Python standard library is required.
"""
import argparse
import base64
import json
import math
import os
import struct
import sys
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def warn(msg):
print("warning: " + msg, file=sys.stderr)
def f32(x):
"""Round a Python float to the nearest binary32 value (returned as float)."""
return struct.unpack("<f", struct.pack("<f", x))[0]
class B3DError(Exception):
pass
# ---------------------------------------------------------------------------
# b3d parsing
# ---------------------------------------------------------------------------
class _Reader:
"""Reads primitives from `data[pos:end]` (one chunk payload)."""
__slots__ = ("data", "pos", "end")
def __init__(self, data, pos, end):
self.data, self.pos, self.end = data, pos, end
def remaining(self):
return self.end - self.pos
def _take(self, n):
if self.pos + n > self.end:
raise B3DError("unexpected end of chunk at byte %d" % self.pos)
pos = self.pos
self.pos += n
return pos
def i32(self):
return struct.unpack_from("<i", self.data, self._take(4))[0]
def f(self):
v = struct.unpack_from("<f", self.data, self._take(4))[0]
if not math.isfinite(v):
raise B3DError("non-finite float at byte %d" % (self.pos - 4))
return v
def floats(self, n):
return [self.f() for _ in range(n)]
def string(self):
nul = self.data.find(b"\0", self.pos, self.end)
if nul < 0:
raise B3DError("unterminated string at byte %d" % self.pos)
s = self.data[self.pos:nul].decode("latin-1")
self.pos = nul + 1
return s
def color(self):
return {"r": self.f(), "g": self.f(), "b": self.f(), "a": self.f()}
def quaternion(self):
# b3d stores w, x, y, z; we keep (x, y, z, w) as glTF does.
w, x, y, z = self.f(), self.f(), self.f(), self.f()
return [x, y, z, w]
def optional_id(self):
i = self.i32()
return None if i == -1 else i
def chunks(self):
"""Iterate (tag, payload_reader) over the sub-chunks of this payload."""
while self.pos < self.end:
if self.remaining() < 8:
raise B3DError("truncated chunk header at byte %d" % self.pos)
tag = self.data[self.pos:self.pos + 4].decode("latin-1")
(length,) = struct.unpack_from("<i", self.data, self.pos + 4)
start = self.pos + 8
if length < 0 or start + length > self.end:
raise B3DError("chunk %r at byte %d overflows its parent"
% (tag, self.pos))
self.pos = start + length
yield tag, _Reader(self.data, start, start + length)
def _parse_texs(r):
textures = []
while r.remaining() > 0:
textures.append({
"file": r.string(),
"flags": r.i32(),
"blend": r.i32(),
"pos": r.floats(2),
"scale": r.floats(2),
"rotation": r.f(),
})
return textures
def _parse_brus(r):
brushes = []
n_texs = r.i32()
if not 0 <= n_texs <= 8:
raise B3DError("invalid number of textures per brush: %d" % n_texs)
while r.remaining() > 0:
brushes.append({
"name": r.string(),
"color": r.color(),
"shininess": r.f(),
"blend": r.i32(),
"fx": r.i32(),
"texture_ids": [r.optional_id() for _ in range(n_texs)],
})
return brushes
def _parse_vrts(r):
flags = r.i32()
tc_sets = r.i32()
tc_size = r.i32()
if not (0 <= tc_sets <= 8 and 0 <= tc_size <= 4):
raise B3DError("invalid texture coordinate layout (%d sets of size %d)"
% (tc_sets, tc_size))
has_normal = bool(flags & 1)
has_color = bool(flags & 2)
vertices = []
while r.remaining() > 0:
vertices.append({
"pos": r.floats(3),
"normal": r.floats(3) if has_normal else None,
"color": r.color() if has_color else None,
"tex_coords": [r.floats(tc_size) for _ in range(tc_sets)],
})
return {
"flags": flags,
"tex_coord_sets": tc_sets,
"tex_coord_set_size": tc_size,
"list": vertices,
}
def _parse_tris(r):
brush_id = r.optional_id()
tris = []
while r.remaining() > 0:
tris.append((r.i32(), r.i32(), r.i32()))
return {"brush_id": brush_id, "tris": tris}
def _parse_mesh(r):
brush_id = r.optional_id()
vertices = None
triangle_sets = []
for tag, cr in r.chunks():
if tag == "VRTS":
if vertices is not None:
raise B3DError("multiple VRTS chunks in MESH")
vertices = _parse_vrts(cr)
elif tag == "TRIS":
triangle_sets.append(_parse_tris(cr))
else:
warn("skipping unknown chunk %r in MESH" % tag)
if vertices is None or not triangle_sets:
raise B3DError("MESH lacks VRTS or TRIS")
n = len(vertices["list"])
for ts in triangle_sets:
for tri in ts["tris"]:
if any(not 0 <= i < n for i in tri):
raise B3DError("triangle vertex index out of range")
return {"brush_id": brush_id, "vertices": vertices,
"triangle_sets": triangle_sets}
def _parse_bone(r):
bone = {}
while r.remaining() > 0:
vertex_id = r.i32()
weight = r.f()
if vertex_id in bone:
raise B3DError("duplicate vertex weight for vertex %d" % vertex_id)
if weight > 0: # many exporters include unneeded zero weights
bone[vertex_id] = weight
return bone
def _parse_keys(r):
flags = r.i32()
has_pos, has_scale, has_rot = bool(flags & 1), bool(flags & 2), bool(flags & 4)
keys = []
while r.remaining() > 0:
key = {"frame": r.i32() - 1} # b3d uses 1-indexed frames
if has_pos:
key["position"] = r.floats(3)
if has_scale:
key["scale"] = r.floats(3)
if has_rot:
key["rotation"] = r.quaternion()
keys.append(key)
return keys
def _parse_anim(r):
return {"flags": r.i32(), "frames": r.i32(), "fps": r.f()}
def _parse_node(r):
node = {
"name": r.string(),
"position": r.floats(3),
"scale": r.floats(3),
"rotation": r.quaternion(),
"keys": [],
"children": [],
"mesh": None,
"bone": None,
"animation": None,
}
for tag, cr in r.chunks():
if tag == "MESH":
if node["mesh"] is not None or node["bone"] is not None:
raise B3DError("node %r has more than one MESH/BONE"
% node["name"])
node["mesh"] = _parse_mesh(cr)
elif tag == "BONE":
if node["mesh"] is not None or node["bone"] is not None:
raise B3DError("node %r has more than one MESH/BONE"
% node["name"])
node["bone"] = _parse_bone(cr)
elif tag == "KEYS":
node["keys"].extend(_parse_keys(cr))
elif tag == "ANIM":
node["animation"] = _parse_anim(cr)
elif tag == "NODE":
node["children"].append(_parse_node(cr))
else:
# Unknown chunks mark the node as a pivot in Blitz3D; skip them.
pass
# Sort keys ascendingly by frame; merge keys sharing a frame
# (e.g. from separate KEYS chunks with different flags).
node["keys"].sort(key=lambda k: k["frame"])
merged = []
for key in node["keys"]:
if merged and merged[-1]["frame"] == key["frame"]:
for field in ("position", "scale", "rotation"):
if field in key:
if field in merged[-1] and merged[-1][field] != key[field]:
warn("node %r: conflicting %s keys for frame %d; "
"keeping the first" % (node["name"], field,
key["frame"]))
else:
merged[-1][field] = key[field]
else:
merged.append(key)
node["keys"] = merged
return node
def parse_b3d(data):
"""Parse a b3d file (bytes) into a model dict."""
root = _Reader(data, 0, len(data))
model = None
for tag, r in root.chunks():
if tag != "BB3D":
raise B3DError("expected BB3D chunk, found %r" % tag)
if model is not None:
raise B3DError("multiple BB3D chunks")
version = r.i32()
model = {
"version": (version // 100, version % 100),
"textures": [],
"brushes": [],
"node": None,
}
if model["version"][0] > 2:
raise B3DError("unsupported version: %d" % version)
for ctag, cr in r.chunks():
if ctag == "TEXS":
model["textures"].extend(_parse_texs(cr))
elif ctag == "BRUS":
model["brushes"].extend(_parse_brus(cr))
elif ctag == "NODE":
if model["node"] is not None:
raise B3DError("multiple root nodes")
model["node"] = _parse_node(cr)
else:
warn("skipping unknown chunk %r in BB3D" % ctag)
if model is None:
raise B3DError("empty file")
if root.remaining() > 0:
warn("%d trailing bytes after BB3D chunk" % root.remaining())
return model
# ---------------------------------------------------------------------------
# Minimal 4x4 matrix math (row-major)
# ---------------------------------------------------------------------------
def mat_identity():
return [[1.0 if i == j else 0.0 for j in range(4)] for i in range(4)]
def mat_mul(a, b):
return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)]
for i in range(4)]
def mat_trs(translation, rotation, scale):
"""T * R * S from glTF-convention TRS (rotation: unit quaternion xyzw)."""
x, y, z, w = rotation
tx, ty, tz = translation
sx, sy, sz = scale
r = [
[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)],
]
s = (sx, sy, sz)
return [
[r[0][0] * s[0], r[0][1] * s[1], r[0][2] * s[2], tx],
[r[1][0] * s[0], r[1][1] * s[1], r[1][2] * s[2], ty],
[r[2][0] * s[0], r[2][1] * s[1], r[2][2] * s[2], tz],
[0.0, 0.0, 0.0, 1.0],
]
def mat_inverse(m):
"""Gauss-Jordan inversion with partial pivoting."""
a = [row[:] for row in m]
inv = mat_identity()
for col in range(4):
pivot = max(range(col, 4), key=lambda r: abs(a[r][col]))
if abs(a[pivot][col]) < 1e-9:
raise B3DError("singular bind matrix (zero scale on a bone?)")
a[col], a[pivot] = a[pivot], a[col]
inv[col], inv[pivot] = inv[pivot], inv[col]
pv = a[col][col]
a[col] = [v / pv for v in a[col]]
inv[col] = [v / pv for v in inv[col]]
for row in range(4):
if row != col and a[row][col] != 0.0:
f = a[row][col]
a[row] = [v - f * p for v, p in zip(a[row], a[col])]
inv[row] = [v - f * p for v, p in zip(inv[row], inv[col])]
return inv
# ---------------------------------------------------------------------------
# glTF conversion
# ---------------------------------------------------------------------------
# coordinate system conversion could be done here, but does not appear to be necessary
def translation_to_gltf(vec):
return [vec[0], vec[1], vec[2]]
def quaternion_to_gltf(quat):
return [quat[0], quat[1], quat[2], quat[3]]
def normalize_quaternion(quat, ctx):
length = math.sqrt(sum(c * c for c in quat))
if length < 1e-9:
warn("%s: zero quaternion, substituting identity" % ctx)
return [0.0, 0.0, 0.0, 1.0]
return [c / length for c in quat]
def color_to_gltf(col):
return [col["r"], col["g"], col["b"], col["a"]]
def clamp01(x, ctx):
if 0.0 <= x <= 1.0:
return x
warn("%s: value %g out of [0, 1], clamping" % (ctx, x))
return min(1.0, max(0.0, x))
ARRAY_BUFFER = 34962
ELEMENT_ARRAY_BUFFER = 34963
COMPONENT_TYPES = {"float": 5126, "unsigned_short": 5123, "unsigned_int": 5125}
COMPONENT_FORMATS = {"float": "f", "unsigned_short": "H", "unsigned_int": "I"}
class _GltfBuffer:
"""Accumulates raw data; produces buffer views & accessors."""
def __init__(self):
self.data = bytearray()
self.views = []
self.accessors = []
def add_accessor(self, acc_type, comp_type, target, values,
amin=None, amax=None):
"""`values` is a flat list of numbers; floats must be f32-rounded
already if min/max consistency matters. Returns the accessor index."""
n_components = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4,
"MAT4": 16}[acc_type]
count, rem = divmod(len(values), n_components)
assert rem == 0 and count > 0
self.data.extend(b"\0" * (-len(self.data) % 4)) # 4-byte alignment
offset = len(self.data)
fmt = "<%d%s" % (len(values), COMPONENT_FORMATS[comp_type])
if comp_type == "float":
for v in values:
if not math.isfinite(v):
raise B3DError("non-finite value in output buffer")
self.data.extend(struct.pack(fmt, *values))
self.views.append({
"buffer": 0,
"byteOffset": offset,
"byteLength": len(self.data) - offset,
**({"target": target} if target is not None else {}),
})
accessor = {
"bufferView": len(self.views) - 1,
"componentType": COMPONENT_TYPES[comp_type],
"type": acc_type,
"count": count,
}
if amin is not None:
accessor["min"] = amin
if amax is not None:
accessor["max"] = amax
self.accessors.append(accessor)
return len(self.accessors) - 1
def to_gltf(model, generator="b3d2gltf.py"):
buf = _GltfBuffer()
textures = []
materials = []
meshes = []
nodes = []
skins = []
samplers = []
channels = []
def add_texture(name):
textures.append({"name": name})
return len(textures) - 1
for i, tex in enumerate(model["textures"]):
# Flag 1 = color texture; blend 2 = multiply (the defaults).
if tex["flags"] & ~65536 != 1 or tex["blend"] != 2:
warn("texture %d (%r): unsupported flags/blend (%d/%d) ignored"
% (i, tex["file"], tex["flags"], tex["blend"]))
if (tex["rotation"] != 0 or tex["pos"] != [0.0, 0.0]
or tex["scale"] != [1.0, 1.0]):
warn("texture %d (%r): texture transform ignored" % (i, tex["file"]))
add_texture(tex["file"])
for i, brush in enumerate(model["brushes"]):
# Blitz3D brush blend: 0 replace, 1 alpha, 2 multiply, 3 add.
if brush["blend"] != 1:
warn("brush %d (%r): unsupported blend %d, using alpha"
% (i, brush["name"], brush["blend"]))
if brush["fx"] != 0:
warn("brush %d (%r): fx %d ignored" % (i, brush["name"], brush["fx"]))
texture_ids = [t for t in brush["texture_ids"] if t is not None]
if len(texture_ids) > 1:
warn("brush %d (%r): only the first of %d textures is used"
% (i, brush["name"], len(texture_ids)))
if texture_ids:
if texture_ids[0] >= len(textures):
raise B3DError("brush %d references undefined texture %d"
% (i, texture_ids[0]))
index = texture_ids[0]
else:
# Implementations seem to implicitly assume textures for brushes.
index = add_texture(brush["name"])
ctx = "brush %d (%r)" % (i, brush["name"])
materials.append({
"name": brush["name"],
"alphaMode": "BLEND",
"pbrMetallicRoughness": {
"baseColorFactor": [clamp01(c, ctx)
for c in color_to_gltf(brush["color"])],
"metallicFactor": clamp01(brush["shininess"], ctx),
"baseColorTexture": {"index": index},
},
})
def add_mesh(mesh, weights, add_neutral_joint):
vertices = mesh["vertices"]["list"]
attributes = {}
positions = []
inf = math.inf
min_pos, max_pos = [inf] * 3, [-inf] * 3
for vertex in vertices:
pos = [f32(c) for c in translation_to_gltf(vertex["pos"])]
positions.extend(pos)
min_pos = [min(a, b) for a, b in zip(min_pos, pos)]
max_pos = [max(a, b) for a, b in zip(max_pos, pos)]
attributes["POSITION"] = buf.add_accessor(
"VEC3", "float", ARRAY_BUFFER, positions, min_pos, max_pos)
if mesh["vertices"]["flags"] & 1: # normals
normals = []
for i, vertex in enumerate(vertices):
n = translation_to_gltf(vertex["normal"])
length = math.sqrt(sum(c * c for c in n))
if length < 1e-9:
# A zero normal is invalid in glTF; substitute something.
warn("vertex %d has a zero normal" % i)
n = [0.0, 1.0, 0.0]
else:
n = [c / length for c in n]
normals.extend(f32(c) for c in n)
attributes["NORMAL"] = buf.add_accessor(
"VEC3", "float", ARRAY_BUFFER, normals)
if mesh["vertices"]["flags"] & 2: # vertex colors
colors = []
for i, vertex in enumerate(vertices):
colors.extend(clamp01(f32(c), "vertex %d color" % i)
for c in color_to_gltf(vertex["color"]))
attributes["COLOR_0"] = buf.add_accessor(
"VEC4", "float", ARRAY_BUFFER, colors)
if mesh["vertices"]["tex_coord_sets"] >= 1:
if mesh["vertices"]["tex_coord_set_size"] != 2:
raise B3DError("only 2D texture coordinates are supported")
for tcs in range(mesh["vertices"]["tex_coord_sets"]):
uvs = []
for vertex in vertices:
uvs.extend(f32(c) for c in vertex["tex_coords"][tcs])
attributes["TEXCOORD_%d" % tcs] = buf.add_accessor(
"VEC2", "float", ARRAY_BUFFER, uvs)
if weights:
# Normalize weights; collect per-vertex influence lists sorted by
# descending weight so that the strongest influences go into
# JOINTS_0/WEIGHTS_0.
influences = [None] * len(vertices)
max_count = 0
for vertex_id, joint_weights in weights.items():
if not 0 <= vertex_id < len(vertices):
raise B3DError("bone weight for out-of-range vertex %d"
% vertex_id)
total = sum(joint_weights.values())
if total <= 0:
continue
inf_list = sorted(joint_weights.items(),
key=lambda jw: (-jw[1], jw[0]))
influences[vertex_id] = [(j, w / total) for j, w in inf_list]
max_count = max(max_count, len(inf_list))
for vertex_id in range(len(vertices)):
if influences[vertex_id] is None:
# Static vertex: influenced by a dummy neutral bone.
# See https://github.com/KhronosGroup/glTF/issues/2269
influences[vertex_id] = [(add_neutral_joint(), 1.0)]
max_count = max(max_count, 1)
if max_count > 4:
warn("up to %d bone influences per vertex; sets beyond "
"JOINTS_0/WEIGHTS_0 may be ignored by engines" % max_count)
for set_id in range((max_count + 3) // 4):
joint_ids = []
joint_weights = []
for infl in influences:
for i in range(4 * set_id, 4 * set_id + 4):
if i < len(infl) and infl[i][1] > 0:
joint_ids.append(infl[i][0])
joint_weights.append(f32(infl[i][1]))
else:
# Zero-weight joints must be 0 per the glTF spec.
joint_ids.append(0)
joint_weights.append(0.0)
attributes["JOINTS_%d" % set_id] = buf.add_accessor(
"VEC4", "unsigned_short", ARRAY_BUFFER, joint_ids)
attributes["WEIGHTS_%d" % set_id] = buf.add_accessor(
"VEC4", "float", ARRAY_BUFFER, joint_weights)
primitives = []
for triangle_set in mesh["triangle_sets"]:
indices = []
for tri in triangle_set["tris"]:
# Flip winding order due to the handedness flip.
indices.extend(reversed(tri))
index_accessor = buf.add_accessor(
"SCALAR", "unsigned_int", ELEMENT_ARRAY_BUFFER, indices)
brush_id = triangle_set["brush_id"]
if brush_id is None:
brush_id = mesh["brush_id"]
if brush_id is not None and brush_id >= len(materials):
raise B3DError("triangle set references undefined brush %d"
% brush_id)
primitive = {"attributes": attributes, "indices": index_accessor}
if brush_id is not None:
primitive["material"] = brush_id
primitives.append(primitive)
meshes.append({"primitives": primitives})
return len(meshes) - 1
def add_node(node, bind_mat, fps, anim):
# Reserve a fixed index for this node.
node_id = len(nodes)
nodes.append(None)
if node["animation"] is not None and node["animation"]["fps"] > 0:
fps = node["animation"]["fps"]
translation = translation_to_gltf(node["position"])
rotation = quaternion_to_gltf(normalize_quaternion(
node["rotation"], "node %r rotation" % node["name"]))
scale = list(node["scale"])
# Keyframes -> animation channels.
if node["keys"]:
targets = {
"translation": ("position", translation_to_gltf),
"scale": ("scale", list),
"rotation": ("rotation", lambda q: quaternion_to_gltf(
normalize_quaternion(q, "node %r rotation key"
% node["name"]))),
}
for path, (field, convert) in targets.items():
keyframes = [(k["frame"], k[field]) for k in node["keys"]
if field in k]
if not keyframes:
continue
times = [f32(frame / (fps or 60.0)) for frame, _ in keyframes]
input_acc = buf.add_accessor(
"SCALAR", "float", None, times, [min(times)], [max(times)])
values = []
for _, value in keyframes:
values.extend(f32(c) for c in convert(value))
output_acc = buf.add_accessor(
"VEC4" if path == "rotation" else "VEC3", "float", None,
values)
samplers.append({"input": input_acc, "output": output_acc})
channels.append({
"sampler": len(samplers) - 1,
"target": {"node": node_id, "path": path},
})
if node["mesh"] is not None:
if anim is not None:
raise B3DError("nested meshes are not supported")
anim = {"weights": {}, "joints": [], "inv_bind_mats": []}
# Bind matrices are relative to the mesh node's space.
bind_mat = None
elif bind_mat is not None or anim is not None:
# Below the mesh node: accumulate *all* local transforms.
local = mat_trs(translation, rotation, scale)
bind_mat = mat_mul(bind_mat, local) if bind_mat is not None else local
if node["bone"] is not None:
if anim is None:
raise B3DError("bone node %r outside of a mesh node"
% node["name"])
joint_id = len(anim["joints"])
anim["joints"].append(node_id)
anim["inv_bind_mats"].append(mat_inverse(bind_mat))
for vertex_id, weight in node["bone"].items():
anim["weights"].setdefault(vertex_id, {})[joint_id] = weight
children = [add_node(child, bind_mat, fps, anim)
for child in node["children"]]
mesh_id = skin_id = neutral_node_id = None
if node["mesh"] is not None:
neutral_joint_id = None
def add_neutral_joint():
# Lazily create a neutral bone for vertices without weights.
# It must be a *parent* of the mesh node: as a sibling, the
# joints would lack a common root; as a child, the mesh node's
# animation would move it.
nonlocal neutral_joint_id, neutral_node_id
if neutral_joint_id is None:
neutral_node_id = len(nodes)
nodes.append({"name": "neutral_bone",
"children": [node_id]})
neutral_joint_id = len(anim["joints"])
anim["joints"].append(neutral_node_id)
return neutral_joint_id
mesh_id = add_mesh(node["mesh"], anim["weights"], add_neutral_joint)
if anim["joints"]:
if neutral_joint_id is not None:
# The neutral joint must leave static vertices where the
# mesh node's transform would put them.
anim["inv_bind_mats"].append(
mat_trs(translation, rotation, scale))
ibms = []
for m in anim["inv_bind_mats"]:
# glTF is column-major; only f32-rounded values may enter
# the buffer.
ibms.extend(f32(m[i][j]) for j in range(4) for i in range(4))
skins.append({
"inverseBindMatrices": buf.add_accessor(
"MAT4", "float", None, ibms),
"joints": anim["joints"],
**({"skeleton": neutral_node_id}
if neutral_node_id is not None else {}),
})
skin_id = len(skins) - 1
gltf_node = {"name": node["name"]}
if mesh_id is not None:
gltf_node["mesh"] = mesh_id
if skin_id is not None:
gltf_node["skin"] = skin_id
if children:
gltf_node["children"] = children
gltf_node["translation"] = translation
gltf_node["rotation"] = rotation
gltf_node["scale"] = scale
nodes[node_id] = gltf_node
# If a neutral bone was created, it replaces the node as subtree root.
return neutral_node_id if neutral_node_id is not None else node_id
gltf = {
"asset": {"generator": generator, "version": "2.0"},
}
if model["node"] is not None:
root = add_node(model["node"], None, None, None)
gltf["scene"] = 0
gltf["scenes"] = [{"nodes": [root]}]
if textures:
gltf["textures"] = textures
if materials:
gltf["materials"] = materials
if meshes:
gltf["meshes"] = meshes
if nodes:
gltf["nodes"] = nodes
if skins:
gltf["skins"] = skins
if channels:
gltf["animations"] = [{"channels": channels, "samplers": samplers}]
if buf.data:
gltf["accessors"] = buf.accessors
gltf["bufferViews"] = buf.views
gltf["buffers"] = [{
"byteLength": len(buf.data),
"uri": "data:application/octet-stream;base64,"
+ base64.b64encode(bytes(buf.data)).decode("ascii"),
}]
return gltf
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(
description="Convert a Blitz3D .b3d model to glTF 2.0 (.gltf).")
parser.add_argument("input", help="input .b3d file")
parser.add_argument("output", nargs="?",
help="output .gltf file (default: input with .gltf "
"extension; '-' for stdout)")
parser.add_argument("--pretty", action="store_true",
help="pretty-print the JSON output")
args = parser.parse_args(argv)
with open(args.input, "rb") as f:
data = f.read()
try:
model = parse_b3d(data)
gltf = to_gltf(model)
except B3DError as e:
print("error: %s" % e, file=sys.stderr)
return 1
dump_args = {"indent": 4} if args.pretty else {"separators": (",", ":")}
text = json.dumps(gltf, allow_nan=False, **dump_args)
output = args.output
if output == "-":
sys.stdout.write(text + "\n")
else:
if output is None:
output = os.path.splitext(args.input)[0] + ".gltf"
with open(output, "w", encoding="utf-8") as f:
f.write(text)
return 0
if __name__ == "__main__":
sys.exit(main())
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论