""" | |
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() |
This comment has been minimized.
This comment has been minimized.
I'm not sure what version of magica voxel you're using, but the .vox file should be dropped on the python script file (in Windows) itself and should create your files automatically for you. This functionality only works on version 3.5 of python or later (if I remember correctly), so make sure your python is updated to that version (or it won't work). Also, I'm not sure what permissions your directory has, but the .obj files should be written out to the same directory. If your script is located in Program Files directory in Windows, it won't work (by default) since administrator privileges are required, meaning you should try putting the python file someplace else. If you're dragging and dropping the .vox file on the "xxx_PROMPT.py" script, then you'll have to go through the motions by answering all the prompts, then the script will output the file inside the folder you told it to. The automatic version just makes this happen without needing to go through all that. You can get more info on the "instruction file" in the main branch of this github repository. Since I don't use it myself, and since it has been a while since I modified this script, I don't remember all the details about it except for what it's function was intended to be. Sorry I couldn't be much use at the moment -- Let me know if this helps at all! |
This comment has been minimized.
This comment has been minimized.
I figured it out... Python files on my computer are set to automatically open in PyCharm. I changed it back to IDLE and it worked perfectly. Lol Thank you for your incites! If you hadn't mentioned my python version, I don't think I would have thought of that! |
This comment has been minimized.
This comment has been minimized.
Not a problem! -- I'm just glad I could help somehow! ^___^ |
This comment has been minimized.
This comment has been minimized.
No instruction file found, falling back to prompt.
|
This comment has been minimized.
This comment has been minimized.
The "output path" is the path to where you want the .obj file to be exported to -- i.e. C:\Windows\Users\yourname\Documents Next -- Did you drag/drop the .vox file onto one of the .py scripts? 1.) Old Python version prior to version 3.5 (I think?) Been a while since I've played with this script, but you don't need an "output directory" when you have an "instruction file" (which is described a little in the main branch of this repository). FINAL NOTE: You'd want to use "vox_to_obj_AUTO.py" (the first script above) most of the time (rather than "vox_to_obj_PROMPT.py", which gives you a prompt for the output directory unless an "instruction file" exists in the same folder as the script) |
This comment has been minimized.
This comment has been minimized.
This script is awesome thank you so much! I've been really stumped and this script is really helping. Im not a programmer but I got this script to output just fine. |
This comment has been minimized.
This comment has been minimized.
I just adapted it to make it more user-friendly -- thanks though! :)
The "importVox(file)" method does exactly what you want -- it returns a VoxelStruct class / set of data, which is what you would want to output to a file (such as JSON), which you'd have to do yourself in Python atm. Finally, you'd need to import that JSON into whatever app you want that data for. I know you said you're not a programmer, but since I don't have time to make it myself, you might want to give it a go. On the plus side - Python is very straightforward, and instead of curly brackets, etc to define "groups" of code -- it uses TAB spacing. That said, if you end up making a version of this script that outputs JSON (which, IMO, is the best raw-text data exchange format available), please let me know. It would be very nice to have a JSON version of this script in this repository for anyone interested in using the raw voxel data structure (instead of just the .obj) to use later in another application. |
This comment has been minimized.
This comment has been minimized.
Phew. Ok, Ive always wanted to start learning python. I will give this a go. Ive needed to learn about JSON as well so this should be a good challenge. I've never coded anything outside of an arduino or a basic webpage in my life. If I do get this coded when I put the code up here I will do something called a fork off your code? Im really new and basic at this sorry lol. You've lit a fire in me and now I really want to do this lol |
This comment has been minimized.
This comment has been minimized.
You should be set then -- Python is extremely useful/versatile, but it is easier to learn than programming webpages and Arduino stuff!!
That's great to hear! -- It sounds like you'd benefit from this experience greatly! If you want to make a "https://pastebin.com/" link when you're done (i.e. create a link on that site and paste it here), I can add your new python script(s) to this (current) repository for you if you'd like. That way everything will stay in one place, and you can always find it with the other .obj scripts in the future. This means you wouldn't have to mess with github (unless you want to -- and in that case, a fork would let you add onto/develop your script(s) further in the future if you wish). If future development isn't what you're after, pastbin and simply posting the link to your script here to be added to this repository when you're done would still allow you to fork it later, assuming you wanted to make changes. However, I offered to keep your changes here to keep all the new scripts in one place (by keeping them in the same area). I can do this in the future for you too, assuming you decide to make future changes (if you wish). I am subscribed here, and active, so I don't mind the github bit. But yeah -- I'm glad you're considering learning this stuff! -- It's extremely useful. After all, Voxels are really cool, and Magica Voxel is a pretty nice tool to create them with. Having a straightforward JSON interchange format from .vox files is definitely handy for working with basic voxel data, and currently there are only paid options available for such a simple script -- so you'd be doing many others a favor too. :) Good luck -- and let me know if you run into any issues! -- I'll be glad to help! :) |
This comment has been minimized.
This comment has been minimized.
Do you have an email address or way to contact you? Ive been staring at this code for hours and I have some questions but I dont know who I should ask them too. I drew a picture to try to make sense of what I was looking at. I really do want to understand what is going on here. |
This comment has been minimized.
This comment has been minimized.
You can contact me here -- it goes straight to my email. No worries. I was looking at your questions and to answer your most basic ones -- The part you've boxed-out in red is building a string. Since you're defining a class here (and not really using the class yet), this is all "setup" work for your data. You're telling the computer where you're going to put the data -- and how to do that -- but not necessarily when it needs to be done. At least not until you call one of the methods defined by the "def" keyword. I know that was a bit long-winded, but maybe that makes a little more sense? Either way -- You might be getting ahead of yourself. I suggest starting with the VoxelStruct class, as it is more straightforward, and ultimately where you want to be. Please let me know if this helps give you a bit more direction. Definitely watch some python videos if you can though, as it's not too hard -- just a little different because you have to "build" your data objects (for more complex behaviors/referencing). In your case though -- remember, all you're doing is grabbing the output from an already "imported" VoxelStruct, and writing those values somewhere else in the "exportObj" routine (which you might call "exportVox" and change any references from "exportObj" to "exportVox" so that when you drag and drop your .vox file on there, a JSON file is output instead of a new .obj file. :) |
This comment has been minimized.
This comment has been minimized.
I haven't started writing code yet but I have been studying python a lot and looking at this code. Still getting my brains around this. An update to where my thought process is: https://i.imgur.com/aFt0GQ5.png edit: Still tearing through the code. I found some pieces further down the list that I think I need to focus on. I will update when I can. This is kinda fun. |
This comment has been minimized.
This comment has been minimized.
I noticed you're focusing on faces. Voxels themselves aren't terribly complex -- you mainly just need a clear forward axis (z), a flat master list of voxels (i.e. count all voxels across each axis, store them to said master list while skipping any you've already found on a previous axis in order to avoid repeating them). Hopefully that gives you some "behind the scenes" insight into this script's thought process. :) Good luck! |
This comment has been minimized.
This comment has been minimized.
I see what you are saying! I dont really want to change any specific quad/face on a voxel I would like each voxel to be monotone in color, but have all voxels retain the palette. But I do want all the internal voxels faces to be present within the render. Te reason I was looking at the faces was to try to figure out how the script determines which faces get rendered or not. I see what you are saying about a master 'position' list for voxel placement. I think I saw somewhere in the code that 1 voxel becomes 0.1m³ square in an obj file. I think if I can find that master position index like you said and then figure out how the code determines whether to render a particular voxel or not I think then I can start figuring out how to edit the code to fit the desired purpose. Since it is helping me to visualize this, heres another pic in cause you want a "behind the scenes" into a complete novice's first time digging into code lol. |
This comment has been minimized.
When it says "drag and drop," what does that mean exactly? I'm a little bit of a noob, but I have tried dragging and dropping the physical .VOX file itself onto the python file, I've tried dragging the file into the IDLE window, and I've tried dragging and dropping the file into the window the program actually runs in and none have worked.