Initial commit

This commit is contained in:
Robert Nasarek 2026-06-25 09:09:16 +02:00
commit a437c068c8
64 changed files with 561683 additions and 0 deletions

6
scripts/.env.example Normal file
View file

@ -0,0 +1,6 @@
BLENDER_BIN=''
# Optional override. If empty, scripts auto-detect the module root from this file location.
SPATH=
BACKUP_SETTINGS_PATH=/var/www/data/project/web/sites/default/settings.php
RENDER_RESOLUTION='1024x1024x16'
RENDER_SAMPLES='20'

14
scripts/2gltf2/2gltf2.bat Executable file
View file

@ -0,0 +1,14 @@
ECHO OFF
IF "%1"=="" GOTO USAGE
"C:/Program Files/Blender Foundation/Blender 2.93/blender.exe" -b -P 2gltf2.py -- %1
GOTO END
:USAGE
ECHO To glTF 2.0 converter.
ECHO Supported file formats: .abc .blend .dae .fbx. .obj .ply .stl .wrl .x3d
ECHO.
ECHO 2gltf2.bat [filename]
:END

127
scripts/2gltf2/2gltf2.py Executable file
View file

@ -0,0 +1,127 @@
#
# The MIT License (MIT)
#
# Copyright (c) since 2017 UX3D GmbH
#
# 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.
#
#
# Imports
#
import bpy
import os
import sys
import argparse
if '--' in sys.argv:
argv = sys.argv[sys.argv.index('--') + 1:]
parser=argparse.ArgumentParser()
parser.add_argument("--input", help="Input file path")
parser.add_argument("--ext", help="Extenstion of imported file")
parser.add_argument("--org_ext", help="Original extenstion of imported file")
parser.add_argument("--output", help="Output file path")
parser.add_argument("--is_archive", help="Importing archive flag")
parser.add_argument("--resolution", help="Resolution preview images")
parser.add_argument("--samples", help="Samples rendering quality")
parser.add_argument("--compression", help="Compress object")
parser.add_argument("--compression_level", help="Compress object level")
args = parser.parse_known_args(argv)[0]
print('input: ', args.input)
print('ext: ', args.ext)
print('org_ext: ', args.org_ext)
print('output: ', args.output)
print('is_archive: ', args.is_archive)
print('resolution: ', args.resolution)
print('samples: ', args.samples)
print('compression: ', args.compression)
print('compression_level: ', args.compression_level)
#
# Globals
#
#
# Functions
#
current_directory = os.getcwd()
compression = "false"
compression_level = 3
if args.ext:
extension = args.ext
if extension == "gltf":
format = "GLTF_EMBEDDED"
else:
format = "GLB"
if args.org_ext:
original_extension = args.ext
if args.compression:
compression = args.compression
if args.compression_level:
compression_level = int(args.compression_level)
root, current_extension = os.path.splitext(args.input)
current_basename = os.path.basename(root)
if current_extension == ".abc" or current_extension == ".blend" or current_extension == ".dae" or current_extension == ".fbx" or current_extension == ".obj" or current_extension == ".ply" or current_extension == ".stl" or current_extension == ".wrl" or current_extension == ".x3d":
bpy.ops.wm.read_factory_settings(use_empty=True)
if current_extension == ".abc":
bpy.ops.wm.alembic_import(filepath=args.input)
if current_extension == ".blend":
bpy.ops.wm.open_mainfile(filepath=args.input)
if current_extension == ".dae":
bpy.ops.wm.collada_import(filepath=args.input)
if current_extension == ".fbx":
bpy.ops.import_scene.fbx(filepath=args.input)
if current_extension == ".obj":
bpy.ops.wm.obj_import(filepath=args.input)
if current_extension == ".ply":
bpy.ops.wm.ply_import(filepath=args.input)
if current_extension == ".stl":
bpy.ops.import_mesh.stl(filepath=args.input)
if current_extension == ".wrl" or current_extension == ".x3d":
bpy.ops.import_scene.x3d(filepath=args.input)
#
if args.output:
export_file = str(args.output)
#export_file = root + current_basename + "." + extension
else:
root = root[::-1].replace(current_basename[::-1], "", 1)[::-1]
export_file = root + "gltf/" + current_basename + "." + extension
print("Writing: '" + export_file + "'")
if compression == 'true':
bpy.ops.export_scene.gltf(filepath=export_file,export_format=format,export_draco_mesh_compression_enable=True,export_draco_mesh_compression_level=compression_level)
else:
bpy.ops.export_scene.gltf(filepath=export_file,export_format=format)

9
scripts/2gltf2/2gltf2.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/bash
#TODO PATH for Blender
if [ "$#" -ge 1 ]
then
/var/lib/snapd/snap/blender/current/blender -b -P 2gltf2.py -- "$1" "$2" "$3"
else
echo Supported file formats: .abc .blend .dae .fbx. .obj .ply .stl .wrl .x3d
echo 2gltf2.sh [filename]
fi

