Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Empty file removed Cache/dummy.txt
Empty file.
14 changes: 14 additions & 0 deletions EarthMesh.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"env": [
{
"GOOGLE_MAPS_API_KEY": "YOUR_API_KEY"
},
{
"EARTHMESH_PATH": "D:/Houdini/EarthMeshHoudini",
},
{
"HOUDINI_PYTHONWARNINGS": "ignore"
}
],
"path": "$EARTHMESH_PATH"
}
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file renamed EarthMesh_Test.hip → examples/EarthMesh_Test.hip
Binary file not shown.
Binary file removed icon/Atlas.png
Binary file not shown.
Binary file removed icon/earth.png
Binary file not shown.
Empty file removed map/dummy.txt
Empty file.
File renamed without changes.
Binary file added otls/EarthMesh.2.0.hda
Binary file not shown.
File renamed without changes.
174 changes: 174 additions & 0 deletions scripts/python/main.py
Original file line number Diff line number Diff line change
@@ -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)

107 changes: 107 additions & 0 deletions scripts/python/utils.py
Original file line number Diff line number Diff line change
@@ -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)

20 changes: 20 additions & 0 deletions toolbar/EARTHMESH.shelf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<shelfDocument>
<!-- This file contains definitions of shelves, toolbars, and tools.
It should not be hand-edited when it is being used by the application.
Note, that two definitions of the same element are not allowed in
a single file. -->

<toolshelf name="EarthMesh_Tools" label="EarthMesh Tools">
<memberTool name="earthmesh_tool"/>
</toolshelf>

<tool name="earthmesh_tool" label="EarthMesh" icon="$EARTHMESH_PATH/icons/earth.png">
<toolMenuContext name="network">
<contextNetType>SOP</contextNetType>
</toolMenuContext>
<script scriptType="python"><![CDATA[import soptoolutils

node = soptoolutils.genericTool(kwargs, 'EarthMesh::2.0', force_filter=True)]]></script>
</tool>
</shelfDocument>