Source code for pyunity.files

## Copyright (c) 2020-2022 The PyUnity Team
## This file is licensed under the MIT License.
## See https://docs.pyunity.x10.bz/en/latest/license.html

"""
Module to load files and scripts.
Also manages project structure.

"""

__all__ = ["Behaviour", "Texture2D", "Prefab", "Asset",
           "File", "Project", "Skybox", "Scripts",
           "ProjectSavingContext"]

from .errors import PyUnityException, ProjectParseException
from .core import Component, GameObject, SavesProjectID, Transform
from .values import ABCMeta, abstractmethod, Vector3, Quaternion
from . import Logger
from types import ModuleType
from functools import wraps
from pathlib import Path
from PIL import Image
from uuid import uuid4
import OpenGL.GL as gl
import textwrap
import ctypes
import copy
import sys
import os

[docs]def convert(type, list): """ Converts a Python array to a C type from :mod:`ctypes`. Parameters ---------- type : _ctypes.PyCSimpleType Type to cast to. list : list List to cast Returns ------- object A C array """ return (type * len(list))(*list)
[docs]class Behaviour(Component): """ Base class for behaviours that can be scripted. """
[docs] def Awake(self): """ Called every time a scene is loaded up, regardless whether the Behaviour is enabled or not. Cannot be an ``async`` function. """ pass
[docs] async def Start(self): """ Called every time a scene is loaded up. Only called when the Behaviour is enabled. Can be either a normal function or an ``async`` function. """ pass
[docs] async def Update(self, dt): """ Called every frame. Can be either a normal function or an ``async`` function. Parameters ---------- dt : float Time since last frame, sent by the scene that the Behaviour is in. """ pass
[docs] async def FixedUpdate(self, dt): """ Called every frame, in each physics step. Can be either a normal function or an ``async`` function. Parameters ---------- dt : float Length of this physics step """ pass
[docs] async def LateUpdate(self, dt): """ Called every frame, after physics processing. Can be either a normal function or an ``async`` function. Parameters ---------- dt : float Time since last frame, sent by the scene that the Behaviour is in. """ pass
[docs] async def OnPreRender(self): """ Called before rendering happens. Can be either a normal function or an ``async`` function. """ pass
[docs] async def OnPostRender(self): """ Called after rendering happens. Can be either a normal function or an ``async`` function. """ pass
[docs] def OnDestroy(self): """ Called at the end of each Scene. Cannot be an ``async`` function. """ pass
[docs]class Scripts: """Utility class for loading scripts in a folder.""" template = textwrap.dedent(""" from pyunity import * class {}(Behaviour): async def Start(self): pass async def Update(self, dt): pass """)[1:] var = {}
[docs] @staticmethod def CheckScript(text): """ Check if ``text`` is a valid script for PyUnity. Parameters ---------- text : list List of lines Returns ------- bool If script is valid or not. Notes ----- This function checks each line to see if it matches at least one of these criteria: 1. The line is an ``import`` statement 2. The line is just whitespace or blank 3. The line is just a comment preceded by whitespace or nothing 4. The line is a class definition 5. The line has an indentation at the beginning These checks are essential to ensure no malicious code is run to break the PyUnity engine. """ for line in text: if line.startswith("import") or \ (line.startswith("from") and " import " in line): continue elif line.isspace() or line == "": continue elif "#" in line: before = line.split("#")[0] if before.isspace() or before == "": continue elif line.startswith("class "): continue elif line.startswith(" ") or line.startswith("\t"): continue return False return True
[docs] @staticmethod def GenerateModule(): if "PyUnityScripts" in sys.modules: if hasattr(sys.modules["PyUnityScripts"], "__pyunity__"): return sys.modules["PyUnityScripts"] Logger.LogLine( Logger.WARN, "PyUnityScripts is already a package") module = ModuleType("PyUnityScripts", None) module.__pyunity__ = True module.__all__ = [] module._lookup = {} sys.modules["PyUnityScripts"] = module return module
[docs] @staticmethod def LoadScript(path, force=False): """ Loads a PyUnity script by path. Parameters ---------- path : Pathlike A path to a PyUnity script force : bool Continue on error Returns ------- type The compiled PyUnity script Notes ----- This function will add a module to ``sys.modules`` that is called ``PyUnityScripts``, and can be imported like any other module. The module will also have a variable called ``__pyunity__`` which shows that it is from PyUnity and not a real module. If an existing module named ``PyUnityScripts`` is present and does not have the ``__pyunity__`` variable set, then a warning will be issued and it will be replaced. """ pathobj = Path(path).absolute() if not pathobj.is_file(): raise PyUnityException( f"The specified file does not exist: {str(path)!r}") Scripts.GenerateModule() import PyUnityScripts with open(path) as f: text = f.read().rstrip().splitlines() name = pathobj.name[:-3] if Scripts.CheckScript(text): c = compile("\n".join(text), name + ".py", "exec") try: exec(c, Scripts.var) except Exception as e: if not force: raise Logger.LogException(e) if name not in Scripts.var: raise PyUnityException( f"Cannot find class {name!r} in {str(pathobj)!r}") setattr(PyUnityScripts, name, Scripts.var[name]) PyUnityScripts.__all__.append(name) PyUnityScripts._lookup[str(path)] = Scripts.var[name] return Scripts.var[name] else: Logger.LogLine(Logger.WARN, f"{str(pathobj)!r} is not a valid PyUnity script") return None
[docs] @staticmethod def Reset(): Scripts.var = {} if "PyUnityScripts" in sys.modules: sys.modules.pop("PyUnityScripts")
[docs]class Asset(SavesProjectID, metaclass=ABCMeta):
[docs] @abstractmethod def GetAssetFile(self, gameObject): pass
[docs] @abstractmethod def SaveAsset(self, ctx): pass
[docs]class Texture2D(Asset): """ Class to represent a texture. """ def __init__(self, pathOrImg): if isinstance(pathOrImg, (str, Path)): self.path = str(pathOrImg) self.img = Image.open(self.path).convert("RGBA") self.imgData = self.img.tobytes() else: self.path = None self.img = pathOrImg self.imgData = self.img.tobytes() self.loaded = False self.texture = None self.mipmaps = False
[docs] def load(self): """ Loads the texture and sets up an OpenGL texture name. """ width, height = self.img.size if self.texture is None: self.texture = gl.glGenTextures(1) gl.glBindTexture(gl.GL_TEXTURE_2D, self.texture) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR if self.mipmaps else gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, width, height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, self.imgData) if self.mipmaps: gl.glGenerateMipmap(gl.GL_TEXTURE_2D) gl.glEnable(gl.GL_TEXTURE_2D) self.loaded = True
[docs] def setImg(self, im): self.loaded = False self.img = im self.path = None self.imgData = self.img.tobytes()
[docs] def use(self): """ Binds the texture for usage. The texture is reloaded if it hasn't already been. """ if not self.loaded: self.load() gl.glActiveTexture(gl.GL_TEXTURE0) gl.glBindTexture(gl.GL_TEXTURE_2D, self.texture)
[docs] def GetAssetFile(self, gameObject): return Path("Textures") / (gameObject.name + ".png")
[docs] def SaveAsset(self, ctx): path = ctx.project.path / ctx.filename path.parent.mkdir(parents=True, exist_ok=True) self.img.save(path)
[docs] @classmethod def FromOpenGL(cls, texture): obj = cls.__new__(cls) obj.loaded = True obj.texture = texture return obj
[docs]class Skybox: """Skybox model consisting of 6 images""" names = ["right.jpg", "left.jpg", "top.jpg", "bottom.jpg", "front.jpg", "back.jpg"] points = [ -1, 1, -1, -1, -1, -1, 1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, 1, -1, -1, -1, -1, 1, -1, -1, 1, -1, -1, 1, 1, -1, -1, 1, 1, -1, -1, 1, -1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, 1, -1, -1, -1, -1, 1, -1, 1, 1, 1, 1, 1, 1, 1, 1, 1, -1, 1, -1, -1, 1, -1, 1, -1, 1, 1, -1, 1, 1, 1, 1, 1, 1, -1, 1, 1, -1, 1, -1, -1, -1, -1, -1, -1, 1, 1, -1, -1, 1, -1, -1, -1, -1, 1, 1, -1, 1 ] def __init__(self, path): self.path = path self.compiled = False
[docs] def compile(self): self.texture = gl.glGenTextures(1) gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, self.texture) for i, name in enumerate(Skybox.names): imgPath = Path(self.path) / name img = Image.open(imgPath).convert("RGBA") imgData = img.tobytes() width, height = img.size gl.glTexImage2D(gl.GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, gl.GL_RGBA, width, height, 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, imgData) gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE) gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE) gl.glTexParameteri(gl.GL_TEXTURE_CUBE_MAP, gl.GL_TEXTURE_WRAP_R, gl.GL_CLAMP_TO_EDGE) self.vbo = gl.glGenBuffers(1) gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo) gl.glBufferData(gl.GL_ARRAY_BUFFER, len(Skybox.points) * gl.sizeof(ctypes.c_float), convert(ctypes.c_float, Skybox.points), gl.GL_STATIC_DRAW) self.vao = gl.glGenVertexArrays(1) gl.glBindVertexArray(self.vao) gl.glEnableVertexAttribArray(0) gl.glVertexAttribPointer( 0, 3, gl.GL_FLOAT, gl.GL_FALSE, 3 * gl.sizeof(ctypes.c_float), None) Logger.LogLine(Logger.INFO, "Loaded skybox") self.compiled = True
[docs] def use(self): if os.environ["PYUNITY_INTERACTIVE"] == "1": if not self.compiled: self.compile() gl.glBindTexture(gl.GL_TEXTURE_CUBE_MAP, self.texture)
[docs]class Prefab(Asset): """Prefab model""" def __init__(self, root, prune=True): if prune: self.gameObjects = [] self.assets = [] components = [] mapping = {} for transform in root.transform.GetDescendants(): gameObject = transform.gameObject copy = GameObject(gameObject.name) copy.tag = gameObject.tag copy.enabled = gameObject.enabled mapping[gameObject] = copy self.gameObjects.append(copy) parentTransform = gameObject.transform.parent if parentTransform is None: newParent = None else: newParent = mapping[parentTransform.gameObject].transform copy.transform.ReparentTo(newParent) for component in gameObject.components: if isinstance(component, Transform): mapping[component] = copy.transform for k in component._shown: v = getattr(component, k) setattr(mapping[component], k, v) else: new = copy.AddComponent(type(component)) mapping[component] = new components.append(new) # 2nd pass setting attributes for transform in root.transform.GetDescendants(): for component in transform.gameObject.components: if isinstance(component, Transform): continue for k in component._saved: v = getattr(component, k) if isinstance(v, (GameObject, Component)): if v not in mapping: continue v = mapping[v] elif isinstance(v, Asset): self.assets.append(v) setattr(mapping[component], k, v) self.gameObject = self.gameObjects[0] else: self.gameObjects = [] self.assets = [] for transform in root.transform.GetDescendants(): if transform.gameObject.scene is not None: raise PyUnityException( "Cannot create prefab with GameObjects that are part of a scene") self.gameObjects.append(transform.gameObject) for component in transform.gameObject.components: if isinstance(component, Transform): continue for k in component._saved: v = getattr(component, k) if isinstance(v, Asset): self.assets.append(v) self.gameObject = root
[docs] def Contains(self, obj): if not isinstance(obj, (GameObject, Component)): raise PyUnityException( f"Cannot check if {type(obj).__name__} is part of a Prefab") if isinstance(obj, GameObject): return obj in self.gameObjects else: return obj.gameObject in self.gameObjects
[docs] def Instantiate(self, scene=None, parent=None, position=Vector3.zero(), rotation=Quaternion.identity(), scale=Vector3.one(), worldSpace=False): """ Instantiate this prefab. Parameters ---------- scene : Scene, optional The scene to instantiate in. If None, the current scene is selected. parent : GameObject, optional The parent to instantiate the Prefab under. If None, the prefab will be instantiated at the root of the scene. position : Vector3, optional Position of the newly created GameObject, by default Vector3.zero() rotation : Quaternion, optional Rotation of the newly created GameObject, by default Quaternion.identity() scale : Vector3, optional Scale of the newly created GameObject, by default Vector3.one() worldSpace : bool, optional Whether the above 3 properties are world space or local space, by default False Returns ------- GameObject The newly created GameObject Raises ------ PyUnityException If ``scene`` is None but no scene is running """ if scene is None: from .scenes import SceneManager scene = SceneManager.CurrentScene() if scene is None: raise PyUnityException("No scene running") root = copy.deepcopy(self.gameObject) for transform in root.transform.GetDescendants(): scene.Add(transform.gameObject) if parent is not None: if not isinstance(parent, (GameObject, Transform)): raise PyUnityException( "Provided parent is not a GameObject or a Transform") if isinstance(parent, GameObject): parent = parent.transform root.transform.ReparentTo(parent) if worldSpace: root.transform.position = position root.transform.rotation = rotation root.transform.scale = scale else: root.transform.localPosition = position root.transform.localRotation = rotation root.transform.localScale = scale return root
[docs] def GetAssetFile(self, gameObject): return Path("Prefabs") / (gameObject.name + ".prefab")
[docs] def SaveAsset(self, ctx): for asset in self.assets: ctx.project.ImportAsset(asset, ctx.gameObject) path = ctx.project.path / ctx.filename ctx.savers[Prefab](self, path, ctx.project)
[docs]class ProjectSavingContext: def __init__(self, asset, gameObject, project, filename=""): if not isinstance(asset, Asset): raise ProjectParseException( f"{type(asset).__name__} does not subclass Asset") ## Not needed since scenes do not belong to GameObjects # if not isinstance(gameObject, GameObject): # raise ProjectParseException( # f"{gameObject!r} is not a GameObject") if not isinstance(project, Project): raise ProjectParseException( f"{project!r} is not a GameObject") self.asset = asset self.gameObject = gameObject self.project = project self.filename = filename from . import Loader self.savers = Loader.savers
[docs]class File: def __init__(self, path, uuid): self.path = os.path.normpath(path) self.uuid = uuid
[docs]def checkScene(func): @wraps(func) def inner(*args, **kwargs): from . import SceneManager if SceneManager.CurrentScene() is not None: raise PyUnityException("Cannot modify project while scene is running") return func(*args, **kwargs) # TODO: disable this check according to a condition? return inner
[docs]class Project: def __init__(self, name="Project"): self.path = Path(name) if not self.path.is_absolute(): self.path = self.path.resolve() self.name = self.path.name self._ids = {} self._idMap = {} self.fileIDs = {} self.filePaths = {} self.firstScene = 0 os.makedirs(self.name, exist_ok=True) self.Write() @property def assets(self): assets = [] for uuid in self.fileIDs: if uuid in self._ids: assets.append(self._ids[uuid]) return assets
[docs] @checkScene def Write(self): with open(Path(self.name) / (self.name + ".pyunity"), "w+") as f: f.write(f"Project\n name: {self.name}\n firstScene: {self.firstScene}\nFiles") for id_ in self.fileIDs: normalized = self.fileIDs[id_].path.replace(os.path.sep, "/") f.write(f"\n {id_}: {normalized}")
[docs] @checkScene def ImportFile(self, file, uuid=None, write=True): if uuid is not None: file = File(file, uuid) fullPath = self.path / file.path if not fullPath.is_file(): raise PyUnityException(f"The specified file does not exist: {fullPath}") self.fileIDs[file.uuid] = file self.filePaths[file.path] = file if write: self.Write()
[docs] @checkScene def ImportAsset(self, asset, gameObject=None, filename=None): if asset not in self._ids: exists = False uuid = str(uuid4()) self._ids[asset] = uuid self._idMap[uuid] = asset if filename is None: filename = asset.GetAssetFile(gameObject) else: exists = True uuid = self._ids[asset] filename = self.fileIDs[uuid].path context = ProjectSavingContext( asset=asset, gameObject=gameObject, project=self, filename=filename) asset.SaveAsset(context) if not exists: file = File(filename, self._ids[asset]) self.ImportFile(file, write=False)
[docs] @checkScene def SetAsset(self, file, obj): if file not in self.filePaths: raise PyUnityException(f"File is not part of project: {file!r}") uuid = self.filePaths[file].uuid self._idMap[uuid] = obj self._ids[obj] = uuid
[docs] @checkScene def GetUuid(self, obj): if obj is None: return None if obj in self._ids: return self._ids[obj] uuid = str(uuid4()) self._ids[obj] = uuid self._idMap[uuid] = obj return self._ids[obj]
[docs] @staticmethod def FromFolder(folder): folder = Path(folder).resolve() if not folder.is_dir(): raise PyUnityException(f"The specified folder does not exist: {folder}") name = folder.name filename = folder / (name + ".pyunity") if not filename.is_file(): raise PyUnityException(f"The specified folder is not a PyUnity project: {folder}") with open(filename) as f: contents = f.read().rstrip().splitlines() if contents.pop(0) != "Project": raise ProjectParseException(f"Expected \"Project\" as first section") if "Files" not in contents: raise ProjectParseException(f"Expected \"Files\" as second section") if contents.count("Files") > 1: raise ProjectParseException(f"Expected \"Files\" only once, found {contents.count('files')}") contents1 = contents[:contents.index("Files")] contents2 = contents[contents.index("Files") + 1:] projectData = {x[0]: x[1] for x in map(lambda x: x[4:].split(": "), contents1)} fileData = {x[0]: x[1] for x in map(lambda x: x[4:].split(": "), contents2)} if "name" not in projectData: raise ProjectParseException(f"Expected \"name\" value in Project section") project = Project.__new__(Project) project.name = projectData["name"] project.firstScene = int(projectData["firstScene"]) project.path = folder project._ids = {} project._idMap = {} project.fileIDs = {} project.filePaths = {} for uuid, path in fileData.items(): file = File(path, uuid) project.fileIDs[uuid] = file project.filePaths[path] = file return project