BIN
scripts/2gltf2/glTF.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,837 @@
##!/usr/bin/python
# -*- coding: utf-8 -*-
import config
# 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 markup3dmodule
import polygon3dmodule
import componentseparationmodule as csm
from lxml import etree
import os
import argparse
import glob
import numpy as np
import itertools
import matplotlib.pyplot as plt
import CityGMLTranslation as cgt
from decimal import Decimal
from config import setVersion
import time
# -- ARGUMENTS
# -i -- input directory (it will read and convert ALL CityGML files in a directory)
# -o -- output directory (it will output the generated OBJs in that directory in the way that Delft.gml becomes Delft.obj)
# -- SETTINGS of the converter (can be combined):
# -s 0 (default) -- converts all geometries in one class in one file under the same object (plain OBJ file).
# -s 1 -- differentiate between semantics, output each semantic class as one file, e.g. Delft-WallSurface.obj. Please note that in this case the grouped "plain" OBJ is not generated.
# if no thematic boundaries are found, this option is ignored.
# -g 0 (default) -- keeps all objects in the same bin.
# -g 1 -- it creates one object for every building.
# -v 1 -- validation
# -p 1 -- skip triangulation and write polygons. Polys with interior not supported.
# -t 1 -- translation (reduction) of coordinates so the smallest vertex (one with the minimum coordinates) is at (0, 0)
# -a 1 or 2 or 3 -- this is a very custom setting for adding the texture based on attributes, here you can see the settings for my particular case of the solar radiation. By default it is off.
# -- Text to be printed at the beginning of each OBJ
header = """# Converted from CityGML to OBJ with CityGML2OBJs.
# Conversion tool developed by Filip Biljecki, TU Delft <fbiljecki@gmail.com>, see more at Github:
# https://github.com/tudelft3d/CityGML2OBJs
#
"""
def get_index(point, list_vertices, shift=0):
"""Index the vertices.
The third option is for incorporating a local index (building-level) to the global one (dataset-level)."""
global vertices
"""Unique identifier and indexer of vertices."""
if point in list_vertices:
return list_vertices.index(point) + 1 + shift, list_vertices
else:
list_vertices.append(point)
return list_vertices.index(point) + 1 + shift, list_vertices
def write_vertices(list_vertices, cla):
"""Write the vertices in the OBJ format."""
global vertices_output
for each in list_vertices:
vertices_output[cla].append("v" + " " + str(each[0]) + " " + str(each[1]) + " " + str(each[2]) + "\n")
def remove_reccuring(list_vertices):
"""Removes recurring vertices, which messes up the triangulation.
Inspired by http://stackoverflow.com/a/1143432"""
# last_point = list_vertices[-1]
list_vertices_without_last = list_vertices[:-1]
found = set()
for item in list_vertices_without_last:
if str(item) not in found:
yield item
found.add(str(item))
def poly_to_obj(poly, cl, material=None):
"""Main conversion function of one polygon to one or more faces in OBJ,
in a specific semantic class. Supports assigning a material."""
global local_vertices
global vertices
global face_output
# -- Decompose the polygon into exterior and interior
e, i = markup3dmodule.polydecomposer(poly)
# -- Points forming the exterior LinearRing
epoints = markup3dmodule.GMLpoints(e[0])
# print(epoints)
# -- Clean recurring points, except the last one
last_ep = epoints[-1]
epoints_clean = list(remove_reccuring(epoints))
epoints_clean.append(last_ep)
# print("epoints: ", epoints)
# print("epoints_clean: ", epoints_clean)
# -- LinearRing(s) forming the interior
irings = []
for iring in i:
ipoints = markup3dmodule.GMLpoints(iring)
# -- Clean them in the same manner as the exterior ring
last_ip = ipoints[-1]
ipoints_clean = list(remove_reccuring(ipoints))
ipoints_clean.append(last_ip)
irings.append(ipoints_clean)
# print("irings: ", irings)
# -- If the polygon validation option is enabled
if VALIDATION:
# -- Check the polygon
valid = polygon3dmodule.isPolyValid(epoints_clean, True)
if valid:
for iring in irings:
if not polygon3dmodule.isPolyValid(iring, False):
valid = False
# -- If everything is valid send them to the Delaunay triangulation
if valid:
if SKIPTRI:
# -- Triangulation is skipped, polygons are converted directly to faces
# -- The last point is removed since it's equal to the first one
t = [epoints_clean[:-1]]
else:
# -- Triangulate polys
# t = polygon3dmodule.triangulation(epoints, irings)
try:
t = polygon3dmodule.triangulation(epoints_clean, irings)
except:
t = []
# t = polygon3dmodule.triangulation(epoints_clean, irings)
# -- Process the triangles/polygons
for tri in t:
# -- Face marker
f = "f "
# -- For each point in the triangle/polygon (face) get the index "v" or add it to the index
for ep in range(0, len(tri)):
v, local_vertices[cl] = get_index(tri[ep], local_vertices[cl], len(vertices[cl]))
f += str(v) + " "
# -- Add the material if invoked
if material:
face_output[cl].append("usemtl " + str(mtl(material, min_value, max_value, res)) + str("\n"))
# -- Store all together
face_output[cl].append(f + "\n")
else:
# Get the gml:id of the Polygon if it exists
polyid = poly.xpath("@g:id", namespaces={'g': ns_gml})
if polyid:
polyid = polyid[0]
print("\t\t!! Detected an invalid polygon (%s). Skipping..." % polyid)
else:
print("\t\t!! Detected an invalid polygon. Skipping...")
else:
# -- Do exactly the same, but without the validation
try:
if SKIPTRI:
t = [epoints_clean[:-1]]
else:
t = polygon3dmodule.triangulation(epoints_clean, irings)
except:
t = []
for tri in t:
f = "f "
for ep in range(0, len(tri)):
v, local_vertices[cl] = get_index(tri[ep], local_vertices[cl], len(vertices[cl]))
f += str(v) + " "
if material:
face_output[cl].append("usemtl " + str(mtl(material, min_value, max_value, res)) + str("\n"))
face_output[cl].append(f + "\n")
# -- Parse command-line arguments
PARSER = argparse.ArgumentParser(description='Convert a CityGML to OBJ.')
PARSER.add_argument('-i', '--directory',
help='Directory containing CityGML file(s).', required=True)
PARSER.add_argument('-o', '--results',
help='Directory where the OBJ file(s) should be written.', required=True)
PARSER.add_argument('-s', '--semantics',
help='Write one OBJ (0) or multiple OBJ per semantic class (1). 0 is default.', required=False)
PARSER.add_argument('-g', '--grouping',
help='Writes all buildings in one group (0) or multiple groups (1). 0 is default.', required=False)
PARSER.add_argument('-a', '--attribute',
help='Creates a texture regarding the value of an attribute of the surface. No material is default.',
required=False)
PARSER.add_argument('-v', '--validation',
help='Validates polygons, and if they are not valid give a warning and skip them. No validation is default.',
required=False)
PARSER.add_argument('-t', '--translate',
help='Translates all vertices, so that the smallest vertex is at zero. No translation is default.',
required=False)
PARSER.add_argument('-p', '--polypreserve',
help='Skip the triangulation (preserve polygons). Triangulation is default.', required=False)
# Changes by Th_Fr: 2 New optional parameters added, see description for details!
PARSER.add_argument('-tC', '--translateCityGML',
help='Perform a Translation of the CityGML Dataset into a local CRS before further processing. No translation is default.',
required=False)
PARSER.add_argument('-tCw', '--translateCityGMLwrite',
help='Perform a Translation of the CityGML Dataset into a local CRS before further processing. The translation parameters are stored in a designated .txt file. No Translation is default ',
required=False)
PARSER.add_argument('-sepC', '--separateComponents',
help='Save each building component into an individual file with the filename serving as an identifier.',
required=False)
PARSER.add_argument('-appW', '--approximateWindows',
help='Approximate windows by their convex hulls to save some processing time.',
required=False)
PARSER.add_argument('-addBB', '--addBoundingBox',
help='Add small triangles defining the bounding box to each of the components.',
required=False)
# Todo: Neue funktion muss noch fertig implementiert werden
PARSER.add_argument('-importBB', '--importBoundingBox',
help='Add small triangles defining an imported bounding box to each of the components.',
required=False)
PARSER.add_argument('-addBBJSON', '--addBoundingBoxJSON',
help='The bounding box of the building is additionally saved in a designated json-file',
required=False)
PARSER.add_argument('-tbw', '--translateBuildingWise', # todo: implementation yet to be completed
help='Translate into a local coordinate system building-wise.',
required=False)
# End of changes by Th_Fr
ARGS = vars(PARSER.parse_args())
DIRECTORY = os.path.join(ARGS['directory'], '')
RESULT = os.path.join(ARGS['results'], '')
SEMANTICS = ARGS['semantics']
if SEMANTICS == '1':
SEMANTICS = True
elif SEMANTICS == '0':
SEMANTICS = False
else:
SEMANTICS = False
OBJECTS = ARGS['grouping']
if OBJECTS == '1':
OBJECTS = True
elif OBJECTS == '0':
OBJECTS = False
else:
OBJECTS = False
ATTRIBUTE = ARGS['attribute']
if ATTRIBUTE == '1':
ATTRIBUTE = 1
elif ATTRIBUTE == '2':
ATTRIBUTE = 2
elif ATTRIBUTE == '3':
ATTRIBUTE = 3
elif ATTRIBUTE == '0':
ATTRIBUTE = False
else:
ATTRIBUTE = False
VALIDATION = ARGS['validation']
if VALIDATION == '1':
VALIDATION = True
elif VALIDATION == '0':
VALIDATION = False
else:
VALIDATION = False
TRANSLATE = ARGS['translate']
if TRANSLATE == '1':
TRANSLATE = True
elif TRANSLATE == '0':
TRANSLATE = False
else:
TRANSLATE = False
if TRANSLATE:
global smallest_point
SKIPTRI = ARGS['polypreserve']
if SKIPTRI == '1':
SKIPTRI = True
elif SKIPTRI == '0':
SKIPTRI = False
else:
SKIPTRI = False
# Changes By Th_Fr:
TRANSLATECGML = ARGS['translateCityGML']
if TRANSLATECGML == '1':
TRANSLATECGML = True
elif TRANSLATECGML == '0':
TRANSLATECGML = False
else:
TRANSLATECGML = False
TRANSLATECGMLW = ARGS['translateCityGMLwrite']
if TRANSLATECGMLW == '1':
TRANSLATECGMLW = True
elif TRANSLATECGMLW == '0':
TRANSLATECGMLW = False
else:
TRANSLATECGMLW = False
SEPARATERCOMPONENTS = ARGS['separateComponents']
if SEPARATERCOMPONENTS == '1':
SEPARATERCOMPONENTS = True
elif SEPARATERCOMPONENTS == '0':
SEPARATERCOMPONENTS = False
else:
SEPARATERCOMPONENTS = False
APPROXIMATEWINDOWS = ARGS['approximateWindows']
if APPROXIMATEWINDOWS == '1':
APPROXIMATEWINDOWS = True
elif APPROXIMATEWINDOWS == '0':
APPROXIMATEWINDOWS = False
else:
APPROXIMATEWINDOWS = False
ADDBOUNDINGBOX = ARGS['addBoundingBox']
if ADDBOUNDINGBOX == '1':
ADDBOUNDINGBOX = True
elif ADDBOUNDINGBOX == '0':
ADDBOUNDINGBOX = False
else:
ADDBOUNDINGBOX = False
IMPORTBOUNDINGBOX = ARGS['importBoundingBox']
if ADDBOUNDINGBOX == True:
IMPORTBOUNDINGBOX = None
ADDBOUNDINGBOXJSON = ARGS['addBoundingBoxJSON']
if ADDBOUNDINGBOXJSON == '1':
ADDBOUNDINGBOXJSON = True
elif ADDBOUNDINGBOXJSON == '0' and IMPORTBOUNDINGBOX is not None:
ADDBOUNDINGBOXJSON = False
else:
ADDBOUNDINGBOXJSON = False
if IMPORTBOUNDINGBOX is not None:
ADDBOUNDINGBOXJSON = True
TRANSLATEBUILDINGS = ARGS['translateBuildingWise'] # todo: muss noch implementiert werden
if TRANSLATEBUILDINGS == '1':
TRANSLATEBUILDINGS = True
elif TRANSLATEBUILDINGS == '0':
TRANSLATEBUILDINGS = False
else:
TRANSLATEBUILDINGS = False
# End of Changes by Th_Fr
# -----------------------------------------------------------------
# -- Attribute stuff
# -- Number of classes (colours)
res = 101
# -- Configuration
# -- Color the surfaces based on the normalised kWh/m^2 value <irradiation>. The plain OBJ will be coloured for the total irradiation.
if ATTRIBUTE == 1:
min_value = 350.0 # 234.591880403
max_value = 1300.0 # 1389.97943395
elif ATTRIBUTE == 2:
min_value = 157.0136575
max_value = 83371.4359245
elif ATTRIBUTE == 3:
min_value = 24925.0
max_value = 103454.0
# -- Statistic parameter
atts = []
# -- Colouring function
def mtl(att, min_value, max_value, res):
"""Finds the corresponding material."""
ar = np.linspace(0, 1, res).tolist()
# -- Get rid of floating point errors
for i in range(0, len(ar)):
ar[i] = round(ar[i], 4)
# -- Normalise the attribute
v = float(att - min_value) / (max_value - min_value)
# -- Get the material
assigned_material = min(ar, key=lambda x: abs(x - v))
return str(assigned_material)
# -----------------------------------------------------------------
# Start time
start_time = time.time()
# -- Start of the program
print("CityGML2OBJ. Searching for CityGML files...")
global _VERSION
# -- Find all CityGML files in the directory
os.chdir(DIRECTORY)
# -- Supported extensions
# Old version: types = ('*.gml', '*.GML', '*.xml', '*.XML')
types = ('*.gml', '*.xml')
files_found = []
for files in types:
files_found.extend(glob.glob(files))
for f in files_found:
FILENAME = f[:f.rfind('.')]
FULLPATH = os.path.join(DIRECTORY, f)
# -- Reading and parsing the CityGML file(s)
CITYGML = etree.parse(FULLPATH)
# -- Getting the root of the XML tree
root = CITYGML.getroot()
# -- Determine CityGML version
# If 1.0
if root.tag == "{http://www.opengis.net/citygml/1.0}CityModel":
print("CityGML 1.0")
config.setVersion(1)
# -- Name spaces
ns_citygml = "http://www.opengis.net/citygml/1.0"
ns_gml = "http://www.opengis.net/gml"
ns_bldg = "http://www.opengis.net/citygml/building/1.0"
ns_tran = "http://www.opengis.net/citygml/transportation/1.0"
ns_veg = "http://www.opengis.net/citygml/vegetation/1.0"
ns_gen = "http://www.opengis.net/citygml/generics/1.0"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_xAL = "urn:oasis:names:tc:ciq:xsdschema:xAL:1.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_dem = "http://www.opengis.net/citygml/relief/1.0"
ns_frn = "http://www.opengis.net/citygml/cityfurniture/1.0"
ns_tun = "http://www.opengis.net/citygml/tunnel/1.0"
ns_wtr = "http://www.opengis.net/citygml/waterbody/1.0"
ns_brid = "http://www.opengis.net/citygml/bridge/1.0"
ns_app = "http://www.opengis.net/citygml/appearance/1.0"
# added by Th_Fr
elif root.tag == "{http://www.opengis.net/citygml/3.0}CityModel":
print("CityGML 3.0")
config.setVersion(3)
ns_citygml = "http://www.opengis.net/citygml/3.0"
ns_con = "http://www.opengis.net/citygml/construction/3.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_gml = "http://www.opengis.net/gml/3.2"
ns_bldg = "http://www.opengis.net/citygml/building/3.0"
ns_app = "http://www.opengis.net/citygml/appearance/3.0"
ns_pcl = "http://www.opengis.net/citygml/pointcloud/3.0"
ns_gen = "http://www.opengis.net/citygml/generics/3.0"
ns_gss = "http://www.isotc211.org/2005/gss"
na_pfx0 = "urn:oasis:names:tc:ciq:xsdschema:xAL:2.0"
ns_gsr = "http://www.isotc211.org/2005/gsr"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_gco = "http://www.isotc211.org/2005/gco"
ns_tran = "http://www.opengis.net/citygml/transportation/3.0"
ns_gmd = "http://www.isotc211.org/2005/gmd"
ns_gts = "http://www.isotc211.org/2005/gts"
ns_veg = "http://www.opengis.net/citygml/vegetation/3.0"
ns_xAL = "urn:oasis:names:tc:ciq:xal:3"
ns_dem = "http://www.opengis.net/citygml/relief/3.0"
ns_brid = "http://www.opengis.net/citygml/bridge/3.0"
ns_frn = "http://www.opengis.net/citygml/cityfurniture/3.0"
ns_tun = "http://www.opengis.net/citygml/tunnel/3.0"
ns_wtr = "http://www.opengis.net/citygml/waterbody/3.0"
# -- Else probably means 2.0
else:
print("CityGML 2.0")
config.setVersion(2)
# -- Name spaces
ns_citygml = "http://www.opengis.net/citygml/2.0"
ns_gml = "http://www.opengis.net/gml"
ns_bldg = "http://www.opengis.net/citygml/building/2.0"
ns_tran = "http://www.opengis.net/citygml/transportation/2.0"
ns_veg = "http://www.opengis.net/citygml/vegetation/2.0"
ns_gen = "http://www.opengis.net/citygml/generics/2.0"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_xAL = "urn:oasis:names:tc:ciq:xsdschema:xAL:2.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_dem = "http://www.opengis.net/citygml/relief/2.0"
ns_frn = "http://www.opengis.net/citygml/cityfurniture/2.0"
ns_tun = "http://www.opengis.net/citygml/tunnel/2.0"
ns_wtr = "http://www.opengis.net/citygml/waterbody/2.0"
ns_brid = "http://www.opengis.net/citygml/bridge/2.0"
ns_app = "http://www.opengis.net/citygml/appearance/2.0"
nsmap = {
None: ns_citygml,
'gml': ns_gml,
'bldg': ns_bldg,
'tran': ns_tran,
'veg': ns_veg,
'gen': ns_gen,
'xsi': ns_xsi,
'xAL': ns_xAL,
'xlink': ns_xlink,
'dem': ns_dem,
'frn': ns_frn,
'tun': ns_tun,
'brid': ns_brid,
'app': ns_app
}
# Changes by Th_FR
if TRANSLATECGML:
cgt.translateToLocalCRS(CITYGML, FILENAME, root, ns_bldg, ns_gml, ns_citygml, ns_frn, ns_veg, RESULT,
write2file=False,
applyHeight=Decimal("0")) # Todo: by TH_Fr: Diese Funktion ist noch nicht fertig
if TRANSLATECGMLW:
cgt.translateToLocalCRS(CITYGML, FILENAME, root, ns_bldg, ns_gml, ns_citygml, ns_frn, ns_veg, RESULT,
write2file=True, applyHeight=Decimal("0"))
# End of changes by Th_FR
# -- Empty lists for cityobjects and buildings
cityObjects = []
buildings = []
other = []
# -- This denotes the dictionaries in which the surfaces are put.
output = {}
vertices_output = {}
face_output = {}
# -- This denotes the dictionaries in which all surfaces are put. It is later ignored in the semantic option was invoked.
output['All'] = []
output['All'].append(header)
if ATTRIBUTE:
output['All'].append("mtllib colormap.mtl\n")
vertices_output['All'] = []
face_output['All'] = []
# -- If the semantic option was invoked, this part adds additional dictionaries.
if SEMANTICS:
# -- Easy to modify list of thematic boundaries
semanticSurfaces = ['GroundSurface', 'WallSurface', 'RoofSurface', 'ClosureSurface', 'CeilingSurface',
'InteriorWallSurface', 'FloorSurface', 'OuterCeilingSurface', 'OuterFloorSurface', 'Door',
'Window']
for semanticSurface in semanticSurfaces:
output[semanticSurface] = []
output[semanticSurface].append(header)
# -- Add the material library
if ATTRIBUTE:
output[semanticSurface].append("mtllib colormap.mtl\n")
vertices_output[semanticSurface] = []
face_output[semanticSurface] = []
# -- Directory of vertices (indexing)
vertices = {}
vertices['All'] = []
if SEMANTICS:
for semanticSurface in semanticSurfaces:
vertices[semanticSurface] = []
vertices['Other'] = []
face_output['Other'] = []
output['Other'] = []
# -- Find all instances of cityObjectMember and put them in a list
for obj in root.getiterator('{%s}cityObjectMember' % ns_citygml):
cityObjects.append(obj)
print(FILENAME)
if len(cityObjects) > 0:
# -- Report the progress and contents of the CityGML file
print("\tThere are", len(cityObjects), "cityObject(s) in this CityGML file.")
# -- Store each building separately
for cityObject in cityObjects:
for child in cityObject.getchildren():
if child.tag == '{%s}Building' % ns_bldg:
buildings.append(child)
for cityObject in cityObjects:
for child in cityObject.getchildren():
if child.tag == '{%s}Road' % ns_tran or child.tag == '{%s}PlantCover' % ns_veg or \
child.tag == '{%s}GenericCityObject' % ns_gen or child.tag == '{%s}CityFurniture' % ns_frn or \
child.tag == '{%s}Relief' % ns_dem or child.tag == '{%s}Tunnel' % ns_tun or \
child.tag == '{%s}WaterBody' % ns_wtr or child.tag == '{%s}Bridge' % ns_brid:
other.append(child)
print("\tAnalysing objects and extracting the geometry...")
# -- Count the buildings
b_counter = 0
b_total = len(buildings)
print(" There are ", b_total, " buildings in the dataset")
# -- Do each building separately
for b in buildings:
# addd by th_fr
if SEPARATERCOMPONENTS:
json_filepath = RESULT + "index.json"
# todo: mus snoch implementiert werde
csm.addCRSToJSON(root, json_filepath)
csm.separateComponents(b, RESULT, APPROXIMATEWINDOWS=APPROXIMATEWINDOWS,
ADDBOUNDINGBOX=ADDBOUNDINGBOX, ADDBOUNDINGBOXJSON=ADDBOUNDINGBOXJSON,
TRANSLATEBUILDINGS=TRANSLATEBUILDINGS, IMPORTBOUNDINGBOX=IMPORTBOUNDINGBOX, b_counter=b_counter)
# End time
end_time = time.time()
# Calculate elapsed time
elapsed_time = end_time - start_time
print(f"Elapsed time: {elapsed_time:.2f} seconds")
b_counter += 1
continue
# -- Build the local list of vertices to speed up the indexing
local_vertices = {}
local_vertices['All'] = []
if SEMANTICS:
for semanticSurface in semanticSurfaces:
local_vertices[semanticSurface] = []
# -- Increment the building counter
b_counter += 1
# -- If the object option is on, get the name for each building or create one
if OBJECTS:
ob = b.xpath("@g:id", namespaces={'g': ns_gml})
if not ob:
ob = b_counter
else:
ob = ob[0]
# -- Print progress for large files every 1000 buildings.
if b_counter == 1000:
print("\t1000... ", )
elif b_counter % 1000 == 0 and b_counter == (b_total - b_total % 1000):
print(str(b_counter) + "...")
elif b_counter > 0 and b_counter % 1000 == 0:
print(str(b_counter) + "... ", )
# -- Add the object identifier
if OBJECTS:
face_output['All'].append('o ' + str(ob) + '\n')
# -- Add the attribute for the building
if ATTRIBUTE:
for ch in b.getchildren():
if ch.tag == "{%s}yearlyIrradiation" % ns_citygml:
bAttVal = float(ch.text)
# -- OBJ with all surfaces in the same bin
polys = markup3dmodule.polygonFinder(b)
# -- Process each surface
polycounter = 0
for poly in polys:
if ATTRIBUTE:
poly_to_obj(poly, 'All', bAttVal)
if ATTRIBUTE == 3:
atts.append(bAttVal)
else:
# print etree.tostring(poly)
poly_to_obj(poly, 'All')
polycounter = polycounter + 1
# -- Semantic decomposition, with taking special care about the openings
if SEMANTICS:
# -- First take care about the openings since they can mix up
openings = []
openingpolygons = []
for child in b.getiterator():
if child.tag == '{%s}opening' % ns_bldg:
openings.append(child)
for o in child.findall('.//{%s}Polygon' % ns_gml):
openingpolygons.append(o)
# -- Process each opening
for o in openings:
for child in o.getiterator():
unique_identifier = child.xpath("@g:id", namespaces={'g': ns_gml})
if child.tag == '{%s}Window' % ns_bldg or child.tag == '{%s}Door' % ns_bldg:
# print(unique_identifier)
if child.tag == '{%s}Window' % ns_bldg:
t = 'Window'
# print(t)
else:
t = 'Door'
# print(t)
polys = markup3dmodule.polygonFinder(o)
for poly in polys:
poly_to_obj(poly, t)
# -- Process other thematic boundaries
for cl in output:
cls = []
for child in b.getiterator():
if child.tag == '{%s}%s' % (ns_bldg, cl):
cls.append(child)
# -- Is this the first feature of this object?
firstF = True
for feature in cls:
# -- If it is the first feature, print the object identifier
unique_identifier = feature.xpath("@g:id", namespaces={
'g': ns_gml})
if OBJECTS and firstF:
face_output[cl].append('o ' + str(ob) + "_" + str(unique_identifier) + '\n')
firstF = False
# -- This is not supposed to happen, but just to be sure...
if feature.tag == '{%s}Window' % ns_bldg or feature.tag == '{%s}Door' % ns_bldg:
continue
# print(f"unigue identifier: {str(ob) + str(unique_identifier)}")
# -- Find all polygons in this semantic boundary hierarchy
for p in feature.findall('.//{%s}Polygon' % ns_gml):
if ATTRIBUTE == 1 or ATTRIBUTE == 2:
# -- Flush the previous value
attVal = None
if cl == 'RoofSurface':
# print p.xpath("//@c:irradiation", namespaces={'c' : ns_citygml})
# -- Silly way but it works, as I can't get the above xpath to work for some reason
for ch in p.getchildren():
if ATTRIBUTE == 1:
if ch.tag == "{%s}irradiation" % ns_citygml:
attVal = float(ch.text)
atts.append(attVal)
elif ATTRIBUTE == 2:
if ch.tag == "{%s}totalIrradiation" % ns_citygml:
attVal = float(ch.text)
atts.append(attVal)
elif ATTRIBUTE == 3:
attVal = None
if cl == 'RoofSurface':
attVal = bAttVal
else:
# -- If the attribute option is off, pass no material
attVal = None
found_opening = False
for optest in openingpolygons:
if p == optest:
found_opening = True
break
# -- If there is an opening skip it
if found_opening:
pass
else:
# -- Finally process the polygon
poly_to_obj(p, cl, attVal)
# -- Merge the local list of vertices to the global
for cl in local_vertices:
for vertex in local_vertices[cl]:
vertices[cl].append(vertex)
if len(other) > 0:
vertices_output['Other'] = []
local_vertices = {}
local_vertices['Other'] = []
for oth in other:
# local_vertices = {}
# local_vertices['All'] = []
polys = markup3dmodule.polygonFinder(oth)
# -- Process each surface
for poly in polys:
poly_to_obj(poly, 'Other')
for vertex in local_vertices['Other']:
vertices['Other'].append(vertex)
print("\tExtraction done. Sorting geometry and writing file(s).")
# -- Translate (convert) the vertices to a local coordinate system
if TRANSLATE:
print("\tTranslating the coordinates of vertices.")
list_of_all_vertices = []
for cl in output:
if len(vertices[cl]) > 0:
for vtx in vertices[cl]:
list_of_all_vertices.append(vtx)
smallest_vtx = polygon3dmodule.smallestPoint(list_of_all_vertices)
dx = smallest_vtx[0]
dy = smallest_vtx[1]
dz = smallest_vtx[2]
for cl in output:
if len(vertices[cl]) > 0:
for idx, vtx in enumerate(vertices[cl]):
vertices[cl][idx][0] = vtx[0] - dx
vertices[cl][idx][1] = vtx[1] - dy
vertices[cl][idx][2] = vtx[2] - dz
# -- Write the OBJ(s)
os.chdir(RESULT)
# -- Theme by theme
for cl in output:
if len(vertices[cl]) > 0:
write_vertices(vertices[cl], cl)
output[cl].append("\n" + ''.join(vertices_output[cl]))
output[cl].append("\n" + ''.join(face_output[cl]))
if cl == 'All':
adj_suffix = ""
else:
adj_suffix = "-" + str(cl)
with open(RESULT + FILENAME + str(adj_suffix) + ".obj", "w") as obj_file:
obj_file.write(''.join(output[cl]))
print("\tOBJ file(s) written.")
# -- Print the range of attributes. Useful for defining the range of the colorbar.
if ATTRIBUTE:
print('\tRange of attributes:', min(atts), '--', max(atts))
else:
print(
"\tThere is a problem with this file: no cityObjects have been found. Please check if the file complies to CityGML.")
# End time
end_time = time.time()
# Calculate elapsed time
elapsed_time = end_time - start_time
print(f"Elapsed time: {elapsed_time:.2f} seconds")

View file

