[Blender3.3] 日本の地形モデルメッシュ追加アドオンの開発 (6): 取り敢えずの完成

前回、スクリプトのファイルを分割して、それぞれの機能が見通し良くなりました。

そこで最後の仕上げに入ります。

まず、距離と緯度との変換のためにヒュベニの公式のモジュールを作ります。ファイル名は、hubeny_formyla.py です。

# -*- coding: utf-8 -*-
#
# package: terrain_model
# file: hubeny_formula.py

# Hubeny's Formula
#
# D = sqrt(Dy * M)^2 + (Dx * N * cos(P))^2)
# D: distance (m)
# Dy: diff of latitude (radian)
# Dx: diff of longitude (radian)
# P: average of latitude (radian)
# M = Rx * (1 - E^2) / W^3
# W = sqrt(1 - E^2 * sin(P)^2)
# N = Rx / W
# E = sqrt((Rx^2 - Ry^2) / Rx^2)
#
#       long radius(Rx), short radius(Ry)
# WGS84: 6,378,137.000, 6,356,752.314 245
#

import math

Rx = 6378137.0
Ry = 6356752.314245
E2 = (Rx ** 2 - Ry ** 2) / Rx ** 2 
#W = math.sqrt(1 - E2 * math.sin(P) ** 2)
#N = Rx / W

def angle_from(distance, latitude):
    '''
    parameter:
        distance (m)
        latitude (radian)
    '''
    W = math.sqrt(1 - E2 * math.sin(latitude) ** 2)
    N = Rx / W
    return distance / N / math.cos(latitude)

def distance_from(angle, latitude):
    '''
    parameter:
        angle (radian)
        latitude (radian)
    '''
    W = math.sqrt(1 - E2 * math.sin(latitude) ** 2)
    N = Rx / W
    return angle * N * math.cos(latitude)

そして、これを使ってモデル作成の処理を見直します。今までは、指定された緯度経度に該当するタイルを特定し、そのタイルの情報だけで地形モデルを作成していましたが、当初構想通り、指定された緯度経度に対し、指定された範囲にある地図タイルにピクセル単位でアクセスして、標高データを集めてメッシュを作成します。

