0.5: # hue values with delta > 180°
# Hue is cyclic
# > interpolation must be done through the shortest path (clockwise or counterclockwise)
# > to interpolate CCW, add 180° to all hue values, then compute modulo 360° on interpolate result
y1, y2 = [hue+0.5 if hue<0 else hue-0.5 for hue in (y1, y2)]
y = linearInterpo(x1, x2, y1, y2, pos) % 1
else:
y = linearInterpo(x1, x2, y1, y2, pos)
interpolateValues.append(round(y,2))
return Color(interpolateValues, colorSpace)
elif method == 'SPLINE':
xData = self.positions
if len(xData) < 3: #spline interpo needs at least 3 pts, otherwise compute a linear interpolation
return self.evaluate(pos, colorSpace, method='LINEAR')
interpolateValues = []
for i in range(4): #4 channels (rgba or hsva)
yData = [color.getColor(colorSpace)[i] for color in self.colors]
dy = (nextStop.color.getColor(colorSpace)[i] - prevStop.color.getColor(colorSpace)[i])
if colorSpace == 'hsva' and i == 0 and abs(dy) > 0.5: # hue values with delta > 180°
# Hue is cyclic
# > interpolation must be done through the shortest path (clockwise or counterclockwise)
# > to interpolate CCW, add 180° to all hue values, then compute modulo 360° on interpolate result
yData = [hue+0.5 if hue<0 else hue-0.5 for hue in yData]
y = akima.interpolate(xData, yData, [pos])[0] % 1
else:
y = akima.interpolate(xData, yData, [pos])[0]
#Constrain result between 0-1
y = 1 if y>1 else 0 if y<0 else y
#append
interpolateValues.append(round(y,2))
return Color(interpolateValues, colorSpace)
def getRangeColor(self, n, interpoSpace='RGB', interpoMethod='LINEAR'):
'''return a new gradient'''
ramp = Gradient(permissive=True)#permissive needed because discrete interpo can return same color for 2 or more following stops
offset = 1/(n-1)
position = 0
for i in range(n):
color = self.evaluate(position, interpoSpace, interpoMethod)
ramp.addStop(position, color, reorder=False)
position += offset
return ramp
def exportSVG(self, svgPath, discrete=False):
name = os.path.splitext(os.path.basename(svgPath))[0]
name = name.replace(" ", "_")
# create an SVG XML element (see the SVG specification for attribute details)
svg = etree.Element('svg', width='300', height='45', version='1.1', xmlns='http://www.w3.org/2000/svg', viewBox='0 0 300 45')
gradient = etree.Element('linearGradient', id=name, gradientUnits='objectBoundingBox', spreadMethod='pad', x1='0%', x2='100%', y1='0%', y2='0%')
#make discrete svg ramp
if discrete:
stops = []
for i, stop in enumerate(self.stops):
if i>0:
stops.append( Stop(stop.position, self.stops[i-1].color) )
stops.append( Stop(stop.position, stop.color) )
else:
stops = self.stops
for stop in stops:
p = stop.position * 100
p = str(round(p,2)) + '%'
r,g,b = stop.color.RGB
c = "rgb(%d,%d,%d)" % (r, g, b)
a = str(stop.color.alpha)
etree.SubElement(gradient, 'stop', {'offset':p, 'stop-color':c, 'stop-opacity':a}) #use dict because hyphens in tags
svg.append(gradient)
rect = etree.Element('rect', {'fill':"url(#%s)" % (name), 'x':'4', 'y':'4', 'width':'292', 'height':'37', 'stroke':'black', 'stroke_width':'1'})
svg.append(rect)
# get string
xmlstr = etree.tostring(svg, encoding='utf8', method='xml').decode('utf-8')
# etree doesn't have pretty xml function, so use minidom tu get a pretty xml ...
reparsed = parseString(xmlstr)
xmlstr = reparsed.toprettyxml()
# write to file
f = open(svgPath,"w")
f.write(xmlstr)
f.close()
return
================================================
FILE: core/utils/timing.py
================================================
import time
def perf_clock():
if hasattr(time, 'clock'):
return time.clock()
elif hasattr(time, 'perf_counter'):
return time.perf_counter()
else:
raise Exception("Python time lib doesn't contain a suitable clock function")
================================================
FILE: core/utils/xy.py
================================================
# -*- coding:utf-8 -*-
# This file is part of BlenderGIS
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
class XY(object):
'''A class to represent 2-tuple value'''
def __init__(self, x, y, z=None):
'''
You can use the constructor in many ways:
XY(0, 1) - passing two arguments
XY(x=0, y=1) - passing keywords arguments
XY(**{'x': 0, 'y': 1}) - unpacking a dictionary
XY(*[0, 1]) - unpacking a list or a tuple (or a generic iterable)
'''
if z is None:
self.data=[x, y]
else:
self.data=[x, y, z]
def __str__(self):
if self.z is not None:
return "(%s, %s, %s)"%(self.x, self.y, self.z)
else:
return "(%s, %s)"%(self.x,self.y)
def __repr__(self):
return self.__str__()
def __getitem__(self,item):
return self.data[item]
def __setitem__(self, idx, value):
self.data[idx] = value
def __iter__(self):
return iter(self.data)
def __len__(self):
return len(self.data)
@property
def x(self):
return self.data[0]
@property
def y(self):
return self.data[1]
@property
def z(self):
try:
return self.data[2]
except IndexError:
return None
@property
def xy(self):
return self.data[:2]
@property
def xyz(self):
return self.data
================================================
FILE: geoscene.py
================================================
# -*- coding:utf-8 -*-
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
import logging
log = logging.getLogger(__name__)
import bpy
from bpy.props import (StringProperty, IntProperty, FloatProperty, BoolProperty,
EnumProperty, FloatVectorProperty, PointerProperty)
from bpy.types import Operator, Panel, PropertyGroup
from .prefs import PredefCRS
from .core.proj.reproj import reprojPt
from .core.proj.srs import SRS
from .operators.utils import mouseTo3d
PKG = __package__
'''
Policy :
This module manages in priority the CRS coordinates of the scene's origin and
updates the corresponding longitude/latitude only if it can to do the math.
A scene is considered correctly georeferenced when at least a valid CRS is defined
and the coordinates of scene's origin in this CRS space is set. A geoscene will be
broken if the origin is set but not the CRS or if the origin is only set as longitude/latitude.
Changing the CRS will raise an error if updating existing origin coordinate is not possible.
Both methods setOriginGeo() and setOriginPrj() try a projection task to maintain
coordinates synchronized. Failing reprojection does not abort the exec, but will
trigger deletion of unsynch coordinates. Synchronization can be disable for
setOriginPrj() method only.
Except setOriginGeo() method, dealing directly with longitude/latitude
automatically trigger a reprojection task which will raise an error if failing.
Sequences of methods :
moveOriginPrj() | updOriginPrj() > setOriginPrj() > [reprojPt()]
moveOriginGeo() > updOriginGeo() > reprojPt() > updOriginPrj() > setOriginPrj()
Standalone properties (lon, lat, crsx et crsy) can be edited independently without any extra checks.
'''
class SK():
"""Alias to Scene Keys used to store georef infos"""
# latitude and longitude of scene origin in decimal degrees
LAT = "latitude"
LON = "longitude"
#Spatial Reference System Identifier
# can be directly an EPSG code or formated following the template "AUTH:4326"
# or a proj4 string definition of Coordinate Reference System (CRS)
CRS = "SRID"
# Coordinates of scene origin in CRS space
CRSX = "crs x"
CRSY = "crs y"
# General scale denominator of the map (1:x)
SCALE = "scale"
# Current zoom level in the Tile Matrix Set
ZOOM = "zoom"
class GeoScene():
def __init__(self, scn=None):
if scn is None:
self.scn = bpy.context.scene
else:
self.scn = scn
self.SK = SK()
@property
def _rna_ui(self):
# get or init the dictionary containing IDprops settings
rna_ui = self.scn.get('_RNA_UI', None)
if rna_ui is None:
self.scn['_RNA_UI'] = {}
rna_ui = self.scn['_RNA_UI']
return rna_ui
def view3dToProj(self, dx, dy):
'''Convert view3d coords to crs coords'''
if self.hasOriginPrj:
x = self.crsx + (dx * self.scale)
y = self.crsy + (dy * self.scale)
return x, y
else:
raise Exception("Scene origin coordinate is unset")
def projToView3d(self, dx, dy):
'''Convert view3d coords to crs coords'''
if self.hasOriginPrj:
x = (dx * self.scale) - self.crsx
y = (dy * self.scale) - self.crsy
return x, y
else:
raise Exception("Scene origin coordinate is unset")
@property
def hasCRS(self):
return SK.CRS in self.scn
@property
def hasValidCRS(self):
if not self.hasCRS:
return False
return SRS.validate(self.crs)
@property
def isGeoref(self):
'''A scene is georef if at least a valid CRS is defined and
the coordinates of scene's origin in this CRS space is set'''
return self.hasValidCRS and self.hasOriginPrj
@property
def isFullyGeoref(self):
return self.hasValidCRS and self.hasOriginPrj and self.hasOriginGeo
@property
def isPartiallyGeoref(self):
return self.hasCRS or self.hasOriginPrj or self.hasOriginGeo
@property
def isBroken(self):
"""partial georef infos make the geoscene unusuable and broken"""
return (self.hasCRS and not self.hasValidCRS) \
or (not self.hasCRS and (self.hasOriginPrj or self.hasOriginGeo)) \
or (self.hasCRS and self.hasOriginGeo and not self.hasOriginPrj)
@property
def hasOriginGeo(self):
return SK.LAT in self.scn and SK.LON in self.scn
@property
def hasOriginPrj(self):
return SK.CRSX in self.scn and SK.CRSY in self.scn
def setOriginGeo(self, lon, lat):
self.lon, self.lat = lon, lat
try:
self.crsx, self.crsy = reprojPt(4326, self.crs, lon, lat)
except Exception as e:
if self.hasOriginPrj:
self.delOriginPrj()
log.warning('Origin proj has been deleted because the property could not be updated', exc_info=True)
def setOriginPrj(self, x, y, synch=True):
self.crsx, self.crsy = x, y
if synch:
try:
self.lon, self.lat = reprojPt(self.crs, 4326, x, y)
except Exception as e:
if self.hasOriginGeo:
self.delOriginGeo()
log.warning('Origin geo has been deleted because the property could not be updated', exc_info=True)
elif self.hasOriginGeo:
self.delOriginGeo()
log.warning('Origin geo has been deleted because coordinate synchronization is disable')
def updOriginPrj(self, x, y, updObjLoc=True, synch=True):
'''Update/move scene origin passing absolute coordinates'''
if not self.hasOriginPrj:
raise Exception("Cannot update an unset origin.")
dx = x - self.crsx
dy = y - self.crsy
self.setOriginPrj(x, y, synch)
if updObjLoc:
self._moveObjLoc(dx, dy)
def updOriginGeo(self, lon, lat, updObjLoc=True):
if not self.isGeoref:
raise Exception("Cannot update geo origin of an ungeoref scene.")
x, y = reprojPt(4326, self.crs, lon, lat)
self.updOriginPrj(x, y, updObjLoc)
def moveOriginGeo(self, dx, dy, updObjLoc=True):
if not self.hasOriginGeo:
raise Exception("Cannot move an unset origin.")
x = self.lon + dx
y = self.lat + dy
self.updOriginGeo(x, y, updObjLoc=updObjLoc)
def moveOriginPrj(self, dx, dy, useScale=True, updObjLoc=True, synch=True):
'''Move scene origin passing relative deltas'''
if not self.hasOriginPrj:
raise Exception("Cannot move an unset origin.")
if useScale:
self.setOriginPrj(self.crsx + dx * self.scale, self.crsy + dy * self.scale, synch)
else:
self.setOriginPrj(self.crsx + dx, self.crsy + dy, synch)
if updObjLoc:
self._moveObjLoc(dx, dy)
def _moveObjLoc(self, dx, dy):
topParents = [obj for obj in self.scn.objects if not obj.parent]
for obj in topParents:
obj.location.x -= dx
obj.location.y -= dy
def getOriginGeo(self):
return self.lon, self.lat
def getOriginPrj(self):
return self.crsx, self.crsy
def delOriginGeo(self):
del self.lat
del self.lon
def delOriginPrj(self):
del self.crsx
del self.crsy
def delOrigin(self):
self.delOriginGeo()
self.delOriginPrj()
@property
def crs(self):
return self.scn.get(SK.CRS, None) #always string
@crs.setter
def crs(self, v):
#Make sure input value is a valid crs string representation
crs = SRS(v) #will raise an error if the crs is not valid
#Reproj existing origin. New CRS will not be set if updating existing origin is not possible
# try first to reproj from origin geo because self.crs can be empty or broken
if self.hasOriginGeo:
if crs.isWGS84:
#if destination crs is wgs84, just assign lonlat to originprj
self.crsx, self.crsy = self.lon, self.lat
self.crsx, self.crsy = reprojPt(4326, str(crs), self.lon, self.lat)
elif self.hasOriginPrj and self.hasCRS:
if self.hasValidCRS:
# will raise an error is current crs is empty or invalid
self.crsx, self.crsy = reprojPt(self.crs, str(crs), self.crsx, self.crsy)
else:
raise Exception("Scene origin coordinates cannot be updated because current CRS is invalid.")
#Set ID prop
if SK.CRS not in self.scn:
self._rna_ui[SK.CRS] = {"description": "Map Coordinate Reference System", "default": ''}
self.scn[SK.CRS] = str(crs)
@crs.deleter
def crs(self):
if SK.CRS in self.scn:
del self.scn[SK.CRS]
@property
def lat(self):
return self.scn.get(SK.LAT, None)
@lat.setter
def lat(self, v):
if SK.LAT not in self.scn:
self._rna_ui[SK.LAT] = {"description": "Scene origin latitude", "default": 0.0, "min":-90.0, "max":90.0}
if -90 <= v <= 90:
self.scn[SK.LAT] = v
else:
raise ValueError('Wrong latitude value '+str(v))
@lat.deleter
def lat(self):
if SK.LAT in self.scn:
del self.scn[SK.LAT]
@property
def lon(self):
return self.scn.get(SK.LON, None)
@lon.setter
def lon(self, v):
if SK.LON not in self.scn:
self._rna_ui[SK.LON] = {"description": "Scene origin longitude", "default": 0.0, "min":-180.0, "max":180.0}
if -180 <= v <= 180:
self.scn[SK.LON] = v
else:
raise ValueError('Wrong longitude value '+str(v))
@lon.deleter
def lon(self):
if SK.LON in self.scn:
del self.scn[SK.LON]
@property
def crsx(self):
return self.scn.get(SK.CRSX, None)
@crsx.setter
def crsx(self, v):
if SK.CRSX not in self.scn:
self._rna_ui[SK.CRSX] = {"description": "Scene x origin in CRS space", "default": 0.0}
if isinstance(v, (int, float)):
self.scn[SK.CRSX] = v
else:
raise ValueError('Wrong x origin value '+str(v))
@crsx.deleter
def crsx(self):
if SK.CRSX in self.scn:
del self.scn[SK.CRSX]
@property
def crsy(self):
return self.scn.get(SK.CRSY, None)
@crsy.setter
def crsy(self, v):
if SK.CRSY not in self.scn:
self._rna_ui[SK.CRSY] = {"description": "Scene y origin in CRS space", "default": 0.0}
if isinstance(v, (int, float)):
self.scn[SK.CRSY] = v
else:
raise ValueError('Wrong y origin value '+str(v))
@crsy.deleter
def crsy(self):
if SK.CRSY in self.scn:
del self.scn[SK.CRSY]
@property
def scale(self):
return self.scn.get(SK.SCALE, 1)
@scale.setter
def scale(self, v):
if SK.SCALE not in self.scn:
self._rna_ui[SK.SCALE] = {"description": "Map scale denominator", "default": 1, "min": 1}
self.scn[SK.SCALE] = v
@scale.deleter
def scale(self):
if SK.SCALE in self.scn:
del self.scn[SK.SCALE]
@property
def zoom(self):
return self.scn.get(SK.ZOOM, None)
@zoom.setter
def zoom(self, v):
if SK.ZOOM not in self.scn:
self._rna_ui[SK.ZOOM] = {"description": "Basemap zoom level", "default": 1, "min": 0, "max":25}
self.scn[SK.ZOOM] = v
@zoom.deleter
def zoom(self):
if SK.ZOOM in self.scn:
del self.scn[SK.ZOOM]
@property
def hasScale(self):
#return self.scale is not None
return SK.SCALE in self.scn
@property
def hasZoom(self):
return self.zoom is not None
################ OPERATORS ######################
from bpy_extras.view3d_utils import region_2d_to_location_3d, region_2d_to_vector_3d
class GEOSCENE_OT_coords_viewer(Operator):
bl_idname = "geoscene.coords_viewer"
bl_description = ''
bl_label = ""
bl_options = {'INTERNAL', 'UNDO'}
coords: FloatVectorProperty(subtype='XYZ')
@classmethod
def poll(cls, context):
return bpy.context.mode == 'OBJECT' and context.area.type == 'VIEW_3D'
def invoke(self, context, event):
self.geoscn = GeoScene(context.scene)
if not self.geoscn.isGeoref or self.geoscn.isBroken:
self.report({'ERROR'}, "Scene is not correctly georeferencing")
return {'CANCELLED'}
#Add modal handler and init a timer
context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(0.05, window=context.window)
context.window.cursor_set('CROSSHAIR')
return {'RUNNING_MODAL'}
def modal(self, context, event):
if event.type == 'MOUSEMOVE':
loc = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
x, y = self.geoscn.view3dToProj(loc.x, loc.y)
context.area.header_text_set("x {:.3f}, y {:.3f}, z {:.3f}".format(x, y, loc.z))
if event.type == 'ESC' and event.value == 'PRESS':
context.window.cursor_set('DEFAULT')
context.area.header_text_set(None)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
class GEOSCENE_OT_set_crs(Operator):
'''
use the enum of predefinites crs defined in addon prefs
to select and switch scene crs definition
'''
bl_idname = "geoscene.set_crs"
bl_description = 'Switch scene crs'
bl_label = "Switch to"
bl_options = {'INTERNAL', 'UNDO'}
"""
#to avoid conflict, make a distinct predef crs enum
#instead of reuse the one defined in addon pref
def listPredefCRS(self, context):
return PredefCRS.getEnumItems()
crsEnum = EnumProperty(
name = "Predefinate CRS",
description = "Choose predefinite Coordinate Reference System",
items = listPredefCRS
)
"""
def draw(self,context):
prefs = context.preferences.addons[PKG].preferences
layout = self.layout
row = layout.row(align=True)
#row.prop(self, "crsEnum", text='')
row.prop(prefs, "predefCrs", text='')
#row.operator("geoscene.show_pref", text='', icon='PREFERENCES')
row.operator("bgis.add_predef_crs", text='', icon='ADD')
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=200)
def execute(self, context):
geoscn = GeoScene(context.scene)
prefs = context.preferences.addons[PKG].preferences
try:
geoscn.crs = prefs.predefCrs
except Exception as err:
log.error('Cannot update crs', exc_info=True)
self.report({'ERROR'}, 'Cannot update crs. Check logs form more info')
return {'CANCELLED'}
#
context.area.tag_redraw()
return {'FINISHED'}
class GEOSCENE_OT_init_org(Operator):
bl_idname = "geoscene.init_org"
bl_description = 'Init scene origin custom props at location 0,0'
bl_label = "Init origin"
bl_options = {'INTERNAL', 'UNDO'}
lonlat: BoolProperty(
name = "As lonlat",
description = "Set origin coordinate as longitude and latitude"
)
x: FloatProperty()
y: FloatProperty()
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self, width=200)
def execute(self, context):
geoscn = GeoScene(context.scene)
if geoscn.hasOriginGeo or geoscn.hasOriginPrj:
log.warning('Cannot init scene origin because it already exist')
return {'CANCELLED'}
else:
#geoscn.lon, geoscn.lat = 0, 0
#geoscn.crsx, geoscn.crsy = 0, 0
if self.lonlat:
geoscn.setOriginGeo(self.x, self.y)
else:
geoscn.setOriginPrj(self.x, self.y)
return {'FINISHED'}
class GEOSCENE_OT_edit_org_geo(Operator):
bl_idname = "geoscene.edit_org_geo"
bl_description = 'Edit scene origin longitude/latitude'
bl_label = "Edit origin geo"
bl_options = {'INTERNAL', 'UNDO'}
lon: FloatProperty()
lat: FloatProperty()
def invoke(self, context, event):
geoscn = GeoScene(context.scene)
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken")
return {'CANCELLED'}
self.lon, self.lat = geoscn.getOriginGeo()
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
geoscn = GeoScene(context.scene)
if geoscn.hasOriginGeo:
geoscn.updOriginGeo(self.lon, self.lat)
else:
geoscn.setOriginGeo(self.lon, self.lat)
return {'FINISHED'}
class GEOSCENE_OT_edit_org_prj(Operator):
bl_idname = "geoscene.edit_org_prj"
bl_description = 'Edit scene origin in projected system'
bl_label = "Edit origin proj"
bl_options = {'INTERNAL', 'UNDO'}
x: FloatProperty()
y: FloatProperty()
def invoke(self, context, event):
geoscn = GeoScene(context.scene)
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken")
return {'CANCELLED'}
self.x, self.y = geoscn.getOriginPrj()
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
geoscn = GeoScene(context.scene)
if geoscn.hasOriginPrj:
geoscn.updOriginPrj(self.x, self.y)
else:
geoscn.setOriginPrj(self.x, self.y)
return {'FINISHED'}
class GEOSCENE_OT_link_org_geo(Operator):
bl_idname = "geoscene.link_org_geo"
bl_description = 'Link scene origin lat long'
bl_label = "Link geo"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context):
geoscn = GeoScene(context.scene)
if geoscn.hasOriginPrj and geoscn.hasCRS:
try:
geoscn.lon, geoscn.lat = reprojPt(geoscn.crs, 4326, geoscn.crsx, geoscn.crsy)
except Exception as err:
log.error('Cannot compute lat/lon coordinates', exc_info=True)
self.report({'ERROR'}, 'Cannot compute lat/lon. Check logs for more infos.')
return {'CANCELLED'}
else:
self.report({'ERROR'}, 'No enough infos')
return {'CANCELLED'}
return {'FINISHED'}
class GEOSCENE_OT_link_org_prj(Operator):
bl_idname = "geoscene.link_org_prj"
bl_description = 'Link scene origin in crs space'
bl_label = "Link prj"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context):
geoscn = GeoScene(context.scene)
if geoscn.hasOriginGeo and geoscn.hasCRS:
try:
geoscn.crsx, geoscn.crsy = reprojPt(4326, geoscn.crs, geoscn.lon, geoscn.lat)
except Exception as err:
log.error('Cannot compute crs coordinates', exc_info=True)
self.report({'ERROR'}, 'Cannot compute crs coordinates. Check logs for more infos.')
return {'CANCELLED'}
else:
self.report({'ERROR'}, 'No enough infos')
return {'CANCELLED'}
return {'FINISHED'}
class GEOSCENE_OT_clear_org(Operator):
bl_idname = "geoscene.clear_org"
bl_description = 'Clear scene origin coordinates'
bl_label = "Clear origin"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context):
geoscn = GeoScene(context.scene)
geoscn.delOrigin()
return {'FINISHED'}
class GEOSCENE_OT_clear_georef(Operator):
bl_idname = "geoscene.clear_georef"
bl_description = 'Clear all georef infos'
bl_label = "Clear georef"
bl_options = {'INTERNAL', 'UNDO'}
def execute(self, context):
geoscn = GeoScene(context.scene)
geoscn.delOrigin()
del geoscn.crs
return {'FINISHED'}
################ PROPS GETTERS SETTERS ######################
def getLon(self):
geoscn = GeoScene()
return geoscn.lon
def getLat(self):
geoscn = GeoScene()
return geoscn.lat
def setLon(self, lon):
geoscn = GeoScene()
prefs = bpy.context.preferences.addons[PKG].preferences
if geoscn.hasOriginGeo:
geoscn.updOriginGeo(lon, geoscn.lat, updObjLoc=prefs.lockObj)
else:
geoscn.setOriginGeo(lon, geoscn.lat)
def setLat(self, lat):
geoscn = GeoScene()
prefs = bpy.context.preferences.addons[PKG].preferences
if geoscn.hasOriginGeo:
geoscn.updOriginGeo(geoscn.lon, lat, updObjLoc=prefs.lockObj)
else:
geoscn.setOriginGeo(geoscn.lon, lat)
def getCrsx(self):
geoscn = GeoScene()
return geoscn.crsx
def getCrsy(self):
geoscn = GeoScene()
return geoscn.crsy
def setCrsx(self, x):
geoscn = GeoScene()
prefs = bpy.context.preferences.addons[PKG].preferences
if geoscn.hasOriginPrj:
geoscn.updOriginPrj(x, geoscn.crsy, updObjLoc=prefs.lockObj)
else:
geoscn.setOriginPrj(x, geoscn.crsy)
def setCrsy(self, y):
geoscn = GeoScene()
prefs = bpy.context.preferences.addons[PKG].preferences
if geoscn.hasOriginPrj:
geoscn.updOriginPrj(geoscn.crsx, y, updObjLoc=prefs.lockObj)
else:
geoscn.setOriginPrj(geoscn.crsx, y)
################ PANEL ######################
class GEOSCENE_PT_georef(Panel):
bl_category = "View"#"GIS"
bl_label = "Geoscene"
bl_space_type = "VIEW_3D"
bl_context = "objectmode"
bl_region_type = "UI"
def draw(self, context):
layout = self.layout
scn = context.scene
geoscn = GeoScene(scn)
#layout.operator("bgis.pref_show", icon='PREFERENCES')
georefManagerLayout(self, context)
layout.operator("geoscene.coords_viewer", icon='WORLD', text='Geo-coordinates')
#hidden props used as display options in georef manager panel
class GLOBAL_PROPS(PropertyGroup):
displayOriginGeo: BoolProperty(
name='Geo', description='Display longitude and latitude of scene origin')
displayOriginPrj: BoolProperty(
name='Proj', description='Display coordinates of scene origin in CRS space')
lon: FloatProperty(get=getLon, set=setLon)
lat: FloatProperty(get=getLat, set=setLat)
crsx: FloatProperty(get=getCrsx, set=setCrsx)
crsy: FloatProperty(get=getCrsy, set=setCrsy)
def georefManagerLayout(self, context):
'''Use this method to extend a panel with georef managment tools'''
layout = self.layout
scn = context.scene
wm = bpy.context.window_manager
geoscn = GeoScene(scn)
prefs = context.preferences.addons[PKG].preferences
if geoscn.isBroken:
layout.alert = True
row = layout.row(align=True)
row.label(text='Scene georeferencing :')
if geoscn.hasCRS:
row.operator("geoscene.clear_georef", text='', icon='CANCEL')
#CRS
row = layout.row(align=True)
#row.alignment = 'LEFT'
#row.label(icon='EMPTY_DATA')
split = row.split(factor=0.25)
if geoscn.hasCRS:
split.label(icon='PROP_ON', text='CRS:')
elif not geoscn.hasCRS and (geoscn.hasOriginGeo or geoscn.hasOriginPrj):
split.label(icon='ERROR', text='CRS:')
else:
split.label(icon='PROP_OFF', text='CRS:')
if geoscn.hasCRS:
##col = split.column(align=True)
##col.enabled = False
##col.prop(scn, '["'+SK.CRS+'"]', text='')
crs = scn[SK.CRS]
name = PredefCRS.getName(crs)
if name is not None:
split.label(text=name)
else:
split.label(text=crs)
else:
split.label(text="Not set")
row.operator("geoscene.set_crs", text='', icon='PREFERENCES')
#Origin
row = layout.row(align=True)
#row.alignment = 'LEFT'
#row.label(icon='PIVOT_CURSOR')
split = row.split(factor=0.25, align=True)
if not geoscn.hasOriginGeo and not geoscn.hasOriginPrj:
split.label(icon='PROP_OFF', text="Origin:")
elif not geoscn.hasOriginGeo and geoscn.hasOriginPrj:
split.label(icon='PROP_CON', text="Origin:")
elif geoscn.hasOriginGeo and geoscn.hasOriginPrj:
split.label(icon='PROP_ON', text="Origin:")
elif geoscn.hasOriginGeo and not geoscn.hasOriginPrj:
split.label(icon='ERROR', text="Origin:")
col = split.column(align=True)
if not geoscn.hasOriginGeo:
col.enabled = False
col.prop(wm.geoscnProps, 'displayOriginGeo', toggle=True)
col = split.column(align=True)
if not geoscn.hasOriginPrj:
col.enabled = False
col.prop(wm.geoscnProps, 'displayOriginPrj', toggle=True)
if geoscn.hasOriginGeo or geoscn.hasOriginPrj:
if geoscn.hasCRS and not geoscn.hasOriginPrj:
row.operator("geoscene.link_org_prj", text="", icon='CONSTRAINT')
if geoscn.hasCRS and not geoscn.hasOriginGeo:
row.operator("geoscene.link_org_geo", text="", icon='CONSTRAINT')
row.operator("geoscene.clear_org", text="", icon='REMOVE')
if not geoscn.hasOriginGeo and not geoscn.hasOriginPrj:
row.operator("geoscene.init_org", text="", icon='ADD')
if geoscn.hasOriginGeo and wm.geoscnProps.displayOriginGeo:
row = layout.row()
row.prop(wm.geoscnProps, 'lon', text='Lon')
row.prop(wm.geoscnProps, 'lat', text='Lat')
'''
row.enabled = False
row.prop(scn, '["'+SK.LON+'"]', text='Lon')
row.prop(scn, '["'+SK.LAT+'"]', text='Lat')
'''
if geoscn.hasOriginPrj and wm.geoscnProps.displayOriginPrj:
row = layout.row()
row.prop(wm.geoscnProps, 'crsx', text='X')
row.prop(wm.geoscnProps, 'crsy', text='Y')
'''
row.enabled = False
row.prop(scn, '["'+SK.CRSX+'"]', text='X')
row.prop(scn, '["'+SK.CRSY+'"]', text='Y')
'''
if geoscn.hasScale:
row = layout.row()
row.label(text='Map scale:')
col = row.column()
col.enabled = False
col.prop(scn, '["'+SK.SCALE+'"]', text='')
#if geoscn.hasZoom:
# layout.prop(scn, '["'+SK.ZOOM+'"]', text='Zoom level', slider=True)
###########################
classes = [
GEOSCENE_OT_coords_viewer,
GEOSCENE_OT_set_crs,
GEOSCENE_OT_init_org,
GEOSCENE_OT_edit_org_geo,
GEOSCENE_OT_edit_org_prj,
GEOSCENE_OT_link_org_geo,
GEOSCENE_OT_link_org_prj,
GEOSCENE_OT_clear_org,
GEOSCENE_OT_clear_georef,
GEOSCENE_PT_georef,
GLOBAL_PROPS
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
bpy.types.WindowManager.geoscnProps = PointerProperty(type=GLOBAL_PROPS)
def unregister():
del bpy.types.WindowManager.geoscnProps
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: issue_template.md
================================================
# **Blender and OS versions**
# **Describe the bug**
# **How to Reproduce**
# **Error message**
================================================
FILE: operators/__init__.py
================================================
__all__ = ["add_camera_exif", "add_camera_georef", "io_export_shp", "io_get_srtm", "io_import_georaster", "io_import_osm", "io_import_shp", "io_import_asc", "mesh_delaunay_voronoi", "nodes_terrain_analysis_builder", "nodes_terrain_analysis_reclassify", "view3d_mapviewer", "object_drop", "mesh_earth_sphere"]
================================================
FILE: operators/add_camera_exif.py
================================================
# -*- coding:utf-8 -*-
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
import os
from math import pi
import logging
log = logging.getLogger(__name__)
import bpy
from bpy.props import StringProperty, CollectionProperty, EnumProperty
from bpy.types import Panel, Operator, OperatorFileListElement
#bgis
from ..geoscene import GeoScene
#core
from ..core.proj import reprojPt
from ..core.georaster import getImgFormat
#deps
from ..core.lib import Tyf
def newEmpty(scene, name, location):
"""Create a new empty"""
target = bpy.data.objects.new(name, None)
target.empty_display_size = 40
target.empty_display_type = 'PLAIN_AXES'
target.location = location
scene.collection.objects.link(target)
return target
def newCamera(scene, name, location, focalLength):
"""Create a new camera"""
cam = bpy.data.cameras.new(name)
cam.sensor_width = 35
cam.lens = focalLength
cam.display_size = 40
cam_obj = bpy.data.objects.new(name,cam)
cam_obj.location = location
cam_obj.rotation_euler[0] = pi/2
cam_obj.rotation_euler[2] = pi
scene.collection.objects.link(cam_obj)
return cam, cam_obj
def newTargetCamera(scene, name, location, focalLength):
"""Create a new camera.target"""
cam, cam_obj = newCamera(scene, name, location, focalLength)
x, y, z = location[:]
target = newEmpty(scene, name+".target", (x, y - 50, z))
constraint = cam_obj.constraints.new(type='TRACK_TO')
constraint.track_axis = 'TRACK_NEGATIVE_Z'
constraint.up_axis = 'UP_Y'
constraint.target = target
return cam, cam_obj
class CAMERA_OT_geophotos_add(Operator):
bl_idname = "camera.geophotos"
bl_description = "Create cameras from geotagged photos"
bl_label = "Exif cam"
bl_options = {"REGISTER"}
files: CollectionProperty(
name="File Path",
type=OperatorFileListElement,
)
directory: StringProperty(
subtype='DIR_PATH',
)
filter_glob: StringProperty(
default="*.jpg;*.jpeg;*.tif;*.tiff",
options={'HIDDEN'},
)
filename_ext = ""
exifMode: EnumProperty(
attr="exif_mode",
name="Action",
description="Choose an action",
items=[('TARGET_CAMERA','Target Camera','Create a camera with target helper'),('CAMERA','Camera','Create a camera'),('EMPTY','Empty','Create an empty helper'),('CURSOR','Cursor','Move cursor')],
default="TARGET_CAMERA"
)
def invoke(self, context, event):
scn = context.scene
geoscn = GeoScene(scn)
if not geoscn.isGeoref:
self.report({'ERROR'},"The scene must be georeferenced.")
return {'CANCELLED'}
#File browser
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
scn = context.scene
geoscn = GeoScene(scn)
directory = self.directory
for file_elem in self.files:
filepath = os.path.join(directory, file_elem.name)
if not os.path.isfile(filepath):
self.report({'ERROR'},"Invalid file")
return {'CANCELLED'}
imgFormat = getImgFormat(filepath)
if imgFormat not in ['JPEG', 'TIFF']:
self.report({'ERROR'},"Invalid format " + str(imgFormat))
return {'CANCELLED'}
try:
exif = Tyf.open(filepath)
except Exception as e:
log.error("Unable to open file", exc_info=True)
self.report({'ERROR'},"Unable to open file. Checks logs for more infos.")
return {'CANCELLED'}
#tags = {t.key:exif[t.key] for t in exif.exif.tags() if t.key != 'Unknown' }
#print(tags)
#Warning : Tyf object does not totally behave like a python dictionnary
#testing if a tags exists with the syntax "if k in exif" does not works
#using the get method does not work either. For example : alt = exif.get("GPSAltitude", 0)
#that's why we proceed with "try except KeyError" blocks instead of conditional block or get() method
try: #if not any([k in exif for k in ('GPSLatitude', 'GPSLatitudeRef', 'GPSLongitude', 'GPSLongitudeRef')]):
lat = exif["GPSLatitude"] * exif["GPSLatitudeRef"]
lon = exif["GPSLongitude"] * exif["GPSLongitudeRef"]
except KeyError:
self.report({'ERROR'},"Can't find GPS longitude or latitude.")
return {'CANCELLED'}
#alt = exif.get("GPSAltitude", 0)
try:
alt = exif["GPSAltitude"]
except KeyError:
alt = 0
try:
x, y = reprojPt(4326, geoscn.crs, lon, lat)
except Exception as e:
log.error("Reprojection fails", exc_info=True)
self.report({'ERROR'},"Reprojection error. Check logs for more infos.")
return {'CANCELLED'}
try:
focalLength = exif["FocalLengthIn35mmFilm"]
except KeyError:
focalLength = 35
location = (x-geoscn.crsx, y-geoscn.crsy, alt)
name = bpy.path.display_name_from_filepath(filepath)
if self.exifMode == "TARGET_CAMERA":
cam, cam_obj = newTargetCamera(scn, name, location, focalLength)
elif self.exifMode == "CAMERA":
cam, cam_obj = newCamera(scn, name, location, focalLength)
elif self.exifMode == "EMPTY":
newEmpty(scn, name, location)
else:
scn.cursor.location = location
if self.exifMode in ["TARGET_CAMERA","CAMERA"]:
cam['background'] = filepath
'''
try:
cam['imageWidth'] = exif["PixelXDimension"] #for jpg, in tif tag is named imageWidth...
cam['imageHeight'] = exif["PixelYDimension"]
except KeyError:
pass
'''
img = bpy.data.images.load(filepath)
w, h = img.size
cam['imageWidth'] = w #exif["PixelXDimension"] #for jpg, in tif file the tag is named imageWidth...
cam['imageHeight'] = h
try:
cam['orientation'] = exif["Orientation"]
except KeyError:
cam['orientation'] = 1 #no rotation
#Set camera rotation (NOT TESTED)
if cam['orientation'] == 8: #90° CCW
cam_obj.rotation_euler[1] -= pi/2
if cam['orientation'] == 6: #90° CW
cam_obj.rotation_euler[1] += pi/2
if cam['orientation'] == 3: #180°
cam_obj.rotation_euler[1] += pi
if scn.camera is None:
bpy.ops.camera.geophotos_setactive('EXEC_DEFAULT', camLst=cam_obj.name)
return {'FINISHED'}
class CAMERA_OT_geophotos_setactive(Operator):
bl_idname = "camera.geophotos_setactive"
bl_description = "Switch active geophoto camera"
bl_label = "Switch geophoto camera"
bl_options = {"REGISTER"}
def listGeoCam(self, context):
scn = context.scene
#put each object in a tuple (key, label, tooltip)
return [(obj.name, obj.name, obj.name) for obj in scn.objects if obj.type == 'CAMERA' and 'background' in obj.data]
camLst: EnumProperty(name='Camera', description='Select camera', items=listGeoCam)
def draw(self, context):
layout = self.layout
layout.prop(self, 'camLst')#, text='')
def invoke(self, context, event):
if len(self.camLst) == 0:
self.report({'ERROR'},"No valid camera")
return {'CANCELLED'}
return context.window_manager.invoke_props_dialog(self)#, width=200)
def execute(self, context):
if context.space_data.type != 'VIEW_3D':
self.report({'ERROR'},"Wrong context")
return {'CANCELLED'}
scn = context.scene
view3d = context.space_data
#Get cam
cam_obj = scn.objects[self.camLst]
cam_obj.select_set(True)
context.view_layer.objects.active = cam_obj
cam = cam_obj.data
scn.camera = cam_obj
#Set render size
scn.render.resolution_x = cam['imageWidth']
scn.render.resolution_y = cam['imageHeight']
scn.render.resolution_percentage = 100
#Get or load bpy image
filepath = cam['background']
try:
img = [img for img in bpy.data.images if img.filepath == filepath][0]
except IndexError:
img = bpy.data.images.load(filepath)
#Activate view3d background
cam.show_background_images = True
#Hide all existing camera background
for bkg in cam.background_images:
bkg.show_background_image = False
#Get or load background image
bkgs = [bkg for bkg in cam.background_images if bkg.image is not None]
try:
bkg = [bkg for bkg in bkgs if bkg.image.filepath == filepath][0]
except IndexError:
bkg = cam.background_images.new()
bkg.image = img
#Set some props
bkg.show_background_image = True
bkg.alpha = 1
return {'FINISHED'}
classes = [
CAMERA_OT_geophotos_add,
CAMERA_OT_geophotos_setactive
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: operators/add_camera_georef.py
================================================
# -*- coding:utf-8 -*-
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
import logging
log = logging.getLogger(__name__)
import bpy
from mathutils import Vector
from bpy.props import StringProperty, BoolProperty, EnumProperty, FloatProperty
from .utils import getBBOX
from ..geoscene import GeoScene
class CAMERA_OT_add_georender_cam(bpy.types.Operator):
'''
Add a new georef camera or update an existing one
A georef camera is a top view orthographic camera that can be used to render a map
The camera is setting to encompass the selected object, the output spatial resolution (meters/pixel) can be set by the user
A worldfile is writen in BLender text editor, it can be used to georef the output render
'''
bl_idname = "camera.georender"
bl_label = "Georef cam"
bl_description = "Create or update a camera to render a georeferencing map"
bl_options = {"REGISTER", "UNDO"}
name: StringProperty(name = "Camera name", default="Georef cam", description="")
target_res: FloatProperty(name = "Pixel size", default=5, description="Pixel size in map units/pixel", min=0.00001)
zLocOffset: FloatProperty(name = "Z loc. off.", default=50, description="Camera z location offet, defined as percentage of z dimension of the target mesh", min=0)
redo = 0
bbox = None #global var used to avoid recomputing the bbox at each redo
def check(self, context):
return True
def draw(self, context):
layout = self.layout
layout.prop(self, 'name')
layout.prop(self, 'target_res')
layout.prop(self, 'zLocOffset')
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def execute(self, context):#every times operator redo options are modified
#Operator redo count
self.redo += 1
#Check georef
scn = context.scene
geoscn = GeoScene(scn)
if not geoscn.isGeoref:
self.report({'ERROR'}, "Scene isn't georef")
return {'CANCELLED'}
#Validate selection
objs = bpy.context.selected_objects
if (not objs or len(objs) > 2) or \
(len(objs) == 1 and not objs[0].type == 'MESH') or \
(len(objs) == 2 and not set( (objs[0].type, objs[1].type )) == set( ('MESH','CAMERA') ) ):
self.report({'ERROR'}, "Pre-selection is incorrect")
return {'CANCELLED'}
#Flag new camera creation
if len(objs) == 2:
newCam = False
else:
newCam = True
#Get georef data
dx, dy = geoscn.getOriginPrj()
#Allocate obj
for obj in objs:
if obj.type == 'MESH':
georefObj = obj
elif obj.type == 'CAMERA':
camObj = obj
cam = camObj.data
#do not recompute bbox at operator redo because zdim is miss-evaluated
#when redoing the op on an obj that have a displace modifier on it
#TODO find a less hacky fix
if self.bbox is None:
bbox = getBBOX.fromObj(georefObj, applyTransform = True)
self.bbox = bbox
else:
bbox = self.bbox
locx, locy, locz = bbox.center
dimx, dimy, dimz = bbox.dimensions
if dimz == 0:
dimz = 1
#dimx, dimy, dimz = georefObj.dimensions #dimensions property apply object transformations (scale and rot.)
#Set active cam
if newCam:
cam = bpy.data.cameras.new(name=self.name)
cam['mapRes'] = self.target_res #custom prop
camObj = bpy.data.objects.new(name=self.name, object_data=cam)
scn.collection.objects.link(camObj)
scn.camera = camObj
elif self.redo == 1: #first exec, get initial camera res
scn.camera = camObj
try:
self.target_res = cam['mapRes']
except KeyError:
self.report({'ERROR'}, "This camera has not map resolution property")
return {'CANCELLED'}
else: #following exec, set camera res in redo panel
try:
cam['mapRes'] = self.target_res
except KeyError:
self.report({'ERROR'}, "This camera has not map resolution property")
return {'CANCELLED'}
#Set camera data
cam.type = 'ORTHO'
cam.ortho_scale = max((dimx, dimy)) #ratio = max((dimx, dimy)) / min((dimx, dimy))
#General offset used to set cam z loc and clip end distance
#needed to avoid clipping/black hole effects
offset = dimz * self.zLocOffset/100
#Set camera location
camLocZ = bbox['zmin'] + dimz + offset
camObj.location = (locx, locy, camLocZ)
#Set camera clipping
cam.clip_start = 0
cam.clip_end = dimz + offset*2
cam.show_limits = True
if not newCam:
if self.redo == 1:#first exec, get initial camera name
self.name = camObj.name
else:#following exec, set camera name in redo panel
camObj.name = self.name
camObj.data.name = self.name
#Update selection
bpy.ops.object.select_all(action='DESELECT')
camObj.select_set(True)
context.view_layer.objects.active = camObj
#setup scene
scn.camera = camObj
scn.render.resolution_x = int(dimx / self.target_res)
scn.render.resolution_y = int(dimy / self.target_res)
scn.render.resolution_percentage = 100
#Write wf
res = self.target_res#dimx / scene.render.resolution_x
rot = 0
x = bbox['xmin'] + dx
y = bbox['ymax'] + dy
wf_data = '\n'.join(map(str, [res, rot, rot, -res, x+res/2, y-res/2]))
wf_name = camObj.name + '.wld'
if wf_name in bpy.data.texts:
wfText = bpy.data.texts[wf_name]
wfText.clear()
else:
wfText = bpy.data.texts.new(name=wf_name)
wfText.write(wf_data)
#Purge old wf text
for wfText in bpy.data.texts:
name, ext = wfText.name[:-4], wfText.name[-4:]
if ext == '.wld' and name not in bpy.data.objects:
bpy.data.texts.remove(wfText)
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(CAMERA_OT_add_georender_cam)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(CAMERA_OT_add_georender_cam))
unregister()
bpy.utils.register_class(CAMERA_OT_add_georender_cam)
def unregister():
bpy.utils.unregister_class(CAMERA_OT_add_georender_cam)
================================================
FILE: operators/io_export_shp.py
================================================
# -*- coding:utf-8 -*-
import os
import bpy
import bmesh
import mathutils
import logging
log = logging.getLogger(__name__)
from ..core.lib.shapefile import Writer as shpWriter
from ..core.lib.shapefile import POINTZ, POLYLINEZ, POLYGONZ, MULTIPOINTZ
from bpy_extras.io_utils import ExportHelper #helper class defines filename and invoke() function which calls the file selector
from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
from bpy.types import Operator
from ..geoscene import GeoScene
from ..core.proj import SRS
class EXPORTGIS_OT_shapefile(Operator, ExportHelper):
"""Export from ESRI shapefile file format (.shp)"""
bl_idname = "exportgis.shapefile" # important since its how bpy.ops.import.shapefile is constructed (allows calling operator from python console or another script)
#bl_idname rules: must contain one '.' (dot) charactere, no capital letters, no reserved words (like 'import')
bl_description = 'export to ESRI shapefile file format (.shp)'
bl_label = "Export SHP"
bl_options = {"UNDO"}
# ExportHelper class properties
filename_ext = ".shp"
filter_glob: StringProperty(
default = "*.shp",
options = {'HIDDEN'},
)
exportType: EnumProperty(
name = "Feature type",
description = "Select feature type",
items = [
('POINTZ', 'Point', ""),
('POLYLINEZ', 'Line', ""),
('POLYGONZ', 'Polygon', "")
])
objectsSource: EnumProperty(
name = "Objects",
description = "Objects to export",
items = [
('COLLEC', 'Collection', "Export a collection of objects"),
('SELECTED', 'Selected objects', "Export the current selection")
],
default = 'SELECTED'
)
def listCollections(self, context):
return [(c.name, c.name, "Collection") for c in bpy.data.collections]
selectedColl: EnumProperty(
name = "Collection",
description = "Select the collection to export",
items = listCollections)
mode: EnumProperty(
name = "Mode",
description = "Select the export strategy",
items = [
('OBJ2FEAT', 'Objects to features', "Create one multipart feature per object"),
('MESH2FEAT', 'Mesh to features', "Decompose mesh primitives to separate features")
],
default = 'OBJ2FEAT'
)
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def draw(self, context):
#Function used by blender to draw the panel.
layout = self.layout
layout.prop(self, 'objectsSource')
if self.objectsSource == 'COLLEC':
layout.prop(self, 'selectedColl')
layout.prop(self, 'mode')
layout.prop(self, 'exportType')
def execute(self, context):
filePath = self.filepath
folder = os.path.dirname(filePath)
scn = context.scene
geoscn = GeoScene(scn)
if geoscn.isGeoref:
dx, dy = geoscn.getOriginPrj()
crs = SRS(geoscn.crs)
try:
wkt = crs.getWKT()
except Exception as e:
log.warning('Cannot convert crs to wkt', exc_info=True)
wkt = None
elif geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
else:
dx, dy = (0, 0)
wkt = None
if self.objectsSource == 'SELECTED':
objects = [obj for obj in bpy.context.selected_objects if obj.type == 'MESH']
elif self.objectsSource == 'COLLEC':
objects = bpy.data.collections[self.selectedColl].all_objects
objects = [obj for obj in objects if obj.type == 'MESH']
if not objects:
self.report({'ERROR'}, "Selection is empty or does not contain any mesh")
return {'CANCELLED'}
outShp = shpWriter(filePath)
if self.exportType == 'POLYGONZ':
outShp.shapeType = POLYGONZ #15
if self.exportType == 'POLYLINEZ':
outShp.shapeType = POLYLINEZ #13
if self.exportType == 'POINTZ' and self.mode == 'MESH2FEAT':
outShp.shapeType = POINTZ
if self.exportType == 'POINTZ' and self.mode == 'OBJ2FEAT':
outShp.shapeType = MULTIPOINTZ
#create fields (all needed fields sould be created before adding any new record)
#TODO more robust evaluation, and check for boolean and date types
cLen = 255 #string fields default length
nLen = 20 #numeric fields default length
dLen = 5 #numeric fields default decimal precision
maxFieldNameLen = 8 #shp capabilities limit field name length to 8 characters
outShp.field('objId','N', nLen) #export id
for obj in objects:
for k, v in obj.items():
k = k[0:maxFieldNameLen]
#evaluate the field type with the first value
if k not in [f[0] for f in outShp.fields]:
if isinstance(v, float) or isinstance(v, int):
fieldType = 'N'
elif isinstance(v, str):
if v.lstrip("-+").isdigit():
v = int(v)
fieldType = 'N'
else:
try:
v = float(v)
except ValueError:
fieldType = 'C'
else:
fieldType = 'N'
else:
continue
if fieldType == 'C':
outShp.field(k, fieldType, cLen)
elif fieldType == 'N':
if isinstance(v, int):
outShp.field(k, fieldType, nLen, 0)
else:
outShp.field(k, fieldType, nLen, dLen)
for i, obj in enumerate(objects):
loc = obj.location
bm = bmesh.new()
bm.from_object(obj, context.evaluated_depsgraph_get())
#bmesh.from_object 'deform=True' arg allows to consider modifier deformation ->> deprecated since Blender 3.0
bm.transform(obj.matrix_world)
nFeat = 1
if self.exportType == 'POINTZ':
if len(bm.verts) == 0:
continue
#Extract coords & adjust values against georef deltas
pts = [[v.co.x+dx, v.co.y+dy, v.co.z] for v in bm.verts]
if self.mode == 'MESH2FEAT':
for j, pt in enumerate(pts):
outShp.pointz(*pt)
nFeat = len(pts)
elif self.mode == 'OBJ2FEAT':
outShp.multipointz(pts)
if self.exportType == 'POLYLINEZ':
if len(bm.edges) == 0:
continue
lines = []
for edge in bm.edges:
#Extract coords & adjust values against georef deltas
line = [(vert.co.x+dx, vert.co.y+dy, vert.co.z) for vert in edge.verts]
lines.append(line)
if self.mode == 'MESH2FEAT':
for j, line in enumerate(lines):
outShp.linez([line])
nFeat = len(lines)
elif self.mode == 'OBJ2FEAT':
outShp.linez(lines)
if self.exportType == 'POLYGONZ':
if len(bm.faces) == 0:
continue
#build geom
polygons = []
for face in bm.faces:
#Extract coords & adjust values against georef deltas
poly = [(vert.co.x+dx, vert.co.y+dy, vert.co.z) for vert in face.verts]
poly.append(poly[0])#close poly
#In Blender face is up if points are in anticlockwise order
#for shapefiles, face's up with clockwise order
poly.reverse()
polygons.append(poly)
if self.mode == 'MESH2FEAT':
for j, polygon in enumerate(polygons):
outShp.polyz([polygon])
nFeat = len(polygons)
elif self.mode == 'OBJ2FEAT':
outShp.polyz(polygons)
#Writing attributes Data
attributes = {'objId':i}
for k, v in obj.items():
k = k[0:maxFieldNameLen]
if not any([f[0] == k for f in outShp.fields]):
continue
fType = next( (f[1] for f in outShp.fields if f[0] == k) )
if fType in ('N', 'F'):
try:
v = float(v)
except ValueError:
log.info('Cannot cast value {} to float for appending field {}, NULL value will be inserted instead'.format(v, k))
v = None
attributes[k] = v
#assign None to orphans shp fields (if the key does not exists in the custom props of this object)
attributes.update({f[0]:None for f in outShp.fields if f[0] not in attributes.keys()})
#Write
for n in range(nFeat):
outShp.record(**attributes)
outShp.close()
if wkt is not None:
prjPath = os.path.splitext(filePath)[0] + '.prj'
prj = open(prjPath, "w")
prj.write(wkt)
prj.close()
self.report({'INFO'}, "Export complete")
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(EXPORTGIS_OT_shapefile)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(EXPORTGIS_OT_shapefile))
unregister()
bpy.utils.register_class(EXPORTGIS_OT_shapefile)
def unregister():
bpy.utils.unregister_class(EXPORTGIS_OT_shapefile)
================================================
FILE: operators/io_get_dem.py
================================================
import os
import time
import logging
log = logging.getLogger(__name__)
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import bpy
import bmesh
from bpy.types import Operator, Panel, AddonPreferences
from bpy.props import StringProperty, IntProperty, FloatProperty, BoolProperty, EnumProperty, FloatVectorProperty
from ..geoscene import GeoScene
from .utils import adjust3Dview, getBBOX, isTopView
from ..core.proj import SRS, reprojBbox
from ..core import settings
USER_AGENT = settings.user_agent
PKG, SUBPKG = __package__.split('.', maxsplit=1)
TIMEOUT = 120
class IMPORTGIS_OT_dem_query(Operator):
"""Import elevation data from a web service"""
bl_idname = "importgis.dem_query"
bl_description = 'Query for elevation data from a web service'
bl_label = "Get elevation (SRTM)"
bl_options = {"UNDO"}
def invoke(self, context, event):
#check georef
geoscn = GeoScene(context.scene)
if not geoscn.isGeoref:
self.report({'ERROR'}, "Scene is not georef")
return {'CANCELLED'}
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
#return self.execute(context)
return context.window_manager.invoke_props_dialog(self)#, width=350)
def draw(self,context):
prefs = context.preferences.addons[PKG].preferences
layout = self.layout
row = layout.row(align=True)
row.prop(prefs, "demServer", text='Server')
if 'opentopography' in prefs.demServer:
row = layout.row(align=True)
row.prop(prefs, "opentopography_api_key", text='Api Key')
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def execute(self, context):
prefs = bpy.context.preferences.addons[PKG].preferences
scn = context.scene
geoscn = GeoScene(scn)
crs = SRS(geoscn.crs)
#Validate selection
objs = bpy.context.selected_objects
aObj = context.active_object
if len(objs) == 1 and aObj.type == 'MESH':
onMesh = True
bbox = getBBOX.fromObj(aObj).toGeo(geoscn)
elif isTopView(context):
onMesh = False
bbox = getBBOX.fromTopView(context).toGeo(geoscn)
else:
self.report({'ERROR'}, "Please define the query extent in orthographic top view or by selecting a reference object")
return {'CANCELLED'}
if bbox.dimensions.x > 1000000 or bbox.dimensions.y > 1000000:
self.report({'ERROR'}, "Too large extent")
return {'CANCELLED'}
bbox = reprojBbox(geoscn.crs, 4326, bbox)
if 'SRTM' in prefs.demServer:
if bbox.ymin > 60:
self.report({'ERROR'}, "SRTM is not available beyond 60 degrees north")
return {'CANCELLED'}
if bbox.ymax < -56:
self.report({'ERROR'}, "SRTM is not available below 56 degrees south")
return {'CANCELLED'}
if 'opentopography' in prefs.demServer:
if not prefs.opentopography_api_key:
self.report({'ERROR'}, "Please register to opentopography.org and request for an API key")
return {'CANCELLED'}
#Set cursor representation to 'loading' icon
w = context.window
w.cursor_set('WAIT')
#url template
#http://opentopo.sdsc.edu/otr/getdem?demtype=SRTMGL3&west=-120.168457&south=36.738884&east=-118.465576&north=38.091337&outputFormat=GTiff
e = 0.002 #opentopo service does not always respect the entire bbox, so request for a little more
xmin, xmax = bbox.xmin - e, bbox.xmax + e
ymin, ymax = bbox.ymin - e, bbox.ymax + e
url = prefs.demServer.format(W=xmin, E=xmax, S=ymin, N=ymax, API_KEY=prefs.opentopography_api_key)
log.debug(url)
# Download the file from url and save it locally
# opentopo return a geotiff object in wgs84
if bpy.data.is_saved:
filePath = os.path.join(os.path.dirname(bpy.data.filepath), 'srtm.tif')
else:
filePath = os.path.join(bpy.app.tempdir, 'srtm.tif')
#we can directly init NpImg from blob but if gdal is not used as image engine then georef will not be extracted
#Alternatively, we can save on disk, open with GeoRaster class (will use tyf if gdal not available)
rq = Request(url, headers={'User-Agent': USER_AGENT})
try:
with urlopen(rq, timeout=TIMEOUT) as response, open(filePath, 'wb') as outFile:
data = response.read() # a `bytes` object
outFile.write(data) #
except (URLError, HTTPError) as err:
log.error('Http request fails url:{}, code:{}, error:{}'.format(url, getattr(err, 'code', None), err.reason))
self.report({'ERROR'}, "Cannot reach OpenTopography web service, check logs for more infos")
return {'CANCELLED'}
except TimeoutError:
log.error('Http request does not respond. url:{}, code:{}, error:{}'.format(url, getattr(err, 'code', None), err.reason))
info = "Cannot reach SRTM web service provider, server can be down or overloaded. Please retry later"
log.info(info)
self.report({'ERROR'}, info)
return {'CANCELLED'}
if not onMesh:
bpy.ops.importgis.georaster(
'EXEC_DEFAULT',
filepath = filePath,
reprojection = True,
rastCRS = 'EPSG:4326',
importMode = 'DEM',
subdivision = 'subsurf',
demInterpolation = True)
else:
bpy.ops.importgis.georaster(
'EXEC_DEFAULT',
filepath = filePath,
reprojection = True,
rastCRS = 'EPSG:4326',
importMode = 'DEM',
subdivision = 'subsurf',
demInterpolation = True,
demOnMesh = True,
objectsLst = [str(i) for i, obj in enumerate(scn.collection.all_objects) if obj.name == bpy.context.active_object.name][0],
clip = False,
fillNodata = False)
bbox = getBBOX.fromScn(scn)
adjust3Dview(context, bbox, zoomToSelect=False)
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(IMPORTGIS_OT_dem_query)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(IMPORTGIS_OT_srtm_query))
unregister()
bpy.utils.register_class(IMPORTGIS_OT_dem_query)
def unregister():
bpy.utils.unregister_class(IMPORTGIS_OT_dem_query)
================================================
FILE: operators/io_import_asc.py
================================================
# Derived from https://github.com/hrbaer/Blender-ASCII-Grid-Import
import re
import os
import string
import bpy
import math
import string
import logging
log = logging.getLogger(__name__)
from bpy_extras.io_utils import ImportHelper #helper class defines filename and invoke() function which calls the file selector
from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
from bpy.types import Operator
from ..core.proj import Reproj
from ..core.utils import XY
from ..geoscene import GeoScene, georefManagerLayout
from ..prefs import PredefCRS
from .utils import bpyGeoRaster as GeoRaster
from .utils import placeObj, adjust3Dview, showTextures, addTexture, getBBOX
from .utils import rasterExtentToMesh, geoRastUVmap, setDisplacer
PKG, SUBPKG = __package__.split('.', maxsplit=1)
class IMPORTGIS_OT_ascii_grid(Operator, ImportHelper):
"""Import ESRI ASCII grid file"""
bl_idname = "importgis.asc_file" # important since its how bpy.ops.importgis.asc is constructed (allows calling operator from python console or another script)
#bl_idname rules: must contain one '.' (dot) charactere, no capital letters, no reserved words (like 'import')
bl_description = 'Import ESRI ASCII grid with world file'
bl_label = "Import ASCII Grid"
bl_options = {"UNDO"}
# ImportHelper class properties
filter_glob: StringProperty(
default="*.asc;*.grd",
options={'HIDDEN'},
)
# Raster CRS definition
def listPredefCRS(self, context):
return PredefCRS.getEnumItems()
fileCRS: EnumProperty(
name = "CRS",
description = "Choose a Coordinate Reference System",
items = listPredefCRS,
)
# List of operator properties, the attributes will be assigned
# to the class instance from the operator settings before calling.
importMode: EnumProperty(
name = "Mode",
description = "Select import mode",
items = [
('MESH', 'Mesh', "Create triangulated regular network mesh"),
('CLOUD', 'Point cloud', "Create vertex point cloud"),
],
)
# Step makes point clouds with billions of points possible to read on consumer hardware
step: IntProperty(
name = "Step",
description = "Only read every Nth point for massive point clouds",
default = 1,
min = 1
)
# Let the user decide whether to use the faster newline method
# Alternatively, use self.total_newlines(filename) to see whether total >= nrows and automatically decide (at the cost of time spent counting lines)
newlines: BoolProperty(
name = "Newline-delimited rows",
description = "Use this method if the file contains newline separated rows for faster import",
default = True,
)
def draw(self, context):
#Function used by blender to draw the panel.
layout = self.layout
layout.prop(self, 'importMode')
layout.prop(self, 'step')
layout.prop(self, 'newlines')
row = layout.row(align=True)
split = row.split(factor=0.35, align=True)
split.label(text='CRS:')
split.prop(self, "fileCRS", text='')
row.operator("bgis.add_predef_crs", text='', icon='ADD')
scn = bpy.context.scene
geoscn = GeoScene(scn)
if geoscn.isPartiallyGeoref:
georefManagerLayout(self, context)
def total_lines(self, filename):
"""
Count newlines in file.
512MB file ~3 seconds.
"""
with open(filename) as f:
lines = 0
for _ in f:
lines += 1
return lines
def read_row_newlines(self, f, ncols):
"""
Read a row by columns separated by newline.
"""
return f.readline().split()
def read_row_whitespace(self, f, ncols):
"""
Read a row by columns separated by whitespace (including newlines).
6x slower than readlines() method but faster than any other method I can come up with. See commit 4d337c4 for alternatives.
"""
# choose a buffer that requires the least reads, but not too much memory (32MB max)
# cols * 6 allows us 5 chars plus space, approximating values such as '12345', '-1234', '12.34', '-12.3'
buf_size = min(1024 * 32, ncols * 6)
row = []
read_f = f.read
while True:
chunk = read_f(buf_size)
# assuming we read a complete chunk, remove end of string up to last whitespace to avoid partial values
# if the chunk is smaller than our buffer size, then we've read to the end of file and
# can skip truncating the chunk since we know the last value will be complete
if len(chunk) == buf_size:
for i in range(len(chunk) - 1, -1, -1):
if chunk[i].isspace():
f.seek(f.tell() - (len(chunk) - i))
chunk = chunk[:i]
break
# either read was EOF or chunk was all whitespace
if not chunk:
return row # eof without reaching ncols?
# find each value separated by any whitespace char
for m in re.finditer('([^\s]+)', chunk):
row.append(m.group(0))
if len(row) == ncols:
# completed a row within this chunk, rewind the position to start at the beginning of the next row
f.seek(f.tell() - (len(chunk) - m.end()))
return row
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
bpy.ops.object.select_all(action='DESELECT')
#Get scene and some georef data
scn = bpy.context.scene
geoscn = GeoScene(scn)
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
if geoscn.isGeoref:
dx, dy = geoscn.getOriginPrj()
scale = geoscn.scale #TODO
if not geoscn.hasCRS:
try:
geoscn.crs = self.fileCRS
except Exception as e:
log.error("Cannot set scene crs", exc_info=True)
self.report({'ERROR'}, "Cannot set scene crs, check logs for more infos")
return {'CANCELLED'}
#build reprojector objects
if geoscn.crs != self.fileCRS:
rprj = True
rprjToRaster = Reproj(geoscn.crs, self.fileCRS)
rprjToScene = Reproj(self.fileCRS, geoscn.crs)
else:
rprj = False
rprjToRaster = None
rprjToScene = None
#Path
filename = self.filepath
name = os.path.splitext(os.path.basename(filename))[0]
log.info('Importing {}...'.format(filename))
f = open(filename, 'r')
meta_re = re.compile('^([^\s]+)\s+([^\s]+)$') # 'abc 123'
meta = {}
for i in range(6):
line = f.readline()
m = meta_re.match(line)
if m:
meta[m.group(1).lower()] = m.group(2)
log.debug(meta)
# step allows reduction during import, only taking every Nth point
step = self.step
nrows = int(meta['nrows'])
ncols = int(meta['ncols'])
cellsize = float(meta['cellsize'])
nodata = float(meta['nodata_value'])
# options are lower left cell corner, or lower left cell centre
reprojection = {}
offset = XY(0, 0)
if 'xllcorner' in meta:
llcorner = XY(float(meta['xllcorner']), float(meta['yllcorner']))
reprojection['from'] = llcorner
elif 'xllcenter' in meta:
centre = XY(float(meta['xllcenter']), float(meta['yllcenter']))
offset = XY(-cellsize / 2, -cellsize / 2)
reprojection['from'] = centre
# now set the correct offset for the mesh
if rprj:
reprojection['to'] = XY(*rprjToScene.pt(*reprojection['from']))
log.debug('{name} reprojected from {from} to {to}'.format(**reprojection, name=name))
else:
reprojection['to'] = reprojection['from']
if not geoscn.isGeoref:
# use the centre of the imported grid as scene origin (calculate only if grid file specified llcorner)
centre = (reprojection['from'].x + offset.x + ((ncols / 2) * cellsize),
reprojection['from'].y + offset.y + ((nrows / 2) * cellsize))
if rprj:
centre = rprjToScene.pt(*centre)
geoscn.setOriginPrj(*centre)
dx, dy = geoscn.getOriginPrj()
index = 0
vertices = []
faces = []
# determine row read method
read = self.read_row_whitespace
if self.newlines:
read = self.read_row_newlines
for y in range(nrows - 1, -1, -step):
# spec doesn't require newline separated rows so make it handle a single line of all values
coldata = read(f, ncols)
if len(coldata) != ncols:
log.error('Incorrect number of columns for row {row}. Expected {expected}, got {actual}.'.format(row=nrows-y, expected=ncols, actual=len(coldata)))
self.report({'ERROR'}, 'Incorrect number of columns for row, check logs for more infos')
return {'CANCELLED'}
for i in range(step - 1):
_ = read(f, ncols)
for x in range(0, ncols, step):
# TODO: exclude nodata values (implications for face generation)
if not (self.importMode == 'CLOUD' and coldata[x] == nodata):
pt = (x * cellsize + offset.x, y * cellsize + offset.y)
if rprj:
# reproject world-space source coordinate, then transform back to target local-space
pt = rprjToScene.pt(pt[0] + reprojection['from'].x, pt[1] + reprojection['from'].y)
pt = (pt[0] - reprojection['to'].x, pt[1] - reprojection['to'].y)
try:
vertices.append(pt + (float(coldata[x]),))
except ValueError as e:
log.error('Value "{val}" in row {row}, column {col} could not be converted to a float.'.format(val=coldata[x], row=nrows-y, col=x))
self.report({'ERROR'}, 'Cannot convert value to float')
return {'CANCELLED'}
if self.importMode == 'MESH':
step_ncols = math.ceil(ncols / step)
for r in range(0, math.ceil(nrows / step) - 1):
for c in range(0, step_ncols - 1):
v1 = index
v2 = v1 + step_ncols
v3 = v2 + 1
v4 = v1 + 1
faces.append((v1, v2, v3, v4))
index += 1
index += 1
# Create mesh
me = bpy.data.meshes.new(name)
ob = bpy.data.objects.new(name, me)
ob.location = (reprojection['to'].x - dx, reprojection['to'].y - dy, 0)
# Link object to scene and make active
scn = bpy.context.scene
scn.collection.objects.link(ob)
bpy.context.view_layer.objects.active = ob
ob.select_set(True)
me.from_pydata(vertices, [], faces)
me.update()
f.close()
if prefs.adjust3Dview:
bb = getBBOX.fromObj(ob)
adjust3Dview(context, bb)
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(IMPORTGIS_OT_ascii_grid)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(IMPORTGIS_OT_ascii_grid))
unregister()
bpy.utils.register_class(IMPORTGIS_OT_ascii_grid)
def unregister():
bpy.utils.unregister_class(IMPORTGIS_OT_ascii_grid)
================================================
FILE: operators/io_import_georaster.py
================================================
# -*- coding:utf-8 -*-
# This file is part of BlenderGIS
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
import bpy
import bmesh
import os
import math
from mathutils import Vector
import numpy as np#Ship with Blender since 2.70
import logging
log = logging.getLogger(__name__)
from ..geoscene import GeoScene, georefManagerLayout
from ..prefs import PredefCRS
from ..core.georaster import GeoRaster
from .utils import bpyGeoRaster, exportAsMesh
from .utils import placeObj, adjust3Dview, showTextures, addTexture, getBBOX
from .utils import rasterExtentToMesh, geoRastUVmap, setDisplacer
from ..core import HAS_GDAL
if HAS_GDAL:
from osgeo import gdal
from ..core import XY as xy
from ..core.errors import OverlapError
from ..core.proj import Reproj
from bpy_extras.io_utils import ImportHelper #helper class defines filename and invoke() function which calls the file selector
from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
from bpy.types import Operator
PKG, SUBPKG = __package__.split('.', maxsplit=1)
class IMPORTGIS_OT_georaster(Operator, ImportHelper):
"""Import georeferenced raster (need world file)"""
bl_idname = "importgis.georaster" # important since its how bpy.ops.importgis.georaster is constructed (allows calling operator from python console or another script)
#bl_idname rules: must contain one '.' (dot) charactere, no capital letters, no reserved words (like 'import')
bl_description = 'Import raster georeferenced with world file'
bl_label = "Import georaster"
bl_options = {"UNDO"}
def listObjects(self, context):
#Function used to update the objects list (obj_list) used by the dropdown box.
objs = [] #list containing tuples of each object
for index, object in enumerate(bpy.context.scene.objects): #iterate over all objects
if object.type == 'MESH':
objs.append((str(index), object.name, "Object named " +object.name)) #put each object in a tuple (key, label, tooltip) and add this to the objects list
return objs
# ImportHelper class properties
filter_glob: StringProperty(
default="*.tif;*.jpg;*.jpeg;*.png;*.bmp",
options={'HIDDEN'},
)
# Raster CRS definition
def listPredefCRS(self, context):
return PredefCRS.getEnumItems()
rastCRS: EnumProperty(
name = "Raster CRS",
description = "Choose a Coordinate Reference System",
items = listPredefCRS,
)
reprojection: BoolProperty(
name="Specifiy raster CRS",
description="Specifiy raster CRS if it's different from scene CRS",
default=False )
# List of operator properties, the attributes will be assigned
# to the class instance from the operator settings before calling.
importMode: EnumProperty(
name="Mode",
description="Select import mode",
items=[ ('PLANE', 'Basemap on new plane', "Place raster texture on new plane mesh"),
('BKG', 'Basemap as background', "Place raster as background image"),
('MESH', 'Basemap on mesh', "UV map raster on an existing mesh"),
('DEM', 'DEM as displacement texture', "Use DEM raster as height texture to wrap a base mesh"),
('DEM_RAW', 'DEM raw data build [slow]', "Import a DEM as pixels points cloud with building faces. Do not use with huge dataset.")]
)
#
objectsLst: EnumProperty(attr="obj_list", name="Objects", description="Choose object to edit", items=listObjects)
#
#Subdivise (as DEM option)
def listSubdivisionModes(self, context):
items = [ ('subsurf', 'Subsurf', "Add a subsurf modifier"), ('none', 'None', "No subdivision")]
if not self.demOnMesh:
#mesh subdivision method can not be applyed on an existing mesh
#this option makes sense only when the mesh is created from scratch
items.append(('mesh', 'Mesh', "Create vertices at each pixels"))
return items
subdivision: EnumProperty(
name="Subdivision",
description="How to subdivise the plane (dispacer needs vertex to work with)",
items=listSubdivisionModes
)
#
demOnMesh: BoolProperty(
name="Apply on existing mesh",
description="Use DEM as displacer for an existing mesh",
default=False
)
#
clip: BoolProperty(
name="Clip to working extent",
description="Use the reference bounding box to clip the DEM",
default=False
)
#
demInterpolation: BoolProperty(
name="Smooth relief",
description="Use texture interpolation to smooth the resulting terrain",
default=True
)
#
fillNodata: BoolProperty(
name="Fill nodata values",
description="Interpolate existing nodata values to get an usuable displacement texture",
default=False
)
#
step: IntProperty(name = "Step", default=1, description="Pixel step", min=1)
buildFaces: BoolProperty(name="Build faces", default=True, description='Build quad faces connecting pixel point cloud')
def draw(self, context):
#Function used by blender to draw the panel.
layout = self.layout
layout.prop(self, 'importMode')
scn = bpy.context.scene
geoscn = GeoScene(scn)
#
if self.importMode == 'PLANE':
pass
#
if self.importMode == 'BKG':
pass
#
if self.importMode == 'MESH':
if geoscn.isGeoref and len(self.objectsLst) > 0:
layout.prop(self, 'objectsLst')
else:
layout.label(text="There isn't georef mesh to UVmap on")
#
if self.importMode == 'DEM':
layout.prop(self, 'demOnMesh')
if self.demOnMesh:
if geoscn.isGeoref and len(self.objectsLst) > 0:
layout.prop(self, 'objectsLst')
layout.prop(self, 'clip')
else:
layout.label(text="There isn't georef mesh to apply on")
layout.prop(self, 'subdivision')
layout.prop(self, 'demInterpolation')
if self.subdivision == 'mesh':
layout.prop(self, 'step')
layout.prop(self, 'fillNodata')
#
if self.importMode == 'DEM_RAW':
layout.prop(self, 'buildFaces')
layout.prop(self, 'step')
layout.prop(self, 'clip')
if self.clip:
if geoscn.isGeoref and len(self.objectsLst) > 0:
layout.prop(self, 'objectsLst')
else:
layout.label(text="There isn't georef mesh to refer")
#
if geoscn.isPartiallyGeoref:
layout.prop(self, 'reprojection')
if self.reprojection:
self.crsInputLayout(context)
#
georefManagerLayout(self, context)
else:
self.crsInputLayout(context)
def crsInputLayout(self, context):
layout = self.layout
row = layout.row(align=True)
split = row.split(factor=0.35, align=True)
split.label(text='CRS:')
split.prop(self, "rastCRS", text='')
row.operator("bgis.add_predef_crs", text='', icon='ADD')
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
bpy.ops.object.select_all(action='DESELECT')
#Get scene and some georef data
scn = bpy.context.scene
geoscn = GeoScene(scn)
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
scale = geoscn.scale #TODO
if geoscn.isGeoref:
dx, dy = geoscn.getOriginPrj()
if self.reprojection:
rastCRS = self.rastCRS
else:
rastCRS = geoscn.crs
else: #if not geoscn.hasCRS
rastCRS = self.rastCRS
try:
geoscn.crs = rastCRS
except Exception as e:
log.error("Cannot set scene crs", exc_info=True)
self.report({'ERROR'}, "Cannot set scene crs, check logs for more infos")
return {'CANCELLED'}
#Raster reprojection throught UV mapping
#build reprojector objects
if geoscn.crs != rastCRS:
rprj = True
rprjToRaster = Reproj(geoscn.crs, rastCRS)
rprjToScene = Reproj(rastCRS, geoscn.crs)
else:
rprj = False
rprjToRaster = None
rprjToScene = None
#Path
filePath = self.filepath
name = os.path.basename(filePath)[:-4]
######################################
if self.importMode == 'PLANE':#on plane
#Load raster
try:
rast = bpyGeoRaster(filePath)
except IOError as e:
log.error("Unable to open raster", exc_info=True)
self.report({'ERROR'}, "Unable to open raster, check logs for more infos")
return {'CANCELLED'}
#Get or set georef dx, dy
if not geoscn.isGeoref:
dx, dy = rast.center.x, rast.center.y
if rprj:
dx, dy = rprjToScene.pt(dx, dy)
geoscn.setOriginPrj(dx, dy)
#create a new mesh from raster extent
mesh = rasterExtentToMesh(name, rast, dx, dy, reproj=rprjToScene)
#place obj
obj = placeObj(mesh, name)
#UV mapping
uvTxtLayer = mesh.uv_layers.new(name='rastUVmap')# Add UV map texture layer
geoRastUVmap(obj, uvTxtLayer, rast, dx, dy, reproj=rprjToRaster)
# Create material
mat = bpy.data.materials.new('rastMat')
# Add material to current object
obj.data.materials.append(mat)
# Add texture to material
addTexture(mat, rast.bpyImg, uvTxtLayer, name='rastText')
######################################
if self.importMode == 'BKG':#background
if rprj:
#TODO, do gdal true reproj
self.report({'ERROR'}, "Raster reprojection is not possible in background mode")
return {'CANCELLED'}
#Load raster
try:
rast = bpyGeoRaster(filePath)
except IOError as e:
log.error("Unable to open raster", exc_info=True)
self.report({'ERROR'}, "Unable to open raster, check logs for more infos")
return {'CANCELLED'}
#Check pixel size and rotation
if rast.rotation.xy != [0,0]:
self.report({'ERROR'}, "Cannot apply a rotation in background image mode")
return {'CANCELLED'}
if abs(round(rast.pxSize.x, 3)) != abs(round(rast.pxSize.y, 3)):
self.report({'ERROR'}, "Background image needs equal pixel size in map units in both x ans y axis")
return {'CANCELLED'}
#
trueSizeX = rast.geoSize.x
trueSizeY = rast.geoSize.y
ratio = rast.size.x / rast.size.y
if geoscn.isGeoref:
offx, offy = rast.center.x - dx, rast.center.y - dy
else:
dx, dy = rast.center.x, rast.center.y
geoscn.setOriginPrj(dx, dy)
offx, offy = 0, 0
bkg = bpy.data.objects.new(self.name, None) #None will create an empty
bkg.empty_display_type = 'IMAGE'
bkg.empty_image_depth = 'BACK'
bkg.data = rast.bpyImg
scn.collection.objects.link(bkg)
bkg.empty_display_size = 1 #a size of 1 means image width=1bu
bkg.scale = (trueSizeX, trueSizeY*ratio, 1)
bkg.location = (offx, offy, 0)
bpy.context.view_layer.objects.active = bkg
bkg.select_set(True)
if prefs.adjust3Dview:
adjust3Dview(context, rast.bbox)
######################################
if self.importMode == 'MESH':
if not geoscn.isGeoref or len(self.objectsLst) == 0:
self.report({'ERROR'}, "There isn't georef mesh to apply on")
return {'CANCELLED'}
# Get choosen object
obj = scn.objects[int(self.objectsLst)]
# Select and active this obj
obj.select_set(True)
context.view_layer.objects.active = obj
# Compute projeted bbox (in geographic coordinates system)
subBox = getBBOX.fromObj(obj).toGeo(geoscn)
if rprj:
subBox = rprjToRaster.bbox(subBox)
#Load raster
try:
rast = bpyGeoRaster(filePath, subBoxGeo=subBox)
except IOError as e:
log.error("Unable to open raster", exc_info=True)
self.report({'ERROR'}, "Unable to open raster, check logs for more infos")
return {'CANCELLED'}
except OverlapError:
self.report({'ERROR'}, "Non overlap data")
return {'CANCELLED'}
# Add UV map texture layer
mesh = obj.data
uvTxtLayer = mesh.uv_layers.new(name='rastUVmap')
uvTxtLayer.active = True
# UV mapping
geoRastUVmap(obj, uvTxtLayer, rast, dx, dy, reproj=rprjToRaster)
# Add material and texture
mat = bpy.data.materials.new('rastMat')
obj.data.materials.append(mat)
addTexture(mat, rast.bpyImg, uvTxtLayer, name='rastText')
######################################
if self.importMode == 'DEM':
# Get reference plane
if self.demOnMesh:
if not geoscn.isGeoref or len(self.objectsLst) == 0:
self.report({'ERROR'}, "There isn't georef mesh to apply on")
return {'CANCELLED'}
# Get choosen object
obj = scn.objects[int(self.objectsLst)]
mesh = obj.data
# Select and active this obj
obj.select_set(True)
context.view_layer.objects.active = obj
# Compute projeted bbox (in geographic coordinates system)
subBox = getBBOX.fromObj(obj).toGeo(geoscn)
if rprj:
subBox = rprjToRaster.bbox(subBox)
else:
subBox = None
# Load raster
try:
grid = bpyGeoRaster(filePath, subBoxGeo=subBox, clip=self.clip, fillNodata=self.fillNodata, useGDAL=HAS_GDAL, raw=True)
except IOError as e:
log.error("Unable to open raster", exc_info=True)
self.report({'ERROR'}, "Unable to open raster, check logs for more infos")
return {'CANCELLED'}
except OverlapError:
self.report({'ERROR'}, "Non overlap data")
return {'CANCELLED'}
# If needed, create a new plane object from raster extent
if not self.demOnMesh:
if not geoscn.isGeoref:
dx, dy = grid.center.x, grid.center.y
if rprj:
dx, dy = rprjToScene.pt(dx, dy)
geoscn.setOriginPrj(dx, dy)
if self.subdivision == 'mesh':#Mesh cut
mesh = exportAsMesh(grid, dx, dy, self.step, reproj=rprjToScene, flat=True)
else:
mesh = rasterExtentToMesh(name, grid, dx, dy, pxLoc='CENTER', reproj=rprjToScene) #use pixel center to avoid displacement glitch
obj = placeObj(mesh, name)
subBox = getBBOX.fromObj(obj).toGeo(geoscn)
# Add UV map texture layer
previousUVmapIdx = mesh.uv_layers.active_index
uvTxtLayer = mesh.uv_layers.new(name='demUVmap')
#UV mapping
geoRastUVmap(obj, uvTxtLayer, grid, dx, dy, reproj=rprjToRaster)
#Restore previous uv map
if previousUVmapIdx != -1:
mesh.uv_layers.active_index = previousUVmapIdx
#Make subdivision
if self.subdivision == 'subsurf':#Add subsurf modifier
if not 'SUBSURF' in [mod.type for mod in obj.modifiers]:
subsurf = obj.modifiers.new('DEM', type='SUBSURF')
subsurf.subdivision_type = 'SIMPLE'
subsurf.levels = 6
subsurf.render_levels = 6
#Set displacer
dsp = setDisplacer(obj, grid, uvTxtLayer, interpolation=self.demInterpolation)
######################################
if self.importMode == 'DEM_RAW':
# Get reference plane
subBox = None
if self.clip:
if not geoscn.isGeoref or len(self.objectsLst) == 0:
self.report({'ERROR'}, "No working extent")
return {'CANCELLED'}
# Get choosen object
obj = scn.objects[int(self.objectsLst)]
subBox = getBBOX.fromObj(obj).toGeo(geoscn)
if rprj:
subBox = rprjToRaster.bbox(subBox)
# Load raster
try:
grid = GeoRaster(filePath, subBoxGeo=subBox, useGDAL=HAS_GDAL)
except IOError as e:
log.error("Unable to open raster", exc_info=True)
self.report({'ERROR'}, "Unable to open raster, check logs for more infos")
return {'CANCELLED'}
except OverlapError:
self.report({'ERROR'}, "Non overlap data")
return {'CANCELLED'}
if not geoscn.isGeoref:
dx, dy = grid.center.x, grid.center.y
if rprj:
dx, dy = rprjToScene.pt(dx, dy)
geoscn.setOriginPrj(dx, dy)
mesh = exportAsMesh(grid, dx, dy, self.step, reproj=rprjToScene, subset=self.clip, flat=False, buildFaces=self.buildFaces)
obj = placeObj(mesh, name)
#grid.unload()
######################################
#Flag if a new object as been created...
if self.importMode == 'PLANE' or (self.importMode == 'DEM' and not self.demOnMesh) or self.importMode == 'DEM_RAW':
newObjCreated = True
else:
newObjCreated = False
#...if so, maybee we need to adjust 3d view settings to it
if newObjCreated and prefs.adjust3Dview:
bb = getBBOX.fromObj(obj)
adjust3Dview(context, bb)
#Force view mode with textures
if prefs.forceTexturedSolid:
showTextures(context)
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(IMPORTGIS_OT_georaster)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(IMPORTGIS_OT_georaster))
unregister()
bpy.utils.register_class(IMPORTGIS_OT_georaster)
def unregister():
bpy.utils.unregister_class(IMPORTGIS_OT_georaster)
================================================
FILE: operators/io_import_osm.py
================================================
import os
import time
import json
import random
import logging
log = logging.getLogger(__name__)
import bpy
import bmesh
from bpy.types import Operator, Panel, AddonPreferences
from bpy.props import StringProperty, IntProperty, FloatProperty, BoolProperty, EnumProperty, FloatVectorProperty
from .lib.osm import overpy
from ..geoscene import GeoScene
from .utils import adjust3Dview, getBBOX, DropToGround, isTopView
from ..core.proj import Reproj, reprojBbox, reprojPt, utm
from ..core.utils import perf_clock
from ..core import settings
USER_AGENT = settings.user_agent
PKG, SUBPKG = __package__.split('.', maxsplit=1)
#WARNING: There is a known bug with using an enum property with a callback, Python must keep a reference to the strings returned
#https://developer.blender.org/T48873
#https://developer.blender.org/T38489
def getTags():
prefs = bpy.context.preferences.addons[PKG].preferences
tags = json.loads(prefs.osmTagsJson)
return tags
#Global variable that will be seed by getTags() at each operator invoke
#then callback of dynamic enum will use this global variable
OSMTAGS = []
closedWaysArePolygons = ['aeroway', 'amenity', 'boundary', 'building', 'craft', 'geological', 'historic', 'landuse', 'leisure', 'military', 'natural', 'office', 'place', 'shop' , 'sport', 'tourism']
closedWaysAreExtruded = ['building']
def queryBuilder(bbox, tags=['building', 'highway'], types=['node', 'way', 'relation'], format='json'):
'''
QL template syntax :
[out:json][bbox:ymin,xmin,ymax,xmax];(node[tag1];node[tag2];((way[tag1];way[tag2];);>;);relation;);out;
'''
#s,w,n,e <--> ymin,xmin,ymax,xmax
bboxStr = ','.join(map(str, bbox.toLatlon()))
if not types:
#if no type filter is defined then just select all kind of type
types = ['node', 'way', 'relation']
head = "[out:"+format+"][bbox:"+bboxStr+"];"
union = '('
#all tagged nodes
if 'node' in types:
if tags:
union += ';'.join( ['node['+tag+']' for tag in tags] ) + ';'
else:
union += 'node;'
#all tagged ways with all their nodes (recurse down)
if 'way' in types:
union += '(('
if tags:
union += ';'.join( ['way['+tag+']' for tag in tags] ) + ';);'
else:
union += 'way;);'
union += '>;);'
#all relations (no filter tag applied)
if 'relation' in types or 'rel' in types:
union += 'relation;'
union += ')'
output = ';out;'
qry = head + union + output
return qry
########################
def joinBmesh(src_bm, dest_bm):
'''
Hack to join a bmesh to another
TODO: replace this function by bmesh.ops.duplicate when 'dest' argument will be implemented
'''
buff = bpy.data.meshes.new(".temp")
src_bm.to_mesh(buff)
dest_bm.from_mesh(buff)
bpy.data.meshes.remove(buff)
class OSM_IMPORT():
"""Import from Open Street Map"""
def enumTags(self, context):
items = []
##prefs = context.preferences.addons[PKG].preferences
##osmTags = json.loads(prefs.osmTagsJson)
#we need to use a global variable as workaround to enum callback bug (T48873, T38489)
for tag in OSMTAGS:
#put each item in a tuple (key, label, tooltip)
items.append( (tag, tag, tag) )
return items
filterTags: EnumProperty(
name = "Tags",
description = "Select tags to include",
items = enumTags,
options = {"ENUM_FLAG"})
featureType: EnumProperty(
name = "Type",
description = "Select types to include",
items = [
('node', 'Nodes', 'Request all nodes'),
('way', 'Ways', 'Request all ways'),
('relation', 'Relations', 'Request all relations')
],
default = {'way'},
options = {"ENUM_FLAG"}
)
# Elevation object
def listObjects(self, context):
objs = []
for index, object in enumerate(bpy.context.scene.objects):
if object.type == 'MESH':
#put each object in a tuple (key, label, tooltip) and add this to the objects list
objs.append((str(index), object.name, "Object named " + object.name))
return objs
objElevLst: EnumProperty(
name="Elev. object",
description="Choose the mesh from which extract z elevation",
items=listObjects )
useElevObj: BoolProperty(
name="Elevation from object",
description="Get z elevation value from an existing ground mesh",
default=False )
separate: BoolProperty(name='Separate objects', description='Warning : can be very slow with lot of features', default=False)
buildingsExtrusion: BoolProperty(name='Buildings extrusion', description='', default=True)
defaultHeight: FloatProperty(name='Default Height', description='Set the height value using for extrude building when the tag is missing', default=20)
levelHeight: FloatProperty(name='Level height', description='Set a height for a building level, using for compute extrude height based on number of levels', default=3)
randomHeightThreshold: IntProperty(name='Random height threshold', description='Threshold value for randomize default height', default=0)
def draw(self, context):
layout = self.layout
row = layout.row()
row.prop(self, "featureType", expand=True)
row = layout.row()
col = row.column()
col.prop(self, "filterTags", expand=True)
layout.prop(self, 'useElevObj')
if self.useElevObj:
layout.prop(self, 'objElevLst')
layout.prop(self, 'buildingsExtrusion')
if self.buildingsExtrusion:
layout.prop(self, 'defaultHeight')
layout.prop(self, 'randomHeightThreshold')
layout.prop(self, 'levelHeight')
layout.prop(self, 'separate')
def build(self, context, result, dstCRS):
prefs = context.preferences.addons[PKG].preferences
scn = context.scene
geoscn = GeoScene(scn)
scale = geoscn.scale #TODO
#Init reprojector class
try:
rprj = Reproj(4326, dstCRS)
except Exception as e:
log.error('Unable to reproject data', exc_info=True)
self.report({'ERROR'}, "Unable to reproject data ckeck logs for more infos")
return {'FINISHED'}
if self.useElevObj:
if not self.objElevLst:
log.error('There is no elevation object in the scene to get elevation from')
self.report({'ERROR'}, "There is no elevation object in the scene to get elevation from")
return {'FINISHED'}
elevObj = scn.objects[int(self.objElevLst)]
rayCaster = DropToGround(scn, elevObj)
bmeshes = {}
vgroupsObj = {}
#######
def seed(id, tags, pts):
'''
Sub funtion :
1. create a bmesh from [pts]
2. seed a global bmesh or create a new object
'''
if len(pts) > 1:
if pts[0] == pts[-1] and any(tag in closedWaysArePolygons for tag in tags):
type = 'Areas'
closed = True
pts.pop() #exclude last duplicate node
else:
type = 'Ways'
closed = False
else:
type = 'Nodes'
closed = False
#reproj and shift coords
pts = rprj.pts(pts)
dx, dy = geoscn.crsx, geoscn.crsy
if self.useElevObj:
#pts = [rayCaster.rayCast(v[0]-dx, v[1]-dy).loc for v in pts]
pts = [rayCaster.rayCast(v[0]-dx, v[1]-dy) for v in pts]
hits = [pt.hit for pt in pts]
if not all(hits) and any(hits):
zs = [p.loc.z for p in pts if p.hit]
meanZ = sum(zs) / len(zs)
for v in pts:
if not v.hit:
v.loc.z = meanZ
pts = [pt.loc for pt in pts]
else:
pts = [ (v[0]-dx, v[1]-dy, 0) for v in pts]
#Create a new bmesh
#>using an intermediate bmesh object allows some extra operation like extrusion
bm = bmesh.new()
if len(pts) == 1:
verts = [bm.verts.new(pt) for pt in pts]
elif closed: #faces
verts = [bm.verts.new(pt) for pt in pts]
face = bm.faces.new(verts)
#ensure face is up (anticlockwise order)
#because in OSM there is no particular order for closed ways
face.normal_update()
if face.normal.z < 0:
face.normal_flip()
if self.buildingsExtrusion and any(tag in closedWaysAreExtruded for tag in tags):
offset = None
if "height" in tags:
htag = tags["height"]
htag.replace(',', '.')
try:
offset = int(htag)
except:
try:
offset = float(htag)
except:
for i, c in enumerate(htag):
if not c.isdigit():
try:
offset, unit = float(htag[:i]), htag[i:].strip()
#todo : parse unit 25, 25m, 25 ft, etc.
except:
offset = None
elif "building:levels" in tags:
try:
offset = int(tags["building:levels"]) * self.levelHeight
except ValueError as e:
offset = None
if offset is None:
minH = self.defaultHeight - self.randomHeightThreshold
if minH < 0 :
minH = 0
maxH = self.defaultHeight + self.randomHeightThreshold
offset = random.randint(int(minH), int(maxH))
#Extrude
"""
if self.extrusionAxis == 'NORMAL':
normal = face.normal
vect = normal * offset
elif self.extrusionAxis == 'Z':
"""
vect = (0, 0, offset)
faces = bmesh.ops.extrude_discrete_faces(bm, faces=[face]) #return {'faces': [BMFace]}
verts = faces['faces'][0].verts
if self.useElevObj:
#Making flat roof
z = max([v.co.z for v in verts]) + offset #get max z coord
for v in verts:
v.co.z = z
else:
bmesh.ops.translate(bm, verts=verts, vec=vect)
elif len(pts) > 1: #edge
verts = [bm.verts.new(pt) for pt in pts]
for i in range(len(pts)-1):
edge = bm.edges.new( [verts[i], verts[i+1] ])
if self.separate:
name = tags.get('name', str(id))
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
mesh.update()
mesh.validate()
obj = bpy.data.objects.new(name, mesh)
#Assign tags to custom props
obj['id'] = str(id) #cast to str to avoid overflow error "Python int too large to convert to C int"
for key in tags.keys():
obj[key] = tags[key]
#Put object in right collection
if self.filterTags:
tagsList = self.filterTags
else:
tagsList = OSMTAGS
if any(tag in tagsList for tag in tags):
for k in tagsList:
if k in tags:
try:
tagCollec = layer.children[k]
except KeyError:
tagCollec = bpy.data.collections.new(k)
layer.children.link(tagCollec)
tagCollec.objects.link(obj)
break
else:
layer.objects.link(obj)
obj.select_set(True)
else:
#Grouping
bm.verts.index_update()
#bm.edges.index_update()
#bm.faces.index_update()
if self.filterTags:
#group by tags (there could be some duplicates)
for k in self.filterTags:
if k in extags: #
objName = type + ':' + k
kbm = bmeshes.setdefault(objName, bmesh.new())
offset = len(kbm.verts)
joinBmesh(bm, kbm)
else:
#group all into one unique mesh
objName = type
_bm = bmeshes.setdefault(objName, bmesh.new())
offset = len(_bm.verts)
joinBmesh(bm, _bm)
#vertex group
name = tags.get('name', None)
vidx = [v.index + offset for v in bm.verts]
vgroups = vgroupsObj.setdefault(objName, {})
for tag in extags:
#if tag in osmTags:#filter
if not tag.startswith('name'):
vgroup = vgroups.setdefault('Tag:'+tag, [])
vgroup.extend(vidx)
if name is not None:
#vgroup['Name:'+name] = [vidx]
vgroup = vgroups.setdefault('Name:'+name, [])
vgroup.extend(vidx)
if 'relation' in self.featureType:
for rel in result.relations:
name = rel.tags.get('name', str(rel.id))
for member in rel.members:
#todo: remove duplicate members
if id == member.ref:
vgroup = vgroups.setdefault('Relation:'+name, [])
vgroup.extend(vidx)
bm.free()
######
if self.separate:
layer = bpy.data.collections.new('OSM')
context.scene.collection.children.link(layer)
#Build mesh
waysNodesId = [node.id for way in result.ways for node in way.nodes]
if 'node' in self.featureType:
for node in result.nodes:
#extended tags list
extags = list(node.tags.keys()) + [k + '=' + v for k, v in node.tags.items()]
if node.id in waysNodesId:
continue
if self.filterTags and not any(tag in self.filterTags for tag in extags):
continue
pt = (float(node.lon), float(node.lat))
seed(node.id, node.tags, [pt])
if 'way' in self.featureType:
for way in result.ways:
extags = list(way.tags.keys()) + [k + '=' + v for k, v in way.tags.items()]
if self.filterTags and not any(tag in self.filterTags for tag in extags):
continue
pts = [(float(node.lon), float(node.lat)) for node in way.nodes]
seed(way.id, way.tags, pts)
if not self.separate:
for name, bm in bmeshes.items():
if prefs.mergeDoubles:
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
bm.free()
mesh.update()#calc_edges=True)
mesh.validate()
obj = bpy.data.objects.new(name, mesh)
scn.collection.objects.link(obj)
obj.select_set(True)
vgroups = vgroupsObj.get(name, None)
if vgroups is not None:
#for vgroupName, vgroupIdx in vgroups.items():
for vgroupName in sorted(vgroups.keys()):
vgroupIdx = vgroups[vgroupName]
g = obj.vertex_groups.new(name=vgroupName)
g.add(vgroupIdx, weight=1, type='ADD')
elif 'relation' in self.featureType:
relations = bpy.data.collections.new('Relations')
bpy.data.collections['OSM'].children.link(relations)
importedObjects = bpy.data.collections['OSM'].objects
for rel in result.relations:
name = rel.tags.get('name', str(rel.id))
try:
relation = relations.children[name] #or bpy.data.collections[name]
except KeyError:
relation = bpy.data.collections.new(name)
relations.children.link(relation)
for member in rel.members:
#todo: remove duplicate members
for obj in importedObjects:
#id = int(obj.get('id', -1))
try:
id = int(obj['id'])
except:
id = None
if id == member.ref:
try:
relation.objects.link(obj)
except Exception as e:
log.error('Object {} already in group {}'.format(obj.name, name), exc_info=True)
#cleanup
if not relation.objects:
bpy.data.collections.remove(relation)
#######################
class IMPORTGIS_OT_osm_file(Operator, OSM_IMPORT):
bl_idname = "importgis.osm_file"
bl_description = 'Select and import osm xml file'
bl_label = "Import OSM"
bl_options = {"UNDO"}
# Import dialog properties
filepath: StringProperty(
name="File Path",
description="Filepath used for importing the file",
maxlen=1024,
subtype='FILE_PATH' )
filename_ext = ".osm"
filter_glob: StringProperty(
default = "*.osm",
options = {'HIDDEN'} )
def invoke(self, context, event):
#workaround to enum callback bug (T48873, T38489)
global OSMTAGS
OSMTAGS = getTags()
#open file browser
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def execute(self, context):
scn = context.scene
if not os.path.exists(self.filepath):
self.report({'ERROR'}, "Invalid file")
return{'CANCELLED'}
try:
bpy.ops.object.mode_set(mode='OBJECT')
except:
pass
bpy.ops.object.select_all(action='DESELECT')
#Set cursor representation to 'loading' icon
w = context.window
w.cursor_set('WAIT')
#Spatial ref system
geoscn = GeoScene(scn)
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
#Parse file
t0 = perf_clock()
api = overpy.Overpass()
#with open(self.filepath, "r", encoding"utf-8") as f:
# result = api.parse_xml(f.read()) #WARNING read() load all the file into memory
result = api.parse_xml(self.filepath)
t = perf_clock() - t0
log.info('File parsed in {} seconds'.format(round(t, 2)))
#Get bbox
bounds = result.bounds
lon = (bounds["minlon"] + bounds["maxlon"])/2
lat = (bounds["minlat"] + bounds["maxlat"])/2
#Set CRS
if not geoscn.hasCRS:
try:
geoscn.crs = utm.lonlat_to_epsg(lon, lat)
except Exception as e:
log.error("Cannot set UTM CRS", exc_info=True)
self.report({'ERROR'}, "Cannot set UTM CRS, ckeck logs for more infos")
return {'CANCELLED'}
#Set scene origin georef
if not geoscn.hasOriginPrj:
x, y = reprojPt(4326, geoscn.crs, lon, lat)
geoscn.setOriginPrj(x, y)
#Build meshes
t0 = perf_clock()
self.build(context, result, geoscn.crs)
t = perf_clock() - t0
log.info('Mesh build in {} seconds'.format(round(t, 2)))
bbox = getBBOX.fromScn(scn)
adjust3Dview(context, bbox)
return{'FINISHED'}
########################
class IMPORTGIS_OT_osm_query(Operator, OSM_IMPORT):
"""Import from Open Street Map"""
bl_idname = "importgis.osm_query"
bl_description = 'Query for Open Street Map data covering the current view3d area'
bl_label = "Get OSM"
bl_options = {"UNDO"}
#special function to auto redraw an operator popup called through invoke_props_dialog
def check(self, context):
return True
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def invoke(self, context, event):
#workaround to enum callback bug (T48873, T38489)
global OSMTAGS
OSMTAGS = getTags()
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
prefs = bpy.context.preferences.addons[PKG].preferences
scn = context.scene
geoscn = GeoScene(scn)
objs = context.selected_objects
aObj = context.active_object
if not geoscn.isGeoref:
self.report({'ERROR'}, "Scene is not georef")
return {'CANCELLED'}
elif geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
if len(objs) == 1 and aObj.type == 'MESH':
bbox = getBBOX.fromObj(aObj).toGeo(geoscn)
elif isTopView(context):
bbox = getBBOX.fromTopView(context).toGeo(geoscn)
else:
self.report({'ERROR'}, "Please define the query extent in orthographic top view or by selecting a reference object")
return {'CANCELLED'}
if bbox.dimensions.x > 20000 or bbox.dimensions.y > 20000:
self.report({'ERROR'}, "Too large extent")
return {'CANCELLED'}
#Get view3d bbox in lonlat
bbox = reprojBbox(geoscn.crs, 4326, bbox)
#Set cursor representation to 'loading' icon
w = context.window
w.cursor_set('WAIT')
#Download from overpass api
log.debug('Requests overpass server : {}'.format(prefs.overpassServer))
api = overpy.Overpass(overpass_server=prefs.overpassServer, user_agent=USER_AGENT)
query = queryBuilder(bbox, tags=list(self.filterTags), types=list(self.featureType), format='xml')
log.debug('Overpass query : {}'.format(query)) # can fails with non utf8 chars
try:
result = api.query(query)
except Exception as e:
log.error("Overpass query failed", exc_info=True)
self.report({'ERROR'}, "Overpass query failed, ckeck logs for more infos.")
return {'CANCELLED'}
else:
log.info('Overpass query successful')
self.build(context, result, geoscn.crs)
bbox = getBBOX.fromScn(scn)
adjust3Dview(context, bbox, zoomToSelect=False)
return {'FINISHED'}
classes = [
IMPORTGIS_OT_osm_file,
IMPORTGIS_OT_osm_query
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: operators/io_import_shp.py
================================================
# -*- coding:utf-8 -*-
import os, sys, time
import bpy
from bpy.props import StringProperty, BoolProperty, EnumProperty, IntProperty
from bpy.types import Operator
import bmesh
import math
from mathutils import Vector
import logging
log = logging.getLogger(__name__)
from ..core.lib.shapefile import Reader as shpReader
from ..geoscene import GeoScene, georefManagerLayout
from ..prefs import PredefCRS
from ..core import BBOX
from ..core.proj import Reproj
from ..core.utils import perf_clock
from .utils import adjust3Dview, getBBOX, DropToGround
PKG, SUBPKG = __package__.split('.', maxsplit=1)
featureType={
0:'Null',
1:'Point',
3:'PolyLine',
5:'Polygon',
8:'MultiPoint',
11:'PointZ',
13:'PolyLineZ',
15:'PolygonZ',
18:'MultiPointZ',
21:'PointM',
23:'PolyLineM',
25:'PolygonM',
28:'MultiPointM',
31:'MultiPatch'
}
"""
dbf fields type:
C is ASCII characters
N is a double precision integer limited to around 18 characters in length
D is for dates in the YYYYMMDD format, with no spaces or hyphens between the sections
F is for floating point numbers with the same length limits as N
L is for logical data which is stored in the shapefile's attribute table as a short integer as a 1 (true) or a 0 (false).
The values it can receive are 1, 0, y, n, Y, N, T, F or the python builtins True and False
"""
class IMPORTGIS_OT_shapefile_file_dialog(Operator):
"""Select shp file, loads the fields and start importgis.shapefile_props_dialog operator"""
bl_idname = "importgis.shapefile_file_dialog"
bl_description = 'Import ESRI shapefile (.shp)'
bl_label = "Import SHP"
bl_options = {'INTERNAL'}
# Import dialog properties
filepath: StringProperty(
name="File Path",
description="Filepath used for importing the file",
maxlen=1024,
subtype='FILE_PATH' )
filename_ext = ".shp"
filter_glob: StringProperty(
default = "*.shp",
options = {'HIDDEN'} )
def invoke(self, context, event):
context.window_manager.fileselect_add(self)
return {'RUNNING_MODAL'}
def draw(self, context):
layout = self.layout
layout.label(text="Options will be available")
layout.label(text="after selecting a file")
def execute(self, context):
if os.path.exists(self.filepath):
bpy.ops.importgis.shapefile_props_dialog('INVOKE_DEFAULT', filepath=self.filepath)
else:
self.report({'ERROR'}, "Invalid filepath")
return{'FINISHED'}
class IMPORTGIS_OT_shapefile_props_dialog(Operator):
"""Shapefile importer properties dialog"""
bl_idname = "importgis.shapefile_props_dialog"
bl_description = 'Import ESRI shapefile (.shp)'
bl_label = "Import SHP"
bl_options = {"INTERNAL"}
filepath: StringProperty()
#special function to auto redraw an operator popup called through invoke_props_dialog
def check(self, context):
return True
def listFields(self, context):
fieldsItems = []
try:
shp = shpReader(self.filepath)
except Exception as e:
log.warning("Unable to read shapefile fields", exc_info=True)
return fieldsItems
fields = [field for field in shp.fields if field[0] != 'DeletionFlag'] #ignore default DeletionFlag field
for i, field in enumerate(fields):
#put each item in a tuple (key, label, tooltip)
fieldsItems.append( (field[0], field[0], '') )
return fieldsItems
# Shapefile CRS definition
def listPredefCRS(self, context):
return PredefCRS.getEnumItems()
def listObjects(self, context):
objs = []
for index, object in enumerate(bpy.context.scene.objects):
if object.type == 'MESH':
#put each object in a tuple (key, label, tooltip) and add this to the objects list
objs.append((object.name, object.name, "Object named " + object.name))
return objs
reprojection: BoolProperty(
name="Specifiy shapefile CRS",
description="Specifiy shapefile CRS if it's different from scene CRS",
default=False )
shpCRS: EnumProperty(
name = "Shapefile CRS",
description = "Choose a Coordinate Reference System",
items = listPredefCRS)
# Elevation source
vertsElevSource: EnumProperty(
name="Elevation source",
description="Select the source of vertices z value",
items=[
('NONE', 'None', "Flat geometry"),
('GEOM', 'Geometry', "Use z value from shape geometry if exists"),
('FIELD', 'Field', "Extract z elevation value from an attribute field"),
('OBJ', 'Object', "Get z elevation value from an existing ground mesh")
],
default='GEOM')
# Elevation object
objElevLst: EnumProperty(
name="Elev. object",
description="Choose the mesh from which extract z elevation",
items=listObjects )
# Elevation field
'''
useFieldElev: BoolProperty(
name="Elevation from field",
description="Extract z elevation value from an attribute field",
default=False )
'''
fieldElevName: EnumProperty(
name = "Elev. field",
description = "Choose field",
items = listFields )
#Extrusion field
useFieldExtrude: BoolProperty(
name="Extrusion from field",
description="Extract z extrusion value from an attribute field",
default=False )
fieldExtrudeName: EnumProperty(
name = "Field",
description = "Choose field",
items = listFields )
#Extrusion axis
extrusionAxis: EnumProperty(
name="Extrude along",
description="Select extrusion axis",
items=[ ('Z', 'z axis', "Extrude along Z axis"),
('NORMAL', 'Normal', "Extrude along normal")] )
#Create separate objects
separateObjects: BoolProperty(
name="Separate objects",
description="Warning : can be very slow with lot of features",
default=False )
#Name objects from field
useFieldName: BoolProperty(
name="Object name from field",
description="Extract name for created objects from an attribute field",
default=False )
fieldObjName: EnumProperty(
name = "Field",
description = "Choose field",
items = listFields )
def draw(self, context):
#Function used by blender to draw the panel.
scn = context.scene
layout = self.layout
#
layout.prop(self, 'vertsElevSource')
#
#layout.prop(self, 'useFieldElev')
if self.vertsElevSource == 'FIELD':
layout.prop(self, 'fieldElevName')
elif self.vertsElevSource == 'OBJ':
layout.prop(self, 'objElevLst')
#
layout.prop(self, 'useFieldExtrude')
if self.useFieldExtrude:
layout.prop(self, 'fieldExtrudeName')
layout.prop(self, 'extrusionAxis')
#
layout.prop(self, 'separateObjects')
if self.separateObjects:
layout.prop(self, 'useFieldName')
else:
self.useFieldName = False
if self.separateObjects and self.useFieldName:
layout.prop(self, 'fieldObjName')
#
geoscn = GeoScene()
#geoscnPrefs = context.preferences.addons['geoscene'].preferences
if geoscn.isPartiallyGeoref:
layout.prop(self, 'reprojection')
if self.reprojection:
self.shpCRSInputLayout(context)
#
georefManagerLayout(self, context)
else:
self.shpCRSInputLayout(context)
def shpCRSInputLayout(self, context):
layout = self.layout
row = layout.row(align=True)
#row.prop(self, "shpCRS", text='CRS')
split = row.split(factor=0.35, align=True)
split.label(text='CRS:')
split.prop(self, "shpCRS", text='')
row.operator("bgis.add_predef_crs", text='', icon='ADD')
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
#elevField = self.fieldElevName if self.useFieldElev else ""
elevField = self.fieldElevName if self.vertsElevSource == 'FIELD' else ""
extrudField = self.fieldExtrudeName if self.useFieldExtrude else ""
nameField = self.fieldObjName if self.useFieldName else ""
if self.vertsElevSource == 'OBJ':
if not self.objElevLst:
self.report({'ERROR'}, "No elevation object")
return {'CANCELLED'}
else:
objElevName = self.objElevLst
else:
objElevName = '' #will not be used
geoscn = GeoScene()
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
if geoscn.isGeoref:
if self.reprojection:
shpCRS = self.shpCRS
else:
shpCRS = geoscn.crs
else:
shpCRS = self.shpCRS
try:
bpy.ops.importgis.shapefile('INVOKE_DEFAULT', filepath=self.filepath, shpCRS=shpCRS, elevSource=self.vertsElevSource,
fieldElevName=elevField, objElevName=objElevName, fieldExtrudeName=extrudField, fieldObjName=nameField,
extrusionAxis=self.extrusionAxis, separateObjects=self.separateObjects)
except Exception as e:
log.error('Shapefile import fails', exc_info=True)
self.report({'ERROR'}, 'Shapefile import fails, check logs.')
return {'CANCELLED'}
return{'FINISHED'}
class IMPORTGIS_OT_shapefile(Operator):
"""Import from ESRI shapefile file format (.shp)"""
bl_idname = "importgis.shapefile" # important since its how bpy.ops.import.shapefile is constructed (allows calling operator from python console or another script)
#bl_idname rules: must contain one '.' (dot) charactere, no capital letters, no reserved words (like 'import')
bl_description = 'Import ESRI shapefile (.shp)'
bl_label = "Import SHP"
bl_options = {"UNDO"}
filepath: StringProperty()
shpCRS: StringProperty(name = "Shapefile CRS", description = "Coordinate Reference System")
elevSource: StringProperty(name = "Elevation source", description = "Elevation source", default='GEOM') # [NONE, GEOM, OBJ, FIELD]
objElevName: StringProperty(name = "Elevation object name", description = "")
fieldElevName: StringProperty(name = "Elevation field", description = "Field name")
fieldExtrudeName: StringProperty(name = "Extrusion field", description = "Field name")
fieldObjName: StringProperty(name = "Objects names field", description = "Field name")
#Extrusion axis
extrusionAxis: EnumProperty(
name="Extrude along",
description="Select extrusion axis",
items=[ ('Z', 'z axis', "Extrude along Z axis"),
('NORMAL', 'Normal', "Extrude along normal")]
)
#Create separate objects
separateObjects: BoolProperty(
name="Separate objects",
description="Import to separate objects instead one large object",
default=False
)
@classmethod
def poll(cls, context):
return context.mode == 'OBJECT'
def __del__(self):
bpy.context.window.cursor_set('DEFAULT')
def execute(self, context):
prefs = bpy.context.preferences.addons[PKG].preferences
#Set cursor representation to 'loading' icon
w = context.window
w.cursor_set('WAIT')
t0 = perf_clock()
bpy.ops.object.select_all(action='DESELECT')
#Path
shpName = os.path.basename(self.filepath)[:-4]
#Get shp reader
log.info("Read shapefile...")
try:
shp = shpReader(self.filepath)
except Exception as e:
log.error("Unable to read shapefile", exc_info=True)
self.report({'ERROR'}, "Unable to read shapefile, check logs")
return {'CANCELLED'}
#Check shape type
shpType = featureType[shp.shapeType]
log.info('Feature type : ' + shpType)
if shpType not in ['Point','PolyLine','Polygon','PointZ','PolyLineZ','PolygonZ']:
self.report({'ERROR'}, "Cannot process multipoint, multipointZ, pointM, polylineM, polygonM and multipatch feature type")
return {'CANCELLED'}
if self.elevSource != 'FIELD':
self.fieldElevName = ''
if self.elevSource == 'OBJ':
scn = bpy.context.scene
elevObj = scn.objects[self.objElevName]
rayCaster = DropToGround(scn, elevObj)
#Get fields
fields = [field for field in shp.fields if field[0] != 'DeletionFlag'] #ignore default DeletionFlag field
fieldsNames = [field[0] for field in fields]
log.debug("DBF fields : "+str(fieldsNames))
if self.separateObjects or self.fieldElevName or self.fieldObjName or self.fieldExtrudeName:
self.useDbf = True
else:
self.useDbf = False
if self.fieldObjName and self.separateObjects:
try:
nameFieldIdx = fieldsNames.index(self.fieldObjName)
except Exception as e:
log.error('Unable to find name field', exc_info=True)
self.report({'ERROR'}, "Unable to find name field")
return {'CANCELLED'}
if self.fieldElevName:
try:
zFieldIdx = fieldsNames.index(self.fieldElevName)
except Exception as e:
log.error('Unable to find elevation field', exc_info=True)
self.report({'ERROR'}, "Unable to find elevation field")
return {'CANCELLED'}
if fields[zFieldIdx][1] not in ['N', 'F', 'L'] :
self.report({'ERROR'}, "Elevation field do not contains numeric values")
return {'CANCELLED'}
if self.fieldExtrudeName:
try:
extrudeFieldIdx = fieldsNames.index(self.fieldExtrudeName)
except ValueError:
log.error('Unable to find extrusion field', exc_info=True)
self.report({'ERROR'}, "Unable to find extrusion field")
return {'CANCELLED'}
if fields[extrudeFieldIdx][1] not in ['N', 'F', 'L'] :
self.report({'ERROR'}, "Extrusion field do not contains numeric values")
return {'CANCELLED'}
#Get shp and scene georef infos
shpCRS = self.shpCRS
geoscn = GeoScene()
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
scale = geoscn.scale #TODO
if not geoscn.hasCRS: #if not geoscn.isGeoref:
try:
geoscn.crs = shpCRS
except Exception as e:
log.error("Cannot set scene crs", exc_info=True)
self.report({'ERROR'}, "Cannot set scene crs, check logs for more infos")
return {'CANCELLED'}
#Init reprojector class
if geoscn.crs != shpCRS:
log.info("Data will be reprojected from {} to {}".format(shpCRS, geoscn.crs))
try:
rprj = Reproj(shpCRS, geoscn.crs)
except Exception as e:
log.error('Reprojection fails', exc_info=True)
self.report({'ERROR'}, "Unable to reproject data, check logs for more infos.")
return {'CANCELLED'}
if rprj.iproj == 'EPSGIO':
if shp.numRecords > 100:
self.report({'ERROR'}, "Reprojection through online epsg.io engine is limited to 100 features. \nPlease install GDAL or pyproj module.")
return {'CANCELLED'}
#Get bbox
bbox = BBOX(shp.bbox)
if geoscn.crs != shpCRS:
bbox = rprj.bbox(bbox)
#Get or set georef dx, dy
if not geoscn.isGeoref:
dx, dy = bbox.center
geoscn.setOriginPrj(dx, dy)
else:
dx, dy = geoscn.getOriginPrj()
#Get reader iterator (using iterator avoids loading all data in memory)
#warn, shp with zero field will return an empty shapeRecords() iterator
#to prevent this issue, iter only on shapes if there is no field required
if self.useDbf:
#Note: using shapeRecord solve the issue where number of shapes does not match number of table records
#because it iter only on features with geom and record
shpIter = shp.iterShapeRecords()
else:
shpIter = shp.iterShapes()
nbFeats = shp.numRecords
#Create an empty BMesh
bm = bmesh.new()
#Extrusion is exponentially slow with large bmesh
#it's fastest to extrude a small bmesh and then join it to a final large bmesh
if not self.separateObjects and self.fieldExtrudeName:
finalBm = bmesh.new()
progress = -1
if self.separateObjects:
layer = bpy.data.collections.new(shpName)
context.scene.collection.children.link(layer)
#Main iteration over features
for i, feat in enumerate(shpIter):
if self.useDbf:
shape = feat.shape
record = feat.record
else:
shape = feat
#Progress infos
pourcent = round(((i+1)*100)/nbFeats)
if pourcent in list(range(0, 110, 10)) and pourcent != progress:
progress = pourcent
if pourcent == 100:
print(str(pourcent)+'%')
else:
print(str(pourcent), end="%, ")
sys.stdout.flush() #we need to flush or it won't print anything until after the loop has finished
#Deal with multipart features
#If the shape record has multiple parts, the 'parts' attribute will contains the index of
#the first point of each part. If there is only one part then a list containing 0 is returned
if (shpType == 'PointZ' or shpType == 'Point'): #point layer has no attribute 'parts'
partsIdx = [0]
else:
try: #prevent "_shape object has no attribute parts" error
partsIdx = shape.parts
except Exception as e:
log.warning('Cannot access "parts" attribute for feature {} : {}'.format(i, e))
partsIdx = [0]
nbParts = len(partsIdx)
#Get list of shape's points
pts = shape.points
nbPts = len(pts)
#Skip null geom
if nbPts == 0:
continue #go to next iteration of the loop
#Reproj geom
if geoscn.crs != shpCRS:
pts = rprj.pts(pts)
#Get extrusion offset
if self.fieldExtrudeName:
try:
offset = float(record[extrudeFieldIdx])
except Exception as e:
log.warning('Cannot extract extrusion value for feature {} : {}'.format(i, e))
offset = 0 #null values will be set to zero
#Iter over parts
for j in range(nbParts):
# EXTRACT 3D GEOM
geom = [] #will contains a list of 3d points
#Find first and last part index
idx1 = partsIdx[j]
if j+1 == nbParts:
idx2 = nbPts
else:
idx2 = partsIdx[j+1]
#Build 3d geom
for k, pt in enumerate(pts[idx1:idx2]):
if self.elevSource == 'OBJ':
rcHit = rayCaster.rayCast(x=pt[0]-dx, y=pt[1]-dy)
z = rcHit.loc.z #will be automatically set to zero if not rcHit.hit
elif self.elevSource == 'FIELD':
try:
z = float(record[zFieldIdx])
except Exception as e:
log.warning('Cannot extract elevation value for feature {} : {}'.format(i, e))
z = 0 #null values will be set to zero
elif shpType[-1] == 'Z' and self.elevSource == 'GEOM':
z = shape.z[idx1:idx2][k]
else:
z = 0
geom.append((pt[0], pt[1], z))
#Shift coords
geom = [(pt[0]-dx, pt[1]-dy, pt[2]) for pt in geom]
# BUILD BMESH
# POINTS
if (shpType == 'PointZ' or shpType == 'Point'):
vert = [bm.verts.new(pt) for pt in geom]
#Extrusion
if self.fieldExtrudeName and offset > 0:
vect = (0, 0, offset) #along Z
result = bmesh.ops.extrude_vert_indiv(bm, verts=vert)
verts = result['verts']
bmesh.ops.translate(bm, verts=verts, vec=vect)
# LINES
if (shpType == 'PolyLine' or shpType == 'PolyLineZ'):
verts = [bm.verts.new(pt) for pt in geom]
edges = []
for i in range(len(geom)-1):
edge = bm.edges.new( [verts[i], verts[i+1] ])
edges.append(edge)
#Extrusion
if self.fieldExtrudeName and offset > 0:
vect = (0, 0, offset) # along Z
result = bmesh.ops.extrude_edge_only(bm, edges=edges)
verts = [elem for elem in result['geom'] if isinstance(elem, bmesh.types.BMVert)]
bmesh.ops.translate(bm, verts=verts, vec=vect)
# NGONS
if (shpType == 'Polygon' or shpType == 'PolygonZ'):
#According to the shapefile spec, polygons points are clockwise and polygon holes are counterclockwise
#in Blender face is up if points are in anticlockwise order
geom.reverse() #face up
geom.pop() #exlude last point because it's the same as first pt
if len(geom) >= 3: #needs 3 points to get a valid face
verts = [bm.verts.new(pt) for pt in geom]
face = bm.faces.new(verts)
#update normal to avoid null vector
face.normal_update()
if face.normal.z < 0: #this is a polygon hole, bmesh cannot handle polygon hole
pass #TODO
#Extrusion
if self.fieldExtrudeName and offset > 0:
#build translate vector
if self.extrusionAxis == 'NORMAL':
normal = face.normal
vect = normal * offset
elif self.extrusionAxis == 'Z':
vect = (0, 0, offset)
faces = bmesh.ops.extrude_discrete_faces(bm, faces=[face]) #return {'faces': [BMFace]}
verts = faces['faces'][0].verts
if self.elevSource == 'OBJ':
# Making flat roof (TODO add an user input parameter to setup this behaviour)
z = max([v.co.z for v in verts]) + offset #get max z coord
for v in verts:
v.co.z = z
else:
##result = bmesh.ops.extrude_face_region(bm, geom=[face]) #return dict {"geom":[BMVert, BMEdge, BMFace]}
##verts = [elem for elem in result['geom'] if isinstance(elem, bmesh.types.BMVert)] #geom type filter
bmesh.ops.translate(bm, verts=verts, vec=vect)
if self.separateObjects:
if self.fieldObjName:
try:
name = record[nameFieldIdx]
except Exception as e:
log.warning('Cannot extract name value for feature {} : {}'.format(i, e))
name = ''
# null values will return a bytes object containing a blank string of length equal to fields length definition
if isinstance(name, bytes):
name = ''
else:
name = str(name)
else:
name = shpName
#Calc bmesh bbox
_bbox = getBBOX.fromBmesh(bm)
#Calc bmesh geometry origin and translate coords according to it
#then object location will be set to initial bmesh origin
#its a work around to bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
ox, oy, oz = _bbox.center
oz = _bbox.zmin
bmesh.ops.translate(bm, verts=bm.verts, vec=(-ox, -oy, -oz))
#Create new mesh from bmesh
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
bm.clear()
#Validate new mesh
mesh.validate(verbose=False)
#Place obj
obj = bpy.data.objects.new(name, mesh)
layer.objects.link(obj)
context.view_layer.objects.active = obj
obj.select_set(True)
obj.location = (ox, oy, oz)
# bpy operators can be very cumbersome when scene contains lot of objects
# because it cause implicit scene updates calls
# so we must avoid using operators when created many objects with the 'separate objects' option)
##bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
#write attributes data
for i, field in enumerate(shp.fields):
fieldName, fieldType, fieldLength, fieldDecLength = field
if fieldName != 'DeletionFlag':
if fieldType in ('N', 'F'):
v = record[i-1]
if v is not None:
#cast to float to avoid overflow error when affecting custom property
obj[fieldName] = float(record[i-1])
else:
obj[fieldName] = record[i-1]
elif self.fieldExtrudeName:
#Join to final bmesh (use from_mesh method hack)
buff = bpy.data.meshes.new(".temp")
bm.to_mesh(buff)
finalBm.from_mesh(buff)
bpy.data.meshes.remove(buff)
bm.clear()
#Write back the whole mesh
if not self.separateObjects:
mesh = bpy.data.meshes.new(shpName)
if self.fieldExtrudeName:
bm.free()
bm = finalBm
if prefs.mergeDoubles:
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
bm.to_mesh(mesh)
#Finish
#mesh.update(calc_edges=True)
mesh.validate(verbose=False) #return true if the mesh has been corrected
obj = bpy.data.objects.new(shpName, mesh)
context.scene.collection.objects.link(obj)
context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
#free the bmesh
bm.free()
t = perf_clock() - t0
log.info('Build in %f seconds' % t)
#Adjust grid size
if prefs.adjust3Dview:
bbox.shift(-dx, -dy) #convert shapefile bbox in 3d view space
adjust3Dview(context, bbox)
return {'FINISHED'}
classes = [
IMPORTGIS_OT_shapefile_file_dialog,
IMPORTGIS_OT_shapefile_props_dialog,
IMPORTGIS_OT_shapefile
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: operators/lib/osm/nominatim.py
================================================
import os, ssl
import logging
log = logging.getLogger(__name__)
import json
from urllib.request import urlopen
from urllib.request import Request
from urllib.parse import quote_plus
TIMEOUT = 2
def nominatimQuery(
query,
base_url = 'https://nominatim.openstreetmap.org/',
referer = None,
user_agent = None,
format = 'json',
limit = 10):
url = base_url + 'search?'
url += 'format=' + format
url += '&q=' + quote_plus(query)
url += '&limit=' + str(limit)
log.debug('Nominatim search request : {}'.format(url))
req = Request(url)
if referer:
req.add_header('Referer', referer)
if user_agent:
req.add_header('User-Agent', user_agent)
response = urlopen(req, timeout=TIMEOUT)
r = json.loads(response.read().decode('utf-8'))
return r
================================================
FILE: operators/lib/osm/overpy/__about__.py
================================================
__all__ = [
"__author__",
"__copyright__",
"__email__",
"__license__",
"__summary__",
"__title__",
"__uri__",
"__version__",
]
__title__ = "overpy"
__summary__ = "Python Wrapper to access the OpenStreepMap Overpass API"
__uri__ = "https://github.com/DinoTools/python-overpy"
__version__ = "0.3.1"
__author__ = "PhiBo (DinoTools)"
__email__ = ""
__license__ = "MIT"
__copyright__ = "Copyright 2014-2015 %s" % __author__
================================================
FILE: operators/lib/osm/overpy/__init__.py
================================================
from collections import OrderedDict
from decimal import Decimal
import re
import sys
import os
from . import exception
from .__about__ import (
__author__, __copyright__, __email__, __license__, __summary__, __title__,
__uri__, __version__
)
import xml.etree.ElementTree as ET
import json
PY2 = sys.version_info[0] == 2
PY3 = sys.version_info[0] == 3
if PY2:
from StringIO import StringIO
from urllib2 import urlopen
from urllib2 import HTTPError
elif PY3:
from io import StringIO
from urllib.request import urlopen, Request
from urllib.error import HTTPError
TIMEOUT = 120
def is_valid_type(element, cls):
"""
Test if an element is of a given type.
:param Element() element: The element instance to test
:param Element cls: The element class to test
:return: False or True
:rtype: Boolean
"""
return isinstance(element, cls) and element.id is not None
class Overpass(object):
"""
Class to access the Overpass API
"""
default_read_chunk_size = 4096
def __init__(self, overpass_server="http://overpass-api.de/api/interpreter", read_chunk_size=None, referer=None, user_agent=None):
"""
:param read_chunk_size: Max size of each chunk read from the server response
:type read_chunk_size: Integer
"""
self.referer = referer
self.user_agent = user_agent
self.url = overpass_server
self._regex_extract_error_msg = re.compile(b"\(?P\")
self._regex_remove_tag = re.compile(b"<[^>]*?>")
if read_chunk_size is None:
read_chunk_size = self.default_read_chunk_size
self.read_chunk_size = read_chunk_size
def query(self, query):
"""
Query the Overpass API
:param String|Bytes query: The query string in Overpass QL
:return: The parsed result
:rtype: overpy.Result
"""
if not isinstance(query, bytes):
query = query.encode("utf-8")
req = Request(self.url)
if self.referer:
req.add_header('Referer', self.referer)
if self.user_agent:
req.add_header('User-Agent', self.user_agent)
try:
f = urlopen(req, query, timeout=TIMEOUT)
except HTTPError as e:
f = e
response = f.read(self.read_chunk_size)
while True:
data = f.read(self.read_chunk_size)
if len(data) == 0:
break
response = response + data
f.close()
if f.code == 200:
if PY2:
http_info = f.info()
content_type = http_info.getheader("content-type")
else:
content_type = f.getheader("Content-Type")
if content_type == "application/json":
return self.parse_json(response)
if content_type == "application/osm3s+xml":
return self.parse_xml(response)
raise exception.OverpassUnknownContentType(content_type)
if f.code == 400:
msgs = []
for msg in self._regex_extract_error_msg.finditer(response):
tmp = self._regex_remove_tag.sub(b"", msg.group("msg"))
try:
tmp = tmp.decode("utf-8")
except UnicodeDecodeError:
tmp = repr(tmp)
msgs.append(tmp)
raise exception.OverpassBadRequest(
query,
msgs=msgs
)
if f.code == 429:
raise exception.OverpassTooManyRequests
if f.code == 504:
raise exception.OverpassGatewayTimeout
raise exception.OverpassUnknownHTTPStatusCode(f.code)
def parse_json(self, data, encoding="utf-8"):
"""
Parse raw response from Overpass service.
:param data: Raw JSON Data
:type data: String or Bytes
:param encoding: Encoding to decode byte string
:type encoding: String
:return: Result object
:rtype: overpy.Result
"""
if isinstance(data, bytes):
data = data.decode(encoding)
data = json.loads(data, parse_float=Decimal)
return Result.from_json(data, api=self)
def parse_xml(self, data, encoding="utf-8"):
"""
:param data: Raw XML Data
:type data: String or Bytes
:param encoding: Encoding to decode byte string
:type encoding: String
:return: Result object
:rtype: overpy.Result
"""
try:
isFile = os.path.exists(data)
except:
isFile = False
if not isFile:
if isinstance(data, bytes):
data = data.decode(encoding)
if PY2 and not isinstance(data, str):
# Python 2.x: Convert unicode strings
data = data.encode(encoding)
return Result.from_xml(data, api=self)
class Result(object):
"""
Class to handle the result.
"""
def __init__(self, elements=None, api=None):
"""
:param List elements:
:param api:
:type api: overpy.Overpass
"""
if elements is None:
elements = []
self._nodes = OrderedDict((element.id, element) for element in elements if is_valid_type(element, Node))
self._ways = OrderedDict((element.id, element) for element in elements if is_valid_type(element, Way))
self._relations = OrderedDict((element.id, element)
for element in elements if is_valid_type(element, Relation))
self._class_collection_map = {Node: self._nodes, Way: self._ways, Relation: self._relations}
self.api = api
self._bounds = {}
def expand(self, other):
"""
Add all elements from an other result to the list of elements of this result object.
It is used by the auto resolve feature.
:param other: Expand the result with the elements from this result.
:type other: overpy.Result
:raises ValueError: If provided parameter is not instance of :class:`overpy.Result`
"""
if not isinstance(other, Result):
raise ValueError("Provided argument has to be instance of overpy:Result()")
other_collection_map = {Node: other.nodes, Way: other.ways, Relation: other.relations}
for element_type, own_collection in self._class_collection_map.items():
for element in other_collection_map[element_type]:
if is_valid_type(element, element_type) and element.id not in own_collection:
own_collection[element.id] = element
def append(self, element):
"""
Append a new element to the result.
:param element: The element to append
:type element: overpy.Element
"""
if is_valid_type(element, Element):
self._class_collection_map[element.__class__].setdefault(element.id, element)
def get_elements(self, filter_cls, elem_id=None):
"""
Get a list of elements from the result and filter the element type by a class.
:param filter_cls:
:param elem_id: ID of the object
:type elem_id: Integer
:return: List of available elements
:rtype: List
"""
result = []
if elem_id is not None:
try:
result = [self._class_collection_map[filter_cls][elem_id]]
except KeyError:
result = []
else:
for e in self._class_collection_map[filter_cls].values():
result.append(e)
return result
def get_ids(self, filter_cls):
"""
:param filter_cls:
:return:
"""
return list(self._class_collection_map[filter_cls].keys())
def get_node_ids(self):
return self.get_ids(filter_cls=Node)
def get_way_ids(self):
return self.get_ids(filter_cls=Way)
def get_relation_ids(self):
return self.get_ids(filter_cls=Relation)
@classmethod
def from_json(cls, data, api=None):
"""
Create a new instance and load data from json object.
:param data: JSON data returned by the Overpass API
:type data: Dict
:param api:
:type api: overpy.Overpass
:return: New instance of Result object
:rtype: overpy.Result
"""
result = cls(api=api)
for elem_cls in [Node, Way, Relation]:
for element in data.get("elements", []):
e_type = element.get("type")
if hasattr(e_type, "lower") and e_type.lower() == elem_cls._type_value:
result.append(elem_cls.from_json(element, result=result))
return result
@classmethod
def from_xml(cls, data, api=None, iterparse=False):
"""
Create a new instance and load data from xml object.
:param data: Root element
:type data: xml.etree.ElementTree.Element
:param api:
:type api: Overpass
:return: New instance of Result object
:rtype: Result
"""
result = cls(api=api)
try:
isFile = os.path.exists(data)
except:
isFile = False
if not iterparse:
#Method 1 : full parsing at once
if isFile:
with open(data, 'r', encoding='utf-8') as f:
data = f.read() #all file in memory
root = ET.fromstring(data)
for elem_cls in [Node, Way, Relation]:
for child in root:
if child.tag.lower() == elem_cls._type_value:
result.append(elem_cls.from_xml(child, result=result))
else:
#Method 2 : iter parsing (memory friendly)
#WARNING Issue #198
if not isFile:
data = StringIO(data)
root = ET.iterparse(data, events=("start", "end"))
elem_clss = {'node':Node, 'way':Way, 'relation':Relation}
for event, child in root:
if event == 'start':
if child.tag.lower() == 'bounds':
result._bounds = {k:float(v) for k, v in child.attrib.items()}
if child.tag.lower() in elem_clss:
elem_cls = elem_clss[child.tag.lower()]
result.append(elem_cls.from_xml(child, result=result))
elif event == 'end':
child.clear()
return result
def get_node(self, node_id, resolve_missing=False):
"""
Get a node by its ID.
:param node_id: The node ID
:type node_id: Integer
:param resolve_missing: Query the Overpass API if the node is missing in the result set.
:return: The node
:rtype: overpy.Node
:raises overpy.exception.DataIncomplete: At least one referenced node is not available in the result cache.
:raises overpy.exception.DataIncomplete: If resolve_missing is True and at least one node can't be resolved.
"""
nodes = self.get_nodes(node_id=node_id)
if len(nodes) == 0:
if not resolve_missing:
raise exception.DataIncomplete("Resolve missing nodes is disabled")
query = ("\n"
"[out:json];\n"
"node({node_id});\n"
"out body;\n"
)
query = query.format(
node_id=node_id
)
tmp_result = self.api.query(query)
self.expand(tmp_result)
nodes = self.get_nodes(node_id=node_id)
if len(nodes) == 0:
raise exception.DataIncomplete("Unable to resolve all nodes")
return nodes[0]
def get_nodes(self, node_id=None, **kwargs):
"""
Alias for get_elements() but filter the result by Node()
:param node_id: The Id of the node
:type node_id: Integer
:return: List of elements
"""
return self.get_elements(Node, elem_id=node_id, **kwargs)
def get_relation(self, rel_id, resolve_missing=False):
"""
Get a relation by its ID.
:param rel_id: The relation ID
:type rel_id: Integer
:param resolve_missing: Query the Overpass API if the relation is missing in the result set.
:return: The relation
:rtype: overpy.Relation
:raises overpy.exception.DataIncomplete: The requested relation is not available in the result cache.
:raises overpy.exception.DataIncomplete: If resolve_missing is True and the relation can't be resolved.
"""
relations = self.get_relations(rel_id=rel_id)
if len(relations) == 0:
if resolve_missing is False:
raise exception.DataIncomplete("Resolve missing relations is disabled")
query = ("\n"
"[out:json];\n"
"relation({relation_id});\n"
"out body;\n"
)
query = query.format(
relation_id=rel_id
)
tmp_result = self.api.query(query)
self.expand(tmp_result)
relations = self.get_relations(rel_id=rel_id)
if len(relations) == 0:
raise exception.DataIncomplete("Unable to resolve requested reference")
return relations[0]
def get_relations(self, rel_id=None, **kwargs):
"""
Alias for get_elements() but filter the result by Relation
:param rel_id: Id of the relation
:type rel_id: Integer
:return: List of elements
"""
return self.get_elements(Relation, elem_id=rel_id, **kwargs)
def get_way(self, way_id, resolve_missing=False):
"""
Get a way by its ID.
:param way_id: The way ID
:type way_id: Integer
:param resolve_missing: Query the Overpass API if the way is missing in the result set.
:return: The way
:rtype: overpy.Way
:raises overpy.exception.DataIncomplete: The requested way is not available in the result cache.
:raises overpy.exception.DataIncomplete: If resolve_missing is True and the way can't be resolved.
"""
ways = self.get_ways(way_id=way_id)
if len(ways) == 0:
if resolve_missing is False:
raise exception.DataIncomplete("Resolve missing way is disabled")
query = ("\n"
"[out:json];\n"
"way({way_id});\n"
"out body;\n"
)
query = query.format(
way_id=way_id
)
tmp_result = self.api.query(query)
self.expand(tmp_result)
ways = self.get_ways(way_id=way_id)
if len(ways) == 0:
raise exception.DataIncomplete("Unable to resolve requested way")
return ways[0]
def get_ways(self, way_id=None, **kwargs):
"""
Alias for get_elements() but filter the result by Way
:param way_id: The Id of the way
:type way_id: Integer
:return: List of elements
"""
return self.get_elements(Way, elem_id=way_id, **kwargs)
def get_bounds(self):
if not self._bounds:
lons, lats = zip(*[(e.lon, e.lat) for e in self._nodes.values()])
self._bounds['minlon'] = float(min(lons))
self._bounds['maxlon'] = float(max(lons))
self._bounds['minlat'] = float(min(lats))
self._bounds['maxlat'] = float(max(lats))
return self._bounds
node_ids = property(get_node_ids)
nodes = property(get_nodes)
relation_ids = property(get_relation_ids)
relations = property(get_relations)
way_ids = property(get_way_ids)
ways = property(get_ways)
bounds = property(get_bounds)
class Element(object):
"""
Base element
"""
def __init__(self, attributes=None, result=None, tags=None):
"""
:param attributes: Additional attributes
:type attributes: Dict
:param result: The result object this element belongs to
:param tags: List of tags
:type tags: Dict
"""
self._result = result
self.attributes = attributes
self.id = None
self.tags = tags
class Node(Element):
"""
Class to represent an element of type node
"""
_type_value = "node"
def __init__(self, node_id=None, lat=None, lon=None, **kwargs):
"""
:param lat: Latitude
:type lat: Decimal or Float
:param lon: Longitude
:type long: Decimal or Float
:param node_id: Id of the node element
:type node_id: Integer
:param kwargs: Additional arguments are passed directly to the parent class
"""
Element.__init__(self, **kwargs)
self.id = node_id
self.lat = lat
self.lon = lon
def __repr__(self):
return "".format(self.id, self.lat, self.lon)
@classmethod
def from_json(cls, data, result=None):
"""
Create new Node element from JSON data
:param data: Element data from JSON
:type data: Dict
:param result: The result this element belongs to
:type result: overpy.Result
:return: New instance of Node
:rtype: overpy.Node
:raises overpy.exception.ElementDataWrongType: If type value of the passed JSON data does not match.
"""
if data.get("type") != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=data.get("type")
)
tags = data.get("tags", {})
node_id = data.get("id")
lat = data.get("lat")
lon = data.get("lon")
attributes = {}
ignore = ["type", "id", "lat", "lon", "tags"]
for n, v in data.items():
if n in ignore:
continue
attributes[n] = v
return cls(node_id=node_id, lat=lat, lon=lon, tags=tags, attributes=attributes, result=result)
@classmethod
def from_xml(cls, child, result=None):
"""
Create new way element from XML data
:param child: XML node to be parsed
:type child: xml.etree.ElementTree.Element
:param result: The result this node belongs to
:type result: overpy.Result
:return: New Way oject
:rtype: overpy.Node
:raises overpy.exception.ElementDataWrongType: If name of the xml child node doesn't match
:raises ValueError: If a tag doesn't have a name
"""
if child.tag.lower() != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=child.tag.lower()
)
tags = {}
for sub_child in child:
if sub_child.tag.lower() == "tag":
name = sub_child.attrib.get("k")
if name is None:
raise ValueError("Tag without name/key.")
value = sub_child.attrib.get("v")
tags[name] = value
node_id = child.attrib.get("id")
if node_id is not None:
node_id = int(node_id)
lat = child.attrib.get("lat")
if lat is not None:
lat = Decimal(lat)
lon = child.attrib.get("lon")
if lon is not None:
lon = Decimal(lon)
attributes = {}
ignore = ["id", "lat", "lon"]
for n, v in child.attrib.items():
if n in ignore:
continue
attributes[n] = v
return cls(node_id=node_id, lat=lat, lon=lon, tags=tags, attributes=attributes, result=result)
class Way(Element):
"""
Class to represent an element of type way
"""
_type_value = "way"
def __init__(self, way_id=None, node_ids=None, **kwargs):
"""
:param node_ids: List of node IDs
:type node_ids: List or Tuple
:param way_id: Id of the way element
:type way_id: Integer
:param kwargs: Additional arguments are passed directly to the parent class
"""
Element.__init__(self, **kwargs)
#: The id of the way
self.id = way_id
#: List of Ids of the associated nodes
self._node_ids = node_ids
def __repr__(self):
return "".format(self.id, self._node_ids)
@property
def nodes(self):
"""
List of nodes associated with the way.
"""
return self.get_nodes()
def get_nodes(self, resolve_missing=False):
"""
Get the nodes defining the geometry of the way
:param resolve_missing: Try to resolve missing nodes.
:type resolve_missing: Boolean
:return: List of nodes
:rtype: List of overpy.Node
:raises overpy.exception.DataIncomplete: At least one referenced node is not available in the result cache.
:raises overpy.exception.DataIncomplete: If resolve_missing is True and at least one node can't be resolved.
"""
result = []
resolved = False
for node_id in self._node_ids:
try:
node = self._result.get_node(node_id)
except exception.DataIncomplete:
node = None
if node is not None:
result.append(node)
continue
if not resolve_missing:
raise exception.DataIncomplete("Resolve missing nodes is disabled")
# We tried to resolve the data but some nodes are still missing
if resolved:
raise exception.DataIncomplete("Unable to resolve all nodes")
query = ("\n"
"[out:json];\n"
"way({way_id});\n"
"node(w);\n"
"out body;\n"
)
query = query.format(
way_id=self.id
)
tmp_result = self._result.api.query(query)
self._result.expand(tmp_result)
resolved = True
try:
node = self._result.get_node(node_id)
except exception.DataIncomplete:
node = None
if node is None:
raise exception.DataIncomplete("Unable to resolve all nodes")
result.append(node)
return result
@classmethod
def from_json(cls, data, result=None):
"""
Create new Way element from JSON data
:param data: Element data from JSON
:type data: Dict
:param result: The result this element belongs to
:type result: overpy.Result
:return: New instance of Way
:rtype: overpy.Way
:raises overpy.exception.ElementDataWrongType: If type value of the passed JSON data does not match.
"""
if data.get("type") != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=data.get("type")
)
tags = data.get("tags", {})
way_id = data.get("id")
node_ids = data.get("nodes")
attributes = {}
ignore = ["id", "nodes", "tags", "type"]
for n, v in data.items():
if n in ignore:
continue
attributes[n] = v
return cls(way_id=way_id, attributes=attributes, node_ids=node_ids, tags=tags, result=result)
@classmethod
def from_xml(cls, child, result=None):
"""
Create new way element from XML data
:param child: XML node to be parsed
:type child: xml.etree.ElementTree.Element
:param result: The result this node belongs to
:type result: overpy.Result
:return: New Way oject
:rtype: overpy.Way
:raises overpy.exception.ElementDataWrongType: If name of the xml child node doesn't match
:raises ValueError: If the ref attribute of the xml node is not provided
:raises ValueError: If a tag doesn't have a name
"""
if child.tag.lower() != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=child.tag.lower()
)
tags = {}
node_ids = []
for sub_child in child:
if sub_child.tag.lower() == "tag":
name = sub_child.attrib.get("k")
if name is None:
raise ValueError("Tag without name/key.")
value = sub_child.attrib.get("v")
tags[name] = value
if sub_child.tag.lower() == "nd":
ref_id = sub_child.attrib.get("ref")
if ref_id is None:
raise ValueError("Unable to find required ref value.")
ref_id = int(ref_id)
node_ids.append(ref_id)
way_id = child.attrib.get("id")
if way_id is not None:
way_id = int(way_id)
attributes = {}
ignore = ["id"]
for n, v in child.attrib.items():
if n in ignore:
continue
attributes[n] = v
return cls(way_id=way_id, attributes=attributes, node_ids=node_ids, tags=tags, result=result)
class Relation(Element):
"""
Class to represent an element of type relation
"""
_type_value = "relation"
def __init__(self, rel_id=None, members=None, **kwargs):
"""
:param members:
:param rel_id: Id of the relation element
:type rel_id: Integer
:param kwargs:
:return:
"""
Element.__init__(self, **kwargs)
self.id = rel_id
self.members = members
def __repr__(self):
return "".format(self.id)
@classmethod
def from_json(cls, data, result=None):
"""
Create new Relation element from JSON data
:param data: Element data from JSON
:type data: Dict
:param result: The result this element belongs to
:type result: overpy.Result
:return: New instance of Relation
:rtype: overpy.Relation
:raises overpy.exception.ElementDataWrongType: If type value of the passed JSON data does not match.
"""
if data.get("type") != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=data.get("type")
)
tags = data.get("tags", {})
rel_id = data.get("id")
members = []
supported_members = [RelationNode, RelationWay, RelationRelation]
for member in data.get("members", []):
type_value = member.get("type")
for member_cls in supported_members:
if member_cls._type_value == type_value:
members.append(
member_cls.from_json(
member,
result=result
)
)
attributes = {}
ignore = ["id", "members", "tags", "type"]
for n, v in data.items():
if n in ignore:
continue
attributes[n] = v
return cls(rel_id=rel_id, attributes=attributes, members=members, tags=tags, result=result)
@classmethod
def from_xml(cls, child, result=None):
"""
Create new way element from XML data
:param child: XML node to be parsed
:type child: xml.etree.ElementTree.Element
:param result: The result this node belongs to
:type result: overpy.Result
:return: New Way oject
:rtype: overpy.Relation
:raises overpy.exception.ElementDataWrongType: If name of the xml child node doesn't match
:raises ValueError: If a tag doesn't have a name
"""
if child.tag.lower() != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=child.tag.lower()
)
tags = {}
members = []
supported_members = [RelationNode, RelationWay, RelationRelation]
for sub_child in child:
if sub_child.tag.lower() == "tag":
name = sub_child.attrib.get("k")
if name is None:
raise ValueError("Tag without name/key.")
value = sub_child.attrib.get("v")
tags[name] = value
if sub_child.tag.lower() == "member":
type_value = sub_child.attrib.get("type")
for member_cls in supported_members:
if member_cls._type_value == type_value:
members.append(
member_cls.from_xml(
sub_child,
result=result
)
)
rel_id = child.attrib.get("id")
if rel_id is not None:
rel_id = int(rel_id)
attributes = {}
ignore = ["id"]
for n, v in child.attrib.items():
if n in ignore:
continue
attributes[n] = v
return cls(rel_id=rel_id, attributes=attributes, members=members, tags=tags, result=result)
class RelationMember(object):
"""
Base class to represent a member of a relation.
"""
def __init__(self, ref=None, role=None, result=None):
"""
:param ref: Reference Id
:type ref: Integer
:param role: The role of the relation member
:type role: String
:param result:
"""
self.ref = ref
self._result = result
self.role = role
@classmethod
def from_json(cls, data, result=None):
"""
Create new RelationMember element from JSON data
:param child: Element data from JSON
:type child: Dict
:param result: The result this element belongs to
:type result: overpy.Result
:return: New instance of RelationMember
:rtype: overpy.RelationMember
:raises overpy.exception.ElementDataWrongType: If type value of the passed JSON data does not match.
"""
if data.get("type") != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=data.get("type")
)
ref = data.get("ref")
role = data.get("role")
return cls(ref=ref, role=role, result=result)
@classmethod
def from_xml(cls, child, result=None):
"""
Create new RelationMember from XML data
:param child: XML node to be parsed
:type child: xml.etree.ElementTree.Element
:param result: The result this element belongs to
:type result: overpy.Result
:return: New relation member oject
:rtype: overpy.RelationMember
:raises overpy.exception.ElementDataWrongType: If name of the xml child node doesn't match
"""
if child.attrib.get("type") != cls._type_value:
raise exception.ElementDataWrongType(
type_expected=cls._type_value,
type_provided=child.tag.lower()
)
ref = child.attrib.get("ref")
if ref is not None:
ref = int(ref)
role = child.attrib.get("role")
return cls(ref=ref, role=role, result=result)
class RelationNode(RelationMember):
_type_value = "node"
def resolve(self, resolve_missing=False):
return self._result.get_node(self.ref, resolve_missing=resolve_missing)
def __repr__(self):
return "".format(self.ref, self.role)
class RelationWay(RelationMember):
_type_value = "way"
def resolve(self, resolve_missing=False):
return self._result.get_way(self.ref, resolve_missing=resolve_missing)
def __repr__(self):
return "".format(self.ref, self.role)
class RelationRelation(RelationMember):
_type_value = "relation"
def resolve(self, resolve_missing=False):
return self._result.get_relation(self.ref, resolve_missing=resolve_missing)
def __repr__(self):
return "".format(self.ref, self.role)
================================================
FILE: operators/lib/osm/overpy/exception.py
================================================
class OverPyException(BaseException):
"""OverPy base exception"""
pass
class DataIncomplete(OverPyException):
"""
Raised if the requested data isn't available in the result.
Try to improve the query or to resolve the missing data.
"""
def __init__(self, *args, **kwargs):
OverPyException.__init__(
self,
"Data incomplete try to improve the query to resolve the missing data",
*args,
**kwargs
)
class ElementDataWrongType(OverPyException):
"""
Raised if the provided element does not match the expected type.
:param type_expected: The expected element type
:type type_expected: String
:param type_provided: The provided element type
:type type_provided: String|None
"""
def __init__(self, type_expected, type_provided=None):
self.type_expected = type_expected
self.type_provided = type_provided
def __str__(self):
return "Type expected '%s' but '%s' provided" % (
self.type_expected,
str(self.type_provided)
)
class OverpassBadRequest(OverPyException):
"""
Raised if the Overpass API service returns a syntax error.
:param query: The encoded query how it was send to the server
:type query: Bytes
:param msgs: List of error messages
:type msgs: List
"""
def __init__(self, query, msgs=None):
self.query = query
if msgs is None:
msgs = []
self.msgs = msgs
def __str__(self):
tmp_msgs = []
for tmp_msg in self.msgs:
if not isinstance(tmp_msg, str):
tmp_msg = str(tmp_msg)
tmp_msgs.append(tmp_msg)
return "\n".join(tmp_msgs)
class OverpassGatewayTimeout(OverPyException):
"""
Raised if load of the Overpass API service is too high and it can't handle the request.
"""
def __init__(self):
OverPyException.__init__(self, "Server load too high")
class OverpassTooManyRequests(OverPyException):
"""
Raised if the Overpass API service returns a 429 status code.
"""
def __init__(self):
OverPyException.__init__(self, "Too many requests")
class OverpassUnknownContentType(OverPyException):
"""
Raised if the reported content type isn't handled by OverPy.
:param content_type: The reported content type
:type content_type: None or String
"""
def __init__(self, content_type):
self.content_type = content_type
def __str__(self):
if self.content_type is None:
return "No content type returned"
return "Unknown content type: %s" % self.content_type
class OverpassUnknownHTTPStatusCode(OverPyException):
"""
Raised if the returned HTTP status code isn't handled by OverPy.
:param code: The HTTP status code
:type code: Integer
"""
def __init__(self, code):
self.code = code
def __str__(self):
return "Unknown/Unhandled status code: %d" % self.code
================================================
FILE: operators/lib/osm/overpy/helper.py
================================================
__author__ = 'mjob'
import overpy
def get_street(street, areacode, api=None):
"""
Retrieve streets in a given bounding area
:param overpy.Overpass api: First street of intersection
:param String street: Name of street
:param String areacode: The OSM id of the bounding area
:return: Parsed result
:raises overpy.exception.OverPyException: If something bad happens.
"""
if api is None:
api = overpy.Overpass()
query = """
area(%s)->.location;
(
way[highway][name="%s"](area.location);
- (
way[highway=service](area.location);
way[highway=track](area.location);
);
);
out body;
>;
out skel qt;
"""
data = api.query(query % (areacode, street))
return data
def get_intersection(street1, street2, areacode, api=None):
"""
Retrieve intersection of two streets in a given bounding area
:param overpy.Overpass api: First street of intersection
:param String street1: Name of first street of intersection
:param String street2: Name of second street of intersection
:param String areacode: The OSM id of the bounding area
:return: List of intersections
:raises overpy.exception.OverPyException: If something bad happens.
"""
if api is None:
api = overpy.Overpass()
query = """
area(%s)->.location;
(
way[highway][name="%s"](area.location); node(w)->.n1;
way[highway][name="%s"](area.location); node(w)->.n2;
);
node.n1.n2;
out meta;
"""
data = api.query(query % (areacode, street1, street2))
return data.get_nodes()
================================================
FILE: operators/mesh_delaunay_voronoi.py
================================================
# -*- coding:utf-8 -*-
#import DelaunayVoronoi
import bpy
import time
from .utils import computeVoronoiDiagram, computeDelaunayTriangulation
from ..core.utils import perf_clock
try:
from mathutils.geometry import delaunay_2d_cdt
except ImportError:
NATIVE = False
else:
NATIVE = True
import logging
log = logging.getLogger(__name__)
class Point:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
def unique(L):
"""Return a list of unhashable elements in s, but without duplicates.
[[1, 2], [2, 3], [1, 2]] >>> [[1, 2], [2, 3]]"""
#For unhashable objects, you can sort the sequence and then scan from the end of the list, deleting duplicates as you go
nDupli=0
nZcolinear=0
L.sort()#sort() brings the equal elements together; then duplicates are easy to weed out in a single pass.
last = L[-1]
for i in range(len(L)-2, -1, -1):
if last[:2] == L[i][:2]:#XY coordinates compararison
if last[2] == L[i][2]:#Z coordinates compararison
nDupli+=1#duplicates vertices
else:#Z colinear
nZcolinear+=1
del L[i]
else:
last = L[i]
return (nDupli, nZcolinear)#list data type is mutable, input list will automatically update and doesn't need to be returned
def checkEqual(lst):
return lst[1:] == lst[:-1]
class OBJECT_OT_tesselation_delaunay(bpy.types.Operator):
bl_idname = "tesselation.delaunay" #name used to refer to this operator (button)
bl_label = "Triangulation" #operator's label
bl_description = "Terrain points cloud Delaunay triangulation in 2.5D" #tooltip
bl_options = {"UNDO"}
def execute(self, context):
w = context.window
w.cursor_set('WAIT')
t0 = perf_clock()
#Get selected obj
objs = context.selected_objects
if len(objs) == 0 or len(objs) > 1:
self.report({'INFO'}, "Selection is empty or too much object selected")
return {'CANCELLED'}
obj = objs[0]
if obj.type != 'MESH':
self.report({'INFO'}, "Selection isn't a mesh")
return {'CANCELLED'}
#Get points coodinates
#bpy.ops.object.transform_apply(rotation=True, scale=True)
r = obj.rotation_euler
s = obj.scale
mesh = obj.data
if NATIVE:
'''
Use native Delaunay triangulation function : delaunay_2d_cdt(verts, edges, faces, output_type, epsilon) >> [verts, edges, faces, orig_verts, orig_edges, orig_faces]
The three returned orig lists give, for each of verts, edges, and faces, the list of input element indices corresponding to the positionally same output element. For edges, the orig indices start with the input edges and then continue with the edges implied by each of the faces (n of them for an n-gon).
Output type :
# 0 => triangles with convex hull.
# 1 => triangles inside constraints.
# 2 => the input constraints, intersected.
# 3 => like 2 but with extra edges to make valid BMesh faces.
'''
log.info("Triangulate {} points...".format(len(mesh.vertices)))
verts, edges, faces, overts, oedges, ofaces = delaunay_2d_cdt([v.co.to_2d() for v in mesh.vertices], [], [], 0, 0.1)
verts = [ (v.x, v.y, mesh.vertices[overts[i][0]].co.z) for i, v in enumerate(verts)] #retrieve z values
log.info("Getting {} triangles".format(len(faces)))
log.info("Create mesh...")
tinMesh = bpy.data.meshes.new("TIN")
tinMesh.from_pydata(verts, edges, faces)
tinMesh.update()
else:
vertsPts = [vertex.co for vertex in mesh.vertices]
#Remove duplicate
verts = [[vert.x, vert.y, vert.z] for vert in vertsPts]
nDupli, nZcolinear = unique(verts)
nVerts = len(verts)
log.info("{} duplicates points ignored".format(nDupli))
log.info("{} z colinear points excluded".format(nZcolinear))
if nVerts < 3:
self.report({'ERROR'}, "Not enough points")
return {'CANCELLED'}
#Check colinear
xValues = [pt[0] for pt in verts]
yValues = [pt[1] for pt in verts]
if checkEqual(xValues) or checkEqual(yValues):
self.report({'ERROR'}, "Points are colinear")
return {'CANCELLED'}
#Triangulate
log.info("Triangulate {} points...".format(nVerts))
vertsPts = [Point(vert[0], vert[1], vert[2]) for vert in verts]
faces = computeDelaunayTriangulation(vertsPts)
faces = [tuple(reversed(tri)) for tri in faces]#reverse point order --> if all triangles are specified anticlockwise then all faces up
log.info("Getting {} triangles".format(len(faces)))
#Create new mesh structure
log.info("Create mesh...")
tinMesh = bpy.data.meshes.new("TIN") #create a new mesh
tinMesh.from_pydata(verts, [], faces) #Fill the mesh with triangles
tinMesh.update(calc_edges=True) #Update mesh with new data
#Create an object with that mesh
tinObj = bpy.data.objects.new("TIN", tinMesh)
#Place object
tinObj.location = obj.location.copy()
tinObj.rotation_euler = r
tinObj.scale = s
#Update scene
context.scene.collection.objects.link(tinObj) #Link object to scene
context.view_layer.objects.active = tinObj
tinObj.select_set(True)
obj.select_set(False)
#Report
t = round(perf_clock() - t0, 2)
msg = "{} triangles created in {} seconds".format(len(faces), t)
self.report({'INFO'}, msg)
#log.info(msg) #duplicate log
return {'FINISHED'}
class OBJECT_OT_tesselation_voronoi(bpy.types.Operator):
bl_idname = "tesselation.voronoi" #name used to refer to this operator (button)
bl_label = "Diagram" #operator's label
bl_description = "Points cloud Voronoi diagram in 2D" #tooltip
bl_options = {"REGISTER","UNDO"}#need register to draw operator options/redo panel (F6)
#options
meshType: bpy.props.EnumProperty(
items = [("Edges", "Edges", ""), ("Faces", "Faces", "")],#(Key, Label, Description)
name = "Mesh type",
description = ""
)
"""
def draw(self, context):
"""
def execute(self, context):
w = context.window
w.cursor_set('WAIT')
t0 = perf_clock()
#Get selected obj
objs = context.selected_objects
if len(objs) == 0 or len(objs) > 1:
self.report({'INFO'}, "Selection is empty or too much object selected")
return {'CANCELLED'}
obj = objs[0]
if obj.type != 'MESH':
self.report({'INFO'}, "Selection isn't a mesh")
return {'CANCELLED'}
#Get points coodinates
r = obj.rotation_euler
s = obj.scale
mesh = obj.data
vertsPts = [vertex.co for vertex in mesh.vertices]
#Remove duplicate
verts = [[vert.x, vert.y, vert.z] for vert in vertsPts]
nDupli, nZcolinear = unique(verts)
nVerts = len(verts)
log.info("{} duplicates points ignored".format(nDupli))
log.info("{} z colinear points excluded".format(nZcolinear))
if nVerts < 3:
self.report({'ERROR'}, "Not enough points")
return {'CANCELLED'}
#Check colinear
xValues = [pt[0] for pt in verts]
yValues = [pt[1] for pt in verts]
if checkEqual(xValues) or checkEqual(yValues):
self.report({'ERROR'}, "Points are colinear")
return {'CANCELLED'}
#Create diagram
log.info("Tesselation... ({} points)".format(nVerts))
xbuff, ybuff = 5, 5 # %
zPosition = 0
vertsPts = [Point(vert[0], vert[1], vert[2]) for vert in verts]
if self.meshType == "Edges":
pts, edgesIdx = computeVoronoiDiagram(vertsPts, xbuff, ybuff, polygonsOutput=False, formatOutput=True)
else:
pts, polyIdx = computeVoronoiDiagram(vertsPts, xbuff, ybuff, polygonsOutput=True, formatOutput=True, closePoly=False)
#
pts = [[pt[0], pt[1], zPosition] for pt in pts]
#Create new mesh structure
log.info("Create mesh...")
voronoiDiagram = bpy.data.meshes.new("VoronoiDiagram") #create a new mesh
if self.meshType == "Edges":
voronoiDiagram.from_pydata(pts, edgesIdx, []) #Fill the mesh with triangles
else:
voronoiDiagram.from_pydata(pts, [], list(polyIdx.values())) #Fill the mesh with triangles
voronoiDiagram.update(calc_edges=True) #Update mesh with new data
#create an object with that mesh
voronoiObj = bpy.data.objects.new("VoronoiDiagram", voronoiDiagram)
#place object
voronoiObj.location = obj.location.copy()
voronoiObj.rotation_euler = r
voronoiObj.scale = s
#update scene
context.scene.collection.objects.link(voronoiObj) #Link object to scene
context.view_layer.objects.active = voronoiObj
voronoiObj.select_set(True)
obj.select_set(False)
#Report
t = round(perf_clock() - t0, 2)
if self.meshType == "Edges":
self.report({'INFO'}, "{} edges created in {} seconds".format(len(edgesIdx), t))
else:
self.report({'INFO'}, "{} polygons created in {} seconds".format(len(polyIdx), t))
return {'FINISHED'}
classes = [
OBJECT_OT_tesselation_delaunay,
OBJECT_OT_tesselation_voronoi
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: operators/mesh_earth_sphere.py
================================================
import bpy
from bpy.types import Operator
from bpy.props import IntProperty
from math import cos, sin, radians, sqrt
from mathutils import Vector
import logging
log = logging.getLogger(__name__)
def lonlat2xyz(R, lon, lat):
lon, lat = radians(lon), radians(lat)
x = R * cos(lat) * cos(lon)
y = R * cos(lat) * sin(lon)
z = R *sin(lat)
return Vector((x, y, z))
class OBJECT_OT_earth_sphere(Operator):
bl_idname = "earth.sphere"
bl_label = "lonlat to sphere"
bl_description = "Transform longitude/latitude data to a sphere like earth globe"
bl_options = {"REGISTER", "UNDO"}
radius: IntProperty(name = "Radius", default=100, description="Sphere radius", min=1)
def execute(self, context):
scn = bpy.context.scene
objs = bpy.context.selected_objects
if not objs:
self.report({'INFO'}, "No selected object")
return {'CANCELLED'}
for obj in objs:
if obj.type != 'MESH':
log.warning("Object {} is not a mesh".format(obj.name))
continue
w, h, thick = obj.dimensions
if w > 360:
log.warning("Longitude of object {} exceed 360°".format(obj.name))
continue
if h > 180:
log.warning("Latitude of object {} exceed 180°".format(obj.name))
continue
mesh = obj.data
m = obj.matrix_world
for vertex in mesh.vertices:
co = m @ vertex.co
lon, lat = co.x, co.y
vertex.co = m.inverted() @ lonlat2xyz(self.radius, lon, lat)
return {'FINISHED'}
EARTH_RADIUS = 6378137 #meters
def getZDelta(d):
'''delta value for adjusting z across earth curvature
http://webhelp.infovista.com/Planet/62/Subsystems/Raster/Content/help/analysis/viewshedanalysis.html'''
return sqrt(EARTH_RADIUS**2 + d**2) - EARTH_RADIUS
class OBJECT_OT_earth_curvature(Operator):
bl_idname = "earth.curvature"
bl_label = "Earth curvature correction"
bl_description = "Apply earth curvature correction for viewsheed analysis"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
scn = bpy.context.scene
obj = bpy.context.view_layer.objects.active
if not obj:
self.report({'INFO'}, "No active object")
return {'CANCELLED'}
if obj.type != 'MESH':
self.report({'INFO'}, "Selection isn't a mesh")
return {'CANCELLED'}
mesh = obj.data
viewpt = scn.cursor.location
for vertex in mesh.vertices:
d = (viewpt.xy - vertex.co.xy).length
vertex.co.z = vertex.co.z - getZDelta(d)
return {'FINISHED'}
classes = [
OBJECT_OT_earth_sphere,
OBJECT_OT_earth_curvature
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: operators/nodes_terrain_analysis_builder.py
================================================
# -*- coding:utf-8 -*-
import math
import bpy
from bpy.types import Panel, Operator
import logging
log = logging.getLogger(__name__)
from .utils import getBBOX
from ..core.maths.interpo import scale
class TERRAIN_ANALYSIS_OT_build_nodes(Operator):
'''Create material node thee to analysis height, slope and aspect'''
bl_idname = "analysis.nodes"
bl_description = "Create height, slope and aspect material nodes setup for Cycles"
bl_label = "Terrain analysis"
def execute(self, context):
scn = context.scene
scn.render.engine = 'CYCLES' #force Cycles render
obj = context.view_layer.objects.active
if obj is None:
self.report({'ERROR'}, "No active object")
return {'CANCELLED'}
#######################
#HEIGHT
#######################
# Create material
heightMatName = 'Height_' + obj.name
if heightMatName not in [m.name for m in bpy.data.materials]:
heightMat = bpy.data.materials.new(heightMatName)
else:#edit existing height material
heightMat = bpy.data.materials[heightMatName]
heightMat.use_nodes = True
heightMat.use_fake_user = True
node_tree = heightMat.node_tree
node_tree.nodes.clear()
# create geometry node (world coordinates)
geomNode = node_tree.nodes.new('ShaderNodeNewGeometry')
geomNode.location = (-600, 200)
# create separate xyz node
xyzSplitNode = node_tree.nodes.new('ShaderNodeSeparateXYZ')
xyzSplitNode.location = (-400, 200)
#
#Normalize node group
groupsTree = bpy.data.node_groups
'''
#make a purge (for testing)
for nodeTree in groupsTree:
name = nodeTree.name
try:
groupsTree.remove(nodeTree)
print(name+' has been deleted')
except:
print('cannot delete '+name)
'''
if 'Normalize' in [nodeTree.name for nodeTree in groupsTree]:
#groupsTree.remove(groupsTree['Normalize'])
scaleNodesGroupTree = groupsTree['Normalize']
scaleNodesGroupTree.nodes.clear()
scaleNodesGroupTree.inputs.clear()
scaleNodesGroupTree.outputs.clear()
else:
scaleNodesGroupTree = groupsTree.new('Normalize', 'ShaderNodeTree') # = bpy.types.node_tree
scaleNodesGroupName = scaleNodesGroupTree.name #Normalize.001 if normalize already exists
# group inputs
scaleInputsNode = scaleNodesGroupTree.nodes.new('NodeGroupInput')
scaleInputsNode.location = (-350,0)
scaleNodesGroupTree.inputs.new('NodeSocketFloat','val')
scaleNodesGroupTree.inputs.new('NodeSocketFloat','min')
scaleNodesGroupTree.inputs.new('NodeSocketFloat','max')
# group outputs
scaleOutputsNode = scaleNodesGroupTree.nodes.new('NodeGroupOutput')
scaleOutputsNode.location = (300,0)
scaleNodesGroupTree.outputs.new('NodeSocketFloat','val')
# create 3 math nodes in a group
subtractNode1 = scaleNodesGroupTree.nodes.new('ShaderNodeMath')
subtractNode1.operation = 'SUBTRACT'
subtractNode1.location = (-100,100)
subtractNode2 = scaleNodesGroupTree.nodes.new('ShaderNodeMath')
subtractNode2.operation = 'SUBTRACT'
subtractNode2.location = (-100,-100)
divideNode = scaleNodesGroupTree.nodes.new('ShaderNodeMath')
divideNode.operation = 'DIVIDE'
divideNode.location = (100,0)
# link nodes
scaleNodesGroupTree.links.new(scaleInputsNode.outputs['val'], subtractNode1.inputs[0])
scaleNodesGroupTree.links.new(scaleInputsNode.outputs['min'], subtractNode1.inputs[1])
scaleNodesGroupTree.links.new(scaleInputsNode.outputs['min'], subtractNode2.inputs[1])
scaleNodesGroupTree.links.new(scaleInputsNode.outputs['max'], subtractNode2.inputs[0])
scaleNodesGroupTree.links.new(subtractNode1.outputs[0], divideNode.inputs[0])
scaleNodesGroupTree.links.new(subtractNode2.outputs[0], divideNode.inputs[1])
scaleNodesGroupTree.links.new(divideNode.outputs[0], scaleOutputsNode.inputs['val'])
# finally add the group to main node_tree
scaleNodeGroup = node_tree.nodes.new('ShaderNodeGroup')
scaleNodeGroup.node_tree = bpy.data.node_groups[scaleNodesGroupName]#['Normalize']
scaleNodeGroup.location = (-200, 200)
#
# create z bbox value nodes
bbox = getBBOX.fromObj(obj)
zmin = node_tree.nodes.new('ShaderNodeValue')
zmin.label = 'zmin ' + obj.name
zmin.outputs[0].default_value = bbox['zmin']
zmin.location = (-400,0)
zmax = node_tree.nodes.new('ShaderNodeValue')
zmax.label = 'zmax ' + obj.name
zmax.outputs[0].default_value = bbox['zmax']
zmax.location = (-400,-100)
# create color ramp node
colorRampNode = node_tree.nodes.new('ShaderNodeValToRGB')
colorRampNode.location = (0, 200)
cr = colorRampNode.color_ramp
cr.elements[0].color = (0,1,0,1)
cr.elements[1].color = (1,0,0,1)
# Create BSDF diffuse node
diffuseNode = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
diffuseNode.location = (300, 200)
# Create output node
outputNode = node_tree.nodes.new('ShaderNodeOutputMaterial')
outputNode.location = (500, 200)
# Connect the nodes
node_tree.links.new(geomNode.outputs['Position'] , xyzSplitNode.inputs['Vector'])
node_tree.links.new(xyzSplitNode.outputs['Z'] , scaleNodeGroup.inputs['val'])
node_tree.links.new(zmin.outputs[0] , scaleNodeGroup.inputs['min'])
node_tree.links.new(zmax.outputs[0] , scaleNodeGroup.inputs['max'])
node_tree.links.new(scaleNodeGroup.outputs['val'] , colorRampNode.inputs['Fac'])
node_tree.links.new(colorRampNode.outputs['Color'] , diffuseNode.inputs['Color'])
node_tree.links.new(diffuseNode.outputs['BSDF'] , outputNode.inputs['Surface'])
# Deselect nodes
for node in node_tree.nodes:
node.select = False
#select color ramp
colorRampNode.select = True
node_tree.nodes.active = colorRampNode
#######################
#SLOPE
#######################
# Create material
slopeMatName = 'Slope'
if slopeMatName not in [m.name for m in bpy.data.materials]:
slopeMat = bpy.data.materials.new(slopeMatName)
else:
slopeMat = bpy.data.materials[slopeMatName]
slopeMat.use_nodes = True
slopeMat.use_fake_user = True
node_tree = slopeMat.node_tree
node_tree.nodes.clear()
'''
# create texture coordinate node (local coordinates)
texCoordNode = node_tree.nodes.new('ShaderNodeTexCoord')
texCoordNode.location = (-600, 0)
'''
# create geometry node (world coordinates)
geomNode = node_tree.nodes.new('ShaderNodeNewGeometry')
geomNode.location = (-600, 0)
# create separate xyz node
xyzSplitNode = node_tree.nodes.new('ShaderNodeSeparateXYZ')
xyzSplitNode.location = (-400, 0)
# create arc-cos node
arcCosNode = node_tree.nodes.new('ShaderNodeMath')
arcCosNode.operation = 'ARCCOSINE'
arcCosNode.location = (-200,0)
# create math node to convert radians to degrees
rad2dg = node_tree.nodes.new('ShaderNodeMath')
rad2dg.operation = 'MULTIPLY'
rad2dg.location = (0,0)
rad2dg.label = "Radians to degrees"
rad2dg.inputs[1].default_value = 180/math.pi
# create math node to normalize value
normalize = node_tree.nodes.new('ShaderNodeMath')
normalize.operation = 'DIVIDE'
normalize.location = (200,0)
normalize.label = "Normalize"
normalize.inputs[1].default_value = 100
# create color ramp node
colorRampNode = node_tree.nodes.new('ShaderNodeValToRGB')
colorRampNode.location = (400, 0)
cr = colorRampNode.color_ramp
cr.elements[0].color = (0,1,0,1)
cr.elements[1].position = 0.5
cr.elements[1].color = (1,0,0,1)
# Create BSDF diffuse node
diffuseNode = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
diffuseNode.location = (800, 0)
# Create output node
outputNode = node_tree.nodes.new('ShaderNodeOutputMaterial')
outputNode.location = (1000, 0)
# Connect the nodes
#node_tree.links.new(texCoordNode.outputs['Normal'] , xyzSplitNode.inputs['Vector'])
node_tree.links.new(geomNode.outputs['True Normal'] , xyzSplitNode.inputs['Vector'])
node_tree.links.new(xyzSplitNode.outputs['Z'] , arcCosNode.inputs[0])
node_tree.links.new(arcCosNode.outputs[0] , rad2dg.inputs[0])
node_tree.links.new(rad2dg.outputs[0] , normalize.inputs[0])
node_tree.links.new(normalize.outputs[0] , colorRampNode.inputs['Fac'])
node_tree.links.new(colorRampNode.outputs['Color'] , diffuseNode.inputs['Color'])
node_tree.links.new(diffuseNode.outputs['BSDF'] , outputNode.inputs['Surface'])
# Deselect nodes
for node in node_tree.nodes:
node.select = False
#select color ramp
colorRampNode.select = True
node_tree.nodes.active = colorRampNode
#######################
#ASPECT
#######################
# Create material
aspectMatName = 'Aspect'
if aspectMatName not in [m.name for m in bpy.data.materials]:
aspectMat = bpy.data.materials.new(aspectMatName)
else:
aspectMat = bpy.data.materials[aspectMatName]
aspectMat.use_nodes = True
aspectMat.use_fake_user = True
node_tree = aspectMat.node_tree
node_tree.nodes.clear()
# create geometry node (world coordinates)
geomNode = node_tree.nodes.new('ShaderNodeNewGeometry')
geomNode.location = (-600, 200)
# create separate xyz node
xyzSplitNode = node_tree.nodes.new('ShaderNodeSeparateXYZ')
xyzSplitNode.location = (-400, 200)
node_tree.links.new(geomNode.outputs['True Normal'] , xyzSplitNode.inputs['Vector'])
# create maths nodes to compute aspect angle = atan(x/y)
xyDiv = node_tree.nodes.new('ShaderNodeMath')
xyDiv.operation = 'DIVIDE'
xyDiv.location = (-200,0)
node_tree.links.new(xyzSplitNode.outputs['X'] , xyDiv.inputs[0])
node_tree.links.new(xyzSplitNode.outputs['Y'] , xyDiv.inputs[1])
atanNode = node_tree.nodes.new('ShaderNodeMath')
atanNode.operation = 'ARCTANGENT'
atanNode.label = 'Aspect radians'
atanNode.location = (0,0)
node_tree.links.new(xyDiv.outputs[0] , atanNode.inputs[0])
# create math node to convert radians to degrees
rad2dg = node_tree.nodes.new('ShaderNodeMath')
rad2dg.operation = 'MULTIPLY'
rad2dg.location = (200,0)
rad2dg.label = "Aspect degrees"
rad2dg.inputs[1].default_value = 180/math.pi
node_tree.links.new(atanNode.outputs[0] , rad2dg.inputs[0])
# maths nodes --> if y < 0 then aspect = aspect + 180
yNegMask = node_tree.nodes.new('ShaderNodeMath')
yNegMask.operation = 'LESS_THAN'
yNegMask.location = (0,200)
yNegMask.label = "y negative ?"
yNegMask.inputs[1].default_value = 0
node_tree.links.new(xyzSplitNode.outputs['Y'] , yNegMask.inputs[0])
yNegMutiply = node_tree.nodes.new('ShaderNodeMath')
yNegMutiply.operation = 'MULTIPLY'
yNegMutiply.location = (200,200)
node_tree.links.new(yNegMask.outputs[0] , yNegMutiply.inputs[0])
yNegMutiply.inputs[1].default_value = 180
yNegAdd = node_tree.nodes.new('ShaderNodeMath')
yNegAdd.operation = 'ADD'
yNegAdd.location = (400,200)
node_tree.links.new(yNegMutiply.outputs[0] , yNegAdd.inputs[0])
node_tree.links.new(rad2dg.outputs[0] , yNegAdd.inputs[1])
# if y > 0 & x < 0 then aspect = aspect + 360
xNegMask = node_tree.nodes.new('ShaderNodeMath')
xNegMask.operation = 'LESS_THAN'
xNegMask.location = (0,600)
xNegMask.label = "x negative ?"
xNegMask.inputs[1].default_value = 0
node_tree.links.new(xyzSplitNode.outputs['X'] , xNegMask.inputs[0])
yPosMask = node_tree.nodes.new('ShaderNodeMath')
yPosMask.operation = 'GREATER_THAN'
yPosMask.location = (0,400)
yPosMask.label = "y positive ?"
yPosMask.inputs[1].default_value = 0
node_tree.links.new(xyzSplitNode.outputs['Y'] , yPosMask.inputs[0])
mask = node_tree.nodes.new('ShaderNodeMath')
mask.operation = 'MULTIPLY'
mask.location = (200,500)
node_tree.links.new(xNegMask.outputs[0] , mask.inputs[0])
node_tree.links.new(yPosMask.outputs[0] , mask.inputs[1])
maskMultiply = node_tree.nodes.new('ShaderNodeMath')
maskMultiply.operation = 'MULTIPLY'
maskMultiply.location = (400,500)
node_tree.links.new(mask.outputs[0] , maskMultiply.inputs[0])
maskMultiply.inputs[1].default_value = 360
maskAdd = node_tree.nodes.new('ShaderNodeMath')
maskAdd.operation = 'ADD'
maskAdd.location = (600,300)
node_tree.links.new(maskMultiply.outputs[0] , maskAdd.inputs[0])
node_tree.links.new(yNegAdd.outputs[0] , maskAdd.inputs[1])
# create math node to normalize value
normalize = node_tree.nodes.new('ShaderNodeMath')
normalize.operation = 'DIVIDE'
normalize.location = (800,300)
normalize.label = "Normalize"
normalize.inputs[1].default_value = 360
node_tree.links.new(maskAdd.outputs[0] , normalize.inputs[0])
# create color ramp node
colorRampNode = node_tree.nodes.new('ShaderNodeValToRGB')
colorRampNode.location = (1000, 300)
cr = colorRampNode.color_ramp
stops = cr.elements
cr.elements[0].color = (1,0,0,1)#first stop = red
stops.remove(stops[1])#remove last stop
#orange, yellow, green, cyan, blue1, blue2, pink, red
colors = [(1,0.5,0,1), (1,1,0,1), (0,1,0,1), (0,1,1,1), (0,0.5,1,1), (0,0,1,1), (1,0,1,1), (1,0,0,1)]
for i, angle in enumerate([22.5, 67.5, 112.5, 157.5, 202.5, 247.5, 292.5, 337.5]):
pos = scale(angle, 0, 360, 0, 1)
stop = stops.new(pos)
stop.color = colors[i]
cr.interpolation = 'CONSTANT'
node_tree.links.new(normalize.outputs[0] , colorRampNode.inputs['Fac'])
# Create BSDF diffuse node
diffuseNode = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
diffuseNode.location = (1300, 300)
node_tree.links.new(colorRampNode.outputs['Color'] , diffuseNode.inputs['Color'])
# Flat color diffuse
diffuseFlat = node_tree.nodes.new('ShaderNodeBsdfDiffuse')
diffuseFlat.location = (1300, 0)
diffuseFlat.inputs[0].default_value = (1,1,1,1)
# flat test
flatMask = node_tree.nodes.new('ShaderNodeMath')
flatMask.operation = 'LESS_THAN'
flatMask.location = (800,-100)
flatMask.label = "is flat?"
flatMask.inputs[1].default_value = 0.999
node_tree.links.new(xyzSplitNode.outputs['Z'] , flatMask.inputs[0])
# Mix shader
mixNode = node_tree.nodes.new('ShaderNodeMixShader')
mixNode.location = (1500, 200)
node_tree.links.new(diffuseNode.outputs['BSDF'] , mixNode.inputs[2])
node_tree.links.new(diffuseFlat.outputs['BSDF'] , mixNode.inputs[1])
node_tree.links.new(flatMask.outputs[0] , mixNode.inputs['Fac'])
# Create output node
outputNode = node_tree.nodes.new('ShaderNodeOutputMaterial')
outputNode.location = (1700, 200)
node_tree.links.new(mixNode.outputs[0] , outputNode.inputs['Surface'])
# Deselect nodes
for node in node_tree.nodes:
node.select = False
#select color ramp
colorRampNode.select = True
node_tree.nodes.active = colorRampNode
#######################
# Add material to current object
'''
if heightMat.name not in [m.name for m in obj.data.materials]:
#add slot & move ui list index
else:#this name already exist, just move ui list index to select it
obj.active_material_index = obj.material_slots.find(heightMat.name)
'''
#add slot
obj.data.materials.append(heightMat)
#move ui list index
obj.active_material_index = len(obj.material_slots)-1
#Assignmaterial to faces
for faces in obj.data.polygons:
faces.material_index = obj.active_material_index
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(TERRAIN_ANALYSIS_OT_build_nodes)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(TERRAIN_ANALYSIS_OT_build_nodes))
unregister()
bpy.utils.register_class(TERRAIN_ANALYSIS_OT_build_nodes)
def unregister():
bpy.utils.unregister_class(TERRAIN_ANALYSIS_OT_build_nodes)
================================================
FILE: operators/nodes_terrain_analysis_reclassify.py
================================================
# -*- coding:utf-8 -*-
import os
import math
import bpy
from mathutils import Vector
from bpy.props import StringProperty, IntProperty, FloatProperty, BoolProperty, EnumProperty, CollectionProperty, FloatVectorProperty
from bpy.types import PropertyGroup, UIList, Panel, Operator
from bpy.app.handlers import persistent
import logging
log = logging.getLogger(__name__)
from .utils import getBBOX
from ..core.utils.gradient import Color, Stop, Gradient
from ..core.maths.interpo import scale
from ..core.maths.kmeans1D import kmeans1d, getBreaks
#from ..core.maths.jenks_caspall import jenksCaspall
#Folder containing SVG gradients
svgGradientFolder = os.path.dirname(os.path.realpath(__file__)) + os.sep + "rsrc" + os.sep + "gradients" + os.sep
#Global var
########################################
#These variables are the bounds values of the topographic property represented
#this is an altitude (zmin & zmax) in meters if material represents a height map
#or a slope in degrees if material represents a slope map
#bounds values are used to scale user input (altitude or slope) between 0 and 1
#then scale values are used to setup color ramp node
inMin = 0
inMax = 0
# other global for handler check
scn = None
obj = None
mat = None
node = None
#Set up a propertyGroup and populate a CollectionProperty
#########################################
class RECLASS_PG_color(PropertyGroup):
#Define update function for FloatProperty
def updStop(item, context):
#first arg is the container of the prop to update, here a customItem
if context.space_data is not None:
if context.space_data.type == 'NODE_EDITOR':
v = item.val
i = item.idx
node = context.active_node
cr = node.color_ramp
stops = cr.elements
newPos = scale(v, inMin, inMax, 0, 1)
#limit move between previous and next stops
if i+1 == len(stops):#this is the last stop
nextPos = 1
else:
nextPos = stops[i+1].position
if i == 0:#this is the first stop
prevPos = 0
else:
prevPos = stops[i-1].position
#
if newPos > nextPos:
stops[i].position = nextPos
item.val = scale(nextPos, 0, 1, inMin, inMax)
elif newPos < prevPos:
stops[i].position = prevPos
item.val = scale(prevPos, 0, 1, inMin, inMax)
else:
stops[i].position = newPos
#Define update function for color property
def updColor(item, context):
if context.space_data is not None:
if context.space_data.type == 'NODE_EDITOR':
color = item.color
i = item.idx
node = context.active_node
cr = node.color_ramp
stops = cr.elements
stops[i].color = color
#Properties in the group
idx: IntProperty()
val: FloatProperty(update=updStop)
color: FloatVectorProperty(subtype='COLOR', min=0, max=1, update=updColor, size=4)
#POPULATE
#Make function to populate collection
def populateList(colorRampNode):
setBounds()
if colorRampNode is not None:
if colorRampNode.bl_idname == 'ShaderNodeValToRGB':
bpy.context.scene.uiListCollec.clear()
cr = colorRampNode.color_ramp
for i, stop in enumerate(cr.elements):
v = scale(stop.position, 0, 1, inMin, inMax, )
item = bpy.context.scene.uiListCollec.add()
item.idx = i #warn. : assign idx before val because idx is used in property update function
item.val = v #warn. : causes exec. of property update function
item.color = stop.color
#Set others properties in scene and their update functions
#########################################
def updateAnalysisMode(scn, context):
if context.space_data.type == 'NODE_EDITOR':
#refresh
node = context.active_node
populateList(node)
def setBounds():
scn = bpy.context.scene
mode = scn.analysisMode
global inMin
global inMax
global obj
if mode == 'HEIGHT':
obj = bpy.context.view_layer.objects.active
bbox = getBBOX.fromObj(obj)
inMin = bbox['zmin']
inMax = bbox['zmax']
elif mode == 'SLOPE':
#slope of a terrain won't exceed vertical plane (90°)
#so for easiest calculation we consider slope between 0 and 100°
inMin = 0
inMax = 100
elif mode == 'ASPECT':
inMin = 0
inMax = 360
#Handler to refresh ui list when user
# > select another obj
# > change active material
# > move, delete or add stop on the node
# > select another color ramp node
#########################################
@persistent
def scene_update(scn):
'keep colorramp node and reclass panel in synch'
global obj
global mat
global node
#print(node.bl_idname)
activeObj = bpy.context.view_layer.objects.active
if activeObj is not None:
activeMat = activeObj.active_material
if activeMat is not None and activeMat.use_nodes:
activeNode = activeMat.node_tree.nodes.active
#check color ramp node edits
#>issue : activeMat.is_updated function is no more available in 2.8, use depsgraph instead
'''
depsgraph = bpy.context.evaluated_depsgraph_get() #cause recursion depth error
if depsgraph.id_type_updated('MATERIAL'):
populateList(activeNode)
'''
#check selected obj
if obj != activeObj:
obj = activeObj
populateList(activeNode)
#check active material
if mat != activeMat:
mat = activeMat
populateList(activeNode)
#check selected node
if node != activeNode:
node = activeNode
populateList(activeNode)
#Set up ui list
#########################################
class RECLASS_UL_stops(UIList):
def getAspectLabels(self):
vals = [round(item.val,2) for item in bpy.context.scene.uiListCollec]
if vals == [0, 45, 135, 225, 315]:
return ['N', 'E', 'S', 'W', 'N']
elif vals == [0, 22.5, 67.5, 112.5, 157.5, 202.5, 247.5, 292.5, 337.5]:
return ['N', 'N-E', 'E', 'S-E', 'S', 'S-W', 'W', 'N-W', 'N']
elif vals == [0, 30, 90, 150, 210, 270, 330]:
return ['N', 'N-E', 'S-E', 'S', 'S-W', 'N-W', 'N']
elif vals == [0, 60, 120, 180, 240, 300, 360]:
return ['N-E', 'E', 'S-E', 'S-W', 'W', 'N-W', 'N-E']
elif vals == [0, 90, 270]:
return ['N', 'S', 'N']
elif vals == [0, 180]:
return ['E', 'W']
else:
return False
def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index):
'''
called for each item of the collection visible in the list
must handle the three layout types 'DEFAULT', 'COMPACT' and 'GRID'
data is the object containing the collection (in our case, the scene)
item is the current drawn item of the collection (in our case a propertyGroup "customItem")
index is index of the current item in the collection (optional)
'''
scn = bpy.context.scene
mode = scn.analysisMode
self.use_filter_show = False
if self.layout_type in {'DEFAULT', 'COMPACT'}:
if mode == 'ASPECT':
aspectLabels = self.getAspectLabels()
split = layout.split(factor=0.2)
if aspectLabels:
split.label(text=aspectLabels[item.idx])
else:
split.label(text=str(item.idx+1))
split = split.split(factor=0.4)
split.prop(item, "color", text="")
split.prop(item, "val", text="")
else:
split = layout.split(factor=0.2)
#split.label(text=str(index))
split.label(text=str(item.idx+1))
split = split.split(factor=0.4)
split.prop(item, "color", text="")
split.prop(item, "val", text="")
elif self.layout_type in {'GRID'}:
layout.alignment = 'CENTER'
#Make a Panel
#########################################
class RECLASS_PT_reclassify(Panel):
"""Creates a panel in the properties of node editor"""
bl_label = "Reclassify"
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "Item"
def draw(self, context):
node = context.active_node
if node is not None:
if node.bl_idname == 'ShaderNodeValToRGB':
layout = self.layout
scn = context.scene
layout.prop(scn, "analysisMode")
row = layout.row()
#Draw ui list with template_list function
row.template_list("RECLASS_UL_stops", "", scn, "uiListCollec", scn, "uiListIndex", rows=10)
#Draw side tools
col = row.column(align=True)
col.operator("reclass.list_add", text="", icon='ADD')
col.operator("reclass.list_rm", text="", icon='REMOVE')
col.operator("reclass.list_clear", text="", icon='FILE_PARENT')
col.separator()
col.operator("reclass.list_refresh", text="", icon='FILE_REFRESH')
col.separator()
col.operator("reclass.switch_interpolation", text="", icon='SMOOTHCURVE')
col.operator("reclass.flip", text="", icon='ARROW_LEFTRIGHT')
col.operator("reclass.quick_gradient", text="", icon="COLOR")
col.operator("reclass.svg_gradient", text="", icon="GROUP_VCOL")
col.operator("reclass.export_svg", text="", icon="FORWARD")
col.separator()
col.operator("reclass.auto", text="", icon='FULLSCREEN_ENTER')
##col.separator()
##col.operator("reclass.settings", text="", icon='PREFERENCES')
#Draw infos
#row = layout.row()
#row.label(text=scn.collection.objects.active.name)
row = layout.row()
row.label(text="min = " + str(round(inMin,2)))
row.label(text="max = " + str(round(inMax,2)))
row = layout.row()
row.label(text="delta = " + str(round(inMax-inMin,2)))
#Make Operators to manage ui list
#########################################
class RECLASS_OT_switch_interpolation(Operator):
'''Switch color interpolation (continuous / discrete)'''
bl_idname = "reclass.switch_interpolation"
bl_label = "Switch color interpolation (continuous or discrete)"
def execute(self, context):
node = context.active_node
cr = node.color_ramp
cr.color_mode = 'RGB'
if cr.interpolation != 'CONSTANT':
cr.interpolation = 'CONSTANT'
else:
cr.interpolation = 'LINEAR'
return {'FINISHED'}
class RECLASS_OT_flip(Operator):
'''Flip color ramp'''
bl_idname = "reclass.flip"
bl_label = "Flip color ramp"
def execute(self, context):
node = context.active_node
cr = node.color_ramp
stops = cr.elements
#buid reversed color ramp
revStops = []
for i, stop in reversed(list(enumerate(stops))):
revPos = 1-stop.position
color = tuple(stop.color)
revStops.append((revPos, color))
#assign new position and color
for i, stop in enumerate(stops):
#stop.position = newStops[i][0]
stop.color = revStops[i][1]
#refresh
populateList(node)
return {'FINISHED'}
class RECLASS_OT_refresh(Operator):
"""Refresh list to match node setting"""
bl_idname = "reclass.list_refresh"
bl_label = "Populate list"
def execute(self, context):
node = context.active_node
populateList(node)
return {'FINISHED'}
class RECLASS_OT_clear(Operator):
"""Clear color ramp"""
bl_idname = "reclass.list_clear"
bl_label = "Clear list"
def execute(self, context):
#bpy.context.scene.uiListCollec.clear()
node = context.active_node
cr = node.color_ramp
stops = cr.elements
#remove stops from color ramp
for stop in reversed(stops):
if len(stops) > 1:#cannot remove last element
stops.remove(stop)
else:
stop.position = 0
#refresh ui list
populateList(node)
return{'FINISHED'}
class RECLASS_OT_add(Operator):
"""Add stop"""
bl_idname = "reclass.list_add"
bl_label = "Add stop"
def execute(self, context):
lst = bpy.context.scene.uiListCollec
currentIdx = bpy.context.scene.uiListIndex
if currentIdx > len(lst)-1:
#return {'CANCELLED'}
currentIdx = 0 #move ui selection to first idx
#lst.add()
#
node = context.active_node
cr = node.color_ramp
stops = cr.elements
if len(stops) >=32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
currentPos = stops[currentIdx].position
if currentIdx == len(stops)-1:#last stop
nextPos = 1.0
else:
nextPos = stops[currentIdx+1].position
newPos = currentPos + ((nextPos-currentPos)/2)
stops.new(newPos)
#Refresh list
populateList(node)
#Move selection in ui list
bpy.context.scene.uiListIndex = currentIdx+1
return {'FINISHED'}
class RECLASS_OT_rm(Operator):
"""Remove stop"""
bl_idname = "reclass.list_rm"
bl_label = "Remove Stop"
def execute(self, context):
currentIdx = bpy.context.scene.uiListIndex
lst = bpy.context.scene.uiListCollec
if currentIdx > len(lst)-1:
return {'CANCELLED'}
#lst.remove(currentIdx)
#
node = context.active_node
cr = node.color_ramp
stops = cr.elements
if len(stops) > 1: #cannot remove last element
stops.remove(stops[currentIdx])
#Refresh list
populateList(node)
#Move selecton in ui list if last element has been removed
if currentIdx > len(lst)-1:
bpy.context.scene.uiListIndex = currentIdx-1
return {'FINISHED'}
#Make Operators to auto reclassify
#########################################
def clearRamp(stops, startColor=(0,0,0,1), endColor=(1,1,1,1), startPos=0, endPos=1):
#clear actual color ramp
for stop in reversed(stops):
if len(stops) > 1:#cannot remove last element
stops.remove(stop)
else:#move last element to first position
first = stop
first.position = startPos
first.color = startColor
#Add last stop
last = stops.new(endPos)
last.color = endColor
return (first, last)
def getValues():
'''Return mesh data values (z, slope or az) for classification'''
scn = bpy.context.scene
obj = bpy.context.view_layer.objects.active
#make a temp mesh with modifiers apply
mesh = obj.to_mesh()
mesh.transform(obj.matrix_world)
#
mode = scn.analysisMode
if mode == 'HEIGHT':
values = [vertex.co.z for vertex in mesh.vertices]
elif mode == 'SLOPE':
z = Vector((0,0,1))
m = obj.matrix_world
values = [math.degrees(z.angle(m * face.normal)) for face in mesh.polygons]
elif mode == 'ASPECT':
y = Vector((0,1,0))
m = obj.matrix_world
#values = [math.degrees(y.angle(m * face.normal)) for face in mesh.polygons]
values = []
for face in mesh.polygons:
normal = face.normal.copy()
normal.z = 0 #project vector into XY plane
try:
a = math.degrees(y.angle(m * normal))
except ValueError:
pass#zero length vector as no angle
else:
#returned angle is between 0° (north) to 180° (south)
#we must correct it to get angle between 0 to 360°
if normal.x <0:
a = 360 - a
values.append(a)
values.sort()
#remove temp mesh
obj.to_mesh_clear()
return values
class RECLASS_OT_auto(Operator):
'''Auto reclass by equal interval or fixed classe number'''
bl_idname = "reclass.auto"
bl_label = "Reclass by equal interval or fixed classe number"
autoReclassMode: EnumProperty(
name="Mode",
description="Select auto reclassify mode",
items=[
('CLASSES_NB', 'Fixed classes number', "Define the expected number of classes"),
('EQUAL_STEP', 'Equal interval value', "Define step value between classes"),
('TARGET_STEP', 'Target interval value', "Define target step value that stops will match"),
('QUANTILE', 'Quantile', 'Assigns the same number of data values to each class.'),
('1DKMEANS', 'Natural breaks', 'kmeans clustering optimized for one dimensional data'),
('ASPECT', 'Aspect reclassification', "Value define the number of azimuth")]
)
color1: FloatVectorProperty(name="Start color", subtype='COLOR', min=0, max=1, size=4)
color2: FloatVectorProperty(name="End color", subtype='COLOR', min=0, max=1, size=4)
value: IntProperty(name="Value", default=4)
def invoke(self, context, event):
#Set color to actual ramp
node = context.active_node
cr = node.color_ramp
stops = cr.elements
self.color1 = stops[0].color
self.color2 = stops[len(stops)-1].color
#Show dialog with operator properties
wm = context.window_manager
return wm.invoke_props_dialog(self)
def execute(self, context):
node = context.active_node
cr = node.color_ramp
#switch to linear so new stops will have correctly evaluate color
cr.color_mode = 'RGB'
cr.interpolation = 'LINEAR'
stops = cr.elements
#Get colors
startColor = self.color1
endColor = self.color2
if self.autoReclassMode == 'TARGET_STEP':
interval = self.value
delta = inMax-inMin
nbClasses = math.ceil(delta/interval)
if nbClasses >= 32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
clearRamp(stops, startColor, endColor)
nextStop = inMin + interval - (inMin % interval)
while nextStop < inMax:
position = scale(nextStop, inMin, inMax, 0, 1)
stop = stops.new(position)
nextStop += interval
if self.autoReclassMode == 'EQUAL_STEP':
interval = self.value
delta = inMax-inMin
nbClasses = math.ceil(delta/interval)
if nbClasses >= 32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
clearRamp(stops, startColor, endColor)
val = inMin
for i in range(nbClasses-1):
val += interval
position = scale(val, inMin, inMax, 0, 1)
stop = stops.new(position)
if self.autoReclassMode == 'CLASSES_NB':
nbClasses = self.value
if nbClasses >= 32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
delta = inMax-inMin
if nbClasses >= delta:
self.report({'ERROR'}, "Too many classes")
return {'CANCELLED'}
clearRamp(stops, startColor, endColor)
interval = delta/nbClasses
val = inMin
for i in range(nbClasses-1):
val += interval
position = scale(val, inMin, inMax, 0, 1)
stop = stops.new(position)
if self.autoReclassMode == 'ASPECT':
bpy.context.scene.analysisMode = 'ASPECT'
delta = inMax-inMin #360°
interval = 360 / self.value
nbClasses = self.value #math.ceil(delta/interval)
if nbClasses >= 32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
first, last = clearRamp(stops, startColor, endColor)
offset = interval/2
intervalNorm = scale(interval, inMin, inMax, 0, 1)
offsetNorm = scale(offset, inMin, inMax, 0, 1)
#move actual last stop to before last position
last.position -= intervalNorm + offsetNorm
#add intermediates stops
val = 0
for i in range(nbClasses-2):
if i == 0:
val += offset
else:
val += interval
position = scale(val, inMin, inMax, 0, 1)
stop = stops.new(position)
#add last
stop = stops.new(1-offsetNorm)
stop.color = first.color
cr.interpolation = 'CONSTANT'
if self.autoReclassMode == 'QUANTILE':
nbClasses = self.value
values = getValues()
if nbClasses >= 32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
if nbClasses >= len(values):
self.report({'ERROR'}, "Too many classes")
return {'CANCELLED'}
clearRamp(stops, startColor, endColor)
n = len(values)
q = int(n/nbClasses) #number of value per quantile
cumulative_q = q
previousVal = scale(0, 0, 1, inMin, inMax)
for i in range(nbClasses-1):
val = values[cumulative_q]
if val != previousVal:
position = scale(val, inMin, inMax, 0, 1)
stop = stops.new(position)
previousVal = val
cumulative_q += q
if self.autoReclassMode == '1DKMEANS':
nbClasses = self.value
values = getValues()
if nbClasses >= 32:
self.report({'ERROR'}, "Ramp is limited to 32 colors")
return {'CANCELLED'}
if nbClasses >= len(values):
self.report({'ERROR'}, "Too many classes")
return {'CANCELLED'}
clearRamp(stops, startColor, endColor)
#compute clusters
#clusters = jenksCaspall(values, nbClasses, 4)
#for val in clusters.breaks:
clusters = kmeans1d(values, nbClasses)
for val in getBreaks(values, clusters):
position = scale(val, inMin, inMax, 0, 1)
stop = stops.new(position)
#refresh
populateList(node)
return {'FINISHED'}
#Operators to change color ramp
#########################################
colorSpaces = [('RGB', 'RGB', "RGB color space"),
('HSV', 'HSV', "HSV color space")]
interpoMethods = [('LINEAR', 'Linear', "Linear interpolation"),
('SPLINE', 'Spline', "Spline interpolation (Akima's method)"),
('DISCRETE', 'Discrete', "No interpolation (return previous color)"),
('NEAREST', 'Nearest', "No interpolation (return nearest color)") ]
#QUICK GRADIENT
class RECLASS_PG_color_preview(PropertyGroup):
color: FloatVectorProperty(subtype='COLOR', min=0, max=1, size=4)
class RECLASS_OT_quick_gradient(Operator):
'''Quick colors gradient edit'''
bl_idname = "reclass.quick_gradient"
bl_label = "Quick colors gradient edit"
colorSpace: EnumProperty(
name="Space",
description="Select interpolation color space",
items = colorSpaces)
method: EnumProperty(
name="Method",
description="Select interpolation method",
items = interpoMethods)
#special function to redraw an operator popup called through invoke_props_dialog
def check(self, context):
return True
def initPreview(self, context):
context.scene.colorRampPreview.clear()
node = context.active_node
cr = node.color_ramp
stops = cr.elements
if self.fitGradient:
minPos, maxPos = stops[0].position, stops[-1].position
delta = maxPos-minPos
else:
delta = 1
offset = delta/(self.nbColors-1)
position = 0
for i in range(self.nbColors):
item = bpy.context.scene.colorRampPreview.add()
item.color = cr.evaluate(position)
position += offset
return
def updatePreview(self, context):
#Add or remove colors from preview when change nb colors
colorItems = bpy.context.scene.colorRampPreview
nb = len(colorItems)
if nb == self.nbColors:
return
delta = abs(self.nbColors - nb)
for i in range(delta):
if self.nbColors > nb:
item = colorItems.add()
item.color = colorItems[-2].color
else:
colorItems.remove(nb-1)
fitGradient: BoolProperty(update=initPreview)
nbColors: IntProperty(
name="Number of colors",
description="Set the number of colors needed to define the quick quadient",
min=2, default=4, update=updatePreview)
def invoke(self, context, event):
#initialize colors preview
self.initPreview(context)
#Show dialog with operator properties
wm = context.window_manager
return wm.invoke_props_dialog(self, width=200, height=200)
def draw(self, context):
layout = self.layout
layout.prop(self, "colorSpace", text='Space')
layout.prop(self, "method", text='Method')
layout.prop(self, "fitGradient", text="Fit gradient to min/max positions")
layout.prop(self, "nbColors", text='Number of colors')
row = layout.row(align=True)
colorItems = context.scene.colorRampPreview
for i in range(self.nbColors):
colorItem = colorItems[i]
row.prop(colorItem, 'color', text='')
def execute(self, context):
#build gradient
colorList = context.scene.colorRampPreview
colorRamp = Gradient()
nbColors = len(colorList)
offset = 1/(nbColors-1)
position = 0
for i, item in enumerate(colorList):
color = Color(list(item.color), 'rgb')
colorRamp.addStop(round(position,4), color)
position += offset
#get color ramp node
node = context.active_node
cr = node.color_ramp
stops = cr.elements
#rescale
if self.fitGradient:
minPos, maxPos = stops[0].position, stops[-1].position
colorRamp.rescale(minPos, maxPos)
#update colors
for stop in stops:
stop.color = colorRamp.evaluate(stop.position, self.colorSpace, self.method).rgba
#
if self.colorSpace == 'HSV':
cr.color_mode = 'HSV'
else:
cr.color_mode = 'RGB'
#refresh
populateList(node)
return {'FINISHED'}
#SVG COLOR RAMP
def filesList(inFolder, ext):
if not os.path.exists(inFolder):
#os.makedirs(inFolder)
return []
lst = os.listdir(inFolder)
extLst=[elem for elem in lst if os.path.splitext(elem)[1]==ext]
extLst.sort()
return extLst
svgFiles = filesList(svgGradientFolder, '.svg')
colorPreviewRange = 20
class RECLASS_OT_svg_gradient(Operator):
'''Define colors gradient with presets'''
bl_idname = "reclass.svg_gradient"
bl_label = "Define colors gradient with presets"
def listSVG(self, context):
#Function used to update the gradient list used by the dropdown box.
svgs = [] #list containing tuples of each object
for index, svg in enumerate(svgFiles): #iterate over all objects
svgs.append((str(index), os.path.splitext(svg)[0], svgGradientFolder + svg)) #tuple (key, label, tooltip)
return svgs
def updatePreview(self, context):
if len(self.colorPresets) == 0:
return
#build gradient
enumIdx = int(self.colorPresets)
path = svgGradientFolder + svgFiles[enumIdx]
colorRamp = Gradient(path)
#make preview
nbColors = colorPreviewRange
interpoGradient = colorRamp.getRangeColor(nbColors, self.colorSpace, self.method)
for i, stop in enumerate(interpoGradient.stops):
item = bpy.context.scene.colorRampPreview[i]
item.color = stop.color.rgba
return
colorPresets: EnumProperty(
name="preset",
description="Select a color ramp preset",
items=listSVG,
update=updatePreview
)
colorSpace: EnumProperty(
name="Space",
description="Select interpolation color space",
items = colorSpaces,
update = updatePreview
)
method: EnumProperty(
name="Method",
description="Select interpolation method",
items = interpoMethods,
update = updatePreview
)
fitGradient: BoolProperty()
def invoke(self, context, event):
#clear collection
context.scene.colorRampPreview.clear()
#feed collection
for i in range(colorPreviewRange):
bpy.context.scene.colorRampPreview.add()
#update colors preview
self.updatePreview(context)
#Show dialog with operator properties
wm = context.window_manager
return wm.invoke_props_dialog(self, width=200, height=200)
def draw(self, context):#layout for invoke props modal dialog
#operator.draw() is different from panel.draw()
#because it's only called once (when the pop-up is created)
layout = self.layout
layout.prop(self, "colorSpace")
layout.prop(self, "method")
layout.prop(self, "colorPresets", text='')
row = layout.row(align=True)
row.enabled = False
for item in context.scene.colorRampPreview:
row.prop(item, 'color', text='')
row = layout.row()
row.prop(self, "fitGradient", text="Fit gradient to min/max positions")
def execute(self, context):
if len(self.colorPresets) == 0:
return {'CANCELLED'}
#build gradient
enumIdx = int(self.colorPresets)
path = svgGradientFolder + svgFiles[enumIdx]
colorRamp = Gradient(path)
#get color ramp node
node = context.active_node
cr = node.color_ramp
stops = cr.elements
#rescale
if self.fitGradient:
minPos, maxPos = stops[0].position, stops[-1].position
colorRamp.rescale(minPos, maxPos)
#update colors
for stop in stops:
stop.color = colorRamp.evaluate(stop.position, self.colorSpace, self.method).rgba
#
if self.colorSpace == 'HSV':
cr.color_mode = 'HSV'
else:
cr.color_mode = 'RGB'
#refresh
populateList(node)
return {'FINISHED'}
class RECLASS_OT_export_svg(Operator):
'''Export current gradient to SVG file'''
bl_idname = "reclass.export_svg"
bl_label = "Export current gradient to SVG file"
name: StringProperty(description="Put name of SVG file")
n: IntProperty(default=5, description="Select expected number of interpolate colors")
gradientType: EnumProperty(
name="Build method",
description="Select methods to build gradient",
items = [('SELF_STOPS', 'Use actual stops', ""),
('INTERPOLATE', 'Interpolate n colors', "")]
)
makeDiscrete: BoolProperty(name="Make discrete", description="Build discrete svg gradient")
colorSpace: EnumProperty(
name="Color space",
description="Select interpolation color space",
items = colorSpaces)
method: EnumProperty(
name="Interp. method",
description="Select interpolation method",
items = interpoMethods)
#special function to redraw an operator popup called through invoke_props_dialog
def check(self, context):
return True
def invoke(self, context, event):
#Show dialog with operator properties
wm = context.window_manager
return wm.invoke_props_dialog(self, width=250, height=200)
def draw(self, context):
layout = self.layout
layout.prop(self, "name", text='Name')
layout.prop(self, "gradientType")
layout.prop(self, "makeDiscrete")
if self.gradientType == "INTERPOLATE":
layout.separator()
layout.label(text='Interpolation options')
layout.prop(self, "colorSpace", text='Color space')
layout.prop(self, "method", text='Method')
layout.prop(self, "n", text="Number of colors")
def execute(self, context):
#Get node color ramp
node = context.active_node
cr = node.color_ramp
stops = cr.elements
#Build gradient class
colorRamp = Gradient()
for stop in stops:
color = Color(list(stop.color), 'rgba')
colorRamp.addStop(stop.position, color)
#write svg
svgPath = svgGradientFolder + self.name + '.svg'
if self.gradientType == "INTERPOLATE":
interpoGradient = colorRamp.getRangeColor(self.n, self.colorSpace, self.method)
interpoGradient.exportSVG(svgPath, self.makeDiscrete)
elif self.gradientType == "SELF_STOPS":
colorRamp.exportSVG(svgPath, self.makeDiscrete)
#update svg files list
global svgFiles
svgFiles = filesList(svgGradientFolder , '.svg')
return {'FINISHED'}
classes = [
RECLASS_PG_color,
RECLASS_PG_color_preview,
RECLASS_UL_stops,
RECLASS_PT_reclassify,
RECLASS_OT_switch_interpolation,
RECLASS_OT_flip,
RECLASS_OT_refresh,
RECLASS_OT_clear,
RECLASS_OT_add,
RECLASS_OT_rm,
RECLASS_OT_auto,
RECLASS_OT_quick_gradient,
RECLASS_OT_svg_gradient,
RECLASS_OT_export_svg
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
#Create uilist collections
bpy.types.Scene.uiListCollec = CollectionProperty(type=RECLASS_PG_color)
bpy.types.Scene.uiListIndex = IntProperty() #used to store the index of the selected item in the uilist
bpy.types.Scene.colorRampPreview = CollectionProperty(type=RECLASS_PG_color_preview)
#Add handlers
bpy.app.handlers.depsgraph_update_post.append(scene_update)
#
bpy.types.Scene.analysisMode = EnumProperty(
name = "Mode",
description = "Choose the type of analysis this material do",
items = [('HEIGHT', 'Height', "Height analysis"),
('SLOPE', 'Slope', "Slope analysis"),
('ASPECT', 'Aspect', "Aspect analysis")],
update = updateAnalysisMode
)
def unregister():
del bpy.types.Scene.analysisMode
#Clear uilist
del bpy.types.Scene.uiListCollec
del bpy.types.Scene.uiListIndex
del bpy.types.Scene.colorRampPreview
#Clear handlers
bpy.app.handlers.depsgraph_update_post.clear()
#Unregister
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: operators/object_drop.py
================================================
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# Original Drop to Ground addon code from Unnikrishnan(kodemax), Florian Meyer(testscreenings)
import logging
log = logging.getLogger(__name__)
import bpy
import bmesh
from .utils import DropToGround, getBBOX
from mathutils import Vector, Matrix
from bpy.types import Operator
from bpy.props import BoolProperty, EnumProperty
def get_align_matrix(location, normal):
up = Vector((0, 0, 1))
angle = normal.angle(up)
axis = up.cross(normal)
mat_rot = Matrix.Rotation(angle, 4, axis)
mat_loc = Matrix.Translation(location)
mat_align = mat_rot @ mat_loc
return mat_align
def get_lowest_world_co(ob, mat_parent=None):
bme = bmesh.new()
bme.from_mesh(ob.data)
mat_to_world = ob.matrix_world.copy()
if mat_parent:
mat_to_world = mat_parent @ mat_to_world
lowest = None
for v in bme.verts:
if not lowest:
lowest = v
if (mat_to_world @ v.co).z < (mat_to_world @ lowest.co).z:
lowest = v
lowest_co = mat_to_world @ lowest.co
bme.free()
return lowest_co
class OBJECT_OT_drop_to_ground(Operator):
bl_idname = "object.drop"
bl_label = "Drop to Ground"
bl_description = ("Drop selected objects on the Active object")
bl_options = {"REGISTER", "UNDO"} #register needed to draw operator options/redo panel
align: BoolProperty(
name="Align to ground",
description="Aligns the objects' rotation to the ground",
default=False)
axisAlign: EnumProperty(
items = [("N", "Normal", "Ground normal"), ("X", "X", "Ground X normal"), ("Y", "Y", "Ground Y normal"), ("Z", "Z", "Ground Z normal")],
name="Align axis",
description="")
useOrigin: BoolProperty(
name="Use Origins",
description="Drop to objects' origins\n"
"Use this option for dropping all types of Objects",
default=False)
#this method will disable the button if the conditions are not respected
@classmethod
def poll(cls, context):
act_obj = context.active_object
return (context.mode == 'OBJECT'
and len(context.selected_objects) >= 2
and act_obj
and act_obj.type in {'MESH', 'FONT', 'META', 'CURVE', 'SURFACE'}
)
def draw(self, context):
layout = self.layout
layout.prop(self, 'align')
if self.align:
layout.prop(self, 'axisAlign')
layout.prop(self, 'useOrigin')
def execute(self, context):
bpy.context.view_layer.update() #needed to make raycast function redoable (evaluate objects)
ground = context.active_object
obs = context.selected_objects
if ground in obs:
obs.remove(ground)
scn = context.scene
rayCaster = DropToGround(scn, ground)
for ob in obs:
if self.useOrigin:
minLoc = ob.location
else:
minLoc = get_lowest_world_co(ob)
#minLoc = min([(ob.matrix_world * v.co).z for v in ob.data.vertices])
#getBBOX.fromObj(ob).zmin #what xy coords ???
if not minLoc:
msg = "Object {} is of type {} works only with Use Center option " \
"checked".format(ob.name, ob.type)
log.info(msg)
x, y = minLoc.x, minLoc.y
hit = rayCaster.rayCast(x, y)
if not hit.hit:
log.info(ob.name + " did not hit the Active Object")
continue
# simple drop down
down = hit.loc - minLoc
ob.location += down
#ob.location = hit.loc
# drop with align to hit normal
if self.align:
vect = ob.location - hit.loc
# rotate object to align with face normal
normal = get_align_matrix(hit.loc, hit.normal)
rot = normal.to_euler()
if self.axisAlign == "X":
rot.y = 0
rot.z = 0
elif self.axisAlign == "Y":
rot.x = 0
rot.z = 0
elif self.axisAlign == "Z":
rot.x = 0
rot.y = 0
matrix = ob.matrix_world.copy().to_3x3()
matrix.rotate(rot)
matrix = matrix.to_4x4()
ob.matrix_world = matrix
# move_object to hit_location
ob.location = hit.loc
# move object above surface again
vect.rotate(rot)
ob.location += vect
return {'FINISHED'}
def register():
try:
bpy.utils.register_class(OBJECT_OT_drop_to_ground)
except ValueError as e:
log.warning('{} is already registered, now unregister and retry... '.format(OBJECT_OT_drop_to_ground))
unregister()
bpy.utils.register_class(OBJECT_OT_drop_to_ground)
def unregister():
bpy.utils.unregister_class(OBJECT_OT_drop_to_ground)
================================================
FILE: operators/utils/__init__.py
================================================
from .bgis_utils import placeObj, adjust3Dview, showTextures, addTexture, getBBOX, DropToGround, mouseTo3d, isTopView
from .georaster_utils import rasterExtentToMesh, geoRastUVmap, setDisplacer, bpyGeoRaster, exportAsMesh
from .delaunay_voronoi import computeVoronoiDiagram, computeDelaunayTriangulation
================================================
FILE: operators/utils/bgis_utils.py
================================================
import bpy
from mathutils import Vector, Matrix
from mathutils.bvhtree import BVHTree
from bpy_extras.view3d_utils import region_2d_to_location_3d, region_2d_to_vector_3d
from ...core import BBOX
def isTopView(context):
if context.area.type == 'VIEW_3D':
reg3d = context.region_data
else:
return False
return reg3d.view_perspective == 'ORTHO' and tuple(reg3d.view_matrix.to_euler()) == (0,0,0)
def mouseTo3d(context, x, y):
'''Convert event.mouse_region to world coordinates'''
if context.area.type != 'VIEW_3D':
raise Exception('Wrong context')
coords = (x, y)
reg = context.region
reg3d = context.region_data
vec = region_2d_to_vector_3d(reg, reg3d, coords)
loc = region_2d_to_location_3d(reg, reg3d, coords, vec) #WARNING, this function return indeterminate value when view3d clip distance is too large
return loc
class DropToGround():
'''A class to perform raycasting accross z axis'''
def __init__(self, scn, ground, method='OBJ'):
self.method = method # 'BVH' or 'OBJ'
self.scn = scn
self.ground = ground
self.bbox = getBBOX.fromObj(ground, applyTransform=True)
self.mw = self.ground.matrix_world
self.mwi = self.mw.inverted()
if self.method == 'BVH':
self.bvh = BVHTree.FromObject(self.ground, bpy.context.evaluated_depsgraph_get(), deform=True)
def rayCast(self, x, y):
#Hit vector
offset = 100
orgWldSpace = Vector((x, y, self.bbox.zmax + offset))
orgObjSpace = self.mwi @ orgWldSpace
direction = Vector((0,0,-1)) #down
#build ray cast hit namespace object
class RayCastHit(): pass
rcHit = RayCastHit()
#raycast
if self.method == 'OBJ':
rcHit.hit, rcHit.loc, rcHit.normal, rcHit.faceIdx = self.ground.ray_cast(orgObjSpace, direction)
elif self.method == 'BVH':
rcHit.loc, rcHit.normal, rcHit.faceIdx, rcHit.dst = self.bvh.ray_cast(orgObjSpace, direction)
if not rcHit.loc:
rcHit.hit = False
else:
rcHit.hit = True
#adjust values
if not rcHit.hit:
#return same original 2d point with z=0
rcHit.loc = Vector((orgWldSpace.x, orgWldSpace.y, 0)) #elseZero
else:
rcHit.hit = True
rcHit.loc = self.mw @ rcHit.loc
return rcHit
def placeObj(mesh, objName):
'''Build and add a new object from a given mesh'''
bpy.ops.object.select_all(action='DESELECT')
#create an object with that mesh
obj = bpy.data.objects.new(objName, mesh)
# Link object to scene
bpy.context.scene.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
#bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY')
return obj
def adjust3Dview(context, bbox, zoomToSelect=True):
'''adjust all 3d views clip distance to match the submited bbox'''
dst = round(max(bbox.dimensions))
k = 5 #increase factor
dst = dst * k
# set each 3d view
areas = context.screen.areas
for area in areas:
if area.type == 'VIEW_3D':
space = area.spaces.active
if dst < 100:
space.clip_start = 1
elif dst < 1000:
space.clip_start = 10
else:
space.clip_start = 100
#Adjust clip end distance if the new obj is largest than actual setting
if space.clip_end < dst:
if dst > 10000000:
dst = 10000000 #too large clip distance broke the 3d view
space.clip_end = dst
if zoomToSelect:
overrideContext = context.copy()
overrideContext['area'] = area
overrideContext['region'] = area.regions[-1]
if bpy.app.version[0] > 3:
with context.temp_override(**overrideContext):
bpy.ops.view3d.view_selected()
else:
bpy.ops.view3d.view_selected(overrideContext)
def showTextures(context):
'''Force view mode with textures'''
scn = context.scene
for area in context.screen.areas:
if area.type == 'VIEW_3D':
space = area.spaces.active
if space.shading.type == 'SOLID':
space.shading.color_type = 'TEXTURE'
def addTexture(mat, img, uvLay, name='texture'):
'''Set a new image texture to a given material and following a given uv map'''
engine = bpy.context.scene.render.engine
mat.use_nodes = True
node_tree = mat.node_tree
node_tree.nodes.clear()
# create uv map node
uvMapNode = node_tree.nodes.new('ShaderNodeUVMap')
uvMapNode.uv_map = uvLay.name
uvMapNode.location = (-800, 200)
# create image texture node
textureNode = node_tree.nodes.new('ShaderNodeTexImage')
textureNode.image = img
textureNode.extension = 'CLIP'
textureNode.show_texture = True
textureNode.location = (-400, 200)
# Create BSDF diffuse node
diffuseNode = node_tree.nodes.new('ShaderNodeBsdfPrincipled')#ShaderNodeBsdfDiffuse
diffuseNode.location = (0, 200)
# Create output node
outputNode = node_tree.nodes.new('ShaderNodeOutputMaterial')
outputNode.location = (400, 200)
# Connect the nodes
node_tree.links.new(uvMapNode.outputs['UV'] , textureNode.inputs['Vector'])
node_tree.links.new(textureNode.outputs['Color'] , diffuseNode.inputs['Base Color'])#diffuseNode.inputs['Color'])
node_tree.links.new(diffuseNode.outputs['BSDF'] , outputNode.inputs['Surface'])
class getBBOX():
'''Utilities to build BBOX object from various Blender context'''
@staticmethod
def fromObj(obj, applyTransform = True):
'''Create a 3D BBOX from Blender object'''
if applyTransform:
boundPts = [obj.matrix_world @ Vector(corner) for corner in obj.bound_box]
else:
boundPts = obj.bound_box
xmin = min([pt[0] for pt in boundPts])
xmax = max([pt[0] for pt in boundPts])
ymin = min([pt[1] for pt in boundPts])
ymax = max([pt[1] for pt in boundPts])
zmin = min([pt[2] for pt in boundPts])
zmax = max([pt[2] for pt in boundPts])
return BBOX(xmin=xmin, ymin=ymin, zmin=zmin, xmax=xmax, ymax=ymax, zmax=zmax)
@classmethod
def fromScn(cls, scn):
'''Create a 3D BBOX from Blender Scene
union of bounding box of all objects containing in the scene'''
#objs = scn.collection.objects
objs = [obj for obj in scn.collection.all_objects if obj.empty_display_type != 'IMAGE']
if len(objs) == 0:
scnBbox = BBOX(0,0,0,0,0,0)
else:
scnBbox = cls.fromObj(objs[0])
for obj in objs:
bbox = cls.fromObj(obj)
scnBbox += bbox
return scnBbox
@staticmethod
def fromBmesh(bm):
'''Create a 3D bounding box from a bmesh object'''
xmin = min([pt.co.x for pt in bm.verts])
xmax = max([pt.co.x for pt in bm.verts])
ymin = min([pt.co.y for pt in bm.verts])
ymax = max([pt.co.y for pt in bm.verts])
zmin = min([pt.co.z for pt in bm.verts])
zmax = max([pt.co.z for pt in bm.verts])
#
return BBOX(xmin=xmin, ymin=ymin, zmin=zmin, xmax=xmax, ymax=ymax, zmax=zmax)
@staticmethod
def fromTopView(context):
'''Create a 2D BBOX from Blender 3dview if the view is top left ortho else return None'''
scn = context.scene
area = context.area
if area.type != 'VIEW_3D':
return None
reg = context.region
reg3d = context.region_data
if reg3d.view_perspective != 'ORTHO' or tuple(reg3d.view_matrix.to_euler()) != (0,0,0):
print("View3d must be in top ortho")
return None
#
loc = mouseTo3d(context, area.width, area.height)
xmax, ymax = loc.x, loc.y
#
loc = mouseTo3d(context, 0, 0)
xmin, ymin = loc.x, loc.y
#
return BBOX(xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax)
================================================
FILE: operators/utils/delaunay_voronoi.py
================================================
# -*- coding: utf-8 -*-
#############################################################################
#
# Voronoi diagram calculator/ Delaunay triangulator
#
# - Voronoi Diagram Sweepline algorithm and C code by Steven Fortune, 1987, http://ect.bell-labs.com/who/sjf/
# - Python translation to file voronoi.py by Bill Simons, 2005, http://www.oxfish.com/
# - Additional changes for QGIS by Carson Farmer added November 2010
# - 2012 Ported to Python 3 and additional clip functions by domlysz at gmail.com
#
# Calculate Delaunay triangulation or the Voronoi polygons for a set of
# 2D input points.
#
# Derived from code bearing the following notice:
#
# The author of this software is Steven Fortune. Copyright (c) 1994 by AT&T
# Bell Laboratories.
# Permission to use, copy, modify, and distribute this software for any
# purpose without fee is hereby granted, provided that this entire notice
# is included in all copies of any software which is or includes a copy
# or modification of this software and in all copies of the supporting
# documentation for such software.
# THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED
# WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR AT&T MAKE ANY
# REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY
# OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE.
#
# Comments were incorporated from Shane O'Sullivan's translation of the
# original code into C++ (http://mapviewer.skynet.ie/voronoi.html)
#
# Steve Fortune's homepage: http://netlib.bell-labs.com/cm/cs/who/sjf/index.html
#
#
#
# For programmatic use two functions are available:
#
# computeVoronoiDiagram(points, xBuff, yBuff, polygonsOutput=False, formatOutput=False) :
# Takes :
# - a list of point objects (which must have x and y fields).
# - x and y buffer values which are the expansion percentages of the bounding box rectangle including all input points.
# Returns :
# - With default options :
# A list of 2-tuples, representing the two points of each Voronoi diagram edge.
# Each point contains 2-tuples which are the x,y coordinates of point.
# if formatOutput is True, returns :
# - a list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices.
# - and a list of 2-tuples (v1, v2) representing edges of the Voronoi diagram.
# v1 and v2 are the indices of the vertices at the end of the edge.
# - If polygonsOutput option is True, returns :
# A dictionary of polygons, keys are the indices of the input points,
# values contains n-tuples representing the n points of each Voronoi diagram polygon.
# Each point contains 2-tuples which are the x,y coordinates of point.
# if formatOutput is True, returns :
# - A list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices.
# - and a dictionary of input points indices. Values contains n-tuples representing the n points of each Voronoi diagram polygon.
# Each tuple contains the vertex indices of the polygon vertices.
#
# computeDelaunayTriangulation(points):
# Takes a list of point objects (which must have x and y fields).
# Returns a list of 3-tuples: the indices of the points that form a Delaunay triangle.
#
#############################################################################
import math
import sys
import getopt
TOLERANCE = 1e-9
BIG_FLOAT = 1e38
if sys.version > '3':
PY3 = True
else:
PY3 = False
#------------------------------------------------------------------
class Context(object):
def __init__(self):
self.doPrint = 0
self.debug = 0
self.extent=()#tuple (xmin, xmax, ymin, ymax)
self.triangulate = False
self.vertices = [] # list of vertex 2-tuples: (x,y)
self.lines = [] # equation of line 3-tuple (a b c), for the equation of the line a*x+b*y = c
self.edges = [] # edge 3-tuple: (line index, vertex 1 index, vertex 2 index) if either vertex index is -1, the edge extends to infinity
self.triangles = [] # 3-tuple of vertex indices
self.polygons = {} # a dict of site:[edges] pairs
########Clip functions########
def getClipEdges(self):
xmin, xmax, ymin, ymax = self.extent
clipEdges=[]
for edge in self.edges:
equation=self.lines[edge[0]]#line equation
if edge[1]!=-1 and edge[2]!=-1:#finite line
x1, y1=self.vertices[edge[1]][0], self.vertices[edge[1]][1]
x2, y2=self.vertices[edge[2]][0], self.vertices[edge[2]][1]
pt1, pt2 = (x1,y1), (x2,y2)
inExtentP1, inExtentP2 = self.inExtent(x1,y1), self.inExtent(x2,y2)
if inExtentP1 and inExtentP2:
clipEdges.append((pt1, pt2))
elif inExtentP1 and not inExtentP2:
pt2=self.clipLine(x1, y1, equation, leftDir=False)
clipEdges.append((pt1, pt2))
elif not inExtentP1 and inExtentP2:
pt1=self.clipLine(x2, y2, equation, leftDir=True)
clipEdges.append((pt1, pt2))
else:#infinite line
if edge[1]!=-1:
x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1]
leftDir=False
else:
x1, y1 = self.vertices[edge[2]][0], self.vertices[edge[2]][1]
leftDir=True
if self.inExtent(x1,y1):
pt1=(x1,y1)
pt2=self.clipLine(x1, y1, equation, leftDir)
clipEdges.append((pt1, pt2))
return clipEdges
def getClipPolygons(self, closePoly):
xmin, xmax, ymin, ymax = self.extent
poly={}
for inPtsIdx, edges in self.polygons.items():
clipEdges=[]
for edge in edges:
equation=self.lines[edge[0]]#line equation
if edge[1]!=-1 and edge[2]!=-1:#finite line
x1, y1=self.vertices[edge[1]][0], self.vertices[edge[1]][1]
x2, y2=self.vertices[edge[2]][0], self.vertices[edge[2]][1]
pt1, pt2 = (x1,y1), (x2,y2)
inExtentP1, inExtentP2 = self.inExtent(x1,y1), self.inExtent(x2,y2)
if inExtentP1 and inExtentP2:
clipEdges.append((pt1, pt2))
elif inExtentP1 and not inExtentP2:
pt2=self.clipLine(x1, y1, equation, leftDir=False)
clipEdges.append((pt1, pt2))
elif not inExtentP1 and inExtentP2:
pt1=self.clipLine(x2, y2, equation, leftDir=True)
clipEdges.append((pt1, pt2))
else:#infinite line
if edge[1]!=-1:
x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1]
leftDir=False
else:
x1, y1 = self.vertices[edge[2]][0], self.vertices[edge[2]][1]
leftDir=True
if self.inExtent(x1,y1):
pt1=(x1,y1)
pt2=self.clipLine(x1, y1, equation, leftDir)
clipEdges.append((pt1, pt2))
#create polygon definition from edges and check if polygon is completely closed
polyPts, complete=self.orderPts(clipEdges)
if not complete:
startPt=polyPts[0]
endPt=polyPts[-1]
if startPt[0]==endPt[0] or startPt[1]==endPt[1]: #if start & end points are collinear then they are along an extent border
polyPts.append(polyPts[0])#simple close
else:#close at extent corner
if (startPt[0]==xmin and endPt[1]==ymax) or (endPt[0]==xmin and startPt[1]==ymax): #upper left
polyPts.append((xmin, ymax))#corner point
polyPts.append(polyPts[0])#close polygon
if (startPt[0]==xmax and endPt[1]==ymax) or (endPt[0]==xmax and startPt[1]==ymax): #upper right
polyPts.append((xmax, ymax))
polyPts.append(polyPts[0])
if (startPt[0]==xmax and endPt[1]==ymin) or (endPt[0]==xmax and startPt[1]==ymin): #bottom right
polyPts.append((xmax, ymin))
polyPts.append(polyPts[0])
if (startPt[0]==xmin and endPt[1]==ymin) or (endPt[0]==xmin and startPt[1]==ymin): #bottom left
polyPts.append((xmin, ymin))
polyPts.append(polyPts[0])
if not closePoly:#unclose polygon
polyPts=polyPts[:-1]
poly[inPtsIdx]=polyPts
return poly
def clipLine(self, x1, y1, equation, leftDir):
xmin, xmax, ymin, ymax = self.extent
a,b,c=equation
if b==0:#vertical line
if leftDir:#left is bottom of vertical line
return (x1,ymax)
else:
return (x1,ymin)
elif a==0:#horizontal line
if leftDir:
return (xmin,y1)
else:
return (xmax,y1)
else:
y2_at_xmin=(c-a*xmin)/b
y2_at_xmax=(c-a*xmax)/b
x2_at_ymin=(c-b*ymin)/a
x2_at_ymax=(c-b*ymax)/a
intersectPts=[]
if ymin<=y2_at_xmin<=ymax:#valid intersect point
intersectPts.append((xmin, y2_at_xmin))
if ymin<=y2_at_xmax<=ymax:
intersectPts.append((xmax, y2_at_xmax))
if xmin<=x2_at_ymin<=xmax:
intersectPts.append((x2_at_ymin, ymin))
if xmin<=x2_at_ymax<=xmax:
intersectPts.append((x2_at_ymax, ymax))
#delete duplicate (happens if intersect point is at extent corner)
intersectPts=set(intersectPts)
#choose target intersect point
if leftDir:
pt=min(intersectPts)#smaller x value
else:
pt=max(intersectPts)
return pt
def inExtent(self, x, y):
xmin, xmax, ymin, ymax = self.extent
return x>=xmin and x<=xmax and y>=ymin and y<=ymax
def orderPts(self, edges):
poly=[]#returned polygon points list [pt1, pt2, pt3, pt4 ....]
pts=[]
#get points list
for edge in edges:
pts.extend([pt for pt in edge])
#try to get start & end point
try:
startPt, endPt = [pt for pt in pts if pts.count(pt)<2]#start and end point aren't duplicate
except:#all points are duplicate --> polygon is complete --> append some or other edge points
complete=True
firstIdx=0
poly.append(edges[0][0])
poly.append(edges[0][1])
else:#incomplete --> append the first edge points
complete=False
#search first edge
for i, edge in enumerate(edges):
if startPt in edge:#find
firstIdx=i
break
poly.append(edges[firstIdx][0])
poly.append(edges[firstIdx][1])
if poly[0]!=startPt: poly.reverse()
#append next points in list
del edges[firstIdx]
while edges:#all points will be treated when edges list will be empty
currentPt = poly[-1]#last item
for i, edge in enumerate(edges):
if currentPt==edge[0]:
poly.append(edge[1])
break
elif currentPt==edge[1]:
poly.append(edge[0])
break
del edges[i]
return poly, complete
def setClipBuffer(self, xpourcent, ypourcent):
xmin, xmax, ymin, ymax = self.extent
witdh=xmax-xmin
height=ymax-ymin
xmin=xmin-witdh*xpourcent/100
xmax=xmax+witdh*xpourcent/100
ymin=ymin-height*ypourcent/100
ymax=ymax+height*ypourcent/100
self.extent=xmin, xmax, ymin, ymax
########End clip functions########
def outSite(self,s):
if(self.debug):
print("site (%d) at %f %f" % (s.sitenum, s.x, s.y))
elif(self.triangulate):
pass
elif(self.doPrint):
print("s %f %f" % (s.x, s.y))
def outVertex(self,s):
self.vertices.append((s.x,s.y))
if(self.debug):
print("vertex(%d) at %f %f" % (s.sitenum, s.x, s.y))
elif(self.triangulate):
pass
elif(self.doPrint):
print("v %f %f" % (s.x,s.y))
def outTriple(self,s1,s2,s3):
self.triangles.append((s1.sitenum, s2.sitenum, s3.sitenum))
if(self.debug):
print("circle through left=%d right=%d bottom=%d" % (s1.sitenum, s2.sitenum, s3.sitenum))
elif(self.triangulate and self.doPrint):
print("%d %d %d" % (s1.sitenum, s2.sitenum, s3.sitenum))
def outBisector(self,edge):
self.lines.append((edge.a, edge.b, edge.c))
if(self.debug):
print("line(%d) %gx+%gy=%g, bisecting %d %d" % (edge.edgenum, edge.a, edge.b, edge.c, edge.reg[0].sitenum, edge.reg[1].sitenum))
elif(self.doPrint):
print("l %f %f %f" % (edge.a, edge.b, edge.c))
def outEdge(self,edge):
sitenumL = -1
if edge.ep[Edge.LE] is not None:
sitenumL = edge.ep[Edge.LE].sitenum
sitenumR = -1
if edge.ep[Edge.RE] is not None:
sitenumR = edge.ep[Edge.RE].sitenum
#polygons dict add by CF
if edge.reg[0].sitenum not in self.polygons:
self.polygons[edge.reg[0].sitenum] = []
if edge.reg[1].sitenum not in self.polygons:
self.polygons[edge.reg[1].sitenum] = []
self.polygons[edge.reg[0].sitenum].append((edge.edgenum,sitenumL,sitenumR))
self.polygons[edge.reg[1].sitenum].append((edge.edgenum,sitenumL,sitenumR))
self.edges.append((edge.edgenum,sitenumL,sitenumR))
if(not self.triangulate):
if(self.doPrint):
print("e %d" % edge.edgenum)
print(" %d " % sitenumL)
print("%d" % sitenumR)
#------------------------------------------------------------------
def voronoi(siteList,context):
context.extent=siteList.extent
edgeList = EdgeList(siteList.xmin,siteList.xmax,len(siteList))
priorityQ = PriorityQueue(siteList.ymin,siteList.ymax,len(siteList))
siteIter = siteList.iterator()
bottomsite = siteIter.next()
context.outSite(bottomsite)
newsite = siteIter.next()
minpt = Site(-BIG_FLOAT,-BIG_FLOAT)
while True:
if not priorityQ.isEmpty():
minpt = priorityQ.getMinPt()
if (newsite and (priorityQ.isEmpty() or newsite top.y:
bot,top = top,bot
pm = Edge.RE
# Create an Edge (or line) that is between the two Sites. This
# creates the formula of the line, and assigns a line number to it
edge = Edge.bisect(bot, top)
context.outBisector(edge)
# create a HE from the edge
bisector = Halfedge(edge, pm)
# insert the new bisector to the right of the left HE
# set one endpoint to the new edge to be the vector point 'v'
# If the site to the left of this bisector is higher than the right
# Site, then this endpoint is put in position 0; otherwise in pos 1
edgeList.insert(llbnd, bisector)
if edge.setEndpoint(Edge.RE - pm, v):
context.outEdge(edge)
# if left HE and the new bisector don't intersect, then delete
# the left HE, and reinsert it
p = llbnd.intersect(bisector)
if p is not None:
priorityQ.delete(llbnd);
priorityQ.insert(llbnd, p, bot.distance(p))
# if right HE and the new bisector don't intersect, then reinsert it
p = bisector.intersect(rrbnd)
if p is not None:
priorityQ.insert(bisector, p, bot.distance(p))
else:
break
he = edgeList.leftend.right
while he is not edgeList.rightend:
context.outEdge(he.edge)
he = he.right
Edge.EDGE_NUM = 0#CF
#------------------------------------------------------------------
def isEqual(a,b,relativeError=TOLERANCE):
# is nearly equal to within the allowed relative error
norm = max(abs(a),abs(b))
return (norm < relativeError) or (abs(a - b) < (relativeError * norm))
#------------------------------------------------------------------
class Site(object):
def __init__(self,x=0.0,y=0.0,sitenum=0):
self.x = x
self.y = y
self.sitenum = sitenum
def dump(self):
print("Site #%d (%g, %g)" % (self.sitenum,self.x,self.y))
def __lt__(self,other):
if self.y < other.y:
return True
elif self.y > other.y:
return False
elif self.x < other.x:
return True
elif self.x > other.x:
return False
else:
return False
def __eq__(self,other):
if self.y == other.y and self.x == other.x:
return True
def distance(self,other):
dx = self.x - other.x
dy = self.y - other.y
return math.sqrt(dx*dx + dy*dy)
#------------------------------------------------------------------
class Edge(object):
LE = 0#left end indice --> edge.ep[Edge.LE]
RE = 1#right end indice
EDGE_NUM = 0
DELETED = {} # marker value
def __init__(self):
self.a = 0.0#equation of the line a*x+b*y = c
self.b = 0.0
self.c = 0.0
self.ep = [None,None]#end point (2 tuples of site)
self.reg = [None,None]
self.edgenum = 0
def dump(self):
print("(#%d a=%g, b=%g, c=%g)" % (self.edgenum,self.a,self.b,self.c))
print("ep",self.ep)
print("reg",self.reg)
def setEndpoint(self, lrFlag, site):
self.ep[lrFlag] = site
if self.ep[Edge.RE - lrFlag] is None:
return False
return True
@staticmethod
def bisect(s1,s2):
newedge = Edge()
newedge.reg[0] = s1 # store the sites that this edge is bisecting
newedge.reg[1] = s2
# to begin with, there are no endpoints on the bisector - it goes to infinity
# ep[0] and ep[1] are None
# get the difference in x dist between the sites
dx = float(s2.x - s1.x)
dy = float(s2.y - s1.y)
adx = abs(dx) # make sure that the difference in positive
ady = abs(dy)
# get the slope of the line
newedge.c = float(s1.x * dx + s1.y * dy + (dx*dx + dy*dy)*0.5)
if adx > ady :
# set formula of line, with x fixed to 1
newedge.a = 1.0
newedge.b = dy/dx
newedge.c /= dx
else:
# set formula of line, with y fixed to 1
newedge.b = 1.0
newedge.a = dx/dy
newedge.c /= dy
newedge.edgenum = Edge.EDGE_NUM
Edge.EDGE_NUM += 1
return newedge
#------------------------------------------------------------------
class Halfedge(object):
def __init__(self,edge=None,pm=Edge.LE):
self.left = None # left Halfedge in the edge list
self.right = None # right Halfedge in the edge list
self.qnext = None # priority queue linked list pointer
self.edge = edge # edge list Edge
self.pm = pm
self.vertex = None # Site()
self.ystar = BIG_FLOAT
def dump(self):
print("Halfedge--------------------------")
print("left: ", self.left)
print("right: ", self.right)
print("edge: ", self.edge)
print("pm: ", self.pm)
print("vertex: "),
if self.vertex: self.vertex.dump()
else: print("None")
print("ystar: ", self.ystar)
def __lt__(self,other):
if self.ystar < other.ystar:
return True
elif self.ystar > other.ystar:
return False
elif self.vertex.x < other.vertex.x:
return True
elif self.vertex.x > other.vertex.x:
return False
else:
return False
def __eq__(self,other):
if self.ystar == other.ystar and self.vertex.x == other.vertex.x:
return True
def leftreg(self,default):
if not self.edge:
return default
elif self.pm == Edge.LE:
return self.edge.reg[Edge.LE]
else:
return self.edge.reg[Edge.RE]
def rightreg(self,default):
if not self.edge:
return default
elif self.pm == Edge.LE:
return self.edge.reg[Edge.RE]
else:
return self.edge.reg[Edge.LE]
# returns True if p is to right of halfedge self
def isPointRightOf(self,pt):
e = self.edge
topsite = e.reg[1]
right_of_site = pt.x > topsite.x
if(right_of_site and self.pm == Edge.LE):
return True
if(not right_of_site and self.pm == Edge.RE):
return False
if(e.a == 1.0):
dyp = pt.y - topsite.y
dxp = pt.x - topsite.x
fast = 0;
if ((not right_of_site and e.b < 0.0) or (right_of_site and e.b >= 0.0)):
above = dyp >= e.b * dxp
fast = above
else:
above = pt.x + pt.y * e.b > e.c
if(e.b < 0.0):
above = not above
if (not above):
fast = 1
if (not fast):
dxs = topsite.x - (e.reg[0]).x
above = e.b * (dxp*dxp - dyp*dyp) < dxs*dyp*(1.0+2.0*dxp/dxs + e.b*e.b)
if(e.b < 0.0):
above = not above
else: # e.b == 1.0
yl = e.c - e.a * pt.x
t1 = pt.y - yl
t2 = pt.x - topsite.x
t3 = yl - topsite.y
above = t1*t1 > t2*t2 + t3*t3
if(self.pm==Edge.LE):
return above
else:
return not above
#--------------------------
# create a new site where the Halfedges el1 and el2 intersect
def intersect(self,other):
e1 = self.edge
e2 = other.edge
if (e1 is None) or (e2 is None):
return None
# if the two edges bisect the same parent return None
if e1.reg[1] is e2.reg[1]:
return None
d = e1.a * e2.b - e1.b * e2.a
if isEqual(d,0.0):
return None
xint = (e1.c*e2.b - e2.c*e1.b) / d
yint = (e2.c*e1.a - e1.c*e2.a) / d
if e1.reg[1]< e2.reg[1]:
he = self
e = e1
else:
he = other
e = e2
rightOfSite = xint >= e.reg[1].x
if((rightOfSite and he.pm == Edge.LE) or
(not rightOfSite and he.pm == Edge.RE)):
return None
# create a new site at the point of intersection - this is a new
# vector event waiting to happen
return Site(xint,yint)
#------------------------------------------------------------------
class EdgeList(object):
def __init__(self,xmin,xmax,nsites):
if xmin > xmax: xmin,xmax = xmax,xmin
self.hashsize = int(2*math.sqrt(nsites+4))
self.xmin = xmin
self.deltax = float(xmax - xmin)
self.hash = [None]*self.hashsize
self.leftend = Halfedge()
self.rightend = Halfedge()
self.leftend.right = self.rightend
self.rightend.left = self.leftend
self.hash[0] = self.leftend
self.hash[-1] = self.rightend
def insert(self,left,he):
he.left = left
he.right = left.right
left.right.left = he
left.right = he
def delete(self,he):
he.left.right = he.right
he.right.left = he.left
he.edge = Edge.DELETED
# Get entry from hash table, pruning any deleted nodes
def gethash(self,b):
if(b < 0 or b >= self.hashsize):
return None
he = self.hash[b]
if he is None or he.edge is not Edge.DELETED:
return he
# Hash table points to deleted half edge. Patch as necessary.
self.hash[b] = None
return None
def leftbnd(self,pt):
# Use hash table to get close to desired halfedge
bucket = int(((pt.x - self.xmin)/self.deltax * self.hashsize))
if(bucket < 0):
bucket =0;
if(bucket >=self.hashsize):
bucket = self.hashsize-1
he = self.gethash(bucket)
if(he is None):
i = 1
while True:
he = self.gethash(bucket-i)
if (he is not None): break;
he = self.gethash(bucket+i)
if (he is not None): break;
i += 1
# Now search linear list of halfedges for the corect one
if (he is self.leftend) or (he is not self.rightend and he.isPointRightOf(pt)):
he = he.right
while he is not self.rightend and he.isPointRightOf(pt):
he = he.right
he = he.left;
else:
he = he.left
while (he is not self.leftend and not he.isPointRightOf(pt)):
he = he.left
# Update hash table and reference counts
if(bucket > 0 and bucket < self.hashsize-1):
self.hash[bucket] = he
return he
#------------------------------------------------------------------
class PriorityQueue(object):
def __init__(self,ymin,ymax,nsites):
self.ymin = ymin
self.deltay = ymax - ymin
self.hashsize = int(4 * math.sqrt(nsites))
self.count = 0
self.minidx = 0
self.hash = []
for i in range(self.hashsize):
self.hash.append(Halfedge())
def __len__(self):
return self.count
def isEmpty(self):
return self.count == 0
def insert(self,he,site,offset):
he.vertex = site
he.ystar = site.y + offset
last = self.hash[self.getBucket(he)]
next = last.qnext
while((next is not None) and he > next):
last = next
next = last.qnext
he.qnext = last.qnext
last.qnext = he
self.count += 1
def delete(self,he):
if (he.vertex is not None):
last = self.hash[self.getBucket(he)]
while last.qnext is not he:
last = last.qnext
last.qnext = he.qnext
self.count -= 1
he.vertex = None
def getBucket(self,he):
bucket = int(((he.ystar - self.ymin) / self.deltay) * self.hashsize)
if bucket < 0: bucket = 0
if bucket >= self.hashsize: bucket = self.hashsize-1
if bucket < self.minidx: self.minidx = bucket
return bucket
def getMinPt(self):
while(self.hash[self.minidx].qnext is None):
self.minidx += 1
he = self.hash[self.minidx].qnext
x = he.vertex.x
y = he.ystar
return Site(x,y)
def popMinHalfedge(self):
curr = self.hash[self.minidx].qnext
self.hash[self.minidx].qnext = curr.qnext
self.count -= 1
return curr
#------------------------------------------------------------------
class SiteList(object):
def __init__(self,pointList):
self.__sites = []
self.__sitenum = 0
self.__xmin = min([pt.x for pt in pointList])
self.__ymin = min([pt.y for pt in pointList])
self.__xmax = max([pt.x for pt in pointList])
self.__ymax = max([pt.y for pt in pointList])
self.__extent=(self.__xmin, self.__xmax, self.__ymin, self.__ymax)
for i,pt in enumerate(pointList):
self.__sites.append(Site(pt.x,pt.y,i))
self.__sites.sort()
def setSiteNumber(self,site):
site.sitenum = self.__sitenum
self.__sitenum += 1
class Iterator(object):
def __init__(this,lst): this.generator = (s for s in lst)
def __iter__(this): return this
def next(this):
try:
if PY3:
return this.generator.__next__()
else:
return this.generator.next()
except StopIteration:
return None
def iterator(self):
return SiteList.Iterator(self.__sites)
def __iter__(self):
return SiteList.Iterator(self.__sites)
def __len__(self):
return len(self.__sites)
def _getxmin(self): return self.__xmin
def _getymin(self): return self.__ymin
def _getxmax(self): return self.__xmax
def _getymax(self): return self.__ymax
def _getextent(self): return self.__extent
xmin = property(_getxmin)
ymin = property(_getymin)
xmax = property(_getxmax)
ymax = property(_getymax)
extent = property(_getextent)
#------------------------------------------------------------------
def computeVoronoiDiagram(points, xBuff=0, yBuff=0, polygonsOutput=False, formatOutput=False, closePoly=True):
"""
Takes :
- a list of point objects (which must have x and y fields).
- x and y buffer values which are the expansion percentages of the bounding box rectangle including all input points.
Returns :
- With default options :
A list of 2-tuples, representing the two points of each Voronoi diagram edge.
Each point contains 2-tuples which are the x,y coordinates of point.
if formatOutput is True, returns :
- a list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices.
- and a list of 2-tuples (v1, v2) representing edges of the Voronoi diagram.
v1 and v2 are the indices of the vertices at the end of the edge.
- If polygonsOutput option is True, returns :
A dictionary of polygons, keys are the indices of the input points,
values contains n-tuples representing the n points of each Voronoi diagram polygon.
Each point contains 2-tuples which are the x,y coordinates of point.
if formatOutput is True, returns :
- A list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices.
- and a dictionary of input points indices. Values contains n-tuples representing the n points of each Voronoi diagram polygon.
Each tuple contains the vertex indices of the polygon vertices.
- if closePoly is True then, in the list of points of a polygon, last point will be the same of first point
"""
siteList = SiteList(points)
context = Context()
voronoi(siteList,context)
context.setClipBuffer(xBuff, yBuff)
if not polygonsOutput:
clipEdges=context.getClipEdges()
if formatOutput:
vertices, edgesIdx = formatEdgesOutput(clipEdges)
return vertices, edgesIdx
else:
return clipEdges
else:
clipPolygons=context.getClipPolygons(closePoly)
if formatOutput:
vertices, polyIdx = formatPolygonsOutput(clipPolygons)
return vertices, polyIdx
else:
return clipPolygons
def formatEdgesOutput(edges):
#get list of points
pts=[]
for edge in edges:
pts.extend(edge)
#get unique values
pts=set(pts)#unique values (tuples are hashable)
#get dict {values:index}
valuesIdxDict = dict(zip(pts,range(len(pts))))
#get edges index reference
edgesIdx=[]
for edge in edges:
edgesIdx.append([valuesIdxDict[pt] for pt in edge])
return list(pts), edgesIdx
def formatPolygonsOutput(polygons):
#get list of points
pts=[]
for poly in polygons.values():
pts.extend(poly)
#get unique values
pts=set(pts)#unique values (tuples are hashable)
#get dict {values:index}
valuesIdxDict = dict(zip(pts,range(len(pts))))
#get polygons index reference
polygonsIdx={}
for inPtsIdx, poly in polygons.items():
polygonsIdx[inPtsIdx]=[valuesIdxDict[pt] for pt in poly]
return list(pts), polygonsIdx
#------------------------------------------------------------------
def computeDelaunayTriangulation(points):
""" Takes a list of point objects (which must have x and y fields).
Returns a list of 3-tuples: the indices of the points that form a
Delaunay triangle.
"""
siteList = SiteList(points)
context = Context()
context.triangulate = True
voronoi(siteList,context)
return context.triangles
#-----------------------------------------------------------------------------
#if __name__=="__main__":
================================================
FILE: operators/utils/georaster_utils.py
================================================
# -*- coding:utf-8 -*-
# This file is part of BlenderGIS
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
import os
import numpy as np
import bpy, bmesh
import math
import logging
log = logging.getLogger(__name__)
from ...core.georaster import GeoRaster
def _exportAsMesh(georaster, dx=0, dy=0, step=1, buildFaces=True, flat=False, subset=False, reproj=None):
'''Numpy test'''
if subset and georaster.subBoxGeo is None:
subset = False
if not subset:
georef = georaster.georef
else:
georef = georaster.getSubBoxGeoRef()
x0, y0 = georef.origin #pxcenter
pxSizeX, pxSizeY = georef.pxSize.x, georef.pxSize.y
w, h = georef.rSize.x, georef.rSize.y
#adjust against step
w, h = math.ceil(w/step), math.ceil(h/step)
pxSizeX, pxSizeY = pxSizeX * step, pxSizeY * step
x = np.array([(x0 + (pxSizeX * i)) - dx for i in range(0, w)])
y = np.array([(y0 + (pxSizeY * i)) - dy for i in range(0, h)])
xx, yy = np.meshgrid(x, y)
#TODO reproj
if flat:
zz = np.zeros((h, w))
else:
zz = georaster.readAsNpArray(subset=subset).data[::step,::step] #TODO raise error if multiband
verts = np.column_stack((xx.ravel(), yy.ravel(), zz.ravel()))
if buildFaces:
faces = [(x+y*w, x+y*w+1, x+y*w+1+w, x+y*w+w) for x in range(0, w-1) for y in range(0, h-1)]
else:
faces = []
mesh = bpy.data.meshes.new("DEM")
mesh.from_pydata(verts, [], faces)
mesh.update()
return mesh
def exportAsMesh(georaster, dx=0, dy=0, step=1, buildFaces=True, subset=False, reproj=None, flat=False):
if subset and georaster.subBoxGeo is None:
subset = False
if not subset:
georef = georaster.georef
else:
georef = georaster.getSubBoxGeoRef()
if not flat:
img = georaster.readAsNpArray(subset=subset)
#TODO raise error if multiband
data = img.data
x0, y0 = georef.origin #pxcenter
pxSizeX, pxSizeY = georef.pxSize.x, georef.pxSize.y
w, h = georef.rSize.x, georef.rSize.y
#Build the mesh (Note : avoid using bmesh because it's very slow with large mesh, use from_pydata instead)
verts = []
faces = []
nodata = []
idxMap = {}
for py in range(0, h, step):
for px in range(0, w, step):
x = x0 + (pxSizeX * px)
y = y0 + (pxSizeY * py)
if reproj is not None:
x, y = reproj.pt(x, y)
#shift
x -= dx
y -= dy
if flat:
z = 0
else:
z = data[py, px]
#vertex index
v1 = px + py * w #bottom right
#Filter nodata
if z == georaster.noData:
nodata.append(v1)
else:
verts.append((x, y, z))
idxMap[v1] = len(verts) - 1
#build face from bottomright to topright (using only points already created)
if buildFaces and px > 0 and py > 0: #filter first row and column
v2 = v1 - step #bottom left
v3 = v2 - w * step #topleft
v4 = v3 + step #topright
f = [v4, v3, v2, v1] #anticlockwise --> face up
if not any(v in f for v in nodata): #TODO too slow ?
f = [idxMap[v] for v in f]
faces.append(f)
mesh = bpy.data.meshes.new("DEM")
mesh.from_pydata(verts, [], faces)
mesh.update()
return mesh
def rasterExtentToMesh(name, rast, dx, dy, pxLoc='CORNER', reproj=None, subdivise=False):
'''Build a new mesh that represent a georaster extent'''
#create mesh
bm = bmesh.new()
if pxLoc == 'CORNER':
pts = [(pt[0], pt[1]) for pt in rast.corners]#shift coords
elif pxLoc == 'CENTER':
pts = [(pt[0], pt[1]) for pt in rast.cornersCenter]
#Reprojection
if reproj is not None:
pts = reproj.pts(pts)
#build shifted flat 3d vertices
pts = [bm.verts.new((pt[0]-dx, pt[1]-dy, 0)) for pt in pts]#upper left to botton left (clockwise)
pts.reverse()#bottom left to upper left (anticlockwise --> face up)
bm.faces.new(pts)
#Create mesh from bmesh
mesh = bpy.data.meshes.new(name)
bm.to_mesh(mesh)
bm.free()
return mesh
def geoRastUVmap(obj, uvLayer, rast, dx, dy, reproj=None):
'''uv map a georaster texture on a given mesh'''
mesh = obj.data
#Assign uv coords
loc = obj.location
for pg in mesh.polygons:
for i in pg.loop_indices:
vertIdx = mesh.loops[i].vertex_index
pt = list(mesh.vertices[vertIdx].co)
#adjust coords against object location and shift values to retrieve original point coords
pt = (pt[0] + loc.x + dx, pt[1] + loc.y + dy)
if reproj is not None:
pt = reproj.pt(*pt)
#Compute UV coords --> pourcent from image origin (bottom left)
dx_px, dy_px = rast.pxFromGeo(pt[0], pt[1], reverseY=True, round2Floor=False)
u = dx_px / rast.size[0]
v = dy_px / rast.size[1]
#Assign coords
#uvLoop = uvLoopLayer.data[i]
#uvLoop.uv = [u,v]
uvLayer.data[i].uv = [u,v]
def setDisplacer(obj, rast, uvTxtLayer, mid=0, interpolation=False):
#Config displacer
displacer = obj.modifiers.new('DEM', type='DISPLACE')
demTex = bpy.data.textures.new('demText', type = 'IMAGE')
demTex.image = rast.bpyImg
demTex.use_interpolation = interpolation
demTex.extension = 'CLIP'
demTex.use_clamp = False #Needed to get negative displacement with float32 texture
displacer.texture = demTex
displacer.texture_coords = 'UV'
displacer.uv_layer = uvTxtLayer.name
displacer.mid_level = mid #Texture values below this value will result in negative displacement
#Setting the displacement strength :
#displacement = (texture value - Midlevel) * Strength
#>> Strength = displacement / texture value (because mid=0)
#If DEM non scaled then
# *displacement = alt max - alt min = delta Z
# *texture value = delta Z / (2^depth-1)
# (because in Blender, pixel values are normalized between 0.0 and 1.0)
#>> Strength = delta Z / (delta Z / (2^depth-1))
#>> Strength = 2^depth-1
if rast.depth < 32:
#8 or 16 bits unsigned values (signed int16 must be converted to float to be usuable)
displacer.strength = 2**rast.depth-1
else:
#32 bits values
#with float raster, blender give directly raw float values(non normalied)
#so a texture value of 100 simply give a displacement of 100
displacer.strength = 1
bpy.ops.object.shade_smooth()
return displacer
#########################################
class bpyGeoRaster(GeoRaster):
def __init__(self, path, subBoxGeo=None, useGDAL=False, clip=False, fillNodata=False, raw=False):
#First init parent class
GeoRaster.__init__(self, path, subBoxGeo=subBoxGeo, useGDAL=useGDAL)
#Before open the raster into blender we need to assert that the file can be correctly loaded and exploited
#- it must be in a file format supported by Blender (jpeg, tiff, png, bmp, or jpeg2000) and not a GIS specific format
#- it must not be coded in int16 because this datatype cannot be correctly handle as displacement texture (issue with negatives values)
#- it must not be too large or it will overflow Blender memory
#- it must does not contain nodata values because nodata is coded with a large value that will cause huge unwanted displacement
if self.format not in ['GTiff', 'TIFF', 'BMP', 'PNG', 'JPEG', 'JPEG2000'] \
or (clip and self.subBoxGeo is not None) \
or fillNodata \
or self.ddtype == 'int16':
#Open the raster as numpy array (read only a subset if we want to clip it)
if clip:
img = self.readAsNpArray(subset=True)
else:
img = self.readAsNpArray()
#always cast to float because it's the more convenient datatype for displace texture
#(will not be normalized from 0.0 to 1.0 in Blender)
img.cast2float()
#replace nodata with interpolated values
if fillNodata:
img.fillNodata()
#save to a new tiff file on disk
filepath = os.path.splitext(self.path)[0] + '_bgis.tif'
img.save(filepath)
#reinit the parent class
GeoRaster.__init__(self, filepath, useGDAL=useGDAL)
self.raw = raw #flag non color raster like DEM
#Open the file into Blender
self._load()
def _load(self, pack=False):
'''Load the georaster in Blender'''
try:
self.bpyImg = bpy.data.images.load(self.path)
except Exception as e:
log.error("Unable to open raster", exc_info=True)
raise IOError("Unable to open raster") #it will not print traceback (instead of a bare raise)
if pack:
#WARN : packed image can only be stored as png and this format does not support float32 datatype
self.bpyImg.pack()
if self.raw:
self.bpyImg.colorspace_settings.is_data = True
def unload(self):
self.bpyImg.user_clear()
bpy.data.images.remove(self.bpyImg)
self.bpyImg = None
@property
def isLoaded(self):
'''Flag if the image has been loaded in Blender'''
if self.bpyImg is not None:
return True
else:
return False
@property
def isPacked(self):
'''Flag if the image has been packed in Blender'''
if self.bpyImg is not None:
if len(self.bpyImg.packed_files) == 0:
return False
else:
return True
else:
return False
###############################################
# Old methods that use bpy.image.pixels and numpy, keeped here as history
# depreciated because bpy is too slow and we need to process the image before load it in Blender
###############################################
def toBitDepth(self, a):
"""
Convert Blender pixel intensity value (from 0.0 to 1.0)
in true pixel value in initial image bit depth range
"""
return a * (2**self.depth - 1)
def fromBitDepth(self, a):
"""
Convert true pixel value in initial image bit depth range
to Blender pixel intensity value (from 0.0 to 1.0)
"""
return a / (2**self.depth - 1)
def getPixelsArray(self, bandIdx=None, subset=False):
'''
Use bpy to extract pixels values as numpy array
In numpy fist dimension of a 2D matrix represents rows (y) and second dimension represents cols (x)
so to get pixel value at a specified location be careful not confusing axes: data[row, column]
It's possible to swap axes if you prefere accessing values with [x,y] indices instead of [y,x]: data.swapaxes(0,1)
Array origin is top left
'''
if not self.isLoaded:
raise IOError("Can read only image opened in Blender")
if self.ddtype is None:
raise IOError("Undefined data type")
if subset and self.subBoxGeo is None:
return None
nbBands = self.bpyImg.channels #Blender will return 4 channels even with a one band tiff
# Make a first Numpy array in one dimension
a = np.array(self.bpyImg.pixels[:])#[r,g,b,a,r,g,b,a,r,g,b,a, ... ] counting from bottom to up and left to right
# Regroup rgba values
a = a.reshape(len(a)/nbBands, nbBands)#[[r,g,b,a],[r,g,b,a],[r,g,b,a],[r,g,b,a]...]
# Build 2 dimensional array (In numpy first dimension represents rows (y) and second dimension represents cols (x))
a = a.reshape(self.size.y, self.size.x, nbBands)# [ [[rgba], [rgba]...], [lines2], [lines3]...]
# Change origin to top left
a = np.flipud(a)
# Swap axes to access pixels with [x,y] indices instead of [y,x]
##a = a.swapaxes(0,1)
# Extract the requested band
if bandIdx is not None:
a = a[:,:,bandIdx]
# In blender, non float raster pixels values are normalized from 0.0 to 1.0
if not self.isFloat:
# Multiply by 2**depth - 1 to get raw values
a = self.toBitDepth(a)
# Round the result to nearest int and cast to orginal data type
# when cast signed 16 bits dataset, the negatives values are correctly interpreted by numpy
a = np.rint(a).astype(self.ddtype)
# Get the negatives values from signed int16 raster
# This part is no longer needed because previous numpy's cast already did the job
'''
if self.ddtype == 'int16':
#16 bits allows coding values from 0 to 65535 (with 65535 == 2**depth / 2 - 1 )
#positives value are coded from 0 to 32767 (from 0.0 to 0.5 in Blender)
#negatives values are coded in reverse order from 65535 to 32768 (1.0 to 0.5 in Blender)
#corresponding to a range from -1 to -32768
a = np.where(a > 32767, -(65536-a), a)
'''
if not subset:
return a
else:
# Get overlay extent (in pixels)
subBoxPx = self.subBoxPx
# Get subset data (min and max pixel number are both include)
a = a[subBoxPx.ymin:subBoxPx.ymax+1, subBoxPx.xmin:subBoxPx.xmax+1] #topleft to bottomright
return a
def flattenPixelsArray(self, px):
'''
Flatten a 3d array of pixels to match the shape of bpy.pixels
[ [[rgba], [rgba]...], [lines2], [lines3]...] >> [r,g,b,a,r,g,b,a,r,g,b,a, ... ]
If the submited array contains only one band, then the band will be duplicate
and an alpha band will be added to get all rgba values.
'''
shape = px.shape
if len(shape) == 2:
px = np.expand_dims(px, axis=2)
px = np.repeat(px, 3, axis=2)
alpha = np.ones(shape)
alpha = np.expand_dims(alpha, axis=2)
px = np.append(px, alpha, axis=2)
#px = px.swapaxes(0,1)
px = np.flipud(px)
px = px.flatten()
return px
================================================
FILE: operators/view3d_mapviewer.py
================================================
# -*- coding:utf-8 -*-
# ***** GPL LICENSE BLOCK *****
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
# All rights reserved.
# ***** GPL LICENSE BLOCK *****
#built-in imports
import math
import os
import threading
import logging
log = logging.getLogger(__name__)
#bpy imports
import bpy
from mathutils import Vector
from bpy.types import Operator, Panel, AddonPreferences
from bpy.props import StringProperty, IntProperty, FloatProperty, BoolProperty, EnumProperty, FloatVectorProperty
import addon_utils
import gpu
from gpu_extras.batch import batch_for_shader
#core imports
from ..core import HAS_GDAL, HAS_PIL, HAS_IMGIO
from ..core.proj import reprojPt, reprojBbox, dd2meters, meters2dd
from ..core.basemaps import GRIDS, SOURCES, MapService
from ..core import settings
USER_AGENT = settings.user_agent
#bgis imports
from ..geoscene import GeoScene, SK, georefManagerLayout
from ..prefs import PredefCRS
#utilities
from .utils import getBBOX, mouseTo3d
from .utils import placeObj, adjust3Dview, showTextures, rasterExtentToMesh, geoRastUVmap, addTexture #for export to mesh tool
#OSM Nominatim API module
#https://github.com/damianbraun/nominatim
from .lib.osm.nominatim import nominatimQuery
PKG, SUBPKG = __package__.split('.', maxsplit=1) #blendergis.basemaps
####################
class BaseMap(GeoScene):
"""Handle a map as background image in Blender"""
def __init__(self, context, srckey, laykey, grdkey=None):
#Get context
self.context = context
self.scn = context.scene
GeoScene.__init__(self, self.scn)
self.area = context.area
self.area3d = [r for r in self.area.regions if r.type == 'WINDOW'][0]
self.view3d = self.area.spaces.active
self.reg3d = self.view3d.region_3d
#Get cache destination folder in addon preferences
prefs = context.preferences.addons[PKG].preferences
cacheFolder = prefs.cacheFolder
self.synchOrj = prefs.synchOrj
#Get resampling algo preference and set the constant
MapService.RESAMP_ALG = prefs.resamplAlg
#Init MapService class
self.srv = MapService(srckey, cacheFolder)
self.name = srckey + '_' + laykey + '_' + grdkey
#Set destination tile matrix
if grdkey is None:
grdkey = self.srv.srcGridKey
if grdkey == self.srv.srcGridKey:
self.tm = self.srv.srcTms
else:
#Define destination grid in map service
self.srv.setDstGrid(grdkey)
self.tm = self.srv.dstTms
#Init some geoscene props if needed
if not self.hasCRS:
self.crs = self.tm.CRS
if not self.hasOriginPrj:
self.setOriginPrj(0, 0, self.synchOrj)
if not self.hasScale:
self.scale = 1
if not self.hasZoom:
self.zoom = 0
self.lockedZoom = None
#Set path to tiles mosaic used as background image in Blender
#We need a format that support transparency so jpg is exclude
#Writing to tif is generally faster than writing to png
if bpy.data.is_saved:
folder = os.path.dirname(bpy.data.filepath) + os.sep
##folder = bpy.path.abspath("//"))
else:
##folder = bpy.context.preferences.filepaths.temporary_directory
#Blender crease a sub-directory within the temp directory, for each session, which is cleared on exit
folder = bpy.app.tempdir
self.imgPath = folder + self.name + ".tif"
#Get layer def obj
self.layer = self.srv.layers[laykey]
#map keys
self.srckey = srckey
self.laykey = laykey
self.grdkey = grdkey
#Thread attributes
self.thread = None
#Background image attributes
self.img = None #bpy image
self.bkg = None #empty image obj
self.viewDstZ = None #view 3d z distance
#Store previous request
#TODO
def get(self):
'''Launch run() function in a new thread'''
self.stop()
self.srv.start()
self.thread = threading.Thread(target=self.run)
self.thread.start()
def stop(self):
'''Stop actual thread'''
if self.srv.running:
self.srv.stop()
self.thread.join()
def run(self):
"""thread method"""
self.mosaic = self.request()
if self.srv.running and self.mosaic is not None:
#save image
self.mosaic.save(self.imgPath)
if self.srv.running:
#Place background image
self.place()
self.srv.stop()
def moveOrigin(self, dx, dy, useScale=True, updObjLoc=True):
'''Move scene origin and update props'''
self.moveOriginPrj(dx, dy, useScale, updObjLoc, self.synchOrj) #geoscene function
def request(self):
'''Request map service to build a mosaic of required tiles to cover view3d area'''
#Get area dimension
w, h = self.area.width, self.area.height
#w, h = self.area3d.width, self.area3d.height #WARN return [1,1] !!!!????
#Get area bbox coords in destination tile matrix crs (map origin is bottom lelf)
#Method 1 : Get bbox coords in scene crs and then reproject the bbox if needed
z = self.lockedZoom if self.lockedZoom is not None else self.zoom
res = self.tm.getRes(z)
if self.crs == 'EPSG:4326':
res = meters2dd(res)
dx, dy, dz = self.reg3d.view_location
ox = self.crsx + (dx * self.scale)
oy = self.crsy + (dy * self.scale)
xmin = ox - w/2 * res * self.scale
ymax = oy + h/2 * res * self.scale
xmax = ox + w/2 * res * self.scale
ymin = oy - h/2 * res * self.scale
bbox = (xmin, ymin, xmax, ymax)
#reproj bbox to destination grid crs if scene crs is different
if self.crs != self.tm.CRS:
bbox = reprojBbox(self.crs, self.tm.CRS, bbox)
'''
#Method 2
bbox = getBBOX.fromTopView(self.context) #ERROR context is None ????
bbox = bbox.toGeo(geoscn=self)
if self.crs != self.tm.CRS:
bbox = reprojBbox(self.crs, self.tm.CRS, bbox)
'''
log.debug('Bounding box request : {}'.format(bbox))
#Stop thread if the request is same as previous
#TODO
if self.srv.srcGridKey == self.grdkey:
toDstGrid = False
else:
toDstGrid = True
mosaic = self.srv.getImage(self.laykey, bbox, self.zoom, toDstGrid=toDstGrid, outCRS=self.crs)
return mosaic
def place(self):
'''Set map as background image'''
#Get or load bpy image
try:
self.img = [img for img in bpy.data.images if img.filepath == self.imgPath and len(img.packed_files) == 0][0]
except IndexError:
self.img = bpy.data.images.load(self.imgPath)
#Get or load background image
empties = [obj for obj in self.scn.objects if obj.type == 'EMPTY']
bkgs = [obj for obj in empties if obj.empty_display_type == 'IMAGE']
for bkg in bkgs:
bkg.hide_viewport = True
try:
self.bkg = [bkg for bkg in bkgs if bkg.data.filepath == self.imgPath and len(bkg.data.packed_files) == 0][0]
except IndexError:
self.bkg = bpy.data.objects.new(self.name, None) #None will create an empty
self.bkg.empty_display_type = 'IMAGE'
self.bkg.empty_image_depth = 'BACK'
self.bkg.data = self.img
self.scn.collection.objects.link(self.bkg)
else:
self.bkg.hide_viewport = False
#Get some image props
img_ox, img_oy = self.mosaic.center
img_w, img_h = self.mosaic.size
res = self.mosaic.pxSize.x
#res = self.tm.getRes(self.zoom)
#Set background size
sizex = img_w * res / self.scale
sizey = img_h * res / self.scale
size = max([sizex, sizey])
#self.bkg.empty_display_size = sizex #limited to 1000
self.bkg.empty_display_size = 1 #a size of 1 means image width=1bu
self.bkg.scale = (size, size, 1)
#Set background offset (image origin does not match scene origin)
dx = (self.crsx - img_ox) / self.scale
dy = (self.crsy - img_oy) / self.scale
#self.bkg.empty_image_offset = [-0.5, -0.5] #in image unit space
self.bkg.location = (-dx, -dy, 0)
#ratio = img_w / img_h
#self.bkg.offset_y = -dy * ratio #https://developer.blender.org/T48034
#Get 3d area's number of pixels and resulting size at the requested zoom level resolution
#dst = max( [self.area3d.width, self.area3d.height] ) #WARN return [1,1] !!!!????
dst = max( [self.area.width, self.area.height] )
z = self.lockedZoom if self.lockedZoom is not None else self.zoom
res = self.tm.getRes(z)
dst = dst * res / self.scale
#Compute 3dview FOV and needed z distance to see the maximum extent that
#can be draw at full res (area 3d needs enough pixels otherwise the image will appears downgraded)
#WARN seems these formulas does not works properly in Blender2.8
view3D_aperture = 36 #Blender constant (see source code)
view3D_zoom = 2 #Blender constant (see source code)
fov = 2 * math.atan(view3D_aperture / (self.view3d.lens*2) ) #fov equation
fov = math.atan(math.tan(fov/2) * view3D_zoom) * 2 #zoom correction (see source code)
zdst = (dst/2) / math.tan(fov/2) #trigo
zdst = math.floor(zdst) #make sure no downgrade
self.reg3d.view_distance = zdst
self.viewDstZ = zdst
#Update image drawing
self.bkg.data.reload()
####################################
def drawInfosText(self, context):
#Get contexts
scn = context.scene
area = context.area
area3d = [reg for reg in area.regions if reg.type == 'WINDOW'][0]
view3d = area.spaces.active
reg3d = view3d.region_3d
#Get map props stored in scene
geoscn = GeoScene(scn)
zoom = geoscn.zoom
scale = geoscn.scale
#
txt = "Map view : "
txt += "Zoom " + str(zoom)
if self.map.lockedZoom is not None:
txt += " (Locked)"
txt += " - Scale 1:" + str(int(scale))
'''
# view3d distance
dst = reg3d.view_distance
if dst > 1000:
dst /= 1000
unit = 'km'
else:
unit = 'm'
txt += ' 3D View distance ' + str(int(dst)) + ' ' + unit
'''
# cursor crs coords
txt += ' ' + str((int(self.posx), int(self.posy)))
# progress
txt += ' ' + self.progress
context.area.header_text_set(txt)
def drawZoomBox(self, context):
if self.zoomBoxMode and not self.zoomBoxDrag:
# before selection starts draw infinite cross
px, py = self.zb_xmax, self.zb_ymax
p1 = (0, py, 0)
p2 = (context.area.width, py, 0)
p3 = (px, 0, 0)
p4 = (px, context.area.height, 0)
coords = [p1, p2, p3, p4]
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'LINES', {"pos": coords})
shader.bind()
shader.uniform_float("color", (0, 0, 0, 1))
batch.draw(shader)
elif self.zoomBoxMode and self.zoomBoxDrag:
p1 = (self.zb_xmin, self.zb_ymin, 0)
p2 = (self.zb_xmin, self.zb_ymax, 0)
p3 = (self.zb_xmax, self.zb_ymax, 0)
p4 = (self.zb_xmax, self.zb_ymin, 0)
coords = [p1, p2, p2, p3, p3, p4, p4, p1]
shader = gpu.shader.from_builtin('UNIFORM_COLOR')
batch = batch_for_shader(shader, 'LINES', {"pos": coords})
shader.bind()
shader.uniform_float("color", (0, 0, 0, 1))
batch.draw(shader)
###############
class VIEW3D_OT_map_start(Operator):
bl_idname = "view3d.map_start"
bl_description = 'Toggle 2d map navigation'
bl_label = "Basemap"
bl_options = {'REGISTER'}
#special function to auto redraw an operator popup called through invoke_props_dialog
def check(self, context):
return True
def listSources(self, context):
srcItems = []
for srckey, src in SOURCES.items():
#put each item in a tuple (key, label, tooltip)
srcItems.append( (srckey, src['name'], src['description']) )
return srcItems
def listGrids(self, context):
grdItems = []
src = SOURCES[self.src]
for gridkey, grd in GRIDS.items():
#put each item in a tuple (key, label, tooltip)
if gridkey == src['grid']:
#insert at first position
grdItems.insert(0, (gridkey, grd['name']+' (source)', grd['description']) )
else:
grdItems.append( (gridkey, grd['name'], grd['description']) )
return grdItems
def listLayers(self, context):
layItems = []
src = SOURCES[self.src]
for laykey, lay in src['layers'].items():
#put each item in a tuple (key, label, tooltip)
layItems.append( (laykey, lay['name'], lay['description']) )
return layItems
src: EnumProperty(
name = "Map",
description = "Choose map service source",
items = listSources
)
grd: EnumProperty(
name = "Grid",
description = "Choose cache tiles matrix",
items = listGrids
)
lay: EnumProperty(
name = "Layer",
description = "Choose layer",
items = listLayers
)
dialog: StringProperty(default='MAP') # 'MAP', 'SEARCH', 'OPTIONS'
query: StringProperty(name="Go to")
zoom: IntProperty(name='Zoom level', min=0, max=25)
recenter: BoolProperty(name='Center to existing objects')
def draw(self, context):
addonPrefs = context.preferences.addons[PKG].preferences
scn = context.scene
layout = self.layout
if self.dialog == 'SEARCH':
layout.prop(self, 'query')
layout.prop(self, 'zoom', slider=True)
elif self.dialog == 'OPTIONS':
#viewPrefs = context.preferences.view
#layout.prop(viewPrefs, "use_zoom_to_mouse")
layout.prop(addonPrefs, "zoomToMouse")
layout.prop(addonPrefs, "lockObj")
layout.prop(addonPrefs, "lockOrigin")
layout.prop(addonPrefs, "synchOrj")
elif self.dialog == 'MAP':
layout.prop(self, 'src', text='Source')
layout.prop(self, 'lay', text='Layer')
col = layout.column()
if not HAS_GDAL:
col.enabled = False
col.label(text='(No raster reprojection support)')
col.prop(self, 'grd', text='Tile matrix set')
#srcCRS = GRIDS[SOURCES[self.src]['grid']]['CRS']
grdCRS = GRIDS[self.grd]['CRS']
row = layout.row()
#row.alignment = 'RIGHT'
desc = PredefCRS.getName(grdCRS)
if desc is not None:
row.label(text='CRS: ' + desc)
else:
row.label(text='CRS: ' + grdCRS)
row = layout.row()
row.prop(self, 'recenter')
geoscn = GeoScene(scn)
if geoscn.isPartiallyGeoref:
#layout.separator()
georefManagerLayout(self, context)
#row = layout.row()
#row.label(text='Map scale:')
#row.prop(scn, '["'+SK.SCALE+'"]', text='')
def invoke(self, context, event):
if not HAS_PIL and not HAS_GDAL and not HAS_IMGIO:
self.report({'ERROR'}, "No imaging library available. ImageIO module was not correctly installed.")
return {'CANCELLED'}
if not context.area.type == 'VIEW_3D':
self.report({'WARNING'}, "View3D not found, cannot run operator")
return {'CANCELLED'}
#Update zoom
geoscn = GeoScene(context.scene)
if geoscn.hasZoom:
self.zoom = geoscn.zoom
#Display dialog
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
scn = context.scene
geoscn = GeoScene(scn)
prefs = context.preferences.addons[PKG].preferences
#check cache folder
folder = prefs.cacheFolder
if folder == "" or not os.path.exists(folder):
self.report({'ERROR'}, "Please define a valid cache folder path in addon's preferences")
return {'CANCELLED'}
if not os.access(folder, os.X_OK | os.W_OK):
self.report({'ERROR'}, "The selected cache folder has no write access")
return {'CANCELLED'}
if self.dialog == 'MAP':
grdCRS = GRIDS[self.grd]['CRS']
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken, please fix it beforehand")
return {'CANCELLED'}
#set scene crs as grid crs
#if not geoscn.hasCRS:
#geoscn.crs = grdCRS
#Check if raster reproj is needed
if geoscn.hasCRS and geoscn.crs != grdCRS and not HAS_GDAL:
self.report({'ERROR'}, "Please install gdal to enable raster reprojection support")
return {'CANCELLED'}
#Move scene origin to the researched place
if self.dialog == 'SEARCH':
r = bpy.ops.view3d.map_search('EXEC_DEFAULT', query=self.query)
if r == {'CANCELLED'}:
self.report({'INFO'}, "No location found")
else:
geoscn.zoom = self.zoom
#Start map viewer operator
self.dialog = 'MAP' #reinit dialog type
bpy.ops.view3d.map_viewer('INVOKE_DEFAULT', srckey=self.src, laykey=self.lay, grdkey=self.grd, recenter=self.recenter)
return {'FINISHED'}
###############
class VIEW3D_OT_map_viewer(Operator):
bl_idname = "view3d.map_viewer"
bl_description = 'Toggle 2d map navigation'
bl_label = "Map viewer"
bl_options = {'INTERNAL'}
srckey: StringProperty()
grdkey: StringProperty()
laykey: StringProperty()
recenter: BoolProperty()
@classmethod
def poll(cls, context):
return context.area.type == 'VIEW_3D'
def __del__(self):
if getattr(self, 'restart', False):
bpy.ops.view3d.map_start('INVOKE_DEFAULT', src=self.srckey, lay=self.laykey, grd=self.grdkey, dialog=self.dialog)
def invoke(self, context, event):
self.restart = False
self.dialog = 'MAP' # dialog name for MAP_START >> string in ['MAP', 'SEARCH', 'OPTIONS']
self.moveFactor = 0.1
self.prefs = context.preferences.addons[PKG].preferences
#Option to adjust or not objects location when panning
self.updObjLoc = self.prefs.lockObj #if georef is locked then we need to adjust object location after each pan
#Add draw callback to view space
args = (self, context)
self._drawTextHandler = bpy.types.SpaceView3D.draw_handler_add(drawInfosText, args, 'WINDOW', 'POST_PIXEL')
self._drawZoomBoxHandler = bpy.types.SpaceView3D.draw_handler_add(drawZoomBox, args, 'WINDOW', 'POST_PIXEL')
#Add modal handler and init a timer
context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(0.04, window=context.window)
#Switch to top view ortho (center to origin)
view3d = context.area.spaces.active
bpy.ops.view3d.view_axis(type='TOP')
view3d.region_3d.view_perspective = 'ORTHO'
context.scene.cursor.location = (0, 0, 0)
if not self.prefs.lockOrigin:
#bpy.ops.view3d.view_center_cursor()
view3d.region_3d.view_location = (0, 0, 0)
#Init some properties
# tag if map is currently drag
self.inMove = False
# mouse crs coordinates reported in draw callback
self.posx, self.posy = 0, 0
# thread progress infos reported in draw callback
self.progress = ''
# Zoom box
self.zoomBoxMode = False
self.zoomBoxDrag = False
self.zb_xmin, self.zb_xmax = 0, 0
self.zb_ymin, self.zb_ymax = 0, 0
#Get map
self.map = BaseMap(context, self.srckey, self.laykey, self.grdkey)
if self.recenter and len(context.scene.objects) > 0:
scnBbox = getBBOX.fromScn(context.scene).to2D()
w, h = scnBbox.dimensions
px_diag = math.sqrt(context.area.width**2 + context.area.height**2)
dst_diag = math.sqrt( w**2 + h**2 )
targetRes = dst_diag / px_diag
z = self.map.tm.getNearestZoom(targetRes, rule='lower')
resFactor = self.map.tm.getFromToResFac(self.map.zoom, z)
context.region_data.view_distance *= resFactor
x, y = scnBbox.center
if self.prefs.lockOrigin:
context.region_data.view_location = (x, y, 0)
else:
self.map.moveOrigin(x, y)
self.map.zoom = z
self.map.get()
return {'RUNNING_MODAL'}
def modal(self, context, event):
context.area.tag_redraw()
scn = bpy.context.scene
if event.type == 'TIMER':
#report thread progression
self.progress = self.map.srv.report
return {'PASS_THROUGH'}
if event.type in ['WHEELUPMOUSE', 'NUMPAD_PLUS']:
if event.value == 'PRESS':
if event.alt:
# map scale up
self.map.scale *= 10
self.map.place()
#Scale existing objects
for obj in scn.objects:
obj.location /= 10
obj.scale /= 10
elif event.ctrl:
# view3d zoom up
dst = context.region_data.view_distance
context.region_data.view_distance -= dst * self.moveFactor
if self.prefs.zoomToMouse:
mouseLoc = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
viewLoc = context.region_data.view_location
deltaVect = (mouseLoc - viewLoc) * self.moveFactor
viewLoc += deltaVect
else:
# map zoom up
if self.map.zoom < self.map.layer.zmax and self.map.zoom < self.map.tm.nbLevels-1:
self.map.zoom += 1
if self.map.lockedZoom is None:
resFactor = self.map.tm.getNextResFac(self.map.zoom)
if not self.prefs.zoomToMouse:
context.region_data.view_distance *= resFactor
else:
#Progressibly zoom to cursor
dst = context.region_data.view_distance
dst2 = dst * resFactor
context.region_data.view_distance = dst2
mouseLoc = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
viewLoc = context.region_data.view_location
moveFactor = (dst - dst2) / dst
deltaVect = (mouseLoc - viewLoc) * moveFactor
if self.prefs.lockOrigin:
viewLoc += deltaVect
else:
dx, dy, dz = deltaVect
if not self.prefs.lockObj and self.map.bkg is not None:
self.map.bkg.location -= deltaVect
self.map.moveOrigin(dx, dy, updObjLoc=self.updObjLoc)
self.map.get()
if event.type in ['WHEELDOWNMOUSE', 'NUMPAD_MINUS']:
if event.value == 'PRESS':
if event.alt:
#map scale down
s = self.map.scale / 10
if s < 1: s = 1
self.map.scale = s
self.map.place()
#Scale existing objects
for obj in scn.objects:
obj.location *= 10
obj.scale *= 10
elif event.ctrl:
#view3d zoom down
dst = context.region_data.view_distance
context.region_data.view_distance += dst * self.moveFactor
if self.prefs.zoomToMouse:
mouseLoc = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
viewLoc = context.region_data.view_location
deltaVect = (mouseLoc - viewLoc) * self.moveFactor
viewLoc -= deltaVect
else:
#map zoom down
if self.map.zoom > self.map.layer.zmin and self.map.zoom > 0:
self.map.zoom -= 1
if self.map.lockedZoom is None:
resFactor = self.map.tm.getPrevResFac(self.map.zoom)
if not self.prefs.zoomToMouse:
context.region_data.view_distance *= resFactor
else:
#Progressibly zoom to cursor
dst = context.region_data.view_distance
dst2 = dst * resFactor
context.region_data.view_distance = dst2
mouseLoc = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
viewLoc = context.region_data.view_location
moveFactor = (dst - dst2) / dst
deltaVect = (mouseLoc - viewLoc) * moveFactor
if self.prefs.lockOrigin:
viewLoc += deltaVect
else:
dx, dy, dz = deltaVect
if not self.prefs.lockObj and self.map.bkg is not None:
self.map.bkg.location -= deltaVect
self.map.moveOrigin(dx, dy, updObjLoc=self.updObjLoc)
self.map.get()
if event.type == 'MOUSEMOVE':
#Report mouse location coords in projeted crs
loc = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
self.posx, self.posy = self.map.view3dToProj(loc.x, loc.y)
if self.zoomBoxMode:
self.zb_xmax, self.zb_ymax = event.mouse_region_x, event.mouse_region_y
#Drag background image (edit its offset values)
if self.inMove:
loc1 = mouseTo3d(context, self.x1, self.y1)
loc2 = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
dlt = loc1 - loc2
if event.ctrl or self.prefs.lockOrigin:
context.region_data.view_location = self.viewLoc1 + dlt
else:
#Move background image
if self.map.bkg is not None:
self.map.bkg.location[0] = self.offx1 - dlt.x
self.map.bkg.location[1] = self.offy1 - dlt.y
#Move existing objects (only top level parent)
if self.updObjLoc:
topParents = [obj for obj in scn.objects if not obj.parent]
for i, obj in enumerate(topParents):
if obj == self.map.bkg: #the background empty used as basemap
continue
loc1 = self.objsLoc1[i]
obj.location.x = loc1.x - dlt.x
obj.location.y = loc1.y - dlt.y
if event.type in {'LEFTMOUSE', 'MIDDLEMOUSE'}:
if event.value == 'PRESS' and not self.zoomBoxMode:
#Get click mouse position and background image offset (if exist)
self.x1, self.y1 = event.mouse_region_x, event.mouse_region_y
self.viewLoc1 = context.region_data.view_location.copy()
if not event.ctrl:
#Stop thread now, because we don't know when the mouse click will be released
self.map.stop()
if not self.prefs.lockOrigin:
if self.map.bkg is not None:
self.offx1 = self.map.bkg.location[0]
self.offy1 = self.map.bkg.location[1]
#Store current location of each objects (only top level parent)
self.objsLoc1 = [obj.location.copy() for obj in scn.objects if not obj.parent]
#Tag that map is currently draging
self.inMove = True
if event.value == 'RELEASE' and not self.zoomBoxMode:
self.inMove = False
if not event.ctrl:
if not self.prefs.lockOrigin:
#Compute final shift
loc1 = mouseTo3d(context, self.x1, self.y1)
loc2 = mouseTo3d(context, event.mouse_region_x, event.mouse_region_y)
dlt = loc1 - loc2
#Update map (do not update objects location because it was updated while mouse move)
self.map.moveOrigin(dlt.x, dlt.y, updObjLoc=False)
self.map.get()
if event.value == 'PRESS' and self.zoomBoxMode:
self.zoomBoxDrag = True
self.zb_xmin, self.zb_ymin = event.mouse_region_x, event.mouse_region_y
if event.value == 'RELEASE' and self.zoomBoxMode:
#Get final zoom box
xmax = max(event.mouse_region_x, self.zb_xmin)
ymax = max(event.mouse_region_y, self.zb_ymin)
xmin = min(event.mouse_region_x, self.zb_xmin)
ymin = min(event.mouse_region_y, self.zb_ymin)
#Exit zoom box mode
self.zoomBoxDrag = False
self.zoomBoxMode = False
context.window.cursor_set('DEFAULT')
#Compute the move to box origin
w = xmax - xmin
h = ymax - ymin
cx = xmin + w/2
cy = ymin + h/2
loc = mouseTo3d(context, cx, cy)
#Compute target resolution
px_diag = math.sqrt(context.area.width**2 + context.area.height**2)
mapRes = self.map.tm.getRes(self.map.zoom)
dst_diag = math.sqrt( (w*mapRes)**2 + (h*mapRes)**2)
targetRes = dst_diag / px_diag
z = self.map.tm.getNearestZoom(targetRes, rule='lower')
resFactor = self.map.tm.getFromToResFac(self.map.zoom, z)
#Preview
context.region_data.view_distance *= resFactor
if self.prefs.lockOrigin:
context.region_data.view_location = loc
else:
self.map.moveOrigin(loc.x, loc.y, updObjLoc=self.updObjLoc)
self.map.zoom = z
self.map.get()
if event.type in ['LEFT_CTRL', 'RIGHT_CTRL']:
if event.value == 'PRESS':
self._viewDstZ = context.region_data.view_distance
self._viewLoc = context.region_data.view_location.copy()
if event.value == 'RELEASE':
#restore view 3d distance and location
context.region_data.view_distance = self._viewDstZ
context.region_data.view_location = self._viewLoc
#NUMPAD MOVES (3D VIEW or MAP)
if event.value == 'PRESS' and event.type in ['NUMPAD_2', 'NUMPAD_4', 'NUMPAD_6', 'NUMPAD_8']:
delta = self.map.bkg.scale.x * self.moveFactor
if event.type == 'NUMPAD_4':
if event.ctrl or self.prefs.lockOrigin:
context.region_data.view_location += Vector( (-delta, 0, 0) )
else:
self.map.moveOrigin(-delta, 0, updObjLoc=self.updObjLoc)
if event.type == 'NUMPAD_6':
if event.ctrl or self.prefs.lockOrigin:
context.region_data.view_location += Vector( (delta, 0, 0) )
else:
self.map.moveOrigin(delta, 0, updObjLoc=self.updObjLoc)
if event.type == 'NUMPAD_2':
if event.ctrl or self.prefs.lockOrigin:
context.region_data.view_location += Vector( (0, -delta, 0) )
else:
self.map.moveOrigin(0, -delta, updObjLoc=self.updObjLoc)
if event.type == 'NUMPAD_8':
if event.ctrl or self.prefs.lockOrigin:
context.region_data.view_location += Vector( (0, delta, 0) )
else:
self.map.moveOrigin(0, delta, updObjLoc=self.updObjLoc)
if not event.ctrl:
self.map.get()
#SWITCH LAYER
if event.type == 'SPACE':
self.map.stop()
bpy.types.SpaceView3D.draw_handler_remove(self._drawTextHandler, 'WINDOW')
bpy.types.SpaceView3D.draw_handler_remove(self._drawZoomBoxHandler, 'WINDOW')
context.area.header_text_set(None)
self.restart = True
return {'FINISHED'}
#GO TO
if event.type == 'G':
self.map.stop()
bpy.types.SpaceView3D.draw_handler_remove(self._drawTextHandler, 'WINDOW')
bpy.types.SpaceView3D.draw_handler_remove(self._drawZoomBoxHandler, 'WINDOW')
context.area.header_text_set(None)
self.restart = True
self.dialog = 'SEARCH'
return {'FINISHED'}
#OPTIONS
if event.type == 'O':
self.map.stop()
bpy.types.SpaceView3D.draw_handler_remove(self._drawTextHandler, 'WINDOW')
bpy.types.SpaceView3D.draw_handler_remove(self._drawZoomBoxHandler, 'WINDOW')
context.area.header_text_set(None)
self.restart = True
self.dialog = 'OPTIONS'
return {'FINISHED'}
#Lock/unlock 3d view zoom distance
if event.type == 'L' and event.value == 'PRESS':
if self.map.lockedZoom is None:
self.map.lockedZoom = self.map.zoom
else:
self.map.lockedZoom = None
self.map.get()
#ZOOM BOX
if event.type == 'B' and event.value == 'PRESS':
self.map.stop()
self.zoomBoxMode = True
self.zb_xmax, self.zb_ymax = event.mouse_region_x, event.mouse_region_y
context.window.cursor_set('CROSSHAIR')
#EXPORT
if event.type == 'E' and event.value == 'PRESS':
#
if not self.map.srv.running and self.map.mosaic is not None:
self.map.stop()
self.map.bkg.hide_viewport = True
bpy.types.SpaceView3D.draw_handler_remove(self._drawTextHandler, 'WINDOW')
bpy.types.SpaceView3D.draw_handler_remove(self._drawZoomBoxHandler, 'WINDOW')
context.area.header_text_set(None)
#Copy image to new datablock
bpyImg = bpy.data.images.load(self.map.imgPath) #(self.map.img.filepath)
name = 'EXPORT_' + self.map.srckey + '_' + self.map.laykey + '_' + self.map.grdkey
bpyImg.name = name
bpyImg.pack()
#Add new attribute to GeoRaster (used by geoRastUVmap function)
rast = self.map.mosaic
setattr(rast, 'bpyImg', bpyImg)
#Create Mesh
dx, dy = self.map.getOriginPrj()
mesh = rasterExtentToMesh(name, rast, dx, dy, pxLoc='CORNER')
#Create object
obj = placeObj(mesh, name)
#UV mapping
uvTxtLayer = mesh.uv_layers.new(name='rastUVmap')# Add UV map texture layer
geoRastUVmap(obj, uvTxtLayer, rast, dx, dy)
#Create material
mat = bpy.data.materials.new('rastMat')
obj.data.materials.append(mat)
addTexture(mat, bpyImg, uvTxtLayer)
#Adjust 3d view and display textures
if self.prefs.adjust3Dview:
adjust3Dview(context, getBBOX.fromObj(obj))
if self.prefs.forceTexturedSolid:
showTextures(context)
return {'FINISHED'}
#EXIT
if event.type == 'ESC' and event.value == 'PRESS':
if self.zoomBoxMode:
self.zoomBoxDrag = False
self.zoomBoxMode = False
context.window.cursor_set('DEFAULT')
else:
self.map.stop()
bpy.types.SpaceView3D.draw_handler_remove(self._drawTextHandler, 'WINDOW')
bpy.types.SpaceView3D.draw_handler_remove(self._drawZoomBoxHandler, 'WINDOW')
context.area.header_text_set(None)
return {'CANCELLED'}
return {'RUNNING_MODAL'}
####################################
class VIEW3D_OT_map_search(bpy.types.Operator):
bl_idname = "view3d.map_search"
bl_description = 'Search for a place and move scene origin to it'
bl_label = "Map search"
bl_options = {'INTERNAL'}
query: StringProperty(name="Go to")
def invoke(self, context, event):
geoscn = GeoScene(context.scene)
if geoscn.isBroken:
self.report({'ERROR'}, "Scene georef is broken")
return {'CANCELLED'}
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
geoscn = GeoScene(context.scene)
prefs = context.preferences.addons[PKG].preferences
try:
results = nominatimQuery(self.query, referer='bgis', user_agent=USER_AGENT)
except Exception as e:
log.error('Failed Nominatim query', exc_info=True)
return {'CANCELLED'}
if len(results) == 0:
return {'CANCELLED'}
else:
log.debug('Nominatim search results : {}'.format([r['display_name'] for r in results]))
result = results[0]
lat, lon = float(result['lat']), float(result['lon'])
if geoscn.isGeoref:
geoscn.updOriginGeo(lon, lat, updObjLoc=prefs.lockObj)
else:
geoscn.setOriginGeo(lon, lat)
return {'FINISHED'}
classes = [
VIEW3D_OT_map_start,
VIEW3D_OT_map_viewer,
VIEW3D_OT_map_search
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
#log.error('Cannot register {}'.format(cls), exc_info=True)
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)
================================================
FILE: prefs.py
================================================
import json
import logging
log = logging.getLogger(__name__)
import sys, os
import bpy
from bpy.props import StringProperty, IntProperty, FloatProperty, BoolProperty, EnumProperty, FloatVectorProperty
from bpy.types import Operator, Panel, AddonPreferences
import addon_utils
from . import bl_info
from .core.proj.reproj import MapTilerCoordinates
from .core.proj.srs import SRS
from .core.checkdeps import HAS_GDAL, HAS_PYPROJ, HAS_PIL, HAS_IMGIO
from .core import settings
PKG = __package__
def getAppData():
home = os.path.expanduser('~')
loc = os.path.join(home, '.bgis')
if not os.path.exists(loc):
os.mkdir(loc)
return loc
APP_DATA = getAppData()
'''
Default Enum properties contents (list of tuple (value, label, tootip))
Theses properties are automatically filled from a serialized json string stored in a StringProperty
This is workaround to have an editable EnumProperty (ie the user can add, remove or edit an entry)
because the Blender Python API does not provides built in functions for these tasks.
To edit the content of these enum, we just need to write new operators which will simply update the json string
As the json backend is stored in addon preferences, the property will be saved and restored for the next blender session
'''
DEFAULT_CRS = [
('EPSG:3857', 'Web Mercator', 'Worldwide projection, high distortions, not suitable for precision modelling'),
('EPSG:4326', 'WGS84 latlon', 'Longitude and latitude in degrees, DO NOT USE AS SCENE CRS (this system is defined only for reprojection tasks')
]
DEFAULT_DEM_SERVER = [
("https://portal.opentopography.org/API/globaldem?demtype=SRTMGL1&west={W}&east={E}&south={S}&north={N}&outputFormat=GTiff&API_Key={API_KEY}", 'OpenTopography SRTM 30m', 'OpenTopography.org web service for SRTM 30m global DEM'),
("https://portal.opentopography.org/API/globaldem?demtype=SRTMGL3&west={W}&east={E}&south={S}&north={N}&outputFormat=GTiff&API_Key={API_KEY}", 'OpenTopography SRTM 90m', 'OpenTopography.org web service for SRTM 90m global DEM'),
("http://www.gmrt.org/services/GridServer?west={W}&east={E}&south={S}&north={N}&layer=topo&format=geotiff&resolution=high", 'Marine-geo.org GMRT', 'Marine-geo.org web service for GMRT global DEM (terrestrial (ASTER) and bathymetry)')
]
DEFAULT_OVERPASS_SERVER = [
("https://lz4.overpass-api.de/api/interpreter", 'overpass-api.de', 'Main Overpass API instance'),
("http://overpass.openstreetmap.fr/api/interpreter", 'overpass.openstreetmap.fr', 'French Overpass API instance'),
("https://overpass.kumi.systems/api/interpreter", 'overpass.kumi.systems', 'Kumi Systems Overpass Instance')
]
#default filter tags for OSM import
DEFAULT_OSM_TAGS = [
'building',
'highway',
'landuse',
'leisure',
'natural',
'railway',
'waterway'
]
class BGIS_OT_pref_show(Operator):
bl_idname = "bgis.pref_show"
bl_description = 'Display BlenderGIS addons preferences'
bl_label = "Preferences"
bl_options = {'INTERNAL'}
def execute(self, context):
addon_utils.modules_refresh()
context.preferences.active_section = 'ADDONS'
bpy.data.window_managers["WinMan"].addon_search = bl_info['name']
#bpy.ops.wm.addon_expand(module=PKG)
mod = addon_utils.addons_fake_modules.get(PKG)
mod.bl_info['show_expanded'] = True
bpy.ops.screen.userpref_show('INVOKE_DEFAULT')
return {'FINISHED'}
class BGIS_PREFS(AddonPreferences):
bl_idname = PKG
################
#Predefined Spatial Ref. Systems
def listPredefCRS(self, context):
return [tuple(elem) for elem in json.loads(self.predefCrsJson)]
#store crs preset as json string into addon preferences
predefCrsJson: StringProperty(default=json.dumps(DEFAULT_CRS))
predefCrs: EnumProperty(
name = "Predefinate CRS",
description = "Choose predefinite Coordinate Reference System",
#default = 1, #possible only since Blender 2.90
items = listPredefCRS
)
################
#proj engine
def getProjEngineItems(self, context):
items = [ ('AUTO', 'Auto detect', 'Auto select the best library for reprojection tasks') ]
if HAS_GDAL:
items.append( ('GDAL', 'GDAL', 'Force GDAL as reprojection engine') )
if HAS_PYPROJ:
items.append( ('PYPROJ', 'pyProj', 'Force pyProj as reprojection engine') )
#if EPSGIO.ping(): #too slow
# items.append( ('EPSGIO', 'epsg.io', '') )
items.append( ('EPSGIO', 'epsg.io / MapTilerCoords', 'Force epsg.io as reprojection engine') )
items.append( ('BUILTIN', 'Built in', 'Force reprojection through built in Python functions') )
return items
def updateProjEngine(self, context):
settings.proj_engine = self.projEngine
projEngine: EnumProperty(
name = "Projection engine",
description = "Select projection engine",
items = getProjEngineItems,
update = updateProjEngine
)
################
#img engine
def getImgEngineItems(self, context):
items = [ ('AUTO', 'Auto detect', 'Auto select the best imaging library') ]
if HAS_GDAL:
items.append( ('GDAL', 'GDAL', 'Force GDAL as image processing engine') )
if HAS_IMGIO:
items.append( ('IMGIO', 'ImageIO', 'Force ImageIO as image processing engine') )
if HAS_PIL:
items.append( ('PIL', 'PIL', 'Force PIL as image processing engine') )
return items
def updateImgEngine(self, context):
settings.img_engine = self.imgEngine
imgEngine: EnumProperty(
name = "Image processing engine",
description = "Select image processing engine",
items = getImgEngineItems,
update = updateImgEngine
)
################
#OSM
osmTagsJson: StringProperty(default=json.dumps(DEFAULT_OSM_TAGS)) #just a serialized list of tags
def listOsmTags(self, context):
prefs = context.preferences.addons[PKG].preferences
tags = json.loads(prefs.osmTagsJson)
#put each item in a tuple (key, label, tooltip)
return [ (tag, tag, tag) for tag in tags]
osmTags: EnumProperty(
name = "OSM tags",
description = "List of registered OSM tags",
items = listOsmTags
)
################
#Basemaps
def getCacheFolder5x(self, v, isSet):
return bpy.path.abspath(v)
def getCacheFolder(self):
return bpy.path.abspath(self.get("cacheFolder", ''))
def setCacheFolder5x(self, newVal, currentVal, isSet):
if os.access(newVal, os.X_OK | os.W_OK):
return newVal
else:
log.error("The selected cache folder has no write access")
def setCacheFolder(self, value):
if os.access(value, os.X_OK | os.W_OK):
self["cacheFolder"] = value
else:
self["cacheFolder"] = "The selected folder has no write access"
if bpy.app.version[0] >= 5 :
cacheFolder: StringProperty(
name = "Cache folder",
default = APP_DATA, #Does not works !?
description = "Define a folder where to store Geopackage SQlite db",
subtype = 'DIR_PATH',
get_transform = getCacheFolder5x,
set_transform = setCacheFolder5x
)
else:
cacheFolder: StringProperty(
name = "Cache folder",
default = APP_DATA, #Does not works !?
description = "Define a folder where to store Geopackage SQlite db",
subtype = 'DIR_PATH',
get = getCacheFolder,
set = setCacheFolder
)
synchOrj: BoolProperty(
name="Synch. lat/long",
description='Keep geo origin synchronized with crs origin. Can be slow with remote reprojection services',
default=True)
zoomToMouse: BoolProperty(name="Zoom to mouse", description='Zoom towards the mouse pointer position', default=True)
lockOrigin: BoolProperty(name="Lock origin", description='Do not move scene origin when panning map', default=False)
lockObj: BoolProperty(name="Lock objects", description='Retain objects geolocation when moving map origin', default=True)
resamplAlg: EnumProperty(
name = "Resampling method",
description = "Choose GDAL's resampling method used for reprojection",
items = [ ('NN', 'Nearest Neighboor', ''), ('BL', 'Bilinear', ''), ('CB', 'Cubic', ''), ('CBS', 'Cubic Spline', ''), ('LCZ', 'Lanczos', '') ]
)
################
#Network
def listOverpassServer(self, context):
return [tuple(entry) for entry in json.loads(self.overpassServerJson)]
#store crs preset as json string into addon preferences
overpassServerJson: StringProperty(default=json.dumps(DEFAULT_OVERPASS_SERVER))
overpassServer: EnumProperty(
name = "Overpass server",
description = "Select an overpass server",
#default = 0,
items = listOverpassServer
)
def listDemServer(self, context):
return [tuple(entry) for entry in json.loads(self.demServerJson)]
#store crs preset as json string into addon preferences
demServerJson: StringProperty(default=json.dumps(DEFAULT_DEM_SERVER))
demServer: EnumProperty(
name = "Elevation server",
description = "Select a server that provides Digital Elevation Model datasource",
#default = 0,
items = listDemServer
)
opentopography_api_key: StringProperty(
name = "",
description="you need to register and request a key from opentopography website"
)
def updateMapTilerApiKey(self, context):
settings.maptiler_api_key = self.maptiler_api_key
maptiler_api_key: StringProperty(
name = "",
description = "API key for MapTiler Coordinates API (required for EPSG.io migration)",
update = updateMapTilerApiKey
)
################
#IO options
mergeDoubles: BoolProperty(
name = "Merge duplicate vertices",
description = 'Merge shared vertices between features when importing vector data',
default = False)
adjust3Dview: BoolProperty(
name = "Adjust 3D view",
description = "Update 3d view grid size and clip distances according to the new imported object's size",
default = True)
forceTexturedSolid: BoolProperty(
name = "Force textured solid shading",
description = "Update shading mode to display raster's texture",
default = True)
################
#System
def updateLogLevel(self, context):
logger = logging.getLogger(PKG)
logger.setLevel(logging.getLevelName(self.logLevel))
logLevel: EnumProperty(
name = "Logging level",
description = "Select the logging level",
items = [('DEBUG', 'Debug', ''), ('INFO', 'Info', ''), ('WARNING', 'Warning', ''), ('ERROR', 'Error', ''), ('CRITICAL', 'Critical', '')],
update = updateLogLevel,
default = 'DEBUG'
)
################
def draw(self, context):
layout = self.layout
#SRS
box = layout.box()
box.label(text='Spatial Reference Systems')
row = box.row().split(factor=0.5)
row.prop(self, "predefCrs", text='')
row.operator("bgis.add_predef_crs", icon='ADD')
row.operator("bgis.edit_predef_crs", icon='PREFERENCES')
row.operator("bgis.rmv_predef_crs", icon='REMOVE')
row.operator("bgis.reset_predef_crs", icon='PLAY_REVERSE')
#Basemaps
box = layout.box()
box.label(text='Basemaps')
box.prop(self, "cacheFolder")
row = box.row()
row.prop(self, "zoomToMouse")
row.prop(self, "lockObj")
row.prop(self, "lockOrigin")
row.prop(self, "synchOrj")
row = box.row()
row.prop(self, "resamplAlg")
#IO
box = layout.box()
box.label(text='Import/Export')
row = box.row().split(factor=0.5)
split = row.split(factor=0.9, align=True)
split.prop(self, "osmTags")
split.operator("wm.url_open", icon='INFO').url = "http://wiki.openstreetmap.org/wiki/Map_Features"
row.operator("bgis.add_osm_tag", icon='ADD')
row.operator("bgis.edit_osm_tag", icon='PREFERENCES')
row.operator("bgis.rmv_osm_tag", icon='REMOVE')
row.operator("bgis.reset_osm_tags", icon='PLAY_REVERSE')
row = box.row()
row.prop(self, "mergeDoubles")
row.prop(self, "adjust3Dview")
row.prop(self, "forceTexturedSolid")
#Network
box = layout.box()
box.label(text='Remote datasource')
row = box.row().split(factor=0.5)
row.prop(self, "overpassServer")
row.operator("bgis.add_overpass_server", icon='ADD')
row.operator("bgis.edit_overpass_server", icon='PREFERENCES')
row.operator("bgis.rmv_overpass_server", icon='REMOVE')
row.operator("bgis.reset_overpass_server", icon='PLAY_REVERSE')
row = box.row().split(factor=0.5)
row.prop(self, "demServer")
row.operator("bgis.add_dem_server", icon='ADD')
row.operator("bgis.edit_dem_server", icon='PREFERENCES')
row.operator("bgis.rmv_dem_server", icon='REMOVE')
row.operator("bgis.reset_dem_server", icon='PLAY_REVERSE')
row = box.row().split(factor=0.2)
row.label(text="Opentopography Api Key")
row.prop(self, "opentopography_api_key")
row = box.row().split(factor=0.2)
row.label(text="MapTiler API Key")
row.prop(self, "maptiler_api_key")
#System
box = layout.box()
box.label(text='System')
box.prop(self, "projEngine")
box.prop(self, "imgEngine")
box.prop(self, "logLevel")
#######################
class PredefCRS():
'''
Collection of utility methods (callable at class level) to deal with predefined CRS dictionary
Can be used by others operators that need to fill their own crs enum
'''
@staticmethod
def getData():
'''Load the json string'''
prefs = bpy.context.preferences.addons[PKG].preferences
return json.loads(prefs.predefCrsJson)
@classmethod
def getName(cls, key):
'''Return the convenient name of a given srid or None if this crs does not exist in the list'''
data = cls.getData()
try:
return [entry[1] for entry in data if entry[0] == key][0]
except IndexError:
return None
@classmethod
def getEnumItems(cls):
'''Return a list of predefined crs usable to fill a bpy EnumProperty'''
return [tuple(entry) for entry in cls.getData()]
#################
# Collection of operators to manage predefined CRS
class BGIS_OT_add_predef_crs(Operator):
bl_idname = "bgis.add_predef_crs"
bl_description = 'Add predefinate CRS'
bl_label = "Add"
bl_options = {'INTERNAL'}
crs: StringProperty(name = "Definition", description = "Specify EPSG code or Proj4 string definition for this CRS")
name: StringProperty(name = "Description", description = "Choose a convenient name for this CRS")
desc: StringProperty(name = "Description", description = "Add a description or comment about this CRS")
def check(self, context):
return True
def search(self, context):
apiKey = settings.maptiler_api_key
if not apiKey:
#self.report({'ERROR'}, "MapTiler API key is required. Please set it in the preferences.") #report is not available outsite of the execute function
log.error("No Maptiler API key")
return
mtc = MapTilerCoordinates(apiKey=apiKey)
results = mtc.search(self.query)
self.results = json.dumps(results)
if results:
self.crs = 'EPSG:' + str(results[0]['id']['code'])
self.name = results[0]['name']
def updEnum(self, context):
crsItems = []
if self.results != '':
for result in json.loads(self.results):
srid = 'EPSG:' + str(result['id']['code'])
crsItems.append( (str(result['id']['code']), result['name'], srid) )
return crsItems
def fill(self, context):
if self.results != '':
crs = [crs for crs in json.loads(self.results) if str(crs['id']['code']) == self.crsEnum][0]
self.crs = 'EPSG:' + str(crs['id']['code'])
self.desc = crs['name']
query: StringProperty(name='Query', description='Hit enter to process the search', update=search)
results: StringProperty()
crsEnum: EnumProperty(name='Results', description='Select the desired CRS', items=updEnum, update=fill)
search: BoolProperty(name='Search', description='Search for coordinate system into EPSG database', default=False)
save: BoolProperty(name='Save to addon preferences', description='Save Blender user settings after the addition', default=False)
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)#, width=300)
def draw(self, context):
layout = self.layout
layout.prop(self, 'search')
if self.search:
prefs = context.preferences.addons[PKG].preferences
if not prefs.maptiler_api_key:
layout.label(text="Searching require a MapTiler API key", icon_value=3)
layout.prop(prefs, "maptiler_api_key", text='API Key')
else:
layout.prop(self, 'query')
layout.prop(self, 'crsEnum')
layout.separator()
layout.prop(self, 'crs')
layout.prop(self, 'name')
layout.prop(self, 'desc')
#layout.prop(self, 'save') #sincce Blender2.8 prefs are autosaved
def execute(self, context):
if not SRS.validate(self.crs):
self.report({'ERROR'}, 'Invalid CRS')
if self.crs.isdigit():
self.crs = 'EPSG:' + self.crs
#append the new crs def to json string
prefs = context.preferences.addons[PKG].preferences
data = json.loads(prefs.predefCrsJson)
data.append((self.crs, self.name, self.desc))
prefs.predefCrsJson = json.dumps(data)
#change enum index to new added crs and redraw
#prefs.predefCrs = self.crs
context.area.tag_redraw()
#end
if self.save:
bpy.ops.wm.save_userpref()
return {'FINISHED'}
class BGIS_OT_rmv_predef_crs(Operator):
bl_idname = "bgis.rmv_predef_crs"
bl_description = 'Remove predefinate CRS'
bl_label = "Remove"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
key = prefs.predefCrs
if key != '':
data = json.loads(prefs.predefCrsJson)
data = [e for e in data if e[0] != key]
prefs.predefCrsJson = json.dumps(data)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_reset_predef_crs(Operator):
bl_idname = "bgis.reset_predef_crs"
bl_description = 'Reset predefinate CRS'
bl_label = "Reset"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
prefs.predefCrsJson = json.dumps(DEFAULT_CRS)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_edit_predef_crs(Operator):
bl_idname = "bgis.edit_predef_crs"
bl_description = 'Edit predefinate CRS'
bl_label = "Edit"
bl_options = {'INTERNAL'}
crs: StringProperty(name = "EPSG code or Proj4 string", description = "Specify EPSG code or Proj4 string definition for this CRS")
name: StringProperty(name = "Description", description = "Choose a convenient name for this CRS")
desc: StringProperty(name = "Name", description = "Add a description or comment about this CRS")
def invoke(self, context, event):
prefs = context.preferences.addons[PKG].preferences
key = prefs.predefCrs
if key == '':
return {'CANCELLED'}
data = json.loads(prefs.predefCrsJson)
entry = [entry for entry in data if entry[0] == key][0]
self.crs, self.name, self.desc = entry
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
key = prefs.predefCrs
data = json.loads(prefs.predefCrsJson)
if SRS.validate(self.crs):
data = [entry for entry in data if entry[0] != key] #deleting
data.append((self.crs, self.name, self.desc))
prefs.predefCrsJson = json.dumps(data)
context.area.tag_redraw()
else:
self.report({'ERROR'}, 'Invalid CRS')
return {'FINISHED'}
#################
# Collection of operators to manage predefinates OSM Tags
class BGIS_OT_add_osm_tag(Operator):
bl_idname = "bgis.add_osm_tag"
bl_description = 'Add new predefinate OSM filter tag'
bl_label = "Add"
bl_options = {'INTERNAL'}
tag: StringProperty(name = "Tag", description = "Specify the tag (examples : 'building', 'landuse=forest' ...)")
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)#, width=300)
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
tags = json.loads(prefs.osmTagsJson)
tags.append(self.tag)
prefs.osmTagsJson = json.dumps(tags)
prefs.osmTags = self.tag #update current idx
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_rmv_osm_tag(Operator):
bl_idname = "bgis.rmv_osm_tag"
bl_description = 'Remove predefinate OSM filter tag'
bl_label = "Remove"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
tag = prefs.osmTags
if tag != '':
tags = json.loads(prefs.osmTagsJson)
del tags[tags.index(tag)]
prefs.osmTagsJson = json.dumps(tags)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_reset_osm_tags(Operator):
bl_idname = "bgis.reset_osm_tags"
bl_description = 'Reset predefinate OSM filter tag'
bl_label = "Reset"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
prefs.osmTagsJson = json.dumps(DEFAULT_OSM_TAGS)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_edit_osm_tag(Operator):
bl_idname = "bgis.edit_osm_tag"
bl_description = 'Edit predefinate OSM filter tag'
bl_label = "Edit"
bl_options = {'INTERNAL'}
tag: StringProperty(name = "Tag", description = "Specify the tag (examples : 'building', 'landuse=forest' ...)")
def invoke(self, context, event):
prefs = context.preferences.addons[PKG].preferences
self.tag = prefs.osmTags
if self.tag == '':
return {'CANCELLED'}
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
tag = prefs.osmTags
tags = json.loads(prefs.osmTagsJson)
del tags[tags.index(tag)]
tags.append(self.tag)
prefs.osmTagsJson = json.dumps(tags)
prefs.osmTags = self.tag #update current idx
context.area.tag_redraw()
return {'FINISHED'}
#################
# Collection of operators to manage DEM server urls
class BGIS_OT_add_dem_server(Operator):
bl_idname = "bgis.add_dem_server"
bl_description = 'Add new topography web service'
bl_label = "Add"
bl_options = {'INTERNAL'}
url: StringProperty(name = "Url template", description = "Define url template string. Bounding box varaibles are {W}, {E}, {S} and {N}")
name: StringProperty(name = "Description", description = "Choose a convenient name for this server")
desc: StringProperty(name = "Description", description = "Add a description or comment about this remote datasource")
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)#, width=300)
def execute(self, context):
templates = ['{W}', '{E}', '{S}', '{N}']
if all([t in self.url for t in templates]):
prefs = context.preferences.addons[PKG].preferences
data = json.loads(prefs.demServerJson)
data.append( (self.url, self.name, self.desc) )
prefs.demServerJson = json.dumps(data)
context.area.tag_redraw()
else:
self.report({'ERROR'}, 'Invalid URL')
return {'FINISHED'}
class BGIS_OT_rmv_dem_server(Operator):
bl_idname = "bgis.rmv_dem_server"
bl_description = 'Remove a given topography web service'
bl_label = "Remove"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
key = prefs.demServer
if key != '':
data = json.loads(prefs.demServerJson)
data = [e for e in data if e[0] != key]
prefs.demServerJson = json.dumps(data)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_reset_dem_server(Operator):
bl_idname = "bgis.reset_dem_server"
bl_description = 'Reset default topographic web server'
bl_label = "Reset"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
prefs.demServerJson = json.dumps(DEFAULT_DEM_SERVER)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_edit_dem_server(Operator):
bl_idname = "bgis.edit_dem_server"
bl_description = 'Edit a topographic web server'
bl_label = "Edit"
bl_options = {'INTERNAL'}
url: StringProperty(name = "Url template", description = "Define url template string. Bounding box varaibles are {W}, {E}, {S} and {N}")
name: StringProperty(name = "Description", description = "Choose a convenient name for this server")
desc: StringProperty(name = "Description", description = "Add a description or comment about this remote datasource")
def invoke(self, context, event):
prefs = context.preferences.addons[PKG].preferences
key = prefs.demServer
if key == '':
return {'CANCELLED'}
data = json.loads(prefs.demServerJson)
entry = [entry for entry in data if entry[0] == key][0]
self.url, self.name, self.desc = entry
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
key = prefs.demServer
data = json.loads(prefs.demServerJson)
templates = ['{W}', '{E}', '{S}', '{N}']
if all([t in self.url for t in templates]):
data = [entry for entry in data if entry[0] != key] #deleting
data.append((self.url, self.name, self.desc))
prefs.demServerJson = json.dumps(data)
context.area.tag_redraw()
else:
self.report({'ERROR'}, 'Invalid URL')
return {'FINISHED'}
#################
class EditEnum():
'''
Helper to deal with an enum property that use a serialized json backend
Can be used by others operators to edit and EnumProperty
WORK IN PROGRESS
'''
def __init__(self, enumName):
self.prefs = bpy.context.preferences.addons[PKG].preferences
self.enumName = enumName
self.jsonName = enumName + 'Json'
def getData(self):
'''Load the json string'''
data = json.loads(getattr(self.prefs, self.jsonName))
return [tuple(entry) for entry in data]
def append(self, value, label, tooltip, check=lambda x: True):
if not check(value):
return
data = self.getData()
data.append((value, label, tooltip))
setattr(self.prefs, self.jsonName, json.dumps(data))
def remove(self, key):
if key != '':
data = self.getData()
data = [e for e in data if e[0] != key]
setattr(self.prefs, self.jsonName, json.dumps(data))
def edit(self, key, value, label, tooltip):
self.remove(key)
self.append(value, label, tooltip)
def reset(self):
setattr(self.prefs, self.jsonName, json.dumps(DEFAULT_OVERPASS_SERVER))
#################
# Collection of operators to manage Overpass server urls
class BGIS_OT_add_overpass_server(Operator):
bl_idname = "bgis.add_overpass_server"
bl_description = 'Add new OSM overpass server url'
bl_label = "Add"
bl_options = {'INTERNAL'}
url: StringProperty(name = "Url template", description = "Define the url end point of the overpass server")
name: StringProperty(name = "Description", description = "Choose a convenient name for this server")
desc: StringProperty(name = "Description", description = "Add a description or comment about this remote server")
def invoke(self, context, event):
return context.window_manager.invoke_props_dialog(self)#, width=300)
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
data = json.loads(prefs.overpassServerJson)
data.append( (self.url, self.name, self.desc) )
prefs.overpassServerJson = json.dumps(data)
#EditEnum('overpassServer').append(self.url, self.name, self.desc, check=lambda url: url.startswith('http'))
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_rmv_overpass_server(Operator):
bl_idname = "bgis.rmv_overpass_server"
bl_description = 'Remove a given overpass server'
bl_label = "Remove"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
key = prefs.overpassServer
if key != '':
data = json.loads(prefs.overpassServerJson)
data = [e for e in data if e[0] != key]
prefs.overpassServerJson = json.dumps(data)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_reset_overpass_server(Operator):
bl_idname = "bgis.reset_overpass_server"
bl_description = 'Reset default overpass server'
bl_label = "Rest"
bl_options = {'INTERNAL'}
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
prefs.overpassServerJson = json.dumps(DEFAULT_OVERPASS_SERVER)
context.area.tag_redraw()
return {'FINISHED'}
class BGIS_OT_edit_overpass_server(Operator):
bl_idname = "bgis.edit_overpass_server"
bl_description = 'Edit an overpass server url'
bl_label = "Edit"
bl_options = {'INTERNAL'}
url: StringProperty(name = "Url template", description = "Define the url end point of the overpass server")
name: StringProperty(name = "Description", description = "Choose a convenient name for this server")
desc: StringProperty(name = "Description", description = "Add a description or comment about this remote server")
def invoke(self, context, event):
prefs = context.preferences.addons[PKG].preferences
key = prefs.overpassServer
if key == '':
return {'CANCELLED'}
data = json.loads(prefs.overpassServerJson)
entry = [entry for entry in data if entry[0] == key][0]
self.url, self.name, self.desc = entry
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
prefs = context.preferences.addons[PKG].preferences
key = prefs.overpassServer
data = json.loads(prefs.overpassServerJson)
data = [entry for entry in data if entry[0] != key] #deleting
data.append((self.url, self.name, self.desc))
prefs.overpassServerJson = json.dumps(data)
context.area.tag_redraw()
return {'FINISHED'}
classes = [
BGIS_OT_pref_show,
BGIS_PREFS,
BGIS_OT_add_predef_crs,
BGIS_OT_rmv_predef_crs,
BGIS_OT_reset_predef_crs,
BGIS_OT_edit_predef_crs,
BGIS_OT_add_osm_tag,
BGIS_OT_rmv_osm_tag,
BGIS_OT_reset_osm_tags,
BGIS_OT_edit_osm_tag,
BGIS_OT_add_dem_server,
BGIS_OT_rmv_dem_server,
BGIS_OT_reset_dem_server,
BGIS_OT_edit_dem_server,
BGIS_OT_add_overpass_server,
BGIS_OT_rmv_overpass_server,
BGIS_OT_reset_overpass_server,
BGIS_OT_edit_overpass_server
]
def register():
for cls in classes:
try:
bpy.utils.register_class(cls)
except ValueError as e:
#log.error('Cannot register {}'.format(cls), exc_info=True)
log.warning('{} is already registered, now unregister and retry... '.format(cls))
bpy.utils.unregister_class(cls)
bpy.utils.register_class(cls)
# set default cache folder
prefs = bpy.context.preferences.addons[PKG].preferences
if prefs.cacheFolder == '':
prefs.cacheFolder = APP_DATA
def unregister():
for cls in classes:
bpy.utils.unregister_class(cls)