dfg_3dviewer_drupal_module/scripts/CityGML2OBJv2/polygon3dmodule.py
2026-06-25 09:09:16 +02:00

716 lines
No EOL
24 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
# The MIT License (MIT)
# This code is part of the CityGML2OBJs package
# Copyright (c) 2014
# Filip Biljecki
# Delft University of Technology
# fbiljecki@gmail.com
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import math
import markup3dmodule
from lxml import etree
import copy
import triangle
import numpy as np
import shapely
from sklearn.decomposition import PCA
def getAreaOfGML(poly, height=True):
"""Function which reads <gml:Polygon> and returns its area.
The function also accounts for the interior and checks for the validity of the polygon."""
exteriorarea = 0.0
interiorarea = 0.0
# -- Decompose the exterior and interior boundary
e, i = markup3dmodule.polydecomposer(poly)
# -- Extract points in the <gml:LinearRing> of <gml:exterior>
epoints = markup3dmodule.GMLpoints(e[0])
if isPolyValid(epoints):
if height:
exteriorarea += get3DArea(epoints)
else:
exteriorarea += get2DArea(epoints)
for idx, iring in enumerate(i):
# -- Extract points in the <gml:LinearRing> of <gml:interior>
ipoints = markup3dmodule.GMLpoints(iring)
if isPolyValid(ipoints):
if height:
interiorarea += get3DArea(ipoints)
else:
interiorarea += get2DArea(ipoints)
# -- Account for the interior
area = exteriorarea - interiorarea
# -- Area in dimensionless units (coordinate units)
return area
# -- Validity of a polygon ---------
def isPolyValid(polypoints, output=True):
"""Checks if a polygon is valid. Second option is to supress output."""
# -- Number of points of the polygon (including the doubled first/last point)
npolypoints = len(polypoints)
# -- Assume that it is valid, and try to disprove the assumption
valid = True
# -- Check if last point equal
if polypoints[0] != polypoints[-1]:
if output:
print("\t\tA degenerate polygon. First and last points do not match.")
valid = False
# -- Check if it has at least three points
if npolypoints < 4: # -- Four because the first point is doubled as the last one in the ring
if output:
print("\t\tA degenerate polygon. The number of points is smaller than 3.")
valid = False
# -- Check if the points are planar
if not isPolyPlanar(polypoints):
if output:
print("\t\tA degenerate polygon. The points are not planar.")
valid = False
# -- Check if some of the points are repeating
for i in range(1, npolypoints):
if polypoints[i] == polypoints[i - 1]:
if output:
print("\t\tA degenerate polygon. There are identical points.")
valid = False
# -- Check if the polygon does not have self-intersections
# -- Disabled, something doesn't work here, will work on this later.
# if not isPolySimple(polypoints):
# print "A degenerate polygon. The edges are intersecting."
# valid = False
return valid
def isPolyPlanar(polypoints):
"""Checks if a polygon is planar."""
# -- Normal of the polygon from the first three points
try:
normal = unit_normal(polypoints[0], polypoints[1], polypoints[2])
except:
return False
# -- Number of points
npolypoints = len(polypoints)
# -- Tolerance
eps = 0.01
# -- Assumes planarity
planar = True
for i in range(3, npolypoints):
vector = [polypoints[i][0] - polypoints[0][0], polypoints[i][1] - polypoints[0][1],
polypoints[i][2] - polypoints[0][2]]
if math.fabs(dot(vector, normal)) > eps:
planar = False
return planar
def isPolySimple(polypoints): #todo: this function has to be adapted
"""Checks if the polygon is simple, i.e. it does not have any self-intersections.
Inspired by http://www.win.tue.nl/~vanwijk/2IV60/2IV60_exercise_3_answers.pdf"""
npolypoints = len(polypoints)
# -- Check if the polygon is vertical, i.e. a projection cannot be made.
# -- First copy the list so the originals are not modified
temppolypoints = copy.deepcopy(polypoints)
newpolypoints = copy.deepcopy(temppolypoints)
# -- If the polygon is vertical
#if math.fabs(unit_normal(temppolypoints[0], temppolypoints[1], temppolypoints[2])[2]) < 10e-6:
# vertical = True
#else:
# vertical = False
normal = calculate_polygon_normal(temppolypoints)
if math.fabs(normal[2]) < 10e-6:
vertical = True
print("The polygon is vertical 2")
print("math.fabs(normal[2]): ", math.fabs(normal[2]))
else:
vertical = False
print("Not vertical 2")
# -- We want to project the vertical polygon to the XZ plane
# -- If a polygon is parallel with the YZ plane that will not be possible
YZ = True
for i in range(1, npolypoints):
if temppolypoints[i][0] != temppolypoints[0][0]:
YZ = False
continue
# -- Project the plane in the special case
if YZ:
for i in range(0, npolypoints):
newpolypoints[i][0] = temppolypoints[i][1]
newpolypoints[i][1] = temppolypoints[i][2]
# -- Project the plane
elif vertical:
for i in range(0, npolypoints):
newpolypoints[i][1] = temppolypoints[i][2]
else:
pass # -- No changes here
# -- Check for the self-intersection edge by edge
for i in range(0, npolypoints - 3):
if i == 0:
m = npolypoints - 3
else:
m = npolypoints - 2
for j in range(i + 2, m):
if intersection(newpolypoints[i], newpolypoints[i + 1], newpolypoints[j % npolypoints],
newpolypoints[(j + 1) % npolypoints]):
return False
return True
def intersection(p, q, r, s):
"""Check if two line segments (pq and rs) intersect. Computation is in 2D.
Inspired by http://www.win.tue.nl/~vanwijk/2IV60/2IV60_exercise_3_answers.pdf"""
eps = 10e-6
V = [q[0] - p[0], q[1] - p[1]]
W = [r[0] - s[0], r[1] - s[1]]
d = V[0] * W[1] - W[0] * V[1]
if math.fabs(d) < eps:
return False
else:
return True
# ------------------------------------------
def collinear(p0, p1, p2):
# -- http://stackoverflow.com/a/9609069
x1, y1 = p1[0] - p0[0], p1[1] - p0[1]
x2, y2 = p2[0] - p0[0], p2[1] - p0[1]
return x1 * y2 - x2 * y1 < 1e-12
# -- Area and other handy computations
def det(a):
"""Determinant of matrix a."""
return a[0][0] * a[1][1] * a[2][2] + a[0][1] * a[1][2] * a[2][0] + a[0][2] * a[1][0] * a[2][1] - a[0][2] * a[1][1] * \
a[2][0] - a[0][1] * a[1][0] * a[2][2] - a[0][0] * a[1][2] * a[2][1]
def unit_normal(a, b, c):
"""Unit normal vector of plane defined by points a, b, and c."""
x = det([[1, a[1], a[2]],
[1, b[1], b[2]],
[1, c[1], c[2]]])
y = det([[a[0], 1, a[2]],
[b[0], 1, b[2]],
[c[0], 1, c[2]]])
z = det([[a[0], a[1], 1],
[b[0], b[1], 1],
[c[0], c[1], 1]])
magnitude = (x ** 2 + y ** 2 + z ** 2) ** .5
if magnitude == 0.0:
raise ValueError(
"The normal of the polygon has no magnitude. Check the polygon. The most common cause for this are two identical sequential points or collinear points.")
return (x / magnitude, y / magnitude, z / magnitude)
def dot(a, b):
"""Dot product of vectors a and b."""
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def cross(a, b):
"""Cross product of vectors a and b."""
x = a[1] * b[2] - a[2] * b[1]
y = a[2] * b[0] - a[0] * b[2]
z = a[0] * b[1] - a[1] * b[0]
return (x, y, z)
def get3DArea(polypoints):
"""Function which reads the list of coordinates and returns its area.
The code has been borrowed from http://stackoverflow.com/questions/12642256/python-find-area-of-polygon-from-xyz-coordinates"""
# -- Compute the area
total = [0, 0, 0]
for i in range(len(polypoints)):
vi1 = polypoints[i]
if i is len(polypoints) - 1:
vi2 = polypoints[0]
else:
vi2 = polypoints[i + 1]
prod = cross(vi1, vi2)
total[0] += prod[0]
total[1] += prod[1]
total[2] += prod[2]
result = dot(total, unit_normal(polypoints[0], polypoints[1], polypoints[2]))
return math.fabs(result * .5)
def get2DArea(polypoints):
"""Reads the list of coordinates and returns its projected area (disregards z coords)."""
flatpolypoints = copy.deepcopy(polypoints)
for p in flatpolypoints:
p[2] = 0.0
return get3DArea(flatpolypoints)
def getNormal(polypoints):
"""Get the normal of the first three points of a polygon. Assumes planarity."""
return unit_normal(polypoints[0], polypoints[1], polypoints[2])
def getAngles(normal):
"""Get the azimuth and altitude from the normal vector."""
# -- Convert from polar system to azimuth
azimuth = 90 - math.degrees(math.atan2(normal[1], normal[0]))
if azimuth >= 360.0:
azimuth -= 360.0
elif azimuth < 0.0:
azimuth += 360.0
t = math.sqrt(normal[0] ** 2 + normal[1] ** 2)
if t == 0:
tilt = 0.0
else:
tilt = 90 - math.degrees(math.atan(normal[2] / t)) # 0 for flat roof, 90 for wall
tilt = round(tilt, 3)
return azimuth, tilt
def GMLstring2points(pointstring):
"""Convert list of points in string to a list of points. Works for 3D points."""
listPoints = []
# -- List of coordinates
coords = pointstring.split()
# -- Store the coordinate tuple
assert (len(coords) % 3 == 0)
for i in range(0, len(coords), 3):
listPoints.append([float(coords[i]), float(coords[i + 1]), float(coords[i + 2])])
return listPoints
def smallestPoint(list_of_points):
"Finds the smallest point from a three-dimensional tuple list."
smallest = []
# -- Sort the points
sorted_points = sorted(list_of_points, key=lambda x: (x[0], x[1], x[2]))
# -- First one is the smallest one
smallest = sorted_points[0]
return smallest
def highestPoint(list_of_points, a=None):
"Finds the highest point from a three-dimensional tuple list."
highest = []
# -- Sort the points
sorted_points = sorted(list_of_points, key=lambda x: (x[0], x[1], x[2]))
# -- Last one is the highest one
if a is not None:
equalZ = True
for i in range(-1, -1 * len(list_of_points), -1):
if equalZ:
highest = sorted_points[i]
if highest[2] != a[2]:
equalZ = False
break
else:
break
else:
highest = sorted_points[-1]
return highest
def centroid(list_of_points):
"""Returns the centroid of the list of points."""
sum_x = 0
sum_y = 0
sum_z = 0
n = float(len(list_of_points))
for p in list_of_points:
sum_x += float(p[0])
sum_y += float(p[1])
sum_z += float(p[2])
return [sum_x / n, sum_y / n, sum_z / n]
# This function delivers very unsatisfying results for some reason.
# The returned point lies on the contour of the polygon sometimes, wich then messes up the triangulation
def point_inside(list_of_points):
"""Returns a point that is guaranteed to be inside the polygon, thanks to Shapely."""
# Th_Fr: new function that actually works
representative_point_tmp = centroid(list_of_points)
representative_point = shapely.geometry.Point(representative_point_tmp)
# End of the changes by Th_Fr
return representative_point.coords
def plane(a, b, c):
"""Returns the equation of a three-dimensional plane in space by entering the three coordinates of the plane."""
p_a = (b[1] - a[1]) * (c[2] - a[2]) - (c[1] - a[1]) * (b[2] - a[2])
p_b = (b[2] - a[2]) * (c[0] - a[0]) - (c[2] - a[2]) * (b[0] - a[0])
p_c = (b[0] - a[0]) * (c[1] - a[1]) - (c[0] - a[0]) * (b[1] - a[1])
p_d = -1 * (p_a * a[0] + p_b * a[1] + p_c * a[2])
return p_a, p_b, p_c, p_d
# added by Th_Fr
def planeAdjusted(points):
"""
Returns the equation of a plane in three dimensions using PCA.
Parameters:
points: list of lists or numpy array of shape (n, 3)
List of points in 3D space [x, y, z] through which the plane should pass.
At least 3 points are required to define a plane uniquely.
Returns:
p_a, p_b, p_c, p_d: float
Parameters of the plane equation ax + by + cz + d = 0.
"""
# Convert points to numpy array for easier manipulation
points = np.array(points)
# Check if at least 3 points are provided
if points.shape[0] < 3:
raise ValueError("At least 3 points are required to define a plane.")
# Use PCA to fit the plane
pca = PCA(n_components=3)
pca.fit(points)
normal = pca.components_[2] # The normal vector to the plane
# Extract coefficients
p_a, p_b, p_c = normal
p_d = -np.dot(normal, pca.mean_) # Calculate d using the mean of points
return p_a, p_b, p_c, p_d
def get_height(plane, x, y):
"""Get the missing coordinate from the plane equation and the partial coordinates."""
p_a, p_b, p_c, p_d = plane
z = (-p_a * x - p_b * y - p_d) / p_c
return z
def get_y(plane, x, z):
"""Get the missing coordinate from the plane equation and the partial coordinates."""
p_a, p_b, p_c, p_d = plane
y = (-p_a * x - p_c * z - p_d) / (p_b)
return y
def compare_normals(n1, n2):
"""Compares if two normals are equal or opposite. Takes into account a small tolerance to overcome floating point errors."""
tolerance = 10e-2
# -- Assume equal and prove otherwise
equal = True
# -- i
if math.fabs(n1[0] - n2[0]) > tolerance:
equal = False
# -- j
elif math.fabs(n1[1] - n2[1]) > tolerance:
equal = False
# -- k
elif math.fabs(n1[2] - n2[2]) > tolerance:
equal = False
return equal
def reverse_vertices(vertices):
"""Reverse vertices. Useful to reorient the normal of the polygon."""
reversed_vertices = []
nv = len(vertices)
for i in range(nv - 1, -1, -1):
reversed_vertices.append(vertices[i])
return reversed_vertices
# Added by Th_FR, inspirde by https://pythonseminar.de/prufen-ob-die-liste-doppelte-elemente-enthalt-in-python/
def has_duplicates(seq):
seen = []
unique_list = [x for x in seq if x not in seen and not seen.append(x)]
return len(seq) != len(unique_list)
# End of changes
# Added by Th_Fr
def weighted_centroid(vertices):
"""
Calculate the weighted centroid of a polygon defined by vertices.
Arguments:
vertices (numpy array): Array of vertices of the polygon.
Returns:
numpy array: Weighted centroid [x, y, z].
"""
total_area = 0.0
centroid = np.zeros(3)
num_vertices = len(vertices)
for i in range(num_vertices):
j = (i + 1) % num_vertices
cross = np.cross(vertices[i], vertices[j])
area = np.linalg.norm(cross)
centroid += (vertices[i] + vertices[j]) * area
total_area += area
return centroid / (3 * total_area)
def calculate_polygon_normal_old(poly):
"""
Calculate the normal vector of a polygon using a weighted centroid and cross product approach.
Arguments:
poly (list of lists): List of vertices of the polygon, where each vertex is [x, y, z].
Returns:
numpy array: Normal vector (nx, ny, nz) of the polygon's plane.
"""
vertices = np.array(poly)
num_vertices = len(vertices)
# Calculate weighted centroid
#print("here b")
centroid1 = centroid(vertices)
# Compute the normal vector using cross product of edges
normal = np.zeros(3)
for i in range(num_vertices):
j = (i + 1) % num_vertices
vi = vertices[i]
vj = vertices[j]
normal[0] += (vi[1] - centroid1[1]) * (vj[2] - centroid1[2]) - (vi[2] - centroid1[2]) * (vj[1] - centroid1[1])
normal[1] += (vi[2] - centroid1[2]) * (vj[0] - centroid1[0]) - (vi[0] - centroid1[0]) * (vj[2] - centroid1[2])
normal[2] += (vi[0] - centroid1[0]) * (vj[1] - centroid1[1]) - (vi[1] - centroid1[1]) * (vj[0] - centroid1[0])
norm = np.linalg.norm(normal)
if norm != 0:
normal /= norm
else:
normal = np.array([0.0, 0.0, 0.0])
return normal
def calculate_polygon_normal(polygon):
"""
Calculate the surface normal of a polygon.
Parameters:
polygon (list): A list of vertices, where each vertex is a list of three coordinates [x, y, z].
Returns:
np.array: A normalized vector representing the surface normal.
"""
normal = np.array([0.0, 0.0, 0.0])
num_verts = len(polygon)
for i in range(num_verts):
current = np.array(polygon[i])
next_vert = np.array(polygon[(i + 1) % num_verts])
normal[0] += (current[1] - next_vert[1]) * (current[2] + next_vert[2])
normal[1] += (current[2] - next_vert[2]) * (current[0] + next_vert[0])
normal[2] += (current[0] - next_vert[0]) * (current[1] + next_vert[1])
normal = normalize(normal)
return normal
def normalize(vector):
"""
Normalize a vector.
Parameters:
vector (np.array): A vector to normalize.
Returns:
np.array: A normalized vector.
"""
norm = np.linalg.norm(vector)
if norm == 0:
return vector
return vector / norm
def triangulation(e, i):
"""Triangulate the polygon with the exterior and interior list of points. Works only for convex polygons.
Assumes planarity. Projects to a 2D plane and goes back to 3D."""
vertices = []
holes = []
segments = []
index_point = 0
# -- Slope computation points
a = [[], [], []]
b = [[], [], []]
for ip in range(len(e) - 1):
vertices.append(e[ip])
if a == [[], [], []] and index_point == 0:
a = [e[ip][0], e[ip][1], e[ip][2]]
if index_point > 0 and (e[ip] != e[ip - 1]):
if b == [[], [], []]:
b = [e[ip][0], e[ip][1], e[ip][2]]
if ip == len(e) - 2:
segments.append([index_point, 0])
else:
segments.append([index_point, index_point + 1])
index_point += 1
for hole in i:
first_point_in_hole = index_point
for p in range(len(hole) - 1):
if p == len(hole) - 2:
segments.append([index_point, first_point_in_hole])
else:
segments.append([index_point, index_point + 1])
index_point += 1
vertices.append(hole[p])
# -- A more robust way to get the point inside the hole, should work for non-convex interior polygons
# alt: holes.append(point_inside(hole[:-1]))
# -- Alternative, use centroid
holes.append(centroid(hole[:-1])) # This should be useful!
# -- Project to 2D since the triangulation cannot be done in 3D with the library that is used
npolypoints = len(vertices)
nholes = len(holes)
# -- Check if the polygon is vertical, i.e. a projection cannot be made.
# -- First copy the list so the originals are not modified
temppolypoints = copy.deepcopy(vertices)
newpolypoints = copy.deepcopy(vertices)
tempholes = copy.deepcopy(holes)
newholes = copy.deepcopy(holes)
# -- Compute the normal of the polygon for detecting vertical polygons and
# -- for the correct orientation of the new triangulated faces
# -- If the polygon is vertical
#normal = unit_normal(temppolypoints[0], temppolypoints[1], temppolypoints[2])
normal = calculate_polygon_normal(temppolypoints)
if math.fabs(normal[2]) < 10e-2:
vertical = True
#print("The polygon is vertical")
#print("math.fabs(normal[2]): ", math.fabs(normal[2]))
else:
vertical = False
#print("Not vertical")
# -- We want to project the vertical polygon to the XZ plane
# -- If a polygon is parallel with the YZ plane that will not be possible
YZ = True
for i in range(1, npolypoints):
if temppolypoints[i][0] != temppolypoints[0][0]:
YZ = False
continue
# -- Project the plane in the special case
if YZ == True:
for i in range(0, npolypoints):
newpolypoints[i][0] = temppolypoints[i][1]
newpolypoints[i][1] = temppolypoints[i][2]
for i in range(0, nholes):
newholes[i][0] = tempholes[i][1]
newholes[i][1] = tempholes[i][2]
# -- Project the plane
elif vertical == True:
for i in range(0, npolypoints):
newpolypoints[i][1] = temppolypoints[i][2]
for i in range(0, nholes):
newholes[i][1] = tempholes[i][2]
else:
pass # -- No changes here
# -- Drop the last point (identical to first)
for p in newpolypoints:
# print("p: ", p)
p.pop(-1)
# print("p: ", p)
# -- If there are no holes
if len(newholes) == 0:
newholes = None
else:
counter = 0
for h in newholes:
counter = counter + 1
h = h.pop(-1)
# -- Plane information (assumes planarity) #todo: hier muss noch etwas angepasst werden; erledigt?
a = e[0]
b = e[1]
c = e[2]
# -- Construct the plane
pl = planeAdjusted(e)
# -- Prepare the polygon to be triangulated
# Change by Th_Fr: Distinguishing different cases!
# There are two cases distinguished here: 1. A Polygon without holes, 2. A polygon with holes
if newholes == None:
poly = {'vertices': np.array(newpolypoints), 'segments': np.array(segments)}
# For some reason this if.case sometimes fails, this is why there is a second version of the
# Trinangulation without the optional 'pQjz' parameter
if has_duplicates(newpolypoints) == False:
t = triangle.triangulate(poly, 'pQjz')
else:
t = triangle.triangulate(poly)
else:
poly = {'vertices': np.array(newpolypoints), 'segments': np.array(segments), 'holes': np.array(newholes)}
t = triangle.triangulate(poly, 'pQjz')
# End of changes by Th_Fr
# -- Get the triangles and their vertices
try:
tris = t['triangles']
except:
print("strange error")
tris = []
try:
vert = t['vertices'].tolist()
except:
vert = []
# -- Store the vertices of each triangle in a list
tri_points = []
for tri in tris:
tri_points_tmp = []
for v in tri.tolist():
vert_adj = [[], [], []]
if YZ:
vert_adj[0] = temppolypoints[0][0]
vert_adj[1] = vert[v][0]
vert_adj[2] = vert[v][1]
elif vertical:
vert_adj[0] = vert[v][0]
vert_adj[2] = vert[v][1]
vert_adj[1] = get_y(pl, vert_adj[0], vert_adj[2])
else:
vert_adj[0] = vert[v][0]
vert_adj[1] = vert[v][1]
vert_adj[2] = get_height(pl, vert_adj[0], vert_adj[1])
tri_points_tmp.append(vert_adj)
try:
tri_normal = unit_normal(tri_points_tmp[0], tri_points_tmp[1], tri_points_tmp[2])
except:
continue
if compare_normals(normal, tri_normal):
tri_points.append(tri_points_tmp)
else:
tri_points_tmp = reverse_vertices(tri_points_tmp)
tri_points.append(tri_points_tmp)
return tri_points