[Blender3.3] 日本の地形モデルメッシュ追加アドオンの開発 (4): 地理院タイル(標高タイル)の読み込み

 さて、いよいよ地理院タイルを読み込んで、地形モデルを作成したいと思います。

地理院タイルタイルの仕様はこちら(標高タイルの作成方法と地理院地図で表示される標高値について)を参照してください。

とは言っても、いきなり仕様通りのメッシュを作るのはハードルが高いので、まずは入力された緯度経度とズーム値で特定される一つの標高タイルを取得してそれをメッシュ化することにします。

スクリプトのコードは以下のようにしました。

  1. # -*- coding: utf-8 -*-
  2.  
  3. bl_info = {
  4. "name": "New Terrain Model (JP)",
  5. "author": "Shiki Kuraga",
  6. "version": (1, 0),
  7. "blender": (3, 3, 0),
  8. "location": "View3D > Add > Mesh > New Terrain Model (JP)",
  9. "description": "Adds a new Terrain Model (JP) Mesh Object",
  10. "warning": "",
  11. "support": "TESTING",
  12. "doc_url": "",
  13. "category": "Add Mesh",
  14. }
  15.  
  16. translation_dict = {
  17. "ja_JP": {
  18. ("*", "Latitude"):
  19. "緯度",
  20. ("*", "Longitude"):
  21. "経度",
  22. ("*", "Scope (km)"):
  23. "範囲長さ (km)",
  24. ("*", "Terrain Model (JP)"):
  25. "地形モデル (日本)",
  26. ("*", "Add Terrain Model Mesh (JP)"):
  27. "地形モデルの追加 (日本)"
  28. }
  29. }
  30.  
  31. import bpy
  32. import math
  33. import requests
  34. from bpy.types import Operator
  35. from bpy.props import FloatProperty, IntProperty
  36. from bpy_extras.object_utils import AddObjectHelper, object_data_add
  37. from mathutils import Vector
  38. from trace import Trace
  39.  
  40. def T(str):
  41. return bpy.app.translations.pgettext(str)
  42.  
  43. def add_object(self, context):
  44. Trace.print("add_object()")
  45. scene = context.scene
  46. scale_length = scene.unit_settings.scale_length
  47.  
  48. scale_x = self.prop_scope * 1000 / scale_length #self.scale.x
  49. scale_y = self.prop_scope * 1000 / scale_length #self.scale.y
  50.  
  51. zoom = self.prop_zoom
  52. (x, y) = TerrainTile.latlon_to_xy(self.prop_lat, self.prop_lon)
  53. ix = int(x * (1 << zoom))
  54. iy = int(y * (1 << zoom))
  55. tile = get_cyberjapandata(zoom, ix, iy)
  56. mesh = tile.create_mesh(-1 * scale_x, 1 * scale_y, 2 * scale_x, -2 * scale_y)
  57. # verts = [
  58. # Vector((-1 * scale_x, 1 * scale_y, 0)),
  59. # Vector((1 * scale_x, 1 * scale_y, 0)),
  60. # Vector((1 * scale_x, -1 * scale_y, 0)),
  61. # Vector((-1 * scale_x, -1 * scale_y, 0)),
  62. # ]
  63. #
  64. # edges = []
  65. # faces = [[0, 1, 2, 3]]
  66. #
  67. # mesh = bpy.data.meshes.new(name = "Terrain Model Mesh")
  68. # mesh.from_pydata(verts, edges, faces)
  69. # useful for development when the mesh may be invalid.
  70. # mesh.validate(verbose=True)
  71. object_data_add(context, mesh, operator = self)
  72.  
  73.  
  74. def get_cyberjapandata(zoom: int, x: int, y: int, dataid = "DEM10B"):
  75. """Get cyberjapandata"""
  76. # URL:https://cyberjapandata.gsi.go.jp/xyz/dem5a/{z}/{x}/{y}.txt(DEM5A テキスト形式)zoom: 1...15
  77. # URL:https://cyberjapandata.gsi.go.jp/xyz/dem5b/{z}/{x}/{y}.txt(DEM5B テキスト形式)zoom: 1...15
  78. # URL:https://cyberjapandata.gsi.go.jp/xyz/dem/{z}/{x}/{y}.txt(DEM10B テキスト形式) zoom: 1...14
  79.  
  80. # check dataid
  81. if dataid == "DEM10B":
  82. (dtype, zmax) = ("dem", 14)
  83. elif dataid == "DEM5A":
  84. (dtype, zmax) = ("dem5a", 15)
  85. elif dataid == "DEM5B":
  86. (dtype, zmax) = ("dem5b", 15)
  87. else:
  88. raise ValueError("Invalid DATAID: %s" % dataid)
  89. # check zoom
  90. if type(zoom) is not int:
  91. raise TypeError("param zoom isn't int: %s" % type(zoom))
  92. elif zoom < 1 or zoom > zmax:
  93. raise ValueError("Invalid value of z(1-%d): %d" % (zmax, zoom))
  94. # check x
  95. tilenum = (1 << zoom)
  96. if type(x) is not int:
  97. raise TypeError("param x isn't int: %s" % type(x))
  98. elif x < 0 or x >= tilenum:
  99. Trace.print("Out of range value x(0-%d): %d" % (tilenum, x))
  100. return TerrainTile()
  101.  
  102. # check y
  103. if type(y) is not int:
  104. raise TypeError("param y isn't int: %s" % type(y))
  105. elif y < 0 or y >= tilenum:
  106. Trace.print("Out of range value y(0-%d): %d" % (tilenum, y))
  107. return TerrainTile()
  108. # get data
  109. url = "https://cyberjapandata.gsi.go.jp/xyz/%s/%d/%d/%d.txt" % (dtype, zoom, x, y)
  110. Trace.print("get data from url: <%s>" % url)
  111. res = requests.get(url)
  112. s = res.content.decode()
  113. tile = TerrainTile(csvtext = s)
  114. Trace.print(tile)
  115. return tile
  116.  
  117.  
  118. class TerrainTile:
  119. """Data Handler of Terrain Tile"""
  120. PIXEL_NUM = 256
  121. def latlon_to_xy(latitude,longitude):
  122. x = ((math.pi + longitude) / (2 * math.pi)) % 1
  123. siny = math.sin(latitude)
  124. siny = max(-0.999, min(0.999, siny))
  125. y = 0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)
  126. return (x, y)
  127. def __init__(self, csvtext = ""):
  128. self.altitudes = []
  129. if len(csvtext) > 0:
  130. self.load_from_csvtext(csvtext)
  131.  
  132. def __str__(self):
  133. return "" % len(self.altitudes)
  134. def load_from_csvtext(self, csvtext):
  135. alts = []
  136. if len(csvtext.strip()) == 0:
  137. self.altitudes = alts
  138. return
  139. PIXEL_NUM = TerrainTile.PIXEL_NUM
  140. lines = csvtext.split('\n')
  141. if len(lines) < PIXEL_NUM:
  142. raise ValueError("num of lines is less than PIXEL_NUM(%d): %d lines" % (PIXEL_NUM, len(lines)))
  143. for i, line in enumerate(lines[:PIXEL_NUM]):
  144. cols = line.split(',')
  145. if len(cols) != PIXEL_NUM:
  146. raise ValueError("num of columns at line %d is not PIXEL_NUM(%d): %d cols" % (i + 1, PIXEL_NUM, len(cols)))
  147. for col in cols:
  148. col = col.strip()
  149. if col == "e":
  150. alts.append(None)
  151. else:
  152. alts.append(float(col))
  153. self.altitudes = alts
  154. def create_mesh(self, start_x, start_y, width, height, name = "Terrain Model Mesh", adjust = True):
  155. offset_z = 0
  156. if adjust:
  157. max_z = max(filter(None, self.altitudes))
  158. min_z = min(filter(None, self.altitudes))
  159. Trace.print("min_z = ", min_z)
  160. if min_z > 0:
  161. offset_z = -min_z
  162. elif max_z < 0:
  163. offset_z = -max_z
  164. idmap = []
  165. verts = []
  166. for index_y in range(0, self.PIXEL_NUM):
  167. y = start_y + (index_y + 0.5) * height / self.PIXEL_NUM
  168. for index_x, alt in enumerate(self.altitudes[(index_y * self.PIXEL_NUM):((index_y + 1) * self.PIXEL_NUM)]):
  169. if alt is not None:
  170. x = start_x + (index_x + 0.5) * width / self.PIXEL_NUM
  171. idmap.append(len(verts))
  172. verts.append(Vector((x, y, alt + offset_z)))
  173. else:
  174. idmap.append(-1)
  175. faces = []
  176. for index_y in range(0, self.PIXEL_NUM - 1):
  177. iy0 = index_y * self.PIXEL_NUM
  178. iy1 = iy0 + self.PIXEL_NUM
  179. for ix0, ix1 in enumerate(range(1, self.PIXEL_NUM)):
  180. face = [idmap[i] for i in [ix0 + iy0, ix1 + iy0, ix1 + iy1, ix0 + iy1] if idmap[i] >= 0]
  181. if (len(face) >= 3):
  182. faces.append(face)
  183. if len(faces) > 0:
  184. edges = []
  185. mesh = bpy.data.meshes.new(name = name)
  186. mesh.from_pydata(verts, edges, faces)
  187. return mesh
  188. return None
  189. class OBJECT_OT_add_object2(Operator, AddObjectHelper):
  190. """Create a new Mesh Object2"""
  191. bl_idname = "mesh.add_object2"
  192. bl_label = T("Add Terrain Model Mesh (JP)")
  193. bl_options = {'REGISTER', 'UNDO'}
  194. prop_lat: FloatProperty(
  195. name = "Latitude",
  196. description = "Latitude",
  197. default = 35.0 * math.pi / 180.0,
  198. subtype = 'ANGLE',
  199. min = -0.495 * math.pi,
  200. max = 0.495 * math.pi
  201. )
  202. prop_lon: FloatProperty(
  203. name = "Longitude",
  204. description = "Longitude",
  205. default = 135.0 * math.pi / 180.0,
  206. subtype = 'ANGLE',
  207. min = -1 * math.pi,
  208. max = 1 * math.pi
  209. )
  210. prop_scope: FloatProperty(
  211. name = "Scope (km)",
  212. description = "Scope (km)",
  213. default = 1.0,
  214. step = 10, # actual value = 10/100
  215. min = 0.1
  216. )
  217. prop_zoom: IntProperty(
  218. name = "Zoom",
  219. description = "Zoom",
  220. default = 14,
  221. min = 3,
  222. max = 15
  223. )
  224. def __init__(self):
  225. Trace.print("__init__")
  226. def __del__(self):
  227. Trace.print("__del__")
  228. def execute(self, context):
  229. add_object(self, context)
  230. return {'FINISHED'}
  231. def invoke(self, context, event):
  232. wm = context.window_manager
  233. # invoke properties dialog for this object
  234. return wm.invoke_props_dialog(self)
  235. # Registration
  236. def add_object_button2(self, context):
  237. # insert separator
  238. self.layout.separator()
  239. self.layout.operator(
  240. OBJECT_OT_add_object2.bl_idname,
  241. text = T("Terrain Model (JP)"),
  242. icon = 'PLUGIN')
  243. ## This allows you to right click on a button and link to documentation
  244. #def add_object_manual_map():
  245. # url_manual_prefix = "https://docs.blender.org/manual/en/latest/"
  246. # url_manual_mapping = (
  247. # ("bpy.ops.mesh.add_object2", "scene_layout/object/types.html"),
  248. # )
  249. # return url_manual_prefix, url_manual_mapping
  250. def register():
  251. Trace.print("register()")
  252. bpy.utils.register_class(OBJECT_OT_add_object2)
  253. bpy.app.translations.register(__name__, translation_dict)
  254. # bpy.utils.register_manual_map(add_object_manual_map)
  255. bpy.types.VIEW3D_MT_mesh_add.append(add_object_button2)
  256. def unregister():
  257. Trace.print("unregister()")
  258. bpy.app.translations.unregister(__name__)
  259. bpy.utils.unregister_class(OBJECT_OT_add_object2)
  260. # bpy.utils.unregister_manual_map(add_object_manual_map)
  261. bpy.types.VIEW3D_MT_mesh_add.remove(add_object_button2)
  262. if __name__ == "__main__":
  263. # to avoid duplicate registration error of translation_dict
  264. if (Trace.activated):
  265. bpy.app.translations.unregister(__name__)
  266. Trace.on()
  267. register()

