|
import numpy as np |
|
from ipywidgets import embed |
|
import pythreejs as p3s |
|
import uuid |
|
|
|
from .color_util import get_colors, gen_circle, gen_checkers |
|
|
|
|
|
EMBED_URL = "https://cdn.jsdelivr.net/npm/@jupyter-widgets/html-manager@1.0.1/dist/embed-amd.js" |
|
|
|
|
|
class PyThreeJSViewer(object): |
|
|
|
def __init__(self, settings, render_mode="WEBSITE"): |
|
self.render_mode = render_mode |
|
self.__update_settings(settings) |
|
self._light = p3s.DirectionalLight(color='white', position=[0, 0, 1], intensity=0.6) |
|
self._light2 = p3s.AmbientLight(intensity=0.5) |
|
self._cam = p3s.PerspectiveCamera(position=[0, 0, 1], lookAt=[0, 0, 0], fov=self.__s["fov"], |
|
aspect=self.__s["width"] / self.__s["height"], children=[self._light]) |
|
self._orbit = p3s.OrbitControls(controlling=self._cam) |
|
self._scene = p3s.Scene(children=[self._cam, self._light2], background=self.__s["background"]) |
|
self._renderer = p3s.Renderer(camera=self._cam, scene=self._scene, controls=[self._orbit], |
|
width=self.__s["width"], height=self.__s["height"], |
|
antialias=self.__s["antialias"]) |
|
|
|
self.__objects = {} |
|
self.__cnt = 0 |
|
|
|
def jupyter_mode(self): |
|
self.render_mode = "JUPYTER" |
|
|
|
def offline(self): |
|
self.render_mode = "OFFLINE" |
|
|
|
def website(self): |
|
self.render_mode = "WEBSITE" |
|
|
|
def __get_shading(self, shading): |
|
shad = {"flat": True, "wireframe": False, "wire_width": 0.03, "wire_color": "black", |
|
"side": 'DoubleSide', "colormap": "viridis", "normalize": [None, None], |
|
"bbox": False, "roughness": 0.5, "metalness": 0.25, "reflectivity": 1.0, |
|
"line_width": 1.0, "line_color": "black", |
|
"point_color": "red", "point_size": 0.01, "point_shape": "circle", |
|
"text_color": "red" |
|
} |
|
for k in shading: |
|
shad[k] = shading[k] |
|
return shad |
|
|
|
def __update_settings(self, settings={}): |
|
sett = {"width": 600, "height": 600, "antialias": True, "scale": 1.5, "background": "#ffffff", |
|
"fov": 30} |
|
for k in settings: |
|
sett[k] = settings[k] |
|
self.__s = sett |
|
|
|
def __add_object(self, obj, parent=None): |
|
if not parent: |
|
self.__objects[self.__cnt] = obj |
|
self.__cnt += 1 |
|
self._scene.add(obj["mesh"]) |
|
else: |
|
parent.add(obj["mesh"]) |
|
|
|
self.__update_view() |
|
|
|
if self.render_mode == "JUPYTER": |
|
return self.__cnt - 1 |
|
elif self.render_mode == "WEBSITE": |
|
return self |
|
|
|
def __add_line_geometry(self, lines, shading, obj=None): |
|
lines = lines.astype("float32", copy=False) |
|
mi = np.min(lines, axis=0) |
|
ma = np.max(lines, axis=0) |
|
|
|
geometry = p3s.LineSegmentsGeometry(positions=lines.reshape((-1, 2, 3))) |
|
material = p3s.LineMaterial(linewidth=shading["line_width"], color=shading["line_color"]) |
|
|
|
lines = p3s.LineSegments2(geometry=geometry, material=material) |
|
line_obj = {"geometry": geometry, "mesh": lines, "material": material, |
|
"max": ma, "min": mi, "type": "Lines", "wireframe": None} |
|
|
|
if obj: |
|
return self.__add_object(line_obj, obj), line_obj |
|
else: |
|
return self.__add_object(line_obj) |
|
|
|
def __update_view(self): |
|
if len(self.__objects) == 0: |
|
return |
|
ma = np.zeros((len(self.__objects), 3)) |
|
mi = np.zeros((len(self.__objects), 3)) |
|
for r, obj in enumerate(self.__objects): |
|
ma[r] = self.__objects[obj]["max"] |
|
mi[r] = self.__objects[obj]["min"] |
|
ma = np.max(ma, axis=0) |
|
mi = np.min(mi, axis=0) |
|
diag = np.linalg.norm(ma - mi) |
|
mean = ((ma - mi) / 2 + mi).tolist() |
|
scale = self.__s["scale"] * (diag) |
|
self._orbit.target = mean |
|
self._cam.lookAt(mean) |
|
self._cam.position = [mean[0], mean[1], mean[2] + scale] |
|
self._light.position = [mean[0], mean[1], mean[2] + scale] |
|
|
|
self._orbit.exec_three_obj_method('update') |
|
self._cam.exec_three_obj_method('updateProjectionMatrix') |
|
|
|
def __get_bbox(self, v): |
|
m = np.min(v, axis=0) |
|
M = np.max(v, axis=0) |
|
|
|
|
|
v_box = np.array([[m[0], m[1], m[2]], [M[0], m[1], m[2]], [M[0], M[1], m[2]], [m[0], M[1], m[2]], |
|
[m[0], m[1], M[2]], [M[0], m[1], M[2]], [M[0], M[1], M[2]], [m[0], M[1], M[2]]]) |
|
|
|
f_box = np.array([[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], |
|
[0, 4], [1, 5], [2, 6], [7, 3]], dtype=np.uint32) |
|
return v_box, f_box |
|
|
|
def __get_colors(self, v, f, c, sh): |
|
coloring = "VertexColors" |
|
if type(c) == np.ndarray and c.size == 3: |
|
colors = np.ones_like(v) |
|
colors[:, 0] = c[0] |
|
colors[:, 1] = c[1] |
|
colors[:, 2] = c[2] |
|
|
|
elif type(c) == np.ndarray and len(c.shape) == 2 and c.shape[1] == 3: |
|
if c.shape[0] == f.shape[0]: |
|
colors = np.hstack([c, c, c]).reshape((-1, 3)) |
|
coloring = "FaceColors" |
|
|
|
elif c.shape[0] == v.shape[0]: |
|
colors = c |
|
|
|
else: |
|
print("Invalid color array given! Supported are numpy arrays.", type(c)) |
|
colors = np.ones_like(v) |
|
colors[:, 0] = 1.0 |
|
colors[:, 1] = 0.874 |
|
colors[:, 2] = 0.0 |
|
elif type(c) == np.ndarray and c.size == f.shape[0]: |
|
normalize = sh["normalize"][0] != None and sh["normalize"][1] != None |
|
cc = get_colors(c, sh["colormap"], normalize=normalize, |
|
vmin=sh["normalize"][0], vmax=sh["normalize"][1]) |
|
|
|
colors = np.hstack([cc, cc, cc]).reshape((-1, 3)) |
|
coloring = "FaceColors" |
|
|
|
elif type(c) == np.ndarray and c.size == v.shape[0]: |
|
normalize = sh["normalize"][0] != None and sh["normalize"][1] != None |
|
colors = get_colors(c, sh["colormap"], normalize=normalize, |
|
vmin=sh["normalize"][0], vmax=sh["normalize"][1]) |
|
|
|
|
|
else: |
|
colors = np.ones_like(v) |
|
|
|
|
|
|
|
colors[:, 0] = 1 |
|
colors[:, 1] = 1 |
|
colors[:, 2] = 1 |
|
|
|
|
|
if c is not None: |
|
print("Invalid color array given! Supported are numpy arrays.", type(c)) |
|
|
|
return colors, coloring |
|
|
|
def __get_point_colors(self, v, c, sh): |
|
v_color = True |
|
if c is None: |
|
|
|
colors = sh["point_color"] |
|
v_color = False |
|
elif isinstance(c, str): |
|
|
|
colors = c |
|
v_color = False |
|
elif type(c) == np.ndarray and len(c.shape) == 2 and c.shape[0] == v.shape[0] and c.shape[1] == 3: |
|
|
|
colors = c.astype("float32", copy=False) |
|
|
|
elif isinstance(c, np.ndarray) and len(c.shape) == 2 and c.shape[0] == v.shape[0] and c.shape[1] != 3: |
|
|
|
c_norm = np.linalg.norm(c, ord=2, axis=-1) |
|
normalize = sh["normalize"][0] != None and sh["normalize"][1] != None |
|
colors = get_colors(c_norm, sh["colormap"], normalize=normalize, |
|
vmin=sh["normalize"][0], vmax=sh["normalize"][1]) |
|
colors = colors.astype("float32", copy=False) |
|
|
|
elif type(c) == np.ndarray and c.size == v.shape[0]: |
|
normalize = sh["normalize"][0] != None and sh["normalize"][1] != None |
|
colors = get_colors(c, sh["colormap"], normalize=normalize, |
|
vmin=sh["normalize"][0], vmax=sh["normalize"][1]) |
|
colors = colors.astype("float32", copy=False) |
|
|
|
|
|
else: |
|
print("Invalid color array given! Supported are numpy arrays.", type(c)) |
|
colors = sh["point_color"] |
|
v_color = False |
|
|
|
return colors, v_color |
|
|
|
def add_mesh(self, v, f, c=None, uv=None, n=None, shading={}, texture_data=None, **kwargs): |
|
shading.update(kwargs) |
|
sh = self.__get_shading(shading) |
|
mesh_obj = {} |
|
|
|
|
|
if v.shape[1] == 3 and f.shape[1] == 4: |
|
f_tmp = np.ndarray([f.shape[0] * 4, 3], dtype=f.dtype) |
|
for i in range(f.shape[0]): |
|
f_tmp[i * 4 + 0] = np.array([f[i][1], f[i][0], f[i][2]]) |
|
f_tmp[i * 4 + 1] = np.array([f[i][0], f[i][1], f[i][3]]) |
|
f_tmp[i * 4 + 2] = np.array([f[i][1], f[i][2], f[i][3]]) |
|
f_tmp[i * 4 + 3] = np.array([f[i][2], f[i][0], f[i][3]]) |
|
f = f_tmp |
|
|
|
if v.shape[1] == 2: |
|
v = np.append(v, np.zeros([v.shape[0], 1]), 1) |
|
|
|
|
|
v = v.astype("float32", copy=False) |
|
|
|
|
|
colors, coloring = self.__get_colors(v, f, c, sh) |
|
|
|
|
|
c = colors.astype("float32", copy=False) |
|
|
|
|
|
ba_dict = {"color": p3s.BufferAttribute(c)} |
|
if coloring == "FaceColors": |
|
verts = np.zeros((f.shape[0] * 3, 3), dtype="float32") |
|
for ii in range(f.shape[0]): |
|
|
|
verts[ii * 3] = v[f[ii, 0]] |
|
verts[ii * 3 + 1] = v[f[ii, 1]] |
|
verts[ii * 3 + 2] = v[f[ii, 2]] |
|
v = verts |
|
else: |
|
f = f.astype("uint32", copy=False).ravel() |
|
ba_dict["index"] = p3s.BufferAttribute(f, normalized=False) |
|
|
|
ba_dict["position"] = p3s.BufferAttribute(v, normalized=False) |
|
|
|
if uv is not None: |
|
uv = (uv - np.min(uv)) / (np.max(uv) - np.min(uv)) |
|
if texture_data is None: |
|
texture_data = gen_checkers(20, 20) |
|
tex = p3s.DataTexture(data=texture_data, format="RGBFormat", type="FloatType") |
|
material = p3s.MeshStandardMaterial(map=tex, reflectivity=sh["reflectivity"], side=sh["side"], |
|
roughness=sh["roughness"], metalness=sh["metalness"], |
|
flatShading=sh["flat"], |
|
polygonOffset=True, polygonOffsetFactor=1, polygonOffsetUnits=5) |
|
ba_dict["uv"] = p3s.BufferAttribute(uv.astype("float32", copy=False)) |
|
else: |
|
material = p3s.MeshStandardMaterial(vertexColors=coloring, reflectivity=sh["reflectivity"], |
|
side=sh["side"], roughness=sh["roughness"], metalness=sh["metalness"], |
|
flatShading=sh["flat"], |
|
polygonOffset=True, polygonOffsetFactor=1, polygonOffsetUnits=5) |
|
|
|
if type(n) != type(None) and coloring == "VertexColors": |
|
ba_dict["normal"] = p3s.BufferAttribute(n.astype("float32", copy=False), normalized=True) |
|
|
|
geometry = p3s.BufferGeometry(attributes=ba_dict) |
|
|
|
if coloring == "VertexColors" and type(n) == type(None): |
|
geometry.exec_three_obj_method('computeVertexNormals') |
|
elif coloring == "FaceColors" and type(n) == type(None): |
|
geometry.exec_three_obj_method('computeFaceNormals') |
|
|
|
|
|
mesh = p3s.Mesh(geometry=geometry, material=material) |
|
|
|
|
|
mesh_obj["wireframe"] = None |
|
if sh["wireframe"]: |
|
wf_geometry = p3s.WireframeGeometry(mesh.geometry) |
|
wf_material = p3s.LineBasicMaterial(color=sh["wire_color"], linewidth=sh["wire_width"]) |
|
wireframe = p3s.LineSegments(wf_geometry, wf_material) |
|
mesh.add(wireframe) |
|
mesh_obj["wireframe"] = wireframe |
|
|
|
|
|
if sh["bbox"]: |
|
v_box, f_box = self.__get_bbox(v) |
|
_, bbox = self.add_edges(v_box, f_box, sh, mesh) |
|
mesh_obj["bbox"] = [bbox, v_box, f_box] |
|
|
|
|
|
mesh_obj["max"] = np.max(v, axis=0) |
|
mesh_obj["min"] = np.min(v, axis=0) |
|
mesh_obj["geometry"] = geometry |
|
mesh_obj["mesh"] = mesh |
|
mesh_obj["material"] = material |
|
mesh_obj["type"] = "Mesh" |
|
mesh_obj["shading"] = sh |
|
mesh_obj["coloring"] = coloring |
|
mesh_obj["arrays"] = [v, f, c] |
|
|
|
return self.__add_object(mesh_obj) |
|
|
|
def add_lines(self, beginning, ending, shading={}, obj=None, **kwargs): |
|
shading.update(kwargs) |
|
if len(beginning.shape) == 1: |
|
if len(beginning) == 2: |
|
beginning = np.array([[beginning[0], beginning[1], 0]]) |
|
else: |
|
if beginning.shape[1] == 2: |
|
beginning = np.append( |
|
beginning, np.zeros([beginning.shape[0], 1]), 1) |
|
if len(ending.shape) == 1: |
|
if len(ending) == 2: |
|
ending = np.array([[ending[0], ending[1], 0]]) |
|
else: |
|
if ending.shape[1] == 2: |
|
ending = np.append( |
|
ending, np.zeros([ending.shape[0], 1]), 1) |
|
|
|
sh = self.__get_shading(shading) |
|
lines = np.hstack([beginning, ending]) |
|
lines = lines.reshape((-1, 3)) |
|
return self.__add_line_geometry(lines, sh, obj) |
|
|
|
def add_edges(self, vertices, edges, shading={}, obj=None, **kwargs): |
|
shading.update(kwargs) |
|
if vertices.shape[1] == 2: |
|
vertices = np.append( |
|
vertices, np.zeros([vertices.shape[0], 1]), 1) |
|
sh = self.__get_shading(shading) |
|
lines = np.zeros((edges.size, 3)) |
|
cnt = 0 |
|
for e in edges: |
|
lines[cnt, :] = vertices[e[0]] |
|
lines[cnt + 1, :] = vertices[e[1]] |
|
cnt += 2 |
|
return self.__add_line_geometry(lines, sh, obj) |
|
|
|
def add_points(self, points, c=None, shading={}, obj=None, **kwargs): |
|
shading.update(kwargs) |
|
if len(points.shape) == 1: |
|
if len(points) == 2: |
|
points = np.array([[points[0], points[1], 0]]) |
|
else: |
|
if points.shape[1] == 2: |
|
points = np.append( |
|
points, np.zeros([points.shape[0], 1]), 1) |
|
sh = self.__get_shading(shading) |
|
points = points.astype("float32", copy=False) |
|
mi = np.min(points, axis=0) |
|
ma = np.max(points, axis=0) |
|
|
|
g_attributes = {"position": p3s.BufferAttribute(points, normalized=False)} |
|
m_attributes = {"size": sh["point_size"]} |
|
|
|
if sh["point_shape"] == "circle": |
|
tex = p3s.DataTexture(data=gen_circle(16, 16), format="RGBAFormat", type="FloatType") |
|
m_attributes["map"] = tex |
|
m_attributes["alphaTest"] = 0.5 |
|
m_attributes["transparency"] = True |
|
else: |
|
pass |
|
|
|
colors, v_colors = self.__get_point_colors(points, c, sh) |
|
if v_colors: |
|
m_attributes["vertexColors"] = 'VertexColors' |
|
g_attributes["color"] = p3s.BufferAttribute(colors, normalized=False) |
|
|
|
else: |
|
m_attributes["color"] = colors |
|
|
|
material = p3s.PointsMaterial(**m_attributes) |
|
geometry = p3s.BufferGeometry(attributes=g_attributes) |
|
points = p3s.Points(geometry=geometry, material=material) |
|
point_obj = {"geometry": geometry, "mesh": points, "material": material, |
|
"max": ma, "min": mi, "type": "Points", "wireframe": None} |
|
|
|
if obj: |
|
return self.__add_object(point_obj, obj), point_obj |
|
else: |
|
return self.__add_object(point_obj) |
|
|
|
def remove_object(self, obj_id): |
|
if obj_id not in self.__objects: |
|
print("Invalid object id. Valid ids are: ", list(self.__objects.keys())) |
|
return |
|
self._scene.remove(self.__objects[obj_id]["mesh"]) |
|
del self.__objects[obj_id] |
|
self.__update_view() |
|
|
|
def reset(self): |
|
for obj_id in list(self.__objects.keys()).copy(): |
|
self._scene.remove(self.__objects[obj_id]["mesh"]) |
|
del self.__objects[obj_id] |
|
self.__update_view() |
|
|
|
def update_object(self, oid=0, vertices=None, colors=None, faces=None): |
|
obj = self.__objects[oid] |
|
if type(vertices) != type(None): |
|
if obj["coloring"] == "FaceColors": |
|
f = obj["arrays"][1] |
|
verts = np.zeros((f.shape[0] * 3, 3), dtype="float32") |
|
for ii in range(f.shape[0]): |
|
|
|
verts[ii * 3] = vertices[f[ii, 0]] |
|
verts[ii * 3 + 1] = vertices[f[ii, 1]] |
|
verts[ii * 3 + 2] = vertices[f[ii, 2]] |
|
v = verts |
|
|
|
else: |
|
v = vertices.astype("float32", copy=False) |
|
obj["geometry"].attributes["position"].array = v |
|
|
|
obj["geometry"].attributes["position"].needsUpdate = True |
|
|
|
if type(colors) != type(None): |
|
colors, coloring = self.__get_colors(obj["arrays"][0], obj["arrays"][1], colors, obj["shading"]) |
|
colors = colors.astype("float32", copy=False) |
|
obj["geometry"].attributes["color"].array = colors |
|
obj["geometry"].attributes["color"].needsUpdate = True |
|
if type(faces) != type(None): |
|
if obj["coloring"] == "FaceColors": |
|
print("Face updates are currently only possible in vertex color mode.") |
|
return |
|
f = faces.astype("uint32", copy=False).ravel() |
|
print(obj["geometry"].attributes) |
|
obj["geometry"].attributes["index"].array = f |
|
|
|
obj["geometry"].attributes["index"].needsUpdate = True |
|
|
|
|
|
|
|
|
|
if self.render_mode == "WEBSITE": |
|
return self |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_text(self, text, shading={}, **kwargs): |
|
shading.update(kwargs) |
|
sh = self.__get_shading(shading) |
|
tt = p3s.TextTexture(string=text, color=sh["text_color"]) |
|
sm = p3s.SpriteMaterial(map=tt) |
|
text = p3s.Sprite(material=sm, scaleToTexture=True) |
|
self._scene.add(text) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_html(self, imports=True, html_frame=True): |
|
|
|
if len(self.__objects) == 0: |
|
return |
|
ma = np.zeros((len(self.__objects), 3)) |
|
mi = np.zeros((len(self.__objects), 3)) |
|
for r, obj in enumerate(self.__objects): |
|
ma[r] = self.__objects[obj]["max"] |
|
mi[r] = self.__objects[obj]["min"] |
|
ma = np.max(ma, axis=0) |
|
mi = np.min(mi, axis=0) |
|
diag = np.linalg.norm(ma - mi) |
|
mean = (ma - mi) / 2 + mi |
|
for r, obj in enumerate(self.__objects): |
|
v = self.__objects[obj]["geometry"].attributes["position"].array |
|
v -= mean |
|
v += np.array([0.0, .9, 0.0]) |
|
|
|
scale = self.__s["scale"] * (diag) |
|
self._orbit.target = [0.0, 0.0, 0.0] |
|
self._cam.lookAt([0.0, 0.0, 0.0]) |
|
|
|
self._cam.position = [0.0, 0.5, scale * 1.3] |
|
self._light.position = [0.0, 0.0, scale] |
|
|
|
state = embed.dependency_state(self._renderer) |
|
|
|
|
|
|
|
for k in state: |
|
if state[k]["model_name"] == "OrbitControlsModel": |
|
state[k]["state"]["maxAzimuthAngle"] = "inf" |
|
state[k]["state"]["maxDistance"] = "inf" |
|
state[k]["state"]["maxZoom"] = "inf" |
|
state[k]["state"]["minAzimuthAngle"] = "-inf" |
|
|
|
tpl = embed.load_requirejs_template |
|
if not imports: |
|
embed.load_requirejs_template = "" |
|
|
|
s = embed.embed_snippet(self._renderer, state=state, embed_url=EMBED_URL) |
|
|
|
embed.load_requirejs_template = tpl |
|
|
|
if html_frame: |
|
s = "<html>\n<body>\n" + s + "\n</body>\n</html>" |
|
|
|
|
|
for r, obj in enumerate(self.__objects): |
|
v = self.__objects[obj]["geometry"].attributes["position"].array |
|
v += mean |
|
self.__update_view() |
|
|
|
return s |
|
|
|
def save(self, filename=""): |
|
if filename == "": |
|
uid = str(uuid.uuid4()) + ".html" |
|
else: |
|
filename = filename.replace(".html", "") |
|
uid = filename + '.html' |
|
with open(uid, "w") as f: |
|
f.write(self.to_html()) |
|
print("Plot saved to file %s." % uid) |
|
|