@ -0,0 +1,339 @@
##!/usr/bin/python
# -*- coding: utf-8 -*-
# The MIT License (MIT)
# This code is part of the CityGML2OBJs package
# Copyright (c) 2023
# Thomas Fröch
# Technische Universität München (TUM)
# thomas.froech@tum.de
# 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 markup3dmodule as m3dm
from decimal import Decimal, getcontext
import numpy as np
# Setting the precision of Decimal
getcontext().prec = 28
def performStableAddition(number1, number2):
# Input: string number1, string number 2, output: string sum
# Determine the number of positions after the comma.
# print("Number 1 totally before: ", number1)
# print("Number 2 totally before: ", number2)
try:
n_after_comma_number1 = len(number1.split(".")[1])
except:
n_after_comma_number1 = 0
try:
n_after_comma_number2 = len(number2.split(".")[1])
except:
n_after_comma_number2 = 0
lengths = [n_after_comma_number1, n_after_comma_number2]
# convert float numbers into integers by removing the comma
# print("Number 1 before all: ", number1)
# print("Number 2 before all: ", number2)
number1 = number1.replace(".", "")
number2 = number2.replace(".", "")
# print("Number 1 after: ", number1)
# print("Number 2 after: ", number2)
# find the number with more positions after the comma
abs_lengths = np.abs(lengths) # Erstellen eines Arrays mit den Beträgen
max_index = np.argmax(abs_lengths)
# Distinguish the two different cases
# Case 1: number one has more positions after the comma than number two
if max_index == 0:
# Find the difference of positions after the comma
n_positions_difference = (lengths[0] - lengths[1])
# print("n_positions_difference: ", n_positions_difference)
# Fill up the missing digits of number 2 with zeros
# print("number2 before: ", number2)
for i in range(n_positions_difference):
number2 = number2 + "0"
# print("number2_after", number2)
# print("Number1: ", number1)
# convert both stings into integers
number1_int = np.double(number1)
number2_int = np.double(number2)
# Add both numbers
number_sum = number1_int + number2_int
# convert back into string
format_string = "{:.0f}"
number_sum_string = format_string.format(number_sum)
# Insert the comma at the correct position
length_of_number = len(number_sum_string.replace("-",
""))
if length_of_number >= lengths[max_index]:
if n_after_comma_number1 != 0:
number_sum_string = number_sum_string[:-(lengths[0])] + "." + number_sum_string[-(lengths[0]):]
return number_sum_string
else:
number_sum_string = number_sum_string
return number_sum_string
elif length_of_number < n_after_comma_number1:
new_string = ""
for i in range(len(number_sum_string)):
if number_sum_string[i] == "-":
new_string += "0."
else:
new_string += number_sum_string[i]
number_sum_string = new_string
return number_sum_string
else:
number_sum_string = number_sum_string
return number_sum_string
# Case 2: number two has more positions after the comma than number one
else:
# find the difference of positions after the comma
n_positions_difference = lengths[1] - lengths[0]
# Fill up the missing digits of number 2 with zeros
for i in range(n_positions_difference):
number1 = number1 + "0"
# convert both stings into integers
number1_int = Decimal(number1)
number2_int = Decimal(number2)
# Add both numbers
number_sum = number1_int + number2_int
# convert back into string
format_string = "{:.0f}"
number_sum_string = format_string.format(number_sum)
# Insert the comma at the correct position
length_of_number = len(number_sum_string.replace("-",
""))
if length_of_number > n_after_comma_number2:
if n_after_comma_number2 != 0:
number_sum_string = number_sum_string[:-(lengths[1])] + "." + number_sum_string[-(lengths[1]):]
return number_sum_string
else:
number_sum_string = number_sum_string
return number_sum_string
elif length_of_number < n_after_comma_number2:
new_string = ""
for i in range(len(number_sum_string)):
if number_sum_string[i] == "-":
new_string += "0."
else:
new_string += number_sum_string[i]
number_sum_string = new_string
return number_sum_string
else:
number_sum_string = number_sum_string
return number_sum_string
# This function is used in order to extract all the envelopes from the CityGML-File
# These envelopes are going to be used in order to determine the translation parameters later
def getEnvelopes(root, ns_bldg, ns_gml, ns_citygml):
envelopes = []
for envelope in root.getiterator('{%s}Envelope' % ns_gml):
envelopes.append(envelope)
return envelopes
# This function is used in order to calculate the translation parameters from the
# envelopes that were previously extracted from the envelopes
def getTranslationParameters(envelopes, ns_gml):
# Setting up some initial values
dx = Decimal("0")
dy = Decimal("0")
lowerCorner = []
upperCorner = []
# Iterating through all the envelopes in the CityGML-File
for envelope in envelopes:
# print(" Envelope: ", envelope)
# Finding the upper and the lower corner
for child in envelope.getchildren():
if child.tag == '{%s}lowerCorner' % ns_gml:
lowerCorner.append(child.text)
elif child.tag == '{%s}upperCorner' % ns_gml:
upperCorner.append(child.text)
# Converting into Decimal
pointCounter = 0
for point in lowerCorner:
dy = dy + (Decimal(point.split(" ")[0]))
dx = dx + (Decimal(point.split(" ")[1]))
pointCounter = pointCounter + 1
dyret = -dy / pointCounter
dxret = -dx / pointCounter
return [Decimal(str(int(dxret))), Decimal(str(int(dyret)))]
# This function is used to Parse the text from the CityGML file to Decimal numbers and
# to aplly the previously calculated translation parameters
# Notice: this code only allows ONE "gml:PosList-Element" for each Interior and exterior of a Polygon
# If there are more than just one,just the first one is going to be transformed
def splitAndApplyTrafo(coordString, transParam):
# Splitting the coordinate string by empty spaces
split = coordString.split(" ")
alalala = len(split)
# Apply the Trafo
if split[0] == '':
split.pop(0)
if split[-1] == "":
split.pop(-1)
counter = 0
length = int(len(split))
length_new = int(length / 3)
for i in range(length_new):
# print("y")
split[counter] = performStableAddition(split[counter], str(transParam[1]))
# print("x")
split[counter + 1] = performStableAddition(split[counter + 1], str(transParam[0]))
# print("z")
split[counter + 2] = performStableAddition(split[counter + 2], str(transParam[2]))
counter += 3
# converting back to a string
translated = ""
for i in split:
if len(translated) == 0:
translated = str(i)
else:
translated = translated + " " + str(i)
return translated
# This code is used in order to find all the coordinates that are defined in the CityGML-File
# Please Notice: the search for coordinates here has the same limitations as the search for coordinates that
# is used in the "CityGML2OBJ" functionality!
def applyTranslationToCityGML(CITYGML, root, transParam, ns_citygml, ns_gml, ns_frn, ns_veg, filename):
# Iterate over all the cityObjectMembers
for obj in root.getiterator('{%s}cityObjectMember' % ns_citygml):
# Iterate over all the children of cityObject Member
for child in obj.getchildren():
# Exclude all the implicitly referenced objects from the transformation
if child.findall(
'.//{%s}ImplicitGeometry' % ns_citygml) == []:
polys = m3dm.polygonFinder(child)
# Iterate over all the polygons of the children of cityObjectMember
for poly in polys:
# decompose all the polygons in the interior and exterior rings
exter, inter = m3dm.polydecomposer(poly)
# iterate over all the exterior rings
for e in exter:
# find all the coordinates that are stored as a "posList"
if len(e.findall('.//{%s}posList' % ns_gml)) > 0:
points_tmp = e.findall('.//{%s}posList' % ns_gml)[0].text
points = points_tmp.replace('\n', ' ')
translated = splitAndApplyTrafo(points, transParam)
# print("Before: ", e.findall('.//{%s}posList' % ns_gml)[0].text)
e.findall('.//{%s}posList' % ns_gml)[0].text = translated
# print("Result: ", e.findall('.//{%s}posList' % ns_gml)[0].text)
# find all the coordinates that are stored as "pos"
elif len(e.findall('.//{%s}pos' % ns_gml)) > 0:
points = e.findall('.//{%s}pos' % ns_gml)
counter = 0
for k in points:
translated = splitAndApplyTrafo(k.text, transParam)
e.findall('.//{%s}pos' % ns_gml)[counter].text = translated
counter = counter + 1
# iterate over all the interior rings
for i in inter:
# find all the coordinates that are stored as a "posList"
if len(i.findall('.//{%s}posList' % ns_gml)) > 0:
points_tmp = i.findall('.//{%s}posList' % ns_gml)[0].text
points = points_tmp.replace('\n', ' ')
translated = splitAndApplyTrafo(points, transParam)
i.findall('.//{%s}posList' % ns_gml)[0].text = translated
# find all the coordinates that are stored as "pos"
elif len(i.findall('.//{%s}pos' % ns_gml)) > 0:
points = i.findall('.//{%s}pos' % ns_gml)
counter = 0
for k in points:
translated = splitAndApplyTrafo(k.text, transParam)
i.findall('.//{%s}pos' % ns_gml)[counter].text = translated
counter = counter + 1
else: # This condition is used in order to transform the reference points of the implicitly defined geometries
# Step 1: find all the reference points:
referencePoints = child.findall('.//{%s}referencePoint' % ns_citygml)
for referencePoint in referencePoints:
points_tmp = referencePoint.findall('.//{%s}pos' % ns_gml)
points = points_tmp.replace('\n', ' ')
counter = 0
for l in points:
translated = splitAndApplyTrafo(l.text, transParam)
referencePoint.findall('.//{%s}pos' % ns_gml)[counter].text = translated
counter = counter + 1
# Iterate over all the envelopes
for envelope in root.getiterator('{%s}Envelope' % ns_gml):
lowerCorner = envelope.findall('.//{%s}lowerCorner' % ns_gml)[0].text
upperCorner = envelope.findall('.//{%s}upperCorner' % ns_gml)[0].text
translatedLowerCorner = splitAndApplyTrafo(lowerCorner, transParam)
translatedUpperCorner = splitAndApplyTrafo(upperCorner, transParam)
envelope.findall('.//{%s}lowerCorner' % ns_gml)[0].text = translatedLowerCorner
envelope.findall('.//{%s}upperCorner' % ns_gml)[0].text = translatedUpperCorner
CITYGML.write(filename + "_local_" + ".gml")
return root
# This function is used in order to write the previously calculated translation parameters to a
# designated .txt file. The use of this functionality is optional and can be activated by setting the
# optional "write2file" parameter to "True" when calling the "translateToLocalCRS" - function.
def writeTransparam2File(filename, directory, transParam):
textfileName = directory + filename + "_Translation_Parameters.txt"
f = open(textfileName, "w")
f.write("This file contains the translation parameters that were applied to the original CityGML file." + "\n" +
"Conversion tool developed by Filip Biljecki, TU Delft <fbiljecki@gmail.com>" + "\n" +
"Conversion tool extended by Thomas Fröch, TUM <thomas.froech@tum.de>" + "\n" +
"see more at Github:" + "\n" +
"https://github.com/tudelft3d/CityGML2OBJs" + "\n" + "\n")
key = ['y', 'x', 'z']
for i in range(len(transParam)):
f.write(key[i] + ': ' + str(transParam[i]) + ' ')
f.close()
print("Translation parameters written to: " + textfileName)
return 0
def translateToLocalCRS(CITYGML, file, root, ns_bldg, ns_gml, ns_citygml, ns_frn, ns_veg, directory, write2file=False,
applyHeight=Decimal("0")):
envelopes = getEnvelopes(root, ns_bldg, ns_gml, ns_citygml)
transParam = getTranslationParameters(envelopes, ns_gml)
transParam.append(applyHeight)
if write2file == True:
writeTransparam2File(file, directory, transParam)
applyTranslationToCityGML(CITYGML, root, transParam, ns_citygml, ns_gml, ns_frn, ns_veg, file)
return 0

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Chair of Geoinformatics, Technical University of Munich
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.

View file

