183 lines
8.6 KiB
Python
183 lines
8.6 KiB
Python
# Small Wavefront OBJ/MTL writer shared by the dragon model builders.
|
|
import os
|
|
import struct
|
|
import numpy as np
|
|
from scipy import ndimage
|
|
|
|
|
|
class ObjWriterT:
|
|
def __init__(self):
|
|
self.vertices = []
|
|
self.uvs = []
|
|
self.normals = []
|
|
self.normalIndex = {}
|
|
self.lines = []
|
|
self.faceCount = 0
|
|
self.currentMaterial = None
|
|
self.currentObject = None
|
|
self.faces = []
|
|
|
|
def vertex(self, p, uv=None):
|
|
self.vertices.append('v %.5f %.5f %.5f' % tuple(p))
|
|
if uv is not None:
|
|
self.uvs.append('vt %.5f %.5f' % tuple(uv))
|
|
return len(self.vertices)
|
|
|
|
def point(self, index):
|
|
parts = self.vertices[index - 1].split()
|
|
return np.array([float(parts[1]), float(parts[2]), float(parts[3])])
|
|
|
|
def object(self, name):
|
|
self.lines.append('o %s' % name)
|
|
self.currentObject = name
|
|
self.currentMaterial = None
|
|
|
|
def material(self, name):
|
|
if name != self.currentMaterial:
|
|
self.lines.append('usemtl %s' % name)
|
|
self.currentMaterial = name
|
|
|
|
def normal(self, n):
|
|
key = tuple(round(float(v), 4) for v in n)
|
|
if key not in self.normalIndex:
|
|
self.normals.append('vn %.4f %.4f %.4f' % key)
|
|
self.normalIndex[key] = len(self.normals)
|
|
return self.normalIndex[key]
|
|
|
|
def face(self, indices, inside=None, textured=False):
|
|
# Flat normal from the polygon; if an inside point is given the winding is fixed to face away from it.
|
|
pts = [self.point(i) for i in indices]
|
|
n = np.zeros(3)
|
|
for i in range(len(pts)):
|
|
n += np.cross(pts[i], pts[(i + 1) % len(pts)])
|
|
length = np.linalg.norm(n)
|
|
# A zero-area face (collinear points from a traced outline) is kept so the shell stays closed.
|
|
n = n / length if length > 1e-12 else np.array([0.0, 0.0, 1.0])
|
|
if inside is not None and n @ (np.mean(pts, axis=0) - inside) < 0:
|
|
indices = indices[::-1]
|
|
n = -n
|
|
ni = self.normal(n)
|
|
self.faces.append((self.currentObject, self.currentMaterial, list(indices)))
|
|
if textured:
|
|
self.lines.append('f ' + ' '.join('%d/%d/%d' % (i, i, ni) for i in indices))
|
|
else:
|
|
self.lines.append('f ' + ' '.join('%d//%d' % (i, ni) for i in indices))
|
|
self.faceCount += 1
|
|
|
|
def write(self, path, mtlName, header):
|
|
out = ['# ' + line for line in header] + ['mtllib %s' % mtlName]
|
|
out += self.vertices + self.uvs + self.normals + self.lines
|
|
open(path, 'w').write('\n'.join(out) + '\n')
|
|
|
|
|
|
def writeStl(self, path, materials, unitScale, name):
|
|
# Binary STL of the faces using any of the given materials, fan-triangulated, scaled to millimetres.
|
|
triangles = []
|
|
for obj, material, indices in self.faces:
|
|
if material not in materials:
|
|
continue
|
|
pts = [self.point(i) * unitScale for i in indices]
|
|
for i in range(1, len(pts) - 1):
|
|
triangles.append((pts[0], pts[i], pts[i + 1]))
|
|
with open(path, 'wb') as f:
|
|
f.write(('Singe %s' % name).encode('ascii')[:80].ljust(80, b'\0'))
|
|
f.write(struct.pack('<I', len(triangles)))
|
|
for a, b, c in triangles:
|
|
n = np.cross(b - a, c - a)
|
|
length = np.linalg.norm(n)
|
|
n = n / length if length > 1e-12 else np.zeros(3)
|
|
f.write(struct.pack('<12fH', *n, *a, *b, *c, 0))
|
|
return len(triangles)
|
|
|
|
|
|
def pieceCount(writer, voxel, margin=12):
|
|
# Voxelises every object of a writer by ray parity along X on a grid of the given spacing, unions
|
|
# them, and returns (count, [(voxels, objects, (min, max)) per piece]). Objects may overlap each
|
|
# other, so each is rasterised on its own; the shells within one object must not overlap.
|
|
points = np.array([writer.point(i + 1) for i in range(len(writer.vertices))])
|
|
lower = points.min(axis=0) - margin * voxel
|
|
upper = points.max(axis=0) + margin * voxel
|
|
size = np.ceil((upper - lower) / voxel).astype(int) + 1
|
|
solid = np.zeros((size[1], size[2], size[0]), dtype=bool)
|
|
byObject = {}
|
|
for obj, material, indices in writer.faces:
|
|
byObject.setdefault(obj, []).append(indices)
|
|
voxelsOf = {}
|
|
for obj, faces in byObject.items():
|
|
crossings = np.zeros((size[1], size[2], size[0] + 1), dtype=np.int8)
|
|
for indices in faces:
|
|
pts = [(writer.point(i) - lower) / voxel for i in indices]
|
|
for t in range(1, len(pts) - 1):
|
|
a, b, c = pts[0], pts[t], pts[t + 1]
|
|
det = (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2])
|
|
if abs(det) < 1e-9:
|
|
continue
|
|
yMin = max(0, int(np.floor(min(a[1], b[1], c[1]))))
|
|
yMax = min(size[1] - 1, int(np.ceil(max(a[1], b[1], c[1]))))
|
|
zMin = max(0, int(np.floor(min(a[2], b[2], c[2]))))
|
|
zMax = min(size[2] - 1, int(np.ceil(max(a[2], b[2], c[2]))))
|
|
if yMin > yMax or zMin > zMax:
|
|
continue
|
|
# Sample off the half-voxel so outline vertices never sit exactly on a ray.
|
|
gy, gz = np.meshgrid(np.arange(yMin, yMax + 1) + 0.5123, np.arange(zMin, zMax + 1) + 0.5123, indexing='ij')
|
|
l1 = ((b[1] - gy) * (c[2] - gz) - (c[1] - gy) * (b[2] - gz)) / det
|
|
l2 = ((c[1] - gy) * (a[2] - gz) - (a[1] - gy) * (c[2] - gz)) / det
|
|
l3 = 1 - l1 - l2
|
|
inside = (l1 >= 0) & (l2 >= 0) & (l3 >= 0)
|
|
if not inside.any():
|
|
continue
|
|
x = l1 * a[0] + l2 * b[0] + l3 * c[0]
|
|
xi = np.clip(np.ceil(x).astype(int), 0, size[0])
|
|
ys, zs = np.nonzero(inside)
|
|
np.add.at(crossings, (ys + yMin, zs + zMin, xi[inside]), 1)
|
|
voxelsOf[obj] = (np.cumsum(crossings, axis=2, dtype=np.int8)[:, :, :size[0]] % 2).astype(bool)
|
|
solid |= voxelsOf[obj]
|
|
labels, count = ndimage.label(solid, structure=np.ones((3, 3, 3)))
|
|
members = {}
|
|
for obj, voxels in voxelsOf.items():
|
|
found = labels[voxels]
|
|
if found.size:
|
|
members.setdefault(int(np.bincount(found).argmax()), []).append(obj)
|
|
pieces = []
|
|
for label in range(1, count + 1):
|
|
box = ndimage.find_objects(labels == label)[0]
|
|
low = lower + np.array([box[2].start, box[0].start, box[1].start]) * voxel
|
|
high = lower + np.array([box[2].stop, box[0].stop, box[1].stop]) * voxel
|
|
pieces.append((int((labels == label).sum()), members.get(label, []), (low, high)))
|
|
return count, pieces
|
|
|
|
|
|
def reportPieces(count, pieces):
|
|
print('check: %d piece%s' % (count, '' if count == 1 else 's'))
|
|
for voxels, objects, (low, high) in pieces:
|
|
print(' piece of %d voxels at x %.2f..%.2f y %.2f..%.2f z %.2f..%.2f: %s' % (voxels, low[0], high[0], low[1], high[1], low[2], high[2], ', '.join(objects) or 'no whole object'))
|
|
|
|
|
|
def writeMtl(path, materials, header):
|
|
lines = ['# ' + header]
|
|
for name, m in materials.items():
|
|
lines.append('newmtl %s' % name)
|
|
lines.append('Ka %.3f %.3f %.3f' % tuple(v * 0.2 for v in m['Kd']))
|
|
lines.append('Kd %.3f %.3f %.3f' % m['Kd'])
|
|
lines.append('Ks %.3f %.3f %.3f' % m['Ks'])
|
|
lines.append('Ns %.1f' % m['Ns'])
|
|
lines.append('d %.2f' % m['d'])
|
|
lines.append('Ni %.2f' % m['Ni'])
|
|
lines.append('illum %d' % m['illum'])
|
|
# PBR extension keys, read by util/objToGlb.py and most modern importers.
|
|
lines.append('Pm %.2f' % m['Pm'])
|
|
lines.append('Pr %.2f' % m['Pr'])
|
|
lines.append('')
|
|
open(path, 'w').write('\n'.join(lines) + '\n')
|
|
|
|
|
|
# The three tile materials, shared by every dragon model.
|
|
DRAGON_MATERIALS = {
|
|
'dark': {'Kd': (0.30, 0.31, 0.33), 'Ks': (0.70, 0.70, 0.70), 'Ns': 180.0, 'd': 1.0, 'Ni': 1.0, 'illum': 2, 'Pm': 0.8, 'Pr': 0.35},
|
|
'champagne': {'Kd': (0.72, 0.64, 0.54), 'Ks': (0.85, 0.80, 0.70), 'Ns': 120.0, 'd': 1.0, 'Ni': 1.0, 'illum': 2, 'Pm': 0.8, 'Pr': 0.40},
|
|
'glass': {'Kd': (0.88, 0.92, 0.90), 'Ks': (0.95, 0.95, 0.95), 'Ns': 300.0, 'd': 0.55, 'Ni': 1.50, 'illum': 4, 'Pm': 0.0, 'Pr': 0.15},
|
|
# The fourth print colour: the core, side walls and grout between tiles of the printable dragon.
|
|
'frame': {'Kd': (0.12, 0.11, 0.10), 'Ks': (0.30, 0.30, 0.30), 'Ns': 40.0, 'd': 1.0, 'Ni': 1.0, 'illum': 2, 'Pm': 0.0, 'Pr': 0.7},
|
|
# Backing plate of the printable logotype and plaque; its own body so any filament can be assigned.
|
|
'plate': {'Kd': (0.55, 0.53, 0.50), 'Ks': (0.20, 0.20, 0.20), 'Ns': 30.0, 'd': 1.0, 'Ni': 1.0, 'illum': 2, 'Pm': 0.0, 'Pr': 0.8},
|
|
}
|