singe/util/objToGlb.py
2026-09-13 22:06:39 -05:00

215 lines
9.1 KiB
Python

# Converts a Wavefront OBJ (+ MTL) into a self-contained binary glTF 2.0 file, which is the only model
# format Singe loads. Each OBJ object becomes a node with one mesh primitive per material; faces are
# fan-triangulated and keep their flat normals. Materials map Kd/d to the base colour and alpha,
# Pm/Pr (or a guess from Ks/Ns) to metallic and roughness; translucent ones render blended, two-sided.
#
# --pivots takes a JSON file naming, per object, where that object's own origin should sit: three
# entries picked from "min", "mid" and "max" of its bounding box, or plain numbers. The vertices
# are moved by that much and the node carries it back as a translation, so the model looks exactly
# the same but each part now turns about a joint instead of about the model's origin. Without it
# every node sits at 0,0,0 and rotating a wing swings it around the middle of the animal.
# Usage: python3 util/objToGlb.py model.obj model.glb [--root Name] [--pivots joints.json]
import argparse
import json
import os
import struct
import numpy as np
parser = argparse.ArgumentParser()
parser.add_argument('obj')
parser.add_argument('out')
parser.add_argument('--root', default=None, help='name of the root node (default: the file name)')
parser.add_argument('--pivots', default=None, help='JSON naming each object\'s own origin, so its node can turn about a joint')
args = parser.parse_args()
def loadMtl(path):
materials = {}
current = None
for line in open(path):
parts = line.split()
if not parts:
continue
if parts[0] == 'newmtl':
current = {'Kd': (0.8, 0.8, 0.8), 'Ks': (0.0, 0.0, 0.0), 'Ns': 10.0, 'd': 1.0, 'Pm': None, 'Pr': None}
materials[parts[1]] = current
elif current is not None and parts[0] in ('Kd', 'Ks'):
current[parts[0]] = tuple(float(v) for v in parts[1:4])
elif current is not None and parts[0] in ('Ns', 'd', 'Pm', 'Pr'):
current[parts[0]] = float(parts[1])
return materials
positions = []
uvs = []
normals = []
materials = {}
objects = {}
objectOrder = []
currentObject = 'default'
currentMaterial = None
for line in open(args.obj):
parts = line.split()
if not parts or parts[0] == '#':
continue
if parts[0] == 'mtllib':
materials = loadMtl(os.path.join(os.path.dirname(args.obj), parts[1]))
elif parts[0] == 'v':
positions.append([float(v) for v in parts[1:4]])
elif parts[0] == 'vt':
uvs.append([float(parts[1]), 1.0 - float(parts[2])])
elif parts[0] == 'vn':
normals.append([float(v) for v in parts[1:4]])
elif parts[0] == 'o':
currentObject = parts[1]
elif parts[0] == 'usemtl':
currentMaterial = parts[1]
elif parts[0] == 'f':
corners = []
for token in parts[1:]:
fields = token.split('/')
v = int(fields[0]) - 1
vt = int(fields[1]) - 1 if len(fields) > 1 and fields[1] else None
vn = int(fields[2]) - 1 if len(fields) > 2 and fields[2] else None
corners.append((v, vt, vn))
if currentObject not in objects:
objects[currentObject] = {}
objectOrder.append(currentObject)
primitive = objects[currentObject].setdefault(currentMaterial, {'lookup': {}, 'vertices': [], 'indices': []})
indices = []
for corner in corners:
if corner not in primitive['lookup']:
primitive['lookup'][corner] = len(primitive['vertices'])
primitive['vertices'].append(corner)
indices.append(primitive['lookup'][corner])
for i in range(1, len(indices) - 1):
primitive['indices'] += [indices[0], indices[i], indices[i + 1]]
positions = np.array(positions, dtype=np.float32)
uvs = np.array(uvs, dtype=np.float32) if uvs else None
normals = np.array(normals, dtype=np.float32) if normals else None
binary = bytearray()
bufferViews = []
accessors = []
def addView(data, target):
while len(binary) % 4:
binary.append(0)
bufferViews.append({'buffer': 0, 'byteOffset': len(binary), 'byteLength': len(data), 'target': target})
binary.extend(data)
return len(bufferViews) - 1
def addAccessor(array, componentType, kind, target, bounds=False):
view = addView(array.tobytes(), target)
accessor = {'bufferView': view, 'componentType': componentType, 'count': int(array.shape[0]), 'type': kind}
if bounds:
accessor['min'] = [float(v) for v in array.min(axis=0)]
accessor['max'] = [float(v) for v in array.max(axis=0)]
accessors.append(accessor)
return len(accessors) - 1
materialNames = list(materials.keys())
glMaterials = []
for name in materialNames:
m = materials[name]
metallic = m['Pm'] if m['Pm'] is not None else (1.0 if (np.mean(m['Ks']) > 0.5 and m['d'] >= 1.0) else 0.0)
roughness = m['Pr'] if m['Pr'] is not None else float(np.clip(1.0 - m['Ns'] / 400.0, 0.05, 1.0))
entry = {'name': name, 'pbrMetallicRoughness': {'baseColorFactor': [m['Kd'][0], m['Kd'][1], m['Kd'][2], m['d']], 'metallicFactor': metallic, 'roughnessFactor': roughness}}
if m['d'] < 1.0:
entry['alphaMode'] = 'BLEND'
entry['doubleSided'] = True
glMaterials.append(entry)
# Where each object's origin should sit, resolved against its own bounding box.
pivots = {}
if args.pivots:
with open(args.pivots) as handle:
pivots = {k: v for k, v in json.load(handle).items() if not k.startswith('_')}
unknown = [name for name in pivots if name not in objects]
if unknown:
# A pivot naming a part that is not in the model is a rename that was not carried across,
# and silently ignoring it would leave that part turning about the model's origin.
raise SystemExit('%s: no such object(s) in %s: %s' % (args.pivots, args.obj, ', '.join(sorted(unknown))))
def pivotFor(name, verts):
rule = pivots.get(name)
if rule is None:
return None
low = verts.min(axis=0)
high = verts.max(axis=0)
out = []
for axis in range(3):
want = rule[axis]
if isinstance(want, (int, float)):
out.append(float(want))
elif want == 'min':
out.append(float(low[axis]))
elif want == 'max':
out.append(float(high[axis]))
elif want == 'mid':
out.append(float((low[axis] + high[axis]) / 2.0))
else:
raise SystemExit('%s: %s has an unknown axis rule %r' % (args.pivots, name, want))
return out
meshes = []
nodes = []
for name in objectOrder:
primitives = []
# The pivot is measured across the whole object, not per material, or the parts of one wing
# would each turn about a different point.
whole = np.concatenate([positions[[v for v, vt, vn in primitive['vertices']]] for primitive in objects[name].values()])
pivot = pivotFor(name, whole)
for materialName, primitive in objects[name].items():
verts = primitive['vertices']
pos = positions[[v for v, vt, vn in verts]]
if pivot is not None:
pos = pos - np.array(pivot, dtype=pos.dtype)
attributes = {'POSITION': addAccessor(pos, 5126, 'VEC3', 34962, bounds=True)}
if normals is not None and all(vn is not None for v, vt, vn in verts):
attributes['NORMAL'] = addAccessor(normals[[vn for v, vt, vn in verts]], 5126, 'VEC3', 34962)
if uvs is not None and all(vt is not None for v, vt, vn in verts):
attributes['TEXCOORD_0'] = addAccessor(uvs[[vt for v, vt, vn in verts]], 5126, 'VEC2', 34962)
indexArray = np.array(primitive['indices'], dtype=np.uint32 if len(verts) > 65535 else np.uint16)
entry = {'attributes': attributes, 'indices': addAccessor(indexArray, 5125 if indexArray.dtype == np.uint32 else 5123, 'SCALAR', 34963), 'mode': 4}
if materialName in materialNames:
entry['material'] = materialNames.index(materialName)
primitives.append(entry)
meshes.append({'name': name, 'primitives': primitives})
node = {'name': name, 'mesh': len(meshes) - 1}
if pivot is not None:
node['translation'] = pivot
nodes.append(node)
rootName = args.root or os.path.splitext(os.path.basename(args.out))[0]
nodes.append({'name': rootName, 'children': list(range(len(nodes)))})
gltf = {
'asset': {'version': '2.0', 'generator': 'Singe util/objToGlb.py'},
'scene': 0,
'scenes': [{'name': rootName, 'nodes': [len(nodes) - 1]}],
'nodes': nodes,
'meshes': meshes,
'materials': glMaterials,
'accessors': accessors,
'bufferViews': bufferViews,
'buffers': [{'byteLength': len(binary)}],
}
while len(binary) % 4:
binary.append(0)
jsonBytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
while len(jsonBytes) % 4:
jsonBytes += b' '
total = 12 + 8 + len(jsonBytes) + 8 + len(binary)
with open(args.out, 'wb') as f:
f.write(struct.pack('<4sII', b'glTF', 2, total))
f.write(struct.pack('<II', len(jsonBytes), 0x4E4F534A))
f.write(jsonBytes)
f.write(struct.pack('<II', len(binary), 0x004E4942))
f.write(binary)
print('%s: %d nodes, %d materials, %d triangles, %d bytes' % (args.out, len(objectOrder), len(glMaterials), sum(len(p['indices']) // 3 for o in objects.values() for p in o.values()), total))