diff --git a/.gitignore b/.gitignore index 7be26c4..51c8bcd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,13 @@ -/backup/ -/EarthMesh/backup/ -/TextureAtlas/backup/ +!Image/ + /render/ /Cache/*.json -/Cache/*.jpg /Cache/*.glb -/map/*.png -/map/*.jpg + +cache +backup +__pycache__ + +*.pyc +*.png +*.jpg diff --git a/Cache/dummy.txt b/Cache/dummy.txt deleted file mode 100644 index e69de29..0000000 diff --git a/EarthMesh.json b/EarthMesh.json new file mode 100644 index 0000000..4d3319c --- /dev/null +++ b/EarthMesh.json @@ -0,0 +1,14 @@ +{ + "env": [ + { + "GOOGLE_MAPS_API_KEY": "YOUR_API_KEY" + }, + { + "EARTHMESH_PATH": "D:/Houdini/EarthMeshHoudini", + }, + { + "HOUDINI_PYTHONWARNINGS": "ignore" + } + ], + "path": "$EARTHMESH_PATH" +} \ No newline at end of file diff --git a/README.md b/README.md index c1175f8..84ca697 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ If you don't do that, most of the things you will see in the viewport won't be t ![Material Warning](https://github.com/xjorma/EarthMeshHoudini/blob/main/Image/Material%20Limit.png) +# Installation +- Download the repo. +- Place the **EarthMeshHoudini** folder somewhere on your local drive. It's important that you do not install **EarthMeshHoudini** directly into your $HOME/houdiniXX.X directory or into any other default Houdini installation directory, or else it may not properly be loaded when you start Houdini. +- Configure the **Google API Key** in the **EarthMesh.json** file and change the **EARTHMESH_PATH** to the path where you placed the **EarthMeshHoudini** folder. +- Copy the **EarthMesh.json** file to your $HOME/houdiniXX.X/packages folder. + ## How to use the HDA ### EarthMesh diff --git a/EarthMesh_Test.hip b/examples/EarthMesh_Test.hip similarity index 92% rename from EarthMesh_Test.hip rename to examples/EarthMesh_Test.hip index b6a02a1..efa5696 100644 Binary files a/EarthMesh_Test.hip and b/examples/EarthMesh_Test.hip differ diff --git a/icon/Atlas.png b/icon/Atlas.png deleted file mode 100644 index 0d75ad2..0000000 Binary files a/icon/Atlas.png and /dev/null differ diff --git a/icon/earth.png b/icon/earth.png deleted file mode 100644 index 025d560..0000000 Binary files a/icon/earth.png and /dev/null differ diff --git a/map/dummy.txt b/map/dummy.txt deleted file mode 100644 index e69de29..0000000 diff --git a/EarthMesh/EarthMesh.1.0.hda b/otls/EarthMesh.1.0.hda similarity index 100% rename from EarthMesh/EarthMesh.1.0.hda rename to otls/EarthMesh.1.0.hda diff --git a/otls/EarthMesh.2.0.hda b/otls/EarthMesh.2.0.hda new file mode 100644 index 0000000..8ab0cef Binary files /dev/null and b/otls/EarthMesh.2.0.hda differ diff --git a/TextureAtlas/TextureAtlas.1.0.hda b/otls/TextureAtlas.1.0.hda similarity index 100% rename from TextureAtlas/TextureAtlas.1.0.hda rename to otls/TextureAtlas.1.0.hda diff --git a/scripts/python/main.py b/scripts/python/main.py new file mode 100644 index 0000000..d7f26ad --- /dev/null +++ b/scripts/python/main.py @@ -0,0 +1,174 @@ +from typing import Any +import logging +import os +import sys +import hou + +from utils import hash_filename, buildBoundingBoxMatrix, sdfBox, file_from_url, json_from_byte, bytes_from_url, find_key +from utils import CACHE_PATH, GOOGLE_API_KEY + +logger = logging.getLogger(__name__) + +# log to the console +handler = logging.StreamHandler(sys.stdout) +formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +handler.setFormatter(formatter) +# logger.addHandler(handler) +# logger.setLevel(logging.INFO) + +# Enum status +class Status: + SUCCESS = 0 + ERROR = 1 + INTERRUPTED = 2 + MAX_MESHES_REACHED = 3 + DOWNLOAD_ERROR = 4 + DOWNLOADING = 5 + + +class EarthMeshDownloader: + def __init__(self, max_meshes, cached, center, min_dist, max_dist, min_error, max_error): + self.tile_url = "https://tile.googleapis.com" + self.root_cmd = "/v1/3dtiles/root.json" + + assert GOOGLE_API_KEY, "GOOGLE_API_KEY environment variable is not set" + + # read Root + b = bytes_from_url(self.tile_url + self.root_cmd + "?key=" + GOOGLE_API_KEY, False) + res = json_from_byte(b) + # find uri + self.uri = find_key(res["root"], "uri") + # extract context + self.context = self.uri.split("?")[-1] + + self.max_meshes = max_meshes + self.cached = cached + self.center = center + + self.min_dist = min_dist + self.max_dist = max_dist + + assert min_dist < max_dist, "min_dist must be less than max_dist" + + self.min_error = min_error + self.max_error = max_error + + assert min_error < max_error, "min_error must be less than max_error" + assert min_error >= 0, "min_error must be greater than or equal to 0" + + self.interrupted = False + self.nb_meshes = 0 + self.mesh_list = [] + self.boxes_str = [] + self.errors_str = [] + self.operation = None + + logger.info(f"EarthMeshDownloader initialized with: center {center}, min_dist {min_dist}, max_dist {max_dist}, min_error {min_error}, max_error {max_error}") + + @property + def status(self): + if self.interrupted: + return Status.INTERRUPTED + + if self.nb_meshes >= self.max_meshes: + return Status.MAX_MESHES_REACHED + + return Status.DOWNLOADING + + def _check_exit(self): + if self.status != Status.DOWNLOADING: + return True + + def _update(self): + if self.operation is None: + return + + try: + self.operation.updateLongProgress(float(self.nb_meshes) / float(self.max_meshes), "Loading mesh # %i" % (self.nb_meshes)) + except hou.OperationInterrupted: + self._exit() + + def _exit(self): + self.interrupted = True + + def _get_children(self, node): + if len(node) == 1: + if "content" not in node[0]: + logger.info("No content in single child node") + return node + + if node[0]['content']['uri'].split(".")[-1] == "json": + url = f"{self.tile_url}{node[0]['content']['uri']}?{self.context}&key={GOOGLE_API_KEY}" + return json_from_byte(bytes_from_url(url, self.cached))["root"]["children"] + return node + + def _download(self, child): + glb = child["content"]["uri"] + file_name = CACHE_PATH / hash_filename(glb.split("/")[-1]) + file_from_url(f"{self.tile_url}{glb}?{self.context}&key={GOOGLE_API_KEY}", file_name.as_posix(), self.cached) + boxes_str = ",".join([str(x) for x in child["boundingVolume"]["box"]]) + return file_name.as_posix(), boxes_str, str(child["geometricError"]) + + def download(self, node): + self._update() + + if self._check_exit(): + return + + if "children" not in node: + return + + children = node["children"] + + if len(children) == 0: + return + + children = self._get_children(children) + + for child in children: + self._update() + + if self._check_exit(): + return + + if "content" not in child: + self.download(child) + continue + + e = child["geometricError"] + + # if e < self.min_error or e > self.max_error: + # logger.info(f"Geometric Error: {e} is out of range [{self.min_error}, {self.max_error}]") + # self.download(child) + # continue + + bbx = buildBoundingBoxMatrix(child["boundingVolume"]["box"]) + dist = sdfBox(bbx, self.center) + + dist_norm = (dist - self.min_dist) / (self.max_dist - self.min_dist) + err = dist_norm * (self.max_error - self.min_error) + self.min_error + + l = max(min(err, self.max_error), self.min_error) + + if (l > e) or ("children" not in child): + logger.info(f"Distance: {dist}, Distance_norm: {dist_norm}, Error: {l}, Geometric Error: {e}") + mesh, boxes, error = self._download(child) + self.mesh_list.append(mesh) + self.boxes_str.append(boxes) + self.errors_str.append(error) + self.nb_meshes = self.nb_meshes + 1 + + self._update() + else: + self.download(child) + + return self.status + + def __call__(self): + node = json_from_byte(bytes_from_url(self.tile_url + self.uri + "&key=" + GOOGLE_API_KEY, self.cached))["root"] + with hou.InterruptableOperation("Get geometry with Google API...", long_operation_name = "Starting Tasks", open_interrupt_dialog = True) as operation: + self.operation = operation + status = self.download(node) + + logger.info(status) + diff --git a/scripts/python/utils.py b/scripts/python/utils.py new file mode 100644 index 0000000..41896b0 --- /dev/null +++ b/scripts/python/utils.py @@ -0,0 +1,107 @@ +import json +import urllib.request +import hashlib +import os +import logging +from pathlib import Path + +try: + import hou +except ImportError: + pass + +logger = logging.getLogger(__name__) + + +CACHE_PATH = Path(os.environ.get("EARTH_MESH_CACHE", "./cache")) +GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", None) + +print(f"Cache path: {CACHE_PATH}") + +# Filename need to be encoded because filenames provided by google are too long for windows. +def hash_filename(filename): + name, ext = os.path.splitext(filename) # Splitting the filename from its extension + hashed_name = hashlib.sha256(name.encode()).hexdigest() # Encoding the filename (without extension) and hashing it + return hashed_name + ext # Returning the new filename with the original extension + + +def bytes_from_url(url, cached): + filename = hash_filename(url.split("?")[0].split("/")[-1]) + + if not CACHE_PATH.exists(): + CACHE_PATH.mkdir(parents=True) # TODO: May want to do this somewhere else + + path = CACHE_PATH / filename + + if cached and path.is_file(): + with open(path, "rb") as file: + return file.read() + else: + with urllib.request.urlopen(url) as response: + logger.info("Download %s", path) + bytes = response.read() + file_from_bytes(bytes, path) + return bytes + + +def json_from_byte(bytes): + return json.loads(bytes.decode("utf-8")) + + +def file_from_bytes(bytes, file_name): + with open(file_name, "wb") as binary_file: + binary_file.write(bytes) + + +def find_key(root, key): + children = root["children"] + for child in children : + if "content" in child: + content = child["content"] + if key in content: + return content[key] + r = find_key(child, key) + if r: + return r + return "" + + +def file_from_url(url, file_name, cached): + if(cached and os.path.isfile(file_name)): + return + file_from_bytes(bytes_from_url(url, cached), file_name) + + +def buildBoundingBoxMatrix(box): + # Bounding boxes don't have the same axis than the meshes + boundingbox2mesh = hou.Matrix4([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, -1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ]) + return hou.Matrix4([ + [box[3], box[4], box[5], 0.0], + [box[6], box[7], box[8], 0.0], + [box[9], box[10], box[11], 0.0], + [box[0], box[1], box[2], 1.0]] + ) * boundingbox2mesh + + +def sdfBox(bbx, ctr): + bbx = bbx.asTupleOfTuples() + m = [hou.Vector3(bbx[0][0:3]), hou.Vector3(bbx[1][0:3]), hou.Vector3(bbx[2][0:3]), hou.Vector3(bbx[3][0:3])] + b = hou.Vector3(m[0].length(), m[1].length(), m[2].length()) + m = [m[0].normalized(), m[1].normalized(), m[2].normalized(), m[3]] + m = hou.Matrix4([ + [m[0].x(), m[0].y(), m[0].z(), 0.0], + [m[1].x(), m[1].y(), m[1].z(), 0.0], + [m[2].x(), m[2].y(), m[2].z(), 0.0], + [m[3].x(), m[3].y(), m[3].z(), 1.0] + ]) + mi = m.inverted() + p = hou.Vector3(ctr) * mi + # inspired by https://iquilezles.org/articles/distfunctions/ + q = hou.Vector3([abs(p.x()), abs(p.y()), abs(p.z())]) - b + return hou.Vector3(max(0.0,q.x()), max(0.0,q.y()), max(0.0,q.z())).length() + min(max(q.x(), max(q.y(), q.z())), 0.0) + \ No newline at end of file diff --git a/toolbar/EARTHMESH.shelf b/toolbar/EARTHMESH.shelf new file mode 100644 index 0000000..74fcf5f --- /dev/null +++ b/toolbar/EARTHMESH.shelf @@ -0,0 +1,20 @@ + + + + + + + + + + + SOP + + + +