そのように改修した model.py を示します。

  1. # -*- coding: utf-8 -*-
  2. #
  3. # package: terrain_model
  4. # file: model.py
  5.  
  6. import bpy
  7. import math
  8. import requests
  9. from bpy_extras.object_utils import object_data_add
  10. from mathutils import Vector
  11. from . import hubeny_formula
  12. from trace import Trace
  13.  
  14. def T(str):
  15. return bpy.app.translations.pgettext(str)
  16.  
  17. def add_object(self, context):
  18. lat = self.prop_lat
  19. lon = self.prop_lon
  20. zoom = self.prop_zoom
  21. scope = self.prop_scope # (km)
  22. Trace.print("add_object(lat=%f, lon=%f, scope=%f, zoom=%d)" % (lat, lon, scope, zoom))
  23. scope_x = hubeny_formula.angle_from(scope * 1000, lat) / (2 * math.pi)
  24.  
  25. (x, y) = TerrainTile.latlon_to_xy(lat, lon)
  26. center_px = x * (256 << zoom)
  27. center_py = y * (256 << zoom)
  28. scope_p = scope_x * (256 << zoom)
  29. start_px = math.ceil(center_px - scope_p)
  30. start_py = math.ceil(center_py - scope_p)
  31. end_px = math.floor(center_px + scope_p)
  32. end_py = math.floor(center_py + scope_p)
  33. tile = get_terrain_tile(start_px, start_py, end_px, end_py, zoom)
  34.  
  35. scene = context.scene
  36. scale_length = scene.unit_settings.scale_length
  37.  
  38. start_x = hubeny_formula.distance_from((start_px - center_px) / (256 << zoom) * 2 * math.pi, lat) / scale_length
  39. start_y = hubeny_formula.distance_from((center_py - start_py) / (256 << zoom) * 2 * math.pi, lat) / scale_length
  40. width = hubeny_formula.distance_from((end_px - start_px) / (256 << zoom) * 2 * math.pi, lat) / scale_length
  41. height = hubeny_formula.distance_from((start_py - end_py) / (256 << zoom) * 2 * math.pi, lat) / scale_length
  42. mesh = tile.create_mesh(start_x, start_y, width, height)
  43.  
  44. # useful for development when the mesh may be invalid.
  45. # mesh.validate(verbose=True)
  46. object_data_add(context, mesh, operator = self)
  47.  
  48. tile_map = {}
  49.  
  50. def get_terrain_tile(start_px: int, start_py: int, end_px: int, end_py: int, zoom: int):
  51. alts = []
  52. for py in range(start_py, end_py):
  53. ty = py >> 8
  54. tpy = py & 255
  55. for px in range(start_px, end_px):
  56. tx = px >> 8
  57. tpx = px & 255
  58. s = (zoom, tx, ty)
  59. if s in tile_map.keys():
  60. tile = tile_map[s]
  61. else:
  62. tile = get_cyberjapandata(zoom, tx, ty)
  63. tile_map[s] = tile
  64. alts.append(tile.altitude_at(tpx, tpy))
  65. tile = TerrainTile(pixel_x = end_px - start_px, pixel_y = end_py - start_py, altitudes = alts)
  66. return tile
  67.  
  68. def get_cyberjapandata(zoom: int, x: int, y: int, dataid = "DEM10B"):
  69. """Get cyberjapandata"""
  70. # URL:https://cyberjapandata.gsi.go.jp/xyz/dem5a/{z}/{x}/{y}.txt(DEM5A テキスト形式)zoom: 1...15
  71. # URL:https://cyberjapandata.gsi.go.jp/xyz/dem5b/{z}/{x}/{y}.txt(DEM5B テキスト形式)zoom: 1...15
  72. # URL:https://cyberjapandata.gsi.go.jp/xyz/dem/{z}/{x}/{y}.txt(DEM10B テキスト形式) zoom: 1...14
  73.  
  74. # check dataid
  75. if dataid == "DEM10B":
  76. (dtype, zmax) = ("dem", 14)
  77. elif dataid == "DEM5A":
  78. (dtype, zmax) = ("dem5a", 15)
  79. elif dataid == "DEM5B":
  80. (dtype, zmax) = ("dem5b", 15)
  81. else:
  82. raise ValueError("Invalid DATAID: %s" % dataid)
  83. # check zoom
  84. if type(zoom) is not int:
  85. raise TypeError("param zoom isn't int: %s" % type(zoom))
  86. elif zoom < 1 or zoom > zmax:
  87. raise ValueError("Invalid value of z(1-%d): %d" % (zmax, zoom))
  88. # check x
  89. tilenum = (1 << zoom)
  90. if type(x) is not int:
  91. raise TypeError("param x isn't int: %s" % type(x))
  92. elif x < 0 or x >= tilenum:
  93. Trace.print("Out of range value x(0-%d): %d" % (tilenum, x))
  94. return TerrainTile()
  95.  
  96. # check y
  97. if type(y) is not int:
  98. raise TypeError("param y isn't int: %s" % type(y))
  99. elif y < 0 or y >= tilenum:
  100. Trace.print("Out of range value y(0-%d): %d" % (tilenum, y))
  101. return TerrainTile()
  102. # get data
  103. url = "https://cyberjapandata.gsi.go.jp/xyz/%s/%d/%d/%d.txt" % (dtype, zoom, x, y)
  104. Trace.print("get data from url: <%s>" % url)
  105. res = requests.get(url)
  106. s = res.content.decode()
  107. tile = TerrainTile(csvtext = s)
  108. Trace.print(tile)
  109. return tile
  110.  
  111.  
  112. class TerrainTile:
  113. """Data Handler of Terrain Tile"""
  114. PIXEL_NUM = 256
  115. def latlon_to_xy(latitude,longitude):
  116. x = ((math.pi + longitude) / (2 * math.pi)) % 1
  117. siny = math.sin(latitude)
  118. siny = max(-0.999, min(0.999, siny))
  119. y = 0.5 - math.log((1 + siny) / (1 - siny)) / (4 * math.pi)
  120. return (x, y)
  121. def __init__(self, pixel_x = PIXEL_NUM, pixel_y = PIXEL_NUM, altitudes = [], csvtext = ""):
  122. self.altitudes = altitudes
  123. self.pixel_x = pixel_x
  124. self.pixel_y = pixel_y
  125. if len(csvtext) > 0:
  126. self.load_from_csvtext(csvtext)
  127.  
  128. def __str__(self):
  129. return "" % len(self.altitudes)
  130. def load_from_csvtext(self, csvtext):
  131. alts = []
  132. if len(csvtext.strip()) == 0:
  133. self.altitudes = alts
  134. return
  135. pixel_y = self.pixel_y
  136. pixel_x = self.pixel_x
  137. lines = csvtext.split('\n')
  138. if len(lines) < pixel_y:
  139. raise ValueError("num of lines is less than pixel_y(%d): %d lines" % (pixel_y, len(lines)))
  140. for i, line in enumerate(lines[:pixel_y]):
  141. cols = line.split(',')
  142. if len(cols) != pixel_x:
  143. raise ValueError("num of columns at line %d is not pixel_x(%d): %d cols" % (i + 1, pixel_x, len(cols)))
  144. for col in cols:
  145. col = col.strip()
  146. if col == "e":
  147. alts.append(None)
  148. else:
  149. alts.append(float(col))
  150. self.altitudes = alts
  151. def altitude_at(self, x, y):
  152. if (x < 0 or x >= self.pixel_x):
  153. return None
  154. if (y < 0 or y >= self.pixel_y):
  155. return None
  156. index = y * self.pixel_x + x
  157. if (len(self.altitudes) < index):
  158. return None
  159. return self.altitudes[index]
  160. def create_mesh(self, start_x, start_y, width, height, name = "Terrain Model Mesh", adjust = True):
  161. offset_z = 0
  162. if adjust:
  163. max_z = max(filter(None, self.altitudes))
  164. min_z = min(filter(None, self.altitudes))
  165. Trace.print("min_z = ", min_z)
  166. if min_z > 0:
  167. offset_z = -min_z
  168. elif max_z < 0:
  169. offset_z = -max_z
  170. idmap = []
  171. verts = []
  172. for index_y in range(0, self.pixel_y):
  173. y = start_y + (index_y + 0.5) * height / self.pixel_y
  174. for index_x, alt in enumerate(self.altitudes[(index_y * self.pixel_x):((index_y + 1) * self.pixel_x)]):
  175. if alt is not None:
  176. x = start_x + (index_x + 0.5) * width / self.pixel_x
  177. idmap.append(len(verts))
  178. verts.append(Vector((x, y, alt + offset_z)))
  179. else:
  180. idmap.append(-1)
  181. faces = []
  182. for index_y in range(0, self.pixel_y - 1):
  183. iy0 = index_y * self.pixel_x
  184. iy1 = iy0 + self.pixel_x
  185. for ix0, ix1 in enumerate(range(1, self.pixel_x)):
  186. face = [idmap[i] for i in [ix0 + iy0, ix1 + iy0, ix1 + iy1, ix0 + iy1] if idmap[i] >= 0]
  187. if (len(face) >= 3):
  188. faces.append(face)
  189. if len(faces) > 0:
  190. edges = []
  191. mesh = bpy.data.meshes.new(name = name)
  192. mesh.from_pydata(verts, edges, faces)
  193. return mesh
  194. return None

