-
-
Save awesomez/cf9e29392a3fb910970b66a6d639a337 to your computer and use it in GitHub Desktop.
""" | |
AUTOMATIC drag and drop support for windows (NO PROMPT!) | |
1. Copy script to directory you want your files copied to. | |
2. Select the files you want to convert. | |
3. Drag & drop onto this script to convert .vox to .obj! | |
Files will be exported to directory of this script. | |
automatic mod by awesomedata | |
This script is designed to export a mass amount of MagicaVoxel .vox files | |
to .obj. Unlike Magica's internal exporter, this exporter preserves the | |
voxel vertices for easy manipulating in a 3d modeling program like Blender. | |
Various meshing algorithms are included (or to be included). MagicaVoxel | |
uses monotone triangulation (I think). The algorithms that will (or do) | |
appear in this script will use methods to potentially reduce rendering | |
artifacts that could be introduced by triangulation of this nature. | |
I may also include some features like light map generation for easy | |
importing into Unreal Engine, etc. | |
Notes: | |
* There may be a few floating point equality comparisons. They seem to | |
work but it scares me a little. | |
* TODO: use constants instead of magic numbers (as defined in AAQuad), | |
(i.e., ..., 2 -> AAQuad.TOP, ...) | |
* A lot of assertions should probably be exceptions since they are | |
error checking user input (this sounds really bad now that I've put | |
it on paper...). So don't run in optimized mode (who does that | |
anyways?). | |
* I am considering adding FBX support. | |
""" | |
import math | |
class AAQuad: | |
""" A solid colored axis aligned quad. """ | |
normals = [ | |
(-1, 0, 0), # left = 0 | |
(1, 0, 0), # right = 1 | |
(0, 0, 1), # top = 2 | |
(0, 0, -1), # bottom = 3 | |
(0, -1, 0), # front = 4 | |
(0, 1, 0) # back = 5 | |
] | |
LEFT = 0 | |
RIGHT = 1 | |
TOP = 2 | |
BOTTOM = 3 | |
FRONT = 4 | |
BACK = 5 | |
def __init__(self, verts, uv=None, normal=None): | |
assert len(verts) == 4, "face must be a quad" | |
self.vertices = verts | |
self.uv = uv | |
self.normal = normal | |
def __str__(self): | |
s = [] | |
for i in self.vertices: | |
s.append( str(i) + '/' + str(self.uv) + '/' + str(self.normal)) | |
return 'f ' + ' '.join(s) | |
def center(self): | |
return ( | |
sum(i[0] for i in self.vertices)/4, | |
sum(i[1] for i in self.vertices)/4, | |
sum(i[2] for i in self.vertices)/4 | |
) | |
def bucketHash(faces, origin, maximum, bucket=16): | |
extents = ( | |
math.ceil((maximum[0] - origin[0])/bucket), | |
math.ceil((maximum[1] - origin[1])/bucket), | |
math.ceil((maximum[2] - origin[2])/bucket) | |
) | |
buckets = {} | |
for f in faces: | |
c = f.center() | |
# TODO | |
def optimizedGreedyMesh(faces): | |
# TODO | |
edges = adjacencyGraphEdges(faces) | |
groups = contiguousFaces(faces, edges) | |
return faces | |
def adjacencyGraphEdges(faces): | |
""" Get the list of edges representing adjacent faces. """ | |
# a list of edges, where edges are tuple(face_a, face_b) | |
edges = [] | |
# build the list of edges in the graph | |
for root in faces: | |
for face in faces: | |
if face is root: | |
continue | |
if facesAreAdjacent(root, face): | |
# the other edge will happen somewhere else in the iteration | |
# (i.e., the relation isAdjacent is symmetric) | |
edges.append((root, face)) | |
return edges | |
def contiguousFaces(faces, adjacencyGraphEdges): | |
""" Get the list of connected components from a list of graph edges. | |
The list will contain lists containing the edges within the components. | |
""" | |
groups = [] | |
visited = dict((f, False) for f in faces) | |
for face in faces: | |
# if the face hasn't been visited, it is not in any found components | |
if not visited[face]: | |
g = [] | |
_visitGraphNodes(face, adjacencyGraphEdges, visited, g) | |
# there is only a new component if face has not been visited yet | |
groups.append(g) | |
return groups | |
def _visitGraphNodes(node, edges, visited, component): | |
""" Recursive routine used in findGraphComponents """ | |
# visit every component connected to this one | |
for edge in edges: | |
# for all x in nodes, (node, x) and (x, node) should be in edges! | |
# therefore we don't have to check for "edge[1] is node" | |
if edge[0] is node and not visited[edge[1]]: | |
assert edge[1] is not node, "(node, node) should not be in edges" | |
# mark the other node as visited | |
visited[edge[1]] = True | |
component.append(edge[1]) | |
# visit all of that nodes connected nodes | |
_visitGraphNodes(edge[1], edges, visited, component) | |
def facesAreAdjacent(a, b): | |
""" Adjacent is defined as same normal, uv, and a shared edge. | |
This isn't entirely intuitive (i.e., corner faces are not adjacent) | |
but this definition fits the problem domain. | |
Only works on AAQuads. | |
""" | |
# note: None is == None, this shouldn't matter | |
if a.uv != b.uv: | |
return False | |
if a.normal != b.normal: | |
return False | |
# to be adjacent, two faces must share an edge | |
# use == and not identity in case edge split was used | |
shared = 0 | |
for vert_a in a.vertices: | |
for vert_b in b.vertices: | |
if vert_a == vert_b: | |
shared += 1 | |
# hooray we have found a shared edge (or a degenerate case...) | |
if shared == 2: | |
return True | |
return False | |
class GeoFace: | |
""" An arbitrary geometry face | |
This should only be used for arbitrary models, not ones we can | |
reasonably assume are axis aligned. | |
""" | |
def __init__(self, verts, uvs=None, normals=None): | |
self.vertices = verts | |
assert len(verts) in (3, 4), "only quads and tris are supported" | |
self.normals = normals | |
self.uvs = uvs | |
def toAAQuad(self, skipAssert=False): | |
q = AAQuad(self.vertices) | |
if self.normals is not None and len(self.normals) > 0: | |
if not skipAssert: | |
for i in self.normals: | |
assert self.normals[0] == i, \ | |
"face must be axis aligned (orthogonal normals)" | |
q.normal = self.normals[0] | |
if self.uvs is not None and len(self.uvs) > 0: | |
if not skipAssert: | |
for i in self.uvs: | |
assert self.uvs[0] == i, \ | |
"face must be axis aligned (orthogonal)" | |
q.uv = self.uvs[0] | |
return q | |
class VoxelStruct: | |
""" Describes a voxel object | |
""" | |
def __init__(self): | |
# a dict is probably the best way to go about this | |
# (as a trade off between performance and code complexity) | |
# see _index for the indexing method | |
self.voxels = {} | |
self.colorIndices = set() | |
def fromList(self, voxels): | |
self.voxels = {} | |
for voxel in voxels: | |
self.setVoxel(voxel) | |
self.colorIndices.add(voxel.colorIndex) | |
def setVoxel(self, voxel): | |
self.voxels[voxel.z*(256**2) + voxel.y * 256 + voxel.x] = voxel | |
def getVoxel(self, x, y, z): | |
return self.voxels.get(z*(256**2) + y * 256 + x, None) | |
def _index(self, x, y, z): | |
return z*(256**2) + y * 256 + x | |
def getBounds(self): | |
origin = (float("inf"), float("inf"), float("inf")) | |
maximum = (float("-inf"), float("-inf"), float("-inf")) | |
for key, voxel in self.voxels.items(): | |
origin = ( | |
min(origin[0], voxel.x), | |
min(origin[1], voxel.y), | |
min(origin[2], voxel.z) | |
) | |
maximum = ( | |
max(maximum[0], voxel.x), | |
max(maximum[1], voxel.y), | |
max(maximum[2], voxel.z) | |
) | |
return origin, maximum | |
def zeroOrigin(self): | |
""" Translate the model so that it's origin is at 0, 0, 0 """ | |
origin, maximum = self.getBounds() | |
result = {} | |
xOff, yOff, zOff = origin | |
for key, voxel in self.voxels.iteritems(): | |
result[self._index(voxel.x-xOff, voxel.y-yOff, voxel.z-zOff)] = \ | |
Voxel(voxel.x-xOff, voxel.y-yOff, voxel.z-zOff, | |
voxel.colorIndex) | |
self.voxels = result | |
return (0, 0, 0), (maximum[0] - xOff, | |
maximum[1] - yOff, | |
maximum[2] - zOff) | |
def toQuads(self): | |
""" --> a list of AAQuads """ | |
faces = [] | |
for key, voxel in self.voxels.items(): | |
self._getObjFaces(voxel, faces) | |
return faces | |
def _getObjFaces(self, voxel, outFaces): | |
if voxel.colorIndex == 0: | |
# do nothing if this is an empty voxel | |
# n.b., I do not know if this ever can happen. | |
return [] | |
sides = self._objExposed(voxel) | |
if sides[0]: | |
f = self._getLeftSide(voxel) | |
self._getObjFacesSupport(0, voxel.colorIndex, f, outFaces) | |
if sides[1]: | |
f = self._getRightSide(voxel) | |
self._getObjFacesSupport(1, voxel.colorIndex, f, outFaces) | |
if sides[2]: | |
f = self._getTopSide(voxel) | |
self._getObjFacesSupport(2, voxel.colorIndex, f, outFaces) | |
if sides[3]: | |
f = self._getBottomSide(voxel) | |
self._getObjFacesSupport(3, voxel.colorIndex, f, outFaces) | |
if sides[4]: | |
f = self._getFrontSide(voxel) | |
self._getObjFacesSupport(4, voxel.colorIndex, f, outFaces) | |
if sides[5]: | |
f = self._getBackSide(voxel) | |
self._getObjFacesSupport(5, voxel.colorIndex, f, outFaces) | |
return | |
n = AAQuad.normals[i] | |
# note: texcoords are based on MagicaVoxel's texturing scheme! | |
# meaning a color index of 0 translates to pixel[255] | |
# and color index [1:256] -> pixel[0:255] | |
u = ((voxel.colorIndex - 1)/256 + 1/512, 0.5) | |
outFaces.append( | |
# this is most definitely not "fun" | |
AAQuad(f, u, n) | |
) | |
def _getObjFacesSupport(self, side, color, faces, outFaces): | |
n = AAQuad.normals[side] | |
# note: texcoords are based on MagicaVoxel's texturing scheme! | |
# meaning a color index of 0 translates to pixel[255] | |
# and color index [1:256] -> pixel[0:255] | |
u = ((color - 1)/256 + 1/512, 0.5) | |
outFaces.append( | |
# fact: the parameters were coincidentally "f, u, n" at one point! | |
AAQuad(faces, u, n) | |
) | |
# MagicaVoxel does -.5 to +.5 for each cube, we'll do 0.0 to 1.0 ;) | |
def _getLeftSide(self, voxel): | |
return [ | |
(voxel.x, voxel.y + 1, voxel.z + 1), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x, voxel.y, voxel.z + 1) | |
] | |
def _getRightSide(self, voxel): | |
return ( | |
(voxel.x + 1, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1) | |
) | |
def _getTopSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y + 1, voxel.z + 1), | |
(voxel.x, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1) | |
) | |
def _getBottomSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z) | |
) | |
def _getFrontSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y, voxel.z + 1), | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z + 1) | |
) | |
def _getBackSide(self, voxel): | |
return ( | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z + 1) | |
) | |
def _objExposed(self, voxel): | |
""" --> a set of [0, 6) representing which voxel faces are shown | |
for the meaning of 0-5, see AAQuad.normals | |
get the sick truth about these voxels' dirty secrets... | |
""" | |
# check left 0 | |
side = self.getVoxel(voxel.x - 1, voxel.y, voxel.z) | |
s0 = side is None or side.colorIndex == 0 | |
# check right 1 | |
side = self.getVoxel(voxel.x + 1, voxel.y, voxel.z) | |
s1 = side is None or side.colorIndex == 0 | |
# check top 2 | |
side = self.getVoxel(voxel.x, voxel.y, voxel.z + 1) | |
s2 = side is None or side.colorIndex == 0 | |
# check bottom 3 | |
side = self.getVoxel(voxel.x, voxel.y, voxel.z - 1) | |
s3 = side is None or side.colorIndex == 0 | |
# check front 4 | |
side = self.getVoxel(voxel.x, voxel.y - 1, voxel.z) | |
s4 = side is None or side.colorIndex == 0 | |
# check back 5 | |
side = self.getVoxel(voxel.x, voxel.y + 1, voxel.z) | |
s5 = side is None or side.colorIndex == 0 | |
return s0, s1, s2, s3, s4, s5 | |
class Voxel: | |
def __init__(self, x, y, z, colorIndex): | |
self.x = x | |
self.y = y | |
self.z = z | |
self.colorIndex = colorIndex | |
def genNormals(self, aaQuads, overwrite=False): | |
# compute CCW normal if it doesn't exist | |
for face in aaQuads: | |
if overwrite or face.normal is None: | |
side_a = (face.vertices[1][0] - face.vertices[0][0], | |
face.vertices[1][1] - face.vertices[0][1], | |
face.vertices[1][2] - face.vertices[0][2]) | |
side_b = (face.vertices[-1][0] - face.vertices[0][0], | |
face.vertices[-1][1] - face.vertices[0][1], | |
face.vertices[-1][2] - face.vertices[0][2]) | |
# compute the cross product | |
face.normal = (side_a[1]*side_b[2] - side_a[2]*side_b[1], | |
side_a[2]*side_b[0] - side_a[0]*side_b[2], | |
side_a[0]*side_b[1] - side_a[1]*side_b[0]) | |
def importObj(stream): | |
vertices = [] | |
faces = [] | |
uvs = [] | |
normals = [] | |
for line in stream: | |
# make sure there's no new line or trailing spaces | |
l = line.strip().split(' ') | |
lineType = l[0].strip() | |
data = l[1:] | |
if lineType == 'v': | |
# vertex | |
v = tuple(map(float, data)) | |
vertices.append(v) | |
elif lineType == 'vt': | |
# uv | |
uvs.append( tuple(map(float, data)) ) | |
elif lineType == 'vn': | |
# normal | |
normals.append( tuple(map(float, data)) ) | |
elif lineType == 'f': | |
# face (assume all verts/uvs/normals have been processed) | |
faceVerts = [] | |
faceUvs = [] | |
faceNormals = [] | |
for v in data: | |
result = v.split('/') | |
print(result) | |
# recall that everything is 1 indexed... | |
faceVerts.append(vertices[int(result[0]) - 1]) | |
if len(result) == 1: | |
continue # there is only a vertex index | |
if result[1] != '': | |
# uvs may not be present, ex: 'f vert//normal ...' | |
faceUvs.append(uvs[int(result[1]) - 1]) | |
if len(result) <= 2: | |
# don't continue if only vert and uv are present | |
continue | |
faceNormals.append(normals[int(result[2]) - 1]) | |
faces.append( GeoFace(faceVerts, faceUvs, faceNormals) ) | |
else: | |
# there could be material specs, smoothing, or comments... ignore! | |
pass | |
return faces | |
def exportObj(stream, aaQuads): | |
# gather some of the needed information | |
faces = aaQuads | |
# copy the normals from AAQuad (99% of cases will use all directions) | |
normals = list(AAQuad.normals) | |
uvs = set() | |
for f in faces: | |
if f.uv is not None: | |
uvs.add(f.uv) | |
# convert this to a list because we need to get their index later | |
uvs = list(uvs) | |
# we will build a list of vertices as we go and then write everything | |
# in bulk, disadvantage that MANY verts will be duplicated in the OBJ file | |
fLines = [] | |
vertices = [] | |
indexOffset = 0 | |
for f in faces: | |
# recall that OBJ files are 1 indexed | |
n = 1 + normals.index(f.normal) if f.normal is not None else '' | |
uv = 1 + uvs.index(f.uv) if f.uv is not None else '' | |
# this used to be a one liner ;) | |
fLine = ['f'] | |
for i, vert in enumerate(f.vertices): | |
# for each vertex of this face | |
v = 1 + indexOffset + f.vertices.index(vert) | |
fLine.append(str(v) + '/' + str(uv) + '/' + str(n)) | |
vertices.extend(f.vertices) | |
indexOffset += len(f.vertices) | |
fLines.append(' '.join(fLine) + '\n') | |
# write to the file | |
stream.write('# shivshank\'s .obj optimizer\n') | |
stream.write('\n') | |
if len(normals) > 0: | |
stream.write('# normals\n') | |
for n in normals: | |
stream.write('vn ' + ' '.join(list(map(str, n))) + '\n') | |
stream.write('\n') | |
if len(uvs) > 0: | |
stream.write('# texcoords\n') | |
for i in uvs: | |
stream.write('vt ' + ' '.join(list(map(str, i))) + '\n') | |
stream.write('\n') | |
# output the vertices and faces | |
stream.write('# verts\n') | |
for v in vertices: | |
stream.write('v ' + ' '.join(list(map(str, v))) + '\n') | |
stream.write('\n') | |
stream.write('# faces\n') | |
for i in fLines: | |
stream.write(i) | |
stream.write('\n') | |
stream.write('\n') | |
return len(vertices), len(fLines) | |
def importVox(file): | |
""" --> a VoxelStruct from this .vox file stream """ | |
# in theory this could elegantly be many functions and classes | |
# but this is such a simple file format... | |
# refactor: ? should probably find a better exception type than value error | |
vox = VoxelStruct() | |
magic = file.read(4) | |
if magic != b'VOX ': | |
print('magic number is', magic) | |
if userAborts('This does not appear to be a VOX file. Abort?'): | |
raise ValueError("Invalid magic number") | |
# the file appears to use little endian consistent with RIFF | |
version = int.from_bytes(file.read(4), byteorder='little') | |
if version != 150: | |
if userAborts('Only version 150 is supported; this file: ' | |
+ str(version) + '. Abort?'): | |
raise ValueError("Invalid file version") | |
mainHeader = _readChunkHeader(file) | |
if mainHeader['id'] != b'MAIN': | |
print('chunk id:', mainId) | |
if userAborts('Did not find the main chunk. Abort?'): | |
raise ValueError("Did not find main VOX chunk. ") | |
#assert mainHeader['size'] == 0, "main chunk should have size 0" | |
# we don't need anything from the size or palette header! | |
# : we can figure out (minimum) bounds later from the voxel data | |
# : we only need UVs from voxel data; user can export palette elsewhere | |
nextHeader = _readChunkHeader(file) | |
while nextHeader['id'] != b'XYZI': | |
# skip the contents of this header and its children, read the next one | |
file.read(nextHeader['size'] + nextHeader['childrenSize']) | |
nextHeader = _readChunkHeader(file) | |
voxelHeader = nextHeader | |
assert voxelHeader['id'] == b'XYZI', 'this should be literally impossible' | |
assert voxelHeader['childrenSize'] == 0, 'why voxel chunk have children?' | |
seekPos = file.tell() | |
totalVoxels = int.from_bytes(file.read(4), byteorder='little') | |
### READ THE VOXELS ### | |
for i in range(totalVoxels): | |
# n.b., byte order should be irrelevant since these are all 1 byte | |
x = int.from_bytes(file.read(1), byteorder='little') | |
y = int.from_bytes(file.read(1), byteorder='little') | |
z = int.from_bytes(file.read(1), byteorder='little') | |
color = int.from_bytes(file.read(1), byteorder='little') | |
vox.setVoxel(Voxel(x, y, z, color)) | |
# assert that we've read the entire voxel chunk | |
assert file.tell() - seekPos == voxelHeader['size'] | |
# (there may be more chunks after this but we don't need them!) | |
#print('\tdone reading voxel data;', totalVoxels , 'voxels read ;D') | |
return vox | |
def _readChunkHeader(buffer): | |
id = buffer.read(4) | |
if id == b'': | |
raise ValueError("Unexpected EOF, expected chunk header") | |
size = int.from_bytes(buffer.read(4), byteorder='little') | |
childrenSize = int.from_bytes(buffer.read(4), byteorder='little') | |
return { | |
'id': id, 'size': size, 'childrenSize': childrenSize | |
} | |
def userAborts(msg): | |
print(msg + ' (y/n)') | |
u = input() | |
if u.startswith('n'): | |
return False | |
return True | |
def exportAll(): | |
""" Uses a file to automatically export a bunch of files! | |
See this function for details on the what the file looks like. | |
""" | |
import os, os.path | |
with open('exporter.txt', mode='r') as file: | |
# use this as a file "spec" | |
fromSource = os.path.abspath(file.readline().strip()) | |
toExportDir = os.path.abspath(file.readline().strip()) | |
optimizing = file.readline() | |
if optimizing.lower() == 'true': | |
optimizing = True | |
else: | |
optimizing = False | |
print('exporting vox files under', fromSource) | |
print('\tto directory', toExportDir) | |
print('\toptimizing?', optimizing) | |
print() | |
# export EVERYTHING (.vox) walking the directory structure | |
for p, dirList, fileList in os.walk(fromSource): | |
pathDiff = os.path.relpath(p, start=fromSource) | |
outDir = os.path.join(toExportDir, pathDiff) | |
# REFACTOR: the loop should be moved to a function | |
for fileName in fileList: | |
# only take vox files | |
if os.path.splitext(fileName)[1] != '.vox': | |
print('ignored', fileName) | |
continue | |
print('exporting', fileName) | |
# read/import the voxel file | |
with open(os.path.join(p, fileName), mode='rb') as file: | |
try: | |
vox = importVox(file) | |
except ValueError as exc: | |
print('aborted', fileName, str(exc)) | |
continue | |
# mirror the directory structure in the export folder | |
if not os.path.exists(outDir): | |
os.makedirs(outDir) | |
print('\tcreated directory', outDir) | |
# export a non-optimized version | |
objName = os.path.splitext(fileName)[0] | |
rawQuads = vox.toQuads() | |
with open(os.path.join(outDir, objName + '.obj'), mode='w') as file: | |
vCount, qCount = exportObj(file, rawQuads) | |
print('\texported', vCount, 'vertices,', qCount, 'quads') | |
if optimizing: | |
# TODO | |
continue | |
optiFaces = optimizedGreedyMesh(rawQuads) | |
bucketHash(optiFaces, *vox.getBounds()) | |
with open(os.path.join(outDir, objName + '.greedy.obj'), | |
mode='w') as file: | |
exportObj(file, optiFaces) | |
def byPrompt(): | |
import os, os.path, sys | |
from glob import glob | |
#### set output directory to script file location | |
# ------------------------------------------------ | |
#### | |
u = os.path.abspath(sys.argv[0]).strip(os.path.basename(sys.argv[0])) | |
print(u) | |
#### drag & dropped files | |
# --------------------- | |
for i in sys.argv: | |
if i != sys.argv[0]: | |
print(i) | |
#### fully manual prompt #### | |
# ------------------- | |
# print('Enter an output path:') | |
# u = input('> ').strip() | |
while not os.path.exists(u): | |
print('That path does not exist.') | |
print('Enter an output path:') | |
u = input('> ').strip() | |
outRoot = os.path.abspath(u) | |
try: | |
#while True: | |
#### grab files from prompt (uncomment lines below if needed) | |
# ---------------------- | |
#print('Enter glob of export files (\'exit\' or blank to quit):') | |
#u = input('> ').strip() | |
#if u == 'exit' or u == '': | |
# break | |
#u = glob(u) | |
#### grab drag & dropped files | |
u = sys.argv | |
for f in u: | |
if f != sys.argv[0]: | |
print('reading VOX file', f) | |
with open(f, mode='rb') as file: | |
try: | |
vox = importVox(file) | |
except ValueError: | |
print('\tfile reading aborted') | |
continue | |
outFile = os.path.splitext(os.path.basename(f))[0] | |
outPath = os.path.join(outRoot, outFile+'.obj') | |
print('exporting VOX to OBJ at path', outPath) | |
with open(outPath, mode='w') as file: | |
exportObj(file, vox.toQuads()) | |
except KeyboardInterrupt: | |
pass | |
if __name__ == "__main__": | |
profiling = False | |
try: | |
import cProfile | |
if profiling: | |
cProfile.run('exportAll()', sort='tottime') | |
else: | |
exportAll() | |
except OSError: | |
print('No instruction file found, falling back to prompt.') | |
byPrompt() |
""" | |
This script is designed to export a mass amount of MagicaVoxel .vox files | |
to .obj. Unlike Magica's internal exporter, this exporter preserves the | |
voxel vertices for easy manipulating in a 3d modeling program like Blender. | |
Various meshing algorithms are included (or to be included). MagicaVoxel | |
uses monotone triangulation (I think). The algorithms that will (or do) | |
appear in this script will use methods to potentially reduce rendering | |
artifacts that could be introduced by triangulation of this nature. | |
I may also include some features like light map generation for easy | |
importing into Unreal Engine, etc. | |
Notes: | |
* There may be a few floating point equality comparisons. They seem to | |
work but it scares me a little. | |
* TODO: use constants instead of magic numbers (as defined in AAQuad), | |
(i.e., ..., 2 -> AAQuad.TOP, ...) | |
* A lot of assertions should probably be exceptions since they are | |
error checking user input (this sounds really bad now that I've put | |
it on paper...). So don't run in optimized mode (who does that | |
anyways?). | |
* I am considering adding FBX support. | |
""" | |
import math | |
class AAQuad: | |
""" A solid colored axis aligned quad. """ | |
normals = [ | |
(-1, 0, 0), # left = 0 | |
(1, 0, 0), # right = 1 | |
(0, 0, 1), # top = 2 | |
(0, 0, -1), # bottom = 3 | |
(0, -1, 0), # front = 4 | |
(0, 1, 0) # back = 5 | |
] | |
LEFT = 0 | |
RIGHT = 1 | |
TOP = 2 | |
BOTTOM = 3 | |
FRONT = 4 | |
BACK = 5 | |
def __init__(self, verts, uv=None, normal=None): | |
assert len(verts) == 4, "face must be a quad" | |
self.vertices = verts | |
self.uv = uv | |
self.normal = normal | |
def __str__(self): | |
s = [] | |
for i in self.vertices: | |
s.append( str(i) + '/' + str(self.uv) + '/' + str(self.normal)) | |
return 'f ' + ' '.join(s) | |
def center(self): | |
return ( | |
sum(i[0] for i in self.vertices)/4, | |
sum(i[1] for i in self.vertices)/4, | |
sum(i[2] for i in self.vertices)/4 | |
) | |
def bucketHash(faces, origin, maximum, bucket=16): | |
extents = ( | |
math.ceil((maximum[0] - origin[0])/bucket), | |
math.ceil((maximum[1] - origin[1])/bucket), | |
math.ceil((maximum[2] - origin[2])/bucket) | |
) | |
buckets = {} | |
for f in faces: | |
c = f.center() | |
# TODO | |
def optimizedGreedyMesh(faces): | |
# TODO | |
edges = adjacencyGraphEdges(faces) | |
groups = contiguousFaces(faces, edges) | |
return faces | |
def adjacencyGraphEdges(faces): | |
""" Get the list of edges representing adjacent faces. """ | |
# a list of edges, where edges are tuple(face_a, face_b) | |
edges = [] | |
# build the list of edges in the graph | |
for root in faces: | |
for face in faces: | |
if face is root: | |
continue | |
if facesAreAdjacent(root, face): | |
# the other edge will happen somewhere else in the iteration | |
# (i.e., the relation isAdjacent is symmetric) | |
edges.append((root, face)) | |
return edges | |
def contiguousFaces(faces, adjacencyGraphEdges): | |
""" Get the list of connected components from a list of graph edges. | |
The list will contain lists containing the edges within the components. | |
""" | |
groups = [] | |
visited = dict((f, False) for f in faces) | |
for face in faces: | |
# if the face hasn't been visited, it is not in any found components | |
if not visited[face]: | |
g = [] | |
_visitGraphNodes(face, adjacencyGraphEdges, visited, g) | |
# there is only a new component if face has not been visited yet | |
groups.append(g) | |
return groups | |
def _visitGraphNodes(node, edges, visited, component): | |
""" Recursive routine used in findGraphComponents """ | |
# visit every component connected to this one | |
for edge in edges: | |
# for all x in nodes, (node, x) and (x, node) should be in edges! | |
# therefore we don't have to check for "edge[1] is node" | |
if edge[0] is node and not visited[edge[1]]: | |
assert edge[1] is not node, "(node, node) should not be in edges" | |
# mark the other node as visited | |
visited[edge[1]] = True | |
component.append(edge[1]) | |
# visit all of that nodes connected nodes | |
_visitGraphNodes(edge[1], edges, visited, component) | |
def facesAreAdjacent(a, b): | |
""" Adjacent is defined as same normal, uv, and a shared edge. | |
This isn't entirely intuitive (i.e., corner faces are not adjacent) | |
but this definition fits the problem domain. | |
Only works on AAQuads. | |
""" | |
# note: None is == None, this shouldn't matter | |
if a.uv != b.uv: | |
return False | |
if a.normal != b.normal: | |
return False | |
# to be adjacent, two faces must share an edge | |
# use == and not identity in case edge split was used | |
shared = 0 | |
for vert_a in a.vertices: | |
for vert_b in b.vertices: | |
if vert_a == vert_b: | |
shared += 1 | |
# hooray we have found a shared edge (or a degenerate case...) | |
if shared == 2: | |
return True | |
return False | |
class GeoFace: | |
""" An arbitrary geometry face | |
This should only be used for arbitrary models, not ones we can | |
reasonably assume are axis aligned. | |
""" | |
def __init__(self, verts, uvs=None, normals=None): | |
self.vertices = verts | |
assert len(verts) in (3, 4), "only quads and tris are supported" | |
self.normals = normals | |
self.uvs = uvs | |
def toAAQuad(self, skipAssert=False): | |
q = AAQuad(self.vertices) | |
if self.normals is not None and len(self.normals) > 0: | |
if not skipAssert: | |
for i in self.normals: | |
assert self.normals[0] == i, \ | |
"face must be axis aligned (orthogonal normals)" | |
q.normal = self.normals[0] | |
if self.uvs is not None and len(self.uvs) > 0: | |
if not skipAssert: | |
for i in self.uvs: | |
assert self.uvs[0] == i, \ | |
"face must be axis aligned (orthogonal)" | |
q.uv = self.uvs[0] | |
return q | |
class VoxelStruct: | |
""" Describes a voxel object | |
""" | |
def __init__(self): | |
# a dict is probably the best way to go about this | |
# (as a trade off between performance and code complexity) | |
# see _index for the indexing method | |
self.voxels = {} | |
self.colorIndices = set() | |
def fromList(self, voxels): | |
self.voxels = {} | |
for voxel in voxels: | |
self.setVoxel(voxel) | |
self.colorIndices.add(voxel.colorIndex) | |
def setVoxel(self, voxel): | |
self.voxels[voxel.z*(256**2) + voxel.y * 256 + voxel.x] = voxel | |
def getVoxel(self, x, y, z): | |
return self.voxels.get(z*(256**2) + y * 256 + x, None) | |
def _index(self, x, y, z): | |
return z*(256**2) + y * 256 + x | |
def getBounds(self): | |
origin = (float("inf"), float("inf"), float("inf")) | |
maximum = (float("-inf"), float("-inf"), float("-inf")) | |
for key, voxel in self.voxels.items(): | |
origin = ( | |
min(origin[0], voxel.x), | |
min(origin[1], voxel.y), | |
min(origin[2], voxel.z) | |
) | |
maximum = ( | |
max(maximum[0], voxel.x), | |
max(maximum[1], voxel.y), | |
max(maximum[2], voxel.z) | |
) | |
return origin, maximum | |
def zeroOrigin(self): | |
""" Translate the model so that it's origin is at 0, 0, 0 """ | |
origin, maximum = self.getBounds() | |
result = {} | |
xOff, yOff, zOff = origin | |
for key, voxel in self.voxels.iteritems(): | |
result[self._index(voxel.x-xOff, voxel.y-yOff, voxel.z-zOff)] = \ | |
Voxel(voxel.x-xOff, voxel.y-yOff, voxel.z-zOff, | |
voxel.colorIndex) | |
self.voxels = result | |
return (0, 0, 0), (maximum[0] - xOff, | |
maximum[1] - yOff, | |
maximum[2] - zOff) | |
def toQuads(self): | |
""" --> a list of AAQuads """ | |
faces = [] | |
for key, voxel in self.voxels.items(): | |
self._getObjFaces(voxel, faces) | |
return faces | |
def _getObjFaces(self, voxel, outFaces): | |
if voxel.colorIndex == 0: | |
# do nothing if this is an empty voxel | |
# n.b., I do not know if this ever can happen. | |
return [] | |
sides = self._objExposed(voxel) | |
if sides[0]: | |
f = self._getLeftSide(voxel) | |
self._getObjFacesSupport(0, voxel.colorIndex, f, outFaces) | |
if sides[1]: | |
f = self._getRightSide(voxel) | |
self._getObjFacesSupport(1, voxel.colorIndex, f, outFaces) | |
if sides[2]: | |
f = self._getTopSide(voxel) | |
self._getObjFacesSupport(2, voxel.colorIndex, f, outFaces) | |
if sides[3]: | |
f = self._getBottomSide(voxel) | |
self._getObjFacesSupport(3, voxel.colorIndex, f, outFaces) | |
if sides[4]: | |
f = self._getFrontSide(voxel) | |
self._getObjFacesSupport(4, voxel.colorIndex, f, outFaces) | |
if sides[5]: | |
f = self._getBackSide(voxel) | |
self._getObjFacesSupport(5, voxel.colorIndex, f, outFaces) | |
return | |
n = AAQuad.normals[i] | |
# note: texcoords are based on MagicaVoxel's texturing scheme! | |
# meaning a color index of 0 translates to pixel[255] | |
# and color index [1:256] -> pixel[0:255] | |
u = ((voxel.colorIndex - 1)/256 + 1/512, 0.5) | |
outFaces.append( | |
# this is most definitely not "fun" | |
AAQuad(f, u, n) | |
) | |
def _getObjFacesSupport(self, side, color, faces, outFaces): | |
n = AAQuad.normals[side] | |
# note: texcoords are based on MagicaVoxel's texturing scheme! | |
# meaning a color index of 0 translates to pixel[255] | |
# and color index [1:256] -> pixel[0:255] | |
u = ((color - 1)/256 + 1/512, 0.5) | |
outFaces.append( | |
# fact: the parameters were coincidentally "f, u, n" at one point! | |
AAQuad(faces, u, n) | |
) | |
# MagicaVoxel does -.5 to +.5 for each cube, we'll do 0.0 to 1.0 ;) | |
def _getLeftSide(self, voxel): | |
return [ | |
(voxel.x, voxel.y + 1, voxel.z + 1), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x, voxel.y, voxel.z + 1) | |
] | |
def _getRightSide(self, voxel): | |
return ( | |
(voxel.x + 1, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1) | |
) | |
def _getTopSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y + 1, voxel.z + 1), | |
(voxel.x, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1) | |
) | |
def _getBottomSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z) | |
) | |
def _getFrontSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y, voxel.z + 1), | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z + 1) | |
) | |
def _getBackSide(self, voxel): | |
return ( | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z + 1) | |
) | |
def _objExposed(self, voxel): | |
""" --> a set of [0, 6) representing which voxel faces are shown | |
for the meaning of 0-5, see AAQuad.normals | |
get the sick truth about these voxels' dirty secrets... | |
""" | |
# check left 0 | |
side = self.getVoxel(voxel.x - 1, voxel.y, voxel.z) | |
s0 = side is None or side.colorIndex == 0 | |
# check right 1 | |
side = self.getVoxel(voxel.x + 1, voxel.y, voxel.z) | |
s1 = side is None or side.colorIndex == 0 | |
# check top 2 | |
side = self.getVoxel(voxel.x, voxel.y, voxel.z + 1) | |
s2 = side is None or side.colorIndex == 0 | |
# check bottom 3 | |
side = self.getVoxel(voxel.x, voxel.y, voxel.z - 1) | |
s3 = side is None or side.colorIndex == 0 | |
# check front 4 | |
side = self.getVoxel(voxel.x, voxel.y - 1, voxel.z) | |
s4 = side is None or side.colorIndex == 0 | |
# check back 5 | |
side = self.getVoxel(voxel.x, voxel.y + 1, voxel.z) | |
s5 = side is None or side.colorIndex == 0 | |
return s0, s1, s2, s3, s4, s5 | |
class Voxel: | |
def __init__(self, x, y, z, colorIndex): | |
self.x = x | |
self.y = y | |
self.z = z | |
self.colorIndex = colorIndex | |
def genNormals(self, aaQuads, overwrite=False): | |
# compute CCW normal if it doesn't exist | |
for face in aaQuads: | |
if overwrite or face.normal is None: | |
side_a = (face.vertices[1][0] - face.vertices[0][0], | |
face.vertices[1][1] - face.vertices[0][1], | |
face.vertices[1][2] - face.vertices[0][2]) | |
side_b = (face.vertices[-1][0] - face.vertices[0][0], | |
face.vertices[-1][1] - face.vertices[0][1], | |
face.vertices[-1][2] - face.vertices[0][2]) | |
# compute the cross product | |
face.normal = (side_a[1]*side_b[2] - side_a[2]*side_b[1], | |
side_a[2]*side_b[0] - side_a[0]*side_b[2], | |
side_a[0]*side_b[1] - side_a[1]*side_b[0]) | |
def importObj(stream): | |
vertices = [] | |
faces = [] | |
uvs = [] | |
normals = [] | |
for line in stream: | |
# make sure there's no new line or trailing spaces | |
l = line.strip().split(' ') | |
lineType = l[0].strip() | |
data = l[1:] | |
if lineType == 'v': | |
# vertex | |
v = tuple(map(float, data)) | |
vertices.append(v) | |
elif lineType == 'vt': | |
# uv | |
uvs.append( tuple(map(float, data)) ) | |
elif lineType == 'vn': | |
# normal | |
normals.append( tuple(map(float, data)) ) | |
elif lineType == 'f': | |
# face (assume all verts/uvs/normals have been processed) | |
faceVerts = [] | |
faceUvs = [] | |
faceNormals = [] | |
for v in data: | |
result = v.split('/') | |
print(result) | |
# recall that everything is 1 indexed... | |
faceVerts.append(vertices[int(result[0]) - 1]) | |
if len(result) == 1: | |
continue # there is only a vertex index | |
if result[1] != '': | |
# uvs may not be present, ex: 'f vert//normal ...' | |
faceUvs.append(uvs[int(result[1]) - 1]) | |
if len(result) <= 2: | |
# don't continue if only vert and uv are present | |
continue | |
faceNormals.append(normals[int(result[2]) - 1]) | |
faces.append( GeoFace(faceVerts, faceUvs, faceNormals) ) | |
else: | |
# there could be material specs, smoothing, or comments... ignore! | |
pass | |
return faces | |
def exportObj(stream, aaQuads): | |
# gather some of the needed information | |
faces = aaQuads | |
# copy the normals from AAQuad (99% of cases will use all directions) | |
normals = list(AAQuad.normals) | |
uvs = set() | |
for f in faces: | |
if f.uv is not None: | |
uvs.add(f.uv) | |
# convert this to a list because we need to get their index later | |
uvs = list(uvs) | |
# we will build a list of vertices as we go and then write everything | |
# in bulk, disadvantage that MANY verts will be duplicated in the OBJ file | |
fLines = [] | |
vertices = [] | |
indexOffset = 0 | |
for f in faces: | |
# recall that OBJ files are 1 indexed | |
n = 1 + normals.index(f.normal) if f.normal is not None else '' | |
uv = 1 + uvs.index(f.uv) if f.uv is not None else '' | |
# this used to be a one liner ;) | |
fLine = ['f'] | |
for i, vert in enumerate(f.vertices): | |
# for each vertex of this face | |
v = 1 + indexOffset + f.vertices.index(vert) | |
fLine.append(str(v) + '/' + str(uv) + '/' + str(n)) | |
vertices.extend(f.vertices) | |
indexOffset += len(f.vertices) | |
fLines.append(' '.join(fLine) + '\n') | |
# write to the file | |
stream.write('# shivshank\'s .obj optimizer\n') | |
stream.write('\n') | |
if len(normals) > 0: | |
stream.write('# normals\n') | |
for n in normals: | |
stream.write('vn ' + ' '.join(list(map(str, n))) + '\n') | |
stream.write('\n') | |
if len(uvs) > 0: | |
stream.write('# texcoords\n') | |
for i in uvs: | |
stream.write('vt ' + ' '.join(list(map(str, i))) + '\n') | |
stream.write('\n') | |
# output the vertices and faces | |
stream.write('# verts\n') | |
for v in vertices: | |
stream.write('v ' + ' '.join(list(map(str, v))) + '\n') | |
stream.write('\n') | |
stream.write('# faces\n') | |
for i in fLines: | |
stream.write(i) | |
stream.write('\n') | |
stream.write('\n') | |
return len(vertices), len(fLines) | |
def importVox(file): | |
""" --> a VoxelStruct from this .vox file stream """ | |
# in theory this could elegantly be many functions and classes | |
# but this is such a simple file format... | |
# refactor: ? should probably find a better exception type than value error | |
vox = VoxelStruct() | |
magic = file.read(4) | |
if magic != b'VOX ': | |
print('magic number is', magic) | |
if userAborts('This does not appear to be a VOX file. Abort?'): | |
raise ValueError("Invalid magic number") | |
# the file appears to use little endian consistent with RIFF | |
version = int.from_bytes(file.read(4), byteorder='little') | |
if version != 150: | |
if userAborts('Only version 150 is supported; this file: ' | |
+ str(version) + '. Abort?'): | |
raise ValueError("Invalid file version") | |
mainHeader = _readChunkHeader(file) | |
if mainHeader['id'] != b'MAIN': | |
print('chunk id:', mainId) | |
if userAborts('Did not find the main chunk. Abort?'): | |
raise ValueError("Did not find main VOX chunk. ") | |
#assert mainHeader['size'] == 0, "main chunk should have size 0" | |
# we don't need anything from the size or palette header! | |
# : we can figure out (minimum) bounds later from the voxel data | |
# : we only need UVs from voxel data; user can export palette elsewhere | |
nextHeader = _readChunkHeader(file) | |
while nextHeader['id'] != b'XYZI': | |
# skip the contents of this header and its children, read the next one | |
file.read(nextHeader['size'] + nextHeader['childrenSize']) | |
nextHeader = _readChunkHeader(file) | |
voxelHeader = nextHeader | |
assert voxelHeader['id'] == b'XYZI', 'this should be literally impossible' | |
assert voxelHeader['childrenSize'] == 0, 'why voxel chunk have children?' | |
seekPos = file.tell() | |
totalVoxels = int.from_bytes(file.read(4), byteorder='little') | |
### READ THE VOXELS ### | |
for i in range(totalVoxels): | |
# n.b., byte order should be irrelevant since these are all 1 byte | |
x = int.from_bytes(file.read(1), byteorder='little') | |
y = int.from_bytes(file.read(1), byteorder='little') | |
z = int.from_bytes(file.read(1), byteorder='little') | |
color = int.from_bytes(file.read(1), byteorder='little') | |
vox.setVoxel(Voxel(x, y, z, color)) | |
# assert that we've read the entire voxel chunk | |
assert file.tell() - seekPos == voxelHeader['size'] | |
# (there may be more chunks after this but we don't need them!) | |
#print('\tdone reading voxel data;', totalVoxels , 'voxels read ;D') | |
return vox | |
def _readChunkHeader(buffer): | |
id = buffer.read(4) | |
if id == b'': | |
raise ValueError("Unexpected EOF, expected chunk header") | |
size = int.from_bytes(buffer.read(4), byteorder='little') | |
childrenSize = int.from_bytes(buffer.read(4), byteorder='little') | |
return { | |
'id': id, 'size': size, 'childrenSize': childrenSize | |
} | |
def userAborts(msg): | |
print(msg + ' (y/n)') | |
u = input() | |
if u.startswith('n'): | |
return False | |
return True | |
def exportAll(): | |
""" Uses a file to automatically export a bunch of files! | |
See this function for details on the what the file looks like. | |
""" | |
import os, os.path | |
with open('exporter.txt', mode='r') as file: | |
# use this as a file "spec" | |
fromSource = os.path.abspath(file.readline().strip()) | |
toExportDir = os.path.abspath(file.readline().strip()) | |
optimizing = file.readline() | |
if optimizing.lower() == 'true': | |
optimizing = True | |
else: | |
optimizing = False | |
print('exporting vox files under', fromSource) | |
print('\tto directory', toExportDir) | |
print('\toptimizing?', optimizing) | |
print() | |
# export EVERYTHING (.vox) walking the directory structure | |
for p, dirList, fileList in os.walk(fromSource): | |
pathDiff = os.path.relpath(p, start=fromSource) | |
outDir = os.path.join(toExportDir, pathDiff) | |
# REFACTOR: the loop should be moved to a function | |
for fileName in fileList: | |
# only take vox files | |
if os.path.splitext(fileName)[1] != '.vox': | |
print('ignored', fileName) | |
continue | |
print('exporting', fileName) | |
# read/import the voxel file | |
with open(os.path.join(p, fileName), mode='rb') as file: | |
try: | |
vox = importVox(file) | |
except ValueError as exc: | |
print('aborted', fileName, str(exc)) | |
continue | |
# mirror the directory structure in the export folder | |
if not os.path.exists(outDir): | |
os.makedirs(outDir) | |
print('\tcreated directory', outDir) | |
# export a non-optimized version | |
objName = os.path.splitext(fileName)[0] | |
rawQuads = vox.toQuads() | |
with open(os.path.join(outDir, objName + '.obj'), mode='w') as file: | |
vCount, qCount = exportObj(file, rawQuads) | |
print('\texported', vCount, 'vertices,', qCount, 'quads') | |
if optimizing: | |
# TODO | |
continue | |
optiFaces = optimizedGreedyMesh(rawQuads) | |
bucketHash(optiFaces, *vox.getBounds()) | |
with open(os.path.join(outDir, objName + '.greedy.obj'), | |
mode='w') as file: | |
exportObj(file, optiFaces) | |
def byPrompt(): | |
import os, os.path | |
from glob import glob | |
print('Enter an output path:') | |
u = input('> ').strip() | |
while not os.path.exists(u): | |
print('That path does not exist.') | |
print('Enter an output path:') | |
u = input('> ').strip() | |
outRoot = os.path.abspath(u) | |
print('Are we optimizing? (y/n)') | |
u = input('> ').strip() | |
# this could be a one liner but I think it's easier to read this way | |
if u.startswith('y'): | |
optimizing = True | |
else: | |
optimizing = False | |
try: | |
while True: | |
print('Enter glob of export files (\'exit\' or blank to quit):') | |
u = input('> ').strip() | |
if u == 'exit' or u == '': | |
break | |
u = glob(u) | |
for f in u: | |
print('reading VOX file', f) | |
with open(f, mode='rb') as file: | |
try: | |
vox = importVox(file) | |
except ValueError: | |
print('\tfile reading aborted') | |
continue | |
outFile = os.path.splitext(os.path.basename(f))[0] | |
outPath = os.path.join(outRoot, outFile) | |
print('exporting VOX to OBJ at path', outPath) | |
with open(outPath, mode='w') as file: | |
exportObj(file, vox.toQuads()) | |
if optimizing: | |
# TODO | |
pass | |
except KeyboardInterrupt: | |
pass | |
if __name__ == "__main__": | |
profiling = False | |
try: | |
import cProfile | |
if profiling: | |
cProfile.run('exportAll()', sort='tottime') | |
else: | |
exportAll() | |
except OSError: | |
print('No instruction file found, falling back to prompt.') | |
byPrompt() |
""" | |
SEMI-AUTOMATIC drag and drop support for windows | |
1. Copy script to directory you want your files copied to. | |
2. Select the files you want to convert. | |
3. Drag & drop onto this script. | |
4. Prompt will appear -- Press "enter" to convert .vox to .obj! (or abort with "y") | |
Files will be exported to directory of this script. | |
semi-automatic mod by awesomedata | |
This script is designed to export a mass amount of MagicaVoxel .vox files | |
to .obj. Unlike Magica's internal exporter, this exporter preserves the | |
voxel vertices for easy manipulating in a 3d modeling program like Blender. | |
Various meshing algorithms are included (or to be included). MagicaVoxel | |
uses monotone triangulation (I think). The algorithms that will (or do) | |
appear in this script will use methods to potentially reduce rendering | |
artifacts that could be introduced by triangulation of this nature. | |
I may also include some features like light map generation for easy | |
importing into Unreal Engine, etc. | |
Notes: | |
* There may be a few floating point equality comparisons. They seem to | |
work but it scares me a little. | |
* TODO: use constants instead of magic numbers (as defined in AAQuad), | |
(i.e., ..., 2 -> AAQuad.TOP, ...) | |
* A lot of assertions should probably be exceptions since they are | |
error checking user input (this sounds really bad now that I've put | |
it on paper...). So don't run in optimized mode (who does that | |
anyways?). | |
* I am considering adding FBX support. | |
""" | |
import math | |
class AAQuad: | |
""" A solid colored axis aligned quad. """ | |
normals = [ | |
(-1, 0, 0), # left = 0 | |
(1, 0, 0), # right = 1 | |
(0, 0, 1), # top = 2 | |
(0, 0, -1), # bottom = 3 | |
(0, -1, 0), # front = 4 | |
(0, 1, 0) # back = 5 | |
] | |
LEFT = 0 | |
RIGHT = 1 | |
TOP = 2 | |
BOTTOM = 3 | |
FRONT = 4 | |
BACK = 5 | |
def __init__(self, verts, uv=None, normal=None): | |
assert len(verts) == 4, "face must be a quad" | |
self.vertices = verts | |
self.uv = uv | |
self.normal = normal | |
def __str__(self): | |
s = [] | |
for i in self.vertices: | |
s.append( str(i) + '/' + str(self.uv) + '/' + str(self.normal)) | |
return 'f ' + ' '.join(s) | |
def center(self): | |
return ( | |
sum(i[0] for i in self.vertices)/4, | |
sum(i[1] for i in self.vertices)/4, | |
sum(i[2] for i in self.vertices)/4 | |
) | |
def bucketHash(faces, origin, maximum, bucket=16): | |
extents = ( | |
math.ceil((maximum[0] - origin[0])/bucket), | |
math.ceil((maximum[1] - origin[1])/bucket), | |
math.ceil((maximum[2] - origin[2])/bucket) | |
) | |
buckets = {} | |
for f in faces: | |
c = f.center() | |
# TODO | |
def optimizedGreedyMesh(faces): | |
# TODO | |
edges = adjacencyGraphEdges(faces) | |
groups = contiguousFaces(faces, edges) | |
return faces | |
def adjacencyGraphEdges(faces): | |
""" Get the list of edges representing adjacent faces. """ | |
# a list of edges, where edges are tuple(face_a, face_b) | |
edges = [] | |
# build the list of edges in the graph | |
for root in faces: | |
for face in faces: | |
if face is root: | |
continue | |
if facesAreAdjacent(root, face): | |
# the other edge will happen somewhere else in the iteration | |
# (i.e., the relation isAdjacent is symmetric) | |
edges.append((root, face)) | |
return edges | |
def contiguousFaces(faces, adjacencyGraphEdges): | |
""" Get the list of connected components from a list of graph edges. | |
The list will contain lists containing the edges within the components. | |
""" | |
groups = [] | |
visited = dict((f, False) for f in faces) | |
for face in faces: | |
# if the face hasn't been visited, it is not in any found components | |
if not visited[face]: | |
g = [] | |
_visitGraphNodes(face, adjacencyGraphEdges, visited, g) | |
# there is only a new component if face has not been visited yet | |
groups.append(g) | |
return groups | |
def _visitGraphNodes(node, edges, visited, component): | |
""" Recursive routine used in findGraphComponents """ | |
# visit every component connected to this one | |
for edge in edges: | |
# for all x in nodes, (node, x) and (x, node) should be in edges! | |
# therefore we don't have to check for "edge[1] is node" | |
if edge[0] is node and not visited[edge[1]]: | |
assert edge[1] is not node, "(node, node) should not be in edges" | |
# mark the other node as visited | |
visited[edge[1]] = True | |
component.append(edge[1]) | |
# visit all of that nodes connected nodes | |
_visitGraphNodes(edge[1], edges, visited, component) | |
def facesAreAdjacent(a, b): | |
""" Adjacent is defined as same normal, uv, and a shared edge. | |
This isn't entirely intuitive (i.e., corner faces are not adjacent) | |
but this definition fits the problem domain. | |
Only works on AAQuads. | |
""" | |
# note: None is == None, this shouldn't matter | |
if a.uv != b.uv: | |
return False | |
if a.normal != b.normal: | |
return False | |
# to be adjacent, two faces must share an edge | |
# use == and not identity in case edge split was used | |
shared = 0 | |
for vert_a in a.vertices: | |
for vert_b in b.vertices: | |
if vert_a == vert_b: | |
shared += 1 | |
# hooray we have found a shared edge (or a degenerate case...) | |
if shared == 2: | |
return True | |
return False | |
class GeoFace: | |
""" An arbitrary geometry face | |
This should only be used for arbitrary models, not ones we can | |
reasonably assume are axis aligned. | |
""" | |
def __init__(self, verts, uvs=None, normals=None): | |
self.vertices = verts | |
assert len(verts) in (3, 4), "only quads and tris are supported" | |
self.normals = normals | |
self.uvs = uvs | |
def toAAQuad(self, skipAssert=False): | |
q = AAQuad(self.vertices) | |
if self.normals is not None and len(self.normals) > 0: | |
if not skipAssert: | |
for i in self.normals: | |
assert self.normals[0] == i, \ | |
"face must be axis aligned (orthogonal normals)" | |
q.normal = self.normals[0] | |
if self.uvs is not None and len(self.uvs) > 0: | |
if not skipAssert: | |
for i in self.uvs: | |
assert self.uvs[0] == i, \ | |
"face must be axis aligned (orthogonal)" | |
q.uv = self.uvs[0] | |
return q | |
class VoxelStruct: | |
""" Describes a voxel object | |
""" | |
def __init__(self): | |
# a dict is probably the best way to go about this | |
# (as a trade off between performance and code complexity) | |
# see _index for the indexing method | |
self.voxels = {} | |
self.colorIndices = set() | |
def fromList(self, voxels): | |
self.voxels = {} | |
for voxel in voxels: | |
self.setVoxel(voxel) | |
self.colorIndices.add(voxel.colorIndex) | |
def setVoxel(self, voxel): | |
self.voxels[voxel.z*(256**2) + voxel.y * 256 + voxel.x] = voxel | |
def getVoxel(self, x, y, z): | |
return self.voxels.get(z*(256**2) + y * 256 + x, None) | |
def _index(self, x, y, z): | |
return z*(256**2) + y * 256 + x | |
def getBounds(self): | |
origin = (float("inf"), float("inf"), float("inf")) | |
maximum = (float("-inf"), float("-inf"), float("-inf")) | |
for key, voxel in self.voxels.items(): | |
origin = ( | |
min(origin[0], voxel.x), | |
min(origin[1], voxel.y), | |
min(origin[2], voxel.z) | |
) | |
maximum = ( | |
max(maximum[0], voxel.x), | |
max(maximum[1], voxel.y), | |
max(maximum[2], voxel.z) | |
) | |
return origin, maximum | |
def zeroOrigin(self): | |
""" Translate the model so that it's origin is at 0, 0, 0 """ | |
origin, maximum = self.getBounds() | |
result = {} | |
xOff, yOff, zOff = origin | |
for key, voxel in self.voxels.iteritems(): | |
result[self._index(voxel.x-xOff, voxel.y-yOff, voxel.z-zOff)] = \ | |
Voxel(voxel.x-xOff, voxel.y-yOff, voxel.z-zOff, | |
voxel.colorIndex) | |
self.voxels = result | |
return (0, 0, 0), (maximum[0] - xOff, | |
maximum[1] - yOff, | |
maximum[2] - zOff) | |
def toQuads(self): | |
""" --> a list of AAQuads """ | |
faces = [] | |
for key, voxel in self.voxels.items(): | |
self._getObjFaces(voxel, faces) | |
return faces | |
def _getObjFaces(self, voxel, outFaces): | |
if voxel.colorIndex == 0: | |
# do nothing if this is an empty voxel | |
# n.b., I do not know if this ever can happen. | |
return [] | |
sides = self._objExposed(voxel) | |
if sides[0]: | |
f = self._getLeftSide(voxel) | |
self._getObjFacesSupport(0, voxel.colorIndex, f, outFaces) | |
if sides[1]: | |
f = self._getRightSide(voxel) | |
self._getObjFacesSupport(1, voxel.colorIndex, f, outFaces) | |
if sides[2]: | |
f = self._getTopSide(voxel) | |
self._getObjFacesSupport(2, voxel.colorIndex, f, outFaces) | |
if sides[3]: | |
f = self._getBottomSide(voxel) | |
self._getObjFacesSupport(3, voxel.colorIndex, f, outFaces) | |
if sides[4]: | |
f = self._getFrontSide(voxel) | |
self._getObjFacesSupport(4, voxel.colorIndex, f, outFaces) | |
if sides[5]: | |
f = self._getBackSide(voxel) | |
self._getObjFacesSupport(5, voxel.colorIndex, f, outFaces) | |
return | |
n = AAQuad.normals[i] | |
# note: texcoords are based on MagicaVoxel's texturing scheme! | |
# meaning a color index of 0 translates to pixel[255] | |
# and color index [1:256] -> pixel[0:255] | |
u = ((voxel.colorIndex - 1)/256 + 1/512, 0.5) | |
outFaces.append( | |
# this is most definitely not "fun" | |
AAQuad(f, u, n) | |
) | |
def _getObjFacesSupport(self, side, color, faces, outFaces): | |
n = AAQuad.normals[side] | |
# note: texcoords are based on MagicaVoxel's texturing scheme! | |
# meaning a color index of 0 translates to pixel[255] | |
# and color index [1:256] -> pixel[0:255] | |
u = ((color - 1)/256 + 1/512, 0.5) | |
outFaces.append( | |
# fact: the parameters were coincidentally "f, u, n" at one point! | |
AAQuad(faces, u, n) | |
) | |
# MagicaVoxel does -.5 to +.5 for each cube, we'll do 0.0 to 1.0 ;) | |
def _getLeftSide(self, voxel): | |
return [ | |
(voxel.x, voxel.y + 1, voxel.z + 1), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x, voxel.y, voxel.z + 1) | |
] | |
def _getRightSide(self, voxel): | |
return ( | |
(voxel.x + 1, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1) | |
) | |
def _getTopSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y + 1, voxel.z + 1), | |
(voxel.x, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y, voxel.z + 1), | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1) | |
) | |
def _getBottomSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z) | |
) | |
def _getFrontSide(self, voxel): | |
return ( | |
(voxel.x, voxel.y, voxel.z + 1), | |
(voxel.x, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z), | |
(voxel.x + 1, voxel.y, voxel.z + 1) | |
) | |
def _getBackSide(self, voxel): | |
return ( | |
(voxel.x + 1, voxel.y + 1, voxel.z + 1), | |
(voxel.x + 1, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z), | |
(voxel.x, voxel.y + 1, voxel.z + 1) | |
) | |
def _objExposed(self, voxel): | |
""" --> a set of [0, 6) representing which voxel faces are shown | |
for the meaning of 0-5, see AAQuad.normals | |
get the sick truth about these voxels' dirty secrets... | |
""" | |
# check left 0 | |
side = self.getVoxel(voxel.x - 1, voxel.y, voxel.z) | |
s0 = side is None or side.colorIndex == 0 | |
# check right 1 | |
side = self.getVoxel(voxel.x + 1, voxel.y, voxel.z) | |
s1 = side is None or side.colorIndex == 0 | |
# check top 2 | |
side = self.getVoxel(voxel.x, voxel.y, voxel.z + 1) | |
s2 = side is None or side.colorIndex == 0 | |
# check bottom 3 | |
side = self.getVoxel(voxel.x, voxel.y, voxel.z - 1) | |
s3 = side is None or side.colorIndex == 0 | |
# check front 4 | |
side = self.getVoxel(voxel.x, voxel.y - 1, voxel.z) | |
s4 = side is None or side.colorIndex == 0 | |
# check back 5 | |
side = self.getVoxel(voxel.x, voxel.y + 1, voxel.z) | |
s5 = side is None or side.colorIndex == 0 | |
return s0, s1, s2, s3, s4, s5 | |
class Voxel: | |
def __init__(self, x, y, z, colorIndex): | |
self.x = x | |
self.y = y | |
self.z = z | |
self.colorIndex = colorIndex | |
def genNormals(self, aaQuads, overwrite=False): | |
# compute CCW normal if it doesn't exist | |
for face in aaQuads: | |
if overwrite or face.normal is None: | |
side_a = (face.vertices[1][0] - face.vertices[0][0], | |
face.vertices[1][1] - face.vertices[0][1], | |
face.vertices[1][2] - face.vertices[0][2]) | |
side_b = (face.vertices[-1][0] - face.vertices[0][0], | |
face.vertices[-1][1] - face.vertices[0][1], | |
face.vertices[-1][2] - face.vertices[0][2]) | |
# compute the cross product | |
face.normal = (side_a[1]*side_b[2] - side_a[2]*side_b[1], | |
side_a[2]*side_b[0] - side_a[0]*side_b[2], | |
side_a[0]*side_b[1] - side_a[1]*side_b[0]) | |
def importObj(stream): | |
vertices = [] | |
faces = [] | |
uvs = [] | |
normals = [] | |
for line in stream: | |
# make sure there's no new line or trailing spaces | |
l = line.strip().split(' ') | |
lineType = l[0].strip() | |
data = l[1:] | |
if lineType == 'v': | |
# vertex | |
v = tuple(map(float, data)) | |
vertices.append(v) | |
elif lineType == 'vt': | |
# uv | |
uvs.append( tuple(map(float, data)) ) | |
elif lineType == 'vn': | |
# normal | |
normals.append( tuple(map(float, data)) ) | |
elif lineType == 'f': | |
# face (assume all verts/uvs/normals have been processed) | |
faceVerts = [] | |
faceUvs = [] | |
faceNormals = [] | |
for v in data: | |
result = v.split('/') | |
print(result) | |
# recall that everything is 1 indexed... | |
faceVerts.append(vertices[int(result[0]) - 1]) | |
if len(result) == 1: | |
continue # there is only a vertex index | |
if result[1] != '': | |
# uvs may not be present, ex: 'f vert//normal ...' | |
faceUvs.append(uvs[int(result[1]) - 1]) | |
if len(result) <= 2: | |
# don't continue if only vert and uv are present | |
continue | |
faceNormals.append(normals[int(result[2]) - 1]) | |
faces.append( GeoFace(faceVerts, faceUvs, faceNormals) ) | |
else: | |
# there could be material specs, smoothing, or comments... ignore! | |
pass | |
return faces | |
def exportObj(stream, aaQuads): | |
# gather some of the needed information | |
faces = aaQuads | |
# copy the normals from AAQuad (99% of cases will use all directions) | |
normals = list(AAQuad.normals) | |
uvs = set() | |
for f in faces: | |
if f.uv is not None: | |
uvs.add(f.uv) | |
# convert this to a list because we need to get their index later | |
uvs = list(uvs) | |
# we will build a list of vertices as we go and then write everything | |
# in bulk, disadvantage that MANY verts will be duplicated in the OBJ file | |
fLines = [] | |
vertices = [] | |
indexOffset = 0 | |
for f in faces: | |
# recall that OBJ files are 1 indexed | |
n = 1 + normals.index(f.normal) if f.normal is not None else '' | |
uv = 1 + uvs.index(f.uv) if f.uv is not None else '' | |
# this used to be a one liner ;) | |
fLine = ['f'] | |
for i, vert in enumerate(f.vertices): | |
# for each vertex of this face | |
v = 1 + indexOffset + f.vertices.index(vert) | |
fLine.append(str(v) + '/' + str(uv) + '/' + str(n)) | |
vertices.extend(f.vertices) | |
indexOffset += len(f.vertices) | |
fLines.append(' '.join(fLine) + '\n') | |
# write to the file | |
stream.write('# shivshank\'s .obj optimizer\n') | |
stream.write('\n') | |
if len(normals) > 0: | |
stream.write('# normals\n') | |
for n in normals: | |
stream.write('vn ' + ' '.join(list(map(str, n))) + '\n') | |
stream.write('\n') | |
if len(uvs) > 0: | |
stream.write('# texcoords\n') | |
for i in uvs: | |
stream.write('vt ' + ' '.join(list(map(str, i))) + '\n') | |
stream.write('\n') | |
# output the vertices and faces | |
stream.write('# verts\n') | |
for v in vertices: | |
stream.write('v ' + ' '.join(list(map(str, v))) + '\n') | |
stream.write('\n') | |
stream.write('# faces\n') | |
for i in fLines: | |
stream.write(i) | |
stream.write('\n') | |
stream.write('\n') | |
return len(vertices), len(fLines) | |
def importVox(file): | |
""" --> a VoxelStruct from this .vox file stream """ | |
# in theory this could elegantly be many functions and classes | |
# but this is such a simple file format... | |
# refactor: ? should probably find a better exception type than value error | |
vox = VoxelStruct() | |
magic = file.read(4) | |
if magic != b'VOX ': | |
print('magic number is', magic) | |
if userAborts('This does not appear to be a VOX file. Abort?'): | |
raise ValueError("Invalid magic number") | |
# the file appears to use little endian consistent with RIFF | |
version = int.from_bytes(file.read(4), byteorder='little') | |
if version != 150: | |
if userAborts('Only version 150 is supported; this file: ' | |
+ str(version) + '. Abort?'): | |
raise ValueError("Invalid file version") | |
mainHeader = _readChunkHeader(file) | |
if mainHeader['id'] != b'MAIN': | |
print('chunk id:', mainId) | |
if userAborts('Did not find the main chunk. Abort?'): | |
raise ValueError("Did not find main VOX chunk. ") | |
#assert mainHeader['size'] == 0, "main chunk should have size 0" | |
# we don't need anything from the size or palette header! | |
# : we can figure out (minimum) bounds later from the voxel data | |
# : we only need UVs from voxel data; user can export palette elsewhere | |
nextHeader = _readChunkHeader(file) | |
while nextHeader['id'] != b'XYZI': | |
# skip the contents of this header and its children, read the next one | |
file.read(nextHeader['size'] + nextHeader['childrenSize']) | |
nextHeader = _readChunkHeader(file) | |
voxelHeader = nextHeader | |
assert voxelHeader['id'] == b'XYZI', 'this should be literally impossible' | |
assert voxelHeader['childrenSize'] == 0, 'why voxel chunk have children?' | |
seekPos = file.tell() | |
totalVoxels = int.from_bytes(file.read(4), byteorder='little') | |
### READ THE VOXELS ### | |
for i in range(totalVoxels): | |
# n.b., byte order should be irrelevant since these are all 1 byte | |
x = int.from_bytes(file.read(1), byteorder='little') | |
y = int.from_bytes(file.read(1), byteorder='little') | |
z = int.from_bytes(file.read(1), byteorder='little') | |
color = int.from_bytes(file.read(1), byteorder='little') | |
vox.setVoxel(Voxel(x, y, z, color)) | |
# assert that we've read the entire voxel chunk | |
assert file.tell() - seekPos == voxelHeader['size'] | |
# (there may be more chunks after this but we don't need them!) | |
#print('\tdone reading voxel data;', totalVoxels , 'voxels read ;D') | |
return vox | |
def _readChunkHeader(buffer): | |
id = buffer.read(4) | |
if id == b'': | |
raise ValueError("Unexpected EOF, expected chunk header") | |
size = int.from_bytes(buffer.read(4), byteorder='little') | |
childrenSize = int.from_bytes(buffer.read(4), byteorder='little') | |
return { | |
'id': id, 'size': size, 'childrenSize': childrenSize | |
} | |
def userAborts(msg): | |
print(msg + ' (y/n)') | |
u = input() | |
if u.startswith('n'): | |
return False | |
return True | |
def exportAll(): | |
""" Uses a file to automatically export a bunch of files! | |
See this function for details on the what the file looks like. | |
""" | |
import os, os.path | |
with open('exporter.txt', mode='r') as file: | |
# use this as a file "spec" | |
fromSource = os.path.abspath(file.readline().strip()) | |
toExportDir = os.path.abspath(file.readline().strip()) | |
optimizing = file.readline() | |
if optimizing.lower() == 'true': | |
optimizing = True | |
else: | |
optimizing = False | |
print('exporting vox files under', fromSource) | |
print('\tto directory', toExportDir) | |
print('\toptimizing?', optimizing) | |
print() | |
# export EVERYTHING (.vox) walking the directory structure | |
for p, dirList, fileList in os.walk(fromSource): | |
pathDiff = os.path.relpath(p, start=fromSource) | |
outDir = os.path.join(toExportDir, pathDiff) | |
# REFACTOR: the loop should be moved to a function | |
for fileName in fileList: | |
# only take vox files | |
if os.path.splitext(fileName)[1] != '.vox': | |
print('ignored', fileName) | |
continue | |
print('exporting', fileName) | |
# read/import the voxel file | |
with open(os.path.join(p, fileName), mode='rb') as file: | |
try: | |
vox = importVox(file) | |
except ValueError as exc: | |
print('aborted', fileName, str(exc)) | |
continue | |
# mirror the directory structure in the export folder | |
if not os.path.exists(outDir): | |
os.makedirs(outDir) | |
print('\tcreated directory', outDir) | |
# export a non-optimized version | |
objName = os.path.splitext(fileName)[0] | |
rawQuads = vox.toQuads() | |
with open(os.path.join(outDir, objName + '.obj'), mode='w') as file: | |
vCount, qCount = exportObj(file, rawQuads) | |
print('\texported', vCount, 'vertices,', qCount, 'quads') | |
if optimizing: | |
# TODO | |
continue | |
optiFaces = optimizedGreedyMesh(rawQuads) | |
bucketHash(optiFaces, *vox.getBounds()) | |
with open(os.path.join(outDir, objName + '.greedy.obj'), | |
mode='w') as file: | |
exportObj(file, optiFaces) | |
def byPrompt(): | |
import os, os.path, sys | |
from glob import glob | |
#### set output directory to first .vox file location | |
# ------------------------------------------------ | |
#### | |
u = os.path.abspath(sys.argv[0]).strip(os.path.basename(sys.argv[0])) | |
print(u) | |
#### drag & dropped files | |
# --------------------- | |
for i in sys.argv: | |
if i != sys.argv[0]: | |
print(i) | |
#### fully manual prompt #### | |
# ------------------- | |
# print('Enter an output path:') | |
# u = input('> ').strip() | |
while not os.path.exists(u): | |
print('That path does not exist.') | |
print('Enter an output path:') | |
u = input('> ').strip() | |
outRoot = os.path.abspath(u) | |
print('\nStop file conversion to .obj? (type "y" or press "enter" to convert.)') | |
u = input('> ').strip() | |
if u.startswith('y'): | |
exit; | |
else: | |
try: | |
#while True: | |
#### grab files from prompt (uncomment lines below if needed) | |
# ---------------------- | |
#print('Enter glob of export files (\'exit\' or blank to quit):') | |
#u = input('> ').strip() | |
#if u == 'exit' or u == '': | |
# break | |
#u = glob(u) | |
#### grab drag & dropped files | |
u = sys.argv | |
for f in u: | |
if f != sys.argv[0]: | |
print('reading VOX file', f) | |
with open(f, mode='rb') as file: | |
try: | |
vox = importVox(file) | |
except ValueError: | |
print('\tfile reading aborted') | |
continue | |
outFile = os.path.splitext(os.path.basename(f))[0] | |
outPath = os.path.join(outRoot, outFile+'.obj') | |
print('exporting VOX to OBJ at path', outPath) | |
with open(outPath, mode='w') as file: | |
exportObj(file, vox.toQuads()) | |
except KeyboardInterrupt: | |
pass | |
if __name__ == "__main__": | |
profiling = False | |
try: | |
import cProfile | |
if profiling: | |
cProfile.run('exportAll()', sort='tottime') | |
else: | |
exportAll() | |
except OSError: | |
print('No instruction file found, falling back to prompt.') | |
byPrompt() |
No worries -- I totally get it. When you get into mesh creation, it can very quickly get overwhelming with all the ins-and-outs. However, if what you are wanting is to gather information on each side, what you can do (rather than inspecting the quads themselves), is make your own list of sides and note what voxel index (and therefore what color) these sides belong to.
You can, at least for now, hijack the function and comment-out the stuff you don't need.
And as far as SHENZHEN IO goes -- I've never seen that game before, but it seems cool, despite being a bit more complex than what I use. I use something called "Pencil" for things like mapping out data. It is really good mind-mapping software. Once you learn how to use it, it becomes second-nature for stuff like this. Using Shift to drag a copy of a node and dragging the points on the sides of a rectangle to hook them up with one another is really all it takes. Using this in combination with the Snip tool could really help with code-architecture stuff.
Good stuff indeed. :)
Here is an update of where I am at looking at this code! I will type more when I can but this is my current "living" document I have in GIMP all layered out so I can move things around as they make more sense.
I have so much typed on this image.
I have so much typed on this image.
You know that stereotypical conspiracy theorist's apartment with red string strung all across the room "connecting" different news articles and photos and whatnot? -- This image is totally that apartment, but digital. 👍
Kidding aside -- Let's start with the * and ** for now.
Think of these as "wildcard characters", rather than "multiply" operators. These "wildcard" chars get 'substituted' for variable 'arguments' instead of strings (i.e. if Windows were Python and you searched folders for ' C:* ', this would return the contents of each and every file on your C: drive and load them into memory, rather than just the location of the files themselves).
The "for x,y in z" statement is essentially a foreach loop in C# -- except a little more 'robust' because it allows you to specify a x (key) and y (value) pair to look for inside of "z" (the data you're looking through). This is different in Python than simply a numerical index of "z" (since it allows it to be used as a dict in Python, whereas in C#, that key/value lookup would have been a more complicated set of statements).
To clarify the * and ** itself though -- the ** simply says there are two parts to "z" (x and y) -- or "key" and "value" pair -- rather than simply a single numerical index (the single * wildcard) that needs to be evaluated for each individual element you're looking for. You pass in the key/value into the function from the function that is running the function that needs the key/value data.
The single wildcard acts as a foreach does in C# (i.e. "foreach 'voxel' in (all) 'voxels', bring in the data being passed in by * through the function / scope that is calling the current function / scope who needs that data, and now apply that data to the particular 'voxel' I am specifying here").
Remember, Python is not my favorite language (due to its convoluted way of abstracting things), so I am not that familiar with how Python handles the memory behind the scenes (as Python is an interpreted language, and the order of operations is not always clear), but the heavy use of the * wildcard characters implies to me that a string reference is replaced behind the scenes with the variable key/value or index you're referring to when it is in use, and only after that, the program interprets it by replacing the * with the actual value. This, however, is a bit of an educated guess. Python, at its core, is still string-based (and therefore what the user means to say has to be interpreted, rather than compiled -- which takes longer at runtime as a trade-off to avoid the lengthy time it would take to compile and test otherwise). Therefore, it is a bit of a Wild-West scenario in that anything can pretty much go as far as syntax -- if the creators of Python deem it useful. I tend to avoid languages like this though, as they can change often (and get rather involved if you get too deep into them!)
So that leads me to my second bit:
I am having just a little trouble following your diagram (so I imagine you might be having a little bit of trouble keeping things in order too). I suggest you use a mind-mapping software like evolus's Pencil (it's free) in order to string together high-level script, class, and function definitions, and contain them in boxes (alongside what arguments link to where). If you're going to code something this complex -- you really do need this kind of software -- badly.
Third:
With what it sounds like you're wanting to do in Unity, it seems like you could just iterate over the voxels that have Quad faces, check which 'side' they're on, and output this data to some script in Unity. This would allow each 'side' or 'surface' to be counted and 'damaged' as the voxel burns away, as the voxel (again) is nothing more than a single numerical index -- and the HP of the individual surfaces are nothing more than the 6 sides of that index that need to be kept track of.
So if you want the entire voxel to disappear (i.e. when all 'sides' are HP=0), you will have to have a voxel display algorithm to handle remeshing the voxels when that voxel is removed. There are free solutions on github for meshing voxels btw (marching cubes algorithm comes to mind) -- but if you want to keep digging at the internals though -- just remember that the OBJ file is only handling quads, and none of this stuff is dynamic at all. You likely won't get much out of studying the quad export process, aside from how (and whether) it optimizes the sides of the quads or not. This script's sole purpose is mainly to export 1x1x1 sides for any voxels that aren't pressing against other voxels. The data you'd need is in the VoxelStruct instancing bit.
I have more to add, but I've got very little time today. However, I wanted to at least mention to keep in mind that we're talking about a script and its class instances (and not particularly the voxels themselves -- as an 'instance' is just the container/structure where the data is stored to be retrieved or modified later). The 'voxels' in general are essentially considered "empty" until we actually 'fill' them with the proper quads to export later. Though, again -- this is not dynamic -- and it seems like you want dynamic (i.e. something to use in Unity, versus something to simply export voxels / faces -- though to get a more simple version of the 'vox' file to work with in Unity is never a bad idea, but even doing this, you only really need the index of the particular voxel to determine the properties of the sides -- and not much else). I think the only bit a .vox has that is 'extra' is the 'normal' which just basically points to which face/direction a voxel has faces. I don't think this is calculated in this script -- MagicaVoxel probably does this for us for each voxel in the grid.
Hopefully that helps for now!
I have so much typed on this image.
Sorry it has been so long since I got back with you. Life tends to catch up sometimes.
How have things progressed? -- Any luck?
I would respond to the images above, but I'm not entirely sure how much has changed since we last spoke. Therefore I figured I'd ask first (just in case I missed something in my hasty reply previously that you later figured out).
I messaged you on unity :)
i have python 3.9, but i cant drop .vox file on .py file
(MagicaVoxel ver 0.99.6.3 win64)
when i drop the file nothing happens its just not working, can you help please?
P.S. sorry for the localized explorer
Have you tried the one ending in _AUTO as well? (Take a peek at whether the directory your .vox file is in is writeable, as I think you have to specify the directory with the original script, as it appears you're using.)
If that vox_to_obj_AUTO.py script doesn't work, I've also seen this happen when someone has another python version / interpreter / handler installed on their machine as well. Check your "Run as Administrator" and UAC settings too, as it could be a windows issue.
And as silly as it sounds, ensure the .vox file is actually a Magica Voxel .vox file (and not a .vox generated from some other application).
That's about all I can offer at the moment. Hopefully some of that works.
Is it possible to get the MTL file on conversion. The imported OBJ don't have the MTL file and it loses the colors
I haven't given up. I just needed a bit of a breather. Wrote some music (the thing im actually good at) to kind of restructure and regroup. I'll be tackling this soon. I plan on going over the entire script from top to bottom and drawing 1 massive diagram for how I think it is all working.
Ive been getting yt video suggestions for a game called SHENZHEN IO and I've been watching those. I think my brain almost wants to take that visual idea and have one giant image that explains all the pieces that I can then look over. Anyways, random post I know. I've had to read and reread your posts many times to try to come to grips with the abstract nature of this. But I just wanted to say im not done with this by a longshot.