Commit 247c550f authored by jonthb's avatar jonthb

First commit, yay

parents
zero_length_zenith/__pycache__/
*.pyc
This diff is collapsed.
# The ZLZ importer !
The ZLZ importer can import Uru Ages (`.age` and `.prp`) into Blender, allowing you to study how Cyan built their Ages.
## Requirements
ZLZ requires Blender (preferably version 2.79), with the [Korman](https://github.com/H-uru/korman) plugin installed with it.
## Installation
Download the ZLZ package.
There are two ways to install it. The easiest is through the "Install Add-on from File" button in Blender's addons window. Just select the ZIP file you downloaded, and activate it. Alternatively, if for some reason you want to install it manually, you can extract the `zero_length_zenith` folder to `<Blender's location>/<version number>/scripts/addons`.
In both cases, don't forget to activate it by clicking its checkbox (and preferably click the "Save User Settings" button after that).
## Why this name in particular ?
It's a reference to Mystcraft, which I was playing when I started developing this plugin. And no, I'm not apologizing for it.
\ No newline at end of file
# -*- coding:utf-8 -*-
"""
This file is part of ZLZ.
ZLZ is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ZLZ is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ZLZ. If not, see <https://www.gnu.org/licenses/>
"""
"""
Handles registration of the plugin.
#"""
bl_info = {
'name': 'ZeroLengthZenith',
'author': 'Sirius',
'version': (0, 0, 1),
'blender': (2, 7, 7),
'location': 'File > Import > ZeroLengthZenith Uru Age Importer',
'description': 'ZLZ',
'category': 'Import-Export'
}
import bpy
import zero_length_zenith.importer
import zero_length_zenith.teximporter
def register():
importer.register()
teximporter.register()
def unregister():
importer.unregister()
teximporter.unregister()
if __name__ == "__main__":
register()
This diff is collapsed.
This diff is collapsed.
# -*- coding:utf-8 -*-
"""
This file is part of ZLZ.
ZLZ is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ZLZ is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ZLZ. If not, see <https://www.gnu.org/licenses/>
"""
"""
Handles importing texture images, or extract their alpha channel.
#"""
import bpy, os
import PyHSPlasma as pl
from zero_length_zenith.utils import *
# TODO: Drizzle has a neat fix to force the game to load higher res textures when possible. Might be useful to add it here.
class ImageImporter:
def __init__(self, parent):
self.parent = parent
def getCubemap(self, plTexKey):
plTex = plTexKey.object
texes = []
for tex, faceName in ((plTex.backFace, "back"), (plTex.bottomFace, "bottom"), (plTex.frontFace, "front"), (plTex.leftFace, "left"), (plTex.rightFace, "right"), (plTex.topFace, "top")):
texName = stripIllegalChars(plTexKey.name) + faceName
if texName in bpy.data.images:
# this is one of the few cases where we can rely on Blender's library and the object's name
image = bpy.data.images[texName]
else:
# Let's simply use the same path as PyPRP: the TMP_Textures folder in the import folder...
cachedFilePath = self.parent.importLocation + os.sep + "TMP_Textures" + os.sep + texName + ".png"
if self.parent.config["reuseTextures"] and os.path.exists(cachedFilePath):
# image was previously extracted, simply reload it - should be faster.
bpy.ops.image.open(filepath=cachedFilePath)
# grmblr we're in 2019 and Blender STILL has a limit of 63 chars per texture name... Seriously.
imgBlenderName = texName + ".png"
imgBlenderName = imgBlenderName[:63] # okay, this is not accurate, but might be enough to prevent errors
image = bpy.data.images[imgBlenderName]
else:
# image does not yet exist, create it
pixels = tex.DecompressImage(0)
image = bpy.data.images.new(texName, tex.width, tex.height, alpha=bool(plTex.flags & pl.plMipmap.kAlphaChannelFlag))
image.file_format = 'PNG'
# pixels is in direct3d order, which means the image is upside down.
# move pixels around to put it in the correct order...
lines = [
pixels[i*tex.width*4:(i+1)*tex.width*4]
for i in range(tex.height-1, -1, -1) # flip the lines
]
newPixels = [
pixel / 255
for line in lines # python is dope
for pixel in line
]
image.pixels = newPixels
# Blender is keen on deleting images even if they are dirty, so make sure to save it somewhere safe
image.filepath_raw = cachedFilePath
image.save()
texes.append(image)
return texes
def getImage(self, plTexKey):
plTex = plTexKey.object
texName = stripIllegalChars(plTexKey.name)
if texName in bpy.data.images:
# this is one of the few cases where we can rely on Blender's library and the object's name
image = bpy.data.images[texName]
else:
# Let's simply use the same path as PyPRP: the TMP_Textures folder in the import folder...
cachedFilePath = self.parent.importLocation + os.sep + "TMP_Textures" + os.sep + texName + ".png"
if self.parent.config["reuseTextures"] and os.path.exists(cachedFilePath):
# image was previously extracted, simply reload it - should be faster.
bpy.ops.image.open(filepath=cachedFilePath)
# grmblr we're in 2019 and Blender STILL has a limit of 63 chars per texture name... Seriously.
imgBlenderName = texName + ".png"
imgBlenderName = imgBlenderName[:63] # okay, this is not accurate, but might be enough to prevent errors
image = bpy.data.images[imgBlenderName]
else:
# image does not yet exist, create it
pixels = plTex.DecompressImage(0)
image = bpy.data.images.new(texName, plTex.width, plTex.height, alpha=bool(plTex.flags & pl.plMipmap.kAlphaChannelFlag))
image.file_format = 'PNG'
# pixels is in direct3d order, which means the image is upside down.
# move pixels around to put it in the correct order... This is a bit slow, but not horribly so.
lines = [
pixels[i*plTex.width*4:(i+1)*plTex.width*4]
for i in range(plTex.height-1, -1, -1) # flip the lines
]
newPixels = [
pixel / 255
for line in lines # python is dope
for pixel in line
]
image.pixels = newPixels
# Blender is keen on deleting images even if they are dirty, so make sure to save it somewhere safe
# A workaround is to save the image to a file, pack it, then delete it. But saving the image directly to the disk
# is generally the behavior we want anyway...
image.filepath_raw = cachedFilePath
image.save()
return image
def getImageAlpha(self, plTexKey, invert):
# TODO: merge this code with the one from getImage()
plTex = plTexKey.object
texName = stripIllegalChars(plTexKey.name) + "_alpha"
if texName in bpy.data.images:
# this is one of the few cases where we can rely on Blender's library and the object's name
image = bpy.data.images[texName]
else:
# Let's simply use the same path as PyPRP: the TMP_Textures folder in the import folder...
cachedFilePath = self.parent.importLocation + os.sep + "TMP_Textures" + os.sep + texName + ".png"
if self.parent.config["reuseTextures"] and os.path.exists(cachedFilePath):
# image was previously extracted, simply reload it - should be faster.
bpy.ops.image.open(filepath=cachedFilePath)
# grmblr we're in 2019 and Blender STILL has a limit of 63 chars per texture name... Seriously.
imgBlenderName = texName + ".png"
imgBlenderName = imgBlenderName[:63] # okay, this is not accurate, but might be enough to prevent errors
image = bpy.data.images[imgBlenderName]
else:
# image does not yet exist, create it
pixels = plTex.DecompressImage(0)
image = bpy.data.images.new(texName, plTex.width, plTex.height, alpha=False)
image.file_format = 'PNG'
# pixels is in direct3d order, which means the image is upside down.
# move pixels around to put it in the correct order...
lines = [
pixels[i*plTex.width*4:(i+1)*plTex.width*4]
for i in range(plTex.height-1, -1, -1) # flip the lines
]
newPixels = [
pixel / 255
for line in lines # python is dope
for pixel in line
]
for i in range(len(newPixels)//4):
alpha = newPixels[i*4+3]
if invert:
alpha = 1-alpha
newPixels[i*4] = alpha
newPixels[i*4+1] = alpha
newPixels[i*4+2] = alpha
newPixels[i*4+3] = 1
image.pixels = newPixels
# Blender is keen on deleting images even if they are dirty, so make sure to save it somewhere safe
# Let's simply use the same path as PyPRP: the TMP_Textures folder in the import folder...
image.filepath_raw = cachedFilePath
image.save()
return image
# -*- coding:utf-8 -*-
"""
This file is part of ZLZ.
ZLZ is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ZLZ is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ZLZ. If not, see <https://www.gnu.org/licenses/>
"""
"""
Operator used for importing Ages, duh.
#"""
import bpy, os, time, sys
from bpy.props import *
from zero_length_zenith.log import Log
import zero_length_zenith.utils
from zero_length_zenith.sceneImporter import SceneImporter
import PyHSPlasma as pl
import ctypes
import sys
#-#-#-# Registration #-#-#-#
def menu_func_import(self, context):
self.layout.operator(Importer.bl_idname, text="ZLZ Uru importer")
def register():
bpy.utils.register_class(Importer)
bpy.types.INFO_MT_file_import.append(menu_func_import)
def unregister():
bpy.utils.unregister_class(Importer)
bpy.types.INFO_MT_file_import.remove(menu_func_import)
#-#-#-# Import Class #-#-#-#
class Importer(bpy.types.Operator):
"""Uru importer"""
bl_idname = "import.zlzimport"
bl_label = "Import .age or .prp"
filepath = StringProperty(subtype='FILE_PATH')
reuseTextures = BoolProperty(name="Reuse textures", default=True,
description="This will skip extracting textures which are already in the TMP_Textures folder and load those instead")
def __init__(self):
self.sceneImporter = None
def execute(self, context):
"""Executes import, called after selecting the file in Blender"""
# first, force the console visible on Windoz (simplified version of code stolen from Korman)
if sys.platform == "win32":
hwnd = ctypes.windll.kernel32.GetConsoleWindow()
if not bool(ctypes.windll.user32.IsWindowVisible(hwnd)):
bpy.ops.wm.console_toggle()
ctypes.windll.user32.ShowWindow(hwnd, 1)
ctypes.windll.user32.BringWindowToTop(hwnd)
start = time.clock()
filePath = self.filepath
config = {
'reuseTextures': self.reuseTextures,
'attachAnimationsToObjects': True, # if set to false, all anims are attached to an empty object instead
}
if not filePath.lower().endswith(".age") and not filePath.lower().endswith(".prp"):
raise RuntimeError("Importer only supports .age and .prp file, aborting.")
log=Log(sys.stdout, filePath+".log", "w")
sys.stdout=log
if filePath.lower().endswith(".age"):
returnvalue = self.importAge(filePath, config)
else: # if filePath.lower().endswith(".prp"):
returnvalue = self.importPrp(filePath, config)
sys.stdout = sys.__stdout__
log.close()
print("Done in %.2f seconds." % (time.clock()-start))
return returnvalue
def importAge(self, filePath, config):
print("\n\n--------------------------------------\n---> Importing %s" % filePath)
rmgr = pl.plResManager()
age = rmgr.ReadAge(filePath, True)
print("Age name is %s" % age.name)
importLocation = os.path.dirname(filePath)
self.sceneImporter = SceneImporter(importLocation, config)
texPath = importLocation + os.sep + "TMP_Textures"
if not os.path.exists(texPath):
os.mkdir(texPath)
# now that things are loaded, iterate through the pages to import stuff...
for location in rmgr.getLocations():
if location.prefix != age.seqPrefix:
# we didn't load any other Age, how can this even happen ? whatever, just ignore it
continue
# this page comes from the Age we loaded (no shit sherlock)
self.sceneImporter.importScene(location, rmgr)
print("\n\nDone !")
return {"FINISHED"}
def importPrp(self, filePath, config):
print("\n\n--------------------------------------\n---> Importing %s" % filePath)
# now this is going to be a bit tricky...
# to not break cross-prp references, we need to load the full Age, but import only one PRP.
# Which means we need to find the .age path. If it does not exist, then try to load the single PRP
prpFileName = os.path.basename(filePath)
district = "_District_"
districtIndex = prpFileName.find(district)
districtLen = len(district)
if districtIndex != -1:
# simple case where the file contains the district string... this is a safe way to guess the Age name
ageFileName = os.path.dirname(filePath) + os.sep + prpFileName[:districtIndex] + ".age"
pageName = prpFileName[districtIndex + districtLen : -len(".prp")]
else:
# ugh, we have to guess the agename based on the underscores only... hopefully the Age name itself doesn't contain any underscore...
underscoreIndex = prpFileName.find('_')
ageFileName = os.path.dirname(filePath) + os.sep + prpFileName[:underscoreIndex] + ".age"
pageName = prpFileName[underscoreIndex + 1 : -len(".prp")]
if os.path.exists(ageFileName):
print("Import as PRP: .age file present, using it to ensure no cross reference breaks...")
rmgr = pl.plResManager()
age = rmgr.ReadAge(ageFileName, True)
print("Age name is %s" % age.name)
pageLoc = None
for i in range(age.getNumPages()):
pageInfoTuple = age.getPage(i)
if pageInfoTuple[0] == pageName:
pageLoc = pageInfoTuple[1]
if pageLoc == None:
raise RuntimeError("Couldn't find PRP ID. Make sure it is registered in the .age file...")
importLocation = os.path.dirname(filePath)
self.sceneImporter = SceneImporter(importLocation, config)
texPath = importLocation + os.sep + "TMP_Textures"
if not os.path.exists(texPath):
os.mkdir(texPath)
# now that things are loaded, iterate through the pages to import stuff...
for location in rmgr.getLocations():
if location.prefix != age.seqPrefix:
# we didn't load any other Age, how can this even happen ? whatever, just ignore it
continue
if location.page != pageLoc:
# not the page we want... Skip it.
continue
# this page comes from the Age we loaded (no shit sherlock)
self.sceneImporter.importScene(location, rmgr)
else:
print(ageFileName)
print("WARNING: couldn't find an .age file for this PRP. This will probably result in broken references and missing textures...")
rmgr = pl.plResManager()
page = rmgr.ReadPage(filePath)
print("Age name is %s, page name is %s" % (page.age, page.page))
importLocation = os.path.dirname(filePath)
self.sceneImporter = SceneImporter(importLocation, config)
texPath = importLocation + os.sep + "TMP_Textures"
if not os.path.exists(texPath):
os.mkdir(texPath)
# now that things are loaded, iterate through the pages to import stuff...
for location in rmgr.getLocations():
if location.prefix != page.location.prefix:
# we didn't load any other Age, how can this even happen ? whatever, just ignore it
continue
# this page comes from the Age we loaded (no shit sherlock)
self.sceneImporter.importScene(location, rmgr)
print("\n\nDone !")
return {"FINISHED"}
def invoke(self, context, event):
"""Displays filepicker interface, after selecting import menu"""
print("\nZLZ Uru Importer: Be Invoked !")
WindowManager = context.window_manager
WindowManager.fileselect_add(self)
return {'RUNNING_MODAL'}
# -*- coding:utf-8 -*-
"""
This file is part of ZLZ.
ZLZ is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ZLZ is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ZLZ. If not, see <https://www.gnu.org/licenses/>
"""
"""
Handles importing light sources.
#"""
import bpy
from mathutils import *
import PyHSPlasma as pl
from zero_length_zenith.utils import *
# might be worth checking the light import code from UPlasma. It's slightly more accurate than this one.
class LightImporter:
def __init__(self, parent):
self.parent = parent
def importLight(self, lightKey, objModifiers):
plLight = lightKey.object
lightType = None
if lightKey.type == pl.plFactory.kOmniLightInfo:
blLight = bpy.data.lamps.new(lightKey.name, "POINT")
lightType = "omni"
elif lightKey.type == pl.plFactory.kDirectionalLightInfo:
blLight = bpy.data.lamps.new(lightKey.name, "SUN")
lightType = "dirlight"
elif lightKey.type == pl.plFactory.kSpotLightInfo:
blLight = bpy.data.lamps.new(lightKey.name, "SPOT")
lightType = "spot"
ambient = [plLight.ambient.red, plLight.ambient.green, plLight.ambient.blue, plLight.ambient.alpha]
diffuse = [plLight.diffuse.red, plLight.diffuse.green, plLight.diffuse.blue, plLight.diffuse.alpha]
ambientIntensity = (ambient[0] + ambient[1] + ambient[2]) / 3 * ambient[3]
diffuseIntensity = (diffuse[0] + diffuse[1] + diffuse[2]) / 3 * diffuse[3]
finalColor = diffuse
if ambientIntensity > diffuseIntensity:
finalColor = ambient
# this color may be over the Blender color range (HDR col for some reason), so remap it to the normal range,
# and tweak the lamp's energy accordingly
maxValue = max(finalColor[0], max(finalColor[1], finalColor[2]))
if finalColor[3]:
# divide color by energy. Dunno why you do that, but that's what Korman does, and yields good results :shrug:
finalColor[0] /= finalColor[3]
finalColor[1] /= finalColor[3]
finalColor[2] /= finalColor[3]
else:
# this DOES happen... but we can't put a light's intensity to infinity ?...
# Let's just ignore this light, and hope it turns out ok
pass
blLight.color = Color((finalColor[0], finalColor[1], finalColor[2]))
blLight.energy = finalColor[3]
hasShadows = False
for interface in objModifiers:
if interface and interface.object:
if interface.type in (pl.plFactory.kPointShadowMaster, pl.plFactory.kDirectShadowMaster):
hasShadows = True
blLight.shadow_method = ("NOSHADOW", "RAY_SHADOW")[hasShadows]
blLight.shadow_ray_samples = 8 # default setting to get good render results
blLight.shadow_soft_size = 2 # same
if lightKey.type != pl.plFactory.kDirectionalLightInfo:
# those values are completely inaccurate. While it's possible to export lights from Blender
# and get a roughly ok result (like Korman does), it's too complicated to import Plasma lights
# without spending hours tweaking the result and remap values (and I don't feel like it).
if plLight.attenCutoff:
blLight.distance = plLight.attenCutoff
blLight.use_sphere = True
blLight.falloff_type = 'INVERSE_SQUARE'
else:
# a lot of lights actually have 0 falloff, but still have limited reach, so ignore it
pass
if lightKey.type == pl.plFactory.kSpotLightInfo:
# three new properties: falloff, spotInner, spotOuter. Falloff is unused as it's mostly a useless hack
blLight.spot_size = plLight.spotOuter
blLight.spot_blend = plLight.spotInner / plLight.spotOuter
if plLight.projection:
# TODO add projection
# blLight.texture_slots
pass
return (blLight, lightType)
# -*- coding:utf-8 -*-
"""
This file is part of ZLZ.
ZLZ is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ZLZ is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ZLZ. If not, see <https://www.gnu.org/licenses/>
"""
"""
Used for logging to both the console and a log file.
#"""
class Log:
def __init__(self, handle, filename, mode="w"):
self.file=open(filename, mode)
self.handle=handle
def write(self, x):
self.handle.write(x)
self.file.write(x)
def flush():
self.handle.flush()
self.file.flush()
def close(self):
self.file.close()
\ No newline at end of file
This diff is collapsed.
# -*- coding:utf-8 -*-
"""
This file is part of ZLZ.
ZLZ is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ZLZ is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with ZLZ. If not, see <https://www.gnu.org/licenses/>
"""
"""
Handles importing physics: regions and colliders.
Imported colliders will be displayed as wireframe in the viewport, and hidden from rendering.
#"""
import bpy, bmesh
from mathutils import *
import PyHSPlasma as pl
from zero_length_zenith.utils import *
import zero_length_zenith.sphere
class PhysImporter:
# none of the enum are available from the Python binding...
# bound type
kBoxBounds = 1
kSphereBounds = 2
kHullBounds = 3
kProxyBounds = 4
kExplicitBounds = 5
kCylinderBounds = 6
# group
kGroupStatic = 0
kGroupAvatar = 1
kGroupDynamic = 2
kGroupDetector = 3
kGroupLOSOnly = 4
# line of sight
kLOSDBNone = 0
kLOSDBUIBlockers = 0x1
kLOSDBUIItems = 0x2
kLOSDBCameraBlockers = 0x4
kLOSDBCustom = 0x8
kLOSDBLocalAvatar = 0x10
kLOSDBShootableItems = 0x20
kLOSDBAvatarWalkable = 0x40
kLOSDBSwimRegion = 0x80
kLOSDBMax = 0x100
kLOSDBForce16 = 0xFFFF
def __init__(self, parent):
self.parent = parent
def importPhysical(self, simKey, version):
physData = None
isCollider = True
if simKey and simKey.object:
sim = simKey.object
if sim.physical and sim.physical.object:
physKey = sim.physical
phys = physKey.object
isCollider = phys.memberGroup not in (PhysImporter.kGroupDetector, PhysImporter.kGroupLOSOnly)
# pos/rot properties:
# available to offset/rotate the collision vertices.
# It seems Plasma uses this as a hack to place physical objects which have mass != 0 (meaning a coordinate interface), but leaves those unused
# for objects with mass == 0 (objects without coordint, which means vertices are expressed in world-space).
# NOTE: in MOUL, it seems physical objects are always expressed in local space, so just ignore these properties altogether.
if phys.mass != 0 or version == pl.pvMoul:
# object has coordinates interface, so pos/rot is useless to us since vertices are already positioned correctly
quatRot = Quaternion()
quatRot.identity()
vectPos = Vector()
else:
# object has no coordint, so collision is expressed from world origin.
# pos/rot should still be null/identity and unused, but we'll take possible values into account either way
quatRot = Quaternion((phys.rot.W, phys.rot.X, phys.rot.Y, phys.rot.Z))
vectPos = Vector((phys.pos.X, phys.pos.Y, phys.pos.Z))
if phys.boundsType in (
PhysImporter.kHullBounds,
PhysImporter.kProxyBounds,
PhysImporter.kExplicitBounds) \
or len(phys.verts):
bm = bmesh.new()
# this is thankfully much easier than importing actual drawable meshes...
smallestX = 1e9
smallestY = 1e9
smallestZ = 1e9
biggestX = -1e9
biggestY = -1e9
biggestZ = -1e9
def addVert(vert):
rotatedVert = Vector(vert)
rotatedVert.rotate(quatRot)
scaledVert = rotatedVert + vectPos
bm.verts.new((scaledVert.x, scaledVert</