機能面での改修は以上ですが、新しくhubeny_formula.pyを作成したので、これのreloadをするように、__init__.py の __all__ に、hubeny_formula を追加します。

  1. # -*- coding: utf-8 -*-
  2. #
  3. # package: terrain_model
  4. # file: __init__.py
  5.  
  6. bl_info = {
  7. "name": "New Terrain Model (JP)",
  8. "author": "Shiki Kuraga",
  9. "version": (1, 0),
  10. "blender": (3, 3, 0),
  11. "location": "View3D > Add > Mesh > New Terrain Model (JP)",
  12. "description": "Adds a new Terrain Model (JP) Mesh Object",
  13. "warning": "",
  14. "support": "TESTING",
  15. "doc_url": "",
  16. "category": "Add Mesh",
  17. }
  18.  
  19. __all__ = [ 'translation', 'operator', 'model', 'hubeny_formula' ]
  20.  
  21. from . import translation
  22. from . import operator
  23.  
  24. import bpy
  25. from trace import Trace
  26.  
  27. ## This allows you to right click on a button and link to documentation
  28. #def add_object_manual_map():
  29. # url_manual_prefix = "https://docs.blender.org/manual/en/latest/"
  30. # url_manual_mapping = (
  31. # ("bpy.ops.mesh.add_object2", "scene_layout/object/types.html"),
  32. # )
  33. # return url_manual_prefix, url_manual_mapping
  34.  
  35.  
  36. def register():
  37. Trace.print("register()")
  38. bpy.app.translations.register(__name__, translation.translation_dict)
  39. operator.register()
  40. # bpy.utils.register_manual_map(add_object_manual_map)
  41.  
  42.  
  43. def unregister():
  44. Trace.print("unregister()")
  45. operator.unregister()
  46. bpy.app.translations.unregister(__name__)
  47. # bpy.utils.unregister_manual_map(add_object_manual_map)
  48.  
  49.  
  50. if __name__ == "__main__":
  51. register()

これで、当初考えていた内容が実装できました。

実際に緯度経度を指定して拾ったデータでメッシュオブジェクトを作成した例を示します。


ぱっと見でお分かりになるでしょうか。富士山です。色が無いと味気ないですね。

色の付け方については、これから勉強していきたいと思います。

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

このブログの人気の投稿

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

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

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