@ -0,0 +1,80 @@
# :cityscape: CityGML2OBJ 2.0 :cityscape:
Command line converter of **CityGML (.gml)** to **OBJ (.obj)** files, while maintaining the semantics
![](https://github.com/tum-gis/CityGML2OBJv2/blob/main/citygmltoobj2small.gif)
## :arrow_forward: How to run?
The `CityGML2OBJs.py` represents the starting point of the code, choose this file when configuring the runtime and pass the following parameters:
`-i your-input-citygml-path-here`
`-o your-output-obj-path-here`
Please make sure to use the absolute paths to the respective directories.
and Bob's your uncle! :construction_worker:
### :wrench: Optional features
| Optional feature | specification |
| -------- | -------- |
| Semanitcs Option|`-s 1`|
| Geometry Validation | `-v 1`|
| Object Preservation | `-g 1`|
| Skip the triangulation | `-p 1`|
| Conversion of the resulting dataset into a local coordinate system | `-t 1`|
| Translation of the CityGML dataset into a local coordinate system before further processing, without saving the translation parameters|`-tC 1`|
| Translation of the CityGML dataset into a local coordinate system before further processing, with saving the translation parameters to a designated .txt file|`-tCw 1`|
## :page_with_curl: Requirements
### Python packages:
+ [Numpy](http://docs.scipy.org/doc/numpy/user/install.html)
+ [Triangle](http://dzhelil.info/triangle/)
+ [lxml](http://lxml.de)
+ [Shapely](https://github.com/Toblerity/Shapely)
+ [Decimal](https://docs.python.org/3/library/decimal.html)
#### Optional:
+ [Matplotlib](http://matplotlib.org/users/installing.html)
### Tested:
Using Python 3.10 and Windows 10 OS
### CityGML Requirements:
#### Mandatory:
+ CityGML 1.0 or 2.0
+ Files must end with `.gml`, `.GML`, `.xml`, or `.XML`
+ Vertices in either `<gml:posList>` or `<gml:pos>`
+ Your files must be valid (e.g., free check with [CityDoctor](https://transfer.hft-stuttgart.de/gitlab/citydoctor/citydoctor2)
#### Optional, but recommended:
+ `<gml:id>` for each `<bldg:Building>` and other types of city objects
+ `<gml:id>` for each `<gml:Polygon>`
## Limitations
Information on the limitations can be found in this [Wiki Page](https://github.com/tum-gis/citygml2obj-2.0/wiki/Limitations)
## :handshake: Credits
We are indebted to [Filip Biljecki](https://github.com/fbiljecki), [Hugo Ledoux](https://github.com/hugoledoux) and [Ravi Peters](https://github.com/Ylannl) from [TU Delft](https://github.com/tudelft3d) for their initial version of the CityGML2OBJs converter. The archived version of the repo can still be found here: https://github.com/tudelft3d/CityGML2OBJs; the paper:
Biljecki, F., & Arroyo Ohori, K. (2015). Automatic semantic-preserving conversion between OBJ and CityGML. Eurographics Workshop on Urban Data Modelling and Visualisation 2015, pp. 25-30.
[[PDF]](https://filipbiljecki.com/publications/2015_udmv_citygml_obj.pdf) [[DOI]](http://doi.org/10.2312/udmv.20151345)
## :mailbox: Contact & Feedback
Feel free to open a discussion under Issues or write us an email
- [Thomas Froech](thomas.froech@tum.de)
- [Benedikt Schwab](benedikt.schwab@tum.de)
- [Olaf Wysocki](olaf.wysocki@tum.de)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,756 @@
import re
import config
import markup3dmodule as m3dm
import polygon3dmodule as p3dm
import json
import numpy as np
import open3d as o3d
import open3d.core as o3c
import os
# Function to create triangles at each corner in 3D
def create_corner_triangles(box_points, triangle_size=1):
triangles = []
for i, point in enumerate(box_points):
x, y, z = point
if i == 0: # Bottom-left-front corner
triangles.append([[x, y, z], [x + triangle_size, y, z], [x, y + triangle_size, z]])
elif i == 1: # Bottom-right-front corner
triangles.append([[x, y, z], [x - triangle_size, y, z], [x, y + triangle_size, z]])
elif i == 2: # Top-left-front corner
triangles.append([[x, y, z], [x + triangle_size, y, z], [x, y - triangle_size, z]])
elif i == 3: # Bottom-left-back corner
triangles.append([[x, y, z], [x + triangle_size, y, z], [x, y + triangle_size, z]])
elif i == 4: # Top-right-back corner
triangles.append([[x, y, z], [x - triangle_size, y, z], [x, y - triangle_size, z]])
elif i == 5: # Top-left-back corner
triangles.append([[x, y, z], [x + triangle_size, y, z], [x, y - triangle_size, z]])
elif i == 6: # Bottom-right-back corner
triangles.append([[x, y, z], [x - triangle_size, y, z], [x, y + triangle_size, z]])
elif i == 7: # Top-right-front corner
triangles.append([[x, y, z], [x - triangle_size, y, z], [x, y - triangle_size, z]])
return triangles
def addTranslationParameters(e, i, trans_param):
# Convert lists to numpy arrays for easier manipulation
e = np.array(e)
# i = np.array(i)
i_translated = []
trans_param = np.array(trans_param)
# case distinction for empty translation parameters
if len(trans_param) > 0:
# Subtract the translation parameters from each point
if len(e) > 0:
e_translated = e - trans_param
elif len(e) == 0:
e_translated = np.asarray([])
# Iterate over all the different interior rings
if len(i) > 0:
for interior_ring in i:
interior_ring = np.asarray(interior_ring)
# Translate the interior ring
interior_ring_translated = interior_ring - trans_param
# Coollect the translated interior rings
i_translated.append(interior_ring_translated.tolist())
else:
i_translated = i
return e_translated.tolist(), i_translated
else:
return e.tolist(), i
def getBufferedBBoxPoints(b):
# Schritt 1: identifying all wallsurfaces and roof surfaces of the building
output = {}
specifyVersion()
# comprehensive list of semantic surfaces
semanticSurfaces = ['GroundSurface', 'WallSurface', 'RoofSurface', 'ClosureSurface', 'CeilingSurface', ]
for semanticSurface in semanticSurfaces:
output[semanticSurface] = []
data = []
for cl in output:
cls = []
for child in b.getiterator():
if child.tag == '{%s}%s' % (ns_bldg, cl):
cls.append(child)
for feature in cls:
for p in feature.findall('.//{%s}Polygon' % ns_gml):
e, i = m3dm.polydecomposer(p)
epoints = m3dm.GMLpoints(e[0])
# -- Clean recurring points, except the last one
last_ep = epoints[-1]
epoints_clean = list(remove_reccuring(epoints))
epoints_clean.append(last_ep)
for point in epoints_clean:
data.append(point)
# Schritt 2: Idetify the Bounding volume
# 2.1 creating an open3d pointcloud from all the idetified vertex points
pcd = o3d.t.geometry.PointCloud(o3c.Tensor(data, o3c.float32))
# 2.2 obtain the axis aligned boundign box of the point cloud
axis_aligned_bb = pcd.get_axis_aligned_bounding_box()
# Schritt 3: Construct small triangles that describe the boundign box sufficienly
box_points = axis_aligned_bb.get_box_points().numpy().tolist()
# Convert the list to a numpy array for easier manipulation
box_points = np.array(box_points)
# Calculate the min and max coordinates
min_x, min_y, min_z = np.min(box_points, axis=0)
max_x, max_y, max_z = np.max(box_points, axis=0)
# Add a 3m buffer
buffer = 3
min_x -= buffer
min_y -= buffer
min_z -= buffer
max_x += buffer
max_y += buffer
max_z += buffer
# Define the buffered bounding box points
buffered_box_points = np.array([
[min_x, min_y, min_z],
[max_x, min_y, min_z],
[min_x, max_y, min_z],
[min_x, min_y, max_z],
[max_x, max_y, max_z],
[min_x, max_y, max_z],
[max_x, min_y, max_z],
[max_x, max_y, min_z]
])
return buffered_box_points
def obtainSRSInfo(root):
specifyVersion()
# obtain the envelope object
envelopes = []
for envelope in root.getiterator('{%s}Envelope' % ns_gml):
envelopes.append(envelope)
# Extracting the srsName attribute from each Envelope
srs_names = [envelope.get('srsName') for envelope in envelopes]
srs_Dimensions = [envelope.get('srsDimension') for envelope in envelopes]
return srs_names, srs_Dimensions
# This function is used to create a corresponding json file defining the bbox of an object for each corresponding obj file
def writeBBOXJSON(b, overall_counter, path, b_counter, trans_param):
if len(trans_param) > 0:
translation_parameters = {
"d_x": str(trans_param[0]),
"d_y": str(trans_param[1]),
"d_z": str(trans_param[2])
}
else:
translation_parameters = {
"d_x": str(0),
"d_y": str(0),
"d_z": str(0)
}
buffered_box_points_global = getBufferedBBoxPoints(b)
# translate to the local coordinate system
buffered_box_points, _ = addTranslationParameters(buffered_box_points_global, [], trans_param=trans_param)
# From this set of points, obtain the minimum set that is necessary to describe the bounding box.
min_point = buffered_box_points[0]
max_point = buffered_box_points[4]
# Construct the json file path
json_file_path = str(path) + str(b_counter) + "_" + str(overall_counter) + "_bbox_" + ".json"
# Write the bbox to a designated json file
# Prüfen, ob die JSON-Datei existiert und laden
if os.path.exists(json_file_path):
with open(json_file_path, 'r') as json_file:
axis_aligned_bbox = json.load(json_file)
else:
axis_aligned_bbox = {}
# Neuen Identifier hinzufügen
axis_aligned_bbox["axis_aligned_bbox"] = {
"min_point": str(min_point),
"max_point": str(max_point),
"translation_parameters": translation_parameters
}
# Zuordnungen in JSON-Datei schreiben
with open(json_file_path, 'w') as json_file:
json.dump(axis_aligned_bbox, json_file, indent=4)
return 0
# This function is used to add information about the used spatial reference system to the json file
def addCRSToJSON(root, json_file_path):
specifyVersion()
# obtain the envelope object
envelopes = []
for envelope in root.getiterator('{%s}Envelope' % ns_gml):
envelopes.append(envelope)
if envelopes:
# Extracting the srsName attribute from each Envelope
srs_names = [envelope.get('srsName') for envelope in envelopes]
srs_Dimensions = [envelope.get('srsDimension') for envelope in envelopes]
elif not envelopes:
srs_names = "Unkown"
srs_Dimensions = "Unkown"
used_srs = srs_names[0]
# Prüfen, ob die JSON-Datei existiert und laden
if os.path.exists(json_file_path):
with open(json_file_path, 'r') as json_file:
crs_info = json.load(json_file)
else:
crs_info = {}
# Neuen Identifier hinzufügen
crs_info["CRS"] = {
"srsName": used_srs,
"srsDimensions": srs_Dimensions
}
# Zuordnungen in JSON-Datei schreiben
with open(json_file_path, 'w') as json_file:
json.dump(crs_info, json_file, indent=4)
return 0
def claculateCornerTriangles(b, trans_param):
buffered_box_points = getBufferedBBoxPoints(b)
# Translate the Bounding box into the local coordinate system
# buffered_box_points, _ = addTranslationParameters(buffered_box_points_global, [], trans_param=trans_param)
# Create triangles at the corners of the buffered bounding box
corner_triangles = create_corner_triangles(buffered_box_points)
# Convert the triangles to lists
corner_triangles = [np.array(triangle).tolist() for triangle in corner_triangles]
print("\nCorner Triangles:")
for i, triangle in enumerate(corner_triangles):
print(f"Triangle {i + 1}: {triangle}")
return corner_triangles
# diese funktion dient dazu ein JSON file zu schreiben um die meta informationen über die einzelnen objekte zuspeichern
def add_identifier_to_json(filename, tag, parentID, gmlID, json_file_path):
"""
Adds the identifier information for one .obj file to a JSON file.
Parameters:
- number (int): The number corresponding to the .obj file.
- tag (str): The tag corresponding to the .obj file.
- parentID (str): The parent ID corresponding to the .obj file.
- gmlID (str): The gml ID corresponding to the .obj file.
- json_file_path (str): Path to the JSON file where identifier information will be stored.
"""
# Prüfen, ob die JSON-Datei existiert und laden
if os.path.exists(json_file_path):
with open(json_file_path, 'r') as json_file:
identifiers = json.load(json_file)
else:
identifiers = {}
# Neuen Identifier hinzufügen
identifiers[filename] = {
'tag': tag,
'parentID': parentID,
'gmlID': gmlID
}
# Zuordnungen in JSON-Datei schreiben
with open(json_file_path, 'w') as json_file:
json.dump(identifiers, json_file, indent=4)
print(f"Zuordnung für {filename} wurde gespeichert.")
def perturb_points(points, perturbation_scale=1e-6):
"""
Perturb the points slightly to avoid degenerate cases.
Parameters:
points (list of tuple of floats): A list where each element is a tuple (x, y, z) representing a 3D point.
perturbation_scale (float): The maximum magnitude of the perturbation applied to each coordinate.
Returns:
list of list of floats: Perturbed list of points.
"""
points_array = np.array(points)
perturbation = np.random.uniform(-perturbation_scale, perturbation_scale, points_array.shape)
perturbed_points = points_array + perturbation
return perturbed_points.tolist()
def write_obj_file(surfaces, filename, tag, parentid, gmlid, counter, path, tr_1, translation_parameters):
for triangle in tr_1:
triangle_local, _ = addTranslationParameters(triangle, [], trans_param=translation_parameters)
surfaces.append(triangle_local)
with open(filename, 'w') as file:
vertex_index = 1
for triangle in surfaces:
for vertex in triangle:
file.write(f"v {vertex[0]} {vertex[1]} {vertex[2]}\n")
file.write(f"f {vertex_index} {vertex_index + 1} {vertex_index + 2}\n")
vertex_index += 3
add_identifier_to_json(filename, tag, parentid, gmlid, (path + "index.json"))
def remove_reccuring(list_vertices):
"""Removes recurring vertices, which messes up the triangulation.
Inspired by http://stackoverflow.com/a/1143432"""
# last_point = list_vertices[-1]
list_vertices_without_last = list_vertices[:-1]
found = set()
for item in list_vertices_without_last:
if str(item) not in found:
yield item
found.add(str(item))
def separate_string(s):
# Define the regex pattern
pattern = r'\{([^}]*)\}(.*)'
# Search for the pattern in the input string
match = re.search(pattern, s)
if match:
# Extract the parts
inside_braces = match.group(1)
outside_braces = match.group(2)
return inside_braces, outside_braces
else:
return None, None
def specifyVersion():
global ns_citygml
global ns_gml
global ns_bldg
global ns_xsi
global ns_xAL
global ns_xlink
global ns_dem
global ns_con
global ns_app
global ns_pcl
global ns_gen
global ns_gss
global ns_pfx0
global ns_gsr
global ns_tran
global ns_gmd
global ns_gts
global ns_veg
global ns_frn
global ns_tun
global ns_wtr
global nsmap
if config.getVersion() == 1:
# -- Name spaces for CityGML 2.0
ns_citygml = "http://www.opengis.net/citygml/1.0"
ns_gml = "http://www.opengis.net/gml"
ns_bldg = "http://www.opengis.net/citygml/building/1.0"
ns_tran = "http://www.opengis.net/citygml/transportation/1.0"
ns_veg = "http://www.opengis.net/citygml/vegetation/1.0"
ns_gen = "http://www.opengis.net/citygml/generics/1.0"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_xAL = "urn:oasis:names:tc:ciq:xsdschema:xAL:1.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_dem = "http://www.opengis.net/citygml/relief/1.0"
ns_frn = "http://www.opengis.net/citygml/cityfurniture/1.0"
ns_tun = "http://www.opengis.net/citygml/tunnel/1.0"
ns_wtr = "http://www.opengis.net/citygml/waterbody/1.0"
ns_brid = "http://www.opengis.net/citygml/bridge/1.0"
ns_app = "http://www.opengis.net/citygml/appearance/1.0"
if config.getVersion() == 2:
# -- Name spaces for CityGML 2.0
ns_citygml = "http://www.opengis.net/citygml/2.0"
ns_gml = "http://www.opengis.net/gml"
ns_bldg = "http://www.opengis.net/citygml/building/2.0"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_xAL = "urn:oasis:names:tc:ciq:xsdschema:xAL:2.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_dem = "http://www.opengis.net/citygml/relief/2.0"
elif config.getVersion() == 3:
# -- Name spaces for CityGML 3.0
ns_citygml = "http://www.opengis.net/citygml/3.0"
ns_con = "http://www.opengis.net/citygml/construction/3.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_gml = "http://www.opengis.net/gml/3.2"
ns_bldg = "http://www.opengis.net/citygml/building/3.0"
ns_app = "http://www.opengis.net/citygml/appearance/3.0"
ns_pcl = "http://www.opengis.net/citygml/pointcloud/3.0"
ns_gen = "http://www.opengis.net/citygml/generics/3.0"
ns_gss = "http://www.isotc211.org/2005/gss"
ns_pfx0 = "urn:oasis:names:tc:ciq:xsdschema:xAL:2.0"
ns_gsr = "http://www.isotc211.org/2005/gsr"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_tran = "http://www.opengis.net/citygml/transportation/3.0"
ns_gmd = "http://www.isotc211.org/2005/gmd"
ns_gts = "http://www.isotc211.org/2005/gts"
ns_veg = "http://www.opengis.net/citygml/vegetation/3.0"
ns_xAL = "urn:oasis:names:tc:ciq:xal:3"
ns_dem = "http://www.opengis.net/citygml/relief/3.0"
ns_frn = "http://www.opengis.net/citygml/cityfurniture/3.0"
ns_tun = "http://www.opengis.net/citygml/tunnel/3.0"
ns_wtr = "http://www.opengis.net/citygml/waterbody/3.0"
nsmap = {
None: ns_citygml,
'gml': ns_gml,
'bldg': ns_bldg,
'xsi': ns_xsi,
'xAL': ns_xAL,
'xlink': ns_xlink,
'dem': ns_dem
}
def compute_convex_hull(points):
"""
Computes the convex hull of a set of 3D points using Open3D, including triangulation of the hull's faces.
Parameters:
points (list of tuple of floats): A list where each element is a tuple (x, y, z) representing a 3D point.
Returns:
list: A list of faces, where each face is a list of vertex coordinates forming that face.
"""
# Convert the list of points to a NumPy array
points_array = np.array(points)
perturbed_points = perturb_points(points_array)
# Create an Open3D PointCloud object
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(perturbed_points)
# Compute the convex hull
hull, triangles = pcd.compute_convex_hull()
# Extract vertices and faces (triangles)
hull_vertices = np.asarray(hull.vertices)
hull_triangles = np.asarray(hull.triangles)
# Prepare the faces in the desired format
faces = []
for triangle in hull_triangles:
face = [list(hull_vertices[vertex]) for vertex in triangle]
faces.append(face)
return faces
def process_polygon(p, trans_param):
e = p[0]
i = p[1]
e_trans, i_trans = addTranslationParameters(e, i, trans_param=trans_param)
t = p3dm.triangulation(e_trans, i_trans)
# print(f"t: {t}")
return t
def process_polygons_parallel(polys, trans_param):
data = []
results = []
for poly in polys:
e, i = m3dm.polydecomposer(poly)
epoints = m3dm.GMLpoints(e[0])
# -- Clean recurring points, except the last one
last_ep = epoints[-1]
epoints_clean = list(remove_reccuring(epoints))
epoints_clean.append(last_ep)
# -- LinearRing(s) forming the interior
irings = []
for iring in i:
ipoints = m3dm.GMLpoints(iring)
# -- Clean them in the same manner as the exterior ring
last_ip = ipoints[-1]
ipoints_clean = list(remove_reccuring(ipoints))
ipoints_clean.append(last_ip)
irings.append(ipoints_clean)
if len(epoints_clean) > 4:
t = process_polygon([epoints_clean, irings], trans_param=trans_param)
results.append(t)
# data.append(poly_components)
if len(epoints_clean) == 4:
epoints_clean_translated, _ = addTranslationParameters(epoints_clean, [], trans_param=trans_param)
results.append([epoints_clean_translated])
# t = process_polygon(poly)
# results.append(t)
# cpu_cores = os.cpu_count()
# print(f'Number of available CPU cores (using os): {cpu_cores}')
# with ThreadPoolExecutor(max_workers=cpu_cores) as executor:
# # Submitting all tasks
# futures = [executor.submit(process_polygon, p) for p in data]
# # Collecting results
# for future in futures:
# result = future.result() # This will re-raise any exception caught during the execution of the task
# results.append(result)
return results
# this is an experimental method for parallelization
def processOpening(o, path, buildingid, overall_counter, tr_1, trans_param, b_counter):
for child in o.getiterator():
unique_identifier = child.xpath("@g:id", namespaces={'g': ns_gml})
if child.tag == '{%s}Window' % ns_bldg or child.tag == '{%s}Door' % ns_bldg:
polys = m3dm.polygonFinder(o)
t = process_polygons_parallel(polys, trans_param=trans_param)
triangles = []
for poly in t:
for tr in poly:
triangles.append(tr)
filename = path + str(b_counter) + "_" + str(overall_counter) + ".obj"
write_obj_file(triangles, filename, str(child.tag), buildingid, unique_identifier, overall_counter, path,
tr_1, trans_param)
def getAllExteriorPoints(polys):
data = []
for poly in polys:
e, i = m3dm.polydecomposer(poly)
epoints = m3dm.GMLpoints(e[0])
# -- Clean recurring points, except the last one
last_ep = epoints[-1]
epoints_clean = list(remove_reccuring(epoints))
epoints_clean.append(last_ep)
for point in epoints_clean:
data.append(point)
return data
def processWithApproximatedWindows(o, path, buildingid, overall_counter, tr_1, translation_parameters, b_counter):
for child in o.getiterator():
unique_identifier = child.xpath("@g:id", namespaces={'g': ns_gml})
if child.tag == '{%s}Window' % ns_bldg or child.tag == '{%s}Door' % ns_bldg:
polys = m3dm.polygonFinder(o)
exterior_points = getAllExteriorPoints(polys)
t_global = compute_convex_hull(exterior_points)
_, t = addTranslationParameters(e=[], i=t_global, trans_param=translation_parameters)
filename = path + str(b_counter) + "_" + str(overall_counter) + ".obj"
write_obj_file(t, filename, str(child.tag), buildingid, unique_identifier, overall_counter, path, tr_1,
translation_parameters=translation_parameters)
# This function is used to im port a bounding box that is associated to the corresponding building component
# It still has to be tested if it works
def importBoundingBox(pathToBoundingBoxFile, trans_param):
print("Hier")
keys = ["_xmin", "_xmax", "_ymin", "_ymax", "_zmin", "_zmax"]
values = {}
with open(pathToBoundingBoxFile, 'r') as file:
for _ in range(15):
line = file.readline()
for key in keys:
match = re.search(f'"{key}"\s*:\s*([-0-9.]+)', line)
if match:
values[key] = float(match.group(1))
if not all(k in values for k in keys):
raise ValueError("Missing bounding box values in the first 15 lines")
min_x, max_x = values["_xmin"], values["_xmax"]
min_y, max_y = values["_ymin"], values["_ymax"]
min_z, max_z = values["_zmin"], values["_zmax"]
print(f"min x: {min_x} min y: {min_y} , min z: {min_z}")
print(f"max x: {max_x} max y: {max_y} , max z: {max_z}")
box_points = np.array([
[min_x, min_y, min_z],
[max_x, min_y, min_z],
[min_x, max_y, min_z],
[min_x, min_y, max_z],
[max_x, max_y, max_z],
[min_x, max_y, max_z],
[max_x, min_y, max_z],
[max_x, max_y, min_z]
])
# Create triangles at the corners of the buffered bounding box
corner_triangles = create_corner_triangles(box_points)
# Convert the triangles to lists
corner_triangles = [np.array(triangle).tolist() for triangle in corner_triangles]
print("\nCorner Triangles:")
for i, triangle in enumerate(corner_triangles):
print(f"Triangle {i + 1}: {triangle}")
return corner_triangles
def separateComponents(b, path, APPROXIMATEWINDOWS, ADDBOUNDINGBOX, ADDBOUNDINGBOXJSON, TRANSLATEBUILDINGS,
IMPORTBOUNDINGBOX, b_counter):
if TRANSLATEBUILDINGS:
# Step 1: Obtain the axis oriented bounding box of the building
bounding_box_points = getBufferedBBoxPoints(b)
# Step 2 calculate the mean value of the points that the bbox points
translation_parameters = np.mean(bounding_box_points, axis=0)
if not TRANSLATEBUILDINGS: # todo: nocheinmal üerlegen ob man hier nicht vielleicht besser elif oder so nehmen sollte
translation_parameters = []
if IMPORTBOUNDINGBOX != None:
ADDBOUNDINGBOX = False # make sure that the bounding box is not calculated
# Option to include the small triangles to mark the buffered bounding box
if ADDBOUNDINGBOX:
tr_1 = claculateCornerTriangles(b, trans_param=translation_parameters)
elif not ADDBOUNDINGBOX:
tr_1 = []
global overall_counter
overall_counter = 0
output = {}
specifyVersion()
# comprehensive list of semantic surfaces
semanticSurfaces = ['GroundSurface', 'WallSurface', 'RoofSurface', 'ClosureSurface', 'CeilingSurface',
'InteriorWallSurface', 'FloorSurface', 'OuterCeilingSurface', 'OuterFloorSurface', 'Door',
"outerBuildingInstallation",
'Window', "BuildingInstallation", "BuildingConstructiveElement"]
for semanticSurface in semanticSurfaces:
output[semanticSurface] = []
# get the building id for the building
buildingid = b.xpath("@g:id", namespaces={'g': ns_gml})
# Handle the case when a building has no gml:id - This should however never occur...
if not buildingid:
buildingid = b_counter
# Todo: Muss noch implementiert werden
# Import the bounding box
if IMPORTBOUNDINGBOX != None:
pathToBoundingBoxFile = IMPORTBOUNDINGBOX + "/" + str(buildingid) + ".json"
tr_1 = importBoundingBox(pathToBoundingBoxFile=pathToBoundingBoxFile, trans_param=translation_parameters)
if config.getVersion() != 3:
openings = []
openingpolygons = []
for child in b.getiterator():
if child.tag == '{%s}opening' % ns_bldg:
openings.append(child)
for o in child.findall('.//{%s}Polygon' % ns_gml):
openingpolygons.append(o)
for o in openings:
# print("approximate windows: ", APPROXIMATEWINDOWS)
if APPROXIMATEWINDOWS:
processWithApproximatedWindows(o, path, buildingid, overall_counter, tr_1=tr_1,
translation_parameters=translation_parameters, b_counter=b_counter)
if not APPROXIMATEWINDOWS:
processOpening(o, path, buildingid, overall_counter, tr_1, trans_param=translation_parameters,
b_counter=b_counter)
if ADDBOUNDINGBOXJSON:
writeBBOXJSON(b, overall_counter=overall_counter, path=path, b_counter=b_counter,
trans_param=translation_parameters)
overall_counter += 1
if config.getVersion() == 3:
openingpolygons = []
print("Component separation for CityGML 3.0 is not implemented yet.")
# todo: muss noch implementiert werden
# -- Process other thematic boundaries
for cl in output:
cls = []
for child in b.getiterator():
if child.tag == '{%s}%s' % (ns_bldg, cl):
cls.append(child)
for feature in cls:
# -- If it is the first feature, print the object identifier
unique_identifier = feature.xpath("@g:id", namespaces={
'g': ns_gml})
if str(unique_identifier) != "[]" or str(unique_identifier) == "[]":
cleaned_filename = str(unique_identifier)
# -- This is not supposed to happen, but just to be sure...
if feature.tag == '{%s}Window' % ns_bldg or feature.tag == '{%s}Door' % ns_bldg:
continue
tag = feature.tag
_, cleaned_tag = separate_string(tag)
# -- Find all polygons in this semantic boundary hierarchy
poly_t = []
t_ges = []
number_of_polygons = len(feature.findall('.//{%s}Polygon' % ns_gml))
pcounter = 0
print(f"there are {number_of_polygons} polygons there!")
for p in feature.findall('.//{%s}Polygon' % ns_gml):
found_opening = False
for optest in openingpolygons:
if p == optest:
found_opening = True
break
# -- If there is an opening skip it
if found_opening:
pass
else:
# -- Decompose the polygon into exterior and interior
e, i = m3dm.polydecomposer(p)
# -- Points forming the exterior LinearRing
epoints = m3dm.GMLpoints(e[0])
# -- Clean recurring points, except the last one
last_ep = epoints[-1]
epoints_clean = list(remove_reccuring(epoints))
epoints_clean.append(last_ep)
# -- LinearRing(s) forming the interior
irings = []
for iring in i:
ipoints = m3dm.GMLpoints(iring)
# -- Clean them in the same manner as the exterior ring
last_ip = ipoints[-1]
ipoints_clean = list(remove_reccuring(ipoints))
ipoints_clean.append(last_ip)
irings.append(ipoints_clean)
# Applying the translation parameters
e_trans, i_trans = addTranslationParameters(e=epoints_clean, i=irings,
trans_param=translation_parameters)
try:
if len(epoints_clean) > 4:
t = p3dm.triangulation(e_trans, i_trans)
# print("Nur drei Punkte")
poly_t.append(t)
if len(epoints_clean) == 4:
t = e_trans[0:3]
poly_t.append([t])
if len(epoints_clean) < 3:
t = []
# print("Empty Surface!")
except:
t = []
for surfaces in poly_t:
t_ges = t_ges + surfaces
pcounter += 1
if pcounter % 100 == 0:
print(pcounter)
filename = path + str(b_counter) + "_" + str(overall_counter) + ".obj"
if ADDBOUNDINGBOXJSON:
writeBBOXJSON(b, overall_counter=overall_counter, path=path, b_counter=b_counter,
trans_param=translation_parameters)
write_obj_file(t_ges, filename, str(feature.tag), buildingid, cleaned_filename, overall_counter, path,
tr_1, translation_parameters=translation_parameters)
overall_counter += 1
print("Segmentation finished!")
return 0

View file

@ -0,0 +1,7 @@
def setVersion(version):
global VERSION
VERSION = version
print(f"version: {VERSION}")
def getVersion():
return VERSION

View file

@ -0,0 +1,52 @@
#!/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 numpy as np
import matplotlib.cm as cm
#-- Number of classes of the colormap
no_values = 101
#-- Select the colormap and get its RGB values for each of the classes
colormap = cm.get_cmap("afmhot", no_values) # http://matplotlib.org/examples/color/colormaps_reference.html
colormap_vals = colormap(np.arange(no_values)).tolist()
#-- This is the MTL file
mtlcontents = ""
#-- Class by class... MTL!
for i in range(0, no_values):
b = float(i)/100
mtlcontents += "newmtl " + str(b) + "\n"
mtlcontents += "Ka " + str(colormap_vals[i][0]) + " " + str(colormap_vals[i][1]) + " " + str(colormap_vals[i][2]) + "\n"
mtlcontents += "Kd " + str(colormap_vals[i][0]) + " " + str(colormap_vals[i][1]) + " " + str(colormap_vals[i][2]) + "\n"
#-- Write the MTL
with open("colormap.mtl", "w") as mtl_file:
mtl_file.write(mtlcontents)

View file

@ -0,0 +1,148 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
import config
# 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.
from config import setVersion
def specifyVersion():
global ns_citygml
global ns_gml
global ns_bldg
global ns_xsi
global ns_xAL
global ns_xlink
global ns_dem
global ns_con
global ns_app
global ns_pcl
global ns_gen
global ns_gss
global ns_pfx0
global ns_gsr
global ns_tran
global ns_gmd
global ns_gts
global ns_veg
global ns_frn
global ns_tun
global ns_wtr
global nsmap
#print("config.getVerision", config.getVersion())
if config.getVersion() == 2 or config.getVersion() == 1:
# -- Name spaces for CityGML 2.0
ns_citygml = "http://www.opengis.net/citygml/2.0"
ns_gml = "http://www.opengis.net/gml"
ns_bldg = "http://www.opengis.net/citygml/building/2.0"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_xAL = "urn:oasis:names:tc:ciq:xsdschema:xAL:2.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_dem = "http://www.opengis.net/citygml/relief/2.0"
elif config.getVersion() == 3:
# -- Name spaces for CityGML 3.0
ns_citygml = "http://www.opengis.net/citygml/3.0"
ns_con = "http://www.opengis.net/citygml/construction/3.0"
ns_xlink = "http://www.w3.org/1999/xlink"
ns_gml = "http://www.opengis.net/gml/3.2"
ns_bldg = "http://www.opengis.net/citygml/building/3.0"
ns_app = "http://www.opengis.net/citygml/appearance/3.0"
ns_pcl = "http://www.opengis.net/citygml/pointcloud/3.0"
ns_gen = "http://www.opengis.net/citygml/generics/3.0"
ns_gss = "http://www.isotc211.org/2005/gss"
ns_pfx0 = "urn:oasis:names:tc:ciq:xsdschema:xAL:2.0"
ns_gsr = "http://www.isotc211.org/2005/gsr"
ns_xsi = "http://www.w3.org/2001/XMLSchema-instance"
ns_tran = "http://www.opengis.net/citygml/transportation/3.0"
ns_gmd = "http://www.isotc211.org/2005/gmd"
ns_gts = "http://www.isotc211.org/2005/gts"
ns_veg = "http://www.opengis.net/citygml/vegetation/3.0"
ns_xAL = "urn:oasis:names:tc:ciq:xal:3"
ns_dem = "http://www.opengis.net/citygml/relief/3.0"
ns_frn = "http://www.opengis.net/citygml/cityfurniture/3.0"
ns_tun = "http://www.opengis.net/citygml/tunnel/3.0"
ns_wtr = "http://www.opengis.net/citygml/waterbody/3.0"
nsmap = {
None: ns_citygml,
'gml': ns_gml,
'bldg': ns_bldg,
'xsi': ns_xsi,
'xAL': ns_xAL,
'xlink': ns_xlink,
'dem': ns_dem
}
def polydecomposer(polygon):
"""Extracts the <gml:exterior> and <gml:interior> of a <gml:Polygon>."""
specifyVersion()
exter = polygon.findall('.//{%s}exterior' % ns_gml)
inter = polygon.findall('.//{%s}interior' % ns_gml)
return exter, inter
def polygonFinder(GMLelement):
"""Find the <gml:polygon> element."""
specifyVersion()
#print("NSgml:", ns_gml)
polygonsLocal = GMLelement.findall('.//{%s}Polygon' % ns_gml)
#print(polygonsLocal)
#for polygon in polygonsLocal:
# gml_id = polygon.get('{%s}id' % ns_gml)
# print(gml_id)
return polygonsLocal
def GMLpoints(ring):
specifyVersion()
"Extract points from a <gml:LinearRing>."
# -- List containing points
listPoints = []
# -- Read the <gml:posList> value and convert to string
if len(ring.findall('.//{%s}posList' % ns_gml)) > 0:
points = ring.findall('.//{%s}posList' % ns_gml)[0].text
# -- List of coordinates
coords = points.split()
assert (len(coords) % 3 == 0)
# -- Store the coordinate tuple
for i in range(0, len(coords), 3):
listPoints.append([float(coords[i]), float(coords[i + 1]), float(coords[i + 2])])
elif len(ring.findall('.//{%s}pos' % ns_gml)) > 0:
points = ring.findall('.//{%s}pos' % ns_gml)
# -- Extract each point separately
for p in points:
coords = p.text.split()
assert (len(coords) % 3 == 0)
# -- Store the coordinate tuple
for i in range(0, len(coords), 3):
listPoints.append([float(coords[i]), float(coords[i + 1]), float(coords[i + 2])])
else:
return None
return listPoints

View file

@ -0,0 +1,77 @@
#!/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 matplotlib as mpl
import matplotlib.pyplot as plt
#-- Resource: http://matplotlib.org/examples/api/colorbar_only.html
plt.rc('text', usetex=True)
plt.rc('font', family='serif')
#-- Make a figure and axes with dimensions as desired
fig = plt.figure(figsize=(8, 1))
ax1 = fig.add_axes([0.05, 0.80, 0.9, 0.15])
#-- Bounds
vmin = 350
vmax = 1300
#-- Colormap
cmap = mpl.cm.afmhot
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
cb1 = mpl.colorbar.ColorbarBase(ax1, cmap=cmap,
norm=norm,
orientation='horizontal')
#-- Label on the axis
cb1.set_label(r"Annual solar irradiation [kWh/m$^{2}$/year]")
cmap = mpl.colors.ListedColormap(['r', 'g', 'b', 'c'])
cmap.set_over('0.25')
cmap.set_under('0.75')
bounds = [1, 2, 4, 7, 8]
norm = mpl.colors.BoundaryNorm(bounds, cmap.N)
#-- Change the last tick label
labels = [item.get_text() for item in cb1.ax.get_xticklabels()]
li = 0
for l in labels:
# labels[li] = r"$"+str(l)+"$"
labels[li] = r""+str(l)
li += 1
labels[-1] = r"$\geq "+str(vmax) + "$"
cb1.ax.set_xticklabels(labels)
#-- Output
plt.savefig('colorbar.pdf', transparent=True)
plt.savefig('colorbar.png', dpi=600, transparent=True)
plt.show()

View file

@ -0,0 +1,716 @@
#!/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

View file

@ -0,0 +1,73 @@
asttokens==3.0.1
attrs==25.4.0
blinker==1.9.0
certifi==2026.1.4
charset-normalizer==3.4.4
click==8.3.1
colorama==0.4.6
comm==0.2.3
ConfigArgParse==1.7.1
contourpy==1.3.2
cycler==0.12.1
dash==4.0.0
dash-core-components==2.0.0
dash-html-components==2.0.0
dash-table==5.0.0
decorator==5.2.1
exceptiongroup==1.3.1
executing==2.2.1
fastjsonschema==2.21.2
Flask==3.1.3
fonttools==4.61.1
idna==3.15
importlib_metadata==8.7.1
ipython==8.38.0
ipywidgets==8.1.8
itsdangerous==2.2.0
jedi==0.19.2
Jinja2==3.1.6
joblib==1.5.3
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
jupyter_core==5.9.1
jupyterlab_widgets==3.0.16
kiwisolver==1.4.9
lxml==6.1.0
MarkupSafe==3.0.3
matplotlib==3.10.8
matplotlib-inline==0.2.1
narwhals==2.16.0
nbformat==5.10.4
nest-asyncio==1.6.0
numpy==2.2.6
open3d==0.19.0
packaging==26.0
parso==0.8.6
pillow==12.2.0
platformdirs==4.9.2
plotly==6.5.2
prompt_toolkit==3.0.52
pure_eval==0.2.3
Pygments==2.20.0
pyparsing==3.3.2
python-dateutil==2.9.0.post0
pywin32==311
referencing==0.37.0
requests==2.33.0
retrying==1.4.2
rpds-py==0.30.0
scikit-learn==1.7.2
scipy==1.15.3
shapely==2.1.2
six==1.17.0
stack-data==0.6.3
tenacity==9.1.4
threadpoolctl==3.6.0
traitlets==5.14.3
triangle==20250106
typing_extensions==4.15.0
urllib3==2.7.0
wcwidth==0.6.0
Werkzeug==3.1.6
widgetsnbextension==4.0.15
zipp==3.23.0

BIN
scripts/IfcConvert Executable file

Binary file not shown.

36
scripts/backup/backup_daily.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# == func log ===================================================================================================================
warning() {
echo "Warning: $1"
}
error() {
echo "Error: $1" && exit 1
}
REPONAME="3drepository"
BPATH="/var/www/data/backups/daily"
BDPATH="/var/www/data/backups/daily/db"
WEBSITE="www.dfg-repository.wisski.cloud"
# == init script ================================================================================================================
CURRENT_DATE=$(date +%d%m%y%H%M)
TGZ_FILE="${BPATH}/backup_${WEBSITE}_${CURRENT_DATE}.tgz"
SQL_FILE="${BDPATH}/${WEBSITE}_databases_${CURRENT_DATE}.sql"
BSOURCE="/var/www/html/${REPONAME}"
mkdir -p "$BPATH"
# == main =======================================================================================================================
mkdir -p "${BDPATH}"
source read_settings.sh
#mysqldump -h ${DATABASE_HOST} -u ${DATABASE_USER} -p${DATABASE_PASS} ${DATABASE} --hex-blob --skip-lock-tables --single-transaction > "$SQL_FILE"
tar --exclude="${BPATH:1}" --warning=none -czpf "${TGZ_FILE}" -C / "$BSOURCE" var/www/data
#scp "$TGZ_FILE" "${USER}@example.work:/mnt/home/${USER}"
find ${BPATH}/*.tgz -mtime +7 -exec rm {} \;
find ${BDPATH}/*.tgz -mtime +7 -exec rm {} \;

View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
error() {
echo -e "Error: $1\nExiting..." && exit 1
}
warning() {
echo "Warning: $1 Skipping..."
}
MONTHLY_BACKUPS_PATH="/var/www/data/backups/monthly"
WEEKLY_BACKUPS_PATH="/var/www/data/backups/weekly"
clean_old_monthly_backups() {
find ${MONTHLY_BACKUPS_PATH}/*.tgz -mtime +181 -exec rm {} \;
}
clean_old_monthly_db_backups() {
find ${MONTHLY_BACKUPS_PATH}/db/*.tgz -mtime +181 -exec rm {} \;
}
sync_from_weekly() {
# shellcheck disable=SC2010
ffd="$(ls -pt | grep -v / | head -1)"
if [[ -n "$ffd" ]]; then
rsync -dtz --ignore-existing "${ffd}" "$1"
else
warning "No files found to sync!"
fi
}
process_with_dbs() {
if [[ -d "${MONTHLY_BACKUPS_PATH}/" ]]; then
clean_old_monthly_backups
if ! cd "${WEEKLY_BACKUPS_PATH}/"; then
warning "Failed to cd into ${WEEKLY_BACKUPS_PATH}/"
else
# clean old weekly backups
find ${WEEKLY_BACKUPS_PATH}/*.tgz -mtime +28 -exec rm {} \;
sync_from_weekly "${MONTHLY_BACKUPS_PATH}/"
fi
if [[ -d "${MONTHLY_BACKUPS_PATH}/db/" ]]; then
clean_old_monthly_db_backups
if ! cd "${WEEKLY_BACKUPS_PATH}/db/"; then
warning "Failed to cd into ${WEEKLY_BACKUPS_PATH}/db/"
else
# clean old weekly db backups
find ${WEEKLY_BACKUPS_PATH}/db/*.tgz -mtime +28 -exec rm {} \;
sync_from_weekly "${MONTHLY_BACKUPS_PATH}/db/"
fi
else
warning "Cannot locate ${MONTHLY_BACKUPS_PATH}/db/"
fi
else
warning "Cannot locate ${MONTHLY_BACKUPS_PATH}/"
fi
}

64
scripts/backup/backup_weekly.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# == func log ===================================================================================================================
warning() {
echo "Warning: $1"
}
error() {
echo "Error: $1" && exit 1
}
BDPATH="/var/www/data/backups/daily/db"
WEBSITE="www.dfg-repository.wisski.cloud"
DAILY_BACKUPS_PATH="/var/www/data/backups/daily"
WEEKLY_BACKUPS_PATH="/var/www/data/backups/weekly"
sync_from_daily() {
ffd="$(ls -pt | grep -v / | head -1)"
if [[ -n "$ffd" ]]; then
rsync -dtz --ignore-existing "${ffd}" "$1"
else
warning "No files found to sync!"
fi
}
process_with_dbs() {
path="${DAILY_BACKUPS_PATH}"
if [[ -d "${WEEKLY_BACKUPS_PATH}/" ]]; then
# clean old weekly backups
find ${WEEKLY_BACKUPS_PATH}/*.tgz -mtime +28 -exec rm {} \;
if ! cd "${path}"; then
warning "Failed to cd into ${path}"
else
# clean old daily backups
find "${path}"/*.tgz -mtime +6 -exec rm {} \;
sync_from_daily "${WEEKLY_BACKUPS_PATH}/"
fi
path+="/db"
if [[ -d "${WEEKLY_BACKUPS_PATH}/db/" ]]; then
# clean old weekly db backups
find ${WEEKLY_BACKUPS_PATH}/db/*.tgz -mtime +28 -exec rm {} \;
if ! cd "${path}"; then
warning "Failed to cd into ${path}"
else
# clean old daily db backups
find "${path}"/*.tgz -mtime +6 -exec rm {} \;
sync_from_daily "${WEEKLY_BACKUPS_PATH}/db/"
fi
else
warning "Cannot locate ${WEEKLY_BACKUPS_PATH}/db/"
fi
else
warning "Cannot locate ${WEEKLY_BACKUPS_PATH}/"
fi
}
process_with_dbs

10
scripts/backup/read_settings.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
FILENAME="/var/www/html/3drepository/sites/default/settings.php"
test -f ../.env && source "$_"
DATABASE=$(php -r "error_reporting(0); \$filename = '$FILENAME'; error_reporting(0); (require(\$filename)); echo \$databases[\"default\"][\"default\"][\"database\"];")
DATABASE_HOST=$(php -r "error_reporting(0); \$filename = '$FILENAME'; error_reporting(0); (require(\$filename)); echo \$databases[\"default\"][\"default\"][\"host\"];")
DATABASE_PASS=$(php -r "error_reporting(0); \$filename = '$FILENAME'; error_reporting(0); (require(\$filename)); echo \$databases[\"default\"][\"default\"][\"password\"];")
DATABASE_USER=$(php -r "error_reporting(0); \$filename = '$FILENAME'; error_reporting(0); (require(\$filename)); echo \$databases[\"default\"][\"default\"][\"username\"];")

View file

@ -0,0 +1,9 @@
import bpy
import os
import sys
print("Converting: '" + sys.argv[6] + "'")
print("Saving to : '" + sys.argv[7] + "'")
bpy.ops.wm.open_mainfile(filepath=sys.argv[6])
bpy.ops.export_scene.gltf(filepath=sys.argv[7], export_format="GLB")

414
scripts/convert.sh Executable file
View file

@ -0,0 +1,414 @@
#!/bin/bash
# Ubuntu way
#apt install xvfb
#apt install libxkbcommon0
#apt install blender python3-pip
#apt install python3-lxml python3-shapely python3-matplotlib (for CityGML converter)
#apt install libxi6 libgconf-2-4
#OR
# Debian way
#wget https://ftp.halifax.rwth-aachen.de/blender/release/Blender4.4/blender-4.4.3-linux-x64.tar.xz
#tar -xvf blender-4.4.3-linux-x64.tar.xz
#change .env BLENDER_PATH or make symlink to it `ln -s PATH_TO_YOUR_UNCOMPRESSED_BLENDER/blender-4.4.3-linux64/blender /usr/local/bin/blender`
#pip install numpy or apt install python3-numpy
#pip install triangle
#usage: ./convert.sh -c COMPRESS -cl COMPRESSION_LEVEL -i 'INPUT' -o 'OUTPUT' -b BINARY -f FORCE_OVERRIDE
#apt install -y libxi6 libxrender1 libxrandr2 libxinerama1 libxcursor1 libxcomposite1 libxdamage1 libxtst6 libglib2.0-0 libsm6 libice6 libgl1 libxkbcommon0
#TESTING:
# sudo blender -b -P ./scripts/2gltf2/2gltf2.py -- --input "/opt/drupal/web/sites/default/files/{NAME}" --ext "$GLTF" --compression "true" --compression_level "3" --output "/opt/drupal/web/sites/default/files/2025-05/test-TEST.glb"
# xvfb-run --auto-servernum --server-args="-screen 0 512x512x16" sudo blender -b -P ./scripts/render.py -- --input "/var/www/html/sites/default/files/{NAME}.glb" --ext "glb" --org_ext "glb" --output "/var/www/html/sites/default/files/views/" --is_archive false --resolution 512x512x16 --samples 20 -E BLENDER_EEVEE -f 1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODULE_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [[ -f "$SCRIPT_DIR/.env" ]]; then
source "$SCRIPT_DIR/.env"
fi
BLENDER_BIN="${BLENDER_BIN:-}"
if [[ -z "$BLENDER_BIN" ]]; then
BLENDER_BIN="blender"
fi
SPATH="${SPATH:-$MODULE_ROOT}"
if [[ ! -d "$SPATH/scripts" ]]; then
SPATH="$MODULE_ROOT"
fi
#Defaults:
COMPRESSION=false
COMPRESSION_LEVEL=3
GLTF="gltf"
FORCE=false
isOutput=false
IS_ARCHIVE=false
LIGHTWEIGHT=false
INPUT=""
OUTPUT=""
OUTPUTPATH=""
resolve_blender_bin () {
local candidate="$1"
if [[ -z "$candidate" ]]; then
return 1
fi
if [[ "$candidate" == /* ]]; then
[[ -f "$candidate" && -x "$candidate" ]] || return 1
printf '%s\n' "$candidate"
return 0
fi
if [[ -f "$SCRIPT_DIR/$candidate" && -x "$SCRIPT_DIR/$candidate" ]]; then
printf '%s\n' "$SCRIPT_DIR/$candidate"
return 0
fi
if [[ -f "$SPATH/$candidate" && -x "$SPATH/$candidate" ]]; then
printf '%s\n' "$SPATH/$candidate"
return 0
fi
if command -v "$candidate" &> /dev/null; then
command -v "$candidate"
return 0
fi
return 1
}
check_blender () {
if ! BLENDER_BIN="$(resolve_blender_bin "$BLENDER_BIN")"; then
echo "Blender doesn't exist, install it by 'apt install blender python3-pip' then 'pip install numpy' or set BLENDER_BIN in scripts/.env"
return 1
elif [[ ! -x "$BLENDER_BIN" ]]; then
echo "Configured BLENDER_BIN is not executable: $BLENDER_BIN"
return 1
fi
if [[ -n "${BLENDER_BIN:-}" ]]; then
echo "Blender exists and be used for next steps..."
return 0
fi
}
check_scripts () {
if [ ! -d "${SPATH}/scripts" ]; then
echo "Can't find dependencies directory. Resolved SPATH=${SPATH}. SCRIPT_DIR=${SCRIPT_DIR}. MODULE_ROOT=${MODULE_ROOT}. Did you change your SPATH value in scripts/.env?"
return 1
else
return 0
fi
}
check_blender
check_scripts
show_usage () {
echo "-c=compress -l=compression level -i=input path -o=output path -b=binary -f=force override existing file"
cat <<EOF
Usage: convert.sh [options]
Options:
-c, --compression true|false
-l, --compression-level 0-9
-i, --input FILE
-o, --output FILE
-b, --binary true|false (true = glb, false = gltf)
-f, --force true|false
-t, --lightweight true|false
-a, --archive true|false
-h, --help
EOF
exit 0
}
######################################
# Helpers
######################################
die() {
echo "Error: $*" >&2
exit 1
}
bool() {
[[ "$1" == "true" || "$1" == "false" ]] || die "Value must be true/false (is: $1)"
}
file_exists() {
[[ -f "$1" ]] || die "File not found: $1"
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
}
######################################
# Parsing
######################################
while [[ $# -gt 0 ]]; do
case "$1" in
-c|--compression)
bool "$2"
COMPRESSION="$2"
shift 2
;;
-l|--compression-level)
[[ "$2" =~ ^[0-9]$ ]] || die "compression-level must be 09"
COMPRESSION_LEVEL="$2"
shift 2
;;
-i|--input)
INPUT="$2"
shift 2
;;
-o|--output)
OUTPUT="$2"
shift 2
;;
-b|--binary)
bool "$2"
[[ "$2" == "true" ]] && GLTF="glb" || GLTF="gltf"
shift 2
;;
-f|--force)
bool "$2"
FORCE="$2"
shift 2
;;
-t|--lightweight)
bool "$2"
LIGHTWEIGHT="$2"
shift 2
;;
-a|--archive)
bool "$2"
IS_ARCHIVE="$2"
shift 2
;;
-h|--help)
show_usage
exit 0
;;
--)
shift
break
;;
*)
die "Error parsing arguments"
;;
esac
done
check_status () {
if [ ! -f "$1.off" ]; then
touch "$1.off"
else
rm -rf "$1.off"
fi;
}
cleanup_main_lock() {
flock -u 200 2>/dev/null || true
exec 200>&- 2>/dev/null || true
if [[ -n "${MAIN_LOCKFILE:-}" ]]; then
rm -f "$MAIN_LOCKFILE"
fi
}
create_dirs () {
mkdir -p "$INPATH"/gltf/
mkdir -p "$INPATH"/metadata/
}
create_flock () {
INPATH=$1
FILENAME=$2
MAIN_LOCKFILE="$INPATH/${FILENAME}.lock"
exec 200>"$MAIN_LOCKFILE" || exit 1
flock -n 200 || {
echo "Process already running for $FILENAME"
exit 0
}
trap cleanup_main_lock EXIT
}
handle_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
create_flock "$INPATH" "$FILENAME"
if [[ "$isOutput" = false ]]; then
"$BLENDER_BIN" -b -P "${SPATH}/scripts/2gltf2/2gltf2.py" -- --input "$INPATH/$FILENAME" --ext "$GLTF" --compression "$COMPRESSION" --compression_level "$COMPRESSION_LEVEL" #> /dev/null 2>&1
else
"$BLENDER_BIN" -b -P "${SPATH}/scripts/2gltf2/2gltf2.py" -- --input "$INPATH/$FILENAME" --ext "$GLTF" --compression "$COMPRESSION" --compression_level "$COMPRESSION_LEVEL" --output "$OUTPUT$OUTPUTPATH" #> /dev/null 2>&1
fi
}
handle_unsupported_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
#touch "${INPATH}/gltf/${NAME}.glb.off"
}
handle_ifc_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
create_flock "$INPATH" "$FILENAME"
create_dirs
${SPATH}/scripts/IfcConvert "$INPATH/$FILENAME" "$INPATH/gltf/$NAME.glb" > /dev/null 2>&1
}
handle_blend_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
create_flock "$INPATH" "$FILENAME"
create_dirs
"$BLENDER_BIN" -b -P "${SPATH}/scripts/convert-blender-to-gltf.py" -- "$INPATH/$FILENAME" "$INPATH/gltf/$NAME.glb" > /dev/null 2>&1
}
handle_gml_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
create_flock "$INPATH" "$FILENAME"
require_cmd python3
GLB_PATH="${INPATH}/${NAME}_GLB"
mkdir -p "$GLB_PATH"
cp -rf "$INPATH/$FILENAME" "$GLB_PATH/"
python3 "${SPATH}/scripts/CityGML2OBJv2/CityGML2OBJs.py" -i "$GLB_PATH" -o "$GLB_PATH" > /dev/null 2>&1
create_dirs
"$BLENDER_BIN" -b -P "${SPATH}/scripts/2gltf2/2gltf2.py" -- "$GLB_PATH/${NAME}.obj" "$GLTF" "$COMPRESSION" "$COMPRESSION_LEVEL" "$INPATH/gltf/$NAME.glb" > /dev/null 2>&1
rm -rf "$GLB_PATH"
}
printf "\n"
echo "======== Parameters ========"
echo " INPUT: $INPUT"
echo " OUTPUT: $OUTPUT"
echo " COMPRESSION: $COMPRESSION"
echo " LEVEL: $COMPRESSION_LEVEL"
echo " FORMAT: $GLTF"
echo " FORCE: $FORCE"
echo " LIGHTWEIGHT: $LIGHTWEIGHT"
echo " ARCHIVE: $IS_ARCHIVE"
echo "==========================="
printf "\n"
if [[ ! -z "$INPUT" && -f $INPUT ]]; then
FILENAME=${INPUT##*/}
NAME="${FILENAME%.*}"
EXT=${FILENAME//*.}
INPATH=${INPUT%/*}
if [[ $FILENAME = $INPATH ]]; then
INPATH="."
fi
if [[ -z "$OUTPUT" ]]; then
OUTPUT=`echo ${INPATH}/gltf`
else
#echo $OUTPUT
OUTFILENAME=${OUTPUT%/*} # trim everything past the last /
OUTFILENAME=${OUTFILENAME##*/}
OUTFILENAME=$(printf '%s' "$OUTFILENAME" | sed -E 's/_(ZIP|RAR|TAR|XZ|GZ)$//I')
OUTPUTPATH=`echo $OUTFILENAME.$GLTF`
OUTPUT=`echo ${OUTPUT}gltf/`
isOutput=true
fi
if [[ "$EXT" != "$FILENAME" ]]; then
EXT="${EXT,,}"
if [[ ! -d "$OUTPUT" ]]; then
mkdir "$OUTPUT"
fi
if [[ ! -f $OUTPUT/$NAME.$GLTF || $FORCE ]]; then
start=`date +%s`
case $EXT in
abc|dae|fbx|obj|ply|stl|wrl|x3d)
echo "Converting $EXT file..."
handle_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
end=`date +%s`
echo "File $FILENAME compressed successfully. Runtime: $((end-start))s."
exit 0;
;;
ifc)
echo "Converting $EXT file..."
handle_ifc_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
end=`date +%s`
echo "File $FILENAME compressed successfully. Runtime: $((end-start))s."
exit 0;
;;
blend)
echo "Converting $EXT file..."
handle_blend_file "$INPATH" "$FILENAME" "$NAME" $EXT
end=`date +%s`
echo "File $FILENAME compressed successfully. Runtime: $((end-start))s."
exit 0;
;;
gml)
echo "Converting $EXT file..."
handle_gml_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
end=`date +%s`
echo "File $FILENAME compressed successfully. Runtime: $((end-start))s."
exit 0;
;;
glb)
end=`date +%s`
echo "Given file was already compressed."
exit 0;
;;
*)
handle_unsupported_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
#echo "File extension $EXT is not supported for conversion yet."
exit 0;
;;
esac
else
echo "Compressed file $OUTPUT/$NAME.$GLTF already exists."
exit 1;
fi
else
echo "No extension found on $FILENAME";
exit 2;
fi
elif [[ -z "$INPUT" ]] && die "No --input argument provided"; then
file_exists "$INPUT"
elif [[ -n "$OUTPUT" && -f "$OUTPUT" && "$FORCE" != "true" ]]; then
die "Output file already exists (use --force true)"
else
echo "Given file '$INPUT' not found"
show_usage
fi

View file

@ -0,0 +1 @@
/opt/drupal/web/sites/default/files/2024-12/MAINZ_3D_Impressao_3D.glb

View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -uo pipefail
######################################
# Config
######################################
LOG_FILE="convert_$(date +%Y-%m-%d_%H-%M-%S).log"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
FILES_LIST="$SCRIPT_DIR/convert_files.txt"
[[ -f $FILES_LIST ]] || { echo "Missing file: $FILES_LIST"; exit 1; }
while IFS= read -r line || [[ -n "$line" ]]; do
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
FILES+=("$line")
done < "$FILES_LIST"
[[ ${#FILES[@]} -eq 0 ]] && {
echo "No files to process."
exit 1
}
######################################
# Logging helper
######################################
log() {
echo "[$(date '+%F %T')] $*"
}
######################################
# Start
######################################
echo "Conversion started: $(date)" | tee -a "$LOG_FILE"
echo "Log: $LOG_FILE" | tee -a "$LOG_FILE"
echo | tee -a "$LOG_FILE"
FAILED=()
SUCCESS=()
for file in "${FILES[@]}"; do
START=$(date +%s)
log "Start: $file" | tee -a "$LOG_FILE"
if "$SCRIPT_DIR/convert.sh" \
--input "$file" \
--compression true \
--compression-level 3 \
--binary true \
--force false \
2>&1 | tee -a "$LOG_FILE"
then
log "OK: $file" | tee -a "$LOG_FILE"
SUCCESS+=("$file")
else
log "FAIL: $file" | tee -a "$LOG_FILE"
FAILED+=("$file")
fi
END=$(date +%s)
log "$((END-START))s"
echo | tee -a "$LOG_FILE"
done
######################################
# Summary
######################################
echo "================ SUMMARY ================" | tee -a "$LOG_FILE"
log "Success: ${#SUCCESS[@]}" | tee -a "$LOG_FILE"
log "Errors: ${#FAILED[@]}" | tee -a "$LOG_FILE"
if (( ${#FAILED[@]} )); then
echo "Failed files:" | tee -a "$LOG_FILE"
printf ' - %s\n' "${FAILED[@]}" | tee -a "$LOG_FILE"
fi
echo "End: $(date)" | tee -a "$LOG_FILE"

157
scripts/convert_test.sh Executable file
View file

@ -0,0 +1,157 @@
#!/bin/bash
#apt install blender python3-pip
#pip install numpy
#usage: ./convert.sh -c COMPRESS -cl COMPRESSION_LEVEL -i INPUT -o OUTPUT -b BINARY -f FORCE_OVERRIDE
BLENDER_PATH=''
#BLENDER_PATH='/var/lib/snapd/snap/blender/current/'
#Defaults:
COMPRESSION=false
COMPRESSION_LEVEL=3
GLTF="gltf"
FORCE="false"
isOutput=false
IS_ARCHIVE=false
while getopts ":c:l:o:i:b:f:" flag; do
case "${flag}" in
c) COMPRESSION=${OPTARG};;
l) COMPRESSION_LEVEL=${OPTARG};;
i) INPUT="${OPTARG}";;
o) OUTPUT="${OPTARG}";;
f) FORCE="${OPTARG}";;
a) IS_ARCHIVE="${OPTARG}";;
b) if [[ "${OPTARG}" = "true" ]]; then GLTF="glb"; else GLTF="gltf"; fi;;
esac
done
render_preview () {
if [[ ! -d "$INPATH/views" ]]; then
mkdir "$INPATH/views/"
fi
if [[ "$EXT" = "glb" ]]; then
xvfb-run --auto-servernum --server-args="-screen 0 512x512x16" sudo ${BLENDER_PATH}blender -b -P /var/www/html/3drepository/modules/dfg_3dviewer/scripts/render.py -- "$INPATH/$NAME.glb" "glb" $1 "$INPATH/views/" $IS_ARCHIVE -E BLENDER_EEVEE -f 1 > /dev/null 2>&1
else
xvfb-run --auto-servernum --server-args="-screen 0 512x512x16" sudo ${BLENDER_PATH}blender -b -P /var/www/html/3drepository/modules/dfg_3dviewer/scripts/render.py -- "$INPATH/gltf/$NAME.glb" "glb" $1 "$INPATH/views/" $IS_ARCHIVE -E BLENDER_EEVEE -f 1 > /dev/null 2>&1
fi;
}
handle_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
if [[ "$isOutput" = false ]]; then
${BLENDER_PATH}blender -b -P /var/www/html/3drepository/modules/dfg_3dviewer/scripts/2gltf2/2gltf2.py -- "$INPATH/$FILENAME" "$GLTF" "$COMPRESSION" "$COMPRESSION_LEVEL" > /dev/null 2>&1
else
${BLENDER_PATH}blender -b -P /var/www/html/3drepository/modules/dfg_3dviewer/scripts/2gltf2/2gltf2.py -- "$INPATH/$FILENAME" "$GLTF" "$COMPRESSION" "$COMPRESSION_LEVEL" "$OUTPUT$OUTPUTPATH" > /dev/null 2>&1
fi
#if [[ -f "$INPATH/gltf/$NAME.glb" ]]; then
# render_preview $EXT
#else
# render_preview "$INPATH/$NAME.$EXT"
#fi;
}
handle_unsupported_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
touch $INPATH/gltf/$NAME.glb.off
}
handle_ifc_file () {
INPATH=$1
FILENAME=$2
NAME=$3
EXT=$4
OUTPUT=$5
OUTPUTPATH=$6
if [[ ! -d "$INPATH"/gltf/ ]]; then
mkdir "$INPATH"/gltf/
fi
/var/www/html/3drepository/modules/dfg_3dviewer/scripts/IfcConvert "$INPATH/$FILENAME" "$INPATH/gltf/$NAME.glb" > /dev/null 2>&1
render_preview $EXT
}
if [[ ! -z "$INPUT" && -f $INPUT ]]; then
FILENAME=${INPUT##*/}
NAME="${FILENAME%.*}"
EXT=${FILENAME//*.}
INPATH=${INPUT%/*}
if [[ $FILENAME = $INPATH ]]; then
INPATH="."
fi
if [[ -z "$OUTPUT" ]]; then
OUTPUT=`echo $INPATH/\gltf`
else
echo $OUTPUT
OUTFILENAME=${OUTPUT%/*} # trim everything past the last /
OUTFILENAME=${OUTFILENAME##*/}
OUTFILENAME=${OUTFILENAME/"_ZIP"/""}
OUTFILENAME=${OUTFILENAME/"_RAR"/""}
OUTFILENAME=${OUTFILENAME/"_TAR"/""}
OUTFILENAME=${OUTFILENAME/"_XZ"/""}
OUTFILENAME=${OUTFILENAME/"_GZ"/""}
OUTPUTPATH=`echo $OUTFILENAME.$GLTF`
OUTPUT=`echo ${OUTPUT}gltf/`
isOutput=true
fi
if [[ "$EXT" != "$filename" ]]; then
EXT="${EXT,,}"
if [[ ! -d "$OUTPUT" ]]; then
mkdir "$OUTPUT"
fi
if [[ ! -f $OUTPUT/$NAME.$GLTF || $FORCE ]]; then
start=`date +%s`
case $EXT in
abc|blend|dae|fbx|obj|ply|stl|wrl|x3d)
handle_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
end=`date +%s`
echo "File $FILENAME compressed successfully. Runtime: $((end-start))s."
exit 0;
;;
ifc)
handle_ifc_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
end=`date +%s`
echo "File $FILENAME compressed successfully. Runtime: $((end-start))s."
exit 0;
;;
glb)
render_preview $EXT
end=`date +%s`
echo "Given file was already compressed."
exit 0;
;;
*)
handle_unsupported_file "$INPATH" "$FILENAME" "$NAME" $EXT "$OUTPUT" "$OUTPUTPATH"
echo "Flie extension $EXT is not supported for conversion yet."
exit 1;
;;
esac
else
echo "Compressed file $OUTPUT/$NAME.$GLTF already exists."
exit 1;
fi
else
echo "No extension found on $FILENAME";
exit 2;
fi
else
echo "No file $INPUT or 0 arguments given."
echo "Usage: ./convert.sh -c true/false -cl [0-6] -i INPUT -o OUTPUT -b true/false -f true/false"
echo "-c=compress -cl=compression level -i=input path -o=output path -b=binary -f=force override existing file"
fi