ソースコードを超簡単に解説しますと、TerrainTileというタイル情報を保持するクラスを定義しています。

標高タイルを取得する関数が get_cyberjapandata() で、これの結果がTerrainTileで戻るとしてあります。

TerrainTileには、自分で保持しているデータをもとにメッシュを作成するcreate_mesh()という関数を持っているので、これでメッシュに変換して追加するようにしたということです。

TerrainTileを作ったのは、ポップアップ画面で指定した領域のデータを入れるのにも使えるようにすれば、create_mesh()の処理を共通的に使えて楽かなと考えたためです。

こうして作成したスクリプトを実行し、北緯35度、東経135度を指定して標高タイルを取得した結果を下に示します。


それらしく読み込めたように見えます。

これはDEM10Bなので、ピクセル一つで約10m、タイル一つは約2.5km四方でしょうか。本来ポップアップ画面では範囲1kmを指定したので2km四方だから気持ち狭い範囲となるわけですが、大体こんな感じになるのだろいうイメージが持てました。

次は仕様通りの読み込み範囲にすることをと思いつつ、開発環境の整備をしないとかな、と考え中です。


前回(3)】日本の地形モデルメッシュ追加アドオンの開発 (4) 【次回(5)

このブログの人気の投稿

パズドラ 12月のクエスト(Lv12) ネフティスさん、出番です

日本酒 烏輪 緑のたいよう 純米酒 無濾過生原酒

[Blender3.3] mmd_toolsはどれが最新?