View file

@ -0,0 +1,98 @@
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = path.resolve(__dirname, '..');
const stagingDir = path.join(root, '.pack-drupal-staging', 'dfg_3dviewer');
const outFile = path.join(root, 'dfg_3dviewer-drupal.zip');
const moduleFiles = [
'dfg_3dviewer.info.yml',
'dfg_3dviewer.libraries.yml',
'dfg_3dviewer.links.menu.yml',
'dfg_3dviewer.module',
'dfg_3dviewer.permissions.yml',
'dfg_3dviewer.routing.yml',
'dfg_3dviewer.services.yml',
'dfg_3dviewer.translation.yml',
'README.md',
];
const moduleDirs = ['src', 'config'];
const scriptExclude = new Set([
'pack-drupal-module.js',
'.env',
]);
function copyRecursive(src, dest) {
fs.cpSync(src, dest, { recursive: true });
}
function stageScripts(targetScriptsDir) {
fs.mkdirSync(targetScriptsDir, { recursive: true });
for (const entry of fs.readdirSync(path.join(root, 'scripts'), { withFileTypes: true })) {
if (entry.isFile() && scriptExclude.has(entry.name)) {
continue;
}
const src = path.join(root, 'scripts', entry.name);
const dest = path.join(targetScriptsDir, entry.name);
if (entry.isDirectory()) {
copyRecursive(src, dest);
} else {
fs.copyFileSync(src, dest);
}
}
}
function stageModule() {
if (fs.existsSync(path.dirname(stagingDir))) {
fs.rmSync(path.dirname(stagingDir), { recursive: true, force: true });
}
fs.mkdirSync(stagingDir, { recursive: true });
for (const file of moduleFiles) {
const src = path.join(root, file);
if (!fs.existsSync(src)) {
console.warn(`[pack-drupal] skip missing file: ${file}`);
continue;
}
fs.copyFileSync(src, path.join(stagingDir, file));
}
for (const dir of moduleDirs) {
copyRecursive(path.join(root, dir), path.join(stagingDir, dir));
}
stageScripts(path.join(stagingDir, 'scripts'));
}
stageModule();
if (fs.existsSync(outFile)) {
fs.rmSync(outFile);
}
const zipProcess = spawnSync('zip', ['-rq', outFile, 'dfg_3dviewer'], {
cwd: path.dirname(stagingDir),
stdio: 'inherit',
});
fs.rmSync(path.dirname(stagingDir), { recursive: true, force: true });
if (zipProcess.error) {
if (zipProcess.error.code === 'ENOENT') {
console.error('`zip` command not found. Install it before running pack.');
process.exit(1);
}
throw zipProcess.error;
}
if (zipProcess.status !== 0) {
process.exit(zipProcess.status ?? 1);
}
console.log(`Created ${outFile} (${fs.statSync(outFile).size} bytes)`);

33
scripts/progress.js Normal file
View file

@ -0,0 +1,33 @@
(function () {
const progressWrapper = document.querySelector('[data-3d-progress]');
if (!progressWrapper) {
return;
}
const entityId = progressWrapper.dataset.entityId;
function checkProgress() {
fetch('/dfg-3dviewer/progress/' + entityId)
.then(r => r.json())
.then(data => {
const bar = document.querySelector('#progress-bar');
const label = document.querySelector('#progress-label');
if (!bar || !label) return;
bar.style.width = data.progress + '%';
label.innerText = data.progress + '%';
if (data.status === 'ready') {
location.reload();
}
});
}
setInterval(checkProgress, 3000);
})();

439
scripts/render.py Executable file
View file

@ -0,0 +1,439 @@
#
# The MIT License (MIT)
#
# Copyright (c) since 2017 UX3D GmbH
#
# 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.
#
#
# Imports
#
import bpy
import os
import sys
import numpy as np
import math
from mathutils import Matrix, Vector
import itertools
from math import radians
import argparse
import time
if '--' in sys.argv:
argv = sys.argv[sys.argv.index('--') + 1:]
parser=argparse.ArgumentParser()
parser.add_argument("--input", help="Input file path")
parser.add_argument("--ext", help="Extenstion of imported file")
parser.add_argument("--org_ext", help="Original extenstion of imported file")
parser.add_argument("--output", help="Output file path")
parser.add_argument("--is_archive", help="Importing archive flag")
parser.add_argument("--resolution", help="Resolution preview images")
parser.add_argument("--samples", help="Samples rendering quality")
args = parser.parse_known_args(argv)[0]
def rotation_matrix(axis, theta):
"""
Return the rotation matrix associated with counterclockwise rotation about
the given axis by theta radians.
"""
axis = np.asarray(axis)
axis = axis / math.sqrt(np.dot(axis, axis))
a = math.cos(theta / 2.0)
b, c, d = -axis * math.sin(theta / 2.0)
aa, bb, cc, dd = a * a, b * b, c * c, d * d
bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d
return np.array([[aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)],
[2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)],
[2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc]])
def rotate(point, angle_degrees, axis=(0,1,0)):
theta_degrees = angle_degrees
theta_radians = math.radians(theta_degrees)
rotated_point = np.dot(rotation_matrix(axis, theta_radians), point)
return rotated_point
""" get_min
- (bound_box) bound_box
utilized bound_box
>>> (Vector) (x,y,z)
get_min estimates the minimal x, y, z values
"""
def get_min(bound_box):
min_x = min([bound_box[i][0] for i in range(0, 8)])
min_y = min([bound_box[i][1] for i in range(0, 8)])
min_z = min([bound_box[i][2] for i in range(0, 8)])
return Vector((min_x, min_y, min_z))
""" get_max
- (bound_box) bound_box
utilized bound_box
>>> (Vector) (x,y,z)
get_max estimates the maximal x, y, z values
"""
def get_max(bound_box):
max_x = max([bound_box[i][0] for i in range(0, 8)])
max_y = max([bound_box[i][1] for i in range(0, 8)])
max_z = max([bound_box[i][2] for i in range(0, 8)])
return Vector((max_x, max_y, max_z))
def get_origin(v1, v2):
return v1 + 0.5 * (v2 - v1)
max_model_dim = 10
def scale_scene():
pmin = Vector((float("inf"), float("inf"), float("inf")))
pmax = Vector((float("-inf"), float("-inf"), float("-inf")))
for o in bpy.data.objects:
if o.type == 'MESH':
mat = o.matrix_world
for v in o.bound_box:
v = mat @ Vector(v)
if v[0] < pmin[0]: pmin[0] = v[0]
if v[1] < pmin[1]: pmin[1] = v[1]
if v[2] < pmin[2]: pmin[2] = v[2]
if v[0] > pmax[0]: pmax[0] = v[0]
if v[1] > pmax[1]: pmax[1] = v[1]
if v[2] > pmax[2]: pmax[2] = v[2]
root = bpy.data.objects.new("scaled_root", None)
for obj in bpy.context.scene.objects:
if not obj.parent:
obj.parent = root
bpy.context.scene.collection.objects.link(root)
center = (pmin + pmax) / 2
scale = max_model_dim / (pmax-pmin).length
root.matrix_world = Matrix.Diagonal((scale,) * 3).to_4x4() @ Matrix.Translation(-center)
pmin = root.matrix_world @ pmin
pmax = root.matrix_world @ pmax
bounds = [
pmin[0], pmin[1], pmin[2], # left front bottom
pmin[0], pmin[1], pmax[2], # left front top
pmin[0], pmax[1], pmax[2], # left back top
pmin[0], pmax[1], pmin[2], # left back bottom
pmax[0], pmin[1], pmin[2], # right front bottom
pmax[0], pmin[1], pmax[2], # right front top
pmax[0], pmax[1], pmax[2], # right back top
pmax[0], pmax[1], pmin[2] # right back bottom
]
return bounds
#
# Globals
#
bpy.context.scene.render.resolution_percentage = 70
bpy.context.scene.render.resolution_x = 1024
bpy.context.scene.render.resolution_y = 1024
bpy.context.scene.cycles.samples = 20
if args.resolution:
resolution = args.resolution.split('x', 2)
bpy.context.scene.render.resolution_x = int(resolution[0])
bpy.context.scene.render.resolution_y = int(resolution[1])
if args.samples:
bpy.context.scene.cycles.samples = int(args.samples)
#
# Functions
#
current_directory = os.getcwd()
extension = "glb"
original_extension = "glb"
if args.ext:
extension = args.ext
if extension == "gltf":
format = "GLTF_EMBEDDED"
else:
format = "GLB"
if args.org_ext:
original_extension = args.org_ext
is_archive = args.is_archive
print("Converting: '" + original_extension + "'")
root, current_extension = os.path.splitext(args.input)
current_basename = os.path.basename(root)
if current_extension == ".abc" or current_extension == ".blend" or current_extension == ".dae" or current_extension == ".fbx" or current_extension == ".gltf" or current_extension == ".glb" or current_extension == ".obj" or current_extension == ".ply" or current_extension == ".stl" or current_extension == ".wrl" or current_extension == ".x3d":
bpy.ops.wm.read_factory_settings(use_empty=True)
if current_extension == ".abc":
bpy.ops.wm.alembic_import(filepath=args.input)
if current_extension == ".blend":
bpy.ops.wm.open_mainfile(filepath=args.input)
if current_extension == ".dae":
bpy.ops.wm.collada_import(filepath=args.input)
if current_extension == ".fbx":
bpy.ops.import_scene.fbx(filepath=args.input)
if current_extension == ".obj":
object=bpy.ops.import_scene.obj(filepath=args.input)
if current_extension == ".ply":
bpy.ops.import_mesh.ply(filepath=args.input)
if current_extension == ".stl":
bpy.ops.import_mesh.stl(filepath=args.input)
if current_extension == ".wrl" or current_extension == ".x3d":
bpy.ops.import_scene.x3d(filepath=args.input)
if current_extension == ".gltf" or current_extension == ".glb":
bpy.ops.import_scene.gltf(filepath=args.input)
scene = bpy.context.scene
context = bpy.context
render = scene.render
# --------------------------------------------------
# UTILS
# --------------------------------------------------
def np_matmul_coords(coords, matrix):
M = matrix.transposed()
ones = np.ones((coords.shape[0], 1))
coords4d = np.hstack((coords, ones))
return np.dot(coords4d, M)[:, :-1]
def get_scene_bounds():
coords = np.vstack(
tuple(
np_matmul_coords(np.array(o.bound_box), o.matrix_world.copy())
for o in scene.objects if o.type == 'MESH'
)
)
bfl = coords.min(axis=0)
tbr = coords.max(axis=0)
size = Vector(tbr - bfl)
center = Vector((bfl + tbr) * 0.5)
return center, size
def fit_camera_to_bounds(cam, center, size, margin=1.2):
# aspect ratio of render
render = bpy.context.scene.render
aspect = render.resolution_x / render.resolution_y
ratio = size.x / size.z
print(f"Camera fit ratio: {ratio:.2f}")
if ratio > 6.0:
cam.data.type = 'ORTHO'
# ORTHO: skala = wysokość kadru
ortho_height = size.z * 1.2
ortho_width = size.x * 1.2 / aspect
cam.data.ortho_scale = max(ortho_height, ortho_width)
# clipping MUST HAVE
cam.data.clip_start = 0.01
cam.data.clip_end = max(size) * 10
else:
cam.data.type = 'PERSP'
cam.data.clip_start = 0.01
cam.data.clip_end = max(size) * 10
# FOV vertical and horizontal
fov_x = cam_data.angle
fov_y = 2 * math.atan(math.tan(fov_x / 2) / aspect)
# required distance to fit bounds in view
dist_x = (size.x * 0.5) / math.tan(fov_x * 0.5)
dist_z = (size.z * 0.5) / math.tan(fov_y * 0.5)
distance = max(dist_x, dist_z) * margin
cam.location = center + Vector((0, -distance, 0))
if args.output:
export_file = args.output
else:
root = root[::-1].replace(current_basename[::-1], "", 1)[::-1]
export_file = root + "_" + extension
if is_archive:
mainfilepath=export_file+current_basename
else:
mainfilepath=export_file+current_basename+"."+original_extension
# --------------------------------------------------
# RENDER / CYCLES
# --------------------------------------------------
render.engine = 'CYCLES'
render.film_transparent = True
render.resolution_x = int(resolution[0])
render.resolution_y = int(resolution[1])
render.resolution_percentage = 100
render.image_settings.file_format = 'PNG'
render.image_settings.color_mode = 'RGBA'
render.image_settings.color_depth = '16'
render.image_settings.color_management = 'FOLLOW_SCENE'
render.image_settings.view_settings.view_transform = 'Standard'
scene.render.use_compositing = True
scene.cycles.device = 'CPU'
scene.cycles.samples = 256
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.03
scene.cycles.adaptive_min_samples = 16
scene.cycles.use_denoising = True
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
scene.cycles.denoising_input_passes = 'RGB_ALBEDO_NORMAL'
scene.cycles.denoising_prefilter = 'ACCURATE'
scene.cycles.max_bounces = 6
scene.cycles.diffuse_bounces = 3
scene.cycles.glossy_bounces = 3
scene.cycles.transparent_max_bounces = 4
scene.cycles.transmission_bounces = 4
scene.cycles.sample_clamp_indirect = 20
scene.cycles.light_sampling_threshold = 0.03
scene.view_settings.exposure = 1.0
scene.view_settings.gamma = 1.0
# CUDA OFF (no warnings)
prefs = bpy.context.preferences
prefs.addons['cycles'].preferences.compute_device_type = 'NONE'
# --------------------------------------------------
# VIEW LAYER PASSES (CLI SAFE)
# --------------------------------------------------
view_layer = scene.view_layers["ViewLayer"]
view_layer.use_pass_normal = True
view_layer.use_pass_diffuse_color = True
view_layer.use_pass_object_index = True
# --------------------------------------------------
# CAMERA
# --------------------------------------------------
center, size = get_scene_bounds()
cam_data = bpy.data.cameras.new("Camera")
cam_data.lens = 50 # product look
cam_data.sensor_width = 36
cam = bpy.data.objects.new("Camera", cam_data)
scene.collection.objects.link(cam)
scene.camera = cam
cam_empty = bpy.data.objects.new("CamTarget", None)
cam_empty.location = center
scene.collection.objects.link(cam_empty)
cam.parent = cam_empty
constraint = cam.constraints.new(type='TRACK_TO')
constraint.target = cam_empty
constraint.track_axis = 'TRACK_NEGATIVE_Z'
constraint.up_axis = 'UP_Y'
constraint.owner_space = 'WORLD'
constraint.target_space = 'WORLD'
# --------------------------------------------------
# BASE CAMERA FIT
# --------------------------------------------------
fit_camera_to_bounds(cam, center, size, margin=1.45)
# --------------------------------------------------
# LIGHT
# --------------------------------------------------
max_size = max(size)
sun_data = bpy.data.lights.new('SunMain', type='SUN')
sun_data.energy = 5.0 # 4.06.0 sweet spot
sun = bpy.data.objects.new('SunMain', sun_data)
scene.collection.objects.link(sun)
sun.rotation_euler = (
math.radians(50),
0.0,
math.radians(30)
)
fill_data = bpy.data.lights.new('SunFill', type='SUN')
fill_data.energy = 0.5 # 10% głównego
fill = bpy.data.objects.new('SunFill', fill_data)
scene.collection.objects.link(fill)
fill.rotation_euler = (
math.radians(75),
0.0,
math.radians(-120)
)
# --------------------------------------------------
# RENDERS
# --------------------------------------------------
t0 = time.perf_counter()
print("Starting rendering...")
def render_angle(angle_deg, suffix):
print(f"Rendering angle {angle_deg}")
cam_empty.rotation_euler = (0, 0, math.radians(angle_deg))
scene.render.filepath = f"{mainfilepath}_{suffix}.png"
bpy.ops.render.render(write_still=True)
# sides
for a in [0, 90, 180, 270]:
render_angle(a, f"side{a}")
# hero angles
for a in [45, 135, 225, 315]:
render_angle(a, f"side{a}")
# top
cam.location = center + Vector((0, 0, max(size.x, size.y) * 1.3))
cam.rotation_euler = (0, 0, 0)
scene.render.filepath = f"{mainfilepath}_top.png"
bpy.ops.render.render(write_still=True)
t1 = time.perf_counter()
print(f"Rendering done (took: {t1 - t0:.3f} s)")
# --------------------------------------------------

194
scripts/render.sh Executable file
View file

@ -0,0 +1,194 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "${SCRIPT_DIR}/.env" ]]; then
source "${SCRIPT_DIR}/.env"
fi
BLENDER_BIN="${BLENDER_BIN:-}"
if [[ -z "$BLENDER_BIN" ]]; then
BLENDER_BIN="blender"
fi
SPATH="${SPATH:-$(cd "$SCRIPT_DIR/.." && pwd)}"
IS_ARCHIVE=false
INPUT=""
GLB_INPUT=""
die() {
echo "Error: $*" >&2
exit 1
}
bool() {
[[ "$1" == "true" || "$1" == "false" ]] || die "Value must be true/false (is: $1)"
}
resolve_blender_bin() {
local candidate="$1"
if [[ -z "$candidate" ]]; then
return 1
fi
if [[ "$candidate" == /* ]]; then
[[ -f "$candidate" && -x "$candidate" ]] || return 1
printf '%s\n' "$candidate"
return 0
fi
if [[ -f "$SCRIPT_DIR/$candidate" && -x "$SCRIPT_DIR/$candidate" ]]; then
printf '%s\n' "$SCRIPT_DIR/$candidate"
return 0
fi
if [[ -f "$SPATH/$candidate" && -x "$SPATH/$candidate" ]]; then
printf '%s\n' "$SPATH/$candidate"
return 0
fi
if command -v "$candidate" &> /dev/null; then
command -v "$candidate"
return 0
fi
return 1
}
if ! BLENDER_BIN="$(resolve_blender_bin "$BLENDER_BIN")"; then
BLENDER_BIN=""
fi
check_blender() {
if ! BLENDER_BIN="$(resolve_blender_bin "$BLENDER_BIN")"; then
echo "Blender doesn't exist, install it by 'apt install blender python3-pip' then 'pip install numpy' or set BLENDER_BIN in scripts/.env"
return 1
elif [[ ! -f "$BLENDER_BIN" || ! -x "$BLENDER_BIN" ]]; then
echo "Configured BLENDER_BIN is not executable: $BLENDER_BIN"
return 1
fi
if [[ -n "${BLENDER_BIN:-}" ]]; then
echo "Blender exists and be used for next steps..."
return 0
fi
}
check_xvfb_run() {
if ! command -v xvfb-run &> /dev/null; then
echo "xvfb-run doesn't exist, install it by 'apt install xvfb'"
return 1
else
echo "xvfb-run exists and be used for next steps..."
return 0
fi
}
check_scripts() {
if [ ! -d "${SPATH}/scripts" ]; then
echo "Can't find dependencies directory. Did you change your SPATH value in scripts/.env?"
return 1
else
return 0
fi
}
show_usage() {
cat <<EOF
Usage: render.sh [options]
Options:
-i, --input FILE Original input model path.
-g, --glb-input FILE Optional explicit GLB path.
-a, --archive true|false
-h, --help
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
-i|--input)
INPUT="$2"
shift 2
;;
-g|--glb-input)
GLB_INPUT="$2"
shift 2
;;
-a|--archive)
bool "$2"
IS_ARCHIVE="$2"
shift 2
;;
-h|--help)
show_usage
;;
*)
die "Error parsing arguments"
;;
esac
done
[[ -n "$INPUT" ]] || die "No --input argument provided"
[[ -f "$INPUT" ]] || die "Input file not found: $INPUT"
FILENAME=${INPUT##*/}
NAME="${FILENAME%.*}"
EXT=${FILENAME//*.}
EXT="${EXT,,}"
INPATH=${INPUT%/*}
if [[ $FILENAME = $INPATH ]]; then
INPATH="."
fi
INPUT_GLTF_PATH="$INPATH/$NAME.glb"
ORG_EXT="$EXT"
if [[ "$EXT" != "glb" ]]; then
INPUT_GLTF_PATH="$INPATH/gltf/$NAME.glb"
fi
if [[ -n "$GLB_INPUT" ]]; then
INPUT_GLTF_PATH="$GLB_INPUT"
fi
if [[ ! -f "$INPUT_GLTF_PATH" ]]; then
die "Render input not found: $INPUT_GLTF_PATH"
fi
mkdir -p "$INPATH/views"
RENDER_LOCKFILE="$INPATH/views/${NAME}.lock"
exec 201>"$RENDER_LOCKFILE" || exit 1
flock -n 201 || {
echo "Render already running for $NAME"
exec 201>&- 2>/dev/null || true
exit 0
}
trap 'flock -u 201 2>/dev/null || true; exec 201>&- 2>/dev/null || true; rm -f "$RENDER_LOCKFILE"' EXIT
check_blender || die "Blender validation failed"
check_xvfb_run || die "xvfb-run validation failed"
check_scripts || die "Dependency scripts directory not found"
if [[ -z "$RENDER_RESOLUTION" ]]; then
RENDER_RESOLUTION='1024x1024x16'
fi
if [[ -z "$RENDER_SAMPLES" ]]; then
RENDER_SAMPLES='20'
fi
echo "Rendering thumbnails..."
xvfb-run --auto-servernum \
--server-args="-screen 0 ${RENDER_RESOLUTION}" \
"$BLENDER_BIN" -b -P "$SPATH/scripts/render.py" -- \
--input "$INPUT_GLTF_PATH" \
--ext glb \
--org_ext "$ORG_EXT" \
--output "$INPATH/views/" \
--is_archive "$IS_ARCHIVE" \
--resolution "$RENDER_RESOLUTION" \
--samples "$RENDER_SAMPLES" \
-E BLENDER_EEVEE -f 1
echo "Blender exit code: $?"

415
scripts/render2.py Normal file
View file

@ -0,0 +1,415 @@
#
# The MIT License (MIT)
#
# Copyright (c) since 2017 UX3D GmbH
#
# 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.
#
#
# Imports
#
import bpy
import os
import sys
import numpy as np
import math
from mathutils import Matrix, Vector
import itertools
from math import radians
bpy.context.scene.render.resolution_percentage = 50
bpy.context.scene.render.resolution_x = 1280
bpy.context.scene.render.resolution_y = 960
bpy.context.scene.cycles.samples = 20
def rotation_matrix(axis, theta):
"""
Return the rotation matrix associated with counterclockwise rotation about
the given axis by theta radians.
"""
axis = np.asarray(axis)
axis = axis / math.sqrt(np.dot(axis, axis))
a = math.cos(theta / 2.0)
b, c, d = -axis * math.sin(theta / 2.0)
aa, bb, cc, dd = a * a, b * b, c * c, d * d
bc, ad, ac, ab, bd, cd = b * c, a * d, a * c, a * b, b * d, c * d
return np.array([[aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)],
[2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)],
[2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc]])
def rotate(point, angle_degrees, axis=(0,1,0)):
theta_degrees = angle_degrees
theta_radians = math.radians(theta_degrees)
rotated_point = np.dot(rotation_matrix(axis, theta_radians), point)
return rotated_point
""" get_min
- (bound_box) bound_box
utilized bound_box
>>> (Vector) (x,y,z)
get_min estimates the minimal x, y, z values
"""
def get_min(bound_box):
min_x = min([bound_box[i][0] for i in range(0, 8)])
min_y = min([bound_box[i][1] for i in range(0, 8)])
min_z = min([bound_box[i][2] for i in range(0, 8)])
return Vector((min_x, min_y, min_z))
""" get_max
- (bound_box) bound_box
utilized bound_box
>>> (Vector) (x,y,z)
get_max estimates the maximal x, y, z values
"""
def get_max(bound_box):
max_x = max([bound_box[i][0] for i in range(0, 8)])
max_y = max([bound_box[i][1] for i in range(0, 8)])
max_z = max([bound_box[i][2] for i in range(0, 8)])
return Vector((max_x, max_y, max_z))
def get_origin(v1, v2):
return v1 + 0.5 * (v2 - v1)
max_model_dim = 10
def scale_scene():
pmin = Vector((float("inf"), float("inf"), float("inf")))
pmax = Vector((float("-inf"), float("-inf"), float("-inf")))
for o in bpy.data.objects:
if o.type == 'MESH':
mat = o.matrix_world
for v in o.bound_box:
v = mat @ Vector(v)
if v[0] < pmin[0]: pmin[0] = v[0]
if v[1] < pmin[1]: pmin[1] = v[1]
if v[2] < pmin[2]: pmin[2] = v[2]
if v[0] > pmax[0]: pmax[0] = v[0]
if v[1] > pmax[1]: pmax[1] = v[1]
if v[2] > pmax[2]: pmax[2] = v[2]
root = bpy.data.objects.new("scaled_root", None)
for obj in bpy.context.scene.objects:
if not obj.parent:
obj.parent = root
bpy.context.scene.collection.objects.link(root)
center = (pmin + pmax) / 2
scale = max_model_dim / (pmax-pmin).length
root.matrix_world = Matrix.Diagonal((scale,) * 3).to_4x4() @ Matrix.Translation(-center)
pmin = root.matrix_world @ pmin
pmax = root.matrix_world @ pmax
bounds = [
pmin[0], pmin[1], pmin[2], # left front bottom
pmin[0], pmin[1], pmax[2], # left front top
pmin[0], pmax[1], pmax[2], # left back top
pmin[0], pmax[1], pmin[2], # left back bottom
pmax[0], pmin[1], pmin[2], # right front bottom
pmax[0], pmin[1], pmax[2], # right front top
pmax[0], pmax[1], pmax[2], # right back top
pmax[0], pmax[1], pmin[2] # right back bottom
]
return bounds
#
# Globals
#
#
# Functions
#
current_directory = os.getcwd()
if sys.argv[6:]:
extension = sys.argv[6]
if extension == "gltf":
format = "GLTF_EMBEDDED"
else:
format = "GLB"
force_continue = True
for current_argument in sys.argv:
if force_continue:
if current_argument == '--':
force_continue = False
continue
#
root, current_extension = os.path.splitext(current_argument)
current_basename = os.path.basename(root)
if current_extension != ".abc" and current_extension != ".blend" and current_extension != ".dae" and current_extension != ".fbx" and current_extension != ".gltf" and current_extension != ".glb" and current_extension != ".obj" and current_extension != ".ply" and current_extension != ".stl" and current_extension != ".wrl" and current_extension != ".x3d":
continue
bpy.ops.wm.read_factory_settings(use_empty=True)
#print("Converting: '" + current_argument + "'")
#
if current_extension == ".abc":
bpy.ops.wm.alembic_import(filepath=current_argument)
if current_extension == ".blend":
bpy.ops.wm.open_mainfile(filepath=current_argument)
if current_extension == ".dae":
bpy.ops.wm.collada_import(filepath=current_argument)
if current_extension == ".fbx":
bpy.ops.import_scene.fbx(filepath=current_argument)
if current_extension == ".obj":
object=bpy.ops.import_scene.obj(filepath=current_argument)
if current_extension == ".ply":
bpy.ops.import_mesh.ply(filepath=current_argument)
if current_extension == ".stl":
bpy.ops.import_mesh.stl(filepath=current_argument)
if current_extension == ".wrl" or current_extension == ".x3d":
bpy.ops.import_scene.x3d(filepath=current_argument)
if current_extension == ".gltf" or current_extension == ".glb":
bpy.ops.import_scene.gltf(filepath=current_argument)
scene = bpy.context.scene
context = bpy.context
render = bpy.context.scene.render
bounds = scale_scene()
item='MESH'
bpy.ops.object.select_all(action='DESELECT')
bpy.ops.object.select_by_type(type=item)
# multiply 3d coord list by matrix
def np_matmul_coords(coords, matrix, space=None):
M = (space @ matrix @ space.inverted()
if space else matrix).transposed()
ones = np.ones((coords.shape[0], 1))
coords4d = np.hstack((coords, ones))
return np.dot(coords4d, M)[:,:-1]
return coords4d[:,:-1]
# get the global coordinates of all object bounding box corners
coords = np.vstack(
tuple(np_matmul_coords(np.array(o.bound_box), o.matrix_world.copy())
for o in
bpy.context.scene.objects
if o.type == 'MESH'
)
)
print("#" * 72)
# bottom front left (all the mins)
bfl = coords.min(axis=0)
# top back right
tbr = coords.max(axis=0)
G = np.array((bfl, tbr)).T
# bound box coords ie the 8 combinations of bfl tbr.
bbc = [i for i in itertools.product(*G)]
print(np.array(bbc))
bb_sides = get_min(bbc) - get_max(bbc)
bb_sides = (abs(bb_sides[0]), abs(bb_sides[1]), abs(bb_sides[2]))
print("#" * 72)
print('BBOX')
print(bb_sides)
group = bpy.data.collections.new("MainGroup")
bpy.context.scene.collection.children.link(group)
#for ob in context.selected_objects: # or whichever list of objects desired
# group.objects.link(ob)
#print("Moving objects into origin (0, 0, 0)")
#group.location = (0, 0, 0)
#for obj in context.selected_objects:
# obj.location = (0, 0, 0)
render.engine = "CYCLES"
render.film_transparent = True
scene.cycles.device = "CPU"
scene.cycles.samples = 128 # default 128
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.1
scene.cycles.adaptive_min_samples = 1
scene.cycles.use_denoising = True
scene.cycles.seed = 0 # default
scene.cycles.use_animated_seed = True
scene.cycles.min_light_bounces = 0 # default
scene.cycles.min_transparent_bounces = 0 # default
scene.cycles.light_sampling_threshold = 0.01 # default
scene.cycles.max_bounces = 5
scene.cycles.sample_clamp_direct = 0 # default
scene.cycles.sample_clamp_indirect = 10 # default
scene.cycles.blur_glossy = 1 # default
scene.cycles.caustics_reflective = False
scene.cycles.caustics_refractive = False
#render.engine = 'BLENDER_EEVEE'
#render.engine = 'CYCLES'
#render.engine = 'BLENDER_WORKBENCH'
render.image_settings.color_mode = 'RGBA'
render.image_settings.color_depth = '16'
render.image_settings.file_format = 'PNG'
render.resolution_x = 512
render.resolution_y = 512
render.resolution_percentage = 100
render.film_transparent = True
#scene.render.engine = 'CYCLES'
scene.render.use_freestyle = False
scene.use_nodes = True
scene.view_layers["View Layer"].use_pass_normal = True
scene.view_layers["View Layer"].use_pass_diffuse_color = True
scene.view_layers["View Layer"].use_pass_object_index = True
#
#print("ROOT" + root)
if sys.argv[7:]:
export_file = str(sys.argv[7])
else:
root = root[::-1].replace(current_basename[::-1], "", 1)[::-1]
export_file = root + "_" + extension
multiplier=10
levels=3
density=5
r_offset=0.2
z_offset=0.2
#target_obj = bpy.context.selected_objects[0]
#target_origin = target_obj.location
# get bounding box side lengths
#bb_sides = get_min(target_obj.bound_box) - get_max(target_obj.bound_box)
(dist_x, dist_y, dist_z) = tuple([abs(c) for c in bb_sides])
originated_dist_y = .5 * dist_y
radius = 0.5 * max(dist_x, dist_z)
max_size = max(dist_x, dist_y, dist_z)
light_data = bpy.data.lights.new('light', type='AREA')
sun = bpy.data.objects.new('light', light_data)
sun.data.energy=max_size*5000.0
sun.data.size = max_size*2
#sun.location = (3, 4, -5)
#sun.location = (dist_x*1.4, dist_y*1.4, dist_z*1.4)
sun.location = (0,0,0)
bpy.context.collection.objects.link(sun)
sun_bottom = bpy.data.objects.new('light_bottom', light_data)
sun_bottom.data.energy=max_size*5000.0
sun_bottom.data.size = max_size*2
sun_bottom.location = (-dist_x*1.4, dist_y*1.4, dist_z*1.4)
#sun_bottom.location = (0, 0, dist_z/8)
sun_bottom.rotation_euler = (2, 0.3, 0.3)
bpy.context.collection.objects.link(sun_bottom)
scene.render.image_settings.file_format='PNG'
scene.render.filepath=export_file+current_basename+'.png'
print("Rendering: " + scene.render.filepath)
cam_data = bpy.data.cameras.new('camera')
cam = bpy.data.objects.new('camera', cam_data)
cam.data.lens = 35
cam.data.sensor_width = 32
cam_constraint = cam.constraints.new(type='TRACK_TO')
cam_constraint.track_axis = 'TRACK_NEGATIVE_Z'
cam_constraint.up_axis = 'UP_Y'
cam_empty = bpy.data.objects.new("Empty", None)
#cam_empty.location = (0, 0, 0)
cam_empty.location = (0, 0, 0)
cam.parent = cam_empty
scene.collection.objects.link(cam_empty)
context.view_layer.objects.active = cam_empty
cam_constraint.target = cam_empty
print(dist_x, dist_y, dist_z)
#cam.location=Vector((dist_x, dist_y, dist_z))
bpy.context.collection.objects.link(cam)
"""
# get the current object
for _obj in scene.objects:
if (_obj.type == 'MESH'):
current_obj = _obj
# set geometry to origin
bpy.ops.object.origin_set(type="GEOMETRY_ORIGIN")
zverts = []
# get all z coordinates of the vertices
for face in current_obj.data.polygons:
verts_in_face = face.vertices[:]
for vert in verts_in_face:
local_point = current_obj.data.vertices[vert].co
world_point = current_obj.matrix_world @ local_point
zverts.append(world_point[2])
scene.cursor.location = (0, 0, min(zverts))
bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
# set the object to (0,0,0)
current_obj.location = (0,0,0)
# reset the cursor
scene.cursor.location = (0,0,0)
"""
#bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
#bpy.ops.export_scene.fbx(filepath=export_file+current_basename+'.fbx')
scene.camera=cam
cam.location = (0, dist_y*2.5, dist_z*0.9)
sun.location = cam.location
sun_bottom.location = cam.location
#cam.location=Vector((dist_x/multiplier, dist_y/multiplier, dist_z/multiplier))
#scene.render.filepath=export_file+current_basename+'_org.png'
#bpy.ops.render.render(write_still=True)
#cam.location = rotate(cam.location, 45, axis=(0, 0, 1))
for angle in range(0, 360, 90):
sun.location = rotate(sun.location, 80, axis=(0, 0, 1))
sun_bottom.location = rotate(sun_bottom.location, 80, axis=(0, 0, 1))
scene.render.filepath=export_file+current_basename+'_side'+str(angle)+'.png'
bpy.ops.render.render(write_still=True)
cam.location = rotate(cam.location, 90, axis=(0, 0, 1))
cam.location = (0, dist_y*2.9, dist_z*1.7)
cam.location = rotate(cam.location, 45, axis=(0, 0, 1))
for angle in range(45, 360, 90):
sun.location = rotate(sun.location, 80, axis=(0, 0, 1))
sun_bottom.location = rotate(sun_bottom.location, 80, axis=(0, 0, 1))
scene.render.filepath=export_file+current_basename+'_side'+str(angle)+'.png'
bpy.ops.render.render(write_still=True)
cam.location = rotate(cam.location, 90, axis=(0, 0, 1))
#top
cam.location=Vector((0, 0, dist_z*5))
sun.location = cam.location
scene.render.filepath=export_file+current_basename+'_top.png'
bpy.ops.render.render(write_still=True)
#bottom
#cam.location=Vector((0, 0, -dist_z*5))
#sun.location = cam.location
#scene.render.filepath=export_file+current_basename+'_bottom.png'
#bpy.ops.render.render(write_still=True)
print("Rendering done")

98
scripts/uncompress.sh Executable file
View file

@ -0,0 +1,98 @@
#!/bin/bash
# Ubuntu way
#apt install unrar-free
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TYPE=""
INPUT=""
OUTPUT=""
NAME=""
FORCE=""
while getopts ":t:o:i:f:n:" flag; do
case "${flag}" in
t) TYPE="${OPTARG}" ;;
i) INPUT="${OPTARG}" ;;
o) OUTPUT="${OPTARG}" ;;
n) NAME="${OPTARG}" ;;
f) FORCE="${OPTARG}" ;;
esac
done
if [[ -f "$SCRIPT_DIR/.env" ]]; then
source "$SCRIPT_DIR/.env"
fi
die() {
echo "Error: $*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1"
}
[[ -n "$TYPE" ]] || die "Missing archive type (-t)"
[[ -n "$INPUT" ]] || die "Missing input archive (-i)"
[[ -n "$OUTPUT" ]] || die "Missing output directory (-o)"
[[ -f "$INPUT" ]] || die "Archive file not found: $INPUT"
TYPE="${TYPE,,}"
mkdir -p "$OUTPUT"
case "$TYPE" in
zip)
if command -v unzip >/dev/null 2>&1; then
unzip -o "$INPUT" -d "$OUTPUT"
elif command -v 7z >/dev/null 2>&1; then
7z x -y "-o$OUTPUT" "$INPUT"
else
die "ZIP extraction requires 'unzip' or '7z'"
fi
;;
rar)
if command -v unrar >/dev/null 2>&1; then
unrar x -o+ "$INPUT" "$OUTPUT/"
elif command -v 7z >/dev/null 2>&1; then
7z x -y "-o$OUTPUT" "$INPUT"
else
die "RAR extraction requires 'unrar' or '7z'"
fi
;;
xz)
require_cmd tar
tar -xJf "$INPUT" -C "$OUTPUT/"
;;
tar)
require_cmd tar
tar -xf "$INPUT" -C "$OUTPUT/"
;;
gz)
require_cmd tar
tar -xzf "$INPUT" -C "$OUTPUT/"
;;
*)
die "Unsupported archive type: $TYPE"
;;
esac
# Keep legacy behavior for archive layouts where the model is placed directly
# in the extraction root and convert.sh is expected to be called from here.
for filename in "$OUTPUT"*; do
[[ -e "$filename" ]] || continue
fname=${filename##*/}
name="${fname%.*}"
ext=${filename##*.}
ext="${ext,,}"
case "$ext" in
abc|blend|dae|fbx|obj|ply|stl|wrl|x3d|ifc)
"${SPATH}/scripts/convert.sh" -c 'true' -l '3' -b 'true' -i "${OUTPUT}${name}.${ext}" -o "${OUTPUT}" -f 'true' -a 'true'
;;
esac
done

47
scripts/worker.sh Normal file
View file

@ -0,0 +1,47 @@
#!/bin/bash
set -euo pipefail
LOG="/opt/drupal/dfg3dworker.log"
PID_FILE="/opt/drupal/dfg3dworker.pid"
DRUPAL_SITE_URI="${DRUPAL_SITE_URI:-https://repository.covher.eu}"
DRUSH_BIN="${DRUSH_BIN:-/opt/drupal/vendor/bin/drush}"
is_pid_running() {
local pid="$1"
if [[ -z "$pid" || ! "$pid" =~ ^[0-9]+$ ]]; then
return 1
fi
if kill -0 "$pid" 2>/dev/null; then
return 0
fi
return 1
}
cleanup() {
rm -f "$PID_FILE"
}
trap cleanup EXIT
if [ -f "$LOG" ] && [ $(stat -c%s "$LOG") -gt 50000000 ]; then
mv "$LOG" "${LOG}.old"
fi
if [ -f "$PID_FILE" ]; then
EXISTING_PID="$(tr -d '[:space:]' < "$PID_FILE" || true)"
if is_pid_running "$EXISTING_PID"; then
echo "$(date '+%Y-%m-%d %H:%M:%S') Worker already running with PID $EXISTING_PID, exiting" >> "$LOG"
exit 0
fi
rm -f "$PID_FILE"
echo "$(date '+%Y-%m-%d %H:%M:%S') Removed stale PID file" >> "$LOG"
fi
echo "$$" > "$PID_FILE"
echo "$(date '+%Y-%m-%d %H:%M:%S') Worker started" >> "$LOG"
while true; do
echo "$(date '+%Y-%m-%d %H:%M:%S') Run started" >> "$LOG"
"$DRUSH_BIN" --uri="$DRUPAL_SITE_URI" queue:run dfg_3dviewer_convert --time-limit=3600 2>&1 | awk '{ print strftime("%Y-%m-%d %H:%M:%S"), $0; fflush(); }' >> "$LOG"
sleep 5
done